Add WebGL page cache and runtime checks

This commit is contained in:
2026-06-08 14:39:42 +02:00
parent 119cefd4bd
commit a73dc5725f
11 changed files with 891 additions and 32 deletions
+1
View File
@@ -42,6 +42,7 @@
"build": "tsc", "build": "tsc",
"generate:webgl-assets": "python scripts/generate-webgl-table-assets.py", "generate:webgl-assets": "python scripts/generate-webgl-table-assets.py",
"check:webgl-lab": "node scripts/check-webgl-book-lab.js", "check:webgl-lab": "node scripts/check-webgl-book-lab.js",
"check:webgl-runtime": "node scripts/check-webgl-book-runtime.js",
"test": "jest", "test": "jest",
"lint": "eslint --ext .ts src/", "lint": "eslint --ext .ts src/",
"lint:fix": "eslint --ext .ts src/ --fix" "lint:fix": "eslint --ext .ts src/ --fix"
+54 -6
View File
@@ -1938,17 +1938,40 @@ body.webgl-mode {
body.webgl-mode #choices, body.webgl-mode #choices,
body.webgl-mode .story-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); 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 { 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:hover,
body.webgl-mode #command_history .history-item.active { 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 { 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 { 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: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); color: rgba(255, 248, 225, 0.98);
background: rgba(255, 236, 190, 0.12); background: rgba(255, 236, 190, 0.12);
outline-color: rgba(255, 236, 190, 0.48); outline-color: rgba(255, 236, 190, 0.48);
} }
body.webgl-mode .choice-list kbd { body.webgl-mode .choice-list kbd {
color: rgba(255, 248, 225, 0.96); color: rgba(246, 231, 201, 0.92);
} }
#webgl_app { #webgl_app {
@@ -2113,6 +2145,22 @@ body.webgl-mode .choice-list kbd {
min-width: 0; 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 { #webgl_book_nav_position {
width: 100%; width: 100%;
accent-color: #f0cd8e; accent-color: #f0cd8e;
+30 -1
View File
@@ -7,10 +7,11 @@ import { BaseModule } from './base-module.js';
class BookTextureRendererModule extends BaseModule { class BookTextureRendererModule extends BaseModule {
constructor() { constructor() {
super('book-texture-renderer', 'Book Texture Renderer'); 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.pageFormat = null;
this.pagination = null; this.pagination = null;
this.localization = null; this.localization = null;
this.pageCache = null;
this.metrics = null; this.metrics = null;
this.canvases = { this.canvases = {
left: null, left: null,
@@ -70,6 +71,7 @@ class BookTextureRendererModule extends BaseModule {
'buildLineSegments', 'buildLineSegments',
'startRevealAnimation', 'startRevealAnimation',
'prepareRevealBlock', 'prepareRevealBlock',
'hasPreparedRevealBlock',
'createAnimationState', 'createAnimationState',
'publishPreparedReveal', 'publishPreparedReveal',
'startPreparedRevealAnimation', 'startPreparedRevealAnimation',
@@ -81,6 +83,8 @@ class BookTextureRendererModule extends BaseModule {
'requestAnimationFrame', 'requestAnimationFrame',
'tickAnimations', 'tickAnimations',
'publishSpread', 'publishSpread',
'cachePublishedPages',
'schedulePageCacheWrite',
'getPageCanvas', 'getPageCanvas',
'getHitMap', 'getHitMap',
'handlePageCountChanged' 'handlePageCountChanged'
@@ -91,6 +95,7 @@ class BookTextureRendererModule extends BaseModule {
this.pageFormat = this.getModule('book-page-format'); this.pageFormat = this.getModule('book-page-format');
this.pagination = this.getModule('book-pagination'); this.pagination = this.getModule('book-pagination');
this.localization = this.getModule('localization'); this.localization = this.getModule('localization');
this.pageCache = this.getModule('webgl-page-cache');
window.BookTextureRendererDebug = { window.BookTextureRendererDebug = {
pipelineTimings: this.pipelineTimings 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) { publishPreparedReveal(prepared) {
if (!prepared) return; if (!prepared) return;
this.markPipelineTiming('publishPreparedReveal', { this.markPipelineTiming('publishPreparedReveal', {
@@ -884,6 +894,7 @@ class BookTextureRendererModule extends BaseModule {
}; };
}); });
if (Object.keys(reveal).length) detail.reveal = reveal; if (Object.keys(reveal).length) detail.reveal = reveal;
this.cachePublishedPages(sidesToPublish, detail);
this.markPipelineTiming('publishSpread', { this.markPipelineTiming('publishSpread', {
sides: sidesToPublish, sides: sidesToPublish,
hasReveal: Object.keys(reveal).length > 0, hasReveal: Object.keys(reveal).length > 0,
@@ -896,6 +907,24 @@ class BookTextureRendererModule extends BaseModule {
return detail; 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) { getPageCanvas(side) {
return this.canvases[side] || null; return this.canvases[side] || null;
} }
+1
View File
@@ -114,6 +114,7 @@ const ModuleLoader = (function() {
{ id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 }, { 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: '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: '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-pagination', script: '/js/book-pagination-module.js', weight: 8 },
{ id: 'book-texture-renderer', script: '/js/book-texture-renderer-module.js', weight: 6 }, { 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 }, { id: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 },
+24 -4
View File
@@ -46,6 +46,7 @@ class SentenceQueueModule extends BaseModule {
'getPreparedSentence', 'getPreparedSentence',
'prefetchAhead', 'prefetchAhead',
'prefetchWebGLBookPresentation', 'prefetchWebGLBookPresentation',
'isWebGLBookPresentationPrepared',
'prepareSpeechMetadata', 'prepareSpeechMetadata',
'preloadAssetsForItem', 'preloadAssetsForItem',
'normalizeTtsText', 'normalizeTtsText',
@@ -200,10 +201,12 @@ class SentenceQueueModule extends BaseModule {
const sentence = await this.getPreparedSentence(item); const sentence = await this.getPreparedSentence(item);
if (!this.isCurrentQueueItem(item, queueGeneration)) return; if (!this.isCurrentQueueItem(item, queueGeneration)) return;
await this.prefetchWebGLBookPresentation(sentence, { if (!this.isWebGLBookPresentationPrepared(sentence)) {
queueGeneration, await this.prefetchWebGLBookPresentation(sentence, {
queueIndex: 0 queueGeneration,
}); queueIndex: 0
});
}
if (!this.isCurrentQueueItem(item, queueGeneration)) return; if (!this.isCurrentQueueItem(item, queueGeneration)) return;
// Prefetch far enough ahead that media pauses do not block TTS // 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'); const bookTextureRenderer = this.getModule('book-texture-renderer');
if (!bookPagination || !bookTextureRenderer) return null; 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) { if (!Array.isArray(sentence.animation?.wordTimings) || sentence.animation.wordTimings.length === 0) {
const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || []; const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || [];
sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []); sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []);
@@ -929,10 +936,23 @@ class SentenceQueueModule extends BaseModule {
spread, spread,
preloadOnly: true preloadOnly: true
}, { preloadOnly: true }); }, { preloadOnly: true });
sentence.webglBookPresentation = {
prepared: true,
blockId,
spread
};
} }
return 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) { isCurrentQueueItem(item, queueGeneration = this.queueGeneration) {
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item; return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
} }
+43 -2
View File
@@ -70,6 +70,7 @@ class UIDisplayHandlerModule extends BaseModule {
'renderSentence', 'renderSentence',
'isWebGLMode', 'isWebGLMode',
'prepareWebGLBookReveal', 'prepareWebGLBookReveal',
'waitForWebGLPageFlip',
'renderStoryBlock', 'renderStoryBlock',
'prepareRenderableBlock', 'prepareRenderableBlock',
'prepareTextRenderable', 'prepareTextRenderable',
@@ -1054,8 +1055,22 @@ class UIDisplayHandlerModule extends BaseModule {
|| { wordTimings: [], cueTimings: [], totalDuration: 0 }; || { wordTimings: [], cueTimings: [], totalDuration: 0 };
} }
let preparedSpread = null;
if (typeof bookPagination.preparePendingBlock === 'function') { 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 { } else {
document.dispatchEvent(new CustomEvent('book-pagination:prepare-block', { document.dispatchEvent(new CustomEvent('book-pagination:prepare-block', {
detail: { detail: {
@@ -1069,7 +1084,8 @@ class UIDisplayHandlerModule extends BaseModule {
blockId: sentence.blockId, blockId: sentence.blockId,
wordTimings: sentence.animation?.wordTimings || [], wordTimings: sentence.animation?.wordTimings || [],
cueTimings: sentence.animation?.cueTimings || [], cueTimings: sentence.animation?.cueTimings || [],
totalDuration: sentence.animation?.totalDuration || 0 totalDuration: sentence.animation?.totalDuration || 0,
spread: preparedSpread
}; };
if (typeof bookTextureRenderer.prepareRevealBlock === 'function') { if (typeof bookTextureRenderer.prepareRevealBlock === 'function') {
bookTextureRenderer.prepareRevealBlock(revealDetail); 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() { async rerenderStory() {
if (!this.paragraphContainer || this.renderedItems.length === 0) return; if (!this.paragraphContainer || this.renderedItems.length === 0) return;
console.log('UIDisplayHandler: Re-typesetting story after page resize'); console.log('UIDisplayHandler: Re-typesetting story after page resize');
+190 -16
View File
@@ -252,6 +252,8 @@ const preparedPageTextures = {
left: new Map(), left: new Map(),
right: new Map() right: new Map()
}; };
const residentPageTextures = new Map();
const maxResidentPageTextures = 18;
let blankPageTexture = null; let blankPageTexture = null;
let currentPageMeta = { let currentPageMeta = {
left: null, left: null,
@@ -529,9 +531,36 @@ window.BookLabDebug = {
writtenPageLimit: bookPaginationState.writtenPageLimit 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) { navigateToPagePosition(value) {
return 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() { redrawPageTextures() {
window.BookTextureRenderer?.publishSpread?.(); window.BookTextureRenderer?.publishSpread?.();
return true; return true;
@@ -592,6 +621,17 @@ document.addEventListener('webgl-book:page-reserve-directive', (event) => {
: Math.round(value); : Math.round(value);
setPageReserve(nextReserve); 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) => { document.addEventListener('ui:command', (event) => {
if (event.detail?.type === 'continue' && pendingRightPageFlip) { if (event.detail?.type === 'continue' && pendingRightPageFlip) {
pendingRightPageFlip = false; pendingRightPageFlip = false;
@@ -1707,12 +1747,14 @@ function clampPageReserve(value, pageCount = bookPageCount) {
function pageToSpreadIndex(pagePosition) { function pageToSpreadIndex(pagePosition) {
const page = Math.max(0, Math.round(Number(pagePosition || 0))); 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) { function spreadIndexToPagePosition(spreadIndex) {
const spread = Math.max(0, Math.round(Number(spreadIndex || 0))); 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() { function getWritablePageLimit() {
@@ -1728,7 +1770,6 @@ function syncReadingProgressToCurrentPage() {
if (Math.abs(nextProgress - readingProgress) < 0.0001) return; if (Math.abs(nextProgress - readingProgress) < 0.0001) return;
readingProgress = nextProgress; readingProgress = nextProgress;
buildBook(); buildBook();
notifyBookPageCountChanged();
window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress); window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress);
} }
@@ -1816,17 +1857,30 @@ function ensureBottomNavigation() {
const backButton = makeButton('webgl_book_nav_back', appInitialState.t?.('webgl.backward') || 'Backward', '◀'); const backButton = makeButton('webgl_book_nav_back', appInitialState.t?.('webgl.backward') || 'Backward', '◀');
const sliderWrap = document.createElement('div'); const sliderWrap = document.createElement('div');
sliderWrap.className = 'webgl-book-nav-slider-wrap'; 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'); const pageLabel = document.createElement('output');
pageLabel.id = 'webgl_book_nav_page_label'; pageLabel.id = 'webgl_book_nav_page_label';
pageLabel.className = 'webgl-book-nav-page-label'; pageLabel.className = 'webgl-book-nav-page-label';
pageLabel.textContent = '0'; 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'); const slider = document.createElement('input');
slider.id = 'webgl_book_nav_position'; slider.id = 'webgl_book_nav_position';
slider.type = 'range'; slider.type = 'range';
slider.min = '0'; slider.min = '0';
slider.step = '1'; slider.step = '1';
slider.value = '0'; slider.value = '0';
sliderWrap.appendChild(slider); sliderTrack.appendChild(minLabel);
sliderTrack.appendChild(slider);
sliderTrack.appendChild(maxLabel);
sliderWrap.appendChild(sliderTrack);
sliderWrap.appendChild(pageLabel); sliderWrap.appendChild(pageLabel);
root.appendChild(sliderWrap); root.appendChild(sliderWrap);
const forwardButton = makeButton('webgl_book_nav_forward', appInitialState.t?.('webgl.forward') || 'Forward', '▶'); const forwardButton = makeButton('webgl_book_nav_forward', appInitialState.t?.('webgl.forward') || 'Forward', '▶');
@@ -1850,6 +1904,8 @@ function ensureBottomNavigation() {
startButton, startButton,
backButton, backButton,
slider, slider,
minLabel,
maxLabel,
pageLabel, pageLabel,
forwardButton, forwardButton,
endButton endButton
@@ -1903,6 +1959,8 @@ function syncBottomNavigation() {
const reservedStart = Math.max(0, writableLimit); const reservedStart = Math.max(0, writableLimit);
bottomNavigation.slider.max = String(Math.max(0, bookPageCount)); bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit)); bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
bottomNavigation.minLabel.textContent = '0';
bottomNavigation.maxLabel.textContent = String(bookPageCount);
bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${Math.min(currentPage, navigableLimit)}`; 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-position', `${bookPageCount > 0 ? currentPage / bookPageCount : 0}`);
bottomNavigation.root.style.setProperty('--book-nav-written', `${bookPageCount > 0 ? writtenLimit / 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) { if (detail.reveal?.left) {
beginPageReveal('left', detail.left, detail.reveal.left); beginPageReveal('left', detail.left, detail.reveal.left);
} else { } else {
uploadPageTextureDirect('left', detail.left); uploadPageTextureDirect('left', detail.left, currentPageMeta.left);
} }
} }
if (detail.right) { if (detail.right) {
if (detail.reveal?.right) { if (detail.reveal?.right) {
beginPageReveal('right', detail.right, detail.reveal.right); beginPageReveal('right', detail.right, detail.reveal.right);
} else { } else {
uploadPageTextureDirect('right', detail.right); uploadPageTextureDirect('right', detail.right, currentPageMeta.right);
} }
} }
markStaticSceneBuffersDirty(); markStaticSceneBuffersDirty();
@@ -1994,6 +2052,75 @@ function preloadPageTexture(side, sourceCanvas, revealDetail = {}) {
return texture; 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 = {}) { function takePreparedPageTexture(side, revealDetail = {}) {
const key = getRevealCacheKey(revealDetail); const key = getRevealCacheKey(revealDetail);
const prepared = preparedPageTextures[side].get(key); const prepared = preparedPageTextures[side].get(key);
@@ -2003,11 +2130,26 @@ function takePreparedPageTexture(side, revealDetail = {}) {
return prepared; return prepared;
} }
function uploadPageTextureDirect(side, sourceCanvas) { function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
const texture = side === 'left' ? leftTexture : rightTexture; const texture = side === 'left' ? leftTexture : rightTexture;
const material = side === 'left' ? materials.leftPage : materials.rightPage; const material = side === 'left' ? materials.leftPage : materials.rightPage;
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'); 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) { if (material.map !== texture) {
material.map = texture; material.map = texture;
material.needsUpdate = true; material.needsUpdate = true;
@@ -2360,8 +2502,20 @@ function textureHitPageSide(hit) {
return null; return null;
} }
function startPageFlip(direction, options = {}) { async function startPageFlip(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false; 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; pendingRightPageFlip = false;
delete document.documentElement.dataset.webglPendingPageFlip; delete document.documentElement.dataset.webglPendingPageFlip;
const flip = createPageFlip(direction, performance.now(), normalFlipDuration); const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
@@ -2374,7 +2528,17 @@ function startPageFlip(direction, options = {}) {
return true; 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; if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration); const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration);
if (!firstFlip) return false; if (!firstFlip) return false;
@@ -2424,7 +2588,12 @@ function prepareStaticPageForFlip(flip) {
const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage; const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage;
const oppositeMaterial = flip.sourcePageSide === 'left' ? materials.rightPage : materials.leftPage; const oppositeMaterial = flip.sourcePageSide === 'left' ? materials.rightPage : materials.leftPage;
const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture); 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.flipPageSurface.map = sourceTexture;
materials.flipPageBackSurface.map = backTexture; materials.flipPageBackSurface.map = backTexture;
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
@@ -2733,15 +2902,20 @@ function createFlippingPageGeometry(surface) {
function finishActiveFlip(flip) { function finishActiveFlip(flip) {
removeFlipMesh(flip); removeFlipMesh(flip);
activeFlips = activeFlips.filter((active) => active !== 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', { document.dispatchEvent(new CustomEvent('webgl-book:page-flip-finished', {
detail: { detail: {
direction: flip.direction, 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 (flip.commitBundleOnFinish) {
if (Number.isFinite(Number(flip.targetSpread))) { if (Number.isFinite(Number(flip.targetSpread))) {
syncBookControls(); syncBookControls();
+3 -2
View File
@@ -224,7 +224,7 @@ class WebGLBookSceneModule extends BaseModule {
book.style.position = 'fixed'; book.style.position = 'fixed';
book.style.left = '1rem'; book.style.left = '1rem';
book.style.top = '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.height = 'min(27rem, calc(100vh - 2rem))';
book.style.background = 'rgba(18, 11, 8, 0.62)'; book.style.background = 'rgba(18, 11, 8, 0.62)';
book.style.border = '1px solid rgba(240, 205, 142, 0.28)'; book.style.border = '1px solid rgba(240, 205, 142, 0.28)';
@@ -273,7 +273,8 @@ class WebGLBookSceneModule extends BaseModule {
width: 'auto', width: 'auto',
height: 'auto', height: 'auto',
padding: '1rem', padding: '1rem',
overflow: 'auto', overflowY: 'auto',
overflowX: 'hidden',
opacity: '1', opacity: '1',
mixBlendMode: 'normal', mixBlendMode: 'normal',
clipPath: 'none', clipPath: 'none',
+264
View File
@@ -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;
+31 -1
View File
@@ -27,6 +27,14 @@ const pageFormatPath = path.join(__dirname, '..', 'public', 'js', 'book-page-for
const pageFormatSource = fs.readFileSync(pageFormatPath, 'utf8'); const pageFormatSource = fs.readFileSync(pageFormatPath, 'utf8');
const stylePath = path.join(__dirname, '..', 'public', 'css', 'style.css'); const stylePath = path.join(__dirname, '..', 'public', 'css', 'style.css');
const styleSource = fs.readFileSync(stylePath, 'utf8'); 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) { function dependencyList(source, moduleId) {
const classStart = source.indexOf(`super('${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)], ['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 can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)],
['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)], ['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)],
['texture renderer caches preload-only reveal canvases for later reuse', /preparedRevealCache/.test(textureRendererSource) && /preloadOnly/.test(textureRendererSource) && /publishPreparedReveal/.test(textureRendererSource) && /reusedPreparedCanvas/.test(textureRendererSource)], ['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)], ['texture renderer paints inline bold and italic styles', /getInlineStyleState/.test(textureRendererSource) && /updateInlineStyleState/.test(textureRendererSource) && /getCanvasFont/.test(textureRendererSource) && /segment\?\.style/.test(textureRendererSource)],
['webgl lab can preload page textures without swapping visible page material', /preparedPageTextures/.test(source) && /preloadPageTexture/.test(source) && /renderer\.initTexture\(texture\)/.test(source) && /takePreparedPageTexture/.test(source)], ['webgl lab can preload page textures without swapping visible page material', /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)], ['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 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 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 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 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 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)], ['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 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)], ['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 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 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)], ['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)], ['markup and 3d pagination accept full-page images', /'full'/.test(markupParserSource) && /size === 'full'/.test(bookPaginationSource)],
['story history can persist 3d pagination decisions', /persistPaginationMetrics/.test(bookPaginationSource) && /collectPaginationMetrics/.test(bookPaginationSource) && /pageStart/.test(storyHistorySource) && /pagination: metrics\.pagination/.test(storyHistorySource)] ['story history can persist 3d pagination decisions', /persistPaginationMetrics/.test(bookPaginationSource) && /collectPaginationMetrics/.test(bookPaginationSource) && /pageStart/.test(storyHistorySource) && /pagination: metrics\.pagination/.test(storyHistorySource)]
+250
View File
@@ -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);
});