From 705d1ea6bf2e6f56dc7cddded0df78a7fbe8b70b Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Sat, 20 Jun 2026 00:59:01 +0200 Subject: [PATCH] Fix new-game title flip + cap lookahead prepare burst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the worker migration with prepare-burst pacing and a title-flip fix: - New game from mid-game left the book on the previous game's spread, so the first block's source and target spread matched and the title->content page turn was skipped. story:client-reset now returns the book to the title spread (spread 0) so the first block flips 0->1 and animates. Verified: requiresSpreadTransition src=0 tgt=1, page-flip-started/near-end fire. - The lookahead burst-prepared many blocks at once, spiking allocation/GC into multi-second main-thread stalls. WebGL book prepares are now serialized through a chain and capped to a small lookahead window (TTS audio prefetch still spans the full window); future lookahead is also deferred until the current sentence has entered the display pipeline, keeping it off the first flip/reveal critical path. Worst game-start stall ~6s -> ~3.4s. - Page flips now drive the scene through the sceneControl prewarm/startPreparedPageFlip API (awaited) instead of an event, and the scene awaits the async initial spread draw. Suite 177. Remaining: a per-block prepare stall (~1.6-3.4s for large blocks at game start) that profiling has not yet attributed to a single function (likely GC from prepare-path allocation) — needs a DevTools performance capture for exact attribution. Co-Authored-By: Claude Opus 4.8 --- public/js/book-playback-timeline-module.js | 79 +++++++++++----- public/js/sentence-queue-module.js | 100 ++++++++++++++++----- public/js/ui-display-handler-module.js | 5 +- public/js/webgl-book-lab.js | 97 +++++++++++++++++--- public/js/webgl-book-scene-module.js | 6 +- scripts/check-webgl-book-lab.js | 17 +++- 6 files changed, 237 insertions(+), 67 deletions(-) diff --git a/public/js/book-playback-timeline-module.js b/public/js/book-playback-timeline-module.js index 178b530..c4e2ab9 100644 --- a/public/js/book-playback-timeline-module.js +++ b/public/js/book-playback-timeline-module.js @@ -10,10 +10,10 @@ * -> activate (upload the visible textures for the target spread) * -> reveal (animate the new block's text in) * - * It drives the scene exclusively through the formal `webgl-book:*` events and - * the registered `webgl-book-scene` accessor. It never touches `window.BookLabDebug` - * (debug-only) and never throws out of the live playback path: a transient cache - * miss is surfaced as a problem state and playback degrades gracefully. + * It drives the scene through the registered `webgl-book-scene` accessor and uses + * `webgl-book:*` events only as state notifications. It never touches + * `window.BookLabDebug` (debug-only). Cache and scene-preparation misses are + * surfaced as problem states instead of being hidden by alternate playback paths. */ import { BaseModule } from './base-module.js'; @@ -120,6 +120,7 @@ class BookPlaybackTimelineModule extends BaseModule { this.recordDiagnostic('segment-play:start', segment); try { + segment.sourceSpreadIndex = this.getVisibleSpreadIndex(); // Commit pagination first so the flip targets the authoritative spread, // not the predicted preview spread. await this.timeStage('commit', segment, () => this.commitSegmentSpread(segment, sentence)); @@ -279,6 +280,9 @@ class BookPlaybackTimelineModule extends BaseModule { async commitSegmentSpread(segment = {}, sentence = segment.sentence) { if (!segment || !sentence) return null; + segment.sourceSpreadIndex = Number.isFinite(Number(segment.sourceSpreadIndex)) + ? Math.max(0, Math.round(Number(segment.sourceSpreadIndex))) + : this.getVisibleSpreadIndex(); const activeSpread = await this.pagination.preparePendingBlock(sentence, { includeUnrenderedHistory: true }); @@ -314,10 +318,18 @@ class BookPlaybackTimelineModule extends BaseModule { }; } const spread = segment.activeSpread || segment.previewSpread; - const revealDetail = this.createRevealDetail(sentence, spread, 'activate'); + let texturePlan = segment.preparedTexturePlan + ? { ...segment.preparedTexturePlan, phase: 'activate' } + : null; + if (texturePlan && this.pageCache?.hasPreparedRevealPlan?.(segment.blockId)) { + this.pageCache.takePreparedRevealPlan(segment.blockId); + } + if (!texturePlan) { + const revealDetail = this.createRevealDetail(sentence, spread, 'activate'); + texturePlan = await this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false }); + } // Reuse the spanning-aware plan prepared during lookahead — its timing already spans // both pages. No synchronous redraw on the critical path. - const texturePlan = await this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false }); segment.activeTexturePlan = texturePlan; this.applyTexturePlan(texturePlan, segment, 'activate'); await this.assertSegmentReady(segment, 'activate'); @@ -439,7 +451,10 @@ class BookPlaybackTimelineModule extends BaseModule { } requiresSpreadTransition(segment = {}) { - return Math.max(0, Number(segment.targetSpreadIndex || 0)) > this.getVisibleSpreadIndex(); + const sourceSpread = Number.isFinite(Number(segment.sourceSpreadIndex)) + ? Math.max(0, Math.round(Number(segment.sourceSpreadIndex))) + : this.getVisibleSpreadIndex(); + return Math.max(0, Number(segment.targetSpreadIndex || 0)) > sourceSpread; } requiresRightPageFlipAfterReveal(spread = {}) { @@ -561,26 +576,42 @@ class BookPlaybackTimelineModule extends BaseModule { async requestPageFlip(direction = 1, options = {}) { if (this.isChoiceAwaitingPlayer()) return false; - // Warm the texture cache for the navigation window and verify the target pages - // are resident before asking the scene to flip. The scene performs its own - // flip-specific prewarm (drawing the spreads), so we do not pass this through. - await this.prepareFlipPlan(direction, options); + const flipPlan = await this.prepareFlipPlan(direction, options); 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, - revealSides: Array.isArray(options.revealSides) ? options.revealSides : null - } - })); - return wait; + const sceneControl = this.scene?.sceneControl || null; + if (typeof sceneControl?.prewarmPageFlip !== 'function' || typeof sceneControl?.startPreparedPageFlip !== 'function') { + this.pageCache?.recordProblem?.({ + type: 'timeline-scene-flip-api-missing', + targetSpread: flipPlan.targetSpread, + reason: options.reason || 'timeline' + }); + return false; + } + const scenePrewarm = await sceneControl.prewarmPageFlip(direction, { + targetSpread: flipPlan.targetSpread, + reason: options.reason || 'timeline' + }); + const started = sceneControl.startPreparedPageFlip(direction, { + force: options.force === true, + reason: options.reason || 'timeline', + targetSpread: flipPlan.targetSpread, + deferRevealSides: Array.isArray(options.revealSides) ? options.revealSides : null, + flipPlan, + prewarm: scenePrewarm + }); + if (!started) { + this.pageCache?.recordProblem?.({ + type: 'timeline-scene-flip-start-failed', + targetSpread: flipPlan.targetSpread, + reason: options.reason || 'timeline' + }); + return false; + } + return this.waitForPageFlipFinished(flipPlan.targetSpread, { alreadyStarted: true }); } async prepareFlipPlan(direction = 1, options = {}) { @@ -728,9 +759,9 @@ class BookPlaybackTimelineModule extends BaseModule { }; } - waitForPageFlipFinished(targetSpread = null) { + waitForPageFlipFinished(targetSpread = null, options = {}) { return new Promise(resolve => { - let started = false; + let started = options.alreadyStarted === true; let resolved = false; const expectedSpread = Number.isFinite(Number(targetSpread)) ? Math.max(0, Math.round(Number(targetSpread))) diff --git a/public/js/sentence-queue-module.js b/public/js/sentence-queue-module.js index be18ddc..f4f1802 100644 --- a/public/js/sentence-queue-module.js +++ b/public/js/sentence-queue-module.js @@ -7,6 +7,7 @@ import { BaseModule } from './base-module.js'; const TTS_GENERATION_TIMEOUT_MS = 60000; const ASSET_PRELOAD_TIMEOUT_MS = 60000; const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000; +const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 2; class SentenceQueueModule extends BaseModule { constructor() { @@ -23,6 +24,7 @@ class SentenceQueueModule extends BaseModule { // Cache prepared future queue items so the playback path can consume // work that was already generated during lookahead. this.prefetchingSpeech = new Map(); + this.prefetchingWebGLBook = new Map(); this.preparedSentenceCache = new Map(); this.autoplay = true; this.inputMode = 'text'; @@ -33,6 +35,7 @@ class SentenceQueueModule extends BaseModule { this.generationRequests = new Map(); this.assetPreloadRequests = new Map(); this.queueGeneration = 0; + this.webglBookPrepareChain = Promise.resolve(); // Bind methods this.bindMethods([ @@ -46,7 +49,10 @@ class SentenceQueueModule extends BaseModule { 'getPreparedSentence', 'prefetchAhead', 'prefetchWebGLBookPresentation', + 'runWebGLBookPresentationPrepare', 'isWebGLBookPresentationPrepared', + 'getWebGLBookPresentationKey', + 'isWebGLBookPresentationEligible', 'prepareSpeechMetadata', 'preloadAssetsForItem', 'normalizeTtsText', @@ -210,18 +216,25 @@ class SentenceQueueModule extends BaseModule { } if (!this.isCurrentQueueItem(item, queueGeneration)) return; - // Prefetch far enough ahead that media pauses do not block TTS - // generation for the next spoken paragraph. - this.prefetchAhead(6, queueGeneration); - if (!this.isCurrentQueueItem(item, queueGeneration)) return; - // Notify display handler with complete sentence if (this.onSentenceReadyCallback) { - await new Promise(resolve => { + const playbackFinished = new Promise(resolve => { sentence.onComplete = resolve; sentence.playbackStartedAt = performance.now(); this.onSentenceReadyCallback(sentence, resolve); }); + // Start lookahead only after the current sentence has entered the display + // pipeline. This keeps future WebGL book preparation out of the first + // flip/reveal critical path while still overlapping it with playback. + window.requestAnimationFrame(() => { + if (this.isCurrentQueueItem(item, queueGeneration)) { + this.prefetchAhead(6, queueGeneration); + } + }); + await playbackFinished; + if (!this.isCurrentQueueItem(item, queueGeneration)) return; + } else { + this.prefetchAhead(6, queueGeneration); if (!this.isCurrentQueueItem(item, queueGeneration)) return; } @@ -890,12 +903,42 @@ class SentenceQueueModule extends BaseModule { return this.prepareSentence(item); } + getWebGLBookPresentationKey(sentence = {}) { + const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null; + if (blockId == null) return null; + return `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${blockId}`; + } + + isWebGLBookPresentationEligible(sentence = {}) { + if (!sentence) return false; + return ['paragraph', 'heading'].includes(sentence.kind || sentence.type); + } + async prefetchWebGLBookPresentation(sentence, options = {}) { - if (!sentence || !['paragraph', 'heading'].includes(sentence.kind || sentence.type)) return null; + if (!this.isWebGLBookPresentationEligible(sentence)) return null; const isWebGLMode = document.body?.dataset?.webglUiMode === '3d' || document.body?.classList?.contains('webgl-mode'); if (!isWebGLMode) return null; + const key = this.getWebGLBookPresentationKey(sentence); + if (!key) return null; + const existing = this.prefetchingWebGLBook.get(key); + if (existing) return existing; + + const queued = this.webglBookPrepareChain + .catch(() => null) + .then(() => this.runWebGLBookPresentationPrepare(sentence, options)); + this.webglBookPrepareChain = queued.catch(() => null); + this.prefetchingWebGLBook.set(key, queued); + return queued.finally(() => { + if (this.prefetchingWebGLBook.get(key) === queued) { + this.prefetchingWebGLBook.delete(key); + } + }); + } + + async runWebGLBookPresentationPrepare(sentence, options = {}) { + if (!this.isWebGLBookPresentationEligible(sentence)) return null; const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null; if (blockId == null) return null; const bookPlaybackTimeline = this.getModule('book-playback-timeline'); @@ -912,6 +955,7 @@ class SentenceQueueModule extends BaseModule { const segment = await bookPlaybackTimeline.prepareSentence(sentence, { immediate: options.immediate === true }); + if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null; if (!segment) return null; sentence.webglBookPresentation = { prepared: true, @@ -944,14 +988,33 @@ class SentenceQueueModule extends BaseModule { } let started = 0; - let spokenPrepared = 0; + let webglBookLookahead = 0; const limit = Math.min(this.sentenceQueue.length, maxLookahead + 1); + const allowWebGLBookPrefetch = document.documentElement.dataset.webglBookPlaybackActive === 'true'; for (let index = 1; index < limit; index += 1) { const nextItem = this.sentenceQueue[index]; const nextCacheKey = this.getCacheKey(nextItem); + const cachedPrepared = this.preparedSentenceCache.get(nextCacheKey); + const webglBookCandidate = this.isWebGLBookPresentationEligible(cachedPrepared || nextItem); + const shouldPrepareWebGLBook = allowWebGLBookPrefetch + && webglBookCandidate + && webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD; + if (webglBookCandidate) webglBookLookahead += 1; + + if (cachedPrepared && !this.prefetchingSpeech.has(nextCacheKey)) { + if (shouldPrepareWebGLBook && !this.isWebGLBookPresentationPrepared(cachedPrepared)) { + this.prefetchWebGLBookPresentation(cachedPrepared, { + queueGeneration, + queueIndex: index + }).catch(err => { + console.warn('SentenceQueue: WebGL book prefetch failed:', err); + }); + } + continue; + } + if (this.prefetchingSpeech.has(nextCacheKey)) { - if (this.isSpeechItem(nextItem)) spokenPrepared += 1; continue; } @@ -969,10 +1032,12 @@ class SentenceQueueModule extends BaseModule { queueIndex: index }); if (queueGeneration !== this.queueGeneration) return null; - await this.prefetchWebGLBookPresentation(prepared, { - queueGeneration, - queueIndex: index - }); + if (shouldPrepareWebGLBook) { + await this.prefetchWebGLBookPresentation(prepared, { + queueGeneration, + queueIndex: index + }); + } if (queueGeneration !== this.queueGeneration) return null; this.preparedSentenceCache.set(nextCacheKey, prepared); return prepared; @@ -997,13 +1062,6 @@ class SentenceQueueModule extends BaseModule { this.prefetchingSpeech.set(nextCacheKey, promise); started += 1; - if (this.isSpeechItem(nextItem)) { - spokenPrepared += 1; - } - - if (spokenPrepared >= 1 && started >= 2) { - break; - } } if (started === 0) { @@ -1409,7 +1467,9 @@ class SentenceQueueModule extends BaseModule { this.cancelGenerationRequests('sentence-queue-cleared'); this.cancelAssetPreloads('sentence-queue-cleared'); this.prefetchingSpeech.clear(); + this.prefetchingWebGLBook.clear(); this.preparedSentenceCache.clear(); + this.webglBookPrepareChain = Promise.resolve(); this.pauseBeforeNextReason = null; document.dispatchEvent(new CustomEvent('tts:queue-empty', { detail: { reason: 'sentence-queue-cleared' } diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index dbede87..3089424 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -1023,9 +1023,10 @@ class UIDisplayHandlerModule extends BaseModule { this.revealImageBlock(element); } else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') { if (useWebGLBookReveal) { - await this.prepareWebGLBookReveal(sentence); + await this.playWebGLBookSentence(sentence); + } else { + await this.playbackCoordinator.play(sentence); } - await this.playbackCoordinator.play(sentence); if (useWebGLBookReveal && sentence.blockId != null) { this.markBlockRendered(sentence.blockId); } diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index a46272c..1cd590b 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -140,6 +140,7 @@ const dynamicBufferRefreshIntervalMs = 1000 / 30; // frames are just the cheap scene render and hold 60fps. Candle flicker is the only thing // changing them then, which 8Hz captures imperceptibly. const staticGeometryBufferRefreshIntervalMs = 1000 / 8; +const revealGeometryBufferRefreshIntervalMs = 1000 / 4; const flipDynamicBufferGraceMs = 180; let lastBookShadowRefreshAt = -Infinity; let lastTableReflectionRefreshAt = -Infinity; @@ -184,6 +185,8 @@ const lastFrameTiming = {}; const slowFrameLog = []; const loaderTimings = {}; const pageTextureTimings = []; +let queuedNavigationPrewarm = null; +let queuedNavigationPrewarmHandle = null; function markLoaderTiming(name) { loaderTimings[name] = performance.now(); @@ -385,6 +388,10 @@ const materials = { }), flipPageSurface: new THREE.MeshStandardMaterial({ color: 0xeee6cc, + map: getBlankPageTexture(), + normalMap: paperTextures.normal, + normalScale: new THREE.Vector2(0.004, 0.004), + roughnessMap: paperTextures.roughness, roughness: 0.92, metalness: 0, emissive: 0x100d08, @@ -440,6 +447,8 @@ const materials = { }; materials.flipPageBackSurface = materials.flipPageSurface.clone(); materials.flipPageBackSurface.map = getBlankPageTexture(); +materials.flipPageBackSurface.normalMap = paperTextures.normal; +materials.flipPageBackSurface.roughnessMap = paperTextures.roughness; materials.flipPageBackSurface.side = THREE.FrontSide; materials.flipPageEdge = materials.pageSurface.clone(); materials.flipPageEdge.map = paperTextures.edge; @@ -622,6 +631,15 @@ window.BookLabDebug = { requestPageFlip(direction = 1, options = {}) { return startPageFlip(direction, options); }, + async prewarmPageFlip(direction = 1, options = {}) { + const targetSpread = Number.isFinite(Number(options.targetSpread)) + ? Math.max(0, Math.round(Number(options.targetSpread))) + : null; + return prewarmFlipTextures(direction, targetSpread); + }, + startPreparedPageFlip(direction = 1, options = {}) { + return startPageFlipPrepared(direction, options); + }, getRevealDebugState() { return getRevealDebugState(); }, @@ -680,7 +698,9 @@ if (webglBookSceneModule) { setPageReserve: (value) => setPageReserve(value), setMaxVisitedPagePosition: (value) => window.BookLabDebug.setMaxVisitedPagePosition(value), redrawPageTextures: () => window.BookLabDebug.redrawPageTextures(), - projectPointerToPage: (clientX, clientY) => projectPointerToPage(clientX, clientY) + projectPointerToPage: (clientX, clientY) => projectPointerToPage(clientX, clientY), + prewarmPageFlip: (direction = 1, options = {}) => window.BookLabDebug.prewarmPageFlip(direction, options), + startPreparedPageFlip: (direction = 1, options = {}) => window.BookLabDebug.startPreparedPageFlip(direction, options) }; } @@ -716,6 +736,15 @@ document.addEventListener('story:client-reset', () => { pageRevealFreezeAt = null; clearPageReveal('left', 'client-reset'); clearPageReveal('right', 'client-reset'); + // Return the book to the title spread so the new game's first block flips in from the + // title page. Otherwise the view stays on the previous game's spread, the segment's + // source and target spread match, and the title->content page turn is skipped. + if (Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))) !== 0) { + bookPaginationState = { ...bookPaginationState, spreadIndex: 0 }; + const titleSpread = getPaginationSpread(0); + if (titleSpread) window.BookTextureRenderer?.drawSpread?.(titleSpread, ['left', 'right'], { force: true }); + syncBookControls(); + } }); // Pagination spread updates only carry state. The playback owner decides when the // visible spread changes (via flips). The scene jumps directly only for non-playback @@ -2232,12 +2261,7 @@ function handlePageTextureRecords(event) { source: 'book-texture-renderer' }); markPageTextureTiming('handlePageTextureRecords:end'); - prewarmNavigationTextureWindow('page-texture-records', { recordMiss: false }).catch((error) => { - pageTextureStore?.recordProblem?.({ - type: 'navigation-window-prewarm-error', - message: error?.message || String(error) - }); - }); + scheduleNavigationTextureWindowPrewarm('page-texture-records', { recordMiss: false }); } function normalizePageTextureRecordDetail(detail = {}) { @@ -2441,6 +2465,35 @@ async function prewarmNavigationTextureWindow(reason = 'navigation-window', opti return result || {}; } +function scheduleNavigationTextureWindowPrewarm(reason = 'navigation-window', options = {}) { + queuedNavigationPrewarm = { + reason, + options: { ...(options || {}) } + }; + if (queuedNavigationPrewarmHandle !== null) return; + const run = () => { + queuedNavigationPrewarmHandle = null; + const queued = queuedNavigationPrewarm; + queuedNavigationPrewarm = null; + if (!queued) return; + if (activeFlips.length > 0 || hasActivePageReveal()) { + scheduleNavigationTextureWindowPrewarm(queued.reason, queued.options); + return; + } + prewarmNavigationTextureWindow(queued.reason, queued.options).catch((error) => { + pageTextureStore?.recordProblem?.({ + type: 'navigation-window-prewarm-error', + message: error?.message || String(error) + }); + }); + }; + if (typeof window.requestIdleCallback === 'function') { + queuedNavigationPrewarmHandle = window.requestIdleCallback(run, { timeout: 350 }); + } else { + queuedNavigationPrewarmHandle = window.setTimeout(run, 80); + } +} + async function prewarmFlipTextures(direction, targetSpread = null) { const currentSpread = Math.max(0, Number(bookPaginationState.spreadIndex || 0)); const nextSpread = Number.isFinite(Number(targetSpread)) @@ -2545,10 +2598,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) { const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? pageTextureStore?.createTextureFromCanvas?.(revealDetail.baseCanvas) : null); const revealBlockIds = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds.map(value => String(value)) : []; - const activeStartedAt = revealBlockIds - .map(blockId => activeRevealBlockStarts.get(blockId)) - .filter(value => Number.isFinite(Number(value))) - .sort((a, b) => a - b)[0] ?? null; + const activeStartedAt = getRevealStartTimeForBlockIds(revealBlockIds); pageRevealState[side] = { startedAt: activeStartedAt ?? (revealDetail.startNow ? performance.now() : null), @@ -2600,6 +2650,22 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) { markPageTextureTiming('revealUpload:end', { side }); } +function getRevealStartTimeForBlockIds(blockIds = []) { + const startedAt = (Array.isArray(blockIds) ? blockIds : []) + .map(blockId => activeRevealBlockStarts.get(String(blockId))) + .filter(value => Number.isFinite(Number(value))) + .sort((a, b) => a - b)[0] ?? null; + if (startedAt !== null) return startedAt; + const pendingBlockId = (Array.isArray(blockIds) ? blockIds : []) + .map(blockId => String(blockId)) + .find(blockId => pendingRevealStartBlockIds.has(blockId)); + if (!pendingBlockId) return null; + const now = performance.now(); + activeRevealBlockStarts.set(pendingBlockId, now); + pendingRevealStartBlockIds.delete(pendingBlockId); + return now; +} + function applyPendingPageReveal(side, shader = getPageRevealShader(side)) { const material = side === 'left' ? materials.leftPage : materials.rightPage; const revealDetail = material?.userData?.pendingPageReveal; @@ -3141,8 +3207,6 @@ function prepareStaticPageForFlip(flip, prewarm = null) { materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap; materials.flipPageBackSurface.roughnessMap = materials.pageSurface.roughnessMap; - materials.flipPageSurface.needsUpdate = true; - materials.flipPageBackSurface.needsUpdate = true; syncFlipRevealShaderFromSource(sourceSide, materials.flipPageSurface); syncFlipRevealShaderFromSource(targetBackSide, materials.flipPageBackSurface); flip.sourceTexture = sourceTexture; @@ -4733,7 +4797,12 @@ function animate(now = performance.now()) { : Infinity; const deferDynamicBuffersForFlipStart = activeFlips.length > 0 && newestFlipAge < flipDynamicBufferGraceMs; const geometryAnimating = activeFlips.length > 0; - const bufferRefreshIntervalMs = geometryAnimating ? dynamicBufferRefreshIntervalMs : staticGeometryBufferRefreshIntervalMs; + const revealAnimating = hasActivePageReveal(); + const bufferRefreshIntervalMs = geometryAnimating + ? dynamicBufferRefreshIntervalMs + : revealAnimating + ? revealGeometryBufferRefreshIntervalMs + : staticGeometryBufferRefreshIntervalMs; const shadowRefreshDue = !deferDynamicBuffersForFlipStart && ( forceDynamicBufferRefresh || now - lastBookShadowRefreshAt >= bufferRefreshIntervalMs ); diff --git a/public/js/webgl-book-scene-module.js b/public/js/webgl-book-scene-module.js index 4f9b266..00e0069 100644 --- a/public/js/webgl-book-scene-module.js +++ b/public/js/webgl-book-scene-module.js @@ -365,14 +365,14 @@ class WebGLBookSceneModule extends BaseModule { async initializeScene() { if (this.labImportPromise) return this.labImportPromise; - const cacheBuster = window.MODULE_CACHE_BUSTER || Date.now(); - this.labImportPromise = import(`/js/webgl-book-lab.js?v=${encodeURIComponent(cacheBuster)}`); + const moduleVersion = window.MODULE_CACHE_BUSTER || 'dev'; + this.labImportPromise = import(`/js/webgl-book-lab.js?v=${encodeURIComponent(moduleVersion)}`); await this.labImportPromise; this.reportProgress(94, 'Uploading initial book page textures'); const pagination = this.getModule('book-pagination'); const initialSpread = pagination?.getCurrentSpread?.(); if (initialSpread && typeof window.BookTextureRenderer?.drawSpread === 'function') { - window.BookTextureRenderer.drawSpread(initialSpread, ['left', 'right'], { force: true }); + await window.BookTextureRenderer.drawSpread(initialSpread, ['left', 'right'], { force: true }); } else { window.BookTextureRenderer?.publishSpread?.(); } diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index ae107c6..d2282c7 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -129,6 +129,7 @@ const checks = [ ['webgl lab exposes reveal uniform diagnostics', /getRevealDebugState/.test(source) && /bookRevealActive/.test(source) && /bookRevealElapsedMs/.test(source) && /bookRevealRegionCount/.test(source)], ['webgl lab records page reveal clear reasons', /clearPageReveal\(side, reason/.test(source) && /webglRevealClearLog/.test(source)], ['webgl reveal clock starts on first render frame', /pendingStart/.test(source) && /state\.pendingStart/.test(source) && /state\.startedAt = now/.test(source)], + ['webgl reveal start survives event-before-state ordering', /function getRevealStartTimeForBlockIds/.test(source) && /activeRevealBlockStarts\.set\(pendingBlockId, now\)/.test(source) && /pendingRevealStartBlockIds\.delete\(pendingBlockId\)/.test(source)], ['webgl reveal visual clock is derived from absolute playback time', /visualElapsedMs/.test(source) && /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/revealFrameDeltaMs/.test(source)], ['webgl fast-forward accelerates reveal instead of clearing the mask immediately', /fastForwarding/.test(source) && /fastForwardDurationMs/.test(source) && !/clearPageReveal\(side, 'fast-forward'\)/.test(source)], ['webgl lab records page texture binding timings', /pageTextureTimings/.test(source) && /markPageTextureTiming/.test(source) && /webglPageTextureTimings/.test(source)], @@ -139,10 +140,13 @@ const checks = [ ['texture renderer diagnostics include reveal region counts', /regionCounts/.test(textureRendererSource) && /lineRects/.test(textureRendererSource) && /durationMs/.test(textureRendererSource)], ['texture renderer does not draw immediate non-pending sides during pending reveal preparation', !/immediateSides/.test(textureRendererSource) && !/this\.drawSpread\(this\.currentSpread, immediateSides\)/.test(textureRendererSource)], ['sentence queue consumes completed prepared lookahead items', /preparedSentenceCache/.test(sentenceQueueSource) && /this\.preparedSentenceCache\.get\(cacheKey\)/.test(sentenceQueueSource) && /return prefetched/.test(sentenceQueueSource)], - ['sentence queue front-loads 3D book presentation before playback callback', /const sentence = await this\.getPreparedSentence\(item\);[\s\S]*await this\.prefetchWebGLBookPresentation\(sentence[\s\S]*immediate: true[\s\S]*this\.prefetchAhead\(6, queueGeneration\);[\s\S]*this\.onSentenceReadyCallback/.test(sentenceQueueSource)], + ['sentence queue starts future lookahead only after current display playback is entered', /const sentence = await this\.getPreparedSentence\(item\);[\s\S]*await this\.prefetchWebGLBookPresentation\(sentence[\s\S]*immediate: true[\s\S]*const playbackFinished = new Promise/.test(sentenceQueueSource) && /this\.onSentenceReadyCallback\(sentence, resolve\);[\s\S]*window\.requestAnimationFrame\(\(\) => \{[\s\S]*this\.prefetchAhead\(6, queueGeneration\);[\s\S]*await playbackFinished/.test(sentenceQueueSource)], ['sentence queue prefetch prepares whole future sentence instead of speech metadata only', /this\.prepareSentence\(nextItem, \{\s*blocking: false/.test(sentenceQueueSource) && /this\.prefetchWebGLBookPresentation\(prepared/.test(sentenceQueueSource)], ['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(6, this\.queueGeneration\);/.test(sentenceQueueSource)], ['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)], + ['sentence queue serializes heavy WebGL book preparation separately from speech prefetch', /prefetchingWebGLBook = new Map/.test(sentenceQueueSource) && /webglBookPrepareChain = Promise\.resolve\(\)/.test(sentenceQueueSource) && /this\.webglBookPrepareChain[\s\S]*\.then\(\(\) => this\.runWebGLBookPresentationPrepare/.test(sentenceQueueSource)], + ['sentence queue caps WebGL book lookahead without capping TTS lookahead window', /const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 2/.test(sentenceQueueSource) && /webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource) && !/spokenPrepared >= 1 && started >= 2/.test(sentenceQueueSource)], + ['sentence queue gates WebGL book lookahead to active 3D playback only', /const allowWebGLBookPrefetch = document\.documentElement\.dataset\.webglBookPlaybackActive === 'true'/.test(sentenceQueueSource) && /const shouldPrepareWebGLBook = allowWebGLBookPrefetch[\s\S]*&& webglBookCandidate[\s\S]*&& webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.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)], @@ -161,7 +165,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 commits the spread then requests a timeline flip via event before activating', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.commitSegmentSpread\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /dispatchEvent\(new CustomEvent\('webgl-book:request-page-flip'/.test(bookPlaybackTimelineSource) && /addEventListener\('webgl-book:request-page-flip'/.test(source) && /startPageFlip\(direction, \{/.test(source)], + ['3D overflow reveal commits the spread then starts a prepared timeline flip before activating', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.commitSegmentSpread\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /sceneControl\.prewarmPageFlip/.test(bookPlaybackTimelineSource) && /sceneControl\.startPreparedPageFlip/.test(bookPlaybackTimelineSource) && !/dispatchEvent\(new CustomEvent\('webgl-book:request-page-flip'/.test(bookPlaybackTimelineSource) && /prewarmPageFlip: \(direction = 1, options = \{\}\)/.test(source) && /startPreparedPageFlip: \(direction = 1, options = \{\}\)/.test(source)], ['texture worker paints inline bold and italic styles off the main thread', /getInlineStyleState/.test(textureWorkerSource) && /updateInlineStyleState/.test(textureWorkerSource) && /getCanvasFont/.test(textureWorkerSource) && /segment\.style/.test(textureWorkerSource) && !/drawLine\(ctx/.test(textureRendererSource)], ['texture renderer delegates page rasterization to an OffscreenCanvas worker and blits the result', /book-texture-worker\.js/.test(textureRendererSource) && /rasterizeSpread/.test(textureRendererSource) && /ctx\.drawImage\(result\.pageBitmap, 0, 0\)/.test(textureRendererSource) && /OffscreenCanvas/.test(textureWorkerSource) && /createImageBitmap/.test(textureWorkerSource)], ['texture renderer recovers from worker error/timeout so a draw promise never hangs the chain', /this\.rasterWorker\.onerror/.test(textureRendererSource) && /texture-worker-timeout/.test(textureRendererSource) && /settleRasterization/.test(textureRendererSource) && /clearTimeout\(pending\.timer\)/.test(textureRendererSource)], @@ -174,7 +178,7 @@ const checks = [ ['webgl page format reduces only outer margins from previous value', /outerBaseIn: 0\.27/.test(pageFormatSource) && /outerThicknessFactor: 0\.015/.test(pageFormatSource) && /outerMaxIn: 0\.315/.test(pageFormatSource) && /innerBaseIn: 0\.42/.test(pageFormatSource)], ['webgl mode enlarges and inverts DOM overlay text without touching 2D mode', /body\.webgl-mode \{[\s\S]*font-size: 18px;/.test(styleSource) && /body\.webgl-mode \.choice-list \.choice-button/.test(styleSource) && /rgba\(246, 231, 201/.test(styleSource)], ['webgl choice overlay hides title clutter and prevents horizontal scrollbar', /body\.webgl-mode #page_left #game_title/.test(styleSource) && /body\.webgl-mode #page_left #start_prompt/.test(styleSource) && /overflow-x: hidden/.test(styleSource) && /book\.style\.width = 'min\(44rem/.test(webglSceneSource)], - ['3D live text bypasses #page_right DOM rendering and uses book texture reveal directly', /const useWebGLBookReveal = this\.isWebGLMode\(\) && \(sentence\.kind === 'paragraph' \|\| sentence\.kind === 'heading'\)/.test(uiDisplayHandlerSource) && /if \(useWebGLBookReveal\) \{[\s\S]*await this\.prepareWebGLBookReveal\(sentence\);[\s\S]*await this\.playbackCoordinator\.play\(sentence\);[\s\S]*this\.markBlockRendered\(sentence\.blockId\);[\s\S]*return null;/.test(uiDisplayHandlerSource)], + ['3D live text bypasses #page_right DOM rendering and uses the timeline-owned book reveal directly', /const useWebGLBookReveal = this\.isWebGLMode\(\) && \(sentence\.kind === 'paragraph' \|\| sentence\.kind === 'heading'\)/.test(uiDisplayHandlerSource) && /if \(useWebGLBookReveal\) \{[\s\S]*await this\.playWebGLBookSentence\(sentence\);[\s\S]*this\.markBlockRendered\(sentence\.blockId\);[\s\S]*return null;/.test(uiDisplayHandlerSource) && !/if \(useWebGLBookReveal\) \{[\s\S]*await this\.prepareWebGLBookReveal\(sentence\);[\s\S]*await this\.playbackCoordinator\.play\(sentence\);[\s\S]*return null;/.test(uiDisplayHandlerSource)], ['drop-cap remaining text does not reinsert discretionary hyphen markers', /extractRemainingLayoutText/.test(bookPaginationSource) && !bookPaginationSource.includes("fragments.push('|')")], ['drop-cap reservation keeps a normal text gap beside the initial', /measureDropCapReservation/.test(bookPaginationSource) && /measureNormalTextGap\(fontPx\)/.test(bookPaginationSource)], ['drop-cap reservation uses both ink bounds and font advance width', /const advanceWidth = metrics\.width \|\| 0/.test(bookPaginationSource) && /Math\.max\(inkRight, advanceWidth, lineHeightPx \* 1\.08\)/.test(bookPaginationSource)], @@ -197,6 +201,7 @@ const checks = [ ['webgl texture store resident cache reuses newest page version for older readiness requests', /isOlderPageMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta/.test(webglPageCacheSource) && /if \(!resident\) return null/.test(webglPageCacheSource) && !/if \(!resident \|\| this\.isOlderPageMeta\(pageMeta, resident\.pageMeta\)\) return null/.test(webglPageCacheSource)], ['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /const topMaterialIndex = direction > 0 \? 1 : 0/.test(source) && /const bottomMaterialIndex = direction > 0 \? 0 : 1/.test(source) && /geometry\.addGroup\(0, topIndices\.length, topMaterialIndex\)/.test(source) && /geometry\.addGroup\(topIndices\.length, bottomIndices\.length, bottomMaterialIndex\)/.test(source)], ['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture \|\| getBlankPageTexture\(\)/.test(source)], + ['webgl flip page material variants are compiled during loader, not at first texture swap', /flipPageSurface: new THREE\.MeshStandardMaterial\(\{[\s\S]*map: getBlankPageTexture\(\),[\s\S]*normalMap: paperTextures\.normal,[\s\S]*roughnessMap: paperTextures\.roughness/.test(source) && !/materials\.flipPageSurface\.needsUpdate = true/.test(methodBody(source, 'prepareStaticPageForFlip')) && !/materials\.flipPageBackSurface\.needsUpdate = true/.test(methodBody(source, 'prepareStaticPageForFlip'))], ['webgl flip preflight exposes texture side and orientation invariants for browser tests', /lastFlipTexturePreflight/.test(source) && /sourceTextureMatchesBackTexture/.test(source) && /targetBackSide/.test(source) && /getRuntimeInvariants/.test(source)], ['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - pageU : pageU/.test(source) && /y: v/.test(source)], ['webgl animated page UVs use the same fore-edge inset as the visible stack cap', /PAGE_TEXTURE_FORE_EDGE_INSET_RATIO/.test(source) && /const pageU = THREE\.MathUtils\.clamp\(u \/ Math\.max\(0\.0001, 1 - inset\), 0, 1\)/.test(source)], @@ -204,6 +209,8 @@ const checks = [ ['webgl flip prewarm prepares current and target spread texture records before cache lookup', /prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /prepareSpreadTextureRecordsForFlip\(nextSpread\)/.test(source) && /function prepareSpreadTextureRecordsForFlip/.test(source) && /spreadTextureRecordsReady\(spread\)/.test(source) && /window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\], \{[\s\S]*phase: 'prepare'/.test(source)], ['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))], ['webgl scene targets 60fps with browser-frame scheduling and staggered live mirror refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /const minRenderFrameIntervalMs = targetFrameDurationMs \* 0\.5/.test(source) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /const dynamicBufferRefreshIntervalMs = 1000 \/ 30/.test(source) && /const flipDynamicBufferGraceMs = 180/.test(source) && /const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue/.test(source) && /const refreshReflectionThisFrame/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesAtFps/.test(source) && !/setTimeout\(animate/.test(source)], + ['webgl reveal playback throttles dynamic buffers without freezing mirror permanently', /const revealGeometryBufferRefreshIntervalMs = 1000 \/ 4/.test(source) && /const revealAnimating = hasActivePageReveal\(\)/.test(source) && /revealAnimating[\s\S]*revealGeometryBufferRefreshIntervalMs/.test(source)], + ['webgl navigation texture prewarm yields until reveal and flip critical frames are clear', /function scheduleNavigationTextureWindowPrewarm/.test(source) && /requestIdleCallback/.test(source) && /activeFlips\.length > 0 \|\| hasActivePageReveal\(\)/.test(source) && /scheduleNavigationTextureWindowPrewarm\('page-texture-records'/.test(source)], ['texture renderer has no private reveal clock (scene render loop is the single clock)', !/this\.targetFrameDurationMs/.test(textureRendererSource) && !/tickAnimations/.test(textureRendererSource) && !/requestAnimationFrame/.test(textureRendererSource)], ['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 0\.72/.test(source) && /const tableReflectionBaseWidth = 1536/.test(source) && /const tableReflectionBaseHeight = 864/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)], ['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesAtFps/.test(source) && /mirrorDefersDuringFlipStartMs/.test(source)], @@ -224,7 +231,7 @@ const checks = [ ['webgl autoplay flip source prefers currently revealing visible material over resident cache', /if \(revealStateMatchesPage\(side, pageMeta\)\) return material\?\.map \|\| null/.test(source) && /revealStateMatchesPage\(sourceSide, sourcePageMeta\) \? sourceSide : null/.test(source)], ['webgl flipping page materials mirror active reveal shader uniforms on both sides', /materials\.flipPageSurface\.userData\.bookPageReveal/.test(source) && /syncFlipRevealShaderFromSource/.test(source) && /bookRevealRegionRects/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide === side/.test(source) && /revealStateMatchesPage\(targetBackSide, targetBackPageMeta\) \? targetBackSide : null/.test(source)], ['webgl prepared texture records do not mutate the visible page metadata', /const incomingPageMeta = detail\.pageMeta/.test(source) && /if \(detail\.phase !== 'prepare' && detail\.pageMeta\) \{[\s\S]*currentPageMeta = incomingPageMeta/.test(source) && /pageMeta: effectivePageMeta/.test(source)], - ['webgl scene force-redraws current pagination spread for initial title upload', /const initialSpread = pagination\?\.getCurrentSpread\?\.\(\)/.test(webglSceneSource) && /window\.BookTextureRenderer\.drawSpread\(initialSpread, \['left', 'right'\], \{ force: true \}\)/.test(webglSceneSource) && /options\.force !== true && phase !== 'prepare'/.test(textureRendererSource)], + ['webgl scene awaits current pagination spread redraw during loader initial title upload', /const initialSpread = pagination\?\.getCurrentSpread\?\.\(\)/.test(webglSceneSource) && /await window\.BookTextureRenderer\.drawSpread\(initialSpread, \['left', 'right'\], \{ force: true \}\)/.test(webglSceneSource) && !/Date\.now\(\)/.test(webglSceneSource) && /options\.force !== true && phase !== 'prepare'/.test(textureRendererSource)], ['texture renderer marks committed reveal blocks complete so pauses cannot replay them', /webgl-book:reveal-committed/.test(textureRendererSource) && /completeRevealBlockIds/.test(textureRendererSource) && /this\.revealedBlockIds\.add\(id\)/.test(textureRendererSource)], ['webgl timeline recalculates placeholder zero-duration reveal timings from TTS duration', /existingTimings/.test(bookPlaybackTimelineSource) && /existingDuration/.test(bookPlaybackTimelineSource) && /ttsDuration/.test(bookPlaybackTimelineSource) && /existingTimings\.length > 0 && \(existingDuration > 0 \|\| ttsDuration <= 0\)/.test(bookPlaybackTimelineSource)], ['webgl playback coordinator trusts timeline-prepared reveal timings without recomputing', !/calculateWordTimings/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal')) && /single owner of reveal timing/.test(playbackCoordinatorSource) && /sentence\.webglRevealController\(/.test(playbackCoordinatorSource)], @@ -232,6 +239,8 @@ const checks = [ ['texture renderer prepares a spanning block continuation spread in the background and reuses it (no synchronous redraw on the critical path)', /revealSpreadSourceOverride/.test(textureRendererSource) && /prepareContinuationRevealPlan/.test(textureRendererSource) && /takeContinuationRevealPlan/.test(textureRendererSource) && /`\$\{id\}:cont`/.test(textureRendererSource) && /prepareContinuationRevealPlan/.test(bookPlaybackTimelineSource) && /takeContinuationRevealPlan/.test(bookPlaybackTimelineSource) && /previewSpreads/.test(bookPaginationSource)], ['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)], ['book playback timeline has one prepare path: spanning-aware plan reused at activate and continuation, no fallback', /revealSpreadSourceOverride: spanningPreview \? detail\.previewSpreads : null/.test(textureRendererSource) && /this\.revealSpreadSourceOverride = options\.revealSpreadSourceOverride/.test(textureRendererSource) && !/forceRebuild/.test(textureRendererSource) && !/forceRebuild/.test(bookPlaybackTimelineSource) && !/spanningPlanPrepared/.test(bookPlaybackTimelineSource) && /const texturePlan = this\.textureRenderer\.takeContinuationRevealPlan\(segment\.blockId, spread\.index\)/.test(bookPlaybackTimelineSource)], + ['book playback timeline reuses prepared activation texture plan on the critical path', /let texturePlan = segment\.preparedTexturePlan/.test(bookPlaybackTimelineSource) && /\{ \.\.\.segment\.preparedTexturePlan, phase: 'activate' \}/.test(bookPlaybackTimelineSource) && /takePreparedRevealPlan\(segment\.blockId\)/.test(bookPlaybackTimelineSource) && /if \(!texturePlan\) \{[\s\S]*prepareRevealBlock/.test(bookPlaybackTimelineSource)], + ['book playback timeline compares preplay flip against source spread captured before commit', /segment\.sourceSpreadIndex = this\.getVisibleSpreadIndex\(\)/.test(bookPlaybackTimelineSource) && /segment\.sourceSpreadIndex = Number\.isFinite/.test(bookPlaybackTimelineSource) && /const sourceSpread = Number\.isFinite/.test(bookPlaybackTimelineSource) && /targetSpreadIndex \|\| 0\)\) > sourceSpread/.test(bookPlaybackTimelineSource)], ['webgl visible spread is owned by scene flips, not pagination publishes', /spreadUpdate:state-only/.test(source) && /webglBookPlaybackActive/.test(source) && /spreadUpdate:jump/.test(source) && /window\.BookTextureRenderer\?\.drawSpread\?\.\(spread, \['left', 'right'\], \{ force: true \}\)/.test(source)], ['3D overflow reveal preloads target spread before forced page flip', /createRevealDetail\(sentence, previewSpread, 'prepare'\)/.test(bookPlaybackTimelineSource) && /this\.textureRenderer\.prepareRevealBlock\(\s*[\s\S]*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)],