From 5a8492388412599ce8730f2d7b5747a18ab15942 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Wed, 10 Jun 2026 08:09:02 +0200 Subject: [PATCH] Restore WebGL reveal timing diagnostics --- public/js/book-playback-timeline-module.js | 113 ++++++++++++++++++--- public/js/loader.js | 2 +- public/js/webgl-book-lab.js | 53 +++++++++- scripts/check-webgl-book-lab.js | 9 +- 4 files changed, 157 insertions(+), 20 deletions(-) diff --git a/public/js/book-playback-timeline-module.js b/public/js/book-playback-timeline-module.js index bc27ee9..4434227 100644 --- a/public/js/book-playback-timeline-module.js +++ b/public/js/book-playback-timeline-module.js @@ -17,6 +17,7 @@ class BookPlaybackTimelineModule extends BaseModule { this.activeSegment = null; this.preparedSegments = new Map(); this.timelineDiagnostics = []; + this.benchmarkEntries = []; this.ownsPageFlipCommit = true; this.bindMethods([ @@ -29,6 +30,7 @@ class BookPlaybackTimelineModule extends BaseModule { 'createRevealDetail', 'requiresSpreadTransition', 'requiresRightPageFlipAfterReveal', + 'getBlockRevealSides', 'waitForVisualCompletion', 'waitForRevealCommit', 'requestPageFlip', @@ -37,6 +39,8 @@ class BookPlaybackTimelineModule extends BaseModule { 'getPageMetaForIndex', 'getVisibleSpreadIndex', 'isChoiceAwaitingPlayer', + 'markBenchmark', + 'timeStage', 'recordDiagnostic', 'getRuntimeState' ]); @@ -48,13 +52,33 @@ class BookPlaybackTimelineModule extends BaseModule { this.pageCache = this.getModule('webgl-page-cache'); this.playbackCoordinator = this.getModule('playback-coordinator'); this.sentenceQueue = this.getModule('sentence-queue'); + this.addEventListener(document, 'webgl-book:page-reveal-start', (event) => { + this.markBenchmark('reveal-start', { + blockId: event.detail?.blockId ?? null + }); + }); + this.addEventListener(document, 'webgl-book:reveal-committed', (event) => { + this.markBenchmark('reveal-committed', { + blockId: event.detail?.blockIds?.[0] ?? null, + side: event.detail?.side || null, + pageFlipAfterReveal: event.detail?.pageFlipAfterReveal === true + }); + }); + this.addEventListener(document, 'webgl-book:page-flip-started', (event) => { + this.markBenchmark('flip-started', event.detail || {}); + }); + this.addEventListener(document, 'webgl-book:page-flip-finished', (event) => { + this.markBenchmark('flip-finished', event.detail || {}); + }); window.BookPlaybackTimeline = this; this.reportProgress(100, 'Book playback timeline ready'); return true; } async playSentence(sentence = {}) { - const segment = await this.prepareSentence(sentence, { immediate: true }); + const segment = await this.timeStage('prepare-current', { blockId: sentence.blockId ?? null }, () => { + return this.prepareSentence(sentence, { immediate: true }); + }); if (!segment) { return this.playbackCoordinator?.play?.(sentence); } @@ -63,11 +87,11 @@ class BookPlaybackTimelineModule extends BaseModule { this.recordDiagnostic('segment-play:start', segment); if (this.requiresSpreadTransition(segment)) { - const flipped = await this.requestPageFlip(1, { + const flipped = await this.timeStage('preplay-flip', segment, () => this.requestPageFlip(1, { reason: 'timeline-preplay-spread-transition', targetSpread: segment.targetSpreadIndex, force: true - }); + })); if (!flipped) { this.pageCache?.recordProblem?.({ type: 'timeline-preplay-flip-failed', @@ -77,10 +101,12 @@ class BookPlaybackTimelineModule extends BaseModule { } } - await this.activatePreparedSegment(segment, sentence); + await this.timeStage('activate', segment, () => this.activatePreparedSegment(segment, sentence)); const visualPromise = this.waitForVisualCompletion(segment); - const playbackPromise = this.playbackCoordinator?.play?.(sentence) || Promise.resolve(); + const playbackPromise = this.timeStage('playback', segment, () => { + return this.playbackCoordinator?.play?.(sentence) || Promise.resolve(); + }); await Promise.all([playbackPromise, visualPromise]); this.recordDiagnostic('segment-play:end', segment); @@ -94,7 +120,10 @@ class BookPlaybackTimelineModule extends BaseModule { 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); + const segment = await this.timeStage(options.immediate === true ? 'segment-prepare-immediate' : 'segment-prepare-lookahead', { + blockId: sentence.blockId, + id: sentence.id + }, () => this.createPreparedSegment(sentence, options)); if (!segment) return null; this.preparedSegments.set(segment.key, segment); sentence.webglBookPresentation = { @@ -121,6 +150,7 @@ class BookPlaybackTimelineModule extends BaseModule { const targetSpreadIndex = Math.max(0, Number(previewSpread.index || 0)); const currentSpreadIndex = this.getVisibleSpreadIndex(); + const revealSides = this.getBlockRevealSides(previewSpread, sentence.blockId); const segment = { key: `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${sentence.blockId}`, id: sentence.id, @@ -129,13 +159,14 @@ class BookPlaybackTimelineModule extends BaseModule { previewSpread, targetSpreadIndex, currentSpreadIndex, + revealSides, requiresPreFlip: targetSpreadIndex > currentSpreadIndex, - requiresRightFlip: this.requiresRightPageFlipAfterReveal(previewSpread), + requiresRightFlip: revealSides.includes('right') && this.requiresRightPageFlipAfterReveal(previewSpread), preparedAt: performance.now(), status: 'prepared' }; - await this.prewarmSegmentTextures(segment); + await this.timeStage('texture-prewarm', segment, () => this.prewarmSegmentTextures(segment)); if (options.immediate !== true) { await new Promise(resolve => setTimeout(resolve, 0)); } @@ -149,7 +180,9 @@ class BookPlaybackTimelineModule extends BaseModule { }); 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); + segment.revealSides = this.getBlockRevealSides(segment.activeSpread || segment.previewSpread, sentence.blockId); + segment.requiresRightFlip = segment.revealSides.includes('right') + && this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread); const revealDetail = this.createRevealDetail(sentence, segment.activeSpread || segment.previewSpread, 'activate'); this.textureRenderer.prepareRevealBlock(revealDetail); @@ -192,15 +225,27 @@ class BookPlaybackTimelineModule extends BaseModule { return maxLine >= Math.max(1, Number(meta.linesPerPage || 25)); } + getBlockRevealSides(spread = {}, blockId = null) { + const id = String(blockId ?? ''); + if (!id) return []; + return ['left', 'right'].filter((side) => { + const lines = Array.isArray(spread?.[side]) ? spread[side] : []; + return lines.some(line => String(line?.blockId ?? '') === id); + }); + } + async waitForVisualCompletion(segment = {}) { - if (!segment.requiresRightFlip) return; - const committed = await this.waitForRevealCommit(segment); + if (!segment.requiresRightFlip || !Array.isArray(segment.revealSides) || !segment.revealSides.includes('right')) { + this.recordDiagnostic('visual-completion:no-right-flip-wait', segment); + return; + } + const committed = await this.timeStage('wait-right-reveal-commit', segment, () => this.waitForRevealCommit(segment)); if (!committed || this.isChoiceAwaitingPlayer()) return; - await this.requestPageFlip(1, { + await this.timeStage('right-page-flip', segment, () => this.requestPageFlip(1, { reason: 'timeline-right-page-filled', targetSpread: Math.max(0, Number(segment.targetSpreadIndex || this.getVisibleSpreadIndex()) + 1), force: true - }); + })); } waitForRevealCommit(segment = {}) { @@ -350,18 +395,58 @@ class BookPlaybackTimelineModule extends BaseModule { blockId: segment.blockId ?? null, spreadIndex: segment.targetSpreadIndex ?? null, status: segment.status || null, + revealSides: Array.isArray(segment.revealSides) ? segment.revealSides : [], at: Math.round(performance.now()) }); while (this.timelineDiagnostics.length > 200) this.timelineDiagnostics.shift(); document.documentElement.dataset.webglBookTimeline = type; } + markBenchmark(stage, detail = {}, startedAt = null) { + const now = performance.now(); + const entry = { + stage, + blockId: detail.blockId ?? null, + spreadIndex: detail.targetSpreadIndex ?? detail.spreadIndex ?? detail.targetSpread ?? null, + durationMs: Number.isFinite(Number(startedAt)) ? Math.round((now - Number(startedAt)) * 100) / 100 : null, + at: Math.round(now), + detail: { + status: detail.status || null, + revealSides: Array.isArray(detail.revealSides) ? detail.revealSides : undefined, + reason: detail.reason || null, + side: detail.side || null, + pageFlipAfterReveal: detail.pageFlipAfterReveal === true + } + }; + this.benchmarkEntries.push(entry); + while (this.benchmarkEntries.length > 240) this.benchmarkEntries.shift(); + document.documentElement.dataset.webglBookBenchmark = JSON.stringify(this.benchmarkEntries.slice(-40)); + return entry; + } + + async timeStage(stage, detail = {}, callback = null) { + const startedAt = performance.now(); + this.markBenchmark(`${stage}:start`, detail); + try { + const result = await callback?.(); + this.markBenchmark(`${stage}:end`, detail, startedAt); + return result; + } catch (error) { + this.markBenchmark(`${stage}:error`, { + ...detail, + reason: error?.message || String(error) + }, startedAt); + throw error; + } + } + getRuntimeState() { return { activeBlockId: this.activeSegment?.blockId ?? null, preparedSegmentCount: this.preparedSegments.size, ownsPageFlipCommit: this.ownsPageFlipCommit, - diagnostics: this.timelineDiagnostics.slice(-20) + diagnostics: this.timelineDiagnostics.slice(-20), + benchmark: this.benchmarkEntries.slice(-40) }; } } diff --git a/public/js/loader.js b/public/js/loader.js index e94622d..f2a55e1 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -24,7 +24,7 @@ const ModuleState = { ERROR: 'ERROR' }; -const MODULE_CACHE_BUSTER = '20260610-book-timeline-a'; +const MODULE_CACHE_BUSTER = '20260610-book-timeline-b'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index eabd5ad..4a78378 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -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=20260610-book-timeline-a'; +import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260610-book-timeline-b'; const canvas = document.getElementById('scene'); canvas.style.cursor = 'grab'; @@ -163,6 +163,7 @@ let fpsDisplay = null; let fpsWindowStartedAt = performance.now(); let fpsWindowFrames = 0; const lastFrameTiming = {}; +const slowFrameLog = []; const loaderTimings = {}; const pageTextureTimings = []; @@ -610,6 +611,14 @@ window.BookLabDebug = { lastFlipTexturePreflight }; }, + getBenchmarkState() { + return { + frameTiming: { ...lastFrameTiming }, + slowFrames: slowFrameLog.slice(-20), + pageTextureTimings: pageTextureTimings.slice(-40), + timeline: window.BookPlaybackTimeline?.getRuntimeState?.()?.benchmark || [] + }; + }, projectPointerToPage(clientX, clientY) { return projectPointerToPage(clientX, clientY); }, @@ -2424,6 +2433,13 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) { shaderReady: Boolean(shader?.uniforms), started: pageRevealState[side].startedAt != null }); + markPageTextureTiming('revealState:created', { + side, + blockIds: pageRevealState[side].blockIds, + started: pageRevealState[side].startedAt != null, + durationMs: pageRevealState[side].durationMs, + regionCount: Array.isArray(revealDetail.lineRects) ? revealDetail.lineRects.length : 0 + }); markPageTextureTiming('revealUpload:end', { side }); } @@ -2540,6 +2556,7 @@ function startPageRevealForBlock(blockId) { const id = String(blockId ?? ''); if (!id) return; if (!activeRevealBlockStarts.has(id)) activeRevealBlockStarts.set(id, performance.now()); + let matchedSides = 0; if (activeFlips.length > 0) { pendingRevealStartBlockIds.add(id); markPageTextureTiming('revealStart:deferred-for-flip', { @@ -2552,11 +2569,18 @@ function startPageRevealForBlock(blockId) { const state = pageRevealState[side]; if (!state || state.startedAt != null) return; if (!state.blockIds.map(value => String(value)).includes(id)) return; + matchedSides += 1; state.pendingStart = true; state.startedAt = activeRevealBlockStarts.get(id) || performance.now(); const shader = getPageRevealShader(side); if (shader?.uniforms?.bookRevealElapsedMs) shader.uniforms.bookRevealElapsedMs.value = 0; }); + markPageTextureTiming('revealStart:applied', { + blockId: id, + matchedSides, + hasLeftState: Boolean(pageRevealState.left), + hasRightState: Boolean(pageRevealState.right) + }); } function fastForwardPageReveals(blockIds = []) { @@ -4353,6 +4377,7 @@ function animate(now = performance.now()) { const delta = Math.min(0.1, frameElapsedMs / 1000); clock.getDelta(); const t = clock.elapsedTime; + const updateStartedAt = performance.now(); updateCameraRig(delta); scene.traverse((object) => { if (!object.userData?.light) return; @@ -4382,10 +4407,15 @@ function animate(now = performance.now()) { } }); const hadActiveFlips = activeFlips.length > 0; + const flipStartedAt = performance.now(); updateActiveFlips(performance.now()); + lastFrameTiming.flips = performance.now() - flipStartedAt; if (hadActiveFlips) markStaticSceneBuffersDirty(); + const revealStartedAt = performance.now(); updatePageRevealAnimations(now); + lastFrameTiming.reveal = performance.now() - revealStartedAt; updateCandleShadowUniforms(); + lastFrameTiming.update = performance.now() - updateStartedAt; renderedFrameCount += 1; const shadowStartedAt = performance.now(); updateBookShadowMaps(); @@ -4405,7 +4435,26 @@ function animate(now = performance.now()) { renderer.render(scene, camera); } lastFrameTiming.render = performance.now() - renderStartedAt; - lastFrameTiming.total = lastFrameTiming.shadows + lastFrameTiming.reflection + lastFrameTiming.render; + lastFrameTiming.total = lastFrameTiming.update + lastFrameTiming.shadows + lastFrameTiming.reflection + lastFrameTiming.render; + if (frameElapsedMs > targetFrameDurationMs * 1.75 || lastFrameTiming.total > targetFrameDurationMs * 1.25) { + slowFrameLog.push({ + at: Math.round(now), + frameElapsedMs: Math.round(frameElapsedMs * 100) / 100, + activeFlips: activeFlips.length, + revealActive: Boolean(pageRevealState.left || pageRevealState.right), + timings: { + update: Math.round(lastFrameTiming.update * 100) / 100, + flips: Math.round(lastFrameTiming.flips * 100) / 100, + reveal: Math.round(lastFrameTiming.reveal * 100) / 100, + shadows: Math.round(lastFrameTiming.shadows * 100) / 100, + reflection: Math.round(lastFrameTiming.reflection * 100) / 100, + render: Math.round(lastFrameTiming.render * 100) / 100, + total: Math.round(lastFrameTiming.total * 100) / 100 + } + }); + while (slowFrameLog.length > 80) slowFrameLog.shift(); + document.documentElement.dataset.webglSlowFrames = JSON.stringify(slowFrameLog.slice(-20)); + } if (refreshStaticSceneBuffers && activeFlips.length === 0) { staticSceneBuffersDirty = false; } diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index b70f843..e462415 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -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) && /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)], + ['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)], ['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,13 +211,16 @@ 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) && /await 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, \{ phase: 'prepare' \}\)/.test(bookPlaybackTimelineSource) && /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)], + ['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)], ['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)], + ['book playback timeline exposes reveal lifecycle benchmark entries', /benchmarkEntries/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-start'/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-committed'/.test(bookPlaybackTimelineSource) && /webglBookBenchmark/.test(bookPlaybackTimelineSource)], + ['webgl scene records reveal start and slow-frame benchmark diagnostics', /revealState:created/.test(source) && /revealStart:applied/.test(source) && /slowFrameLog/.test(source) && /getBenchmarkState/.test(source) && /webglSlowFrames/.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)],