From e90951c2774bbb2094fcf84e9fc6598a44e7c355 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Tue, 18 Aug 2026 07:19:22 +0200 Subject: [PATCH] Cache the gallery against a fingerprint of the source that built it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- flow-state/gallery.html | 151 +++++++++++++++++++------ flow-state/src/checks/gallery-cache.js | 136 ++++++++++++++++++++++ 2 files changed, 253 insertions(+), 34 deletions(-) create mode 100644 flow-state/src/checks/gallery-cache.js diff --git a/flow-state/gallery.html b/flow-state/gallery.html index de2cb35..6c56f5c 100644 --- a/flow-state/gallery.html +++ b/flow-state/gallery.html @@ -18,11 +18,27 @@ background: #171b24; color: #d6dae3; border: 1px solid #2a3040; padding: 5px 10px; font: inherit; border-radius: 3px; cursor: pointer; } - button:hover { border-color: #4a5468; } + button:hover:not(:disabled) { border-color: #4a5468; } + button:disabled { opacity: .5; cursor: default; } #status { color: #8b94a7; margin-left: 10px; } + #status.cached { color: #4ade80; } .ctx { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 0; font-size: 11px; color: #6b7280; } .ctx span { background: #11141b; padding: 3px 7px; border-radius: 3px; } + /* Building blocks the main thread in bursts, so the spinner is a CSS + animation — it keeps turning through the GPU work where a JS-driven one + would freeze and look like a hang. */ + .spinner { + display: none; width: 13px; height: 13px; vertical-align: -2px; + border: 2px solid #2a3040; border-top-color: #7dd3fc; border-radius: 50%; + animation: spin .8s linear infinite; margin-right: 6px; + } + .building .spinner { display: inline-block; } + @keyframes spin { to { transform: rotate(360deg); } } + #progress { height: 2px; background: #1a1e28; margin-top: 8px; border-radius: 2px; overflow: hidden; display: none; } + .building #progress { display: block; } + #progress i { display: block; height: 100%; width: 0; background: #7dd3fc; transition: width .2s; } + .row { margin-bottom: 22px; border-left: 3px solid #232838; padding-left: 12px; } .row.flat { border-color: #ef4444; } .row.thin { border-color: #eab308; } @@ -38,7 +54,7 @@ .blocks { color: #5c6577; font-size: 11px; } .thumbs { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; } .thumbs figure { margin: 0; } - .thumbs canvas { width: 100%; display: block; background: #05070a; border-radius: 2px; } + .thumbs canvas { width: 100%; display: block; background: #05070a; border-radius: 2px; aspect-ratio: 16 / 9; } .thumbs figcaption { font-size: 10px; color: #565f70; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .err { color: #f87171; } @media (max-width: 1100px) { .thumbs { grid-template-columns: repeat(3, 1fr); } } @@ -55,51 +71,57 @@ one image. ← all debug tools

-
- +
+ + - idle + checking cache…
+
diff --git a/flow-state/src/checks/gallery-cache.js b/flow-state/src/checks/gallery-cache.js new file mode 100644 index 0000000..295c39c --- /dev/null +++ b/flow-state/src/checks/gallery-cache.js @@ -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(); +}