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
+80 -20
View File
@@ -7,6 +7,7 @@ import { BaseModule } from './base-module.js';
const TTS_GENERATION_TIMEOUT_MS = 60000;
const ASSET_PRELOAD_TIMEOUT_MS = 60000;
const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000;
const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 2;
class SentenceQueueModule extends BaseModule {
constructor() {
@@ -23,6 +24,7 @@ class SentenceQueueModule extends BaseModule {
// Cache prepared future queue items so the playback path can consume
// work that was already generated during lookahead.
this.prefetchingSpeech = new Map();
this.prefetchingWebGLBook = new Map();
this.preparedSentenceCache = new Map();
this.autoplay = true;
this.inputMode = 'text';
@@ -33,6 +35,7 @@ class SentenceQueueModule extends BaseModule {
this.generationRequests = new Map();
this.assetPreloadRequests = new Map();
this.queueGeneration = 0;
this.webglBookPrepareChain = Promise.resolve();
// Bind methods
this.bindMethods([
@@ -46,7 +49,10 @@ class SentenceQueueModule extends BaseModule {
'getPreparedSentence',
'prefetchAhead',
'prefetchWebGLBookPresentation',
'runWebGLBookPresentationPrepare',
'isWebGLBookPresentationPrepared',
'getWebGLBookPresentationKey',
'isWebGLBookPresentationEligible',
'prepareSpeechMetadata',
'preloadAssetsForItem',
'normalizeTtsText',
@@ -210,18 +216,25 @@ class SentenceQueueModule extends BaseModule {
}
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
// Prefetch far enough ahead that media pauses do not block TTS
// generation for the next spoken paragraph.
this.prefetchAhead(6, queueGeneration);
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
// Notify display handler with complete sentence
if (this.onSentenceReadyCallback) {
await new Promise(resolve => {
const playbackFinished = new Promise(resolve => {
sentence.onComplete = resolve;
sentence.playbackStartedAt = performance.now();
this.onSentenceReadyCallback(sentence, resolve);
});
// Start lookahead only after the current sentence has entered the display
// pipeline. This keeps future WebGL book preparation out of the first
// flip/reveal critical path while still overlapping it with playback.
window.requestAnimationFrame(() => {
if (this.isCurrentQueueItem(item, queueGeneration)) {
this.prefetchAhead(6, queueGeneration);
}
});
await playbackFinished;
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
} else {
this.prefetchAhead(6, queueGeneration);
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
}
@@ -890,12 +903,42 @@ class SentenceQueueModule extends BaseModule {
return this.prepareSentence(item);
}
getWebGLBookPresentationKey(sentence = {}) {
const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null;
if (blockId == null) return null;
return `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${blockId}`;
}
isWebGLBookPresentationEligible(sentence = {}) {
if (!sentence) return false;
return ['paragraph', 'heading'].includes(sentence.kind || sentence.type);
}
async prefetchWebGLBookPresentation(sentence, options = {}) {
if (!sentence || !['paragraph', 'heading'].includes(sentence.kind || sentence.type)) return null;
if (!this.isWebGLBookPresentationEligible(sentence)) return null;
const isWebGLMode = document.body?.dataset?.webglUiMode === '3d'
|| document.body?.classList?.contains('webgl-mode');
if (!isWebGLMode) return null;
const key = this.getWebGLBookPresentationKey(sentence);
if (!key) return null;
const existing = this.prefetchingWebGLBook.get(key);
if (existing) return existing;
const queued = this.webglBookPrepareChain
.catch(() => null)
.then(() => this.runWebGLBookPresentationPrepare(sentence, options));
this.webglBookPrepareChain = queued.catch(() => null);
this.prefetchingWebGLBook.set(key, queued);
return queued.finally(() => {
if (this.prefetchingWebGLBook.get(key) === queued) {
this.prefetchingWebGLBook.delete(key);
}
});
}
async runWebGLBookPresentationPrepare(sentence, options = {}) {
if (!this.isWebGLBookPresentationEligible(sentence)) return null;
const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null;
if (blockId == null) return null;
const bookPlaybackTimeline = this.getModule('book-playback-timeline');
@@ -912,6 +955,7 @@ class SentenceQueueModule extends BaseModule {
const segment = await bookPlaybackTimeline.prepareSentence(sentence, {
immediate: options.immediate === true
});
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
if (!segment) return null;
sentence.webglBookPresentation = {
prepared: true,
@@ -944,14 +988,33 @@ class SentenceQueueModule extends BaseModule {
}
let started = 0;
let spokenPrepared = 0;
let webglBookLookahead = 0;
const limit = Math.min(this.sentenceQueue.length, maxLookahead + 1);
const allowWebGLBookPrefetch = document.documentElement.dataset.webglBookPlaybackActive === 'true';
for (let index = 1; index < limit; index += 1) {
const nextItem = this.sentenceQueue[index];
const nextCacheKey = this.getCacheKey(nextItem);
const cachedPrepared = this.preparedSentenceCache.get(nextCacheKey);
const webglBookCandidate = this.isWebGLBookPresentationEligible(cachedPrepared || nextItem);
const shouldPrepareWebGLBook = allowWebGLBookPrefetch
&& webglBookCandidate
&& webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD;
if (webglBookCandidate) webglBookLookahead += 1;
if (cachedPrepared && !this.prefetchingSpeech.has(nextCacheKey)) {
if (shouldPrepareWebGLBook && !this.isWebGLBookPresentationPrepared(cachedPrepared)) {
this.prefetchWebGLBookPresentation(cachedPrepared, {
queueGeneration,
queueIndex: index
}).catch(err => {
console.warn('SentenceQueue: WebGL book prefetch failed:', err);
});
}
continue;
}
if (this.prefetchingSpeech.has(nextCacheKey)) {
if (this.isSpeechItem(nextItem)) spokenPrepared += 1;
continue;
}
@@ -969,10 +1032,12 @@ class SentenceQueueModule extends BaseModule {
queueIndex: index
});
if (queueGeneration !== this.queueGeneration) return null;
await this.prefetchWebGLBookPresentation(prepared, {
queueGeneration,
queueIndex: index
});
if (shouldPrepareWebGLBook) {
await this.prefetchWebGLBookPresentation(prepared, {
queueGeneration,
queueIndex: index
});
}
if (queueGeneration !== this.queueGeneration) return null;
this.preparedSentenceCache.set(nextCacheKey, prepared);
return prepared;
@@ -997,13 +1062,6 @@ class SentenceQueueModule extends BaseModule {
this.prefetchingSpeech.set(nextCacheKey, promise);
started += 1;
if (this.isSpeechItem(nextItem)) {
spokenPrepared += 1;
}
if (spokenPrepared >= 1 && started >= 2) {
break;
}
}
if (started === 0) {
@@ -1409,7 +1467,9 @@ class SentenceQueueModule extends BaseModule {
this.cancelGenerationRequests('sentence-queue-cleared');
this.cancelAssetPreloads('sentence-queue-cleared');
this.prefetchingSpeech.clear();
this.prefetchingWebGLBook.clear();
this.preparedSentenceCache.clear();
this.webglBookPrepareChain = Promise.resolve();
this.pauseBeforeNextReason = null;
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
detail: { reason: 'sentence-queue-cleared' }