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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user