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
+117 -34
View File
@@ -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. <a href="/debug.html">← all debug tools</a>
</p>
<div class="bar">
<button id="run">build gallery</button>
<div class="bar" id="bar">
<span class="spinner"></span>
<button id="rebuild">rebuild</button>
<select id="sort">
<option value="variety">sort: least varied first</option>
<option value="name">sort: by name</option>
<option value="family">sort: by family</option>
</select>
<span id="status">idle</span>
<span id="status">checking cache…</span>
<div class="ctx" id="ctx"></div>
<div id="progress"><i></i></div>
</div>
<div id="out"></div>
<script type="module">
import { buildGallery, galleryContexts, blit, THUMB } from '/src/checks/gallery.js';
import { buildGallery, galleryContexts, THUMB } from '/src/checks/gallery.js';
import {
sourceFingerprint, loadGallery, saveGallery, clearGallery,
pixelsToBlob, blobToCanvas,
} from '/src/checks/gallery-cache.js';
const bar = document.getElementById('bar');
const out = document.getElementById('out');
const status = document.getElementById('status');
const sortSel = document.getElementById('sort');
let rows = [];
const progress = document.querySelector('#progress i');
const rebuildBtn = document.getElementById('rebuild');
let rows = []; // { name, family, role, variety, byBlock, blobs, error }
let contexts = [];
// A scene that changes very little across six songs is the thing this page
// exists to surface, so the thresholds are set where "I have seen this before"
// starts rather than at anything statistical.
const grade = (v) => (v < 0.05 ? 'flat' : v < 0.10 ? 'thin' : 'good');
function draw() {
const mode = sortSel.value;
const sorted = rows.slice().sort((a, b) => (
mode === 'name' ? a.module.name.localeCompare(b.module.name)
: mode === 'family' ? (a.module.family.localeCompare(b.module.family)
|| a.variety - b.variety)
mode === 'name' ? a.name.localeCompare(b.name)
: mode === 'family' ? (a.family.localeCompare(b.family) || a.variety - b.variety)
: a.variety - b.variety));
out.innerHTML = '';
for (const row of sorted) {
const el = document.createElement('div');
el.className = `row ${grade(row.variety)}`;
const blocks = Object.entries(row.byBlock)
const blocks = Object.entries(row.byBlock || {})
.map(([k, v]) => `${k} ${v.toFixed(3)}`).join(' · ');
el.innerHTML = `
<div class="head">
<span class="name">${row.module.name}</span>
<span class="fam">${row.module.family}${row.module.role === 'accent' ? ' · accent' : ''}</span>
<span class="name">${row.name}</span>
<span class="fam">${row.family}${row.role === 'accent' ? ' · accent' : ''}</span>
<span class="score">
<span class="meter"><i style="width:${Math.min(100, row.variety * 500)}%"></i></span>
${row.variety.toFixed(3)}
@@ -108,16 +130,18 @@ function draw() {
</div>
<div class="thumbs"></div>`;
const grid = el.querySelector('.thumbs');
row.thumbs.forEach((pixels, i) => {
(row.blobs || []).forEach((blob, i) => {
const fig = document.createElement('figure');
const canvas = document.createElement('canvas');
blit(canvas, pixels, THUMB.width, THUMB.height);
fig.appendChild(canvas);
const cap = document.createElement('figcaption');
cap.textContent = contexts[i] ? contexts[i].name : '';
cap.title = contexts[i] ? contexts[i].identity : '';
fig.appendChild(cap);
grid.appendChild(fig);
// Decoded lazily and in the background; sixty-five rows of six is
// 390 images and decoding them all synchronously stalls the page.
blobToCanvas(canvas, blob);
});
out.appendChild(el);
}
@@ -125,37 +149,96 @@ function draw() {
sortSel.addEventListener('change', draw);
document.getElementById('run').addEventListener('click', async () => {
const button = document.getElementById('run');
button.disabled = true;
function summarise(built) {
const flat = rows.filter((r) => r.variety < 0.05).length;
return `${rows.length} scenes · ${flat} barely change across songs` +
(built ? ` · built ${new Date(built).toLocaleString()}` : '');
}
async function build(fingerprint) {
bar.classList.add('building');
rebuildBtn.disabled = true;
rows = [];
out.innerHTML = '';
status.className = '';
status.textContent = 'analysing six songs…';
await new Promise((r) => setTimeout(r, 0));
contexts = galleryContexts(6);
contexts = galleryContexts(6).map((c) => ({ ...c }));
document.getElementById('ctx').innerHTML = contexts
.map((c) => `<span title="${c.identity}">${c.name}</span>`).join('');
const started = Date.now();
await buildGallery({
const built = await buildGallery({
contexts,
onScene: (done, total, name, row) => {
rows.push(row);
status.textContent = `${done}/${total} · ${name}`;
// Redraw periodically rather than every scene: sorting the list on
// every row would reflow sixty times for no benefit.
if (done % 8 === 0 || done === total) draw();
onScene: (done, total, name) => {
status.textContent = `rendering ${done}/${total} · ${name}`;
progress.style.width = `${(done / total) * 100}%`;
},
});
const flat = rows.filter((r) => r.variety < 0.05);
status.textContent = `${rows.length} scenes in ${((Date.now() - started) / 1000).toFixed(0)}s · ` +
`${flat.length} barely change across songs`;
status.textContent = 'compressing…';
await new Promise((r) => setTimeout(r, 0));
rows = [];
for (const row of built) {
const blobs = [];
for (const pixels of row.thumbs) {
blobs.push(await pixelsToBlob(pixels, THUMB.width, THUMB.height));
}
rows.push({
name: row.module.name,
family: row.module.family,
role: row.module.role || 'stage',
variety: row.variety,
byBlock: row.byBlock,
error: row.error,
blobs,
});
}
const payload = {
fingerprint,
built: Date.now(),
contexts: contexts.map((c) => ({ name: c.name, identity: c.identity })),
rows,
};
const stored = await saveGallery(fingerprint, payload);
bar.classList.remove('building');
rebuildBtn.disabled = false;
draw();
button.disabled = false;
window.__GALLERY__ = rows.map((r) => ({ name: r.module.name, variety: r.variety, error: r.error }));
status.textContent = `${summarise(payload.built)} in ${((Date.now() - started) / 1000).toFixed(0)}s` +
(stored ? '' : ' · not cached (storage unavailable)');
window.__GALLERY__ = rows.map((r) => ({ name: r.name, variety: r.variety, error: r.error }));
}
async function main() {
const fingerprint = sourceFingerprint();
const cached = await loadGallery(fingerprint);
if (cached) {
rows = cached.rows;
contexts = cached.contexts;
document.getElementById('ctx').innerHTML = contexts
.map((c) => `<span title="${c.identity}">${c.name}</span>`).join('');
draw();
status.className = 'cached';
status.textContent = `cached · ${summarise(cached.built)}`;
window.__GALLERY__ = rows.map((r) => ({ name: r.name, variety: r.variety, error: r.error }));
return;
}
// No entry for this source. Something under src/ changed since the last
// build — or there has never been one — so rebuild without being asked.
await build(fingerprint);
}
rebuildBtn.addEventListener('click', async () => {
await clearGallery();
await build(sourceFingerprint());
});
main();
</script>
</body>
</html>