288 lines
11 KiB
JavaScript
288 lines
11 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.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;
|