Fix new-game title flip + cap lookahead prepare burst

Builds on the worker migration with prepare-burst pacing and a title-flip fix:

- New game from mid-game left the book on the previous game's spread, so the first block's
  source and target spread matched and the title->content page turn was skipped. story:client-reset
  now returns the book to the title spread (spread 0) so the first block flips 0->1 and animates.
  Verified: requiresSpreadTransition src=0 tgt=1, page-flip-started/near-end fire.

- The lookahead burst-prepared many blocks at once, spiking allocation/GC into multi-second
  main-thread stalls. WebGL book prepares are now serialized through a chain and capped to a
  small lookahead window (TTS audio prefetch still spans the full window); future lookahead is
  also deferred until the current sentence has entered the display pipeline, keeping it off the
  first flip/reveal critical path. Worst game-start stall ~6s -> ~3.4s.

- Page flips now drive the scene through the sceneControl prewarm/startPreparedPageFlip API
  (awaited) instead of an event, and the scene awaits the async initial spread draw.

Suite 177. Remaining: a per-block prepare stall (~1.6-3.4s for large blocks at game start)
that profiling has not yet attributed to a single function (likely GC from prepare-path
allocation) — needs a DevTools performance capture for exact attribution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 00:59:01 +02:00
parent 004c077181
commit 705d1ea6bf
6 changed files with 237 additions and 67 deletions
+55 -24
View File
@@ -10,10 +10,10 @@
* -> activate (upload the visible textures for the target spread)
* -> reveal (animate the new block's text in)
*
* It drives the scene exclusively through the formal `webgl-book:*` events and
* the registered `webgl-book-scene` accessor. It never touches `window.BookLabDebug`
* (debug-only) and never throws out of the live playback path: a transient cache
* miss is surfaced as a problem state and playback degrades gracefully.
* It drives the scene through the registered `webgl-book-scene` accessor and uses
* `webgl-book:*` events only as state notifications. It never touches
* `window.BookLabDebug` (debug-only). Cache and scene-preparation misses are
* surfaced as problem states instead of being hidden by alternate playback paths.
*/
import { BaseModule } from './base-module.js';
@@ -120,6 +120,7 @@ class BookPlaybackTimelineModule extends BaseModule {
this.recordDiagnostic('segment-play:start', segment);
try {
segment.sourceSpreadIndex = this.getVisibleSpreadIndex();
// Commit pagination first so the flip targets the authoritative spread,
// not the predicted preview spread.
await this.timeStage('commit', segment, () => this.commitSegmentSpread(segment, sentence));
@@ -279,6 +280,9 @@ class BookPlaybackTimelineModule extends BaseModule {
async commitSegmentSpread(segment = {}, sentence = segment.sentence) {
if (!segment || !sentence) return null;
segment.sourceSpreadIndex = Number.isFinite(Number(segment.sourceSpreadIndex))
? Math.max(0, Math.round(Number(segment.sourceSpreadIndex)))
: this.getVisibleSpreadIndex();
const activeSpread = await this.pagination.preparePendingBlock(sentence, {
includeUnrenderedHistory: true
});
@@ -314,10 +318,18 @@ class BookPlaybackTimelineModule extends BaseModule {
};
}
const spread = segment.activeSpread || segment.previewSpread;
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
let texturePlan = segment.preparedTexturePlan
? { ...segment.preparedTexturePlan, phase: 'activate' }
: null;
if (texturePlan && this.pageCache?.hasPreparedRevealPlan?.(segment.blockId)) {
this.pageCache.takePreparedRevealPlan(segment.blockId);
}
if (!texturePlan) {
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
texturePlan = await this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
}
// Reuse the spanning-aware plan prepared during lookahead — its timing already spans
// both pages. No synchronous redraw on the critical path.
const texturePlan = await this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
segment.activeTexturePlan = texturePlan;
this.applyTexturePlan(texturePlan, segment, 'activate');
await this.assertSegmentReady(segment, 'activate');
@@ -439,7 +451,10 @@ class BookPlaybackTimelineModule extends BaseModule {
}
requiresSpreadTransition(segment = {}) {
return Math.max(0, Number(segment.targetSpreadIndex || 0)) > this.getVisibleSpreadIndex();
const sourceSpread = Number.isFinite(Number(segment.sourceSpreadIndex))
? Math.max(0, Math.round(Number(segment.sourceSpreadIndex)))
: this.getVisibleSpreadIndex();
return Math.max(0, Number(segment.targetSpreadIndex || 0)) > sourceSpread;
}
requiresRightPageFlipAfterReveal(spread = {}) {
@@ -561,26 +576,42 @@ class BookPlaybackTimelineModule extends BaseModule {
async requestPageFlip(direction = 1, options = {}) {
if (this.isChoiceAwaitingPlayer()) return false;
// Warm the texture cache for the navigation window and verify the target pages
// are resident before asking the scene to flip. The scene performs its own
// flip-specific prewarm (drawing the spreads), so we do not pass this through.
await this.prepareFlipPlan(direction, options);
const flipPlan = await this.prepareFlipPlan(direction, options);
await this.assertSegmentReady({
blockId: options.blockId ?? null,
targetSpreadIndex: options.targetSpread,
revealSides: []
}, 'flip');
const wait = this.waitForPageFlipFinished(options.targetSpread);
document.dispatchEvent(new CustomEvent('webgl-book:request-page-flip', {
detail: {
direction,
force: options.force === true,
reason: options.reason || 'timeline',
targetSpread: options.targetSpread,
revealSides: Array.isArray(options.revealSides) ? options.revealSides : null
}
}));
return wait;
const sceneControl = this.scene?.sceneControl || null;
if (typeof sceneControl?.prewarmPageFlip !== 'function' || typeof sceneControl?.startPreparedPageFlip !== 'function') {
this.pageCache?.recordProblem?.({
type: 'timeline-scene-flip-api-missing',
targetSpread: flipPlan.targetSpread,
reason: options.reason || 'timeline'
});
return false;
}
const scenePrewarm = await sceneControl.prewarmPageFlip(direction, {
targetSpread: flipPlan.targetSpread,
reason: options.reason || 'timeline'
});
const started = sceneControl.startPreparedPageFlip(direction, {
force: options.force === true,
reason: options.reason || 'timeline',
targetSpread: flipPlan.targetSpread,
deferRevealSides: Array.isArray(options.revealSides) ? options.revealSides : null,
flipPlan,
prewarm: scenePrewarm
});
if (!started) {
this.pageCache?.recordProblem?.({
type: 'timeline-scene-flip-start-failed',
targetSpread: flipPlan.targetSpread,
reason: options.reason || 'timeline'
});
return false;
}
return this.waitForPageFlipFinished(flipPlan.targetSpread, { alreadyStarted: true });
}
async prepareFlipPlan(direction = 1, options = {}) {
@@ -728,9 +759,9 @@ class BookPlaybackTimelineModule extends BaseModule {
};
}
waitForPageFlipFinished(targetSpread = null) {
waitForPageFlipFinished(targetSpread = null, options = {}) {
return new Promise(resolve => {
let started = false;
let started = options.alreadyStarted === true;
let resolved = false;
const expectedSpread = Number.isFinite(Number(targetSpread))
? Math.max(0, Math.round(Number(targetSpread)))