A minimum variety bar in the gallery, drawn as a red line

A visualizer that looks the same wherever it appears does not just fail to vary
— it leaks between songs, because it is cast into many of them and the viewer
who watches two videos ends up recognising the shot rather than the song. That
makes the floor a property of the scene, so the gallery now states it and the
scene has to clear it.

0.04 to start with, and honestly a starting bar rather than a derived one: set
where the flat cluster measurably sits, to be raised as scenes are lifted to it.

Drawn twice, because the two readings are different questions. Every meter
carries a red marker at the bar, so a single row can be judged on its own. And
in the default sort the list is cut by one red line where the scores cross it,
which answers the browsing question — everything above that line repeats itself
between songs.

Also restored progressive drawing, which the caching rewrite had quietly lost:
every draw had moved to the end of the build, leaving three minutes of spinner
and nothing to look at. The first rows are the interesting ones, since the sort
puts the repetitive scenes on top.

Verified by seeding the cache with rows spanning the bar and reloading: four
below, four above, the line between them, the marker at 20% of each meter, and
the grade colours switching at the right place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-18 16:29:09 +02:00
parent e2e5104646
commit 71ddd37711
2 changed files with 54 additions and 28 deletions

View File

@ -47,8 +47,10 @@
.name { font-size: 14px; color: #e6eaf2; }
.fam { color: #6b7280; }
.score { color: #9aa3b5; }
.meter { display: inline-block; width: 130px; height: 7px; background: #1a1e28; border-radius: 4px; overflow: hidden; vertical-align: middle; }
.meter i { display: block; height: 100%; background: #4ade80; }
.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; }
@ -57,6 +59,11 @@
.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>
@ -87,7 +94,7 @@
<div id="out"></div>
<script type="module">
import { buildGallery, galleryContexts, THUMB } from '/src/checks/gallery.js';
import { buildGallery, galleryContexts, THUMB, MIN_VARIETY } from '/src/checks/gallery.js';
import {
sourceFingerprint, loadGallery, saveGallery, clearGallery,
pixelsToBlob, blobToCanvas,
@ -103,7 +110,9 @@ const rebuildBtn = document.getElementById('rebuild');
let rows = []; // { name, family, role, variety, byBlock, blobs, error }
let contexts = [];
const grade = (v) => (v < 0.05 ? 'flat' : v < 0.10 ? 'thin' : 'good');
// 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) => (v < MIN_VARIETY ? 'flat' : v < MIN_VARIETY * 2 ? 'thin' : 'good');
function draw() {
const mode = sortSel.value;
@ -113,7 +122,17 @@ function draw() {
: 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)}`;
const blocks = Object.entries(row.byBlock || {})
@ -123,7 +142,7 @@ function draw() {
<span class="name">${row.name}</span>
<span class="fam">${row.family}${row.role === 'accent' ? ' · accent' : ''}</span>
<span class="score">
<span class="meter"><i style="width:${Math.min(100, row.variety * 500)}%"></i></span>
<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>
@ -150,8 +169,8 @@ function draw() {
sortSel.addEventListener('change', draw);
function summarise(built) {
const flat = rows.filter((r) => r.variety < 0.05).length;
return `${rows.length} scenes · ${flat} barely change across songs` +
const flat = rows.filter((r) => r.variety < MIN_VARIETY).length;
return `${rows.length} scenes · ${flat} below the ${MIN_VARIETY} bar` +
(built ? ` · built ${new Date(built).toLocaleString()}` : '');
}
@ -170,33 +189,26 @@ async function build(fingerprint) {
.map((c) => `<span title="${c.identity}">${c.name}</span>`).join('');
const started = Date.now();
const built = await buildGallery({
await buildGallery({
contexts,
onScene: (done, total, name) => {
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,
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();
},
});
status.textContent = 'compressing…';
await new Promise((r) => setTimeout(r, 0));
rows = [];
for (const row of built) {
const blobs = [];
for (const pixels of row.thumbs) {
blobs.push(await pixelsToBlob(pixels, THUMB.width, THUMB.height));
}
rows.push({
name: row.module.name,
family: row.module.family,
role: row.module.role || 'stage',
variety: row.variety,
byBlock: row.byBlock,
error: row.error,
blobs,
});
}
const payload = {
fingerprint,
built: Date.now(),

View File

@ -28,6 +28,20 @@ import { descriptorDistance, STRUCTURAL } from './variety/signature.js';
const THUMB = { width: 256, height: 144 };
/**
* The floor a visualizer has to clear: how different its own six frames must be
* from each other, across six songs' identities.
*
* Below this a scene is the same picture wherever it appears, and because it is
* cast into many songs that repetition leaks between them the viewer who
* watches two videos recognises the shot rather than the song. It is a property
* of the scene, not of the generator, so it is the scene that has to be lifted.
*
* 0.04 is a starting bar rather than a derived one. Set where the flat cluster
* measurably sits, to be raised as scenes are brought up to it.
*/
export const MIN_VARIETY = 0.04;
/**
* Six contexts, one per song: everything a scene is handed when it is cast.
*