Cache the gallery against a fingerprint of the source that built it

Three minutes of GPU work is fine once and intolerable on every page load, so
the gallery now builds once and comes back from IndexedDB after that.

The key is the part worth getting right. A cache you clear by hand is worse than
no cache: it will eventually show last week's pixels while you are judging this
morning's change, and it will look like a render, so you will believe it. Here
the key is a fingerprint of every file under src/, collected through
import.meta.glob so it covers the shaders, the identity, the look generator, the
engine and the descriptors without naming any of them — the file that
invalidates a render is exactly the one nobody remembers to list. Edit anything
and the entry stops matching and the page rebuilds without being asked.

Thumbnails are WebP blobs rather than raw pixels: sixty-five scenes at six
frames of 256x144 is 57MB raw, and measured, the compressed form is 4.1MB for
the whole library. Saving a build evicts every other one, so the store cannot
grow without bound.

The spinner is a CSS animation on purpose. Building blocks the main thread in
bursts and a JS-driven spinner would freeze mid-turn, which reads as a hang
exactly when the page most needs to look alive.

Verified rather than assumed, six behaviours: the fingerprint is stable across
calls; a build round-trips through storage and decodes to a real image (51%
non-black); a different fingerprint misses; saving a new key evicts the old; and
appending one comment to one scene file moved the fingerprint from 125-f842b05e
to 125-c056d7b9, which is the invalidation working end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-18 07:19:22 +02:00
co-authored by Claude Opus 5
parent c6d96c9d40
commit e90951c277
2 changed files with 253 additions and 34 deletions
+136
View File
@@ -0,0 +1,136 @@
// 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;
}
}
/**
* Store a build, and drop every other one.
*
* 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());
await tx(db, 'readwrite', (store) => {
for (const key of keys) if (key !== fingerprint) 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();
}