Files
music-video-gen/flow-state/src/checks/gallery.js
T
DejvinoandClaude 7736390f9c Comfort: agitation ceiling, composition validator and whole-screen caps
Whole-screen flashing/coherent motion is now a gated comfort signal
rather than a hunt-and-tweak. Three-arm agitation (flash/3,
tile-motion/0.32, structure/0.12) over a 1s floating window, worst
window wins — engine/agitation.js.

Policy centralised in look/comfort.js: director comfort appetite,
per-scene agitationOf proxy, agitationWeight lean and stackAgitation.
Canvas beat/flux capped in Layer.resolveParams (0.15/0.25, spike→smooth)
so one contract change tames every full-frame scene; lint warns above
the cap.

Selection now filters before casting (look/scene-filter.js, 12+ ranged
metrics from checks/scene-metrics.js, SCHEMA 7) and validates after
stacking (look/composition-validator.js) with a deterministic retune:
drop overlay → swap quiet composable → reselect filtered shot. Fix
validator attempt-3 ground-layer bug that crashed ?song=dusk
(LookGenerator now passes module, not layer).

Eight canvas shaders window their beat/flux (focusField + per-seed hash
mask) and report the scaled amount to Layer so the cap stays authoritative:
scan-tear, gate-corridor, analog-wow, block-mosh, metaballs,
pitch-shatter, scale-mosaic, tide-rings.

Checks: scene-gate comfort gate (agitation ≤ 0.70), filmstrip/gallery
meters + per-scene drawer, metadata SCHEMA 7 with ranged metrics and
fingerprint over agitation/comfort sources. main.js surfaces
COMPOSITION_REFUSED as a user-facing error.

Lint: 114 files clean, 71 scenes ok. All 17 songs generate looks.
Build passes. metadata.json refresh deferred to gallery → Refresh
metadata (SCHEMA 7 writer in vite.config).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-13 19:42:50 +02:00

