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>
563 lines
26 KiB
HTML
563 lines
26 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>
|
||
<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 "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, 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>
|