Six thumbnails is enough to rank sixty-eight scenes and far too few to study one. Worse, six songs at one seed each confounds the two inputs: a scene whose frames are interchangeable might be ignoring the identity or ignoring the music, and the library gallery cannot say which. So a scene's name in the gallery now opens it on a grid — songs down, seeds across, up to every song in the bank by ten draws. Buttons pick the size and walk onto a fresh block of seeds, which is a much better answer to "is it flat or was that ten unlucky rolls" than staring at the same ten. The state lives in the URL, so every button is also a back button. Three scores rather than one, and the two new ones are the diagnosis: ACROSS SEEDS is the same song with a different draw, so low means the scene ignores the identity; ACROSS SONGS is the same draw against different music, so low means it ignores the song. They point at different fixes. The closest pair is outlined, because at 170 cells no eye is finding it. The first version hashed the song name into the seed so a column would not be one roll repeated down the grid. It reads better and measures nothing: with the seed varying on both axes the two scores are the same comparison, and they came back within 0.001 of each other for every scene tried. A column holds its seed fixed now, which is what makes it attributable to the music — and the scores separate, with the seed moving every scene tried more than the song does. 170 cells in 9s. The songs are the slow part and are analysed one at a time so the count moves, then cached for the session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
524 lines
24 KiB
HTML
524 lines
24 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); } }
|
||
|
||
/* ---- 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>
|
||
<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 "is it flat, or was that ten unlucky draws"">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, MIN_VARIETY, blit } from '/src/checks/gallery.js';
|
||
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');
|
||
|
||
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"><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 ${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 }));
|
||
}
|
||
|
||
// ---- 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 < MIN_VARIETY ? ' 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 · bar ${MIN_VARIETY}</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);
|
||
}
|
||
|
||
rebuildBtn.addEventListener('click', async () => {
|
||
await clearGallery();
|
||
await build(sourceFingerprint());
|
||
});
|
||
|
||
main();
|
||
</script>
|
||
</body>
|
||
</html>
|