Checkpoint WebGL book playback refactor state

This commit is contained in:
2026-06-10 01:07:22 +02:00
parent 171cafeb65
commit b41340151d
8 changed files with 824 additions and 370 deletions
+24 -7
View File
@@ -94,7 +94,7 @@ class BookPaginationModule extends BaseModule {
this.pages = this.buildPages([]);
this.spreads = this.buildSpreadsFromPages(this.pages);
this.currentSpreadIndex = 0;
this.publish({ reason: 'initial-title-spread', allowFutureUnrendered: true });
this.publish({ reason: 'initial-title-spread', visibility: 'future-ready' });
this.reportProgress(100, 'Book pagination ready');
return true;
}
@@ -146,7 +146,7 @@ class BookPaginationModule extends BaseModule {
: renderedSpreadIndex >= 0
? renderedSpreadIndex
: Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
this.publish({ reason: 'history-refresh', allowFutureUnrendered: true });
this.publish({ reason: 'history-refresh', visibility: 'future-ready' });
}
getContinuationBlockId(latestBlockId = 0, latestRenderedBlockId = 0) {
@@ -187,7 +187,7 @@ class BookPaginationModule extends BaseModule {
spreadIndex: cached.targetSpread?.index ?? this.currentSpreadIndex,
latestBlockId: pendingBlockId,
latestRenderedBlockId,
preloadOnly: false,
phase: 'activate',
reusedPreparedPagination: true
}
}));
@@ -240,7 +240,7 @@ class BookPaginationModule extends BaseModule {
spreadIndex: targetSpread?.index ?? this.currentSpreadIndex,
latestBlockId: pendingBlockId,
latestRenderedBlockId,
preloadOnly: options.activate === false
phase: options.activate === false ? 'prepare' : 'activate'
}
}));
return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread());
@@ -371,7 +371,8 @@ class BookPaginationModule extends BaseModule {
buildSpreadsFromPages(pages = []) {
const spreads = [];
const linesPerPage = this.getLinesPerPage();
pages.forEach((page, pageIndex) => {
const normalizedPages = this.normalizePagesForSpreads(pages);
normalizedPages.forEach((page, pageIndex) => {
const spreadIndex = Math.floor(pageIndex / 2);
const side = pageIndex % 2 === 0 ? 'left' : 'right';
if (!spreads[spreadIndex]) {
@@ -398,6 +399,22 @@ class BookPaginationModule extends BaseModule {
return spreads.filter(Boolean);
}
normalizePagesForSpreads(pages = []) {
const source = Array.isArray(pages) ? pages : [];
const lastPageIndex = source.reduce((max, page, index) => {
const explicitIndex = Number(page?.index);
return Math.max(max, Number.isFinite(explicitIndex) ? explicitIndex : index);
}, 1);
const lastSpreadRightIndex = Math.max(1, lastPageIndex % 2 === 0 ? lastPageIndex + 1 : lastPageIndex);
const normalized = [];
for (let index = 0; index <= lastSpreadRightIndex; index += 1) {
normalized[index] = source[index] || this.createBlankPage(index, {
section: index < 3 ? 'frontmatter' : 'body'
});
}
return normalized;
}
applyPageReserveDirective(block = {}) {
const directive = block?.metadata?.pageReserve || block?.pageReserve || null;
const blockId = Number(block?.blockId || block?.metadata?.blockId || 0);
@@ -967,7 +984,7 @@ class BookPaginationModule extends BaseModule {
setCurrentSpread(index = 0) {
this.currentSpreadIndex = Math.max(0, Math.min(Math.round(Number(index || 0)), Math.max(0, this.spreads.length - 1)));
this.publish({ reason: 'set-current-spread', allowFutureUnrendered: true });
this.publish({ reason: 'set-current-spread', visibility: 'future-ready' });
return this.currentSpreadIndex;
}
@@ -982,7 +999,7 @@ class BookPaginationModule extends BaseModule {
latestBlockId: this.latestBlockId,
latestRenderedBlockId: this.latestRenderedBlockId,
reason: options.reason || 'publish',
allowFutureUnrendered: options.allowFutureUnrendered === true
visibility: options.visibility || 'current'
}
}));
}
+88 -112
View File
@@ -29,7 +29,6 @@ class BookTextureRendererModule extends BaseModule {
this.activeAnimations = new Map();
this.revealedBlockIds = new Set();
this.pendingRevealBlockIds = new Set();
this.preparedRevealCache = new Map();
this.revealBaseCanvases = null;
this.revealPublishBlockIds = null;
this.lastDrawSignature = null;
@@ -39,7 +38,6 @@ class BookTextureRendererModule extends BaseModule {
this.targetFrameDurationMs = 1000 / 60;
this.pipelineTimings = [];
this.imageCache = new Map();
this.pendingPageCacheWrites = new Map();
this.pageContentVersions = new Map();
this.bindMethods([
@@ -63,6 +61,7 @@ class BookTextureRendererModule extends BaseModule {
'drawLine',
'drawWord',
'buildRevealRegions',
'shouldFlipAfterSideReveal',
'collectRevealRegionCandidates',
'createRevealRegionForLine',
'assignRevealTiming',
@@ -82,6 +81,7 @@ class BookTextureRendererModule extends BaseModule {
'spreadContainsBlock',
'hasPreparedRevealBlock',
'createAnimationState',
'getDrawPhase',
'publishPreparedReveal',
'startPreparedRevealAnimation',
'fastForwardAnimations',
@@ -92,10 +92,8 @@ class BookTextureRendererModule extends BaseModule {
'requestAnimationFrame',
'tickAnimations',
'publishSpread',
'buildPageTextureRecords',
'cachePublishedPages',
'getPageCacheWriteKey',
'isOlderPageMeta',
'schedulePageCacheWrite',
'getPageCanvas',
'getHitMap',
'handlePageCountChanged'
@@ -120,11 +118,12 @@ class BookTextureRendererModule extends BaseModule {
const spreadIndex = Math.max(0, Number(event.detail?.spreadIndex ?? spread?.index ?? 0));
const latestBlockId = event.detail?.latestBlockId;
const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0));
const visibility = event.detail?.visibility || 'current';
this.currentSpread = spread || { left: [], right: [] };
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) {
this.markPendingReveal(latestBlockId);
const id = String(latestBlockId);
if (event.detail?.allowFutureUnrendered === true && !this.activeAnimations.has(id)) {
if (visibility === 'future-ready' && !this.activeAnimations.has(id)) {
this.drawSpread(this.stripUnrenderedLines(this.currentSpread, latestRenderedBlockId), ['left', 'right']);
return;
}
@@ -132,8 +131,8 @@ class BookTextureRendererModule extends BaseModule {
this.revealPublishBlockIds = new Set([id]);
const visibleSpread = Math.max(0, Number(window.BookLabDebug?.getBookState?.().spreadIndex || 0));
const flipActive = document.documentElement.dataset.webglPageFlipActive === 'true';
if (!flipActive && event.detail?.allowFutureUnrendered !== true && spreadIndex > visibleSpread) {
this.drawSpread(this.currentSpread, ['left', 'right'], { preloadOnly: true });
if (!flipActive && visibility !== 'future-ready' && spreadIndex > visibleSpread) {
this.drawSpread(this.currentSpread, ['left', 'right'], { phase: 'prepare' });
return;
}
this.drawSpread(this.currentSpread, ['left', 'right']);
@@ -226,20 +225,21 @@ class BookTextureRendererModule extends BaseModule {
this.currentSpread = spread || { left: [], right: [] };
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0;
const phase = this.getDrawPhase(options);
const drawSignature = this.getDrawSignature(this.currentSpread, sidesToDraw);
if (!options.preloadOnly && !hasReveal && drawSignature === this.lastDrawSignature) {
if (phase !== 'prepare' && !hasReveal && drawSignature === this.lastDrawSignature) {
const now = performance.now();
if (now - this.lastDrawSkipLoggedAt > 1000) {
this.lastDrawSkipLoggedAt = now;
this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw });
}
if (options.preloadOnly) this.currentSpread = previousSpread;
if (phase === 'prepare') this.currentSpread = previousSpread;
return null;
}
this.markPipelineTiming('drawSpread:start', {
sides: sidesToDraw,
revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : [],
preloadOnly: Boolean(options.preloadOnly)
phase
});
this.revealBaseCanvases = { left: null, right: null };
sidesToDraw.forEach((side) => {
@@ -253,15 +253,20 @@ class BookTextureRendererModule extends BaseModule {
const published = this.publishSpread(sidesToDraw, options);
this.markPipelineTiming('drawSpread:end', {
sides: sidesToDraw,
preloadOnly: Boolean(options.preloadOnly)
phase
});
this.revealBaseCanvases = null;
this.revealPublishBlockIds = null;
if (!options.preloadOnly && !hasReveal) this.lastDrawSignature = drawSignature;
if (options.preloadOnly) this.currentSpread = previousSpread;
if (phase !== 'prepare' && !hasReveal) this.lastDrawSignature = drawSignature;
if (phase === 'prepare') this.currentSpread = previousSpread;
return published;
}
getDrawPhase(options = {}) {
if (options.phase === 'prepare' || options.phase === 'activate') return options.phase;
return 'activate';
}
getDrawSignature(spread = null, sides = []) {
const source = spread || {};
return sides.map(side => {
@@ -644,6 +649,7 @@ class BookTextureRendererModule extends BaseModule {
return {
blockIds: Array.from(byBlock.keys()),
durationMs: sideRegions.reduce((maxDuration, region) => Math.max(maxDuration, region.timing.delay + region.timing.duration), 0),
pageFlipAfterReveal: this.shouldFlipAfterSideReveal(side),
baseCanvas: null,
lineRects: sideRegions.map(region => ({
blockId: region.blockId,
@@ -660,6 +666,19 @@ class BookTextureRendererModule extends BaseModule {
};
}
shouldFlipAfterSideReveal(side) {
if (side !== 'right') return false;
const meta = this.currentSpread?.pageMeta?.right || null;
if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false;
const rightLines = Array.isArray(this.currentSpread?.right) ? this.currentSpread.right : [];
const maxLine = rightLines.reduce((max, line) => Math.max(
max,
Number(line?.pageLine || 0) + Math.max(1, Number(line?.lineCount || 1))
), 0);
const expectedLines = Math.max(1, Number(meta.linesPerPage || 25));
return maxLine >= expectedLines;
}
collectRevealRegionCandidates() {
const candidates = [];
const sourceSpreads = Array.isArray(this.pagination?.spreads) && this.pagination.spreads.length
@@ -879,14 +898,16 @@ class BookTextureRendererModule extends BaseModule {
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const id = String(blockId);
const wordTimings = detail.wordTimings;
const preloadOnly = Boolean(detail.preloadOnly || options.preloadOnly);
const phase = detail.phase === 'prepare' || options.phase === 'prepare'
? 'prepare'
: 'activate';
this.markPipelineTiming('prepareRevealBlock:start', {
blockId: id,
wordTimingCount: wordTimings.length,
preloadOnly
phase
});
if (!preloadOnly && this.preparedRevealCache.has(id)) {
const cached = this.preparedRevealCache.get(id);
if (phase === 'activate' && this.pageCache?.hasPreparedRevealPlan?.(id)) {
const cached = this.pageCache.takePreparedRevealPlan(id);
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
this.pendingRevealBlockIds.delete(id);
this.publishPreparedReveal(cached);
@@ -903,10 +924,10 @@ class BookTextureRendererModule extends BaseModule {
this.revealPublishBlockIds = new Set([id]);
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
const sides = ['left', 'right'];
const published = this.drawSpread(spread, sides, { preloadOnly });
if (!preloadOnly) this.preloadAdditionalRevealSpreads(id, spread);
if (preloadOnly && published) {
this.preparedRevealCache.set(id, {
const published = this.drawSpread(spread, sides, { phase });
if (phase !== 'prepare') this.preloadAdditionalRevealSpreads(id, spread);
if (phase === 'prepare' && published) {
this.pageCache?.rememberPreparedRevealPlan?.(id, {
...published,
blockId,
wordTimings,
@@ -916,7 +937,7 @@ class BookTextureRendererModule extends BaseModule {
this.markPipelineTiming('prepareRevealBlock:end', {
blockId: id,
wordTimingCount: wordTimings.length,
preloadOnly
phase
});
}
@@ -927,7 +948,7 @@ class BookTextureRendererModule extends BaseModule {
spreads.forEach((spread) => {
if (!spread || Number(spread.index) === primaryIndex) return;
if (!this.spreadContainsBlock(spread, blockId)) return;
this.drawSpread(spread, ['left', 'right'], { preloadOnly: true });
this.drawSpread(spread, ['left', 'right'], { phase: 'prepare' });
});
}
@@ -941,7 +962,7 @@ class BookTextureRendererModule extends BaseModule {
hasPreparedRevealBlock(blockId) {
const id = String(blockId ?? '');
return Boolean(id && this.preparedRevealCache.has(id));
return Boolean(id && this.pageCache?.hasPreparedRevealPlan?.(id));
}
publishPreparedReveal(prepared) {
@@ -951,14 +972,14 @@ class BookTextureRendererModule extends BaseModule {
sides: prepared.sides || [],
hasReveal: Boolean(prepared.reveal && Object.keys(prepared.reveal).length)
});
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', {
detail: {
metrics: prepared.metrics,
hitMaps: prepared.hitMaps || this.hitMaps,
left: prepared.left || null,
right: prepared.right || null,
records: prepared.records || this.buildPageTextureRecords(prepared.sides || ['left', 'right'], prepared),
reveal: prepared.reveal || {},
pageMeta: prepared.pageMeta || {},
phase: 'activate',
preparedFromCache: true
}
}));
@@ -1095,6 +1116,7 @@ class BookTextureRendererModule extends BaseModule {
publishSpread(sides = null, options = {}) {
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const phase = this.getDrawPhase(options);
const regionCounts = {
left: 0,
right: 0
@@ -1103,142 +1125,96 @@ class BookTextureRendererModule extends BaseModule {
metrics: this.metrics,
hitMaps: this.hitMaps,
sides: sidesToPublish,
pageMeta: this.buildPublishPageMeta(sidesToPublish)
pageMeta: this.buildPublishPageMeta(sidesToPublish),
phase
};
if (options.preloadOnly) detail.preloadOnly = true;
if (sidesToPublish.includes('left')) {
detail.left = options.preloadOnly ? this.cloneCanvas(this.canvases.left) : this.canvases.left;
detail.left = phase === 'prepare' ? this.cloneCanvas(this.canvases.left) : this.canvases.left;
}
if (sidesToPublish.includes('right')) {
detail.right = options.preloadOnly ? this.cloneCanvas(this.canvases.right) : this.canvases.right;
detail.right = phase === 'prepare' ? this.cloneCanvas(this.canvases.right) : this.canvases.right;
}
const reveal = {};
sidesToPublish.forEach((side) => {
const sideReveal = this.buildRevealRegions(side);
if (!sideReveal) return;
sideReveal.baseCanvas = options.preloadOnly
sideReveal.baseCanvas = phase === 'prepare'
? this.cloneCanvas(this.revealBaseCanvases?.[side])
: this.revealBaseCanvases?.[side] || null;
regionCounts[side] = sideReveal.lineRects.length;
reveal[side] = sideReveal;
});
if (Object.keys(reveal).length) detail.reveal = reveal;
detail.records = this.buildPageTextureRecords(sidesToPublish, detail);
this.cachePublishedPages(sidesToPublish, detail);
this.markPipelineTiming('publishSpread', {
sides: sidesToPublish,
hasReveal: Object.keys(reveal).length > 0,
regionCounts,
preloadOnly: Boolean(options.preloadOnly)
phase
});
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', {
detail
}));
return detail;
}
buildPageTextureRecords(sides = [], detail = {}) {
return sides.map((side) => ({
side,
phase: detail.phase || 'activate',
canvas: detail[side] || null,
pageMeta: detail.pageMeta?.[side] || null,
reveal: detail.reveal?.[side] || null,
state: {
canvasReady: Boolean(detail[side]),
vramReady: detail.phase === 'prepare',
visible: detail.phase !== 'prepare'
}
}));
}
buildPublishPageMeta(sides = []) {
const baseMeta = this.currentSpread?.pageMeta || {};
const spreadIndex = Math.max(0, Math.round(Number(this.currentSpread?.index || 0)));
return sides.reduce((meta, side) => {
const source = baseMeta[side] || null;
if (!source) {
meta[side] = null;
return meta;
}
const pageIndex = side === 'left' ? spreadIndex * 2 : spreadIndex * 2 + 1;
const source = baseMeta[side] || {
kind: 'blank',
section: pageIndex < 3 ? 'frontmatter' : 'body',
pageIndex,
pageNumber: null,
omitPageNumber: true
};
const lines = Array.isArray(this.currentSpread?.[side]) ? this.currentSpread[side] : [];
const maxBlockId = lines.reduce((max, line) => Math.max(max, Number(line?.blockId || 0)), 0);
const lineCount = lines.length;
const pageIndex = Number(source.pageIndex);
const key = Number.isFinite(pageIndex) ? pageIndex : side;
const normalizedPageIndex = Number(source.pageIndex);
const key = Number.isFinite(normalizedPageIndex) ? normalizedPageIndex : side;
const nextVersion = Math.max(1, Number(this.pageContentVersions.get(key) || 0) + 1);
this.pageContentVersions.set(key, nextVersion);
meta[side] = {
...source,
pageIndex: Number.isFinite(normalizedPageIndex) ? normalizedPageIndex : pageIndex,
contentVersion: nextVersion,
completenessScore: (maxBlockId * 1000) + lineCount,
maxBlockId,
lineCount
};
return meta;
}, {
left: Object.prototype.hasOwnProperty.call(baseMeta, 'left') ? baseMeta.left : null,
right: Object.prototype.hasOwnProperty.call(baseMeta, 'right') ? baseMeta.right : null
});
}, {});
}
cachePublishedPages(sides = [], detail = {}) {
if (!this.pageCache || typeof this.pageCache.cachePageCanvas !== 'function') return;
if (!this.pageCache || typeof this.pageCache.storePageCanvas !== 'function') return;
sides.forEach((side) => {
const canvas = detail[side];
const pageMeta = detail.pageMeta?.[side] || null;
if (!canvas || !pageMeta || !Number.isFinite(Number(pageMeta.pageIndex))) return;
this.schedulePageCacheWrite(pageMeta, canvas);
this.pageCache.storePageCanvas(pageMeta, canvas, { persist: true, resident: true });
});
}
schedulePageCacheWrite(pageMeta, canvas) {
const frozenCanvas = this.cloneCanvas(canvas);
const key = this.getPageCacheWriteKey(pageMeta, frozenCanvas);
const pending = this.pendingPageCacheWrites.get(key);
if (pending && this.isOlderPageMeta(pageMeta, pending.pageMeta)) return pending.promise;
const previousWrite = pending?.promise || Promise.resolve();
const write = previousWrite.catch(() => false).then(() => this.pageCache?.cachePageCanvas?.(pageMeta, frozenCanvas))
.then((stored) => {
if (!stored) {
document.dispatchEvent(new CustomEvent('webgl-book:page-cache-problem', {
detail: {
type: 'db-write-failed',
pageIndex: pageMeta?.pageIndex ?? null,
key
}
}));
}
return stored;
})
.catch((error) => {
document.dispatchEvent(new CustomEvent('webgl-book:page-cache-problem', {
detail: {
type: 'db-write-error',
pageIndex: pageMeta?.pageIndex ?? null,
key,
message: error?.message || String(error)
}
}));
return false;
})
.finally(() => {
if (this.pendingPageCacheWrites.get(key)?.promise === write) {
this.pendingPageCacheWrites.delete(key);
}
});
this.pendingPageCacheWrites.set(key, {
promise: write,
pageMeta: { ...(pageMeta || {}) }
});
return write;
}
isOlderPageMeta(incoming = {}, existing = null) {
if (!existing) return false;
const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0));
if (incomingCompleteness < existingCompleteness) return true;
if (incomingCompleteness > existingCompleteness) return false;
const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0));
const existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
return incomingVersion > 0 && existingVersion > incomingVersion;
}
getPageCacheWriteKey(pageMeta = {}, canvas = null) {
if (this.pageCache && typeof this.pageCache.makePageKey === 'function') {
return this.pageCache.makePageKey({
...pageMeta,
width: canvas?.width ?? pageMeta.width,
height: canvas?.height ?? pageMeta.height
});
}
return `${pageMeta.cacheKey || window.MODULE_CACHE_BUSTER || 'dev'}:page:${pageMeta.pageIndex}:${canvas?.width || pageMeta.width}x${canvas?.height || pageMeta.height}`;
}
getPageCanvas(side) {
return this.canvases[side] || null;
}
+2 -2
View File
@@ -937,8 +937,8 @@ class SentenceQueueModule extends BaseModule {
cueTimings: sentence.animation?.cueTimings || [],
totalDuration: sentence.animation?.totalDuration || 0,
spread,
preloadOnly: true
}, { preloadOnly: true });
phase: 'prepare'
}, { phase: 'prepare' });
sentence.webglBookPresentation = {
prepared: true,
blockId,
+2 -2
View File
@@ -1081,10 +1081,10 @@ class UIDisplayHandlerModule extends BaseModule {
cueTimings: sentence.animation?.cueTimings || [],
totalDuration: sentence.animation?.totalDuration || 0,
spread: previewSpread,
preloadOnly: true
phase: 'prepare'
};
if (previewSpread && typeof bookTextureRenderer.prepareRevealBlock === 'function') {
bookTextureRenderer.prepareRevealBlock(previewRevealDetail, { preloadOnly: true });
bookTextureRenderer.prepareRevealBlock(previewRevealDetail, { phase: 'prepare' });
}
if (Number(previewSpread?.index || 0) > currentSpreadIndex) {
const flipped = await this.waitForWebGLPageFlip({
+244 -220
View File
@@ -213,6 +213,7 @@ let activeFlips = [];
let pendingPageFlips = 0;
const pendingRevealStartBlockIds = new Set();
const activeRevealBlockStarts = new Map();
let lastFlipTexturePreflight = null;
const paperColor = new THREE.Color(0xece4ca);
const inkColor = '#1a1009';
@@ -246,19 +247,24 @@ function createPageCanvasTexture(sourceCanvas) {
}
function getBlankPageTexture() {
if (blankPageTexture) return blankPageTexture;
blankPageTexture = createPageCanvasTexture(createPageCanvas('blank'));
return blankPageTexture;
return pageTextureStore?.getBlankTexture?.() || createPageCanvasTexture(createPageCanvas('blank'));
}
const preparedPageTextures = {
left: new Map(),
right: new Map()
};
const residentPageTextures = new Map();
const maxResidentPageTextures = 192;
let blankPageTexture = null;
const pageCacheProblemLog = [];
const pageTextureStore = window.moduleRegistry?.getModule?.('webgl-page-cache') || window.WebGLPageCache || null;
pageTextureStore?.configureTextureRuntime?.({
THREE,
renderer,
configureTexture: configurePageCanvasTexture,
createBlankCanvas: () => createPageCanvas('blank'),
maxResidentTextureCount: maxResidentPageTextures,
maxPreparedTextureCount: 128
});
pageTextureStore?.registerVisibleTexture?.('left', leftTexture, leftCanvas);
pageTextureStore?.registerVisibleTexture?.('right', rightTexture, rightCanvas);
await reportLabStep(50, 'Initializing page texture store VRAM window');
pageTextureStore?.getBlankTexture?.();
await prewarmNavigationTextureWindow('loader-prime', { recordMiss: false });
let currentPageMeta = {
left: null,
right: null
@@ -589,14 +595,18 @@ window.BookLabDebug = {
};
},
getRuntimeInvariants() {
const textureStoreState = pageTextureStore?.getRuntimeState?.() || {};
return {
targetFrameDurationMs,
residentPageTextureCount: residentPageTextures.size,
residentPageTextureCount: textureStoreState.residentTextureCount || 0,
maxResidentPageTextures,
pageCacheProblemCount: pageCacheProblemLog.length,
pageCacheProblemCount: textureStoreState.problemCount || 0,
preparedPageTextureCount: textureStoreState.preparedTextureCount || 0,
singlePageTextureStore: true,
flipFrontBackShareMaterial: materials.flipPageSurface === materials.flipPageBackSurface,
mirrorRefreshesEveryFrame: true,
mirrorRefreshesWhenStaticDirty: true
mirrorRefreshesWhenStaticDirty: true,
lastFlipTexturePreflight
};
},
projectPointerToPage(clientX, clientY) {
@@ -610,7 +620,7 @@ window.BookLabDebug = {
};
window.addEventListener('resize', resize);
document.addEventListener('webgl-book:page-canvases', handlePageCanvases);
document.addEventListener('webgl-book:page-texture-records', handlePageTextureRecords);
document.addEventListener('webgl-book:page-reveal-start', (event) => {
startPageRevealForBlock(event.detail?.blockId);
});
@@ -621,7 +631,7 @@ document.addEventListener('webgl-book:reveal-committed', (event) => {
handleRevealCommittedForPageFlip(event.detail || {});
});
document.addEventListener('webgl-book:page-cache-problem', (event) => {
recordPageCacheProblem(event.detail || {});
pageTextureStore?.recordProblem?.(event.detail || {});
});
document.addEventListener('book-pagination:spread-updated', (event) => {
const detail = event.detail || {};
@@ -630,7 +640,7 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0));
if (
latestBlockId > latestRenderedBlockId
&& detail.allowFutureUnrendered !== true
&& detail.visibility !== 'future-ready'
&& activeFlips.length === 0
&& incomingSpreadIndex > Math.max(0, Number(bookPaginationState.spreadIndex || 0))
) {
@@ -2028,35 +2038,34 @@ function syncBottomNavigation() {
bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit;
}
function handlePageCanvases(event) {
const detail = event.detail || {};
function handlePageTextureRecords(event) {
const detail = normalizePageTextureRecordDetail(event.detail || {});
if (detail.pageMeta) {
const hasLeftMeta = Object.prototype.hasOwnProperty.call(detail.pageMeta, 'left');
const hasRightMeta = Object.prototype.hasOwnProperty.call(detail.pageMeta, 'right');
currentPageMeta = {
left: hasLeftMeta ? detail.pageMeta.left : currentPageMeta.left || null,
right: hasRightMeta ? detail.pageMeta.right : currentPageMeta.right || null
};
currentPageMeta = normalizePageMetaPair(detail.pageMeta, currentPageMeta);
}
markPageTextureTiming('handlePageCanvases:start', {
markPageTextureTiming('handlePageTextureRecords:start', {
hasLeft: Boolean(detail.left),
hasRight: Boolean(detail.right),
revealSides: Object.keys(detail.reveal || {}),
preloadOnly: Boolean(detail.preloadOnly),
phase: detail.phase || 'activate',
pageMeta: currentPageMeta
});
const leftReveal = attachRevealPageMeta(detail.reveal?.left, detail.pageMeta?.left || currentPageMeta.left || null);
const rightReveal = attachRevealPageMeta(detail.reveal?.right, detail.pageMeta?.right || currentPageMeta.right || null);
if (detail.preloadOnly) {
const leftReveal = attachRevealPageMeta(detail.reveal?.left, currentPageMeta.left || null);
const rightReveal = attachRevealPageMeta(detail.reveal?.right, currentPageMeta.right || null);
if (detail.phase === 'prepare') {
if (detail.left) {
const texture = preloadPageTexture('left', detail.left, leftReveal, detail.pageMeta?.left || null);
rememberResidentPageTexture(detail.pageMeta?.left || null, texture, detail.left);
const texture = preloadPageTexture('left', detail.left, leftReveal, currentPageMeta.left);
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.left, texture, detail.left, true);
} else if (currentPageMeta.left?.kind === 'blank') {
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.left, getBlankPageTexture(), null, false);
}
if (detail.right) {
const texture = preloadPageTexture('right', detail.right, rightReveal, detail.pageMeta?.right || null);
rememberResidentPageTexture(detail.pageMeta?.right || null, texture, detail.right);
const texture = preloadPageTexture('right', detail.right, rightReveal, currentPageMeta.right);
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.right, texture, detail.right, true);
} else if (currentPageMeta.right?.kind === 'blank') {
pageTextureStore?.rememberResidentTexture?.(currentPageMeta.right, getBlankPageTexture(), null, false);
}
markPageTextureTiming('handlePageCanvases:preloadOnly:end');
markPageTextureTiming('handlePageTextureRecords:prepare:end');
return;
}
if (detail.left) {
@@ -2073,13 +2082,110 @@ function handlePageCanvases(event) {
uploadPageTextureDirect('right', detail.right, currentPageMeta.right);
}
}
if (!detail.left && currentPageMeta.left?.kind === 'blank') {
applyExplicitBlankPageTexture('left', currentPageMeta.left, 'page-texture-records');
}
if (!detail.right && currentPageMeta.right?.kind === 'blank') {
applyExplicitBlankPageTexture('right', currentPageMeta.right, 'page-texture-records');
}
markStaticSceneBuffersDirty();
document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({
width: leftCanvas.width,
height: leftCanvas.height,
source: 'book-texture-renderer'
});
markPageTextureTiming('handlePageCanvases:end');
markPageTextureTiming('handlePageTextureRecords:end');
prewarmNavigationTextureWindow('page-texture-records').catch((error) => {
pageTextureStore?.recordProblem?.({
type: 'navigation-window-prewarm-error',
message: error?.message || String(error)
});
});
}
function normalizePageTextureRecordDetail(detail = {}) {
if (!Array.isArray(detail.records) || detail.records.length === 0) {
return {
...detail,
phase: detail.phase === 'prepare' ? 'prepare' : 'activate'
};
}
return detail.records.reduce((normalized, record) => {
const side = record?.side === 'right' ? 'right' : 'left';
normalized[side] = record.canvas || normalized[side] || null;
normalized.pageMeta[side] = record.pageMeta || detail.pageMeta?.[side] || normalized.pageMeta[side] || null;
if (record.reveal) normalized.reveal[side] = record.reveal;
return normalized;
}, {
metrics: detail.metrics,
hitMaps: detail.hitMaps || {},
sides: detail.records.map(record => record?.side).filter(Boolean),
records: detail.records,
reveal: {},
pageMeta: {},
phase: detail.phase === 'prepare' ? 'prepare' : 'activate',
preparedFromCache: detail.preparedFromCache === true
});
}
function normalizePageMetaPair(pageMeta = {}, previousMeta = currentPageMeta) {
const spreadIndex = getSpreadIndexFromPageMeta(pageMeta)
?? getSpreadIndexFromPageMeta(previousMeta)
?? Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
const pageIndices = spreadPageIndices(spreadIndex);
return {
left: normalizePageSideMeta(pageMeta.left, pageIndices.left, 'left'),
right: normalizePageSideMeta(pageMeta.right, pageIndices.right, 'right')
};
}
function getSpreadIndexFromPageMeta(pageMeta = {}) {
const leftIndex = Number(pageMeta?.left?.pageIndex);
if (Number.isFinite(leftIndex)) return Math.floor(Math.max(0, leftIndex) / 2);
const rightIndex = Number(pageMeta?.right?.pageIndex);
if (Number.isFinite(rightIndex)) return Math.floor(Math.max(0, rightIndex) / 2);
return null;
}
function normalizePageSideMeta(meta = null, pageIndex = 0, side = 'left') {
const index = Math.max(0, Math.round(Number(meta?.pageIndex ?? pageIndex)));
if (!meta || meta.kind === 'blank') return makeBlankPageMeta(index, meta?.section || (index < 3 ? 'frontmatter' : 'body'));
return {
...meta,
pageIndex: index,
side
};
}
function makeBlankPageMeta(pageIndex = 0, section = 'body') {
return {
kind: 'blank',
section,
pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))),
pageNumber: null,
omitPageNumber: true,
lineCount: 0,
maxBlockId: 0,
completenessScore: 0,
side: Math.max(0, Math.round(Number(pageIndex || 0))) % 2 === 0 ? 'left' : 'right'
};
}
function applyExplicitBlankPageTexture(side, pageMeta = null, reason = 'blank-page') {
const material = side === 'left' ? materials.leftPage : materials.rightPage;
const blankTexture = getBlankPageTexture();
clearPageReveal(side, reason);
if (material.map !== blankTexture) {
material.map = blankTexture;
material.needsUpdate = true;
}
pageTextureStore?.rememberResidentTexture?.(pageMeta || makeBlankPageMeta(side === 'left' ? 0 : 1), blankTexture, null, false);
markPageTextureTiming('explicitBlankTexture', {
side,
pageIndex: pageMeta?.pageIndex ?? null,
reason
});
return blankTexture;
}
function attachRevealPageMeta(revealDetail = null, pageMeta = null) {
@@ -2099,30 +2205,15 @@ function getRevealCacheKey(revealDetail = {}) {
function preloadPageTexture(side, sourceCanvas, revealDetail = {}, pageMeta = null) {
if (!sourceCanvas) return null;
const texture = createPageCanvasTexture(sourceCanvas);
const baseTexture = revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null;
const key = getRevealCacheKey({ ...(revealDetail || {}), pageMeta: revealDetail?.pageMeta || pageMeta || null });
markPageTextureTiming('preloadTexture:start', {
side,
key,
width: sourceCanvas.width,
height: sourceCanvas.height,
hasBaseTexture: Boolean(baseTexture)
hasBaseTexture: Boolean(revealDetail?.baseCanvas)
});
preparedPageTextures[side].set(key, {
texture,
baseTexture,
sourceCanvas,
revealDetail,
uploadedAt: performance.now()
});
if (preparedPageTextures[side].size > 128) {
const oldestKey = preparedPageTextures[side].keys().next().value;
const oldest = preparedPageTextures[side].get(oldestKey);
oldest?.texture?.dispose?.();
oldest?.baseTexture?.dispose?.();
preparedPageTextures[side].delete(oldestKey);
}
const texture = pageTextureStore?.preparePageTexture?.(side, key, pageMeta, sourceCanvas, revealDetail) || null;
markPageTextureTiming('preloadTexture:end', { side, key });
return texture;
}
@@ -2142,54 +2233,6 @@ function setPageFlipActiveFlag() {
}
}
function recordPageCacheProblem(detail = {}) {
const entry = {
...detail,
at: performance.now()
};
pageCacheProblemLog.push(entry);
if (pageCacheProblemLog.length > 80) pageCacheProblemLog.splice(0, pageCacheProblemLog.length - 80);
document.documentElement.dataset.webglPageCacheProblems = JSON.stringify(pageCacheProblemLog);
console.warn('WebGL page cache problem', entry);
}
function rememberResidentPageTexture(pageMeta = null, texture = null, sourceCanvas = null, ownsTexture = true) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!texture || !Number.isFinite(pageIndex) || pageIndex < 0) return null;
const key = makeResidentPageTextureKey(pageMeta);
const existing = residentPageTextures.get(key);
if (isOlderPageTextureMeta(pageMeta, existing?.pageMeta)) return existing?.texture || null;
if (existing?.ownsTexture && existing.texture && existing.texture !== texture) existing.texture.dispose?.();
residentPageTextures.set(key, {
texture,
sourceCanvas: sourceCanvas || existing?.sourceCanvas || null,
lastUsedAt: performance.now(),
ownsTexture,
pageMeta: {
...(existing?.pageMeta || {}),
...(pageMeta || {})
}
});
while (residentPageTextures.size > maxResidentPageTextures) {
const oldestKey = residentPageTextures.keys().next().value;
const oldest = residentPageTextures.get(oldestKey);
if (oldest?.ownsTexture) oldest.texture?.dispose?.();
residentPageTextures.delete(oldestKey);
}
return texture;
}
function isOlderPageTextureMeta(incoming = {}, existing = null) {
if (!existing) return false;
const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0));
if (incomingCompleteness < existingCompleteness) return true;
if (incomingCompleteness > existingCompleteness) return false;
const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0));
const existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
return incomingVersion > 0 && existingVersion > incomingVersion;
}
function makePageMetaForCache(pageIndex) {
const index = Math.max(0, Math.round(Number(pageIndex || 0)));
const paginationMeta = getPaginationPageMeta(index) || {};
@@ -2201,16 +2244,6 @@ function makePageMetaForCache(pageIndex) {
};
}
function makeResidentPageTextureKey(pageMetaOrIndex = {}) {
const pageMeta = typeof pageMetaOrIndex === 'number'
? makePageMetaForCache(pageMetaOrIndex)
: pageMetaOrIndex || {};
const pageIndex = Math.max(0, Math.round(Number(pageMeta.pageIndex || 0)));
const kind = String(pageMeta.kind || 'content');
const section = String(pageMeta.section || 'body');
return `${pageIndex}:${kind}:${section}`;
}
function spreadPageIndices(spreadIndex) {
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
return {
@@ -2219,67 +2252,6 @@ function spreadPageIndices(spreadIndex) {
};
}
function getResidentPageTexture(pageIndex) {
const key = makeResidentPageTextureKey(pageIndex);
const resident = residentPageTextures.get(key);
if (!resident) return null;
resident.lastUsedAt = performance.now();
residentPageTextures.delete(key);
residentPageTextures.set(key, resident);
return resident.texture || null;
}
function getResidentPageTextureForMeta(pageMeta = null) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!Number.isFinite(pageIndex)) return null;
const key = makeResidentPageTextureKey(pageMeta);
const resident = residentPageTextures.get(key);
if (!resident || isOlderPageTextureMeta(pageMeta, resident.pageMeta)) return null;
return getResidentPageTexture(pageIndex);
}
async function preloadCachedPageTexture(pageIndex) {
const meta = makePageMetaForCache(pageIndex);
const residentKey = makeResidentPageTextureKey(meta);
if (residentPageTextures.has(residentKey)) {
getResidentPageTexture(meta.pageIndex);
return residentPageTextures.get(residentKey)?.texture || null;
}
const cache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache') || null;
const sourceCanvas = await cache?.getPageCanvas?.(meta);
if (!sourceCanvas) {
const pageMeta = getPaginationPageMeta(meta.pageIndex);
if (pageMeta?.kind === 'blank') {
const blankTexture = getBlankPageTexture();
rememberResidentPageTexture({ ...meta, ...pageMeta }, blankTexture, null, false);
return blankTexture;
}
recordPageCacheProblem({
type: 'db-cache-miss',
pageIndex: meta.pageIndex,
width: meta.width,
height: meta.height
});
return null;
}
const texture = createPageCanvasTexture(sourceCanvas);
const cachedMeta = sourceCanvas.__webglPageCacheMeta || meta;
residentPageTextures.set(residentKey, {
texture,
sourceCanvas,
lastUsedAt: performance.now(),
ownsTexture: true,
pageMeta: cachedMeta
});
while (residentPageTextures.size > maxResidentPageTextures) {
const oldestKey = residentPageTextures.keys().next().value;
const oldest = residentPageTextures.get(oldestKey);
if (oldest?.ownsTexture) oldest.texture?.dispose?.();
residentPageTextures.delete(oldestKey);
}
return texture;
}
function getPaginationPageMeta(pageIndex) {
const index = Math.max(0, Math.round(Number(pageIndex || 0)));
const spreadIndex = Math.floor(index / 2);
@@ -2294,27 +2266,47 @@ function getPaginationPageMeta(pageIndex) {
}
async function prewarmSpreadTextures(spreadIndex) {
const indices = spreadPageIndices(spreadIndex);
const [left, right] = await Promise.all([
preloadCachedPageTexture(indices.left),
preloadCachedPageTexture(indices.right)
]);
return {
return pageTextureStore?.prewarmSpreadTextures?.(spreadIndex, makePageMetaForCache) || {
spreadIndex: Math.max(0, Math.round(Number(spreadIndex || 0))),
left,
right
left: null,
right: null
};
}
async function prewarmNavigationTextureWindow(reason = 'navigation-window', options = {}) {
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
const endSpread = Math.max(
0,
Math.round(Number(bookPaginationState.spreadCount || 1)) - 1,
pageToSpreadIndex(maxVisitedPagePosition)
);
markPageTextureTiming('textureStorePrewarm:start', {
reason,
currentSpread,
endSpread
});
const result = await pageTextureStore?.prewarmNavigationWindow?.({
currentSpread,
targetSpread: options.targetSpread,
endSpread,
getPageMetaForIndex: makePageMetaForCache,
recordMiss: options.recordMiss !== false
});
markPageTextureTiming('textureStorePrewarm:end', {
reason,
spreadCount: result ? Object.keys(result).length : 0
});
return result || {};
}
async function prewarmFlipTextures(direction, targetSpread = null) {
const currentSpread = Math.max(0, Number(bookPaginationState.spreadIndex || 0));
const nextSpread = Number.isFinite(Number(targetSpread))
? Math.max(0, Math.round(Number(targetSpread)))
: Math.max(0, currentSpread + Math.sign(Number(direction || 0)));
const [current, next] = await Promise.all([
prewarmSpreadTextures(currentSpread),
prewarmSpreadTextures(nextSpread)
]);
const windowMap = await prewarmNavigationTextureWindow('flip-prewarm', { targetSpread: nextSpread });
const current = windowMap?.[currentSpread] || await prewarmSpreadTextures(currentSpread);
const next = windowMap?.[nextSpread] || await prewarmSpreadTextures(nextSpread);
return {
current,
next
@@ -2323,19 +2315,22 @@ async function prewarmFlipTextures(direction, targetSpread = null) {
function takePreparedPageTexture(side, revealDetail = {}) {
const key = getRevealCacheKey(revealDetail);
const prepared = preparedPageTextures[side].get(key);
const prepared = pageTextureStore?.takePreparedPageTexture?.(side, key) || null;
if (!prepared) return null;
preparedPageTextures[side].delete(key);
markPageTextureTiming('preloadTexture:activate', { side, key });
return prepared;
}
function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
if (pageMeta?.kind === 'blank') {
applyExplicitBlankPageTexture(side, pageMeta, 'direct-upload');
return;
}
const texture = side === 'left' ? leftTexture : rightTexture;
const material = side === 'left' ? materials.leftPage : materials.rightPage;
const shouldUseResidentTexture = pageMeta?.kind !== 'title';
const residentTexture = shouldUseResidentTexture && Number.isFinite(Number(pageMeta?.pageIndex))
? getResidentPageTextureForMeta(pageMeta)
? pageTextureStore?.getResidentTextureForMeta?.(pageMeta)
: null;
markPageTextureTiming('directUpload:start', {
side,
@@ -2356,7 +2351,7 @@ function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
material.needsUpdate = true;
}
bindPageTextureSource(side, texture, sourceCanvas);
rememberResidentPageTexture(pageMeta, texture, sourceCanvas, false);
pageTextureStore?.rememberResidentTexture?.(pageMeta, texture, sourceCanvas, false);
markPageTextureTiming('directUpload:end', { side });
}
@@ -2381,7 +2376,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
}
bindPageTextureSource(side, texture, sourceCanvas);
}
const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null);
const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? pageTextureStore?.createTextureFromCanvas?.(revealDetail.baseCanvas) : null);
const revealBlockIds = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds.map(value => String(value)) : [];
const activeStartedAt = revealBlockIds
@@ -2397,6 +2392,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)),
blockIds: revealBlockIds,
baseTexture,
pageFlipAfterReveal: revealDetail.pageFlipAfterReveal === true,
fastForwarding: false,
fastForwardStartedAt: null,
fastForwardStartElapsedMs: 0,
@@ -2408,12 +2404,12 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
if (shader?.uniforms?.bookRevealElapsedMs) {
shader.uniforms.bookRevealElapsedMs.value = pageRevealState[side].visualElapsedMs;
}
if (side === 'right' && isRightBodyPageComplete()) {
if (side === 'right' && revealDetail.pageFlipAfterReveal === true) {
const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1);
prewarmFlipTextures(1, targetSpread).then(() => {
markPageTextureTiming('rightPageReveal:flip-prewarm-ready', { targetSpread });
}).catch((error) => {
recordPageCacheProblem({
pageTextureStore?.recordProblem?.({
type: 'right-page-flip-prewarm-error',
targetSpread,
message: error?.message || String(error)
@@ -2618,7 +2614,8 @@ function updatePageRevealAnimations(now) {
document.dispatchEvent(new CustomEvent('webgl-book:reveal-committed', {
detail: {
side,
blockIds: state.blockIds
blockIds: state.blockIds,
pageFlipAfterReveal: state.pageFlipAfterReveal === true
}
}));
});
@@ -2632,8 +2629,11 @@ function bindPageTextureSource(side, texture, sourceCanvas) {
width: nextCanvas?.width || 0,
height: nextCanvas?.height || 0
});
texture.image = sourceCanvas || fallbackCanvas;
texture.needsUpdate = true;
const boundTexture = pageTextureStore?.bindVisibleTextureSource?.(side, sourceCanvas) || null;
if (!boundTexture) {
texture.image = nextCanvas;
texture.needsUpdate = true;
}
updatePageTextureDebugState(side, nextCanvas, sourceCanvas, true);
markPageTextureTiming('bindPageTextureSource:end', { side });
}
@@ -2841,20 +2841,27 @@ function createPageFlip(direction, startTime, duration) {
function prepareStaticPageForFlip(flip, prewarm = null) {
if (!flip) return false;
const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage;
const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture);
const sourceSide = flip.direction > 0 ? 'right' : 'left';
const sourceTexture = resolveCurrentFlipSourceTexture(sourceSide);
const sourcePageMeta = currentPageMeta?.[sourceSide] || getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[sourceSide]) || null;
const targetSpread = Number.isFinite(Number(flip.targetSpread))
? Math.max(0, Math.round(Number(flip.targetSpread)))
: Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0)));
const targetPages = spreadPageIndices(targetSpread);
const targetBackPageIndex = flip.direction > 0 ? targetPages.left : targetPages.right;
const targetBackSide = flip.direction > 0 ? 'left' : 'right';
const targetBackPageIndex = targetPages[targetBackSide];
const targetBackPageMeta = getPaginationPageMeta(targetBackPageIndex) || makeBlankPageMeta(targetBackPageIndex);
const prewarmedBackTexture = flip.direction > 0 ? prewarm?.next?.left : prewarm?.next?.right;
const residentBackTexture = prewarmedBackTexture || getResidentPageTexture(targetBackPageIndex);
const requiresWrittenTexture = targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0));
if (!residentBackTexture && requiresWrittenTexture) {
recordPageCacheProblem({
type: 'flip-back-texture-missing',
const backTexture = resolveFlipBackTexture(targetBackPageMeta, prewarmedBackTexture);
const requiresWrittenTexture = targetBackPageMeta.kind !== 'blank'
&& targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0));
if (!sourceTexture || (!backTexture && requiresWrittenTexture)) {
pageTextureStore?.recordProblem?.({
type: !sourceTexture ? 'flip-source-texture-missing' : 'flip-back-texture-missing',
sourceSide,
sourcePageIndex: sourcePageMeta?.pageIndex ?? null,
targetBackPageIndex,
targetBackKind: targetBackPageMeta.kind,
targetSpread,
direction: flip.direction,
prewarmedCurrent: Boolean(prewarm?.current),
@@ -2862,9 +2869,8 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
});
return false;
}
const backTexture = residentBackTexture || getBlankPageTexture();
materials.flipPageSurface.map = sourceTexture;
materials.flipPageBackSurface.map = backTexture;
materials.flipPageBackSurface.map = backTexture || getBlankPageTexture();
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
@@ -2872,8 +2878,24 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
materials.flipPageSurface.needsUpdate = true;
materials.flipPageBackSurface.needsUpdate = true;
flip.sourceTexture = sourceTexture;
flip.backTexture = backTexture;
flip.sourcePageMeta = sourcePageMeta ? { ...sourcePageMeta } : null;
flip.backTexture = backTexture || getBlankPageTexture();
flip.backPageMeta = targetBackPageMeta ? { ...targetBackPageMeta } : null;
flip.targetBackPageIndex = targetBackPageIndex;
flip.sourcePageSide = sourceSide;
lastFlipTexturePreflight = {
direction: flip.direction,
sourceSide,
sourcePageIndex: sourcePageMeta?.pageIndex ?? null,
sourceKind: sourcePageMeta?.kind || 'content',
targetSpread,
targetBackSide,
targetBackPageIndex,
targetBackKind: targetBackPageMeta.kind,
hasSourceTexture: Boolean(sourceTexture),
hasBackTexture: Boolean(backTexture || getBlankPageTexture()),
sourceTextureMatchesBackTexture: sourceTexture === (backTexture || getBlankPageTexture())
};
if (flip.direction > 0) {
const blankTexture = getBlankPageTexture();
if (blankTexture && materials.rightPage.map !== blankTexture) {
@@ -2890,15 +2912,27 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
}
}
markPageTextureTiming('flipTexturePreflight:ready', {
direction: flip.direction,
sourceSide: flip.sourcePageSide,
targetSpread,
targetBackPageIndex,
usedResidentBackTexture: Boolean(residentBackTexture)
...lastFlipTexturePreflight,
usedResidentBackTexture: Boolean(backTexture && backTexture !== getBlankPageTexture())
});
return true;
}
function resolveCurrentFlipSourceTexture(side) {
const pageMeta = currentPageMeta?.[side] || null;
if (pageMeta?.kind === 'blank') return getBlankPageTexture();
const resident = pageTextureStore?.getResidentTextureForMeta?.(pageMeta);
if (resident) return resident;
const material = side === 'left' ? materials.leftPage : materials.rightPage;
return material?.map || null;
}
function resolveFlipBackTexture(pageMeta = null, prewarmedTexture = null) {
if (pageMeta?.kind === 'blank') return getBlankPageTexture();
if (prewarmedTexture) return prewarmedTexture;
return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);
}
function canPageFlip(direction) {
if (!currentProceduralBookModel) return false;
const currentPage = getCurrentPagePosition();
@@ -2908,7 +2942,7 @@ function canPageFlip(direction) {
}
function handleRevealCommittedForPageFlip(detail = {}) {
if (detail.side !== 'right' || !isRightBodyPageComplete()) return;
if (detail.side !== 'right' || detail.pageFlipAfterReveal !== true) return;
if (activeFlips.length > 0 || pendingRightPageFlip) return;
if (isChoiceAwaitingPlayer()) return;
const autoplayFlip = isTtsPlaybackActive();
@@ -2937,16 +2971,6 @@ async function tryStartPendingRightPageFlip(reason = 'pending', options = {}) {
return flipped;
}
function isRightBodyPageComplete() {
const meta = currentPageMeta?.right || null;
if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false;
const rendererDebug = window.BookTextureRenderer?.currentSpread || null;
const rightLines = Array.isArray(rendererDebug?.right) ? rendererDebug.right : [];
const maxLine = rightLines.reduce((max, line) => Math.max(max, Number(line?.pageLine || 0) + Math.max(1, Number(line?.lineCount || 1))), 0);
const expectedLines = Math.max(1, Number(meta.linesPerPage || 25));
return maxLine >= expectedLines;
}
function isChoiceAwaitingPlayer() {
return document.documentElement.dataset.choiceAwaiting === 'true'
|| document.body?.dataset?.choiceAwaiting === 'true'
+400
View File
@@ -18,16 +18,63 @@ class WebGLPageCacheModule extends BaseModule {
this.maxCacheSizeBytes = 5 * 1024 * 1024 * 1024;
this.memoryCanvasCache = new Map();
this.maxMemoryCanvasCount = 256;
this.textureRuntime = null;
this.residentTextures = new Map();
this.maxResidentTextureCount = 192;
this.preparedTextures = {
left: new Map(),
right: new Map()
};
this.preparedRevealPlans = new Map();
this.visibleTextures = {
left: null,
right: null
};
this.visibleFallbackCanvases = {
left: null,
right: null
};
this.maxPreparedTextureCount = 128;
this.blankTexture = null;
this.problemLog = [];
this.pendingPageWrites = new Map();
this.bindMethods([
'initialize',
'openDB',
'configureTextureRuntime',
'cachePageCanvas',
'getPageCanvas',
'putPageCanvas',
'storePageCanvas',
'preparePageTexture',
'takePreparedPageTexture',
'rememberPreparedRevealPlan',
'takePreparedRevealPlan',
'hasPreparedRevealPlan',
'registerVisibleTexture',
'bindVisibleTextureSource',
'getVisibleTexture',
'rememberResidentTexture',
'getResidentTexture',
'getResidentTextureForMeta',
'ensurePageTexture',
'prewarmPageTexture',
'prewarmSpreadTextures',
'prewarmNavigationWindow',
'getBlankTexture',
'createTextureFromCanvas',
'disposeTextureRecord',
'makePageKey',
'getPageWriteKey',
'makeResidentKey',
'cloneCanvas',
'canvasToBlob',
'blobToCanvas',
'isOlderPageEntry',
'isOlderPageMeta',
'recordProblem',
'getRuntimeState',
'manageCacheSize',
'calculateTotalCacheSize',
'deleteEntry',
@@ -53,6 +100,25 @@ class WebGLPageCacheModule extends BaseModule {
}
}
configureTextureRuntime({
THREE = null,
renderer = null,
configureTexture = null,
createBlankCanvas = null,
maxResidentTextureCount = this.maxResidentTextureCount,
maxPreparedTextureCount = this.maxPreparedTextureCount
} = {}) {
this.textureRuntime = {
THREE,
renderer,
configureTexture,
createBlankCanvas
};
this.maxResidentTextureCount = Math.max(1, Math.round(Number(maxResidentTextureCount || this.maxResidentTextureCount)));
this.maxPreparedTextureCount = Math.max(1, Math.round(Number(maxPreparedTextureCount || this.maxPreparedTextureCount)));
return this.getRuntimeState();
}
openDB() {
if (this.db) return Promise.resolve(this.db);
return new Promise((resolve, reject) => {
@@ -93,6 +159,303 @@ class WebGLPageCacheModule extends BaseModule {
return `${cacheKey}:page:${safePage}:${safeKind}:${safeSection}:${safeWidth}x${safeHeight}`;
}
getPageWriteKey(pageMeta = {}, canvas = null) {
return this.makePageKey({
...pageMeta,
width: canvas?.width ?? pageMeta.width,
height: canvas?.height ?? pageMeta.height
});
}
makeResidentKey(pageMetaOrIndex = {}) {
const pageMeta = typeof pageMetaOrIndex === 'number'
? { pageIndex: pageMetaOrIndex }
: pageMetaOrIndex || {};
const pageIndex = Math.max(0, Math.round(Number(pageMeta.pageIndex || 0)));
const kind = String(pageMeta.kind || 'content').replace(/[^a-z0-9_-]/gi, '');
const section = String(pageMeta.section || 'body').replace(/[^a-z0-9_-]/gi, '');
return `${pageIndex}:${kind}:${section}`;
}
createTextureFromCanvas(canvas = null) {
const runtime = this.textureRuntime || {};
if (!canvas || !runtime.THREE?.CanvasTexture) return null;
const texture = new runtime.THREE.CanvasTexture(canvas);
if (typeof runtime.configureTexture === 'function') runtime.configureTexture(texture);
texture.needsUpdate = true;
if (typeof runtime.renderer?.initTexture === 'function') {
runtime.renderer.initTexture(texture);
texture.needsUpdate = false;
}
return texture;
}
getBlankTexture() {
if (this.blankTexture) return this.blankTexture;
const canvas = this.textureRuntime?.createBlankCanvas?.();
this.blankTexture = this.createTextureFromCanvas(canvas);
return this.blankTexture;
}
async putPageCanvas(pageMeta = {}, canvas = null, options = {}) {
const texture = options.resident === false
? null
: this.rememberResidentTexture(pageMeta, this.createTextureFromCanvas(canvas), canvas, true);
if (options.persist !== false) {
const stored = await this.cachePageCanvas(pageMeta, canvas);
return texture || stored;
}
return texture;
}
storePageCanvas(pageMeta = {}, canvas = null, options = {}) {
if (!canvas || !pageMeta || !Number.isFinite(Number(pageMeta.pageIndex))) return Promise.resolve(false);
const frozenCanvas = this.cloneCanvas(canvas);
const key = this.getPageWriteKey(pageMeta, frozenCanvas);
const pending = this.pendingPageWrites.get(key);
if (pending && this.isOlderPageMeta(pageMeta, pending.pageMeta)) return pending.promise;
const previousWrite = pending?.promise || Promise.resolve();
const write = previousWrite.catch(() => false)
.then(() => this.putPageCanvas(pageMeta, frozenCanvas, {
persist: options.persist !== false,
resident: options.resident !== false
}))
.then((stored) => {
if (!stored) {
this.recordProblem({
type: 'db-write-failed',
pageIndex: pageMeta?.pageIndex ?? null,
key
});
}
return stored;
})
.catch((error) => {
this.recordProblem({
type: 'db-write-error',
pageIndex: pageMeta?.pageIndex ?? null,
key,
message: error?.message || String(error)
});
return false;
})
.finally(() => {
if (this.pendingPageWrites.get(key)?.promise === write) {
this.pendingPageWrites.delete(key);
}
});
this.pendingPageWrites.set(key, {
promise: write,
pageMeta: { ...(pageMeta || {}) }
});
return write;
}
cloneCanvas(canvas = null) {
if (!canvas) return null;
const clone = document.createElement('canvas');
clone.width = canvas.width;
clone.height = canvas.height;
const context = clone.getContext('2d');
if (context) context.drawImage(canvas, 0, 0);
return clone;
}
preparePageTexture(side = 'left', key = '', pageMeta = {}, canvas = null, revealDetail = {}) {
if (!canvas || !key) return null;
const normalizedSide = side === 'right' ? 'right' : 'left';
const texture = this.createTextureFromCanvas(canvas);
const baseTexture = revealDetail?.baseCanvas ? this.createTextureFromCanvas(revealDetail.baseCanvas) : null;
this.preparedTextures[normalizedSide].set(key, {
texture,
baseTexture,
sourceCanvas: canvas,
revealDetail,
pageMeta: { ...(pageMeta || {}) },
uploadedAt: performance.now()
});
this.rememberResidentTexture(pageMeta, texture, canvas, false);
while (this.preparedTextures[normalizedSide].size > this.maxPreparedTextureCount) {
const oldestKey = this.preparedTextures[normalizedSide].keys().next().value;
const oldest = this.preparedTextures[normalizedSide].get(oldestKey);
this.disposeTextureRecord(oldest);
this.preparedTextures[normalizedSide].delete(oldestKey);
}
return texture;
}
takePreparedPageTexture(side = 'left', key = '') {
const normalizedSide = side === 'right' ? 'right' : 'left';
const prepared = this.preparedTextures[normalizedSide].get(key);
if (!prepared) return null;
this.preparedTextures[normalizedSide].delete(key);
return prepared;
}
rememberPreparedRevealPlan(blockId = '', prepared = null) {
const id = String(blockId ?? '');
if (!id || !prepared) return null;
this.preparedRevealPlans.set(id, {
...prepared,
storedAt: performance.now()
});
while (this.preparedRevealPlans.size > this.maxPreparedTextureCount) {
const oldestKey = this.preparedRevealPlans.keys().next().value;
this.preparedRevealPlans.delete(oldestKey);
}
return prepared;
}
takePreparedRevealPlan(blockId = '') {
const id = String(blockId ?? '');
const prepared = this.preparedRevealPlans.get(id);
if (!prepared) return null;
this.preparedRevealPlans.delete(id);
return prepared;
}
hasPreparedRevealPlan(blockId = '') {
const id = String(blockId ?? '');
return Boolean(id && this.preparedRevealPlans.has(id));
}
registerVisibleTexture(side = 'left', texture = null, fallbackCanvas = null) {
const normalizedSide = side === 'right' ? 'right' : 'left';
this.visibleTextures[normalizedSide] = texture || null;
this.visibleFallbackCanvases[normalizedSide] = fallbackCanvas || null;
return texture || null;
}
bindVisibleTextureSource(side = 'left', sourceCanvas = null) {
const normalizedSide = side === 'right' ? 'right' : 'left';
const texture = this.visibleTextures[normalizedSide];
const canvas = sourceCanvas || this.visibleFallbackCanvases[normalizedSide] || null;
if (!texture || !canvas) return null;
texture.image = canvas;
texture.needsUpdate = true;
return texture;
}
getVisibleTexture(side = 'left') {
return this.visibleTextures[side === 'right' ? 'right' : 'left'] || null;
}
rememberResidentTexture(pageMeta = {}, texture = null, sourceCanvas = null, ownsTexture = true) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!texture || !Number.isFinite(pageIndex) || pageIndex < 0) return null;
const key = this.makeResidentKey(pageMeta);
const existing = this.residentTextures.get(key);
if (this.isOlderPageMeta(pageMeta, existing?.pageMeta)) return existing?.texture || null;
if (existing?.ownsTexture && existing.texture && existing.texture !== texture) existing.texture.dispose?.();
this.residentTextures.set(key, {
texture,
sourceCanvas: sourceCanvas || existing?.sourceCanvas || null,
lastUsedAt: performance.now(),
ownsTexture,
pageMeta: {
...(existing?.pageMeta || {}),
...(pageMeta || {})
}
});
while (this.residentTextures.size > this.maxResidentTextureCount) {
const oldestKey = this.residentTextures.keys().next().value;
const oldest = this.residentTextures.get(oldestKey);
if (oldest?.ownsTexture) oldest.texture?.dispose?.();
this.residentTextures.delete(oldestKey);
}
return texture;
}
getResidentTexture(pageMetaOrIndex = {}) {
const key = this.makeResidentKey(pageMetaOrIndex);
const resident = this.residentTextures.get(key);
if (!resident) return null;
resident.lastUsedAt = performance.now();
this.residentTextures.delete(key);
this.residentTextures.set(key, resident);
return resident.texture || null;
}
getResidentTextureForMeta(pageMeta = {}) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!Number.isFinite(pageIndex)) return null;
const key = this.makeResidentKey(pageMeta);
const resident = this.residentTextures.get(key);
if (!resident || this.isOlderPageMeta(pageMeta, resident.pageMeta)) return null;
return this.getResidentTexture(pageMeta);
}
async ensurePageTexture(pageMeta = {}, options = {}) {
if (pageMeta?.kind === 'blank') {
return this.rememberResidentTexture(pageMeta, this.getBlankTexture(), null, false);
}
const resident = this.getResidentTextureForMeta(pageMeta);
if (resident) return resident;
if (options.canvas) return this.putPageCanvas(pageMeta, options.canvas, {
persist: options.persist !== false,
resident: true
});
const sourceCanvas = await this.getPageCanvas(pageMeta);
if (!sourceCanvas) {
if (options.recordMiss !== false) {
this.recordProblem({
type: 'db-cache-miss',
pageIndex: pageMeta?.pageIndex ?? null,
width: pageMeta?.width ?? null,
height: pageMeta?.height ?? null
});
}
return null;
}
const cachedMeta = sourceCanvas.__webglPageCacheMeta || pageMeta;
return this.rememberResidentTexture(cachedMeta, this.createTextureFromCanvas(sourceCanvas), sourceCanvas, true);
}
async prewarmPageTexture(pageMeta = {}, options = {}) {
return this.ensurePageTexture(pageMeta, {
recordMiss: options.recordMiss !== false && pageMeta?.kind !== 'blank'
});
}
async prewarmSpreadTextures(spreadIndex = 0, getPageMetaForIndex = null, options = {}) {
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
const leftIndex = spread * 2;
const rightIndex = leftIndex + 1;
const leftMeta = getPageMetaForIndex?.(leftIndex) || { pageIndex: leftIndex, kind: 'blank', section: leftIndex < 3 ? 'frontmatter' : 'body' };
const rightMeta = getPageMetaForIndex?.(rightIndex) || { pageIndex: rightIndex, kind: 'blank', section: rightIndex < 3 ? 'frontmatter' : 'body' };
const [left, right] = await Promise.all([
this.prewarmPageTexture(leftMeta, options),
this.prewarmPageTexture(rightMeta, options)
]);
return { spreadIndex: spread, left, right };
}
async prewarmNavigationWindow({
currentSpread = 0,
targetSpread = null,
endSpread = 0,
getPageMetaForIndex = null,
recordMiss = true
} = {}) {
const current = Math.max(0, Math.round(Number(currentSpread || 0)));
const end = Math.max(0, Math.round(Number(endSpread || 0)));
const spreads = new Set([0, end, current, Math.max(0, current - 1), current + 1]);
const explicitTarget = Number.isFinite(Number(targetSpread)) ? Math.max(0, Math.round(Number(targetSpread))) : null;
if (explicitTarget !== null) spreads.add(explicitTarget);
const upperBound = Math.max(end, current + 1, explicitTarget ?? 0);
const bounded = Array.from(spreads).filter(value => value >= 0 && value <= upperBound);
const results = await Promise.all(bounded.map(spread => this.prewarmSpreadTextures(spread, getPageMetaForIndex, { recordMiss })));
return results.reduce((map, spread) => {
map[spread.spreadIndex] = spread;
return map;
}, {});
}
disposeTextureRecord(record = null) {
record?.texture?.dispose?.();
record?.baseTexture?.dispose?.();
}
async cachePageCanvas(pageMeta = {}, canvas = null) {
if (!canvas || !this.db || this.cacheStatus !== 'ready') return false;
const pageIndex = Number(pageMeta.pageIndex);
@@ -200,6 +563,43 @@ class WebGLPageCacheModule extends BaseModule {
return incomingVersion > 0 && existingVersion > incomingVersion;
}
isOlderPageMeta(incoming = {}, existing = null) {
if (!existing) return false;
const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0));
if (incomingCompleteness < existingCompleteness) return true;
if (incomingCompleteness > existingCompleteness) return false;
const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0));
const existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
return incomingVersion > 0 && existingVersion > incomingVersion;
}
recordProblem(detail = {}) {
const entry = {
...detail,
at: performance.now()
};
this.problemLog.push(entry);
if (this.problemLog.length > 80) this.problemLog.splice(0, this.problemLog.length - 80);
document.documentElement.dataset.webglPageCacheProblems = JSON.stringify(this.problemLog);
console.warn('WebGL page texture store problem', entry);
return entry;
}
getRuntimeState() {
return {
cacheStatus: this.cacheStatus,
residentTextureCount: this.residentTextures.size,
maxResidentTextureCount: this.maxResidentTextureCount,
preparedTextureCount: this.preparedTextures.left.size + this.preparedTextures.right.size,
preparedRevealPlanCount: this.preparedRevealPlans.size,
pendingPageWriteCount: this.pendingPageWrites.size,
problemCount: this.problemLog.length,
hasRuntime: Boolean(this.textureRuntime?.THREE && this.textureRuntime?.renderer),
hasBlankTexture: Boolean(this.blankTexture)
};
}
canvasToBlob(canvas) {
return new Promise((resolve) => {
if (typeof canvas.toBlob !== 'function') {