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(); +}