music-video-gen/flow-state/gallery.html
Dejvino 89e05459c0 Every shot stands on something, and the frame has two ends
A section used to be one scene, and two thirds of the library is composable —
sparse by design, elements ON something. Cast as backgrounds anyway, they left
9 of 40 sampled frames under 20% painted, the darkest at 0.3%: a minute and a
half of a few bright things on black, invisible to every gate because every
gate on the stack was a limit rather than a floor.

Every section now stands on a GROUND: a canvas that fills the frame, cast per
section kind so a shot cut changes the shot and not the world. When the shot
fills the frame itself it IS the ground — two canvases stacked is two pictures
fighting. Above that, a coverage BUDGET: director appetite times the section's
energy times where the story is, capped at two frames' worth of material.

The measured facts move into the repo. scenes/metadata.json is generated from
the gallery — coverage as a shot, coverage as a bed, variety, the structural
profile — tracked in git, stamped with a fingerprint of the scenes and the
metric definitions, and refreshed from gallery.html. `surface` is derived from
it rather than declared; nine scenes claimed `canvas` while painting under a
third of the frame, and declaring it is now a lint error. The generator weights
every layering choice by measured structural distance, because family labels
and the render disagree: two `geometric` scenes can be 0.31 apart and a `flow`
and an `organic` scene 0.04.

The gallery's 0.1 red line is gone. It was right when a section was one scene
and wrong now — nineteen scenes were failing a bar for being consistent, which
is a virtue in an ingredient.

Chasing the numbers turned up four real faults:

  * A scene that reads prev() cannot be a ground. It returns the whole
    composited frame including the layers above it, so a datamosh under a shot
    is eating it: the render stopped reproducing from a seek and two WebGL
    contexts diverged by 91/255 against a tolerance of 4.
  * Screen was the wrong operator for a shot over a bed. It lightens, so a
    median quarter of every frame clipped to paper and whole sections rendered
    100% white. Replaced by a lumakey — the shot's brightness is its alpha.
  * Feedback was an accumulator: a still image settled at 2.3x its own
    brightness. Fine over black, fatal over a filled ground. Normalised at 0.6,
    plus a highlight shoulder so the top rolls off instead of clipping.
  * useTrack never prewarmed, so a fresh Show's first frame differed from every
    later render of it — the export-breaking hazard Compositor.prime documents.

Blazing is a decision now, not a side effect: directors declare an appetite for
it, a section must be loud and late in the story to earn one, and quiet kinds
never do. The ceiling gate matches that — a hard cap per section, and no more
than a fifth of them hot at all.

Rendered across twelve videos, middle of every section:

    painted        51% mean, darkest 0.3%  ->  87% mean, darkest 43%
    clipped white  24% median, worst 100%  ->   1% mean, worst 30%
    separation     0.10                    ->  0.44

Seven scenes can ground a section — five geometric, two organic — so every
quiet section of every video stands on one of two beds. That is the library's
largest hole and it is scene work: there is no minimal or flow canvas that
fills half the frame without reading prev().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:02:01 +02:00

