From 0e4d9e89d791fa36f5260788eccf2dcef9c5330d Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Fri, 19 Jun 2026 16:09:34 +0200 Subject: [PATCH] Move page rasterization to an OffscreenCanvas worker Page text drawing (the bulk of drawSpread cost: layout, fonts, fillText across ~25 lines x 2 pages at 3072px) ran synchronously on the main thread during prepare/lookahead, tanking FPS at load and at flips/word boundaries. New public/js/book-texture-worker.js owns rasterization off-thread: it loads the EB Garamond faces via FontFace, draws base + title + lines + page number into an OffscreenCanvas, and returns a full-page ImageBitmap plus a background-only base ImageBitmap (for the reveal mask) per side. The main thread blits those onto the existing page canvases with one drawImage, so the texture/reveal/scene pipeline downstream is unchanged. The worker also owns image loading (fetch + createImageBitmap) and a DOM-free inline-tag parser (no document in a worker); the renderer marshals the DOM-sourced title data in. drawSpread is now async and serialized through a promise chain so the shared render state (currentSpread, revealPublishBlockIds, spread override, reveal base) stays consistent across the worker round trip even with concurrent lookahead prepares; the reveal context is passed per draw rather than left on the instance. prepareRevealBlock / prepareContinuationRevealPlan / preloadAdditionalRevealSpreads and their timeline callers await accordingly. The old main-thread drawing methods are deleted (single implementation now lives in the worker). Verified live: pages render correctly via the worker (text + drop caps crisp), worker fonts load (probe returns fonts-ready + drawn), idle ~66fps, playback median ~60fps. Remaining non-rasterization main-thread costs (procedural texture generation in the loader; pagination text layout; per-frame reflection/shadow on content change) are separate follow-ups. Suite 166. Co-Authored-By: Claude Opus 4.8 --- public/js/book-playback-timeline-module.js | 6 +- public/js/book-texture-renderer-module.js | 529 ++++++--------------- public/js/book-texture-worker.js | 362 ++++++++++++++ scripts/check-webgl-book-lab.js | 13 +- 4 files changed, 513 insertions(+), 397 deletions(-) create mode 100644 public/js/book-texture-worker.js diff --git a/public/js/book-playback-timeline-module.js b/public/js/book-playback-timeline-module.js index 603f385..178b530 100644 --- a/public/js/book-playback-timeline-module.js +++ b/public/js/book-playback-timeline-module.js @@ -222,12 +222,12 @@ class BookPlaybackTimelineModule extends BaseModule { : null; const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare'); - const texturePlan = this.textureRenderer.prepareRevealBlock( + const texturePlan = await this.textureRenderer.prepareRevealBlock( continuationSpread ? { ...revealDetail, previewSpreads } : revealDetail, { phase: 'prepare', publishEvent: false } ); if (continuationSpread) { - this.textureRenderer.prepareContinuationRevealPlan({ + await this.textureRenderer.prepareContinuationRevealPlan({ ...revealDetail, previewSpreads, continuationSpread @@ -317,7 +317,7 @@ class BookPlaybackTimelineModule extends BaseModule { const revealDetail = this.createRevealDetail(sentence, spread, 'activate'); // Reuse the spanning-aware plan prepared during lookahead — its timing already spans // both pages. No synchronous redraw on the critical path. - const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false }); + const texturePlan = await this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false }); segment.activeTexturePlan = texturePlan; this.applyTexturePlan(texturePlan, segment, 'activate'); await this.assertSegmentReady(segment, 'activate'); diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index fbba799..ff22642 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -40,7 +40,6 @@ class BookTextureRendererModule extends BaseModule { this.lastDrawSignature = null; this.lastDrawSkipLoggedAt = 0; this.pipelineTimings = []; - this.imageCache = new Map(); this.pageContentVersions = new Map(); this.bindMethods([ @@ -49,20 +48,12 @@ class BookTextureRendererModule extends BaseModule { 'waitForTextureFonts', 'ensureTextureFontFace', 'createPageCanvases', + 'createRasterWorker', 'drawSpread', + 'drawSpreadSerial', + 'rasterizeSpread', 'getDrawSignature', 'cloneCanvas', - 'drawPageBase', - 'drawPageMeta', - 'drawTitlePage', - 'drawPageNumber', - 'drawPageLines', - 'drawImageRecord', - 'resolveImageSource', - 'getCachedImage', - 'drawImageFitted', - 'drawLine', - 'drawWord', 'buildRevealRegions', 'shouldFlipAfterSideReveal', 'collectRevealRegionCandidates', @@ -72,12 +63,7 @@ class BookTextureRendererModule extends BaseModule { 'getLineNaturalWidth', 'getLineWordCount', 'getImageRevealDurationMs', - 'getInlineStyleState', - 'updateInlineStyleState', - 'getCanvasFont', - 'applyTextStyle', 'getPageContent', - 'buildLineSegments', 'prepareRevealBlock', 'prepareContinuationRevealPlan', 'takeContinuationRevealPlan', @@ -113,6 +99,7 @@ class BookTextureRendererModule extends BaseModule { 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) @@ -128,11 +115,94 @@ class BookTextureRendererModule extends BaseModule { 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.drawSpread(this.currentSpread); + 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.rasterChain = Promise.resolve(); + this.rasterWorker.onmessage = (event) => { + const data = event.data || {}; + if (data.type !== 'drawn') return; + const resolve = this.pendingRasterizations.get(data.requestId); + if (resolve) { + this.pendingRasterizations.delete(data.requestId); + resolve(data.results); + } + }; + // Warm the worker's fonts immediately so the first real page render is not delayed. + this.rasterWorker.postMessage({ type: 'warm-fonts' }); + } + + // 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) => { + this.pendingRasterizations.set(requestId, resolve); + 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, @@ -182,9 +252,23 @@ class BookTextureRendererModule extends BaseModule { }); } + // 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); @@ -195,7 +279,9 @@ class BookTextureRendererModule extends BaseModule { this.lastDrawSkipLoggedAt = now; this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw }); } - if (phase === 'prepare') this.currentSpread = previousSpread; + this.revealPublishBlockIds = null; + this.revealSpreadSourceOverride = null; + this.currentSpread = previousSpread; return null; } this.markPipelineTiming('drawSpread:start', { @@ -204,21 +290,24 @@ class BookTextureRendererModule extends BaseModule { phase }); this.revealBaseCanvases = { left: null, right: null }; + const results = await this.rasterizeSpread(sidesToDraw, hasReveal); 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 result = results?.[side]; + if (!this.canvases[side] || !result) return; + const ctx = this.contexts[side]; + ctx.clearRect(0, 0, this.canvases[side].width, this.canvases[side].height); + ctx.drawImage(result.pageBitmap, 0, 0); + result.pageBitmap.close?.(); + if (hasReveal && result.baseBitmap) { + this.revealBaseCanvases[side] = this.canvasFromBitmap(result.baseBitmap); + } + result.baseBitmap?.close?.(); }); const published = this.publishSpread(sidesToDraw, options); - this.markPipelineTiming('drawSpread:end', { - sides: sidesToDraw, - phase - }); + this.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; @@ -249,273 +338,6 @@ class BookTextureRendererModule extends BaseModule { 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 metadata = this.gameConfig?.getMetadata?.() || {}; - const titleText = document.getElementById('game_title')?.textContent?.trim() || metadata.title || ''; - const authorText = document.getElementById('game_author')?.textContent?.trim() - || (metadata.author ? this.localization?.t?.('title.byAuthor', { author: metadata.author }) : '') - || ''; - const subtitleText = document.getElementById('game_subtitle')?.textContent?.trim() || metadata.subtitle || ''; - 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() || [ - metadata.version ? this.localization?.t?.('title.version', { version: metadata.version }) : '', - metadata.copyright || '' - ].filter(Boolean).join(' | '); - 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); - 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); - } - buildRevealRegions(side) { if (!this.revealPublishBlockIds || !this.metrics) return null; const candidates = this.collectRevealRegionCandidates(); @@ -880,7 +642,7 @@ class BookTextureRendererModule extends BaseModule { }; } - prepareRevealBlock(detail = {}, options = {}) { + async prepareRevealBlock(detail = {}, options = {}) { const blockId = detail.blockId ?? detail.id ?? null; if (blockId == null || !Array.isArray(detail.wordTimings)) return; const id = String(blockId); @@ -912,25 +674,19 @@ class BookTextureRendererModule extends BaseModule { } this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail)); - this.revealPublishBlockIds = new Set([id]); 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 previousOverride = this.revealSpreadSourceOverride; - if (spanningPreview) this.revealSpreadSourceOverride = detail.previewSpreads; - let published = null; - try { - published = this.drawSpread(spread, sides, { - phase, - publishEvent: options.publishEvent !== false - }); - } finally { - this.revealSpreadSourceOverride = previousOverride; - } - if (!spanningPreview) this.preloadAdditionalRevealSpreads(id, spread); + 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, @@ -957,7 +713,7 @@ class BookTextureRendererModule extends BaseModule { // 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). - prepareContinuationRevealPlan(detail = {}) { + async prepareContinuationRevealPlan(detail = {}) { const blockId = detail.blockId ?? detail.id ?? null; const previewSpreads = Array.isArray(detail.previewSpreads) ? detail.previewSpreads : null; const continuationSpread = detail.continuationSpread || null; @@ -968,18 +724,12 @@ class BookTextureRendererModule extends BaseModule { if (!existing || existing.completed) { this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail)); } - const previousOverride = this.revealSpreadSourceOverride; - const previousPublishIds = this.revealPublishBlockIds; - this.revealSpreadSourceOverride = previewSpreads; - this.revealPublishBlockIds = new Set([id]); - let published = null; - try { - published = this.drawSpread(continuationSpread, ['left', 'right'], { phase: 'prepare', publishEvent: false }); - } finally { - // drawSpread nulls revealPublishBlockIds when it finishes; restore the caller's state. - this.revealSpreadSourceOverride = previousOverride; - this.revealPublishBlockIds = previousPublishIds; - } + 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, @@ -1020,15 +770,16 @@ class BookTextureRendererModule extends BaseModule { return activated; } - preloadAdditionalRevealSpreads(blockId, primarySpread = null) { + async preloadAdditionalRevealSpreads(blockId, primarySpread = null) { const spreads = Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : []; if (!spreads.length) return; const primaryIndex = Number(primarySpread?.index); - spreads.forEach((spread) => { - if (!spread || Number(spread.index) === primaryIndex) return; - if (!this.spreadContainsBlock(spread, blockId)) return; - this.drawSpread(spread, ['left', 'right'], { phase: 'prepare' }); - }); + 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 = '') { diff --git a/public/js/book-texture-worker.js b/public/js/book-texture-worker.js new file mode 100644 index 0000000..3e4e49e --- /dev/null +++ b/public/js/book-texture-worker.js @@ -0,0 +1,362 @@ +// OffscreenCanvas page rasterizer. Runs off the main thread so the heavy page text drawing +// (the bulk of drawSpread cost) never blocks the render loop or UI. The main thread sends a +// draw job (line records + metrics + page meta + title data + preloaded image bitmaps) and +// receives back a full-page ImageBitmap and a background-only base ImageBitmap per side; the +// main thread blits those onto its existing page canvases, leaving the texture/reveal pipeline +// unchanged. This is the single rasterization implementation — the main thread no longer draws +// page text itself. + +let fontsReady = null; +const imageCache = new Map(); // src -> ImageBitmap | null +const surfaces = {}; // side -> { canvas, ctx } + +function 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, '/')}`; +} + +async function ensureImages(srcs = []) { + await Promise.all(srcs.map(async (src) => { + if (!src || imageCache.has(src)) return; + try { + const response = await fetch(src); + const blob = await response.blob(); + imageCache.set(src, await createImageBitmap(blob)); + } catch (error) { + imageCache.set(src, null); + } + })); +} + +function ensureFonts() { + if (fontsReady) return fontsReady; + if (typeof FontFace === 'undefined' || !self.fonts) { + fontsReady = Promise.resolve(); + return fontsReady; + } + const faces = [ + new FontFace('EB Garamond', 'url(/fonts/EBGaramond12-Regular.otf)', { style: 'normal', weight: '400' }), + new FontFace('EB Garamond', 'url(/fonts/EBGaramond12-Italic.otf)', { style: 'italic', weight: '400' }), + new FontFace('EB Garamond 12', 'url(/fonts/EBGaramond12/webfonts/EBGaramond-Regular.woff2)', {}), + new FontFace('EB Garamond Initials', 'url(/fonts/EB-Garamond-Initials/EBGaramond-0.016/otf/EBGaramond-Initials.otf)', {}) + ]; + fontsReady = Promise.all(faces.map(face => face.load() + .then(loaded => { self.fonts.add(loaded); }) + .catch(() => {}))); + return fontsReady; +} + +function getSurface(width, height) { + if (!surfaces.shared) { + surfaces.shared = { canvas: new OffscreenCanvas(width, height) }; + surfaces.shared.ctx = surfaces.shared.canvas.getContext('2d'); + } + const surface = surfaces.shared; + if (surface.canvas.width !== width) surface.canvas.width = width; + if (surface.canvas.height !== height) surface.canvas.height = height; + return surface; +} + +function getPageContent(metrics, side) { + return metrics?.contentBySide?.[side] || metrics?.content || { + x: 0, y: 0, width: metrics?.width || 1, height: metrics?.height || 1 + }; +} + +function 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; +} + +// DOM-free inline-tag parser (the main-thread renderer used document.createElement; a worker +// has no DOM, so parse the tag string directly). +function 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 = 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') { + updateInlineStyleState(styleStack, node.value); + } + }); + + return segments; +} + +function drawLine(ctx, metrics, lineRecord, side) { + const content = getPageContent(metrics, 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); + const x = content.x + centerOffset; + const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps); + const baseStyle = 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'; + applyTextStyle(ctx, metrics, 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); + 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'; + applyTextStyle(ctx, metrics, fontPx, smallCaps, baseStyle); + } + buildLineSegments(ctx, nodes, line, ratio, baseStyle).forEach((segment) => { + applyTextStyle(ctx, metrics, fontPx, smallCaps, segment.style || {}); + ctx.fillText(segment.value || '', x + segment.x, baseY); + }); +} + +function drawImageFitted(ctx, bitmap, x, y, width, height) { + const sourceWidth = bitmap.width || 1; + const sourceHeight = bitmap.height || 1; + const sourceAspect = sourceWidth / sourceHeight; + const targetAspect = width / height; + let sx = 0, sy = 0, sw = sourceWidth, 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(bitmap, sx, sy, sw, sh, x, y, width, height); +} + +function drawImageRecord(ctx, metrics, lineRecord, side) { + const content = getPageContent(metrics, 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 || metrics.typographyLineHeightPx)); + const bitmap = imageCache.get(resolveImageSource(lineRecord.metadata || {})); + if (!bitmap) return; + ctx.save(); + drawImageFitted(ctx, bitmap, x, y, width, height); + ctx.restore(); +} + +function drawPageBase(ctx, side, width, height) { + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = '#f2ead0'; + ctx.fillRect(0, 0, width, height); + const shade = ctx.createLinearGradient(0, 0, 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, width, height); +} + +function drawTitlePage(ctx, metrics, side, titleData) { + if (!titleData) return; + const content = getPageContent(metrics, side); + const centerX = content.x + content.width * 0.5; + const font = 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 (titleData.author) { + ctx.font = `italic ${Math.round(metrics.bodyFontSizePx * 0.86)}px ${font}`; + ctx.fillText(titleData.author, centerX, content.y + content.height * 0.18); + } + if (titleData.title) { + ctx.font = `${Math.round(metrics.bodyFontSizePx * 1.55)}px ${font}`; + if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'small-caps'; + ctx.fillText(titleData.title, centerX, content.y + content.height * 0.28); + if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal'; + } + if (titleData.subtitle) { + ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.94)}px ${font}`; + ctx.fillText(titleData.subtitle, centerX, content.y + content.height * 0.39); + } + if (titleData.ornament) { + ctx.font = `${Math.round(metrics.bodyFontSizePx * 1.3)}px ${font}`; + ctx.fillText(titleData.ornament, centerX, content.y + content.height * 0.52); + } + if (titleData.legal) { + ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.62)}px ${font}`; + ctx.fillText(titleData.legal, centerX, content.y + content.height * 0.96); + } + ctx.restore(); +} + +function drawPageNumber(ctx, metrics, side, meta) { + if (!meta || meta.omitPageNumber || meta.pageNumber == null) return; + const content = getPageContent(metrics, side); + ctx.save(); + ctx.fillStyle = 'rgba(31, 19, 10, 0.74)'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.68)}px ${metrics.typography.fontFamily}`; + ctx.fillText(String(meta.pageNumber), content.x + content.width * 0.5, content.y + content.height + metrics.margins.bottom * 0.48); + ctx.restore(); +} + +function drawPageLines(ctx, metrics, side, lines) { + 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'; + (Array.isArray(lines) ? lines : []).forEach(line => { + if (line?.type === 'image' || line?.kind === 'image') drawImageRecord(ctx, metrics, line, side); + else drawLine(ctx, metrics, line, side); + }); + ctx.restore(); +} + +async function renderSide(job, side) { + const { metrics, width, height } = job; + const surface = getSurface(width, height); + const ctx = surface.ctx; + const meta = job.pageMeta?.[side] || null; + + drawPageBase(ctx, side, width, height); + let baseBitmap = null; + if (job.hasReveal) baseBitmap = await createImageBitmap(surface.canvas); + if (meta?.kind === 'title') drawTitlePage(ctx, metrics, side, job.titleData); + drawPageLines(ctx, metrics, side, job.spreads?.[side] || []); + drawPageNumber(ctx, metrics, side, meta); + const pageBitmap = await createImageBitmap(surface.canvas); + return { pageBitmap, baseBitmap }; +} + +function collectImageSources(job) { + const srcs = new Set(); + (job.sides || ['left', 'right']).forEach((side) => { + (job.spreads?.[side] || []).forEach((line) => { + if (line?.type === 'image' || line?.kind === 'image') { + const src = resolveImageSource(line.metadata || {}); + if (src) srcs.add(src); + } + }); + }); + return Array.from(srcs); +} + +async function handleDraw(job) { + await ensureFonts(); + await ensureImages(collectImageSources(job)); + const results = {}; + const transfer = []; + for (const side of (job.sides || ['left', 'right'])) { + // eslint-disable-next-line no-await-in-loop + const { pageBitmap, baseBitmap } = await renderSide(job, side); + results[side] = { pageBitmap, baseBitmap }; + transfer.push(pageBitmap); + if (baseBitmap) transfer.push(baseBitmap); + } + self.postMessage({ type: 'drawn', requestId: job.requestId, results }, transfer); +} + +self.onmessage = (event) => { + const data = event.data || {}; + if (data.type === 'draw') handleDraw(data); + else if (data.type === 'warm-fonts') ensureFonts().then(() => self.postMessage({ type: 'fonts-ready' })); +}; diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index 23994b7..1ff1a12 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -37,6 +37,8 @@ const bookPlaybackTimelinePath = path.join(__dirname, '..', 'public', 'js', 'boo const bookPlaybackTimelineSource = fs.readFileSync(bookPlaybackTimelinePath, 'utf8'); const ttsFactoryPath = path.join(__dirname, '..', 'public', 'js', 'tts-factory-module.js'); const ttsFactorySource = fs.readFileSync(ttsFactoryPath, 'utf8'); +const textureWorkerPath = path.join(__dirname, '..', 'public', 'js', 'book-texture-worker.js'); +const textureWorkerSource = fs.readFileSync(textureWorkerPath, 'utf8'); function dependencyList(source, moduleId) { const classStart = source.indexOf(`super('${moduleId}'`); @@ -160,7 +162,8 @@ const checks = [ ['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)], ['sentence queue skips duplicate current-item 3D book presentation when reveal is cached', /isWebGLBookPresentationPrepared/.test(sentenceQueueSource) && /if \(!this\.isWebGLBookPresentationPrepared\(sentence\)\) \{\s*await this\.prefetchWebGLBookPresentation/.test(sentenceQueueSource) && /sentence\.webglBookPresentation = \{\s*prepared: true/.test(sentenceQueueSource)], ['3D overflow reveal commits the spread then requests a timeline flip via event before activating', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.commitSegmentSpread\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /dispatchEvent\(new CustomEvent\('webgl-book:request-page-flip'/.test(bookPlaybackTimelineSource) && /addEventListener\('webgl-book:request-page-flip'/.test(source) && /startPageFlip\(direction, \{/.test(source)], - ['texture renderer paints inline bold and italic styles', /getInlineStyleState/.test(textureRendererSource) && /updateInlineStyleState/.test(textureRendererSource) && /getCanvasFont/.test(textureRendererSource) && /segment\?\.style/.test(textureRendererSource)], + ['texture worker paints inline bold and italic styles off the main thread', /getInlineStyleState/.test(textureWorkerSource) && /updateInlineStyleState/.test(textureWorkerSource) && /getCanvasFont/.test(textureWorkerSource) && /segment\.style/.test(textureWorkerSource) && !/drawLine\(ctx/.test(textureRendererSource)], + ['texture renderer delegates page rasterization to an OffscreenCanvas worker and blits the result', /book-texture-worker\.js/.test(textureRendererSource) && /rasterizeSpread/.test(textureRendererSource) && /ctx\.drawImage\(result\.pageBitmap, 0, 0\)/.test(textureRendererSource) && /OffscreenCanvas/.test(textureWorkerSource) && /createImageBitmap/.test(textureWorkerSource)], ['webgl lab can preload page textures without swapping visible page material through texture store', /preparePageTexture\(side = 'left'/.test(webglPageCacheSource) && /takePreparedPageTexture\(side = 'left'/.test(webglPageCacheSource) && /renderer\.initTexture\(texture\)/.test(webglPageCacheSource) && /takePreparedPageTexture/.test(source) && !/const preparedPageTextures/.test(source)], ['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)], ['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)], @@ -181,12 +184,12 @@ const checks = [ ['pagination normalizes every spread to explicit left and right page records', /normalizePagesForSpreads/.test(bookPaginationSource) && /const lastSpreadRightIndex/.test(bookPaginationSource) && /this\.createBlankPage\(index/.test(bookPaginationSource) && /normalizedPages\.forEach/.test(bookPaginationSource)], ['texture renderer adopts initial pagination spread so title page is painted after loader order', /this\.currentSpread = this\.pagination\?\.getCurrentSpread\?\.\(\) \|\| \{ index: 0/.test(textureRendererSource) && /this\.drawSpread\(this\.currentSpread\);/.test(textureRendererSource)], ['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)], - ['texture renderer draws title page and page numbers from versioned page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)], - ['texture renderer uses plural page margin metrics for page numbers', /this\.metrics\.margins\.bottom/.test(textureRendererSource) && !/this\.metrics\.margin\.bottom/.test(textureRendererSource)], + ['texture worker draws title page and page numbers; renderer marshals title data and versioned page metadata', /drawTitlePage/.test(textureWorkerSource) && /drawPageNumber/.test(textureWorkerSource) && /game_title/.test(textureRendererSource) && /buildTitleData/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)], + ['texture worker uses plural page margin metrics for page numbers', /metrics\.margins\.bottom/.test(textureWorkerSource) && !/metrics\.margin\.bottom/.test(textureWorkerSource)], ['webgl flip assigns explicit source and back page textures before animation starts', /resolveCurrentFlipSourceTexture\(sourceSide\)/.test(source) && /const targetBackSide = flip\.direction > 0 \? 'left' : 'right'/.test(source) && /const targetBackPageMeta = getPaginationPageMeta\(targetBackPageIndex\) \|\| makeBlankPageMeta\(targetBackPageIndex\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source)], ['webgl flip never falls back to the opposite visible stack for target back texture', /function resolveFlipBackTexture\(pageMeta = null, prewarmedTexture = null\)/.test(source) && source.includes('return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);') && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))], ['webgl page texture record metadata normalizes omitted or null sides into explicit blank pages', /function normalizePageMetaPair/.test(source) && /function makeBlankPageMeta/.test(source) && /applyExplicitBlankPageTexture/.test(source) && /normalizePageTextureRecordDetail/.test(source) && !/hasLeftMeta/.test(methodBody(source, 'handlePageTextureRecords'))], - ['texture renderer publishes both spread sides for reveal preparation', /const sides = \['left', 'right'\]/.test(textureRendererSource) && /published = this\.drawSpread\(spread, sides/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)], + ['texture renderer publishes both spread sides for reveal preparation', /const sides = \['left', 'right'\]/.test(textureRendererSource) && /published = await this\.drawSpread\(spread, sides/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)], ['texture renderer marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)], ['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(webglPageCacheSource)], ['webgl texture store resident cache reuses newest page version for older readiness requests', /isOlderPageMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta/.test(webglPageCacheSource) && /if \(!resident\) return null/.test(webglPageCacheSource) && !/if \(!resident \|\| this\.isOlderPageMeta\(pageMeta, resident\.pageMeta\)\) return null/.test(webglPageCacheSource)], @@ -226,7 +229,7 @@ const checks = [ ['texture renderer derives reveal regions from the just-drawn preview spread before committed pagination', /if \(this\.currentSpread\) sourceSpreads\.push\(this\.currentSpread\)/.test(textureRendererSource) && /paginationSpreads\.forEach/.test(textureRendererSource) && /Number\(spread\.index\) === Number\(this\.currentSpread\.index\)/.test(textureRendererSource)], ['texture renderer prepares a spanning block continuation spread in the background and reuses it (no synchronous redraw on the critical path)', /revealSpreadSourceOverride/.test(textureRendererSource) && /prepareContinuationRevealPlan/.test(textureRendererSource) && /takeContinuationRevealPlan/.test(textureRendererSource) && /`\$\{id\}:cont`/.test(textureRendererSource) && /prepareContinuationRevealPlan/.test(bookPlaybackTimelineSource) && /takeContinuationRevealPlan/.test(bookPlaybackTimelineSource) && /previewSpreads/.test(bookPaginationSource)], ['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource)], - ['book playback timeline has one prepare path: spanning-aware plan reused at activate and continuation, no fallback', /this\.revealSpreadSourceOverride = detail\.previewSpreads/.test(textureRendererSource) && !/forceRebuild/.test(textureRendererSource) && !/forceRebuild/.test(bookPlaybackTimelineSource) && !/spanningPlanPrepared/.test(bookPlaybackTimelineSource) && /const texturePlan = this\.textureRenderer\.takeContinuationRevealPlan\(segment\.blockId, spread\.index\)/.test(bookPlaybackTimelineSource)], + ['book playback timeline has one prepare path: spanning-aware plan reused at activate and continuation, no fallback', /revealSpreadSourceOverride: spanningPreview \? detail\.previewSpreads : null/.test(textureRendererSource) && /this\.revealSpreadSourceOverride = options\.revealSpreadSourceOverride/.test(textureRendererSource) && !/forceRebuild/.test(textureRendererSource) && !/forceRebuild/.test(bookPlaybackTimelineSource) && !/spanningPlanPrepared/.test(bookPlaybackTimelineSource) && /const texturePlan = this\.textureRenderer\.takeContinuationRevealPlan\(segment\.blockId, spread\.index\)/.test(bookPlaybackTimelineSource)], ['webgl visible spread is owned by scene flips, not pagination publishes', /spreadUpdate:state-only/.test(source) && /webglBookPlaybackActive/.test(source) && /spreadUpdate:jump/.test(source) && /window\.BookTextureRenderer\?\.drawSpread\?\.\(spread, \['left', 'right'\], \{ force: true \}\)/.test(source)], ['3D overflow reveal preloads target spread before forced page flip', /createRevealDetail\(sentence, previewSpread, 'prepare'\)/.test(bookPlaybackTimelineSource) && /this\.textureRenderer\.prepareRevealBlock\(\s*[\s\S]*revealDetail[\s\S]*phase: 'prepare'[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /this\.assertSegmentReady\(segment, 'prepare'\)/.test(bookPlaybackTimelineSource) && /reason: 'timeline-preplay-spread-transition'/.test(bookPlaybackTimelineSource)], ['book playback timeline is loaded through module infrastructure', /book-playback-timeline-module\.js/.test(loaderSource) && /super\('book-playback-timeline'/.test(bookPlaybackTimelineSource) && /reportProgress\(100, 'Book playback timeline ready'\)/.test(bookPlaybackTimelineSource)],