Checkpoint WebGL book reveal optimization

This commit is contained in:
2026-06-08 08:19:20 +02:00
parent 7abd3387f3
commit c86a304364
13 changed files with 618 additions and 112 deletions
+209 -34
View File
@@ -28,9 +28,13 @@ class BookTextureRendererModule extends BaseModule {
this.activeAnimations = new Map();
this.revealedBlockIds = new Set();
this.pendingRevealBlockIds = new Set();
this.preparedRevealCache = new Map();
this.revealBounds = null;
this.revealWords = null;
this.revealBaseCanvases = null;
this.revealPublishBlockIds = null;
this.lastDrawSignature = null;
this.lastDrawSkipLoggedAt = 0;
this.animationFrameId = null;
this.lastAnimationFrameAt = 0;
this.targetFrameDurationMs = 1000 / 30;
@@ -43,15 +47,23 @@ class BookTextureRendererModule extends BaseModule {
'ensureTextureFontFace',
'createPageCanvases',
'drawSpread',
'getDrawSignature',
'cloneCanvas',
'drawPageBase',
'drawPageLines',
'drawLine',
'drawWord',
'recordRevealRect',
'getInlineStyleState',
'updateInlineStyleState',
'getCanvasFont',
'applyTextStyle',
'getPageContent',
'buildLineSegments',
'startRevealAnimation',
'prepareRevealBlock',
'createAnimationState',
'publishPreparedReveal',
'startPreparedRevealAnimation',
'fastForwardAnimations',
'stopAnimations',
@@ -121,21 +133,25 @@ class BookTextureRendererModule extends BaseModule {
async waitForTextureFonts() {
if (!document.fonts) return;
await Promise.all([
this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Regular.otf'),
this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Regular.otf', { style: 'normal', weight: '400' }),
this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Italic.otf', { style: 'italic', weight: '400' }),
this.ensureTextureFontFace('EB Garamond 12', '/fonts/EBGaramond12/webfonts/EBGaramond-Regular.woff2'),
this.ensureTextureFontFace('EB Garamond Initials', '/fonts/EB-Garamond-Initials/EBGaramond-0.016/otf/EBGaramond-Initials.otf')
]);
await Promise.all([
document.fonts.load('24px "EB Garamond"'),
document.fonts.load('italic 24px "EB Garamond"'),
document.fonts.load('bold 24px "EB Garamond"'),
document.fonts.load('italic bold 24px "EB Garamond"'),
document.fonts.load('24px "EB Garamond 12"'),
document.fonts.load('72px "EB Garamond Initials"')
]);
await document.fonts.ready;
}
async ensureTextureFontFace(family, url) {
async ensureTextureFontFace(family, url, descriptors = {}) {
if (!window.FontFace) return;
const face = new FontFace(family, `url(${url})`);
const face = new FontFace(family, `url(${url})`, descriptors);
const loadedFace = await face.load();
document.fonts.add(loadedFace);
}
@@ -151,27 +167,66 @@ class BookTextureRendererModule extends BaseModule {
});
}
drawSpread(spread = null, sides = null) {
drawSpread(spread = null, sides = null, options = {}) {
const previousSpread = this.currentSpread;
this.currentSpread = spread || { left: [], right: [] };
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0;
const drawSignature = this.getDrawSignature(this.currentSpread, sidesToDraw);
if (!options.preloadOnly && !hasReveal && drawSignature === this.lastDrawSignature) {
const now = performance.now();
if (now - this.lastDrawSkipLoggedAt > 1000) {
this.lastDrawSkipLoggedAt = now;
this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw });
}
if (options.preloadOnly) this.currentSpread = previousSpread;
return null;
}
this.markPipelineTiming('drawSpread:start', {
sides: sidesToDraw,
revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : []
revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : [],
preloadOnly: Boolean(options.preloadOnly)
});
this.revealBounds = { left: null, right: null };
this.revealWords = { left: [], right: [] };
this.revealBaseCanvases = { left: null, right: null };
sidesToDraw.forEach((side) => {
if (!this.canvases[side]) return;
this.drawPageBase(side);
if (hasReveal) this.revealBaseCanvases[side] = this.cloneCanvas(this.canvases[side]);
this.drawPageLines(side, this.currentSpread?.[side] || []);
});
this.publishSpread(sidesToDraw);
const published = this.publishSpread(sidesToDraw, options);
this.markPipelineTiming('drawSpread:end', {
sides: sidesToDraw
sides: sidesToDraw,
preloadOnly: Boolean(options.preloadOnly)
});
this.revealBounds = null;
this.revealWords = null;
this.revealBaseCanvases = null;
this.revealPublishBlockIds = null;
if (!options.preloadOnly && !hasReveal) this.lastDrawSignature = drawSignature;
if (options.preloadOnly) this.currentSpread = previousSpread;
return published;
}
getDrawSignature(spread = null, sides = []) {
const source = spread || {};
return sides.map(side => {
const lines = Array.isArray(source[side]) ? source[side] : [];
const ids = lines.map(line => `${line.blockId ?? ''}:${line.lineIndex ?? ''}:${line.pageLine ?? ''}:${line.line?.nodes?.length || 0}`).join(',');
return `${side}[${ids}]`;
}).join('|');
}
cloneCanvas(canvas) {
if (!canvas) return null;
const clone = document.createElement('canvas');
clone.width = canvas.width;
clone.height = canvas.height;
const context = clone.getContext('2d');
if (context) context.drawImage(canvas, 0, 0);
return clone;
}
drawPageBase(side) {
@@ -217,7 +272,6 @@ class BookTextureRendererModule extends BaseModule {
const content = this.getPageContent(side);
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30));
const fontStyle = lineRecord.fontStyle === 'italic' ? 'italic ' : '';
const line = lineRecord.line || {};
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
const baseY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx;
@@ -233,10 +287,13 @@ class BookTextureRendererModule extends BaseModule {
const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps);
const previousVariantCaps = 'fontVariantCaps' in ctx ? ctx.fontVariantCaps : null;
const previousLetterSpacing = 'letterSpacing' in ctx ? ctx.letterSpacing : null;
const baseStyle = this.getInlineStyleState(line.activeStyleTags || [], {
italic: lineRecord.fontStyle === 'italic'
});
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`;
this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle);
if (lineRecord.dropCapText) {
ctx.save();
const dropCapFontPx = Math.round(fontPx * 2.68);
@@ -249,15 +306,65 @@ class BookTextureRendererModule extends BaseModule {
ctx.restore();
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
ctx.font = `${fontStyle}${smallCaps ? 'small-caps ' : ''}${fontPx}px ${metrics.typography.fontFamily}`;
this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle);
}
this.buildLineSegments(ctx, nodes, line, ratio).forEach((segment) => {
this.drawWord(ctx, segment.value, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx);
this.buildLineSegments(ctx, nodes, line, ratio, baseStyle).forEach((segment) => {
this.drawWord(ctx, segment, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx, smallCaps);
});
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = previousLetterSpacing || '0px';
}
getInlineStyleState(tags = [], base = {}) {
const state = {
bold: Boolean(base.bold),
italic: Boolean(base.italic)
};
tags.forEach(tag => {
if (tag?.bold) state.bold = true;
if (tag?.italic) state.italic = true;
});
return state;
}
updateInlineStyleState(stack = [], value = '') {
const text = String(value || '');
if (!text.startsWith('<')) return stack;
if (text.startsWith('</')) {
if (stack.length) stack.pop();
return stack;
}
const template = document.createElement('div');
template.innerHTML = text;
const element = template.firstElementChild;
if (!element) return stack;
const tagName = element.tagName.toLowerCase();
const style = String(element.getAttribute('style') || '').toLowerCase();
const className = String(element.getAttribute('class') || '').toLowerCase();
stack.push({
tagName,
bold: tagName === 'strong' || tagName === 'b' || /font-weight\s*:\s*(bold|[6-9]00)/.test(style) || className.includes('bold'),
italic: tagName === 'em' || tagName === 'i' || /font-style\s*:\s*italic/.test(style) || className.includes('italic')
});
return stack;
}
getCanvasFont(fontPx, smallCaps = false, style = {}) {
const metrics = this.metrics;
return [
style.italic ? 'italic' : '',
smallCaps ? 'small-caps' : '',
style.bold ? '700' : '',
`${fontPx}px`,
metrics.typography.fontFamily
].filter(Boolean).join(' ');
}
applyTextStyle(ctx, fontPx, smallCaps = false, style = {}) {
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
ctx.font = this.getCanvasFont(fontPx, smallCaps, style);
}
getPageContent(side = 'left') {
return this.metrics?.contentBySide?.[side] || this.metrics?.content || {
x: 0,
@@ -267,26 +374,31 @@ class BookTextureRendererModule extends BaseModule {
};
}
buildLineSegments(ctx, nodes = [], line = {}, ratio = 0) {
buildLineSegments(ctx, nodes = [], line = {}, ratio = 0, baseStyle = {}) {
const segments = [];
let x = 0;
let currentSegment = null;
let previousWasGlue = true;
let currentWordIndex = -1;
const styleStack = Array.isArray(line.activeStyleTags) ? line.activeStyleTags.map(tag => ({ ...tag })) : [];
nodes.forEach((node, index) => {
if (!node) return;
if (node.type === 'box' && node.value) {
const value = String(node.value);
const width = Number(node.width || ctx.measureText(value).width || 0);
if (currentSegment && !previousWasGlue) {
const style = this.getInlineStyleState(styleStack, baseStyle);
if (currentSegment && !previousWasGlue && currentSegment.style.bold === style.bold && currentSegment.style.italic === style.italic) {
currentSegment.value += value;
currentSegment.width += width;
} else {
if (previousWasGlue) currentWordIndex += 1;
currentSegment = {
value,
x,
width,
wordIndex: segments.length
wordIndex: Math.max(0, currentWordIndex),
style
};
segments.push(currentSegment);
}
@@ -308,15 +420,19 @@ class BookTextureRendererModule extends BaseModule {
x += hyphenWidth;
}
previousWasGlue = false;
} else if (node.type === 'tag') {
this.updateInlineStyleState(styleStack, node.value);
}
});
return segments;
}
drawWord(ctx, value, x, baseY, lineRecord, localWordIndex, side, fontPx, lineHeightPx) {
drawWord(ctx, segment, x, baseY, lineRecord, localWordIndex, side, fontPx, lineHeightPx, smallCaps = false) {
const value = segment?.value || '';
this.applyTextStyle(ctx, fontPx, smallCaps, segment?.style || {});
ctx.fillText(value, x, baseY);
const width = ctx.measureText(value).width || fontPx;
const width = Number(segment?.width || 0) || ctx.measureText(value).width || fontPx;
this.recordRevealRect(side, lineRecord, x, baseY - fontPx, width, lineHeightPx, localWordIndex);
}
@@ -392,16 +508,8 @@ class BookTextureRendererModule extends BaseModule {
this.requestAnimationFrame();
}
prepareRevealBlock(detail = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const id = String(blockId);
const wordTimings = detail.wordTimings;
this.markPipelineTiming('prepareRevealBlock:start', {
blockId: id,
wordTimingCount: wordTimings.length
});
this.activeAnimations.set(id, {
createAnimationState(blockId, wordTimings = [], detail = {}) {
return {
blockId,
wordTimings,
startedAt: null,
@@ -411,16 +519,73 @@ class BookTextureRendererModule extends BaseModule {
),
completed: false,
prepared: true
};
}
prepareRevealBlock(detail = {}, options = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const id = String(blockId);
const wordTimings = detail.wordTimings;
const preloadOnly = Boolean(detail.preloadOnly || options.preloadOnly);
this.markPipelineTiming('prepareRevealBlock:start', {
blockId: id,
wordTimingCount: wordTimings.length,
preloadOnly
});
if (!preloadOnly && this.preparedRevealCache.has(id)) {
const cached = this.preparedRevealCache.get(id);
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
this.pendingRevealBlockIds.delete(id);
this.publishPreparedReveal(cached);
this.markPipelineTiming('prepareRevealBlock:end', {
blockId: id,
wordTimingCount: wordTimings.length,
reusedPreparedCanvas: true
});
return;
}
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
this.pendingRevealBlockIds.delete(id);
this.revealPublishBlockIds = new Set([id]);
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
const sides = this.getBlockSides(blockId);
const published = this.drawSpread(spread, sides, { preloadOnly });
if (preloadOnly && published) {
this.preparedRevealCache.set(id, {
...published,
blockId,
wordTimings,
totalDuration: detail.totalDuration || 0
});
}
this.markPipelineTiming('prepareRevealBlock:end', {
blockId: id,
wordTimingCount: wordTimings.length
wordTimingCount: wordTimings.length,
preloadOnly
});
}
publishPreparedReveal(prepared) {
if (!prepared) return;
this.markPipelineTiming('publishPreparedReveal', {
blockId: prepared.blockId,
sides: prepared.sides || [],
hasReveal: Boolean(prepared.reveal && Object.keys(prepared.reveal).length)
});
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
detail: {
metrics: prepared.metrics,
hitMaps: prepared.hitMaps || this.hitMaps,
left: prepared.left || null,
right: prepared.right || null,
reveal: prepared.reveal || {},
preparedFromCache: true
}
}));
}
startPreparedRevealAnimation(blockId) {
const id = String(blockId ?? '');
const animation = this.activeAnimations.get(id);
@@ -534,7 +699,7 @@ class BookTextureRendererModule extends BaseModule {
if (hasActive) this.requestAnimationFrame();
}
publishSpread(sides = null) {
publishSpread(sides = null, options = {}) {
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const wordCounts = {
left: this.revealWords?.left?.length || 0,
@@ -542,10 +707,16 @@ class BookTextureRendererModule extends BaseModule {
};
const detail = {
metrics: this.metrics,
hitMaps: this.hitMaps
hitMaps: this.hitMaps,
sides: sidesToPublish
};
if (sidesToPublish.includes('left')) detail.left = this.canvases.left;
if (sidesToPublish.includes('right')) detail.right = this.canvases.right;
if (options.preloadOnly) detail.preloadOnly = true;
if (sidesToPublish.includes('left')) {
detail.left = options.preloadOnly ? this.cloneCanvas(this.canvases.left) : this.canvases.left;
}
if (sidesToPublish.includes('right')) {
detail.right = options.preloadOnly ? this.cloneCanvas(this.canvases.right) : this.canvases.right;
}
const reveal = {};
sidesToPublish.forEach((side) => {
const bounds = this.revealBounds?.[side];
@@ -559,6 +730,7 @@ class BookTextureRendererModule extends BaseModule {
reveal[side] = {
blockIds,
durationMs,
baseCanvas: options.preloadOnly ? this.cloneCanvas(this.revealBaseCanvases?.[side]) : this.revealBaseCanvases?.[side] || null,
wordRects: (this.revealWords?.[side] || []).map(word => ({
blockId: word.blockId,
wordIndex: word.wordIndex,
@@ -577,11 +749,13 @@ class BookTextureRendererModule extends BaseModule {
this.markPipelineTiming('publishSpread', {
sides: sidesToPublish,
hasReveal: Object.keys(reveal).length > 0,
wordCounts
wordCounts,
preloadOnly: Boolean(options.preloadOnly)
});
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
detail
}));
return detail;
}
getPageCanvas(side) {
@@ -595,6 +769,7 @@ class BookTextureRendererModule extends BaseModule {
handlePageCountChanged(event) {
this.pageFormat?.setPageCount?.(event.detail?.pageCount);
this.createPageCanvases();
this.lastDrawSignature = null;
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
}