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) * -> activate (upload the visible textures for the target spread)
* -> reveal (animate the new block's text in) * -> reveal (animate the new block's text in)
* *
* It drives the scene exclusively through the formal `webgl-book:*` events and * It drives the scene through the registered `webgl-book-scene` accessor and uses
* the registered `webgl-book-scene` accessor. It never touches `window.BookLabDebug` * `webgl-book:*` events only as state notifications. It never touches
* (debug-only) and never throws out of the live playback path: a transient cache * `window.BookLabDebug` (debug-only). Cache and scene-preparation misses are
* miss is surfaced as a problem state and playback degrades gracefully. * surfaced as problem states instead of being hidden by alternate playback paths.
*/ */
import { BaseModule } from './base-module.js'; import { BaseModule } from './base-module.js';
@@ -120,6 +120,7 @@ class BookPlaybackTimelineModule extends BaseModule {
this.recordDiagnostic('segment-play:start', segment); this.recordDiagnostic('segment-play:start', segment);
try { try {
segment.sourceSpreadIndex = this.getVisibleSpreadIndex();
// Commit pagination first so the flip targets the authoritative spread, // Commit pagination first so the flip targets the authoritative spread,
// not the predicted preview spread. // not the predicted preview spread.
await this.timeStage('commit', segment, () => this.commitSegmentSpread(segment, sentence)); await this.timeStage('commit', segment, () => this.commitSegmentSpread(segment, sentence));
@@ -279,6 +280,9 @@ class BookPlaybackTimelineModule extends BaseModule {
async commitSegmentSpread(segment = {}, sentence = segment.sentence) { async commitSegmentSpread(segment = {}, sentence = segment.sentence) {
if (!segment || !sentence) return null; 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, { const activeSpread = await this.pagination.preparePendingBlock(sentence, {
includeUnrenderedHistory: true includeUnrenderedHistory: true
}); });
@@ -314,10 +318,18 @@ class BookPlaybackTimelineModule extends BaseModule {
}; };
} }
const spread = segment.activeSpread || segment.previewSpread; 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 // Reuse the spanning-aware plan prepared during lookahead — its timing already spans
// both pages. No synchronous redraw on the critical path. // both pages. No synchronous redraw on the critical path.
const texturePlan = await this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
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');
@@ -439,7 +451,10 @@ class BookPlaybackTimelineModule extends BaseModule {
} }
requiresSpreadTransition(segment = {}) { 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 = {}) { requiresRightPageFlipAfterReveal(spread = {}) {
@@ -561,26 +576,42 @@ class BookPlaybackTimelineModule extends BaseModule {
async requestPageFlip(direction = 1, options = {}) { async requestPageFlip(direction = 1, options = {}) {
if (this.isChoiceAwaitingPlayer()) return false; if (this.isChoiceAwaitingPlayer()) return false;
// Warm the texture cache for the navigation window and verify the target pages const flipPlan = await this.prepareFlipPlan(direction, options);
// 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);
await this.assertSegmentReady({ await this.assertSegmentReady({
blockId: options.blockId ?? null, blockId: options.blockId ?? null,
targetSpreadIndex: options.targetSpread, targetSpreadIndex: options.targetSpread,
revealSides: [] revealSides: []
}, 'flip'); }, 'flip');
const wait = this.waitForPageFlipFinished(options.targetSpread); const sceneControl = this.scene?.sceneControl || null;
document.dispatchEvent(new CustomEvent('webgl-book:request-page-flip', { if (typeof sceneControl?.prewarmPageFlip !== 'function' || typeof sceneControl?.startPreparedPageFlip !== 'function') {
detail: { this.pageCache?.recordProblem?.({
direction, type: 'timeline-scene-flip-api-missing',
force: options.force === true, targetSpread: flipPlan.targetSpread,
reason: options.reason || 'timeline', reason: options.reason || 'timeline'
targetSpread: options.targetSpread, });
revealSides: Array.isArray(options.revealSides) ? options.revealSides : null return false;
} }
})); const scenePrewarm = await sceneControl.prewarmPageFlip(direction, {
return wait; 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 = {}) { async prepareFlipPlan(direction = 1, options = {}) {
@@ -728,9 +759,9 @@ class BookPlaybackTimelineModule extends BaseModule {
}; };
} }
waitForPageFlipFinished(targetSpread = null) { waitForPageFlipFinished(targetSpread = null, options = {}) {
return new Promise(resolve => { return new Promise(resolve => {
let started = false; let started = options.alreadyStarted === true;
let resolved = false; let resolved = false;
const expectedSpread = Number.isFinite(Number(targetSpread)) const expectedSpread = Number.isFinite(Number(targetSpread))
? Math.max(0, Math.round(Number(targetSpread))) ? Math.max(0, Math.round(Number(targetSpread)))
+80 -20
View File
@@ -7,6 +7,7 @@ import { BaseModule } from './base-module.js';
const TTS_GENERATION_TIMEOUT_MS = 60000; const TTS_GENERATION_TIMEOUT_MS = 60000;
const ASSET_PRELOAD_TIMEOUT_MS = 60000; const ASSET_PRELOAD_TIMEOUT_MS = 60000;
const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000; const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000;
const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 2;
class SentenceQueueModule extends BaseModule { class SentenceQueueModule extends BaseModule {
constructor() { constructor() {
@@ -23,6 +24,7 @@ class SentenceQueueModule extends BaseModule {
// Cache prepared future queue items so the playback path can consume // Cache prepared future queue items so the playback path can consume
// work that was already generated during lookahead. // work that was already generated during lookahead.
this.prefetchingSpeech = new Map(); this.prefetchingSpeech = new Map();
this.prefetchingWebGLBook = new Map();
this.preparedSentenceCache = new Map(); this.preparedSentenceCache = new Map();
this.autoplay = true; this.autoplay = true;
this.inputMode = 'text'; this.inputMode = 'text';
@@ -33,6 +35,7 @@ class SentenceQueueModule extends BaseModule {
this.generationRequests = new Map(); this.generationRequests = new Map();
this.assetPreloadRequests = new Map(); this.assetPreloadRequests = new Map();
this.queueGeneration = 0; this.queueGeneration = 0;
this.webglBookPrepareChain = Promise.resolve();
// Bind methods // Bind methods
this.bindMethods([ this.bindMethods([
@@ -46,7 +49,10 @@ class SentenceQueueModule extends BaseModule {
'getPreparedSentence', 'getPreparedSentence',
'prefetchAhead', 'prefetchAhead',
'prefetchWebGLBookPresentation', 'prefetchWebGLBookPresentation',
'runWebGLBookPresentationPrepare',
'isWebGLBookPresentationPrepared', 'isWebGLBookPresentationPrepared',
'getWebGLBookPresentationKey',
'isWebGLBookPresentationEligible',
'prepareSpeechMetadata', 'prepareSpeechMetadata',
'preloadAssetsForItem', 'preloadAssetsForItem',
'normalizeTtsText', 'normalizeTtsText',
@@ -210,18 +216,25 @@ class SentenceQueueModule extends BaseModule {
} }
if (!this.isCurrentQueueItem(item, queueGeneration)) return; 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 // Notify display handler with complete sentence
if (this.onSentenceReadyCallback) { if (this.onSentenceReadyCallback) {
await new Promise(resolve => { const playbackFinished = new Promise(resolve => {
sentence.onComplete = resolve; sentence.onComplete = resolve;
sentence.playbackStartedAt = performance.now(); sentence.playbackStartedAt = performance.now();
this.onSentenceReadyCallback(sentence, resolve); 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; if (!this.isCurrentQueueItem(item, queueGeneration)) return;
} }
@@ -890,12 +903,42 @@ class SentenceQueueModule extends BaseModule {
return this.prepareSentence(item); 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 = {}) { 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' const isWebGLMode = document.body?.dataset?.webglUiMode === '3d'
|| document.body?.classList?.contains('webgl-mode'); || document.body?.classList?.contains('webgl-mode');
if (!isWebGLMode) return null; 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; const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null;
if (blockId == null) return null; if (blockId == null) return null;
const bookPlaybackTimeline = this.getModule('book-playback-timeline'); const bookPlaybackTimeline = this.getModule('book-playback-timeline');
@@ -912,6 +955,7 @@ class SentenceQueueModule extends BaseModule {
const segment = await bookPlaybackTimeline.prepareSentence(sentence, { const segment = await bookPlaybackTimeline.prepareSentence(sentence, {
immediate: options.immediate === true immediate: options.immediate === true
}); });
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
if (!segment) return null; if (!segment) return null;
sentence.webglBookPresentation = { sentence.webglBookPresentation = {
prepared: true, prepared: true,
@@ -944,14 +988,33 @@ class SentenceQueueModule extends BaseModule {
} }
let started = 0; let started = 0;
let spokenPrepared = 0; let webglBookLookahead = 0;
const limit = Math.min(this.sentenceQueue.length, maxLookahead + 1); const limit = Math.min(this.sentenceQueue.length, maxLookahead + 1);
const allowWebGLBookPrefetch = document.documentElement.dataset.webglBookPlaybackActive === 'true';
for (let index = 1; index < limit; index += 1) { for (let index = 1; index < limit; index += 1) {
const nextItem = this.sentenceQueue[index]; const nextItem = this.sentenceQueue[index];
const nextCacheKey = this.getCacheKey(nextItem); 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.prefetchingSpeech.has(nextCacheKey)) {
if (this.isSpeechItem(nextItem)) spokenPrepared += 1;
continue; continue;
} }
@@ -969,10 +1032,12 @@ class SentenceQueueModule extends BaseModule {
queueIndex: index queueIndex: index
}); });
if (queueGeneration !== this.queueGeneration) return null; if (queueGeneration !== this.queueGeneration) return null;
await this.prefetchWebGLBookPresentation(prepared, { if (shouldPrepareWebGLBook) {
queueGeneration, await this.prefetchWebGLBookPresentation(prepared, {
queueIndex: index queueGeneration,
}); queueIndex: index
});
}
if (queueGeneration !== this.queueGeneration) return null; if (queueGeneration !== this.queueGeneration) return null;
this.preparedSentenceCache.set(nextCacheKey, prepared); this.preparedSentenceCache.set(nextCacheKey, prepared);
return prepared; return prepared;
@@ -997,13 +1062,6 @@ class SentenceQueueModule extends BaseModule {
this.prefetchingSpeech.set(nextCacheKey, promise); this.prefetchingSpeech.set(nextCacheKey, promise);
started += 1; started += 1;
if (this.isSpeechItem(nextItem)) {
spokenPrepared += 1;
}
if (spokenPrepared >= 1 && started >= 2) {
break;
}
} }
if (started === 0) { if (started === 0) {
@@ -1409,7 +1467,9 @@ class SentenceQueueModule extends BaseModule {
this.cancelGenerationRequests('sentence-queue-cleared'); this.cancelGenerationRequests('sentence-queue-cleared');
this.cancelAssetPreloads('sentence-queue-cleared'); this.cancelAssetPreloads('sentence-queue-cleared');
this.prefetchingSpeech.clear(); this.prefetchingSpeech.clear();
this.prefetchingWebGLBook.clear();
this.preparedSentenceCache.clear(); this.preparedSentenceCache.clear();
this.webglBookPrepareChain = Promise.resolve();
this.pauseBeforeNextReason = null; this.pauseBeforeNextReason = null;
document.dispatchEvent(new CustomEvent('tts:queue-empty', { document.dispatchEvent(new CustomEvent('tts:queue-empty', {
detail: { reason: 'sentence-queue-cleared' } detail: { reason: 'sentence-queue-cleared' }
+3 -2
View File
@@ -1023,9 +1023,10 @@ class UIDisplayHandlerModule extends BaseModule {
this.revealImageBlock(element); this.revealImageBlock(element);
} else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') { } else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') {
if (useWebGLBookReveal) { if (useWebGLBookReveal) {
await this.prepareWebGLBookReveal(sentence); await this.playWebGLBookSentence(sentence);
} else {
await this.playbackCoordinator.play(sentence);
} }
await this.playbackCoordinator.play(sentence);
if (useWebGLBookReveal && sentence.blockId != null) { if (useWebGLBookReveal && sentence.blockId != null) {
this.markBlockRendered(sentence.blockId); this.markBlockRendered(sentence.blockId);
} }
+83 -14
View File
@@ -140,6 +140,7 @@ const dynamicBufferRefreshIntervalMs = 1000 / 30;
// frames are just the cheap scene render and hold 60fps. Candle flicker is the only thing // frames are just the cheap scene render and hold 60fps. Candle flicker is the only thing
// changing them then, which 8Hz captures imperceptibly. // changing them then, which 8Hz captures imperceptibly.
const staticGeometryBufferRefreshIntervalMs = 1000 / 8; const staticGeometryBufferRefreshIntervalMs = 1000 / 8;
const revealGeometryBufferRefreshIntervalMs = 1000 / 4;
const flipDynamicBufferGraceMs = 180; const flipDynamicBufferGraceMs = 180;
let lastBookShadowRefreshAt = -Infinity; let lastBookShadowRefreshAt = -Infinity;
let lastTableReflectionRefreshAt = -Infinity; let lastTableReflectionRefreshAt = -Infinity;
@@ -184,6 +185,8 @@ const lastFrameTiming = {};
const slowFrameLog = []; const slowFrameLog = [];
const loaderTimings = {}; const loaderTimings = {};
const pageTextureTimings = []; const pageTextureTimings = [];
let queuedNavigationPrewarm = null;
let queuedNavigationPrewarmHandle = null;
function markLoaderTiming(name) { function markLoaderTiming(name) {
loaderTimings[name] = performance.now(); loaderTimings[name] = performance.now();
@@ -385,6 +388,10 @@ const materials = {
}), }),
flipPageSurface: new THREE.MeshStandardMaterial({ flipPageSurface: new THREE.MeshStandardMaterial({
color: 0xeee6cc, color: 0xeee6cc,
map: getBlankPageTexture(),
normalMap: paperTextures.normal,
normalScale: new THREE.Vector2(0.004, 0.004),
roughnessMap: paperTextures.roughness,
roughness: 0.92, roughness: 0.92,
metalness: 0, metalness: 0,
emissive: 0x100d08, emissive: 0x100d08,
@@ -440,6 +447,8 @@ const materials = {
}; };
materials.flipPageBackSurface = materials.flipPageSurface.clone(); materials.flipPageBackSurface = materials.flipPageSurface.clone();
materials.flipPageBackSurface.map = getBlankPageTexture(); materials.flipPageBackSurface.map = getBlankPageTexture();
materials.flipPageBackSurface.normalMap = paperTextures.normal;
materials.flipPageBackSurface.roughnessMap = paperTextures.roughness;
materials.flipPageBackSurface.side = THREE.FrontSide; materials.flipPageBackSurface.side = THREE.FrontSide;
materials.flipPageEdge = materials.pageSurface.clone(); materials.flipPageEdge = materials.pageSurface.clone();
materials.flipPageEdge.map = paperTextures.edge; materials.flipPageEdge.map = paperTextures.edge;
@@ -622,6 +631,15 @@ window.BookLabDebug = {
requestPageFlip(direction = 1, options = {}) { requestPageFlip(direction = 1, options = {}) {
return startPageFlip(direction, options); return startPageFlip(direction, options);
}, },
async prewarmPageFlip(direction = 1, options = {}) {
const targetSpread = Number.isFinite(Number(options.targetSpread))
? Math.max(0, Math.round(Number(options.targetSpread)))
: null;
return prewarmFlipTextures(direction, targetSpread);
},
startPreparedPageFlip(direction = 1, options = {}) {
return startPageFlipPrepared(direction, options);
},
getRevealDebugState() { getRevealDebugState() {
return getRevealDebugState(); return getRevealDebugState();
}, },
@@ -680,7 +698,9 @@ if (webglBookSceneModule) {
setPageReserve: (value) => setPageReserve(value), setPageReserve: (value) => setPageReserve(value),
setMaxVisitedPagePosition: (value) => window.BookLabDebug.setMaxVisitedPagePosition(value), setMaxVisitedPagePosition: (value) => window.BookLabDebug.setMaxVisitedPagePosition(value),
redrawPageTextures: () => window.BookLabDebug.redrawPageTextures(), redrawPageTextures: () => window.BookLabDebug.redrawPageTextures(),
projectPointerToPage: (clientX, clientY) => projectPointerToPage(clientX, clientY) projectPointerToPage: (clientX, clientY) => projectPointerToPage(clientX, clientY),
prewarmPageFlip: (direction = 1, options = {}) => window.BookLabDebug.prewarmPageFlip(direction, options),
startPreparedPageFlip: (direction = 1, options = {}) => window.BookLabDebug.startPreparedPageFlip(direction, options)
}; };
} }
@@ -716,6 +736,15 @@ document.addEventListener('story:client-reset', () => {
pageRevealFreezeAt = null; pageRevealFreezeAt = null;
clearPageReveal('left', 'client-reset'); clearPageReveal('left', 'client-reset');
clearPageReveal('right', 'client-reset'); clearPageReveal('right', 'client-reset');
// Return the book to the title spread so the new game's first block flips in from the
// title page. Otherwise the view stays on the previous game's spread, the segment's
// source and target spread match, and the title->content page turn is skipped.
if (Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))) !== 0) {
bookPaginationState = { ...bookPaginationState, spreadIndex: 0 };
const titleSpread = getPaginationSpread(0);
if (titleSpread) window.BookTextureRenderer?.drawSpread?.(titleSpread, ['left', 'right'], { force: true });
syncBookControls();
}
}); });
// Pagination spread updates only carry state. The playback owner decides when the // Pagination spread updates only carry state. The playback owner decides when the
// visible spread changes (via flips). The scene jumps directly only for non-playback // visible spread changes (via flips). The scene jumps directly only for non-playback
@@ -2232,12 +2261,7 @@ function handlePageTextureRecords(event) {
source: 'book-texture-renderer' source: 'book-texture-renderer'
}); });
markPageTextureTiming('handlePageTextureRecords:end'); markPageTextureTiming('handlePageTextureRecords:end');
prewarmNavigationTextureWindow('page-texture-records', { recordMiss: false }).catch((error) => { scheduleNavigationTextureWindowPrewarm('page-texture-records', { recordMiss: false });
pageTextureStore?.recordProblem?.({
type: 'navigation-window-prewarm-error',
message: error?.message || String(error)
});
});
} }
function normalizePageTextureRecordDetail(detail = {}) { function normalizePageTextureRecordDetail(detail = {}) {
@@ -2441,6 +2465,35 @@ async function prewarmNavigationTextureWindow(reason = 'navigation-window', opti
return result || {}; return result || {};
} }
function scheduleNavigationTextureWindowPrewarm(reason = 'navigation-window', options = {}) {
queuedNavigationPrewarm = {
reason,
options: { ...(options || {}) }
};
if (queuedNavigationPrewarmHandle !== null) return;
const run = () => {
queuedNavigationPrewarmHandle = null;
const queued = queuedNavigationPrewarm;
queuedNavigationPrewarm = null;
if (!queued) return;
if (activeFlips.length > 0 || hasActivePageReveal()) {
scheduleNavigationTextureWindowPrewarm(queued.reason, queued.options);
return;
}
prewarmNavigationTextureWindow(queued.reason, queued.options).catch((error) => {
pageTextureStore?.recordProblem?.({
type: 'navigation-window-prewarm-error',
message: error?.message || String(error)
});
});
};
if (typeof window.requestIdleCallback === 'function') {
queuedNavigationPrewarmHandle = window.requestIdleCallback(run, { timeout: 350 });
} else {
queuedNavigationPrewarmHandle = window.setTimeout(run, 80);
}
}
async function prewarmFlipTextures(direction, targetSpread = null) { async function prewarmFlipTextures(direction, targetSpread = null) {
const currentSpread = Math.max(0, Number(bookPaginationState.spreadIndex || 0)); const currentSpread = Math.max(0, Number(bookPaginationState.spreadIndex || 0));
const nextSpread = Number.isFinite(Number(targetSpread)) const nextSpread = Number.isFinite(Number(targetSpread))
@@ -2545,10 +2598,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? pageTextureStore?.createTextureFromCanvas?.(revealDetail.baseCanvas) : null); const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? pageTextureStore?.createTextureFromCanvas?.(revealDetail.baseCanvas) : null);
const revealBlockIds = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds.map(value => String(value)) : []; const revealBlockIds = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds.map(value => String(value)) : [];
const activeStartedAt = revealBlockIds const activeStartedAt = getRevealStartTimeForBlockIds(revealBlockIds);
.map(blockId => activeRevealBlockStarts.get(blockId))
.filter(value => Number.isFinite(Number(value)))
.sort((a, b) => a - b)[0] ?? null;
pageRevealState[side] = { pageRevealState[side] = {
startedAt: activeStartedAt ?? (revealDetail.startNow ? performance.now() : null), startedAt: activeStartedAt ?? (revealDetail.startNow ? performance.now() : null),
@@ -2600,6 +2650,22 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
markPageTextureTiming('revealUpload:end', { side }); markPageTextureTiming('revealUpload:end', { side });
} }
function getRevealStartTimeForBlockIds(blockIds = []) {
const startedAt = (Array.isArray(blockIds) ? blockIds : [])
.map(blockId => activeRevealBlockStarts.get(String(blockId)))
.filter(value => Number.isFinite(Number(value)))
.sort((a, b) => a - b)[0] ?? null;
if (startedAt !== null) return startedAt;
const pendingBlockId = (Array.isArray(blockIds) ? blockIds : [])
.map(blockId => String(blockId))
.find(blockId => pendingRevealStartBlockIds.has(blockId));
if (!pendingBlockId) return null;
const now = performance.now();
activeRevealBlockStarts.set(pendingBlockId, now);
pendingRevealStartBlockIds.delete(pendingBlockId);
return now;
}
function applyPendingPageReveal(side, shader = getPageRevealShader(side)) { function applyPendingPageReveal(side, shader = getPageRevealShader(side)) {
const material = side === 'left' ? materials.leftPage : materials.rightPage; const material = side === 'left' ? materials.leftPage : materials.rightPage;
const revealDetail = material?.userData?.pendingPageReveal; const revealDetail = material?.userData?.pendingPageReveal;
@@ -3141,8 +3207,6 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap; materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
materials.flipPageBackSurface.roughnessMap = materials.pageSurface.roughnessMap; materials.flipPageBackSurface.roughnessMap = materials.pageSurface.roughnessMap;
materials.flipPageSurface.needsUpdate = true;
materials.flipPageBackSurface.needsUpdate = true;
syncFlipRevealShaderFromSource(sourceSide, materials.flipPageSurface); syncFlipRevealShaderFromSource(sourceSide, materials.flipPageSurface);
syncFlipRevealShaderFromSource(targetBackSide, materials.flipPageBackSurface); syncFlipRevealShaderFromSource(targetBackSide, materials.flipPageBackSurface);
flip.sourceTexture = sourceTexture; flip.sourceTexture = sourceTexture;
@@ -4733,7 +4797,12 @@ function animate(now = performance.now()) {
: Infinity; : Infinity;
const deferDynamicBuffersForFlipStart = activeFlips.length > 0 && newestFlipAge < flipDynamicBufferGraceMs; const deferDynamicBuffersForFlipStart = activeFlips.length > 0 && newestFlipAge < flipDynamicBufferGraceMs;
const geometryAnimating = activeFlips.length > 0; const geometryAnimating = activeFlips.length > 0;
const bufferRefreshIntervalMs = geometryAnimating ? dynamicBufferRefreshIntervalMs : staticGeometryBufferRefreshIntervalMs; const revealAnimating = hasActivePageReveal();
const bufferRefreshIntervalMs = geometryAnimating
? dynamicBufferRefreshIntervalMs
: revealAnimating
? revealGeometryBufferRefreshIntervalMs
: staticGeometryBufferRefreshIntervalMs;
const shadowRefreshDue = !deferDynamicBuffersForFlipStart && ( const shadowRefreshDue = !deferDynamicBuffersForFlipStart && (
forceDynamicBufferRefresh || now - lastBookShadowRefreshAt >= bufferRefreshIntervalMs forceDynamicBufferRefresh || now - lastBookShadowRefreshAt >= bufferRefreshIntervalMs
); );
+3 -3
View File
@@ -365,14 +365,14 @@ class WebGLBookSceneModule extends BaseModule {
async initializeScene() { async initializeScene() {
if (this.labImportPromise) return this.labImportPromise; if (this.labImportPromise) return this.labImportPromise;
const cacheBuster = window.MODULE_CACHE_BUSTER || Date.now(); const moduleVersion = window.MODULE_CACHE_BUSTER || 'dev';
this.labImportPromise = import(`/js/webgl-book-lab.js?v=${encodeURIComponent(cacheBuster)}`); this.labImportPromise = import(`/js/webgl-book-lab.js?v=${encodeURIComponent(moduleVersion)}`);
await this.labImportPromise; await this.labImportPromise;
this.reportProgress(94, 'Uploading initial book page textures'); this.reportProgress(94, 'Uploading initial book page textures');
const pagination = this.getModule('book-pagination'); const pagination = this.getModule('book-pagination');
const initialSpread = pagination?.getCurrentSpread?.(); const initialSpread = pagination?.getCurrentSpread?.();
if (initialSpread && typeof window.BookTextureRenderer?.drawSpread === 'function') { if (initialSpread && typeof window.BookTextureRenderer?.drawSpread === 'function') {
window.BookTextureRenderer.drawSpread(initialSpread, ['left', 'right'], { force: true }); await window.BookTextureRenderer.drawSpread(initialSpread, ['left', 'right'], { force: true });
} else { } else {
window.BookTextureRenderer?.publishSpread?.(); window.BookTextureRenderer?.publishSpread?.();
} }
+13 -4
View File
@@ -129,6 +129,7 @@ const checks = [
['webgl lab exposes reveal uniform diagnostics', /getRevealDebugState/.test(source) && /bookRevealActive/.test(source) && /bookRevealElapsedMs/.test(source) && /bookRevealRegionCount/.test(source)], ['webgl lab exposes reveal uniform diagnostics', /getRevealDebugState/.test(source) && /bookRevealActive/.test(source) && /bookRevealElapsedMs/.test(source) && /bookRevealRegionCount/.test(source)],
['webgl lab records page reveal clear reasons', /clearPageReveal\(side, reason/.test(source) && /webglRevealClearLog/.test(source)], ['webgl lab records page reveal clear reasons', /clearPageReveal\(side, reason/.test(source) && /webglRevealClearLog/.test(source)],
['webgl reveal clock starts on first render frame', /pendingStart/.test(source) && /state\.pendingStart/.test(source) && /state\.startedAt = now/.test(source)], ['webgl reveal clock starts on first render frame', /pendingStart/.test(source) && /state\.pendingStart/.test(source) && /state\.startedAt = now/.test(source)],
['webgl reveal start survives event-before-state ordering', /function getRevealStartTimeForBlockIds/.test(source) && /activeRevealBlockStarts\.set\(pendingBlockId, now\)/.test(source) && /pendingRevealStartBlockIds\.delete\(pendingBlockId\)/.test(source)],
['webgl reveal visual clock is derived from absolute playback time', /visualElapsedMs/.test(source) && /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/revealFrameDeltaMs/.test(source)], ['webgl reveal visual clock is derived from absolute playback time', /visualElapsedMs/.test(source) && /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/revealFrameDeltaMs/.test(source)],
['webgl fast-forward accelerates reveal instead of clearing the mask immediately', /fastForwarding/.test(source) && /fastForwardDurationMs/.test(source) && !/clearPageReveal\(side, 'fast-forward'\)/.test(source)], ['webgl fast-forward accelerates reveal instead of clearing the mask immediately', /fastForwarding/.test(source) && /fastForwardDurationMs/.test(source) && !/clearPageReveal\(side, 'fast-forward'\)/.test(source)],
['webgl lab records page texture binding timings', /pageTextureTimings/.test(source) && /markPageTextureTiming/.test(source) && /webglPageTextureTimings/.test(source)], ['webgl lab records page texture binding timings', /pageTextureTimings/.test(source) && /markPageTextureTiming/.test(source) && /webglPageTextureTimings/.test(source)],
@@ -139,10 +140,13 @@ const checks = [
['texture renderer diagnostics include reveal region counts', /regionCounts/.test(textureRendererSource) && /lineRects/.test(textureRendererSource) && /durationMs/.test(textureRendererSource)], ['texture renderer diagnostics include reveal region counts', /regionCounts/.test(textureRendererSource) && /lineRects/.test(textureRendererSource) && /durationMs/.test(textureRendererSource)],
['texture renderer does not draw immediate non-pending sides during pending reveal preparation', !/immediateSides/.test(textureRendererSource) && !/this\.drawSpread\(this\.currentSpread, immediateSides\)/.test(textureRendererSource)], ['texture renderer does not draw immediate non-pending sides during pending reveal preparation', !/immediateSides/.test(textureRendererSource) && !/this\.drawSpread\(this\.currentSpread, immediateSides\)/.test(textureRendererSource)],
['sentence queue consumes completed prepared lookahead items', /preparedSentenceCache/.test(sentenceQueueSource) && /this\.preparedSentenceCache\.get\(cacheKey\)/.test(sentenceQueueSource) && /return prefetched/.test(sentenceQueueSource)], ['sentence queue consumes completed prepared lookahead items', /preparedSentenceCache/.test(sentenceQueueSource) && /this\.preparedSentenceCache\.get\(cacheKey\)/.test(sentenceQueueSource) && /return prefetched/.test(sentenceQueueSource)],
['sentence queue front-loads 3D book presentation before playback callback', /const sentence = await this\.getPreparedSentence\(item\);[\s\S]*await this\.prefetchWebGLBookPresentation\(sentence[\s\S]*immediate: true[\s\S]*this\.prefetchAhead\(6, queueGeneration\);[\s\S]*this\.onSentenceReadyCallback/.test(sentenceQueueSource)], ['sentence queue starts future lookahead only after current display playback is entered', /const sentence = await this\.getPreparedSentence\(item\);[\s\S]*await this\.prefetchWebGLBookPresentation\(sentence[\s\S]*immediate: true[\s\S]*const playbackFinished = new Promise/.test(sentenceQueueSource) && /this\.onSentenceReadyCallback\(sentence, resolve\);[\s\S]*window\.requestAnimationFrame\(\(\) => \{[\s\S]*this\.prefetchAhead\(6, queueGeneration\);[\s\S]*await playbackFinished/.test(sentenceQueueSource)],
['sentence queue prefetch prepares whole future sentence instead of speech metadata only', /this\.prepareSentence\(nextItem, \{\s*blocking: false/.test(sentenceQueueSource) && /this\.prefetchWebGLBookPresentation\(prepared/.test(sentenceQueueSource)], ['sentence queue prefetch prepares whole future sentence instead of speech metadata only', /this\.prepareSentence\(nextItem, \{\s*blocking: false/.test(sentenceQueueSource) && /this\.prefetchWebGLBookPresentation\(prepared/.test(sentenceQueueSource)],
['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(6, this\.queueGeneration\);/.test(sentenceQueueSource)], ['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(6, this\.queueGeneration\);/.test(sentenceQueueSource)],
['sentence queue keeps current 3D page prep immediate while future lookahead yields cooperatively', /if \(!options\.immediate\) \{[\s\S]*requestIdleCallback[\s\S]*timeout: 80/.test(sentenceQueueSource) && /prefetchAhead\(maxLookahead = 6/.test(sentenceQueueSource)], ['sentence queue keeps current 3D page prep immediate while future lookahead yields cooperatively', /if \(!options\.immediate\) \{[\s\S]*requestIdleCallback[\s\S]*timeout: 80/.test(sentenceQueueSource) && /prefetchAhead\(maxLookahead = 6/.test(sentenceQueueSource)],
['sentence queue serializes heavy WebGL book preparation separately from speech prefetch', /prefetchingWebGLBook = new Map/.test(sentenceQueueSource) && /webglBookPrepareChain = Promise\.resolve\(\)/.test(sentenceQueueSource) && /this\.webglBookPrepareChain[\s\S]*\.then\(\(\) => this\.runWebGLBookPresentationPrepare/.test(sentenceQueueSource)],
['sentence queue caps WebGL book lookahead without capping TTS lookahead window', /const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 2/.test(sentenceQueueSource) && /webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource) && !/spokenPrepared >= 1 && started >= 2/.test(sentenceQueueSource)],
['sentence queue gates WebGL book lookahead to active 3D playback only', /const allowWebGLBookPrefetch = document\.documentElement\.dataset\.webglBookPlaybackActive === 'true'/.test(sentenceQueueSource) && /const shouldPrepareWebGLBook = allowWebGLBookPrefetch[\s\S]*&& webglBookCandidate[\s\S]*&& webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource)],
['pagination can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)], ['pagination can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)],
['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)], ['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)],
['texture renderer stores prepared reveal plans in the shared texture store', !/preparedRevealCache/.test(textureRendererSource) && /rememberPreparedRevealPlan/.test(webglPageCacheSource) && /takePreparedRevealPlan/.test(textureRendererSource) && /publishPreparedReveal/.test(textureRendererSource) && !/hasPreparedRevealBlock/.test(textureRendererSource)], ['texture renderer stores prepared reveal plans in the shared texture store', !/preparedRevealCache/.test(textureRendererSource) && /rememberPreparedRevealPlan/.test(webglPageCacheSource) && /takePreparedRevealPlan/.test(textureRendererSource) && /publishPreparedReveal/.test(textureRendererSource) && !/hasPreparedRevealBlock/.test(textureRendererSource)],
@@ -161,7 +165,7 @@ const checks = [
['webgl debug test hook awaits the same async page flip path', /startPageFlipForTest\(direction, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)], ['webgl debug test hook awaits the same async page flip path', /startPageFlipForTest\(direction, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)],
['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)], ['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)],
['sentence queue skips duplicate current-item 3D book presentation when reveal is cached', /isWebGLBookPresentationPrepared/.test(sentenceQueueSource) && /if \(!this\.isWebGLBookPresentationPrepared\(sentence\)\) \{\s*await this\.prefetchWebGLBookPresentation/.test(sentenceQueueSource) && /sentence\.webglBookPresentation = \{\s*prepared: true/.test(sentenceQueueSource)], ['sentence queue skips duplicate current-item 3D book presentation when reveal is cached', /isWebGLBookPresentationPrepared/.test(sentenceQueueSource) && /if \(!this\.isWebGLBookPresentationPrepared\(sentence\)\) \{\s*await this\.prefetchWebGLBookPresentation/.test(sentenceQueueSource) && /sentence\.webglBookPresentation = \{\s*prepared: true/.test(sentenceQueueSource)],
['3D overflow reveal commits the spread then requests a timeline flip via event before activating', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.commitSegmentSpread\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /dispatchEvent\(new CustomEvent\('webgl-book:request-page-flip'/.test(bookPlaybackTimelineSource) && /addEventListener\('webgl-book:request-page-flip'/.test(source) && /startPageFlip\(direction, \{/.test(source)], ['3D overflow reveal commits the spread then starts a prepared timeline flip before activating', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.commitSegmentSpread\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /sceneControl\.prewarmPageFlip/.test(bookPlaybackTimelineSource) && /sceneControl\.startPreparedPageFlip/.test(bookPlaybackTimelineSource) && !/dispatchEvent\(new CustomEvent\('webgl-book:request-page-flip'/.test(bookPlaybackTimelineSource) && /prewarmPageFlip: \(direction = 1, options = \{\}\)/.test(source) && /startPreparedPageFlip: \(direction = 1, options = \{\}\)/.test(source)],
['texture worker paints inline bold and italic styles off the main thread', /getInlineStyleState/.test(textureWorkerSource) && /updateInlineStyleState/.test(textureWorkerSource) && /getCanvasFont/.test(textureWorkerSource) && /segment\.style/.test(textureWorkerSource) && !/drawLine\(ctx/.test(textureRendererSource)], ['texture worker paints inline bold and italic styles off the main thread', /getInlineStyleState/.test(textureWorkerSource) && /updateInlineStyleState/.test(textureWorkerSource) && /getCanvasFont/.test(textureWorkerSource) && /segment\.style/.test(textureWorkerSource) && !/drawLine\(ctx/.test(textureRendererSource)],
['texture renderer delegates page rasterization to an OffscreenCanvas worker and blits the result', /book-texture-worker\.js/.test(textureRendererSource) && /rasterizeSpread/.test(textureRendererSource) && /ctx\.drawImage\(result\.pageBitmap, 0, 0\)/.test(textureRendererSource) && /OffscreenCanvas/.test(textureWorkerSource) && /createImageBitmap/.test(textureWorkerSource)], ['texture renderer delegates page rasterization to an OffscreenCanvas worker and blits the result', /book-texture-worker\.js/.test(textureRendererSource) && /rasterizeSpread/.test(textureRendererSource) && /ctx\.drawImage\(result\.pageBitmap, 0, 0\)/.test(textureRendererSource) && /OffscreenCanvas/.test(textureWorkerSource) && /createImageBitmap/.test(textureWorkerSource)],
['texture renderer recovers from worker error/timeout so a draw promise never hangs the chain', /this\.rasterWorker\.onerror/.test(textureRendererSource) && /texture-worker-timeout/.test(textureRendererSource) && /settleRasterization/.test(textureRendererSource) && /clearTimeout\(pending\.timer\)/.test(textureRendererSource)], ['texture renderer recovers from worker error/timeout so a draw promise never hangs the chain', /this\.rasterWorker\.onerror/.test(textureRendererSource) && /texture-worker-timeout/.test(textureRendererSource) && /settleRasterization/.test(textureRendererSource) && /clearTimeout\(pending\.timer\)/.test(textureRendererSource)],
@@ -174,7 +178,7 @@ const checks = [
['webgl page format reduces only outer margins from previous value', /outerBaseIn: 0\.27/.test(pageFormatSource) && /outerThicknessFactor: 0\.015/.test(pageFormatSource) && /outerMaxIn: 0\.315/.test(pageFormatSource) && /innerBaseIn: 0\.42/.test(pageFormatSource)], ['webgl page format reduces only outer margins from previous value', /outerBaseIn: 0\.27/.test(pageFormatSource) && /outerThicknessFactor: 0\.015/.test(pageFormatSource) && /outerMaxIn: 0\.315/.test(pageFormatSource) && /innerBaseIn: 0\.42/.test(pageFormatSource)],
['webgl mode enlarges and inverts DOM overlay text without touching 2D mode', /body\.webgl-mode \{[\s\S]*font-size: 18px;/.test(styleSource) && /body\.webgl-mode \.choice-list \.choice-button/.test(styleSource) && /rgba\(246, 231, 201/.test(styleSource)], ['webgl mode enlarges and inverts DOM overlay text without touching 2D mode', /body\.webgl-mode \{[\s\S]*font-size: 18px;/.test(styleSource) && /body\.webgl-mode \.choice-list \.choice-button/.test(styleSource) && /rgba\(246, 231, 201/.test(styleSource)],
['webgl choice overlay hides title clutter and prevents horizontal scrollbar', /body\.webgl-mode #page_left #game_title/.test(styleSource) && /body\.webgl-mode #page_left #start_prompt/.test(styleSource) && /overflow-x: hidden/.test(styleSource) && /book\.style\.width = 'min\(44rem/.test(webglSceneSource)], ['webgl choice overlay hides title clutter and prevents horizontal scrollbar', /body\.webgl-mode #page_left #game_title/.test(styleSource) && /body\.webgl-mode #page_left #start_prompt/.test(styleSource) && /overflow-x: hidden/.test(styleSource) && /book\.style\.width = 'min\(44rem/.test(webglSceneSource)],
['3D live text bypasses #page_right DOM rendering and uses book texture reveal directly', /const useWebGLBookReveal = this\.isWebGLMode\(\) && \(sentence\.kind === 'paragraph' \|\| sentence\.kind === 'heading'\)/.test(uiDisplayHandlerSource) && /if \(useWebGLBookReveal\) \{[\s\S]*await this\.prepareWebGLBookReveal\(sentence\);[\s\S]*await this\.playbackCoordinator\.play\(sentence\);[\s\S]*this\.markBlockRendered\(sentence\.blockId\);[\s\S]*return null;/.test(uiDisplayHandlerSource)], ['3D live text bypasses #page_right DOM rendering and uses the timeline-owned book reveal directly', /const useWebGLBookReveal = this\.isWebGLMode\(\) && \(sentence\.kind === 'paragraph' \|\| sentence\.kind === 'heading'\)/.test(uiDisplayHandlerSource) && /if \(useWebGLBookReveal\) \{[\s\S]*await this\.playWebGLBookSentence\(sentence\);[\s\S]*this\.markBlockRendered\(sentence\.blockId\);[\s\S]*return null;/.test(uiDisplayHandlerSource) && !/if \(useWebGLBookReveal\) \{[\s\S]*await this\.prepareWebGLBookReveal\(sentence\);[\s\S]*await this\.playbackCoordinator\.play\(sentence\);[\s\S]*return null;/.test(uiDisplayHandlerSource)],
['drop-cap remaining text does not reinsert discretionary hyphen markers', /extractRemainingLayoutText/.test(bookPaginationSource) && !bookPaginationSource.includes("fragments.push('|')")], ['drop-cap remaining text does not reinsert discretionary hyphen markers', /extractRemainingLayoutText/.test(bookPaginationSource) && !bookPaginationSource.includes("fragments.push('|')")],
['drop-cap reservation keeps a normal text gap beside the initial', /measureDropCapReservation/.test(bookPaginationSource) && /measureNormalTextGap\(fontPx\)/.test(bookPaginationSource)], ['drop-cap reservation keeps a normal text gap beside the initial', /measureDropCapReservation/.test(bookPaginationSource) && /measureNormalTextGap\(fontPx\)/.test(bookPaginationSource)],
['drop-cap reservation uses both ink bounds and font advance width', /const advanceWidth = metrics\.width \|\| 0/.test(bookPaginationSource) && /Math\.max\(inkRight, advanceWidth, lineHeightPx \* 1\.08\)/.test(bookPaginationSource)], ['drop-cap reservation uses both ink bounds and font advance width', /const advanceWidth = metrics\.width \|\| 0/.test(bookPaginationSource) && /Math\.max\(inkRight, advanceWidth, lineHeightPx \* 1\.08\)/.test(bookPaginationSource)],
@@ -197,6 +201,7 @@ const checks = [
['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)],
['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /const topMaterialIndex = direction > 0 \? 1 : 0/.test(source) && /const bottomMaterialIndex = direction > 0 \? 0 : 1/.test(source) && /geometry\.addGroup\(0, topIndices\.length, topMaterialIndex\)/.test(source) && /geometry\.addGroup\(topIndices\.length, bottomIndices\.length, bottomMaterialIndex\)/.test(source)], ['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /const topMaterialIndex = direction > 0 \? 1 : 0/.test(source) && /const bottomMaterialIndex = direction > 0 \? 0 : 1/.test(source) && /geometry\.addGroup\(0, topIndices\.length, topMaterialIndex\)/.test(source) && /geometry\.addGroup\(topIndices\.length, bottomIndices\.length, bottomMaterialIndex\)/.test(source)],
['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture \|\| getBlankPageTexture\(\)/.test(source)], ['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture \|\| getBlankPageTexture\(\)/.test(source)],
['webgl flip page material variants are compiled during loader, not at first texture swap', /flipPageSurface: new THREE\.MeshStandardMaterial\(\{[\s\S]*map: getBlankPageTexture\(\),[\s\S]*normalMap: paperTextures\.normal,[\s\S]*roughnessMap: paperTextures\.roughness/.test(source) && !/materials\.flipPageSurface\.needsUpdate = true/.test(methodBody(source, 'prepareStaticPageForFlip')) && !/materials\.flipPageBackSurface\.needsUpdate = true/.test(methodBody(source, 'prepareStaticPageForFlip'))],
['webgl flip preflight exposes texture side and orientation invariants for browser tests', /lastFlipTexturePreflight/.test(source) && /sourceTextureMatchesBackTexture/.test(source) && /targetBackSide/.test(source) && /getRuntimeInvariants/.test(source)], ['webgl flip preflight exposes texture side and orientation invariants for browser tests', /lastFlipTexturePreflight/.test(source) && /sourceTextureMatchesBackTexture/.test(source) && /targetBackSide/.test(source) && /getRuntimeInvariants/.test(source)],
['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - pageU : pageU/.test(source) && /y: v/.test(source)], ['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - pageU : pageU/.test(source) && /y: v/.test(source)],
['webgl animated page UVs use the same fore-edge inset as the visible stack cap', /PAGE_TEXTURE_FORE_EDGE_INSET_RATIO/.test(source) && /const pageU = THREE\.MathUtils\.clamp\(u \/ Math\.max\(0\.0001, 1 - inset\), 0, 1\)/.test(source)], ['webgl animated page UVs use the same fore-edge inset as the visible stack cap', /PAGE_TEXTURE_FORE_EDGE_INSET_RATIO/.test(source) && /const pageU = THREE\.MathUtils\.clamp\(u \/ Math\.max\(0\.0001, 1 - inset\), 0, 1\)/.test(source)],
@@ -204,6 +209,8 @@ const checks = [
['webgl flip prewarm prepares current and target spread texture records before cache lookup', /prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /prepareSpreadTextureRecordsForFlip\(nextSpread\)/.test(source) && /function prepareSpreadTextureRecordsForFlip/.test(source) && /spreadTextureRecordsReady\(spread\)/.test(source) && /window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\], \{[\s\S]*phase: 'prepare'/.test(source)], ['webgl flip prewarm prepares current and target spread texture records before cache lookup', /prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /prepareSpreadTextureRecordsForFlip\(nextSpread\)/.test(source) && /function prepareSpreadTextureRecordsForFlip/.test(source) && /spreadTextureRecordsReady\(spread\)/.test(source) && /window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\], \{[\s\S]*phase: 'prepare'/.test(source)],
['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))], ['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))],
['webgl scene targets 60fps with browser-frame scheduling and staggered live mirror refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /const minRenderFrameIntervalMs = targetFrameDurationMs \* 0\.5/.test(source) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /const dynamicBufferRefreshIntervalMs = 1000 \/ 30/.test(source) && /const flipDynamicBufferGraceMs = 180/.test(source) && /const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue/.test(source) && /const refreshReflectionThisFrame/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesAtFps/.test(source) && !/setTimeout\(animate/.test(source)], ['webgl scene targets 60fps with browser-frame scheduling and staggered live mirror refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /const minRenderFrameIntervalMs = targetFrameDurationMs \* 0\.5/.test(source) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /const dynamicBufferRefreshIntervalMs = 1000 \/ 30/.test(source) && /const flipDynamicBufferGraceMs = 180/.test(source) && /const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue/.test(source) && /const refreshReflectionThisFrame/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesAtFps/.test(source) && !/setTimeout\(animate/.test(source)],
['webgl reveal playback throttles dynamic buffers without freezing mirror permanently', /const revealGeometryBufferRefreshIntervalMs = 1000 \/ 4/.test(source) && /const revealAnimating = hasActivePageReveal\(\)/.test(source) && /revealAnimating[\s\S]*revealGeometryBufferRefreshIntervalMs/.test(source)],
['webgl navigation texture prewarm yields until reveal and flip critical frames are clear', /function scheduleNavigationTextureWindowPrewarm/.test(source) && /requestIdleCallback/.test(source) && /activeFlips\.length > 0 \|\| hasActivePageReveal\(\)/.test(source) && /scheduleNavigationTextureWindowPrewarm\('page-texture-records'/.test(source)],
['texture renderer has no private reveal clock (scene render loop is the single clock)', !/this\.targetFrameDurationMs/.test(textureRendererSource) && !/tickAnimations/.test(textureRendererSource) && !/requestAnimationFrame/.test(textureRendererSource)], ['texture renderer has no private reveal clock (scene render loop is the single clock)', !/this\.targetFrameDurationMs/.test(textureRendererSource) && !/tickAnimations/.test(textureRendererSource) && !/requestAnimationFrame/.test(textureRendererSource)],
['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 0\.72/.test(source) && /const tableReflectionBaseWidth = 1536/.test(source) && /const tableReflectionBaseHeight = 864/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)], ['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 0\.72/.test(source) && /const tableReflectionBaseWidth = 1536/.test(source) && /const tableReflectionBaseHeight = 864/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)],
['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesAtFps/.test(source) && /mirrorDefersDuringFlipStartMs/.test(source)], ['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesAtFps/.test(source) && /mirrorDefersDuringFlipStartMs/.test(source)],
@@ -224,7 +231,7 @@ const checks = [
['webgl autoplay flip source prefers currently revealing visible material over resident cache', /if \(revealStateMatchesPage\(side, pageMeta\)\) return material\?\.map \|\| null/.test(source) && /revealStateMatchesPage\(sourceSide, sourcePageMeta\) \? sourceSide : null/.test(source)], ['webgl autoplay flip source prefers currently revealing visible material over resident cache', /if \(revealStateMatchesPage\(side, pageMeta\)\) return material\?\.map \|\| null/.test(source) && /revealStateMatchesPage\(sourceSide, sourcePageMeta\) \? sourceSide : null/.test(source)],
['webgl flipping page materials mirror active reveal shader uniforms on both sides', /materials\.flipPageSurface\.userData\.bookPageReveal/.test(source) && /syncFlipRevealShaderFromSource/.test(source) && /bookRevealRegionRects/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide === side/.test(source) && /revealStateMatchesPage\(targetBackSide, targetBackPageMeta\) \? targetBackSide : null/.test(source)], ['webgl flipping page materials mirror active reveal shader uniforms on both sides', /materials\.flipPageSurface\.userData\.bookPageReveal/.test(source) && /syncFlipRevealShaderFromSource/.test(source) && /bookRevealRegionRects/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide === side/.test(source) && /revealStateMatchesPage\(targetBackSide, targetBackPageMeta\) \? targetBackSide : null/.test(source)],
['webgl prepared texture records do not mutate the visible page metadata', /const incomingPageMeta = detail\.pageMeta/.test(source) && /if \(detail\.phase !== 'prepare' && detail\.pageMeta\) \{[\s\S]*currentPageMeta = incomingPageMeta/.test(source) && /pageMeta: effectivePageMeta/.test(source)], ['webgl prepared texture records do not mutate the visible page metadata', /const incomingPageMeta = detail\.pageMeta/.test(source) && /if \(detail\.phase !== 'prepare' && detail\.pageMeta\) \{[\s\S]*currentPageMeta = incomingPageMeta/.test(source) && /pageMeta: effectivePageMeta/.test(source)],
['webgl scene force-redraws current pagination spread for initial title upload', /const initialSpread = pagination\?\.getCurrentSpread\?\.\(\)/.test(webglSceneSource) && /window\.BookTextureRenderer\.drawSpread\(initialSpread, \['left', 'right'\], \{ force: true \}\)/.test(webglSceneSource) && /options\.force !== true && phase !== 'prepare'/.test(textureRendererSource)], ['webgl scene awaits current pagination spread redraw during loader initial title upload', /const initialSpread = pagination\?\.getCurrentSpread\?\.\(\)/.test(webglSceneSource) && /await window\.BookTextureRenderer\.drawSpread\(initialSpread, \['left', 'right'\], \{ force: true \}\)/.test(webglSceneSource) && !/Date\.now\(\)/.test(webglSceneSource) && /options\.force !== true && phase !== 'prepare'/.test(textureRendererSource)],
['texture renderer marks committed reveal blocks complete so pauses cannot replay them', /webgl-book:reveal-committed/.test(textureRendererSource) && /completeRevealBlockIds/.test(textureRendererSource) && /this\.revealedBlockIds\.add\(id\)/.test(textureRendererSource)], ['texture renderer marks committed reveal blocks complete so pauses cannot replay them', /webgl-book:reveal-committed/.test(textureRendererSource) && /completeRevealBlockIds/.test(textureRendererSource) && /this\.revealedBlockIds\.add\(id\)/.test(textureRendererSource)],
['webgl timeline recalculates placeholder zero-duration reveal timings from TTS duration', /existingTimings/.test(bookPlaybackTimelineSource) && /existingDuration/.test(bookPlaybackTimelineSource) && /ttsDuration/.test(bookPlaybackTimelineSource) && /existingTimings\.length > 0 && \(existingDuration > 0 \|\| ttsDuration <= 0\)/.test(bookPlaybackTimelineSource)], ['webgl timeline recalculates placeholder zero-duration reveal timings from TTS duration', /existingTimings/.test(bookPlaybackTimelineSource) && /existingDuration/.test(bookPlaybackTimelineSource) && /ttsDuration/.test(bookPlaybackTimelineSource) && /existingTimings\.length > 0 && \(existingDuration > 0 \|\| ttsDuration <= 0\)/.test(bookPlaybackTimelineSource)],
['webgl playback coordinator trusts timeline-prepared reveal timings without recomputing', !/calculateWordTimings/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal')) && /single owner of reveal timing/.test(playbackCoordinatorSource) && /sentence\.webglRevealController\(/.test(playbackCoordinatorSource)], ['webgl playback coordinator trusts timeline-prepared reveal timings without recomputing', !/calculateWordTimings/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal')) && /single owner of reveal timing/.test(playbackCoordinatorSource) && /sentence\.webglRevealController\(/.test(playbackCoordinatorSource)],
@@ -232,6 +239,8 @@ const checks = [
['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 has one prepare path: spanning-aware plan reused at activate and continuation, no fallback', /revealSpreadSourceOverride: spanningPreview \? detail\.previewSpreads : null/.test(textureRendererSource) && /this\.revealSpreadSourceOverride = options\.revealSpreadSourceOverride/.test(textureRendererSource) && !/forceRebuild/.test(textureRendererSource) && !/forceRebuild/.test(bookPlaybackTimelineSource) && !/spanningPlanPrepared/.test(bookPlaybackTimelineSource) && /const texturePlan = this\.textureRenderer\.takeContinuationRevealPlan\(segment\.blockId, spread\.index\)/.test(bookPlaybackTimelineSource)], ['book playback timeline has one prepare path: spanning-aware plan reused at activate and continuation, no fallback', /revealSpreadSourceOverride: spanningPreview \? detail\.previewSpreads : null/.test(textureRendererSource) && /this\.revealSpreadSourceOverride = options\.revealSpreadSourceOverride/.test(textureRendererSource) && !/forceRebuild/.test(textureRendererSource) && !/forceRebuild/.test(bookPlaybackTimelineSource) && !/spanningPlanPrepared/.test(bookPlaybackTimelineSource) && /const texturePlan = this\.textureRenderer\.takeContinuationRevealPlan\(segment\.blockId, spread\.index\)/.test(bookPlaybackTimelineSource)],
['book playback timeline reuses prepared activation texture plan on the critical path', /let texturePlan = segment\.preparedTexturePlan/.test(bookPlaybackTimelineSource) && /\{ \.\.\.segment\.preparedTexturePlan, phase: 'activate' \}/.test(bookPlaybackTimelineSource) && /takePreparedRevealPlan\(segment\.blockId\)/.test(bookPlaybackTimelineSource) && /if \(!texturePlan\) \{[\s\S]*prepareRevealBlock/.test(bookPlaybackTimelineSource)],
['book playback timeline compares preplay flip against source spread captured before commit', /segment\.sourceSpreadIndex = this\.getVisibleSpreadIndex\(\)/.test(bookPlaybackTimelineSource) && /segment\.sourceSpreadIndex = Number\.isFinite/.test(bookPlaybackTimelineSource) && /const sourceSpread = Number\.isFinite/.test(bookPlaybackTimelineSource) && /targetSpreadIndex \|\| 0\)\) > sourceSpread/.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)],