Add WebGL page cache and runtime checks
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user