A gallery, and a debug index to reach it from

Sixty-five visualizers, six renders each, one per song — each carrying that
song's whole identity: its cast, ink, lattice, palette and a fresh parameter
draw. Side by side, a scene that cannot be changed by the song is obvious in a
way no aggregate could show, which is the complaint this answers.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-18 07:13:54 +02:00
co-authored by Claude Opus 5
parent 4217cca3d3
commit c6d96c9d40
4 changed files with 502 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
// The gallery: every visualizer, six times, on six different songs' content.
//
// The complaint this answers is one a metric could not raise — watching a few
// videos, certain scenes announce themselves. You have seen that one before.
// You cannot tell which ones from a number, because the harness measures whole
// videos and a scene that always looks like itself is averaged in with
// everything around it.
//
// So this renders each scene under six complete identities — six casts, six ink
// treatments, six lattices, six palettes, six parameter draws — and puts them
// side by side. A scene whose six thumbnails are interchangeable is a scene the
// song cannot change, and that is the definition of the problem.
//
// Then it scores them, using the same structural descriptor the variety harness
// runs on, so the gallery can be sorted worst-first. Browsing sixty-six scenes
// looking for the repetitive ones is exactly the job a sort order should do.
import { Engine } from '../engine/Engine.js';
import { scenes } from '../scenes/registry.js';
import { sampleValues } from '../params/schema.js';
import { Rng, hashString } from '../engine/rng.js';
import { featureProviderFor } from '../audio/FeatureTrack.js';
import { songBank } from '../audio/songbank.js';
import { generateLook } from '../look/LookGenerator.js';
import { describeIdentity } from '../look/Identity.js';
import { frameDescriptor } from './variety/descriptors.js';
import { descriptorDistance, STRUCTURAL } from './variety/signature.js';
const THUMB = { width: 256, height: 144 };
/**
* Six contexts, one per song: everything a scene is handed when it is cast.
*
* Built from real bank entries rather than invented, so what the gallery shows
* is what the generator would actually produce — the same identities, palettes
* and section biases, only with the scene held fixed instead of chosen.
*/
export function galleryContexts(count = 6) {
return songBank({ count }).map((entry) => {
const look = generateLook(entry.track, { seed: hashString(entry.name) });
// The busiest section, because that is where a scene is asked for the
// most and where two scenes are most likely to converge.
const section = look.sections.reduce(
(best, s) => (s.bias.energy > best.bias.energy ? s : best), look.sections[0]);
return {
name: entry.name,
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),
};
});
}
/** Bottom-up WebGL pixels into a canvas the right way up. */
function blit(canvas, pixels, width, height) {
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
const image = ctx.createImageData(width, height);
const row = width * 4;
for (let y = 0; y < height; y++) {
const src = (height - 1 - y) * row;
image.data.set(pixels.subarray(src, src + row), y * row);
}
ctx.putImageData(image, 0, 0);
}
/**
* Render one scene across every context.
*
* @returns {{thumbs: Uint8Array[], variety: number, byBlock: object}}
*/
export function renderScene(engine, module, contexts) {
const thumbs = [];
const descriptors = [];
for (const ctx of contexts) {
engine.timeline.setDuration(ctx.track.duration);
engine.setFeatureProvider(featureProviderFor(ctx.track));
const rng = new Rng(hashString(`${module.name}:${ctx.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();
// A few frames of warm-up so anything with state is past its first frame.
for (let f = ctx.frame - 6; f < ctx.frame; f++) engine.renderFrame(f);
const pixels = Uint8Array.from(engine.readPixels(engine.renderFrame(ctx.frame)));
thumbs.push(pixels);
descriptors.push(frameDescriptor(pixels, THUMB.width, THUMB.height));
}
// How different this scene's six outputs are from each other, on the same
// structural descriptor the variety harness uses — colour excluded, because
// six palettes would otherwise make every scene look varied.
const byBlock = {};
let total = 0, pairs = 0;
for (let i = 0; i < descriptors.length; i++) {
for (let j = i + 1; j < descriptors.length; j++) {
const d = descriptorDistance(descriptors[i], descriptors[j]);
for (const b of [...STRUCTURAL, 'colour']) byBlock[b] = (byBlock[b] || 0) + (d[b] || 0);
total += STRUCTURAL.reduce((a, b) => a + (d[b] || 0), 0) / STRUCTURAL.length;
pairs++;
}
}
for (const b of Object.keys(byBlock)) byBlock[b] /= pairs || 1;
return { thumbs, variety: pairs ? total / pairs : 0, byBlock };
}
/**
* Build the whole gallery, reporting progress as it goes.
*
* @param {(done:number, of:number, name:string, row:object) => void} onScene
*/
export async function buildGallery({ contexts, onScene, only = null } = {}) {
const list = only
? scenes.filter((m) => only.includes(m.name))
: scenes.filter((m) => m.kind === 'fragment');
const engine = new Engine({ ...THUMB });
const rows = [];
try {
for (let i = 0; i < list.length; i++) {
const module = list[i];
let row;
try {
row = { module, ...renderScene(engine, module, contexts) };
} catch (err) {
row = { module, thumbs: [], variety: 0, byBlock: {}, error: err.message };
}
rows.push(row);
if (onScene) onScene(i + 1, list.length, module.name, row);
// Yield so the page can paint each row as it lands rather than
// freezing for a minute and then showing everything at once.
await new Promise((r) => setTimeout(r, 0));
}
} finally {
engine.dispose();
}
return rows;
}
export { blit, THUMB };