Checkpoint WebGL book reveal optimization
This commit is contained in:
@@ -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?.());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user