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:
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user