One scene, wide: songs against seeds

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>
This commit is contained in:
Dejvino 2026-08-19 17:16:15 +02:00
parent 89277705c4
commit 0a89676cc8
3 changed files with 511 additions and 9 deletions

View File

@ -53,6 +53,19 @@
<div class="meta">~3 min · the one to open when a scene feels familiar ·
<code>HOWTO-variety.md</code> for how to fix what it finds</div>
</div>
<div class="card fast">
<h3><a href="/gallery.html?scene=Effigy&amp;songs=6&amp;seeds=6">one scene, wide</a></h3>
<p>The gallery for a single visualizer, on a grid of songs (rows) against
seeds (columns) — up to every song in the bank by ten draws. Six
thumbnails ranks a library; it is far too few to study one scene.
Scores the two axes separately, which is the thing the library
gallery cannot tell you: <strong>across seeds</strong> low means the
scene ignores the identity, <strong>across songs</strong> low means it
ignores the music. The closest pair in the grid is outlined.</p>
<div class="meta">seconds, after the songs are analysed once ·
click any scene name in the gallery to open it here ·
the buttons change grid size and walk onto fresh seeds</div>
</div>
<div class="card">
<h3><a href="/filmstrip.html">filmstrip</a></h3>
<p>Every song in the bank, one frame every 30 seconds, left to right.

View File

