Files
ai.interactive.fiction/public/js/book-texture-renderer-module.js
T
Georg c7364b0497 Cut per-paragraph GC stalls: reuse static paper base, cap lookahead to 1
Profiling the per-paragraph playback stutter showed the JS heap sawtoothing (37<->71MB) with
0.4-2.2s long tasks once per block — GC pauses from large (24-48MB) per-block canvas/ImageBitmap
allocations, not pagination (buildPages was ~29ms). These pauses freeze the flip/reveal
animation, which is also why the title flip looked un-animated.

- The reveal "base" layer is the plain paper background, identical for every page of a side.
  The worker now sends its bitmap once per side+size; the renderer caches the canvas and reuses
  it for all reveals, removing a large per-block bitmap+canvas allocation.
- WEBGL_BOOK_PREFETCH_LOOKAHEAD 2 -> 1 so only the next block's page render is prepared, instead
  of letting multiple large rasterizations overlap.

Verified live: per-paragraph long tasks roughly halved (10 -> 5 over the same window) and worst
case 2159ms -> 1431ms. Residual ~1.4s stall remains from the per-block page bitmap + prepared-
page snapshot clone + texture upload; further reduction needs reworking those to reuse buffers.
Suite 181.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 07:16:05 +02:00

1071 lines
49 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 } };
this.reportProgress(60, 'Loading page fonts in render worker');
await this.waitForWorkerFonts();
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 = 6000;
this.rasterChain = Promise.resolve();
this.fontsReadyPromise = new Promise((resolve) => { this.resolveFontsReady = resolve; });
this.rasterWorker.onmessage = (event) => {
const data = event.data || {};
if (data.type === 'fonts-ready') {
this.resolveFontsReady?.();
return;
}
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' });
}
// Block until the worker has loaded its fonts before the first timed draw, so a cold font
// load is not counted inside a draw's timeout budget (which would otherwise fire on a cold
// load, leave the page blank, and let the loader complete over a black scene).
async waitForWorkerFonts() {
if (!this.fontsReadyPromise) return;
await Promise.race([
this.fontsReadyPromise,
new Promise(resolve => setTimeout(resolve, 15000))
]);
}
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?.();
// The paper base is identical for every page of a side; the worker sends its bitmap
// only once, and we cache the canvas and reuse it for all reveals. This removes a
// large per-block canvas/bitmap allocation that was driving GC stalls.
if (result.baseBitmap) {
if (!this.cachedBaseCanvas) this.cachedBaseCanvas = {};
this.cachedBaseCanvas[side] = this.canvasFromBitmap(result.baseBitmap);
result.baseBitmap.close?.();
}
if (hasReveal) {
this.revealBaseCanvases[side] = this.cachedBaseCanvas?.[side] || null;
}
});
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;
}
});
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
detail: {
blockIds,
broad: !changed
}
}));
}
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;