b0175b7cdc
Two robustness gaps from the worker migration, both raised in review: - The raster worker had no failure recovery: a thrown createImageBitmap/font error or a dropped message would leave the draw promise pending forever, stalling the serialized draw chain and hanging prepare/playback. Added worker.onerror and a per-job timeout; both settle the in-flight draw to a logged miss (texture-worker-error / -timeout) so the pipeline degrades to last-good per the spec instead of hanging. A single settleRasterization path clears the timer and resolves. - prepareSpreadTextureRecordsForFlip() called drawSpread() without awaiting it. That was safe when drawSpread was synchronous, but now that it is async (worker) the flip could race ahead of the worker draw and miss the resident texture. prewarmFlipTextures now awaits both spread draws before the resident-texture lookup. Suite 168 (added assertions for worker error/timeout recovery and the awaited prewarm). Normal draw path is behaviorally unchanged from the verified worker commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1047 lines
48 KiB
JavaScript
1047 lines
48 KiB
JavaScript
/**
|
|
* Book Texture Renderer Module
|
|
* Draws the virtual book pages directly into texture-space canvases.
|
|
*/
|
|
import { BaseModule } from './base-module.js';
|
|
|
|
class BookTextureRendererModule extends BaseModule {
|
|
constructor() {
|
|
super('book-texture-renderer', 'Book Texture Renderer');
|
|
this.dependencies = ['book-page-format', 'book-pagination', 'localization', 'game-config', 'webgl-page-cache'];
|
|
this.pageFormat = null;
|
|
this.pagination = null;
|
|
this.localization = null;
|
|
this.gameConfig = null;
|
|
this.pageCache = null;
|
|
this.metrics = null;
|
|
this.canvases = {
|
|
left: null,
|
|
right: null
|
|
};
|
|
this.contexts = {
|
|
left: null,
|
|
right: null
|
|
};
|
|
this.hitMaps = {
|
|
left: [],
|
|
right: []
|
|
};
|
|
this.currentSpread = null;
|
|
this.activeAnimations = new Map();
|
|
this.revealedBlockIds = new Set();
|
|
this.revealBaseCanvases = null;
|
|
this.revealPublishBlockIds = null;
|
|
// During lookahead we prepare a block that has not been committed to pagination yet,
|
|
// so this.pagination.spreads does not include its (preview) spreads. When set, reveal
|
|
// region collection uses these preview spreads instead, so a spanning block's reveal
|
|
// timing is computed across both spreads in the background (no synchronous rebuild on
|
|
// the critical path at activate / after the flip). See no-synchronous-main-thread rule.
|
|
this.revealSpreadSourceOverride = null;
|
|
this.lastDrawSignature = null;
|
|
this.lastDrawSkipLoggedAt = 0;
|
|
this.pipelineTimings = [];
|
|
this.pageContentVersions = new Map();
|
|
|
|
this.bindMethods([
|
|
'initialize',
|
|
'markPipelineTiming',
|
|
'waitForTextureFonts',
|
|
'ensureTextureFontFace',
|
|
'createPageCanvases',
|
|
'createRasterWorker',
|
|
'drawSpread',
|
|
'drawSpreadSerial',
|
|
'rasterizeSpread',
|
|
'getDrawSignature',
|
|
'cloneCanvas',
|
|
'buildRevealRegions',
|
|
'shouldFlipAfterSideReveal',
|
|
'collectRevealRegionCandidates',
|
|
'createRevealRegionForLine',
|
|
'assignRevealTiming',
|
|
'getLineInkRect',
|
|
'getLineNaturalWidth',
|
|
'getLineWordCount',
|
|
'getImageRevealDurationMs',
|
|
'getPageContent',
|
|
'prepareRevealBlock',
|
|
'prepareContinuationRevealPlan',
|
|
'takeContinuationRevealPlan',
|
|
'preloadAdditionalRevealSpreads',
|
|
'spreadContainsBlock',
|
|
'createAnimationState',
|
|
'getDrawPhase',
|
|
'publishPreparedReveal',
|
|
'startPreparedRevealAnimation',
|
|
'fastForwardAnimations',
|
|
'stopAnimations',
|
|
'getBlockSides',
|
|
'getAnimatedSides',
|
|
'publishSpread',
|
|
'buildPageTextureRecords',
|
|
'cachePublishedPages',
|
|
'getPageCanvas',
|
|
'getHitMap',
|
|
'handlePageCountChanged'
|
|
]);
|
|
}
|
|
|
|
async initialize() {
|
|
this.pageFormat = this.getModule('book-page-format');
|
|
this.pagination = this.getModule('book-pagination');
|
|
this.localization = this.getModule('localization');
|
|
this.gameConfig = this.getModule('game-config');
|
|
this.pageCache = this.getModule('webgl-page-cache');
|
|
window.BookTextureRendererDebug = {
|
|
pipelineTimings: this.pipelineTimings
|
|
};
|
|
this.reportProgress(10, 'Waiting for book fonts');
|
|
await this.waitForTextureFonts();
|
|
this.reportProgress(20, 'Preparing page texture canvases');
|
|
this.createPageCanvases();
|
|
this.createRasterWorker();
|
|
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
|
|
// The renderer is a pure renderer. It does not react to pagination spread
|
|
// updates with draws or reveals — the playback owner (book-playback-timeline)
|
|
// drives every draw explicitly. See docs/webgl-3d-ui-spec.md "Single ownership".
|
|
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
|
|
this.addEventListener(document, 'webgl-book:reveal-committed', (event) => {
|
|
this.completeRevealBlockIds(event.detail?.blockIds || []);
|
|
});
|
|
this.addEventListener(document, 'ui:command', (event) => {
|
|
if (event.detail?.type === 'continue') this.fastForwardAnimations();
|
|
});
|
|
this.addEventListener(document, 'story:manual-scroll', this.fastForwardAnimations);
|
|
this.addEventListener(document, 'story:history-restoring', this.stopAnimations);
|
|
this.addEventListener(document, 'story:client-reset', this.stopAnimations);
|
|
this.currentSpread = this.pagination?.getCurrentSpread?.() || { index: 0, left: [], right: [], pageMeta: { left: null, right: null } };
|
|
await this.drawSpread(this.currentSpread);
|
|
this.reportProgress(100, 'Book texture renderer ready');
|
|
return true;
|
|
}
|
|
|
|
createRasterWorker() {
|
|
const version = window.MODULE_CACHE_BUSTER ? `?v=${window.MODULE_CACHE_BUSTER}` : '';
|
|
this.rasterWorker = new Worker(`/js/book-texture-worker.js${version}`);
|
|
this.pendingRasterizations = new Map();
|
|
this.rasterRequestId = 0;
|
|
this.rasterTimeoutMs = 4000;
|
|
this.rasterChain = Promise.resolve();
|
|
this.rasterWorker.onmessage = (event) => {
|
|
const data = event.data || {};
|
|
if (data.type !== 'drawn') return;
|
|
this.settleRasterization(data.requestId, data.results);
|
|
};
|
|
// A worker crash or load failure must never leave a draw promise pending (that would
|
|
// stall the serialized draw chain and hang prepare/playback). Surface it and settle any
|
|
// in-flight draws to a logged miss so the pipeline degrades to last-good, not a hang.
|
|
this.rasterWorker.onerror = (event) => {
|
|
this.pageCache?.recordProblem?.({ type: 'texture-worker-error', message: event?.message || String(event) });
|
|
const pending = Array.from(this.pendingRasterizations.keys());
|
|
pending.forEach(id => this.settleRasterization(id, null));
|
|
};
|
|
// Warm the worker's fonts immediately so the first real page render is not delayed.
|
|
this.rasterWorker.postMessage({ type: 'warm-fonts' });
|
|
}
|
|
|
|
settleRasterization(requestId, results) {
|
|
const pending = this.pendingRasterizations.get(requestId);
|
|
if (!pending) return;
|
|
this.pendingRasterizations.delete(requestId);
|
|
clearTimeout(pending.timer);
|
|
pending.resolve(results);
|
|
}
|
|
|
|
// Plain, structured-cloneable subset of metrics the worker needs to draw a page.
|
|
buildWorkerMetrics() {
|
|
const m = this.metrics || {};
|
|
return {
|
|
width: m.width,
|
|
height: m.height,
|
|
content: m.content,
|
|
contentBySide: m.contentBySide,
|
|
typography: { fontFamily: m.typography?.fontFamily || 'serif' },
|
|
bodyFontSizePx: m.bodyFontSizePx,
|
|
typographyLineHeightPx: m.typographyLineHeightPx,
|
|
margins: { bottom: m.margins?.bottom || 0 }
|
|
};
|
|
}
|
|
|
|
// Title-page text lives in the DOM; read it here (the worker has no DOM) and pass it in.
|
|
buildTitleData() {
|
|
const metadata = this.gameConfig?.getMetadata?.() || {};
|
|
const t = this.localization?.t ? this.localization.t.bind(this.localization) : null;
|
|
return {
|
|
title: document.getElementById('game_title')?.textContent?.trim() || metadata.title || '',
|
|
author: document.getElementById('game_author')?.textContent?.trim()
|
|
|| (metadata.author && t ? t('title.byAuthor', { author: metadata.author }) : '') || '',
|
|
subtitle: document.getElementById('game_subtitle')?.textContent?.trim() || metadata.subtitle || '',
|
|
ornament: document.querySelector('#start_prompt .separator, #start_prompt .ornament, #start_prompt [class*="separator"]')?.textContent?.trim() || '',
|
|
legal: document.getElementById('game_legal_text')?.textContent?.trim() || [
|
|
metadata.version && t ? t('title.version', { version: metadata.version }) : '',
|
|
metadata.copyright || ''
|
|
].filter(Boolean).join(' | ')
|
|
};
|
|
}
|
|
|
|
rasterizeSpread(sidesToDraw, hasReveal) {
|
|
if (!this.rasterWorker || !this.metrics) return Promise.resolve(null);
|
|
const requestId = ++this.rasterRequestId;
|
|
const job = {
|
|
type: 'draw',
|
|
requestId,
|
|
width: this.metrics.width,
|
|
height: this.metrics.height,
|
|
sides: sidesToDraw,
|
|
hasReveal,
|
|
metrics: this.buildWorkerMetrics(),
|
|
pageMeta: this.currentSpread?.pageMeta || {},
|
|
titleData: this.buildTitleData(),
|
|
spreads: {
|
|
left: sidesToDraw.includes('left') ? (this.currentSpread?.left || []) : [],
|
|
right: sidesToDraw.includes('right') ? (this.currentSpread?.right || []) : []
|
|
}
|
|
};
|
|
return new Promise((resolve) => {
|
|
// Bound every job so a dropped/stuck worker response can never leave this promise
|
|
// pending and stall the draw chain; on timeout, settle to a logged miss (last-good).
|
|
const timer = setTimeout(() => {
|
|
if (!this.pendingRasterizations.has(requestId)) return;
|
|
this.pageCache?.recordProblem?.({ type: 'texture-worker-timeout', requestId, sides: sidesToDraw });
|
|
this.settleRasterization(requestId, null);
|
|
}, this.rasterTimeoutMs || 4000);
|
|
this.pendingRasterizations.set(requestId, { resolve, timer });
|
|
this.rasterWorker.postMessage(job);
|
|
});
|
|
}
|
|
|
|
canvasFromBitmap(bitmap) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = bitmap.width;
|
|
canvas.height = bitmap.height;
|
|
canvas.getContext('2d')?.drawImage(bitmap, 0, 0);
|
|
return canvas;
|
|
}
|
|
|
|
markPipelineTiming(name, detail = {}) {
|
|
const entry = {
|
|
name,
|
|
at: performance.now(),
|
|
detail
|
|
};
|
|
this.pipelineTimings.push(entry);
|
|
if (this.pipelineTimings.length > 120) this.pipelineTimings.splice(0, this.pipelineTimings.length - 120);
|
|
document.documentElement.dataset.webglTexturePipeline = JSON.stringify(this.pipelineTimings);
|
|
return entry;
|
|
}
|
|
|
|
async waitForTextureFonts() {
|
|
if (!document.fonts) return;
|
|
await Promise.all([
|
|
this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Regular.otf', { style: 'normal', weight: '400' }),
|
|
this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Italic.otf', { style: 'italic', weight: '400' }),
|
|
this.ensureTextureFontFace('EB Garamond 12', '/fonts/EBGaramond12/webfonts/EBGaramond-Regular.woff2'),
|
|
this.ensureTextureFontFace('EB Garamond Initials', '/fonts/EB-Garamond-Initials/EBGaramond-0.016/otf/EBGaramond-Initials.otf')
|
|
]);
|
|
await Promise.all([
|
|
document.fonts.load('24px "EB Garamond"'),
|
|
document.fonts.load('italic 24px "EB Garamond"'),
|
|
document.fonts.load('bold 24px "EB Garamond"'),
|
|
document.fonts.load('italic bold 24px "EB Garamond"'),
|
|
document.fonts.load('24px "EB Garamond 12"'),
|
|
document.fonts.load('72px "EB Garamond Initials"')
|
|
]);
|
|
await document.fonts.ready;
|
|
}
|
|
|
|
async ensureTextureFontFace(family, url, descriptors = {}) {
|
|
if (!window.FontFace) return;
|
|
const face = new FontFace(family, `url(${url})`, descriptors);
|
|
const loadedFace = await face.load();
|
|
document.fonts.add(loadedFace);
|
|
}
|
|
|
|
createPageCanvases(textureWidth = this.pageFormat?.getTextureWidth?.() || 3072) {
|
|
this.metrics = this.pageFormat.getTextureMetrics(textureWidth);
|
|
['left', 'right'].forEach((side) => {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = this.metrics.width;
|
|
canvas.height = this.metrics.height;
|
|
this.canvases[side] = canvas;
|
|
this.contexts[side] = canvas.getContext('2d');
|
|
});
|
|
}
|
|
|
|
// Rasterization runs in a worker and is therefore async. Serialize draws through a chain so
|
|
// the shared render state (currentSpread, revealPublishBlockIds, revealSpreadSourceOverride,
|
|
// revealBaseCanvases) is never mutated by an overlapping draw — the critical section from
|
|
// setting that state to publishSpread stays atomic even across the worker round trip.
|
|
drawSpread(spread = null, sides = null, options = {}) {
|
|
const run = () => this.drawSpreadSerial(spread, sides, options);
|
|
this.rasterChain = (this.rasterChain || Promise.resolve()).then(run, run);
|
|
return this.rasterChain;
|
|
}
|
|
|
|
async drawSpreadSerial(spread = null, sides = null, options = {}) {
|
|
const previousSpread = this.currentSpread;
|
|
this.currentSpread = spread || { left: [], right: [] };
|
|
// Reveal context is passed per draw (not left on the instance by the caller) so it can be
|
|
// set inside this serialized section without racing concurrent lookahead prepares.
|
|
this.revealPublishBlockIds = options.revealPublishBlockIds || null;
|
|
this.revealSpreadSourceOverride = options.revealSpreadSourceOverride || null;
|
|
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.force !== true && phase !== 'prepare' && !hasReveal && drawSignature === this.lastDrawSignature) {
|
|
const now = performance.now();
|
|
if (now - this.lastDrawSkipLoggedAt > 1000) {
|
|
this.lastDrawSkipLoggedAt = now;
|
|
this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw });
|
|
}
|
|
this.revealPublishBlockIds = null;
|
|
this.revealSpreadSourceOverride = null;
|
|
this.currentSpread = previousSpread;
|
|
return null;
|
|
}
|
|
this.markPipelineTiming('drawSpread:start', {
|
|
sides: sidesToDraw,
|
|
revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : [],
|
|
phase
|
|
});
|
|
this.revealBaseCanvases = { left: null, right: null };
|
|
const results = await this.rasterizeSpread(sidesToDraw, hasReveal);
|
|
sidesToDraw.forEach((side) => {
|
|
const result = results?.[side];
|
|
if (!this.canvases[side] || !result) return;
|
|
const ctx = this.contexts[side];
|
|
ctx.clearRect(0, 0, this.canvases[side].width, this.canvases[side].height);
|
|
ctx.drawImage(result.pageBitmap, 0, 0);
|
|
result.pageBitmap.close?.();
|
|
if (hasReveal && result.baseBitmap) {
|
|
this.revealBaseCanvases[side] = this.canvasFromBitmap(result.baseBitmap);
|
|
}
|
|
result.baseBitmap?.close?.();
|
|
});
|
|
const published = this.publishSpread(sidesToDraw, options);
|
|
this.markPipelineTiming('drawSpread:end', { sides: sidesToDraw, phase });
|
|
this.revealBaseCanvases = null;
|
|
this.revealPublishBlockIds = null;
|
|
this.revealSpreadSourceOverride = null;
|
|
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 => {
|
|
const lines = Array.isArray(source[side]) ? source[side] : [];
|
|
const meta = source.pageMeta?.[side] || {};
|
|
const ids = lines.map(line => `${line.type || 'line'}:${line.blockId ?? ''}:${line.lineIndex ?? ''}:${line.pageLine ?? ''}:${line.lineCount ?? ''}:${line.line?.nodes?.length || 0}`).join(',');
|
|
return `${side}:${meta.kind || ''}:${meta.pageIndex ?? ''}:${meta.pageNumber ?? ''}:${meta.omitPageNumber === true}[${ids}]`;
|
|
}).join('|');
|
|
}
|
|
|
|
cloneCanvas(canvas) {
|
|
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;
|
|
}
|
|
|
|
getPageContent(side = 'left') {
|
|
return this.metrics?.contentBySide?.[side] || this.metrics?.content || {
|
|
x: 0,
|
|
y: 0,
|
|
width: this.metrics?.width || 1,
|
|
height: this.metrics?.height || 1
|
|
};
|
|
}
|
|
|
|
buildRevealRegions(side) {
|
|
if (!this.revealPublishBlockIds || !this.metrics) return null;
|
|
const candidates = this.collectRevealRegionCandidates();
|
|
if (!candidates.length) return null;
|
|
const byBlock = candidates.reduce((map, region) => {
|
|
if (!map.has(region.blockId)) map.set(region.blockId, []);
|
|
map.get(region.blockId).push(region);
|
|
return map;
|
|
}, new Map());
|
|
const regions = [];
|
|
byBlock.forEach((blockRegions, blockId) => {
|
|
const animation = this.activeAnimations.get(blockId);
|
|
if (!animation || animation.completed) return;
|
|
regions.push(...this.assignRevealTiming(blockRegions, animation));
|
|
});
|
|
const currentSpreadIndex = Math.max(0, Number(this.currentSpread?.index ?? this.pagination?.currentSpreadIndex ?? 0));
|
|
const sideRegions = regions.filter(region => region.side === side && Math.max(0, Number(region.spreadIndex || 0)) === currentSpreadIndex);
|
|
if (!sideRegions.length) return null;
|
|
const bounds = sideRegions.reduce((box, region) => ({
|
|
x: Math.min(box.x, region.pixelRect.x),
|
|
y: Math.min(box.y, region.pixelRect.y),
|
|
right: Math.max(box.right, region.pixelRect.right),
|
|
bottom: Math.max(box.bottom, region.pixelRect.bottom)
|
|
}), {
|
|
x: this.metrics.width,
|
|
y: this.metrics.height,
|
|
right: 0,
|
|
bottom: 0
|
|
});
|
|
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,
|
|
lineIndex: region.lineIndex,
|
|
rect: region.rect,
|
|
timing: region.timing,
|
|
timingArea: region.timingArea || region.area || 0
|
|
})),
|
|
bounds: {
|
|
x: bounds.x / this.metrics.width,
|
|
y: bounds.y / this.metrics.height,
|
|
width: Math.max(0.001, (bounds.right - bounds.x) / this.metrics.width),
|
|
height: Math.max(0.001, (bounds.bottom - bounds.y) / this.metrics.height)
|
|
}
|
|
};
|
|
}
|
|
|
|
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 = [];
|
|
if (this.currentSpread) sourceSpreads.push(this.currentSpread);
|
|
const paginationSpreads = Array.isArray(this.revealSpreadSourceOverride)
|
|
? this.revealSpreadSourceOverride
|
|
: (Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : null);
|
|
if (paginationSpreads) {
|
|
paginationSpreads.forEach((spread) => {
|
|
if (!spread) return;
|
|
if (this.currentSpread && Number(spread.index) === Number(this.currentSpread.index)) return;
|
|
sourceSpreads.push(spread);
|
|
});
|
|
}
|
|
if (!sourceSpreads.length) sourceSpreads.push({ index: 0, left: [], right: [] });
|
|
sourceSpreads.forEach((spread) => {
|
|
['left', 'right'].forEach((side) => {
|
|
const spreadLines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
|
spreadLines.forEach((lineRecord) => {
|
|
const region = this.createRevealRegionForLine(side, lineRecord, spread?.index);
|
|
if (region) candidates.push(region);
|
|
});
|
|
});
|
|
});
|
|
return candidates;
|
|
}
|
|
|
|
assignRevealTiming(blockRegions = [], animation = {}) {
|
|
const requestedTotalDuration = Math.max(
|
|
Number(animation.totalDuration || 0),
|
|
...((Array.isArray(animation.wordTimings) ? animation.wordTimings : []).map(timing => Number(timing.delay || 0) + Number(timing.duration || 0)))
|
|
);
|
|
const sortedRegions = [...blockRegions].sort((a, b) => {
|
|
const aSpread = Math.max(0, Number(a.spreadIndex || 0));
|
|
const bSpread = Math.max(0, Number(b.spreadIndex || 0));
|
|
if (aSpread !== bSpread) return aSpread - bSpread;
|
|
const aLine = Math.max(0, Number(a.lineIndex || 0));
|
|
const bLine = Math.max(0, Number(b.lineIndex || 0));
|
|
return aLine - bLine;
|
|
});
|
|
const timedRegions = [];
|
|
const textRegions = sortedRegions.filter(region => !(region.fixedDurationMs > 0));
|
|
const fixedRegions = sortedRegions.filter(region => region.fixedDurationMs > 0);
|
|
const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.timingArea || region.area), 0);
|
|
const lineHeight = Math.max(1, Number(this.metrics?.typographyLineHeightPx || 1));
|
|
const estimatedTextWidth = totalArea / lineHeight;
|
|
const baseDuration = requestedTotalDuration > 1
|
|
? requestedTotalDuration
|
|
: Math.max(800, estimatedTextWidth * 16);
|
|
// Word-proportional scaling: these regions may cover only part of the block (the
|
|
// rest is on another spread this reveal does not include). Reveal only this portion's
|
|
// share of the block TTS, offset by the words before it, so the page reveals at
|
|
// normal pace and flips when its words are spoken — the continuation then resumes on
|
|
// the next spread instead of the page absorbing the whole TTS. When the regions cover
|
|
// the whole block (unified plan or single-page block) this is a no-op.
|
|
const totalBlockWords = Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0;
|
|
const collectedWords = textRegions.reduce((sum, region) => sum + Math.max(0, Number(region.blockWordCount || 0)), 0);
|
|
const wordsBefore = textRegions.reduce((min, region) => Math.min(min, Math.max(0, Number(region.blockWordStart || 0))), Number.POSITIVE_INFINITY);
|
|
const useWordShare = totalBlockWords > 0 && collectedWords > 0 && collectedWords < totalBlockWords;
|
|
const totalDuration = useWordShare ? baseDuration * (collectedWords / totalBlockWords) : baseDuration;
|
|
let fallbackDelay = useWordShare && Number.isFinite(wordsBefore) ? baseDuration * (wordsBefore / totalBlockWords) : 0;
|
|
textRegions.forEach((region) => {
|
|
const duration = totalArea > 0
|
|
? Math.max(1, totalDuration * (Math.max(1, region.timingArea || region.area) / totalArea))
|
|
: Math.max(1, totalDuration / Math.max(1, textRegions.length));
|
|
timedRegions.push({
|
|
...region,
|
|
timing: { delay: fallbackDelay, duration }
|
|
});
|
|
fallbackDelay += duration;
|
|
});
|
|
|
|
fixedRegions.forEach((region) => {
|
|
timedRegions.push({
|
|
...region,
|
|
timing: {
|
|
delay: fallbackDelay,
|
|
duration: Math.max(1, region.fixedDurationMs)
|
|
}
|
|
});
|
|
fallbackDelay += Math.max(1, region.fixedDurationMs);
|
|
});
|
|
|
|
return timedRegions.sort((a, b) => {
|
|
const aDelay = Number(a.timing?.delay || 0);
|
|
const bDelay = Number(b.timing?.delay || 0);
|
|
if (aDelay !== bDelay) return aDelay - bDelay;
|
|
return Number(a.lineIndex || 0) - Number(b.lineIndex || 0);
|
|
});
|
|
}
|
|
|
|
getLineTimingFromWords(region = {}, wordTimings = []) {
|
|
const start = Math.max(0, Math.floor(Number(region.blockWordStart || 0)));
|
|
const count = Math.max(1, Math.floor(Number(region.blockWordCount || 1)));
|
|
const first = wordTimings[Math.min(start, wordTimings.length - 1)] || { delay: 0, duration: 1 };
|
|
const lastIndex = Math.min(wordTimings.length - 1, start + count - 1);
|
|
const last = wordTimings[lastIndex] || first;
|
|
const delay = Math.max(0, Number(first.delay || 0));
|
|
const end = Math.max(
|
|
delay + 1,
|
|
Number(last.delay || 0) + Math.max(1, Number(last.duration || 1))
|
|
);
|
|
return {
|
|
delay,
|
|
duration: Math.max(1, end - delay)
|
|
};
|
|
}
|
|
|
|
createRevealRegionForLine(side, lineRecord = {}, spreadIndex = null) {
|
|
const blockId = String(lineRecord?.blockId ?? '');
|
|
if (!blockId || !this.revealPublishBlockIds.has(blockId)) return null;
|
|
const animation = this.activeAnimations.get(blockId);
|
|
if (!animation || animation.completed) return null;
|
|
if (lineRecord.type === 'image' || lineRecord.kind === 'image') {
|
|
const content = this.getPageContent(side);
|
|
const rect = lineRecord.metadata?.imageLayout?.textureRect || {};
|
|
const x = content.x + Number(rect.x || 0);
|
|
const y = content.y + Number(rect.y || 0);
|
|
const width = Math.max(1, Number(rect.width || content.width));
|
|
const height = Math.max(1, Number(rect.height || this.metrics.typographyLineHeightPx));
|
|
return this.normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, this.getImageRevealDurationMs(lineRecord), spreadIndex);
|
|
}
|
|
const rect = this.getLineInkRect(side, lineRecord);
|
|
if (!rect) return null;
|
|
return this.normalizeRevealRegion(side, blockId, lineRecord, rect.x, rect.y, rect.width, rect.height, 0, spreadIndex);
|
|
}
|
|
|
|
normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, fixedDurationMs = 0, spreadIndex = null) {
|
|
const padding = Math.max(2, Number(lineRecord.fontPx || 18) * 0.12);
|
|
const left = Math.max(0, x - padding);
|
|
const top = Math.max(0, y - padding);
|
|
const right = Math.min(this.metrics.width, x + width + padding);
|
|
const bottom = Math.min(this.metrics.height, y + height + padding);
|
|
const rectWidth = Math.max(1, right - left);
|
|
const rectHeight = Math.max(1, bottom - top);
|
|
const timingWidth = Math.max(1, Number(lineRecord.timingWidthPx || width || rectWidth));
|
|
const timingHeight = Math.max(1, Number(lineRecord.timingHeightPx || height || rectHeight));
|
|
return {
|
|
side,
|
|
spreadIndex: Math.max(0, Number((spreadIndex ?? Math.floor(Number(lineRecord.pageIndex || 0) / 2)) || 0)),
|
|
blockId,
|
|
lineIndex: Number(lineRecord.lineIndex ?? lineRecord.pageLine ?? 0),
|
|
blockWordStart: Number(lineRecord.blockWordStart ?? 0),
|
|
blockWordCount: Number(lineRecord.lineWordCount ?? 0),
|
|
fixedDurationMs,
|
|
area: rectWidth * rectHeight,
|
|
timingArea: timingWidth * timingHeight,
|
|
pixelRect: { x: left, y: top, right, bottom },
|
|
rect: {
|
|
x: left / this.metrics.width,
|
|
y: top / this.metrics.height,
|
|
width: Math.max(0.001, rectWidth / this.metrics.width),
|
|
height: Math.max(0.001, rectHeight / this.metrics.height)
|
|
}
|
|
};
|
|
}
|
|
|
|
getLineInkRect(side, lineRecord = {}) {
|
|
const content = this.getPageContent(side);
|
|
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
|
|
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || this.metrics.typographyLineHeightPx || 30));
|
|
const line = lineRecord.line || {};
|
|
const naturalWidth = this.getLineNaturalWidth(line);
|
|
const centerOffset = line.align === 'center'
|
|
? Math.max(0, (content.width - naturalWidth) / 2)
|
|
: Number(line.offset || 0);
|
|
const measuredWidth = Number(line.measure || lineRecord.measure || 0);
|
|
const isJustified = line.align !== 'center' && !line.isFinal;
|
|
let x = content.x + centerOffset;
|
|
let y = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx);
|
|
let width = Math.max(1, Math.min(content.width - centerOffset, isJustified ? (measuredWidth || content.width - centerOffset) : (naturalWidth || measuredWidth || content.width - centerOffset)));
|
|
let height = lineHeightPx;
|
|
if (lineRecord.dropCapText) {
|
|
const dropCapFontPx = Math.round(fontPx * 2.68);
|
|
const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25);
|
|
const dropCapWidth = fontPx * 2.9;
|
|
const normalRight = x + width;
|
|
x = Math.min(content.x, x);
|
|
y = Math.min(y, dropCapY);
|
|
width = Math.max(normalRight, content.x + dropCapWidth) - x;
|
|
height = Math.max((content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx)) + lineHeightPx, dropCapY + (dropCapFontPx * 0.9)) - y;
|
|
}
|
|
return { x, y, width, height };
|
|
}
|
|
|
|
getLineNaturalWidth(line = {}) {
|
|
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
|
|
return nodes.reduce((sum, node) => {
|
|
if (node?.type === 'box' || node?.type === 'glue') return sum + Number(node.width || 0);
|
|
return sum;
|
|
}, 0);
|
|
}
|
|
|
|
getLineWordCount(line = {}) {
|
|
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
|
|
let count = 0;
|
|
let previousWasGlue = true;
|
|
nodes.forEach((node) => {
|
|
if (!node) return;
|
|
if (node.type === 'glue') {
|
|
previousWasGlue = true;
|
|
return;
|
|
}
|
|
if (node.type === 'penalty') return;
|
|
if (node.type === 'box' && node.value) {
|
|
if (previousWasGlue) count += 1;
|
|
previousWasGlue = false;
|
|
}
|
|
});
|
|
return count;
|
|
}
|
|
|
|
getImageRevealDurationMs(lineRecord = {}) {
|
|
const metadata = lineRecord.metadata || {};
|
|
const explicit = Number(metadata.animationMs || metadata.revealMs || metadata.imageRevealMs || 0);
|
|
return Number.isFinite(explicit) && explicit > 0 ? explicit : 2000;
|
|
}
|
|
|
|
createAnimationState(blockId, wordTimings = [], detail = {}) {
|
|
return {
|
|
blockId,
|
|
wordTimings,
|
|
startedAt: null,
|
|
totalDuration: Math.max(
|
|
Number(detail.totalDuration || 0),
|
|
...wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
|
|
),
|
|
completed: false,
|
|
prepared: true
|
|
};
|
|
}
|
|
|
|
async prepareRevealBlock(detail = {}, options = {}) {
|
|
const blockId = detail.blockId ?? detail.id ?? null;
|
|
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
|
|
const id = String(blockId);
|
|
const wordTimings = detail.wordTimings;
|
|
const phase = detail.phase === 'prepare' || options.phase === 'prepare'
|
|
? 'prepare'
|
|
: 'activate';
|
|
this.markPipelineTiming('prepareRevealBlock:start', {
|
|
blockId: id,
|
|
wordTimingCount: wordTimings.length,
|
|
phase
|
|
});
|
|
// At activate, reuse the plan prepared during lookahead (it is spanning-aware when the
|
|
// block overflows). Building only happens when no plan was prepared yet.
|
|
if (phase === 'activate' && this.pageCache?.hasPreparedRevealPlan?.(id)) {
|
|
const cached = this.pageCache.takePreparedRevealPlan(id);
|
|
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
|
this.publishPreparedReveal(cached, options);
|
|
this.markPipelineTiming('prepareRevealBlock:end', {
|
|
blockId: id,
|
|
wordTimingCount: wordTimings.length,
|
|
reusedPreparedCanvas: true
|
|
});
|
|
return {
|
|
...cached,
|
|
phase: 'activate',
|
|
preparedFromCache: true
|
|
};
|
|
}
|
|
|
|
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
|
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
|
|
const sides = ['left', 'right'];
|
|
// When the caller supplies the (not-yet-committed) preview spreads for a spanning
|
|
// block, derive this spread's reveal timing across all of them so the cached plan
|
|
// already spans both pages, letting activate reuse it directly.
|
|
const spanningPreview = Array.isArray(detail.previewSpreads) && detail.previewSpreads.length > 1;
|
|
const published = await this.drawSpread(spread, sides, {
|
|
phase,
|
|
publishEvent: options.publishEvent !== false,
|
|
revealPublishBlockIds: new Set([id]),
|
|
revealSpreadSourceOverride: spanningPreview ? detail.previewSpreads : null
|
|
});
|
|
if (!spanningPreview) await this.preloadAdditionalRevealSpreads(id, spread);
|
|
if (phase === 'prepare' && published) {
|
|
this.pageCache?.rememberPreparedRevealPlan?.(id, {
|
|
...published,
|
|
blockId,
|
|
wordTimings,
|
|
totalDuration: detail.totalDuration || 0
|
|
});
|
|
}
|
|
this.markPipelineTiming('prepareRevealBlock:end', {
|
|
blockId: id,
|
|
wordTimingCount: wordTimings.length,
|
|
phase
|
|
});
|
|
return published ? {
|
|
...published,
|
|
blockId,
|
|
wordTimings,
|
|
totalDuration: detail.totalDuration || 0
|
|
} : null;
|
|
}
|
|
|
|
// Lookahead-only: draw and cache the reveal plan for the spread a spanning block
|
|
// continues onto, using the not-yet-committed preview spreads so the per-line timing is
|
|
// computed across both spreads. revealContinuationSpread reuses this after the flip
|
|
// instead of redrawing the spread synchronously on the critical path. Returns the plan
|
|
// or null (caller falls back to the synchronous redraw).
|
|
async prepareContinuationRevealPlan(detail = {}) {
|
|
const blockId = detail.blockId ?? detail.id ?? null;
|
|
const previewSpreads = Array.isArray(detail.previewSpreads) ? detail.previewSpreads : null;
|
|
const continuationSpread = detail.continuationSpread || null;
|
|
if (blockId == null || !previewSpreads || !continuationSpread) return null;
|
|
const id = String(blockId);
|
|
const wordTimings = Array.isArray(detail.wordTimings) ? detail.wordTimings : [];
|
|
const existing = this.activeAnimations.get(id);
|
|
if (!existing || existing.completed) {
|
|
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
|
}
|
|
const published = await this.drawSpread(continuationSpread, ['left', 'right'], {
|
|
phase: 'prepare',
|
|
publishEvent: false,
|
|
revealPublishBlockIds: new Set([id]),
|
|
revealSpreadSourceOverride: previewSpreads
|
|
});
|
|
if (!published || !published.reveal || !Object.keys(published.reveal).length) return null;
|
|
const plan = {
|
|
...published,
|
|
blockId,
|
|
wordTimings,
|
|
totalDuration: detail.totalDuration || 0,
|
|
continuationSpreadIndex: Math.max(0, Number(continuationSpread.index ?? 0))
|
|
};
|
|
this.pageCache?.rememberPreparedRevealPlan?.(`${id}:cont`, plan);
|
|
this.markPipelineTiming('prepareContinuationRevealPlan', {
|
|
blockId: id,
|
|
continuationSpreadIndex: plan.continuationSpreadIndex,
|
|
sides: Object.keys(published.reveal)
|
|
});
|
|
return plan;
|
|
}
|
|
|
|
// Reuse a continuation plan prepared during lookahead. Returns the cached publish detail
|
|
// (ready to apply) or null when none was prepared for this block+spread.
|
|
takeContinuationRevealPlan(blockId = '', spreadIndex = null) {
|
|
const id = String(blockId ?? '');
|
|
const key = `${id}:cont`;
|
|
if (!id || !this.pageCache?.hasPreparedRevealPlan?.(key)) return null;
|
|
const cached = this.pageCache.takePreparedRevealPlan(key);
|
|
if (!cached || Number(cached.continuationSpreadIndex) !== Math.max(0, Number(spreadIndex ?? -1))) return null;
|
|
// The block reveals again on this spread; refresh its (uncompleted) animation state so
|
|
// region/commit bookkeeping treats it as actively revealing.
|
|
this.activeAnimations.set(id, this.createAnimationState(id, cached.wordTimings || [], cached));
|
|
this.revealedBlockIds.delete(id);
|
|
// The plan was published at 'prepare' phase (records marked not-yet-visible). Re-stamp
|
|
// it as 'activate' and rebuild its records so the scene shows it like a fresh draw.
|
|
const activated = { ...cached, phase: 'activate', preparedFromCache: true };
|
|
activated.records = this.buildPageTextureRecords(cached.sides || ['left', 'right'], activated);
|
|
this.markPipelineTiming('takeContinuationRevealPlan', {
|
|
blockId: id,
|
|
continuationSpreadIndex: cached.continuationSpreadIndex
|
|
});
|
|
return activated;
|
|
}
|
|
|
|
async preloadAdditionalRevealSpreads(blockId, primarySpread = null) {
|
|
const spreads = Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : [];
|
|
if (!spreads.length) return;
|
|
const primaryIndex = Number(primarySpread?.index);
|
|
for (const spread of spreads) {
|
|
if (!spread || Number(spread.index) === primaryIndex) continue;
|
|
if (!this.spreadContainsBlock(spread, blockId)) continue;
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await this.drawSpread(spread, ['left', 'right'], { phase: 'prepare' });
|
|
}
|
|
}
|
|
|
|
spreadContainsBlock(spread = {}, blockId = '') {
|
|
const id = String(blockId ?? '');
|
|
return ['left', 'right'].some((side) => {
|
|
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
|
return lines.some(line => String(line?.blockId ?? '') === id);
|
|
});
|
|
}
|
|
|
|
publishPreparedReveal(prepared, options = {}) {
|
|
if (!prepared) return null;
|
|
this.markPipelineTiming('publishPreparedReveal', {
|
|
blockId: prepared.blockId,
|
|
sides: prepared.sides || [],
|
|
hasReveal: Boolean(prepared.reveal && Object.keys(prepared.reveal).length)
|
|
});
|
|
const detail = {
|
|
metrics: prepared.metrics,
|
|
hitMaps: prepared.hitMaps || this.hitMaps,
|
|
records: prepared.records || this.buildPageTextureRecords(prepared.sides || ['left', 'right'], prepared),
|
|
reveal: prepared.reveal || {},
|
|
pageMeta: prepared.pageMeta || {},
|
|
phase: 'activate',
|
|
preparedFromCache: true
|
|
};
|
|
if (options.publishEvent !== false) {
|
|
document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', { detail }));
|
|
}
|
|
return detail;
|
|
}
|
|
|
|
startPreparedRevealAnimation(blockId, options = {}) {
|
|
const id = String(blockId ?? '');
|
|
const animation = this.activeAnimations.get(id);
|
|
if (!animation) return false;
|
|
this.markPipelineTiming('startPreparedRevealAnimation', {
|
|
blockId: id,
|
|
wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0
|
|
});
|
|
animation.startedAt = performance.now();
|
|
animation.prepared = false;
|
|
animation.completed = false;
|
|
if (options.publishEvent !== false) {
|
|
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
|
|
detail: {
|
|
blockId: animation.blockId
|
|
}
|
|
}));
|
|
}
|
|
return {
|
|
blockId: animation.blockId,
|
|
wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0
|
|
};
|
|
}
|
|
|
|
fastForwardAnimations() {
|
|
let changed = false;
|
|
const blockIds = [];
|
|
this.activeAnimations.forEach((animation) => {
|
|
if (!animation.completed) {
|
|
animation.completed = true;
|
|
this.revealedBlockIds.add(String(animation.blockId ?? ''));
|
|
blockIds.push(animation.blockId);
|
|
changed = true;
|
|
}
|
|
});
|
|
if (changed) {
|
|
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
|
|
detail: {
|
|
blockIds
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
|
|
completeRevealBlockIds(blockIds = []) {
|
|
const ids = Array.isArray(blockIds) ? blockIds : [];
|
|
ids.forEach((blockId) => {
|
|
const id = String(blockId ?? '');
|
|
if (!id) return;
|
|
const animation = this.activeAnimations.get(id);
|
|
if (animation) animation.completed = true;
|
|
this.revealedBlockIds.add(id);
|
|
});
|
|
}
|
|
|
|
stopAnimations() {
|
|
this.activeAnimations.clear();
|
|
this.revealedBlockIds.clear();
|
|
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
|
}
|
|
|
|
getBlockSides(blockId) {
|
|
const id = String(blockId ?? '');
|
|
const spread = this.currentSpread || this.pagination?.getCurrentSpread?.() || { left: [], right: [] };
|
|
return ['left', 'right'].filter((side) => {
|
|
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
|
return lines.some(line => String(line?.blockId ?? '') === id);
|
|
});
|
|
}
|
|
|
|
getAnimatedSides(includeCompleted = false) {
|
|
const spread = this.currentSpread || this.pagination?.getCurrentSpread?.() || { left: [], right: [] };
|
|
const activeBlockIds = new Set();
|
|
this.activeAnimations.forEach((animation, blockId) => {
|
|
if (includeCompleted || !animation.completed) activeBlockIds.add(String(blockId));
|
|
});
|
|
const sides = ['left', 'right'].filter((side) => {
|
|
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
|
return lines.some(line => activeBlockIds.has(String(line?.blockId ?? '')));
|
|
});
|
|
return sides.length ? sides : ['left', 'right'];
|
|
}
|
|
|
|
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
|
|
};
|
|
const detail = {
|
|
metrics: this.metrics,
|
|
hitMaps: this.hitMaps,
|
|
sides: sidesToPublish,
|
|
pageMeta: this.buildPublishPageMeta(sidesToPublish),
|
|
phase
|
|
};
|
|
if (sidesToPublish.includes('left')) {
|
|
detail.left = phase === 'prepare' ? this.cloneCanvas(this.canvases.left) : this.canvases.left;
|
|
}
|
|
if (sidesToPublish.includes('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 = 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,
|
|
phase
|
|
});
|
|
if (options.publishEvent !== false) {
|
|
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 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 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;
|
|
}, {});
|
|
}
|
|
|
|
cachePublishedPages(sides = [], detail = {}) {
|
|
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.pageCache.storePageCanvas(pageMeta, canvas, { persist: true, resident: true });
|
|
});
|
|
}
|
|
|
|
getPageCanvas(side) {
|
|
return this.canvases[side] || null;
|
|
}
|
|
|
|
getHitMap(side) {
|
|
return this.hitMaps[side] || [];
|
|
}
|
|
|
|
handlePageCountChanged(event) {
|
|
this.pageFormat?.setPageCount?.(event.detail?.pageCount);
|
|
this.createPageCanvases();
|
|
this.lastDrawSignature = null;
|
|
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
|
}
|
|
|
|
}
|
|
|
|
const bookTextureRenderer = new BookTextureRendererModule();
|
|
|
|
export { bookTextureRenderer as BookTextureRenderer };
|
|
|
|
if (window.moduleRegistry) {
|
|
window.moduleRegistry.register(bookTextureRenderer);
|
|
}
|
|
|
|
window.BookTextureRenderer = bookTextureRenderer;
|