Checkpoint WebGL book reveal optimization

This commit is contained in:
2026-06-08 08:19:20 +02:00
parent 7abd3387f3
commit c86a304364
13 changed files with 618 additions and 112 deletions
+41
View File
@@ -1931,6 +1931,47 @@ body.webgl-mode {
background: #090705; background: #090705;
align-items: stretch; align-items: stretch;
justify-content: 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 { #webgl_app {
+1 -1
View File
@@ -280,6 +280,6 @@
console.log(message); console.log(message);
}; };
</script> </script>
<script type="module" src="/js/loader.js?v=20260607-webgl-forced-font-mask"></script> <script type="module" src="/js/loader.js?v=20260608-webgl-mask-timing-c"></script>
</body> </body>
</html> </html>
+4 -4
View File
@@ -3,7 +3,7 @@
* Defines the canonical page geometry used by the WebGL book renderer. * Defines the canonical page geometry used by the WebGL book renderer.
*/ */
import { BaseModule } from './base-module.js'; 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; export const BOOK_TEXTURE_WIDTH = 3072;
@@ -24,9 +24,9 @@ class BookPageFormatModule extends BaseModule {
innerMinIn: 0.48, innerMinIn: 0.48,
innerMaxIn: 0.74, innerMaxIn: 0.74,
innerThicknessFactor: 0.32, innerThicknessFactor: 0.32,
outerBaseIn: 0.36, outerBaseIn: 0.27,
outerThicknessFactor: 0.02, outerThicknessFactor: 0.015,
outerMaxIn: 0.42 outerMaxIn: 0.315
}), }),
typography: Object.freeze({ typography: Object.freeze({
fontFamily: '"EB Garamond", "EB Garamond 12", serif', fontFamily: '"EB Garamond", "EB Garamond 12", serif',
+60 -17
View File
@@ -32,6 +32,8 @@ class BookPaginationModule extends BaseModule {
'extractLayoutLine', 'extractLayoutLine',
'extractRemainingLayoutText', 'extractRemainingLayoutText',
'extractLines', 'extractLines',
'getActiveStyleTags',
'updateStyleTagStack',
'countLineWords', 'countLineWords',
'getLineGeometry', 'getLineGeometry',
'getSpread', 'getSpread',
@@ -95,8 +97,8 @@ class BookPaginationModule extends BaseModule {
this.publish(); this.publish();
} }
async preparePendingBlock(block = {}) { async preparePendingBlock(block = {}, options = {}) {
const token = ++this.refreshToken; const token = options.activate === false ? this.refreshToken : ++this.refreshToken;
const gameId = block.gameId || block.metadata?.gameId || this.storyHistory?.currentGameId || null; const gameId = block.gameId || block.metadata?.gameId || this.storyHistory?.currentGameId || null;
const latestRenderedBlockId = Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0)); const latestRenderedBlockId = Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0));
const pendingBlockId = Math.max(0, Number(block.blockId || block.metadata?.blockId || 0)); const pendingBlockId = Math.max(0, Number(block.blockId || block.metadata?.blockId || 0));
@@ -104,10 +106,13 @@ class BookPaginationModule extends BaseModule {
return null; return null;
} }
const historyBlocks = latestRenderedBlockId > 0 const historyEndBlockId = options.includeUnrenderedHistory
? await this.storyHistory.getBlocksRange(gameId, 1, latestRenderedBlockId) ? 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 = { const normalizedBlock = {
...block, ...block,
@@ -121,26 +126,30 @@ class BookPaginationModule extends BaseModule {
gameId gameId
} }
}; };
this.latestBlockId = pendingBlockId; const preparedSpreads = this.buildSpreads([...historyBlocks, normalizedBlock]);
this.latestRenderedBlockId = latestRenderedBlockId; const targetSpread = preparedSpreads.find(spread => ['left', 'right'].some(side => {
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 lines = Array.isArray(spread?.[side]) ? spread[side] : []; const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
return lines.some(line => Number(line?.blockId || 0) === pendingBlockId); return lines.some(line => Number(line?.blockId || 0) === pendingBlockId);
})); }));
if (targetSpread) this.currentSpreadIndex = targetSpread.index; if (options.activate !== false) {
this.publish(); 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', { document.dispatchEvent(new CustomEvent('book-pagination:block-prepared', {
detail: { detail: {
blockId: pendingBlockId, blockId: pendingBlockId,
spread: this.getCurrentSpread(), spread: targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread()),
spreadIndex: this.currentSpreadIndex, spreadIndex: targetSpread?.index ?? this.currentSpreadIndex,
latestBlockId: this.latestBlockId, latestBlockId: pendingBlockId,
latestRenderedBlockId: this.latestRenderedBlockId latestRenderedBlockId,
preloadOnly: options.activate === false
} }
})); }));
return this.getCurrentSpread(); return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread());
} }
buildSpreads(blocks = []) { buildSpreads(blocks = []) {
@@ -342,6 +351,7 @@ class BookPaginationModule extends BaseModule {
isFinal: lineIndex === layout.breaks.length - 2, isFinal: lineIndex === layout.breaks.length - 2,
smallCaps: Boolean(metadata.smallCaps), smallCaps: Boolean(metadata.smallCaps),
hyphenated: Boolean(endNode?.type === 'penalty' && endNode.penalty === 100), hyphenated: Boolean(endNode?.type === 'penalty' && endNode.penalty === 100),
activeStyleTags: this.getActiveStyleTags(layout.nodes, startBreak.position),
align: 'justify' align: 'justify'
}; };
} }
@@ -385,12 +395,45 @@ class BookPaginationModule extends BaseModule {
ratio: breaks[index].ratio || 0, ratio: breaks[index].ratio || 0,
isFinal: index === breaks.length - 1, isFinal: index === breaks.length - 1,
hyphenated: Boolean(lineNodes.at(-1)?.type === 'penalty' && lineNodes.at(-1)?.penalty === 100), hyphenated: Boolean(lineNodes.at(-1)?.type === 'penalty' && lineNodes.at(-1)?.penalty === 100),
activeStyleTags: this.getActiveStyleTags(nodes, start),
align: options.align || 'justify' align: options.align || 'justify'
}); });
} }
return lines; 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('</')) {
if (stack.length) stack.pop();
return stack;
}
const template = document.createElement('div');
template.innerHTML = text;
const element = template.firstElementChild;
if (!element) return stack;
const tagName = element.tagName.toLowerCase();
const style = String(element.getAttribute('style') || '').toLowerCase();
const className = String(element.getAttribute('class') || '').toLowerCase();
stack.push({
tagName,
bold: tagName === 'strong' || tagName === 'b' || /font-weight\s*:\s*(bold|[6-9]00)/.test(style) || className.includes('bold'),
italic: tagName === 'em' || tagName === 'i' || /font-style\s*:\s*italic/.test(style) || className.includes('italic')
});
return stack;
}
countLineWords(line = {}) { countLineWords(line = {}) {
const nodes = Array.isArray(line.nodes) ? line.nodes : []; const nodes = Array.isArray(line.nodes) ? line.nodes : [];
let count = 0; let count = 0;
+209 -34
View File
@@ -28,9 +28,13 @@ class BookTextureRendererModule extends BaseModule {
this.activeAnimations = new Map(); this.activeAnimations = new Map();
this.revealedBlockIds = new Set(); this.revealedBlockIds = new Set();
this.pendingRevealBlockIds = new Set(); this.pendingRevealBlockIds = new Set();
this.preparedRevealCache = new Map();
this.revealBounds = null; this.revealBounds = null;
this.revealWords = null; this.revealWords = null;
this.revealBaseCanvases = null;
this.revealPublishBlockIds = null; this.revealPublishBlockIds = null;
this.lastDrawSignature = null;
this.lastDrawSkipLoggedAt = 0;
this.animationFrameId = null; this.animationFrameId = null;
this.lastAnimationFrameAt = 0; this.lastAnimationFrameAt = 0;
this.targetFrameDurationMs = 1000 / 30; this.targetFrameDurationMs = 1000 / 30;
@@ -43,15 +47,23 @@ class BookTextureRendererModule extends BaseModule {
'ensureTextureFontFace', 'ensureTextureFontFace',
'createPageCanvases', 'createPageCanvases',
'drawSpread', 'drawSpread',
'getDrawSignature',
'cloneCanvas',
'drawPageBase', 'drawPageBase',
'drawPageLines', 'drawPageLines',
'drawLine', 'drawLine',
'drawWord', 'drawWord',
'recordRevealRect', 'recordRevealRect',
'getInlineStyleState',
'updateInlineStyleState',
'getCanvasFont',
'applyTextStyle',
'getPageContent', 'getPageContent',
'buildLineSegments', 'buildLineSegments',
'startRevealAnimation', 'startRevealAnimation',
'prepareRevealBlock', 'prepareRevealBlock',
'createAnimationState',
'publishPreparedReveal',
'startPreparedRevealAnimation', 'startPreparedRevealAnimation',
'fastForwardAnimations', 'fastForwardAnimations',
'stopAnimations', 'stopAnimations',
@@ -121,21 +133,25 @@ class BookTextureRendererModule extends BaseModule {
async waitForTextureFonts() { async waitForTextureFonts() {
if (!document.fonts) return; if (!document.fonts) return;
await Promise.all([ await Promise.all([
this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Regular.otf'), this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Regular.otf', { style: 'normal', weight: '400' }),
this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Italic.otf', { style: 'italic', weight: '400' }),
this.ensureTextureFontFace('EB Garamond 12', '/fonts/EBGaramond12/webfonts/EBGaramond-Regular.woff2'), this.ensureTextureFontFace('EB Garamond 12', '/fonts/EBGaramond12/webfonts/EBGaramond-Regular.woff2'),
this.ensureTextureFontFace('EB Garamond Initials', '/fonts/EB-Garamond-Initials/EBGaramond-0.016/otf/EBGaramond-Initials.otf') this.ensureTextureFontFace('EB Garamond Initials', '/fonts/EB-Garamond-Initials/EBGaramond-0.016/otf/EBGaramond-Initials.otf')
]); ]);
await Promise.all([ await Promise.all([
document.fonts.load('24px "EB Garamond"'), document.fonts.load('24px "EB Garamond"'),
document.fonts.load('italic 24px "EB Garamond"'),
document.fonts.load('bold 24px "EB Garamond"'),
document.fonts.load('italic bold 24px "EB Garamond"'),
document.fonts.load('24px "EB Garamond 12"'), document.fonts.load('24px "EB Garamond 12"'),
document.fonts.load('72px "EB Garamond Initials"') document.fonts.load('72px "EB Garamond Initials"')
]); ]);
await document.fonts.ready; await document.fonts.ready;
} }
async ensureTextureFontFace(family, url) { async ensureTextureFontFace(family, url, descriptors = {}) {
if (!window.FontFace) return; if (!window.FontFace) return;
const face = new FontFace(family, `url(${url})`); const face = new FontFace(family, `url(${url})`, descriptors);
const loadedFace = await face.load(); const loadedFace = await face.load();
document.fonts.add(loadedFace); document.fonts.add(loadedFace);
} }
@@ -151,27 +167,66 @@ class BookTextureRendererModule extends BaseModule {
}); });
} }
drawSpread(spread = null, sides = null) { drawSpread(spread = null, sides = null, options = {}) {
const previousSpread = this.currentSpread;
this.currentSpread = spread || { left: [], right: [] }; this.currentSpread = spread || { left: [], right: [] };
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0;
const drawSignature = this.getDrawSignature(this.currentSpread, sidesToDraw);
if (!options.preloadOnly && !hasReveal && drawSignature === this.lastDrawSignature) {
const now = performance.now();
if (now - this.lastDrawSkipLoggedAt > 1000) {
this.lastDrawSkipLoggedAt = now;
this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw });
}
if (options.preloadOnly) this.currentSpread = previousSpread;
return null;
}
this.markPipelineTiming('drawSpread:start', { this.markPipelineTiming('drawSpread:start', {
sides: sidesToDraw, 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.revealBounds = { left: null, right: null };
this.revealWords = { left: [], right: [] }; this.revealWords = { left: [], right: [] };
this.revealBaseCanvases = { left: null, right: null };
sidesToDraw.forEach((side) => { sidesToDraw.forEach((side) => {
if (!this.canvases[side]) return; if (!this.canvases[side]) return;
this.drawPageBase(side); this.drawPageBase(side);
if (hasReveal) this.revealBaseCanvases[side] = this.cloneCanvas(this.canvases[side]);
this.drawPageLines(side, this.currentSpread?.[side] || []); this.drawPageLines(side, this.currentSpread?.[side] || []);
}); });
this.publishSpread(sidesToDraw); const published = this.publishSpread(sidesToDraw, options);
this.markPipelineTiming('drawSpread:end', { this.markPipelineTiming('drawSpread:end', {
sides: sidesToDraw sides: sidesToDraw,
preloadOnly: Boolean(options.preloadOnly)
}); });
this.revealBounds = null; this.revealBounds = null;
this.revealWords = null; this.revealWords = null;
this.revealBaseCanvases = null;
this.revealPublishBlockIds = 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) { drawPageBase(side) {
@@ -217,7 +272,6 @@ class BookTextureRendererModule extends BaseModule {
const content = this.getPageContent(side); const content = this.getPageContent(side);
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22)); const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30)); const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30));
const fontStyle = lineRecord.fontStyle === 'italic' ? 'italic ' : '';
const line = lineRecord.line || {}; const line = lineRecord.line || {};
const nodes = Array.isArray(line.nodes) ? line.nodes : []; const nodes = Array.isArray(line.nodes) ? line.nodes : [];
const baseY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx; 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 smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps);
const previousVariantCaps = 'fontVariantCaps' in ctx ? ctx.fontVariantCaps : null; const previousVariantCaps = 'fontVariantCaps' in ctx ? ctx.fontVariantCaps : null;
const previousLetterSpacing = 'letterSpacing' in ctx ? ctx.letterSpacing : 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 ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px'; 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) { if (lineRecord.dropCapText) {
ctx.save(); ctx.save();
const dropCapFontPx = Math.round(fontPx * 2.68); const dropCapFontPx = Math.round(fontPx * 2.68);
@@ -249,15 +306,65 @@ class BookTextureRendererModule extends BaseModule {
ctx.restore(); ctx.restore();
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal'; if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px'; 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.buildLineSegments(ctx, nodes, line, ratio, baseStyle).forEach((segment) => {
this.drawWord(ctx, segment.value, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx); this.drawWord(ctx, segment, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx, smallCaps);
}); });
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal'; if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = previousLetterSpacing || '0px'; 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('</')) {
if (stack.length) stack.pop();
return stack;
}
const template = document.createElement('div');
template.innerHTML = text;
const element = template.firstElementChild;
if (!element) return stack;
const tagName = element.tagName.toLowerCase();
const style = String(element.getAttribute('style') || '').toLowerCase();
const className = String(element.getAttribute('class') || '').toLowerCase();
stack.push({
tagName,
bold: tagName === 'strong' || tagName === 'b' || /font-weight\s*:\s*(bold|[6-9]00)/.test(style) || className.includes('bold'),
italic: tagName === 'em' || tagName === 'i' || /font-style\s*:\s*italic/.test(style) || className.includes('italic')
});
return stack;
}
getCanvasFont(fontPx, smallCaps = false, style = {}) {
const metrics = this.metrics;
return [
style.italic ? 'italic' : '',
smallCaps ? 'small-caps' : '',
style.bold ? '700' : '',
`${fontPx}px`,
metrics.typography.fontFamily
].filter(Boolean).join(' ');
}
applyTextStyle(ctx, fontPx, smallCaps = false, style = {}) {
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
ctx.font = this.getCanvasFont(fontPx, smallCaps, style);
}
getPageContent(side = 'left') { getPageContent(side = 'left') {
return this.metrics?.contentBySide?.[side] || this.metrics?.content || { return this.metrics?.contentBySide?.[side] || this.metrics?.content || {
x: 0, x: 0,
@@ -267,26 +374,31 @@ class BookTextureRendererModule extends BaseModule {
}; };
} }
buildLineSegments(ctx, nodes = [], line = {}, ratio = 0) { buildLineSegments(ctx, nodes = [], line = {}, ratio = 0, baseStyle = {}) {
const segments = []; const segments = [];
let x = 0; let x = 0;
let currentSegment = null; let currentSegment = null;
let previousWasGlue = true; let previousWasGlue = true;
let currentWordIndex = -1;
const styleStack = Array.isArray(line.activeStyleTags) ? line.activeStyleTags.map(tag => ({ ...tag })) : [];
nodes.forEach((node, index) => { nodes.forEach((node, index) => {
if (!node) return; if (!node) return;
if (node.type === 'box' && node.value) { if (node.type === 'box' && node.value) {
const value = String(node.value); const value = String(node.value);
const width = Number(node.width || ctx.measureText(value).width || 0); 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.value += value;
currentSegment.width += width; currentSegment.width += width;
} else { } else {
if (previousWasGlue) currentWordIndex += 1;
currentSegment = { currentSegment = {
value, value,
x, x,
width, width,
wordIndex: segments.length wordIndex: Math.max(0, currentWordIndex),
style
}; };
segments.push(currentSegment); segments.push(currentSegment);
} }
@@ -308,15 +420,19 @@ class BookTextureRendererModule extends BaseModule {
x += hyphenWidth; x += hyphenWidth;
} }
previousWasGlue = false; previousWasGlue = false;
} else if (node.type === 'tag') {
this.updateInlineStyleState(styleStack, node.value);
} }
}); });
return segments; 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); 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); this.recordRevealRect(side, lineRecord, x, baseY - fontPx, width, lineHeightPx, localWordIndex);
} }
@@ -392,16 +508,8 @@ class BookTextureRendererModule extends BaseModule {
this.requestAnimationFrame(); this.requestAnimationFrame();
} }
prepareRevealBlock(detail = {}) { createAnimationState(blockId, wordTimings = [], detail = {}) {
const blockId = detail.blockId ?? detail.id ?? null; return {
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, {
blockId, blockId,
wordTimings, wordTimings,
startedAt: null, startedAt: null,
@@ -411,16 +519,73 @@ class BookTextureRendererModule extends BaseModule {
), ),
completed: false, completed: false,
prepared: true 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.pendingRevealBlockIds.delete(id);
this.revealPublishBlockIds = new Set([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', { this.markPipelineTiming('prepareRevealBlock:end', {
blockId: id, 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) { startPreparedRevealAnimation(blockId) {
const id = String(blockId ?? ''); const id = String(blockId ?? '');
const animation = this.activeAnimations.get(id); const animation = this.activeAnimations.get(id);
@@ -534,7 +699,7 @@ class BookTextureRendererModule extends BaseModule {
if (hasActive) this.requestAnimationFrame(); if (hasActive) this.requestAnimationFrame();
} }
publishSpread(sides = null) { publishSpread(sides = null, options = {}) {
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right']; const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const wordCounts = { const wordCounts = {
left: this.revealWords?.left?.length || 0, left: this.revealWords?.left?.length || 0,
@@ -542,10 +707,16 @@ class BookTextureRendererModule extends BaseModule {
}; };
const detail = { const detail = {
metrics: this.metrics, metrics: this.metrics,
hitMaps: this.hitMaps hitMaps: this.hitMaps,
sides: sidesToPublish
}; };
if (sidesToPublish.includes('left')) detail.left = this.canvases.left; if (options.preloadOnly) detail.preloadOnly = true;
if (sidesToPublish.includes('right')) detail.right = this.canvases.right; 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 = {}; const reveal = {};
sidesToPublish.forEach((side) => { sidesToPublish.forEach((side) => {
const bounds = this.revealBounds?.[side]; const bounds = this.revealBounds?.[side];
@@ -559,6 +730,7 @@ class BookTextureRendererModule extends BaseModule {
reveal[side] = { reveal[side] = {
blockIds, blockIds,
durationMs, durationMs,
baseCanvas: options.preloadOnly ? this.cloneCanvas(this.revealBaseCanvases?.[side]) : this.revealBaseCanvases?.[side] || null,
wordRects: (this.revealWords?.[side] || []).map(word => ({ wordRects: (this.revealWords?.[side] || []).map(word => ({
blockId: word.blockId, blockId: word.blockId,
wordIndex: word.wordIndex, wordIndex: word.wordIndex,
@@ -577,11 +749,13 @@ class BookTextureRendererModule extends BaseModule {
this.markPipelineTiming('publishSpread', { this.markPipelineTiming('publishSpread', {
sides: sidesToPublish, sides: sidesToPublish,
hasReveal: Object.keys(reveal).length > 0, hasReveal: Object.keys(reveal).length > 0,
wordCounts wordCounts,
preloadOnly: Boolean(options.preloadOnly)
}); });
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
detail detail
})); }));
return detail;
} }
getPageCanvas(side) { getPageCanvas(side) {
@@ -595,6 +769,7 @@ class BookTextureRendererModule extends BaseModule {
handlePageCountChanged(event) { handlePageCountChanged(event) {
this.pageFormat?.setPageCount?.(event.detail?.pageCount); this.pageFormat?.setPageCount?.(event.detail?.pageCount);
this.createPageCanvases(); this.createPageCanvases();
this.lastDrawSignature = null;
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.()); this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
} }
+7 -1
View File
@@ -20,6 +20,7 @@ class ChoiceDisplayModule extends BaseModule {
this.currentTurnId = 0; this.currentTurnId = 0;
this.autoTurnCounter = 0; this.autoTurnCounter = 0;
this.lastAutoTurn = new Map(); this.lastAutoTurn = new Map();
this.selectionInProgress = false;
this.template = { this.template = {
cells: { cells: {
default: { default: {
@@ -136,6 +137,7 @@ class ChoiceDisplayModule extends BaseModule {
}; };
this.currentGlossaryEntries = detail.glossaryEntries; this.currentGlossaryEntries = detail.glossaryEntries;
this.choices = this.normalizeChoices(detail.choices); this.choices = this.normalizeChoices(detail.choices);
this.selectionInProgress = false;
this.render(); this.render();
} }
@@ -159,7 +161,7 @@ class ChoiceDisplayModule extends BaseModule {
return; 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; return;
} }
@@ -434,6 +436,9 @@ class ChoiceDisplayModule extends BaseModule {
} }
async selectChoice(index) { async selectChoice(index) {
if (this.selectionInProgress) {
return;
}
if (!this.socketClient) { if (!this.socketClient) {
this.socketClient = this.getModule('socket-client'); this.socketClient = this.getModule('socket-client');
} }
@@ -442,6 +447,7 @@ class ChoiceDisplayModule extends BaseModule {
return; return;
} }
this.selectionInProgress = true;
this.clear(); this.clear();
document.dispatchEvent(new CustomEvent('story:process-state', { document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'command-waiting', reason: 'choice-selected', choiceIndex: index } detail: { state: 'command-waiting', reason: 'choice-selected', choiceIndex: index }
+1 -1
View File
@@ -24,7 +24,7 @@ const ModuleState = {
ERROR: 'ERROR' 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; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
/** /**
+99 -27
View File
@@ -20,8 +20,10 @@ class SentenceQueueModule extends BaseModule {
this.isProcessing = false; this.isProcessing = false;
this.onSentenceReadyCallback = null; 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.prefetchingSpeech = new Map();
this.preparedSentenceCache = new Map();
this.autoplay = true; this.autoplay = true;
this.inputMode = 'text'; this.inputMode = 'text';
this.lastContinueAt = 0; this.lastContinueAt = 0;
@@ -43,6 +45,7 @@ class SentenceQueueModule extends BaseModule {
'getCacheKey', 'getCacheKey',
'getPreparedSentence', 'getPreparedSentence',
'prefetchAhead', 'prefetchAhead',
'prefetchWebGLBookPresentation',
'prepareSpeechMetadata', 'prepareSpeechMetadata',
'preloadAssetsForItem', 'preloadAssetsForItem',
'normalizeTtsText', 'normalizeTtsText',
@@ -156,9 +159,12 @@ class SentenceQueueModule extends BaseModule {
text: String(queueItem.text || '').trim() 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) { if (!this.isProcessing) {
this.processNextSentence(); this.processNextSentence();
} else {
this.prefetchAhead(4, this.queueGeneration);
} }
} }
@@ -194,6 +200,11 @@ class SentenceQueueModule extends BaseModule {
const sentence = await this.getPreparedSentence(item); const sentence = await this.getPreparedSentence(item);
if (!this.isCurrentQueueItem(item, queueGeneration)) return; 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 // Prefetch far enough ahead that media pauses do not block TTS
// generation for the next spoken paragraph. // generation for the next spoken paragraph.
@@ -499,14 +510,15 @@ class SentenceQueueModule extends BaseModule {
* Prepare queue metadata. This module intentionally does not create layout: * Prepare queue metadata. This module intentionally does not create layout:
* live rendering and history rendering must go through the same renderer. * 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 text = typeof item === 'string' ? item : item.text;
const id = item.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const id = item.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const metadata = typeof item === 'object' && item !== null ? item : {}; const metadata = typeof item === 'object' && item !== null ? item : {};
const blocking = options.blocking !== false;
try { try {
if (metadata.type && !['paragraph', 'heading'].includes(metadata.type)) { 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 { return {
id, id,
@@ -529,7 +541,7 @@ class SentenceQueueModule extends BaseModule {
await this.preloadAssetsForItem({ await this.preloadAssetsForItem({
type: 'paragraph', type: 'paragraph',
cueMarkers: metadata.cueMarkers || [] cueMarkers: metadata.cueMarkers || []
}, { blocking: true, sentenceId: id }); }, { blocking, sentenceId: id, prefetch: Boolean(options.prefetch) });
} }
const ttsData = await this.prepareSpeechMetadata(text, { const ttsData = await this.prepareSpeechMetadata(text, {
@@ -537,7 +549,7 @@ class SentenceQueueModule extends BaseModule {
blockId: metadata.blockId ?? null, blockId: metadata.blockId ?? null,
turnId: metadata.turnId ?? null, turnId: metadata.turnId ?? null,
ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [], ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [],
blocking: true blocking
}); });
console.log(`SentenceQueue: Prepared speech "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms`); console.log(`SentenceQueue: Prepared speech "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms`);
@@ -834,7 +846,7 @@ class SentenceQueueModule extends BaseModule {
resolve(); resolve();
}; };
const onCommand = (event) => { const onCommand = (event) => {
if (event.detail?.type === 'continue') { if (event.detail?.type === 'continue' && !this.isChoiceAwaitingPlayer()) {
finish(); finish();
} }
}; };
@@ -846,15 +858,81 @@ class SentenceQueueModule extends BaseModule {
return `${item?.id || ''}:${item?.text || ''}`; 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) { 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) { if (pending) {
pending.catch(() => null); const prefetched = await pending.catch(() => null);
if (prefetched) {
this.preparedSentenceCache.delete(cacheKey);
return prefetched;
}
} }
return this.prepareSentence(item); 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) { isCurrentQueueItem(item, queueGeneration = this.queueGeneration) {
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item; return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
} }
@@ -888,35 +966,28 @@ class SentenceQueueModule extends BaseModule {
const promise = (async () => { const promise = (async () => {
if (queueGeneration !== this.queueGeneration) return null; if (queueGeneration !== this.queueGeneration) return null;
await this.preloadAssetsForItem(nextItem, { const prepared = await this.prepareSentence(nextItem, {
sentenceId: nextItem.id,
blocking: false, blocking: false,
prefetch: true prefetch: true,
queueIndex: index
}); });
if (queueGeneration !== this.queueGeneration) return null; if (queueGeneration !== this.queueGeneration) return null;
await this.prefetchWebGLBookPresentation(prepared, {
if (!this.isSpeechItem(nextItem)) { queueGeneration,
return null; queueIndex: index
}
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
}); });
if (queueGeneration !== this.queueGeneration) return null;
this.preparedSentenceCache.set(nextCacheKey, prepared);
return prepared;
})() })()
.then(() => { .then((prepared) => {
if (queueGeneration !== this.queueGeneration) return false; if (queueGeneration !== this.queueGeneration) return false;
console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index }); console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index });
document.dispatchEvent(new CustomEvent('story:process-state', { document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index } 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 }); console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index });
return true; return prepared || true;
}) })
.catch(err => { .catch(err => {
console.warn('SentenceQueue: Prefetch failed:', err); console.warn('SentenceQueue: Prefetch failed:', err);
@@ -1341,6 +1412,7 @@ class SentenceQueueModule extends BaseModule {
this.cancelGenerationRequests('sentence-queue-cleared'); this.cancelGenerationRequests('sentence-queue-cleared');
this.cancelAssetPreloads('sentence-queue-cleared'); this.cancelAssetPreloads('sentence-queue-cleared');
this.prefetchingSpeech.clear(); this.prefetchingSpeech.clear();
this.preparedSentenceCache.clear();
this.pauseBeforeNextReason = null; this.pauseBeforeNextReason = null;
document.dispatchEvent(new CustomEvent('tts:queue-empty', { document.dispatchEvent(new CustomEvent('tts:queue-empty', {
detail: { reason: 'sentence-queue-cleared' } detail: { reason: 'sentence-queue-cleared' }
+17 -1
View File
@@ -29,6 +29,7 @@ class UIControllerModule extends BaseModule {
this.ttsHandler = null; this.ttsHandler = null;
this.socketClient = null; this.socketClient = null;
this.animationQueue = null; this.animationQueue = null;
this.currentInputMode = document.documentElement.dataset.inputMode || 'none';
// Add TTS toggle state // Add TTS toggle state
this.ttsEnabled = false; this.ttsEnabled = false;
@@ -56,6 +57,7 @@ class UIControllerModule extends BaseModule {
'clearDisplay', 'clearDisplay',
'sendCommand', 'sendCommand',
'isInteractiveClickTarget', 'isInteractiveClickTarget',
'isChoiceAwaitingPlayer',
'updateButtonStates' 'updateButtonStates'
]); ]);
} }
@@ -262,6 +264,9 @@ class UIControllerModule extends BaseModule {
if (!event.detail || event.detail.moduleId === this.id) return; if (!event.detail || event.detail.moduleId === this.id) return;
this.handleCommand(event.detail); 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) => { this.addEventListener(document, 'click', (event) => {
if (this.isInteractiveClickTarget(event.target)) { if (this.isInteractiveClickTarget(event.target)) {
@@ -270,7 +275,7 @@ class UIControllerModule extends BaseModule {
const playbackCoordinator = this.getModule('playback-coordinator'); const playbackCoordinator = this.getModule('playback-coordinator');
const hasSkippablePause = document.documentElement.dataset.skippablePause === 'true'; 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' }); this.handleCommand({ type: 'continue', source: 'book-click' });
} }
@@ -668,6 +673,14 @@ class UIControllerModule extends BaseModule {
].join(','))); ].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) { handleCommand(command) {
// Route commands to appropriate handlers // Route commands to appropriate handlers
switch (command.type) { switch (command.type) {
@@ -679,6 +692,9 @@ class UIControllerModule extends BaseModule {
break; break;
case 'continue': case 'continue':
{ {
if (this.isChoiceAwaitingPlayer()) {
return;
}
document.dispatchEvent(new CustomEvent('ui:command', { document.dispatchEvent(new CustomEvent('ui:command', {
detail: { moduleId: this.id, type: 'continue', source: command.source || 'ui-controller-forward' } detail: { moduleId: this.id, type: 'continue', source: command.source || 'ui-controller-forward' }
})); }));
+1 -1
View File
@@ -1814,7 +1814,7 @@ class UIDisplayHandlerModule extends BaseModule {
} }
getLatestHistoryBlockId() { getLatestHistoryBlockId() {
return Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0)); return Math.max(0, Number((this.storyHistory?.nextBlockId || 1) - 1));
} }
updateStoryScrollbar(detail = {}) { updateStoryScrollbar(detail = {}) {
+148 -25
View File
@@ -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 { 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 { 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 { 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'); const canvas = document.getElementById('scene');
canvas.style.cursor = 'grab'; canvas.style.cursor = 'grab';
@@ -214,13 +214,31 @@ const leftCanvas = createPageCanvas('left');
const rightCanvas = createPageCanvas('right'); const rightCanvas = createPageCanvas('right');
const leftTexture = new THREE.CanvasTexture(leftCanvas); const leftTexture = new THREE.CanvasTexture(leftCanvas);
const rightTexture = new THREE.CanvasTexture(rightCanvas); const rightTexture = new THREE.CanvasTexture(rightCanvas);
[leftTexture, rightTexture].forEach((texture) => { function configurePageCanvasTexture(texture) {
texture.colorSpace = THREE.SRGBColorSpace; texture.colorSpace = THREE.SRGBColorSpace;
texture.anisotropy = maxTextureAnisotropy; texture.anisotropy = maxTextureAnisotropy;
texture.minFilter = THREE.LinearMipmapLinearFilter; texture.minFilter = THREE.LinearFilter;
texture.magFilter = 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 = { const pageRevealState = {
left: null, left: null,
right: null right: null
@@ -585,7 +603,7 @@ function configureBookShadowReceiver(material, strength) {
const isHardcoverPaper = material.userData?.isHardcoverPaper === true; const isHardcoverPaper = material.userData?.isHardcoverPaper === true;
const isHeadband = material.userData?.isHeadband === true; const isHeadband = material.userData?.isHeadband === true;
const pageReveal = material.userData?.bookPageReveal || null; 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) => { material.onBeforeCompile = (shader) => {
shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) }; shader.uniforms.bookShadowMaps = { value: bookShadowTargets.map((target) => target.texture) };
shader.uniforms.bookShadowMatrices = { value: bookShadowMatrices }; 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.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.bookRevealWordTimings = { value: Array.from({ length: maxRevealWords }, () => new THREE.Vector4(0, 1, 0, 0)) };
shader.uniforms.bookRevealPaperColor = { value: paperColor.clone() }; 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; material.userData.bookRevealShader = shader;
applyPendingPageReveal(pageReveal.side, shader); applyPendingPageReveal(pageReveal.side, shader);
} }
@@ -642,6 +662,8 @@ function configureBookShadowReceiver(material, strength) {
uniform vec4 bookRevealWordRects[256]; uniform vec4 bookRevealWordRects[256];
uniform vec4 bookRevealWordTimings[256]; uniform vec4 bookRevealWordTimings[256];
uniform vec3 bookRevealPaperColor; uniform vec3 bookRevealPaperColor;
uniform sampler2D bookRevealBaseMap;
uniform float bookRevealUseBaseMap;
uniform float bookRevealSoftness; uniform float bookRevealSoftness;
float bookRevealVisibleMask(vec2 uv) { 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); 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]; vec4 timing = bookRevealWordTimings[i];
float progress = clamp((bookRevealElapsedMs - timing.x) / max(1.0, timing.y), 0.0, 1.0); 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 feather = max(0.0001, bookRevealSoftness);
float visible = smoothstep(scan - feather, scan + feather, progress); float visible = smoothstep(scan - feather, scan + feather, progress);
hidden = max(hidden, inside * (1.0 - visible)); hidden = max(hidden, inside * (1.0 - visible));
@@ -805,8 +827,9 @@ function configureBookShadowReceiver(material, strength) {
if (bookRevealActive > 0.5) { if (bookRevealActive > 0.5) {
float hiddenInk = bookRevealVisibleMask(vMapUv); float hiddenInk = bookRevealVisibleMask(vMapUv);
float luminance = dot(sampledDiffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722)); float luminance = dot(sampledDiffuseColor.rgb, vec3(0.2126, 0.7152, 0.0722));
float inkMask = 1.0 - smoothstep(0.26, 0.72, luminance); float inkMask = 1.0 - smoothstep(0.52, 0.9, luminance);
sampledDiffuseColor.rgb = mix(sampledDiffuseColor.rgb, bookRevealPaperColor, hiddenInk * inkMask); 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; diffuseColor *= sampledDiffuseColor;
#endif` #endif`
@@ -1653,8 +1676,15 @@ function handlePageCanvases(event) {
markPageTextureTiming('handlePageCanvases:start', { markPageTextureTiming('handlePageCanvases:start', {
hasLeft: Boolean(detail.left), hasLeft: Boolean(detail.left),
hasRight: Boolean(detail.right), 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.left) {
if (detail.reveal?.left) { if (detail.reveal?.left) {
beginPageReveal('left', detail.left, detail.reveal.left); beginPageReveal('left', detail.left, detail.reveal.left);
@@ -1678,10 +1708,59 @@ function handlePageCanvases(event) {
markPageTextureTiming('handlePageCanvases:end'); 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) { function uploadPageTextureDirect(side, sourceCanvas) {
const texture = side === 'left' ? leftTexture : rightTexture; const texture = side === 'left' ? leftTexture : rightTexture;
const material = side === 'left' ? materials.leftPage : materials.rightPage;
markPageTextureTiming('directUpload:start', { side }); markPageTextureTiming('directUpload:start', { side });
clearPageReveal(side, 'direct-upload'); clearPageReveal(side, 'direct-upload');
if (material.map !== texture) {
material.map = texture;
material.needsUpdate = true;
}
bindPageTextureSource(side, texture, sourceCanvas); bindPageTextureSource(side, texture, sourceCanvas);
markPageTextureTiming('directUpload:end', { side }); markPageTextureTiming('directUpload:end', { side });
} }
@@ -1689,12 +1768,25 @@ function uploadPageTextureDirect(side, sourceCanvas) {
function beginPageReveal(side, sourceCanvas, revealDetail = {}) { function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
const texture = side === 'left' ? leftTexture : rightTexture; const texture = side === 'left' ? leftTexture : rightTexture;
const shader = getPageRevealShader(side); const shader = getPageRevealShader(side);
const material = side === 'left' ? materials.leftPage : materials.rightPage;
const prepared = takePreparedPageTexture(side, revealDetail);
markPageTextureTiming('revealUpload:start', { markPageTextureTiming('revealUpload:start', {
side, 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] = { pageRevealState[side] = {
startedAt: revealDetail.startNow ? performance.now() : null, startedAt: revealDetail.startNow ? performance.now() : null,
@@ -1702,9 +1794,13 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
lastRevealFrameAt: null, lastRevealFrameAt: null,
visualElapsedMs: 0, visualElapsedMs: 0,
durationMs: Math.max(1, Number(revealDetail.durationMs || 1)), 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 (material?.userData) material.userData.pendingPageReveal = revealDetail;
if (shader?.uniforms) applyPendingPageReveal(side, shader); if (shader?.uniforms) applyPendingPageReveal(side, shader);
else if (material) material.needsUpdate = true; else if (material) material.needsUpdate = true;
@@ -1725,6 +1821,9 @@ function applyPendingPageReveal(side, shader = getPageRevealShader(side)) {
applyPageRevealWords(shader, revealDetail.wordRects || []); applyPageRevealWords(shader, revealDetail.wordRects || []);
shader.uniforms.bookRevealActive.value = 1; shader.uniforms.bookRevealActive.value = 1;
shader.uniforms.bookRevealElapsedMs.value = 0; 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({ document.documentElement.dataset.webglRevealDebug = JSON.stringify({
side, side,
blockIds: pageRevealState[side]?.blockIds || revealDetail.blockIds || [], blockIds: pageRevealState[side]?.blockIds || revealDetail.blockIds || [],
@@ -1744,6 +1843,12 @@ function applyPageRevealWords(shader, words = []) {
source.forEach((word, index) => { source.forEach((word, index) => {
const rect = word.rect || {}; const rect = word.rect || {};
const timing = word.timing || {}; 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 x = THREE.MathUtils.clamp(Number(rect.x || 0), 0, 1);
const y = THREE.MathUtils.clamp(Number(rect.y || 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); const width = THREE.MathUtils.clamp(Number(rect.width || 0), 0, 1);
@@ -1755,8 +1860,8 @@ function applyPageRevealWords(shader, words = []) {
Math.max(0.0001, height) Math.max(0.0001, height)
); );
timingUniforms[index].set( timingUniforms[index].set(
Math.max(0, Number(timing.delay || 0)), delay,
Math.max(1, Number(timing.duration || 1)), Math.max(1, allottedDuration),
0, 0,
0 0
); );
@@ -1781,6 +1886,8 @@ function getRevealDebugState() {
elapsedMs: Number(uniforms.bookRevealElapsedMs?.value || 0), elapsedMs: Number(uniforms.bookRevealElapsedMs?.value || 0),
visualElapsedMs: Number(pageRevealState[side]?.visualElapsedMs || 0), visualElapsedMs: Number(pageRevealState[side]?.visualElapsedMs || 0),
wordCount: Number(uniforms.bookRevealWordCount?.value || 0), wordCount: Number(uniforms.bookRevealWordCount?.value || 0),
usesBaseTexture: Number(uniforms.bookRevealUseBaseMap?.value || 0),
fastForwarding: pageRevealState[side]?.fastForwarding === true,
started: pageRevealState[side]?.startedAt != null, started: pageRevealState[side]?.startedAt != null,
pendingStart: pageRevealState[side]?.pendingStart === true, pendingStart: pageRevealState[side]?.pendingStart === true,
durationMs: Number(pageRevealState[side]?.durationMs || 0), durationMs: Number(pageRevealState[side]?.durationMs || 0),
@@ -1791,16 +1898,17 @@ function getRevealDebugState() {
} }
function clearPageReveal(side, reason = 'clear') { function clearPageReveal(side, reason = 'clear') {
const previousState = pageRevealState[side];
pageRevealClearLog.push({ pageRevealClearLog.push({
side, side,
reason, reason,
at: performance.now(), at: performance.now(),
state: pageRevealState[side] ? { state: previousState ? {
started: pageRevealState[side].startedAt != null, started: previousState.startedAt != null,
pendingStart: pageRevealState[side].pendingStart === true, pendingStart: previousState.pendingStart === true,
visualElapsedMs: pageRevealState[side].visualElapsedMs || 0, visualElapsedMs: previousState.visualElapsedMs || 0,
durationMs: pageRevealState[side].durationMs, durationMs: previousState.durationMs,
blockIds: pageRevealState[side].blockIds || [] blockIds: previousState.blockIds || []
} : null } : null
}); });
if (pageRevealClearLog.length > 40) pageRevealClearLog.splice(0, pageRevealClearLog.length - 40); 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.bookRevealActive.value = 0;
shader.uniforms.bookRevealElapsedMs.value = completedRevealElapsedMs; shader.uniforms.bookRevealElapsedMs.value = completedRevealElapsedMs;
shader.uniforms.bookRevealWordCount.value = 0; shader.uniforms.bookRevealWordCount.value = 0;
if (shader.uniforms.bookRevealUseBaseMap) shader.uniforms.bookRevealUseBaseMap.value = 0;
} }
previousState?.baseTexture?.dispose?.();
} }
function startPageRevealForBlock(blockId) { function startPageRevealForBlock(blockId) {
@@ -1833,7 +1943,10 @@ function fastForwardPageReveals(blockIds = []) {
if (!state) return; if (!state) return;
const matches = ids.size === 0 || state.blockIds.some(blockId => ids.has(String(blockId))); const matches = ids.size === 0 || state.blockIds.some(blockId => ids.has(String(blockId)));
if (!matches) return; 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); const revealFrameDeltaMs = state.lastRevealFrameAt == null ? 0 : Math.max(0, now - state.lastRevealFrameAt);
state.lastRevealFrameAt = now; 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); const progress = THREE.MathUtils.clamp(state.visualElapsedMs / state.durationMs, 0, 1);
shader.uniforms.bookRevealElapsedMs.value = state.visualElapsedMs; shader.uniforms.bookRevealElapsedMs.value = state.visualElapsedMs;
if (progress < 1) return; if (progress < 1) return;
+2
View File
@@ -313,6 +313,8 @@ class WebGLBookSceneModule extends BaseModule {
return; return;
} }
if (!target) return; if (!target) return;
event.preventDefault();
event.stopPropagation();
if (type === 'pointermove' || type === 'mousemove') { if (type === 'pointermove' || type === 'mousemove') {
this.updateProjectedHover(target, event); this.updateProjectedHover(target, event);
} }
+28
View File
@@ -13,10 +13,16 @@ const uiDisplayHandlerPath = path.join(__dirname, '..', 'public', 'js', 'ui-disp
const uiDisplayHandlerSource = fs.readFileSync(uiDisplayHandlerPath, 'utf8'); const uiDisplayHandlerSource = fs.readFileSync(uiDisplayHandlerPath, 'utf8');
const bookPaginationPath = path.join(__dirname, '..', 'public', 'js', 'book-pagination-module.js'); const bookPaginationPath = path.join(__dirname, '..', 'public', 'js', 'book-pagination-module.js');
const bookPaginationSource = fs.readFileSync(bookPaginationPath, 'utf8'); 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 webglScenePath = path.join(__dirname, '..', 'public', 'js', 'webgl-book-scene-module.js');
const webglSceneSource = fs.readFileSync(webglScenePath, 'utf8'); const webglSceneSource = fs.readFileSync(webglScenePath, 'utf8');
const loaderPath = path.join(__dirname, '..', 'public', 'js', 'loader.js'); const loaderPath = path.join(__dirname, '..', 'public', 'js', 'loader.js');
const loaderSource = fs.readFileSync(loaderPath, 'utf8'); 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) { function dependencyList(source, moduleId) {
const classStart = source.indexOf(`super('${moduleId}'`); const classStart = source.indexOf(`super('${moduleId}'`);
@@ -56,6 +62,12 @@ function methodBody(source, methodName) {
return ''; 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 = [ const checks = [
['scene-level SSAO import', /SSAOPass/.test(source)], ['scene-level SSAO import', /SSAOPass/.test(source)],
['postprocess anti-aliasing import', /SMAAPass/.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 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 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 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 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'))], ['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)], ['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 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 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)], ['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 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 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)], ['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)],