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:
@@ -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;
|
||||
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,
|
||||
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: options.targetSpread,
|
||||
revealSides: Array.isArray(options.revealSides) ? options.revealSides : null
|
||||
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 wait;
|
||||
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)))
|
||||
|
||||
@@ -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;
|
||||
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' }
|
||||
|
||||
@@ -1023,9 +1023,10 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.revealImageBlock(element);
|
||||
} else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') {
|
||||
if (useWebGLBookReveal) {
|
||||
await this.prepareWebGLBookReveal(sentence);
|
||||
}
|
||||
await this.playWebGLBookSentence(sentence);
|
||||
} else {
|
||||
await this.playbackCoordinator.play(sentence);
|
||||
}
|
||||
if (useWebGLBookReveal && sentence.blockId != null) {
|
||||
this.markBlockRendered(sentence.blockId);
|
||||
}
|
||||
|
||||
+83
-14
@@ -140,6 +140,7 @@ const dynamicBufferRefreshIntervalMs = 1000 / 30;
|
||||
// frames are just the cheap scene render and hold 60fps. Candle flicker is the only thing
|
||||
// changing them then, which 8Hz captures imperceptibly.
|
||||
const staticGeometryBufferRefreshIntervalMs = 1000 / 8;
|
||||
const revealGeometryBufferRefreshIntervalMs = 1000 / 4;
|
||||
const flipDynamicBufferGraceMs = 180;
|
||||
let lastBookShadowRefreshAt = -Infinity;
|
||||
let lastTableReflectionRefreshAt = -Infinity;
|
||||
@@ -184,6 +185,8 @@ const lastFrameTiming = {};
|
||||
const slowFrameLog = [];
|
||||
const loaderTimings = {};
|
||||
const pageTextureTimings = [];
|
||||
let queuedNavigationPrewarm = null;
|
||||
let queuedNavigationPrewarmHandle = null;
|
||||
|
||||
function markLoaderTiming(name) {
|
||||
loaderTimings[name] = performance.now();
|
||||
@@ -385,6 +388,10 @@ const materials = {
|
||||
}),
|
||||
flipPageSurface: new THREE.MeshStandardMaterial({
|
||||
color: 0xeee6cc,
|
||||
map: getBlankPageTexture(),
|
||||
normalMap: paperTextures.normal,
|
||||
normalScale: new THREE.Vector2(0.004, 0.004),
|
||||
roughnessMap: paperTextures.roughness,
|
||||
roughness: 0.92,
|
||||
metalness: 0,
|
||||
emissive: 0x100d08,
|
||||
@@ -440,6 +447,8 @@ const materials = {
|
||||
};
|
||||
materials.flipPageBackSurface = materials.flipPageSurface.clone();
|
||||
materials.flipPageBackSurface.map = getBlankPageTexture();
|
||||
materials.flipPageBackSurface.normalMap = paperTextures.normal;
|
||||
materials.flipPageBackSurface.roughnessMap = paperTextures.roughness;
|
||||
materials.flipPageBackSurface.side = THREE.FrontSide;
|
||||
materials.flipPageEdge = materials.pageSurface.clone();
|
||||
materials.flipPageEdge.map = paperTextures.edge;
|
||||
@@ -622,6 +631,15 @@ window.BookLabDebug = {
|
||||
requestPageFlip(direction = 1, 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() {
|
||||
return getRevealDebugState();
|
||||
},
|
||||
@@ -680,7 +698,9 @@ if (webglBookSceneModule) {
|
||||
setPageReserve: (value) => setPageReserve(value),
|
||||
setMaxVisitedPagePosition: (value) => window.BookLabDebug.setMaxVisitedPagePosition(value),
|
||||
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;
|
||||
clearPageReveal('left', '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
|
||||
// visible spread changes (via flips). The scene jumps directly only for non-playback
|
||||
@@ -2232,12 +2261,7 @@ function handlePageTextureRecords(event) {
|
||||
source: 'book-texture-renderer'
|
||||
});
|
||||
markPageTextureTiming('handlePageTextureRecords:end');
|
||||
prewarmNavigationTextureWindow('page-texture-records', { recordMiss: false }).catch((error) => {
|
||||
pageTextureStore?.recordProblem?.({
|
||||
type: 'navigation-window-prewarm-error',
|
||||
message: error?.message || String(error)
|
||||
});
|
||||
});
|
||||
scheduleNavigationTextureWindowPrewarm('page-texture-records', { recordMiss: false });
|
||||
}
|
||||
|
||||
function normalizePageTextureRecordDetail(detail = {}) {
|
||||
@@ -2441,6 +2465,35 @@ async function prewarmNavigationTextureWindow(reason = 'navigation-window', opti
|
||||
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) {
|
||||
const currentSpread = Math.max(0, Number(bookPaginationState.spreadIndex || 0));
|
||||
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 revealBlockIds = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds.map(value => String(value)) : [];
|
||||
const activeStartedAt = revealBlockIds
|
||||
.map(blockId => activeRevealBlockStarts.get(blockId))
|
||||
.filter(value => Number.isFinite(Number(value)))
|
||||
.sort((a, b) => a - b)[0] ?? null;
|
||||
const activeStartedAt = getRevealStartTimeForBlockIds(revealBlockIds);
|
||||
|
||||
pageRevealState[side] = {
|
||||
startedAt: activeStartedAt ?? (revealDetail.startNow ? performance.now() : null),
|
||||
@@ -2600,6 +2650,22 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
|
||||
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)) {
|
||||
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
||||
const revealDetail = material?.userData?.pendingPageReveal;
|
||||
@@ -3141,8 +3207,6 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
|
||||
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
|
||||
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
|
||||
materials.flipPageBackSurface.roughnessMap = materials.pageSurface.roughnessMap;
|
||||
materials.flipPageSurface.needsUpdate = true;
|
||||
materials.flipPageBackSurface.needsUpdate = true;
|
||||
syncFlipRevealShaderFromSource(sourceSide, materials.flipPageSurface);
|
||||
syncFlipRevealShaderFromSource(targetBackSide, materials.flipPageBackSurface);
|
||||
flip.sourceTexture = sourceTexture;
|
||||
@@ -4733,7 +4797,12 @@ function animate(now = performance.now()) {
|
||||
: Infinity;
|
||||
const deferDynamicBuffersForFlipStart = activeFlips.length > 0 && newestFlipAge < flipDynamicBufferGraceMs;
|
||||
const geometryAnimating = activeFlips.length > 0;
|
||||
const bufferRefreshIntervalMs = geometryAnimating ? dynamicBufferRefreshIntervalMs : staticGeometryBufferRefreshIntervalMs;
|
||||
const revealAnimating = hasActivePageReveal();
|
||||
const bufferRefreshIntervalMs = geometryAnimating
|
||||
? dynamicBufferRefreshIntervalMs
|
||||
: revealAnimating
|
||||
? revealGeometryBufferRefreshIntervalMs
|
||||
: staticGeometryBufferRefreshIntervalMs;
|
||||
const shadowRefreshDue = !deferDynamicBuffersForFlipStart && (
|
||||
forceDynamicBufferRefresh || now - lastBookShadowRefreshAt >= bufferRefreshIntervalMs
|
||||
);
|
||||
|
||||
@@ -365,14 +365,14 @@ class WebGLBookSceneModule extends BaseModule {
|
||||
|
||||
async initializeScene() {
|
||||
if (this.labImportPromise) return this.labImportPromise;
|
||||
const cacheBuster = window.MODULE_CACHE_BUSTER || Date.now();
|
||||
this.labImportPromise = import(`/js/webgl-book-lab.js?v=${encodeURIComponent(cacheBuster)}`);
|
||||
const moduleVersion = window.MODULE_CACHE_BUSTER || 'dev';
|
||||
this.labImportPromise = import(`/js/webgl-book-lab.js?v=${encodeURIComponent(moduleVersion)}`);
|
||||
await this.labImportPromise;
|
||||
this.reportProgress(94, 'Uploading initial book page textures');
|
||||
const pagination = this.getModule('book-pagination');
|
||||
const initialSpread = pagination?.getCurrentSpread?.();
|
||||
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 {
|
||||
window.BookTextureRenderer?.publishSpread?.();
|
||||
}
|
||||
|
||||
@@ -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 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 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 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)],
|
||||
@@ -139,10 +140,13 @@ const checks = [
|
||||
['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)],
|
||||
['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 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 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 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)],
|
||||
@@ -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 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)],
|
||||
['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 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)],
|
||||
@@ -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 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)],
|
||||
['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 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)],
|
||||
@@ -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 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 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 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)],
|
||||
@@ -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 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 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)],
|
||||
['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)],
|
||||
@@ -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 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 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)],
|
||||
['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)],
|
||||
@@ -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 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 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)],
|
||||
['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)],
|
||||
|
||||
Reference in New Issue
Block a user