Rework WebGL book playback to single ownership and fix flip/reveal pipeline

Establish book-playback-timeline as the sole playback owner driving the
scene through formal webgl-book:* events (not the BookLabDebug surface),
with a single reveal clock in the scene render loop and webgl-page-cache as
the only texture cache. Remove the legacy dual playback path and the
ownsPageFlipCommit gating.

Fixes:
- Flip page detached/folded at the spine: restore the raw page-cap line for
  flip geometry (matches the prototype/pre-regression), removing
  normalizeFlipLineToVisiblePage which moved the pivot off the spine arc.
- Flip textures: distance-based UVs (no horizontal compression),
  direction-aware face material (source on the camera-facing side), source
  meta derived from the visible spread (manual flips), prewarm shape fix.
- Reveal: flash removed on the static page and the flip back surface;
  spanning blocks rebuild the reveal plan at activate and continue the
  reveal on the next spread after the fill flip.
- Cache staleness is contentVersion-primary; nav clamps to spreadCount.

Docs updated to describe the intended single-owner architecture. Regression
checks updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 15:29:50 +02:00
parent c19ebe3089
commit 8bb18fa201
9 changed files with 439 additions and 438 deletions
+72 -22
View File
@@ -52,40 +52,90 @@ This section records the current state after the procedural book integration wor
- The custom book material shader includes a small local indirect/bounce-light term for book surfaces. This is not emissive; it is multiplied by material albedo so paper, leather, and head/end-band colors remain distinct while shadowed side faces stay readable. - The custom book material shader includes a small local indirect/bounce-light term for book surfaces. This is not emissive; it is multiplied by material albedo so paper, leather, and head/end-band colors remain distinct while shadowed side faces stay readable.
- Temporary screenshots and generated debug images are not product assets unless explicitly promoted. - Temporary screenshots and generated debug images are not product assets unless explicitly promoted.
## Current Page-Flow Architecture ## Page-Flow Architecture
The 3D book pipeline is module-owned. No page content should be generated by ad hoc scene code. The 3D book is the primary display. A secondary 2D DOM view may be driven from the same content,
but it is never the source of truth and must not own playback decisions. No page content is
generated by ad hoc scene code.
- `ui-display-handler` owns the live 3D prepare -> optional page flip -> activate sequence for story text. ### Single ownership
- `sentence-queue` may prepare future page presentations during lookahead using the same pagination and texture renderer modules.
- `book-pagination` owns page/spread construction, page metadata, widows/orphans/hyphenation decisions, image placement decisions, and explicit blank/title/body page records.
- `book-texture-renderer` owns drawing final page canvases, reveal-region coordinates, reveal timing metadata, and publishing explicit `webgl-book:page-texture-records`.
- `webgl-page-cache` owns persistent page canvases, memory canvases, prepared reveal plans, prepared GPU textures, resident VRAM textures, blank page texture, and visible texture bindings.
- `webgl-book-lab` owns the Three.js scene, materials, geometry, pointer projection, page flip meshes, and consuming page texture records. It must not become a second page cache.
Problem states must be surfaced instead of hidden: There is exactly one playback owner: `book-playback-timeline`. It owns the complete content
lifecycle for story text and is the only module allowed to sequence it:
```
prepare (pagination + textures + prewarm)
-> activate (make the target spread the visible spread)
-> reveal (animate the new block's text in)
-> flip (turn the page when a spread boundary is crossed)
```
- `ui-display-handler` and `sentence-queue` are clients of the owner. They call
`book-playback-timeline.playSentence` (live) and `prepareSentence` (lookahead). They contain no
flip, reveal, or spread-transition logic of their own.
- `book-pagination` owns page/spread construction, page metadata, widows/orphans/hyphenation,
image placement, and explicit blank/title/body page records. It does not decide playback timing.
- `book-texture-renderer` owns drawing final page canvases and computing reveal-region coordinates
and per-region timing metadata. It is a pure renderer: it does **not** run a playback clock, does
**not** decide when reveals start/finish, and does **not** trigger flips. Reactive redraws keyed
off pagination events are forbidden; the owner asks it to draw.
- `webgl-page-cache` is the single texture/canvas cache: persistent canvases, memory canvases,
prepared reveal plans, prepared GPU textures, resident VRAM textures, the blank texture, and
visible texture bindings. No other module may keep a parallel cache.
- `webgl-book-scene` (implemented in `webgl-book-lab.js`) owns the Three.js scene, materials,
geometry, pointer projection, page-flip meshes, the single reveal clock, and consuming page
texture records. It must not become a second page cache and must not decide playback order.
### Single reveal clock
Reveal timing has exactly one authority: the scene render loop. It advances reveal progress,
freezes it during a physical flip, and emits `webgl-book:reveal-committed` when a side's reveal
finishes. No module runs a second `setTimeout`/`requestAnimationFrame` reveal clock in parallel.
The texture renderer supplies region timings; it does not measure their elapsed time.
### Owner-to-scene command channel
The owner drives the scene through the registered scene command interface obtained from the module
registry (`webgl-book-scene`), or through the formal `webgl-book:*` events listed below. The owner
must never reach into `window.BookLabDebug` — that object is a debug/inspection surface only and is
not part of any production control path. Production code must not throw because a debug global is
missing.
### Problem states are surfaced, never hidden
- A missing persistent page canvas during prewarm is a `db-cache-miss`. - A missing persistent page canvas during prewarm is a `db-cache-miss`.
- A missing source or required back texture before a page flip is a `flip-source-texture-missing` or `flip-back-texture-missing`. - A missing source or required back texture before a page flip is a `flip-source-texture-missing`
- These problem states must appear in `webglPageCacheProblems` and must not be silently fixed by borrowing unrelated visible stack textures. or `flip-back-texture-missing`.
- These appear in `webglPageCacheProblems` and must not be silently fixed by borrowing unrelated
visible stack textures. Surfacing a problem must not abort live reading: a transient miss degrades
(blank/last-good texture) and is logged, rather than throwing out of the playback path.
## Event Surface ## Event Surface
The preferred 3D book content path is direct module calls through `ui-display-handler` for live playback and `sentence-queue` for lookahead preparation. The owner controls the scene through the scene command interface (preferred for direct,
ordered calls) and through these formal events. Each event has one producer and stable meaning:
The following events remain formal integration events and must keep their meaning stable while they exist: - `webgl-book:page-texture-records` (owner/renderer -> scene): publishes explicit page texture
records for a spread, carrying `phase` (`prepare`|`activate`) and per-side `visibility`.
- `webgl-book:page-reveal-start` (owner -> scene): starts the scene reveal clock for a block.
- `webgl-book:page-reveal-fast-forward` (owner -> scene): accelerates reveal timing without
replacing the page pipeline.
- `webgl-book:reveal-committed` (scene -> owner): a page-side reveal completed. The owner — not the
scene — decides whether a flip follows.
- `webgl-book:request-page-flip` (owner -> scene): requests a physical page flip.
- `webgl-book:page-flip-started`, `webgl-book:page-flip-near-end`, `webgl-book:page-flip-finished`
(scene -> owner): the physical flip lifecycle, each carrying the resolved `targetSpread`.
- `webgl-book:page-texture-records` publishes explicit page texture records from `book-texture-renderer` to the WebGL scene. The scene reacts to these events; it does not originate flip decisions from `reveal-committed`.
- `webgl-book:page-reveal-start` starts shader reveal timing for the prepared block.
- `webgl-book:page-reveal-fast-forward` accelerates reveal timing without replacing the page pipeline.
- `webgl-book:reveal-committed` reports that a page-side reveal completed; if `pageFlipAfterReveal` is true, the WebGL scene may arm a page flip.
- `webgl-book:request-page-flip` requests a physical page flip through the WebGL scene.
- `webgl-book:page-flip-started`, `webgl-book:page-flip-near-end`, and `webgl-book:page-flip-finished` describe the physical flip lifecycle.
Deprecated or forbidden event contracts: Deprecated or forbidden contracts:
- `webgl-book:page-canvases` is obsolete. New code must use `webgl-book:page-texture-records`. - `webgl-book:page-canvases` is obsolete; use `webgl-book:page-texture-records`.
- `preloadOnly` and `allowFutureUnrendered` are obsolete boolean flags. New code must use explicit `phase` and `visibility` values. - `preloadOnly` and `allowFutureUnrendered` boolean flags are obsolete; use explicit `phase` and
`visibility` values.
- The legacy `ownsPageFlipCommit` toggle and the `book-pagination:spread-updated`-driven reveal/flip
path in `book-texture-renderer` and the scene are removed. There is no second playback path to
gate, so no gating flag exists.
## Non-Negotiable Workflow Rules ## Non-Negotiable Workflow Rules
+194 -71
View File
@@ -1,28 +1,44 @@
/** /**
* Book Playback Timeline Module * Book Playback Timeline Module
* Owns prepared WebGL book playback order: pagination, texture readiness, *
* reveal start, page-flip timing, and visual completion. * The single owner of WebGL book playback. It sequences the full content
* lifecycle for story text:
*
* prepare (pagination + textures + prewarm)
* -> commit (resolve the authoritative target spread)
* -> flip (animate a page turn when a spread boundary is crossed)
* -> 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.
*/ */
import { BaseModule } from './base-module.js'; import { BaseModule } from './base-module.js';
class BookPlaybackTimelineModule extends BaseModule { class BookPlaybackTimelineModule extends BaseModule {
constructor() { constructor() {
super('book-playback-timeline', 'Book Playback Timeline'); super('book-playback-timeline', 'Book Playback Timeline');
this.dependencies = ['book-pagination', 'book-texture-renderer', 'webgl-page-cache', 'playback-coordinator']; this.dependencies = ['book-pagination', 'book-texture-renderer', 'webgl-page-cache', 'playback-coordinator', 'webgl-book-scene'];
this.pagination = null; this.pagination = null;
this.textureRenderer = null; this.textureRenderer = null;
this.pageCache = null; this.pageCache = null;
this.playbackCoordinator = null; this.playbackCoordinator = null;
this.scene = null;
this.activeSegment = null; this.activeSegment = null;
this.preparedSegments = new Map(); this.preparedSegments = new Map();
this.maxPreparedSegments = 48;
this.paginationGeneration = 0;
this.visibleSpreadIndex = 0;
this.timelineDiagnostics = []; this.timelineDiagnostics = [];
this.benchmarkEntries = []; this.benchmarkEntries = [];
this.ownsPageFlipCommit = true;
this.bindMethods([ this.bindMethods([
'initialize', 'initialize',
'playSentence', 'playSentence',
'prepareSentence', 'prepareSentence',
'commitSegmentSpread',
'activatePreparedSegment', 'activatePreparedSegment',
'ensureAnimationTimings', 'ensureAnimationTimings',
'calculateAnimationTiming', 'calculateAnimationTiming',
@@ -37,6 +53,8 @@ class BookPlaybackTimelineModule extends BaseModule {
'requiresRightPageFlipAfterReveal', 'requiresRightPageFlipAfterReveal',
'getBlockRevealSides', 'getBlockRevealSides',
'waitForVisualCompletion', 'waitForVisualCompletion',
'revealContinuationSpread',
'waitForPlannedRightReveal',
'waitForRevealCommit', 'waitForRevealCommit',
'requestPageFlip', 'requestPageFlip',
'prepareFlipPlan', 'prepareFlipPlan',
@@ -45,6 +63,8 @@ class BookPlaybackTimelineModule extends BaseModule {
'getPageMetaForIndex', 'getPageMetaForIndex',
'getVisibleSpreadIndex', 'getVisibleSpreadIndex',
'isChoiceAwaitingPlayer', 'isChoiceAwaitingPlayer',
'invalidatePreparedSegments',
'rememberPreparedSegment',
'markBenchmark', 'markBenchmark',
'timeStage', 'timeStage',
'recordDiagnostic', 'recordDiagnostic',
@@ -57,10 +77,10 @@ class BookPlaybackTimelineModule extends BaseModule {
this.textureRenderer = this.getModule('book-texture-renderer'); this.textureRenderer = this.getModule('book-texture-renderer');
this.pageCache = this.getModule('webgl-page-cache'); this.pageCache = this.getModule('webgl-page-cache');
this.playbackCoordinator = this.getModule('playback-coordinator'); this.playbackCoordinator = this.getModule('playback-coordinator');
this.scene = this.getModule('webgl-book-scene');
this.visibleSpreadIndex = Math.max(0, Math.round(Number(this.pagination?.currentSpreadIndex || 0)));
this.addEventListener(document, 'webgl-book:page-reveal-start', (event) => { this.addEventListener(document, 'webgl-book:page-reveal-start', (event) => {
this.markBenchmark('reveal-start', { this.markBenchmark('reveal-start', { blockId: event.detail?.blockId ?? null });
blockId: event.detail?.blockId ?? null
});
}); });
this.addEventListener(document, 'webgl-book:reveal-committed', (event) => { this.addEventListener(document, 'webgl-book:reveal-committed', (event) => {
this.markBenchmark('reveal-committed', { this.markBenchmark('reveal-committed', {
@@ -73,8 +93,12 @@ class BookPlaybackTimelineModule extends BaseModule {
this.markBenchmark('flip-started', event.detail || {}); this.markBenchmark('flip-started', event.detail || {});
}); });
this.addEventListener(document, 'webgl-book:page-flip-finished', (event) => { this.addEventListener(document, 'webgl-book:page-flip-finished', (event) => {
const targetSpread = Number(event.detail?.targetSpread);
if (Number.isFinite(targetSpread)) this.visibleSpreadIndex = Math.max(0, Math.round(targetSpread));
this.markBenchmark('flip-finished', event.detail || {}); this.markBenchmark('flip-finished', event.detail || {});
}); });
this.addEventListener(document, 'webgl-book:page-count-changed', this.invalidatePreparedSegments);
this.addEventListener(document, 'story:history-restoring', this.invalidatePreparedSegments);
window.BookPlaybackTimeline = this; window.BookPlaybackTimeline = this;
this.reportProgress(100, 'Book playback timeline ready'); this.reportProgress(100, 'Book playback timeline ready');
return true; return true;
@@ -89,49 +113,62 @@ class BookPlaybackTimelineModule extends BaseModule {
} }
this.activeSegment = segment; this.activeSegment = segment;
document.documentElement.dataset.webglBookPlaybackActive = 'true';
this.recordDiagnostic('segment-play:start', segment); this.recordDiagnostic('segment-play:start', segment);
if (this.requiresSpreadTransition(segment)) { try {
const flipped = await this.timeStage('preplay-flip', segment, () => this.requestPageFlip(1, { // Commit pagination first so the flip targets the authoritative spread,
reason: 'timeline-preplay-spread-transition', // not the predicted preview spread.
targetSpread: segment.targetSpreadIndex, await this.timeStage('commit', segment, () => this.commitSegmentSpread(segment, sentence));
force: true
})); if (this.requiresSpreadTransition(segment)) {
if (!flipped) { const flipped = await this.timeStage('preplay-flip', segment, () => this.requestPageFlip(1, {
this.pageCache?.recordProblem?.({ reason: 'timeline-preplay-spread-transition',
type: 'timeline-preplay-flip-failed', targetSpread: segment.targetSpreadIndex,
blockId: segment.blockId, // The block reveals on these sides right after the flip; the scene must
targetSpread: segment.targetSpreadIndex // not flash their full (unmasked) content during the flip's near-end
}); // texture swap — activate will land the masked reveal instead.
revealSides: segment.revealSides,
force: true
}));
if (!flipped) {
this.pageCache?.recordProblem?.({
type: 'timeline-preplay-flip-failed',
blockId: segment.blockId,
targetSpread: segment.targetSpreadIndex
});
}
} }
await this.timeStage('activate', segment, () => this.activatePreparedSegment(segment, sentence));
sentence.webglRevealController = () => this.startRevealForSegment(segment);
const playbackPromise = this.timeStage('playback', segment, () => {
return this.playbackCoordinator?.play?.(sentence) || Promise.resolve();
});
const visualPromise = this.waitForVisualCompletion(segment);
await Promise.all([playbackPromise, visualPromise]);
} finally {
this.recordDiagnostic('segment-play:end', segment);
if (this.activeSegment?.key === segment.key) this.activeSegment = null;
delete document.documentElement.dataset.webglBookPlaybackActive;
} }
await this.timeStage('activate', segment, () => this.activatePreparedSegment(segment, sentence));
sentence.webglRevealController = () => this.startRevealForSegment(segment);
const playbackPromise = this.timeStage('playback', segment, () => {
return this.playbackCoordinator?.play?.(sentence) || Promise.resolve();
});
const visualPromise = this.waitForVisualCompletion(segment);
await Promise.all([playbackPromise, visualPromise]);
this.recordDiagnostic('segment-play:end', segment);
if (this.activeSegment?.key === segment.key) this.activeSegment = null;
return segment; return segment;
} }
async prepareSentence(sentence = {}, options = {}) { async prepareSentence(sentence = {}, options = {}) {
if (!sentence || sentence.blockId == null || !this.pagination || !this.textureRenderer) return null; if (!sentence || sentence.blockId == null || !this.pagination || !this.textureRenderer) return null;
const key = `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${sentence.blockId}`; const key = `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${sentence.blockId}`;
const existing = sentence.webglBookPresentation?.timelineSegment || this.preparedSegments.get(key); const cached = sentence.webglBookPresentation?.timelineSegment || this.preparedSegments.get(key);
if (existing && options.force !== true) return existing; const reusable = cached && cached.generation === this.paginationGeneration;
if (reusable && options.force !== true) return cached;
this.ensureAnimationTimings(sentence); this.ensureAnimationTimings(sentence);
const segment = await this.timeStage(options.immediate === true ? 'segment-prepare-immediate' : 'segment-prepare-lookahead', { const segment = await this.timeStage(options.immediate === true ? 'segment-prepare-immediate' : 'segment-prepare-lookahead', {
blockId: sentence.blockId, blockId: sentence.blockId,
id: sentence.id id: sentence.id
}, () => this.createPreparedSegment(sentence, options)); }, () => this.createPreparedSegment(sentence, options));
if (!segment) return null; if (!segment) return null;
this.preparedSegments.set(segment.key, segment); this.rememberPreparedSegment(segment);
sentence.webglBookPresentation = { sentence.webglBookPresentation = {
...(sentence.webglBookPresentation || {}), ...(sentence.webglBookPresentation || {}),
prepared: true, prepared: true,
@@ -143,6 +180,21 @@ class BookPlaybackTimelineModule extends BaseModule {
return segment; return segment;
} }
rememberPreparedSegment(segment = {}) {
if (!segment?.key) return;
this.preparedSegments.delete(segment.key);
this.preparedSegments.set(segment.key, segment);
while (this.preparedSegments.size > this.maxPreparedSegments) {
const oldestKey = this.preparedSegments.keys().next().value;
this.preparedSegments.delete(oldestKey);
}
}
invalidatePreparedSegments() {
this.paginationGeneration += 1;
this.preparedSegments.clear();
}
async createPreparedSegment(sentence = {}, options = {}) { async createPreparedSegment(sentence = {}, options = {}) {
const previewSpread = sentence.webglBookPresentation?.spread || await this.pagination.preparePendingBlock(sentence, { const previewSpread = sentence.webglBookPresentation?.spread || await this.pagination.preparePendingBlock(sentence, {
activate: false, activate: false,
@@ -165,6 +217,7 @@ class BookPlaybackTimelineModule extends BaseModule {
id: sentence.id, id: sentence.id,
blockId: sentence.blockId, blockId: sentence.blockId,
sentence, sentence,
generation: this.paginationGeneration,
previewSpread, previewSpread,
targetSpreadIndex, targetSpreadIndex,
currentSpreadIndex, currentSpreadIndex,
@@ -191,7 +244,7 @@ class BookPlaybackTimelineModule extends BaseModule {
return segment; return segment;
} }
async activatePreparedSegment(segment = {}, sentence = segment.sentence) { async commitSegmentSpread(segment = {}, sentence = segment.sentence) {
if (!segment || !sentence) return null; if (!segment || !sentence) return null;
const activeSpread = await this.pagination.preparePendingBlock(sentence, { const activeSpread = await this.pagination.preparePendingBlock(sentence, {
includeUnrenderedHistory: true includeUnrenderedHistory: true
@@ -199,19 +252,40 @@ class BookPlaybackTimelineModule extends BaseModule {
segment.activeSpread = activeSpread || segment.previewSpread; segment.activeSpread = activeSpread || segment.previewSpread;
segment.targetSpreadIndex = Math.max(0, Number(segment.activeSpread?.index ?? segment.targetSpreadIndex ?? 0)); segment.targetSpreadIndex = Math.max(0, Number(segment.activeSpread?.index ?? segment.targetSpreadIndex ?? 0));
segment.revealSides = this.getBlockRevealSides(segment.activeSpread || segment.previewSpread, sentence.blockId); segment.revealSides = this.getBlockRevealSides(segment.activeSpread || segment.previewSpread, sentence.blockId);
// Does the block overflow onto the next spread? The committed pagination now knows
// this (during lookahead it was not yet committed), so detect it here.
const nextSpread = typeof this.pagination?.getSpread === 'function'
? this.pagination.getSpread(segment.targetSpreadIndex + 1)
: this.pagination?.spreads?.[segment.targetSpreadIndex + 1];
segment.spansToNextSpread = Boolean(nextSpread)
&& this.getBlockRevealSides(nextSpread, sentence.blockId).length > 0;
// A spanning block, or one that fills the right page, must flip to keep revealing
// its continuation rather than leaving the right page's last line to absorb the
// whole TTS while the rest pops in complete after the flip.
segment.requiresRightFlip = segment.revealSides.includes('right') segment.requiresRightFlip = segment.revealSides.includes('right')
&& this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread); && (segment.spansToNextSpread || this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread));
this.recordDiagnostic('segment-commit:end', segment);
return segment.activeSpread;
}
const revealDetail = this.createRevealDetail(sentence, segment.activeSpread || segment.previewSpread, 'activate'); async activatePreparedSegment(segment = {}, sentence = segment.sentence) {
if (!segment || !sentence) return null;
const spread = segment.activeSpread || segment.previewSpread;
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
// For a spanning block the prepared reveal plan was built during lookahead before
// the continuation was committed, so it is right-only. Rebuild from the committed
// spreads so the reveal timing spans both pages (right page no longer absorbs the
// whole duration) and the continuation has reveal regions.
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, {
publishEvent: false publishEvent: false,
forceRebuild: segment.spansToNextSpread === true
}); });
segment.activeTexturePlan = texturePlan; segment.activeTexturePlan = texturePlan;
this.applyTexturePlan(texturePlan, segment, 'activate'); this.applyTexturePlan(texturePlan, segment, 'activate');
await this.assertSegmentReady(segment, 'activate'); await this.assertSegmentReady(segment, 'activate');
segment.status = 'activated'; segment.status = 'activated';
this.recordDiagnostic('segment-activate:end', segment); this.recordDiagnostic('segment-activate:end', segment);
return segment.activeSpread; return spread;
} }
ensureAnimationTimings(sentence = {}) { ensureAnimationTimings(sentence = {}) {
@@ -285,31 +359,37 @@ class BookPlaybackTimelineModule extends BaseModule {
applyTexturePlan(texturePlan = null, segment = {}, phase = 'activate') { applyTexturePlan(texturePlan = null, segment = {}, phase = 'activate') {
if (!texturePlan) { if (!texturePlan) {
throw new Error(`BookPlaybackTimeline: Missing texture plan for block ${segment.blockId ?? 'unknown'} during ${phase}`); this.pageCache?.recordProblem?.({
type: 'timeline-missing-texture-plan',
blockId: segment.blockId ?? null,
phase
});
return false;
} }
if (typeof window.BookLabDebug?.applyPageTextureRecords !== 'function') { document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', {
throw new Error('BookPlaybackTimeline: WebGL book lab cannot apply prepared texture plans'); detail: {
} ...texturePlan,
window.BookLabDebug.applyPageTextureRecords({ phase: phase === 'prepare' ? 'prepare' : 'activate'
...texturePlan, }
phase: phase === 'prepare' ? 'prepare' : 'activate' }));
});
this.recordDiagnostic(`texture-plan-applied:${phase}`, segment); this.recordDiagnostic(`texture-plan-applied:${phase}`, segment);
return true; return true;
} }
startRevealForSegment(segment = {}) { startRevealForSegment(segment = {}) {
if (!segment?.blockId) return false; if (!segment?.blockId) return false;
// Mark the renderer animation as started, then let the scene render loop —
// the single reveal clock — drive timing via the dispatched reveal-start event.
const revealStart = this.textureRenderer?.startPreparedRevealAnimation?.(segment.blockId, { const revealStart = this.textureRenderer?.startPreparedRevealAnimation?.(segment.blockId, {
publishEvent: false publishEvent: true
}); });
if (!revealStart) { if (!revealStart) {
throw new Error(`BookPlaybackTimeline: Prepared reveal animation is missing for block ${segment.blockId}`); this.pageCache?.recordProblem?.({
type: 'timeline-prepared-reveal-missing',
blockId: segment.blockId
});
return false;
} }
if (typeof window.BookLabDebug?.startRevealForBlock !== 'function') {
throw new Error('BookPlaybackTimeline: WebGL book lab cannot start prepared reveals explicitly');
}
window.BookLabDebug.startRevealForBlock(segment.blockId);
segment.revealStartedAt = performance.now(); segment.revealStartedAt = performance.now();
if (typeof segment.resolveRevealStarted === 'function') { if (typeof segment.resolveRevealStarted === 'function') {
segment.resolveRevealStarted(segment.revealStartedAt); segment.resolveRevealStarted(segment.revealStartedAt);
@@ -351,11 +431,46 @@ class BookPlaybackTimelineModule extends BaseModule {
} }
const committed = await this.timeStage('wait-right-reveal-commit', segment, () => this.waitForPlannedRightReveal(segment)); const committed = await this.timeStage('wait-right-reveal-commit', segment, () => this.waitForPlannedRightReveal(segment));
if (!committed || this.isChoiceAwaitingPlayer()) return; if (!committed || this.isChoiceAwaitingPlayer()) return;
await this.timeStage('right-page-flip', segment, () => this.requestPageFlip(1, { const continuationSpreadIndex = Math.max(0, Number(segment.targetSpreadIndex || this.getVisibleSpreadIndex()) + 1);
const continuationSpread = typeof this.pagination?.getSpread === 'function'
? this.pagination.getSpread(continuationSpreadIndex)
: this.pagination?.spreads?.[continuationSpreadIndex];
// If the block continues onto the next spread, that page must keep revealing the
// carried-over lines after the flip instead of appearing already complete.
const continuationSides = continuationSpread ? this.getBlockRevealSides(continuationSpread, segment.blockId) : [];
const flipped = await this.timeStage('right-page-flip', segment, () => this.requestPageFlip(1, {
reason: 'timeline-right-page-filled', reason: 'timeline-right-page-filled',
targetSpread: Math.max(0, Number(segment.targetSpreadIndex || this.getVisibleSpreadIndex()) + 1), targetSpread: continuationSpreadIndex,
revealSides: continuationSides,
force: true force: true
})); }));
if (flipped && continuationSides.length > 0) {
await this.timeStage('reveal-continuation', segment, () => this.revealContinuationSpread(segment, continuationSpread));
}
}
// Re-apply the active block's reveal on the spread it continues onto. The renderer
// already produces reveal regions for that spread with global (continuous) timing;
// the scene resumes the same reveal clock (the block's original start persists), so
// the carried-over lines animate in instead of popping in fully revealed.
async revealContinuationSpread(segment = {}, spread = null) {
const sentence = segment.sentence;
if (!sentence || !spread) return false;
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
if (!texturePlan) {
this.pageCache?.recordProblem?.({
type: 'timeline-reveal-continuation-missing',
blockId: segment.blockId,
spreadIndex: Number(spread.index ?? null)
});
return false;
}
segment.activeTexturePlan = texturePlan;
this.applyTexturePlan(texturePlan, segment, 'activate');
await this.assertSegmentReady(segment, 'activate');
this.recordDiagnostic('reveal-continuation:applied', segment);
return true;
} }
async waitForPlannedRightReveal(segment = {}) { async waitForPlannedRightReveal(segment = {}) {
@@ -419,23 +534,25 @@ class BookPlaybackTimelineModule extends BaseModule {
async requestPageFlip(direction = 1, options = {}) { async requestPageFlip(direction = 1, options = {}) {
if (this.isChoiceAwaitingPlayer()) return false; if (this.isChoiceAwaitingPlayer()) return false;
const flipPlan = await this.prepareFlipPlan(direction, options); // 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);
await this.assertSegmentReady({ await this.assertSegmentReady({
blockId: options.blockId ?? null, blockId: options.blockId ?? null,
targetSpreadIndex: options.targetSpread, targetSpreadIndex: options.targetSpread,
revealSides: [] revealSides: []
}, 'flip'); }, 'flip');
const wait = this.waitForPageFlipFinished(options.targetSpread); const wait = this.waitForPageFlipFinished(options.targetSpread);
if (typeof window.BookLabDebug?.requestPageFlip !== 'function') { document.dispatchEvent(new CustomEvent('webgl-book:request-page-flip', {
throw new Error('BookPlaybackTimeline: WebGL book lab cannot execute prepared flip plans'); detail: {
} direction,
window.BookLabDebug.requestPageFlip(direction, { force: options.force === true,
force: options.force === true, reason: options.reason || 'timeline',
reason: options.reason || 'timeline', targetSpread: options.targetSpread,
targetSpread: options.targetSpread, revealSides: Array.isArray(options.revealSides) ? options.revealSides : null
prewarm: flipPlan.prewarm, }
flipPlan }));
});
return wait; return wait;
} }
@@ -525,7 +642,8 @@ class BookPlaybackTimelineModule extends BaseModule {
async assertSegmentReady(segment = {}, phase = 'play') { async assertSegmentReady(segment = {}, phase = 'play') {
if (!this.pageCache || typeof this.pageCache.ensurePageTexture !== 'function') { if (!this.pageCache || typeof this.pageCache.ensurePageTexture !== 'function') {
throw new Error('BookPlaybackTimeline: Page texture cache is not available'); this.recordDiagnostic(`cache-unavailable:${phase}`, segment);
return false;
} }
const metas = this.collectRequiredPageMetas(segment, phase); const metas = this.collectRequiredPageMetas(segment, phase);
const missing = []; const missing = [];
@@ -536,13 +654,16 @@ class BookPlaybackTimelineModule extends BaseModule {
if (!texture) missing.push(meta); if (!texture) missing.push(meta);
})); }));
if (missing.length > 0) { if (missing.length > 0) {
// Surface the problem but do not throw out of the live playback path.
this.pageCache.recordProblem?.({ this.pageCache.recordProblem?.({
type: 'timeline-cache-readiness-failed', type: 'timeline-cache-readiness-failed',
phase, phase,
blockId: segment.blockId ?? null, blockId: segment.blockId ?? null,
missingPages: missing.map(meta => meta.pageIndex ?? null) missingPages: missing.map(meta => meta.pageIndex ?? null)
}); });
throw new Error(`BookPlaybackTimeline: Cache readiness failed during ${phase} for pages ${missing.map(meta => meta.pageIndex).join(', ')}`); segment.cacheReady = false;
segment.cacheReadyPhase = phase;
return false;
} }
segment.cacheReady = true; segment.cacheReady = true;
segment.cacheReadyPhase = phase; segment.cacheReadyPhase = phase;
@@ -623,8 +744,9 @@ class BookPlaybackTimelineModule extends BaseModule {
} }
getVisibleSpreadIndex() { getVisibleSpreadIndex() {
const labSpread = window.BookLabDebug?.getBookState?.()?.spreadIndex; const sceneSpread = this.scene?.getVisibleSpreadIndex?.();
if (Number.isFinite(Number(labSpread))) return Math.max(0, Math.round(Number(labSpread))); if (Number.isFinite(Number(sceneSpread))) return Math.max(0, Math.round(Number(sceneSpread)));
if (Number.isFinite(Number(this.visibleSpreadIndex))) return Math.max(0, Math.round(Number(this.visibleSpreadIndex)));
return Math.max(0, Math.round(Number(this.pagination?.currentSpreadIndex || 0))); return Math.max(0, Math.round(Number(this.pagination?.currentSpreadIndex || 0)));
} }
@@ -692,7 +814,8 @@ class BookPlaybackTimelineModule extends BaseModule {
return { return {
activeBlockId: this.activeSegment?.blockId ?? null, activeBlockId: this.activeSegment?.blockId ?? null,
preparedSegmentCount: this.preparedSegments.size, preparedSegmentCount: this.preparedSegments.size,
ownsPageFlipCommit: this.ownsPageFlipCommit, paginationGeneration: this.paginationGeneration,
visibleSpreadIndex: this.visibleSpreadIndex,
diagnostics: this.timelineDiagnostics.slice(-20), diagnostics: this.timelineDiagnostics.slice(-20),
benchmark: this.benchmarkEntries.slice(-40) benchmark: this.benchmarkEntries.slice(-40)
}; };
+7 -172
View File
@@ -29,14 +29,10 @@ class BookTextureRendererModule extends BaseModule {
this.currentSpread = null; this.currentSpread = null;
this.activeAnimations = new Map(); this.activeAnimations = new Map();
this.revealedBlockIds = new Set(); this.revealedBlockIds = new Set();
this.pendingRevealBlockIds = new Set();
this.revealBaseCanvases = null; this.revealBaseCanvases = null;
this.revealPublishBlockIds = null; this.revealPublishBlockIds = null;
this.lastDrawSignature = null; this.lastDrawSignature = null;
this.lastDrawSkipLoggedAt = 0; this.lastDrawSkipLoggedAt = 0;
this.animationFrameId = null;
this.lastAnimationFrameAt = 0;
this.targetFrameDurationMs = 1000 / 60;
this.pipelineTimings = []; this.pipelineTimings = [];
this.imageCache = new Map(); this.imageCache = new Map();
this.pageContentVersions = new Map(); this.pageContentVersions = new Map();
@@ -76,7 +72,6 @@ class BookTextureRendererModule extends BaseModule {
'applyTextStyle', 'applyTextStyle',
'getPageContent', 'getPageContent',
'buildLineSegments', 'buildLineSegments',
'startRevealAnimation',
'prepareRevealBlock', 'prepareRevealBlock',
'preloadAdditionalRevealSpreads', 'preloadAdditionalRevealSpreads',
'spreadContainsBlock', 'spreadContainsBlock',
@@ -88,9 +83,6 @@ class BookTextureRendererModule extends BaseModule {
'stopAnimations', 'stopAnimations',
'getBlockSides', 'getBlockSides',
'getAnimatedSides', 'getAnimatedSides',
'markPendingReveal',
'requestAnimationFrame',
'tickAnimations',
'publishSpread', 'publishSpread',
'buildPageTextureRecords', 'buildPageTextureRecords',
'cachePublishedPages', 'cachePublishedPages',
@@ -114,62 +106,9 @@ class BookTextureRendererModule extends BaseModule {
this.reportProgress(20, 'Preparing page texture canvases'); this.reportProgress(20, 'Preparing page texture canvases');
this.createPageCanvases(); this.createPageCanvases();
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged); this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
this.addEventListener(document, 'book-pagination:spread-updated', (event) => { // The renderer is a pure renderer. It does not react to pagination spread
const spread = event.detail?.spread || this.pagination?.getCurrentSpread?.(); // updates with draws or reveals — the playback owner (book-playback-timeline)
const spreadIndex = Math.max(0, Number(event.detail?.spreadIndex ?? spread?.index ?? 0)); // drives every draw explicitly. See docs/webgl-3d-ui-spec.md "Single ownership".
const latestBlockId = event.detail?.latestBlockId;
const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0));
const visibility = event.detail?.visibility || 'current';
this.currentSpread = spread || { left: [], right: [] };
const timelineOwnsPlayback = window.BookPlaybackTimeline?.ownsPageFlipCommit === true;
if (document.documentElement.dataset.webglPageFlipActive === 'true' && this.activeAnimations.size === 0) {
this.markPipelineTiming('spreadUpdate:skip-during-flip', {
spreadIndex,
visibility
});
return;
}
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) {
this.markPendingReveal(latestBlockId);
const id = String(latestBlockId);
if (timelineOwnsPlayback && visibility !== 'future-ready') {
this.markPipelineTiming('spreadUpdate:skip-timeline-owned-reveal', {
spreadIndex,
latestBlockId: id,
visibility
});
return;
}
if (visibility === 'future-ready' && !this.activeAnimations.has(id)) {
this.drawSpread(this.stripUnrenderedLines(this.currentSpread, latestRenderedBlockId), ['left', 'right'], {
phase: 'prepare',
publishEvent: !timelineOwnsPlayback
});
return;
}
if (this.activeAnimations.has(id)) {
this.revealPublishBlockIds = new Set([id]);
const visibleSpread = Math.max(0, Number(window.BookLabDebug?.getBookState?.().spreadIndex || 0));
const flipActive = document.documentElement.dataset.webglPageFlipActive === 'true';
if (!flipActive && visibility !== 'future-ready' && spreadIndex > visibleSpread) {
this.drawSpread(this.currentSpread, ['left', 'right'], { phase: 'prepare' });
return;
}
this.drawSpread(this.currentSpread, ['left', 'right']);
}
return;
}
if (timelineOwnsPlayback && visibility !== 'future-ready' && latestBlockId) {
this.markPipelineTiming('spreadUpdate:skip-timeline-owned-commit', {
spreadIndex,
latestBlockId,
latestRenderedBlockId,
visibility
});
return;
}
this.drawSpread(this.currentSpread);
});
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations); this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
this.addEventListener(document, 'webgl-book:reveal-committed', (event) => { this.addEventListener(document, 'webgl-book:reveal-committed', (event) => {
this.completeRevealBlockIds(event.detail?.blockIds || []); this.completeRevealBlockIds(event.detail?.blockIds || []);
@@ -185,18 +124,6 @@ class BookTextureRendererModule extends BaseModule {
return true; return true;
} }
stripUnrenderedLines(spread = {}, latestRenderedBlockId = 0) {
const latestRendered = Math.max(0, Number(latestRenderedBlockId || 0));
return {
...spread,
left: (Array.isArray(spread.left) ? spread.left : [])
.filter(line => Math.max(0, Number(line?.blockId || 0)) <= latestRendered),
right: (Array.isArray(spread.right) ? spread.right : [])
.filter(line => Math.max(0, Number(line?.blockId || 0)) <= latestRendered),
pageMeta: spread.pageMeta || { left: null, right: null }
};
}
markPipelineTiming(name, detail = {}) { markPipelineTiming(name, detail = {}) {
const entry = { const entry = {
name, name,
@@ -916,35 +843,6 @@ class BookTextureRendererModule extends BaseModule {
return Number.isFinite(explicit) && explicit > 0 ? explicit : 2000; return Number.isFinite(explicit) && explicit > 0 ? explicit : 2000;
} }
startRevealAnimation(detail = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const existing = this.activeAnimations.get(String(blockId));
if (existing && existing.prepared) {
this.startPreparedRevealAnimation(blockId);
return;
}
this.activeAnimations.set(String(blockId), {
blockId,
wordTimings: detail.wordTimings,
startedAt: performance.now(),
totalDuration: Math.max(
Number(detail.totalDuration || 0),
...detail.wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
),
completed: false
});
this.pendingRevealBlockIds.delete(String(blockId));
this.revealPublishBlockIds = new Set([String(blockId)]);
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), ['left', 'right']);
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
detail: {
blockId
}
}));
this.requestAnimationFrame();
}
createAnimationState(blockId, wordTimings = [], detail = {}) { createAnimationState(blockId, wordTimings = [], detail = {}) {
return { return {
blockId, blockId,
@@ -972,10 +870,12 @@ class BookTextureRendererModule extends BaseModule {
wordTimingCount: wordTimings.length, wordTimingCount: wordTimings.length,
phase phase
}); });
if (phase === 'activate' && this.pageCache?.hasPreparedRevealPlan?.(id)) { // forceRebuild: the cached plan was built before the block's continuation was
// committed (it would be right-only). Discard it and redraw from current spreads.
if (options.forceRebuild === true) this.pageCache?.takePreparedRevealPlan?.(id);
if (phase === 'activate' && options.forceRebuild !== true && this.pageCache?.hasPreparedRevealPlan?.(id)) {
const cached = this.pageCache.takePreparedRevealPlan(id); const cached = this.pageCache.takePreparedRevealPlan(id);
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail)); this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
this.pendingRevealBlockIds.delete(id);
this.publishPreparedReveal(cached, options); this.publishPreparedReveal(cached, options);
this.markPipelineTiming('prepareRevealBlock:end', { this.markPipelineTiming('prepareRevealBlock:end', {
blockId: id, blockId: id,
@@ -990,7 +890,6 @@ class BookTextureRendererModule extends BaseModule {
} }
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail)); this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
this.pendingRevealBlockIds.delete(id);
this.revealPublishBlockIds = new Set([id]); this.revealPublishBlockIds = new Set([id]);
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.(); const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
const sides = ['left', 'right']; const sides = ['left', 'right'];
@@ -1079,7 +978,6 @@ class BookTextureRendererModule extends BaseModule {
} }
})); }));
} }
this.requestAnimationFrame();
return { return {
blockId: animation.blockId, blockId: animation.blockId,
wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0 wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0
@@ -1098,7 +996,6 @@ class BookTextureRendererModule extends BaseModule {
} }
}); });
if (changed) { if (changed) {
this.pendingRevealBlockIds.clear();
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', { document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
detail: { detail: {
blockIds blockIds
@@ -1115,17 +1012,11 @@ class BookTextureRendererModule extends BaseModule {
const animation = this.activeAnimations.get(id); const animation = this.activeAnimations.get(id);
if (animation) animation.completed = true; if (animation) animation.completed = true;
this.revealedBlockIds.add(id); this.revealedBlockIds.add(id);
this.pendingRevealBlockIds.delete(id);
}); });
} }
stopAnimations() { stopAnimations() {
this.activeAnimations.clear(); this.activeAnimations.clear();
this.pendingRevealBlockIds.clear();
if (this.animationFrameId) {
clearTimeout(this.animationFrameId);
this.animationFrameId = null;
}
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
} }
@@ -1151,62 +1042,6 @@ class BookTextureRendererModule extends BaseModule {
return sides.length ? sides : ['left', 'right']; return sides.length ? sides : ['left', 'right'];
} }
markPendingReveal(blockId) {
const id = String(blockId ?? '');
if (!id || this.activeAnimations.has(id) || this.revealedBlockIds.has(id)) return;
this.pendingRevealBlockIds.add(id);
}
requestAnimationFrame() {
if (this.animationFrameId) return;
this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs);
}
isWebGLPageFlipActive() {
return document.documentElement.dataset.webglPageFlipActive === 'true';
}
tickAnimations(now) {
this.animationFrameId = null;
if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) {
this.requestAnimationFrame();
return;
}
this.lastAnimationFrameAt = now;
let hasActive = false;
const currentNow = performance.now();
if (this.isWebGLPageFlipActive()) {
this.activeAnimations.forEach((animation) => {
if (animation.completed) return;
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
hasActive = true;
if (animation.startedAt != null) {
animation.startedAt += this.targetFrameDurationMs;
}
});
if (hasActive) this.requestAnimationFrame();
return;
}
this.activeAnimations.forEach((animation) => {
if (animation.completed) return;
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
if (animation.startedAt == null) {
hasActive = true;
return;
}
const lastTiming = animation.wordTimings.at(-1);
const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0);
if (currentNow - animation.startedAt >= total + 50) {
animation.completed = true;
this.revealedBlockIds.add(String(animation.blockId ?? ''));
} else {
hasActive = true;
}
});
if (hasActive) this.requestAnimationFrame();
}
publishSpread(sides = null, options = {}) { publishSpread(sides = null, options = {}) {
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const phase = this.getDrawPhase(options); const phase = this.getDrawPhase(options);
+1 -3
View File
@@ -526,9 +526,7 @@ class GameLoopModule extends BaseModule {
} }
getWebGLBookState() { getWebGLBookState() {
return window.WebGLBookPreferenceBridge?.getBookState?.() return window.WebGLBookPreferenceBridge?.getBookState?.() || null;
|| window.BookLabDebug?.getBookState?.()
|| null;
} }
applyWebGLBookState(state = null) { applyWebGLBookState(state = null) {
+5 -19
View File
@@ -309,29 +309,15 @@ class PlaybackCoordinatorModule extends BaseModule {
} }
scheduleWebGLReveal(sentence, animQueue) { scheduleWebGLReveal(sentence, animQueue) {
let wordTimings = Array.isArray(sentence.animation?.wordTimings) // The book playback timeline is the single owner of reveal timing. It guarantees
// sentence.animation is populated (ensureAnimationTimings) before playback. The
// coordinator trusts those timings and never recomputes them here.
const wordTimings = Array.isArray(sentence.animation?.wordTimings)
? sentence.animation.wordTimings ? sentence.animation.wordTimings
: []; : [];
let cueTimings = Array.isArray(sentence.animation?.cueTimings) const cueTimings = Array.isArray(sentence.animation?.cueTimings)
? sentence.animation.cueTimings ? sentence.animation.cueTimings
: []; : [];
const timingDuration = wordTimings.reduce((max, timing) => Math.max(
max,
Number(timing?.delay || 0) + Number(timing?.duration || 0)
), Number(sentence.animation?.totalDuration || 0));
const ttsDuration = Number(sentence.tts?.duration || sentence.animation?.totalDuration || 0);
if (wordTimings.length === 0 || (timingDuration <= 0 && ttsDuration > 0)) {
const words = String(sentence.text || '').match(/\S+/g) || [];
const calculated = this.calculateWordTimings(words, ttsDuration);
wordTimings = calculated.wordTimings;
cueTimings = [];
sentence.animation = {
...(sentence.animation || {}),
wordTimings,
cueTimings,
totalDuration: calculated.totalDuration
};
}
if (typeof sentence.webglRevealController !== 'function') { if (typeof sentence.webglRevealController !== 'function') {
throw new Error('PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller'); throw new Error('PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller');
+118 -115
View File
@@ -275,8 +275,6 @@ let currentPageMeta = {
left: null, left: null,
right: null right: null
}; };
let pendingRightPageFlip = false;
let pendingRightPageFlipAutoplay = false;
const pageRevealState = { const pageRevealState = {
left: null, left: null,
right: null right: null
@@ -655,6 +653,24 @@ window.BookLabDebug = {
} }
}; };
// Publish the visible spread as a production accessor on the scene module so the
// playback owner can read it without touching the debug surface (window.BookLabDebug).
const webglBookSceneModule = window.moduleRegistry?.getModule?.('webgl-book-scene') || null;
if (webglBookSceneModule) {
webglBookSceneModule.getVisibleSpreadIndex = () => Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
// Production control surface for the scene host (webgl-book-scene) and save/restore.
// window.BookLabDebug remains a debug/inspection-only alias; production code uses this.
webglBookSceneModule.sceneControl = {
getBookState: () => window.BookLabDebug.getBookState(),
setReadingProgress: (value) => setReadingProgress(value),
setBookPageCount: (value) => setBookPageCount(value),
setPageReserve: (value) => setPageReserve(value),
setMaxVisitedPagePosition: (value) => window.BookLabDebug.setMaxVisitedPagePosition(value),
redrawPageTextures: () => window.BookLabDebug.redrawPageTextures(),
projectPointerToPage: (clientX, clientY) => projectPointerToPage(clientX, clientY)
};
}
window.addEventListener('resize', resize); window.addEventListener('resize', resize);
document.addEventListener('webgl-book:page-texture-records', handlePageTextureRecords); document.addEventListener('webgl-book:page-texture-records', handlePageTextureRecords);
document.addEventListener('webgl-book:page-reveal-start', (event) => { document.addEventListener('webgl-book:page-reveal-start', (event) => {
@@ -663,27 +679,38 @@ document.addEventListener('webgl-book:page-reveal-start', (event) => {
document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => { document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
fastForwardPageReveals(event.detail?.blockIds || []); fastForwardPageReveals(event.detail?.blockIds || []);
}); });
document.addEventListener('webgl-book:reveal-committed', (event) => { document.addEventListener('webgl-book:request-page-flip', (event) => {
handleRevealCommittedForPageFlip(event.detail || {}); const detail = event.detail || {};
const direction = Number(detail.direction) || (detail.targetSpread > bookPaginationState.spreadIndex ? 1 : -1);
// Let the scene own flip prewarming via prewarmFlipTextures (which draws and
// makes resident the current + target spreads). The owner's cache-warming plan
// is a different shape and must not be passed through as the flip prewarm.
startPageFlip(direction, {
force: detail.force === true,
reason: detail.reason,
targetSpread: detail.targetSpread,
deferRevealSides: Array.isArray(detail.revealSides) ? detail.revealSides : null
});
}); });
document.addEventListener('webgl-book:page-cache-problem', (event) => { document.addEventListener('webgl-book:page-cache-problem', (event) => {
pageTextureStore?.recordProblem?.(event.detail || {}); pageTextureStore?.recordProblem?.(event.detail || {});
}); });
// 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
// commits such as history restore. See docs/webgl-3d-ui-spec.md "Single ownership".
document.addEventListener('book-pagination:spread-updated', (event) => { document.addEventListener('book-pagination:spread-updated', (event) => {
const detail = event.detail || {}; const detail = event.detail || {};
const incomingSpreadIndex = Math.max(0, Number(detail.spreadIndex || 0)); const incomingSpreadIndex = Math.max(0, Number(detail.spreadIndex || 0));
const latestBlockId = Math.max(0, Number(detail.latestBlockId || 0)); const playbackActive = document.documentElement.dataset.webglBookPlaybackActive === 'true';
const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0)); const stateOnly = playbackActive
if ( || activeFlips.length > 0
window.BookPlaybackTimeline?.ownsPageFlipCommit === true || detail.visibility === 'future-ready';
&& detail.visibility !== 'future-ready' if (stateOnly) {
&& latestBlockId > 0 markPageTextureTiming('spreadUpdate:state-only', {
) {
markPageTextureTiming('spreadUpdate:timeline-owned-state-only', {
incomingSpreadIndex, incomingSpreadIndex,
visibleSpreadIndex: bookPaginationState.spreadIndex, visibleSpreadIndex: bookPaginationState.spreadIndex,
latestBlockId, visibility: detail.visibility || 'current',
latestRenderedBlockId playbackActive
}); });
bookPaginationState = { bookPaginationState = {
...bookPaginationState, ...bookPaginationState,
@@ -695,23 +722,10 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
}; };
growBookIfWritableLimitReached(); growBookIfWritableLimitReached();
syncBookControls(); syncBookControls();
if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated');
return;
}
if (
latestBlockId > latestRenderedBlockId
&& detail.visibility !== 'future-ready'
&& activeFlips.length === 0
&& incomingSpreadIndex > Math.max(0, Number(bookPaginationState.spreadIndex || 0))
) {
markPageTextureTiming('spreadUpdate:deferred-future-unrendered', {
incomingSpreadIndex,
visibleSpreadIndex: bookPaginationState.spreadIndex,
latestBlockId,
latestRenderedBlockId
});
return; return;
} }
// Non-playback committed update (history restore, continuation reload): jump
// directly to the committed spread and paint it.
const previousPageCount = bookPageCount; const previousPageCount = bookPageCount;
bookPaginationState = { bookPaginationState = {
spreadIndex: incomingSpreadIndex, spreadIndex: incomingSpreadIndex,
@@ -724,8 +738,10 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
notifyBookPageCountChanged(); notifyBookPageCountChanged();
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount); window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
} }
const spread = detail.spread || getPaginationSpread(incomingSpreadIndex);
if (spread) window.BookTextureRenderer?.drawSpread?.(spread, ['left', 'right'], { force: true });
syncBookControls(); syncBookControls();
if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated'); markPageTextureTiming('spreadUpdate:jump', { incomingSpreadIndex });
}); });
document.addEventListener('webgl-book:page-reserve-directive', (event) => { document.addEventListener('webgl-book:page-reserve-directive', (event) => {
const detail = event.detail || {}; const detail = event.detail || {};
@@ -736,11 +752,6 @@ document.addEventListener('webgl-book:page-reserve-directive', (event) => {
: Math.round(value); : Math.round(value);
setPageReserve(nextReserve); setPageReserve(nextReserve);
}); });
document.addEventListener('ui:command', (event) => {
if (event.detail?.type === 'continue' && pendingRightPageFlip) {
tryStartPendingRightPageFlip('continue', { force: true });
}
});
installBookControls(); installBookControls();
installCameraControls(); installCameraControls();
resize(); resize();
@@ -1881,6 +1892,17 @@ function getCurrentPagePosition() {
return spreadIndexToPagePosition(bookPaginationState.spreadIndex); return spreadIndexToPagePosition(bookPaginationState.spreadIndex);
} }
// Manual navigation must not run past the spreads that actually exist (so a stale
// restored maxVisitedPagePosition cannot enable a flip into empty pages), but it must
// still reach the last existing spread. spreadCount is the real spread count; the last
// navigable spread is spreadCount - 1. (writtenPageLimit deliberately under-counts by a
// spread, so it must not be used for this.)
function getNavigablePageLimit() {
const lastSpreadIndex = Math.max(0, Math.round(Number(bookPaginationState.spreadCount || 1)) - 1);
const contentNavigable = spreadIndexToPagePosition(lastSpreadIndex);
return Math.min(maxVisitedPagePosition, getWritablePageLimit(), contentNavigable);
}
function scheduleBookRebuild(reason = 'scheduled') { function scheduleBookRebuild(reason = 'scheduled') {
if (scheduledBookRebuildFrame !== null) return; if (scheduledBookRebuildFrame !== null) return;
const scheduler = typeof window.requestIdleCallback === 'function' const scheduler = typeof window.requestIdleCallback === 'function'
@@ -2090,7 +2112,7 @@ function syncBottomNavigation() {
if (!bottomNavigation) return; if (!bottomNavigation) return;
const currentPage = getCurrentPagePosition(); const currentPage = getCurrentPagePosition();
const writableLimit = getWritablePageLimit(); const writableLimit = getWritablePageLimit();
const navigableLimit = Math.min(maxVisitedPagePosition, writableLimit); const navigableLimit = getNavigablePageLimit();
const reservedStart = Math.max(0, writableLimit); const reservedStart = Math.max(0, writableLimit);
bottomNavigation.slider.max = String(Math.max(0, bookPageCount)); bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit)); bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
@@ -2938,12 +2960,10 @@ function startPageFlipPrepared(direction, options = {}) {
const flip = createPageFlip(direction, performance.now(), normalFlipDuration); const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
if (!flip) return false; if (!flip) return false;
flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null; flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
flip.deferRevealSides = Array.isArray(options.deferRevealSides) ? options.deferRevealSides : null;
if (!prepareStaticPageForFlip(flip, options.prewarm || null)) { if (!prepareStaticPageForFlip(flip, options.prewarm || null)) {
return false; return false;
} }
pendingRightPageFlip = false;
pendingRightPageFlipAutoplay = false;
delete document.documentElement.dataset.webglPendingPageFlip;
activeFlips.push(flip); activeFlips.push(flip);
setPageFlipActiveFlag(); setPageFlipActiveFlag();
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', { document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', {
@@ -3007,8 +3027,12 @@ function startFastPageFlipPrepared(direction, options = {}) {
function createPageFlip(direction, startTime, duration) { function createPageFlip(direction, startTime, duration) {
const sourceSide = direction > 0 ? 1 : -1; const sourceSide = direction > 0 ? 1 : -1;
const sourcePageSide = direction > 0 ? 'right' : 'left'; const sourcePageSide = direction > 0 ? 'right' : 'left';
const sourceLine = normalizeFlipLineToVisiblePage(topVisibleLine(sourceSide), sourceSide); // Use the raw page-cap line (as the working prototype / pre-ef358c5 lab did). Each
const destinationLine = normalizeFlipLineToVisiblePage(topVisibleLine(-sourceSide), -sourceSide); // line's points[0] === its spine-arc anchor (spineCurvePoint(t)), so the flip sheet
// hinges at the spine. Rewriting the line to the "visible page width" moved the pivot
// off the spine arc and folded the inner spine-wall climb into a crease at the spine.
const sourceLine = topVisibleLine(sourceSide);
const destinationLine = topVisibleLine(-sourceSide);
if (!sourceLine || !destinationLine) return null; if (!sourceLine || !destinationLine) return null;
return { return {
direction, direction,
@@ -3024,34 +3048,11 @@ function createPageFlip(direction, startTime, duration) {
}; };
} }
function normalizeFlipLineToVisiblePage(line, side) {
if (!line || !currentProceduralBookModel) return line;
const points = Array.isArray(line.points) ? line.points : [];
if (points.length < 2) return line;
const pageStartX = side * Math.max(0, Number(currentProceduralBookModel.spineHalf || 0));
const endpoint = points[points.length - 1];
const sourceStart = points[0];
const sourceSpan = Math.max(0.0001, side * (endpoint.x - sourceStart.x));
const normalizedPoints = points.map((point) => {
const u = THREE.MathUtils.clamp(side * (point.x - sourceStart.x) / sourceSpan, 0, 1);
return {
x: THREE.MathUtils.lerp(pageStartX, endpoint.x, u),
y: point.y
};
});
return {
...line,
anchor: normalizedPoints[0],
points: normalizedPoints,
endpoint: normalizedPoints[normalizedPoints.length - 1]
};
}
function prepareStaticPageForFlip(flip, prewarm = null) { function prepareStaticPageForFlip(flip, prewarm = null) {
if (!flip) return false; if (!flip) return false;
const sourceSide = flip.direction > 0 ? 'right' : 'left'; const sourceSide = flip.direction > 0 ? 'right' : 'left';
const sourceTexture = resolveCurrentFlipSourceTexture(sourceSide); const sourceTexture = resolveCurrentFlipSourceTexture(sourceSide);
const sourcePageMeta = currentPageMeta?.[sourceSide] || getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[sourceSide]) || null; const sourcePageMeta = getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[sourceSide]) || currentPageMeta?.[sourceSide] || null;
const targetSpread = Number.isFinite(Number(flip.targetSpread)) const targetSpread = Number.isFinite(Number(flip.targetSpread))
? Math.max(0, Math.round(Number(flip.targetSpread))) ? Math.max(0, Math.round(Number(flip.targetSpread)))
: Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0))); : Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0)));
@@ -3077,10 +3078,15 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
}); });
return false; return false;
} }
// If the page the flip lands on will be revealed right after (a block reveals on
// that side), do not show its full text on the turning page's back face — that
// flashes the not-yet-revealed content. Show blank during the turn; the masked
// reveal lands on the static page once the flip finishes.
const backDeferred = Array.isArray(flip.deferRevealSides) && flip.deferRevealSides.includes(targetBackSide);
materials.flipPageSurface.map = sourceTexture; materials.flipPageSurface.map = sourceTexture;
materials.flipPageBackSurface.map = backTexture || getBlankPageTexture(); materials.flipPageBackSurface.map = backDeferred ? getBlankPageTexture() : (backTexture || getBlankPageTexture());
materials.flipPageSurface.userData.sourceRevealSide = revealStateMatchesPage(sourceSide, sourcePageMeta) ? sourceSide : null; materials.flipPageSurface.userData.sourceRevealSide = revealStateMatchesPage(sourceSide, sourcePageMeta) ? sourceSide : null;
materials.flipPageBackSurface.userData.sourceRevealSide = revealStateMatchesPage(targetBackSide, targetBackPageMeta) ? targetBackSide : null; materials.flipPageBackSurface.userData.sourceRevealSide = backDeferred ? null : (revealStateMatchesPage(targetBackSide, targetBackPageMeta) ? targetBackSide : null);
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap; materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
@@ -3131,7 +3137,11 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
} }
function resolveCurrentFlipSourceTexture(side) { function resolveCurrentFlipSourceTexture(side) {
const pageMeta = currentPageMeta?.[side] || null; // Derive the source page meta from the actually-visible spread. currentPageMeta is
// only refreshed by the activate pipeline, so it is stale after manual navigation —
// using it here resolved the wrong source texture for the next flip.
const visiblePageIndex = spreadPageIndices(bookPaginationState.spreadIndex)[side];
const pageMeta = getPaginationPageMeta(visiblePageIndex) || currentPageMeta?.[side] || null;
if (pageMeta?.kind === 'blank') return getBlankPageTexture(); if (pageMeta?.kind === 'blank') return getBlankPageTexture();
const material = side === 'left' ? materials.leftPage : materials.rightPage; const material = side === 'left' ? materials.leftPage : materials.rightPage;
if (revealStateMatchesPage(side, pageMeta)) return material?.map || null; if (revealStateMatchesPage(side, pageMeta)) return material?.map || null;
@@ -3149,53 +3159,16 @@ function resolveFlipBackTexture(pageMeta = null, prewarmedTexture = null) {
function canPageFlip(direction) { function canPageFlip(direction) {
if (!currentProceduralBookModel) return false; if (!currentProceduralBookModel) return false;
const currentPage = getCurrentPagePosition(); const currentPage = getCurrentPagePosition();
const maxNavigablePage = Math.min(maxVisitedPagePosition, getWritablePageLimit()); if (direction > 0) return currentPage < getNavigablePageLimit();
if (direction > 0) return currentPage < maxNavigablePage;
return currentPage > 0; return currentPage > 0;
} }
function handleRevealCommittedForPageFlip(detail = {}) {
if (window.BookPlaybackTimeline?.ownsPageFlipCommit === true) return;
if (detail.side !== 'right' || detail.pageFlipAfterReveal !== true) return;
if (activeFlips.length > 0 || pendingRightPageFlip) return;
if (isChoiceAwaitingPlayer()) return;
const autoplayFlip = isTtsPlaybackActive();
pendingRightPageFlip = true;
pendingRightPageFlipAutoplay = autoplayFlip;
document.documentElement.dataset.webglPendingPageFlip = 'right';
if (autoplayFlip) {
tryStartPendingRightPageFlip('tts-active');
}
}
async function tryStartPendingRightPageFlip(reason = 'pending', options = {}) {
if (!pendingRightPageFlip || activeFlips.length > 0 || isChoiceAwaitingPlayer()) return false;
if (!options.force && !pendingRightPageFlipAutoplay) return false;
const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1);
const flipped = await startPageFlip(1, {
force: options.force === true || pendingRightPageFlipAutoplay,
reason,
targetSpread
});
if (flipped) {
pendingRightPageFlip = false;
pendingRightPageFlipAutoplay = false;
delete document.documentElement.dataset.webglPendingPageFlip;
}
return flipped;
}
function isChoiceAwaitingPlayer() { function isChoiceAwaitingPlayer() {
return document.documentElement.dataset.choiceAwaiting === 'true' return document.documentElement.dataset.choiceAwaiting === 'true'
|| document.body?.dataset?.choiceAwaiting === 'true' || document.body?.dataset?.choiceAwaiting === 'true'
|| Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice')); || Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice'));
} }
function isTtsPlaybackActive() {
const coordinator = window.moduleRegistry?.getModule?.('playback-coordinator') || window.PlaybackCoordinator || null;
return Boolean(coordinator?.isPlaying || coordinator?.state === 'playing' || document.documentElement.dataset.ttsPlaying === 'true');
}
function topVisibleLine(side) { function topVisibleLine(side) {
const sideLines = currentProceduralBookModel.lines const sideLines = currentProceduralBookModel.lines
.filter((line) => line.side === side) .filter((line) => line.side === side)
@@ -3205,9 +3178,15 @@ function topVisibleLine(side) {
function updateActiveFlips(now) { function updateActiveFlips(now) {
if (!activeFlips.length || !currentProceduralBookModel) return; if (!activeFlips.length || !currentProceduralBookModel) return;
// Debug/isolation hook: when window.__debugFlipFreezeT is a finite number in [0,1],
// hold every active flip at that progress so a single frame can be inspected.
const freezeT = Number(window.__debugFlipFreezeT);
const frozen = Number.isFinite(freezeT);
const completed = []; const completed = [];
activeFlips.forEach((flip) => { activeFlips.forEach((flip) => {
const elapsed = (now - flip.startTime) / flip.duration; const elapsed = frozen
? THREE.MathUtils.clamp(freezeT, 0, 1)
: (now - flip.startTime) / flip.duration;
if (elapsed < 0) return; if (elapsed < 0) return;
const t = THREE.MathUtils.clamp(elapsed, 0, 1); const t = THREE.MathUtils.clamp(elapsed, 0, 1);
const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset); const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset);
@@ -3218,7 +3197,10 @@ function updateActiveFlips(now) {
? Math.max(0, Math.round(Number(flip.targetSpread))) ? Math.max(0, Math.round(Number(flip.targetSpread)))
: null; : null;
if (targetSpread !== null && !hasActivePageReveal()) { if (targetSpread !== null && !hasActivePageReveal()) {
applyResidentSpreadTextures(targetSpread, 'page-flip-near-end'); // Skip the revealing side(s): the timeline's activate lands the masked
// reveal for them right after the flip. Showing the full resident texture
// here would flash the not-yet-revealed block.
applyResidentSpreadTextures(targetSpread, 'page-flip-near-end', { skipSides: flip.deferRevealSides });
} }
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', { document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', {
detail: { detail: {
@@ -3242,9 +3224,11 @@ function hasActivePageReveal() {
}); });
} }
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread') { function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread', options = {}) {
const skipSides = Array.isArray(options.skipSides) ? options.skipSides : [];
const pageIndices = spreadPageIndices(spreadIndex); const pageIndices = spreadPageIndices(spreadIndex);
['left', 'right'].forEach((side) => { ['left', 'right'].forEach((side) => {
if (skipSides.includes(side)) return;
const pageIndex = pageIndices[side]; const pageIndex = pageIndices[side];
const pageMeta = getPaginationPageMeta(pageIndex) || makeBlankPageMeta(pageIndex); const pageMeta = getPaginationPageMeta(pageIndex) || makeBlankPageMeta(pageIndex);
const texture = pageMeta.kind === 'blank' const texture = pageMeta.kind === 'blank'
@@ -3389,8 +3373,9 @@ function lineYAtX(points, x) {
} }
function setActivePageGeometry(flip, surface) { function setActivePageGeometry(flip, surface) {
const widthRatios = flipWidthRatios(flip.sourceLine?.points);
if (!flip.mesh) { if (!flip.mesh) {
const geometry = createFlippingPageGeometry(surface, flip.direction); const geometry = createFlippingPageGeometry(surface, flip.direction, widthRatios);
flip.mesh = new THREE.Mesh(geometry, [ flip.mesh = new THREE.Mesh(geometry, [
materials.flipPageSurface, materials.flipPageSurface,
materials.flipPageBackSurface, materials.flipPageBackSurface,
@@ -3404,13 +3389,25 @@ function setActivePageGeometry(flip, surface) {
return; return;
} }
if (!updateFlippingPageGeometry(flip.mesh.geometry, surface)) { if (!updateFlippingPageGeometry(flip.mesh.geometry, surface)) {
const geometry = createFlippingPageGeometry(surface, flip.direction); const geometry = createFlippingPageGeometry(surface, flip.direction, widthRatios);
flip.mesh.geometry.dispose(); flip.mesh.geometry.dispose();
flip.mesh.geometry = geometry; flip.mesh.geometry = geometry;
} }
} }
function createFlippingPageGeometry(surface, direction = 1) { // Texture U coordinates must follow physical page width (the spline uses short
// segments near the spine and long segments near the fore-edge), not the uniform
// vertex index, otherwise the flip texture is horizontally compressed relative to
// the static stack cap.
function flipWidthRatios(points) {
if (!Array.isArray(points) || points.length < 2) return null;
const lengths = cumulativeLineLengths(points);
const total = lengths[lengths.length - 1];
if (!(total > 0)) return null;
return lengths.map(length => length / total);
}
function createFlippingPageGeometry(surface, direction = 1, widthRatios = null) {
const positions = []; const positions = [];
const uvs = []; const uvs = [];
const indices = []; const indices = [];
@@ -3426,8 +3423,12 @@ function createFlippingPageGeometry(surface, direction = 1) {
const targetSide = -sourceSide; const targetSide = -sourceSide;
const topPageSide = direction > 0 ? targetSide : sourceSide; const topPageSide = direction > 0 ? targetSide : sourceSide;
const bottomPageSide = direction > 0 ? sourceSide : targetSide; const bottomPageSide = direction > 0 ? sourceSide : targetSide;
const topMaterialIndex = 0; // The page's width index runs spine->right for forward flips and spine->left for
const bottomMaterialIndex = 1; // backward flips, which inverts the computed face normals. Assign the source
// texture to the face that actually points at the camera at the start of the turn
// so the lifting page shows the page it came from (not the page it lands on).
const topMaterialIndex = direction > 0 ? 1 : 0;
const bottomMaterialIndex = direction > 0 ? 0 : 1;
const push = (point, yOffset, uv) => { const push = (point, yOffset, uv) => {
const index = positions.length / 3; const index = positions.length / 3;
positions.push(point.x, point.y + yOffset, point.z); positions.push(point.x, point.y + yOffset, point.z);
@@ -3438,7 +3439,9 @@ function createFlippingPageGeometry(surface, direction = 1) {
surface.forEach((rowPoints, widthIndex) => { surface.forEach((rowPoints, widthIndex) => {
const topRow = []; const topRow = [];
const bottomRow = []; const bottomRow = [];
const u = widthSegments <= 0 ? 0 : widthIndex / widthSegments; const u = Array.isArray(widthRatios) && widthRatios.length === surface.length
? widthRatios[widthIndex]
: (widthSegments <= 0 ? 0 : widthIndex / widthSegments);
rowPoints.forEach((point, depthIndex) => { rowPoints.forEach((point, depthIndex) => {
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments; const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
topRow.push(push(point, pageThickness, pageUvForSide(topPageSide, u, v))); topRow.push(push(point, pageThickness, pageUvForSide(topPageSide, u, v)));
+14 -10
View File
@@ -17,6 +17,10 @@ class WebGLBookSceneModule extends BaseModule {
this.gameConfig = null; this.gameConfig = null;
this.mode = '2d'; this.mode = '2d';
this.is3dSupported = false; this.is3dSupported = false;
// Production control surface + visible-spread accessor for the dynamically
// imported webgl-book-lab. Populated by the lab once the scene is built.
this.sceneControl = null;
this.getVisibleSpreadIndex = null;
this.labImportPromise = null; this.labImportPromise = null;
this.textureRefreshTimer = null; this.textureRefreshTimer = null;
this.textureRefreshAnimationId = null; this.textureRefreshAnimationId = null;
@@ -330,7 +334,7 @@ class WebGLBookSceneModule extends BaseModule {
this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', value); this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', value);
this.preferenceWriteGuard = false; this.preferenceWriteGuard = false;
}, },
getBookState: () => window.BookLabDebug?.getBookState?.() || { getBookState: () => this.sceneControl?.getBookState?.() || {
pageCount: this.persistenceManager?.getPreference?.('webgl', 'bookPageCount', DEFAULT_BOOK_PAGE_COUNT) ?? DEFAULT_BOOK_PAGE_COUNT, pageCount: this.persistenceManager?.getPreference?.('webgl', 'bookPageCount', DEFAULT_BOOK_PAGE_COUNT) ?? DEFAULT_BOOK_PAGE_COUNT,
pageReserve: this.persistenceManager?.getPreference?.('webgl', 'pageReserve', DEFAULT_PAGE_RESERVE) ?? DEFAULT_PAGE_RESERVE, pageReserve: this.persistenceManager?.getPreference?.('webgl', 'pageReserve', DEFAULT_PAGE_RESERVE) ?? DEFAULT_PAGE_RESERVE,
progress: this.persistenceManager?.getPreference?.('webgl', 'bookProgress', DEFAULT_BOOK_PROGRESS) ?? DEFAULT_BOOK_PROGRESS progress: this.persistenceManager?.getPreference?.('webgl', 'bookProgress', DEFAULT_BOOK_PROGRESS) ?? DEFAULT_BOOK_PROGRESS
@@ -341,19 +345,19 @@ class WebGLBookSceneModule extends BaseModule {
const progress = Number(state.progress); const progress = Number(state.progress);
if (Number.isFinite(pageCount)) { if (Number.isFinite(pageCount)) {
this.persistenceManager?.updatePreference?.('webgl', 'bookPageCount', pageCount); this.persistenceManager?.updatePreference?.('webgl', 'bookPageCount', pageCount);
window.BookLabDebug?.setBookPageCount?.(pageCount); this.sceneControl?.setBookPageCount?.(pageCount);
} }
if (Number.isFinite(pageReserve)) { if (Number.isFinite(pageReserve)) {
this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', pageReserve); this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', pageReserve);
window.BookLabDebug?.setPageReserve?.(pageReserve); this.sceneControl?.setPageReserve?.(pageReserve);
} }
if (Number.isFinite(progress)) { if (Number.isFinite(progress)) {
this.persistenceManager?.updatePreference?.('webgl', 'bookProgress', progress); this.persistenceManager?.updatePreference?.('webgl', 'bookProgress', progress);
window.BookLabDebug?.setReadingProgress?.(progress); this.sceneControl?.setReadingProgress?.(progress);
} }
const maxVisitedPagePosition = Number(state.maxVisitedPagePosition ?? state.pagePosition); const maxVisitedPagePosition = Number(state.maxVisitedPagePosition ?? state.pagePosition);
if (Number.isFinite(maxVisitedPagePosition)) { if (Number.isFinite(maxVisitedPagePosition)) {
window.BookLabDebug?.setMaxVisitedPagePosition?.(maxVisitedPagePosition); this.sceneControl?.setMaxVisitedPagePosition?.(maxVisitedPagePosition);
} }
} }
}; };
@@ -456,7 +460,7 @@ class WebGLBookSceneModule extends BaseModule {
} }
projectCanvasEventTarget(event) { projectCanvasEventTarget(event) {
const projection = window.BookLabDebug?.projectPointerToPage?.(event.clientX, event.clientY); const projection = this.sceneControl?.projectPointerToPage?.(event.clientX, event.clientY);
if (!projection) { if (!projection) {
document.documentElement.dataset.webglLastProjection = JSON.stringify({ document.documentElement.dataset.webglLastProjection = JSON.stringify({
hit: false, hit: false,
@@ -531,11 +535,11 @@ class WebGLBookSceneModule extends BaseModule {
this.initializeScene(); this.initializeScene();
} }
} else if (key === 'bookProgress' && !this.preferenceWriteGuard) { } else if (key === 'bookProgress' && !this.preferenceWriteGuard) {
window.BookLabDebug?.setReadingProgress?.(value); this.sceneControl?.setReadingProgress?.(value);
} else if (key === 'bookPageCount' && !this.preferenceWriteGuard) { } else if (key === 'bookPageCount' && !this.preferenceWriteGuard) {
window.BookLabDebug?.setBookPageCount?.(value); this.sceneControl?.setBookPageCount?.(value);
} else if (key === 'pageReserve' && !this.preferenceWriteGuard) { } else if (key === 'pageReserve' && !this.preferenceWriteGuard) {
window.BookLabDebug?.setPageReserve?.(value); this.sceneControl?.setPageReserve?.(value);
} }
} }
@@ -556,7 +560,7 @@ class WebGLBookSceneModule extends BaseModule {
triggerTextureRefresh() { triggerTextureRefresh() {
clearTimeout(this.textureRefreshTimer); clearTimeout(this.textureRefreshTimer);
this.textureRefreshTimer = setTimeout(() => { this.textureRefreshTimer = setTimeout(() => {
window.BookLabDebug?.redrawPageTextures?.(); this.sceneControl?.redrawPageTextures?.();
}, 60); }, 60);
} }
+11 -10
View File
@@ -552,26 +552,27 @@ class WebGLPageCacheModule extends BaseModule {
} }
} }
// contentVersion is the monotonic authority: a higher version is always newer and
// wins, even when the re-typeset page legitimately has fewer lines (lower
// completeness). completenessScore only breaks ties when versions are equal/absent.
isOlderPageEntry(pageMeta = {}, oldEntry = null) { isOlderPageEntry(pageMeta = {}, oldEntry = null) {
if (!oldEntry) return false; if (!oldEntry) return false;
const incomingCompleteness = Math.max(0, Number(pageMeta.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(oldEntry.completenessScore || 0));
if (incomingCompleteness < existingCompleteness) return true;
if (incomingCompleteness > existingCompleteness) return false;
const incomingVersion = Math.max(0, Number(pageMeta.contentVersion || 0)); const incomingVersion = Math.max(0, Number(pageMeta.contentVersion || 0));
const existingVersion = Math.max(0, Number(oldEntry.contentVersion || 0)); const existingVersion = Math.max(0, Number(oldEntry.contentVersion || 0));
return incomingVersion > 0 && existingVersion > incomingVersion; if (incomingVersion !== existingVersion) return incomingVersion < existingVersion;
const incomingCompleteness = Math.max(0, Number(pageMeta.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(oldEntry.completenessScore || 0));
return incomingCompleteness < existingCompleteness;
} }
isOlderPageMeta(incoming = {}, existing = null) { isOlderPageMeta(incoming = {}, existing = null) {
if (!existing) return false; if (!existing) return false;
const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0));
if (incomingCompleteness < existingCompleteness) return true;
if (incomingCompleteness > existingCompleteness) return false;
const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0)); const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0));
const existingVersion = Math.max(0, Number(existing?.contentVersion || 0)); const existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
return incomingVersion > 0 && existingVersion > incomingVersion; if (incomingVersion !== existingVersion) return incomingVersion < existingVersion;
const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0));
return incomingCompleteness < existingCompleteness;
} }
recordProblem(detail = {}) { recordProblem(detail = {}) {
+17 -16
View File
@@ -159,7 +159,7 @@ const checks = [
['webgl debug test hook awaits the same async page flip path', /startPageFlipForTest\(direction, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)], ['webgl debug test hook awaits the same async page flip path', /startPageFlipForTest\(direction, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)],
['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)], ['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)],
['sentence queue skips duplicate current-item 3D book presentation when reveal is cached', /isWebGLBookPresentationPrepared/.test(sentenceQueueSource) && /if \(!this\.isWebGLBookPresentationPrepared\(sentence\)\) \{\s*await this\.prefetchWebGLBookPresentation/.test(sentenceQueueSource) && /sentence\.webglBookPresentation = \{\s*prepared: true/.test(sentenceQueueSource)], ['sentence queue skips duplicate current-item 3D book presentation when reveal is cached', /isWebGLBookPresentationPrepared/.test(sentenceQueueSource) && /if \(!this\.isWebGLBookPresentationPrepared\(sentence\)\) \{\s*await this\.prefetchWebGLBookPresentation/.test(sentenceQueueSource) && /sentence\.webglBookPresentation = \{\s*prepared: true/.test(sentenceQueueSource)],
['3D overflow reveal waits for an explicit timeline flip plan before activating future spread', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /BookLabDebug\?\.requestPageFlip/.test(bookPlaybackTimelineSource) && /requestPageFlip\(direction = 1, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)], ['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)], ['texture renderer paints inline bold and italic styles', /getInlineStyleState/.test(textureRendererSource) && /updateInlineStyleState/.test(textureRendererSource) && /getCanvasFont/.test(textureRendererSource) && /segment\?\.style/.test(textureRendererSource)],
['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 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 page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
@@ -183,22 +183,23 @@ const checks = [
['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)], ['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 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 renderer uses plural page margin metrics for page numbers', /this\.metrics\.margins\.bottom/.test(textureRendererSource) && !/this\.metrics\.margin\.bottom/.test(textureRendererSource)],
['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 = backTexture \|\| getBlankPageTexture\(\)/.test(source)], ['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 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'))], ['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 and fallback start', /this\.drawSpread\(this\.currentSpread \|\| this\.pagination\?\.getCurrentSpread\?\.\(\), \['left', 'right'\]\)/.test(textureRendererSource) && /const sides = \['left', 'right'\]/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)], ['texture renderer publishes both spread sides for reveal preparation', /const sides = \['left', 'right'\]/.test(textureRendererSource) && /const published = 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 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)], ['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 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 = 0/.test(source) && /const bottomMaterialIndex = 1/.test(source) && /geometry\.addGroup\(0, topIndices\.length, topMaterialIndex\)/.test(source) && /geometry\.addGroup\(topIndices\.length, bottomIndices\.length, bottomMaterialIndex\)/.test(source)], ['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /const topMaterialIndex = direction > 0 \? 1 : 0/.test(source) && /const bottomMaterialIndex = direction > 0 \? 0 : 1/.test(source) && /geometry\.addGroup\(0, topIndices\.length, topMaterialIndex\)/.test(source) && /geometry\.addGroup\(topIndices\.length, bottomIndices\.length, bottomMaterialIndex\)/.test(source)],
['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backTexture \|\| getBlankPageTexture\(\)/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture \|\| getBlankPageTexture\(\)/.test(source)], ['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture \|\| getBlankPageTexture\(\)/.test(source)],
['webgl flip preflight exposes texture side and orientation invariants for browser tests', /lastFlipTexturePreflight/.test(source) && /sourceTextureMatchesBackTexture/.test(source) && /targetBackSide/.test(source) && /getRuntimeInvariants/.test(source)], ['webgl flip preflight exposes texture side and orientation invariants for browser tests', /lastFlipTexturePreflight/.test(source) && /sourceTextureMatchesBackTexture/.test(source) && /targetBackSide/.test(source) && /getRuntimeInvariants/.test(source)],
['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - pageU : pageU/.test(source) && /y: v/.test(source)], ['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - pageU : pageU/.test(source) && /y: v/.test(source)],
['webgl animated page UVs use the same fore-edge inset as the visible stack cap', /PAGE_TEXTURE_FORE_EDGE_INSET_RATIO/.test(source) && /const pageU = THREE\.MathUtils\.clamp\(u \/ Math\.max\(0\.0001, 1 - inset\), 0, 1\)/.test(source)], ['webgl animated page UVs use the same fore-edge inset as the visible stack cap', /PAGE_TEXTURE_FORE_EDGE_INSET_RATIO/.test(source) && /const pageU = THREE\.MathUtils\.clamp\(u \/ Math\.max\(0\.0001, 1 - inset\), 0, 1\)/.test(source)],
['webgl flip geometry samples the same visible page plane as the static stack', /normalizeFlipLineToVisiblePage/.test(source) && /currentProceduralBookModel\.spineHalf/.test(source) && /const pageStartX = side \* Math\.max/.test(source) && /normalizeFlipLineToVisiblePage\(topVisibleLine\(sourceSide\), sourceSide\)/.test(source)], ['webgl flip geometry hinges the flip sheet at the spine using the raw page line', !/normalizeFlipLineToVisiblePage/.test(source) && /const sourceLine = topVisibleLine\(sourceSide\)/.test(source) && /const destinationLine = topVisibleLine\(-sourceSide\)/.test(source) && /lerp\(sourceLine\.anchor\.x, destinationLine\.anchor\.x, t\)/.test(source)],
['webgl flip prewarm prepares current and target spread texture records before cache lookup', /prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /prepareSpreadTextureRecordsForFlip\(nextSpread\)/.test(source) && /function prepareSpreadTextureRecordsForFlip/.test(source) && /spreadTextureRecordsReady\(spread\)/.test(source) && /window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\], \{[\s\S]*phase: 'prepare'/.test(source)], ['webgl flip prewarm prepares current and target spread texture records before cache lookup', /prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /prepareSpreadTextureRecordsForFlip\(nextSpread\)/.test(source) && /function prepareSpreadTextureRecordsForFlip/.test(source) && /spreadTextureRecordsReady\(spread\)/.test(source) && /window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\], \{[\s\S]*phase: 'prepare'/.test(source)],
['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))], ['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))],
['webgl scene targets 60fps with browser-frame scheduling and staggered live mirror refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /const minRenderFrameIntervalMs = targetFrameDurationMs \* 0\.5/.test(source) && /this\.targetFrameDurationMs = 1000 \/ 60/.test(textureRendererSource) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /const dynamicBufferRefreshIntervalMs = 1000 \/ 30/.test(source) && /const flipDynamicBufferGraceMs = 180/.test(source) && /const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue/.test(source) && /const refreshReflectionThisFrame/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesAtFps/.test(source) && !/setTimeout\(animate/.test(source)], ['webgl scene targets 60fps with browser-frame scheduling and staggered live mirror refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /const minRenderFrameIntervalMs = targetFrameDurationMs \* 0\.5/.test(source) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /const dynamicBufferRefreshIntervalMs = 1000 \/ 30/.test(source) && /const flipDynamicBufferGraceMs = 180/.test(source) && /const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue/.test(source) && /const refreshReflectionThisFrame/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesAtFps/.test(source) && !/setTimeout\(animate/.test(source)],
['texture renderer has no private reveal clock (scene render loop is the single clock)', !/this\.targetFrameDurationMs/.test(textureRendererSource) && !/tickAnimations/.test(textureRendererSource) && !/requestAnimationFrame/.test(textureRendererSource)],
['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 0\.72/.test(source) && /const tableReflectionBaseWidth = 1536/.test(source) && /const tableReflectionBaseHeight = 864/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)], ['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 0\.72/.test(source) && /const tableReflectionBaseWidth = 1536/.test(source) && /const tableReflectionBaseHeight = 864/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)],
['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesAtFps/.test(source) && /mirrorDefersDuringFlipStartMs/.test(source)], ['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesAtFps/.test(source) && /mirrorDefersDuringFlipStartMs/.test(source)],
['book pagination reloads to the continuation block spread when unrendered history exists', /getContinuationBlockId/.test(bookPaginationSource) && /const continuationBlockId = this\.getContinuationBlockId\(latestBlockId, latestRenderedBlockId\)/.test(bookPaginationSource) && /const continuationSpreadIndex = this\.findSpreadIndexForBlock\(continuationBlockId\)/.test(bookPaginationSource) && /rendered < latest \? rendered \+ 1 : latest/.test(bookPaginationSource)], ['book pagination reloads to the continuation block spread when unrendered history exists', /getContinuationBlockId/.test(bookPaginationSource) && /const continuationBlockId = this\.getContinuationBlockId\(latestBlockId, latestRenderedBlockId\)/.test(bookPaginationSource) && /const continuationSpreadIndex = this\.findSpreadIndexForBlock\(continuationBlockId\)/.test(bookPaginationSource) && /rendered < latest \? rendered \+ 1 : latest/.test(bookPaginationSource)],
@@ -210,21 +211,21 @@ const checks = [
['webgl page reserve persists with sane defaults', /bookPageCount: 300/.test(persistenceSource) && /bookProgress: 0/.test(persistenceSource) && /pageReserve: 50/.test(persistenceSource)], ['webgl page reserve persists with sane defaults', /bookPageCount: 300/.test(persistenceSource) && /bookProgress: 0/.test(persistenceSource) && /pageReserve: 50/.test(persistenceSource)],
['markup parser strips and stores pagereserve directives', /parsePageReserveDirective/.test(markupParserSource) && /#pagereserve\\\[/.test(markupParserSource) && /unit: match\[2\] === '%' \? 'percent' : 'pages'/.test(markupParserSource)], ['markup parser strips and stores pagereserve directives', /parsePageReserveDirective/.test(markupParserSource) && /#pagereserve\\\[/.test(markupParserSource) && /unit: match\[2\] === '%' \? 'percent' : 'pages'/.test(markupParserSource)],
['game loop persists webgl book state in save slots', /webglBookState: this\.getWebGLBookState\(\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8')) && /applyWebGLBookState\(browserSave\.webglBookState\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8'))], ['game loop persists webgl book state in save slots', /webglBookState: this\.getWebGLBookState\(\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8')) && /applyWebGLBookState\(browserSave\.webglBookState\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8'))],
['webgl right-page reveal records arm a durable autoplay-targeted flip without bypassing choices', /handleRevealCommittedForPageFlip/.test(source) && /pageFlipAfterReveal/.test(textureRendererSource) && /tryStartPendingRightPageFlip/.test(source) && /pendingRightPageFlipAutoplay/.test(source) && /const targetSpread = Math\.max\(0, Math\.round\(Number\(bookPaginationState\.spreadIndex \|\| 0\)\) \+ 1\)/.test(source) && /force: options\.force === true \|\| pendingRightPageFlipAutoplay/.test(source) && /isChoiceAwaitingPlayer/.test(source) && /pendingRightPageFlip = true/.test(source)], ['webgl right-page reveal flips are owned by the timeline, not the scene', !/pendingRightPageFlip/.test(source) && !/handleRevealCommittedForPageFlip/.test(source) && /waitForVisualCompletion/.test(bookPlaybackTimelineSource) && /reason: 'timeline-right-page-filled'/.test(bookPlaybackTimelineSource) && /requiresRightPageFlipAfterReveal/.test(bookPlaybackTimelineSource) && /isChoiceAwaitingPlayer/.test(bookPlaybackTimelineSource)],
['webgl reveal clock follows absolute playback time and continues across page flips', /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/Math\.min\(revealFrameDeltaMs, targetFrameDurationMs\)/.test(source) && /prewarmFlipTextures\(1, targetSpread\)/.test(source)], ['webgl reveal clock follows absolute playback time and continues across page flips', /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/Math\.min\(revealFrameDeltaMs, targetFrameDurationMs\)/.test(source) && /prewarmFlipTextures\(1, targetSpread\)/.test(source)],
['webgl line reveal timing uses area-weighted regions instead of word-span timing', /lineWordCount/.test(bookPaginationSource) && /blockWordStart/.test(textureRendererSource) && /blockWordCount/.test(textureRendererSource) && /timingArea/.test(textureRendererSource) && /totalDuration \* \(Math\.max\(1, region\.timingArea \|\| region\.area\) \/ totalArea\)/.test(textureRendererSource) && !/const canUseLineWordSpans/.test(textureRendererSource)], ['webgl line reveal timing uses area-weighted regions instead of word-span timing', /lineWordCount/.test(bookPaginationSource) && /blockWordStart/.test(textureRendererSource) && /blockWordCount/.test(textureRendererSource) && /timingArea/.test(textureRendererSource) && /totalDuration \* \(Math\.max\(1, region\.timingArea \|\| region\.area\) \/ totalArea\)/.test(textureRendererSource) && !/const canUseLineWordSpans/.test(textureRendererSource)],
['webgl flip completion defers book rebuild out of the final animation frame', /scheduledBookRebuildFrame/.test(source) && /function scheduleBookRebuild/.test(source) && /syncReadingProgressToCurrentPage\(\{[\s\S]*rebuild: 'defer'[\s\S]*reason: 'page-flip-finished'/.test(source)], ['webgl flip completion defers book rebuild out of the final animation frame', /scheduledBookRebuildFrame/.test(source) && /function scheduleBookRebuild/.test(source) && /syncReadingProgressToCurrentPage\(\{[\s\S]*rebuild: 'defer'[\s\S]*reason: 'page-flip-finished'/.test(source)],
['webgl ordinary flip near-end uses resident target textures instead of renderer redraw', /applyResidentSpreadTextures\(targetSpread, 'page-flip-near-end'\)/.test(source) && /function applyResidentSpreadTextures/.test(source) && /residentSpreadTextures:applied/.test(source) && /spreadUpdate:skip-during-flip/.test(textureRendererSource)], ['webgl ordinary flip near-end uses resident target textures and defers revealing sides', /applyResidentSpreadTextures\(targetSpread, 'page-flip-near-end', \{ skipSides: flip\.deferRevealSides \}\)/.test(source) && /function applyResidentSpreadTextures\(spreadIndex, reason = 'resident-spread', options = \{\}\)/.test(source) && /const skipSides = Array\.isArray\(options\.skipSides\)/.test(source) && /residentSpreadTextures:applied/.test(source) && /spreadUpdate:state-only/.test(source)],
['webgl autoplay flip source prefers currently revealing visible material over resident cache', /if \(revealStateMatchesPage\(side, pageMeta\)\) return material\?\.map \|\| null/.test(source) && /revealStateMatchesPage\(sourceSide, sourcePageMeta\) \? sourceSide : null/.test(source)], ['webgl autoplay flip source prefers currently revealing visible material over resident cache', /if \(revealStateMatchesPage\(side, pageMeta\)\) return material\?\.map \|\| null/.test(source) && /revealStateMatchesPage\(sourceSide, sourcePageMeta\) \? sourceSide : null/.test(source)],
['webgl flipping page materials mirror active reveal shader uniforms on both sides', /materials\.flipPageSurface\.userData\.bookPageReveal/.test(source) && /syncFlipRevealShaderFromSource/.test(source) && /bookRevealRegionRects/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide === side/.test(source) && /revealStateMatchesPage\(targetBackSide, targetBackPageMeta\) \? targetBackSide : null/.test(source)], ['webgl flipping page materials mirror active reveal shader uniforms on both sides', /materials\.flipPageSurface\.userData\.bookPageReveal/.test(source) && /syncFlipRevealShaderFromSource/.test(source) && /bookRevealRegionRects/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide === side/.test(source) && /revealStateMatchesPage\(targetBackSide, targetBackPageMeta\) \? targetBackSide : null/.test(source)],
['webgl prepared texture records do not mutate the visible page metadata', /const incomingPageMeta = detail\.pageMeta/.test(source) && /if \(detail\.phase !== 'prepare' && detail\.pageMeta\) \{[\s\S]*currentPageMeta = incomingPageMeta/.test(source) && /pageMeta: effectivePageMeta/.test(source)], ['webgl prepared texture records do not mutate the visible page metadata', /const incomingPageMeta = detail\.pageMeta/.test(source) && /if \(detail\.phase !== 'prepare' && detail\.pageMeta\) \{[\s\S]*currentPageMeta = incomingPageMeta/.test(source) && /pageMeta: effectivePageMeta/.test(source)],
['webgl scene force-redraws current pagination spread for initial title upload', /const initialSpread = pagination\?\.getCurrentSpread\?\.\(\)/.test(webglSceneSource) && /window\.BookTextureRenderer\.drawSpread\(initialSpread, \['left', 'right'\], \{ force: true \}\)/.test(webglSceneSource) && /options\.force !== true && phase !== 'prepare'/.test(textureRendererSource)], ['webgl scene 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)],
['texture renderer marks committed reveal blocks complete so pauses cannot replay them', /webgl-book:reveal-committed/.test(textureRendererSource) && /completeRevealBlockIds/.test(textureRendererSource) && /this\.pendingRevealBlockIds\.delete\(id\)/.test(textureRendererSource)], ['texture renderer marks committed reveal blocks complete so pauses cannot replay them', /webgl-book:reveal-committed/.test(textureRendererSource) && /completeRevealBlockIds/.test(textureRendererSource) && /this\.revealedBlockIds\.add\(id\)/.test(textureRendererSource)],
['webgl timeline recalculates placeholder zero-duration reveal timings from TTS duration', /existingTimings/.test(bookPlaybackTimelineSource) && /existingDuration/.test(bookPlaybackTimelineSource) && /ttsDuration/.test(bookPlaybackTimelineSource) && /existingTimings\.length > 0 && \(existingDuration > 0 \|\| ttsDuration <= 0\)/.test(bookPlaybackTimelineSource)], ['webgl timeline recalculates placeholder zero-duration reveal timings from TTS duration', /existingTimings/.test(bookPlaybackTimelineSource) && /existingDuration/.test(bookPlaybackTimelineSource) && /ttsDuration/.test(bookPlaybackTimelineSource) && /existingTimings\.length > 0 && \(existingDuration > 0 \|\| ttsDuration <= 0\)/.test(bookPlaybackTimelineSource)],
['webgl playback coordinator rejects placeholder zero-duration reveal timings', /timingDuration/.test(playbackCoordinatorSource) && /ttsDuration/.test(playbackCoordinatorSource) && /timingDuration <= 0 && ttsDuration > 0/.test(playbackCoordinatorSource) && /sentence\.animation = \{[\s\S]*wordTimings,[\s\S]*totalDuration: calculated\.totalDuration/.test(playbackCoordinatorSource)], ['webgl playback coordinator trusts timeline-prepared reveal timings without recomputing', !/calculateWordTimings/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal')) && /single owner of reveal timing/.test(playbackCoordinatorSource) && /sentence\.webglRevealController\(/.test(playbackCoordinatorSource)],
['texture renderer derives reveal regions from the just-drawn preview spread before committed pagination', /if \(this\.currentSpread\) sourceSpreads\.push\(this\.currentSpread\)/.test(textureRendererSource) && /this\.pagination\.spreads\.forEach/.test(textureRendererSource) && /Number\(spread\.index\) === Number\(this\.currentSpread\.index\)/.test(textureRendererSource)], ['texture renderer derives reveal regions from the just-drawn preview spread before committed pagination', /if \(this\.currentSpread\) sourceSpreads\.push\(this\.currentSpread\)/.test(textureRendererSource) && /this\.pagination\.spreads\.forEach/.test(textureRendererSource) && /Number\(spread\.index\) === Number\(this\.currentSpread\.index\)/.test(textureRendererSource)],
['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource) && /this\.activeAnimations\.has\(id\)/.test(textureRendererSource)], ['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource)],
['webgl visible spread state ignores future prepared publishes before flip', /spreadUpdate:deferred-future-unrendered/.test(source) && /incomingSpreadIndex > Math\.max\(0, Number\(bookPaginationState\.spreadIndex/.test(source) && /this\.drawSpread\(this\.currentSpread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource)], ['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\(revealDetail, \{[\s\S]*phase: 'prepare'[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /this\.assertSegmentReady\(segment, 'prepare'\)/.test(bookPlaybackTimelineSource) && /reason: 'timeline-preplay-spread-transition'/.test(bookPlaybackTimelineSource)], ['3D overflow reveal preloads target spread before forced page flip', /createRevealDetail\(sentence, previewSpread, 'prepare'\)/.test(bookPlaybackTimelineSource) && /this\.textureRenderer\.prepareRevealBlock\(revealDetail, \{[\s\S]*phase: 'prepare'[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /this\.assertSegmentReady\(segment, 'prepare'\)/.test(bookPlaybackTimelineSource) && /reason: 'timeline-preplay-spread-transition'/.test(bookPlaybackTimelineSource)],
['book playback timeline is loaded through module infrastructure', /book-playback-timeline-module\.js/.test(loaderSource) && /super\('book-playback-timeline'/.test(bookPlaybackTimelineSource) && /reportProgress\(100, 'Book playback timeline ready'\)/.test(bookPlaybackTimelineSource)], ['book playback timeline is loaded through module infrastructure', /book-playback-timeline-module\.js/.test(loaderSource) && /super\('book-playback-timeline'/.test(bookPlaybackTimelineSource) && /reportProgress\(100, 'Book playback timeline ready'\)/.test(bookPlaybackTimelineSource)],
['book playback timeline initializes before sentence queue without a dependency cycle', /this\.dependencies = \[[^\]]*'book-playback-timeline'[^\]]*\]/.test(sentenceQueueSource) && !/this\.dependencies = \[[^\]]*'sentence-queue'[^\]]*\]/.test(bookPlaybackTimelineSource) && /calculateAnimationTiming\(words = \[\]/.test(bookPlaybackTimelineSource)], ['book playback timeline initializes before sentence queue without a dependency cycle', /this\.dependencies = \[[^\]]*'book-playback-timeline'[^\]]*\]/.test(sentenceQueueSource) && !/this\.dependencies = \[[^\]]*'sentence-queue'[^\]]*\]/.test(bookPlaybackTimelineSource) && /calculateAnimationTiming\(words = \[\]/.test(bookPlaybackTimelineSource)],
@@ -232,14 +233,14 @@ const checks = [
['sentence queue lookahead prepares 3D book timeline segments', /book-playback-timeline/.test(sentenceQueueSource) && /bookPlaybackTimeline\.prepareSentence\(sentence/.test(sentenceQueueSource) && /timelineSegment: segment/.test(sentenceQueueSource)], ['sentence queue lookahead prepares 3D book timeline segments', /book-playback-timeline/.test(sentenceQueueSource) && /bookPlaybackTimeline\.prepareSentence\(sentence/.test(sentenceQueueSource) && /timelineSegment: segment/.test(sentenceQueueSource)],
['book playback timeline prewarms texture window before prepared playback and flips', /prewarmSegmentTextures/.test(bookPlaybackTimelineSource) && /pageCache\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /await this\.pageCache\?\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource)], ['book playback timeline prewarms texture window before prepared playback and flips', /prewarmSegmentTextures/.test(bookPlaybackTimelineSource) && /pageCache\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /await this\.pageCache\?\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource)],
['book playback timeline enforces resident page textures before prepared playback', /assertSegmentReady/.test(bookPlaybackTimelineSource) && /collectRequiredPageMetas/.test(bookPlaybackTimelineSource) && /collectTexturePlanPageMetas/.test(bookPlaybackTimelineSource) && /this\.pageCache\.ensurePageTexture\(meta/.test(bookPlaybackTimelineSource) && /timeline-cache-readiness-failed/.test(bookPlaybackTimelineSource) && !/spreads\.add\(currentSpread \+ 1\)/.test(bookPlaybackTimelineSource)], ['book playback timeline enforces resident page textures before prepared playback', /assertSegmentReady/.test(bookPlaybackTimelineSource) && /collectRequiredPageMetas/.test(bookPlaybackTimelineSource) && /collectTexturePlanPageMetas/.test(bookPlaybackTimelineSource) && /this\.pageCache\.ensurePageTexture\(meta/.test(bookPlaybackTimelineSource) && /timeline-cache-readiness-failed/.test(bookPlaybackTimelineSource) && !/spreads\.add\(currentSpread \+ 1\)/.test(bookPlaybackTimelineSource)],
['3D reveal start is controlled by the prepared timeline instead of texture events', /sentence\.webglRevealController = \(\) => this\.startRevealForSegment\(segment\)/.test(bookPlaybackTimelineSource) && /startPreparedRevealAnimation\?\.\(segment\.blockId, \{[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller/.test(playbackCoordinatorSource) && !/document\.dispatchEvent\(new CustomEvent\('book-texture:reveal-block'/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal'))], ['3D reveal start is owned by the timeline and dispatched to the single scene clock', /sentence\.webglRevealController = \(\) => this\.startRevealForSegment\(segment\)/.test(bookPlaybackTimelineSource) && /startPreparedRevealAnimation\?\.\(segment\.blockId, \{[\s\S]*publishEvent: true/.test(bookPlaybackTimelineSource) && /PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller/.test(playbackCoordinatorSource) && !/document\.dispatchEvent\(new CustomEvent\('book-texture:reveal-block'/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal'))],
['webgl lab delegates right-page reveal commits to timeline owner', /BookPlaybackTimeline\?\.ownsPageFlipCommit === true/.test(source) && /handleRevealCommittedForPageFlip/.test(source)], ['webgl scene reports reveal commits but does not own flips and no ownership flag survives', /dispatchEvent\(new CustomEvent\('webgl-book:reveal-committed'/.test(source) && !/handleRevealCommittedForPageFlip/.test(source) && !/ownsPageFlipCommit/.test(source) && !/ownsPageFlipCommit/.test(textureRendererSource) && !/ownsPageFlipCommit/.test(bookPlaybackTimelineSource)],
['webgl reveal clock explicitly freezes during physical flips', /pageRevealFreezeAt/.test(source) && /state\.startedAt \+= frozenMs/.test(source) && /activeRevealBlockStarts\.set\(blockId, Number\(value\) \+ frozenMs\)/.test(source)], ['webgl reveal clock explicitly freezes during physical flips', /pageRevealFreezeAt/.test(source) && /state\.startedAt \+= frozenMs/.test(source) && /activeRevealBlockStarts\.set\(blockId, Number\(value\) \+ frozenMs\)/.test(source)],
['book playback timeline waits for right reveal only when current block is on right page', /getBlockRevealSides/.test(bookPlaybackTimelineSource) && /revealSides\.includes\('right'\) && this\.requiresRightPageFlipAfterReveal/.test(bookPlaybackTimelineSource) && /visual-completion:no-right-flip-wait/.test(bookPlaybackTimelineSource)], ['book playback timeline waits for right reveal only when current block is on right page', /getBlockRevealSides/.test(bookPlaybackTimelineSource) && /revealSides\.includes\('right'\) && this\.requiresRightPageFlipAfterReveal/.test(bookPlaybackTimelineSource) && /visual-completion:no-right-flip-wait/.test(bookPlaybackTimelineSource)],
['book playback timeline flips at planned right-page fragment time instead of full TTS completion', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /Promise\.race\(\[[\s\S]*this\.waitForRevealCommit\(segment\)/.test(bookPlaybackTimelineSource)], ['book playback timeline flips at planned right-page fragment time instead of full TTS completion', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /Promise\.race\(\[[\s\S]*this\.waitForRevealCommit\(segment\)/.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)], ['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 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 buttons use visited page limit instead of future prepared pages', /maxVisitedPagePosition/.test(source) && /navigateToPagePosition\(maxVisitedPagePosition\)/.test(source) && /const navigableLimit = Math\.min\(maxVisitedPagePosition, writableLimit\)/.test(source) && !/navigateToPagePosition\(bookPaginationState\.writtenPageLimit\)/.test(source)], ['webgl navigation buttons cap at visited page and written content limit', /maxVisitedPagePosition/.test(source) && /navigateToPagePosition\(maxVisitedPagePosition\)/.test(source) && /function getNavigablePageLimit\(\)/.test(source) && /const navigableLimit = getNavigablePageLimit\(\)/.test(source) && /Math\.min\(maxVisitedPagePosition, getWritablePageLimit\(\), contentNavigable\)/.test(source) && !/navigateToPagePosition\(bookPaginationState\.writtenPageLimit\)/.test(source)],
['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)], ['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 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)], ['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)],