The top of the gallery is full of black frames with a bright element on them. That is genuinely varied and thin to watch, and the reason it ranks is that the gallery previews every scene ALONE — which is not how anyone watches one. Measured, the split is not marginal: 37 of 66 scenes paint under 30% of the frame, and the sparsest are exactly the ones topping the ranking. Storm Rift and Pendulum Trace paint 0%, Droste Feedback and Spectrum Sculpture 2%. At the other end Block Mosh covers 98% and Scan Tear 96%. So a scene now declares a `surface`. A canvas fills the frame and belongs underneath; two of them stacked is two pictures fighting rather than one picture with depth. A composable scene is mostly empty by design and reads as elements ON something. The distinction was implicit in `role: 'accent'`, which marked the depth passes and said nothing about the sixty scenes treated as interchangeable backgrounds while half of them were sparse. Verified rather than trusted, in the pattern the rest of the project uses: a gate renders every scene and measures what it actually paints, failing a composable that covers more than 55% — it would hide whatever it sits on — and a canvas that covers less than 8%, which is a canvas in name only. The gate also prints the whole coverage table, since that list is how the library gets labelled in the first place. Casting uses it: only composable scenes are drawn as overlays, and because what goes on top is now guaranteed to leave the shot underneath visible, layering happens far more often — the overlay chance went from 0.12 to 0.3 at its base. The gallery shows coverage and surface beside each score, sorts by sparsest, and says in the header why the two numbers explain each other. Nothing is labelled `composable` yet. Every scene keeps the behaviour it had through `surfaceOf`, which defaults to canvas unless the scene is an accent, so this commit changes the mechanism and not the output. The coverage table is what the labelling should be read off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
276 lines
13 KiB
HTML
276 lines
13 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; }
|
|
/* 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); } }
|
|
</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>.
|
|
<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>
|
|
<option value="coverage">sort: sparsest first</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 === 'coverage' ? (a.coverage || 0) - (b.coverage || 0)
|
|
: 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="surf ${row.surface}" title="paints ${((row.coverage || 0) * 100).toFixed(0)}% of the frame">${row.surface || '?'} ${((row.coverage || 0) * 100).toFixed(0)}%</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` : '') +
|
|
` · ${rows.filter((r) => (r.coverage || 0) < 0.3).length} paint under 30% of the frame` +
|
|
(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,
|
|
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 }));
|
|
}
|
|
|
|
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>
|