/** * 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.bindMethods([ 'initialize', 'openDB', 'cachePageCanvas', 'getPageCanvas', 'makePageKey', 'canvasToBlob', 'blobToCanvas', 'isOlderPageEntry', '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; } } 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, 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))); return `${cacheKey}:page:${safePage}:${safeWidth}x${safeHeight}`; } 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, 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)), 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, 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; } } isOlderPageEntry(pageMeta = {}, oldEntry = null) { if (!oldEntry) return false; const incomingCompleteness = Math.max(0, Number(pageMeta.completenessScore || 0)); const existingCompleteness = Math.max(0, Number(oldEntry.completenessScore || 0)); if (incomingCompleteness < existingCompleteness) return true; if (incomingCompleteness > existingCompleteness) return false; const incomingVersion = Math.max(0, Number(pageMeta.contentVersion || 0)); const existingVersion = Math.max(0, Number(oldEntry.contentVersion || 0)); return incomingVersion > 0 && existingVersion > incomingVersion; } 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;