Checkpoint WebGL book playback refactor state
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user