From b41340151d195c75dbdc1574542980d02c39109c Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Wed, 10 Jun 2026 01:07:22 +0200 Subject: [PATCH] Checkpoint WebGL book playback refactor state --- docs/webgl-3d-ui-spec.md | 45 ++- public/js/book-pagination-module.js | 31 +- public/js/book-texture-renderer-module.js | 200 ++++------ public/js/sentence-queue-module.js | 4 +- public/js/ui-display-handler-module.js | 4 +- public/js/webgl-book-lab.js | 464 ++++++++++++---------- public/js/webgl-page-cache-module.js | 400 +++++++++++++++++++ scripts/check-webgl-book-lab.js | 46 ++- 8 files changed, 824 insertions(+), 370 deletions(-) diff --git a/docs/webgl-3d-ui-spec.md b/docs/webgl-3d-ui-spec.md index 18fce9e..19fcd65 100644 --- a/docs/webgl-3d-ui-spec.md +++ b/docs/webgl-3d-ui-spec.md @@ -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. -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 This section records the current state after the procedural book integration work. -- The active standalone scene is `public/webgl-book-lab.html`. -- The intended local test URL is `http://localhost:3001/webgl-book-lab.html`. +- The active integrated scene is loaded through the game at `http://localhost:3001/`. +- `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 procedural book model lives in `public/js/procedural-book-model.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`. - 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 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. - 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. @@ -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. - 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 - Do not continue visual coding without a concrete plan for the current sprint. diff --git a/public/js/book-pagination-module.js b/public/js/book-pagination-module.js index 6319c24..9debffb 100644 --- a/public/js/book-pagination-module.js +++ b/public/js/book-pagination-module.js @@ -94,7 +94,7 @@ class BookPaginationModule extends BaseModule { this.pages = this.buildPages([]); this.spreads = this.buildSpreadsFromPages(this.pages); 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'); return true; } @@ -146,7 +146,7 @@ class BookPaginationModule extends BaseModule { : renderedSpreadIndex >= 0 ? renderedSpreadIndex : 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) { @@ -187,7 +187,7 @@ class BookPaginationModule extends BaseModule { spreadIndex: cached.targetSpread?.index ?? this.currentSpreadIndex, latestBlockId: pendingBlockId, latestRenderedBlockId, - preloadOnly: false, + phase: 'activate', reusedPreparedPagination: true } })); @@ -240,7 +240,7 @@ class BookPaginationModule extends BaseModule { spreadIndex: targetSpread?.index ?? this.currentSpreadIndex, latestBlockId: pendingBlockId, latestRenderedBlockId, - preloadOnly: options.activate === false + phase: options.activate === false ? 'prepare' : 'activate' } })); return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread()); @@ -371,7 +371,8 @@ class BookPaginationModule extends BaseModule { buildSpreadsFromPages(pages = []) { const spreads = []; const linesPerPage = this.getLinesPerPage(); - pages.forEach((page, pageIndex) => { + const normalizedPages = this.normalizePagesForSpreads(pages); + normalizedPages.forEach((page, pageIndex) => { const spreadIndex = Math.floor(pageIndex / 2); const side = pageIndex % 2 === 0 ? 'left' : 'right'; if (!spreads[spreadIndex]) { @@ -398,6 +399,22 @@ class BookPaginationModule extends BaseModule { 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 = {}) { const directive = block?.metadata?.pageReserve || block?.pageReserve || null; const blockId = Number(block?.blockId || block?.metadata?.blockId || 0); @@ -967,7 +984,7 @@ class BookPaginationModule extends BaseModule { setCurrentSpread(index = 0) { 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; } @@ -982,7 +999,7 @@ class BookPaginationModule extends BaseModule { latestBlockId: this.latestBlockId, latestRenderedBlockId: this.latestRenderedBlockId, reason: options.reason || 'publish', - allowFutureUnrendered: options.allowFutureUnrendered === true + visibility: options.visibility || 'current' } })); } diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 991cf7d..2e4e371 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -29,7 +29,6 @@ class BookTextureRendererModule extends BaseModule { this.activeAnimations = new Map(); this.revealedBlockIds = new Set(); this.pendingRevealBlockIds = new Set(); - this.preparedRevealCache = new Map(); this.revealBaseCanvases = null; this.revealPublishBlockIds = null; this.lastDrawSignature = null; @@ -39,7 +38,6 @@ class BookTextureRendererModule extends BaseModule { this.targetFrameDurationMs = 1000 / 60; this.pipelineTimings = []; this.imageCache = new Map(); - this.pendingPageCacheWrites = new Map(); this.pageContentVersions = new Map(); this.bindMethods([ @@ -63,6 +61,7 @@ class BookTextureRendererModule extends BaseModule { 'drawLine', 'drawWord', 'buildRevealRegions', + 'shouldFlipAfterSideReveal', 'collectRevealRegionCandidates', 'createRevealRegionForLine', 'assignRevealTiming', @@ -82,6 +81,7 @@ class BookTextureRendererModule extends BaseModule { 'spreadContainsBlock', 'hasPreparedRevealBlock', 'createAnimationState', + 'getDrawPhase', 'publishPreparedReveal', 'startPreparedRevealAnimation', 'fastForwardAnimations', @@ -92,10 +92,8 @@ class BookTextureRendererModule extends BaseModule { 'requestAnimationFrame', 'tickAnimations', 'publishSpread', + 'buildPageTextureRecords', 'cachePublishedPages', - 'getPageCacheWriteKey', - 'isOlderPageMeta', - 'schedulePageCacheWrite', 'getPageCanvas', 'getHitMap', 'handlePageCountChanged' @@ -120,11 +118,12 @@ class BookTextureRendererModule extends BaseModule { const spreadIndex = Math.max(0, Number(event.detail?.spreadIndex ?? spread?.index ?? 0)); const latestBlockId = event.detail?.latestBlockId; const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0)); + const visibility = event.detail?.visibility || 'current'; this.currentSpread = spread || { left: [], right: [] }; if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) { this.markPendingReveal(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']); return; } @@ -132,8 +131,8 @@ class BookTextureRendererModule extends BaseModule { this.revealPublishBlockIds = new Set([id]); const visibleSpread = Math.max(0, Number(window.BookLabDebug?.getBookState?.().spreadIndex || 0)); const flipActive = document.documentElement.dataset.webglPageFlipActive === 'true'; - if (!flipActive && event.detail?.allowFutureUnrendered !== true && spreadIndex > visibleSpread) { - this.drawSpread(this.currentSpread, ['left', 'right'], { preloadOnly: true }); + if (!flipActive && visibility !== 'future-ready' && spreadIndex > visibleSpread) { + this.drawSpread(this.currentSpread, ['left', 'right'], { phase: 'prepare' }); return; } this.drawSpread(this.currentSpread, ['left', 'right']); @@ -226,20 +225,21 @@ class BookTextureRendererModule extends BaseModule { this.currentSpread = spread || { left: [], right: [] }; const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0; + const phase = this.getDrawPhase(options); 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(); if (now - this.lastDrawSkipLoggedAt > 1000) { this.lastDrawSkipLoggedAt = now; this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw }); } - if (options.preloadOnly) this.currentSpread = previousSpread; + if (phase === 'prepare') this.currentSpread = previousSpread; return null; } this.markPipelineTiming('drawSpread:start', { sides: sidesToDraw, revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : [], - preloadOnly: Boolean(options.preloadOnly) + phase }); this.revealBaseCanvases = { left: null, right: null }; sidesToDraw.forEach((side) => { @@ -253,15 +253,20 @@ class BookTextureRendererModule extends BaseModule { const published = this.publishSpread(sidesToDraw, options); this.markPipelineTiming('drawSpread:end', { sides: sidesToDraw, - preloadOnly: Boolean(options.preloadOnly) + phase }); this.revealBaseCanvases = null; this.revealPublishBlockIds = null; - if (!options.preloadOnly && !hasReveal) this.lastDrawSignature = drawSignature; - if (options.preloadOnly) this.currentSpread = previousSpread; + if (phase !== 'prepare' && !hasReveal) this.lastDrawSignature = drawSignature; + if (phase === 'prepare') this.currentSpread = previousSpread; return published; } + getDrawPhase(options = {}) { + if (options.phase === 'prepare' || options.phase === 'activate') return options.phase; + return 'activate'; + } + getDrawSignature(spread = null, sides = []) { const source = spread || {}; return sides.map(side => { @@ -644,6 +649,7 @@ class BookTextureRendererModule extends BaseModule { return { blockIds: Array.from(byBlock.keys()), durationMs: sideRegions.reduce((maxDuration, region) => Math.max(maxDuration, region.timing.delay + region.timing.duration), 0), + pageFlipAfterReveal: this.shouldFlipAfterSideReveal(side), baseCanvas: null, lineRects: sideRegions.map(region => ({ 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() { const candidates = []; 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; const id = String(blockId); 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', { blockId: id, wordTimingCount: wordTimings.length, - preloadOnly + phase }); - if (!preloadOnly && this.preparedRevealCache.has(id)) { - const cached = this.preparedRevealCache.get(id); + if (phase === 'activate' && this.pageCache?.hasPreparedRevealPlan?.(id)) { + const cached = this.pageCache.takePreparedRevealPlan(id); this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail)); this.pendingRevealBlockIds.delete(id); this.publishPreparedReveal(cached); @@ -903,10 +924,10 @@ class BookTextureRendererModule extends BaseModule { this.revealPublishBlockIds = new Set([id]); const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.(); const sides = ['left', 'right']; - const published = this.drawSpread(spread, sides, { preloadOnly }); - if (!preloadOnly) this.preloadAdditionalRevealSpreads(id, spread); - if (preloadOnly && published) { - this.preparedRevealCache.set(id, { + const published = this.drawSpread(spread, sides, { phase }); + if (phase !== 'prepare') this.preloadAdditionalRevealSpreads(id, spread); + if (phase === 'prepare' && published) { + this.pageCache?.rememberPreparedRevealPlan?.(id, { ...published, blockId, wordTimings, @@ -916,7 +937,7 @@ class BookTextureRendererModule extends BaseModule { this.markPipelineTiming('prepareRevealBlock:end', { blockId: id, wordTimingCount: wordTimings.length, - preloadOnly + phase }); } @@ -927,7 +948,7 @@ class BookTextureRendererModule extends BaseModule { spreads.forEach((spread) => { if (!spread || Number(spread.index) === primaryIndex) 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) { const id = String(blockId ?? ''); - return Boolean(id && this.preparedRevealCache.has(id)); + return Boolean(id && this.pageCache?.hasPreparedRevealPlan?.(id)); } publishPreparedReveal(prepared) { @@ -951,14 +972,14 @@ class BookTextureRendererModule extends BaseModule { sides: prepared.sides || [], 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: { metrics: prepared.metrics, hitMaps: prepared.hitMaps || this.hitMaps, - left: prepared.left || null, - right: prepared.right || null, + records: prepared.records || this.buildPageTextureRecords(prepared.sides || ['left', 'right'], prepared), reveal: prepared.reveal || {}, pageMeta: prepared.pageMeta || {}, + phase: 'activate', preparedFromCache: true } })); @@ -1095,6 +1116,7 @@ class BookTextureRendererModule extends BaseModule { publishSpread(sides = null, options = {}) { const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; + const phase = this.getDrawPhase(options); const regionCounts = { left: 0, right: 0 @@ -1103,142 +1125,96 @@ class BookTextureRendererModule extends BaseModule { metrics: this.metrics, hitMaps: this.hitMaps, sides: sidesToPublish, - pageMeta: this.buildPublishPageMeta(sidesToPublish) + pageMeta: this.buildPublishPageMeta(sidesToPublish), + phase }; - if (options.preloadOnly) detail.preloadOnly = true; 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')) { - 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 = {}; sidesToPublish.forEach((side) => { const sideReveal = this.buildRevealRegions(side); if (!sideReveal) return; - sideReveal.baseCanvas = options.preloadOnly + sideReveal.baseCanvas = phase === 'prepare' ? this.cloneCanvas(this.revealBaseCanvases?.[side]) : this.revealBaseCanvases?.[side] || null; regionCounts[side] = sideReveal.lineRects.length; reveal[side] = sideReveal; }); if (Object.keys(reveal).length) detail.reveal = reveal; + detail.records = this.buildPageTextureRecords(sidesToPublish, detail); this.cachePublishedPages(sidesToPublish, detail); this.markPipelineTiming('publishSpread', { sides: sidesToPublish, hasReveal: Object.keys(reveal).length > 0, regionCounts, - preloadOnly: Boolean(options.preloadOnly) + phase }); - document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { + document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', { 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 = []) { const baseMeta = this.currentSpread?.pageMeta || {}; + const spreadIndex = Math.max(0, Math.round(Number(this.currentSpread?.index || 0))); return sides.reduce((meta, side) => { - const source = baseMeta[side] || null; - if (!source) { - meta[side] = null; - return meta; - } + const pageIndex = side === 'left' ? spreadIndex * 2 : spreadIndex * 2 + 1; + const source = baseMeta[side] || { + kind: 'blank', + section: pageIndex < 3 ? 'frontmatter' : 'body', + pageIndex, + pageNumber: null, + omitPageNumber: true + }; 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 lineCount = lines.length; - const pageIndex = Number(source.pageIndex); - const key = Number.isFinite(pageIndex) ? pageIndex : side; + const normalizedPageIndex = Number(source.pageIndex); + const key = Number.isFinite(normalizedPageIndex) ? normalizedPageIndex : side; const nextVersion = Math.max(1, Number(this.pageContentVersions.get(key) || 0) + 1); this.pageContentVersions.set(key, nextVersion); meta[side] = { ...source, + pageIndex: Number.isFinite(normalizedPageIndex) ? normalizedPageIndex : pageIndex, contentVersion: nextVersion, completenessScore: (maxBlockId * 1000) + lineCount, maxBlockId, lineCount }; 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 = {}) { - if (!this.pageCache || typeof this.pageCache.cachePageCanvas !== 'function') return; + if (!this.pageCache || typeof this.pageCache.storePageCanvas !== 'function') return; sides.forEach((side) => { const canvas = detail[side]; const pageMeta = detail.pageMeta?.[side] || null; 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) { return this.canvases[side] || null; } diff --git a/public/js/sentence-queue-module.js b/public/js/sentence-queue-module.js index 206fcbe..5dc7517 100644 --- a/public/js/sentence-queue-module.js +++ b/public/js/sentence-queue-module.js @@ -937,8 +937,8 @@ class SentenceQueueModule extends BaseModule { cueTimings: sentence.animation?.cueTimings || [], totalDuration: sentence.animation?.totalDuration || 0, spread, - preloadOnly: true - }, { preloadOnly: true }); + phase: 'prepare' + }, { phase: 'prepare' }); sentence.webglBookPresentation = { prepared: true, blockId, diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index 39de72e..2d62e05 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -1081,10 +1081,10 @@ class UIDisplayHandlerModule extends BaseModule { cueTimings: sentence.animation?.cueTimings || [], totalDuration: sentence.animation?.totalDuration || 0, spread: previewSpread, - preloadOnly: true + phase: 'prepare' }; if (previewSpread && typeof bookTextureRenderer.prepareRevealBlock === 'function') { - bookTextureRenderer.prepareRevealBlock(previewRevealDetail, { preloadOnly: true }); + bookTextureRenderer.prepareRevealBlock(previewRevealDetail, { phase: 'prepare' }); } if (Number(previewSpread?.index || 0) > currentSpreadIndex) { const flipped = await this.waitForWebGLPageFlip({ diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 28060bc..7ee1aeb 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -213,6 +213,7 @@ let activeFlips = []; let pendingPageFlips = 0; const pendingRevealStartBlockIds = new Set(); const activeRevealBlockStarts = new Map(); +let lastFlipTexturePreflight = null; const paperColor = new THREE.Color(0xece4ca); const inkColor = '#1a1009'; @@ -246,19 +247,24 @@ function createPageCanvasTexture(sourceCanvas) { } function getBlankPageTexture() { - if (blankPageTexture) return blankPageTexture; - blankPageTexture = createPageCanvasTexture(createPageCanvas('blank')); - return blankPageTexture; + return pageTextureStore?.getBlankTexture?.() || createPageCanvasTexture(createPageCanvas('blank')); } -const preparedPageTextures = { - left: new Map(), - right: new Map() -}; -const residentPageTextures = new Map(); const maxResidentPageTextures = 192; -let blankPageTexture = null; -const pageCacheProblemLog = []; +const pageTextureStore = window.moduleRegistry?.getModule?.('webgl-page-cache') || window.WebGLPageCache || null; +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 = { left: null, right: null @@ -589,14 +595,18 @@ window.BookLabDebug = { }; }, getRuntimeInvariants() { + const textureStoreState = pageTextureStore?.getRuntimeState?.() || {}; return { targetFrameDurationMs, - residentPageTextureCount: residentPageTextures.size, + residentPageTextureCount: textureStoreState.residentTextureCount || 0, maxResidentPageTextures, - pageCacheProblemCount: pageCacheProblemLog.length, + pageCacheProblemCount: textureStoreState.problemCount || 0, + preparedPageTextureCount: textureStoreState.preparedTextureCount || 0, + singlePageTextureStore: true, flipFrontBackShareMaterial: materials.flipPageSurface === materials.flipPageBackSurface, mirrorRefreshesEveryFrame: true, - mirrorRefreshesWhenStaticDirty: true + mirrorRefreshesWhenStaticDirty: true, + lastFlipTexturePreflight }; }, projectPointerToPage(clientX, clientY) { @@ -610,7 +620,7 @@ window.BookLabDebug = { }; 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) => { startPageRevealForBlock(event.detail?.blockId); }); @@ -621,7 +631,7 @@ document.addEventListener('webgl-book:reveal-committed', (event) => { handleRevealCommittedForPageFlip(event.detail || {}); }); document.addEventListener('webgl-book:page-cache-problem', (event) => { - recordPageCacheProblem(event.detail || {}); + pageTextureStore?.recordProblem?.(event.detail || {}); }); document.addEventListener('book-pagination:spread-updated', (event) => { const detail = event.detail || {}; @@ -630,7 +640,7 @@ document.addEventListener('book-pagination:spread-updated', (event) => { const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0)); if ( latestBlockId > latestRenderedBlockId - && detail.allowFutureUnrendered !== true + && detail.visibility !== 'future-ready' && activeFlips.length === 0 && incomingSpreadIndex > Math.max(0, Number(bookPaginationState.spreadIndex || 0)) ) { @@ -2028,35 +2038,34 @@ function syncBottomNavigation() { bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit; } -function handlePageCanvases(event) { - const detail = event.detail || {}; +function handlePageTextureRecords(event) { + const detail = normalizePageTextureRecordDetail(event.detail || {}); if (detail.pageMeta) { - const hasLeftMeta = Object.prototype.hasOwnProperty.call(detail.pageMeta, 'left'); - 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 - }; + currentPageMeta = normalizePageMetaPair(detail.pageMeta, currentPageMeta); } - markPageTextureTiming('handlePageCanvases:start', { + markPageTextureTiming('handlePageTextureRecords:start', { hasLeft: Boolean(detail.left), hasRight: Boolean(detail.right), revealSides: Object.keys(detail.reveal || {}), - preloadOnly: Boolean(detail.preloadOnly), + phase: detail.phase || 'activate', pageMeta: currentPageMeta }); - const leftReveal = attachRevealPageMeta(detail.reveal?.left, detail.pageMeta?.left || currentPageMeta.left || null); - const rightReveal = attachRevealPageMeta(detail.reveal?.right, detail.pageMeta?.right || currentPageMeta.right || null); - if (detail.preloadOnly) { + const leftReveal = attachRevealPageMeta(detail.reveal?.left, currentPageMeta.left || null); + const rightReveal = attachRevealPageMeta(detail.reveal?.right, currentPageMeta.right || null); + if (detail.phase === 'prepare') { if (detail.left) { - const texture = preloadPageTexture('left', detail.left, leftReveal, detail.pageMeta?.left || null); - rememberResidentPageTexture(detail.pageMeta?.left || null, texture, detail.left); + const texture = preloadPageTexture('left', detail.left, leftReveal, currentPageMeta.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) { - const texture = preloadPageTexture('right', detail.right, rightReveal, detail.pageMeta?.right || null); - rememberResidentPageTexture(detail.pageMeta?.right || null, texture, detail.right); + const texture = preloadPageTexture('right', detail.right, rightReveal, currentPageMeta.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; } if (detail.left) { @@ -2073,13 +2082,110 @@ function handlePageCanvases(event) { 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(); document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({ width: leftCanvas.width, height: leftCanvas.height, 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) { @@ -2099,30 +2205,15 @@ function getRevealCacheKey(revealDetail = {}) { function preloadPageTexture(side, sourceCanvas, revealDetail = {}, pageMeta = 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 }); markPageTextureTiming('preloadTexture:start', { side, key, width: sourceCanvas.width, height: sourceCanvas.height, - hasBaseTexture: Boolean(baseTexture) + hasBaseTexture: Boolean(revealDetail?.baseCanvas) }); - preparedPageTextures[side].set(key, { - 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); - } + const texture = pageTextureStore?.preparePageTexture?.(side, key, pageMeta, sourceCanvas, revealDetail) || null; markPageTextureTiming('preloadTexture:end', { side, key }); 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) { const index = Math.max(0, Math.round(Number(pageIndex || 0))); 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) { const spread = Math.max(0, Math.round(Number(spreadIndex || 0))); 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) { const index = Math.max(0, Math.round(Number(pageIndex || 0))); const spreadIndex = Math.floor(index / 2); @@ -2294,27 +2266,47 @@ function getPaginationPageMeta(pageIndex) { } async function prewarmSpreadTextures(spreadIndex) { - const indices = spreadPageIndices(spreadIndex); - const [left, right] = await Promise.all([ - preloadCachedPageTexture(indices.left), - preloadCachedPageTexture(indices.right) - ]); - return { + return pageTextureStore?.prewarmSpreadTextures?.(spreadIndex, makePageMetaForCache) || { spreadIndex: Math.max(0, Math.round(Number(spreadIndex || 0))), - left, - right + left: null, + 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) { const currentSpread = Math.max(0, Number(bookPaginationState.spreadIndex || 0)); const nextSpread = Number.isFinite(Number(targetSpread)) ? Math.max(0, Math.round(Number(targetSpread))) : Math.max(0, currentSpread + Math.sign(Number(direction || 0))); - const [current, next] = await Promise.all([ - prewarmSpreadTextures(currentSpread), - prewarmSpreadTextures(nextSpread) - ]); + const windowMap = await prewarmNavigationTextureWindow('flip-prewarm', { targetSpread: nextSpread }); + const current = windowMap?.[currentSpread] || await prewarmSpreadTextures(currentSpread); + const next = windowMap?.[nextSpread] || await prewarmSpreadTextures(nextSpread); return { current, next @@ -2323,19 +2315,22 @@ async function prewarmFlipTextures(direction, targetSpread = null) { function takePreparedPageTexture(side, revealDetail = {}) { const key = getRevealCacheKey(revealDetail); - const prepared = preparedPageTextures[side].get(key); + const prepared = pageTextureStore?.takePreparedPageTexture?.(side, key) || null; if (!prepared) return null; - preparedPageTextures[side].delete(key); markPageTextureTiming('preloadTexture:activate', { side, key }); return prepared; } function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) { + if (pageMeta?.kind === 'blank') { + applyExplicitBlankPageTexture(side, pageMeta, 'direct-upload'); + return; + } const texture = side === 'left' ? leftTexture : rightTexture; const material = side === 'left' ? materials.leftPage : materials.rightPage; const shouldUseResidentTexture = pageMeta?.kind !== 'title'; const residentTexture = shouldUseResidentTexture && Number.isFinite(Number(pageMeta?.pageIndex)) - ? getResidentPageTextureForMeta(pageMeta) + ? pageTextureStore?.getResidentTextureForMeta?.(pageMeta) : null; markPageTextureTiming('directUpload:start', { side, @@ -2356,7 +2351,7 @@ function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) { material.needsUpdate = true; } bindPageTextureSource(side, texture, sourceCanvas); - rememberResidentPageTexture(pageMeta, texture, sourceCanvas, false); + pageTextureStore?.rememberResidentTexture?.(pageMeta, texture, sourceCanvas, false); markPageTextureTiming('directUpload:end', { side }); } @@ -2381,7 +2376,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) { } 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 activeStartedAt = revealBlockIds @@ -2397,6 +2392,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) { durationMs: Math.max(1, Number(revealDetail.durationMs || 1)), blockIds: revealBlockIds, baseTexture, + pageFlipAfterReveal: revealDetail.pageFlipAfterReveal === true, fastForwarding: false, fastForwardStartedAt: null, fastForwardStartElapsedMs: 0, @@ -2408,12 +2404,12 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) { if (shader?.uniforms?.bookRevealElapsedMs) { 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); prewarmFlipTextures(1, targetSpread).then(() => { markPageTextureTiming('rightPageReveal:flip-prewarm-ready', { targetSpread }); }).catch((error) => { - recordPageCacheProblem({ + pageTextureStore?.recordProblem?.({ type: 'right-page-flip-prewarm-error', targetSpread, message: error?.message || String(error) @@ -2618,7 +2614,8 @@ function updatePageRevealAnimations(now) { document.dispatchEvent(new CustomEvent('webgl-book:reveal-committed', { detail: { side, - blockIds: state.blockIds + blockIds: state.blockIds, + pageFlipAfterReveal: state.pageFlipAfterReveal === true } })); }); @@ -2632,8 +2629,11 @@ function bindPageTextureSource(side, texture, sourceCanvas) { width: nextCanvas?.width || 0, height: nextCanvas?.height || 0 }); - texture.image = sourceCanvas || fallbackCanvas; - texture.needsUpdate = true; + const boundTexture = pageTextureStore?.bindVisibleTextureSource?.(side, sourceCanvas) || null; + if (!boundTexture) { + texture.image = nextCanvas; + texture.needsUpdate = true; + } updatePageTextureDebugState(side, nextCanvas, sourceCanvas, true); markPageTextureTiming('bindPageTextureSource:end', { side }); } @@ -2841,20 +2841,27 @@ function createPageFlip(direction, startTime, duration) { function prepareStaticPageForFlip(flip, prewarm = null) { if (!flip) return false; - const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage; - const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture); + const sourceSide = flip.direction > 0 ? 'right' : 'left'; + const sourceTexture = resolveCurrentFlipSourceTexture(sourceSide); + const sourcePageMeta = currentPageMeta?.[sourceSide] || getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[sourceSide]) || null; const targetSpread = Number.isFinite(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))); 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 residentBackTexture = prewarmedBackTexture || getResidentPageTexture(targetBackPageIndex); - const requiresWrittenTexture = targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0)); - if (!residentBackTexture && requiresWrittenTexture) { - recordPageCacheProblem({ - type: 'flip-back-texture-missing', + const backTexture = resolveFlipBackTexture(targetBackPageMeta, prewarmedBackTexture); + const requiresWrittenTexture = targetBackPageMeta.kind !== 'blank' + && targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0)); + if (!sourceTexture || (!backTexture && requiresWrittenTexture)) { + pageTextureStore?.recordProblem?.({ + type: !sourceTexture ? 'flip-source-texture-missing' : 'flip-back-texture-missing', + sourceSide, + sourcePageIndex: sourcePageMeta?.pageIndex ?? null, targetBackPageIndex, + targetBackKind: targetBackPageMeta.kind, targetSpread, direction: flip.direction, prewarmedCurrent: Boolean(prewarm?.current), @@ -2862,9 +2869,8 @@ function prepareStaticPageForFlip(flip, prewarm = null) { }); return false; } - const backTexture = residentBackTexture || getBlankPageTexture(); materials.flipPageSurface.map = sourceTexture; - materials.flipPageBackSurface.map = backTexture; + materials.flipPageBackSurface.map = backTexture || getBlankPageTexture(); materials.flipPageSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap; @@ -2872,8 +2878,24 @@ function prepareStaticPageForFlip(flip, prewarm = null) { materials.flipPageSurface.needsUpdate = true; materials.flipPageBackSurface.needsUpdate = true; flip.sourceTexture = sourceTexture; - flip.backTexture = backTexture; + flip.sourcePageMeta = sourcePageMeta ? { ...sourcePageMeta } : null; + flip.backTexture = backTexture || getBlankPageTexture(); + flip.backPageMeta = targetBackPageMeta ? { ...targetBackPageMeta } : null; 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) { const blankTexture = getBlankPageTexture(); if (blankTexture && materials.rightPage.map !== blankTexture) { @@ -2890,15 +2912,27 @@ function prepareStaticPageForFlip(flip, prewarm = null) { } } markPageTextureTiming('flipTexturePreflight:ready', { - direction: flip.direction, - sourceSide: flip.sourcePageSide, - targetSpread, - targetBackPageIndex, - usedResidentBackTexture: Boolean(residentBackTexture) + ...lastFlipTexturePreflight, + usedResidentBackTexture: Boolean(backTexture && backTexture !== getBlankPageTexture()) }); 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) { if (!currentProceduralBookModel) return false; const currentPage = getCurrentPagePosition(); @@ -2908,7 +2942,7 @@ function canPageFlip(direction) { } function handleRevealCommittedForPageFlip(detail = {}) { - if (detail.side !== 'right' || !isRightBodyPageComplete()) return; + if (detail.side !== 'right' || detail.pageFlipAfterReveal !== true) return; if (activeFlips.length > 0 || pendingRightPageFlip) return; if (isChoiceAwaitingPlayer()) return; const autoplayFlip = isTtsPlaybackActive(); @@ -2937,16 +2971,6 @@ async function tryStartPendingRightPageFlip(reason = 'pending', options = {}) { 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() { return document.documentElement.dataset.choiceAwaiting === 'true' || document.body?.dataset?.choiceAwaiting === 'true' diff --git a/public/js/webgl-page-cache-module.js b/public/js/webgl-page-cache-module.js index b3328d7..79891ee 100644 --- a/public/js/webgl-page-cache-module.js +++ b/public/js/webgl-page-cache-module.js @@ -18,16 +18,63 @@ class WebGLPageCacheModule extends BaseModule { this.maxCacheSizeBytes = 5 * 1024 * 1024 * 1024; this.memoryCanvasCache = new Map(); 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([ 'initialize', 'openDB', + 'configureTextureRuntime', 'cachePageCanvas', 'getPageCanvas', + 'putPageCanvas', + 'storePageCanvas', + 'preparePageTexture', + 'takePreparedPageTexture', + 'rememberPreparedRevealPlan', + 'takePreparedRevealPlan', + 'hasPreparedRevealPlan', + 'registerVisibleTexture', + 'bindVisibleTextureSource', + 'getVisibleTexture', + 'rememberResidentTexture', + 'getResidentTexture', + 'getResidentTextureForMeta', + 'ensurePageTexture', + 'prewarmPageTexture', + 'prewarmSpreadTextures', + 'prewarmNavigationWindow', + 'getBlankTexture', + 'createTextureFromCanvas', + 'disposeTextureRecord', 'makePageKey', + 'getPageWriteKey', + 'makeResidentKey', + 'cloneCanvas', 'canvasToBlob', 'blobToCanvas', 'isOlderPageEntry', + 'isOlderPageMeta', + 'recordProblem', + 'getRuntimeState', 'manageCacheSize', 'calculateTotalCacheSize', '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() { if (this.db) return Promise.resolve(this.db); return new Promise((resolve, reject) => { @@ -93,6 +159,303 @@ class WebGLPageCacheModule extends BaseModule { 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) { if (!canvas || !this.db || this.cacheStatus !== 'ready') return false; const pageIndex = Number(pageMeta.pageIndex); @@ -200,6 +563,43 @@ class WebGLPageCacheModule extends BaseModule { 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) { return new Promise((resolve) => { if (typeof canvas.toBlob !== 'function') { diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index 824de14..dc45fbf 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -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 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 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)], ['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)], @@ -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)], ['pagination can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)], ['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)], - ['texture renderer 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 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)], - ['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 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 records cache misses as problem states', /pageCacheProblemLog/.test(source) && /recordPageCacheProblem/.test(source) && /db-cache-miss/.test(source) && /webglPageCacheProblems/.test(source)], - ['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 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 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)], + ['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 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 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 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 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 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 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 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)], @@ -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)], ['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)], - ['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 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)], @@ -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 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)], - ['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)], ['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)], ['texture renderer draws title page and page numbers from versioned page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)], ['texture renderer uses plural page margin metrics for page numbers', /this\.metrics\.margins\.bottom/.test(textureRendererSource) && !/this\.metrics\.margin\.bottom/.test(textureRendererSource)], - ['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 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 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 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', /function resolveFlipBackTexture\(pageMeta = null, prewarmedTexture = null\)/.test(source) && source.includes('return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);') && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))], + ['webgl page texture record metadata normalizes omitted or null sides into explicit blank pages', /function normalizePageMetaPair/.test(source) && /function makeBlankPageMeta/.test(source) && /applyExplicitBlankPageTexture/.test(source) && /normalizePageTextureRecordDetail/.test(source) && !/hasLeftMeta/.test(methodBody(source, 'handlePageTextureRecords'))], ['texture renderer publishes both spread sides for reveal preparation 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 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)], - ['webgl resident page texture cache rejects older page versions before direct reuse', /isOlderPageTextureMeta/.test(source) && /getResidentPageTextureForMeta/.test(source) && /getResidentPageTextureForMeta\(pageMeta\)/.test(source)], + ['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(webglPageCacheSource)], + ['webgl texture store resident cache 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 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 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)], @@ -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)], ['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'))], - ['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)], - ['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)], - ['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)], - ['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)], + ['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 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) && /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 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)], ['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)]