Spanning playback: one background-prepared path, no fallbacks
For a block that overflows onto the next spread, the plan is now prepared spanning-aware during the background lookahead — the start spread's reveal timing is derived across both preview spreads, and the continuation spread's plan is prepared and cached at the same time. playback then follows a single path: - activate reuses the prepared start plan (removed the synchronous forceRebuild rebuild). - revealContinuationSpread reuses the prepared continuation plan (removed the redraw fallback); a missing plan is surfaced as a problem, not silently redrawn. This removes the parallel/immediate prepare distinction and the two fallbacks, leaving one intended path, and moves the spanning draw work off the critical path. Verified live on a real spanning block: right line reveals at its area share (~3.3s), the flip fires, and the continuation appears ~0.3s after the flip (was ~2.7s) and animates progressively across the next spread over the full TTS — no pop-in, no fast-forward, no timeline-reveal-continuation-missing. Static suite passes (165). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -206,13 +206,12 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
});
|
});
|
||||||
if (!previewSpread) return null;
|
if (!previewSpread) return null;
|
||||||
|
|
||||||
// Detect a spanning block from the (not-yet-committed) preview layout so its reveal
|
// Every block is prepared once, spanning-aware. The preview layout (attached to the
|
||||||
// plan can be prepared spanning-aware in the background, off the critical path. Only
|
// preview spread by pagination) tells us whether the block overflows onto the next
|
||||||
// during background prepares and when the preview layout is available; otherwise we
|
// spread; if so we derive the start spread's timing across both spreads and prepare the
|
||||||
// fall back to the synchronous activate rebuild + continuation redraw (today's path).
|
// continuation spread now. activate and revealContinuationSpread then reuse these — one
|
||||||
const previewSpreads = options.immediate !== true && Array.isArray(previewSpread.previewSpreads)
|
// prepare path, no synchronous rebuild or redraw on the critical path.
|
||||||
? previewSpread.previewSpreads
|
const previewSpreads = Array.isArray(previewSpread.previewSpreads) ? previewSpread.previewSpreads : null;
|
||||||
: null;
|
|
||||||
const startIndex = Math.max(0, Number(previewSpread.index || 0));
|
const startIndex = Math.max(0, Number(previewSpread.index || 0));
|
||||||
const continuationSpread = previewSpreads
|
const continuationSpread = previewSpreads
|
||||||
? (previewSpreads
|
? (previewSpreads
|
||||||
@@ -221,20 +220,14 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
&& this.getBlockRevealSides(spread, sentence.blockId).length > 0)
|
&& this.getBlockRevealSides(spread, sentence.blockId).length > 0)
|
||||||
.sort((a, b) => Number(a.index) - Number(b.index))[0] || null)
|
.sort((a, b) => Number(a.index) - Number(b.index))[0] || null)
|
||||||
: null;
|
: null;
|
||||||
const spanningPlanPrepared = Boolean(continuationSpread);
|
|
||||||
|
|
||||||
const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare');
|
const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare');
|
||||||
// For a spanning block, derive the start spread's timing across both preview spreads so
|
|
||||||
// the cached plan already spans both pages and activate can reuse it (no rebuild).
|
|
||||||
const texturePlan = this.textureRenderer.prepareRevealBlock(
|
const texturePlan = this.textureRenderer.prepareRevealBlock(
|
||||||
spanningPlanPrepared ? { ...revealDetail, previewSpreads } : revealDetail,
|
continuationSpread ? { ...revealDetail, previewSpreads } : revealDetail,
|
||||||
{ phase: 'prepare', publishEvent: false }
|
{ phase: 'prepare', publishEvent: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Also prepare the continuation spread's plan so revealContinuationSpread reuses it
|
|
||||||
// after the flip instead of redrawing synchronously.
|
|
||||||
if (continuationSpread) {
|
if (continuationSpread) {
|
||||||
this.textureRenderer.prepareContinuationRevealPlan?.({
|
this.textureRenderer.prepareContinuationRevealPlan({
|
||||||
...revealDetail,
|
...revealDetail,
|
||||||
previewSpreads,
|
previewSpreads,
|
||||||
continuationSpread
|
continuationSpread
|
||||||
@@ -256,9 +249,6 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
revealSides,
|
revealSides,
|
||||||
requiresPreFlip: targetSpreadIndex > currentSpreadIndex,
|
requiresPreFlip: targetSpreadIndex > currentSpreadIndex,
|
||||||
requiresRightFlip: revealSides.includes('right') && this.requiresRightPageFlipAfterReveal(previewSpread),
|
requiresRightFlip: revealSides.includes('right') && this.requiresRightPageFlipAfterReveal(previewSpread),
|
||||||
// True when this block spans and its plan was prepared spanning-aware in the
|
|
||||||
// background, so activate can reuse it without a synchronous forceRebuild.
|
|
||||||
spanningPlanPrepared,
|
|
||||||
// Snapshot the reveal timings now. A reused lookahead segment can be played by
|
// Snapshot the reveal timings now. A reused lookahead segment can be played by
|
||||||
// a sentence instance whose animation timings were lost; without them the
|
// a sentence instance whose animation timings were lost; without them the
|
||||||
// reveal can't be word-paced and stretches across the whole TTS.
|
// reveal can't be word-paced and stretches across the whole TTS.
|
||||||
@@ -325,15 +315,9 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
const spread = segment.activeSpread || segment.previewSpread;
|
const spread = segment.activeSpread || segment.previewSpread;
|
||||||
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
|
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
|
||||||
// A spanning block needs timing across both pages. When the plan was prepared
|
// Reuse the spanning-aware plan prepared during lookahead — its timing already spans
|
||||||
// spanning-aware during lookahead (the common case), reuse it — no synchronous redraw
|
// both pages. No synchronous redraw on the critical path.
|
||||||
// on the critical path. Only when it spans but was NOT prepared spanning-aware (e.g. an
|
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
|
||||||
// immediate prepare with no preview layout) do we rebuild from the committed spreads.
|
|
||||||
const forceRebuild = segment.spansToNextSpread === true && segment.spanningPlanPrepared !== true;
|
|
||||||
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, {
|
|
||||||
publishEvent: false,
|
|
||||||
forceRebuild
|
|
||||||
});
|
|
||||||
segment.activeTexturePlan = texturePlan;
|
segment.activeTexturePlan = texturePlan;
|
||||||
this.applyTexturePlan(texturePlan, segment, 'activate');
|
this.applyTexturePlan(texturePlan, segment, 'activate');
|
||||||
await this.assertSegmentReady(segment, 'activate');
|
await this.assertSegmentReady(segment, 'activate');
|
||||||
@@ -510,11 +494,9 @@ 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;
|
||||||
// Reuse the continuation plan prepared during lookahead (no synchronous redraw on the
|
// Reuse the continuation plan prepared during lookahead. It is always prepared when a
|
||||||
// critical path). Falls back to rebuilding the plan if none was prepared.
|
// block spans (createPreparedSegment), so a miss is a real bug, not a redraw cue.
|
||||||
const reused = this.textureRenderer.takeContinuationRevealPlan?.(segment.blockId, spread.index);
|
const texturePlan = 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',
|
||||||
|
|||||||
@@ -893,10 +893,9 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
wordTimingCount: wordTimings.length,
|
wordTimingCount: wordTimings.length,
|
||||||
phase
|
phase
|
||||||
});
|
});
|
||||||
// forceRebuild: the cached plan was built before the block's continuation was
|
// At activate, reuse the plan prepared during lookahead (it is spanning-aware when the
|
||||||
// committed (it would be right-only). Discard it and redraw from current spreads.
|
// block overflows). Building only happens when no plan was prepared yet.
|
||||||
if (options.forceRebuild === true) this.pageCache?.takePreparedRevealPlan?.(id);
|
if (phase === 'activate' && this.pageCache?.hasPreparedRevealPlan?.(id)) {
|
||||||
if (phase === 'activate' && options.forceRebuild !== true && this.pageCache?.hasPreparedRevealPlan?.(id)) {
|
|
||||||
const cached = this.pageCache.takePreparedRevealPlan(id);
|
const cached = this.pageCache.takePreparedRevealPlan(id);
|
||||||
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
||||||
this.publishPreparedReveal(cached, options);
|
this.publishPreparedReveal(cached, options);
|
||||||
@@ -918,8 +917,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
const sides = ['left', 'right'];
|
const sides = ['left', 'right'];
|
||||||
// When the caller supplies the (not-yet-committed) preview spreads for a spanning
|
// When the caller supplies the (not-yet-committed) preview spreads for a spanning
|
||||||
// block, derive this spread's reveal timing across all of them so the cached plan
|
// block, derive this spread's reveal timing across all of them so the cached plan
|
||||||
// already spans both pages. activate can then reuse it instead of forcing a
|
// already spans both pages, letting activate reuse it directly.
|
||||||
// synchronous rebuild on the critical path.
|
|
||||||
const spanningPreview = Array.isArray(detail.previewSpreads) && detail.previewSpreads.length > 1;
|
const spanningPreview = Array.isArray(detail.previewSpreads) && detail.previewSpreads.length > 1;
|
||||||
const previousOverride = this.revealSpreadSourceOverride;
|
const previousOverride = this.revealSpreadSourceOverride;
|
||||||
if (spanningPreview) this.revealSpreadSourceOverride = detail.previewSpreads;
|
if (spanningPreview) this.revealSpreadSourceOverride = detail.previewSpreads;
|
||||||
@@ -938,8 +936,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
...published,
|
...published,
|
||||||
blockId,
|
blockId,
|
||||||
wordTimings,
|
wordTimings,
|
||||||
totalDuration: detail.totalDuration || 0,
|
totalDuration: detail.totalDuration || 0
|
||||||
spanningTimingPrepared: spanningPreview === true
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.markPipelineTiming('prepareRevealBlock:end', {
|
this.markPipelineTiming('prepareRevealBlock:end', {
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ const checks = [
|
|||||||
['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 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 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)],
|
||||||
['book playback timeline reuses the spanning-aware prepared plan at activate instead of a synchronous forceRebuild', /spanningPlanPrepared/.test(bookPlaybackTimelineSource) && /forceRebuild = segment\.spansToNextSpread === true && segment\.spanningPlanPrepared !== true/.test(bookPlaybackTimelineSource) && /spanningTimingPrepared/.test(textureRendererSource) && /this\.revealSpreadSourceOverride = detail\.previewSpreads/.test(textureRendererSource)],
|
['book playback timeline has one prepare path: spanning-aware plan reused at activate and continuation, no fallback', /this\.revealSpreadSourceOverride = detail\.previewSpreads/.test(textureRendererSource) && !/forceRebuild/.test(textureRendererSource) && !/forceRebuild/.test(bookPlaybackTimelineSource) && !/spanningPlanPrepared/.test(bookPlaybackTimelineSource) && /const texturePlan = this\.textureRenderer\.takeContinuationRevealPlan\(segment\.blockId, spread\.index\)/.test(bookPlaybackTimelineSource)],
|
||||||
['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\(\s*[\s\S]*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\(\s*[\s\S]*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)],
|
||||||
['book playback timeline is loaded through module infrastructure', /book-playback-timeline-module\.js/.test(loaderSource) && /super\('book-playback-timeline'/.test(bookPlaybackTimelineSource) && /reportProgress\(100, 'Book playback timeline ready'\)/.test(bookPlaybackTimelineSource)],
|
['book playback timeline is loaded through module infrastructure', /book-playback-timeline-module\.js/.test(loaderSource) && /super\('book-playback-timeline'/.test(bookPlaybackTimelineSource) && /reportProgress\(100, 'Book playback timeline ready'\)/.test(bookPlaybackTimelineSource)],
|
||||||
|
|||||||
Reference in New Issue
Block a user