From 419691000cc3a9b91d9f14828c9a723922d16398 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Mon, 8 Jun 2026 23:08:13 +0200 Subject: [PATCH] Fix WebGL page cache and flip sequencing --- public/js/book-pagination-module.js | 44 ++++-- public/js/book-texture-renderer-module.js | 106 ++++++++++++-- public/js/sentence-queue-module.js | 19 +-- public/js/webgl-book-lab.js | 161 ++++++++++++++++++---- public/js/webgl-page-cache-module.js | 31 ++++- scripts/check-webgl-book-lab.js | 31 ++++- scripts/check-webgl-book-runtime.js | 32 +++++ 7 files changed, 364 insertions(+), 60 deletions(-) diff --git a/public/js/book-pagination-module.js b/public/js/book-pagination-module.js index f61820a..6289424 100644 --- a/public/js/book-pagination-module.js +++ b/public/js/book-pagination-module.js @@ -54,6 +54,7 @@ class BookPaginationModule extends BaseModule { 'countLineWords', 'getLineGeometry', 'getSpread', + 'findSpreadIndexForBlock', 'getCurrentSpread', 'setCurrentSpread', 'handlePageCountChanged', @@ -99,11 +100,17 @@ class BookPaginationModule extends BaseModule { const token = ++this.refreshToken; const detail = event?.detail || {}; const gameId = detail.gameId || this.storyHistory?.currentGameId || null; + const latestRenderedBlockId = Math.max( + 0, + Number(detail.latestRenderedBlockId || this.storyHistory?.latestRenderedBlockId || 0) + ); const latestBlockId = Math.max( 0, - Number(detail.latestRenderedBlockId || detail.latestBlockId || this.storyHistory?.latestRenderedBlockId || (this.storyHistory?.nextBlockId || 1) - 1) + Number(detail.latestBlockId || (this.storyHistory?.nextBlockId || 1) - 1 || latestRenderedBlockId) ); - if (!gameId || latestBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') { + const continuationBlockId = this.getContinuationBlockId(latestBlockId, latestRenderedBlockId); + const paginationEndBlockId = Math.max(latestRenderedBlockId, continuationBlockId); + if (!gameId || paginationEndBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') { this.pages = this.buildPages([]); this.spreads = this.buildSpreadsFromPages(this.pages); this.latestBlockId = 0; @@ -114,20 +121,31 @@ class BookPaginationModule extends BaseModule { return; } - const blocks = await this.storyHistory.getBlocksRange(gameId, 1, latestBlockId); + const blocks = await this.storyHistory.getBlocksRange(gameId, 1, paginationEndBlockId); if (token !== this.refreshToken) return; this.latestBlockId = latestBlockId; - this.latestRenderedBlockId = Math.max( - 0, - Number(detail.latestRenderedBlockId || this.storyHistory?.latestRenderedBlockId || 0) - ); + this.latestRenderedBlockId = latestRenderedBlockId; this.pages = this.buildPages(blocks); this.spreads = this.buildSpreadsFromPages(this.pages); this.persistPaginationMetrics(this.pages); - this.currentSpreadIndex = Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1))); + const continuationSpreadIndex = this.findSpreadIndexForBlock(continuationBlockId); + const renderedSpreadIndex = this.findSpreadIndexForBlock(latestRenderedBlockId); + this.currentSpreadIndex = continuationSpreadIndex >= 0 + ? continuationSpreadIndex + : renderedSpreadIndex >= 0 + ? renderedSpreadIndex + : Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1))); this.publish(); } + getContinuationBlockId(latestBlockId = 0, latestRenderedBlockId = 0) { + const latest = Math.max(0, Number(latestBlockId || 0)); + const rendered = Math.max(0, Number(latestRenderedBlockId || 0)); + if (latest <= 0) return 0; + if (rendered <= 0) return 1; + return rendered < latest ? rendered + 1 : latest; + } + async preparePendingBlock(block = {}, options = {}) { const token = options.activate === false ? this.refreshToken : ++this.refreshToken; const gameId = block.gameId || block.metadata?.gameId || this.storyHistory?.currentGameId || null; @@ -857,6 +875,16 @@ class BookPaginationModule extends BaseModule { return this.spreads[Math.max(0, Number(index || 0))] || { index: 0, left: [], right: [] }; } + findSpreadIndexForBlock(blockId) { + const id = Math.max(0, Number(blockId || 0)); + if (id <= 0) return -1; + const spread = this.spreads.find(entry => ['left', 'right'].some((side) => { + const lines = Array.isArray(entry?.[side]) ? entry[side] : []; + return lines.some(line => Number(line?.blockId || 0) === id); + })); + return Number.isFinite(Number(spread?.index)) ? Math.max(0, Math.round(Number(spread.index))) : -1; + } + getCurrentSpread() { return this.getSpread(this.currentSpreadIndex); } diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index dbbdac9..2146863 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -38,9 +38,11 @@ class BookTextureRendererModule extends BaseModule { this.lastDrawSkipLoggedAt = 0; this.animationFrameId = null; this.lastAnimationFrameAt = 0; - this.targetFrameDurationMs = 1000 / 30; + this.targetFrameDurationMs = 1000 / 60; this.pipelineTimings = []; this.imageCache = new Map(); + this.pendingPageCacheWrites = new Map(); + this.pageContentVersions = new Map(); this.bindMethods([ 'initialize', @@ -84,6 +86,8 @@ class BookTextureRendererModule extends BaseModule { 'tickAnimations', 'publishSpread', 'cachePublishedPages', + 'getPageCacheWriteKey', + 'isOlderPageMeta', 'schedulePageCacheWrite', 'getPageCanvas', 'getHitMap', @@ -641,7 +645,7 @@ class BookTextureRendererModule extends BaseModule { }); this.pendingRevealBlockIds.delete(String(blockId)); this.revealPublishBlockIds = new Set([String(blockId)]); - this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId)); + this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), ['left', 'right']); document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', { detail: { blockId @@ -692,7 +696,7 @@ class BookTextureRendererModule extends BaseModule { this.pendingRevealBlockIds.delete(id); this.revealPublishBlockIds = new Set([id]); const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.(); - const sides = this.getBlockSides(blockId); + const sides = ['left', 'right']; const published = this.drawSpread(spread, sides, { preloadOnly }); if (preloadOnly && published) { this.preparedRevealCache.set(id, { @@ -728,6 +732,7 @@ class BookTextureRendererModule extends BaseModule { left: prepared.left || null, right: prepared.right || null, reveal: prepared.reveal || {}, + pageMeta: prepared.pageMeta || {}, preparedFromCache: true } })); @@ -856,7 +861,7 @@ class BookTextureRendererModule extends BaseModule { metrics: this.metrics, hitMaps: this.hitMaps, sides: sidesToPublish, - pageMeta: this.currentSpread?.pageMeta || {} + pageMeta: this.buildPublishPageMeta(sidesToPublish) }; if (options.preloadOnly) detail.preloadOnly = true; if (sidesToPublish.includes('left')) { @@ -907,6 +912,35 @@ class BookTextureRendererModule extends BaseModule { return detail; } + buildPublishPageMeta(sides = []) { + const baseMeta = this.currentSpread?.pageMeta || {}; + return sides.reduce((meta, side) => { + const source = baseMeta[side] || null; + if (!source) { + meta[side] = null; + return meta; + } + 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 nextVersion = Math.max(1, Number(this.pageContentVersions.get(key) || 0) + 1); + this.pageContentVersions.set(key, nextVersion); + meta[side] = { + ...source, + 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; sides.forEach((side) => { @@ -919,10 +953,66 @@ class BookTextureRendererModule extends BaseModule { schedulePageCacheWrite(pageMeta, canvas) { const frozenCanvas = this.cloneCanvas(canvas); - const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 16)); - scheduler(() => { - this.pageCache?.cachePageCanvas?.(pageMeta, frozenCanvas); - }, { timeout: 250 }); + 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) { diff --git a/public/js/sentence-queue-module.js b/public/js/sentence-queue-module.js index fa4f5b9..206fcbe 100644 --- a/public/js/sentence-queue-module.js +++ b/public/js/sentence-queue-module.js @@ -165,7 +165,7 @@ class SentenceQueueModule extends BaseModule { if (!this.isProcessing) { this.processNextSentence(); } else { - this.prefetchAhead(4, this.queueGeneration); + this.prefetchAhead(6, this.queueGeneration); } } @@ -204,14 +204,15 @@ class SentenceQueueModule extends BaseModule { if (!this.isWebGLBookPresentationPrepared(sentence)) { await this.prefetchWebGLBookPresentation(sentence, { queueGeneration, - queueIndex: 0 + queueIndex: 0, + immediate: true }); } if (!this.isCurrentQueueItem(item, queueGeneration)) return; // Prefetch far enough ahead that media pauses do not block TTS // generation for the next spoken paragraph. - this.prefetchAhead(4, queueGeneration); + this.prefetchAhead(6, queueGeneration); if (!this.isCurrentQueueItem(item, queueGeneration)) return; // Notify display handler with complete sentence @@ -910,10 +911,12 @@ class SentenceQueueModule extends BaseModule { sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []); } - await new Promise(resolve => { - const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 1)); - scheduler(() => resolve(), { timeout: 120 }); - }); + if (!options.immediate) { + await new Promise(resolve => { + const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 1)); + scheduler(() => resolve(), { timeout: 80 }); + }); + } if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null; const spread = typeof bookPagination.preparePendingBlock === 'function' @@ -957,7 +960,7 @@ class SentenceQueueModule extends BaseModule { return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item; } - prefetchAhead(maxLookahead = 4, queueGeneration = this.queueGeneration) { + prefetchAhead(maxLookahead = 6, queueGeneration = this.queueGeneration) { if (this.sentenceQueue.length <= 1) { document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: this.sentenceQueue[0]?.id } diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 08e6a0f..bb06cf2 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -157,7 +157,7 @@ updateCameraRig(0); configureScenePostprocessing(); const clock = new THREE.Clock(); -const targetFrameDurationMs = 1000 / 30; +const targetFrameDurationMs = 1000 / 60; let lastRenderFrameAt = 0; let fpsDisplay = null; let fpsWindowStartedAt = performance.now(); @@ -253,13 +253,15 @@ const preparedPageTextures = { right: new Map() }; const residentPageTextures = new Map(); -const maxResidentPageTextures = 18; +const maxResidentPageTextures = 192; let blankPageTexture = null; +const pageCacheProblemLog = []; let currentPageMeta = { left: null, right: null }; let pendingRightPageFlip = false; +let pendingRightPageFlipAutoplay = false; const pageRevealState = { left: null, right: null @@ -575,6 +577,16 @@ window.BookLabDebug = { debug: getPageTextureDebugState() }; }, + getRuntimeInvariants() { + return { + targetFrameDurationMs, + residentPageTextureCount: residentPageTextures.size, + maxResidentPageTextures, + pageCacheProblemCount: pageCacheProblemLog.length, + flipFrontBackShareMaterial: materials.flipPageSurface === materials.flipPageBackSurface, + mirrorRefreshesEveryFrame: true + }; + }, projectPointerToPage(clientX, clientY) { return projectPointerToPage(clientX, clientY); }, @@ -596,6 +608,9 @@ document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => { document.addEventListener('webgl-book:reveal-committed', (event) => { handleRevealCommittedForPageFlip(event.detail || {}); }); +document.addEventListener('webgl-book:page-cache-problem', (event) => { + recordPageCacheProblem(event.detail || {}); +}); document.addEventListener('book-pagination:spread-updated', (event) => { const detail = event.detail || {}; const previousPageCount = bookPageCount; @@ -611,6 +626,7 @@ document.addEventListener('book-pagination:spread-updated', (event) => { window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount); } syncBookControls(); + if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated'); }); document.addEventListener('webgl-book:page-reserve-directive', (event) => { const detail = event.detail || {}; @@ -634,8 +650,7 @@ document.addEventListener('webgl-book:request-page-flip', (event) => { }); document.addEventListener('ui:command', (event) => { if (event.detail?.type === 'continue' && pendingRightPageFlip) { - pendingRightPageFlip = false; - startPageFlip(1); + tryStartPendingRightPageFlip('continue', { force: true }); } }); installBookControls(); @@ -1976,9 +1991,11 @@ function syncBottomNavigation() { function handlePageCanvases(event) { const detail = 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: detail.pageMeta.left || currentPageMeta.left || null, - right: detail.pageMeta.right || currentPageMeta.right || null + left: hasLeftMeta ? detail.pageMeta.left : currentPageMeta.left || null, + right: hasRightMeta ? detail.pageMeta.right : currentPageMeta.right || null }; } markPageTextureTiming('handlePageCanvases:start', { @@ -1989,8 +2006,14 @@ function handlePageCanvases(event) { pageMeta: currentPageMeta }); if (detail.preloadOnly) { - if (detail.left) preloadPageTexture('left', detail.left, detail.reveal?.left); - if (detail.right) preloadPageTexture('right', detail.right, detail.reveal?.right); + if (detail.left) { + const texture = preloadPageTexture('left', detail.left, detail.reveal?.left); + rememberResidentPageTexture(currentPageMeta.left, texture, detail.left); + } + if (detail.right) { + const texture = preloadPageTexture('right', detail.right, detail.reveal?.right); + rememberResidentPageTexture(currentPageMeta.right, texture, detail.right); + } markPageTextureTiming('handlePageCanvases:preloadOnly:end'); return; } @@ -2041,7 +2064,7 @@ function preloadPageTexture(side, sourceCanvas, revealDetail = {}) { revealDetail, uploadedAt: performance.now() }); - if (preparedPageTextures[side].size > 12) { + if (preparedPageTextures[side].size > 128) { const oldestKey = preparedPageTextures[side].keys().next().value; const oldest = preparedPageTextures[side].get(oldestKey); oldest?.texture?.dispose?.(); @@ -2052,6 +2075,54 @@ function preloadPageTexture(side, sourceCanvas, revealDetail = {}) { return texture; } +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 = makePageMetaForCache(pageIndex).pageIndex; + 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) { return { pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))), @@ -2078,6 +2149,15 @@ function getResidentPageTexture(pageIndex) { return resident.texture || null; } +function getResidentPageTextureForMeta(pageMeta = null) { + const pageIndex = Number(pageMeta?.pageIndex); + if (!Number.isFinite(pageIndex)) return null; + const key = makePageMetaForCache(pageIndex).pageIndex; + const resident = residentPageTextures.get(key); + if (!resident || isOlderPageTextureMeta(pageMeta, resident.pageMeta)) return null; + return getResidentPageTexture(pageIndex); +} + async function preloadCachedPageTexture(pageIndex) { const meta = makePageMetaForCache(pageIndex); if (residentPageTextures.has(meta.pageIndex)) { @@ -2086,17 +2166,28 @@ async function preloadCachedPageTexture(pageIndex) { } const cache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache') || null; const sourceCanvas = await cache?.getPageCanvas?.(meta); - if (!sourceCanvas) return null; + if (!sourceCanvas) { + 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(meta.pageIndex, { texture, sourceCanvas, - lastUsedAt: performance.now() + lastUsedAt: performance.now(), + ownsTexture: true, + pageMeta: cachedMeta }); while (residentPageTextures.size > maxResidentPageTextures) { const oldestKey = residentPageTextures.keys().next().value; const oldest = residentPageTextures.get(oldestKey); - oldest?.texture?.dispose?.(); + if (oldest?.ownsTexture) oldest.texture?.dispose?.(); residentPageTextures.delete(oldestKey); } return texture; @@ -2134,7 +2225,7 @@ function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) { const texture = side === 'left' ? leftTexture : rightTexture; const material = side === 'left' ? materials.leftPage : materials.rightPage; const residentTexture = Number.isFinite(Number(pageMeta?.pageIndex)) - ? getResidentPageTexture(pageMeta.pageIndex) + ? getResidentPageTextureForMeta(pageMeta) : null; markPageTextureTiming('directUpload:start', { side, @@ -2155,6 +2246,7 @@ function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) { material.needsUpdate = true; } bindPageTextureSource(side, texture, sourceCanvas); + rememberResidentPageTexture(pageMeta, texture, sourceCanvas, false); markPageTextureTiming('directUpload:end', { side }); } @@ -2516,10 +2608,11 @@ async function startPageFlip(direction, options = {}) { function startPageFlipPrepared(direction, options = {}) { if (activeFlips.length || !currentProceduralBookModel) return false; if (!options.force && !canPageFlip(direction)) return false; - pendingRightPageFlip = false; - delete document.documentElement.dataset.webglPendingPageFlip; const flip = createPageFlip(direction, performance.now(), normalFlipDuration); if (!flip) return false; + pendingRightPageFlip = false; + pendingRightPageFlipAutoplay = false; + delete document.documentElement.dataset.webglPendingPageFlip; flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null; prepareStaticPageForFlip(flip); activeFlips.push(flip); @@ -2586,14 +2679,13 @@ function createPageFlip(direction, startTime, duration) { function prepareStaticPageForFlip(flip) { if (!flip) return; const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage; - const oppositeMaterial = flip.sourcePageSide === 'left' ? materials.rightPage : materials.leftPage; const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture); 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 backTexture = getResidentPageTexture(targetBackPageIndex) || oppositeMaterial?.map || getBlankPageTexture(); + const backTexture = getResidentPageTexture(targetBackPageIndex) || getBlankPageTexture(); materials.flipPageSurface.map = sourceTexture; materials.flipPageBackSurface.map = backTexture; materials.flipPageSurface.normalMap = materials.pageSurface.normalMap; @@ -2604,6 +2696,7 @@ function prepareStaticPageForFlip(flip) { materials.flipPageBackSurface.needsUpdate = true; flip.sourceTexture = sourceTexture; flip.backTexture = backTexture; + flip.targetBackPageIndex = targetBackPageIndex; if (flip.direction > 0) { const blankTexture = getBlankPageTexture(); if (blankTexture && materials.rightPage.map !== blankTexture) { @@ -2633,12 +2726,30 @@ function handleRevealCommittedForPageFlip(detail = {}) { if (detail.side !== 'right' || !isRightBodyPageComplete()) return; if (activeFlips.length > 0 || pendingRightPageFlip) return; if (isChoiceAwaitingPlayer()) return; - if (isTtsPlaybackActive()) { - startPageFlip(1); - return; - } + const autoplayFlip = isTtsPlaybackActive(); pendingRightPageFlip = true; + pendingRightPageFlipAutoplay = autoplayFlip; document.documentElement.dataset.webglPendingPageFlip = 'right'; + if (autoplayFlip) { + tryStartPendingRightPageFlip('tts-active'); + } +} + +async function tryStartPendingRightPageFlip(reason = 'pending', options = {}) { + if (!pendingRightPageFlip || activeFlips.length > 0 || isChoiceAwaitingPlayer()) return false; + if (!options.force && !pendingRightPageFlipAutoplay) return false; + const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1); + const flipped = await startPageFlip(1, { + force: options.force === true || pendingRightPageFlipAutoplay, + reason, + targetSpread + }); + if (flipped) { + pendingRightPageFlip = false; + pendingRightPageFlipAutoplay = false; + delete document.documentElement.dataset.webglPendingPageFlip; + } + return flipped; } function isRightBodyPageComplete() { @@ -2853,7 +2964,7 @@ function createFlippingPageGeometry(surface) { rowPoints.forEach((point, depthIndex) => { const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments; topRow.push(push(point, pageThickness, u, v)); - bottomRow.push(push(point, 0, u, v)); + bottomRow.push(push(point, 0, u, 1 - v)); }); topGrid.push(topRow); bottomGrid.push(bottomRow); @@ -3961,12 +4072,12 @@ function renderMirrorDebugView() { function animate(now = performance.now()) { const elapsedSinceLastFrame = lastRenderFrameAt ? now - lastRenderFrameAt : targetFrameDurationMs; if (lastRenderFrameAt && elapsedSinceLastFrame < targetFrameDurationMs) { - setTimeout(animate, Math.max(1, targetFrameDurationMs - elapsedSinceLastFrame)); + requestAnimationFrame(animate); return; } const frameElapsedMs = lastRenderFrameAt ? elapsedSinceLastFrame : targetFrameDurationMs; lastRenderFrameAt = now; - setTimeout(animate, targetFrameDurationMs); + requestAnimationFrame(animate); const delta = Math.min(0.1, frameElapsedMs / 1000); clock.getDelta(); const t = clock.elapsedTime; @@ -4008,7 +4119,7 @@ function animate(now = performance.now()) { updateBookShadowMaps(); lastFrameTiming.shadows = performance.now() - shadowStartedAt; const reflectionStartedAt = performance.now(); - const refreshStaticSceneBuffers = staticSceneBuffersDirty || activeFlips.length > 0; + const refreshStaticSceneBuffers = true; if (refreshStaticSceneBuffers) { updateTableReflection(); } diff --git a/public/js/webgl-page-cache-module.js b/public/js/webgl-page-cache-module.js index 46c04ee..22ef1cf 100644 --- a/public/js/webgl-page-cache-module.js +++ b/public/js/webgl-page-cache-module.js @@ -15,9 +15,9 @@ class WebGLPageCacheModule extends BaseModule { this.db = null; this.cacheStatus = 'uninitialized'; this.currentCacheSize = 0; - this.maxCacheSizeBytes = 180 * 1024 * 1024; + this.maxCacheSizeBytes = 5 * 1024 * 1024 * 1024; this.memoryCanvasCache = new Map(); - this.maxMemoryCanvasCount = 12; + this.maxMemoryCanvasCount = 256; this.bindMethods([ 'initialize', @@ -27,6 +27,7 @@ class WebGLPageCacheModule extends BaseModule { 'makePageKey', 'canvasToBlob', 'blobToCanvas', + 'isOlderPageEntry', 'manageCacheSize', 'calculateTotalCacheSize', 'deleteEntry', @@ -45,7 +46,7 @@ class WebGLPageCacheModule extends BaseModule { this.reportProgress(100, 'WebGL page texture cache ready'); return true; } catch (error) { - console.warn('WebGLPageCache: IndexedDB unavailable, continuing without persistent page cache', error); + console.error('WebGLPageCache: IndexedDB unavailable; persistent page caching is in a problem state', error); this.cacheStatus = 'error'; this.reportProgress(100, 'WebGL page texture cache unavailable'); return true; @@ -100,7 +101,6 @@ class WebGLPageCacheModule extends BaseModule { height: canvas.height, cacheKey: pageMeta.cacheKey }); - if (this.memoryCanvasCache.has(key)) return true; try { const blob = await this.canvasToBlob(canvas); if (!blob) return false; @@ -109,6 +109,7 @@ class WebGLPageCacheModule extends BaseModule { request.onsuccess = () => resolve(request.result || null); request.onerror = () => reject(request.error); }); + if (this.isOlderPageEntry(pageMeta, oldEntry)) return true; await this.manageCacheSize(blob.size); await new Promise((resolve, reject) => { const request = this.tx('readwrite').put({ @@ -116,6 +117,10 @@ class WebGLPageCacheModule extends BaseModule { pageIndex, width: canvas.width, height: canvas.height, + contentVersion: Math.max(0, Number(pageMeta.contentVersion || 0)), + completenessScore: Math.max(0, Number(pageMeta.completenessScore || 0)), + maxBlockId: Math.max(0, Number(pageMeta.maxBlockId || 0)), + lineCount: Math.max(0, Number(pageMeta.lineCount || 0)), blob, size: blob.size, lastAccessed: Date.now() @@ -161,6 +166,13 @@ class WebGLPageCacheModule extends BaseModule { }); if (!entry?.blob) return null; const canvas = await this.blobToCanvas(entry.blob, entry.width, entry.height); + if (canvas) canvas.__webglPageCacheMeta = { + pageIndex: entry.pageIndex, + contentVersion: entry.contentVersion, + completenessScore: entry.completenessScore, + maxBlockId: entry.maxBlockId, + lineCount: entry.lineCount + }; if (canvas) this.rememberCanvas(key, canvas); return canvas; } catch (error) { @@ -169,6 +181,17 @@ class WebGLPageCacheModule extends BaseModule { } } + isOlderPageEntry(pageMeta = {}, oldEntry = null) { + if (!oldEntry) return false; + const incomingCompleteness = Math.max(0, Number(pageMeta.completenessScore || 0)); + const existingCompleteness = Math.max(0, Number(oldEntry.completenessScore || 0)); + if (incomingCompleteness < existingCompleteness) return true; + if (incomingCompleteness > existingCompleteness) return false; + const incomingVersion = Math.max(0, Number(pageMeta.contentVersion || 0)); + const existingVersion = Math.max(0, Number(oldEntry.contentVersion || 0)); + return incomingVersion > 0 && existingVersion > incomingVersion; + } + 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 4da2343..1ceb91d 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -132,18 +132,24 @@ const checks = [ ['texture renderer diagnostics include reveal word counts', /wordCounts/.test(textureRendererSource) && /revealWords/.test(textureRendererSource) && /wordRects/.test(textureRendererSource)], ['texture renderer does not draw immediate non-pending sides during pending reveal preparation', !/immediateSides/.test(textureRendererSource) && !/this\.drawSpread\(this\.currentSpread, immediateSides\)/.test(textureRendererSource)], ['sentence queue consumes completed prepared lookahead items', /preparedSentenceCache/.test(sentenceQueueSource) && /this\.preparedSentenceCache\.get\(cacheKey\)/.test(sentenceQueueSource) && /return prefetched/.test(sentenceQueueSource)], - ['sentence queue front-loads 3D book presentation before playback callback', /const sentence = await this\.getPreparedSentence\(item\);[\s\S]*await this\.prefetchWebGLBookPresentation\(sentence[\s\S]*this\.prefetchAhead\(4, queueGeneration\);[\s\S]*this\.onSentenceReadyCallback/.test(sentenceQueueSource)], + ['sentence queue front-loads 3D book presentation before playback callback', /const sentence = await this\.getPreparedSentence\(item\);[\s\S]*await this\.prefetchWebGLBookPresentation\(sentence[\s\S]*immediate: true[\s\S]*this\.prefetchAhead\(6, queueGeneration\);[\s\S]*this\.onSentenceReadyCallback/.test(sentenceQueueSource)], ['sentence queue prefetch prepares whole future sentence instead of speech metadata only', /this\.prepareSentence\(nextItem, \{\s*blocking: false/.test(sentenceQueueSource) && /this\.prefetchWebGLBookPresentation\(prepared/.test(sentenceQueueSource)], - ['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(4, this\.queueGeneration\);/.test(sentenceQueueSource)], + ['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(6, this\.queueGeneration\);/.test(sentenceQueueSource)], + ['sentence queue keeps current 3D page prep immediate while future lookahead yields cooperatively', /if \(!options\.immediate\) \{[\s\S]*requestIdleCallback[\s\S]*timeout: 80/.test(sentenceQueueSource) && /prefetchAhead\(maxLookahead = 6/.test(sentenceQueueSource)], ['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)], ['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 without blocking publish', /webgl-page-cache/.test(textureRendererSource) && /cachePublishedPages/.test(textureRendererSource) && /schedulePageCacheWrite/.test(textureRendererSource) && /const frozenCanvas = this\.cloneCanvas\(canvas\)/.test(textureRendererSource) && /requestIdleCallback/.test(textureRendererSource) && /cachePageCanvas/.test(textureRendererSource)], - ['webgl lab prewarms cached page textures into bounded vram before flips', /residentPageTextures/.test(source) && /maxResidentPageTextures/.test(source) && /preloadCachedPageTexture/.test(source) && /prewarmFlipTextures/.test(source) && /await prewarmFlipTextures\(direction, targetSpread\)/.test(source) && /getResidentPageTexture\(targetBackPageIndex\)/.test(source)], - ['webgl lab reuses resident cached page textures for direct stack switches', /uploadPageTextureDirect\(side, sourceCanvas, pageMeta = null\)/.test(source) && /getResidentPageTexture\(pageMeta\.pageIndex\)/.test(source) && /usedResidentTexture/.test(source) && /uploadPageTextureDirect\('left', detail\.left, currentPageMeta\.left\)/.test(source) && /uploadPageTextureDirect\('right', detail\.right, currentPageMeta\.right\)/.test(source)], + ['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 in vram immediately', /rememberResidentPageTexture/.test(source) && /if \(detail\.preloadOnly\) \{[\s\S]*rememberResidentPageTexture\(currentPageMeta\.left, texture, detail\.left\)[\s\S]*rememberResidentPageTexture\(currentPageMeta\.right, 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)], ['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)], ['webgl debug test hook awaits the same async page flip path', /startPageFlipForTest\(direction, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)], ['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)], @@ -167,10 +173,21 @@ const checks = [ ['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)], ['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 page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.currentSpread\?\.pageMeta/.test(textureRendererSource)], + ['texture renderer draws title page and page numbers from versioned page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)], ['texture renderer uses plural page margin metrics for page numbers', /this\.metrics\.margins\.bottom/.test(textureRendererSource) && !/this\.metrics\.margin\.bottom/.test(textureRendererSource)], ['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 backTexture = getResidentPageTexture\(targetBackPageIndex\) \|\| 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)], + ['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)], ['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 back face uses its own unflipped page orientation', /bottomRow\.push\(push\(point, 0, u, 1 - v\)\)/.test(source)], + ['webgl scene targets 60fps with browser-frame scheduling and live mirror refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /this\.targetFrameDurationMs = 1000 \/ 60/.test(textureRendererSource) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = true/.test(source) && !/setTimeout\(animate/.test(source)], + ['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesEveryFrame: true/.test(source)], + ['book pagination reloads to the continuation block spread when unrendered history exists', /getContinuationBlockId/.test(bookPaginationSource) && /const continuationBlockId = this\.getContinuationBlockId\(latestBlockId, latestRenderedBlockId\)/.test(bookPaginationSource) && /const continuationSpreadIndex = this\.findSpreadIndexForBlock\(continuationBlockId\)/.test(bookPaginationSource) && /rendered < latest \? rendered \+ 1 : latest/.test(bookPaginationSource)], ['webgl page navigation is page-count based with explicit spread mapping', /function pageToSpreadIndex/.test(source) && /Math\.floor\(page \/ 2\) \+ 1/.test(source) && /function spreadIndexToPagePosition/.test(source) && /\(spread - 1\) \* 2/.test(source)], ['webgl reading progress sync does not rebuild pagination as a page-count change', /function syncReadingProgressToCurrentPage/.test(source) && !/notifyBookPageCountChanged/.test(methodBody(source, 'syncReadingProgressToCurrentPage'))], ['webgl page reserve grows book size without shrinking', /function growBookIfWritableLimitReached/.test(source) && /bookPageCount < PROCEDURAL_BOOK\.PAGE_COUNT_MAX/.test(source) && /snapProceduralPageCount\(bookPageCount \+ PROCEDURAL_BOOK\.PAGE_COUNT_STEP\)/.test(source) && /bookPageCount = Math\.max\(nextPageCount, bookPageCount\)/.test(source)], @@ -179,7 +196,7 @@ 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 flip without bypassing choices', /handleRevealCommittedForPageFlip/.test(source) && /isRightBodyPageComplete/.test(source) && /isChoiceAwaitingPlayer/.test(source) && /pendingRightPageFlip/.test(source)], + ['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)], ['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)] ]; diff --git a/scripts/check-webgl-book-runtime.js b/scripts/check-webgl-book-runtime.js index 2664b8a..5a898e9 100644 --- a/scripts/check-webgl-book-runtime.js +++ b/scripts/check-webgl-book-runtime.js @@ -27,6 +27,7 @@ async function main() { const minLabel = document.getElementById('webgl_book_nav_min_label'); const maxLabel = document.getElementById('webgl_book_nav_max_label'); const textureInfo = window.BookLabDebug.getTextureInfo(); + const runtimeInvariants = window.BookLabDebug.getRuntimeInvariants?.() || {}; const initialBookState = window.BookLabDebug.getBookState(); const initialSliderMax = slider?.max || null; const initialMinLabel = minLabel?.textContent || ''; @@ -56,6 +57,12 @@ async function main() { spreadCount: 8, writtenPageLimit: 10 }); + const initialNavigationDisabled = { + topBackward: Boolean(document.getElementById('flip_backward')?.disabled), + topFastBackward: Boolean(document.getElementById('fast_flip_backward')?.disabled), + bottomStart: Boolean(document.getElementById('webgl_book_nav_start')?.disabled), + bottomBack: Boolean(document.getElementById('webgl_book_nav_back')?.disabled) + }; slider.value = '100'; slider.dispatchEvent(new Event('input', { bubbles: true })); await new Promise(resolve => { @@ -158,9 +165,21 @@ async function main() { new Promise(resolve => window.setTimeout(() => resolve(false), 5000)) ]); const postTargetFlipState = window.BookLabDebug.getBookState(); + window.BookLabDebug.setPaginationStateForTest({ + spreadIndex: 5, + spreadCount: 8, + writtenPageLimit: 10 + }); + const endNavigationDisabled = { + topForward: Boolean(document.getElementById('flip_forward')?.disabled), + topFastForward: Boolean(document.getElementById('fast_flip_forward')?.disabled), + bottomForward: Boolean(document.getElementById('webgl_book_nav_forward')?.disabled), + bottomEnd: Boolean(document.getElementById('webgl_book_nav_end')?.disabled) + }; return { navExists: Boolean(nav), + runtimeInvariants, initialSliderMax, initialMinLabel, initialMaxLabel, @@ -176,6 +195,7 @@ async function main() { height: cacheProbeResult?.height || 0 }, grownBookState, + initialNavigationDisabled, clampedSliderValue, percentReserveState, overlayLayout, @@ -186,6 +206,7 @@ async function main() { targetFlipFinished, targetFlipEventDetail, postTargetFlipState, + endNavigationDisabled, textureInfo }; }); @@ -202,6 +223,11 @@ async function main() { if (result.initialBookState?.pageCount !== 300) failures.push(`expected initial pageCount 300, got ${result.initialBookState?.pageCount}`); if (result.initialBookState?.pageReserve !== 50) failures.push(`expected initial pageReserve 50, got ${result.initialBookState?.pageReserve}`); if (result.initialBookState?.progress !== 0) failures.push(`expected initial progress 0, got ${result.initialBookState?.progress}`); + if (Math.abs(Number(result.runtimeInvariants?.targetFrameDurationMs || 0) - (1000 / 60)) > 0.001) { + failures.push(`expected 60fps target frame duration, got ${result.runtimeInvariants?.targetFrameDurationMs}`); + } + if (result.runtimeInvariants?.flipFrontBackShareMaterial) failures.push('flip front/back materials are shared instead of independently switchable'); + if (!result.runtimeInvariants?.mirrorRefreshesEveryFrame) failures.push('mirror reflection is not marked for per-frame refresh'); if (JSON.stringify(result.pageSpreadMap) !== JSON.stringify([[0, 0], [1, 1], [2, 2], [3, 2], [4, 3], [5, 3]])) { failures.push(`unexpected page-to-spread map ${JSON.stringify(result.pageSpreadMap)}`); } @@ -213,6 +239,9 @@ async function main() { failures.push(`WebGL page cache probe failed: ${JSON.stringify(result.pageCacheProbe)}`); } if (result.grownBookState?.pageCount !== 310) failures.push(`expected page count to grow to 310 at writable limit, got ${result.grownBookState?.pageCount}`); + if (!result.initialNavigationDisabled?.topBackward || !result.initialNavigationDisabled?.topFastBackward || !result.initialNavigationDisabled?.bottomStart || !result.initialNavigationDisabled?.bottomBack) { + failures.push(`backward navigation should be disabled at first page: ${JSON.stringify(result.initialNavigationDisabled)}`); + } if (result.finalSliderMax !== '310') failures.push(`expected final slider max 310, got ${result.finalSliderMax}`); if (result.finalMaxLabel !== '310') failures.push(`expected final max label 310, got ${result.finalMaxLabel}`); if (result.clampedSliderValue !== '10') failures.push(`expected slider clamp to written page 10, got ${result.clampedSliderValue}`); @@ -233,6 +262,9 @@ async function main() { eventDetail: result.targetFlipEventDetail })}`); if (result.postTargetFlipState?.spreadIndex !== 2) failures.push(`targeted page flip should commit spread 2, got ${result.postTargetFlipState?.spreadIndex}`); + if (!result.endNavigationDisabled?.topForward || !result.endNavigationDisabled?.topFastForward || !result.endNavigationDisabled?.bottomForward || !result.endNavigationDisabled?.bottomEnd) { + failures.push(`forward navigation should be disabled at written end: ${JSON.stringify(result.endNavigationDisabled)}`); + } if (!result.textureInfo?.debug?.left?.painted || !result.textureInfo?.debug?.right?.painted) failures.push('page texture publish did not paint both pages'); if (failures.length) {