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