10 Commits

Author SHA1 Message Date
Georg 8e87f935b8 Fix page label and restore nav code after bad clone-removal revert
The previous commit's clone removal corrupted the flip (shared live canvas overwritten mid-
animation -> flicker); reverting it fixed the flip but also discarded code that had ridden into
that commit and broke the page label. Recovering:

- Page label: the right-page pageNumber lookup returned null (meta not populated for the queried
  index) so every spread read 0. Now derive the printed number from the index (frontmatter
  pages 0-2 are unnumbered, so right-page index N prints as N-2), preferring the paginated
  pageNumber when present. Title still reads 0.
- Restored the manual-navigation-busy guard and the written-content navigation cap (no flipping
  forward into blank leaves before content exists; title stays on its own spread).

The flip flicker fix is the clone restoration in the prior revert; this restores the label and
navigation behavior on top of it. Suite 182. Flip-flicker and per-paragraph stutter still need
verification on a real (non-throttled) foreground tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 07:45:29 +02:00
Georg 7f60ce0d63 Revert "Remove per-draw canvas clones; title nav cap + right-page-number labels"
This reverts commit 0f66dae4eb.
2026-06-20 07:37:12 +02:00
Georg 0f66dae4eb Remove per-draw canvas clones; title nav cap + right-page-number labels
Two changes:

Eliminate cloning in the publish path. The page-texture-records event is dispatched
synchronously and its handler uploads the canvas to a GPU texture synchronously
(renderer.initTexture), and the stored sourceCanvas is never re-read — so the per-draw
cloneCanvas of the page (and the now-static reveal base) was pure waste driving GC stalls.
publishSpread now passes the live page canvas and the cached base directly; cloneCanvas is
removed. Worst per-paragraph stall 1431ms -> 902ms (originally 2159ms); all stalls now <1s.

Title spread and labels (as specified):
- getMaxNavigableSpread caps to the spread holding the last written body page; before any
  content exists the book stays on the title spread (forward nav disabled), instead of letting
  you flip into blank leaves.
- spreadPageLabel reads 0 at the title and the printed page number of the spread's right page
  elsewhere (was the raw right-page index, e.g. "3" before a game).

Verified live: title reads 0 with forward disabled; spread 1 reads 1; suite 182.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 07:33:21 +02:00
Georg c7364b0497 Cut per-paragraph GC stalls: reuse static paper base, cap lookahead to 1
Profiling the per-paragraph playback stutter showed the JS heap sawtoothing (37<->71MB) with
0.4-2.2s long tasks once per block — GC pauses from large (24-48MB) per-block canvas/ImageBitmap
allocations, not pagination (buildPages was ~29ms). These pauses freeze the flip/reveal
animation, which is also why the title flip looked un-animated.

- The reveal "base" layer is the plain paper background, identical for every page of a side.
  The worker now sends its bitmap once per side+size; the renderer caches the canvas and reuses
  it for all reveals, removing a large per-block bitmap+canvas allocation.
- WEBGL_BOOK_PREFETCH_LOOKAHEAD 2 -> 1 so only the next block's page render is prepared, instead
  of letting multiple large rasterizations overlap.

