Add timeline owner for WebGL book playback

This commit is contained in:
2026-06-10 02:00:57 +02:00
parent b41340151d
commit 10bf23b10b
6 changed files with 447 additions and 74 deletions
+377
View File
@@ -0,0 +1,377 @@
/**
* Book Playback Timeline Module
* Owns prepared WebGL book playback order: pagination, texture readiness,
* reveal start, page-flip timing, and visual completion.
*/
import { BaseModule } from './base-module.js';
class BookPlaybackTimelineModule extends BaseModule {
constructor() {
super('book-playback-timeline', 'Book Playback Timeline');
this.dependencies = ['book-pagination', 'book-texture-renderer', 'webgl-page-cache', 'playback-coordinator', 'sentence-queue'];
this.pagination = null;
this.textureRenderer = null;
this.pageCache = null;
this.playbackCoordinator = null;
this.sentenceQueue = null;
this.activeSegment = null;
this.preparedSegments = new Map();
this.timelineDiagnostics = [];
this.ownsPageFlipCommit = true;
this.bindMethods([
'initialize',
'playSentence',
'prepareSentence',
'activatePreparedSegment',
'ensureAnimationTimings',
'createPreparedSegment',
'createRevealDetail',
'requiresSpreadTransition',
'requiresRightPageFlipAfterReveal',
'waitForVisualCompletion',
'waitForRevealCommit',
'requestPageFlip',
'waitForPageFlipFinished',
'prewarmSegmentTextures',
'getPageMetaForIndex',
'getVisibleSpreadIndex',
'isChoiceAwaitingPlayer',
'recordDiagnostic',
'getRuntimeState'
]);
}
async initialize() {
this.pagination = this.getModule('book-pagination');
this.textureRenderer = this.getModule('book-texture-renderer');
this.pageCache = this.getModule('webgl-page-cache');
this.playbackCoordinator = this.getModule('playback-coordinator');
this.sentenceQueue = this.getModule('sentence-queue');
window.BookPlaybackTimeline = this;
this.reportProgress(100, 'Book playback timeline ready');
return true;
}
async playSentence(sentence = {}) {
const segment = await this.prepareSentence(sentence, { immediate: true });
if (!segment) {
return this.playbackCoordinator?.play?.(sentence);
}
this.activeSegment = segment;
this.recordDiagnostic('segment-play:start', segment);
if (this.requiresSpreadTransition(segment)) {
const flipped = await this.requestPageFlip(1, {
reason: 'timeline-preplay-spread-transition',
targetSpread: segment.targetSpreadIndex,
force: true
});
if (!flipped) {
this.pageCache?.recordProblem?.({
type: 'timeline-preplay-flip-failed',
blockId: segment.blockId,
targetSpread: segment.targetSpreadIndex
});
}
}
await this.activatePreparedSegment(segment, sentence);
const visualPromise = this.waitForVisualCompletion(segment);
const playbackPromise = this.playbackCoordinator?.play?.(sentence) || Promise.resolve();
await Promise.all([playbackPromise, visualPromise]);
this.recordDiagnostic('segment-play:end', segment);
if (this.activeSegment?.key === segment.key) this.activeSegment = null;
return segment;
}
async prepareSentence(sentence = {}, options = {}) {
if (!sentence || sentence.blockId == null || !this.pagination || !this.textureRenderer) return null;
const key = `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${sentence.blockId}`;
const existing = sentence.webglBookPresentation?.timelineSegment || this.preparedSegments.get(key);
if (existing && options.force !== true) return existing;
this.ensureAnimationTimings(sentence);
const segment = await this.createPreparedSegment(sentence, options);
if (!segment) return null;
this.preparedSegments.set(segment.key, segment);
sentence.webglBookPresentation = {
...(sentence.webglBookPresentation || {}),
prepared: true,
blockId: segment.blockId,
spread: segment.previewSpread,
timelineSegment: segment
};
this.recordDiagnostic('segment-prepare:end', segment);
return segment;
}
async createPreparedSegment(sentence = {}, options = {}) {
const previewSpread = sentence.webglBookPresentation?.spread || await this.pagination.preparePendingBlock(sentence, {
activate: false,
publish: false,
includeUnrenderedHistory: true
});
if (!previewSpread) return null;
const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare');
this.textureRenderer.prepareRevealBlock(revealDetail, { phase: 'prepare' });
const targetSpreadIndex = Math.max(0, Number(previewSpread.index || 0));
const currentSpreadIndex = this.getVisibleSpreadIndex();
const segment = {
key: `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${sentence.blockId}`,
id: sentence.id,
blockId: sentence.blockId,
sentence,
previewSpread,
targetSpreadIndex,
currentSpreadIndex,
requiresPreFlip: targetSpreadIndex > currentSpreadIndex,
requiresRightFlip: this.requiresRightPageFlipAfterReveal(previewSpread),
preparedAt: performance.now(),
status: 'prepared'
};
await this.prewarmSegmentTextures(segment);
if (options.immediate !== true) {
await new Promise(resolve => setTimeout(resolve, 0));
}
return segment;
}
async activatePreparedSegment(segment = {}, sentence = segment.sentence) {
if (!segment || !sentence) return null;
const activeSpread = await this.pagination.preparePendingBlock(sentence, {
includeUnrenderedHistory: true
});
segment.activeSpread = activeSpread || segment.previewSpread;
segment.targetSpreadIndex = Math.max(0, Number(segment.activeSpread?.index ?? segment.targetSpreadIndex ?? 0));
segment.requiresRightFlip = this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread);
const revealDetail = this.createRevealDetail(sentence, segment.activeSpread || segment.previewSpread, 'activate');
this.textureRenderer.prepareRevealBlock(revealDetail);
segment.status = 'activated';
this.recordDiagnostic('segment-activate:end', segment);
return segment.activeSpread;
}
ensureAnimationTimings(sentence = {}) {
if (Array.isArray(sentence.animation?.wordTimings) && sentence.animation.wordTimings.length > 0) return;
const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || [];
sentence.animation = this.sentenceQueue?.calculateAnimationTiming?.(words, sentence.tts?.duration || 0, sentence.cueMarkers || [])
|| { wordTimings: [], cueTimings: [], totalDuration: 0 };
}
createRevealDetail(sentence = {}, spread = null, phase = 'activate') {
return {
id: sentence.id,
blockId: sentence.blockId,
wordTimings: sentence.animation?.wordTimings || [],
cueTimings: sentence.animation?.cueTimings || [],
totalDuration: sentence.animation?.totalDuration || 0,
spread,
phase
};
}
requiresSpreadTransition(segment = {}) {
return Math.max(0, Number(segment.targetSpreadIndex || 0)) > this.getVisibleSpreadIndex();
}
requiresRightPageFlipAfterReveal(spread = {}) {
const meta = spread?.pageMeta?.right || null;
if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false;
const rightLines = Array.isArray(spread?.right) ? spread.right : [];
const maxLine = rightLines.reduce((max, line) => Math.max(
max,
Number(line?.pageLine || 0) + Math.max(1, Number(line?.lineCount || 1))
), 0);
return maxLine >= Math.max(1, Number(meta.linesPerPage || 25));
}
async waitForVisualCompletion(segment = {}) {
if (!segment.requiresRightFlip) return;
const committed = await this.waitForRevealCommit(segment);
if (!committed || this.isChoiceAwaitingPlayer()) return;
await this.requestPageFlip(1, {
reason: 'timeline-right-page-filled',
targetSpread: Math.max(0, Number(segment.targetSpreadIndex || this.getVisibleSpreadIndex()) + 1),
force: true
});
}
waitForRevealCommit(segment = {}) {
const blockId = String(segment.blockId ?? '');
if (!blockId) return Promise.resolve(false);
return new Promise(resolve => {
let resolved = false;
const finish = (value) => {
if (resolved) return;
resolved = true;
clearTimeout(timeoutId);
document.removeEventListener('webgl-book:reveal-committed', onCommitted);
resolve(value);
};
const onCommitted = (event) => {
const detail = event.detail || {};
if (detail.side !== 'right' || detail.pageFlipAfterReveal !== true) return;
const ids = Array.isArray(detail.blockIds) ? detail.blockIds.map(value => String(value)) : [];
if (!ids.includes(blockId)) return;
finish(true);
};
const timeoutId = setTimeout(() => {
this.pageCache?.recordProblem?.({
type: 'timeline-reveal-commit-timeout',
blockId: segment.blockId,
targetSpread: segment.targetSpreadIndex
});
finish(false);
}, Math.max(2000, Number(segment.sentence?.animation?.totalDuration || 0) + 3000));
document.addEventListener('webgl-book:reveal-committed', onCommitted);
});
}
async requestPageFlip(direction = 1, options = {}) {
if (this.isChoiceAwaitingPlayer()) return false;
await this.pageCache?.prewarmNavigationWindow?.({
currentSpread: this.getVisibleSpreadIndex(),
targetSpread: options.targetSpread,
endSpread: Math.max(0, Number(this.pagination?.spreads?.length || 1) - 1),
getPageMetaForIndex: this.getPageMetaForIndex,
recordMiss: true
});
const wait = this.waitForPageFlipFinished(options.targetSpread);
document.dispatchEvent(new CustomEvent('webgl-book:request-page-flip', {
detail: {
direction,
force: options.force === true,
reason: options.reason || 'timeline',
targetSpread: options.targetSpread
}
}));
return wait;
}
async prewarmSegmentTextures(segment = {}) {
if (!this.pageCache || typeof this.pageCache.prewarmNavigationWindow !== 'function') return null;
const targetSpread = Math.max(0, Number(segment.targetSpreadIndex || 0));
const endSpread = Math.max(targetSpread, Math.max(0, Number(this.pagination?.spreads?.length || 1) - 1));
const result = await this.pageCache.prewarmNavigationWindow({
currentSpread: this.getVisibleSpreadIndex(),
targetSpread,
endSpread,
getPageMetaForIndex: this.getPageMetaForIndex,
recordMiss: false
});
segment.textureWindowReady = true;
segment.textureWindowSpreadCount = result ? Object.keys(result).length : 0;
return result;
}
getPageMetaForIndex(pageIndex = 0) {
const index = Math.max(0, Math.round(Number(pageIndex || 0)));
const spreadIndex = Math.floor(index / 2);
const side = index % 2 === 0 ? 'left' : 'right';
const spread = typeof this.pagination?.getSpread === 'function'
? this.pagination.getSpread(spreadIndex)
: this.pagination?.spreads?.[spreadIndex];
const source = spread?.pageMeta?.[side] || {};
const metrics = this.textureRenderer?.metrics || {};
return {
...source,
pageIndex: index,
width: metrics.width,
height: metrics.height,
kind: source.kind || (index < 3 ? 'blank' : 'content'),
section: source.section || (index < 3 ? 'frontmatter' : 'body')
};
}
waitForPageFlipFinished(targetSpread = null) {
return new Promise(resolve => {
let started = false;
let resolved = false;
const expectedSpread = Number.isFinite(Number(targetSpread))
? Math.max(0, Math.round(Number(targetSpread)))
: null;
const cleanup = () => {
document.removeEventListener('webgl-book:page-flip-started', onStarted);
document.removeEventListener('webgl-book:page-flip-finished', onFinished);
clearTimeout(timeoutId);
};
const finish = (value) => {
if (resolved) return;
resolved = true;
cleanup();
resolve(value);
};
const matches = (detail = {}) => {
if (expectedSpread === null) return true;
const spread = Number(detail.targetSpread);
return Number.isFinite(spread) && Math.max(0, Math.round(spread)) === expectedSpread;
};
const onStarted = (event) => {
if (matches(event.detail || {})) started = true;
};
const onFinished = (event) => {
if (matches(event.detail || {})) finish(true);
};
const timeoutId = setTimeout(() => {
this.pageCache?.recordProblem?.({
type: 'timeline-page-flip-timeout',
targetSpread: expectedSpread,
started
});
finish(false);
}, 2600);
document.addEventListener('webgl-book:page-flip-started', onStarted);
document.addEventListener('webgl-book:page-flip-finished', onFinished);
});
}
getVisibleSpreadIndex() {
const labSpread = window.BookLabDebug?.getBookState?.()?.spreadIndex;
if (Number.isFinite(Number(labSpread))) return Math.max(0, Math.round(Number(labSpread)));
return Math.max(0, Math.round(Number(this.pagination?.currentSpreadIndex || 0)));
}
isChoiceAwaitingPlayer() {
return document.documentElement.dataset.choiceAwaiting === 'true'
|| document.body?.dataset?.choiceAwaiting === 'true'
|| Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice'));
}
recordDiagnostic(type, segment = {}) {
this.timelineDiagnostics.push({
type,
blockId: segment.blockId ?? null,
spreadIndex: segment.targetSpreadIndex ?? null,
status: segment.status || null,
at: Math.round(performance.now())
});
while (this.timelineDiagnostics.length > 200) this.timelineDiagnostics.shift();
document.documentElement.dataset.webglBookTimeline = type;
}
getRuntimeState() {
return {
activeBlockId: this.activeSegment?.blockId ?? null,
preparedSegmentCount: this.preparedSegments.size,
ownsPageFlipCommit: this.ownsPageFlipCommit,
diagnostics: this.timelineDiagnostics.slice(-20)
};
}
}
const bookPlaybackTimeline = new BookPlaybackTimelineModule();
export { bookPlaybackTimeline as BookPlaybackTimeline };
if (window.moduleRegistry) {
window.moduleRegistry.register(bookPlaybackTimeline);
}
window.BookPlaybackTimeline = bookPlaybackTimeline;
+2 -1
View File
@@ -24,7 +24,7 @@ const ModuleState = {
ERROR: 'ERROR'
};
const MODULE_CACHE_BUSTER = '20260608-webgl-mask-timing-c';
const MODULE_CACHE_BUSTER = '20260610-book-timeline-a';
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
/**
@@ -120,6 +120,7 @@ const ModuleLoader = (function() {
{ id: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 },
{ id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 },
{ id: 'playback-coordinator', script: '/js/playback-coordinator-module.js', weight: 8 }, // Synchronizes animation + TTS
{ id: 'book-playback-timeline', script: '/js/book-playback-timeline-module.js', weight: 8 },
// Audio and TTS modules
{ id: 'audio-manager', script: '/js/audio-manager-module.js', weight: 12 },
+21
View File
@@ -898,6 +898,27 @@ class SentenceQueueModule extends BaseModule {
const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null;
if (blockId == null) return null;
const bookPlaybackTimeline = this.getModule('book-playback-timeline');
if (bookPlaybackTimeline && typeof bookPlaybackTimeline.prepareSentence === 'function') {
if (!options.immediate) {
await new Promise(resolve => {
const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 1));
scheduler(() => resolve(), { timeout: 80 });
});
}
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
const segment = await bookPlaybackTimeline.prepareSentence(sentence, {
immediate: options.immediate === true
});
if (!segment) return null;
sentence.webglBookPresentation = {
prepared: true,
blockId,
spread: segment.previewSpread || segment.activeSpread || null,
timelineSegment: segment
};
return sentence.webglBookPresentation.spread;
}
const bookPagination = this.getModule('book-pagination');
const bookTextureRenderer = this.getModule('book-texture-renderer');
if (!bookPagination || !bookTextureRenderer) return null;
+15 -69
View File
@@ -11,7 +11,7 @@ class UIDisplayHandlerModule extends BaseModule {
super('ui-display-handler', 'UI Display Handler');
// Module dependencies
this.dependencies = ['layout-renderer', 'webgl-book-scene', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser', 'book-pagination', 'book-texture-renderer'];
this.dependencies = ['layout-renderer', 'webgl-book-scene', 'playback-coordinator', 'book-playback-timeline', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser', 'book-pagination', 'book-texture-renderer'];
// DOM elements
this.container = null;
@@ -69,6 +69,7 @@ class UIDisplayHandlerModule extends BaseModule {
'applyTranslations',
'renderSentence',
'isWebGLMode',
'playWebGLBookSentence',
'prepareWebGLBookReveal',
'waitForWebGLPageFlip',
'renderStoryBlock',
@@ -985,9 +986,7 @@ class UIDisplayHandlerModule extends BaseModule {
try {
if (useWebGLBookReveal) {
await this.prepareWebGLBookReveal(sentence);
if (!isCurrent()) return null;
await this.playbackCoordinator.play(sentence);
await this.playWebGLBookSentence(sentence);
if (!isCurrent()) return null;
if (sentence.blockId != null) this.markBlockRendered(sentence.blockId);
this.dispatchDeferredTagsForBlock(sentence);
@@ -1055,73 +1054,20 @@ class UIDisplayHandlerModule extends BaseModule {
|| document.body?.classList?.contains('webgl-mode');
}
async playWebGLBookSentence(sentence) {
const timeline = this.getModule('book-playback-timeline');
if (!timeline || typeof timeline.playSentence !== 'function') {
throw new Error('WebGL book playback timeline is required for 3D sentence playback');
}
return timeline.playSentence(sentence);
}
async prepareWebGLBookReveal(sentence) {
const bookPagination = this.getModule('book-pagination');
const bookTextureRenderer = this.getModule('book-texture-renderer');
if (!bookPagination || !bookTextureRenderer || sentence.blockId == null) return;
const sentenceQueue = this.getModule('sentence-queue');
if (!Array.isArray(sentence.animation?.wordTimings) || sentence.animation.wordTimings.length === 0) {
const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || [];
sentence.animation = sentenceQueue?.calculateAnimationTiming?.(words, sentence.tts?.duration || 0, sentence.cueMarkers || [])
|| { wordTimings: [], cueTimings: [], totalDuration: 0 };
}
let preparedSpread = null;
if (typeof bookPagination.preparePendingBlock === 'function') {
const currentSpreadIndex = Math.max(0, Number(bookPagination.currentSpreadIndex || 0));
const previewSpread = sentence.webglBookPresentation?.spread || await bookPagination.preparePendingBlock(sentence, {
activate: false,
publish: false,
includeUnrenderedHistory: true
});
const previewRevealDetail = {
id: sentence.id,
blockId: sentence.blockId,
wordTimings: sentence.animation?.wordTimings || [],
cueTimings: sentence.animation?.cueTimings || [],
totalDuration: sentence.animation?.totalDuration || 0,
spread: previewSpread,
phase: 'prepare'
};
if (previewSpread && typeof bookTextureRenderer.prepareRevealBlock === 'function') {
bookTextureRenderer.prepareRevealBlock(previewRevealDetail, { phase: 'prepare' });
}
if (Number(previewSpread?.index || 0) > currentSpreadIndex) {
const flipped = await this.waitForWebGLPageFlip({
direction: 1,
reason: 'pending-block-overflow',
targetSpread: previewSpread.index
});
if (!flipped) {
throw new Error(`WebGL book page flip did not start for prepared spread ${previewSpread.index}`);
}
}
preparedSpread = await bookPagination.preparePendingBlock(sentence, {
includeUnrenderedHistory: true
});
} else {
document.dispatchEvent(new CustomEvent('book-pagination:prepare-block', {
detail: {
block: sentence
}
}));
}
const revealDetail = {
id: sentence.id,
blockId: sentence.blockId,
wordTimings: sentence.animation?.wordTimings || [],
cueTimings: sentence.animation?.cueTimings || [],
totalDuration: sentence.animation?.totalDuration || 0,
spread: preparedSpread
};
if (typeof bookTextureRenderer.prepareRevealBlock === 'function') {
bookTextureRenderer.prepareRevealBlock(revealDetail);
} else {
document.dispatchEvent(new CustomEvent('book-texture:prepare-reveal-block', {
detail: revealDetail
}));
const timeline = this.getModule('book-playback-timeline');
if (!timeline || typeof timeline.prepareSentence !== 'function') {
throw new Error('WebGL book playback timeline is required for 3D reveal preparation');
}
return timeline.prepareSentence(sentence, { immediate: true });
}
waitForWebGLPageFlip(detail = {}) {
+22 -2
View File
@@ -4,7 +4,7 @@ import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postproces
import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js';
import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js';
import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js';
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260608-webgl-mask-timing-c';
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260610-book-timeline-a';
const canvas = document.getElementById('scene');
canvas.style.cursor = 'grab';
@@ -275,6 +275,7 @@ const pageRevealState = {
left: null,
right: null
};
let pageRevealFreezeAt = null;
const pageRevealClearLog = [];
await reportLabStep(52, 'Generating leather texture set');
const leatherTextures = createLeatherTextures();
@@ -2573,7 +2574,25 @@ function fastForwardPageReveals(blockIds = []) {
}
function updatePageRevealAnimations(now) {
if (activeFlips.length > 0) return;
if (activeFlips.length > 0) {
if (pageRevealFreezeAt === null) pageRevealFreezeAt = now;
return;
}
if (pageRevealFreezeAt !== null) {
const frozenMs = Math.max(0, now - pageRevealFreezeAt);
['left', 'right'].forEach((side) => {
const state = pageRevealState[side];
if (!state || state.startedAt == null) return;
state.startedAt += frozenMs;
state.lastRevealFrameAt = now;
});
activeRevealBlockStarts.forEach((value, blockId) => {
if (Number.isFinite(Number(value))) {
activeRevealBlockStarts.set(blockId, Number(value) + frozenMs);
}
});
pageRevealFreezeAt = null;
}
['left', 'right'].forEach((side) => {
const state = pageRevealState[side];
if (!state) return;
@@ -2942,6 +2961,7 @@ function canPageFlip(direction) {
}
function handleRevealCommittedForPageFlip(detail = {}) {
if (window.BookPlaybackTimeline?.ownsPageFlipCommit === true) return;
if (detail.side !== 'right' || detail.pageFlipAfterReveal !== true) return;
if (activeFlips.length > 0 || pendingRightPageFlip) return;
if (isChoiceAwaitingPlayer()) return;
+10 -2
View File
@@ -33,6 +33,8 @@ const persistencePath = path.join(__dirname, '..', 'public', 'js', 'persistence-
const persistenceSource = fs.readFileSync(persistencePath, 'utf8');
const webglPageCachePath = path.join(__dirname, '..', 'public', 'js', 'webgl-page-cache-module.js');
const webglPageCacheSource = fs.readFileSync(webglPageCachePath, 'utf8');
const bookPlaybackTimelinePath = path.join(__dirname, '..', 'public', 'js', 'book-playback-timeline-module.js');
const bookPlaybackTimelineSource = fs.readFileSync(bookPlaybackTimelinePath, 'utf8');
const ttsFactoryPath = path.join(__dirname, '..', 'public', 'js', 'tts-factory-module.js');
const ttsFactorySource = fs.readFileSync(ttsFactoryPath, 'utf8');
@@ -157,7 +159,7 @@ const checks = [
['webgl debug test hook awaits the same async page flip path', /startPageFlipForTest\(direction, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)],
['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)],
['sentence queue skips duplicate current-item 3D book presentation when reveal is cached', /isWebGLBookPresentationPrepared/.test(sentenceQueueSource) && /if \(!this\.isWebGLBookPresentationPrepared\(sentence\)\) \{\s*await this\.prefetchWebGLBookPresentation/.test(sentenceQueueSource) && /sentence\.webglBookPresentation = \{\s*prepared: true/.test(sentenceQueueSource)],
['3D overflow reveal waits for page flip before activating future spread', /sentence\.webglBookPresentation\?\.spread/.test(uiDisplayHandlerSource) && /preparePendingBlock\(sentence, \{\s*activate: false,\s*publish: false,\s*includeUnrenderedHistory: true\s*\}/.test(uiDisplayHandlerSource) && /waitForWebGLPageFlip/.test(uiDisplayHandlerSource) && /targetSpread: previewSpread\.index/.test(uiDisplayHandlerSource) && /webgl-book:request-page-flip/.test(uiDisplayHandlerSource) && /const targetSpread = Number\.isFinite\(Number\(detail\.targetSpread\)\)/.test(source) && /startPageFlip\(direction, \{[\s\S]*targetSpread/.test(source)],
['3D overflow reveal waits for timeline-owned page flip before activating future spread', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /await this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /await this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /webgl-book:request-page-flip/.test(bookPlaybackTimelineSource) && /const targetSpread = Number\.isFinite\(Number\(detail\.targetSpread\)\)/.test(source) && /startPageFlip\(direction, \{[\s\S]*targetSpread/.test(source)],
['texture renderer paints inline bold and italic styles', /getInlineStyleState/.test(textureRendererSource) && /updateInlineStyleState/.test(textureRendererSource) && /getCanvasFont/.test(textureRendererSource) && /segment\?\.style/.test(textureRendererSource)],
['webgl lab can preload page textures without swapping visible page material through texture store', /preparePageTexture\(side = 'left'/.test(webglPageCacheSource) && /takePreparedPageTexture\(side = 'left'/.test(webglPageCacheSource) && /renderer\.initTexture\(texture\)/.test(webglPageCacheSource) && /takePreparedPageTexture/.test(source) && !/const preparedPageTextures/.test(source)],
['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
@@ -209,7 +211,13 @@ const checks = [
['webgl reveal clock follows absolute playback time and continues across page flips', /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/Math\.min\(revealFrameDeltaMs, targetFrameDurationMs\)/.test(source) && /prewarmFlipTextures\(1, targetSpread\)/.test(source)],
['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource) && /this\.activeAnimations\.has\(id\)/.test(textureRendererSource)],
['webgl visible spread state ignores future prepared publishes before flip', /spreadUpdate:deferred-future-unrendered/.test(source) && /incomingSpreadIndex > Math\.max\(0, Number\(bookPaginationState\.spreadIndex/.test(source) && /this\.drawSpread\(this\.currentSpread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource)],
['3D overflow reveal preloads target spread before forced page flip', /previewRevealDetail/.test(uiDisplayHandlerSource) && /phase: 'prepare'/.test(uiDisplayHandlerSource) && /bookTextureRenderer\.prepareRevealBlock\(previewRevealDetail, \{ phase: 'prepare' \}\)/.test(uiDisplayHandlerSource) && /await this\.waitForWebGLPageFlip/.test(uiDisplayHandlerSource)],
['3D overflow reveal preloads target spread before forced page flip', /createRevealDetail\(sentence, previewSpread, 'prepare'\)/.test(bookPlaybackTimelineSource) && /this\.textureRenderer\.prepareRevealBlock\(revealDetail, \{ phase: 'prepare' \}\)/.test(bookPlaybackTimelineSource) && /await this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /reason: 'timeline-preplay-spread-transition'/.test(bookPlaybackTimelineSource)],
['book playback timeline is loaded through module infrastructure', /book-playback-timeline-module\.js/.test(loaderSource) && /super\('book-playback-timeline'/.test(bookPlaybackTimelineSource) && /reportProgress\(100, 'Book playback timeline ready'\)/.test(bookPlaybackTimelineSource)],
['3D display playback is owned by book playback timeline', /book-playback-timeline/.test(uiDisplayHandlerSource) && /playWebGLBookSentence/.test(uiDisplayHandlerSource) && /timeline\.playSentence\(sentence\)/.test(uiDisplayHandlerSource) && /if \(useWebGLBookReveal\) \{[\s\S]*await this\.playWebGLBookSentence\(sentence\);[\s\S]*this\.markBlockRendered\(sentence\.blockId\);[\s\S]*return null;/.test(uiDisplayHandlerSource)],
['sentence queue lookahead prepares 3D book timeline segments', /book-playback-timeline/.test(sentenceQueueSource) && /bookPlaybackTimeline\.prepareSentence\(sentence/.test(sentenceQueueSource) && /timelineSegment: segment/.test(sentenceQueueSource)],
['book playback timeline prewarms texture window before prepared playback and flips', /prewarmSegmentTextures/.test(bookPlaybackTimelineSource) && /pageCache\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource) && /await this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /await this\.pageCache\?\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource)],
['webgl lab delegates right-page reveal commits to timeline owner', /BookPlaybackTimeline\?\.ownsPageFlipCommit === true/.test(source) && /handleRevealCommittedForPageFlip/.test(source)],
['webgl reveal clock explicitly freezes during physical flips', /pageRevealFreezeAt/.test(source) && /state\.startedAt \+= frozenMs/.test(source) && /activeRevealBlockStarts\.set\(blockId, Number\(value\) \+ frozenMs\)/.test(source)],
['webgl navigation buttons use visited page limit instead of future prepared pages', /maxVisitedPagePosition/.test(source) && /navigateToPagePosition\(maxVisitedPagePosition\)/.test(source) && /const navigableLimit = Math\.min\(maxVisitedPagePosition, writableLimit\)/.test(source) && !/navigateToPagePosition\(bookPaginationState\.writtenPageLimit\)/.test(source)],
['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)],
['webgl page flips require resident nonblank back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.test(source) && /targetBackPageMeta\.kind !== 'blank'/.test(source) && /return false;/.test(methodBody(source, 'prepareStaticPageForFlip')) && /flipTexturePreflight:ready/.test(source) && /if \(!prepareStaticPageForFlip\(flip, options\.prewarm \|\| null\)\) \{[\s\S]*return false;[\s\S]*\}/.test(source)],