328 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 { blit } from './blit.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, motionDescriptor } from './variety/descriptors.js';
import { frameLuminance, frameVariance } from '../engine/hash.js';
import { descriptorDistance, STRUCTURAL } from './variety/signature.js';
import { generateActor } from '../actors/ActorGenerator.js';
import { agitationScore } from '../engine/agitation.js';
import { probesForContext, ranged, METRIC_KEYS } from './scene-metrics.js';
const THUMB = { width: 256, height: 144 };
/*
* There used to be a MIN_VARIETY floor here — 0.1, drawn as a red line across
* the gallery — on the theory that a scene which looks the same in every song
* leaks that sameness between videos.
*
* That was true when a section was ONE scene. It is not any more: a section is
* a ground, a shot over it and sometimes a pass over that, so what a viewer
* sees is a combination, and a scene that is reliably itself is a perfectly
* good ingredient in one. Held against a floor, such scenes were failing for
* being consistent.
*
* The score is still measured, still reported, and still what the gallery sorts
* by — it is genuinely the right question to ask about a scene you are working
* on. It is no longer a bar anything has to clear, and the interesting quantity
* moved up a level: how unalike the scenes in one stack are. That lives in
* scenes/metadata.json and is read by the look generator.
*/
/**
* 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),
};
});
}
/**
* 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);
const actorRng = new Rng(rng.fork(`actor:${module.actor || 'model'}`).seed);
const summary = ctx.track.summary;
const actorSpec = module.kind === 'model' && module.actor
? generateActor({ summary, rng: actorRng, archetype: module.actor, personality: ctx.personality, identity: ctx.personality.identity })
: null;
engine.setLayerSpecs([{
module,
params,
seed: rng.int(0, 0x7fffffff),
opacity: 1,
blend: 'normal',
palette: ctx.palette,
personality: ctx.personality,
actorSpec,
}]);
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)));
// A second frame, so MOTION is measured rather than silently scored zero.
//
// The descriptor has five structural blocks and this only ever built
// four of them, so `motion` came back 0.000 for every scene in the
// library and dragged the mean down by a fifth across the board. It
// looked like a property of the scenes; it was a missing render.
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) });
}
// 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;
// How much of the frame this scene paints, averaged over the six. Free —
// the pixels are already here — and it is the number that explains the
// ranking: a scene covering 2% of the frame is a few bright things on
// black, which scores well for variety and is thin to watch on its own.
// Whether that is a problem depends on whether it is ever layered.
let covered = 0;
for (const px of thumbs) {
let lit = 0;
for (let i = 0; i < px.length; i += 4) {
if (px[i] + px[i + 1] + px[i + 2] > 90) lit++;
}
covered += lit / (px.length / 4) / thumbs.length;
}
// A dead shader scores zero on every block, which is indistinguishable from
// a very boring scene if you only read the number — and it happened: the
// subject helpers were declared above the ink they call, the whole preamble
// failed to compile, and every scene rendered black while the gallery
// reported a variety of exactly 0.000. Say which it is.
const lum = frameLuminance(thumbs[0]);
const variance = frameVariance(thumbs[0]);
const dead = lum < 0.002 || variance < 0.001;
// --- per-visualizer 12+ ranged metrics + legacy flat agitation --------
// Legacy flat agitation/flashPeak/motionPeak/structPeak: busiest context
// only (cheapest, what existing gallery.html reads). Ranged metrics:
// 6 contexts × 90-frame strip each → {mean,min,max,std,p10,p90} per key
// (see src/checks/scene-metrics.js). Vast preliminary library the director
// filters BEFORE casting; some songs simply won't use certain scenes.
let agitation = 0;
let flashPeak = 0;
let motionPeak = 0;
let motionMean = 0;
let structPeak = 0;
let structMean = 0;
let metrics = null;
if (!dead && contexts && contexts.length) {
try {
// Busiest context = most energetic bias — legacy flat value.
let probe = contexts[0];
for (const c of contexts) {
if ((c.bias.energy || 0) > (probe.bias.energy || 0)) probe = c;
}
engine.timeline.setDuration(probe.track.duration);
engine.setFeatureProvider(featureProviderFor(probe.track));
const arng = new Rng(hashString(`${module.name}:agitation:${probe.name}`));
const aparams = sampleValues(module, arng, probe.bias, probe.personality.temperament);
const aactorRng = new Rng(arng.fork(`actor:${module.actor || 'model'}`).seed);
const aactorSpec = module.kind === 'model' && module.actor
? generateActor({ summary: probe.track.summary, rng: aactorRng, archetype: module.actor, personality: probe.personality, identity: probe.personality.identity })
: null;
engine.setLayerSpecs([{
module,
params: aparams,
seed: arng.int(0, 0x7fffffff),
opacity: 1,
blend: 'normal',
palette: probe.palette,
personality: probe.personality,
actorSpec: aactorSpec,
}]);
engine.compositor.reset();
for (let f = probe.frame - 6; f < probe.frame; f++) engine.renderFrame(f);
const STRIP = 90; // 1.5 s @ 60 fps — catches Voronoi recut between phrase windows
const stripFrames = [];
const luminances = [];
for (let f = probe.frame; f < probe.frame + STRIP; f++) {
const pix = Uint8Array.from(engine.readPixels(engine.renderFrame(f)));
stripFrames.push(pix);
luminances.push(frameLuminance(pix));
}
const score = agitationScore(luminances, stripFrames, 60, { width: THUMB.width, height: THUMB.height });
agitation = score.agitation;
flashPeak = score.peakFlashRate;
motionPeak = score.peakMotion;
motionMean = score.meanMotion;
structPeak = score.peakStructure;
structMean = score.meanStructure;
} catch {
// leave agitation at 0 — gallery will show 0.000 rather than crash
}
// Ranged metrics across all 6 contexts (one 90-frame strip each, just once)
try {
const perContext = [];
for (const ctx of contexts) {
engine.timeline.setDuration(ctx.track.duration);
engine.setFeatureProvider(featureProviderFor(ctx.track));
const crng = new Rng(hashString(`${module.name}:metrics:${ctx.name}`));
const cparams = sampleValues(module, crng, ctx.bias, ctx.personality.temperament);
const cActorRng = new Rng(crng.fork(`actor:${module.actor || 'model'}`).seed);
const cActorSpec = module.kind === 'model' && module.actor
? generateActor({ summary: ctx.track.summary, rng: cActorRng, archetype: module.actor, personality: ctx.personality, identity: ctx.personality.identity })
: null;
engine.setLayerSpecs([{
module,
params: cparams,
seed: crng.int(0, 0x7fffffff),
opacity: 1,
blend: 'normal',
palette: ctx.palette,
personality: ctx.personality,
actorSpec: cActorSpec,
}]);
engine.compositor.reset();
for (let f = ctx.frame - 6; f < ctx.frame; f++) engine.renderFrame(f);
const MSTRIP = 90;
const mFrames = [];
const mLums = [];
for (let f = ctx.frame; f < ctx.frame + MSTRIP; f++) {
const px = Uint8Array.from(engine.readPixels(engine.renderFrame(f)));
mFrames.push(px);
mLums.push(frameLuminance(px));
}
perContext.push(probesForContext({ luminances: mLums, stripFrames: mFrames, width: THUMB.width, height: THUMB.height }));
}
metrics = {};
for (const key of METRIC_KEYS) {
const vals = perContext.map((p) => p[key]).filter((v) => typeof v === 'number' && Number.isFinite(v));
metrics[key] = ranged(vals);
}
} catch {
// metrics stays null — gallery shows legacy flat values
}
}
return {
thumbs,
// The six per-context descriptors, so a caller can average them into a
// profile rather than re-render the library to get one. See
// checks/metadata.js — this function is the only place the scenes are
// rendered under real identities, and everything measured about a scene
// comes out of here.
descriptors,
variety: pairs ? total / pairs : 0,
coverage: covered,
byBlock,
agitation,
flashPeak,
motionPeak,
motionMean,
structPeak,
structMean,
metrics,
error: dead
? `renders nothing — luminance ${lum.toFixed(4)}, variance ${variance.toFixed(4)}. ` +
'A failed shader compile scores 0.000 on every block; check the console for GLSL errors.'
: undefined,
};
}
/**
* 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' || m.kind === 'model');
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 };