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 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 00:04:21 +02:00
parent e72594b3ff
commit 47af10d60c
4 changed files with 117 additions and 7 deletions
+9 -2
View File
@@ -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 = {}) {
+25 -2
View File
@@ -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',
+81 -2
View File
@@ -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;
+2 -1
View File
@@ -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)],