From 47af10d60cc87b86b494aa5849e702bf61831510 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Fri, 19 Jun 2026 00:04:21 +0200 Subject: [PATCH] Prepare spanning-block continuation spread in background (kill post-flip redraw) For a paragraph that overflows onto the next spread, the continuation page was redrawn synchronously after the flip (drawSpread on the main thread), so the next page stayed blank for ~2.7s and then the carried-over lines popped in already ~24% revealed instead of animating from the start. Move that work off the critical path: during lookahead, prepare and cache the continuation spread's reveal plan using the not-yet-committed preview spreads (so per-line timing is computed across both spreads), then reuse it after the flip instead of redrawing. - pagination: expose the preview spread layout on the returned preview spread so the owner can detect the continuation spread (race-free; each call owns its preparedSpreads). - renderer: revealSpreadSourceOverride lets region collection use preview spreads during lookahead; prepareContinuationRevealPlan draws+caches the continuation plan (publishEvent off); takeContinuationRevealPlan reuses it, re-stamped as an activate-phase publish. - timeline: prepare the continuation plan during background (non-immediate) prepares; revealContinuationSpread reuses it, falling back to the redraw when none was prepared. Verified live on a spanning block: continuation now appears ~0.25s after the flip (was ~2.7s) at ve~3471 = the right line's duration, i.e. it animates from the start (no pop-in), runs to ~full over the TTS, no fast-forward, no continuation-missing problems. Co-Authored-By: Claude Opus 4.8 --- public/js/book-pagination-module.js | 11 ++- public/js/book-playback-timeline-module.js | 27 ++++++- public/js/book-texture-renderer-module.js | 83 +++++++++++++++++++++- scripts/check-webgl-book-lab.js | 3 +- 4 files changed, 117 insertions(+), 7 deletions(-) diff --git a/public/js/book-pagination-module.js b/public/js/book-pagination-module.js index 3c9a4b0..2604a95 100644 --- a/public/js/book-pagination-module.js +++ b/public/js/book-pagination-module.js @@ -233,17 +233,24 @@ class BookPaginationModule extends BaseModule { if (targetSpread) this.currentSpreadIndex = targetSpread.index; } if (options.publish !== false) this.publish({ reason: options.activate === false ? 'prepare-preload' : 'prepare-activate' }); + const resultSpread = targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread()); + // Expose the full preview layout on the returned (preview) spread so the playback owner + // can detect a spanning block and prepare its continuation spread in the background, + // off the critical path. Each call returns its own preparedSpreads, so this is race-free. + if (options.activate === false && resultSpread) { + resultSpread.previewSpreads = preparedSpreads; + } document.dispatchEvent(new CustomEvent('book-pagination:block-prepared', { detail: { blockId: pendingBlockId, - spread: targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread()), + spread: resultSpread, spreadIndex: targetSpread?.index ?? this.currentSpreadIndex, latestBlockId: pendingBlockId, latestRenderedBlockId, phase: options.activate === false ? 'prepare' : 'activate' } })); - return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread()); + return resultSpread; } getPreparedBlockCacheKey(gameId, blockId, historyEndBlockId, latestRenderedBlockId, options = {}) { diff --git a/public/js/book-playback-timeline-module.js b/public/js/book-playback-timeline-module.js index ac3acd0..06d662f 100644 --- a/public/js/book-playback-timeline-module.js +++ b/public/js/book-playback-timeline-module.js @@ -212,6 +212,26 @@ class BookPlaybackTimelineModule extends BaseModule { publishEvent: false }); + // If this block overflows onto the next spread, prepare that continuation spread's + // reveal plan now (lookahead/background) so revealContinuationSpread reuses it after the + // flip instead of redrawing synchronously. Only during background prepares and when the + // preview layout is available; otherwise the continuation falls back to the redraw path. + if (options.immediate !== true && Array.isArray(previewSpread.previewSpreads)) { + const startIndex = Math.max(0, Number(previewSpread.index || 0)); + const continuationSpread = previewSpread.previewSpreads + .filter(spread => spread + && Number(spread.index) > startIndex + && this.getBlockRevealSides(spread, sentence.blockId).length > 0) + .sort((a, b) => Number(a.index) - Number(b.index))[0] || null; + if (continuationSpread) { + this.textureRenderer.prepareContinuationRevealPlan?.({ + ...revealDetail, + previewSpreads: previewSpread.previewSpreads, + continuationSpread + }); + } + } + const targetSpreadIndex = Math.max(0, Number(previewSpread.index || 0)); const currentSpreadIndex = this.getVisibleSpreadIndex(); const revealSides = this.getBlockRevealSides(previewSpread, sentence.blockId); @@ -477,8 +497,11 @@ class BookPlaybackTimelineModule extends BaseModule { async revealContinuationSpread(segment = {}, spread = null) { const sentence = segment.sentence; if (!sentence || !spread) return false; - const revealDetail = this.createRevealDetail(sentence, spread, 'activate'); - const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false }); + // Reuse the continuation plan prepared during lookahead (no synchronous redraw on the + // critical path). Falls back to rebuilding the plan if none was prepared. + const reused = this.textureRenderer.takeContinuationRevealPlan?.(segment.blockId, spread.index); + const texturePlan = reused + || this.textureRenderer.prepareRevealBlock(this.createRevealDetail(sentence, spread, 'activate'), { publishEvent: false }); if (!texturePlan) { this.pageCache?.recordProblem?.({ type: 'timeline-reveal-continuation-missing', diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 44b2a90..a565f6c 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -31,6 +31,12 @@ class BookTextureRendererModule extends BaseModule { this.revealedBlockIds = new Set(); this.revealBaseCanvases = null; this.revealPublishBlockIds = null; + // During lookahead we prepare a block that has not been committed to pagination yet, + // so this.pagination.spreads does not include its (preview) spreads. When set, reveal + // region collection uses these preview spreads instead, so a spanning block's reveal + // timing is computed across both spreads in the background (no synchronous rebuild on + // the critical path at activate / after the flip). See no-synchronous-main-thread rule. + this.revealSpreadSourceOverride = null; this.lastDrawSignature = null; this.lastDrawSkipLoggedAt = 0; this.pipelineTimings = []; @@ -73,6 +79,8 @@ class BookTextureRendererModule extends BaseModule { 'getPageContent', 'buildLineSegments', 'prepareRevealBlock', + 'prepareContinuationRevealPlan', + 'takeContinuationRevealPlan', 'preloadAdditionalRevealSpreads', 'spreadContainsBlock', 'createAnimationState', @@ -644,8 +652,11 @@ class BookTextureRendererModule extends BaseModule { const candidates = []; const sourceSpreads = []; if (this.currentSpread) sourceSpreads.push(this.currentSpread); - if (Array.isArray(this.pagination?.spreads)) { - this.pagination.spreads.forEach((spread) => { + const paginationSpreads = Array.isArray(this.revealSpreadSourceOverride) + ? this.revealSpreadSourceOverride + : (Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : null); + if (paginationSpreads) { + paginationSpreads.forEach((spread) => { if (!spread) return; if (this.currentSpread && Number(spread.index) === Number(this.currentSpread.index)) return; sourceSpreads.push(spread); @@ -931,6 +942,74 @@ class BookTextureRendererModule extends BaseModule { } : null; } + // Lookahead-only: draw and cache the reveal plan for the spread a spanning block + // continues onto, using the not-yet-committed preview spreads so the per-line timing is + // computed across both spreads. revealContinuationSpread reuses this after the flip + // instead of redrawing the spread synchronously on the critical path. Returns the plan + // or null (caller falls back to the synchronous redraw). + prepareContinuationRevealPlan(detail = {}) { + const blockId = detail.blockId ?? detail.id ?? null; + const previewSpreads = Array.isArray(detail.previewSpreads) ? detail.previewSpreads : null; + const continuationSpread = detail.continuationSpread || null; + if (blockId == null || !previewSpreads || !continuationSpread) return null; + const id = String(blockId); + const wordTimings = Array.isArray(detail.wordTimings) ? detail.wordTimings : []; + const existing = this.activeAnimations.get(id); + if (!existing || existing.completed) { + this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail)); + } + const previousOverride = this.revealSpreadSourceOverride; + const previousPublishIds = this.revealPublishBlockIds; + this.revealSpreadSourceOverride = previewSpreads; + this.revealPublishBlockIds = new Set([id]); + let published = null; + try { + published = this.drawSpread(continuationSpread, ['left', 'right'], { phase: 'prepare', publishEvent: false }); + } finally { + // drawSpread nulls revealPublishBlockIds when it finishes; restore the caller's state. + this.revealSpreadSourceOverride = previousOverride; + this.revealPublishBlockIds = previousPublishIds; + } + if (!published || !published.reveal || !Object.keys(published.reveal).length) return null; + const plan = { + ...published, + blockId, + wordTimings, + totalDuration: detail.totalDuration || 0, + continuationSpreadIndex: Math.max(0, Number(continuationSpread.index ?? 0)) + }; + this.pageCache?.rememberPreparedRevealPlan?.(`${id}:cont`, plan); + this.markPipelineTiming('prepareContinuationRevealPlan', { + blockId: id, + continuationSpreadIndex: plan.continuationSpreadIndex, + sides: Object.keys(published.reveal) + }); + return plan; + } + + // Reuse a continuation plan prepared during lookahead. Returns the cached publish detail + // (ready to apply) or null when none was prepared for this block+spread. + takeContinuationRevealPlan(blockId = '', spreadIndex = null) { + const id = String(blockId ?? ''); + const key = `${id}:cont`; + if (!id || !this.pageCache?.hasPreparedRevealPlan?.(key)) return null; + const cached = this.pageCache.takePreparedRevealPlan(key); + if (!cached || Number(cached.continuationSpreadIndex) !== Math.max(0, Number(spreadIndex ?? -1))) return null; + // The block reveals again on this spread; refresh its (uncompleted) animation state so + // region/commit bookkeeping treats it as actively revealing. + this.activeAnimations.set(id, this.createAnimationState(id, cached.wordTimings || [], cached)); + this.revealedBlockIds.delete(id); + // The plan was published at 'prepare' phase (records marked not-yet-visible). Re-stamp + // it as 'activate' and rebuild its records so the scene shows it like a fresh draw. + const activated = { ...cached, phase: 'activate', preparedFromCache: true }; + activated.records = this.buildPageTextureRecords(cached.sides || ['left', 'right'], activated); + this.markPipelineTiming('takeContinuationRevealPlan', { + blockId: id, + continuationSpreadIndex: cached.continuationSpreadIndex + }); + return activated; + } + preloadAdditionalRevealSpreads(blockId, primarySpread = null) { const spreads = Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : []; if (!spreads.length) return; diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index 391f2b2..e5de2fa 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -223,7 +223,8 @@ const checks = [ ['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)], - ['texture renderer derives reveal regions from the just-drawn preview spread before committed pagination', /if \(this\.currentSpread\) sourceSpreads\.push\(this\.currentSpread\)/.test(textureRendererSource) && /this\.pagination\.spreads\.forEach/.test(textureRendererSource) && /Number\(spread\.index\) === Number\(this\.currentSpread\.index\)/.test(textureRendererSource)], + ['texture renderer derives reveal regions from the just-drawn preview spread before committed pagination', /if \(this\.currentSpread\) sourceSpreads\.push\(this\.currentSpread\)/.test(textureRendererSource) && /paginationSpreads\.forEach/.test(textureRendererSource) && /Number\(spread\.index\) === Number\(this\.currentSpread\.index\)/.test(textureRendererSource)], + ['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)], ['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\(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)],