@ -72,6 +72,35 @@
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>
@ -87,11 +116,15 @@
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>
@ -99,19 +132,34 @@
<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 &quot;is it flat, or was that ten unlucky draws&quot;">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 } from '/src/checks/gallery.js';
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');
@ -153,7 +201,7 @@ function draw() {
.map(([k, v]) => `${k} ${v.toFixed(3)}`).join(' · ');
el.innerHTML = `
<div class="head">
<span class="name">${row.name}</span>
<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">
@ -244,7 +292,207 @@ async function build(fingerprint) {
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);

View File

@ -0,0 +1,241 @@
// One scene, across a wide grid of songs and seeds.
//
// The gallery answers "which scenes repeat themselves" by showing every scene
// six times. This answers the question you have immediately afterwards, which it
// cannot: WHY does this one repeat, and what actually moves it?
//
// Six thumbnails is enough to rank sixty-eight scenes and far too few to study
// one. Six songs at one seed each confounds the two inputs — a scene that looks
// the same six times might be ignoring the song, or ignoring the seed, and the
// gallery cannot tell you which. So this separates them onto the two axes of a
// grid:
//
// rows the SONG — different audio, so different features, sections,
// biases and sampled parameter centres.
// columns the SEED — same audio, so the same everything except the roll:
// a different identity, palette and parameter draw.
//
// A row that is uniform means the seed does nothing for this scene. A column
// that is uniform means the song does nothing. A grid that is uniform means the
// scene is a constant, and the diagonal being varied while the rows are flat is
// the shape you get when the palette is doing all the work.
//
// Everything is a real generator output — the same generateLook the exporter
// runs — so the grid shows what would actually be produced, with only the scene
// held fixed instead of chosen.
import { Rng, hashString } from '../engine/rng.js';
import { featureProviderFor } from '../audio/FeatureTrack.js';
import { song, SONGS } from '../audio/songbank.js';
import { generateLook } from '../look/LookGenerator.js';
import { describeIdentity } from '../look/Identity.js';
import { sampleValues } from '../params/schema.js';
import { frameDescriptor, motionDescriptor } from './variety/descriptors.js';
import { descriptorDistance, STRUCTURAL } from './variety/signature.js';
import { THUMB } from './gallery.js';
/** How many songs the bank can offer. The grid cannot ask for more. */
export const MAX_SONGS = SONGS.length;
/** Grid sizes the buttons offer, widest last. */
export const SWEEP_SIZES = [
{ label: '4 × 4', songs: 4, seeds: 4 },
{ label: '6 × 6', songs: 6, seeds: 6 },
{ label: '8 × 8', songs: 8, seeds: 8 },
{ label: '12 × 8', songs: 12, seeds: 8 },
{ label: 'every song × 10', songs: MAX_SONGS, seeds: 10 },
];
// Analysing a song is the slow part of building a grid — seconds each, against
// milliseconds for a look and a render. Re-rendering with a different seed count
// or a different scene must not pay for it twice, so tracks are held for the
// life of the page.
const trackCache = new Map();
/** Which bank entries a grid of this width uses — the spread songBank would pick. */
function specsFor(count) {
const n = Math.min(count, SONGS.length);
if (n >= SONGS.length) return SONGS;
return Array.from({ length: n }, (_, i) => SONGS[Math.round((i * (SONGS.length - 1)) / (n - 1))]);
}
/**
* Analyse the songs a grid needs, one at a time, reporting as each lands.
*
* Analysis is seconds per song and everything after it is milliseconds, so the
* widest grid spends most of its wall time here. Done in one call it froze the
* page for the better part of a minute with a status line nobody could see
* update; one song per turn of the event loop costs nothing and keeps the count
* moving. Cached for the life of the page, so only the first grid pays.
*/
export async function prepareSongs(count, onProgress) {
const specs = specsFor(count);
const tracks = [];
for (let i = 0; i < specs.length; i++) {
const name = specs[i].name;
if (!trackCache.has(name)) trackCache.set(name, song(name));
tracks.push(trackCache.get(name));
if (onProgress) onProgress(i + 1, specs.length, name);
await new Promise((r) => setTimeout(r, 0));
}
return tracks;
}
function tracksFor(count) {
return specsFor(count).map((s) => {
if (!trackCache.has(s.name)) trackCache.set(s.name, song(s.name));
return trackCache.get(s.name);
});
}
/**
* The grid's cells, in row-major order: song by song, seed by seed.
*
* `seedOffset` walks the grid onto a fresh set of seeds without changing its
* shape, which is how you check whether a flat-looking scene is flat or merely
* unlucky ten more draws is a much better answer to that than staring harder
* at the same ten.
*
* @returns {{cells: object[], songNames: string[], seedLabels: string[]}}
*/
export function sweepGrid({ songs = 6, seeds = 6, seedOffset = 0 } = {}) {
const bank = tracksFor(Math.min(songs, MAX_SONGS));
const cells = [];
const seedLabels = [];
for (let s = 0; s < seeds; s++) seedLabels.push(`#${seedOffset + s}`);
for (const entry of bank) {
for (let s = 0; s < seeds; s++) {
// The SAME seed all the way down a column, deliberately.
//
// The first version hashed the song name in as well, so that column
// zero would not be one roll repeated down the grid. That reads
// better and measures nothing: with the seed varying on both axes,
// "across songs" and "across seeds" are the same comparison, and
// measured, they came back within 0.001 of each other for every
// scene tried — two numbers that could never disagree. Holding the
// seed fixed down a column is what makes that column attributable
// to the music.
//
// The identity still differs down a column, because it is derived
// from the track's summary as well as the seed. That is the point:
// the difference that survives a fixed roll is the song's doing.
const seed = hashString(`sweep:${seedOffset + s}`);
const look = generateLook(entry.track, { seed });
// The busiest section: where the scene is asked for the most, and
// where two draws are likeliest to converge on the same picture.
const section = look.sections.reduce(
(best, x) => (x.bias.energy > best.bias.energy ? x : best), look.sections[0]);
cells.push({
name: `${entry.name}${seedLabels[s]}`,
song: entry.name,
seedIndex: seedOffset + s,
seed,
track: entry.track,
palette: look.palette,
personality: look.personality,
bias: section.bias,
frame: section.startFrame + Math.floor((section.endFrame - section.startFrame) * 0.5),
identity: describeIdentity(look.personality.identity),
});
}
}
return { cells, songNames: bank.map((e) => e.name), seedLabels };
}
/**
* Render one scene over every cell, reporting each as it lands.
*
* Progressive on purpose: a wide grid is a hundred and seventy renders plus the
* audio analysis in front of it, and a page that shows nothing until the end of
* that reads as broken.
*/
export async function sweepScene({ engine, module, cells, onCell }) {
const thumbs = [];
const descriptors = [];
for (let i = 0; i < cells.length; i++) {
const ctx = cells[i];
engine.timeline.setDuration(ctx.track.duration);
engine.setFeatureProvider(featureProviderFor(ctx.track));
// Params come from the cell's own draw, exactly as the generator would
// sample them: the song's section bias and the seed's temperament.
const rng = new Rng(ctx.seed ^ hashString(module.name));
const params = sampleValues(module, rng, ctx.bias, ctx.personality.temperament);
engine.setLayerSpecs([{
module, params, seed: rng.int(0, 0x7fffffff),
opacity: 1, blend: 'normal',
palette: ctx.palette, personality: ctx.personality,
}]);
engine.compositor.reset();
for (let f = ctx.frame - 6; f < ctx.frame; f++) engine.renderFrame(f);
const pixels = Uint8Array.from(engine.readPixels(engine.renderFrame(ctx.frame)));
const moved = Uint8Array.from(engine.readPixels(engine.renderFrame(ctx.frame + 5)));
thumbs.push(pixels);
const still = frameDescriptor(pixels, THUMB.width, THUMB.height);
const motion = motionDescriptor(pixels, moved, THUMB.width, THUMB.height);
descriptors.push({ ...still, motion: motion.scale.concat(motion.layout) });
if (onCell) await onCell(i, cells.length, pixels);
}
return { thumbs, ...scoreGrid(descriptors, cells) };
}
/**
* Score the grid three ways, because one number cannot say what is wrong.
*
* overall every cell against every other comparable to the gallery's
* variety score, on the same structural descriptor.
* bySeed cells of the SAME song, different seeds. Low means the seed does
* nothing here: the scene ignores the identity.
* bySong cells at the same seed index, different songs. Low means the song
* does nothing: the scene ignores the music.
*
* Those two are the diagnosis the gallery's single number cannot give, and they
* point at different fixes a flat seed axis is a scene not reading the cast,
* a flat song axis is a scene not reading its features or its bias.
*/
function scoreGrid(descriptors, cells) {
const byBlock = {};
let total = 0, pairs = 0;
let seedSum = 0, seedPairs = 0;
let songSum = 0, songPairs = 0;
let closest = null;
for (let i = 0; i < descriptors.length; i++) {
for (let j = i + 1; j < descriptors.length; j++) {
const d = descriptorDistance(descriptors[i], descriptors[j]);
const structural = STRUCTURAL.reduce((a, b) => a + (d[b] || 0), 0) / STRUCTURAL.length;
for (const b of [...STRUCTURAL, 'colour']) byBlock[b] = (byBlock[b] || 0) + (d[b] || 0);
total += structural;
pairs++;
if (cells[i].song === cells[j].song) { seedSum += structural; seedPairs++; }
if (cells[i].seedIndex === cells[j].seedIndex) { songSum += structural; songPairs++; }
// The two cells that are most alike. On a wide grid this is the only
// practical way to find the pair worth looking at — a hundred and
// seventy thumbnails is past what an eye will compare.
if (!closest || structural < closest.distance) {
closest = { distance: structural, a: i, b: j };
}
}
}
for (const b of Object.keys(byBlock)) byBlock[b] /= pairs || 1;
return {
variety: pairs ? total / pairs : 0,
bySeed: seedPairs ? seedSum / seedPairs : 0,
bySong: songPairs ? songSum / songPairs : 0,
byBlock,
closest,
};
}