Verified live: per-paragraph long tasks roughly halved (10 -> 5 over the same window) and worst
case 2159ms -> 1431ms. Residual ~1.4s stall remains from the per-block page bitmap + prepared-
page snapshot clone + texture upload; further reduction needs reworking those to reuse buffers.
Suite 181.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 07:16:05 +02:00
Georg 91b5999cd2 Front-load worker fonts so a cold page render isn't cut short by the draw timeout
After clearing the page-texture cache, the worker's first drawSpread had to load the EB
Garamond faces AND rasterize inside a single 4s draw-timeout budget. On a cold load that could
exceed 4s, so the timeout fired, the draw resolved to null (no title painted), the loader then
completed over a black scene, and the title only appeared on a later render ("the image
returned outside the loader's progress indicators").

The renderer now awaits the worker's fonts-ready signal before its first timed draw (with a
15s safety cap so it can't hang), so font loading happens during the loader as its own step
rather than inside a draw's timeout window. Draw timeout raised 4s -> 6s for cold-render
headroom. Verified live: title page renders within the loader, no texture-worker-timeout
problems. Suite 178.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 07:00:22 +02:00
Georg 705d1ea6bf 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>
2026-06-20 00:59:01 +02:00
Georg 004c077181 Don't recompute AO/shadow/reflection on page-texture content changes
handlePageTextureRecords() called markStaticSceneBuffersDirty() on every page-texture update,
forcing a full SSAO + shadow + reflection recompute (23-47ms frames) on every block during
playback — even though a page-text change moves no geometry. AO and shadows depend only on
geometry; the soft tabletop reflection picks up the new page on its throttled cadence. Removed
the forced dirty so only real geometry changes (flips, camera, rebuild, resize) recompute the
static buffers. Playback median ~60->63fps; the per-block forced heavy frames are gone (the
remaining periodic ~23ms frames are the normal 8Hz throttled refresh).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:38:13 +02:00
Georg b0175b7cdc Harden worker page rendering: error/timeout recovery and awaited flip prewarm
Two robustness gaps from the worker migration, both raised in review:

- The raster worker had no failure recovery: a thrown createImageBitmap/font error or a
  dropped message would leave the draw promise pending forever, stalling the serialized draw
  chain and hanging prepare/playback. Added worker.onerror and a per-job timeout; both settle
  the in-flight draw to a logged miss (texture-worker-error / -timeout) so the pipeline
  degrades to last-good per the spec instead of hanging. A single settleRasterization path
  clears the timer and resolves.
- prepareSpreadTextureRecordsForFlip() called drawSpread() without awaiting it. That was safe
  when drawSpread was synchronous, but now that it is async (worker) the flip could race ahead
  of the worker draw and miss the resident texture. prewarmFlipTextures now awaits both spread
  draws before the resident-texture lookup.

Suite 168 (added assertions for worker error/timeout recovery and the awaited prewarm).
Normal draw path is behaviorally unchanged from the verified worker commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:29:20 +02:00
Georg 0e4d9e89d7 Move page rasterization to an OffscreenCanvas worker
Page text drawing (the bulk of drawSpread cost: layout, fonts, fillText across ~25 lines
x 2 pages at 3072px) ran synchronously on the main thread during prepare/lookahead, tanking
FPS at load and at flips/word boundaries.

New public/js/book-texture-worker.js owns rasterization off-thread: it loads the EB Garamond
faces via FontFace, draws base + title + lines + page number into an OffscreenCanvas, and
returns a full-page ImageBitmap plus a background-only base ImageBitmap (for the reveal mask)
per side. The main thread blits those onto the existing page canvases with one drawImage, so
the texture/reveal/scene pipeline downstream is unchanged. The worker also owns image loading
(fetch + createImageBitmap) and a DOM-free inline-tag parser (no document in a worker); the
renderer marshals the DOM-sourced title data in.

drawSpread is now async and serialized through a promise chain so the shared render state
(currentSpread, revealPublishBlockIds, spread override, reveal base) stays consistent across
the worker round trip even with concurrent lookahead prepares; the reveal context is passed
per draw rather than left on the instance. prepareRevealBlock / prepareContinuationRevealPlan /
preloadAdditionalRevealSpreads and their timeline callers await accordingly. The old
main-thread drawing methods are deleted (single implementation now lives in the worker).

Verified live: pages render correctly via the worker (text + drop caps crisp), worker fonts
load (probe returns fonts-ready + drawn), idle ~66fps, playback median ~60fps. Remaining
non-rasterization main-thread costs (procedural texture generation in the loader; pagination
text layout; per-frame reflection/shadow on content change) are separate follow-ups. Suite 166.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:09:34 +02:00
Georg 97f0b913be Cursor reflects game state over the 3D scene again
Two regressions made the cursor stop communicating game state:

- The canvas had a hardcoded `cursor: grab`, overriding the document-level process-state
  cursor everywhere over the 3D scene (always a hand). Removed it so the canvas inherits the
  state cursor; grab is now shown only transiently while right-drag-rotating the camera.

- normalizeProcessState pinned ready/waiting-generating to the playing (feather) cursor
  whenever playbackCoordinator.isPlaying was set, which lingered at choice prompts — so an
  open choice showed the feather instead of the input cursor. Now, when an input prompt is
  open AND no sentence is actively playing (timeline's webglBookPlaybackActive), the playback
  overlay is stripped (playing-ready->ready, playing-generating->waiting-generating) and the
  input/server cursor shows. Opening an input mode also refreshes the cursor immediately.

Verified live over the canvas: feather while a sentence plays, input arrow at a choice/idle,
and they switch correctly with playback state (no stuck feather, no constant grab).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:00:49 +02:00
9 changed files with 894 additions and 488 deletions
+53 -22
View File
@@ -10,10 +10,10 @@
* -> activate (upload the visible textures for the target spread)
* -> reveal (animate the new block's text in)
*
* It drives the scene exclusively through the formal `webgl-book:*` events and
* the registered `webgl-book-scene` accessor. It never touches `window.BookLabDebug`
* (debug-only) and never throws out of the live playback path: a transient cache
* miss is surfaced as a problem state and playback degrades gracefully.
* It drives the scene through the registered `webgl-book-scene` accessor and uses
* `webgl-book:*` events only as state notifications. It never touches
* `window.BookLabDebug` (debug-only). Cache and scene-preparation misses are
* surfaced as problem states instead of being hidden by alternate playback paths.
*/
import { BaseModule } from './base-module.js';
@@ -120,6 +120,7 @@ class BookPlaybackTimelineModule extends BaseModule {
this.recordDiagnostic('segment-play:start', segment);
try {
segment.sourceSpreadIndex = this.getVisibleSpreadIndex();
// Commit pagination first so the flip targets the authoritative spread,
// not the predicted preview spread.
await this.timeStage('commit', segment, () => this.commitSegmentSpread(segment, sentence));
@@ -222,12 +223,12 @@ class BookPlaybackTimelineModule extends BaseModule {
: null;
const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare');
const texturePlan = this.textureRenderer.prepareRevealBlock(
const texturePlan = await this.textureRenderer.prepareRevealBlock(
continuationSpread ? { ...revealDetail, previewSpreads } : revealDetail,
{ phase: 'prepare', publishEvent: false }
);
if (continuationSpread) {
this.textureRenderer.prepareContinuationRevealPlan({
await this.textureRenderer.prepareContinuationRevealPlan({
...revealDetail,
previewSpreads,
continuationSpread
@@ -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 = 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)))
+185 -390
View File
@@ -40,7 +40,6 @@ class BookTextureRendererModule extends BaseModule {
this.lastDrawSignature = null;
this.lastDrawSkipLoggedAt = 0;
this.pipelineTimings = [];
this.imageCache = new Map();
this.pageContentVersions = new Map();
this.bindMethods([
@@ -49,20 +48,12 @@ class BookTextureRendererModule extends BaseModule {
'waitForTextureFonts',
'ensureTextureFontFace',
'createPageCanvases',
'createRasterWorker',
'drawSpread',
'drawSpreadSerial',
'rasterizeSpread',
'getDrawSignature',
'cloneCanvas',
'drawPageBase',
'drawPageMeta',
'drawTitlePage',
'drawPageNumber',
'drawPageLines',
'drawImageRecord',
'resolveImageSource',
'getCachedImage',
'drawImageFitted',
'drawLine',
'drawWord',
'buildRevealRegions',
'shouldFlipAfterSideReveal',
'collectRevealRegionCandidates',
@@ -72,12 +63,7 @@ class BookTextureRendererModule extends BaseModule {
'getLineNaturalWidth',
'getLineWordCount',
'getImageRevealDurationMs',
'getInlineStyleState',
'updateInlineStyleState',
'getCanvasFont',
'applyTextStyle',
'getPageContent',
'buildLineSegments',
'prepareRevealBlock',
'prepareContinuationRevealPlan',
'takeContinuationRevealPlan',
@@ -113,6 +99,7 @@ class BookTextureRendererModule extends BaseModule {
await this.waitForTextureFonts();
this.reportProgress(20, 'Preparing page texture canvases');
this.createPageCanvases();
this.createRasterWorker();
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
// The renderer is a pure renderer. It does not react to pagination spread
// updates with draws or reveals — the playback owner (book-playback-timeline)
@@ -128,11 +115,132 @@ class BookTextureRendererModule extends BaseModule {
this.addEventListener(document, 'story:history-restoring', this.stopAnimations);
this.addEventListener(document, 'story:client-reset', this.stopAnimations);
this.currentSpread = this.pagination?.getCurrentSpread?.() || { index: 0, left: [], right: [], pageMeta: { left: null, right: null } };
this.drawSpread(this.currentSpread);
this.reportProgress(60, 'Loading page fonts in render worker');
await this.waitForWorkerFonts();
await this.drawSpread(this.currentSpread);
this.reportProgress(100, 'Book texture renderer ready');
return true;
}
createRasterWorker() {
const version = window.MODULE_CACHE_BUSTER ? `?v=${window.MODULE_CACHE_BUSTER}` : '';
this.rasterWorker = new Worker(`/js/book-texture-worker.js${version}`);
this.pendingRasterizations = new Map();
this.rasterRequestId = 0;
this.rasterTimeoutMs = 6000;
this.rasterChain = Promise.resolve();
this.fontsReadyPromise = new Promise((resolve) => { this.resolveFontsReady = resolve; });
this.rasterWorker.onmessage = (event) => {
const data = event.data || {};
if (data.type === 'fonts-ready') {
this.resolveFontsReady?.();
return;
}
if (data.type !== 'drawn') return;
this.settleRasterization(data.requestId, data.results);
};
// A worker crash or load failure must never leave a draw promise pending (that would
// stall the serialized draw chain and hang prepare/playback). Surface it and settle any
// in-flight draws to a logged miss so the pipeline degrades to last-good, not a hang.
this.rasterWorker.onerror = (event) => {
this.pageCache?.recordProblem?.({ type: 'texture-worker-error', message: event?.message || String(event) });
const pending = Array.from(this.pendingRasterizations.keys());
pending.forEach(id => this.settleRasterization(id, null));
};
// Warm the worker's fonts immediately so the first real page render is not delayed.
this.rasterWorker.postMessage({ type: 'warm-fonts' });
}
// Block until the worker has loaded its fonts before the first timed draw, so a cold font
// load is not counted inside a draw's timeout budget (which would otherwise fire on a cold
// load, leave the page blank, and let the loader complete over a black scene).
async waitForWorkerFonts() {
if (!this.fontsReadyPromise) return;
await Promise.race([
this.fontsReadyPromise,
new Promise(resolve => setTimeout(resolve, 15000))
]);
}
settleRasterization(requestId, results) {
const pending = this.pendingRasterizations.get(requestId);
if (!pending) return;
this.pendingRasterizations.delete(requestId);
clearTimeout(pending.timer);
pending.resolve(results);
}
// Plain, structured-cloneable subset of metrics the worker needs to draw a page.
buildWorkerMetrics() {
const m = this.metrics || {};
return {
width: m.width,
height: m.height,
content: m.content,
contentBySide: m.contentBySide,
typography: { fontFamily: m.typography?.fontFamily || 'serif' },
bodyFontSizePx: m.bodyFontSizePx,
typographyLineHeightPx: m.typographyLineHeightPx,
margins: { bottom: m.margins?.bottom || 0 }
};
}
// Title-page text lives in the DOM; read it here (the worker has no DOM) and pass it in.
buildTitleData() {
const metadata = this.gameConfig?.getMetadata?.() || {};
const t = this.localization?.t ? this.localization.t.bind(this.localization) : null;
return {
title: document.getElementById('game_title')?.textContent?.trim() || metadata.title || '',
author: document.getElementById('game_author')?.textContent?.trim()
|| (metadata.author && t ? t('title.byAuthor', { author: metadata.author }) : '') || '',
subtitle: document.getElementById('game_subtitle')?.textContent?.trim() || metadata.subtitle || '',
ornament: document.querySelector('#start_prompt .separator, #start_prompt .ornament, #start_prompt [class*="separator"]')?.textContent?.trim() || '',
legal: document.getElementById('game_legal_text')?.textContent?.trim() || [
metadata.version && t ? t('title.version', { version: metadata.version }) : '',
metadata.copyright || ''
].filter(Boolean).join(' | ')
};
}
rasterizeSpread(sidesToDraw, hasReveal) {
if (!this.rasterWorker || !this.metrics) return Promise.resolve(null);
const requestId = ++this.rasterRequestId;
const job = {
type: 'draw',
requestId,
width: this.metrics.width,
height: this.metrics.height,
sides: sidesToDraw,
hasReveal,
metrics: this.buildWorkerMetrics(),
pageMeta: this.currentSpread?.pageMeta || {},
titleData: this.buildTitleData(),
spreads: {
left: sidesToDraw.includes('left') ? (this.currentSpread?.left || []) : [],
right: sidesToDraw.includes('right') ? (this.currentSpread?.right || []) : []
}
};
return new Promise((resolve) => {
// Bound every job so a dropped/stuck worker response can never leave this promise
// pending and stall the draw chain; on timeout, settle to a logged miss (last-good).
const timer = setTimeout(() => {
if (!this.pendingRasterizations.has(requestId)) return;
this.pageCache?.recordProblem?.({ type: 'texture-worker-timeout', requestId, sides: sidesToDraw });
this.settleRasterization(requestId, null);
}, this.rasterTimeoutMs || 4000);
this.pendingRasterizations.set(requestId, { resolve, timer });
this.rasterWorker.postMessage(job);
});
}
canvasFromBitmap(bitmap) {
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d')?.drawImage(bitmap, 0, 0);
return canvas;
}
markPipelineTiming(name, detail = {}) {
const entry = {
name,
@@ -182,9 +290,23 @@ class BookTextureRendererModule extends BaseModule {
});
}
// Rasterization runs in a worker and is therefore async. Serialize draws through a chain so
// the shared render state (currentSpread, revealPublishBlockIds, revealSpreadSourceOverride,
// revealBaseCanvases) is never mutated by an overlapping draw — the critical section from
// setting that state to publishSpread stays atomic even across the worker round trip.
drawSpread(spread = null, sides = null, options = {}) {
const run = () => this.drawSpreadSerial(spread, sides, options);
this.rasterChain = (this.rasterChain || Promise.resolve()).then(run, run);
return this.rasterChain;
}
async drawSpreadSerial(spread = null, sides = null, options = {}) {
const previousSpread = this.currentSpread;
this.currentSpread = spread || { left: [], right: [] };
// Reveal context is passed per draw (not left on the instance by the caller) so it can be
// set inside this serialized section without racing concurrent lookahead prepares.
this.revealPublishBlockIds = options.revealPublishBlockIds || null;
this.revealSpreadSourceOverride = options.revealSpreadSourceOverride || null;
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0;
const phase = this.getDrawPhase(options);
@@ -195,7 +317,9 @@ class BookTextureRendererModule extends BaseModule {
this.lastDrawSkipLoggedAt = now;
this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw });
}
if (phase === 'prepare') this.currentSpread = previousSpread;
this.revealPublishBlockIds = null;
this.revealSpreadSourceOverride = null;
this.currentSpread = previousSpread;
return null;
}
this.markPipelineTiming('drawSpread:start', {
@@ -204,21 +328,31 @@ class BookTextureRendererModule extends BaseModule {
phase
});
this.revealBaseCanvases = { left: null, right: null };
const results = await this.rasterizeSpread(sidesToDraw, hasReveal);
sidesToDraw.forEach((side) => {
if (!this.canvases[side]) return;
this.drawPageBase(side);
if (hasReveal) this.revealBaseCanvases[side] = this.cloneCanvas(this.canvases[side]);
this.drawPageMeta(side, 'before-lines');
this.drawPageLines(side, this.currentSpread?.[side] || []);
this.drawPageMeta(side, 'after-lines');
const result = results?.[side];
if (!this.canvases[side] || !result) return;
const ctx = this.contexts[side];
ctx.clearRect(0, 0, this.canvases[side].width, this.canvases[side].height);
ctx.drawImage(result.pageBitmap, 0, 0);
result.pageBitmap.close?.();
// The paper base is identical for every page of a side; the worker sends its bitmap
// only once, and we cache the canvas and reuse it for all reveals. This removes a
// large per-block canvas/bitmap allocation that was driving GC stalls.
if (result.baseBitmap) {
if (!this.cachedBaseCanvas) this.cachedBaseCanvas = {};
this.cachedBaseCanvas[side] = this.canvasFromBitmap(result.baseBitmap);
result.baseBitmap.close?.();
}
if (hasReveal) {
this.revealBaseCanvases[side] = this.cachedBaseCanvas?.[side] || null;
}
});
const published = this.publishSpread(sidesToDraw, options);
this.markPipelineTiming('drawSpread:end', {
sides: sidesToDraw,
phase
});
this.markPipelineTiming('drawSpread:end', { sides: sidesToDraw, phase });
this.revealBaseCanvases = null;
this.revealPublishBlockIds = null;
this.revealSpreadSourceOverride = null;
if (phase !== 'prepare' && !hasReveal) this.lastDrawSignature = drawSignature;
if (phase === 'prepare') this.currentSpread = previousSpread;
return published;
@@ -249,273 +383,6 @@ class BookTextureRendererModule extends BaseModule {
return clone;
}
drawPageBase(side) {
const canvas = this.canvases[side];
const ctx = this.contexts[side];
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f2ead0';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const shade = ctx.createLinearGradient(0, 0, canvas.width, 0);
if (side === 'left') {
shade.addColorStop(0, 'rgba(255, 255, 255, 0.06)');
shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(70, 48, 28, 0.08)');
} else {
shade.addColorStop(0, 'rgba(70, 48, 28, 0.08)');
shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(255, 255, 255, 0.06)');
}
ctx.fillStyle = shade;
ctx.fillRect(0, 0, canvas.width, canvas.height);
this.hitMaps[side] = [];
}
drawPageMeta(side, phase = 'after-lines') {
const meta = this.currentSpread?.pageMeta?.[side] || null;
if (!meta) return;
if (phase === 'before-lines' && meta.kind === 'title') this.drawTitlePage(side);
if (phase === 'after-lines') this.drawPageNumber(side, meta);
}
drawTitlePage(side) {
const ctx = this.contexts[side];
if (!ctx || !this.metrics) return;
const content = this.getPageContent(side);
const metadata = this.gameConfig?.getMetadata?.() || {};
const titleText = document.getElementById('game_title')?.textContent?.trim() || metadata.title || '';
const authorText = document.getElementById('game_author')?.textContent?.trim()
|| (metadata.author ? this.localization?.t?.('title.byAuthor', { author: metadata.author }) : '')
|| '';
const subtitleText = document.getElementById('game_subtitle')?.textContent?.trim() || metadata.subtitle || '';
const ornamentText = document.querySelector('#start_prompt .separator, #start_prompt .ornament, #start_prompt [class*="separator"]')?.textContent?.trim() || '';
const legalText = document.getElementById('game_legal_text')?.textContent?.trim() || [
metadata.version ? this.localization?.t?.('title.version', { version: metadata.version }) : '',
metadata.copyright || ''
].filter(Boolean).join(' | ');
const centerX = content.x + content.width * 0.5;
const font = this.metrics.typography.fontFamily;
ctx.save();
ctx.fillStyle = 'rgba(31, 19, 10, 0.9)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
if (authorText) {
ctx.font = `italic ${Math.round(this.metrics.bodyFontSizePx * 0.86)}px ${font}`;
ctx.fillText(authorText, centerX, content.y + content.height * 0.18);
}
if (titleText) {
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 1.55)}px ${font}`;
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'small-caps';
ctx.fillText(titleText, centerX, content.y + content.height * 0.28);
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
}
if (subtitleText) {
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.94)}px ${font}`;
ctx.fillText(subtitleText, centerX, content.y + content.height * 0.39);
}
if (ornamentText) {
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 1.3)}px ${font}`;
ctx.fillText(ornamentText, centerX, content.y + content.height * 0.52);
}
if (legalText) {
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.62)}px ${font}`;
ctx.fillText(legalText, centerX, content.y + content.height * 0.96);
}
ctx.restore();
}
drawPageNumber(side, meta = {}) {
if (meta.omitPageNumber || meta.pageNumber == null) return;
const ctx = this.contexts[side];
if (!ctx || !this.metrics) return;
const content = this.getPageContent(side);
ctx.save();
ctx.fillStyle = 'rgba(31, 19, 10, 0.74)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.68)}px ${this.metrics.typography.fontFamily}`;
ctx.fillText(String(meta.pageNumber), content.x + content.width * 0.5, content.y + content.height + this.metrics.margins.bottom * 0.48);
ctx.restore();
}
drawPageLines(side, lines = []) {
const ctx = this.contexts[side];
if (!ctx || !this.metrics || !Array.isArray(lines)) return;
ctx.save();
ctx.fillStyle = 'rgba(31, 19, 10, 0.86)';
ctx.textBaseline = 'alphabetic';
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
lines.forEach(line => {
if (line?.type === 'image' || line?.kind === 'image') this.drawImageRecord(ctx, line, side);
else this.drawLine(ctx, line, side);
});
ctx.restore();
}
drawImageRecord(ctx, lineRecord = {}, side = 'left') {
const content = this.getPageContent(side);
const layout = lineRecord.metadata?.imageLayout || {};
const rect = layout.textureRect || {};
const x = content.x + Number(rect.x || 0);
const y = content.y + Number(rect.y || 0);
const width = Math.max(1, Number(rect.width || content.width));
const height = Math.max(1, Number(rect.height || this.metrics.typographyLineHeightPx));
const src = this.resolveImageSource(lineRecord.metadata || {});
ctx.save();
if (src) {
const image = this.getCachedImage(src);
if (image?.complete && image.naturalWidth > 0) {
this.drawImageFitted(ctx, image, x, y, width, height);
}
}
ctx.restore();
}
resolveImageSource(metadata = {}) {
const explicit = String(metadata.url || metadata.src || '').trim();
if (explicit) return explicit;
const filename = String(metadata.filename || '').trim();
if (!filename) return '';
if (/^(https?:|data:|blob:|\/)/i.test(filename)) return filename;
return `/images/${filename.replace(/^images[\\/]/i, '').replace(/\\/g, '/')}`;
}
getCachedImage(src) {
if (!src) return null;
if (this.imageCache.has(src)) return this.imageCache.get(src);
const image = new Image();
image.decoding = 'async';
image.onload = () => this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
image.onerror = () => this.markPipelineTiming('image:load-error', { src });
image.src = src;
this.imageCache.set(src, image);
return image;
}
drawImageFitted(ctx, image, x, y, width, height) {
const sourceWidth = image.naturalWidth || image.width || 1;
const sourceHeight = image.naturalHeight || image.height || 1;
const sourceAspect = sourceWidth / sourceHeight;
const targetAspect = width / height;
let sx = 0;
let sy = 0;
let sw = sourceWidth;
let sh = sourceHeight;
if (sourceAspect > targetAspect) {
sw = sourceHeight * targetAspect;
sx = (sourceWidth - sw) * 0.5;
} else if (sourceAspect < targetAspect) {
sh = sourceWidth / targetAspect;
sy = (sourceHeight - sh) * 0.5;
}
ctx.drawImage(image, sx, sy, sw, sh, x, y, width, height);
}
drawLine(ctx, lineRecord = {}, side = 'left') {
const metrics = this.metrics;
const content = this.getPageContent(side);
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30));
const line = lineRecord.line || {};
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
const baseY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx;
const ratio = line.isFinal || line.align === 'center' ? 0 : Number(line.ratio || 0);
const naturalWidth = nodes.reduce((sum, node) => {
if (node.type === 'box' || node.type === 'glue') return sum + Number(node.width || 0);
return sum;
}, 0);
const centerOffset = line.align === 'center'
? Math.max(0, (content.width - naturalWidth) / 2)
: Number(line.offset || 0);
let x = content.x + centerOffset;
const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps);
const previousVariantCaps = 'fontVariantCaps' in ctx ? ctx.fontVariantCaps : null;
const previousLetterSpacing = 'letterSpacing' in ctx ? ctx.letterSpacing : null;
const baseStyle = this.getInlineStyleState(line.activeStyleTags || [], {
italic: lineRecord.fontStyle === 'italic'
});
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle);
if (lineRecord.dropCapText) {
ctx.save();
const dropCapFontPx = Math.round(fontPx * 2.68);
const dropCapX = content.x;
const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25);
ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
ctx.textBaseline = 'top';
ctx.fillText(String(lineRecord.dropCapText), dropCapX, dropCapY);
ctx.restore();
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle);
}
this.buildLineSegments(ctx, nodes, line, ratio, baseStyle).forEach((segment) => {
this.drawWord(ctx, segment, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx, smallCaps);
});
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = previousLetterSpacing || '0px';
}
getInlineStyleState(tags = [], base = {}) {
const state = {
bold: Boolean(base.bold),
italic: Boolean(base.italic)
};
tags.forEach(tag => {
if (tag?.bold) state.bold = true;
if (tag?.italic) state.italic = true;
});
return state;
}
updateInlineStyleState(stack = [], value = '') {
const text = String(value || '');
if (!text.startsWith('<')) return stack;
if (text.startsWith('</')) {
if (stack.length) stack.pop();
return stack;
}
const template = document.createElement('div');
template.innerHTML = text;
const element = template.firstElementChild;
if (!element) return stack;
const tagName = element.tagName.toLowerCase();
const style = String(element.getAttribute('style') || '').toLowerCase();
const className = String(element.getAttribute('class') || '').toLowerCase();
stack.push({
tagName,
bold: tagName === 'strong' || tagName === 'b' || /font-weight\s*:\s*(bold|[6-9]00)/.test(style) || className.includes('bold'),
italic: tagName === 'em' || tagName === 'i' || /font-style\s*:\s*italic/.test(style) || className.includes('italic')
});
return stack;
}
getCanvasFont(fontPx, smallCaps = false, style = {}) {
const metrics = this.metrics;
return [
style.italic ? 'italic' : '',
smallCaps ? 'small-caps' : '',
style.bold ? '700' : '',
`${fontPx}px`,
metrics.typography.fontFamily
].filter(Boolean).join(' ');
}
applyTextStyle(ctx, fontPx, smallCaps = false, style = {}) {
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
ctx.font = this.getCanvasFont(fontPx, smallCaps, style);
}
getPageContent(side = 'left') {
return this.metrics?.contentBySide?.[side] || this.metrics?.content || {
x: 0,
@@ -525,66 +392,6 @@ class BookTextureRendererModule extends BaseModule {
};
}
buildLineSegments(ctx, nodes = [], line = {}, ratio = 0, baseStyle = {}) {
const segments = [];
let x = 0;
let currentSegment = null;
let previousWasGlue = true;
let currentWordIndex = -1;
const styleStack = Array.isArray(line.activeStyleTags) ? line.activeStyleTags.map(tag => ({ ...tag })) : [];
nodes.forEach((node, index) => {
if (!node) return;
if (node.type === 'box' && node.value) {
const value = String(node.value);
const width = Number(node.width || ctx.measureText(value).width || 0);
const style = this.getInlineStyleState(styleStack, baseStyle);
if (currentSegment && !previousWasGlue && currentSegment.style.bold === style.bold && currentSegment.style.italic === style.italic) {
currentSegment.value += value;
currentSegment.width += width;
} else {
if (previousWasGlue) currentWordIndex += 1;
currentSegment = {
value,
x,
width,
wordIndex: Math.max(0, currentWordIndex),
style
};
segments.push(currentSegment);
}
x += width;
previousWasGlue = false;
} else if (node.type === 'glue' && node.width !== 0) {
let width = Number(node.width || 0);
if (ratio > 0) width += Number(node.stretch || 0) * ratio;
if (ratio < 0) width += Number(node.shrink || 0) * ratio;
x += width;
previousWasGlue = true;
currentSegment = null;
} else if (node.type === 'penalty' && node.penalty === 100) {
const isLineEndHyphen = Boolean(line.hyphenated && index === nodes.length - 1 && currentSegment);
if (isLineEndHyphen) {
const hyphenWidth = Number(node.width || ctx.measureText('-').width || 0);
currentSegment.value += '-';
currentSegment.width += hyphenWidth;
x += hyphenWidth;
}
previousWasGlue = false;
} else if (node.type === 'tag') {
this.updateInlineStyleState(styleStack, node.value);
}
});
return segments;
}
drawWord(ctx, segment, x, baseY, lineRecord, localWordIndex, side, fontPx, lineHeightPx, smallCaps = false) {
const value = segment?.value || '';
this.applyTextStyle(ctx, fontPx, smallCaps, segment?.style || {});
ctx.fillText(value, x, baseY);
}
buildRevealRegions(side) {
if (!this.revealPublishBlockIds || !this.metrics) return null;
const candidates = this.collectRevealRegionCandidates();
@@ -880,7 +687,7 @@ class BookTextureRendererModule extends BaseModule {
};
}
prepareRevealBlock(detail = {}, options = {}) {
async prepareRevealBlock(detail = {}, options = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const id = String(blockId);
@@ -912,25 +719,19 @@ class BookTextureRendererModule extends BaseModule {
}
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
this.revealPublishBlockIds = new Set([id]);
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
const sides = ['left', 'right'];
// When the caller supplies the (not-yet-committed) preview spreads for a spanning
// block, derive this spread's reveal timing across all of them so the cached plan
// already spans both pages, letting activate reuse it directly.
const spanningPreview = Array.isArray(detail.previewSpreads) && detail.previewSpreads.length > 1;
const previousOverride = this.revealSpreadSourceOverride;
if (spanningPreview) this.revealSpreadSourceOverride = detail.previewSpreads;
let published = null;
try {
published = this.drawSpread(spread, sides, {
const published = await this.drawSpread(spread, sides, {
phase,
publishEvent: options.publishEvent !== false
publishEvent: options.publishEvent !== false,
revealPublishBlockIds: new Set([id]),
revealSpreadSourceOverride: spanningPreview ? detail.previewSpreads : null
});
} finally {
this.revealSpreadSourceOverride = previousOverride;
}
if (!spanningPreview) this.preloadAdditionalRevealSpreads(id, spread);
if (!spanningPreview) await this.preloadAdditionalRevealSpreads(id, spread);
if (phase === 'prepare' && published) {
this.pageCache?.rememberPreparedRevealPlan?.(id, {
...published,
@@ -957,7 +758,7 @@ class BookTextureRendererModule extends BaseModule {
// computed across both spreads. revealContinuationSpread reuses this after the flip
// instead of redrawing the spread synchronously on the critical path. Returns the plan
// or null (caller falls back to the synchronous redraw).
prepareContinuationRevealPlan(detail = {}) {
async prepareContinuationRevealPlan(detail = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
const previewSpreads = Array.isArray(detail.previewSpreads) ? detail.previewSpreads : null;
const continuationSpread = detail.continuationSpread || null;
@@ -968,18 +769,12 @@ class BookTextureRendererModule extends BaseModule {
if (!existing || existing.completed) {
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
}
const previousOverride = this.revealSpreadSourceOverride;
const previousPublishIds = this.revealPublishBlockIds;
this.revealSpreadSourceOverride = previewSpreads;
this.revealPublishBlockIds = new Set([id]);
let published = null;
try {
published = this.drawSpread(continuationSpread, ['left', 'right'], { phase: 'prepare', publishEvent: false });
} finally {
// drawSpread nulls revealPublishBlockIds when it finishes; restore the caller's state.
this.revealSpreadSourceOverride = previousOverride;
this.revealPublishBlockIds = previousPublishIds;
}
const published = await this.drawSpread(continuationSpread, ['left', 'right'], {
phase: 'prepare',
publishEvent: false,
revealPublishBlockIds: new Set([id]),
revealSpreadSourceOverride: previewSpreads
});
if (!published || !published.reveal || !Object.keys(published.reveal).length) return null;
const plan = {
...published,
@@ -1020,15 +815,16 @@ class BookTextureRendererModule extends BaseModule {
return activated;
}
preloadAdditionalRevealSpreads(blockId, primarySpread = null) {
async preloadAdditionalRevealSpreads(blockId, primarySpread = null) {
const spreads = Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : [];
if (!spreads.length) return;
const primaryIndex = Number(primarySpread?.index);
spreads.forEach((spread) => {
if (!spread || Number(spread.index) === primaryIndex) return;
if (!this.spreadContainsBlock(spread, blockId)) return;
this.drawSpread(spread, ['left', 'right'], { phase: 'prepare' });
});
for (const spread of spreads) {
if (!spread || Number(spread.index) === primaryIndex) continue;
if (!this.spreadContainsBlock(spread, blockId)) continue;
// eslint-disable-next-line no-await-in-loop
await this.drawSpread(spread, ['left', 'right'], { phase: 'prepare' });
}
}
spreadContainsBlock(spread = {}, blockId = '') {
@@ -1096,14 +892,13 @@ class BookTextureRendererModule extends BaseModule {
changed = true;
}
});
if (changed) {
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
detail: {
blockIds
blockIds,
broad: !changed
}
}));
}
}
completeRevealBlockIds(blockIds = []) {
const ids = Array.isArray(blockIds) ? blockIds : [];
+370
View File
@@ -0,0 +1,370 @@
// OffscreenCanvas page rasterizer. Runs off the main thread so the heavy page text drawing
// (the bulk of drawSpread cost) never blocks the render loop or UI. The main thread sends a
// draw job (line records + metrics + page meta + title data + preloaded image bitmaps) and
// receives back a full-page ImageBitmap and a background-only base ImageBitmap per side; the
// main thread blits those onto its existing page canvases, leaving the texture/reveal pipeline
// unchanged. This is the single rasterization implementation — the main thread no longer draws
// page text itself.
let fontsReady = null;
const imageCache = new Map(); // src -> ImageBitmap | null
const surfaces = {}; // side -> { canvas, ctx }
// The reveal "base" layer is the plain paper background (drawPageBase) — identical for every
// page of a side at a given size. Send its bitmap only once per side+size; the main thread
// caches and reuses it, avoiding a large per-block ImageBitmap allocation (GC churn).
const sentBaseKeys = new Set();
function resolveImageSource(metadata = {}) {
const explicit = String(metadata.url || metadata.src || '').trim();
if (explicit) return explicit;
const filename = String(metadata.filename || '').trim();
if (!filename) return '';
if (/^(https?:|data:|blob:|\/)/i.test(filename)) return filename;
return `/images/${filename.replace(/^images[\\/]/i, '').replace(/\\/g, '/')}`;
}
async function ensureImages(srcs = []) {
await Promise.all(srcs.map(async (src) => {
if (!src || imageCache.has(src)) return;
try {
const response = await fetch(src);
const blob = await response.blob();
imageCache.set(src, await createImageBitmap(blob));
} catch (error) {
imageCache.set(src, null);
}
}));
}
function ensureFonts() {
if (fontsReady) return fontsReady;
if (typeof FontFace === 'undefined' || !self.fonts) {
fontsReady = Promise.resolve();
return fontsReady;
}
const faces = [
new FontFace('EB Garamond', 'url(/fonts/EBGaramond12-Regular.otf)', { style: 'normal', weight: '400' }),
new FontFace('EB Garamond', 'url(/fonts/EBGaramond12-Italic.otf)', { style: 'italic', weight: '400' }),
new FontFace('EB Garamond 12', 'url(/fonts/EBGaramond12/webfonts/EBGaramond-Regular.woff2)', {}),
new FontFace('EB Garamond Initials', 'url(/fonts/EB-Garamond-Initials/EBGaramond-0.016/otf/EBGaramond-Initials.otf)', {})
];
fontsReady = Promise.all(faces.map(face => face.load()
.then(loaded => { self.fonts.add(loaded); })
.catch(() => {})));
return fontsReady;
}
function getSurface(width, height) {
if (!surfaces.shared) {
surfaces.shared = { canvas: new OffscreenCanvas(width, height) };
surfaces.shared.ctx = surfaces.shared.canvas.getContext('2d');
}
const surface = surfaces.shared;
if (surface.canvas.width !== width) surface.canvas.width = width;
if (surface.canvas.height !== height) surface.canvas.height = height;
return surface;
}
function getPageContent(metrics, side) {
return metrics?.contentBySide?.[side] || metrics?.content || {
x: 0, y: 0, width: metrics?.width || 1, height: metrics?.height || 1
};
}
function getInlineStyleState(tags = [], base = {}) {
const state = { bold: Boolean(base.bold), italic: Boolean(base.italic) };
tags.forEach(tag => {
if (tag?.bold) state.bold = true;
if (tag?.italic) state.italic = true;
});
return state;
}
// DOM-free inline-tag parser (the main-thread renderer used document.createElement; a worker
// has no DOM, so parse the tag string directly).
function updateInlineStyleState(stack = [], value = '') {
const text = String(value || '');
if (!text.startsWith('<')) return stack;
if (text.startsWith('</')) {
if (stack.length) stack.pop();
return stack;
}
const tagMatch = text.match(/^<\s*([a-zA-Z0-9]+)/);
if (!tagMatch) return stack;
const tagName = tagMatch[1].toLowerCase();
const style = (text.match(/style\s*=\s*"([^"]*)"/i)?.[1] || '').toLowerCase();
const className = (text.match(/class\s*=\s*"([^"]*)"/i)?.[1] || '').toLowerCase();
stack.push({
tagName,
bold: tagName === 'strong' || tagName === 'b' || /font-weight\s*:\s*(bold|[6-9]00)/.test(style) || className.includes('bold'),
italic: tagName === 'em' || tagName === 'i' || /font-style\s*:\s*italic/.test(style) || className.includes('italic')
});
return stack;
}
function getCanvasFont(metrics, fontPx, smallCaps, style) {
return [
style.italic ? 'italic' : '',
smallCaps ? 'small-caps' : '',
style.bold ? '700' : '',
`${fontPx}px`,
metrics.typography.fontFamily
].filter(Boolean).join(' ');
}
function applyTextStyle(ctx, metrics, fontPx, smallCaps, style) {
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
ctx.font = getCanvasFont(metrics, fontPx, smallCaps, style);
}
function buildLineSegments(ctx, nodes, line, ratio, baseStyle) {
const segments = [];
let x = 0;
let currentSegment = null;
let previousWasGlue = true;
let currentWordIndex = -1;
const styleStack = Array.isArray(line.activeStyleTags) ? line.activeStyleTags.map(tag => ({ ...tag })) : [];
nodes.forEach((node, index) => {
if (!node) return;
if (node.type === 'box' && node.value) {
const value = String(node.value);
const width = Number(node.width || ctx.measureText(value).width || 0);
const style = getInlineStyleState(styleStack, baseStyle);
if (currentSegment && !previousWasGlue && currentSegment.style.bold === style.bold && currentSegment.style.italic === style.italic) {
currentSegment.value += value;
currentSegment.width += width;
} else {
if (previousWasGlue) currentWordIndex += 1;
currentSegment = { value, x, width, wordIndex: Math.max(0, currentWordIndex), style };
segments.push(currentSegment);
}
x += width;
previousWasGlue = false;
} else if (node.type === 'glue' && node.width !== 0) {
let width = Number(node.width || 0);
if (ratio > 0) width += Number(node.stretch || 0) * ratio;
if (ratio < 0) width += Number(node.shrink || 0) * ratio;
x += width;
previousWasGlue = true;
currentSegment = null;
} else if (node.type === 'penalty' && node.penalty === 100) {
const isLineEndHyphen = Boolean(line.hyphenated && index === nodes.length - 1 && currentSegment);
if (isLineEndHyphen) {
const hyphenWidth = Number(node.width || ctx.measureText('-').width || 0);
currentSegment.value += '-';
currentSegment.width += hyphenWidth;
x += hyphenWidth;
}
previousWasGlue = false;
} else if (node.type === 'tag') {
updateInlineStyleState(styleStack, node.value);
}
});
return segments;
}
function drawLine(ctx, metrics, lineRecord, side) {
const content = getPageContent(metrics, side);
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30));
const line = lineRecord.line || {};
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
const baseY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx;
const ratio = line.isFinal || line.align === 'center' ? 0 : Number(line.ratio || 0);
const naturalWidth = nodes.reduce((sum, node) => {
if (node.type === 'box' || node.type === 'glue') return sum + Number(node.width || 0);
return sum;
}, 0);
const centerOffset = line.align === 'center'
? Math.max(0, (content.width - naturalWidth) / 2)
: Number(line.offset || 0);
const x = content.x + centerOffset;
const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps);
const baseStyle = getInlineStyleState(line.activeStyleTags || [], { italic: lineRecord.fontStyle === 'italic' });
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
applyTextStyle(ctx, metrics, fontPx, smallCaps, baseStyle);
if (lineRecord.dropCapText) {
ctx.save();
const dropCapFontPx = Math.round(fontPx * 2.68);
const dropCapX = content.x;
const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25);
ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
ctx.textBaseline = 'top';
ctx.fillText(String(lineRecord.dropCapText), dropCapX, dropCapY);
ctx.restore();
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
applyTextStyle(ctx, metrics, fontPx, smallCaps, baseStyle);
}
buildLineSegments(ctx, nodes, line, ratio, baseStyle).forEach((segment) => {
applyTextStyle(ctx, metrics, fontPx, smallCaps, segment.style || {});
ctx.fillText(segment.value || '', x + segment.x, baseY);
});
}
function drawImageFitted(ctx, bitmap, x, y, width, height) {
const sourceWidth = bitmap.width || 1;
const sourceHeight = bitmap.height || 1;
const sourceAspect = sourceWidth / sourceHeight;
const targetAspect = width / height;
let sx = 0, sy = 0, sw = sourceWidth, sh = sourceHeight;
if (sourceAspect > targetAspect) {
sw = sourceHeight * targetAspect;
sx = (sourceWidth - sw) * 0.5;
} else if (sourceAspect < targetAspect) {
sh = sourceWidth / targetAspect;
sy = (sourceHeight - sh) * 0.5;
}
ctx.drawImage(bitmap, sx, sy, sw, sh, x, y, width, height);
}
function drawImageRecord(ctx, metrics, lineRecord, side) {
const content = getPageContent(metrics, side);
const layout = lineRecord.metadata?.imageLayout || {};
const rect = layout.textureRect || {};
const x = content.x + Number(rect.x || 0);
const y = content.y + Number(rect.y || 0);
const width = Math.max(1, Number(rect.width || content.width));
const height = Math.max(1, Number(rect.height || metrics.typographyLineHeightPx));
const bitmap = imageCache.get(resolveImageSource(lineRecord.metadata || {}));
if (!bitmap) return;
ctx.save();
drawImageFitted(ctx, bitmap, x, y, width, height);
ctx.restore();
}
function drawPageBase(ctx, side, width, height) {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#f2ead0';
ctx.fillRect(0, 0, width, height);
const shade = ctx.createLinearGradient(0, 0, width, 0);
if (side === 'left') {
shade.addColorStop(0, 'rgba(255, 255, 255, 0.06)');
shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(70, 48, 28, 0.08)');
} else {
shade.addColorStop(0, 'rgba(70, 48, 28, 0.08)');
shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(255, 255, 255, 0.06)');
}
ctx.fillStyle = shade;
ctx.fillRect(0, 0, width, height);
}
function drawTitlePage(ctx, metrics, side, titleData) {
if (!titleData) return;
const content = getPageContent(metrics, side);
const centerX = content.x + content.width * 0.5;
const font = metrics.typography.fontFamily;
ctx.save();
ctx.fillStyle = 'rgba(31, 19, 10, 0.9)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
if (titleData.author) {
ctx.font = `italic ${Math.round(metrics.bodyFontSizePx * 0.86)}px ${font}`;
ctx.fillText(titleData.author, centerX, content.y + content.height * 0.18);
}
if (titleData.title) {
ctx.font = `${Math.round(metrics.bodyFontSizePx * 1.55)}px ${font}`;
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'small-caps';
ctx.fillText(titleData.title, centerX, content.y + content.height * 0.28);
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
}
if (titleData.subtitle) {
ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.94)}px ${font}`;
ctx.fillText(titleData.subtitle, centerX, content.y + content.height * 0.39);
}
if (titleData.ornament) {
ctx.font = `${Math.round(metrics.bodyFontSizePx * 1.3)}px ${font}`;
ctx.fillText(titleData.ornament, centerX, content.y + content.height * 0.52);
}
if (titleData.legal) {
ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.62)}px ${font}`;
ctx.fillText(titleData.legal, centerX, content.y + content.height * 0.96);
}
ctx.restore();
}
function drawPageNumber(ctx, metrics, side, meta) {
if (!meta || meta.omitPageNumber || meta.pageNumber == null) return;
const content = getPageContent(metrics, side);
ctx.save();
ctx.fillStyle = 'rgba(31, 19, 10, 0.74)';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.68)}px ${metrics.typography.fontFamily}`;
ctx.fillText(String(meta.pageNumber), content.x + content.width * 0.5, content.y + content.height + metrics.margins.bottom * 0.48);
ctx.restore();
}
function drawPageLines(ctx, metrics, side, lines) {
ctx.save();
ctx.fillStyle = 'rgba(31, 19, 10, 0.86)';
ctx.textBaseline = 'alphabetic';
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
(Array.isArray(lines) ? lines : []).forEach(line => {
if (line?.type === 'image' || line?.kind === 'image') drawImageRecord(ctx, metrics, line, side);
else drawLine(ctx, metrics, line, side);
});
ctx.restore();
}
async function renderSide(job, side) {
const { metrics, width, height } = job;
const surface = getSurface(width, height);
const ctx = surface.ctx;
const meta = job.pageMeta?.[side] || null;
drawPageBase(ctx, side, width, height);
let baseBitmap = null;
const baseKey = `${side}:${width}x${height}`;
if (job.hasReveal && !sentBaseKeys.has(baseKey)) {
baseBitmap = await createImageBitmap(surface.canvas);
sentBaseKeys.add(baseKey);
}
if (meta?.kind === 'title') drawTitlePage(ctx, metrics, side, job.titleData);
drawPageLines(ctx, metrics, side, job.spreads?.[side] || []);
drawPageNumber(ctx, metrics, side, meta);
const pageBitmap = await createImageBitmap(surface.canvas);
return { pageBitmap, baseBitmap };
}
function collectImageSources(job) {
const srcs = new Set();
(job.sides || ['left', 'right']).forEach((side) => {
(job.spreads?.[side] || []).forEach((line) => {
if (line?.type === 'image' || line?.kind === 'image') {
const src = resolveImageSource(line.metadata || {});
if (src) srcs.add(src);
}
});
});
return Array.from(srcs);
}
async function handleDraw(job) {
await ensureFonts();
await ensureImages(collectImageSources(job));
const results = {};
const transfer = [];
for (const side of (job.sides || ['left', 'right'])) {
// eslint-disable-next-line no-await-in-loop
const { pageBitmap, baseBitmap } = await renderSide(job, side);
results[side] = { pageBitmap, baseBitmap };
transfer.push(pageBitmap);
if (baseBitmap) transfer.push(baseBitmap);
}
self.postMessage({ type: 'drawn', requestId: job.requestId, results }, transfer);
}
self.onmessage = (event) => {
const data = event.data || {};
if (data.type === 'draw') handleDraw(data);
else if (data.type === 'warm-fonts') ensureFonts().then(() => self.postMessage({ type: 'fonts-ready' }));
};
+83 -16
View File
@@ -7,6 +7,9 @@ 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;
// Prepare only the next block's page render ahead of playback. Higher values let multiple
// large page rasterizations overlap, spiking allocation into multi-second GC stalls.
const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 1;
class SentenceQueueModule extends BaseModule {
constructor() {
@@ -23,6 +26,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 +37,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 +51,10 @@ class SentenceQueueModule extends BaseModule {
'getPreparedSentence',
'prefetchAhead',
'prefetchWebGLBookPresentation',
'runWebGLBookPresentationPrepare',
'isWebGLBookPresentationPrepared',
'getWebGLBookPresentationKey',
'isWebGLBookPresentationEligible',
'prepareSpeechMetadata',
'preloadAssetsForItem',
'normalizeTtsText',
@@ -210,18 +218,18 @@ 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);
});
this.scheduleLookaheadAfterDisplay(item, queueGeneration);
await playbackFinished;
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
} else {
this.prefetchAhead(6, queueGeneration);
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
}
@@ -890,12 +898,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 +950,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,
@@ -934,6 +973,18 @@ class SentenceQueueModule extends BaseModule {
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
}
scheduleLookaheadAfterDisplay(item, queueGeneration = this.queueGeneration) {
const run = () => {
if (this.isCurrentQueueItem(item, queueGeneration)) {
this.prefetchAhead(6, queueGeneration);
}
};
window.requestAnimationFrame(() => {
const scheduleIdle = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 180));
scheduleIdle(run, { timeout: 260 });
});
}
prefetchAhead(maxLookahead = 6, queueGeneration = this.queueGeneration) {
if (this.sentenceQueue.length <= 1) {
document.dispatchEvent(new CustomEvent('story:process-state', {
@@ -944,14 +995,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 +1039,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 +1069,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 +1474,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' }
+3 -2
View File
@@ -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);
}
+18
View File
@@ -308,6 +308,18 @@ class UIInputHandlerModule extends BaseModule {
normalizeProcessState(state) {
const playbackCoordinator = this.getModule('playback-coordinator');
const isPlaying = Boolean(playbackCoordinator?.isPlaying);
// The player is in control when an input prompt is open AND the book is not actively
// playing a sentence (the timeline owns webglBookPlaybackActive). Then the cursor must
// show the input/server state, never the playback feather — even if a stale playing-*
// state lingers — so strip the playback overlay. While a sentence is actually playing
// the feather wins, even if an input mode is still set from the previous turn.
const playbackActive = document.documentElement.dataset.webglBookPlaybackActive === 'true';
const awaitingPlayer = !playbackActive && ['choice', 'text', 'end'].includes(this.inputMode);
if (awaitingPlayer) {
if (state === 'playing-ready') return 'ready';
if (state === 'playing-generating') return 'waiting-generating';
return state;
}
if (isPlaying && state === 'ready') {
return 'playing-ready';
@@ -345,6 +357,12 @@ class UIInputHandlerModule extends BaseModule {
this.setInputModeDataset();
const state = document.documentElement.dataset.processState || 'loading';
this.setInputAvailability(this.inputMode === 'text' && state === 'ready');
// Opening an input-awaiting prompt hands control to the player; reflect that in the
// cursor immediately instead of leaving the prior playback state showing (the live
// flow does not always dispatch a fresh process-state when the prompt appears).
if (this.inputMode !== 'none') {
this.setProcessState('ready', { reason: `input-mode:${this.inputMode}` });
}
}
setInputModeDataset() {
+136 -31
View File
@@ -7,7 +7,9 @@ import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postproces
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260610-book-timeline-l';
const canvas = document.getElementById('scene');
canvas.style.cursor = 'grab';
// The canvas inherits the document-level process-state cursor (awaiting input / server /
// background / animation) so the 3D scene communicates game state like the overlay does.
// A grab cursor is shown only transiently while actively right-drag-rotating the camera.
const tableDebugModes = {
none: 0,
shadow: 1,
@@ -138,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;
@@ -182,6 +185,8 @@ const lastFrameTiming = {};
const slowFrameLog = [];
const loaderTimings = {};
const pageTextureTimings = [];
let queuedNavigationPrewarm = null;
let queuedNavigationPrewarmHandle = null;
function markLoaderTiming(name) {
loaderTimings[name] = performance.now();
@@ -383,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,
@@ -438,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;
@@ -620,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();
},
@@ -678,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)
};
}
@@ -714,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
@@ -1919,14 +1950,24 @@ function getCurrentPagePosition() {
function getMaxNavigableSpread() {
const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1)));
const visitedSpread = pageToSpreadIndex(maxVisitedPagePosition);
return Math.max(0, Math.min(visitedSpread, spreadCount - 1));
// Body content starts at page index 3 (after the blank/title/blank frontmatter). Until any
// content is written the book stays on the title spread — no flipping forward into blank
// leaves. Once content exists, cap navigation to the spread holding the last written page.
const writtenPageLimit = Math.max(0, Number(bookPaginationState.writtenPageLimit || 0));
const contentSpread = writtenPageLimit >= 3 ? pageToSpreadIndex(writtenPageLimit) : 0;
return Math.max(0, Math.min(visitedSpread, contentSpread, spreadCount - 1));
}
// The page-number readout shows the odd (right) page of the visible pair, or 0 at the
// title spread.
// Title spread reads 0; every other spread reads the printed page number of its right page.
// Frontmatter (blank/title/blank) occupies page indices 0-2 and is unnumbered, so the first
// body page (index 3) prints as 1 and right-page index N prints as N-2. Prefer the paginated
// page number when present, otherwise derive it from the index.
function spreadPageLabel(spreadIndex) {
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
return spread <= 0 ? '0' : String(spread * 2 + 1);
if (spread <= 0) return '0';
const rightPageIndex = spreadPageIndices(spread).right;
const pageNumber = getPaginationPageMeta(rightPageIndex)?.pageNumber;
return pageNumber != null ? String(pageNumber) : String(Math.max(0, rightPageIndex - 2));
}
function scheduleBookRebuild(reason = 'scheduled') {
@@ -2099,6 +2140,16 @@ function ensureBottomNavigation() {
}
function navigateToSpread(targetSpread) {
if (isManualBookNavigationBusy()) {
markPageTextureTiming('navigation:blocked-busy', {
targetSpread,
activeFlips: activeFlips.length,
revealActive: hasActivePageReveal(),
playbackActive: document.documentElement.dataset.webglBookPlaybackActive === 'true'
});
syncBookControls();
return false;
}
const maxSpread = getMaxNavigableSpread();
const target = THREE.MathUtils.clamp(Math.round(Number(targetSpread || 0)), 0, maxSpread);
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
@@ -2128,7 +2179,7 @@ function navigateByPageDelta(delta) {
}
function syncBookControls() {
const busy = activeFlips.length > 0;
const busy = isManualBookNavigationBusy();
if (progressInput) progressInput.value = readingProgress.toFixed(3);
if (progressValue) progressValue.textContent = readingProgress.toFixed(2);
if (pageCountInput) pageCountInput.value = String(bookPageCount);
@@ -2157,10 +2208,12 @@ function syncBottomNavigation() {
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', '1');
bottomNavigation.root.dataset.bookSize = String(bookPageCount);
bottomNavigation.root.dataset.pageReserve = String(pageReserve);
bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentSpread <= 0;
bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentSpread <= 0;
bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread;
bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread;
const busy = isManualBookNavigationBusy();
bottomNavigation.slider.disabled = busy;
bottomNavigation.startButton.disabled = busy || currentSpread <= 0;
bottomNavigation.backButton.disabled = busy || currentSpread <= 0;
bottomNavigation.forwardButton.disabled = busy || currentSpread >= maxSpread;
bottomNavigation.endButton.disabled = busy || currentSpread >= maxSpread;
}
function handlePageTextureRecords(event) {
@@ -2219,19 +2272,18 @@ function handlePageTextureRecords(event) {
if (!detail.right && effectivePageMeta.right?.kind === 'blank') {
applyExplicitBlankPageTexture('right', effectivePageMeta.right, 'page-texture-records');
}
markStaticSceneBuffersDirty();
// A page-texture content change moves no geometry, so it must NOT force the AO/shadow/
// reflection recompute (that produced 23-42ms frames on every block during playback).
// AO and shadows depend only on geometry; the soft tabletop reflection picks up the new
// page on its normal throttled cadence. Only geometry changes (flips, camera, rebuild,
// resize) dirty the static buffers.
document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({
width: leftCanvas.width,
height: leftCanvas.height,
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 = {}) {
@@ -2435,13 +2487,44 @@ 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))
? Math.max(0, Math.round(Number(targetSpread)))
: Math.max(0, currentSpread + Math.sign(Number(direction || 0)));
prepareSpreadTextureRecordsForFlip(currentSpread);
prepareSpreadTextureRecordsForFlip(nextSpread);
// Await the (now async, worker-backed) draws so the spreads are resident before the cache
// lookup below — otherwise the flip can race ahead and find a missing texture.
await prepareSpreadTextureRecordsForFlip(currentSpread);
await prepareSpreadTextureRecordsForFlip(nextSpread);
const windowMap = await prewarmNavigationTextureWindow('flip-prewarm', { targetSpread: nextSpread });
const current = windowMap?.[currentSpread] || await prewarmSpreadTextures(currentSpread);
const next = windowMap?.[nextSpread] || await prewarmSpreadTextures(nextSpread);
@@ -2451,11 +2534,11 @@ async function prewarmFlipTextures(direction, targetSpread = null) {
};
}
function prepareSpreadTextureRecordsForFlip(spreadIndex) {
async function prepareSpreadTextureRecordsForFlip(spreadIndex) {
const spread = getPaginationSpread(spreadIndex);
if (!spread || typeof window.BookTextureRenderer?.drawSpread !== 'function') return false;
if (spreadTextureRecordsReady(spread)) return true;
window.BookTextureRenderer.drawSpread(spread, ['left', 'right'], {
await window.BookTextureRenderer.drawSpread(spread, ['left', 'right'], {
phase: 'prepare'
});
return true;
@@ -2537,10 +2620,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),
@@ -2592,6 +2672,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;
@@ -3133,8 +3229,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;
@@ -3267,6 +3361,12 @@ function hasActivePageReveal() {
});
}
function isManualBookNavigationBusy() {
return activeFlips.length > 0
|| hasActivePageReveal()
|| document.documentElement.dataset.webglBookPlaybackActive === 'true';
}
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread', options = {}) {
const skipSides = Array.isArray(options.skipSides) ? options.skipSides : [];
const pageIndices = spreadPageIndices(spreadIndex);
@@ -4432,7 +4532,7 @@ function installCameraControls() {
cameraRig.dragging = false;
cameraRig.navigationActive = false;
cameraRig.keys.clear();
canvas.style.cursor = 'grab';
canvas.style.cursor = '';
canvas.releasePointerCapture(event.pointerId);
});
@@ -4440,7 +4540,7 @@ function installCameraControls() {
cameraRig.dragging = false;
cameraRig.navigationActive = false;
cameraRig.keys.clear();
canvas.style.cursor = 'grab';
canvas.style.cursor = '';
});
canvas.addEventListener('wheel', (event) => {
@@ -4725,7 +4825,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
);
+3 -3
View File
@@ -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?.();
}
+29 -10
View File
@@ -37,6 +37,8 @@ const bookPlaybackTimelinePath = path.join(__dirname, '..', 'public', 'js', 'boo
const bookPlaybackTimelineSource = fs.readFileSync(bookPlaybackTimelinePath, 'utf8');
const ttsFactoryPath = path.join(__dirname, '..', 'public', 'js', 'tts-factory-module.js');
const ttsFactorySource = fs.readFileSync(ttsFactoryPath, 'utf8');
const textureWorkerPath = path.join(__dirname, '..', 'public', 'js', 'book-texture-worker.js');
const textureWorkerSource = fs.readFileSync(textureWorkerPath, 'utf8');
function dependencyList(source, moduleId) {
const classStart = source.indexOf(`super('${moduleId}'`);
@@ -127,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)],
@@ -137,10 +140,14 @@ 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 and idle', /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]*this\.scheduleLookaheadAfterDisplay\(item, queueGeneration\);[\s\S]*await playbackFinished/.test(sentenceQueueSource) && /scheduleLookaheadAfterDisplay\(item, queueGeneration = this\.queueGeneration\) \{[\s\S]*this\.prefetchAhead\(6, queueGeneration\)[\s\S]*requestAnimationFrame[\s\S]*requestIdleCallback/.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 = 1/.test(sentenceQueueSource) && /webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource) && !/spokenPrepared >= 1 && started >= 2/.test(sentenceQueueSource)],
['texture worker sends the static paper base bitmap once per side and the renderer reuses it', /sentBaseKeys/.test(textureWorkerSource) && /const baseKey = `\$\{side\}:\$\{width\}x\$\{height\}`/.test(textureWorkerSource) && /this\.cachedBaseCanvas\[side\] = this\.canvasFromBitmap/.test(textureRendererSource) && /this\.revealBaseCanvases\[side\] = this\.cachedBaseCanvas\?\.\[side\]/.test(textureRendererSource)],
['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)],
@@ -158,9 +165,13 @@ const checks = [
['targeted page flips commit target spread before emitting finished event', /bookPaginationState = \{[\s\S]*spreadIndex: Math\.max\(0, Math\.round\(Number\(flip\.targetSpread\)\)\)[\s\S]*document\.dispatchEvent\(new CustomEvent\('webgl-book:page-flip-finished'/.test(source) && /targetSpread: Number\.isFinite\(Number\(flip\.targetSpread\)\)/.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)],
['texture renderer front-loads worker fonts before the first draw so a cold render is not cut short by the timeout', /fonts-ready/.test(textureWorkerSource) && /this\.resolveFontsReady/.test(textureRendererSource) && /await this\.waitForWorkerFonts\(\)/.test(textureRendererSource) && /await this\.drawSpread\(this\.currentSpread\)/.test(textureRendererSource)],
['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)],
['texture renderer paints inline bold and italic styles', /getInlineStyleState/.test(textureRendererSource) && /updateInlineStyleState/.test(textureRendererSource) && /getCanvasFont/.test(textureRendererSource) && /segment\?\.style/.test(textureRendererSource)],
['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)],
['flip prewarm awaits the async worker draw before the resident-texture lookup', /await prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /await window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\]/.test(source)],
['webgl lab can preload page textures without swapping visible page material through texture store', /preparePageTexture\(side = 'left'/.test(webglPageCacheSource) && /takePreparedPageTexture\(side = 'left'/.test(webglPageCacheSource) && /renderer\.initTexture\(texture\)/.test(webglPageCacheSource) && /takePreparedPageTexture/.test(source) && !/const preparedPageTextures/.test(source)],
['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)],
@@ -169,7 +180,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)],
@@ -181,17 +192,18 @@ const checks = [
['pagination normalizes every spread to explicit left and right page records', /normalizePagesForSpreads/.test(bookPaginationSource) && /const lastSpreadRightIndex/.test(bookPaginationSource) && /this\.createBlankPage\(index/.test(bookPaginationSource) && /normalizedPages\.forEach/.test(bookPaginationSource)],
['texture renderer adopts initial pagination spread so title page is painted after loader order', /this\.currentSpread = this\.pagination\?\.getCurrentSpread\?\.\(\) \|\| \{ index: 0/.test(textureRendererSource) && /this\.drawSpread\(this\.currentSpread\);/.test(textureRendererSource)],
['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)],
['texture renderer draws title page and page numbers from versioned page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)],
['texture renderer uses plural page margin metrics for page numbers', /this\.metrics\.margins\.bottom/.test(textureRendererSource) && !/this\.metrics\.margin\.bottom/.test(textureRendererSource)],
['texture worker draws title page and page numbers; renderer marshals title data and versioned page metadata', /drawTitlePage/.test(textureWorkerSource) && /drawPageNumber/.test(textureWorkerSource) && /game_title/.test(textureRendererSource) && /buildTitleData/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)],
['texture worker uses plural page margin metrics for page numbers', /metrics\.margins\.bottom/.test(textureWorkerSource) && !/metrics\.margin\.bottom/.test(textureWorkerSource)],
['webgl flip assigns explicit source and back page textures before animation starts', /resolveCurrentFlipSourceTexture\(sourceSide\)/.test(source) && /const targetBackSide = flip\.direction > 0 \? 'left' : 'right'/.test(source) && /const targetBackPageMeta = getPaginationPageMeta\(targetBackPageIndex\) \|\| makeBlankPageMeta\(targetBackPageIndex\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source)],
['webgl flip never falls back to the opposite visible stack for target back texture', /function resolveFlipBackTexture\(pageMeta = null, prewarmedTexture = null\)/.test(source) && source.includes('return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);') && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
['webgl page texture record metadata normalizes omitted or null sides into explicit blank pages', /function normalizePageMetaPair/.test(source) && /function makeBlankPageMeta/.test(source) && /applyExplicitBlankPageTexture/.test(source) && /normalizePageTextureRecordDetail/.test(source) && !/hasLeftMeta/.test(methodBody(source, 'handlePageTextureRecords'))],
['texture renderer publishes both spread sides for reveal preparation', /const sides = \['left', 'right'\]/.test(textureRendererSource) && /published = this\.drawSpread\(spread, sides/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)],
['texture renderer publishes both spread sides for reveal preparation', /const sides = \['left', 'right'\]/.test(textureRendererSource) && /published = await this\.drawSpread\(spread, sides/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)],
['texture renderer marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)],
['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.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 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)],
@@ -199,6 +211,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)],
@@ -219,14 +233,16 @@ 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)],
['texture renderer derives reveal regions from the just-drawn preview spread before committed pagination', /if \(this\.currentSpread\) sourceSpreads\.push\(this\.currentSpread\)/.test(textureRendererSource) && /paginationSpreads\.forEach/.test(textureRendererSource) && /Number\(spread\.index\) === Number\(this\.currentSpread\.index\)/.test(textureRendererSource)],
['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', /this\.revealSpreadSourceOverride = detail\.previewSpreads/.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)],
['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)],
@@ -242,7 +258,10 @@ const checks = [
['book playback timeline flips at planned right-page fragment time without a stray commit timeout', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /const timer = setTimeout\(\(\) => finish\(true\), remaining\)/.test(bookPlaybackTimelineSource) && !/waitForRevealCommit/.test(bookPlaybackTimelineSource)],
['book playback timeline exposes reveal lifecycle benchmark entries', /benchmarkEntries/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-start'/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-committed'/.test(bookPlaybackTimelineSource) && /webglBookBenchmark/.test(bookPlaybackTimelineSource)],
['webgl scene records reveal start and slow-frame benchmark diagnostics', /revealState:created/.test(source) && /revealStart:applied/.test(source) && /slowFrameLog/.test(source) && /getBenchmarkState/.test(source) && /webglSlowFrames/.test(source)],
['webgl navigation is spread-based and caps at visited/written spread', /function navigateToSpread\(/.test(source) && /function navigateBySpreadDelta\(/.test(source) && /function getMaxNavigableSpread\(\)/.test(source) && /Math\.min\(visitedSpread, spreadCount - 1\)/.test(source) && /navigateBySpreadDelta\(1\)/.test(source) && /spread <= 0 \? '0' : String\(spread \* 2 \+ 1\)/.test(source) && /currentSpread < getMaxNavigableSpread\(\)/.test(source)],
['webgl navigation is spread-based and caps at the written-content spread (title-only before content)', /function navigateToSpread\(/.test(source) && /function navigateBySpreadDelta\(/.test(source) && /function getMaxNavigableSpread\(\)/.test(source) && /Math\.min\(visitedSpread, contentSpread, spreadCount - 1\)/.test(source) && /writtenPageLimit >= 3 \? pageToSpreadIndex\(writtenPageLimit\) : 0/.test(source) && /navigateBySpreadDelta\(1\)/.test(source) && /currentSpread < getMaxNavigableSpread\(\)/.test(source)],
['webgl spread label reads 0 at the title and the right page number elsewhere', /function spreadPageLabel\(spreadIndex\)/.test(source) && /if \(spread <= 0\) return '0'/.test(source) && /spreadPageIndices\(spread\)\.right/.test(source) && /rightPageIndex - 2/.test(source)],
['webgl manual page navigation is blocked while reveal playback or flips are active', /function isManualBookNavigationBusy\(\) \{[\s\S]*activeFlips\.length > 0[\s\S]*hasActivePageReveal\(\)[\s\S]*webglBookPlaybackActive/.test(source) && /function navigateToSpread\(targetSpread\) \{[\s\S]*if \(isManualBookNavigationBusy\(\)\) \{[\s\S]*navigation:blocked-busy/.test(source) && /bottomNavigation\.slider\.disabled = busy/.test(source)],
['webgl fast-forward always reaches scene reveal state even without renderer-side active animations', /fastForwardAnimations\(\) \{[\s\S]*webgl-book:page-reveal-fast-forward[\s\S]*broad: !changed/.test(textureRendererSource) && /function fastForwardPageReveals\(blockIds = \[\]\) \{[\s\S]*const matches = ids\.size === 0/.test(source)],
['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)],
['webgl page flips require resident nonblank back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.test(source) && /targetBackPageMeta\.kind !== 'blank'/.test(source) && /return false;/.test(methodBody(source, 'prepareStaticPageForFlip')) && /flipTexturePreflight:ready/.test(source) && /if \(!prepareStaticPageForFlip\(flip, options\.prewarm \|\| null\)\) \{[\s\S]*return false;[\s\S]*\}/.test(source)],
['webgl fast page flips preflight the actual target spread', /firstFlip\.targetSpread = Number\.isFinite\(Number\(options\.targetSpread\)\)/.test(source) && /if \(!prepareStaticPageForFlip\(firstFlip, options\.prewarm \|\| null\)\) return false/.test(source)],