/** * 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;