From c86a30436491db36cf95f1d39cdf7be6276b6c58 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Mon, 8 Jun 2026 08:19:20 +0200 Subject: [PATCH] Checkpoint WebGL book reveal optimization --- public/css/style.css | 41 ++++ public/index.html | 2 +- public/js/book-page-format-module.js | 8 +- public/js/book-pagination-module.js | 77 +++++-- public/js/book-texture-renderer-module.js | 243 +++++++++++++++++++--- public/js/choice-display-module.js | 8 +- public/js/loader.js | 2 +- public/js/sentence-queue-module.js | 126 ++++++++--- public/js/ui-controller-module.js | 18 +- public/js/ui-display-handler-module.js | 2 +- public/js/webgl-book-lab.js | 173 ++++++++++++--- public/js/webgl-book-scene-module.js | 2 + scripts/check-webgl-book-lab.js | 28 +++ 13 files changed, 618 insertions(+), 112 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 1bc61ae..0328ec9 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1931,6 +1931,47 @@ body.webgl-mode { background: #090705; align-items: stretch; justify-content: stretch; + --ui-menu-font-size: 1rem; + --ui-modal-font-size: 1.18rem; + font-size: 18px; +} + +body.webgl-mode #choices, +body.webgl-mode .story-choices { + color: rgba(246, 231, 201, 0.92); + scrollbar-color: rgba(246, 231, 201, 0.54) rgba(255, 236, 190, 0.08); +} + +body.webgl-mode #command_history .history-item { + color: rgba(246, 231, 201, 0.78); +} + +body.webgl-mode #command_history .history-item:hover, +body.webgl-mode #command_history .history-item.active { + color: rgba(255, 246, 220, 0.96); +} + +body.webgl-mode .story-choices::-webkit-scrollbar-track { + background: rgba(255, 236, 190, 0.08); +} + +body.webgl-mode .story-choices::-webkit-scrollbar-thumb { + background-color: rgba(246, 231, 201, 0.54); +} + +body.webgl-mode .choice-list .choice-button { + color: rgba(246, 231, 201, 0.82); +} + +body.webgl-mode .choice-list .choice-button:hover, +body.webgl-mode .choice-list .choice-button:focus-visible { + color: rgba(255, 248, 225, 0.98); + background: rgba(255, 236, 190, 0.12); + outline-color: rgba(255, 236, 190, 0.48); +} + +body.webgl-mode .choice-list kbd { + color: rgba(255, 248, 225, 0.96); } #webgl_app { diff --git a/public/index.html b/public/index.html index 5ac2f6d..a277050 100644 --- a/public/index.html +++ b/public/index.html @@ -280,6 +280,6 @@ console.log(message); }; - + diff --git a/public/js/book-page-format-module.js b/public/js/book-page-format-module.js index b976874..f4d8f45 100644 --- a/public/js/book-page-format-module.js +++ b/public/js/book-page-format-module.js @@ -3,7 +3,7 @@ * Defines the canonical page geometry used by the WebGL book renderer. */ import { BaseModule } from './base-module.js'; -import { calculateProceduralBookThickness, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-typography-a'; +import { calculateProceduralBookThickness, snapProceduralPageCount } from './procedural-book-model.js?v=20260608-webgl-mask-timing-c'; export const BOOK_TEXTURE_WIDTH = 3072; @@ -24,9 +24,9 @@ class BookPageFormatModule extends BaseModule { innerMinIn: 0.48, innerMaxIn: 0.74, innerThicknessFactor: 0.32, - outerBaseIn: 0.36, - outerThicknessFactor: 0.02, - outerMaxIn: 0.42 + outerBaseIn: 0.27, + outerThicknessFactor: 0.015, + outerMaxIn: 0.315 }), typography: Object.freeze({ fontFamily: '"EB Garamond", "EB Garamond 12", serif', diff --git a/public/js/book-pagination-module.js b/public/js/book-pagination-module.js index 4e198ae..1989418 100644 --- a/public/js/book-pagination-module.js +++ b/public/js/book-pagination-module.js @@ -32,6 +32,8 @@ class BookPaginationModule extends BaseModule { 'extractLayoutLine', 'extractRemainingLayoutText', 'extractLines', + 'getActiveStyleTags', + 'updateStyleTagStack', 'countLineWords', 'getLineGeometry', 'getSpread', @@ -95,8 +97,8 @@ class BookPaginationModule extends BaseModule { this.publish(); } - async preparePendingBlock(block = {}) { - const token = ++this.refreshToken; + async preparePendingBlock(block = {}, options = {}) { + const token = options.activate === false ? this.refreshToken : ++this.refreshToken; const gameId = block.gameId || block.metadata?.gameId || this.storyHistory?.currentGameId || null; const latestRenderedBlockId = Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0)); const pendingBlockId = Math.max(0, Number(block.blockId || block.metadata?.blockId || 0)); @@ -104,10 +106,13 @@ class BookPaginationModule extends BaseModule { return null; } - const historyBlocks = latestRenderedBlockId > 0 - ? await this.storyHistory.getBlocksRange(gameId, 1, latestRenderedBlockId) + const historyEndBlockId = options.includeUnrenderedHistory + ? Math.max(0, pendingBlockId - 1) + : latestRenderedBlockId; + const historyBlocks = historyEndBlockId > 0 + ? await this.storyHistory.getBlocksRange(gameId, 1, historyEndBlockId) : []; - if (token !== this.refreshToken) return null; + if (options.activate !== false && token !== this.refreshToken) return null; const normalizedBlock = { ...block, @@ -121,26 +126,30 @@ class BookPaginationModule extends BaseModule { gameId } }; - this.latestBlockId = pendingBlockId; - this.latestRenderedBlockId = latestRenderedBlockId; - this.spreads = this.buildSpreads([...historyBlocks, normalizedBlock]); - this.currentSpreadIndex = Math.max(0, Math.min(this.spreads.length - 1, this.currentSpreadIndex)); - const targetSpread = this.spreads.find(spread => ['left', 'right'].some(side => { + const preparedSpreads = this.buildSpreads([...historyBlocks, normalizedBlock]); + const targetSpread = preparedSpreads.find(spread => ['left', 'right'].some(side => { const lines = Array.isArray(spread?.[side]) ? spread[side] : []; return lines.some(line => Number(line?.blockId || 0) === pendingBlockId); })); - if (targetSpread) this.currentSpreadIndex = targetSpread.index; - this.publish(); + if (options.activate !== false) { + this.latestBlockId = pendingBlockId; + this.latestRenderedBlockId = latestRenderedBlockId; + this.spreads = preparedSpreads; + this.currentSpreadIndex = Math.max(0, Math.min(this.spreads.length - 1, this.currentSpreadIndex)); + if (targetSpread) this.currentSpreadIndex = targetSpread.index; + } + if (options.publish !== false) this.publish(); document.dispatchEvent(new CustomEvent('book-pagination:block-prepared', { detail: { blockId: pendingBlockId, - spread: this.getCurrentSpread(), - spreadIndex: this.currentSpreadIndex, - latestBlockId: this.latestBlockId, - latestRenderedBlockId: this.latestRenderedBlockId + spread: targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread()), + spreadIndex: targetSpread?.index ?? this.currentSpreadIndex, + latestBlockId: pendingBlockId, + latestRenderedBlockId, + preloadOnly: options.activate === false } })); - return this.getCurrentSpread(); + return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread()); } buildSpreads(blocks = []) { @@ -342,6 +351,7 @@ class BookPaginationModule extends BaseModule { isFinal: lineIndex === layout.breaks.length - 2, smallCaps: Boolean(metadata.smallCaps), hyphenated: Boolean(endNode?.type === 'penalty' && endNode.penalty === 100), + activeStyleTags: this.getActiveStyleTags(layout.nodes, startBreak.position), align: 'justify' }; } @@ -385,12 +395,45 @@ class BookPaginationModule extends BaseModule { ratio: breaks[index].ratio || 0, isFinal: index === breaks.length - 1, hyphenated: Boolean(lineNodes.at(-1)?.type === 'penalty' && lineNodes.at(-1)?.penalty === 100), + activeStyleTags: this.getActiveStyleTags(nodes, start), align: options.align || 'justify' }); } return lines; } + getActiveStyleTags(nodes = [], endPosition = 0) { + const stack = []; + for (let index = 0; index < endPosition; index += 1) { + const node = nodes[index]; + if (node?.type !== 'tag') continue; + this.updateStyleTagStack(stack, node.value); + } + return stack.map(tag => ({ ...tag })); + } + + updateStyleTagStack(stack = [], value = '') { + const text = String(value || ''); + if (!text.startsWith('<')) return stack; + if (text.startsWith(' 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) : [] + 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.drawPageLines(side, this.currentSpread?.[side] || []); }); - this.publishSpread(sidesToDraw); + const published = this.publishSpread(sidesToDraw, options); this.markPipelineTiming('drawSpread:end', { - sides: sidesToDraw + 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 ids = lines.map(line => `${line.blockId ?? ''}:${line.lineIndex ?? ''}:${line.pageLine ?? ''}:${line.line?.nodes?.length || 0}`).join(','); + return `${side}[${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) { @@ -217,7 +272,6 @@ class BookTextureRendererModule extends BaseModule { 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 fontStyle = lineRecord.fontStyle === 'italic' ? 'italic ' : ''; const line = lineRecord.line || {}; const nodes = Array.isArray(line.nodes) ? line.nodes : []; const baseY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx; @@ -233,10 +287,13 @@ class BookTextureRendererModule extends BaseModule { 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'; - ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`; + this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle); if (lineRecord.dropCapText) { ctx.save(); const dropCapFontPx = Math.round(fontPx * 2.68); @@ -249,15 +306,65 @@ class BookTextureRendererModule extends BaseModule { 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'; - ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`; + this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle); } - this.buildLineSegments(ctx, nodes, line, ratio).forEach((segment) => { - this.drawWord(ctx, segment.value, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx); + 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); - if (currentSegment && !previousWasGlue) { + 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: segments.length + wordIndex: Math.max(0, currentWordIndex), + style }; segments.push(currentSegment); } @@ -308,15 +420,19 @@ class BookTextureRendererModule extends BaseModule { x += hyphenWidth; } previousWasGlue = false; + } else if (node.type === 'tag') { + this.updateInlineStyleState(styleStack, node.value); } }); return segments; } - drawWord(ctx, value, x, baseY, lineRecord, localWordIndex, side, fontPx, lineHeightPx) { + 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 = ctx.measureText(value).width || fontPx; + const width = Number(segment?.width || 0) || ctx.measureText(value).width || fontPx; this.recordRevealRect(side, lineRecord, x, baseY - fontPx, width, lineHeightPx, localWordIndex); } @@ -392,16 +508,8 @@ class BookTextureRendererModule extends BaseModule { this.requestAnimationFrame(); } - prepareRevealBlock(detail = {}) { - const blockId = detail.blockId ?? detail.id ?? null; - if (blockId == null || !Array.isArray(detail.wordTimings)) return; - const id = String(blockId); - const wordTimings = detail.wordTimings; - this.markPipelineTiming('prepareRevealBlock:start', { - blockId: id, - wordTimingCount: wordTimings.length - }); - this.activeAnimations.set(id, { + createAnimationState(blockId, wordTimings = [], detail = {}) { + return { blockId, wordTimings, startedAt: null, @@ -411,16 +519,73 @@ class BookTextureRendererModule extends BaseModule { ), 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]); - this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId)); + 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 + 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); @@ -534,7 +699,7 @@ class BookTextureRendererModule extends BaseModule { if (hasActive) this.requestAnimationFrame(); } - publishSpread(sides = null) { + publishSpread(sides = null, options = {}) { const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; const wordCounts = { left: this.revealWords?.left?.length || 0, @@ -542,10 +707,16 @@ class BookTextureRendererModule extends BaseModule { }; const detail = { metrics: this.metrics, - hitMaps: this.hitMaps + hitMaps: this.hitMaps, + sides: sidesToPublish }; - if (sidesToPublish.includes('left')) detail.left = this.canvases.left; - if (sidesToPublish.includes('right')) detail.right = this.canvases.right; + 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]; @@ -559,6 +730,7 @@ class BookTextureRendererModule extends BaseModule { 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, @@ -577,11 +749,13 @@ class BookTextureRendererModule extends BaseModule { this.markPipelineTiming('publishSpread', { sides: sidesToPublish, hasReveal: Object.keys(reveal).length > 0, - wordCounts + wordCounts, + preloadOnly: Boolean(options.preloadOnly) }); document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { detail })); + return detail; } getPageCanvas(side) { @@ -595,6 +769,7 @@ class BookTextureRendererModule extends BaseModule { handlePageCountChanged(event) { this.pageFormat?.setPageCount?.(event.detail?.pageCount); this.createPageCanvases(); + this.lastDrawSignature = null; this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); } diff --git a/public/js/choice-display-module.js b/public/js/choice-display-module.js index 83a64c5..0389255 100644 --- a/public/js/choice-display-module.js +++ b/public/js/choice-display-module.js @@ -20,6 +20,7 @@ class ChoiceDisplayModule extends BaseModule { this.currentTurnId = 0; this.autoTurnCounter = 0; this.lastAutoTurn = new Map(); + this.selectionInProgress = false; this.template = { cells: { default: { @@ -136,6 +137,7 @@ class ChoiceDisplayModule extends BaseModule { }; this.currentGlossaryEntries = detail.glossaryEntries; this.choices = this.normalizeChoices(detail.choices); + this.selectionInProgress = false; this.render(); } @@ -159,7 +161,7 @@ class ChoiceDisplayModule extends BaseModule { return; } - if (event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) { + if (event.repeat || event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) { return; } @@ -434,6 +436,9 @@ class ChoiceDisplayModule extends BaseModule { } async selectChoice(index) { + if (this.selectionInProgress) { + return; + } if (!this.socketClient) { this.socketClient = this.getModule('socket-client'); } @@ -442,6 +447,7 @@ class ChoiceDisplayModule extends BaseModule { return; } + this.selectionInProgress = true; this.clear(); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'command-waiting', reason: 'choice-selected', choiceIndex: index } diff --git a/public/js/loader.js b/public/js/loader.js index 2e3e371..e22297e 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -24,7 +24,7 @@ const ModuleState = { ERROR: 'ERROR' }; -const MODULE_CACHE_BUSTER = '20260607-webgl-typography-a'; +const MODULE_CACHE_BUSTER = '20260608-webgl-mask-timing-c'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** diff --git a/public/js/sentence-queue-module.js b/public/js/sentence-queue-module.js index 32ea79a..3559370 100644 --- a/public/js/sentence-queue-module.js +++ b/public/js/sentence-queue-module.js @@ -20,8 +20,10 @@ class SentenceQueueModule extends BaseModule { this.isProcessing = false; this.onSentenceReadyCallback = null; - // Cache in-flight TTS prefetches only. Layout belongs to the renderer. + // Cache prepared future queue items so the playback path can consume + // work that was already generated during lookahead. this.prefetchingSpeech = new Map(); + this.preparedSentenceCache = new Map(); this.autoplay = true; this.inputMode = 'text'; this.lastContinueAt = 0; @@ -43,6 +45,7 @@ class SentenceQueueModule extends BaseModule { 'getCacheKey', 'getPreparedSentence', 'prefetchAhead', + 'prefetchWebGLBookPresentation', 'prepareSpeechMetadata', 'preloadAssetsForItem', 'normalizeTtsText', @@ -156,9 +159,12 @@ class SentenceQueueModule extends BaseModule { text: String(queueItem.text || '').trim() }); - // Process the queue if not already processing + // Process the queue if not already processing. If playback is already + // running, immediately start lookahead for the newly appended item. if (!this.isProcessing) { this.processNextSentence(); + } else { + this.prefetchAhead(4, this.queueGeneration); } } @@ -194,6 +200,11 @@ class SentenceQueueModule extends BaseModule { const sentence = await this.getPreparedSentence(item); if (!this.isCurrentQueueItem(item, queueGeneration)) return; + await this.prefetchWebGLBookPresentation(sentence, { + queueGeneration, + queueIndex: 0 + }); + if (!this.isCurrentQueueItem(item, queueGeneration)) return; // Prefetch far enough ahead that media pauses do not block TTS // generation for the next spoken paragraph. @@ -499,14 +510,15 @@ class SentenceQueueModule extends BaseModule { * Prepare queue metadata. This module intentionally does not create layout: * live rendering and history rendering must go through the same renderer. */ - async prepareSentence(item) { + async prepareSentence(item, options = {}) { const text = typeof item === 'string' ? item : item.text; const id = item.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const metadata = typeof item === 'object' && item !== null ? item : {}; + const blocking = options.blocking !== false; try { if (metadata.type && !['paragraph', 'heading'].includes(metadata.type)) { - await this.preloadAssetsForItem(metadata, { blocking: true, sentenceId: id }); + await this.preloadAssetsForItem(metadata, { blocking, sentenceId: id, prefetch: Boolean(options.prefetch) }); return { id, @@ -529,7 +541,7 @@ class SentenceQueueModule extends BaseModule { await this.preloadAssetsForItem({ type: 'paragraph', cueMarkers: metadata.cueMarkers || [] - }, { blocking: true, sentenceId: id }); + }, { blocking, sentenceId: id, prefetch: Boolean(options.prefetch) }); } const ttsData = await this.prepareSpeechMetadata(text, { @@ -537,7 +549,7 @@ class SentenceQueueModule extends BaseModule { blockId: metadata.blockId ?? null, turnId: metadata.turnId ?? null, ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [], - blocking: true + blocking }); console.log(`SentenceQueue: Prepared speech "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms`); @@ -834,7 +846,7 @@ class SentenceQueueModule extends BaseModule { resolve(); }; const onCommand = (event) => { - if (event.detail?.type === 'continue') { + if (event.detail?.type === 'continue' && !this.isChoiceAwaitingPlayer()) { finish(); } }; @@ -846,15 +858,81 @@ class SentenceQueueModule extends BaseModule { return `${item?.id || ''}:${item?.text || ''}`; } + isChoiceAwaitingPlayer() { + if (this.inputMode !== 'choice') { + return false; + } + const choicePanel = document.getElementById('story_choices'); + return Boolean(choicePanel && !choicePanel.hidden && choicePanel.dataset.choiceReady === 'true'); + } + async getPreparedSentence(item) { - const pending = this.prefetchingSpeech.get(this.getCacheKey(item)); + const cacheKey = this.getCacheKey(item); + const prepared = this.preparedSentenceCache.get(cacheKey); + if (prepared) { + this.preparedSentenceCache.delete(cacheKey); + return prepared; + } + + const pending = this.prefetchingSpeech.get(cacheKey); if (pending) { - pending.catch(() => null); + const prefetched = await pending.catch(() => null); + if (prefetched) { + this.preparedSentenceCache.delete(cacheKey); + return prefetched; + } } return this.prepareSentence(item); } + async prefetchWebGLBookPresentation(sentence, options = {}) { + if (!sentence || !['paragraph', 'heading'].includes(sentence.kind || sentence.type)) return null; + const isWebGLMode = document.body?.dataset?.webglUiMode === '3d' + || document.body?.classList?.contains('webgl-mode'); + if (!isWebGLMode) return null; + + const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null; + if (blockId == null) return null; + const bookPagination = this.getModule('book-pagination'); + const bookTextureRenderer = this.getModule('book-texture-renderer'); + if (!bookPagination || !bookTextureRenderer) return null; + + if (!Array.isArray(sentence.animation?.wordTimings) || sentence.animation.wordTimings.length === 0) { + const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || []; + sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []); + } + + await new Promise(resolve => { + const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 1)); + scheduler(() => resolve(), { timeout: 120 }); + }); + if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null; + + const spread = typeof bookPagination.preparePendingBlock === 'function' + ? await bookPagination.preparePendingBlock(sentence, { + activate: false, + publish: false, + includeUnrenderedHistory: true + }) + : null; + if (!spread) return null; + if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null; + + if (typeof bookTextureRenderer.prepareRevealBlock === 'function') { + bookTextureRenderer.prepareRevealBlock({ + id: sentence.id, + blockId, + wordTimings: sentence.animation?.wordTimings || [], + cueTimings: sentence.animation?.cueTimings || [], + totalDuration: sentence.animation?.totalDuration || 0, + spread, + preloadOnly: true + }, { preloadOnly: true }); + } + return spread; + } + isCurrentQueueItem(item, queueGeneration = this.queueGeneration) { return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item; } @@ -888,35 +966,28 @@ class SentenceQueueModule extends BaseModule { const promise = (async () => { if (queueGeneration !== this.queueGeneration) return null; - await this.preloadAssetsForItem(nextItem, { - sentenceId: nextItem.id, + const prepared = await this.prepareSentence(nextItem, { blocking: false, - prefetch: true + prefetch: true, + queueIndex: index }); if (queueGeneration !== this.queueGeneration) return null; - - if (!this.isSpeechItem(nextItem)) { - return null; - } - - return this.prepareSpeechMetadata(nextItem.text || '', { - sentenceId: nextItem.id, - blockId: nextItem.blockId ?? null, - turnId: nextItem.turnId ?? null, - ttsInstructions: Array.isArray(nextItem.ttsInstructions) ? nextItem.ttsInstructions : [], - queueIndex: index, - prefetch: true, - blocking: false + await this.prefetchWebGLBookPresentation(prepared, { + queueGeneration, + queueIndex: index }); + if (queueGeneration !== this.queueGeneration) return null; + this.preparedSentenceCache.set(nextCacheKey, prepared); + return prepared; })() - .then(() => { + .then((prepared) => { if (queueGeneration !== this.queueGeneration) return false; console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index }); document.dispatchEvent(new CustomEvent('story:process-state', { detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index } })); console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index }); - return true; + return prepared || true; }) .catch(err => { console.warn('SentenceQueue: Prefetch failed:', err); @@ -1341,6 +1412,7 @@ class SentenceQueueModule extends BaseModule { this.cancelGenerationRequests('sentence-queue-cleared'); this.cancelAssetPreloads('sentence-queue-cleared'); this.prefetchingSpeech.clear(); + this.preparedSentenceCache.clear(); this.pauseBeforeNextReason = null; document.dispatchEvent(new CustomEvent('tts:queue-empty', { detail: { reason: 'sentence-queue-cleared' } diff --git a/public/js/ui-controller-module.js b/public/js/ui-controller-module.js index 0d9c447..d030d3e 100644 --- a/public/js/ui-controller-module.js +++ b/public/js/ui-controller-module.js @@ -29,6 +29,7 @@ class UIControllerModule extends BaseModule { this.ttsHandler = null; this.socketClient = null; this.animationQueue = null; + this.currentInputMode = document.documentElement.dataset.inputMode || 'none'; // Add TTS toggle state this.ttsEnabled = false; @@ -56,6 +57,7 @@ class UIControllerModule extends BaseModule { 'clearDisplay', 'sendCommand', 'isInteractiveClickTarget', + 'isChoiceAwaitingPlayer', 'updateButtonStates' ]); } @@ -262,6 +264,9 @@ class UIControllerModule extends BaseModule { if (!event.detail || event.detail.moduleId === this.id) return; this.handleCommand(event.detail); }); + this.addEventListener(document, 'story:input-mode', (event) => { + this.currentInputMode = ['text', 'choice', 'end', 'none'].includes(event.detail) ? event.detail : 'none'; + }); this.addEventListener(document, 'click', (event) => { if (this.isInteractiveClickTarget(event.target)) { @@ -270,7 +275,7 @@ class UIControllerModule extends BaseModule { const playbackCoordinator = this.getModule('playback-coordinator'); const hasSkippablePause = document.documentElement.dataset.skippablePause === 'true'; - if ((playbackCoordinator && playbackCoordinator.isPlaying) || hasSkippablePause) { + if (((playbackCoordinator && playbackCoordinator.isPlaying) || hasSkippablePause) && !this.isChoiceAwaitingPlayer()) { this.handleCommand({ type: 'continue', source: 'book-click' }); } @@ -667,6 +672,14 @@ class UIControllerModule extends BaseModule { '.volume-toggle' ].join(','))); } + + isChoiceAwaitingPlayer() { + if (this.currentInputMode !== 'choice') { + return false; + } + const choicePanel = document.getElementById('story_choices'); + return Boolean(choicePanel && !choicePanel.hidden && choicePanel.dataset.choiceReady === 'true'); + } handleCommand(command) { // Route commands to appropriate handlers @@ -679,6 +692,9 @@ class UIControllerModule extends BaseModule { break; case 'continue': { + if (this.isChoiceAwaitingPlayer()) { + return; + } document.dispatchEvent(new CustomEvent('ui:command', { detail: { moduleId: this.id, type: 'continue', source: command.source || 'ui-controller-forward' } })); diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index a403722..1415b1e 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -1814,7 +1814,7 @@ class UIDisplayHandlerModule extends BaseModule { } getLatestHistoryBlockId() { - return Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0)); + return Math.max(0, Number((this.storyHistory?.nextBlockId || 1) - 1)); } updateStoryScrollbar(detail = {}) { diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index c7869ba..73f1e8c 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -4,7 +4,7 @@ import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postproces import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js'; import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js'; import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js'; -import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260607-webgl-typography-a'; +import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260608-webgl-mask-timing-c'; const canvas = document.getElementById('scene'); canvas.style.cursor = 'grab'; @@ -214,13 +214,31 @@ const leftCanvas = createPageCanvas('left'); const rightCanvas = createPageCanvas('right'); const leftTexture = new THREE.CanvasTexture(leftCanvas); const rightTexture = new THREE.CanvasTexture(rightCanvas); -[leftTexture, rightTexture].forEach((texture) => { +function configurePageCanvasTexture(texture) { texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = maxTextureAnisotropy; - texture.minFilter = THREE.LinearMipmapLinearFilter; + texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; - texture.generateMipmaps = true; -}); + texture.generateMipmaps = false; + return texture; +} +[leftTexture, rightTexture].forEach(configurePageCanvasTexture); + +function createPageCanvasTexture(sourceCanvas) { + if (!sourceCanvas) return null; + const texture = configurePageCanvasTexture(new THREE.CanvasTexture(sourceCanvas)); + texture.needsUpdate = true; + if (typeof renderer?.initTexture === 'function') { + renderer.initTexture(texture); + texture.needsUpdate = false; + } + return texture; +} + +const preparedPageTextures = { + left: new Map(), + right: new Map() +}; const pageRevealState = { left: null, right: null @@ -585,7 +603,7 @@ function configureBookShadowReceiver(material, strength) { const isHardcoverPaper = material.userData?.isHardcoverPaper === true; const isHeadband = material.userData?.isHeadband === true; const pageReveal = material.userData?.bookPageReveal || null; - material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${pageReveal ? 'page-reveal-v1' : isHeadband ? 'headband-v1' : isSpineCloth ? 'spine-cloth-v4' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`; + material.customProgramCacheKey = () => `book-shadow-receiver-${strength.toFixed(2)}-${pageReveal ? 'page-reveal-v2' : isHeadband ? 'headband-v1' : isSpineCloth ? 'spine-cloth-v4' : isHardcoverPaper ? 'hardcover-paper-v1' : 'plain'}`; material.onBeforeCompile = (shader) => { shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) }; shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices }; @@ -599,7 +617,9 @@ function configureBookShadowReceiver(material, strength) { shader.uniforms.bookRevealWordRects = { value: Array.from({ length: maxRevealWords }, () => new THREE.Vector4(0, 0, 0, 0)) }; shader.uniforms.bookRevealWordTimings = { value: Array.from({ length: maxRevealWords }, () => new THREE.Vector4(0, 1, 0, 0)) }; shader.uniforms.bookRevealPaperColor = { value: paperColor.clone() }; - shader.uniforms.bookRevealSoftness = { value: 0.035 }; + shader.uniforms.bookRevealBaseMap = { value: leftTexture }; + shader.uniforms.bookRevealUseBaseMap = { value: 0 }; + shader.uniforms.bookRevealSoftness = { value: 0.025 }; material.userData.bookRevealShader = shader; applyPendingPageReveal(pageReveal.side, shader); } @@ -642,6 +662,8 @@ function configureBookShadowReceiver(material, strength) { uniform vec4 bookRevealWordRects[256]; uniform vec4 bookRevealWordTimings[256]; uniform vec3 bookRevealPaperColor; + uniform sampler2D bookRevealBaseMap; + uniform float bookRevealUseBaseMap; uniform float bookRevealSoftness; float bookRevealVisibleMask(vec2 uv) { @@ -653,7 +675,7 @@ function configureBookShadowReceiver(material, strength) { float inside = step(0.0, local.x) * step(0.0, local.y) * step(local.x, 1.0) * step(local.y, 1.0); vec4 timing = bookRevealWordTimings[i]; float progress = clamp((bookRevealElapsedMs - timing.x) / max(1.0, timing.y), 0.0, 1.0); - float scan = clamp((local.x + (1.0 - local.y)) * 0.5, 0.0, 1.0); + float scan = clamp(local.x * 0.88 + (1.0 - local.y) * 0.12, 0.0, 1.0); float feather = max(0.0001, bookRevealSoftness); float visible = smoothstep(scan - feather, scan + feather, progress); hidden = max(hidden, inside * (1.0 - visible)); @@ -805,8 +827,9 @@ function configureBookShadowReceiver(material, strength) { if (bookRevealActive > 0.5) { float hiddenInk = bookRevealVisibleMask(vMapUv); float luminance = dot(sampledDiffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722)); - float inkMask = 1.0 - smoothstep(0.26, 0.72, luminance); - sampledDiffuseColor.rgb = mix(sampledDiffuseColor.rgb, bookRevealPaperColor, hiddenInk * inkMask); + float inkMask = 1.0 - smoothstep(0.52, 0.9, luminance); + vec3 revealBaseColor = mix(bookRevealPaperColor, texture2D(bookRevealBaseMap, vMapUv).rgb, bookRevealUseBaseMap); + sampledDiffuseColor.rgb = mix(sampledDiffuseColor.rgb, revealBaseColor, clamp(hiddenInk * inkMask * 1.55, 0.0, 1.0)); } diffuseColor *= sampledDiffuseColor; #endif` @@ -1653,8 +1676,15 @@ function handlePageCanvases(event) { markPageTextureTiming('handlePageCanvases:start', { hasLeft: Boolean(detail.left), hasRight: Boolean(detail.right), - revealSides: Object.keys(detail.reveal || {}) + revealSides: Object.keys(detail.reveal || {}), + preloadOnly: Boolean(detail.preloadOnly) }); + if (detail.preloadOnly) { + if (detail.left) preloadPageTexture('left', detail.left, detail.reveal?.left); + if (detail.right) preloadPageTexture('right', detail.right, detail.reveal?.right); + markPageTextureTiming('handlePageCanvases:preloadOnly:end'); + return; + } if (detail.left) { if (detail.reveal?.left) { beginPageReveal('left', detail.left, detail.reveal.left); @@ -1678,10 +1708,59 @@ function handlePageCanvases(event) { markPageTextureTiming('handlePageCanvases:end'); } +function getRevealCacheKey(revealDetail = {}) { + const ids = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : []; + return ids.map(id => String(id)).join('|') || 'direct'; +} + +function preloadPageTexture(side, sourceCanvas, revealDetail = {}) { + if (!sourceCanvas) return null; + const texture = createPageCanvasTexture(sourceCanvas); + const baseTexture = revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null; + const key = getRevealCacheKey(revealDetail); + markPageTextureTiming('preloadTexture:start', { + side, + key, + width: sourceCanvas.width, + height: sourceCanvas.height, + hasBaseTexture: Boolean(baseTexture) + }); + preparedPageTextures[side].set(key, { + texture, + baseTexture, + sourceCanvas, + revealDetail, + uploadedAt: performance.now() + }); + if (preparedPageTextures[side].size > 12) { + const oldestKey = preparedPageTextures[side].keys().next().value; + const oldest = preparedPageTextures[side].get(oldestKey); + oldest?.texture?.dispose?.(); + oldest?.baseTexture?.dispose?.(); + preparedPageTextures[side].delete(oldestKey); + } + markPageTextureTiming('preloadTexture:end', { side, key }); + return texture; +} + +function takePreparedPageTexture(side, revealDetail = {}) { + const key = getRevealCacheKey(revealDetail); + const prepared = preparedPageTextures[side].get(key); + if (!prepared) return null; + preparedPageTextures[side].delete(key); + markPageTextureTiming('preloadTexture:activate', { side, key }); + return prepared; +} + function uploadPageTextureDirect(side, sourceCanvas) { const texture = side === 'left' ? leftTexture : rightTexture; + const material = side === 'left' ? materials.leftPage : materials.rightPage; markPageTextureTiming('directUpload:start', { side }); clearPageReveal(side, 'direct-upload'); + if (material.map !== texture) { + material.map = texture; + material.needsUpdate = true; + } bindPageTextureSource(side, texture, sourceCanvas); markPageTextureTiming('directUpload:end', { side }); } @@ -1689,12 +1768,25 @@ function uploadPageTextureDirect(side, sourceCanvas) { function beginPageReveal(side, sourceCanvas, revealDetail = {}) { const texture = side === 'left' ? leftTexture : rightTexture; const shader = getPageRevealShader(side); + const material = side === 'left' ? materials.leftPage : materials.rightPage; + const prepared = takePreparedPageTexture(side, revealDetail); markPageTextureTiming('revealUpload:start', { side, - wordCount: Array.isArray(revealDetail.wordRects) ? revealDetail.wordRects.length : 0 + wordCount: Array.isArray(revealDetail.wordRects) ? revealDetail.wordRects.length : 0, + usedPreparedTexture: Boolean(prepared), + usedPreparedBaseTexture: Boolean(prepared?.baseTexture) }); - bindPageTextureSource(side, texture, sourceCanvas); + if (prepared?.texture) { + material.map = prepared.texture; + } else { + if (material.map !== texture) { + material.map = texture; + material.needsUpdate = true; + } + bindPageTextureSource(side, texture, sourceCanvas); + } + const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? createPageCanvasTexture(revealDetail.baseCanvas) : null); pageRevealState[side] = { startedAt: revealDetail.startNow ? performance.now() : null, @@ -1702,9 +1794,13 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) { lastRevealFrameAt: null, visualElapsedMs: 0, durationMs: Math.max(1, Number(revealDetail.durationMs || 1)), - blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : [] + blockIds: Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds : [], + baseTexture, + fastForwarding: false, + fastForwardStartedAt: null, + fastForwardStartElapsedMs: 0, + fastForwardDurationMs: 260 }; - const material = side === 'left' ? materials.leftPage : materials.rightPage; if (material?.userData) material.userData.pendingPageReveal = revealDetail; if (shader?.uniforms) applyPendingPageReveal(side, shader); else if (material) material.needsUpdate = true; @@ -1725,6 +1821,9 @@ function applyPendingPageReveal(side, shader = getPageRevealShader(side)) { applyPageRevealWords(shader, revealDetail.wordRects || []); shader.uniforms.bookRevealActive.value = 1; shader.uniforms.bookRevealElapsedMs.value = 0; + const baseTexture = pageRevealState[side]?.baseTexture; + if (shader.uniforms.bookRevealBaseMap) shader.uniforms.bookRevealBaseMap.value = baseTexture || (side === 'left' ? leftTexture : rightTexture); + if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = baseTexture ? 1 : 0; document.documentElement.dataset.webglRevealDebug = JSON.stringify({ side, blockIds: pageRevealState[side]?.blockIds || revealDetail.blockIds || [], @@ -1744,6 +1843,12 @@ function applyPageRevealWords(shader, words = []) { source.forEach((word, index) => { const rect = word.rect || {}; const timing = word.timing || {}; + const nextTiming = source[index + 1]?.timing || {}; + const delay = Math.max(0, Number(timing.delay || 0)); + const nextDelay = Number(nextTiming.delay); + const allottedDuration = Number.isFinite(nextDelay) && nextDelay > delay + ? nextDelay - delay + : Number(timing.duration || 1); const x = THREE.MathUtils.clamp(Number(rect.x || 0), 0, 1); const y = THREE.MathUtils.clamp(Number(rect.y || 0), 0, 1); const width = THREE.MathUtils.clamp(Number(rect.width || 0), 0, 1); @@ -1755,8 +1860,8 @@ function applyPageRevealWords(shader, words = []) { Math.max(0.0001, height) ); timingUniforms[index].set( - Math.max(0, Number(timing.delay || 0)), - Math.max(1, Number(timing.duration || 1)), + delay, + Math.max(1, allottedDuration), 0, 0 ); @@ -1781,6 +1886,8 @@ function getRevealDebugState() { elapsedMs: Number(uniforms.bookRevealElapsedMs?.value || 0), visualElapsedMs: Number(pageRevealState[side]?.visualElapsedMs || 0), wordCount: Number(uniforms.bookRevealWordCount?.value || 0), + usesBaseTexture: Number(uniforms.bookRevealUseBaseMap?.value || 0), + fastForwarding: pageRevealState[side]?.fastForwarding === true, started: pageRevealState[side]?.startedAt != null, pendingStart: pageRevealState[side]?.pendingStart === true, durationMs: Number(pageRevealState[side]?.durationMs || 0), @@ -1791,16 +1898,17 @@ function getRevealDebugState() { } function clearPageReveal(side, reason = 'clear') { + const previousState = pageRevealState[side]; pageRevealClearLog.push({ side, reason, at: performance.now(), - state: pageRevealState[side] ? { - started: pageRevealState[side].startedAt != null, - pendingStart: pageRevealState[side].pendingStart === true, - visualElapsedMs: pageRevealState[side].visualElapsedMs || 0, - durationMs: pageRevealState[side].durationMs, - blockIds: pageRevealState[side].blockIds || [] + state: previousState ? { + started: previousState.startedAt != null, + pendingStart: previousState.pendingStart === true, + visualElapsedMs: previousState.visualElapsedMs || 0, + durationMs: previousState.durationMs, + blockIds: previousState.blockIds || [] } : null }); if (pageRevealClearLog.length > 40) pageRevealClearLog.splice(0, pageRevealClearLog.length - 40); @@ -1811,7 +1919,9 @@ function clearPageReveal(side, reason = 'clear') { shader.uniforms.bookRevealActive.value = 0; shader.uniforms.bookRevealElapsedMs.value = completedRevealElapsedMs; shader.uniforms.bookRevealWordCount.value = 0; + if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = 0; } + previousState?.baseTexture?.dispose?.(); } function startPageRevealForBlock(blockId) { @@ -1833,7 +1943,10 @@ function fastForwardPageReveals(blockIds = []) { if (!state) return; const matches = ids.size === 0 || state.blockIds.some(blockId => ids.has(String(blockId))); if (!matches) return; - clearPageReveal(side, 'fast-forward'); + state.fastForwarding = true; + state.fastForwardStartedAt = performance.now(); + state.fastForwardStartElapsedMs = Math.max(0, Number(state.visualElapsedMs || 0)); + state.fastForwardDurationMs = 260; }); } @@ -1860,7 +1973,17 @@ function updatePageRevealAnimations(now) { } const revealFrameDeltaMs = state.lastRevealFrameAt == null ? 0 : Math.max(0, now - state.lastRevealFrameAt); state.lastRevealFrameAt = now; - state.visualElapsedMs = Math.max(0, Number(state.visualElapsedMs || 0)) + Math.min(revealFrameDeltaMs, targetFrameDurationMs); + if (state.fastForwarding) { + const fastElapsed = Math.max(0, now - Number(state.fastForwardStartedAt || now)); + const fastProgress = THREE.MathUtils.clamp(fastElapsed / Math.max(1, Number(state.fastForwardDurationMs || 1)), 0, 1); + state.visualElapsedMs = THREE.MathUtils.lerp( + Math.max(0, Number(state.fastForwardStartElapsedMs || 0)), + state.durationMs, + fastProgress + ); + } else { + state.visualElapsedMs = Math.max(0, Number(state.visualElapsedMs || 0)) + Math.min(revealFrameDeltaMs, targetFrameDurationMs); + } const progress = THREE.MathUtils.clamp(state.visualElapsedMs / state.durationMs, 0, 1); shader.uniforms.bookRevealElapsedMs.value = state.visualElapsedMs; if (progress < 1) return; diff --git a/public/js/webgl-book-scene-module.js b/public/js/webgl-book-scene-module.js index d27705b..d494879 100644 --- a/public/js/webgl-book-scene-module.js +++ b/public/js/webgl-book-scene-module.js @@ -313,6 +313,8 @@ class WebGLBookSceneModule extends BaseModule { return; } if (!target) return; + event.preventDefault(); + event.stopPropagation(); if (type === 'pointermove' || type === 'mousemove') { this.updateProjectedHover(target, event); } diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index f80933b..c2d1be1 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -13,10 +13,16 @@ const uiDisplayHandlerPath = path.join(__dirname, '..', 'public', 'js', 'ui-disp const uiDisplayHandlerSource = fs.readFileSync(uiDisplayHandlerPath, 'utf8'); const bookPaginationPath = path.join(__dirname, '..', 'public', 'js', 'book-pagination-module.js'); const bookPaginationSource = fs.readFileSync(bookPaginationPath, 'utf8'); +const sentenceQueuePath = path.join(__dirname, '..', 'public', 'js', 'sentence-queue-module.js'); +const sentenceQueueSource = fs.readFileSync(sentenceQueuePath, 'utf8'); const webglScenePath = path.join(__dirname, '..', 'public', 'js', 'webgl-book-scene-module.js'); const webglSceneSource = fs.readFileSync(webglScenePath, 'utf8'); const loaderPath = path.join(__dirname, '..', 'public', 'js', 'loader.js'); const loaderSource = fs.readFileSync(loaderPath, 'utf8'); +const pageFormatPath = path.join(__dirname, '..', 'public', 'js', 'book-page-format-module.js'); +const pageFormatSource = fs.readFileSync(pageFormatPath, 'utf8'); +const stylePath = path.join(__dirname, '..', 'public', 'css', 'style.css'); +const styleSource = fs.readFileSync(stylePath, 'utf8'); function dependencyList(source, moduleId) { const classStart = source.indexOf(`super('${moduleId}'`); @@ -56,6 +62,12 @@ function methodBody(source, methodName) { return ''; } +function sourceOrder(source, first, second) { + const firstIndex = source.indexOf(first); + const secondIndex = source.indexOf(second); + return firstIndex >= 0 && secondIndex >= 0 && firstIndex < secondIndex; +} + const checks = [ ['scene-level SSAO import', /SSAOPass/.test(source)], ['postprocess anti-aliasing import', /SMAAPass/.test(source)], @@ -99,6 +111,7 @@ const checks = [ ['webgl lab records page reveal clear reasons', /clearPageReveal\(side, reason/.test(source) && /webglRevealClearLog/.test(source)], ['webgl reveal clock starts on first render frame', /pendingStart/.test(source) && /state\.pendingStart/.test(source) && /state\.startedAt = now/.test(source)], ['webgl reveal visual clock caps missed-frame deltas', /visualElapsedMs/.test(source) && /revealFrameDeltaMs/.test(source) && /Math\.min\(revealFrameDeltaMs/.test(source)], + ['webgl fast-forward accelerates reveal instead of clearing the mask immediately', /fastForwarding/.test(source) && /fastForwardDurationMs/.test(source) && !/clearPageReveal\(side, 'fast-forward'\)/.test(source)], ['webgl lab records page texture binding timings', /pageTextureTimings/.test(source) && /markPageTextureTiming/.test(source) && /webglPageTextureTimings/.test(source)], ['webgl lab binds source canvases directly instead of copying whole page textures', /bindPageTextureSource/.test(source) && /texture\.image = sourceCanvas/.test(source) && !/drawCanvasPageTexture/.test(methodBody(source, 'uploadPageTextureDirect')) && !/drawCanvasPageTexture/.test(methodBody(source, 'beginPageReveal'))], ['page texture dark-pixel sampling only runs in table debug mode', /function shouldSamplePageTextureDebug\(\)/.test(source) && /tableDebugMode !== tableDebugModes\.none/.test(source) && /shouldSamplePageTextureDebug\(\) \? countPageTextureDarkPixels\(canvas\) : null/.test(source)], @@ -106,6 +119,21 @@ const checks = [ ['texture renderer records prepare draw publish and start reveal timing', /markPipelineTiming\('prepareRevealBlock:start'/.test(textureRendererSource) && /markPipelineTiming\('drawSpread:start'/.test(textureRendererSource) && /markPipelineTiming\('publishSpread'/.test(textureRendererSource) && /markPipelineTiming\('startPreparedRevealAnimation'/.test(textureRendererSource)], ['texture renderer diagnostics include reveal word counts', /wordCounts/.test(textureRendererSource) && /revealWords/.test(textureRendererSource) && /wordRects/.test(textureRendererSource)], ['texture renderer does not draw immediate non-pending sides during pending reveal preparation', !/immediateSides/.test(textureRendererSource) && !/this\.drawSpread\(this\.currentSpread, immediateSides\)/.test(textureRendererSource)], + ['sentence queue consumes completed prepared lookahead items', /preparedSentenceCache/.test(sentenceQueueSource) && /this\.preparedSentenceCache\.get\(cacheKey\)/.test(sentenceQueueSource) && /return prefetched/.test(sentenceQueueSource)], + ['sentence queue front-loads 3D book presentation before playback callback', /const sentence = await this\.getPreparedSentence\(item\);[\s\S]*await this\.prefetchWebGLBookPresentation\(sentence[\s\S]*this\.prefetchAhead\(4, queueGeneration\);[\s\S]*this\.onSentenceReadyCallback/.test(sentenceQueueSource)], + ['sentence queue prefetch prepares whole future sentence instead of speech metadata only', /this\.prepareSentence\(nextItem, \{\s*blocking: false/.test(sentenceQueueSource) && /this\.prefetchWebGLBookPresentation\(prepared/.test(sentenceQueueSource)], + ['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(4, this\.queueGeneration\);/.test(sentenceQueueSource)], + ['pagination can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)], + ['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)], + ['texture renderer caches preload-only reveal canvases for later reuse', /preparedRevealCache/.test(textureRendererSource) && /preloadOnly/.test(textureRendererSource) && /publishPreparedReveal/.test(textureRendererSource) && /reusedPreparedCanvas/.test(textureRendererSource)], + ['texture renderer paints inline bold and italic styles', /getInlineStyleState/.test(textureRendererSource) && /updateInlineStyleState/.test(textureRendererSource) && /getCanvasFont/.test(textureRendererSource) && /segment\?\.style/.test(textureRendererSource)], + ['webgl lab can preload page textures without swapping visible page material', /preparedPageTextures/.test(source) && /preloadPageTexture/.test(source) && /renderer\.initTexture\(texture\)/.test(source) && /takePreparedPageTexture/.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)], + ['webgl reveal shader masks antialiased ink and uses smooth x-dominant scan', /smoothstep\(0\.52, 0\.9, luminance\)/.test(source) && /local\.x \* 0\.88/.test(source) && /bookRevealSoftness = \{ value: 0\.025 \}/.test(source)], + ['webgl reveal words consume the allotted time until the next word', /nextTiming/.test(source) && /allottedDuration/.test(source) && /nextDelay - delay/.test(source)], + ['webgl page format reduces only outer margins from previous value', /outerBaseIn: 0\.27/.test(pageFormatSource) && /outerThicknessFactor: 0\.015/.test(pageFormatSource) && /outerMaxIn: 0\.315/.test(pageFormatSource) && /innerBaseIn: 0\.42/.test(pageFormatSource)], + ['webgl mode enlarges and inverts DOM overlay text without touching 2D mode', /body\.webgl-mode \{[\s\S]*font-size: 18px;/.test(styleSource) && /body\.webgl-mode \.choice-list \.choice-button/.test(styleSource) && /rgba\(246, 231, 201/.test(styleSource)], ['drop-cap remaining text does not reinsert discretionary hyphen markers', /extractRemainingLayoutText/.test(bookPaginationSource) && !bookPaginationSource.includes("fragments.push('|')")], ['drop-cap reservation keeps a normal text gap beside the initial', /measureDropCapReservation/.test(bookPaginationSource) && /measureNormalTextGap\(fontPx\)/.test(bookPaginationSource)], ['drop-cap reservation uses both ink bounds and font advance width', /const advanceWidth = metrics\.width \|\| 0/.test(bookPaginationSource) && /Math\.max\(inkRight, advanceWidth, lineHeightPx \* 1\.08\)/.test(bookPaginationSource)],