8bb18fa201
Establish book-playback-timeline as the sole playback owner driving the scene through formal webgl-book:* events (not the BookLabDebug surface), with a single reveal clock in the scene render loop and webgl-page-cache as the only texture cache. Remove the legacy dual playback path and the ownsPageFlipCommit gating. Fixes: - Flip page detached/folded at the spine: restore the raw page-cap line for flip geometry (matches the prototype/pre-regression), removing normalizeFlipLineToVisiblePage which moved the pivot off the spine arc. - Flip textures: distance-based UVs (no horizontal compression), direction-aware face material (source on the camera-facing side), source meta derived from the visible spread (manual flips), prewarm shape fix. - Reveal: flash removed on the static page and the flip back surface; spanning blocks rebuild the reveal plan at activate and continue the reveal on the next spread after the fill flip. - Cache staleness is contentVersion-primary; nav clamps to spreadCount. Docs updated to describe the intended single-owner architecture. Regression checks updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
697 lines
28 KiB
JavaScript
697 lines
28 KiB
JavaScript
/**
|
|
* WebGL Page Cache Module
|
|
* Persists fully typeset book page canvases in IndexedDB for fast VRAM prewarm.
|
|
*/
|
|
import { BaseModule } from './base-module.js';
|
|
|
|
class WebGLPageCacheModule extends BaseModule {
|
|
constructor() {
|
|
super('webgl-page-cache', 'WebGL Page Cache');
|
|
|
|
this.dependencies = [];
|
|
this.dbName = 'webglPageTextureCacheDB';
|
|
this.dbVersion = 1;
|
|
this.storeName = 'webglPageTextureStore';
|
|
this.db = null;
|
|
this.cacheStatus = 'uninitialized';
|
|
this.currentCacheSize = 0;
|
|
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',
|
|
'rememberCanvas',
|
|
'tx'
|
|
]);
|
|
}
|
|
|
|
async initialize() {
|
|
this.reportProgress(20, 'Opening WebGL page texture cache');
|
|
try {
|
|
await this.openDB();
|
|
this.reportProgress(70, 'Measuring WebGL page texture cache');
|
|
this.currentCacheSize = await this.calculateTotalCacheSize();
|
|
this.cacheStatus = 'ready';
|
|
this.reportProgress(100, 'WebGL page texture cache ready');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('WebGLPageCache: IndexedDB unavailable; persistent page caching is in a problem state', error);
|
|
this.cacheStatus = 'error';
|
|
this.reportProgress(100, 'WebGL page texture cache unavailable');
|
|
return true;
|
|
}
|
|
}
|
|
|
|
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) => {
|
|
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
request.onerror = () => reject(request.error);
|
|
request.onblocked = () => reject(new Error('WebGL page texture cache upgrade blocked'));
|
|
request.onsuccess = () => {
|
|
this.db = request.result;
|
|
this.db.onversionchange = () => {
|
|
this.db?.close?.();
|
|
this.db = null;
|
|
this.cacheStatus = 'uninitialized';
|
|
};
|
|
resolve(this.db);
|
|
};
|
|
request.onupgradeneeded = () => {
|
|
const db = request.result;
|
|
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
const pageStore = db.createObjectStore(this.storeName, { keyPath: 'key' });
|
|
pageStore.createIndex('lastAccessed', 'lastAccessed', { unique: false });
|
|
pageStore.createIndex('size', 'size', { unique: false });
|
|
pageStore.createIndex('pageIndex', 'pageIndex', { unique: false });
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
tx(mode = 'readonly') {
|
|
return this.db.transaction([this.storeName], mode).objectStore(this.storeName);
|
|
}
|
|
|
|
makePageKey({ pageIndex, width, height, kind = 'content', section = 'body', cacheKey = window.MODULE_CACHE_BUSTER || 'dev' } = {}) {
|
|
const safePage = Math.max(0, Math.round(Number(pageIndex || 0)));
|
|
const safeWidth = Math.max(1, Math.round(Number(width || 0)));
|
|
const safeHeight = Math.max(1, Math.round(Number(height || 0)));
|
|
const safeKind = String(kind || 'content').replace(/[^a-z0-9_-]/gi, '');
|
|
const safeSection = String(section || 'body').replace(/[^a-z0-9_-]/gi, '');
|
|
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) 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);
|
|
if (!Number.isFinite(pageIndex) || pageIndex < 0) return false;
|
|
const key = this.makePageKey({
|
|
pageIndex,
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
kind: pageMeta.kind,
|
|
section: pageMeta.section,
|
|
cacheKey: pageMeta.cacheKey
|
|
});
|
|
try {
|
|
const blob = await this.canvasToBlob(canvas);
|
|
if (!blob) return false;
|
|
const oldEntry = await new Promise((resolve, reject) => {
|
|
const request = this.tx('readonly').get(key);
|
|
request.onsuccess = () => resolve(request.result || null);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
if (this.isOlderPageEntry(pageMeta, oldEntry)) return true;
|
|
await this.manageCacheSize(blob.size);
|
|
await new Promise((resolve, reject) => {
|
|
const request = this.tx('readwrite').put({
|
|
key,
|
|
pageIndex,
|
|
width: canvas.width,
|
|
height: canvas.height,
|
|
contentVersion: Math.max(0, Number(pageMeta.contentVersion || 0)),
|
|
completenessScore: Math.max(0, Number(pageMeta.completenessScore || 0)),
|
|
kind: pageMeta.kind || 'content',
|
|
section: pageMeta.section || 'body',
|
|
maxBlockId: Math.max(0, Number(pageMeta.maxBlockId || 0)),
|
|
lineCount: Math.max(0, Number(pageMeta.lineCount || 0)),
|
|
blob,
|
|
size: blob.size,
|
|
lastAccessed: Date.now()
|
|
});
|
|
request.onsuccess = () => {
|
|
this.currentCacheSize += blob.size - Number(oldEntry?.size || 0);
|
|
this.rememberCanvas(key, canvas);
|
|
resolve();
|
|
};
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
console.warn('WebGLPageCache: Failed to cache page canvas', { pageIndex, error });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async getPageCanvas(pageMeta = {}) {
|
|
if (!this.db || this.cacheStatus !== 'ready') return null;
|
|
const key = this.makePageKey(pageMeta);
|
|
const cachedCanvas = this.memoryCanvasCache.get(key);
|
|
if (cachedCanvas) {
|
|
this.memoryCanvasCache.delete(key);
|
|
this.memoryCanvasCache.set(key, cachedCanvas);
|
|
return cachedCanvas;
|
|
}
|
|
try {
|
|
const entry = await new Promise((resolve, reject) => {
|
|
const store = this.tx('readwrite');
|
|
const request = store.get(key);
|
|
request.onsuccess = () => {
|
|
const result = request.result || null;
|
|
if (!result) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
result.lastAccessed = Date.now();
|
|
store.put(result);
|
|
resolve(result);
|
|
};
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
if (!entry?.blob) return null;
|
|
const canvas = await this.blobToCanvas(entry.blob, entry.width, entry.height);
|
|
if (canvas) canvas.__webglPageCacheMeta = {
|
|
pageIndex: entry.pageIndex,
|
|
kind: entry.kind || pageMeta.kind || 'content',
|
|
section: entry.section || pageMeta.section || 'body',
|
|
contentVersion: entry.contentVersion,
|
|
completenessScore: entry.completenessScore,
|
|
maxBlockId: entry.maxBlockId,
|
|
lineCount: entry.lineCount
|
|
};
|
|
if (canvas) this.rememberCanvas(key, canvas);
|
|
return canvas;
|
|
} catch (error) {
|
|
console.warn('WebGLPageCache: Failed to read cached page canvas', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// contentVersion is the monotonic authority: a higher version is always newer and
|
|
// wins, even when the re-typeset page legitimately has fewer lines (lower
|
|
// completeness). completenessScore only breaks ties when versions are equal/absent.
|
|
isOlderPageEntry(pageMeta = {}, oldEntry = null) {
|
|
if (!oldEntry) return false;
|
|
const incomingVersion = Math.max(0, Number(pageMeta.contentVersion || 0));
|
|
const existingVersion = Math.max(0, Number(oldEntry.contentVersion || 0));
|
|
if (incomingVersion !== existingVersion) return incomingVersion < existingVersion;
|
|
const incomingCompleteness = Math.max(0, Number(pageMeta.completenessScore || 0));
|
|
const existingCompleteness = Math.max(0, Number(oldEntry.completenessScore || 0));
|
|
return incomingCompleteness < existingCompleteness;
|
|
}
|
|
|
|
isOlderPageMeta(incoming = {}, existing = null) {
|
|
if (!existing) return false;
|
|
const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0));
|
|
const existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
|
|
if (incomingVersion !== existingVersion) return incomingVersion < existingVersion;
|
|
const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0));
|
|
const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0));
|
|
return incomingCompleteness < existingCompleteness;
|
|
}
|
|
|
|
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') {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
canvas.toBlob(resolve, 'image/png');
|
|
});
|
|
}
|
|
|
|
async blobToCanvas(blob, width, height) {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = Math.max(1, Math.round(Number(width || 1)));
|
|
canvas.height = Math.max(1, Math.round(Number(height || 1)));
|
|
const context = canvas.getContext('2d');
|
|
if (!context) return null;
|
|
const bitmap = await createImageBitmap(blob);
|
|
context.drawImage(bitmap, 0, 0);
|
|
bitmap.close?.();
|
|
return canvas;
|
|
}
|
|
|
|
rememberCanvas(key, canvas) {
|
|
this.memoryCanvasCache.set(key, canvas);
|
|
while (this.memoryCanvasCache.size > this.maxMemoryCanvasCount) {
|
|
const oldestKey = this.memoryCanvasCache.keys().next().value;
|
|
this.memoryCanvasCache.delete(oldestKey);
|
|
}
|
|
}
|
|
|
|
async manageCacheSize(sizeToAdd = 0) {
|
|
if (!this.db || this.cacheStatus !== 'ready') return;
|
|
if (this.currentCacheSize + sizeToAdd <= this.maxCacheSizeBytes) return;
|
|
const entries = await new Promise((resolve, reject) => {
|
|
const results = [];
|
|
const request = this.tx('readonly').index('lastAccessed').openCursor();
|
|
request.onsuccess = () => {
|
|
const cursor = request.result;
|
|
if (!cursor) {
|
|
resolve(results);
|
|
return;
|
|
}
|
|
results.push({
|
|
key: cursor.value.key,
|
|
size: Number(cursor.value.size || 0)
|
|
});
|
|
cursor.continue();
|
|
};
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
for (const entry of entries) {
|
|
if (this.currentCacheSize + sizeToAdd <= this.maxCacheSizeBytes) break;
|
|
await this.deleteEntry(entry.key);
|
|
this.currentCacheSize = Math.max(0, this.currentCacheSize - entry.size);
|
|
}
|
|
}
|
|
|
|
async calculateTotalCacheSize() {
|
|
if (!this.db) return 0;
|
|
return new Promise((resolve, reject) => {
|
|
let total = 0;
|
|
const request = this.tx('readonly').openCursor();
|
|
request.onsuccess = () => {
|
|
const cursor = request.result;
|
|
if (!cursor) {
|
|
resolve(total);
|
|
return;
|
|
}
|
|
total += Number(cursor.value.size || 0);
|
|
cursor.continue();
|
|
};
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
deleteEntry(key) {
|
|
return new Promise((resolve, reject) => {
|
|
const request = this.tx('readwrite').delete(key);
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
}
|
|
|
|
const webglPageCache = new WebGLPageCacheModule();
|
|
|
|
export { webglPageCache as WebGLPageCache };
|
|
|
|
if (window.moduleRegistry) {
|
|
window.moduleRegistry.register(webglPageCache);
|
|
}
|
|
|
|
window.WebGLPageCache = webglPageCache;
|