/** * 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;