From a73dc5725f8414c9d636ed8c422c334010cc221a Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Mon, 8 Jun 2026 14:39:42 +0200 Subject: [PATCH] Add WebGL page cache and runtime checks --- package.json | 1 + public/css/style.css | 60 ++++- public/js/book-texture-renderer-module.js | 31 ++- public/js/loader.js | 1 + public/js/sentence-queue-module.js | 28 ++- public/js/ui-display-handler-module.js | 45 +++- public/js/webgl-book-lab.js | 206 +++++++++++++++-- public/js/webgl-book-scene-module.js | 5 +- public/js/webgl-page-cache-module.js | 264 ++++++++++++++++++++++ scripts/check-webgl-book-lab.js | 32 ++- scripts/check-webgl-book-runtime.js | 250 ++++++++++++++++++++ 11 files changed, 891 insertions(+), 32 deletions(-) create mode 100644 public/js/webgl-page-cache-module.js create mode 100644 scripts/check-webgl-book-runtime.js diff --git a/package.json b/package.json index 1936ad6..b569193 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "build": "tsc", "generate:webgl-assets": "python scripts/generate-webgl-table-assets.py", "check:webgl-lab": "node scripts/check-webgl-book-lab.js", + "check:webgl-runtime": "node scripts/check-webgl-book-runtime.js", "test": "jest", "lint": "eslint --ext .ts src/", "lint:fix": "eslint --ext .ts src/ --fix" diff --git a/public/css/style.css b/public/css/style.css index 9704a8e..942a4fd 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1938,17 +1938,40 @@ body.webgl-mode { body.webgl-mode #choices, body.webgl-mode .story-choices { - color: rgba(246, 231, 201, 0.92); + color: rgba(236, 218, 183, 0.9); scrollbar-color: rgba(246, 231, 201, 0.54) rgba(255, 236, 190, 0.08); + max-width: none; + overflow-x: hidden; +} + +body.webgl-mode #page_left #game_title, +body.webgl-mode #page_left #game_author, +body.webgl-mode #page_left #game_subtitle, +body.webgl-mode #page_left #start_prompt, +body.webgl-mode #page_left .separator, +body.webgl-mode #page_left .ornament, +body.webgl-mode #page_left #game_legal_text, +body.webgl-mode #game_title, +body.webgl-mode #game_author, +body.webgl-mode #game_subtitle, +body.webgl-mode #start_prompt { + display: none !important; +} + +body.webgl-mode #choices, +body.webgl-mode .choices-group, +body.webgl-mode .choice-list, +body.webgl-mode .choice-list-item { + color: rgba(222, 202, 166, 0.86); } body.webgl-mode #command_history .history-item { - color: rgba(246, 231, 201, 0.78); + color: rgba(222, 202, 166, 0.76); } body.webgl-mode #command_history .history-item:hover, body.webgl-mode #command_history .history-item.active { - color: rgba(255, 246, 220, 0.96); + color: rgba(246, 231, 201, 0.96); } body.webgl-mode .story-choices::-webkit-scrollbar-track { @@ -1960,18 +1983,27 @@ body.webgl-mode .story-choices::-webkit-scrollbar-thumb { } body.webgl-mode .choice-list .choice-button { - color: rgba(246, 231, 201, 0.82); + color: rgba(232, 214, 176, 0.88); +} + +body.webgl-mode .choices-group > .choice-button { + color: rgba(232, 214, 176, 0.88); + width: 100%; + max-width: 100%; + overflow-wrap: anywhere; } body.webgl-mode .choice-list .choice-button:hover, -body.webgl-mode .choice-list .choice-button:focus-visible { +body.webgl-mode .choice-list .choice-button:focus-visible, +body.webgl-mode .choices-group > .choice-button:hover, +body.webgl-mode .choices-group > .choice-button:focus-visible { color: rgba(255, 248, 225, 0.98); background: rgba(255, 236, 190, 0.12); outline-color: rgba(255, 236, 190, 0.48); } body.webgl-mode .choice-list kbd { - color: rgba(255, 248, 225, 0.96); + color: rgba(246, 231, 201, 0.92); } #webgl_app { @@ -2113,6 +2145,22 @@ body.webgl-mode .choice-list kbd { min-width: 0; } +.webgl-book-nav-slider-track { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-width: 0; +} + +.webgl-book-nav-limit-label { + min-width: 1.4rem; + font-size: 12px; + line-height: 1; + color: rgba(246, 231, 201, 0.68); + text-align: center; +} + #webgl_book_nav_position { width: 100%; accent-color: #f0cd8e; diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index ce06240..dbbdac9 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -7,10 +7,11 @@ import { BaseModule } from './base-module.js'; class BookTextureRendererModule extends BaseModule { constructor() { super('book-texture-renderer', 'Book Texture Renderer'); - this.dependencies = ['book-page-format', 'book-pagination', 'localization']; + this.dependencies = ['book-page-format', 'book-pagination', 'localization', 'webgl-page-cache']; this.pageFormat = null; this.pagination = null; this.localization = null; + this.pageCache = null; this.metrics = null; this.canvases = { left: null, @@ -70,6 +71,7 @@ class BookTextureRendererModule extends BaseModule { 'buildLineSegments', 'startRevealAnimation', 'prepareRevealBlock', + 'hasPreparedRevealBlock', 'createAnimationState', 'publishPreparedReveal', 'startPreparedRevealAnimation', @@ -81,6 +83,8 @@ class BookTextureRendererModule extends BaseModule { 'requestAnimationFrame', 'tickAnimations', 'publishSpread', + 'cachePublishedPages', + 'schedulePageCacheWrite', 'getPageCanvas', 'getHitMap', 'handlePageCountChanged' @@ -91,6 +95,7 @@ class BookTextureRendererModule extends BaseModule { this.pageFormat = this.getModule('book-page-format'); this.pagination = this.getModule('book-pagination'); this.localization = this.getModule('localization'); + this.pageCache = this.getModule('webgl-page-cache'); window.BookTextureRendererDebug = { pipelineTimings: this.pipelineTimings }; @@ -704,6 +709,11 @@ class BookTextureRendererModule extends BaseModule { }); } + hasPreparedRevealBlock(blockId) { + const id = String(blockId ?? ''); + return Boolean(id && this.preparedRevealCache.has(id)); + } + publishPreparedReveal(prepared) { if (!prepared) return; this.markPipelineTiming('publishPreparedReveal', { @@ -884,6 +894,7 @@ class BookTextureRendererModule extends BaseModule { }; }); if (Object.keys(reveal).length) detail.reveal = reveal; + this.cachePublishedPages(sidesToPublish, detail); this.markPipelineTiming('publishSpread', { sides: sidesToPublish, hasReveal: Object.keys(reveal).length > 0, @@ -896,6 +907,24 @@ class BookTextureRendererModule extends BaseModule { return detail; } + cachePublishedPages(sides = [], detail = {}) { + if (!this.pageCache || typeof this.pageCache.cachePageCanvas !== '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); + }); + } + + 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 }); + } + getPageCanvas(side) { return this.canvases[side] || null; } diff --git a/public/js/loader.js b/public/js/loader.js index e22297e..d14e415 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -114,6 +114,7 @@ const ModuleLoader = (function() { { id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 }, { id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module { id: 'book-page-format', script: '/js/book-page-format-module.js', weight: 4 }, + { id: 'webgl-page-cache', script: '/js/webgl-page-cache-module.js', weight: 5 }, { id: 'book-pagination', script: '/js/book-pagination-module.js', weight: 8 }, { id: 'book-texture-renderer', script: '/js/book-texture-renderer-module.js', weight: 6 }, { id: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 }, diff --git a/public/js/sentence-queue-module.js b/public/js/sentence-queue-module.js index 3559370..fa4f5b9 100644 --- a/public/js/sentence-queue-module.js +++ b/public/js/sentence-queue-module.js @@ -46,6 +46,7 @@ class SentenceQueueModule extends BaseModule { 'getPreparedSentence', 'prefetchAhead', 'prefetchWebGLBookPresentation', + 'isWebGLBookPresentationPrepared', 'prepareSpeechMetadata', 'preloadAssetsForItem', 'normalizeTtsText', @@ -200,10 +201,12 @@ class SentenceQueueModule extends BaseModule { const sentence = await this.getPreparedSentence(item); if (!this.isCurrentQueueItem(item, queueGeneration)) return; - await this.prefetchWebGLBookPresentation(sentence, { - queueGeneration, - queueIndex: 0 - }); + if (!this.isWebGLBookPresentationPrepared(sentence)) { + await this.prefetchWebGLBookPresentation(sentence, { + queueGeneration, + queueIndex: 0 + }); + } if (!this.isCurrentQueueItem(item, queueGeneration)) return; // Prefetch far enough ahead that media pauses do not block TTS @@ -898,6 +901,10 @@ class SentenceQueueModule extends BaseModule { const bookTextureRenderer = this.getModule('book-texture-renderer'); if (!bookPagination || !bookTextureRenderer) return null; + if (this.isWebGLBookPresentationPrepared(sentence)) { + return sentence.webglBookPresentation?.spread || null; + } + if (!Array.isArray(sentence.animation?.wordTimings) || sentence.animation.wordTimings.length === 0) { const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || []; sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []); @@ -929,10 +936,23 @@ class SentenceQueueModule extends BaseModule { spread, preloadOnly: true }, { preloadOnly: true }); + sentence.webglBookPresentation = { + prepared: true, + blockId, + spread + }; } return spread; } + isWebGLBookPresentationPrepared(sentence) { + const blockId = sentence?.blockId ?? sentence?.metadata?.blockId ?? null; + if (blockId == null) return false; + if (sentence?.webglBookPresentation?.prepared === true) return true; + const bookTextureRenderer = this.getModule('book-texture-renderer'); + return Boolean(bookTextureRenderer?.hasPreparedRevealBlock?.(blockId)); + } + isCurrentQueueItem(item, queueGeneration = this.queueGeneration) { return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item; } diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index ecd4a9d..f03a6b2 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -70,6 +70,7 @@ class UIDisplayHandlerModule extends BaseModule { 'renderSentence', 'isWebGLMode', 'prepareWebGLBookReveal', + 'waitForWebGLPageFlip', 'renderStoryBlock', 'prepareRenderableBlock', 'prepareTextRenderable', @@ -1054,8 +1055,22 @@ class UIDisplayHandlerModule extends BaseModule { || { wordTimings: [], cueTimings: [], totalDuration: 0 }; } + let preparedSpread = null; if (typeof bookPagination.preparePendingBlock === 'function') { - await bookPagination.preparePendingBlock(sentence); + const currentSpreadIndex = Math.max(0, Number(bookPagination.currentSpreadIndex || 0)); + const previewSpread = sentence.webglBookPresentation?.spread || await bookPagination.preparePendingBlock(sentence, { + activate: false, + publish: false, + includeUnrenderedHistory: true + }); + if (Number(previewSpread?.index || 0) > currentSpreadIndex) { + await this.waitForWebGLPageFlip({ + direction: 1, + reason: 'pending-block-overflow', + targetSpread: previewSpread.index + }); + } + preparedSpread = await bookPagination.preparePendingBlock(sentence); } else { document.dispatchEvent(new CustomEvent('book-pagination:prepare-block', { detail: { @@ -1069,7 +1084,8 @@ class UIDisplayHandlerModule extends BaseModule { blockId: sentence.blockId, wordTimings: sentence.animation?.wordTimings || [], cueTimings: sentence.animation?.cueTimings || [], - totalDuration: sentence.animation?.totalDuration || 0 + totalDuration: sentence.animation?.totalDuration || 0, + spread: preparedSpread }; if (typeof bookTextureRenderer.prepareRevealBlock === 'function') { bookTextureRenderer.prepareRevealBlock(revealDetail); @@ -1080,6 +1096,31 @@ class UIDisplayHandlerModule extends BaseModule { } } + waitForWebGLPageFlip(detail = {}) { + return new Promise((resolve) => { + let resolved = false; + const finish = () => { + if (resolved) return; + resolved = true; + window.clearTimeout(timeout); + document.removeEventListener('webgl-book:page-flip-finished', finish); + resolve(true); + }; + const timeout = window.setTimeout(finish, 1400); + document.addEventListener('webgl-book:page-flip-finished', finish, { once: true }); + document.dispatchEvent(new CustomEvent('webgl-book:request-page-flip', { + detail: { + direction: Math.sign(Number(detail.direction || 1)) || 1, + reason: detail.reason || 'pending-block-overflow', + force: true, + targetSpread: Number.isFinite(Number(detail.targetSpread)) + ? Math.max(0, Math.round(Number(detail.targetSpread))) + : null + } + })); + }); + } + async rerenderStory() { if (!this.paragraphContainer || this.renderedItems.length === 0) return; console.log('UIDisplayHandler: Re-typesetting story after page resize'); diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index db9eff1..08e6a0f 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -252,6 +252,8 @@ const preparedPageTextures = { left: new Map(), right: new Map() }; +const residentPageTextures = new Map(); +const maxResidentPageTextures = 18; let blankPageTexture = null; let currentPageMeta = { left: null, @@ -529,9 +531,36 @@ window.BookLabDebug = { writtenPageLimit: bookPaginationState.writtenPageLimit }; }, + setPaginationStateForTest(state = {}) { + bookPaginationState = { + spreadIndex: Math.max(0, Number(state.spreadIndex ?? bookPaginationState.spreadIndex ?? 0)), + spreadCount: Math.max(1, Number(state.spreadCount ?? bookPaginationState.spreadCount ?? 1)), + writtenPageLimit: Math.max(0, Number(state.writtenPageLimit ?? bookPaginationState.writtenPageLimit ?? 0)) + }; + growBookIfWritableLimitReached(); + syncBookControls(); + return this.getBookState(); + }, navigateToPagePosition(value) { return navigateToPagePosition(value); }, + startPageFlipForTest(direction, options = {}) { + return startPageFlip(direction, options); + }, + advancePageFlipForTest(elapsedMs = normalFlipDuration + 16) { + if (!activeFlips.length) return this.getBookState(); + const targetNow = activeFlips.reduce((maxTime, flip) => { + return Math.max(maxTime, flip.startTime + Math.max(0, Number(elapsedMs || 0))); + }, performance.now()); + updateActiveFlips(targetNow); + return this.getBookState(); + }, + mapPageToSpread(value) { + return pageToSpreadIndex(value); + }, + mapSpreadToPage(value) { + return spreadIndexToPagePosition(value); + }, redrawPageTextures() { window.BookTextureRenderer?.publishSpread?.(); return true; @@ -592,6 +621,17 @@ document.addEventListener('webgl-book:page-reserve-directive', (event) => { : Math.round(value); setPageReserve(nextReserve); }); +document.addEventListener('webgl-book:request-page-flip', (event) => { + const detail = event.detail || {}; + const direction = Math.sign(Number(detail.direction || 1)) || 1; + const targetSpread = Number.isFinite(Number(detail.targetSpread)) + ? Math.max(0, Math.round(Number(detail.targetSpread))) + : null; + startPageFlip(direction, { + force: detail.force === true, + targetSpread + }); +}); document.addEventListener('ui:command', (event) => { if (event.detail?.type === 'continue' && pendingRightPageFlip) { pendingRightPageFlip = false; @@ -1707,12 +1747,14 @@ function clampPageReserve(value, pageCount = bookPageCount) { function pageToSpreadIndex(pagePosition) { const page = Math.max(0, Math.round(Number(pagePosition || 0))); - return page <= 0 ? 0 : Math.ceil(page / 2); + return page <= 0 ? 0 : Math.floor(page / 2) + 1; } function spreadIndexToPagePosition(spreadIndex) { const spread = Math.max(0, Math.round(Number(spreadIndex || 0))); - return spread <= 0 ? 0 : Math.max(1, spread * 2 - 1); + if (spread <= 0) return 0; + if (spread === 1) return 1; + return (spread - 1) * 2; } function getWritablePageLimit() { @@ -1728,7 +1770,6 @@ function syncReadingProgressToCurrentPage() { if (Math.abs(nextProgress - readingProgress) < 0.0001) return; readingProgress = nextProgress; buildBook(); - notifyBookPageCountChanged(); window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress); } @@ -1816,17 +1857,30 @@ function ensureBottomNavigation() { const backButton = makeButton('webgl_book_nav_back', appInitialState.t?.('webgl.backward') || 'Backward', '◀'); const sliderWrap = document.createElement('div'); sliderWrap.className = 'webgl-book-nav-slider-wrap'; + const minLabel = document.createElement('span'); + minLabel.id = 'webgl_book_nav_min_label'; + minLabel.className = 'webgl-book-nav-limit-label'; + minLabel.textContent = '0'; + const sliderTrack = document.createElement('div'); + sliderTrack.className = 'webgl-book-nav-slider-track'; const pageLabel = document.createElement('output'); pageLabel.id = 'webgl_book_nav_page_label'; pageLabel.className = 'webgl-book-nav-page-label'; pageLabel.textContent = '0'; + const maxLabel = document.createElement('span'); + maxLabel.id = 'webgl_book_nav_max_label'; + maxLabel.className = 'webgl-book-nav-limit-label'; + maxLabel.textContent = String(bookPageCount); const slider = document.createElement('input'); slider.id = 'webgl_book_nav_position'; slider.type = 'range'; slider.min = '0'; slider.step = '1'; slider.value = '0'; - sliderWrap.appendChild(slider); + sliderTrack.appendChild(minLabel); + sliderTrack.appendChild(slider); + sliderTrack.appendChild(maxLabel); + sliderWrap.appendChild(sliderTrack); sliderWrap.appendChild(pageLabel); root.appendChild(sliderWrap); const forwardButton = makeButton('webgl_book_nav_forward', appInitialState.t?.('webgl.forward') || 'Forward', '▶'); @@ -1850,6 +1904,8 @@ function ensureBottomNavigation() { startButton, backButton, slider, + minLabel, + maxLabel, pageLabel, forwardButton, endButton @@ -1903,6 +1959,8 @@ function syncBottomNavigation() { const reservedStart = Math.max(0, writableLimit); bottomNavigation.slider.max = String(Math.max(0, bookPageCount)); bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit)); + bottomNavigation.minLabel.textContent = '0'; + bottomNavigation.maxLabel.textContent = String(bookPageCount); bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${Math.min(currentPage, navigableLimit)}`; bottomNavigation.root.style.setProperty('--book-nav-position', `${bookPageCount > 0 ? currentPage / bookPageCount : 0}`); bottomNavigation.root.style.setProperty('--book-nav-written', `${bookPageCount > 0 ? writtenLimit / bookPageCount : 0}`); @@ -1940,14 +1998,14 @@ function handlePageCanvases(event) { if (detail.reveal?.left) { beginPageReveal('left', detail.left, detail.reveal.left); } else { - uploadPageTextureDirect('left', detail.left); + uploadPageTextureDirect('left', detail.left, currentPageMeta.left); } } if (detail.right) { if (detail.reveal?.right) { beginPageReveal('right', detail.right, detail.reveal.right); } else { - uploadPageTextureDirect('right', detail.right); + uploadPageTextureDirect('right', detail.right, currentPageMeta.right); } } markStaticSceneBuffersDirty(); @@ -1994,6 +2052,75 @@ function preloadPageTexture(side, sourceCanvas, revealDetail = {}) { return texture; } +function makePageMetaForCache(pageIndex) { + return { + pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))), + width: pageTextureWidth, + height: leftCanvas?.height || Math.round(pageTextureWidth * PROCEDURAL_BOOK.PAGE_DEPTH / PROCEDURAL_BOOK.PAGE_WIDTH) + }; +} + +function spreadPageIndices(spreadIndex) { + const spread = Math.max(0, Math.round(Number(spreadIndex || 0))); + return { + left: spread * 2, + right: spread * 2 + 1 + }; +} + +function getResidentPageTexture(pageIndex) { + const key = makePageMetaForCache(pageIndex).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; +} + +async function preloadCachedPageTexture(pageIndex) { + const meta = makePageMetaForCache(pageIndex); + if (residentPageTextures.has(meta.pageIndex)) { + getResidentPageTexture(meta.pageIndex); + return residentPageTextures.get(meta.pageIndex)?.texture || null; + } + const cache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache') || null; + const sourceCanvas = await cache?.getPageCanvas?.(meta); + if (!sourceCanvas) return null; + const texture = createPageCanvasTexture(sourceCanvas); + residentPageTextures.set(meta.pageIndex, { + texture, + sourceCanvas, + lastUsedAt: performance.now() + }); + while (residentPageTextures.size > maxResidentPageTextures) { + const oldestKey = residentPageTextures.keys().next().value; + const oldest = residentPageTextures.get(oldestKey); + oldest?.texture?.dispose?.(); + residentPageTextures.delete(oldestKey); + } + return texture; +} + +async function prewarmSpreadTextures(spreadIndex) { + const indices = spreadPageIndices(spreadIndex); + await Promise.all([ + preloadCachedPageTexture(indices.left), + preloadCachedPageTexture(indices.right) + ]); +} + +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))); + await Promise.all([ + prewarmSpreadTextures(currentSpread), + prewarmSpreadTextures(nextSpread) + ]); +} + function takePreparedPageTexture(side, revealDetail = {}) { const key = getRevealCacheKey(revealDetail); const prepared = preparedPageTextures[side].get(key); @@ -2003,11 +2130,26 @@ function takePreparedPageTexture(side, revealDetail = {}) { return prepared; } -function uploadPageTextureDirect(side, sourceCanvas) { +function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) { const texture = side === 'left' ? leftTexture : rightTexture; const material = side === 'left' ? materials.leftPage : materials.rightPage; - markPageTextureTiming('directUpload:start', { side }); + const residentTexture = Number.isFinite(Number(pageMeta?.pageIndex)) + ? getResidentPageTexture(pageMeta.pageIndex) + : null; + markPageTextureTiming('directUpload:start', { + side, + pageIndex: pageMeta?.pageIndex ?? null, + usedResidentTexture: Boolean(residentTexture) + }); clearPageReveal(side, 'direct-upload'); + if (residentTexture) { + if (material.map !== residentTexture) { + material.map = residentTexture; + material.needsUpdate = true; + } + markPageTextureTiming('directUpload:end', { side, usedResidentTexture: true }); + return; + } if (material.map !== texture) { material.map = texture; material.needsUpdate = true; @@ -2360,8 +2502,20 @@ function textureHitPageSide(hit) { return null; } -function startPageFlip(direction, options = {}) { - if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false; +async function startPageFlip(direction, options = {}) { + if (activeFlips.length || !currentProceduralBookModel) return false; + if (!options.force && !canPageFlip(direction)) return false; + const targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null; + await prewarmFlipTextures(direction, targetSpread); + return startPageFlipPrepared(direction, { + ...options, + targetSpread + }); +} + +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); @@ -2374,7 +2528,17 @@ function startPageFlip(direction, options = {}) { return true; } -function startFastPageFlip(direction, options = {}) { +async function startFastPageFlip(direction, options = {}) { + if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false; + const targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null; + await prewarmFlipTextures(direction, targetSpread); + return startFastPageFlipPrepared(direction, { + ...options, + targetSpread + }); +} + +function startFastPageFlipPrepared(direction, options = {}) { if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false; const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration); if (!firstFlip) return false; @@ -2424,7 +2588,12 @@ function prepareStaticPageForFlip(flip) { 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 backTexture = oppositeMaterial?.map || getBlankPageTexture(); + 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(); materials.flipPageSurface.map = sourceTexture; materials.flipPageBackSurface.map = backTexture; materials.flipPageSurface.normalMap = materials.pageSurface.normalMap; @@ -2733,15 +2902,20 @@ function createFlippingPageGeometry(surface) { function finishActiveFlip(flip) { removeFlipMesh(flip); activeFlips = activeFlips.filter((active) => active !== flip); + if (activeFlips.length === 0 && Number.isFinite(Number(flip.targetSpread))) { + bookPaginationState = { + ...bookPaginationState, + spreadIndex: Math.max(0, Math.round(Number(flip.targetSpread))) + }; + syncReadingProgressToCurrentPage(); + } document.dispatchEvent(new CustomEvent('webgl-book:page-flip-finished', { detail: { direction: flip.direction, - sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left') + sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left'), + targetSpread: Number.isFinite(Number(flip.targetSpread)) ? Math.max(0, Math.round(Number(flip.targetSpread))) : null } })); - if (activeFlips.length === 0 && Number.isFinite(Number(flip.targetSpread))) { - syncReadingProgressToCurrentPage(); - } if (flip.commitBundleOnFinish) { if (Number.isFinite(Number(flip.targetSpread))) { syncBookControls(); diff --git a/public/js/webgl-book-scene-module.js b/public/js/webgl-book-scene-module.js index 68843c3..ea1b8bf 100644 --- a/public/js/webgl-book-scene-module.js +++ b/public/js/webgl-book-scene-module.js @@ -224,7 +224,7 @@ class WebGLBookSceneModule extends BaseModule { book.style.position = 'fixed'; book.style.left = '1rem'; book.style.top = '1rem'; - book.style.width = 'min(31rem, calc(100vw - 2rem))'; + book.style.width = 'min(44rem, calc(100vw - 2rem))'; book.style.height = 'min(27rem, calc(100vh - 2rem))'; book.style.background = 'rgba(18, 11, 8, 0.62)'; book.style.border = '1px solid rgba(240, 205, 142, 0.28)'; @@ -273,7 +273,8 @@ class WebGLBookSceneModule extends BaseModule { width: 'auto', height: 'auto', padding: '1rem', - overflow: 'auto', + overflowY: 'auto', + overflowX: 'hidden', opacity: '1', mixBlendMode: 'normal', clipPath: 'none', diff --git a/public/js/webgl-page-cache-module.js b/public/js/webgl-page-cache-module.js new file mode 100644 index 0000000..46c04ee --- /dev/null +++ b/public/js/webgl-page-cache-module.js @@ -0,0 +1,264 @@ +/** + * WebGL Page Cache Module + * Persists fully typeset book page canvases in IndexedDB for fast VRAM prewarm. + */ +import { BaseModule } from './base-module.js'; + +class WebGLPageCacheModule extends BaseModule { + constructor() { + super('webgl-page-cache', 'WebGL Page Cache'); + + this.dependencies = []; + this.dbName = 'webglPageTextureCacheDB'; + this.dbVersion = 1; + this.storeName = 'webglPageTextureStore'; + this.db = null; + this.cacheStatus = 'uninitialized'; + this.currentCacheSize = 0; + this.maxCacheSizeBytes = 180 * 1024 * 1024; + this.memoryCanvasCache = new Map(); + this.maxMemoryCanvasCount = 12; + + this.bindMethods([ + 'initialize', + 'openDB', + 'cachePageCanvas', + 'getPageCanvas', + 'makePageKey', + 'canvasToBlob', + 'blobToCanvas', + 'manageCacheSize', + 'calculateTotalCacheSize', + 'deleteEntry', + 'rememberCanvas', + 'tx' + ]); + } + + async initialize() { + this.reportProgress(20, 'Opening WebGL page texture cache'); + try { + await this.openDB(); + this.reportProgress(70, 'Measuring WebGL page texture cache'); + this.currentCacheSize = await this.calculateTotalCacheSize(); + this.cacheStatus = 'ready'; + this.reportProgress(100, 'WebGL page texture cache ready'); + return true; + } catch (error) { + console.warn('WebGLPageCache: IndexedDB unavailable, continuing without persistent page cache', error); + this.cacheStatus = 'error'; + this.reportProgress(100, 'WebGL page texture cache unavailable'); + return true; + } + } + + openDB() { + if (this.db) return Promise.resolve(this.db); + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.dbVersion); + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error('WebGL page texture cache upgrade blocked')); + request.onsuccess = () => { + this.db = request.result; + this.db.onversionchange = () => { + this.db?.close?.(); + this.db = null; + this.cacheStatus = 'uninitialized'; + }; + resolve(this.db); + }; + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(this.storeName)) { + const pageStore = db.createObjectStore(this.storeName, { keyPath: 'key' }); + pageStore.createIndex('lastAccessed', 'lastAccessed', { unique: false }); + pageStore.createIndex('size', 'size', { unique: false }); + pageStore.createIndex('pageIndex', 'pageIndex', { unique: false }); + } + }; + }); + } + + tx(mode = 'readonly') { + return this.db.transaction([this.storeName], mode).objectStore(this.storeName); + } + + makePageKey({ pageIndex, width, height, cacheKey = window.MODULE_CACHE_BUSTER || 'dev' } = {}) { + const safePage = Math.max(0, Math.round(Number(pageIndex || 0))); + const safeWidth = Math.max(1, Math.round(Number(width || 0))); + const safeHeight = Math.max(1, Math.round(Number(height || 0))); + return `${cacheKey}:page:${safePage}:${safeWidth}x${safeHeight}`; + } + + async cachePageCanvas(pageMeta = {}, canvas = null) { + if (!canvas || !this.db || this.cacheStatus !== 'ready') return false; + const pageIndex = Number(pageMeta.pageIndex); + if (!Number.isFinite(pageIndex) || pageIndex < 0) return false; + const key = this.makePageKey({ + pageIndex, + width: canvas.width, + height: canvas.height, + cacheKey: pageMeta.cacheKey + }); + if (this.memoryCanvasCache.has(key)) return true; + try { + const blob = await this.canvasToBlob(canvas); + if (!blob) return false; + const oldEntry = await new Promise((resolve, reject) => { + const request = this.tx('readonly').get(key); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + }); + await this.manageCacheSize(blob.size); + await new Promise((resolve, reject) => { + const request = this.tx('readwrite').put({ + key, + pageIndex, + width: canvas.width, + height: canvas.height, + blob, + size: blob.size, + lastAccessed: Date.now() + }); + request.onsuccess = () => { + this.currentCacheSize += blob.size - Number(oldEntry?.size || 0); + this.rememberCanvas(key, canvas); + resolve(); + }; + request.onerror = () => reject(request.error); + }); + return true; + } catch (error) { + console.warn('WebGLPageCache: Failed to cache page canvas', { pageIndex, error }); + return false; + } + } + + async getPageCanvas(pageMeta = {}) { + if (!this.db || this.cacheStatus !== 'ready') return null; + const key = this.makePageKey(pageMeta); + const cachedCanvas = this.memoryCanvasCache.get(key); + if (cachedCanvas) { + this.memoryCanvasCache.delete(key); + this.memoryCanvasCache.set(key, cachedCanvas); + return cachedCanvas; + } + try { + const entry = await new Promise((resolve, reject) => { + const store = this.tx('readwrite'); + const request = store.get(key); + request.onsuccess = () => { + const result = request.result || null; + if (!result) { + resolve(null); + return; + } + result.lastAccessed = Date.now(); + store.put(result); + resolve(result); + }; + request.onerror = () => reject(request.error); + }); + if (!entry?.blob) return null; + const canvas = await this.blobToCanvas(entry.blob, entry.width, entry.height); + if (canvas) this.rememberCanvas(key, canvas); + return canvas; + } catch (error) { + console.warn('WebGLPageCache: Failed to read cached page canvas', error); + return null; + } + } + + canvasToBlob(canvas) { + return new Promise((resolve) => { + if (typeof canvas.toBlob !== 'function') { + resolve(null); + return; + } + canvas.toBlob(resolve, 'image/png'); + }); + } + + async blobToCanvas(blob, width, height) { + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(Number(width || 1))); + canvas.height = Math.max(1, Math.round(Number(height || 1))); + const context = canvas.getContext('2d'); + if (!context) return null; + const bitmap = await createImageBitmap(blob); + context.drawImage(bitmap, 0, 0); + bitmap.close?.(); + return canvas; + } + + rememberCanvas(key, canvas) { + this.memoryCanvasCache.set(key, canvas); + while (this.memoryCanvasCache.size > this.maxMemoryCanvasCount) { + const oldestKey = this.memoryCanvasCache.keys().next().value; + this.memoryCanvasCache.delete(oldestKey); + } + } + + async manageCacheSize(sizeToAdd = 0) { + if (!this.db || this.cacheStatus !== 'ready') return; + if (this.currentCacheSize + sizeToAdd <= this.maxCacheSizeBytes) return; + const entries = await new Promise((resolve, reject) => { + const results = []; + const request = this.tx('readonly').index('lastAccessed').openCursor(); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(results); + return; + } + results.push({ + key: cursor.value.key, + size: Number(cursor.value.size || 0) + }); + cursor.continue(); + }; + request.onerror = () => reject(request.error); + }); + for (const entry of entries) { + if (this.currentCacheSize + sizeToAdd <= this.maxCacheSizeBytes) break; + await this.deleteEntry(entry.key); + this.currentCacheSize = Math.max(0, this.currentCacheSize - entry.size); + } + } + + async calculateTotalCacheSize() { + if (!this.db) return 0; + return new Promise((resolve, reject) => { + let total = 0; + const request = this.tx('readonly').openCursor(); + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(total); + return; + } + total += Number(cursor.value.size || 0); + cursor.continue(); + }; + request.onerror = () => reject(request.error); + }); + } + + deleteEntry(key) { + return new Promise((resolve, reject) => { + const request = this.tx('readwrite').delete(key); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } +} + +const webglPageCache = new WebGLPageCacheModule(); + +export { webglPageCache as WebGLPageCache }; + +if (window.moduleRegistry) { + window.moduleRegistry.register(webglPageCache); +} + +window.WebGLPageCache = webglPageCache; diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index c699083..4da2343 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -27,6 +27,14 @@ const pageFormatPath = path.join(__dirname, '..', 'public', 'js', 'book-page-for const pageFormatSource = fs.readFileSync(pageFormatPath, 'utf8'); const stylePath = path.join(__dirname, '..', 'public', 'css', 'style.css'); const styleSource = fs.readFileSync(stylePath, 'utf8'); +const optionsUiPath = path.join(__dirname, '..', 'public', 'js', 'options-ui-module.js'); +const optionsUiSource = fs.readFileSync(optionsUiPath, 'utf8'); +const persistencePath = path.join(__dirname, '..', 'public', 'js', 'persistence-manager-module.js'); +const persistenceSource = fs.readFileSync(persistencePath, 'utf8'); +const webglPageCachePath = path.join(__dirname, '..', 'public', 'js', 'webgl-page-cache-module.js'); +const webglPageCacheSource = fs.readFileSync(webglPageCachePath, 'utf8'); +const ttsFactoryPath = path.join(__dirname, '..', 'public', 'js', 'tts-factory-module.js'); +const ttsFactorySource = fs.readFileSync(ttsFactoryPath, 'utf8'); function dependencyList(source, moduleId) { const classStart = source.indexOf(`super('${moduleId}'`); @@ -129,7 +137,18 @@ const checks = [ ['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(4, this\.queueGeneration\);/.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)], + ['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)], + ['webgl page cache preserves explicit cache keys across writes and reads', /cacheKey: pageMeta\.cacheKey/.test(webglPageCacheSource) && /makePageKey\(pageMeta\)/.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)], + ['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 page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)], @@ -138,6 +157,7 @@ const checks = [ ['webgl reveal words consume the allotted time until the next word', /nextTiming/.test(source) && /allottedDuration/.test(source) && /nextDelay - delay/.test(source)], ['webgl page format reduces only outer margins from previous value', /outerBaseIn: 0\.27/.test(pageFormatSource) && /outerThicknessFactor: 0\.015/.test(pageFormatSource) && /outerMaxIn: 0\.315/.test(pageFormatSource) && /innerBaseIn: 0\.42/.test(pageFormatSource)], ['webgl mode enlarges and inverts DOM overlay text without touching 2D mode', /body\.webgl-mode \{[\s\S]*font-size: 18px;/.test(styleSource) && /body\.webgl-mode \.choice-list \.choice-button/.test(styleSource) && /rgba\(246, 231, 201/.test(styleSource)], + ['webgl choice overlay hides title clutter and prevents horizontal scrollbar', /body\.webgl-mode #page_left #game_title/.test(styleSource) && /body\.webgl-mode #page_left #start_prompt/.test(styleSource) && /overflow-x: hidden/.test(styleSource) && /book\.style\.width = 'min\(44rem/.test(webglSceneSource)], ['drop-cap remaining text does not reinsert discretionary hyphen markers', /extractRemainingLayoutText/.test(bookPaginationSource) && !bookPaginationSource.includes("fragments.push('|')")], ['drop-cap reservation keeps a normal text gap beside the initial', /measureDropCapReservation/.test(bookPaginationSource) && /measureNormalTextGap\(fontPx\)/.test(bookPaginationSource)], ['drop-cap reservation uses both ink bounds and font advance width', /const advanceWidth = metrics\.width \|\| 0/.test(bookPaginationSource) && /Math\.max\(inkRight, advanceWidth, lineHeightPx \* 1\.08\)/.test(bookPaginationSource)], @@ -148,7 +168,17 @@ const checks = [ ['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 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 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 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)], + ['webgl bottom navigation shows media buttons and endpoint labels', /webgl_book_navigation/.test(source) && /webgl_book_nav_min_label/.test(source) && /webgl_book_nav_max_label/.test(source) && /webgl-book-nav-slider-track/.test(styleSource)], + ['webgl page reserve options replace old progress slider and hide fixed metadata values', /data-pref-bind': 'webgl\.pageReserve'/.test(optionsUiSource) && /hasFixedBookPageCount/.test(optionsUiSource) && /hasFixedPageReserve/.test(optionsUiSource) && !/data-pref-bind': 'webgl\.bookProgress'/.test(optionsUiSource)], + ['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)], ['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 new file mode 100644 index 0000000..2664b8a --- /dev/null +++ b/scripts/check-webgl-book-runtime.js @@ -0,0 +1,250 @@ +const { chromium } = require('playwright'); + +const targetUrl = process.env.WEBGL_RUNTIME_URL || 'http://localhost:3001/'; + +async function main() { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); + const errors = []; + + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + page.on('pageerror', (error) => errors.push(error.message)); + + await page.addInitScript(() => { + localStorage.removeItem('ai-interactive-fiction-preferences'); + }); + await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForFunction(() => window.BookTextureRenderer && window.BookLabDebug, null, { timeout: 180000 }); + + const result = await page.evaluate(async () => { + window.BookTextureRenderer.publishSpread(); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + const nav = document.getElementById('webgl_book_navigation'); + const slider = document.getElementById('webgl_book_nav_position'); + const minLabel = document.getElementById('webgl_book_nav_min_label'); + const maxLabel = document.getElementById('webgl_book_nav_max_label'); + const textureInfo = window.BookLabDebug.getTextureInfo(); + const initialBookState = window.BookLabDebug.getBookState(); + const initialSliderMax = slider?.max || null; + const initialMinLabel = minLabel?.textContent || ''; + const initialMaxLabel = maxLabel?.textContent || ''; + const pageSpreadMap = [0, 1, 2, 3, 4, 5].map(page => [page, window.BookLabDebug.mapPageToSpread(page)]); + const spreadPageMap = [0, 1, 2, 3].map(spread => [spread, window.BookLabDebug.mapSpreadToPage(spread)]); + const pageCache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache'); + const cacheProbeCanvas = document.createElement('canvas'); + cacheProbeCanvas.width = 8; + cacheProbeCanvas.height = 8; + const cacheProbeContext = cacheProbeCanvas.getContext('2d'); + cacheProbeContext.fillStyle = '#000'; + cacheProbeContext.fillRect(0, 0, 8, 8); + const cacheProbeMeta = { pageIndex: 9999, width: 8, height: 8, cacheKey: 'runtime-probe' }; + const cacheStoreResult = await pageCache?.cachePageCanvas?.(cacheProbeMeta, cacheProbeCanvas); + const cacheProbeResult = await pageCache?.getPageCanvas?.(cacheProbeMeta); + + window.BookLabDebug.setPaginationStateForTest({ + spreadIndex: 0, + spreadCount: 126, + writtenPageLimit: 250 + }); + const grownBookState = window.BookLabDebug.getBookState(); + + window.BookLabDebug.setPaginationStateForTest({ + spreadIndex: 0, + spreadCount: 8, + writtenPageLimit: 10 + }); + slider.value = '100'; + slider.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise(resolve => { + const startedAt = Date.now(); + const check = () => { + if ((window.BookLabDebug?.activeFlips || 0) === 0 || Date.now() - startedAt > 2200) { + resolve(); + return; + } + requestAnimationFrame(check); + }; + requestAnimationFrame(check); + }); + const clampedSliderValue = slider.value; + + document.dispatchEvent(new CustomEvent('webgl-book:page-reserve-directive', { + detail: { + value: 20, + unit: 'percent' + } + })); + const percentReserveState = window.BookLabDebug.getBookState(); + + document.body.classList.add('webgl-mode'); + if (!document.getElementById('page_left')) { + window.moduleRegistry?.getModule?.('ui-display-handler')?.initializeContainers?.(); + } + window.moduleRegistry?.getModule?.('webgl-book-scene')?.moveBookToControlOverlay?.(); + const pageLeft = document.getElementById('page_left'); + let choicesPanel = document.getElementById('choices'); + if (!choicesPanel && pageLeft) { + choicesPanel = document.createElement('div'); + choicesPanel.id = 'choices'; + choicesPanel.className = 'container'; + pageLeft.appendChild(choicesPanel); + } + const choicesGroup = document.createElement('div'); + choicesGroup.className = 'choices-group'; + const choiceButton = document.createElement('button'); + choiceButton.className = 'choice-button'; + choiceButton.textContent = 'A deliberately long choice label that must stay inside the WebGL overlay without creating horizontal scrolling'; + choicesGroup.appendChild(choiceButton); + choicesPanel?.appendChild(choicesGroup); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const gameTitle = document.getElementById('game_title'); + const startPrompt = document.getElementById('start_prompt'); + const titleDisplay = gameTitle ? window.getComputedStyle(gameTitle).display : 'absent'; + const startPromptDisplay = startPrompt ? window.getComputedStyle(startPrompt).display : 'absent'; + const pageLeftStyle = pageLeft ? window.getComputedStyle(pageLeft) : null; + const choicesStyle = choicesPanel ? window.getComputedStyle(choicesPanel) : null; + const buttonStyle = window.getComputedStyle(choiceButton); + const overlayLayout = { + pageLeftExists: Boolean(pageLeft), + choicesPanelExists: Boolean(choicesPanel), + pageLeftNoHorizontalScrollbar: pageLeft ? pageLeft.scrollWidth <= pageLeft.clientWidth + 1 : false, + choicesNoHorizontalScrollbar: choicesPanel ? choicesPanel.scrollWidth <= choicesPanel.clientWidth + 1 : false, + pageLeftOverflowX: pageLeftStyle?.overflowX || null, + choicesOverflowX: choicesStyle?.overflowX || null, + titleDisplay, + startPromptDisplay, + buttonColor: buttonStyle.color, + buttonBackground: buttonStyle.backgroundColor + }; + + window.BookLabDebug.setPaginationStateForTest({ + spreadIndex: 1, + spreadCount: 8, + writtenPageLimit: 10 + }); + if (window.BookPagination) { + window.BookPagination.spreads = Array.from({ length: 8 }, (_, index) => ({ + index, + left: [], + right: [], + pageMeta: {} + })); + window.BookPagination.currentSpreadIndex = 1; + } + let targetFlipEventDetail = null; + const flipFinished = new Promise(resolve => { + document.addEventListener('webgl-book:page-flip-finished', (event) => { + targetFlipEventDetail = event.detail || null; + resolve(true); + }, { once: true }); + }); + const requestedFlip = await window.BookLabDebug.startPageFlipForTest(1, { + force: true, + targetSpread: 2 + }); + const activeFlipsAfterRequest = window.BookLabDebug.activeFlips; + let postAdvanceState = null; + if (requestedFlip && window.BookLabDebug.activeFlips > 0) { + postAdvanceState = window.BookLabDebug.advancePageFlipForTest(); + } + const activeFlipsAfterAdvance = window.BookLabDebug.activeFlips; + const targetFlipFinished = targetFlipEventDetail + ? true + : await Promise.race([ + flipFinished, + new Promise(resolve => window.setTimeout(() => resolve(false), 5000)) + ]); + const postTargetFlipState = window.BookLabDebug.getBookState(); + + return { + navExists: Boolean(nav), + initialSliderMax, + initialMinLabel, + initialMaxLabel, + finalSliderMax: slider?.max || null, + finalMaxLabel: maxLabel?.textContent || '', + initialBookState, + pageSpreadMap, + spreadPageMap, + pageCacheReady: pageCache?.cacheStatus === 'ready', + pageCacheProbe: { + stored: cacheStoreResult === true, + width: cacheProbeResult?.width || 0, + height: cacheProbeResult?.height || 0 + }, + grownBookState, + clampedSliderValue, + percentReserveState, + overlayLayout, + requestedFlip, + activeFlipsAfterRequest, + activeFlipsAfterAdvance, + postAdvanceState, + targetFlipFinished, + targetFlipEventDetail, + postTargetFlipState, + textureInfo + }; + }); + + await browser.close(); + + const failures = []; + const relevantErrors = errors.filter((error) => !/^Failed to load resource: the server responded with a status of 400/.test(error)); + if (relevantErrors.length) failures.push(`browser errors: ${relevantErrors.join(' | ')}`); + if (!result.navExists) failures.push('bottom navigation missing'); + if (result.initialSliderMax !== '300') failures.push(`expected initial slider max 300, got ${result.initialSliderMax}`); + if (result.initialMinLabel !== '0') failures.push(`expected min label 0, got ${result.initialMinLabel}`); + if (result.initialMaxLabel !== '300') failures.push(`expected initial max label 300, got ${result.initialMaxLabel}`); + 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 (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)}`); + } + if (JSON.stringify(result.spreadPageMap) !== JSON.stringify([[0, 0], [1, 1], [2, 2], [3, 4]])) { + failures.push(`unexpected spread-to-page map ${JSON.stringify(result.spreadPageMap)}`); + } + if (!result.pageCacheReady) failures.push('WebGL page cache is not ready'); + if (!result.pageCacheProbe?.stored || result.pageCacheProbe?.width !== 8 || result.pageCacheProbe?.height !== 8) { + 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.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}`); + if (result.percentReserveState?.pageReserve !== 62) failures.push(`expected 20% reserve of 310 pages to be 62, got ${result.percentReserveState?.pageReserve}`); + if (!result.overlayLayout?.pageLeftNoHorizontalScrollbar) failures.push('WebGL overlay page_left has a horizontal scrollbar'); + if (!result.overlayLayout?.choicesNoHorizontalScrollbar) failures.push('WebGL choices panel has a horizontal scrollbar'); + if (result.overlayLayout?.pageLeftOverflowX !== 'hidden') failures.push(`expected page_left overflow-x hidden, got ${result.overlayLayout?.pageLeftOverflowX}`); + if (result.overlayLayout?.choicesOverflowX !== 'hidden') failures.push(`expected choices overflow-x hidden, got ${result.overlayLayout?.choicesOverflowX}`); + if (!['none', 'absent'].includes(result.overlayLayout?.titleDisplay)) failures.push(`expected title hidden in WebGL overlay, got ${result.overlayLayout?.titleDisplay}`); + if (!['none', 'absent'].includes(result.overlayLayout?.startPromptDisplay)) failures.push(`expected start prompt hidden in WebGL overlay, got ${result.overlayLayout?.startPromptDisplay}`); + if (/^rgb\(0,\s*0,\s*0\)$/.test(result.overlayLayout?.buttonColor || '')) failures.push('choice button text is still black in WebGL overlay'); + if (!result.requestedFlip) failures.push('targeted page flip request was rejected'); + if (!result.targetFlipFinished) failures.push(`targeted page flip did not finish: ${JSON.stringify({ + requestedFlip: result.requestedFlip, + activeFlipsAfterRequest: result.activeFlipsAfterRequest, + activeFlipsAfterAdvance: result.activeFlipsAfterAdvance, + postAdvanceState: result.postAdvanceState, + eventDetail: result.targetFlipEventDetail + })}`); + if (result.postTargetFlipState?.spreadIndex !== 2) failures.push(`targeted page flip should commit spread 2, got ${result.postTargetFlipState?.spreadIndex}`); + if (!result.textureInfo?.debug?.left?.painted || !result.textureInfo?.debug?.right?.painted) failures.push('page texture publish did not paint both pages'); + + if (failures.length) { + console.error('WebGL runtime regression checks failed:'); + failures.forEach(failure => console.error(`- ${failure}`)); + process.exit(1); + } + + console.log('WebGL runtime regression checks passed.'); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});