Checkpoint WebGL book reveal optimization
This commit is contained in:
@@ -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 {
|
||||
|
||||
+1
-1
@@ -280,6 +280,6 @@
|
||||
console.log(message);
|
||||
};
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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('</')) {
|
||||
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 = {}) {
|
||||
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
|
||||
let count = 0;
|
||||
|
||||
@@ -28,9 +28,13 @@ class BookTextureRendererModule extends BaseModule {
|
||||
this.activeAnimations = new Map();
|
||||
this.revealedBlockIds = new Set();
|
||||
this.pendingRevealBlockIds = new Set();
|
||||
this.preparedRevealCache = new Map();
|
||||
this.revealBounds = null;
|
||||
this.revealWords = null;
|
||||
this.revealBaseCanvases = null;
|
||||
this.revealPublishBlockIds = null;
|
||||
this.lastDrawSignature = null;
|
||||
this.lastDrawSkipLoggedAt = 0;
|
||||
this.animationFrameId = null;
|
||||
this.lastAnimationFrameAt = 0;
|
||||
this.targetFrameDurationMs = 1000 / 30;
|
||||
@@ -43,15 +47,23 @@ class BookTextureRendererModule extends BaseModule {
|
||||
'ensureTextureFontFace',
|
||||
'createPageCanvases',
|
||||
'drawSpread',
|
||||
'getDrawSignature',
|
||||
'cloneCanvas',
|
||||
'drawPageBase',
|
||||
'drawPageLines',
|
||||
'drawLine',
|
||||
'drawWord',
|
||||
'recordRevealRect',
|
||||
'getInlineStyleState',
|
||||
'updateInlineStyleState',
|
||||
'getCanvasFont',
|
||||
'applyTextStyle',
|
||||
'getPageContent',
|
||||
'buildLineSegments',
|
||||
'startRevealAnimation',
|
||||
'prepareRevealBlock',
|
||||
'createAnimationState',
|
||||
'publishPreparedReveal',
|
||||
'startPreparedRevealAnimation',
|
||||
'fastForwardAnimations',
|
||||
'stopAnimations',
|
||||
@@ -121,21 +133,25 @@ class BookTextureRendererModule extends BaseModule {
|
||||
async waitForTextureFonts() {
|
||||
if (!document.fonts) return;
|
||||
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 Initials', '/fonts/EB-Garamond-Initials/EBGaramond-0.016/otf/EBGaramond-Initials.otf')
|
||||
]);
|
||||
await Promise.all([
|
||||
document.fonts.load('24px "EB Garamond"'),
|
||||
document.fonts.load('italic 24px "EB Garamond"'),
|
||||
document.fonts.load('bold 24px "EB Garamond"'),
|
||||
document.fonts.load('italic bold 24px "EB Garamond"'),
|
||||
document.fonts.load('24px "EB Garamond 12"'),
|
||||
document.fonts.load('72px "EB Garamond Initials"')
|
||||
]);
|
||||
await document.fonts.ready;
|
||||
}
|
||||
|
||||
async ensureTextureFontFace(family, url) {
|
||||
async ensureTextureFontFace(family, url, descriptors = {}) {
|
||||
if (!window.FontFace) return;
|
||||
const face = new FontFace(family, `url(${url})`);
|
||||
const face = new FontFace(family, `url(${url})`, descriptors);
|
||||
const loadedFace = await face.load();
|
||||
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: [] };
|
||||
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
||||
const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0;
|
||||
const drawSignature = this.getDrawSignature(this.currentSpread, sidesToDraw);
|
||||
if (!options.preloadOnly && !hasReveal && drawSignature === this.lastDrawSignature) {
|
||||
const now = performance.now();
|
||||
if (now - this.lastDrawSkipLoggedAt > 1000) {
|
||||
this.lastDrawSkipLoggedAt = now;
|
||||
this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw });
|
||||
}
|
||||
if (options.preloadOnly) this.currentSpread = previousSpread;
|
||||
return null;
|
||||
}
|
||||
this.markPipelineTiming('drawSpread:start', {
|
||||
sides: sidesToDraw,
|
||||
revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : []
|
||||
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('</')) {
|
||||
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') {
|
||||
return this.metrics?.contentBySide?.[side] || this.metrics?.content || {
|
||||
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 = [];
|
||||
let x = 0;
|
||||
let currentSegment = null;
|
||||
let previousWasGlue = true;
|
||||
let currentWordIndex = -1;
|
||||
const styleStack = Array.isArray(line.activeStyleTags) ? line.activeStyleTags.map(tag => ({ ...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?.());
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -668,6 +673,14 @@ class UIControllerModule extends BaseModule {
|
||||
].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
|
||||
switch (command.type) {
|
||||
@@ -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' }
|
||||
}));
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
+148
-25
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
|
||||
Reference in New Issue
Block a user