Checkpoint WebGL book playback refactor state

This commit is contained in:
2026-06-10 01:07:22 +02:00
parent 171cafeb65
commit b41340151d
8 changed files with 824 additions and 370 deletions
+40 -5
View File
@@ -6,16 +6,16 @@ This document captures the agreed direction for the WebGL book UI. Later decisio
Build a beautiful, readable, extensible WebGL book interface for the interactive fiction UI. Build a beautiful, readable, extensible WebGL book interface for the interactive fiction UI.
The current stable milestone is `public/webgl-book-lab.html`: an open procedural book lying on a polished wooden table, lit by flickering candles, with the existing application page content rendered into textures that are applied to the actual top surfaces of the paper stacks. The current stable milestone is the integrated 3D game UI at `http://localhost:3001/`: an open procedural book lying on a polished wooden table, lit by flickering candles, with game content typeset directly into texture-space canvases and applied to the actual top surfaces of the paper stacks.
The later product goal is a procedural book UI that supports virtual scrolling, animated page flips, dynamic page stacks, and content backfilling across spreads. The product goal is a procedural book UI that supports virtual scrolling, animated page flips, dynamic page stacks, and content backfilling across spreads.
## Current Implementation Snapshot ## Current Implementation Snapshot
This section records the current state after the procedural book integration work. This section records the current state after the procedural book integration work.
- The active standalone scene is `public/webgl-book-lab.html`. - The active integrated scene is loaded through the game at `http://localhost:3001/`.
- The intended local test URL is `http://localhost:3001/webgl-book-lab.html`. - `public/webgl-book-lab.html` remains a reference/prototype file, not the primary implementation target.
- The current test server is expected to be the single Node process listening on port `3001`. - The current test server is expected to be the single Node process listening on port `3001`.
- The procedural book model lives in `public/js/procedural-book-model.js`. - The procedural book model lives in `public/js/procedural-book-model.js`.
- The WebGL lab integration lives in `public/js/webgl-book-lab.js`. - The WebGL lab integration lives in `public/js/webgl-book-lab.js`.
@@ -29,7 +29,7 @@ This section records the current state after the procedural book integration wor
- `PAGE_SPLINE_LENGTH` must match `PAGE_WIDTH`. - `PAGE_SPLINE_LENGTH` must match `PAGE_WIDTH`.
- Cover width is derived from page width plus cover overhang. - Cover width is derived from page width plus cover overhang.
- The spine bottom is aligned to the table plane with only a tiny render clearance. - The spine bottom is aligned to the table plane with only a tiny render clearance.
- The book controls are in the top bar: fast backward, backward, progress slider, page-count slider, forward, and fast forward. - The book navigation controls are the bottom media-style navigation controls: return to beginning, flip backward, page-position scrollbar, flip forward, and go to end.
- Slow page flips and fast 10-page transitions are implemented. - Slow page flips and fast 10-page transitions are implemented.
- Fast transitions run overlapping flip animations before shifting the book by one bundle. - Fast transitions run overlapping flip animations before shifting the book by one bundle.
- The readable page content belongs on the visible top cap of the paper stacks. - The readable page content belongs on the visible top cap of the paper stacks.
@@ -52,6 +52,41 @@ 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
The 3D book pipeline is module-owned. No page content should be generated by ad hoc scene code.
- `ui-display-handler` owns the live 3D prepare -> optional page flip -> activate sequence for story text.
- `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:
- 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`.
- These problem states must appear in `webglPageCacheProblems` and must not be silently fixed by borrowing unrelated visible stack textures.
## 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 following events remain formal integration events and must keep their meaning stable while they exist:
- `webgl-book:page-texture-records` publishes explicit page texture records from `book-texture-renderer` to the WebGL scene.
- `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:
- `webgl-book:page-canvases` is obsolete. New code must use `webgl-book:page-texture-records`.
- `preloadOnly` and `allowFutureUnrendered` are obsolete boolean flags. New code must use explicit `phase` and `visibility` values.
## Non-Negotiable Workflow Rules ## Non-Negotiable Workflow Rules
- Do not continue visual coding without a concrete plan for the current sprint. - Do not continue visual coding without a concrete plan for the current sprint.
+24 -7
View File
@@ -94,7 +94,7 @@ class BookPaginationModule extends BaseModule {
this.pages = this.buildPages([]); this.pages = this.buildPages([]);
this.spreads = this.buildSpreadsFromPages(this.pages); this.spreads = this.buildSpreadsFromPages(this.pages);
this.currentSpreadIndex = 0; this.currentSpreadIndex = 0;
this.publish({ reason: 'initial-title-spread', allowFutureUnrendered: true }); this.publish({ reason: 'initial-title-spread', visibility: 'future-ready' });
this.reportProgress(100, 'Book pagination ready'); this.reportProgress(100, 'Book pagination ready');
return true; return true;
} }
@@ -146,7 +146,7 @@ class BookPaginationModule extends BaseModule {
: renderedSpreadIndex >= 0 : renderedSpreadIndex >= 0
? renderedSpreadIndex ? renderedSpreadIndex
: Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1))); : Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
this.publish({ reason: 'history-refresh', allowFutureUnrendered: true }); this.publish({ reason: 'history-refresh', visibility: 'future-ready' });
} }
getContinuationBlockId(latestBlockId = 0, latestRenderedBlockId = 0) { getContinuationBlockId(latestBlockId = 0, latestRenderedBlockId = 0) {
@@ -187,7 +187,7 @@ class BookPaginationModule extends BaseModule {
spreadIndex: cached.targetSpread?.index ?? this.currentSpreadIndex, spreadIndex: cached.targetSpread?.index ?? this.currentSpreadIndex,
latestBlockId: pendingBlockId, latestBlockId: pendingBlockId,
latestRenderedBlockId, latestRenderedBlockId,
preloadOnly: false, phase: 'activate',
reusedPreparedPagination: true reusedPreparedPagination: true
} }
})); }));
@@ -240,7 +240,7 @@ class BookPaginationModule extends BaseModule {
spreadIndex: targetSpread?.index ?? this.currentSpreadIndex, spreadIndex: targetSpread?.index ?? this.currentSpreadIndex,
latestBlockId: pendingBlockId, latestBlockId: pendingBlockId,
latestRenderedBlockId, latestRenderedBlockId,
preloadOnly: options.activate === false phase: options.activate === false ? 'prepare' : 'activate'
} }
})); }));
return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread()); return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread());
@@ -371,7 +371,8 @@ class BookPaginationModule extends BaseModule {
buildSpreadsFromPages(pages = []) { buildSpreadsFromPages(pages = []) {
const spreads = []; const spreads = [];
const linesPerPage = this.getLinesPerPage(); const linesPerPage = this.getLinesPerPage();
pages.forEach((page, pageIndex) => { const normalizedPages = this.normalizePagesForSpreads(pages);
normalizedPages.forEach((page, pageIndex) => {
const spreadIndex = Math.floor(pageIndex / 2); const spreadIndex = Math.floor(pageIndex / 2);
const side = pageIndex % 2 === 0 ? 'left' : 'right'; const side = pageIndex % 2 === 0 ? 'left' : 'right';
if (!spreads[spreadIndex]) { if (!spreads[spreadIndex]) {
@@ -398,6 +399,22 @@ class BookPaginationModule extends BaseModule {
return spreads.filter(Boolean); return spreads.filter(Boolean);
} }
normalizePagesForSpreads(pages = []) {
const source = Array.isArray(pages) ? pages : [];
const lastPageIndex = source.reduce((max, page, index) => {
const explicitIndex = Number(page?.index);
return Math.max(max, Number.isFinite(explicitIndex) ? explicitIndex : index);
}, 1);
const lastSpreadRightIndex = Math.max(1, lastPageIndex % 2 === 0 ? lastPageIndex + 1 : lastPageIndex);
const normalized = [];
for (let index = 0; index <= lastSpreadRightIndex; index += 1) {
normalized[index] = source[index] || this.createBlankPage(index, {
section: index < 3 ? 'frontmatter' : 'body'
});
}
return normalized;
}
applyPageReserveDirective(block = {}) { applyPageReserveDirective(block = {}) {
const directive = block?.metadata?.pageReserve || block?.pageReserve || null; const directive = block?.metadata?.pageReserve || block?.pageReserve || null;
const blockId = Number(block?.blockId || block?.metadata?.blockId || 0); const blockId = Number(block?.blockId || block?.metadata?.blockId || 0);
@@ -967,7 +984,7 @@ class BookPaginationModule extends BaseModule {
setCurrentSpread(index = 0) { setCurrentSpread(index = 0) {
this.currentSpreadIndex = Math.max(0, Math.min(Math.round(Number(index || 0)), Math.max(0, this.spreads.length - 1))); this.currentSpreadIndex = Math.max(0, Math.min(Math.round(Number(index || 0)), Math.max(0, this.spreads.length - 1)));
this.publish({ reason: 'set-current-spread', allowFutureUnrendered: true }); this.publish({ reason: 'set-current-spread', visibility: 'future-ready' });
return this.currentSpreadIndex; return this.currentSpreadIndex;
} }
@@ -982,7 +999,7 @@ class BookPaginationModule extends BaseModule {
latestBlockId: this.latestBlockId, latestBlockId: this.latestBlockId,
latestRenderedBlockId: this.latestRenderedBlockId, latestRenderedBlockId: this.latestRenderedBlockId,
reason: options.reason || 'publish', reason: options.reason || 'publish',
allowFutureUnrendered: options.allowFutureUnrendered === true visibility: options.visibility || 'current'
} }
})); }));
} }
+88 -112
View File
@@ -29,7 +29,6 @@ class BookTextureRendererModule extends BaseModule {
this.activeAnimations = new Map(); this.activeAnimations = new Map();
this.revealedBlockIds = new Set(); this.revealedBlockIds = new Set();
this.pendingRevealBlockIds = new Set(); this.pendingRevealBlockIds = new Set();
this.preparedRevealCache = new Map();
this.revealBaseCanvases = null; this.revealBaseCanvases = null;
this.revealPublishBlockIds = null; this.revealPublishBlockIds = null;
this.lastDrawSignature = null; this.lastDrawSignature = null;
@@ -39,7 +38,6 @@ class BookTextureRendererModule extends BaseModule {
this.targetFrameDurationMs = 1000 / 60; this.targetFrameDurationMs = 1000 / 60;
this.pipelineTimings = []; this.pipelineTimings = [];
this.imageCache = new Map(); this.imageCache = new Map();
this.pendingPageCacheWrites = new Map();
this.pageContentVersions = new Map(); this.pageContentVersions = new Map();
this.bindMethods([ this.bindMethods([
@@ -63,6 +61,7 @@ class BookTextureRendererModule extends BaseModule {
'drawLine', 'drawLine',
'drawWord', 'drawWord',
'buildRevealRegions', 'buildRevealRegions',
'shouldFlipAfterSideReveal',
'collectRevealRegionCandidates', 'collectRevealRegionCandidates',
'createRevealRegionForLine', 'createRevealRegionForLine',
'assignRevealTiming', 'assignRevealTiming',
@@ -82,6 +81,7 @@ class BookTextureRendererModule extends BaseModule {
'spreadContainsBlock', 'spreadContainsBlock',
'hasPreparedRevealBlock', 'hasPreparedRevealBlock',
'createAnimationState', 'createAnimationState',
'getDrawPhase',
'publishPreparedReveal', 'publishPreparedReveal',
'startPreparedRevealAnimation', 'startPreparedRevealAnimation',
'fastForwardAnimations', 'fastForwardAnimations',
@@ -92,10 +92,8 @@ class BookTextureRendererModule extends BaseModule {
'requestAnimationFrame', 'requestAnimationFrame',
'tickAnimations', 'tickAnimations',
'publishSpread', 'publishSpread',
'buildPageTextureRecords',
'cachePublishedPages', 'cachePublishedPages',
'getPageCacheWriteKey',
'isOlderPageMeta',
'schedulePageCacheWrite',
'getPageCanvas', 'getPageCanvas',
'getHitMap', 'getHitMap',
'handlePageCountChanged' 'handlePageCountChanged'
@@ -120,11 +118,12 @@ class BookTextureRendererModule extends BaseModule {
const spreadIndex = Math.max(0, Number(event.detail?.spreadIndex ?? spread?.index ?? 0)); const spreadIndex = Math.max(0, Number(event.detail?.spreadIndex ?? spread?.index ?? 0));
const latestBlockId = event.detail?.latestBlockId; const latestBlockId = event.detail?.latestBlockId;
const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0)); const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0));
const visibility = event.detail?.visibility || 'current';
this.currentSpread = spread || { left: [], right: [] }; this.currentSpread = spread || { left: [], right: [] };
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) { if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) {
this.markPendingReveal(latestBlockId); this.markPendingReveal(latestBlockId);
const id = String(latestBlockId); const id = String(latestBlockId);
if (event.detail?.allowFutureUnrendered === true && !this.activeAnimations.has(id)) { if (visibility === 'future-ready' && !this.activeAnimations.has(id)) {
this.drawSpread(this.stripUnrenderedLines(this.currentSpread, latestRenderedBlockId), ['left', 'right']); this.drawSpread(this.stripUnrenderedLines(this.currentSpread, latestRenderedBlockId), ['left', 'right']);
return; return;
} }
@@ -132,8 +131,8 @@ class BookTextureRendererModule extends BaseModule {
this.revealPublishBlockIds = new Set([id]); this.revealPublishBlockIds = new Set([id]);
const visibleSpread = Math.max(0, Number(window.BookLabDebug?.getBookState?.().spreadIndex || 0)); const visibleSpread = Math.max(0, Number(window.BookLabDebug?.getBookState?.().spreadIndex || 0));
const flipActive = document.documentElement.dataset.webglPageFlipActive === 'true'; const flipActive = document.documentElement.dataset.webglPageFlipActive === 'true';
if (!flipActive && event.detail?.allowFutureUnrendered !== true && spreadIndex > visibleSpread) { if (!flipActive && visibility !== 'future-ready' && spreadIndex > visibleSpread) {
this.drawSpread(this.currentSpread, ['left', 'right'], { preloadOnly: true }); this.drawSpread(this.currentSpread, ['left', 'right'], { phase: 'prepare' });
return; return;
} }
this.drawSpread(this.currentSpread, ['left', 'right']); this.drawSpread(this.currentSpread, ['left', 'right']);
@@ -226,20 +225,21 @@ class BookTextureRendererModule extends BaseModule {
this.currentSpread = spread || { left: [], right: [] }; this.currentSpread = spread || { left: [], right: [] };
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0; const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0;
const phase = this.getDrawPhase(options);
const drawSignature = this.getDrawSignature(this.currentSpread, sidesToDraw); const drawSignature = this.getDrawSignature(this.currentSpread, sidesToDraw);
if (!options.preloadOnly && !hasReveal && drawSignature === this.lastDrawSignature) { if (phase !== 'prepare' && !hasReveal && drawSignature === this.lastDrawSignature) {
const now = performance.now(); const now = performance.now();
if (now - this.lastDrawSkipLoggedAt > 1000) { if (now - this.lastDrawSkipLoggedAt > 1000) {
this.lastDrawSkipLoggedAt = now; this.lastDrawSkipLoggedAt = now;
this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw }); this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw });
} }
if (options.preloadOnly) this.currentSpread = previousSpread; if (phase === 'prepare') this.currentSpread = previousSpread;
return null; return null;
} }
this.markPipelineTiming('drawSpread:start', { this.markPipelineTiming('drawSpread:start', {
sides: sidesToDraw, sides: sidesToDraw,
revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : [], revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : [],
preloadOnly: Boolean(options.preloadOnly) phase
}); });
this.revealBaseCanvases = { left: null, right: null }; this.revealBaseCanvases = { left: null, right: null };
sidesToDraw.forEach((side) => { sidesToDraw.forEach((side) => {
@@ -253,15 +253,20 @@ class BookTextureRendererModule extends BaseModule {
const published = this.publishSpread(sidesToDraw, options); const published = this.publishSpread(sidesToDraw, options);
this.markPipelineTiming('drawSpread:end', { this.markPipelineTiming('drawSpread:end', {
sides: sidesToDraw, sides: sidesToDraw,
preloadOnly: Boolean(options.preloadOnly) phase
}); });
this.revealBaseCanvases = null; this.revealBaseCanvases = null;
this.revealPublishBlockIds = null; this.revealPublishBlockIds = null;
if (!options.preloadOnly && !hasReveal) this.lastDrawSignature = drawSignature; if (phase !== 'prepare' && !hasReveal) this.lastDrawSignature = drawSignature;
if (options.preloadOnly) this.currentSpread = previousSpread; if (phase === 'prepare') this.currentSpread = previousSpread;
return published; return published;
} }
getDrawPhase(options = {}) {
if (options.phase === 'prepare' || options.phase === 'activate') return options.phase;
return 'activate';
}
getDrawSignature(spread = null, sides = []) { getDrawSignature(spread = null, sides = []) {
const source = spread || {}; const source = spread || {};
return sides.map(side => { return sides.map(side => {
@@ -644,6 +649,7 @@ class BookTextureRendererModule extends BaseModule {
return { return {
blockIds: Array.from(byBlock.keys()), blockIds: Array.from(byBlock.keys()),
durationMs: sideRegions.reduce((maxDuration, region) => Math.max(maxDuration, region.timing.delay + region.timing.duration), 0), durationMs: sideRegions.reduce((maxDuration, region) => Math.max(maxDuration, region.timing.delay + region.timing.duration), 0),
pageFlipAfterReveal: this.shouldFlipAfterSideReveal(side),
baseCanvas: null, baseCanvas: null,
lineRects: sideRegions.map(region => ({ lineRects: sideRegions.map(region => ({
blockId: region.blockId, blockId: region.blockId,
@@ -660,6 +666,19 @@ class BookTextureRendererModule extends BaseModule {
}; };
} }
shouldFlipAfterSideReveal(side) {
if (side !== 'right') return false;
const meta = this.currentSpread?.pageMeta?.right || null;
if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false;
const rightLines = Array.isArray(this.currentSpread?.right) ? this.currentSpread.right : [];
const maxLine = rightLines.reduce((max, line) => Math.max(
max,
Number(line?.pageLine || 0) + Math.max(1, Number(line?.lineCount || 1))
), 0);
const expectedLines = Math.max(1, Number(meta.linesPerPage || 25));
return maxLine >= expectedLines;
}
collectRevealRegionCandidates() { collectRevealRegionCandidates() {
const candidates = []; const candidates = [];
const sourceSpreads = Array.isArray(this.pagination?.spreads) && this.pagination.spreads.length const sourceSpreads = Array.isArray(this.pagination?.spreads) && this.pagination.spreads.length
@@ -879,14 +898,16 @@ class BookTextureRendererModule extends BaseModule {
if (blockId == null || !Array.isArray(detail.wordTimings)) return; if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const id = String(blockId); const id = String(blockId);
const wordTimings = detail.wordTimings; const wordTimings = detail.wordTimings;
const preloadOnly = Boolean(detail.preloadOnly || options.preloadOnly); const phase = detail.phase === 'prepare' || options.phase === 'prepare'
? 'prepare'
: 'activate';
this.markPipelineTiming('prepareRevealBlock:start', { this.markPipelineTiming('prepareRevealBlock:start', {
blockId: id, blockId: id,
wordTimingCount: wordTimings.length, wordTimingCount: wordTimings.length,
preloadOnly phase
}); });
if (!preloadOnly && this.preparedRevealCache.has(id)) { if (phase === 'activate' && this.pageCache?.hasPreparedRevealPlan?.(id)) {
const cached = this.preparedRevealCache.get(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.pendingRevealBlockIds.delete(id);
this.publishPreparedReveal(cached); this.publishPreparedReveal(cached);
@@ -903,10 +924,10 @@ class BookTextureRendererModule extends BaseModule {
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'];
const published = this.drawSpread(spread, sides, { preloadOnly }); const published = this.drawSpread(spread, sides, { phase });
if (!preloadOnly) this.preloadAdditionalRevealSpreads(id, spread); if (phase !== 'prepare') this.preloadAdditionalRevealSpreads(id, spread);
if (preloadOnly && published) { if (phase === 'prepare' && published) {
this.preparedRevealCache.set(id, { this.pageCache?.rememberPreparedRevealPlan?.(id, {
...published, ...published,
blockId, blockId,
wordTimings, wordTimings,
@@ -916,7 +937,7 @@ class BookTextureRendererModule extends BaseModule {
this.markPipelineTiming('prepareRevealBlock:end', { this.markPipelineTiming('prepareRevealBlock:end', {
blockId: id, blockId: id,
wordTimingCount: wordTimings.length, wordTimingCount: wordTimings.length,
preloadOnly phase
}); });
} }
@@ -927,7 +948,7 @@ class BookTextureRendererModule extends BaseModule {
spreads.forEach((spread) => { spreads.forEach((spread) => {
if (!spread || Number(spread.index) === primaryIndex) return; if (!spread || Number(spread.index) === primaryIndex) return;
if (!this.spreadContainsBlock(spread, blockId)) return; if (!this.spreadContainsBlock(spread, blockId)) return;
this.drawSpread(spread, ['left', 'right'], { preloadOnly: true }); this.drawSpread(spread, ['left', 'right'], { phase: 'prepare' });
}); });
} }
@@ -941,7 +962,7 @@ class BookTextureRendererModule extends BaseModule {
hasPreparedRevealBlock(blockId) { hasPreparedRevealBlock(blockId) {
const id = String(blockId ?? ''); const id = String(blockId ?? '');
return Boolean(id && this.preparedRevealCache.has(id)); return Boolean(id && this.pageCache?.hasPreparedRevealPlan?.(id));
} }
publishPreparedReveal(prepared) { publishPreparedReveal(prepared) {
@@ -951,14 +972,14 @@ class BookTextureRendererModule extends BaseModule {
sides: prepared.sides || [], sides: prepared.sides || [],
hasReveal: Boolean(prepared.reveal && Object.keys(prepared.reveal).length) hasReveal: Boolean(prepared.reveal && Object.keys(prepared.reveal).length)
}); });
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', {
detail: { detail: {
metrics: prepared.metrics, metrics: prepared.metrics,
hitMaps: prepared.hitMaps || this.hitMaps, hitMaps: prepared.hitMaps || this.hitMaps,
left: prepared.left || null, records: prepared.records || this.buildPageTextureRecords(prepared.sides || ['left', 'right'], prepared),
right: prepared.right || null,
reveal: prepared.reveal || {}, reveal: prepared.reveal || {},
pageMeta: prepared.pageMeta || {}, pageMeta: prepared.pageMeta || {},
phase: 'activate',
preparedFromCache: true preparedFromCache: true
} }
})); }));
@@ -1095,6 +1116,7 @@ class BookTextureRendererModule extends BaseModule {
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 regionCounts = { const regionCounts = {
left: 0, left: 0,
right: 0 right: 0
@@ -1103,142 +1125,96 @@ class BookTextureRendererModule extends BaseModule {
metrics: this.metrics, metrics: this.metrics,
hitMaps: this.hitMaps, hitMaps: this.hitMaps,
sides: sidesToPublish, sides: sidesToPublish,
pageMeta: this.buildPublishPageMeta(sidesToPublish) pageMeta: this.buildPublishPageMeta(sidesToPublish),
phase
}; };
if (options.preloadOnly) detail.preloadOnly = true;
if (sidesToPublish.includes('left')) { if (sidesToPublish.includes('left')) {
detail.left = options.preloadOnly ? this.cloneCanvas(this.canvases.left) : this.canvases.left; detail.left = phase === 'prepare' ? this.cloneCanvas(this.canvases.left) : this.canvases.left;
} }
if (sidesToPublish.includes('right')) { if (sidesToPublish.includes('right')) {
detail.right = options.preloadOnly ? this.cloneCanvas(this.canvases.right) : this.canvases.right; detail.right = phase === 'prepare' ? this.cloneCanvas(this.canvases.right) : this.canvases.right;
} }
const reveal = {}; const reveal = {};
sidesToPublish.forEach((side) => { sidesToPublish.forEach((side) => {
const sideReveal = this.buildRevealRegions(side); const sideReveal = this.buildRevealRegions(side);
if (!sideReveal) return; if (!sideReveal) return;
sideReveal.baseCanvas = options.preloadOnly sideReveal.baseCanvas = phase === 'prepare'
? this.cloneCanvas(this.revealBaseCanvases?.[side]) ? this.cloneCanvas(this.revealBaseCanvases?.[side])
: this.revealBaseCanvases?.[side] || null; : this.revealBaseCanvases?.[side] || null;
regionCounts[side] = sideReveal.lineRects.length; regionCounts[side] = sideReveal.lineRects.length;
reveal[side] = sideReveal; reveal[side] = sideReveal;
}); });
if (Object.keys(reveal).length) detail.reveal = reveal; if (Object.keys(reveal).length) detail.reveal = reveal;
detail.records = this.buildPageTextureRecords(sidesToPublish, detail);
this.cachePublishedPages(sidesToPublish, detail); this.cachePublishedPages(sidesToPublish, detail);
this.markPipelineTiming('publishSpread', { this.markPipelineTiming('publishSpread', {
sides: sidesToPublish, sides: sidesToPublish,
hasReveal: Object.keys(reveal).length > 0, hasReveal: Object.keys(reveal).length > 0,
regionCounts, regionCounts,
preloadOnly: Boolean(options.preloadOnly) phase
}); });
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', {
detail detail
})); }));
return detail; return detail;
} }
buildPageTextureRecords(sides = [], detail = {}) {
return sides.map((side) => ({
side,
phase: detail.phase || 'activate',
canvas: detail[side] || null,
pageMeta: detail.pageMeta?.[side] || null,
reveal: detail.reveal?.[side] || null,
state: {
canvasReady: Boolean(detail[side]),
vramReady: detail.phase === 'prepare',
visible: detail.phase !== 'prepare'
}
}));
}
buildPublishPageMeta(sides = []) { buildPublishPageMeta(sides = []) {
const baseMeta = this.currentSpread?.pageMeta || {}; const baseMeta = this.currentSpread?.pageMeta || {};
const spreadIndex = Math.max(0, Math.round(Number(this.currentSpread?.index || 0)));
return sides.reduce((meta, side) => { return sides.reduce((meta, side) => {
const source = baseMeta[side] || null; const pageIndex = side === 'left' ? spreadIndex * 2 : spreadIndex * 2 + 1;
if (!source) { const source = baseMeta[side] || {
meta[side] = null; kind: 'blank',
return meta; section: pageIndex < 3 ? 'frontmatter' : 'body',
} pageIndex,
pageNumber: null,
omitPageNumber: true
};
const lines = Array.isArray(this.currentSpread?.[side]) ? this.currentSpread[side] : []; const lines = Array.isArray(this.currentSpread?.[side]) ? this.currentSpread[side] : [];
const maxBlockId = lines.reduce((max, line) => Math.max(max, Number(line?.blockId || 0)), 0); const maxBlockId = lines.reduce((max, line) => Math.max(max, Number(line?.blockId || 0)), 0);
const lineCount = lines.length; const lineCount = lines.length;
const pageIndex = Number(source.pageIndex); const normalizedPageIndex = Number(source.pageIndex);
const key = Number.isFinite(pageIndex) ? pageIndex : side; const key = Number.isFinite(normalizedPageIndex) ? normalizedPageIndex : side;
const nextVersion = Math.max(1, Number(this.pageContentVersions.get(key) || 0) + 1); const nextVersion = Math.max(1, Number(this.pageContentVersions.get(key) || 0) + 1);
this.pageContentVersions.set(key, nextVersion); this.pageContentVersions.set(key, nextVersion);
meta[side] = { meta[side] = {
...source, ...source,
pageIndex: Number.isFinite(normalizedPageIndex) ? normalizedPageIndex : pageIndex,
contentVersion: nextVersion, contentVersion: nextVersion,
completenessScore: (maxBlockId * 1000) + lineCount, completenessScore: (maxBlockId * 1000) + lineCount,
maxBlockId, maxBlockId,
lineCount lineCount
}; };
return meta; return meta;
}, { }, {});
left: Object.prototype.hasOwnProperty.call(baseMeta, 'left') ? baseMeta.left : null,
right: Object.prototype.hasOwnProperty.call(baseMeta, 'right') ? baseMeta.right : null
});
} }
cachePublishedPages(sides = [], detail = {}) { cachePublishedPages(sides = [], detail = {}) {
if (!this.pageCache || typeof this.pageCache.cachePageCanvas !== 'function') return; if (!this.pageCache || typeof this.pageCache.storePageCanvas !== 'function') return;
sides.forEach((side) => { sides.forEach((side) => {
const canvas = detail[side]; const canvas = detail[side];
const pageMeta = detail.pageMeta?.[side] || null; const pageMeta = detail.pageMeta?.[side] || null;
if (!canvas || !pageMeta || !Number.isFinite(Number(pageMeta.pageIndex))) return; if (!canvas || !pageMeta || !Number.isFinite(Number(pageMeta.pageIndex))) return;
this.schedulePageCacheWrite(pageMeta, canvas); this.pageCache.storePageCanvas(pageMeta, canvas, { persist: true, resident: true });
}); });
} }
schedulePageCacheWrite(pageMeta, canvas) {
const frozenCanvas = this.cloneCanvas(canvas);
const key = this.getPageCacheWriteKey(pageMeta, frozenCanvas);
const pending = this.pendingPageCacheWrites.get(key);
if (pending && this.isOlderPageMeta(pageMeta, pending.pageMeta)) return pending.promise;
const previousWrite = pending?.promise || Promise.resolve();
const write = previousWrite.catch(() => false).then(() => this.pageCache?.cachePageCanvas?.(pageMeta, frozenCanvas))
.then((stored) => {
if (!stored) {
document.dispatchEvent(new CustomEvent('webgl-book:page-cache-problem', {
detail: {
type: 'db-write-failed',
pageIndex: pageMeta?.pageIndex ?? null,
key
}
}));
}
return stored;
})
.catch((error) => {
document.dispatchEvent(new CustomEvent('webgl-book:page-cache-problem', {
detail: {
type: 'db-write-error',
pageIndex: pageMeta?.pageIndex ?? null,
key,
message: error?.message || String(error)
}
}));
return false;
})
.finally(() => {
if (this.pendingPageCacheWrites.get(key)?.promise === write) {
this.pendingPageCacheWrites.delete(key);
}
});
this.pendingPageCacheWrites.set(key, {
promise: write,
pageMeta: { ...(pageMeta || {}) }
});
return write;
}
isOlderPageMeta(incoming = {}, existing = null) {
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 existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
return incomingVersion > 0 && existingVersion > incomingVersion;
}
getPageCacheWriteKey(pageMeta = {}, canvas = null) {
if (this.pageCache && typeof this.pageCache.makePageKey === 'function') {
return this.pageCache.makePageKey({
...pageMeta,
width: canvas?.width ?? pageMeta.width,
height: canvas?.height ?? pageMeta.height
});
}
return `${pageMeta.cacheKey || window.MODULE_CACHE_BUSTER || 'dev'}:page:${pageMeta.pageIndex}:${canvas?.width || pageMeta.width}x${canvas?.height || pageMeta.height}`;
}
getPageCanvas(side) { getPageCanvas(side) {
return this.canvases[side] || null; return this.canvases[side] || null;
} }
+2 -2
View File
@@ -937,8 +937,8 @@ class SentenceQueueModule extends BaseModule {
cueTimings: sentence.animation?.cueTimings || [], cueTimings: sentence.animation?.cueTimings || [],
totalDuration: sentence.animation?.totalDuration || 0, totalDuration: sentence.animation?.totalDuration || 0,
spread, spread,
preloadOnly: true phase: 'prepare'
}, { preloadOnly: true }); }, { phase: 'prepare' });
sentence.webglBookPresentation = { sentence.webglBookPresentation = {
prepared: true, prepared: true,
blockId, blockId,
+2 -2
View File
@@ -1081,10 +1081,10 @@ class UIDisplayHandlerModule extends BaseModule {
cueTimings: sentence.animation?.cueTimings || [], cueTimings: sentence.animation?.cueTimings || [],
totalDuration: sentence.animation?.totalDuration || 0, totalDuration: sentence.animation?.totalDuration || 0,
spread: previewSpread, spread: previewSpread,
preloadOnly: true phase: 'prepare'
}; };
if (previewSpread && typeof bookTextureRenderer.prepareRevealBlock === 'function') { if (previewSpread && typeof bookTextureRenderer.prepareRevealBlock === 'function') {
bookTextureRenderer.prepareRevealBlock(previewRevealDetail, { preloadOnly: true }); bookTextureRenderer.prepareRevealBlock(previewRevealDetail, { phase: 'prepare' });
} }
if (Number(previewSpread?.index || 0) > currentSpreadIndex) { if (Number(previewSpread?.index || 0) > currentSpreadIndex) {
const flipped = await this.waitForWebGLPageFlip({ const flipped = await this.waitForWebGLPageFlip({
+243 -219
View File
@@ -213,6 +213,7 @@ let activeFlips = [];
let pendingPageFlips = 0; let pendingPageFlips = 0;
const pendingRevealStartBlockIds = new Set(); const pendingRevealStartBlockIds = new Set();
const activeRevealBlockStarts = new Map(); const activeRevealBlockStarts = new Map();
let lastFlipTexturePreflight = null;
const paperColor = new THREE.Color(0xece4ca); const paperColor = new THREE.Color(0xece4ca);
const inkColor = '#1a1009'; const inkColor = '#1a1009';
@@ -246,19 +247,24 @@ function createPageCanvasTexture(sourceCanvas) {
} }
function getBlankPageTexture() { function getBlankPageTexture() {
if (blankPageTexture) return blankPageTexture; return pageTextureStore?.getBlankTexture?.() || createPageCanvasTexture(createPageCanvas('blank'));
blankPageTexture = createPageCanvasTexture(createPageCanvas('blank'));
return blankPageTexture;
} }
const preparedPageTextures = {
left: new Map(),
right: new Map()
};
const residentPageTextures = new Map();
const maxResidentPageTextures = 192; const maxResidentPageTextures = 192;
let blankPageTexture = null; const pageTextureStore = window.moduleRegistry?.getModule?.('webgl-page-cache') || window.WebGLPageCache || null;
const pageCacheProblemLog = []; pageTextureStore?.configureTextureRuntime?.({
THREE,
renderer,
configureTexture: configurePageCanvasTexture,
createBlankCanvas: () => createPageCanvas('blank'),
maxResidentTextureCount: maxResidentPageTextures,
maxPreparedTextureCount: 128
});
pageTextureStore?.registerVisibleTexture?.('left', leftTexture, leftCanvas);
pageTextureStore?.registerVisibleTexture?.('right', rightTexture, rightCanvas);
await reportLabStep(50, 'Initializing page texture store VRAM window');
pageTextureStore?.getBlankTexture?.();
await prewarmNavigationTextureWindow('loader-prime', { recordMiss: false });
let currentPageMeta = { let currentPageMeta = {
left: null, left: null,
right: null right: null
@@ -589,14 +595,18 @@ window.BookLabDebug = {
}; };
}, },
getRuntimeInvariants() { getRuntimeInvariants() {
const textureStoreState = pageTextureStore?.getRuntimeState?.() || {};
return { return {
targetFrameDurationMs, targetFrameDurationMs,
residentPageTextureCount: residentPageTextures.size, residentPageTextureCount: textureStoreState.residentTextureCount || 0,
maxResidentPageTextures, maxResidentPageTextures,
pageCacheProblemCount: pageCacheProblemLog.length, pageCacheProblemCount: textureStoreState.problemCount || 0,
preparedPageTextureCount: textureStoreState.preparedTextureCount || 0,
singlePageTextureStore: true,
flipFrontBackShareMaterial: materials.flipPageSurface === materials.flipPageBackSurface, flipFrontBackShareMaterial: materials.flipPageSurface === materials.flipPageBackSurface,
mirrorRefreshesEveryFrame: true, mirrorRefreshesEveryFrame: true,
mirrorRefreshesWhenStaticDirty: true mirrorRefreshesWhenStaticDirty: true,
lastFlipTexturePreflight
}; };
}, },
projectPointerToPage(clientX, clientY) { projectPointerToPage(clientX, clientY) {
@@ -610,7 +620,7 @@ window.BookLabDebug = {
}; };
window.addEventListener('resize', resize); window.addEventListener('resize', resize);
document.addEventListener('webgl-book:page-canvases', handlePageCanvases); document.addEventListener('webgl-book:page-texture-records', handlePageTextureRecords);
document.addEventListener('webgl-book:page-reveal-start', (event) => { document.addEventListener('webgl-book:page-reveal-start', (event) => {
startPageRevealForBlock(event.detail?.blockId); startPageRevealForBlock(event.detail?.blockId);
}); });
@@ -621,7 +631,7 @@ document.addEventListener('webgl-book:reveal-committed', (event) => {
handleRevealCommittedForPageFlip(event.detail || {}); handleRevealCommittedForPageFlip(event.detail || {});
}); });
document.addEventListener('webgl-book:page-cache-problem', (event) => { document.addEventListener('webgl-book:page-cache-problem', (event) => {
recordPageCacheProblem(event.detail || {}); pageTextureStore?.recordProblem?.(event.detail || {});
}); });
document.addEventListener('book-pagination:spread-updated', (event) => { document.addEventListener('book-pagination:spread-updated', (event) => {
const detail = event.detail || {}; const detail = event.detail || {};
@@ -630,7 +640,7 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0)); const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0));
if ( if (
latestBlockId > latestRenderedBlockId latestBlockId > latestRenderedBlockId
&& detail.allowFutureUnrendered !== true && detail.visibility !== 'future-ready'
&& activeFlips.length === 0 && activeFlips.length === 0
&& incomingSpreadIndex > Math.max(0, Number(bookPaginationState.spreadIndex || 0)) && incomingSpreadIndex > Math.max(0, Number(bookPaginationState.spreadIndex || 0))
) { ) {
@@ -2028,35 +2038,34 @@ function syncBottomNavigation() {
bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit; bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit;
} }
function handlePageCanvases(event) { function handlePageTextureRecords(event) {
const detail = event.detail || {}; const detail = normalizePageTextureRecordDetail(event.detail || {});
if (detail.pageMeta) { if (detail.pageMeta) {
const hasLeftMeta = Object.prototype.hasOwnProperty.call(detail.pageMeta, 'left'); currentPageMeta = normalizePageMetaPair(detail.pageMeta, currentPageMeta);
const hasRightMeta = Object.prototype.hasOwnProperty.call(detail.pageMeta, 'right');
currentPageMeta = {
left: hasLeftMeta ? detail.pageMeta.left : currentPageMeta.left || null,
right: hasRightMeta ? detail.pageMeta.right : currentPageMeta.right || null
};
} }
markPageTextureTiming('handlePageCanvases:start', { markPageTextureTiming('handlePageTextureRecords:start', {
hasLeft: Boolean(detail.left), hasLeft: Boolean(detail.left),
hasRight: Boolean(detail.right), hasRight: Boolean(detail.right),
revealSides: Object.keys(detail.reveal || {}), revealSides: Object.keys(detail.reveal || {}),
preloadOnly: Boolean(detail.preloadOnly), phase: detail.phase || 'activate',
pageMeta: currentPageMeta pageMeta: currentPageMeta
}); });
const leftReveal = attachRevealPageMeta(detail.reveal?.left, detail.pageMeta?.left || currentPageMeta.left || null); const leftReveal = attachRevealPageMeta(detail.reveal?.left, currentPageMeta.left || null);
const rightReveal = attachRevealPageMeta(detail.reveal?.right, detail.pageMeta?.right || currentPageMeta.right || null); const rightReveal = attachRevealPageMeta(detail.reveal?.right, currentPageMeta.right || null);
if (detail.preloadOnly) { if (detail.phase === 'prepare') {
if (detail.left) { if (detail.left) {
const texture = preloadPageTexture('left', detail.left, leftReveal, detail.pageMeta?.left || null); const texture = preloadPageTexture('left', detail.left, leftReveal, currentPageMeta.left);
rememberResidentPageTexture(detail.pageMeta?.left || null, texture, detail.left); pageTextureStore?.rememberResidentTexture?.(currentPageMeta.left, texture, detail.left, true);
} else if (currentPageMeta.left?.kind === 'blank') {
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.left, getBlankPageTexture(), null, false);
} }
if (detail.right) { if (detail.right) {
const texture = preloadPageTexture('right', detail.right, rightReveal, detail.pageMeta?.right || null); const texture = preloadPageTexture('right', detail.right, rightReveal, currentPageMeta.right);
rememberResidentPageTexture(detail.pageMeta?.right || null, texture, detail.right); pageTextureStore?.rememberResidentTexture?.(currentPageMeta.right, texture, detail.right, true);
} else if (currentPageMeta.right?.kind === 'blank') {
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.right, getBlankPageTexture(), null, false);
} }
markPageTextureTiming('handlePageCanvases:preloadOnly:end'); markPageTextureTiming('handlePageTextureRecords:prepare:end');
return; return;
} }
if (detail.left) { if (detail.left) {
@@ -2073,13 +2082,110 @@ function handlePageCanvases(event) {
uploadPageTextureDirect('right', detail.right, currentPageMeta.right); uploadPageTextureDirect('right', detail.right, currentPageMeta.right);
} }
} }
if (!detail.left && currentPageMeta.left?.kind === 'blank') {
applyExplicitBlankPageTexture('left', currentPageMeta.left, 'page-texture-records');
}
if (!detail.right && currentPageMeta.right?.kind === 'blank') {
applyExplicitBlankPageTexture('right', currentPageMeta.right, 'page-texture-records');
}
markStaticSceneBuffersDirty(); markStaticSceneBuffersDirty();
document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({ document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({
width: leftCanvas.width, width: leftCanvas.width,
height: leftCanvas.height, height: leftCanvas.height,
source: 'book-texture-renderer' source: 'book-texture-renderer'
}); });
markPageTextureTiming('handlePageCanvases:end'); markPageTextureTiming('handlePageTextureRecords:end');
prewarmNavigationTextureWindow('page-texture-records').catch((error) => {
pageTextureStore?.recordProblem?.({
type: 'navigation-window-prewarm-error',
message: error?.message || String(error)
});
});
}
function normalizePageTextureRecordDetail(detail = {}) {
if (!Array.isArray(detail.records) || detail.records.length === 0) {
return {
...detail,
phase: detail.phase === 'prepare' ? 'prepare' : 'activate'
};
}
return detail.records.reduce((normalized, record) => {
const side = record?.side === 'right' ? 'right' : 'left';
normalized[side] = record.canvas || normalized[side] || null;
normalized.pageMeta[side] = record.pageMeta || detail.pageMeta?.[side] || normalized.pageMeta[side] || null;
if (record.reveal) normalized.reveal[side] = record.reveal;
return normalized;
}, {
metrics: detail.metrics,
hitMaps: detail.hitMaps || {},
sides: detail.records.map(record => record?.side).filter(Boolean),
records: detail.records,
reveal: {},
pageMeta: {},
phase: detail.phase === 'prepare' ? 'prepare' : 'activate',
preparedFromCache: detail.preparedFromCache === true
});
}
function normalizePageMetaPair(pageMeta = {}, previousMeta = currentPageMeta) {
const spreadIndex = getSpreadIndexFromPageMeta(pageMeta)
?? getSpreadIndexFromPageMeta(previousMeta)
?? Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
const pageIndices = spreadPageIndices(spreadIndex);
return {
left: normalizePageSideMeta(pageMeta.left, pageIndices.left, 'left'),
right: normalizePageSideMeta(pageMeta.right, pageIndices.right, 'right')
};
}
function getSpreadIndexFromPageMeta(pageMeta = {}) {
const leftIndex = Number(pageMeta?.left?.pageIndex);
if (Number.isFinite(leftIndex)) return Math.floor(Math.max(0, leftIndex) / 2);
const rightIndex = Number(pageMeta?.right?.pageIndex);
if (Number.isFinite(rightIndex)) return Math.floor(Math.max(0, rightIndex) / 2);
return null;
}
function normalizePageSideMeta(meta = null, pageIndex = 0, side = 'left') {
const index = Math.max(0, Math.round(Number(meta?.pageIndex ?? pageIndex)));
if (!meta || meta.kind === 'blank') return makeBlankPageMeta(index, meta?.section || (index < 3 ? 'frontmatter' : 'body'));
return {
...meta,
pageIndex: index,
side
};
}
function makeBlankPageMeta(pageIndex = 0, section = 'body') {
return {
kind: 'blank',
section,
pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))),
pageNumber: null,
omitPageNumber: true,
lineCount: 0,
maxBlockId: 0,
completenessScore: 0,
side: Math.max(0, Math.round(Number(pageIndex || 0))) % 2 === 0 ? 'left' : 'right'
};
}
function applyExplicitBlankPageTexture(side, pageMeta = null, reason = 'blank-page') {
const material = side === 'left' ? materials.leftPage : materials.rightPage;
const blankTexture = getBlankPageTexture();
clearPageReveal(side, reason);
if (material.map !== blankTexture) {
material.map = blankTexture;
material.needsUpdate = true;
}
pageTextureStore?.rememberResidentTexture?.(pageMeta || makeBlankPageMeta(side === 'left' ? 0 : 1), blankTexture, null, false);
markPageTextureTiming('explicitBlankTexture', {
side,
pageIndex: pageMeta?.pageIndex ?? null,
reason
});
return blankTexture;
} }
function attachRevealPageMeta(revealDetail = null, pageMeta = null) { function attachRevealPageMeta(revealDetail = null, pageMeta = null) {
@@ -2099,30 +2205,15 @@ function getRevealCacheKey(revealDetail = {}) {
function preloadPageTexture(side, sourceCanvas, revealDetail = {}, pageMeta = null) { function preloadPageTexture(side, sourceCanvas, revealDetail = {}, pageMeta = null) {
if (!sourceCanvas) return null; if (!sourceCanvas) return null;
const texture = createPageCanvasTexture(sourceCanvas);
const baseTexture = revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null;
const key = getRevealCacheKey({ ...(revealDetail || {}), pageMeta: revealDetail?.pageMeta || pageMeta || null }); const key = getRevealCacheKey({ ...(revealDetail || {}), pageMeta: revealDetail?.pageMeta || pageMeta || null });
markPageTextureTiming('preloadTexture:start', { markPageTextureTiming('preloadTexture:start', {
side, side,
key, key,
width: sourceCanvas.width, width: sourceCanvas.width,
height: sourceCanvas.height, height: sourceCanvas.height,
hasBaseTexture: Boolean(baseTexture) hasBaseTexture: Boolean(revealDetail?.baseCanvas)
}); });
preparedPageTextures[side].set(key, { const texture = pageTextureStore?.preparePageTexture?.(side, key, pageMeta, sourceCanvas, revealDetail) || null;
texture,
baseTexture,
sourceCanvas,
revealDetail,
uploadedAt: performance.now()
});
if (preparedPageTextures[side].size > 128) {
const oldestKey = preparedPageTextures[side].keys().next().value;
const oldest = preparedPageTextures[side].get(oldestKey);
oldest?.texture?.dispose?.();
oldest?.baseTexture?.dispose?.();
preparedPageTextures[side].delete(oldestKey);
}
markPageTextureTiming('preloadTexture:end', { side, key }); markPageTextureTiming('preloadTexture:end', { side, key });
return texture; return texture;
} }
@@ -2142,54 +2233,6 @@ function setPageFlipActiveFlag() {
} }
} }
function recordPageCacheProblem(detail = {}) {
const entry = {
...detail,
at: performance.now()
};
pageCacheProblemLog.push(entry);
if (pageCacheProblemLog.length > 80) pageCacheProblemLog.splice(0, pageCacheProblemLog.length - 80);
document.documentElement.dataset.webglPageCacheProblems = JSON.stringify(pageCacheProblemLog);
console.warn('WebGL page cache problem', entry);
}
function rememberResidentPageTexture(pageMeta = null, texture = null, sourceCanvas = null, ownsTexture = true) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!texture || !Number.isFinite(pageIndex) || pageIndex < 0) return null;
const key = makeResidentPageTextureKey(pageMeta);
const existing = residentPageTextures.get(key);
if (isOlderPageTextureMeta(pageMeta, existing?.pageMeta)) return existing?.texture || null;
if (existing?.ownsTexture && existing.texture && existing.texture !== texture) existing.texture.dispose?.();
residentPageTextures.set(key, {
texture,
sourceCanvas: sourceCanvas || existing?.sourceCanvas || null,
lastUsedAt: performance.now(),
ownsTexture,
pageMeta: {
...(existing?.pageMeta || {}),
...(pageMeta || {})
}
});
while (residentPageTextures.size > maxResidentPageTextures) {
const oldestKey = residentPageTextures.keys().next().value;
const oldest = residentPageTextures.get(oldestKey);
if (oldest?.ownsTexture) oldest.texture?.dispose?.();
residentPageTextures.delete(oldestKey);
}
return texture;
}
function isOlderPageTextureMeta(incoming = {}, existing = null) {
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 existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
return incomingVersion > 0 && existingVersion > incomingVersion;
}
function makePageMetaForCache(pageIndex) { function makePageMetaForCache(pageIndex) {
const index = Math.max(0, Math.round(Number(pageIndex || 0))); const index = Math.max(0, Math.round(Number(pageIndex || 0)));
const paginationMeta = getPaginationPageMeta(index) || {}; const paginationMeta = getPaginationPageMeta(index) || {};
@@ -2201,16 +2244,6 @@ function makePageMetaForCache(pageIndex) {
}; };
} }
function makeResidentPageTextureKey(pageMetaOrIndex = {}) {
const pageMeta = typeof pageMetaOrIndex === 'number'
? makePageMetaForCache(pageMetaOrIndex)
: pageMetaOrIndex || {};
const pageIndex = Math.max(0, Math.round(Number(pageMeta.pageIndex || 0)));
const kind = String(pageMeta.kind || 'content');
const section = String(pageMeta.section || 'body');
return `${pageIndex}:${kind}:${section}`;
}
function spreadPageIndices(spreadIndex) { function spreadPageIndices(spreadIndex) {
const spread = Math.max(0, Math.round(Number(spreadIndex || 0))); const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
return { return {
@@ -2219,67 +2252,6 @@ function spreadPageIndices(spreadIndex) {
}; };
} }
function getResidentPageTexture(pageIndex) {
const key = makeResidentPageTextureKey(pageIndex);
const resident = residentPageTextures.get(key);
if (!resident) return null;
resident.lastUsedAt = performance.now();
residentPageTextures.delete(key);
residentPageTextures.set(key, resident);
return resident.texture || null;
}
function getResidentPageTextureForMeta(pageMeta = null) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!Number.isFinite(pageIndex)) return null;
const key = makeResidentPageTextureKey(pageMeta);
const resident = residentPageTextures.get(key);
if (!resident || isOlderPageTextureMeta(pageMeta, resident.pageMeta)) return null;
return getResidentPageTexture(pageIndex);
}
async function preloadCachedPageTexture(pageIndex) {
const meta = makePageMetaForCache(pageIndex);
const residentKey = makeResidentPageTextureKey(meta);
if (residentPageTextures.has(residentKey)) {
getResidentPageTexture(meta.pageIndex);
return residentPageTextures.get(residentKey)?.texture || null;
}
const cache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache') || null;
const sourceCanvas = await cache?.getPageCanvas?.(meta);
if (!sourceCanvas) {
const pageMeta = getPaginationPageMeta(meta.pageIndex);
if (pageMeta?.kind === 'blank') {
const blankTexture = getBlankPageTexture();
rememberResidentPageTexture({ ...meta, ...pageMeta }, blankTexture, null, false);
return blankTexture;
}
recordPageCacheProblem({
type: 'db-cache-miss',
pageIndex: meta.pageIndex,
width: meta.width,
height: meta.height
});
return null;
}
const texture = createPageCanvasTexture(sourceCanvas);
const cachedMeta = sourceCanvas.__webglPageCacheMeta || meta;
residentPageTextures.set(residentKey, {
texture,
sourceCanvas,
lastUsedAt: performance.now(),
ownsTexture: true,
pageMeta: cachedMeta
});
while (residentPageTextures.size > maxResidentPageTextures) {
const oldestKey = residentPageTextures.keys().next().value;
const oldest = residentPageTextures.get(oldestKey);
if (oldest?.ownsTexture) oldest.texture?.dispose?.();
residentPageTextures.delete(oldestKey);
}
return texture;
}
function getPaginationPageMeta(pageIndex) { function getPaginationPageMeta(pageIndex) {
const index = Math.max(0, Math.round(Number(pageIndex || 0))); const index = Math.max(0, Math.round(Number(pageIndex || 0)));
const spreadIndex = Math.floor(index / 2); const spreadIndex = Math.floor(index / 2);
@@ -2294,27 +2266,47 @@ function getPaginationPageMeta(pageIndex) {
} }
async function prewarmSpreadTextures(spreadIndex) { async function prewarmSpreadTextures(spreadIndex) {
const indices = spreadPageIndices(spreadIndex); return pageTextureStore?.prewarmSpreadTextures?.(spreadIndex, makePageMetaForCache) || {
const [left, right] = await Promise.all([
preloadCachedPageTexture(indices.left),
preloadCachedPageTexture(indices.right)
]);
return {
spreadIndex: Math.max(0, Math.round(Number(spreadIndex || 0))), spreadIndex: Math.max(0, Math.round(Number(spreadIndex || 0))),
left, left: null,
right right: null
}; };
} }
async function prewarmNavigationTextureWindow(reason = 'navigation-window', options = {}) {
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
const endSpread = Math.max(
0,
Math.round(Number(bookPaginationState.spreadCount || 1)) - 1,
pageToSpreadIndex(maxVisitedPagePosition)
);
markPageTextureTiming('textureStorePrewarm:start', {
reason,
currentSpread,
endSpread
});
const result = await pageTextureStore?.prewarmNavigationWindow?.({
currentSpread,
targetSpread: options.targetSpread,
endSpread,
getPageMetaForIndex: makePageMetaForCache,
recordMiss: options.recordMiss !== false
});
markPageTextureTiming('textureStorePrewarm:end', {
reason,
spreadCount: result ? Object.keys(result).length : 0
});
return result || {};
}
async function prewarmFlipTextures(direction, targetSpread = null) { async function prewarmFlipTextures(direction, targetSpread = null) {
const currentSpread = Math.max(0, Number(bookPaginationState.spreadIndex || 0)); const currentSpread = Math.max(0, Number(bookPaginationState.spreadIndex || 0));
const nextSpread = Number.isFinite(Number(targetSpread)) const nextSpread = Number.isFinite(Number(targetSpread))
? Math.max(0, Math.round(Number(targetSpread))) ? Math.max(0, Math.round(Number(targetSpread)))
: Math.max(0, currentSpread + Math.sign(Number(direction || 0))); : Math.max(0, currentSpread + Math.sign(Number(direction || 0)));
const [current, next] = await Promise.all([ const windowMap = await prewarmNavigationTextureWindow('flip-prewarm', { targetSpread: nextSpread });
prewarmSpreadTextures(currentSpread), const current = windowMap?.[currentSpread] || await prewarmSpreadTextures(currentSpread);
prewarmSpreadTextures(nextSpread) const next = windowMap?.[nextSpread] || await prewarmSpreadTextures(nextSpread);
]);
return { return {
current, current,
next next
@@ -2323,19 +2315,22 @@ async function prewarmFlipTextures(direction, targetSpread = null) {
function takePreparedPageTexture(side, revealDetail = {}) { function takePreparedPageTexture(side, revealDetail = {}) {
const key = getRevealCacheKey(revealDetail); const key = getRevealCacheKey(revealDetail);
const prepared = preparedPageTextures[side].get(key); const prepared = pageTextureStore?.takePreparedPageTexture?.(side, key) || null;
if (!prepared) return null; if (!prepared) return null;
preparedPageTextures[side].delete(key);
markPageTextureTiming('preloadTexture:activate', { side, key }); markPageTextureTiming('preloadTexture:activate', { side, key });
return prepared; return prepared;
} }
function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) { function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
if (pageMeta?.kind === 'blank') {
applyExplicitBlankPageTexture(side, pageMeta, 'direct-upload');
return;
}
const texture = side === 'left' ? leftTexture : rightTexture; const texture = side === 'left' ? leftTexture : rightTexture;
const material = side === 'left' ? materials.leftPage : materials.rightPage; const material = side === 'left' ? materials.leftPage : materials.rightPage;
const shouldUseResidentTexture = pageMeta?.kind !== 'title'; const shouldUseResidentTexture = pageMeta?.kind !== 'title';
const residentTexture = shouldUseResidentTexture && Number.isFinite(Number(pageMeta?.pageIndex)) const residentTexture = shouldUseResidentTexture && Number.isFinite(Number(pageMeta?.pageIndex))
? getResidentPageTextureForMeta(pageMeta) ? pageTextureStore?.getResidentTextureForMeta?.(pageMeta)
: null; : null;
markPageTextureTiming('directUpload:start', { markPageTextureTiming('directUpload:start', {
side, side,
@@ -2356,7 +2351,7 @@ function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
material.needsUpdate = true; material.needsUpdate = true;
} }
bindPageTextureSource(side, texture, sourceCanvas); bindPageTextureSource(side, texture, sourceCanvas);
rememberResidentPageTexture(pageMeta, texture, sourceCanvas, false); pageTextureStore?.rememberResidentTexture?.(pageMeta, texture, sourceCanvas, false);
markPageTextureTiming('directUpload:end', { side }); markPageTextureTiming('directUpload:end', { side });
} }
@@ -2381,7 +2376,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
} }
bindPageTextureSource(side, texture, sourceCanvas); bindPageTextureSource(side, texture, sourceCanvas);
} }
const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null); const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? pageTextureStore?.createTextureFromCanvas?.(revealDetail.baseCanvas) : null);
const revealBlockIds = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds.map(value => String(value)) : []; const revealBlockIds = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds.map(value => String(value)) : [];
const activeStartedAt = revealBlockIds const activeStartedAt = revealBlockIds
@@ -2397,6 +2392,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)), durationMs: Math.max(1, Number(revealDetail.durationMs || 1)),
blockIds: revealBlockIds, blockIds: revealBlockIds,
baseTexture, baseTexture,
pageFlipAfterReveal: revealDetail.pageFlipAfterReveal === true,
fastForwarding: false, fastForwarding: false,
fastForwardStartedAt: null, fastForwardStartedAt: null,
fastForwardStartElapsedMs: 0, fastForwardStartElapsedMs: 0,
@@ -2408,12 +2404,12 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
if (shader?.uniforms?.bookRevealElapsedMs) { if (shader?.uniforms?.bookRevealElapsedMs) {
shader.uniforms.bookRevealElapsedMs.value = pageRevealState[side].visualElapsedMs; shader.uniforms.bookRevealElapsedMs.value = pageRevealState[side].visualElapsedMs;
} }
if (side === 'right' && isRightBodyPageComplete()) { if (side === 'right' && revealDetail.pageFlipAfterReveal === true) {
const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1); const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1);
prewarmFlipTextures(1, targetSpread).then(() => { prewarmFlipTextures(1, targetSpread).then(() => {
markPageTextureTiming('rightPageReveal:flip-prewarm-ready', { targetSpread }); markPageTextureTiming('rightPageReveal:flip-prewarm-ready', { targetSpread });
}).catch((error) => { }).catch((error) => {
recordPageCacheProblem({ pageTextureStore?.recordProblem?.({
type: 'right-page-flip-prewarm-error', type: 'right-page-flip-prewarm-error',
targetSpread, targetSpread,
message: error?.message || String(error) message: error?.message || String(error)
@@ -2618,7 +2614,8 @@ function updatePageRevealAnimations(now) {
document.dispatchEvent(new CustomEvent('webgl-book:reveal-committed', { document.dispatchEvent(new CustomEvent('webgl-book:reveal-committed', {
detail: { detail: {
side, side,
blockIds: state.blockIds blockIds: state.blockIds,
pageFlipAfterReveal: state.pageFlipAfterReveal === true
} }
})); }));
}); });
@@ -2632,8 +2629,11 @@ function bindPageTextureSource(side, texture, sourceCanvas) {
width: nextCanvas?.width || 0, width: nextCanvas?.width || 0,
height: nextCanvas?.height || 0 height: nextCanvas?.height || 0
}); });
texture.image = sourceCanvas || fallbackCanvas; const boundTexture = pageTextureStore?.bindVisibleTextureSource?.(side, sourceCanvas) || null;
if (!boundTexture) {
texture.image = nextCanvas;
texture.needsUpdate = true; texture.needsUpdate = true;
}
updatePageTextureDebugState(side, nextCanvas, sourceCanvas, true); updatePageTextureDebugState(side, nextCanvas, sourceCanvas, true);
markPageTextureTiming('bindPageTextureSource:end', { side }); markPageTextureTiming('bindPageTextureSource:end', { side });
} }
@@ -2841,20 +2841,27 @@ function createPageFlip(direction, startTime, duration) {
function prepareStaticPageForFlip(flip, prewarm = null) { function prepareStaticPageForFlip(flip, prewarm = null) {
if (!flip) return false; if (!flip) return false;
const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage; const sourceSide = flip.direction > 0 ? 'right' : 'left';
const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture); const sourceTexture = resolveCurrentFlipSourceTexture(sourceSide);
const sourcePageMeta = currentPageMeta?.[sourceSide] || getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[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)));
const targetPages = spreadPageIndices(targetSpread); const targetPages = spreadPageIndices(targetSpread);
const targetBackPageIndex = flip.direction > 0 ? targetPages.left : targetPages.right; const targetBackSide = flip.direction > 0 ? 'left' : 'right';
const targetBackPageIndex = targetPages[targetBackSide];
const targetBackPageMeta = getPaginationPageMeta(targetBackPageIndex) || makeBlankPageMeta(targetBackPageIndex);
const prewarmedBackTexture = flip.direction > 0 ? prewarm?.next?.left : prewarm?.next?.right; const prewarmedBackTexture = flip.direction > 0 ? prewarm?.next?.left : prewarm?.next?.right;
const residentBackTexture = prewarmedBackTexture || getResidentPageTexture(targetBackPageIndex); const backTexture = resolveFlipBackTexture(targetBackPageMeta, prewarmedBackTexture);
const requiresWrittenTexture = targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0)); const requiresWrittenTexture = targetBackPageMeta.kind !== 'blank'
if (!residentBackTexture && requiresWrittenTexture) { && targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0));
recordPageCacheProblem({ if (!sourceTexture || (!backTexture && requiresWrittenTexture)) {
type: 'flip-back-texture-missing', pageTextureStore?.recordProblem?.({
type: !sourceTexture ? 'flip-source-texture-missing' : 'flip-back-texture-missing',
sourceSide,
sourcePageIndex: sourcePageMeta?.pageIndex ?? null,
targetBackPageIndex, targetBackPageIndex,
targetBackKind: targetBackPageMeta.kind,
targetSpread, targetSpread,
direction: flip.direction, direction: flip.direction,
prewarmedCurrent: Boolean(prewarm?.current), prewarmedCurrent: Boolean(prewarm?.current),
@@ -2862,9 +2869,8 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
}); });
return false; return false;
} }
const backTexture = residentBackTexture || getBlankPageTexture();
materials.flipPageSurface.map = sourceTexture; materials.flipPageSurface.map = sourceTexture;
materials.flipPageBackSurface.map = backTexture; materials.flipPageBackSurface.map = backTexture || getBlankPageTexture();
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;
@@ -2872,8 +2878,24 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
materials.flipPageSurface.needsUpdate = true; materials.flipPageSurface.needsUpdate = true;
materials.flipPageBackSurface.needsUpdate = true; materials.flipPageBackSurface.needsUpdate = true;
flip.sourceTexture = sourceTexture; flip.sourceTexture = sourceTexture;
flip.backTexture = backTexture; flip.sourcePageMeta = sourcePageMeta ? { ...sourcePageMeta } : null;
flip.backTexture = backTexture || getBlankPageTexture();
flip.backPageMeta = targetBackPageMeta ? { ...targetBackPageMeta } : null;
flip.targetBackPageIndex = targetBackPageIndex; flip.targetBackPageIndex = targetBackPageIndex;
flip.sourcePageSide = sourceSide;
lastFlipTexturePreflight = {
direction: flip.direction,
sourceSide,
sourcePageIndex: sourcePageMeta?.pageIndex ?? null,
sourceKind: sourcePageMeta?.kind || 'content',
targetSpread,
targetBackSide,
targetBackPageIndex,
targetBackKind: targetBackPageMeta.kind,
hasSourceTexture: Boolean(sourceTexture),
hasBackTexture: Boolean(backTexture || getBlankPageTexture()),
sourceTextureMatchesBackTexture: sourceTexture === (backTexture || getBlankPageTexture())
};
if (flip.direction > 0) { if (flip.direction > 0) {
const blankTexture = getBlankPageTexture(); const blankTexture = getBlankPageTexture();
if (blankTexture && materials.rightPage.map !== blankTexture) { if (blankTexture && materials.rightPage.map !== blankTexture) {
@@ -2890,15 +2912,27 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
} }
} }
markPageTextureTiming('flipTexturePreflight:ready', { markPageTextureTiming('flipTexturePreflight:ready', {
direction: flip.direction, ...lastFlipTexturePreflight,
sourceSide: flip.sourcePageSide, usedResidentBackTexture: Boolean(backTexture && backTexture !== getBlankPageTexture())
targetSpread,
targetBackPageIndex,
usedResidentBackTexture: Boolean(residentBackTexture)
}); });
return true; return true;
} }
function resolveCurrentFlipSourceTexture(side) {
const pageMeta = currentPageMeta?.[side] || null;
if (pageMeta?.kind === 'blank') return getBlankPageTexture();
const resident = pageTextureStore?.getResidentTextureForMeta?.(pageMeta);
if (resident) return resident;
const material = side === 'left' ? materials.leftPage : materials.rightPage;
return material?.map || null;
}
function resolveFlipBackTexture(pageMeta = null, prewarmedTexture = null) {
if (pageMeta?.kind === 'blank') return getBlankPageTexture();
if (prewarmedTexture) return prewarmedTexture;
return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);
}
function canPageFlip(direction) { function canPageFlip(direction) {
if (!currentProceduralBookModel) return false; if (!currentProceduralBookModel) return false;
const currentPage = getCurrentPagePosition(); const currentPage = getCurrentPagePosition();
@@ -2908,7 +2942,7 @@ function canPageFlip(direction) {
} }
function handleRevealCommittedForPageFlip(detail = {}) { function handleRevealCommittedForPageFlip(detail = {}) {
if (detail.side !== 'right' || !isRightBodyPageComplete()) return; if (detail.side !== 'right' || detail.pageFlipAfterReveal !== true) return;
if (activeFlips.length > 0 || pendingRightPageFlip) return; if (activeFlips.length > 0 || pendingRightPageFlip) return;
if (isChoiceAwaitingPlayer()) return; if (isChoiceAwaitingPlayer()) return;
const autoplayFlip = isTtsPlaybackActive(); const autoplayFlip = isTtsPlaybackActive();
@@ -2937,16 +2971,6 @@ async function tryStartPendingRightPageFlip(reason = 'pending', options = {}) {
return flipped; return flipped;
} }
function isRightBodyPageComplete() {
const meta = currentPageMeta?.right || null;
if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false;
const rendererDebug = window.BookTextureRenderer?.currentSpread || null;
const rightLines = Array.isArray(rendererDebug?.right) ? rendererDebug.right : [];
const maxLine = rightLines.reduce((max, line) => Math.max(max, Number(line?.pageLine || 0) + Math.max(1, Number(line?.lineCount || 1))), 0);
const expectedLines = Math.max(1, Number(meta.linesPerPage || 25));
return maxLine >= expectedLines;
}
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'
+400
View File
@@ -18,16 +18,63 @@ class WebGLPageCacheModule extends BaseModule {
this.maxCacheSizeBytes = 5 * 1024 * 1024 * 1024; this.maxCacheSizeBytes = 5 * 1024 * 1024 * 1024;
this.memoryCanvasCache = new Map(); this.memoryCanvasCache = new Map();
this.maxMemoryCanvasCount = 256; this.maxMemoryCanvasCount = 256;
this.textureRuntime = null;
this.residentTextures = new Map();
this.maxResidentTextureCount = 192;
this.preparedTextures = {
left: new Map(),
right: new Map()
};
this.preparedRevealPlans = new Map();
this.visibleTextures = {
left: null,
right: null
};
this.visibleFallbackCanvases = {
left: null,
right: null
};
this.maxPreparedTextureCount = 128;
this.blankTexture = null;
this.problemLog = [];
this.pendingPageWrites = new Map();
this.bindMethods([ this.bindMethods([
'initialize', 'initialize',
'openDB', 'openDB',
'configureTextureRuntime',
'cachePageCanvas', 'cachePageCanvas',
'getPageCanvas', 'getPageCanvas',
'putPageCanvas',
'storePageCanvas',
'preparePageTexture',
'takePreparedPageTexture',
'rememberPreparedRevealPlan',
'takePreparedRevealPlan',
'hasPreparedRevealPlan',
'registerVisibleTexture',
'bindVisibleTextureSource',
'getVisibleTexture',
'rememberResidentTexture',
'getResidentTexture',
'getResidentTextureForMeta',
'ensurePageTexture',
'prewarmPageTexture',
'prewarmSpreadTextures',
'prewarmNavigationWindow',
'getBlankTexture',
'createTextureFromCanvas',
'disposeTextureRecord',
'makePageKey', 'makePageKey',
'getPageWriteKey',
'makeResidentKey',
'cloneCanvas',
'canvasToBlob', 'canvasToBlob',
'blobToCanvas', 'blobToCanvas',
'isOlderPageEntry', 'isOlderPageEntry',
'isOlderPageMeta',
'recordProblem',
'getRuntimeState',
'manageCacheSize', 'manageCacheSize',
'calculateTotalCacheSize', 'calculateTotalCacheSize',
'deleteEntry', 'deleteEntry',
@@ -53,6 +100,25 @@ class WebGLPageCacheModule extends BaseModule {
} }
} }
configureTextureRuntime({
THREE = null,
renderer = null,
configureTexture = null,
createBlankCanvas = null,
maxResidentTextureCount = this.maxResidentTextureCount,
maxPreparedTextureCount = this.maxPreparedTextureCount
} = {}) {
this.textureRuntime = {
THREE,
renderer,
configureTexture,
createBlankCanvas
};
this.maxResidentTextureCount = Math.max(1, Math.round(Number(maxResidentTextureCount || this.maxResidentTextureCount)));
this.maxPreparedTextureCount = Math.max(1, Math.round(Number(maxPreparedTextureCount || this.maxPreparedTextureCount)));
return this.getRuntimeState();
}
openDB() { openDB() {
if (this.db) return Promise.resolve(this.db); if (this.db) return Promise.resolve(this.db);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -93,6 +159,303 @@ class WebGLPageCacheModule extends BaseModule {
return `${cacheKey}:page:${safePage}:${safeKind}:${safeSection}:${safeWidth}x${safeHeight}`; return `${cacheKey}:page:${safePage}:${safeKind}:${safeSection}:${safeWidth}x${safeHeight}`;
} }
getPageWriteKey(pageMeta = {}, canvas = null) {
return this.makePageKey({
...pageMeta,
width: canvas?.width ?? pageMeta.width,
height: canvas?.height ?? pageMeta.height
});
}
makeResidentKey(pageMetaOrIndex = {}) {
const pageMeta = typeof pageMetaOrIndex === 'number'
? { pageIndex: pageMetaOrIndex }
: pageMetaOrIndex || {};
const pageIndex = Math.max(0, Math.round(Number(pageMeta.pageIndex || 0)));
const kind = String(pageMeta.kind || 'content').replace(/[^a-z0-9_-]/gi, '');
const section = String(pageMeta.section || 'body').replace(/[^a-z0-9_-]/gi, '');
return `${pageIndex}:${kind}:${section}`;
}
createTextureFromCanvas(canvas = null) {
const runtime = this.textureRuntime || {};
if (!canvas || !runtime.THREE?.CanvasTexture) return null;
const texture = new runtime.THREE.CanvasTexture(canvas);
if (typeof runtime.configureTexture === 'function') runtime.configureTexture(texture);
texture.needsUpdate = true;
if (typeof runtime.renderer?.initTexture === 'function') {
runtime.renderer.initTexture(texture);
texture.needsUpdate = false;
}
return texture;
}
getBlankTexture() {
if (this.blankTexture) return this.blankTexture;
const canvas = this.textureRuntime?.createBlankCanvas?.();
this.blankTexture = this.createTextureFromCanvas(canvas);
return this.blankTexture;
}
async putPageCanvas(pageMeta = {}, canvas = null, options = {}) {
const texture = options.resident === false
? null
: this.rememberResidentTexture(pageMeta, this.createTextureFromCanvas(canvas), canvas, true);
if (options.persist !== false) {
const stored = await this.cachePageCanvas(pageMeta, canvas);
return texture || stored;
}
return texture;
}
storePageCanvas(pageMeta = {}, canvas = null, options = {}) {
if (!canvas || !pageMeta || !Number.isFinite(Number(pageMeta.pageIndex))) return Promise.resolve(false);
const frozenCanvas = this.cloneCanvas(canvas);
const key = this.getPageWriteKey(pageMeta, frozenCanvas);
const pending = this.pendingPageWrites.get(key);
if (pending && this.isOlderPageMeta(pageMeta, pending.pageMeta)) return pending.promise;
const previousWrite = pending?.promise || Promise.resolve();
const write = previousWrite.catch(() => false)
.then(() => this.putPageCanvas(pageMeta, frozenCanvas, {
persist: options.persist !== false,
resident: options.resident !== false
}))
.then((stored) => {
if (!stored) {
this.recordProblem({
type: 'db-write-failed',
pageIndex: pageMeta?.pageIndex ?? null,
key
});
}
return stored;
})
.catch((error) => {
this.recordProblem({
type: 'db-write-error',
pageIndex: pageMeta?.pageIndex ?? null,
key,
message: error?.message || String(error)
});
return false;
})
.finally(() => {
if (this.pendingPageWrites.get(key)?.promise === write) {
this.pendingPageWrites.delete(key);
}
});
this.pendingPageWrites.set(key, {
promise: write,
pageMeta: { ...(pageMeta || {}) }
});
return write;
}
cloneCanvas(canvas = null) {
if (!canvas) return null;
const clone = document.createElement('canvas');
clone.width = canvas.width;
clone.height = canvas.height;
const context = clone.getContext('2d');
if (context) context.drawImage(canvas, 0, 0);
return clone;
}
preparePageTexture(side = 'left', key = '', pageMeta = {}, canvas = null, revealDetail = {}) {
if (!canvas || !key) return null;
const normalizedSide = side === 'right' ? 'right' : 'left';
const texture = this.createTextureFromCanvas(canvas);
const baseTexture = revealDetail?.baseCanvas ? this.createTextureFromCanvas(revealDetail.baseCanvas) : null;
this.preparedTextures[normalizedSide].set(key, {
texture,
baseTexture,
sourceCanvas: canvas,
revealDetail,
pageMeta: { ...(pageMeta || {}) },
uploadedAt: performance.now()
});
this.rememberResidentTexture(pageMeta, texture, canvas, false);
while (this.preparedTextures[normalizedSide].size > this.maxPreparedTextureCount) {
const oldestKey = this.preparedTextures[normalizedSide].keys().next().value;
const oldest = this.preparedTextures[normalizedSide].get(oldestKey);
this.disposeTextureRecord(oldest);
this.preparedTextures[normalizedSide].delete(oldestKey);
}
return texture;
}
takePreparedPageTexture(side = 'left', key = '') {
const normalizedSide = side === 'right' ? 'right' : 'left';
const prepared = this.preparedTextures[normalizedSide].get(key);
if (!prepared) return null;
this.preparedTextures[normalizedSide].delete(key);
return prepared;
}
rememberPreparedRevealPlan(blockId = '', prepared = null) {
const id = String(blockId ?? '');
if (!id || !prepared) return null;
this.preparedRevealPlans.set(id, {
...prepared,
storedAt: performance.now()
});
while (this.preparedRevealPlans.size > this.maxPreparedTextureCount) {
const oldestKey = this.preparedRevealPlans.keys().next().value;
this.preparedRevealPlans.delete(oldestKey);
}
return prepared;
}
takePreparedRevealPlan(blockId = '') {
const id = String(blockId ?? '');
const prepared = this.preparedRevealPlans.get(id);
if (!prepared) return null;
this.preparedRevealPlans.delete(id);
return prepared;
}
hasPreparedRevealPlan(blockId = '') {
const id = String(blockId ?? '');
return Boolean(id && this.preparedRevealPlans.has(id));
}
registerVisibleTexture(side = 'left', texture = null, fallbackCanvas = null) {
const normalizedSide = side === 'right' ? 'right' : 'left';
this.visibleTextures[normalizedSide] = texture || null;
this.visibleFallbackCanvases[normalizedSide] = fallbackCanvas || null;
return texture || null;
}
bindVisibleTextureSource(side = 'left', sourceCanvas = null) {
const normalizedSide = side === 'right' ? 'right' : 'left';
const texture = this.visibleTextures[normalizedSide];
const canvas = sourceCanvas || this.visibleFallbackCanvases[normalizedSide] || null;
if (!texture || !canvas) return null;
texture.image = canvas;
texture.needsUpdate = true;
return texture;
}
getVisibleTexture(side = 'left') {
return this.visibleTextures[side === 'right' ? 'right' : 'left'] || null;
}
rememberResidentTexture(pageMeta = {}, texture = null, sourceCanvas = null, ownsTexture = true) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!texture || !Number.isFinite(pageIndex) || pageIndex < 0) return null;
const key = this.makeResidentKey(pageMeta);
const existing = this.residentTextures.get(key);
if (this.isOlderPageMeta(pageMeta, existing?.pageMeta)) return existing?.texture || null;
if (existing?.ownsTexture && existing.texture && existing.texture !== texture) existing.texture.dispose?.();
this.residentTextures.set(key, {
texture,
sourceCanvas: sourceCanvas || existing?.sourceCanvas || null,
lastUsedAt: performance.now(),
ownsTexture,
pageMeta: {
...(existing?.pageMeta || {}),
...(pageMeta || {})
}
});
while (this.residentTextures.size > this.maxResidentTextureCount) {
const oldestKey = this.residentTextures.keys().next().value;
const oldest = this.residentTextures.get(oldestKey);
if (oldest?.ownsTexture) oldest.texture?.dispose?.();
this.residentTextures.delete(oldestKey);
}
return texture;
}
getResidentTexture(pageMetaOrIndex = {}) {
const key = this.makeResidentKey(pageMetaOrIndex);
const resident = this.residentTextures.get(key);
if (!resident) return null;
resident.lastUsedAt = performance.now();
this.residentTextures.delete(key);
this.residentTextures.set(key, resident);
return resident.texture || null;
}
getResidentTextureForMeta(pageMeta = {}) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!Number.isFinite(pageIndex)) return null;
const key = this.makeResidentKey(pageMeta);
const resident = this.residentTextures.get(key);
if (!resident || this.isOlderPageMeta(pageMeta, resident.pageMeta)) return null;
return this.getResidentTexture(pageMeta);
}
async ensurePageTexture(pageMeta = {}, options = {}) {
if (pageMeta?.kind === 'blank') {
return this.rememberResidentTexture(pageMeta, this.getBlankTexture(), null, false);
}
const resident = this.getResidentTextureForMeta(pageMeta);
if (resident) return resident;
if (options.canvas) return this.putPageCanvas(pageMeta, options.canvas, {
persist: options.persist !== false,
resident: true
});
const sourceCanvas = await this.getPageCanvas(pageMeta);
if (!sourceCanvas) {
if (options.recordMiss !== false) {
this.recordProblem({
type: 'db-cache-miss',
pageIndex: pageMeta?.pageIndex ?? null,
width: pageMeta?.width ?? null,
height: pageMeta?.height ?? null
});
}
return null;
}
const cachedMeta = sourceCanvas.__webglPageCacheMeta || pageMeta;
return this.rememberResidentTexture(cachedMeta, this.createTextureFromCanvas(sourceCanvas), sourceCanvas, true);
}
async prewarmPageTexture(pageMeta = {}, options = {}) {
return this.ensurePageTexture(pageMeta, {
recordMiss: options.recordMiss !== false && pageMeta?.kind !== 'blank'
});
}
async prewarmSpreadTextures(spreadIndex = 0, getPageMetaForIndex = null, options = {}) {
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
const leftIndex = spread * 2;
const rightIndex = leftIndex + 1;
const leftMeta = getPageMetaForIndex?.(leftIndex) || { pageIndex: leftIndex, kind: 'blank', section: leftIndex < 3 ? 'frontmatter' : 'body' };
const rightMeta = getPageMetaForIndex?.(rightIndex) || { pageIndex: rightIndex, kind: 'blank', section: rightIndex < 3 ? 'frontmatter' : 'body' };
const [left, right] = await Promise.all([
this.prewarmPageTexture(leftMeta, options),
this.prewarmPageTexture(rightMeta, options)
]);
return { spreadIndex: spread, left, right };
}
async prewarmNavigationWindow({
currentSpread = 0,
targetSpread = null,
endSpread = 0,
getPageMetaForIndex = null,
recordMiss = true
} = {}) {
const current = Math.max(0, Math.round(Number(currentSpread || 0)));
const end = Math.max(0, Math.round(Number(endSpread || 0)));
const spreads = new Set([0, end, current, Math.max(0, current - 1), current + 1]);
const explicitTarget = Number.isFinite(Number(targetSpread)) ? Math.max(0, Math.round(Number(targetSpread))) : null;
if (explicitTarget !== null) spreads.add(explicitTarget);
const upperBound = Math.max(end, current + 1, explicitTarget ?? 0);
const bounded = Array.from(spreads).filter(value => value >= 0 && value <= upperBound);
const results = await Promise.all(bounded.map(spread => this.prewarmSpreadTextures(spread, getPageMetaForIndex, { recordMiss })));
return results.reduce((map, spread) => {
map[spread.spreadIndex] = spread;
return map;
}, {});
}
disposeTextureRecord(record = null) {
record?.texture?.dispose?.();
record?.baseTexture?.dispose?.();
}
async cachePageCanvas(pageMeta = {}, canvas = null) { async cachePageCanvas(pageMeta = {}, canvas = null) {
if (!canvas || !this.db || this.cacheStatus !== 'ready') return false; if (!canvas || !this.db || this.cacheStatus !== 'ready') return false;
const pageIndex = Number(pageMeta.pageIndex); const pageIndex = Number(pageMeta.pageIndex);
@@ -200,6 +563,43 @@ class WebGLPageCacheModule extends BaseModule {
return incomingVersion > 0 && existingVersion > incomingVersion; return incomingVersion > 0 && existingVersion > incomingVersion;
} }
isOlderPageMeta(incoming = {}, existing = null) {
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 existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
return incomingVersion > 0 && existingVersion > incomingVersion;
}
recordProblem(detail = {}) {
const entry = {
...detail,
at: performance.now()
};
this.problemLog.push(entry);
if (this.problemLog.length > 80) this.problemLog.splice(0, this.problemLog.length - 80);
document.documentElement.dataset.webglPageCacheProblems = JSON.stringify(this.problemLog);
console.warn('WebGL page texture store problem', entry);
return entry;
}
getRuntimeState() {
return {
cacheStatus: this.cacheStatus,
residentTextureCount: this.residentTextures.size,
maxResidentTextureCount: this.maxResidentTextureCount,
preparedTextureCount: this.preparedTextures.left.size + this.preparedTextures.right.size,
preparedRevealPlanCount: this.preparedRevealPlans.size,
pendingPageWriteCount: this.pendingPageWrites.size,
problemCount: this.problemLog.length,
hasRuntime: Boolean(this.textureRuntime?.THREE && this.textureRuntime?.renderer),
hasBlankTexture: Boolean(this.blankTexture)
};
}
canvasToBlob(canvas) { canvasToBlob(canvas) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (typeof canvas.toBlob !== 'function') { if (typeof canvas.toBlob !== 'function') {
+24 -22
View File
@@ -128,7 +128,7 @@ const checks = [
['webgl reveal visual clock is derived from absolute playback time', /visualElapsedMs/.test(source) && /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/revealFrameDeltaMs/.test(source)], ['webgl reveal visual clock is derived from absolute playback time', /visualElapsedMs/.test(source) && /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/revealFrameDeltaMs/.test(source)],
['webgl fast-forward accelerates reveal instead of clearing the mask immediately', /fastForwarding/.test(source) && /fastForwardDurationMs/.test(source) && !/clearPageReveal\(side, 'fast-forward'\)/.test(source)], ['webgl fast-forward accelerates reveal instead of clearing the mask immediately', /fastForwarding/.test(source) && /fastForwardDurationMs/.test(source) && !/clearPageReveal\(side, 'fast-forward'\)/.test(source)],
['webgl lab records page texture binding timings', /pageTextureTimings/.test(source) && /markPageTextureTiming/.test(source) && /webglPageTextureTimings/.test(source)], ['webgl lab records page texture binding timings', /pageTextureTimings/.test(source) && /markPageTextureTiming/.test(source) && /webglPageTextureTimings/.test(source)],
['webgl lab binds source canvases directly instead of copying whole page textures', /bindPageTextureSource/.test(source) && /texture\.image = sourceCanvas/.test(source) && !/drawCanvasPageTexture/.test(methodBody(source, 'uploadPageTextureDirect')) && !/drawCanvasPageTexture/.test(methodBody(source, 'beginPageReveal'))], ['webgl lab binds visible page texture sources through the single texture store', /bindPageTextureSource/.test(source) && /bindVisibleTextureSource/.test(source) && /registerVisibleTexture/.test(webglPageCacheSource) && !/drawCanvasPageTexture/.test(methodBody(source, 'uploadPageTextureDirect')) && !/drawCanvasPageTexture/.test(methodBody(source, 'beginPageReveal'))],
['page texture dark-pixel sampling only runs in table debug mode', /function shouldSamplePageTextureDebug\(\)/.test(source) && /tableDebugMode !== tableDebugModes\.none/.test(source) && /shouldSamplePageTextureDebug\(\) \? countPageTextureDarkPixels\(canvas\) : null/.test(source)], ['page texture dark-pixel sampling only runs in table debug mode', /function shouldSamplePageTextureDebug\(\)/.test(source) && /tableDebugMode !== tableDebugModes\.none/.test(source) && /shouldSamplePageTextureDebug\(\) \? countPageTextureDarkPixels\(canvas\) : null/.test(source)],
['texture renderer exposes reveal pipeline diagnostics', /pipelineTimings/.test(textureRendererSource) && /markPipelineTiming/.test(textureRendererSource) && /webglTexturePipeline/.test(textureRendererSource)], ['texture renderer exposes reveal pipeline diagnostics', /pipelineTimings/.test(textureRendererSource) && /markPipelineTiming/.test(textureRendererSource) && /webglTexturePipeline/.test(textureRendererSource)],
['texture renderer records prepare draw publish and start reveal timing', /markPipelineTiming\('prepareRevealBlock:start'/.test(textureRendererSource) && /markPipelineTiming\('drawSpread:start'/.test(textureRendererSource) && /markPipelineTiming\('publishSpread'/.test(textureRendererSource) && /markPipelineTiming\('startPreparedRevealAnimation'/.test(textureRendererSource)], ['texture renderer records prepare draw publish and start reveal timing', /markPipelineTiming\('prepareRevealBlock:start'/.test(textureRendererSource) && /markPipelineTiming\('drawSpread:start'/.test(textureRendererSource) && /markPipelineTiming\('publishSpread'/.test(textureRendererSource) && /markPipelineTiming\('startPreparedRevealAnimation'/.test(textureRendererSource)],
@@ -141,16 +141,16 @@ const checks = [
['sentence queue keeps current 3D page prep immediate while future lookahead yields cooperatively', /if \(!options\.immediate\) \{[\s\S]*requestIdleCallback[\s\S]*timeout: 80/.test(sentenceQueueSource) && /prefetchAhead\(maxLookahead = 6/.test(sentenceQueueSource)], ['sentence queue keeps current 3D page prep immediate while future lookahead yields cooperatively', /if \(!options\.immediate\) \{[\s\S]*requestIdleCallback[\s\S]*timeout: 80/.test(sentenceQueueSource) && /prefetchAhead\(maxLookahead = 6/.test(sentenceQueueSource)],
['pagination can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)], ['pagination can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)],
['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)], ['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)],
['texture renderer caches preload-only reveal canvases for later reuse', /preparedRevealCache/.test(textureRendererSource) && /preloadOnly/.test(textureRendererSource) && /publishPreparedReveal/.test(textureRendererSource) && /reusedPreparedCanvas/.test(textureRendererSource) && /hasPreparedRevealBlock/.test(textureRendererSource)], ['texture renderer stores prepared reveal plans in the shared texture store', !/preparedRevealCache/.test(textureRendererSource) && /rememberPreparedRevealPlan/.test(webglPageCacheSource) && /takePreparedRevealPlan/.test(textureRendererSource) && /publishPreparedReveal/.test(textureRendererSource) && /hasPreparedRevealBlock/.test(textureRendererSource)],
['webgl page cache is loaded through module infrastructure', /webgl-page-cache-module\.js/.test(loaderSource) && /super\('webgl-page-cache'/.test(webglPageCacheSource) && /reportProgress\(20, 'Opening WebGL page texture cache'\)/.test(webglPageCacheSource)], ['webgl page cache is loaded through module infrastructure', /webgl-page-cache-module\.js/.test(loaderSource) && /super\('webgl-page-cache'/.test(webglPageCacheSource) && /reportProgress\(20, 'Opening WebGL page texture cache'\)/.test(webglPageCacheSource)],
['webgl page cache uses an isolated browser database without upgrading tts history state', /this\.dbName = 'webglPageTextureCacheDB'/.test(webglPageCacheSource) && /this\.dbVersion = 1/.test(webglPageCacheSource) && /this\.dbVersion = 3/.test(ttsFactorySource) && /this\.dbVersion = 3/.test(storyHistorySource) && !/webglPageTextureStore/.test(ttsFactorySource) && !/webglPageTextureStore/.test(storyHistorySource)], ['webgl page cache uses an isolated browser database without upgrading tts history state', /this\.dbName = 'webglPageTextureCacheDB'/.test(webglPageCacheSource) && /this\.dbVersion = 1/.test(webglPageCacheSource) && /this\.dbVersion = 3/.test(ttsFactorySource) && /this\.dbVersion = 3/.test(storyHistorySource) && !/webglPageTextureStore/.test(ttsFactorySource) && !/webglPageTextureStore/.test(storyHistorySource)],
['texture renderer persists frozen completed page canvases immediately and without duplicate work', /webgl-page-cache/.test(textureRendererSource) && /cachePublishedPages/.test(textureRendererSource) && /schedulePageCacheWrite/.test(textureRendererSource) && /pendingPageCacheWrites/.test(textureRendererSource) && /const frozenCanvas = this\.cloneCanvas\(canvas\)/.test(textureRendererSource) && !/requestIdleCallback/.test(methodBody(textureRendererSource, 'schedulePageCacheWrite')) && /cachePageCanvas/.test(textureRendererSource)], ['texture renderer hands completed page canvases to the single texture store without owning write queues', /webgl-page-cache/.test(textureRendererSource) && /cachePublishedPages/.test(textureRendererSource) && /storePageCanvas\(pageMeta, canvas, \{ persist: true, resident: true \}\)/.test(textureRendererSource) && !/schedulePageCacheWrite/.test(textureRendererSource) && !/pendingPageCacheWrites/.test(textureRendererSource)],
['webgl cache is non-optional with a 5gb persistent budget and large memory cache', /maxCacheSizeBytes = 5 \* 1024 \* 1024 \* 1024/.test(webglPageCacheSource) && /maxMemoryCanvasCount = 256/.test(webglPageCacheSource) && /persistent page caching is in a problem state/.test(webglPageCacheSource) && !/if \(this\.memoryCanvasCache\.has\(key\)\) return true/.test(webglPageCacheSource)], ['webgl texture store is non-optional with db memory cache prepared textures and vram cache', /maxCacheSizeBytes = 5 \* 1024 \* 1024 \* 1024/.test(webglPageCacheSource) && /maxMemoryCanvasCount = 256/.test(webglPageCacheSource) && /residentTextures = new Map/.test(webglPageCacheSource) && /preparedTextures = \{/.test(webglPageCacheSource) && /persistent page caching is in a problem state/.test(webglPageCacheSource) && !/if \(this\.memoryCanvasCache\.has\(key\)\) return true/.test(webglPageCacheSource)],
['webgl lab prewarms cached page textures into generous vram before flips', /residentPageTextures/.test(source) && /const maxResidentPageTextures = 192/.test(source) && /preloadCachedPageTexture/.test(source) && /prewarmFlipTextures/.test(source) && /await prewarmFlipTextures\(direction, targetSpread\)/.test(source) && /getResidentPageTexture\(targetBackPageIndex\)/.test(source)], ['webgl lab prewarms navigation texture window through single store before flips', /const maxResidentPageTextures = 192/.test(source) && /configureTextureRuntime/.test(source) && /prewarmNavigationTextureWindow/.test(source) && /await prewarmFlipTextures\(direction, targetSpread\)/.test(source) && /resolveFlipBackTexture\(targetBackPageMeta, prewarmedBackTexture\)/.test(source) && !/const residentPageTextures = new Map/.test(source)],
['webgl lab records cache misses as problem states', /pageCacheProblemLog/.test(source) && /recordPageCacheProblem/.test(source) && /db-cache-miss/.test(source) && /webglPageCacheProblems/.test(source)], ['webgl texture store records cache misses as problem states', /problemLog/.test(webglPageCacheSource) && /recordProblem/.test(webglPageCacheSource) && /db-cache-miss/.test(webglPageCacheSource) && /webglPageCacheProblems/.test(webglPageCacheSource)],
['webgl lab makes preload-only page canvases resident by explicit page metadata', /rememberResidentPageTexture/.test(source) && /attachRevealPageMeta/.test(source) && /rememberResidentPageTexture\(detail\.pageMeta\?\.left \|\| null, texture, detail\.left\)/.test(source) && /rememberResidentPageTexture\(detail\.pageMeta\?\.right \|\| null, texture, detail\.right\)/.test(source)], ['webgl lab makes preload-only page canvases resident by explicit page metadata through store', /pageTextureStore\?\.preparePageTexture/.test(source) && /attachRevealPageMeta/.test(source) && source.includes('pageTextureStore?.rememberResidentTexture?.(currentPageMeta.left, texture, detail.left, true)') && source.includes('pageTextureStore?.rememberResidentTexture?.(currentPageMeta.right, texture, detail.right, true)')],
['webgl lab keeps current visible page textures resident without disposing shared maps', /rememberResidentPageTexture\(pageMeta, texture, sourceCanvas, false\)/.test(source) && /ownsTexture/.test(source) && /if \(oldest\?\.ownsTexture\) oldest\.texture\?\.dispose\?\.\(\)/.test(source)], ['webgl texture store keeps current visible page textures resident without disposing shared maps', /rememberResidentTexture\(pageMeta = \{\}, texture = null, sourceCanvas = null, ownsTexture = true\)/.test(webglPageCacheSource) && /ownsTexture/.test(webglPageCacheSource) && /if \(oldest\?\.ownsTexture\) oldest\.texture\?\.dispose\?\.\(\)/.test(webglPageCacheSource)],
['webgl lab reuses current-enough resident cached page textures for direct stack switches', /uploadPageTextureDirect\(side, sourceCanvas, pageMeta = null\)/.test(source) && /getResidentPageTextureForMeta\(pageMeta\)/.test(source) && /usedResidentTexture/.test(source) && /uploadPageTextureDirect\('left', detail\.left, currentPageMeta\.left\)/.test(source) && /uploadPageTextureDirect\('right', detail\.right, currentPageMeta\.right\)/.test(source)], ['webgl lab reuses current-enough resident cached page textures via single store for direct stack switches', /uploadPageTextureDirect\(side, sourceCanvas, pageMeta = null\)/.test(source) && source.includes('pageTextureStore?.getResidentTextureForMeta?.(pageMeta)') && /usedResidentTexture/.test(source) && /uploadPageTextureDirect\('left', detail\.left, currentPageMeta\.left\)/.test(source) && /uploadPageTextureDirect\('right', detail\.right, currentPageMeta\.right\)/.test(source) && !/function getResidentPageTextureForMeta/.test(source)],
['webgl page cache preserves explicit cache keys across writes and reads', /cacheKey: pageMeta\.cacheKey/.test(webglPageCacheSource) && /makePageKey\(pageMeta\)/.test(webglPageCacheSource)], ['webgl page cache preserves explicit cache keys across writes and reads', /cacheKey: pageMeta\.cacheKey/.test(webglPageCacheSource) && /makePageKey\(pageMeta\)/.test(webglPageCacheSource)],
['webgl page cache rejects older page versions for the same page key', /isOlderPageEntry/.test(webglPageCacheSource) && /contentVersion/.test(webglPageCacheSource) && /completenessScore/.test(webglPageCacheSource) && /if \(this\.isOlderPageEntry\(pageMeta, oldEntry\)\) return true/.test(webglPageCacheSource)], ['webgl page cache rejects older page versions for the same page key', /isOlderPageEntry/.test(webglPageCacheSource) && /contentVersion/.test(webglPageCacheSource) && /completenessScore/.test(webglPageCacheSource) && /if \(this\.isOlderPageEntry\(pageMeta, oldEntry\)\) return true/.test(webglPageCacheSource)],
['targeted page flips commit target spread before emitting finished event', /bookPaginationState = \{[\s\S]*spreadIndex: Math\.max\(0, Math\.round\(Number\(flip\.targetSpread\)\)\)[\s\S]*document\.dispatchEvent\(new CustomEvent\('webgl-book:page-flip-finished'/.test(source) && /targetSpread: Number\.isFinite\(Number\(flip\.targetSpread\)\)/.test(source)], ['targeted page flips commit target spread before emitting finished event', /bookPaginationState = \{[\s\S]*spreadIndex: Math\.max\(0, Math\.round\(Number\(flip\.targetSpread\)\)\)[\s\S]*document\.dispatchEvent\(new CustomEvent\('webgl-book:page-flip-finished'/.test(source) && /targetSpread: Number\.isFinite\(Number\(flip\.targetSpread\)\)/.test(source)],
@@ -159,7 +159,7 @@ const checks = [
['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 page flip before activating future spread', /sentence\.webglBookPresentation\?\.spread/.test(uiDisplayHandlerSource) && /preparePendingBlock\(sentence, \{\s*activate: false,\s*publish: false,\s*includeUnrenderedHistory: true\s*\}/.test(uiDisplayHandlerSource) && /waitForWebGLPageFlip/.test(uiDisplayHandlerSource) && /targetSpread: previewSpread\.index/.test(uiDisplayHandlerSource) && /webgl-book:request-page-flip/.test(uiDisplayHandlerSource) && /const targetSpread = Number\.isFinite\(Number\(detail\.targetSpread\)\)/.test(source) && /startPageFlip\(direction, \{[\s\S]*targetSpread/.test(source)], ['3D overflow reveal waits for page flip before activating future spread', /sentence\.webglBookPresentation\?\.spread/.test(uiDisplayHandlerSource) && /preparePendingBlock\(sentence, \{\s*activate: false,\s*publish: false,\s*includeUnrenderedHistory: true\s*\}/.test(uiDisplayHandlerSource) && /waitForWebGLPageFlip/.test(uiDisplayHandlerSource) && /targetSpread: previewSpread\.index/.test(uiDisplayHandlerSource) && /webgl-book:request-page-flip/.test(uiDisplayHandlerSource) && /const targetSpread = Number\.isFinite\(Number\(detail\.targetSpread\)\)/.test(source) && /startPageFlip\(direction, \{[\s\S]*targetSpread/.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', /preparedPageTextures/.test(source) && /preloadPageTexture/.test(source) && /renderer\.initTexture\(texture\)/.test(source) && /takePreparedPageTexture/.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)],
['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)], ['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)],
['webgl reveal shader masks antialiased ink and uses smooth line-dominant scan', /smoothstep\(0\.52, 0\.9, luminance\)/.test(source) && /local\.x \* 0\.96/.test(source) && /bookRevealSoftness = \{ value: 0\.025 \}/.test(source)], ['webgl reveal shader masks antialiased ink and uses smooth line-dominant scan', /smoothstep\(0\.52, 0\.9, luminance\)/.test(source) && /local\.x \* 0\.96/.test(source) && /bookRevealSoftness = \{ value: 0\.025 \}/.test(source)],
@@ -175,20 +175,22 @@ const checks = [
['webgl scene does not republish 3D page textures from DOM refresh events', !/addEventListener\(document, 'story:turn-start', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:turn-complete', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:history-updated', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'input', this\.triggerTextureRefresh/.test(webglSceneSource) && !/addEventListener\(document, 'change', this\.triggerTextureRefresh/.test(webglSceneSource)], ['webgl scene does not republish 3D page textures from DOM refresh events', !/addEventListener\(document, 'story:turn-start', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:turn-complete', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:history-updated', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'input', this\.triggerTextureRefresh/.test(webglSceneSource) && !/addEventListener\(document, 'change', this\.triggerTextureRefresh/.test(webglSceneSource)],
['webgl scene adoptPageContent does not republish 3D page textures', !/triggerTextureRefresh/.test(methodBody(webglSceneSource, 'adoptPageContent'))], ['webgl scene adoptPageContent does not republish 3D page textures', !/triggerTextureRefresh/.test(methodBody(webglSceneSource, 'adoptPageContent'))],
['webgl book starts at progress zero', /const DEFAULT_BOOK_PROGRESS = 0;/.test(webglSceneSource) && /appInitialState\.progress \?\? '0'/.test(source)], ['webgl book starts at progress zero', /const DEFAULT_BOOK_PROGRESS = 0;/.test(webglSceneSource) && /appInitialState\.progress \?\? '0'/.test(source)],
['pagination opens with blank left and title right spread', /this\.createBlankPage\(0, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.createTitlePage\(1\)/.test(bookPaginationSource) && /this\.createBlankPage\(2, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.pages = this\.buildPages\(\[\]\);/.test(bookPaginationSource) && /this\.currentSpreadIndex = 0;[\s\S]*this\.publish\(\{ reason: 'initial-title-spread', allowFutureUnrendered: true \}\);/.test(bookPaginationSource)], ['pagination opens with blank left and title right spread', /this\.createBlankPage\(0, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.createTitlePage\(1\)/.test(bookPaginationSource) && /this\.createBlankPage\(2, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.pages = this\.buildPages\(\[\]\);/.test(bookPaginationSource) && /this\.currentSpreadIndex = 0;[\s\S]*this\.publish\(\{ reason: 'initial-title-spread', visibility: 'future-ready' \}\);/.test(bookPaginationSource)],
['pagination normalizes every spread to explicit left and right page records', /normalizePagesForSpreads/.test(bookPaginationSource) && /const lastSpreadRightIndex/.test(bookPaginationSource) && /this\.createBlankPage\(index/.test(bookPaginationSource) && /normalizedPages\.forEach/.test(bookPaginationSource)],
['texture renderer adopts initial pagination spread so title page is painted after loader order', /this\.currentSpread = this\.pagination\?\.getCurrentSpread\?\.\(\) \|\| \{ index: 0/.test(textureRendererSource) && /this\.drawSpread\(this\.currentSpread\);/.test(textureRendererSource)], ['texture renderer adopts initial pagination spread so title page is painted after loader order', /this\.currentSpread = this\.pagination\?\.getCurrentSpread\?\.\(\) \|\| \{ index: 0/.test(textureRendererSource) && /this\.drawSpread\(this\.currentSpread\);/.test(textureRendererSource)],
['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)], ['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 borrows resident page texture and blanks right stack before forward animation', /prepareStaticPageForFlip/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.rightPage\.map = blankTexture/.test(source) && /webgl-book:page-flip-near-end/.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 = backTexture \|\| getBlankPageTexture\(\)/.test(source)],
['webgl flip never falls back to the opposite visible stack for target back texture', /const prewarmedBackTexture = flip\.direction > 0 \? prewarm\?\.next\?\.left : prewarm\?\.next\?\.right/.test(source) && /const residentBackTexture = prewarmedBackTexture \|\| getResidentPageTexture\(targetBackPageIndex\)/.test(source) && /const backTexture = residentBackTexture \|\| getBlankPageTexture\(\)/.test(source) && !/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 canvas metadata accepts explicit blank sides instead of retaining stale pages', /hasLeftMeta/.test(source) && /hasRightMeta/.test(source) && /Object\.prototype\.hasOwnProperty\.call\(detail\.pageMeta, 'right'\)/.test(source)], ['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 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 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 renderer queues newer same-page cache writes instead of dropping them', /isOlderPageMeta/.test(textureRendererSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(textureRendererSource) && /pendingPageCacheWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(textureRendererSource)], ['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(webglPageCacheSource)],
['webgl resident page texture cache rejects older page versions before direct reuse', /isOlderPageTextureMeta/.test(source) && /getResidentPageTextureForMeta/.test(source) && /getResidentPageTextureForMeta\(pageMeta\)/.test(source)], ['webgl texture store resident cache rejects older page versions before direct reuse', /isOlderPageMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta\(pageMeta\)/.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) && /geometry\.addGroup\(0, topIndices\.length, 0\)/.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) && /geometry\.addGroup\(0, topIndices\.length, 0\)/.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/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture/.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 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 back face uses destination-side page UV orientation', /pageUvForSide\(targetSide, u, v\)/.test(source) && /pageUvForSide\(sourceSide, u, v\)/.test(source) && /side < 0 \? 1 - u : u/.test(source)], ['webgl animated page back face uses destination-side page UV orientation', /pageUvForSide\(targetSide, u, v\)/.test(source) && /pageUvForSide\(sourceSide, u, v\)/.test(source) && /side < 0 \? 1 - u : u/.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 live mirror and static heavy refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /this\.targetFrameDurationMs = 1000 \/ 60/.test(textureRendererSource) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesEveryFrame: true/.test(source) && !/setTimeout\(animate/.test(source)], ['webgl scene targets 60fps with browser-frame scheduling live mirror and static heavy refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /this\.targetFrameDurationMs = 1000 \/ 60/.test(textureRendererSource) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesEveryFrame: true/.test(source) && !/setTimeout\(animate/.test(source)],
@@ -203,14 +205,14 @@ 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 completion arms a durable autoplay-targeted flip without bypassing choices', /handleRevealCommittedForPageFlip/.test(source) && /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 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 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)],
['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ preloadOnly: true \}\)/.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) && /this\.activeAnimations\.has\(id\)/.test(textureRendererSource)],
['webgl visible spread state ignores future unrendered prepare publishes before flip', /spreadUpdate:deferred-future-unrendered/.test(source) && /incomingSpreadIndex > Math\.max\(0, Number\(bookPaginationState\.spreadIndex/.test(source) && /this\.drawSpread\(this\.currentSpread, \['left', 'right'\], \{ preloadOnly: true \}\)/.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)],
['3D overflow reveal preloads target spread before forced page flip', /previewRevealDetail/.test(uiDisplayHandlerSource) && /preloadOnly: true/.test(uiDisplayHandlerSource) && /bookTextureRenderer\.prepareRevealBlock\(previewRevealDetail, \{ preloadOnly: true \}\)/.test(uiDisplayHandlerSource) && /await this\.waitForWebGLPageFlip/.test(uiDisplayHandlerSource)], ['3D overflow reveal preloads target spread before forced page flip', /previewRevealDetail/.test(uiDisplayHandlerSource) && /phase: 'prepare'/.test(uiDisplayHandlerSource) && /bookTextureRenderer\.prepareRevealBlock\(previewRevealDetail, \{ phase: 'prepare' \}\)/.test(uiDisplayHandlerSource) && /await this\.waitForWebGLPageFlip/.test(uiDisplayHandlerSource)],
['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 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 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 back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.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)],
['markup and 3d pagination accept full-page images', /'full'/.test(markupParserSource) && /size === 'full'/.test(bookPaginationSource)], ['markup and 3d pagination accept full-page images', /'full'/.test(markupParserSource) && /size === 'full'/.test(bookPaginationSource)],
['story history can persist 3d pagination decisions', /persistPaginationMetrics/.test(bookPaginationSource) && /collectPaginationMetrics/.test(bookPaginationSource) && /pageStart/.test(storyHistorySource) && /pagination: metrics\.pagination/.test(storyHistorySource)] ['story history can persist 3d pagination decisions', /persistPaginationMetrics/.test(bookPaginationSource) && /collectPaginationMetrics/.test(bookPaginationSource) && /pageStart/.test(storyHistorySource) && /pagination: metrics\.pagination/.test(storyHistorySource)]