Checkpoint WebGL book reveal optimization
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 (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 (targetSpread) this.currentSpreadIndex = targetSpread.index;
|
||||||
this.publish();
|
}
|
||||||
|
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;
|
||||||
|
|||||||
@@ -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,14 +519,71 @@ 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.pendingRevealBlockIds.delete(id);
|
||||||
this.revealPublishBlockIds = new Set([id]);
|
this.publishPreparedReveal(cached);
|
||||||
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
|
|
||||||
this.markPipelineTiming('prepareRevealBlock:end', {
|
this.markPipelineTiming('prepareRevealBlock:end', {
|
||||||
blockId: id,
|
blockId: id,
|
||||||
wordTimingCount: wordTimings.length
|
wordTimingCount: wordTimings.length,
|
||||||
|
reusedPreparedCanvas: true
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
||||||
|
this.pendingRevealBlockIds.delete(id);
|
||||||
|
this.revealPublishBlockIds = new Set([id]);
|
||||||
|
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
|
||||||
|
const sides = this.getBlockSides(blockId);
|
||||||
|
const published = this.drawSpread(spread, sides, { preloadOnly });
|
||||||
|
if (preloadOnly && published) {
|
||||||
|
this.preparedRevealCache.set(id, {
|
||||||
|
...published,
|
||||||
|
blockId,
|
||||||
|
wordTimings,
|
||||||
|
totalDuration: detail.totalDuration || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.markPipelineTiming('prepareRevealBlock:end', {
|
||||||
|
blockId: id,
|
||||||
|
wordTimingCount: wordTimings.length,
|
||||||
|
preloadOnly
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publishPreparedReveal(prepared) {
|
||||||
|
if (!prepared) return;
|
||||||
|
this.markPipelineTiming('publishPreparedReveal', {
|
||||||
|
blockId: prepared.blockId,
|
||||||
|
sides: prepared.sides || [],
|
||||||
|
hasReveal: Boolean(prepared.reveal && Object.keys(prepared.reveal).length)
|
||||||
|
});
|
||||||
|
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
|
||||||
|
detail: {
|
||||||
|
metrics: prepared.metrics,
|
||||||
|
hitMaps: prepared.hitMaps || this.hitMaps,
|
||||||
|
left: prepared.left || null,
|
||||||
|
right: prepared.right || null,
|
||||||
|
reveal: prepared.reveal || {},
|
||||||
|
preparedFromCache: true
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
startPreparedRevealAnimation(blockId) {
|
startPreparedRevealAnimation(blockId) {
|
||||||
@@ -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?.());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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' }
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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 = {}) {
|
||||||
|
|||||||
+146
-23
@@ -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)
|
||||||
});
|
});
|
||||||
|
if (prepared?.texture) {
|
||||||
|
material.map = prepared.texture;
|
||||||
|
} else {
|
||||||
|
if (material.map !== texture) {
|
||||||
|
material.map = texture;
|
||||||
|
material.needsUpdate = true;
|
||||||
|
}
|
||||||
bindPageTextureSource(side, texture, sourceCanvas);
|
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;
|
||||||
|
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);
|
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)],
|
||||||
|
|||||||
Reference in New Issue
Block a user