Add WebGL page cache and runtime checks

This commit is contained in:
2026-06-08 14:39:42 +02:00
parent 119cefd4bd
commit a73dc5725f
11 changed files with 891 additions and 32 deletions
+264
View File
@@ -0,0 +1,264 @@
/**
* 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 = 180 * 1024 * 1024;
this.memoryCanvasCache = new Map();
this.maxMemoryCanvasCount = 12;
this.bindMethods([
'initialize',
'openDB',
'cachePageCanvas',
'getPageCanvas',
'makePageKey',
'canvasToBlob',
'blobToCanvas',
'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.warn('WebGLPageCache: IndexedDB unavailable, continuing without persistent page cache', 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
});
if (this.memoryCanvasCache.has(key)) return true;
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);
});
await this.manageCacheSize(blob.size);
await new Promise((resolve, reject) => {
const request = this.tx('readwrite').put({
key,
pageIndex,
width: canvas.width,
height: canvas.height,
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) this.rememberCanvas(key, canvas);
return canvas;
} catch (error) {
console.warn('WebGLPageCache: Failed to read cached page canvas', error);
return null;
}
}
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;