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
+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;
}