music-video-gen/flow-state/gallery.html
Dejvino c6d96c9d40 A gallery, and a debug index to reach it from
Sixty-five visualizers, six renders each, one per song — each carrying that
song's whole identity: its cast, ink, lattice, palette and a fresh parameter
draw. Side by side, a scene that cannot be changed by the song is obvious in a
way no aggregate could show, which is the complaint this answers.

It scores as well as shows. Each row carries the mean structural distance
between its own six frames, on the same descriptor the variety harness uses and
with colour excluded, so six palettes cannot disguise one image. Sorted
least-varied first, because browsing sixty-five scenes hunting for the
repetitive ones is precisely what a sort order should do for you.

The result names names. Thirteen scenes barely change across six songs, and they
fall into two groups that were already known separately. The ink-only
migrations — Analog Wow, Halftone Misprint, Pitch Shatter, Block Mosh, Scan
Tear — are the shallow tier flagged in MIGRATION.md, where the whole migration
was one posterisation. And Moiré Grid, Isometric Blocks, Quasicrystal, Truchet
Fold, Voronoi Shatter and Apollonian Gasket are the structural twin cliques the
library sweep found weeks of measurement ago, arriving here by a completely
different route: the sweep compared scenes to each other, the gallery compares a
scene to itself, and they agree on the same offenders.

debug.html collects the tools, since there are now enough of them that knowing
which to open is its own problem. It also carries the two things a newcomer
would otherwise learn the hard way: which measurements are safe to steer by
(direct render comparisons) and which are not (aggregate ratios), and what to do
when a page renders perfectly and does nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 07:13:54 +02:00

162 lines
6.9 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 { border-color: #4a5468; }
#status { color: #8b94a7; margin-left: 10px; }
.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; }
.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; }
.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 { display: inline-block; width: 130px; height: 7px; background: #1a1e28; border-radius: 4px; overflow: hidden; vertical-align: middle; }
.meter i { display: block; height: 100%; background: #4ade80; }
.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; }
.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); } }
</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">
<button id="run">build gallery</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>
<div class="ctx" id="ctx"></div>
</div>
<div id="out"></div>
<script type="module">
import { buildGallery, galleryContexts, blit, THUMB } from '/src/checks/gallery.js';
const out = document.getElementById('out');
const status = document.getElementById('status');
const sortSel = document.getElementById('sort');
let rows = [];
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)
: 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)
.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="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.thumbs.forEach((pixels, 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);
});
out.appendChild(el);
}
}
sortSel.addEventListener('change', draw);
document.getElementById('run').addEventListener('click', async () => {
const button = document.getElementById('run');
button.disabled = true;
rows = [];
out.innerHTML = '';
status.textContent = 'analysing six songs…';
await new Promise((r) => setTimeout(r, 0));
contexts = galleryContexts(6);
document.getElementById('ctx').innerHTML = contexts
.map((c) => `<span title="${c.identity}">${c.name}</span>`).join('');
const started = Date.now();
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();
},
});
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`;
draw();
button.disabled = false;
window.__GALLERY__ = rows.map((r) => ({ name: r.module.name, variety: r.variety, error: r.error }));
});
</script>
</body>
</html>