Checkpoint WebGL book playback refactor state

This commit is contained in:
2026-06-10 01:07:22 +02:00
parent 171cafeb65
commit b41340151d
8 changed files with 824 additions and 370 deletions
+400
View File
@@ -18,16 +18,63 @@ class WebGLPageCacheModule extends BaseModule {
this.maxCacheSizeBytes = 5 * 1024 * 1024 * 1024;
this.memoryCanvasCache = new Map();
this.maxMemoryCanvasCount = 256;
this.textureRuntime = null;
this.residentTextures = new Map();
this.maxResidentTextureCount = 192;
this.preparedTextures = {
left: new Map(),
right: new Map()
};
this.preparedRevealPlans = new Map();
this.visibleTextures = {
left: null,
right: null
};
this.visibleFallbackCanvases = {
left: null,
right: null
};
this.maxPreparedTextureCount = 128;
this.blankTexture = null;
this.problemLog = [];
this.pendingPageWrites = new Map();
this.bindMethods([
'initialize',
'openDB',
'configureTextureRuntime',
'cachePageCanvas',
'getPageCanvas',
'putPageCanvas',
'storePageCanvas',
'preparePageTexture',
'takePreparedPageTexture',
'rememberPreparedRevealPlan',
'takePreparedRevealPlan',
'hasPreparedRevealPlan',
'registerVisibleTexture',
'bindVisibleTextureSource',
'getVisibleTexture',
'rememberResidentTexture',
'getResidentTexture',
'getResidentTextureForMeta',
'ensurePageTexture',
'prewarmPageTexture',
'prewarmSpreadTextures',
'prewarmNavigationWindow',
'getBlankTexture',
'createTextureFromCanvas',
'disposeTextureRecord',
'makePageKey',
'getPageWriteKey',
'makeResidentKey',
'cloneCanvas',
'canvasToBlob',
'blobToCanvas',
'isOlderPageEntry',
'isOlderPageMeta',
'recordProblem',
'getRuntimeState',
'manageCacheSize',
'calculateTotalCacheSize',
'deleteEntry',
@@ -53,6 +100,25 @@ class WebGLPageCacheModule extends BaseModule {
}
}
configureTextureRuntime({
THREE = null,
renderer = null,
configureTexture = null,
createBlankCanvas = null,
maxResidentTextureCount = this.maxResidentTextureCount,
maxPreparedTextureCount = this.maxPreparedTextureCount
} = {}) {
this.textureRuntime = {
THREE,
renderer,
configureTexture,
createBlankCanvas
};
this.maxResidentTextureCount = Math.max(1, Math.round(Number(maxResidentTextureCount || this.maxResidentTextureCount)));
this.maxPreparedTextureCount = Math.max(1, Math.round(Number(maxPreparedTextureCount || this.maxPreparedTextureCount)));
return this.getRuntimeState();
}
openDB() {
if (this.db) return Promise.resolve(this.db);
return new Promise((resolve, reject) => {
@@ -93,6 +159,303 @@ class WebGLPageCacheModule extends BaseModule {
return `${cacheKey}:page:${safePage}:${safeKind}:${safeSection}:${safeWidth}x${safeHeight}`;
}
getPageWriteKey(pageMeta = {}, canvas = null) {
return this.makePageKey({
...pageMeta,
width: canvas?.width ?? pageMeta.width,
height: canvas?.height ?? pageMeta.height
});
}
makeResidentKey(pageMetaOrIndex = {}) {
const pageMeta = typeof pageMetaOrIndex === 'number'
? { pageIndex: pageMetaOrIndex }
: pageMetaOrIndex || {};
const pageIndex = Math.max(0, Math.round(Number(pageMeta.pageIndex || 0)));
const kind = String(pageMeta.kind || 'content').replace(/[^a-z0-9_-]/gi, '');
const section = String(pageMeta.section || 'body').replace(/[^a-z0-9_-]/gi, '');
return `${pageIndex}:${kind}:${section}`;
}
createTextureFromCanvas(canvas = null) {
const runtime = this.textureRuntime || {};
if (!canvas || !runtime.THREE?.CanvasTexture) return null;
const texture = new runtime.THREE.CanvasTexture(canvas);
if (typeof runtime.configureTexture === 'function') runtime.configureTexture(texture);
texture.needsUpdate = true;
if (typeof runtime.renderer?.initTexture === 'function') {
runtime.renderer.initTexture(texture);
texture.needsUpdate = false;
}
return texture;
}
getBlankTexture() {
if (this.blankTexture) return this.blankTexture;
const canvas = this.textureRuntime?.createBlankCanvas?.();
this.blankTexture = this.createTextureFromCanvas(canvas);
return this.blankTexture;
}
async putPageCanvas(pageMeta = {}, canvas = null, options = {}) {
const texture = options.resident === false
? null
: this.rememberResidentTexture(pageMeta, this.createTextureFromCanvas(canvas), canvas, true);
if (options.persist !== false) {
const stored = await this.cachePageCanvas(pageMeta, canvas);
return texture || stored;
}
return texture;
}
storePageCanvas(pageMeta = {}, canvas = null, options = {}) {
if (!canvas || !pageMeta || !Number.isFinite(Number(pageMeta.pageIndex))) return Promise.resolve(false);
const frozenCanvas = this.cloneCanvas(canvas);
const key = this.getPageWriteKey(pageMeta, frozenCanvas);
const pending = this.pendingPageWrites.get(key);
if (pending && this.isOlderPageMeta(pageMeta, pending.pageMeta)) return pending.promise;
const previousWrite = pending?.promise || Promise.resolve();
const write = previousWrite.catch(() => false)
.then(() => this.putPageCanvas(pageMeta, frozenCanvas, {
persist: options.persist !== false,
resident: options.resident !== false
}))
.then((stored) => {
if (!stored) {
this.recordProblem({
type: 'db-write-failed',
pageIndex: pageMeta?.pageIndex ?? null,
key
});
}
return stored;
})
.catch((error) => {
this.recordProblem({
type: 'db-write-error',
pageIndex: pageMeta?.pageIndex ?? null,
key,
message: error?.message || String(error)
});
return false;
})
.finally(() => {
if (this.pendingPageWrites.get(key)?.promise === write) {
this.pendingPageWrites.delete(key);
}
});
this.pendingPageWrites.set(key, {
promise: write,
pageMeta: { ...(pageMeta || {}) }
});
return write;
}
cloneCanvas(canvas = null) {
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;
}
preparePageTexture(side = 'left', key = '', pageMeta = {}, canvas = null, revealDetail = {}) {
if (!canvas || !key) return null;
const normalizedSide = side === 'right' ? 'right' : 'left';
const texture = this.createTextureFromCanvas(canvas);
const baseTexture = revealDetail?.baseCanvas ? this.createTextureFromCanvas(revealDetail.baseCanvas) : null;
this.preparedTextures[normalizedSide].set(key, {
texture,
baseTexture,
sourceCanvas: canvas,
revealDetail,
pageMeta: { ...(pageMeta || {}) },
uploadedAt: performance.now()
});
this.rememberResidentTexture(pageMeta, texture, canvas, false);
while (this.preparedTextures[normalizedSide].size > this.maxPreparedTextureCount) {
const oldestKey = this.preparedTextures[normalizedSide].keys().next().value;
const oldest = this.preparedTextures[normalizedSide].get(oldestKey);
this.disposeTextureRecord(oldest);
this.preparedTextures[normalizedSide].delete(oldestKey);
}
return texture;
}
takePreparedPageTexture(side = 'left', key = '') {
const normalizedSide = side === 'right' ? 'right' : 'left';
const prepared = this.preparedTextures[normalizedSide].get(key);
if (!prepared) return null;
this.preparedTextures[normalizedSide].delete(key);
return prepared;
}
rememberPreparedRevealPlan(blockId = '', prepared = null) {
const id = String(blockId ?? '');
if (!id || !prepared) return null;
this.preparedRevealPlans.set(id, {
...prepared,
storedAt: performance.now()
});
while (this.preparedRevealPlans.size > this.maxPreparedTextureCount) {
const oldestKey = this.preparedRevealPlans.keys().next().value;
this.preparedRevealPlans.delete(oldestKey);
}
return prepared;
}
takePreparedRevealPlan(blockId = '') {
const id = String(blockId ?? '');
const prepared = this.preparedRevealPlans.get(id);
if (!prepared) return null;
this.preparedRevealPlans.delete(id);
return prepared;
}
hasPreparedRevealPlan(blockId = '') {
const id = String(blockId ?? '');
return Boolean(id && this.preparedRevealPlans.has(id));
}
registerVisibleTexture(side = 'left', texture = null, fallbackCanvas = null) {
const normalizedSide = side === 'right' ? 'right' : 'left';
this.visibleTextures[normalizedSide] = texture || null;
this.visibleFallbackCanvases[normalizedSide] = fallbackCanvas || null;
return texture || null;
}
bindVisibleTextureSource(side = 'left', sourceCanvas = null) {
const normalizedSide = side === 'right' ? 'right' : 'left';
const texture = this.visibleTextures[normalizedSide];
const canvas = sourceCanvas || this.visibleFallbackCanvases[normalizedSide] || null;
if (!texture || !canvas) return null;
texture.image = canvas;
texture.needsUpdate = true;
return texture;
}
getVisibleTexture(side = 'left') {
return this.visibleTextures[side === 'right' ? 'right' : 'left'] || null;
}
rememberResidentTexture(pageMeta = {}, texture = null, sourceCanvas = null, ownsTexture = true) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!texture || !Number.isFinite(pageIndex) || pageIndex < 0) return null;
const key = this.makeResidentKey(pageMeta);
const existing = this.residentTextures.get(key);
if (this.isOlderPageMeta(pageMeta, existing?.pageMeta)) return existing?.texture || null;
if (existing?.ownsTexture && existing.texture && existing.texture !== texture) existing.texture.dispose?.();
this.residentTextures.set(key, {
texture,
sourceCanvas: sourceCanvas || existing?.sourceCanvas || null,
lastUsedAt: performance.now(),
ownsTexture,
pageMeta: {
...(existing?.pageMeta || {}),
...(pageMeta || {})
}
});
while (this.residentTextures.size > this.maxResidentTextureCount) {
const oldestKey = this.residentTextures.keys().next().value;
const oldest = this.residentTextures.get(oldestKey);
if (oldest?.ownsTexture) oldest.texture?.dispose?.();
this.residentTextures.delete(oldestKey);
}
return texture;
}
getResidentTexture(pageMetaOrIndex = {}) {
const key = this.makeResidentKey(pageMetaOrIndex);
const resident = this.residentTextures.get(key);
if (!resident) return null;
resident.lastUsedAt = performance.now();
this.residentTextures.delete(key);
this.residentTextures.set(key, resident);
return resident.texture || null;
}
getResidentTextureForMeta(pageMeta = {}) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!Number.isFinite(pageIndex)) return null;
const key = this.makeResidentKey(pageMeta);
const resident = this.residentTextures.get(key);
if (!resident || this.isOlderPageMeta(pageMeta, resident.pageMeta)) return null;
return this.getResidentTexture(pageMeta);
}
async ensurePageTexture(pageMeta = {}, options = {}) {
if (pageMeta?.kind === 'blank') {
return this.rememberResidentTexture(pageMeta, this.getBlankTexture(), null, false);
}
const resident = this.getResidentTextureForMeta(pageMeta);
if (resident) return resident;
if (options.canvas) return this.putPageCanvas(pageMeta, options.canvas, {
persist: options.persist !== false,
resident: true
});
const sourceCanvas = await this.getPageCanvas(pageMeta);
if (!sourceCanvas) {
if (options.recordMiss !== false) {
this.recordProblem({
type: 'db-cache-miss',
pageIndex: pageMeta?.pageIndex ?? null,
width: pageMeta?.width ?? null,
height: pageMeta?.height ?? null
});
}
return null;
}
const cachedMeta = sourceCanvas.__webglPageCacheMeta || pageMeta;
return this.rememberResidentTexture(cachedMeta, this.createTextureFromCanvas(sourceCanvas), sourceCanvas, true);
}
async prewarmPageTexture(pageMeta = {}, options = {}) {
return this.ensurePageTexture(pageMeta, {
recordMiss: options.recordMiss !== false && pageMeta?.kind !== 'blank'
});
}
async prewarmSpreadTextures(spreadIndex = 0, getPageMetaForIndex = null, options = {}) {
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
const leftIndex = spread * 2;
const rightIndex = leftIndex + 1;
const leftMeta = getPageMetaForIndex?.(leftIndex) || { pageIndex: leftIndex, kind: 'blank', section: leftIndex < 3 ? 'frontmatter' : 'body' };
const rightMeta = getPageMetaForIndex?.(rightIndex) || { pageIndex: rightIndex, kind: 'blank', section: rightIndex < 3 ? 'frontmatter' : 'body' };
const [left, right] = await Promise.all([
this.prewarmPageTexture(leftMeta, options),
this.prewarmPageTexture(rightMeta, options)
]);
return { spreadIndex: spread, left, right };
}
async prewarmNavigationWindow({
currentSpread = 0,
targetSpread = null,
endSpread = 0,
getPageMetaForIndex = null,
recordMiss = true
} = {}) {
const current = Math.max(0, Math.round(Number(currentSpread || 0)));
const end = Math.max(0, Math.round(Number(endSpread || 0)));
const spreads = new Set([0, end, current, Math.max(0, current - 1), current + 1]);
const explicitTarget = Number.isFinite(Number(targetSpread)) ? Math.max(0, Math.round(Number(targetSpread))) : null;
if (explicitTarget !== null) spreads.add(explicitTarget);
const upperBound = Math.max(end, current + 1, explicitTarget ?? 0);
const bounded = Array.from(spreads).filter(value => value >= 0 && value <= upperBound);
const results = await Promise.all(bounded.map(spread => this.prewarmSpreadTextures(spread, getPageMetaForIndex, { recordMiss })));
return results.reduce((map, spread) => {
map[spread.spreadIndex] = spread;
return map;
}, {});
}
disposeTextureRecord(record = null) {
record?.texture?.dispose?.();
record?.baseTexture?.dispose?.();
}
async cachePageCanvas(pageMeta = {}, canvas = null) {
if (!canvas || !this.db || this.cacheStatus !== 'ready') return false;
const pageIndex = Number(pageMeta.pageIndex);
@@ -200,6 +563,43 @@ class WebGLPageCacheModule extends BaseModule {
return incomingVersion > 0 && existingVersion > incomingVersion;
}
isOlderPageMeta(incoming = {}, existing = null) {
if (!existing) return false;
const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0));
if (incomingCompleteness < existingCompleteness) return true;
if (incomingCompleteness > existingCompleteness) return false;
const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0));
const existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
return incomingVersion > 0 && existingVersion > incomingVersion;
}
recordProblem(detail = {}) {
const entry = {
...detail,
at: performance.now()
};
this.problemLog.push(entry);
if (this.problemLog.length > 80) this.problemLog.splice(0, this.problemLog.length - 80);
document.documentElement.dataset.webglPageCacheProblems = JSON.stringify(this.problemLog);
console.warn('WebGL page texture store problem', entry);
return entry;
}
getRuntimeState() {
return {
cacheStatus: this.cacheStatus,
residentTextureCount: this.residentTextures.size,
maxResidentTextureCount: this.maxResidentTextureCount,
preparedTextureCount: this.preparedTextures.left.size + this.preparedTextures.right.size,
preparedRevealPlanCount: this.preparedRevealPlans.size,
pendingPageWriteCount: this.pendingPageWrites.size,
problemCount: this.problemLog.length,
hasRuntime: Boolean(this.textureRuntime?.THREE && this.textureRuntime?.renderer),
hasBlankTexture: Boolean(this.blankTexture)
};
}
canvasToBlob(canvas) {
return new Promise((resolve) => {
if (typeof canvas.toBlob !== 'function') {