Files
ai.interactive.fiction/public/js/book-texture-renderer-module.js
T
2026-06-07 16:42:09 +02:00

615 lines
25 KiB
JavaScript

/**
* Book Texture Renderer Module
* Draws the virtual book pages directly into texture-space canvases.
*/
import { BaseModule } from './base-module.js';
class BookTextureRendererModule extends BaseModule {
constructor() {
super('book-texture-renderer', 'Book Texture Renderer');
this.dependencies = ['book-page-format', 'book-pagination', 'localization'];
this.pageFormat = null;
this.pagination = null;
this.localization = null;
this.metrics = null;
this.canvases = {
left: null,
right: null
};
this.contexts = {
left: null,
right: null
};
this.hitMaps = {
left: [],
right: []
};
this.currentSpread = null;
this.activeAnimations = new Map();
this.revealedBlockIds = new Set();
this.pendingRevealBlockIds = new Set();
this.revealBounds = null;
this.revealWords = null;
this.revealPublishBlockIds = null;
this.animationFrameId = null;
this.lastAnimationFrameAt = 0;
this.targetFrameDurationMs = 1000 / 30;
this.pipelineTimings = [];
this.bindMethods([
'initialize',
'markPipelineTiming',
'waitForTextureFonts',
'ensureTextureFontFace',
'createPageCanvases',
'drawSpread',
'drawPageBase',
'drawPageLines',
'drawLine',
'drawWord',
'recordRevealRect',
'getPageContent',
'buildLineSegments',
'startRevealAnimation',
'prepareRevealBlock',
'startPreparedRevealAnimation',
'fastForwardAnimations',
'stopAnimations',
'getBlockSides',
'getAnimatedSides',
'markPendingReveal',
'requestAnimationFrame',
'tickAnimations',
'publishSpread',
'getPageCanvas',
'getHitMap',
'handlePageCountChanged'
]);
}
async initialize() {
this.pageFormat = this.getModule('book-page-format');
this.pagination = this.getModule('book-pagination');
this.localization = this.getModule('localization');
window.BookTextureRendererDebug = {
pipelineTimings: this.pipelineTimings
};
this.reportProgress(10, 'Waiting for book fonts');
await this.waitForTextureFonts();
this.reportProgress(20, 'Preparing page texture canvases');
this.createPageCanvases();
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
this.addEventListener(document, 'book-pagination:spread-updated', (event) => {
const spread = event.detail?.spread || this.pagination?.getCurrentSpread?.();
const latestBlockId = event.detail?.latestBlockId;
const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0));
this.currentSpread = spread || { left: [], right: [] };
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) {
this.markPendingReveal(latestBlockId);
const pendingSides = this.getBlockSides(latestBlockId);
const immediateSides = ['left', 'right'].filter(side => !pendingSides.includes(side));
if (immediateSides.length) this.drawSpread(this.currentSpread, immediateSides);
return;
}
this.drawSpread(this.currentSpread);
});
this.addEventListener(document, 'book-texture:reveal-block', (event) => {
this.startRevealAnimation(event.detail || {});
});
this.addEventListener(document, 'book-texture:prepare-reveal-block', (event) => {
this.prepareRevealBlock(event.detail || {});
});
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
this.addEventListener(document, 'ui:command', (event) => {
if (event.detail?.type === 'continue') this.fastForwardAnimations();
});
this.addEventListener(document, 'story:manual-scroll', this.fastForwardAnimations);
this.addEventListener(document, 'story:history-restoring', this.stopAnimations);
this.reportProgress(100, 'Book texture renderer ready');
return true;
}
markPipelineTiming(name, detail = {}) {
const entry = {
name,
at: performance.now(),
detail
};
this.pipelineTimings.push(entry);
if (this.pipelineTimings.length > 120) this.pipelineTimings.splice(0, this.pipelineTimings.length - 120);
document.documentElement.dataset.webglTexturePipeline = JSON.stringify(this.pipelineTimings);
return entry;
}
async waitForTextureFonts() {
if (!document.fonts) return;
await Promise.all([
this.ensureTextureFontFace('EB Garamond', '/fonts/EBGaramond12-Regular.otf'),
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('24px "EB Garamond 12"'),
document.fonts.load('72px "EB Garamond Initials"')
]);
await document.fonts.ready;
}
async ensureTextureFontFace(family, url) {
if (!window.FontFace) return;
const face = new FontFace(family, `url(${url})`);
const loadedFace = await face.load();
document.fonts.add(loadedFace);
}
createPageCanvases(textureWidth = this.pageFormat?.getTextureWidth?.() || 3072) {
this.metrics = this.pageFormat.getTextureMetrics(textureWidth);
['left', 'right'].forEach((side) => {
const canvas = document.createElement('canvas');
canvas.width = this.metrics.width;
canvas.height = this.metrics.height;
this.canvases[side] = canvas;
this.contexts[side] = canvas.getContext('2d');
});
}
drawSpread(spread = null, sides = null) {
this.currentSpread = spread || { left: [], right: [] };
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
this.markPipelineTiming('drawSpread:start', {
sides: sidesToDraw,
revealBlockIds: this.revealPublishBlockIds ? Array.from(this.revealPublishBlockIds) : []
});
this.revealBounds = { left: null, right: null };
this.revealWords = { left: [], right: [] };
sidesToDraw.forEach((side) => {
if (!this.canvases[side]) return;
this.drawPageBase(side);
this.drawPageLines(side, this.currentSpread?.[side] || []);
});
this.publishSpread(sidesToDraw);
this.markPipelineTiming('drawSpread:end', {
sides: sidesToDraw
});
this.revealBounds = null;
this.revealWords = null;
this.revealPublishBlockIds = null;
}
drawPageBase(side) {
const canvas = this.canvases[side];
const ctx = this.contexts[side];
if (!canvas || !ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#f2ead0';
ctx.fillRect(0, 0, canvas.width, canvas.height);
const shade = ctx.createLinearGradient(0, 0, canvas.width, 0);
if (side === 'left') {
shade.addColorStop(0, 'rgba(255, 255, 255, 0.06)');
shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(70, 48, 28, 0.08)');
} else {
shade.addColorStop(0, 'rgba(70, 48, 28, 0.08)');
shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)');
shade.addColorStop(1, 'rgba(255, 255, 255, 0.06)');
}
ctx.fillStyle = shade;
ctx.fillRect(0, 0, canvas.width, canvas.height);
this.hitMaps[side] = [];
}
drawPageLines(side, lines = []) {
const ctx = this.contexts[side];
if (!ctx || !this.metrics || !Array.isArray(lines)) return;
ctx.save();
ctx.fillStyle = 'rgba(31, 19, 10, 0.86)';
ctx.textBaseline = 'alphabetic';
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
lines.forEach(line => this.drawLine(ctx, line, side));
ctx.restore();
}
drawLine(ctx, lineRecord = {}, side = 'left') {
const metrics = this.metrics;
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;
const ratio = line.isFinal || line.align === 'center' ? 0 : Number(line.ratio || 0);
const naturalWidth = nodes.reduce((sum, node) => {
if (node.type === 'box' || node.type === 'glue') return sum + Number(node.width || 0);
return sum;
}, 0);
const centerOffset = line.align === 'center'
? Math.max(0, (content.width - naturalWidth) / 2)
: Number(line.offset || 0);
let x = content.x + centerOffset;
const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps);
const previousVariantCaps = 'fontVariantCaps' in ctx ? ctx.fontVariantCaps : null;
const previousLetterSpacing = 'letterSpacing' in ctx ? ctx.letterSpacing : null;
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}`;
if (lineRecord.dropCapText) {
ctx.save();
const dropCapFontPx = Math.round(fontPx * 2.68);
const dropCapX = content.x;
const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25);
ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
ctx.textBaseline = 'top';
ctx.fillText(String(lineRecord.dropCapText), dropCapX, dropCapY);
this.recordRevealRect(side, lineRecord, dropCapX, dropCapY, fontPx * 2.9, dropCapFontPx * 0.9, 0);
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.buildLineSegments(ctx, nodes, line, ratio).forEach((segment) => {
this.drawWord(ctx, segment.value, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx);
});
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal';
if ('letterSpacing' in ctx) ctx.letterSpacing = previousLetterSpacing || '0px';
}
getPageContent(side = 'left') {
return this.metrics?.contentBySide?.[side] || this.metrics?.content || {
x: 0,
y: 0,
width: this.metrics?.width || 1,
height: this.metrics?.height || 1
};
}
buildLineSegments(ctx, nodes = [], line = {}, ratio = 0) {
const segments = [];
let x = 0;
let currentSegment = null;
let previousWasGlue = true;
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) {
currentSegment.value += value;
currentSegment.width += width;
} else {
currentSegment = {
value,
x,
width,
wordIndex: segments.length
};
segments.push(currentSegment);
}
x += width;
previousWasGlue = false;
} else if (node.type === 'glue' && node.width !== 0) {
let width = Number(node.width || 0);
if (ratio > 0) width += Number(node.stretch || 0) * ratio;
if (ratio < 0) width += Number(node.shrink || 0) * ratio;
x += width;
previousWasGlue = true;
currentSegment = null;
} else if (node.type === 'penalty' && node.penalty === 100) {
const isLineEndHyphen = Boolean(line.hyphenated && index === nodes.length - 1 && currentSegment);
if (isLineEndHyphen) {
const hyphenWidth = Number(node.width || ctx.measureText('-').width || 0);
currentSegment.value += '-';
currentSegment.width += hyphenWidth;
x += hyphenWidth;
}
previousWasGlue = false;
}
});
return segments;
}
drawWord(ctx, value, x, baseY, lineRecord, localWordIndex, side, fontPx, lineHeightPx) {
ctx.fillText(value, x, baseY);
const width = ctx.measureText(value).width || fontPx;
this.recordRevealRect(side, lineRecord, x, baseY - fontPx, width, lineHeightPx, localWordIndex);
}
recordRevealRect(side, lineRecord, x, y, width, height, localWordIndex = 0) {
if (!this.revealBounds || !this.revealPublishBlockIds) return;
const blockId = String(lineRecord?.blockId ?? '');
if (!blockId || !this.revealPublishBlockIds.has(blockId)) return;
const animation = this.activeAnimations.get(blockId);
if (!animation || animation.completed) return;
const padding = Math.max(2, Number(lineRecord.fontPx || 18) * 0.12);
const nextRect = {
x: Math.max(0, x - padding),
y: Math.max(0, y - padding),
right: Math.min(this.metrics.width, x + width + padding),
bottom: Math.min(this.metrics.height, y + height + padding)
};
const current = this.revealBounds[side];
this.revealBounds[side] = current ? {
x: Math.min(current.x, nextRect.x),
y: Math.min(current.y, nextRect.y),
right: Math.max(current.right, nextRect.right),
bottom: Math.max(current.bottom, nextRect.bottom),
blockIds: current.blockIds.add(blockId)
} : {
...nextRect,
blockIds: new Set([blockId])
};
const globalWordIndex = Math.max(0, Number(lineRecord.blockWordStart || 0) + Number(localWordIndex || 0));
const timing = Array.isArray(animation.wordTimings) ? animation.wordTimings[globalWordIndex] : null;
if (!timing || !this.revealWords?.[side]) return;
this.revealWords[side].push({
blockId,
wordIndex: globalWordIndex,
rect: {
x: nextRect.x / this.metrics.width,
y: nextRect.y / this.metrics.height,
width: Math.max(0.001, (nextRect.right - nextRect.x) / this.metrics.width),
height: Math.max(0.001, (nextRect.bottom - nextRect.y) / this.metrics.height)
},
timing: {
delay: Math.max(0, Number(timing.delay || 0)),
duration: Math.max(1, Number(timing.duration || 1))
}
});
}
startRevealAnimation(detail = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const existing = this.activeAnimations.get(String(blockId));
if (existing && existing.prepared) {
this.startPreparedRevealAnimation(blockId);
return;
}
this.activeAnimations.set(String(blockId), {
blockId,
wordTimings: detail.wordTimings,
startedAt: performance.now(),
totalDuration: Math.max(
Number(detail.totalDuration || 0),
...detail.wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
),
completed: false
});
this.pendingRevealBlockIds.delete(String(blockId));
this.revealPublishBlockIds = new Set([String(blockId)]);
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
detail: {
blockId
}
}));
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, {
blockId,
wordTimings,
startedAt: null,
totalDuration: Math.max(
Number(detail.totalDuration || 0),
...wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
),
completed: false,
prepared: true
});
this.pendingRevealBlockIds.delete(id);
this.revealPublishBlockIds = new Set([id]);
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
this.markPipelineTiming('prepareRevealBlock:end', {
blockId: id,
wordTimingCount: wordTimings.length
});
}
startPreparedRevealAnimation(blockId) {
const id = String(blockId ?? '');
const animation = this.activeAnimations.get(id);
if (!animation) return false;
this.markPipelineTiming('startPreparedRevealAnimation', {
blockId: id,
wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0
});
animation.startedAt = performance.now();
animation.prepared = false;
animation.completed = false;
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
detail: {
blockId: animation.blockId
}
}));
this.requestAnimationFrame();
return true;
}
fastForwardAnimations() {
let changed = false;
const blockIds = [];
this.activeAnimations.forEach((animation) => {
if (!animation.completed) {
animation.completed = true;
this.revealedBlockIds.add(String(animation.blockId ?? ''));
blockIds.push(animation.blockId);
changed = true;
}
});
if (changed) {
this.pendingRevealBlockIds.clear();
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
detail: {
blockIds
}
}));
}
}
stopAnimations() {
this.activeAnimations.clear();
this.pendingRevealBlockIds.clear();
if (this.animationFrameId) {
clearTimeout(this.animationFrameId);
this.animationFrameId = null;
}
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
}
getBlockSides(blockId) {
const id = String(blockId ?? '');
const spread = this.currentSpread || this.pagination?.getCurrentSpread?.() || { left: [], right: [] };
return ['left', 'right'].filter((side) => {
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
return lines.some(line => String(line?.blockId ?? '') === id);
});
}
getAnimatedSides(includeCompleted = false) {
const spread = this.currentSpread || this.pagination?.getCurrentSpread?.() || { left: [], right: [] };
const activeBlockIds = new Set();
this.activeAnimations.forEach((animation, blockId) => {
if (includeCompleted || !animation.completed) activeBlockIds.add(String(blockId));
});
const sides = ['left', 'right'].filter((side) => {
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
return lines.some(line => activeBlockIds.has(String(line?.blockId ?? '')));
});
return sides.length ? sides : ['left', 'right'];
}
markPendingReveal(blockId) {
const id = String(blockId ?? '');
if (!id || this.activeAnimations.has(id) || this.revealedBlockIds.has(id)) return;
this.pendingRevealBlockIds.add(id);
}
requestAnimationFrame() {
if (this.animationFrameId) return;
this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs);
}
tickAnimations(now) {
this.animationFrameId = null;
if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) {
this.requestAnimationFrame();
return;
}
this.lastAnimationFrameAt = now;
let hasActive = false;
const currentNow = performance.now();
this.activeAnimations.forEach((animation) => {
if (animation.completed) return;
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
if (animation.startedAt == null) {
hasActive = true;
return;
}
const lastTiming = animation.wordTimings.at(-1);
const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0);
if (currentNow - animation.startedAt >= total + 50) {
animation.completed = true;
this.revealedBlockIds.add(String(animation.blockId ?? ''));
} else {
hasActive = true;
}
});
if (hasActive) this.requestAnimationFrame();
}
publishSpread(sides = null) {
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const wordCounts = {
left: this.revealWords?.left?.length || 0,
right: this.revealWords?.right?.length || 0
};
const detail = {
metrics: this.metrics,
hitMaps: this.hitMaps
};
if (sidesToPublish.includes('left')) detail.left = this.canvases.left;
if (sidesToPublish.includes('right')) detail.right = this.canvases.right;
const reveal = {};
sidesToPublish.forEach((side) => {
const bounds = this.revealBounds?.[side];
if (!bounds) return;
const blockIds = Array.from(bounds.blockIds || []);
const durationMs = blockIds.reduce((maxDuration, blockId) => {
const animation = this.activeAnimations.get(String(blockId));
return Math.max(maxDuration, Number(animation?.totalDuration || 0));
}, 0);
if (durationMs <= 0) return;
reveal[side] = {
blockIds,
durationMs,
wordRects: (this.revealWords?.[side] || []).map(word => ({
blockId: word.blockId,
wordIndex: word.wordIndex,
rect: word.rect,
timing: word.timing
})),
bounds: {
x: bounds.x / this.metrics.width,
y: bounds.y / this.metrics.height,
width: Math.max(0.001, (bounds.right - bounds.x) / this.metrics.width),
height: Math.max(0.001, (bounds.bottom - bounds.y) / this.metrics.height)
}
};
});
if (Object.keys(reveal).length) detail.reveal = reveal;
this.markPipelineTiming('publishSpread', {
sides: sidesToPublish,
hasReveal: Object.keys(reveal).length > 0,
wordCounts
});
document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', {
detail
}));
}
getPageCanvas(side) {
return this.canvases[side] || null;
}
getHitMap(side) {
return this.hitMaps[side] || [];
}
handlePageCountChanged(event) {
this.pageFormat?.setPageCount?.(event.detail?.pageCount);
this.createPageCanvases();
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
}
}
const bookTextureRenderer = new BookTextureRendererModule();
export { bookTextureRenderer as BookTextureRenderer };
if (window.moduleRegistry) {
window.moduleRegistry.register(bookTextureRenderer);
}
window.BookTextureRenderer = bookTextureRenderer;