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:
@@ -233,17 +233,24 @@ class BookPaginationModule extends BaseModule {
|
|||||||
if (targetSpread) this.currentSpreadIndex = targetSpread.index;
|
if (targetSpread) this.currentSpreadIndex = targetSpread.index;
|
||||||
}
|
}
|
||||||
if (options.publish !== false) this.publish({ reason: options.activate === false ? 'prepare-preload' : 'prepare-activate' });
|
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', {
|
document.dispatchEvent(new CustomEvent('book-pagination:block-prepared', {
|
||||||
detail: {
|
detail: {
|
||||||
blockId: pendingBlockId,
|
blockId: pendingBlockId,
|
||||||
spread: targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread()),
|
spread: resultSpread,
|
||||||
spreadIndex: targetSpread?.index ?? this.currentSpreadIndex,
|
spreadIndex: targetSpread?.index ?? this.currentSpreadIndex,
|
||||||
latestBlockId: pendingBlockId,
|
latestBlockId: pendingBlockId,
|
||||||
latestRenderedBlockId,
|
latestRenderedBlockId,
|
||||||
phase: options.activate === false ? 'prepare' : 'activate'
|
phase: options.activate === false ? 'prepare' : 'activate'
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread());
|
return resultSpread;
|
||||||
}
|
}
|
||||||
|
|
||||||
getPreparedBlockCacheKey(gameId, blockId, historyEndBlockId, latestRenderedBlockId, options = {}) {
|
getPreparedBlockCacheKey(gameId, blockId, historyEndBlockId, latestRenderedBlockId, options = {}) {
|
||||||
|
|||||||
@@ -212,6 +212,26 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
publishEvent: false
|
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 targetSpreadIndex = Math.max(0, Number(previewSpread.index || 0));
|
||||||
const currentSpreadIndex = this.getVisibleSpreadIndex();
|
const currentSpreadIndex = this.getVisibleSpreadIndex();
|
||||||
const revealSides = this.getBlockRevealSides(previewSpread, sentence.blockId);
|
const revealSides = this.getBlockRevealSides(previewSpread, sentence.blockId);
|
||||||
@@ -477,8 +497,11 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
async revealContinuationSpread(segment = {}, spread = null) {
|
async revealContinuationSpread(segment = {}, spread = null) {
|
||||||
const sentence = segment.sentence;
|
const sentence = segment.sentence;
|
||||||
if (!sentence || !spread) return false;
|
if (!sentence || !spread) return false;
|
||||||
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
|
// Reuse the continuation plan prepared during lookahead (no synchronous redraw on the
|
||||||
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
|
// 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) {
|
if (!texturePlan) {
|
||||||
this.pageCache?.recordProblem?.({
|
this.pageCache?.recordProblem?.({
|
||||||
type: 'timeline-reveal-continuation-missing',
|
type: 'timeline-reveal-continuation-missing',
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.revealedBlockIds = new Set();
|
this.revealedBlockIds = new Set();
|
||||||
this.revealBaseCanvases = null;
|
this.revealBaseCanvases = null;
|
||||||
this.revealPublishBlockIds = 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.lastDrawSignature = null;
|
||||||
this.lastDrawSkipLoggedAt = 0;
|
this.lastDrawSkipLoggedAt = 0;
|
||||||
this.pipelineTimings = [];
|
this.pipelineTimings = [];
|
||||||
@@ -73,6 +79,8 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
'getPageContent',
|
'getPageContent',
|
||||||
'buildLineSegments',
|
'buildLineSegments',
|
||||||
'prepareRevealBlock',
|
'prepareRevealBlock',
|
||||||
|
'prepareContinuationRevealPlan',
|
||||||
|
'takeContinuationRevealPlan',
|
||||||
'preloadAdditionalRevealSpreads',
|
'preloadAdditionalRevealSpreads',
|
||||||
'spreadContainsBlock',
|
'spreadContainsBlock',
|
||||||
'createAnimationState',
|
'createAnimationState',
|
||||||
@@ -644,8 +652,11 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
const candidates = [];
|
const candidates = [];
|
||||||
const sourceSpreads = [];
|
const sourceSpreads = [];
|
||||||
if (this.currentSpread) sourceSpreads.push(this.currentSpread);
|
if (this.currentSpread) sourceSpreads.push(this.currentSpread);
|
||||||
if (Array.isArray(this.pagination?.spreads)) {
|
const paginationSpreads = Array.isArray(this.revealSpreadSourceOverride)
|
||||||
this.pagination.spreads.forEach((spread) => {
|
? this.revealSpreadSourceOverride
|
||||||
|
: (Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : null);
|
||||||
|
if (paginationSpreads) {
|
||||||
|
paginationSpreads.forEach((spread) => {
|
||||||
if (!spread) return;
|
if (!spread) return;
|
||||||
if (this.currentSpread && Number(spread.index) === Number(this.currentSpread.index)) return;
|
if (this.currentSpread && Number(spread.index) === Number(this.currentSpread.index)) return;
|
||||||
sourceSpreads.push(spread);
|
sourceSpreads.push(spread);
|
||||||
@@ -931,6 +942,74 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
} : null;
|
} : 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) {
|
preloadAdditionalRevealSpreads(blockId, primarySpread = null) {
|
||||||
const spreads = Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : [];
|
const spreads = Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : [];
|
||||||
if (!spreads.length) return;
|
if (!spreads.length) return;
|
||||||
|
|||||||
@@ -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)],
|
['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 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)],
|
['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)],
|
['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)],
|
['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)],
|
['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)],
|
||||||
|
|||||||
Reference in New Issue
Block a user