A failed shader compile renders black, and black frames are identical to each other, so every block scores 0.000 and the row reads as the least varied scene in the library rather than as a broken one. That happened yesterday — the subject helpers were declared above the ink they call, GLSL has no forward declarations, the whole preamble failed to compile, and the gallery reported a variety of exactly 0.000 with no indication anything was wrong. It is a particularly bad failure mode for a tool whose entire job is ranking scenes by that number. A scene whose first frame has no luminance and no variance is now reported as broken, with both figures and a pointer to the console, and the row is coloured apart from the merely flat ones so it cannot be mistaken for a bad score. The summary counts them separately. Verified against both answers rather than only the happy one: a healthy Moiré Grid comes back 0.0365 with no error, and a deliberately broken copy of the same scene comes back with the diagnosis instead of a zero. Recorded in HOWTO-variety.md alongside the other instrument traps, since the general lesson outlives this instance — a broken render produces the most boring possible numbers rather than an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
12 KiB
HTML
260 lines
12 KiB
HTML
<!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; }
|
|
.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); } }
|
|
</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. <a href="/debug.html">← all debug tools</a>
|
|
</p>
|
|
|
|
<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">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, THUMB, MIN_VARIETY } 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');
|
|
const progress = document.querySelector('#progress i');
|
|
const rebuildBtn = document.getElementById('rebuild');
|
|
|
|
let rows = []; // { name, family, role, variety, byBlock, blobs, error }
|
|
let contexts = [];
|
|
|
|
// Graded against the bar rather than against taste: below it is a failure, and
|
|
// the band just above it is where a scene is passing by too little to trust.
|
|
const grade = (v, err) => (err ? 'broken' : v < MIN_VARIETY ? 'flat' : v < MIN_VARIETY * 2 ? 'thin' : 'good');
|
|
|
|
function draw() {
|
|
const mode = sortSel.value;
|
|
const sorted = rows.slice().sort((a, b) => (
|
|
mode === 'name' ? a.name.localeCompare(b.name)
|
|
: mode === 'family' ? (a.family.localeCompare(b.family) || a.variety - b.variety)
|
|
: a.variety - b.variety));
|
|
|
|
out.innerHTML = '';
|
|
let drewCut = false;
|
|
for (const row of sorted) {
|
|
// One line across the whole gallery, where the sort crosses the bar.
|
|
// Everything above it repeats itself between songs.
|
|
if (mode === 'variety' && !drewCut && row.variety >= MIN_VARIETY) {
|
|
drewCut = true;
|
|
const cut = document.createElement('div');
|
|
cut.className = 'cutline';
|
|
cut.innerHTML = `<span>minimum variety ${MIN_VARIETY.toFixed(3)} — everything above repeats itself between songs</span>`;
|
|
out.appendChild(cut);
|
|
}
|
|
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">${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><b style="left:${MIN_VARIETY * 500}%" title="minimum ${MIN_VARIETY}"></b></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);
|
|
|
|
function summarise(built) {
|
|
const flat = rows.filter((r) => r.variety < MIN_VARIETY && !r.error).length;
|
|
const broken = rows.filter((r) => r.error).length;
|
|
return `${rows.length} scenes · ${flat} below the ${MIN_VARIETY} bar` +
|
|
(broken ? ` · ${broken} BROKEN` : '') +
|
|
(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).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,
|
|
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 }));
|
|
}
|
|
|
|
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>
|