// Caching for the gallery, keyed on the source that produced it. // // Building the gallery takes three minutes of GPU work, which is fine once and // intolerable every time the page is opened. But a cache that has to be cleared // by hand is worse than no cache: it will eventually show you last week's // pixels while you are trying to judge this morning's change, and you will trust // it because it looks like a render. // // So the key is a fingerprint of the code itself. Edit any file under src/ and // the fingerprint changes, the old entry stops matching, and the gallery // rebuilds without being asked. Nothing to remember and nothing to invalidate. // // Thumbnails are stored as WebP blobs in IndexedDB rather than raw pixels in // localStorage: sixty-five scenes at six frames of 256x144 is about 57MB raw, // which localStorage would refuse and IndexedDB would rather not hold either. // Compressed it is a few megabytes. const DB_NAME = 'flow-state-gallery'; const STORE = 'builds'; const DB_VERSION = 1; /** * A fingerprint of every source file that can change what the gallery renders. * * `import.meta.glob` is resolved by Vite at build time, so this covers the * shaders, the identity, the look generator, the engine and the descriptors * without naming any of them — which matters, because the file that invalidates * a render is exactly the one nobody remembers to list. */ const SOURCES = import.meta.glob('/src/**/*.js', { query: '?raw', import: 'default', eager: true }); export function sourceFingerprint() { // Sorted, so the hash does not depend on glob iteration order. const paths = Object.keys(SOURCES).sort(); let h = 0x811c9dc5 >>> 0; const mix = (str) => { for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } }; for (const path of paths) { mix(path); mix(String(SOURCES[path])); } return `${paths.length}-${h.toString(16).padStart(8, '0')}`; } function openDb() { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function tx(db, mode, fn) { return new Promise((resolve, reject) => { const t = db.transaction(STORE, mode); const request = fn(t.objectStore(STORE)); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } /** The cached build for this exact source, or null. */ export async function loadGallery(fingerprint) { try { const db = await openDb(); const record = await tx(db, 'readonly', (store) => store.get(fingerprint)); db.close(); return record || null; } catch { // A debug page is not worth failing over a storage quota or a private // window that refuses IndexedDB. Fall through to rebuilding. return null; } } /** * Which page a key belongs to. * * The store holds more than one page's builds now — the gallery keys on the * bare source fingerprint, the filmstrip prefixes its sampling parameters — and * "drop every other build" has to mean every other build OF THIS PAGE. Without * the namespace the two evict each other on every save, and each page rebuilds * for three minutes every time you visit the other one. */ const namespaceOf = (key) => { const k = String(key); return k.includes(':') ? k.slice(0, k.indexOf(':')) : 'gallery'; }; /** * Store a build, and drop every other one from the same page. * * Only the current source is ever wanted, and keeping stale builds around is how * a cache quietly grows to hundreds of megabytes of images nobody will look at. */ export async function saveGallery(fingerprint, payload) { try { const db = await openDb(); const keys = await tx(db, 'readonly', (store) => store.getAllKeys()); const mine = namespaceOf(fingerprint); await tx(db, 'readwrite', (store) => { for (const key of keys) { if (key !== fingerprint && namespaceOf(key) === mine) store.delete(key); } return store.put(payload, fingerprint); }); db.close(); return true; } catch { return false; } } export async function clearGallery() { try { const db = await openDb(); await tx(db, 'readwrite', (store) => store.clear()); db.close(); } catch { /* nothing to clear */ } } /** Raw RGBA (bottom-up, as WebGL hands it over) to a compressed blob. */ export function pixelsToBlob(pixels, width, height) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); const image = ctx.createImageData(width, height); const row = width * 4; for (let y = 0; y < height; y++) { const src = (height - 1 - y) * row; image.data.set(pixels.subarray(src, src + row), y * row); } ctx.putImageData(image, 0, 0); return new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', 0.85)); } /** Draw a stored blob into a canvas, sized to it. */ export async function blobToCanvas(canvas, blob) { const bitmap = await createImageBitmap(blob); canvas.width = bitmap.width; canvas.height = bitmap.height; canvas.getContext('2d').drawImage(bitmap, 0, 0); bitmap.close(); }