Reuse spanning-aware prepared plan at activate (kill pre-playback pause)
A spanning block stalled ~2.1s before it began playing: activate ran prepareRevealBlock with forceRebuild, synchronously redrawing the start spread (and preloading the continuation spread) on the main thread, because the lookahead plan had been built with right-only timing before pagination committed the overflow. Build the start-spread plan spanning-aware during lookahead instead: when the preview layout shows the block overflows, derive its timing across both preview spreads (via the revealSpreadSourceOverride) and cache it. activate then reuses that plan — the same fast cached-plan path non-spanning blocks already use — with no synchronous redraw. forceRebuild is kept only as a fallback when a block spans but was not prepared spanning-aware (e.g. an immediate prepare with no preview layout), and an evicted plan still rebuilds correctly because pagination is committed by then. Verified live: the spanning block's pre-playback gap dropped from ~2088ms to 139ms (equal to non-spanning blocks), while the right line still reveals over its area share (~3.3s), the continuation still animates from the start, and there are no fast-forwards or problems. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -206,31 +206,40 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
});
|
});
|
||||||
if (!previewSpread) return null;
|
if (!previewSpread) return null;
|
||||||
|
|
||||||
const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare');
|
// Detect a spanning block from the (not-yet-committed) preview layout so its reveal
|
||||||
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, {
|
// plan can be prepared spanning-aware in the background, off the critical path. Only
|
||||||
phase: 'prepare',
|
// during background prepares and when the preview layout is available; otherwise we
|
||||||
publishEvent: false
|
// fall back to the synchronous activate rebuild + continuation redraw (today's path).
|
||||||
});
|
const previewSpreads = options.immediate !== true && Array.isArray(previewSpread.previewSpreads)
|
||||||
|
? previewSpread.previewSpreads
|
||||||
// If this block overflows onto the next spread, prepare that continuation spread's
|
: null;
|
||||||
// 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 startIndex = Math.max(0, Number(previewSpread.index || 0));
|
||||||
const continuationSpread = previewSpread.previewSpreads
|
const continuationSpread = previewSpreads
|
||||||
|
? (previewSpreads
|
||||||
.filter(spread => spread
|
.filter(spread => spread
|
||||||
&& Number(spread.index) > startIndex
|
&& Number(spread.index) > startIndex
|
||||||
&& 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;
|
||||||
|
const spanningPlanPrepared = Boolean(continuationSpread);
|
||||||
|
|
||||||
|
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(
|
||||||
|
spanningPlanPrepared ? { ...revealDetail, previewSpreads } : revealDetail,
|
||||||
|
{ 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: previewSpread.previewSpreads,
|
previewSpreads,
|
||||||
continuationSpread
|
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();
|
||||||
@@ -247,6 +256,9 @@ 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.
|
||||||
@@ -313,13 +325,14 @@ 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');
|
||||||
// For a spanning block the prepared reveal plan was built during lookahead before
|
// A spanning block needs timing across both pages. When the plan was prepared
|
||||||
// the continuation was committed, so it is right-only. Rebuild from the committed
|
// spanning-aware during lookahead (the common case), reuse it — no synchronous redraw
|
||||||
// spreads so the reveal timing spans both pages (right page no longer absorbs the
|
// on the critical path. Only when it spans but was NOT prepared spanning-aware (e.g. an
|
||||||
// whole duration) and the continuation has reveal regions.
|
// 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, {
|
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, {
|
||||||
publishEvent: false,
|
publishEvent: false,
|
||||||
forceRebuild: segment.spansToNextSpread === true
|
forceRebuild
|
||||||
});
|
});
|
||||||
segment.activeTexturePlan = texturePlan;
|
segment.activeTexturePlan = texturePlan;
|
||||||
this.applyTexturePlan(texturePlan, segment, 'activate');
|
this.applyTexturePlan(texturePlan, segment, 'activate');
|
||||||
|
|||||||
@@ -916,17 +916,30 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.revealPublishBlockIds = new Set([id]);
|
this.revealPublishBlockIds = new Set([id]);
|
||||||
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
|
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
|
||||||
const sides = ['left', 'right'];
|
const sides = ['left', 'right'];
|
||||||
const published = this.drawSpread(spread, sides, {
|
// 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
|
||||||
|
// already spans both pages. activate can then reuse it instead of forcing a
|
||||||
|
// synchronous rebuild on the critical path.
|
||||||
|
const spanningPreview = Array.isArray(detail.previewSpreads) && detail.previewSpreads.length > 1;
|
||||||
|
const previousOverride = this.revealSpreadSourceOverride;
|
||||||
|
if (spanningPreview) this.revealSpreadSourceOverride = detail.previewSpreads;
|
||||||
|
let published = null;
|
||||||
|
try {
|
||||||
|
published = this.drawSpread(spread, sides, {
|
||||||
phase,
|
phase,
|
||||||
publishEvent: options.publishEvent !== false
|
publishEvent: options.publishEvent !== false
|
||||||
});
|
});
|
||||||
this.preloadAdditionalRevealSpreads(id, spread);
|
} finally {
|
||||||
|
this.revealSpreadSourceOverride = previousOverride;
|
||||||
|
}
|
||||||
|
if (!spanningPreview) this.preloadAdditionalRevealSpreads(id, spread);
|
||||||
if (phase === 'prepare' && published) {
|
if (phase === 'prepare' && published) {
|
||||||
this.pageCache?.rememberPreparedRevealPlan?.(id, {
|
this.pageCache?.rememberPreparedRevealPlan?.(id, {
|
||||||
...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', {
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ const checks = [
|
|||||||
['webgl flip assigns explicit source and back page textures before animation starts', /resolveCurrentFlipSourceTexture\(sourceSide\)/.test(source) && /const targetBackSide = flip\.direction > 0 \? 'left' : 'right'/.test(source) && /const targetBackPageMeta = getPaginationPageMeta\(targetBackPageIndex\) \|\| makeBlankPageMeta\(targetBackPageIndex\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source)],
|
['webgl flip assigns explicit source and back page textures before animation starts', /resolveCurrentFlipSourceTexture\(sourceSide\)/.test(source) && /const targetBackSide = flip\.direction > 0 \? 'left' : 'right'/.test(source) && /const targetBackPageMeta = getPaginationPageMeta\(targetBackPageIndex\) \|\| makeBlankPageMeta\(targetBackPageIndex\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source)],
|
||||||
['webgl flip never falls back to the opposite visible stack for target back texture', /function resolveFlipBackTexture\(pageMeta = null, prewarmedTexture = null\)/.test(source) && source.includes('return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);') && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
|
['webgl flip never falls back to the opposite visible stack for target back texture', /function resolveFlipBackTexture\(pageMeta = null, prewarmedTexture = null\)/.test(source) && source.includes('return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);') && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
|
||||||
['webgl page texture record metadata normalizes omitted or null sides into explicit blank pages', /function normalizePageMetaPair/.test(source) && /function makeBlankPageMeta/.test(source) && /applyExplicitBlankPageTexture/.test(source) && /normalizePageTextureRecordDetail/.test(source) && !/hasLeftMeta/.test(methodBody(source, 'handlePageTextureRecords'))],
|
['webgl page texture record metadata normalizes omitted or null sides into explicit blank pages', /function normalizePageMetaPair/.test(source) && /function makeBlankPageMeta/.test(source) && /applyExplicitBlankPageTexture/.test(source) && /normalizePageTextureRecordDetail/.test(source) && !/hasLeftMeta/.test(methodBody(source, 'handlePageTextureRecords'))],
|
||||||
['texture renderer publishes both spread sides for reveal preparation', /const sides = \['left', 'right'\]/.test(textureRendererSource) && /const published = this\.drawSpread\(spread, sides/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)],
|
['texture renderer publishes both spread sides for reveal preparation', /const sides = \['left', 'right'\]/.test(textureRendererSource) && /published = this\.drawSpread\(spread, sides/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)],
|
||||||
['texture renderer marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)],
|
['texture renderer marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)],
|
||||||
['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(webglPageCacheSource)],
|
['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(webglPageCacheSource)],
|
||||||
['webgl texture store resident cache reuses newest page version for older readiness requests', /isOlderPageMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta/.test(webglPageCacheSource) && /if \(!resident\) return null/.test(webglPageCacheSource) && !/if \(!resident \|\| this\.isOlderPageMeta\(pageMeta, resident\.pageMeta\)\) return null/.test(webglPageCacheSource)],
|
['webgl texture store resident cache reuses newest page version for older readiness requests', /isOlderPageMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta/.test(webglPageCacheSource) && /if \(!resident\) return null/.test(webglPageCacheSource) && !/if \(!resident \|\| this\.isOlderPageMeta\(pageMeta, resident\.pageMeta\)\) return null/.test(webglPageCacheSource)],
|
||||||
@@ -226,8 +226,9 @@ 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)],
|
||||||
['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\(\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)],
|
||||||
['book playback timeline initializes before sentence queue without a dependency cycle', /this\.dependencies = \[[^\]]*'book-playback-timeline'[^\]]*\]/.test(sentenceQueueSource) && !/this\.dependencies = \[[^\]]*'sentence-queue'[^\]]*\]/.test(bookPlaybackTimelineSource) && /calculateAnimationTiming\(words = \[\]/.test(bookPlaybackTimelineSource)],
|
['book playback timeline initializes before sentence queue without a dependency cycle', /this\.dependencies = \[[^\]]*'book-playback-timeline'[^\]]*\]/.test(sentenceQueueSource) && !/this\.dependencies = \[[^\]]*'sentence-queue'[^\]]*\]/.test(bookPlaybackTimelineSource) && /calculateAnimationTiming\(words = \[\]/.test(bookPlaybackTimelineSource)],
|
||||||
['3D display playback is owned by book playback timeline', /book-playback-timeline/.test(uiDisplayHandlerSource) && /playWebGLBookSentence/.test(uiDisplayHandlerSource) && /timeline\.playSentence\(sentence\)/.test(uiDisplayHandlerSource) && /if \(useWebGLBookReveal\) \{[\s\S]*await this\.playWebGLBookSentence\(sentence\);[\s\S]*this\.markBlockRendered\(sentence\.blockId\);[\s\S]*return null;/.test(uiDisplayHandlerSource)],
|
['3D display playback is owned by book playback timeline', /book-playback-timeline/.test(uiDisplayHandlerSource) && /playWebGLBookSentence/.test(uiDisplayHandlerSource) && /timeline\.playSentence\(sentence\)/.test(uiDisplayHandlerSource) && /if \(useWebGLBookReveal\) \{[\s\S]*await this\.playWebGLBookSentence\(sentence\);[\s\S]*this\.markBlockRendered\(sentence\.blockId\);[\s\S]*return null;/.test(uiDisplayHandlerSource)],
|
||||||
|
|||||||
Reference in New Issue
Block a user