563 lines
26 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>flow-state · gallery</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0; padding: 20px 24px 60px;
background: #0b0d12; color: #d6dae3;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
}
h1 { font-size: 15px; letter-spacing: .12em; text-transform: uppercase; color: #7d8698; margin: 0 0 4px; }
a { color: #7dd3fc; }
.lede { color: #8b94a7; max-width: 78ch; margin: 0 0 14px; }
.bar { position: sticky; top: 0; z-index: 5; background: #0b0d12; padding: 10px 0 12px; border-bottom: 1px solid #1c2030; margin-bottom: 16px; }
button, select {
background: #171b24; color: #d6dae3; border: 1px solid #2a3040;
padding: 5px 10px; font: inherit; border-radius: 3px; cursor: pointer;
}
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; }
.row.good { border-color: #22c55e; }
.row.broken { border-color: #a855f7; background: #150e1c; }
.head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 6px; flex-wrap: wrap; }
.name { font-size: 14px; color: #e6eaf2; }
.fam { color: #6b7280; }
.score { color: #9aa3b5; }
.meter { position: relative; display: inline-block; width: 130px; height: 7px; background: #1a1e28; border-radius: 4px; vertical-align: middle; }
.meter i { display: block; height: 100%; background: #4ade80; border-radius: 4px 0 0 4px; }
/* The bar, on every meter, so a row can be read on its own. */
.meter b { position: absolute; top: -2px; bottom: -2px; width: 2px; background: #ef4444; }
.row.flat .meter i { background: #ef4444; }
.row.thin .meter i { background: #eab308; }
.blocks { color: #5c6577; font-size: 11px; }
/* Coverage sits next to the score because the two explain each other: a
scene painting 2% of the frame scores well for variety and is thin to
watch alone. */
.surf { font-size: 11px; padding: 1px 6px; border-radius: 3px; }
.surf.canvas { background: #16232b; color: #7dd3fc; }
.surf.composable { background: #2a1f30; color: #d8b4fe; }
.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; 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; }
.cutline { position: relative; border-top: 2px solid #ef4444; margin: 26px 0 24px; }
.cutline span {
position: absolute; top: -9px; left: 12px; background: #0b0d12;
padding: 0 8px; color: #ef4444; font-size: 11px; letter-spacing: .04em;
}
@media (max-width: 1100px) { .thumbs { grid-template-columns: repeat(3, 1fr); } }
/* ---- single-scene sweep --------------------------------------------- */
.name a { color: inherit; text-decoration: none; border-bottom: 1px dotted #3a4256; }
.name a:hover { color: #7dd3fc; }
.btns { display: inline-flex; gap: 4px; margin-left: 4px; }
.btns button.on { border-color: #7dd3fc; color: #7dd3fc; }
.sweep { display: grid; gap: 5px; align-items: center; margin-top: 8px; }
.sweep .colhead { font-size: 10px; color: #565f70; text-align: center; }
.sweep .rowlabel {
font-size: 11px; color: #8b94a7; text-align: right;
padding-right: 8px; white-space: nowrap;
}
.sweep .cell { position: relative; line-height: 0; }
.sweep canvas {
width: 100%; display: block; background: #05070a;
border-radius: 2px; aspect-ratio: 16 / 9;
}
/* The two cells that measured closest — on a grid this wide they are the
only pair an eye has no chance of finding on its own. */
.sweep .cell.twin canvas { outline: 2px solid #ef4444; outline-offset: 1px; }
.sweep .cell.twin::after {
content: 'twin'; position: absolute; top: 2px; left: 3px;
font-size: 9px; color: #ef4444; line-height: 1;
}
.axes { display: flex; gap: 18px; flex-wrap: wrap; margin: 10px 0 2px; }
.axes div { font-size: 12px; }
.axes b { color: #e6eaf2; font-weight: normal; }
.axes .weak b { color: #ef4444; }
.axes small { color: #6b7280; display: block; font-size: 11px; }
</style>
</head>
<body>
<h1>flow-state · gallery</h1>
<p class="lede">
Every visualizer rendered six times, once per song, each with that song's
complete identity — its cast, ink, lattice, palette and sampled parameters.
A scene whose six frames are interchangeable is one the song cannot change.
Sorted least-varied first, scored on the same structural descriptor the
variety harness uses, with colour excluded so six palettes cannot disguise
one image.
<strong>Coverage</strong> is shown beside each score because the two explain
each other: the highest-scoring scenes are often a few bright elements on
black, which is varied and thin to watch on its own. Those are
<em>composable</em> — they are meant to sit on top of a <em>canvas</em>.
<strong>Click a scene's name</strong> to open it on its own, across a much
wider grid of songs and seeds — six frames ranks a library and is far too
few to study one scene.
<a href="/debug.html">← all debug tools</a>
</p>
<div class="bar" id="bar">
<span class="spinner"></span>
<span id="libctl">
<button id="rebuild">rebuild</button>
<button id="refresh" title="re-measure the library and write src/scenes/metadata.json — the git-tracked numbers the generator composes with">refresh metadata</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>
<option value="coverage">sort: sparsest first</option>
</select>
</span>
<span id="sweepctl" hidden>
<button id="back">← all scenes</button>
<select id="scenesel"></select>
<span class="btns" id="sizes"></span>
<span class="btns">
<button id="prevseeds" title="the same grid on the previous block of seeds">← seeds</button>
<button id="nextseeds" title="the same grid on the next block of seeds — the answer to &quot;is it flat, or was that ten unlucky draws&quot;">seeds →</button>
<button id="rerender" title="render this grid again">re-render</button>
</span>
</span>
<span id="status">checking cache…</span>
<div class="ctx" id="ctx"></div>
<div id="progress"><i></i></div>
</div>
<div id="sweepinfo"></div>
<div id="out"></div>
<script type="module">
import { buildGallery, galleryContexts, THUMB, blit } from '/src/checks/gallery.js';
import { measureLibrary, metadataDrift, writeMetadata, metadataIsFresh } from '/src/checks/metadata.js';
// A gallery showing numbers the generator is not using is a trap, so say so.
const STALE_NOTE = metadataIsFresh() ? '' : ' · metadata is STALE — press refresh';
import {
sourceFingerprint, loadGallery, saveGallery, clearGallery,
pixelsToBlob, blobToCanvas,
} from '/src/checks/gallery-cache.js';
import { sweepGrid, sweepScene, prepareSongs, SWEEP_SIZES } from '/src/checks/scene-sweep.js';
import { Engine } from '/src/engine/Engine.js';
import { scenes } from '/src/scenes/registry.js';
const bar = document.getElementById('bar');
const out = document.getElementById('out');
const status = document.getElementById('status');
const sortSel = document.getElementById('sort');
const progress = document.querySelector('#progress i');
const rebuildBtn = document.getElementById('rebuild');
const refreshBtn = document.getElementById('refresh');
let rows = []; // { name, family, role, variety, byBlock, blobs, error }
let contexts = [];
// There is no bar any more — see checks/gallery.js for why the 0.1 red line
// went. What is left is a broken/working distinction and a relative shading, so
// the eye can still find the flattest scenes in a sorted list without anyone
// pretending a threshold decides whether a scene is allowed to exist.
const grade = (v, err) => (err ? 'broken' : v < 0.08 ? 'thin' : 'good');
function draw() {
const mode = sortSel.value;
const sorted = rows.slice().sort((a, b) => (
mode === 'name' ? a.name.localeCompare(b.name)
: mode === 'coverage' ? (a.coverage || 0) - (b.coverage || 0)
: 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, row.error)}`;
const blocks = Object.entries(row.byBlock || {})
.map(([k, v]) => `${k} ${v.toFixed(3)}`).join(' · ');
el.innerHTML = `
<div class="head">
<span class="name"><a href="?scene=${encodeURIComponent(row.name)}" title="open this one on a wide grid of songs and seeds">${row.name}</a></span>
<span class="fam">${row.family}${row.role === 'accent' ? ' · accent' : ''}</span>
<span class="surf ${surfaceFor(row)}" title="paints ${((row.coverage || 0) * 100).toFixed(0)}% of the frame across the six songs — this is what decides the label">${surfaceFor(row)} ${((row.coverage || 0) * 100).toFixed(0)}%</span>
<span class="score">
<span class="meter"><i style="width:${Math.min(100, row.variety * 500)}%"></i></span>
${row.variety.toFixed(3)}
</span>
<span class="blocks">${row.error ? `<span class="err">${row.error}</span>` : blocks}</span>
</div>
<div class="thumbs"></div>`;
const grid = el.querySelector('.thumbs');
(row.blobs || []).forEach((blob, i) => {
const fig = document.createElement('figure');
const canvas = document.createElement('canvas');
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);
}
}
sortSel.addEventListener('change', draw);
// The label a scene WOULD carry from this build's numbers, which is not
// necessarily the one the checked-in metadata gives it — that is the whole
// point of showing it here.
const surfaceFor = (row) => ((row.coverage || 0) >= 0.5 ? 'canvas' : 'composable');
function summarise(built) {
const broken = rows.filter((r) => r.error).length;
const canvases = rows.filter((r) => surfaceFor(r) === 'canvas').length;
return `${rows.length} scenes · ${canvases} canvas / ${rows.length - canvases} composable` +
(broken ? ` · ${broken} BROKEN` : '') +
` · ${rows.filter((r) => (r.coverage || 0) < 0.3).length} paint under 30% of the frame` +
(built ? ` · built ${new Date(built).toLocaleString()}` : '') + STALE_NOTE;
}
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).map((c) => ({ ...c }));
document.getElementById('ctx').innerHTML = contexts
.map((c) => `<span title="${c.identity}">${c.name}</span>`).join('');
const started = Date.now();
await buildGallery({
contexts,
onScene: async (done, total, name, row) => {
status.textContent = `rendering ${done}/${total} · ${name}`;
progress.style.width = `${(done / total) * 100}%`;
// Show rows as they land. The caching rewrite moved every draw to
// the end, which left three minutes of spinner and nothing to look
// at — and the first scenes are the interesting ones, since the
// sort puts the repetitive ones on top.
rows.push({
name: row.module.name, family: row.module.family,
role: row.module.role || 'stage', variety: row.variety,
coverage: row.coverage, surface: row.surface,
byBlock: row.byBlock, error: row.error,
blobs: await Promise.all(row.thumbs.map(
(px) => pixelsToBlob(px, THUMB.width, THUMB.height))),
});
if (done % 8 === 0 || done === total) draw();
},
});
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();
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 }));
}
// ---- single-scene sweep -------------------------------------------------
// One scene over a grid of songs (rows) and seeds (columns). See
// src/checks/scene-sweep.js for what the two axes are for.
const sweepInfo = document.getElementById('sweepinfo');
const sceneSel = document.getElementById('scenesel');
const sizesEl = document.getElementById('sizes');
/** The sweep's whole state lives in the URL, so every button is a back button. */
function sweepState() {
const p = new URLSearchParams(location.search);
return {
scene: p.get('scene'),
songs: Number(p.get('songs')) || 6,
seeds: Number(p.get('seeds')) || 6,
offset: Number(p.get('offset')) || 0,
};
}
function goSweep(patch) {
const next = { ...sweepState(), ...patch };
const p = new URLSearchParams({
scene: next.scene, songs: next.songs, seeds: next.seeds, offset: next.offset,
});
history.pushState(null, '', `?${p}`);
renderSweep();
}
let sweepRun = 0; // cancels an in-flight grid when the buttons move on
async function renderSweep() {
const state = sweepState();
const module = scenes.find((m) => m.name === state.scene);
const run = ++sweepRun;
document.getElementById('libctl').hidden = true;
document.getElementById('sweepctl').hidden = false;
document.getElementById('ctx').innerHTML = '';
out.innerHTML = '';
// The page's own lede describes the library view, which is the wrong thing
// to be reading over a grid of one scene.
document.querySelector('.lede').innerHTML =
'One visualizer over a grid of songs and seeds. <strong>Rows are songs</strong> — ' +
'different audio, so different features, sections and biases. ' +
'<strong>Columns are seeds</strong> — the same audio with a different roll of ' +
'the identity, palette and parameters. A flat row means the seed does nothing ' +
'here; a flat column means the song does nothing; the two scores below say ' +
'which. Frames are the busiest section of each look, where scenes are most ' +
'likely to converge. <a href="?">← all scenes</a> · ' +
'<a href="/debug.html">all debug tools</a>';
if (!module) {
sweepInfo.innerHTML = `<p class="err">no scene named "${state.scene}"</p>`;
return;
}
sceneSel.value = module.name;
for (const b of sizesEl.children) {
b.classList.toggle('on', Number(b.dataset.songs) === state.songs
&& Number(b.dataset.seeds) === state.seeds);
}
bar.classList.add('building');
status.className = '';
sweepInfo.innerHTML = '';
// Analysed one at a time so the count moves; cached, so only the first grid
// of a session waits for it.
await prepareSongs(state.songs, (done, total, name) => {
status.textContent = `${module.name}: analysing songs ${done}/${total} · ${name}`;
progress.style.width = `${(done / total) * 100}%`;
});
if (run !== sweepRun) return;
const { cells, songNames, seedLabels } = sweepGrid({
songs: state.songs, seeds: state.seeds, seedOffset: state.offset,
});
if (run !== sweepRun) return;
// The grid is laid out before anything is rendered, so the shape of what is
// coming is visible immediately and each cell fills in where it belongs.
const grid = document.createElement('div');
grid.className = 'sweep';
grid.style.gridTemplateColumns = `auto repeat(${seedLabels.length}, 1fr)`;
grid.appendChild(document.createElement('span'));
for (const label of seedLabels) {
const h = document.createElement('span');
h.className = 'colhead';
h.textContent = label;
grid.appendChild(h);
}
const canvases = [];
cells.forEach((cell, i) => {
if (i % seedLabels.length === 0) {
const label = document.createElement('span');
label.className = 'rowlabel';
label.textContent = cell.song;
grid.appendChild(label);
}
const holder = document.createElement('span');
holder.className = 'cell';
const canvas = document.createElement('canvas');
canvas.width = THUMB.width; canvas.height = THUMB.height;
holder.title = `${cell.name} · seed ${cell.seed}\n${cell.identity}`;
holder.appendChild(canvas);
grid.appendChild(holder);
canvases.push({ canvas, holder });
});
out.appendChild(grid);
const started = Date.now();
const engine = new Engine({ ...THUMB });
let result;
try {
result = await sweepScene({
engine, module, cells,
onCell: async (i, total, pixels) => {
if (run !== sweepRun) return;
blit(canvases[i].canvas, pixels, THUMB.width, THUMB.height);
status.textContent = `${module.name}: ${i + 1}/${total} cells`;
progress.style.width = `${((i + 1) / total) * 100}%`;
// Yield every row, so the grid fills in visibly instead of
// freezing the page for a hundred and seventy renders.
if (i % seedLabels.length === seedLabels.length - 1) {
await new Promise((r) => setTimeout(r, 0));
}
},
});
} finally {
engine.dispose();
}
if (run !== sweepRun) return;
if (result.closest) {
canvases[result.closest.a].holder.classList.add('twin');
canvases[result.closest.b].holder.classList.add('twin');
}
// Three numbers rather than one: which AXIS is flat is the thing the
// library gallery cannot tell you, and it decides what to go and fix.
const weak = (v) => (v < 0.08 ? ' weak' : '');
const twin = result.closest
? `${cells[result.closest.a].name}${cells[result.closest.b].name} at ${result.closest.distance.toFixed(3)}`
: '—';
sweepInfo.innerHTML = `
<div class="axes">
<div class="${weak(result.variety)}">overall <b>${result.variety.toFixed(3)}</b>
<small>every cell against every other</small></div>
<div class="${weak(result.bySeed)}">across seeds <b>${result.bySeed.toFixed(3)}</b>
<small>same song, different draw — low means the scene ignores the identity</small></div>
<div class="${weak(result.bySong)}">across songs <b>${result.bySong.toFixed(3)}</b>
<small>same draw, different music — low means the scene ignores the song</small></div>
<div>closest pair <b>${twin}</b>
<small>outlined in the grid</small></div>
</div>
<div class="blocks">${Object.entries(result.byBlock)
.map(([k, v]) => `${k} ${v.toFixed(3)}`).join(' · ')}</div>`;
bar.classList.remove('building');
status.textContent = `${module.name} · ${cells.length} cells ` +
`(${songNames.length} songs × ${seedLabels.length} seeds, from ${seedLabels[0]}) ` +
`in ${((Date.now() - started) / 1000).toFixed(0)}s`;
window.__SWEEP__ = {
scene: module.name, variety: result.variety,
bySeed: result.bySeed, bySong: result.bySong,
};
}
function initSweepControls() {
// Alphabetical, not registry order: the registry is chronological — the
// order scenes were added — which is meaningless to anyone hunting for one
// by name in a list of sixty-seven.
sceneSel.innerHTML = scenes.filter((m) => m.kind === 'fragment')
.map((m) => m.name).sort((a, b) => a.localeCompare(b))
.map((name) => `<option value="${name}">${name}</option>`).join('');
sceneSel.addEventListener('change', () => goSweep({ scene: sceneSel.value, offset: 0 }));
sizesEl.innerHTML = SWEEP_SIZES.map((s) =>
`<button data-songs="${s.songs}" data-seeds="${s.seeds}" ` +
`title="${s.songs} songs × ${s.seeds} seeds = ${s.songs * s.seeds} renders">${s.label}</button>`).join('');
sizesEl.addEventListener('click', (e) => {
const b = e.target.closest('button');
if (b) goSweep({ songs: Number(b.dataset.songs), seeds: Number(b.dataset.seeds) });
});
document.getElementById('nextseeds').addEventListener('click',
() => goSweep({ offset: sweepState().offset + sweepState().seeds }));
document.getElementById('prevseeds').addEventListener('click',
() => goSweep({ offset: Math.max(0, sweepState().offset - sweepState().seeds) }));
document.getElementById('rerender').addEventListener('click', renderSweep);
document.getElementById('back').addEventListener('click', () => { location.search = ''; });
window.addEventListener('popstate', renderSweep);
}
async function main() {
if (sweepState().scene) {
initSweepControls();
await renderSweep();
return;
}
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);
}
// ---- metadata refresh ---------------------------------------------------
// The gallery is where the library gets measured, so it is where the measured
// facts are written back. The numbers go to src/scenes/metadata.json — tracked
// in git, read by the look generator, and stamped with a fingerprint of the
// scenes and the metric definitions so a stale file is detectable rather than
// merely old. See src/checks/metadata.js.
refreshBtn.addEventListener('click', async () => {
refreshBtn.disabled = true;
rebuildBtn.disabled = true;
bar.classList.add('building');
const started = Date.now();
try {
const fresh = measureLibrary({
onScene: (done, total, name) => {
status.textContent = `measuring ${done}/${total} · ${name}`;
progress.style.width = `${(done / total) * 100}%`;
},
});
const { moved, gone } = metadataDrift(fresh);
await writeMetadata(fresh);
const changes = moved.slice(0, 6).map((m) => `${m.name} ${m.note}`).join(' · ');
status.textContent =
`metadata written in ${((Date.now() - started) / 1000).toFixed(0)}s · ` +
`${Object.keys(fresh.scenes).length} scenes · ` +
(moved.length ? `${moved.length} moved — ${changes}` : 'nothing moved') +
(gone.length ? ` · dropped ${gone.join(', ')}` : '');
} catch (err) {
status.textContent = `metadata refresh failed: ${err.message} ` +
'(the write endpoint only exists under npm run dev)';
} finally {
bar.classList.remove('building');
refreshBtn.disabled = false;
rebuildBtn.disabled = false;
progress.style.width = '0%';
}
});
rebuildBtn.addEventListener('click', async () => {
await clearGallery();
await build(sourceFingerprint());
});
main();
</script>
</body>
</html>