From dc2afcf83191bb9dcc876a27a6f0b4001b02ff77 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Thu, 18 Jun 2026 17:13:39 +0200 Subject: [PATCH] Fix spanning-paragraph reveal pacing (right page no longer consumes full TTS) A paragraph that overflows the right page onto the next spread revealed its single right-page line over the entire TTS, then timed out (timeline-reveal-commit-timeout) and only flipped after the whole narration. Two root causes: - At activate the reused lookahead segment played a sentence instance whose animation word-timings were lost (wordTimings=[], totalDuration=0), so reveal timing fell back to an area estimate spanning the full TTS. Snapshot the timings at prepare and restore them at activate. - Reveal duration was distributed by ink area, but just-paginated continuation lines have ~0 area, so the one right-page line received the whole duration. Distribute by word count (reliable) with area as fallback. Now the right page reveals only its word share (~2.7s for a 6/55-word line), commits, and flips while TTS continues; the continuation animates on the next spread. Also rewrote the right-reveal wait to a single timer + commit/fast-forward listeners with cleanup, removing the stray timeline-reveal-commit-timeout. Co-Authored-By: Claude Opus 4.8 --- public/js/book-playback-timeline-module.js | 81 ++++++++++++---------- public/js/book-texture-renderer-module.js | 13 +++- scripts/check-webgl-book-lab.js | 6 +- 3 files changed, 57 insertions(+), 43 deletions(-) diff --git a/public/js/book-playback-timeline-module.js b/public/js/book-playback-timeline-module.js index edf2985..ac3acd0 100644 --- a/public/js/book-playback-timeline-module.js +++ b/public/js/book-playback-timeline-module.js @@ -55,7 +55,6 @@ class BookPlaybackTimelineModule extends BaseModule { 'waitForVisualCompletion', 'revealContinuationSpread', 'waitForPlannedRightReveal', - 'waitForRevealCommit', 'requestPageFlip', 'prepareFlipPlan', 'waitForPageFlipFinished', @@ -228,6 +227,14 @@ class BookPlaybackTimelineModule extends BaseModule { revealSides, requiresPreFlip: targetSpreadIndex > currentSpreadIndex, requiresRightFlip: revealSides.includes('right') && this.requiresRightPageFlipAfterReveal(previewSpread), + // Snapshot the reveal timings now. A reused lookahead segment can be played by + // a sentence instance whose animation timings were lost; without them the + // reveal can't be word-paced and stretches across the whole TTS. + preparedAnimation: { + wordTimings: Array.isArray(revealDetail.wordTimings) ? revealDetail.wordTimings : [], + cueTimings: Array.isArray(revealDetail.cueTimings) ? revealDetail.cueTimings : [], + totalDuration: Number(revealDetail.totalDuration || 0) + }, preparedTexturePlan: texturePlan, preparedAt: performance.now(), revealStartedAt: null, @@ -274,6 +281,16 @@ class BookPlaybackTimelineModule extends BaseModule { async activatePreparedSegment(segment = {}, sentence = segment.sentence) { if (!segment || !sentence) return null; + // Restore the reveal timings captured at prepare if the live sentence lost them, + // otherwise the reveal degrades to an area estimate spanning the whole TTS. + if (segment.preparedAnimation?.wordTimings?.length && !(sentence.animation?.wordTimings?.length)) { + sentence.animation = { + ...(sentence.animation || {}), + wordTimings: segment.preparedAnimation.wordTimings, + cueTimings: segment.preparedAnimation.cueTimings, + totalDuration: segment.preparedAnimation.totalDuration + }; + } const spread = segment.activeSpread || segment.previewSpread; const revealDetail = this.createRevealDetail(sentence, spread, 'activate'); // For a spanning block the prepared reveal plan was built during lookahead before @@ -477,6 +494,9 @@ class BookPlaybackTimelineModule extends BaseModule { return true; } + // Resolve when the right page's own portion of the reveal is done — its computed + // duration elapses, the reveal commits, or the player fast-forwards — whichever comes + // first. Single timer + listeners with full cleanup, so no stray commit-timeout fires. async waitForPlannedRightReveal(segment = {}) { const startedAt = Number(segment.revealStartedAt) || await (segment.revealStartedPromise || Promise.resolve(performance.now())); @@ -488,13 +508,29 @@ class BookPlaybackTimelineModule extends BaseModule { }); const elapsed = Math.max(0, performance.now() - Number(startedAt || performance.now())); const remaining = Math.max(0, duration - elapsed); - const planned = new Promise(resolve => { - setTimeout(() => resolve(true), remaining); + const blockId = String(segment.blockId ?? ''); + return new Promise((resolve) => { + let done = false; + const finish = (value) => { + if (done) return; + done = true; + clearTimeout(timer); + document.removeEventListener('webgl-book:reveal-committed', onCommit); + document.removeEventListener('webgl-book:page-reveal-fast-forward', onFastForward); + resolve(value); + }; + const onCommit = (event) => { + const detail = event.detail || {}; + if (detail.side !== 'right') return; + const ids = Array.isArray(detail.blockIds) ? detail.blockIds.map(value => String(value)) : []; + if (blockId && ids.length && !ids.includes(blockId)) return; + finish(true); + }; + const onFastForward = () => finish(true); + const timer = setTimeout(() => finish(true), remaining); + document.addEventListener('webgl-book:reveal-committed', onCommit); + document.addEventListener('webgl-book:page-reveal-fast-forward', onFastForward); }); - return Promise.race([ - planned, - this.waitForRevealCommit(segment) - ]); } getRightRevealDurationMs(segment = {}) { @@ -505,37 +541,6 @@ class BookPlaybackTimelineModule extends BaseModule { return Math.max(1, Number(segment.sentence?.animation?.totalDuration || 1)); } - 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; // Warm the texture cache for the navigation window and verify the target pages diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 44b2a90..517d825 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -698,9 +698,18 @@ class BookTextureRendererModule extends BaseModule { const useWordShare = totalBlockWords > 0 && collectedWords > 0 && collectedWords < totalBlockWords; const totalDuration = useWordShare ? baseDuration * (collectedWords / totalBlockWords) : baseDuration; let fallbackDelay = useWordShare && Number.isFinite(wordsBefore) ? baseDuration * (wordsBefore / totalBlockWords) : 0; + // Weight each line by its word count when available, falling back to ink area. + // Word counts are reliable even for just-paginated continuation lines whose rect + // area can be ~0; area-weighting there would hand the whole duration to the one + // line on the current page and stretch it across the entire TTS. + const useWordWeights = collectedWords > 0; + const totalWeight = useWordWeights ? collectedWords : totalArea; textRegions.forEach((region) => { - const duration = totalArea > 0 - ? Math.max(1, totalDuration * (Math.max(1, region.timingArea || region.area) / totalArea)) + const weight = useWordWeights + ? Math.max(0, Number(region.blockWordCount || 0)) + : Math.max(1, region.timingArea || region.area); + const duration = totalWeight > 0 + ? Math.max(1, totalDuration * (weight / totalWeight)) : Math.max(1, totalDuration / Math.max(1, textRegions.length)); timedRegions.push({ ...region, diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index 9683014..a7e85c5 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -165,7 +165,7 @@ const checks = [ ['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)], ['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)], ['webgl reveal shader masks antialiased ink and uses smooth line-dominant scan', /smoothstep\(0\.52, 0\.9, luminance\)/.test(source) && /local\.x \* 0\.96/.test(source) && /bookRevealSoftness = \{ value: 0\.025 \}/.test(source)], - ['webgl reveal line timings use global area timing across split-page spreads', /assignRevealTiming/.test(textureRendererSource) && /sourceSpreads/.test(textureRendererSource) && /this\.pagination\?\.spreads/.test(textureRendererSource) && /spreadIndex/.test(textureRendererSource) && /totalDuration \* \(Math\.max\(1, region\.timingArea \|\| region\.area\) \/ totalArea\)/.test(textureRendererSource) && /durationMs: sideRegions\.reduce/.test(textureRendererSource)], + ['webgl reveal line timings use global word-weighted timing across split-page spreads', /assignRevealTiming/.test(textureRendererSource) && /sourceSpreads/.test(textureRendererSource) && /this\.pagination\?\.spreads/.test(textureRendererSource) && /spreadIndex/.test(textureRendererSource) && /totalDuration \* \(weight \/ totalWeight\)/.test(textureRendererSource) && /durationMs: sideRegions\.reduce/.test(textureRendererSource)], ['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)], @@ -213,7 +213,7 @@ const checks = [ ['game loop persists webgl book state in save slots', /webglBookState: this\.getWebGLBookState\(\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8')) && /applyWebGLBookState\(browserSave\.webglBookState\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8'))], ['webgl right-page reveal flips are owned by the timeline, not the scene', !/pendingRightPageFlip/.test(source) && !/handleRevealCommittedForPageFlip/.test(source) && /waitForVisualCompletion/.test(bookPlaybackTimelineSource) && /reason: 'timeline-right-page-filled'/.test(bookPlaybackTimelineSource) && /requiresRightPageFlipAfterReveal/.test(bookPlaybackTimelineSource) && /isChoiceAwaitingPlayer/.test(bookPlaybackTimelineSource)], ['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)], - ['webgl line reveal timing uses area-weighted regions instead of word-span timing', /lineWordCount/.test(bookPaginationSource) && /blockWordStart/.test(textureRendererSource) && /blockWordCount/.test(textureRendererSource) && /timingArea/.test(textureRendererSource) && /totalDuration \* \(Math\.max\(1, region\.timingArea \|\| region\.area\) \/ totalArea\)/.test(textureRendererSource) && !/const canUseLineWordSpans/.test(textureRendererSource)], + ['webgl line reveal timing is word-weighted (reliable for 0-area continuation lines) with area fallback', /lineWordCount/.test(bookPaginationSource) && /blockWordStart/.test(textureRendererSource) && /blockWordCount/.test(textureRendererSource) && /timingArea/.test(textureRendererSource) && /const useWordWeights = collectedWords > 0/.test(textureRendererSource) && /totalDuration \* \(weight \/ totalWeight\)/.test(textureRendererSource) && !/const canUseLineWordSpans/.test(textureRendererSource)], ['webgl flip completion defers book rebuild out of the final animation frame', /scheduledBookRebuildFrame/.test(source) && /function scheduleBookRebuild/.test(source) && /syncReadingProgressToCurrentPage\(\{[\s\S]*rebuild: 'defer'[\s\S]*reason: 'page-flip-finished'/.test(source)], ['webgl ordinary flip near-end uses resident target textures and defers revealing sides', /applyResidentSpreadTextures\(targetSpread, 'page-flip-near-end', \{ skipSides: flip\.deferRevealSides \}\)/.test(source) && /function applyResidentSpreadTextures\(spreadIndex, reason = 'resident-spread', options = \{\}\)/.test(source) && /const skipSides = Array\.isArray\(options\.skipSides\)/.test(source) && /residentSpreadTextures:applied/.test(source) && /spreadUpdate:state-only/.test(source)], ['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)], @@ -237,7 +237,7 @@ const checks = [ ['webgl scene reports reveal commits but does not own flips and no ownership flag survives', /dispatchEvent\(new CustomEvent\('webgl-book:reveal-committed'/.test(source) && !/handleRevealCommittedForPageFlip/.test(source) && !/ownsPageFlipCommit/.test(source) && !/ownsPageFlipCommit/.test(textureRendererSource) && !/ownsPageFlipCommit/.test(bookPlaybackTimelineSource)], ['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 flips at planned right-page fragment time instead of full TTS completion', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /Promise\.race\(\[[\s\S]*this\.waitForRevealCommit\(segment\)/.test(bookPlaybackTimelineSource)], + ['book playback timeline flips at planned right-page fragment time without a stray commit timeout', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /const timer = setTimeout\(\(\) => finish\(true\), remaining\)/.test(bookPlaybackTimelineSource) && !/waitForRevealCommit/.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 is spread-based and caps at visited/written spread', /function navigateToSpread\(/.test(source) && /function navigateBySpreadDelta\(/.test(source) && /function getMaxNavigableSpread\(\)/.test(source) && /Math\.min\(visitedSpread, spreadCount - 1\)/.test(source) && /navigateBySpreadDelta\(1\)/.test(source) && /spread <= 0 \? '0' : String\(spread \* 2 \+ 1\)/.test(source) && /currentSpread < getMaxNavigableSpread\(\)/.test(source)],