/** * 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']; this.pageFormat = null; this.pagination = null; this.localization = 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.pendingRevealBlockIds = new Set(); this.preparedRevealCache = new Map(); this.revealBounds = null; this.revealWords = null; this.revealBaseCanvases = null; this.revealPublishBlockIds = null; this.lastDrawSignature = null; this.lastDrawSkipLoggedAt = 0; this.animationFrameId = null; this.lastAnimationFrameAt = 0; this.targetFrameDurationMs = 1000 / 30; this.pipelineTimings = []; this.imageCache = new Map(); this.bindMethods([ 'initialize', 'markPipelineTiming', 'waitForTextureFonts', 'ensureTextureFontFace', 'createPageCanvases', 'drawSpread', 'getDrawSignature', 'cloneCanvas', 'drawPageBase', 'drawPageMeta', 'drawTitlePage', 'drawPageNumber', 'drawPageLines', 'drawImageRecord', 'resolveImageSource', 'getCachedImage', 'drawImageFitted', 'drawLine', 'drawWord', 'recordRevealRect', 'getInlineStyleState', 'updateInlineStyleState', 'getCanvasFont', 'applyTextStyle', 'getPageContent', 'buildLineSegments', 'startRevealAnimation', 'prepareRevealBlock', 'createAnimationState', 'publishPreparedReveal', 'startPreparedRevealAnimation', 'fastForwardAnimations', 'stopAnimations', 'getBlockSides', 'getAnimatedSides', 'markPendingReveal', 'requestAnimationFrame', 'tickAnimations', 'publishSpread', 'getPageCanvas', 'getHitMap', 'handlePageCountChanged' ]); } async initialize() { this.pageFormat = this.getModule('book-page-format'); this.pagination = this.getModule('book-pagination'); this.localization = this.getModule('localization'); 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.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged); this.addEventListener(document, 'book-pagination:spread-updated', (event) => { const spread = event.detail?.spread || this.pagination?.getCurrentSpread?.(); const latestBlockId = event.detail?.latestBlockId; const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0)); this.currentSpread = spread || { left: [], right: [] }; if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) { this.markPendingReveal(latestBlockId); return; } this.drawSpread(this.currentSpread); }); this.addEventListener(document, 'book-texture:reveal-block', (event) => { this.startRevealAnimation(event.detail || {}); }); this.addEventListener(document, 'book-texture:prepare-reveal-block', (event) => { this.prepareRevealBlock(event.detail || {}); }); this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations); 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.reportProgress(100, 'Book texture renderer ready'); return true; } 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'); }); } drawSpread(spread = null, sides = null, options = {}) { const previousSpread = this.currentSpread; this.currentSpread = spread || { left: [], right: [] }; const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0; const drawSignature = this.getDrawSignature(this.currentSpread, sidesToDraw); if (!options.preloadOnly && !hasReveal && drawSignature === this.lastDrawSignature) { const now = performance.now(); if (now - this.lastDrawSkipLoggedAt > 1000) { this.lastDrawSkipLoggedAt = now; this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw }); } if (options.preloadOnly) this.currentSpread = previousSpread; return null; } this.markPipelineTiming('drawSpread:start', { sides: sidesToDraw, revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : [], preloadOnly: Boolean(options.preloadOnly) }); this.revealBounds = { left: null, right: null }; this.revealWords = { left: [], right: [] }; this.revealBaseCanvases = { left: null, right: null }; sidesToDraw.forEach((side) => { if (!this.canvases[side]) return; this.drawPageBase(side); if (hasReveal) this.revealBaseCanvases[side] = this.cloneCanvas(this.canvases[side]); this.drawPageMeta(side, 'before-lines'); this.drawPageLines(side, this.currentSpread?.[side] || []); this.drawPageMeta(side, 'after-lines'); }); const published = this.publishSpread(sidesToDraw, options); this.markPipelineTiming('drawSpread:end', { sides: sidesToDraw, preloadOnly: Boolean(options.preloadOnly) }); this.revealBounds = null; this.revealWords = null; this.revealBaseCanvases = null; this.revealPublishBlockIds = null; if (!options.preloadOnly && !hasReveal) this.lastDrawSignature = drawSignature; if (options.preloadOnly) this.currentSpread = previousSpread; return published; } 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; } drawPageBase(side) { const canvas = this.canvases[side]; const ctx = this.contexts[side]; if (!canvas || !ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#f2ead0'; ctx.fillRect(0, 0, canvas.width, canvas.height); const shade = ctx.createLinearGradient(0, 0, canvas.width, 0); if (side === 'left') { shade.addColorStop(0, 'rgba(255, 255, 255, 0.06)'); shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)'); shade.addColorStop(1, 'rgba(70, 48, 28, 0.08)'); } else { shade.addColorStop(0, 'rgba(70, 48, 28, 0.08)'); shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)'); shade.addColorStop(1, 'rgba(255, 255, 255, 0.06)'); } ctx.fillStyle = shade; ctx.fillRect(0, 0, canvas.width, canvas.height); this.hitMaps[side] = []; } drawPageMeta(side, phase = 'after-lines') { const meta = this.currentSpread?.pageMeta?.[side] || null; if (!meta) return; if (phase === 'before-lines' && meta.kind === 'title') this.drawTitlePage(side); if (phase === 'after-lines') this.drawPageNumber(side, meta); } drawTitlePage(side) { const ctx = this.contexts[side]; if (!ctx || !this.metrics) return; const content = this.getPageContent(side); const titleText = document.getElementById('game_title')?.textContent?.trim() || ''; const authorText = document.getElementById('game_author')?.textContent?.trim() || ''; const subtitleText = document.getElementById('game_subtitle')?.textContent?.trim() || ''; const ornamentText = document.querySelector('#start_prompt .separator, #start_prompt .ornament, #start_prompt [class*="separator"]')?.textContent?.trim() || ''; const legalText = document.getElementById('game_legal_text')?.textContent?.trim() || ''; const centerX = content.x + content.width * 0.5; const font = this.metrics.typography.fontFamily; ctx.save(); ctx.fillStyle = 'rgba(31, 19, 10, 0.9)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; if ('fontKerning' in ctx) ctx.fontKerning = 'normal'; if (authorText) { ctx.font = `italic ${Math.round(this.metrics.bodyFontSizePx * 0.86)}px ${font}`; ctx.fillText(authorText, centerX, content.y + content.height * 0.18); } if (titleText) { ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 1.55)}px ${font}`; if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'small-caps'; ctx.fillText(titleText, centerX, content.y + content.height * 0.28); if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal'; } if (subtitleText) { ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.94)}px ${font}`; ctx.fillText(subtitleText, centerX, content.y + content.height * 0.39); } if (ornamentText) { ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 1.3)}px ${font}`; ctx.fillText(ornamentText, centerX, content.y + content.height * 0.52); } if (legalText) { ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.62)}px ${font}`; ctx.fillText(legalText, centerX, content.y + content.height * 0.96); } ctx.restore(); } drawPageNumber(side, meta = {}) { if (meta.omitPageNumber || meta.pageNumber == null) return; const ctx = this.contexts[side]; if (!ctx || !this.metrics) return; const content = this.getPageContent(side); ctx.save(); ctx.fillStyle = 'rgba(31, 19, 10, 0.74)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.68)}px ${this.metrics.typography.fontFamily}`; ctx.fillText(String(meta.pageNumber), content.x + content.width * 0.5, content.y + content.height + this.metrics.margins.bottom * 0.48); ctx.restore(); } drawPageLines(side, lines = []) { const ctx = this.contexts[side]; if (!ctx || !this.metrics || !Array.isArray(lines)) return; ctx.save(); ctx.fillStyle = 'rgba(31, 19, 10, 0.86)'; ctx.textBaseline = 'alphabetic'; if ('fontKerning' in ctx) ctx.fontKerning = 'normal'; if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal'; lines.forEach(line => { if (line?.type === 'image' || line?.kind === 'image') this.drawImageRecord(ctx, line, side); else this.drawLine(ctx, line, side); }); ctx.restore(); } drawImageRecord(ctx, lineRecord = {}, side = 'left') { const content = this.getPageContent(side); const layout = lineRecord.metadata?.imageLayout || {}; const rect = layout.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)); const src = this.resolveImageSource(lineRecord.metadata || {}); ctx.save(); if (src) { const image = this.getCachedImage(src); if (image?.complete && image.naturalWidth > 0) { this.drawImageFitted(ctx, image, x, y, width, height); } } ctx.restore(); } resolveImageSource(metadata = {}) { const explicit = String(metadata.url || metadata.src || '').trim(); if (explicit) return explicit; const filename = String(metadata.filename || '').trim(); if (!filename) return ''; if (/^(https?:|data:|blob:|\/)/i.test(filename)) return filename; return `/images/${filename.replace(/^images[\\/]/i, '').replace(/\\/g, '/')}`; } getCachedImage(src) { if (!src) return null; if (this.imageCache.has(src)) return this.imageCache.get(src); const image = new Image(); image.decoding = 'async'; image.onload = () => this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); image.onerror = () => this.markPipelineTiming('image:load-error', { src }); image.src = src; this.imageCache.set(src, image); return image; } drawImageFitted(ctx, image, x, y, width, height) { const sourceWidth = image.naturalWidth || image.width || 1; const sourceHeight = image.naturalHeight || image.height || 1; const sourceAspect = sourceWidth / sourceHeight; const targetAspect = width / height; let sx = 0; let sy = 0; let sw = sourceWidth; let sh = sourceHeight; if (sourceAspect > targetAspect) { sw = sourceHeight * targetAspect; sx = (sourceWidth - sw) * 0.5; } else if (sourceAspect < targetAspect) { sh = sourceWidth / targetAspect; sy = (sourceHeight - sh) * 0.5; } ctx.drawImage(image, sx, sy, sw, sh, x, y, width, height); } drawLine(ctx, lineRecord = {}, side = 'left') { const metrics = this.metrics; const content = this.getPageContent(side); const fontPx = Math.max(1, Number(lineRecord.fontPx || 22)); const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30)); const line = lineRecord.line || {}; const nodes = Array.isArray(line.nodes) ? line.nodes : []; const baseY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx; const ratio = line.isFinal || line.align === 'center' ? 0 : Number(line.ratio || 0); const naturalWidth = nodes.reduce((sum, node) => { if (node.type === 'box' || node.type === 'glue') return sum + Number(node.width || 0); return sum; }, 0); const centerOffset = line.align === 'center' ? Math.max(0, (content.width - naturalWidth) / 2) : Number(line.offset || 0); let x = content.x + centerOffset; const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps); const previousVariantCaps = 'fontVariantCaps' in ctx ? ctx.fontVariantCaps : null; const previousLetterSpacing = 'letterSpacing' in ctx ? ctx.letterSpacing : null; const baseStyle = this.getInlineStyleState(line.activeStyleTags || [], { italic: lineRecord.fontStyle === 'italic' }); if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal'; if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px'; this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle); if (lineRecord.dropCapText) { ctx.save(); const dropCapFontPx = Math.round(fontPx * 2.68); const dropCapX = content.x; const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25); ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`; ctx.textBaseline = 'top'; ctx.fillText(String(lineRecord.dropCapText), dropCapX, dropCapY); this.recordRevealRect(side, lineRecord, dropCapX, dropCapY, fontPx * 2.9, dropCapFontPx * 0.9, 0); ctx.restore(); if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal'; if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px'; this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle); } this.buildLineSegments(ctx, nodes, line, ratio, baseStyle).forEach((segment) => { this.drawWord(ctx, segment, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx, smallCaps); }); if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal'; if ('letterSpacing' in ctx) ctx.letterSpacing = previousLetterSpacing || '0px'; } getInlineStyleState(tags = [], base = {}) { const state = { bold: Boolean(base.bold), italic: Boolean(base.italic) }; tags.forEach(tag => { if (tag?.bold) state.bold = true; if (tag?.italic) state.italic = true; }); return state; } updateInlineStyleState(stack = [], value = '') { const text = String(value || ''); if (!text.startsWith('<')) return stack; if (text.startsWith(' ({ ...tag })) : []; nodes.forEach((node, index) => { if (!node) return; if (node.type === 'box' && node.value) { const value = String(node.value); const width = Number(node.width || ctx.measureText(value).width || 0); const style = this.getInlineStyleState(styleStack, baseStyle); if (currentSegment && !previousWasGlue && currentSegment.style.bold === style.bold && currentSegment.style.italic === style.italic) { currentSegment.value += value; currentSegment.width += width; } else { if (previousWasGlue) currentWordIndex += 1; currentSegment = { value, x, width, wordIndex: Math.max(0, currentWordIndex), style }; segments.push(currentSegment); } x += width; previousWasGlue = false; } else if (node.type === 'glue' && node.width !== 0) { let width = Number(node.width || 0); if (ratio > 0) width += Number(node.stretch || 0) * ratio; if (ratio < 0) width += Number(node.shrink || 0) * ratio; x += width; previousWasGlue = true; currentSegment = null; } else if (node.type === 'penalty' && node.penalty === 100) { const isLineEndHyphen = Boolean(line.hyphenated && index === nodes.length - 1 && currentSegment); if (isLineEndHyphen) { const hyphenWidth = Number(node.width || ctx.measureText('-').width || 0); currentSegment.value += '-'; currentSegment.width += hyphenWidth; x += hyphenWidth; } previousWasGlue = false; } else if (node.type === 'tag') { this.updateInlineStyleState(styleStack, node.value); } }); return segments; } drawWord(ctx, segment, x, baseY, lineRecord, localWordIndex, side, fontPx, lineHeightPx, smallCaps = false) { const value = segment?.value || ''; this.applyTextStyle(ctx, fontPx, smallCaps, segment?.style || {}); ctx.fillText(value, x, baseY); const width = Number(segment?.width || 0) || ctx.measureText(value).width || fontPx; this.recordRevealRect(side, lineRecord, x, baseY - fontPx, width, lineHeightPx, localWordIndex); } recordRevealRect(side, lineRecord, x, y, width, height, localWordIndex = 0) { if (!this.revealBounds || !this.revealPublishBlockIds) return; const blockId = String(lineRecord?.blockId ?? ''); if (!blockId || !this.revealPublishBlockIds.has(blockId)) return; const animation = this.activeAnimations.get(blockId); if (!animation || animation.completed) return; const padding = Math.max(2, Number(lineRecord.fontPx || 18) * 0.12); const nextRect = { x: Math.max(0, x - padding), y: Math.max(0, y - padding), right: Math.min(this.metrics.width, x + width + padding), bottom: Math.min(this.metrics.height, y + height + padding) }; const current = this.revealBounds[side]; this.revealBounds[side] = current ? { x: Math.min(current.x, nextRect.x), y: Math.min(current.y, nextRect.y), right: Math.max(current.right, nextRect.right), bottom: Math.max(current.bottom, nextRect.bottom), blockIds: current.blockIds.add(blockId) } : { ...nextRect, blockIds: new Set([blockId]) }; const globalWordIndex = Math.max(0, Number(lineRecord.blockWordStart || 0) + Number(localWordIndex || 0)); const timing = Array.isArray(animation.wordTimings) ? animation.wordTimings[globalWordIndex] : null; if (!timing || !this.revealWords?.[side]) return; this.revealWords[side].push({ blockId, wordIndex: globalWordIndex, rect: { x: nextRect.x / this.metrics.width, y: nextRect.y / this.metrics.height, width: Math.max(0.001, (nextRect.right - nextRect.x) / this.metrics.width), height: Math.max(0.001, (nextRect.bottom - nextRect.y) / this.metrics.height) }, timing: { delay: Math.max(0, Number(timing.delay || 0)), duration: Math.max(1, Number(timing.duration || 1)) } }); } startRevealAnimation(detail = {}) { const blockId = detail.blockId ?? detail.id ?? null; if (blockId == null || !Array.isArray(detail.wordTimings)) return; const existing = this.activeAnimations.get(String(blockId)); if (existing && existing.prepared) { this.startPreparedRevealAnimation(blockId); return; } this.activeAnimations.set(String(blockId), { blockId, wordTimings: detail.wordTimings, startedAt: performance.now(), totalDuration: Math.max( Number(detail.totalDuration || 0), ...detail.wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0)) ), completed: false }); this.pendingRevealBlockIds.delete(String(blockId)); this.revealPublishBlockIds = new Set([String(blockId)]); this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId)); document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', { detail: { blockId } })); this.requestAnimationFrame(); } 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 }; } 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 preloadOnly = Boolean(detail.preloadOnly || options.preloadOnly); this.markPipelineTiming('prepareRevealBlock:start', { blockId: id, wordTimingCount: wordTimings.length, preloadOnly }); if (!preloadOnly && this.preparedRevealCache.has(id)) { const cached = this.preparedRevealCache.get(id); this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail)); this.pendingRevealBlockIds.delete(id); this.publishPreparedReveal(cached); this.markPipelineTiming('prepareRevealBlock:end', { blockId: id, wordTimingCount: wordTimings.length, reusedPreparedCanvas: true }); return; } this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail)); this.pendingRevealBlockIds.delete(id); this.revealPublishBlockIds = new Set([id]); const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.(); const sides = this.getBlockSides(blockId); const published = this.drawSpread(spread, sides, { preloadOnly }); if (preloadOnly && published) { this.preparedRevealCache.set(id, { ...published, blockId, wordTimings, totalDuration: detail.totalDuration || 0 }); } this.markPipelineTiming('prepareRevealBlock:end', { blockId: id, wordTimingCount: wordTimings.length, preloadOnly }); } publishPreparedReveal(prepared) { if (!prepared) return; this.markPipelineTiming('publishPreparedReveal', { blockId: prepared.blockId, sides: prepared.sides || [], hasReveal: Boolean(prepared.reveal && Object.keys(prepared.reveal).length) }); document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { detail: { metrics: prepared.metrics, hitMaps: prepared.hitMaps || this.hitMaps, left: prepared.left || null, right: prepared.right || null, reveal: prepared.reveal || {}, preparedFromCache: true } })); } startPreparedRevealAnimation(blockId) { 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; document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', { detail: { blockId: animation.blockId } })); this.requestAnimationFrame(); return true; } 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) { this.pendingRevealBlockIds.clear(); document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', { detail: { blockIds } })); } } stopAnimations() { this.activeAnimations.clear(); this.pendingRevealBlockIds.clear(); if (this.animationFrameId) { clearTimeout(this.animationFrameId); this.animationFrameId = null; } 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']; } markPendingReveal(blockId) { const id = String(blockId ?? ''); if (!id || this.activeAnimations.has(id) || this.revealedBlockIds.has(id)) return; this.pendingRevealBlockIds.add(id); } requestAnimationFrame() { if (this.animationFrameId) return; this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs); } tickAnimations(now) { this.animationFrameId = null; if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) { this.requestAnimationFrame(); return; } this.lastAnimationFrameAt = now; let hasActive = false; const currentNow = performance.now(); this.activeAnimations.forEach((animation) => { if (animation.completed) return; if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return; if (animation.startedAt == null) { hasActive = true; return; } const lastTiming = animation.wordTimings.at(-1); const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0); if (currentNow - animation.startedAt >= total + 50) { animation.completed = true; this.revealedBlockIds.add(String(animation.blockId ?? '')); } else { hasActive = true; } }); if (hasActive) this.requestAnimationFrame(); } publishSpread(sides = null, options = {}) { const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; const wordCounts = { left: this.revealWords?.left?.length || 0, right: this.revealWords?.right?.length || 0 }; const detail = { metrics: this.metrics, hitMaps: this.hitMaps, sides: sidesToPublish, pageMeta: this.currentSpread?.pageMeta || {} }; if (options.preloadOnly) detail.preloadOnly = true; if (sidesToPublish.includes('left')) { detail.left = options.preloadOnly ? this.cloneCanvas(this.canvases.left) : this.canvases.left; } if (sidesToPublish.includes('right')) { detail.right = options.preloadOnly ? this.cloneCanvas(this.canvases.right) : this.canvases.right; } const reveal = {}; sidesToPublish.forEach((side) => { const bounds = this.revealBounds?.[side]; if (!bounds) return; const blockIds = Array.from(bounds.blockIds || []); const durationMs = blockIds.reduce((maxDuration, blockId) => { const animation = this.activeAnimations.get(String(blockId)); return Math.max(maxDuration, Number(animation?.totalDuration || 0)); }, 0); if (durationMs <= 0) return; reveal[side] = { blockIds, durationMs, baseCanvas: options.preloadOnly ? this.cloneCanvas(this.revealBaseCanvases?.[side]) : this.revealBaseCanvases?.[side] || null, wordRects: (this.revealWords?.[side] || []).map(word => ({ blockId: word.blockId, wordIndex: word.wordIndex, rect: word.rect, timing: word.timing })), 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) } }; }); if (Object.keys(reveal).length) detail.reveal = reveal; this.markPipelineTiming('publishSpread', { sides: sidesToPublish, hasReveal: Object.keys(reveal).length > 0, wordCounts, preloadOnly: Boolean(options.preloadOnly) }); document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { detail })); return detail; } 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;