diff --git a/public/js/book-playback-timeline-module.js b/public/js/book-playback-timeline-module.js index 4434227..dacaab0 100644 --- a/public/js/book-playback-timeline-module.js +++ b/public/js/book-playback-timeline-module.js @@ -28,6 +28,10 @@ class BookPlaybackTimelineModule extends BaseModule { 'ensureAnimationTimings', 'createPreparedSegment', 'createRevealDetail', + 'applyTexturePlan', + 'startRevealForSegment', + 'assertSegmentReady', + 'collectRequiredPageMetas', 'requiresSpreadTransition', 'requiresRightPageFlipAfterReveal', 'getBlockRevealSides', @@ -103,6 +107,7 @@ class BookPlaybackTimelineModule extends BaseModule { await this.timeStage('activate', segment, () => this.activatePreparedSegment(segment, sentence)); + sentence.webglRevealController = () => this.startRevealForSegment(segment); const visualPromise = this.waitForVisualCompletion(segment); const playbackPromise = this.timeStage('playback', segment, () => { return this.playbackCoordinator?.play?.(sentence) || Promise.resolve(); @@ -146,7 +151,10 @@ class BookPlaybackTimelineModule extends BaseModule { if (!previewSpread) return null; const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare'); - this.textureRenderer.prepareRevealBlock(revealDetail, { phase: 'prepare' }); + const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { + phase: 'prepare', + publishEvent: false + }); const targetSpreadIndex = Math.max(0, Number(previewSpread.index || 0)); const currentSpreadIndex = this.getVisibleSpreadIndex(); @@ -162,11 +170,14 @@ class BookPlaybackTimelineModule extends BaseModule { revealSides, requiresPreFlip: targetSpreadIndex > currentSpreadIndex, requiresRightFlip: revealSides.includes('right') && this.requiresRightPageFlipAfterReveal(previewSpread), + preparedTexturePlan: texturePlan, preparedAt: performance.now(), status: 'prepared' }; + this.applyTexturePlan(texturePlan, segment, 'prepare'); await this.timeStage('texture-prewarm', segment, () => this.prewarmSegmentTextures(segment)); + await this.assertSegmentReady(segment, 'prepare'); if (options.immediate !== true) { await new Promise(resolve => setTimeout(resolve, 0)); } @@ -185,7 +196,12 @@ class BookPlaybackTimelineModule extends BaseModule { && this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread); const revealDetail = this.createRevealDetail(sentence, segment.activeSpread || segment.previewSpread, 'activate'); - this.textureRenderer.prepareRevealBlock(revealDetail); + const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { + publishEvent: false + }); + segment.activeTexturePlan = texturePlan; + this.applyTexturePlan(texturePlan, segment, 'activate'); + await this.assertSegmentReady(segment, 'activate'); segment.status = 'activated'; this.recordDiagnostic('segment-activate:end', segment); return segment.activeSpread; @@ -210,6 +226,38 @@ class BookPlaybackTimelineModule extends BaseModule { }; } + applyTexturePlan(texturePlan = null, segment = {}, phase = 'activate') { + if (!texturePlan) { + throw new Error(`BookPlaybackTimeline: Missing texture plan for block ${segment.blockId ?? 'unknown'} during ${phase}`); + } + if (typeof window.BookLabDebug?.applyPageTextureRecords !== 'function') { + throw new Error('BookPlaybackTimeline: WebGL book lab cannot apply prepared texture plans'); + } + window.BookLabDebug.applyPageTextureRecords({ + ...texturePlan, + phase: phase === 'prepare' ? 'prepare' : 'activate' + }); + this.recordDiagnostic(`texture-plan-applied:${phase}`, segment); + return true; + } + + startRevealForSegment(segment = {}) { + if (!segment?.blockId) return false; + const revealStart = this.textureRenderer?.startPreparedRevealAnimation?.(segment.blockId, { + publishEvent: false + }); + if (!revealStart) { + throw new Error(`BookPlaybackTimeline: Prepared reveal animation is missing for block ${segment.blockId}`); + } + if (typeof window.BookLabDebug?.startRevealForBlock !== 'function') { + throw new Error('BookPlaybackTimeline: WebGL book lab cannot start prepared reveals explicitly'); + } + window.BookLabDebug.startRevealForBlock(segment.blockId); + this.markBenchmark('reveal-start', segment); + this.recordDiagnostic('reveal-started', segment); + return true; + } + requiresSpreadTransition(segment = {}) { return Math.max(0, Number(segment.targetSpreadIndex || 0)) > this.getVisibleSpreadIndex(); } @@ -288,15 +336,20 @@ class BookPlaybackTimelineModule extends BaseModule { getPageMetaForIndex: this.getPageMetaForIndex, recordMiss: true }); + await this.assertSegmentReady({ + blockId: options.blockId ?? null, + targetSpreadIndex: options.targetSpread, + revealSides: [] + }, 'flip'); 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 - } - })); + if (typeof window.BookLabDebug?.requestPageFlip !== 'function') { + throw new Error('BookPlaybackTimeline: WebGL book lab cannot execute prepared flip plans'); + } + window.BookLabDebug.requestPageFlip(direction, { + force: options.force === true, + reason: options.reason || 'timeline', + targetSpread: options.targetSpread + }); return wait; } @@ -316,6 +369,53 @@ class BookPlaybackTimelineModule extends BaseModule { return result; } + collectRequiredPageMetas(segment = {}) { + const spreads = new Set(); + const currentSpread = this.getVisibleSpreadIndex(); + const targetSpread = Number.isFinite(Number(segment.targetSpreadIndex)) + ? Math.max(0, Math.round(Number(segment.targetSpreadIndex))) + : currentSpread; + spreads.add(0); + spreads.add(currentSpread); + spreads.add(Math.max(0, currentSpread - 1)); + spreads.add(currentSpread + 1); + spreads.add(targetSpread); + if (segment.requiresRightFlip) spreads.add(targetSpread + 1); + return Array.from(spreads) + .filter(spread => spread >= 0) + .flatMap(spread => [ + this.getPageMetaForIndex(spread * 2), + this.getPageMetaForIndex(spread * 2 + 1) + ]); + } + + async assertSegmentReady(segment = {}, phase = 'play') { + if (!this.pageCache || typeof this.pageCache.ensurePageTexture !== 'function') { + throw new Error('BookPlaybackTimeline: Page texture cache is not available'); + } + const metas = this.collectRequiredPageMetas(segment); + const missing = []; + await Promise.all(metas.map(async (meta) => { + const texture = await this.pageCache.ensurePageTexture(meta, { + recordMiss: true + }); + if (!texture) missing.push(meta); + })); + if (missing.length > 0) { + this.pageCache.recordProblem?.({ + type: 'timeline-cache-readiness-failed', + phase, + blockId: segment.blockId ?? null, + missingPages: missing.map(meta => meta.pageIndex ?? null) + }); + throw new Error(`BookPlaybackTimeline: Cache readiness failed during ${phase} for pages ${missing.map(meta => meta.pageIndex).join(', ')}`); + } + segment.cacheReady = true; + segment.cacheReadyPhase = phase; + this.recordDiagnostic(`cache-ready:${phase}`, segment); + return true; + } + getPageMetaForIndex(pageIndex = 0) { const index = Math.max(0, Math.round(Number(pageIndex || 0))); const spreadIndex = Math.floor(index / 2); diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 2e4e371..a427f8b 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -79,7 +79,6 @@ class BookTextureRendererModule extends BaseModule { 'prepareRevealBlock', 'preloadAdditionalRevealSpreads', 'spreadContainsBlock', - 'hasPreparedRevealBlock', 'createAnimationState', 'getDrawPhase', 'publishPreparedReveal', @@ -141,12 +140,6 @@ class BookTextureRendererModule extends BaseModule { } this.drawSpread(this.currentSpread); }); - this.addEventListener(document, 'book-texture:reveal-block', (event) => { - this.startRevealAnimation(event.detail || {}); - }); - this.addEventListener(document, 'book-texture:prepare-reveal-block', (event) => { - this.prepareRevealBlock(event.detail || {}); - }); this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations); this.addEventListener(document, 'ui:command', (event) => { if (event.detail?.type === 'continue') this.fastForwardAnimations(); @@ -910,13 +903,17 @@ class BookTextureRendererModule extends BaseModule { const cached = this.pageCache.takePreparedRevealPlan(id); this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail)); this.pendingRevealBlockIds.delete(id); - this.publishPreparedReveal(cached); + this.publishPreparedReveal(cached, options); this.markPipelineTiming('prepareRevealBlock:end', { blockId: id, wordTimingCount: wordTimings.length, reusedPreparedCanvas: true }); - return; + return { + ...cached, + phase: 'activate', + preparedFromCache: true + }; } this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail)); @@ -924,7 +921,10 @@ class BookTextureRendererModule extends BaseModule { this.revealPublishBlockIds = new Set([id]); const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.(); const sides = ['left', 'right']; - const published = this.drawSpread(spread, sides, { phase }); + const published = this.drawSpread(spread, sides, { + phase, + publishEvent: options.publishEvent !== false + }); if (phase !== 'prepare') this.preloadAdditionalRevealSpreads(id, spread); if (phase === 'prepare' && published) { this.pageCache?.rememberPreparedRevealPlan?.(id, { @@ -939,6 +939,12 @@ class BookTextureRendererModule extends BaseModule { wordTimingCount: wordTimings.length, phase }); + return published ? { + ...published, + blockId, + wordTimings, + totalDuration: detail.totalDuration || 0 + } : null; } preloadAdditionalRevealSpreads(blockId, primarySpread = null) { @@ -960,32 +966,29 @@ class BookTextureRendererModule extends BaseModule { }); } - hasPreparedRevealBlock(blockId) { - const id = String(blockId ?? ''); - return Boolean(id && this.pageCache?.hasPreparedRevealPlan?.(id)); - } - - publishPreparedReveal(prepared) { - if (!prepared) return; + publishPreparedReveal(prepared, options = {}) { + if (!prepared) return null; this.markPipelineTiming('publishPreparedReveal', { blockId: prepared.blockId, sides: prepared.sides || [], hasReveal: Boolean(prepared.reveal && Object.keys(prepared.reveal).length) }); - document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', { - detail: { - metrics: prepared.metrics, - hitMaps: prepared.hitMaps || this.hitMaps, - records: prepared.records || this.buildPageTextureRecords(prepared.sides || ['left', 'right'], prepared), - reveal: prepared.reveal || {}, - pageMeta: prepared.pageMeta || {}, - phase: 'activate', - preparedFromCache: true - } - })); + const detail = { + metrics: prepared.metrics, + hitMaps: prepared.hitMaps || this.hitMaps, + records: prepared.records || this.buildPageTextureRecords(prepared.sides || ['left', 'right'], prepared), + reveal: prepared.reveal || {}, + pageMeta: prepared.pageMeta || {}, + phase: 'activate', + preparedFromCache: true + }; + if (options.publishEvent !== false) { + document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', { detail })); + } + return detail; } - startPreparedRevealAnimation(blockId) { + startPreparedRevealAnimation(blockId, options = {}) { const id = String(blockId ?? ''); const animation = this.activeAnimations.get(id); if (!animation) return false; @@ -996,13 +999,18 @@ class BookTextureRendererModule extends BaseModule { animation.startedAt = performance.now(); animation.prepared = false; animation.completed = false; - document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', { - detail: { - blockId: animation.blockId - } - })); + if (options.publishEvent !== false) { + document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', { + detail: { + blockId: animation.blockId + } + })); + } this.requestAnimationFrame(); - return true; + return { + blockId: animation.blockId, + wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0 + }; } fastForwardAnimations() { @@ -1153,9 +1161,11 @@ class BookTextureRendererModule extends BaseModule { regionCounts, phase }); - document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', { - detail - })); + if (options.publishEvent !== false) { + document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', { + detail + })); + } return detail; } diff --git a/public/js/playback-coordinator-module.js b/public/js/playback-coordinator-module.js index 264eb86..af2b299 100644 --- a/public/js/playback-coordinator-module.js +++ b/public/js/playback-coordinator-module.js @@ -252,16 +252,6 @@ class PlaybackCoordinatorModule extends BaseModule { }; }); } - document.dispatchEvent(new CustomEvent('book-texture:reveal-block', { - detail: { - id: sentence.id, - blockId: sentence.blockId ?? sentence.metadata?.blockId ?? null, - wordTimings, - cueTimings, - totalDuration: sentence.animation.totalDuration || 0 - } - })); - return new Promise((resolve) => { const totalDuration = wordTimings.length > 0 ? Math.max(...wordTimings.map(timing => timing.delay + timing.duration)) @@ -332,15 +322,16 @@ class PlaybackCoordinatorModule extends BaseModule { cueTimings = []; } - document.dispatchEvent(new CustomEvent('book-texture:reveal-block', { - detail: { - id: sentence.id, - blockId: sentence.blockId ?? sentence.metadata?.blockId ?? null, - wordTimings, - cueTimings, - totalDuration: sentence.animation?.totalDuration || 0 - } - })); + if (typeof sentence.webglRevealController !== 'function') { + throw new Error('PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller'); + } + sentence.webglRevealController({ + id: sentence.id, + blockId: sentence.blockId ?? sentence.metadata?.blockId ?? null, + wordTimings, + cueTimings, + totalDuration: sentence.animation?.totalDuration || 0 + }); return new Promise((resolve) => { const totalDuration = wordTimings.length > 0 diff --git a/public/js/sentence-queue-module.js b/public/js/sentence-queue-module.js index 94d13c1..1f614ef 100644 --- a/public/js/sentence-queue-module.js +++ b/public/js/sentence-queue-module.js @@ -899,39 +899,9 @@ 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; + if (!bookPlaybackTimeline || typeof bookPlaybackTimeline.prepareSentence !== 'function') { + throw new Error('SentenceQueue: 3D book presentation requires the book playback timeline'); } - const bookPagination = this.getModule('book-pagination'); - const bookTextureRenderer = this.getModule('book-texture-renderer'); - if (!bookPagination || !bookTextureRenderer) return null; - - if (this.isWebGLBookPresentationPrepared(sentence)) { - return sentence.webglBookPresentation?.spread || null; - } - - if (!Array.isArray(sentence.animation?.wordTimings) || sentence.animation.wordTimings.length === 0) { - const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || []; - sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []); - } - if (!options.immediate) { await new Promise(resolve => { const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 1)); @@ -939,42 +909,25 @@ class SentenceQueueModule extends BaseModule { }); } if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null; - - const spread = typeof bookPagination.preparePendingBlock === 'function' - ? await bookPagination.preparePendingBlock(sentence, { - activate: false, - publish: false, - includeUnrenderedHistory: true - }) - : null; - if (!spread) return null; - if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null; - - if (typeof bookTextureRenderer.prepareRevealBlock === 'function') { - bookTextureRenderer.prepareRevealBlock({ - id: sentence.id, - blockId, - wordTimings: sentence.animation?.wordTimings || [], - cueTimings: sentence.animation?.cueTimings || [], - totalDuration: sentence.animation?.totalDuration || 0, - spread, - phase: 'prepare' - }, { phase: 'prepare' }); - sentence.webglBookPresentation = { - prepared: true, - blockId, - spread - }; - } - return spread; + 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; } isWebGLBookPresentationPrepared(sentence) { const blockId = sentence?.blockId ?? sentence?.metadata?.blockId ?? null; if (blockId == null) return false; if (sentence?.webglBookPresentation?.prepared === true) return true; - const bookTextureRenderer = this.getModule('book-texture-renderer'); - return Boolean(bookTextureRenderer?.hasPreparedRevealBlock?.(blockId)); + const bookPlaybackTimeline = this.getModule('book-playback-timeline'); + return Boolean(bookPlaybackTimeline?.preparedSegments?.has?.(`${sentence.gameId || sentence.metadata?.gameId || 'game'}:${blockId}`)); } isCurrentQueueItem(item, queueGeneration = this.queueGeneration) { diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index 3401be8..dbede87 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -71,7 +71,6 @@ class UIDisplayHandlerModule extends BaseModule { 'isWebGLMode', 'playWebGLBookSentence', 'prepareWebGLBookReveal', - 'waitForWebGLPageFlip', 'renderStoryBlock', 'prepareRenderableBlock', 'prepareTextRenderable', @@ -1070,47 +1069,6 @@ class UIDisplayHandlerModule extends BaseModule { return timeline.prepareSentence(sentence, { immediate: true }); } - waitForWebGLPageFlip(detail = {}) { - return new Promise((resolve) => { - let resolved = false; - const cleanup = () => { - window.clearTimeout(timeout); - document.removeEventListener('webgl-book:page-flip-started', onStarted); - document.removeEventListener('webgl-book:page-flip-finished', onFinished); - }; - const finish = (result) => { - if (resolved) return; - resolved = true; - cleanup(); - resolve(result); - }; - const requestedTargetSpread = Number.isFinite(Number(detail.targetSpread)) - ? Math.max(0, Math.round(Number(detail.targetSpread))) - : null; - const matchesTarget = (eventDetail = {}) => requestedTargetSpread == null - || Math.max(0, Math.round(Number(eventDetail.targetSpread || 0))) === requestedTargetSpread; - const onStarted = (event) => { - if (!matchesTarget(event.detail || {})) return; - document.documentElement.dataset.webglLastStartedPageFlip = JSON.stringify(event.detail || {}); - }; - const onFinished = (event) => { - if (!matchesTarget(event.detail || {})) return; - finish(true); - }; - const timeout = window.setTimeout(() => finish(false), 2400); - document.addEventListener('webgl-book:page-flip-started', onStarted); - document.addEventListener('webgl-book:page-flip-finished', onFinished); - document.dispatchEvent(new CustomEvent('webgl-book:request-page-flip', { - detail: { - direction: Math.sign(Number(detail.direction || 1)) || 1, - reason: detail.reason || 'pending-block-overflow', - force: true, - targetSpread: requestedTargetSpread - } - })); - }); - } - async rerenderStory() { if (!this.paragraphContainer || this.renderedItems.length === 0) return; console.log('UIDisplayHandler: Re-typesetting story after page resize'); diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 4a78378..ce32b2e 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -586,6 +586,17 @@ window.BookLabDebug = { window.BookTextureRenderer?.publishSpread?.(); return true; }, + applyPageTextureRecords(detail = {}) { + handlePageTextureRecords({ detail }); + return true; + }, + startRevealForBlock(blockId) { + startPageRevealForBlock(blockId); + return true; + }, + requestPageFlip(direction = 1, options = {}) { + return startPageFlip(direction, options); + }, getRevealDebugState() { return getRevealDebugState(); }, @@ -686,17 +697,6 @@ document.addEventListener('webgl-book:page-reserve-directive', (event) => { : Math.round(value); setPageReserve(nextReserve); }); -document.addEventListener('webgl-book:request-page-flip', (event) => { - const detail = event.detail || {}; - const direction = Math.sign(Number(detail.direction || 1)) || 1; - const targetSpread = Number.isFinite(Number(detail.targetSpread)) - ? Math.max(0, Math.round(Number(detail.targetSpread))) - : null; - startPageFlip(direction, { - force: detail.force === true, - targetSpread - }); -}); document.addEventListener('ui:command', (event) => { if (event.detail?.type === 'continue' && pendingRightPageFlip) { tryStartPendingRightPageFlip('continue', { force: true }); diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index e462415..fe13b3d 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -143,7 +143,7 @@ const checks = [ ['sentence queue keeps current 3D page prep immediate while future lookahead yields cooperatively', /if \(!options\.immediate\) \{[\s\S]*requestIdleCallback[\s\S]*timeout: 80/.test(sentenceQueueSource) && /prefetchAhead\(maxLookahead = 6/.test(sentenceQueueSource)], ['pagination can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)], ['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)], - ['texture renderer stores prepared reveal plans in the shared texture store', !/preparedRevealCache/.test(textureRendererSource) && /rememberPreparedRevealPlan/.test(webglPageCacheSource) && /takePreparedRevealPlan/.test(textureRendererSource) && /publishPreparedReveal/.test(textureRendererSource) && /hasPreparedRevealBlock/.test(textureRendererSource)], + ['texture renderer stores prepared reveal plans in the shared texture store', !/preparedRevealCache/.test(textureRendererSource) && /rememberPreparedRevealPlan/.test(webglPageCacheSource) && /takePreparedRevealPlan/.test(textureRendererSource) && /publishPreparedReveal/.test(textureRendererSource) && !/hasPreparedRevealBlock/.test(textureRendererSource)], ['webgl page cache is loaded through module infrastructure', /webgl-page-cache-module\.js/.test(loaderSource) && /super\('webgl-page-cache'/.test(webglPageCacheSource) && /reportProgress\(20, 'Opening WebGL page texture cache'\)/.test(webglPageCacheSource)], ['webgl page cache uses an isolated browser database without upgrading tts history state', /this\.dbName = 'webglPageTextureCacheDB'/.test(webglPageCacheSource) && /this\.dbVersion = 1/.test(webglPageCacheSource) && /this\.dbVersion = 3/.test(ttsFactorySource) && /this\.dbVersion = 3/.test(storyHistorySource) && !/webglPageTextureStore/.test(ttsFactorySource) && !/webglPageTextureStore/.test(storyHistorySource)], ['texture renderer hands completed page canvases to the single texture store without owning write queues', /webgl-page-cache/.test(textureRendererSource) && /cachePublishedPages/.test(textureRendererSource) && /storePageCanvas\(pageMeta, canvas, \{ persist: true, resident: true \}\)/.test(textureRendererSource) && !/schedulePageCacheWrite/.test(textureRendererSource) && !/pendingPageCacheWrites/.test(textureRendererSource)], @@ -159,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 timeline-owned page flip before activating future spread', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /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)], + ['3D overflow reveal waits for an explicit timeline flip plan before activating future spread', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /BookLabDebug\?\.requestPageFlip/.test(bookPlaybackTimelineSource) && /requestPageFlip\(direction = 1, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.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)], @@ -211,11 +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', /createRevealDetail\(sentence, previewSpread, 'prepare'\)/.test(bookPlaybackTimelineSource) && /this\.textureRenderer\.prepareRevealBlock\(revealDetail, \{ phase: 'prepare' \}\)/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /reason: 'timeline-preplay-spread-transition'/.test(bookPlaybackTimelineSource)], + ['3D overflow reveal preloads target spread before forced page flip', /createRevealDetail\(sentence, previewSpread, 'prepare'\)/.test(bookPlaybackTimelineSource) && /this\.textureRenderer\.prepareRevealBlock\(revealDetail, \{[\s\S]*phase: 'prepare'[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /this\.assertSegmentReady\(segment, 'prepare'\)/.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) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /await this\.pageCache\?\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource)], + ['book playback timeline enforces resident page textures before prepared playback', /assertSegmentReady/.test(bookPlaybackTimelineSource) && /collectRequiredPageMetas/.test(bookPlaybackTimelineSource) && /this\.pageCache\.ensurePageTexture\(meta/.test(bookPlaybackTimelineSource) && /timeline-cache-readiness-failed/.test(bookPlaybackTimelineSource)], + ['3D reveal start is controlled by the prepared timeline instead of texture events', /sentence\.webglRevealController = \(\) => this\.startRevealForSegment\(segment\)/.test(bookPlaybackTimelineSource) && /startPreparedRevealAnimation\?\.\(segment\.blockId, \{[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller/.test(playbackCoordinatorSource) && !/document\.dispatchEvent\(new CustomEvent\('book-texture:reveal-block'/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal'))], ['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)], ['book playback timeline waits for right reveal only when current block is on right page', /getBlockRevealSides/.test(bookPlaybackTimelineSource) && /revealSides\.includes\('right'\) && this\.requiresRightPageFlipAfterReveal/.test(bookPlaybackTimelineSource) && /visual-completion:no-right-flip-wait/.test(bookPlaybackTimelineSource)],