Files
music-video-gen/flow-state/src/checks/metadata.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

257 lines
12 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.
// Measuring every visualizer, and writing the answers back into the repo.
//
// The library has always had two kinds of fact about a scene. Declared ones —
// family, traits, `consumes` — which say what the scene is FOR, and measured
// ones, which say what it actually does when rendered. Declared facts belong in
// the scene file. Measured facts do not: hand-written, they drift the moment a
// shader changes, and nine scenes declaring `surface: 'canvas'` while painting
// under a third of the frame is what that drift looks like.
//
// So the measured half lives in scenes/metadata.json, generated from here,
// tracked in git, and stamped with a fingerprint of everything that could
// change it. When the fingerprint stops matching, the numbers are stale and the
// phase 12 gate says so — the file is a cache of a render, and a cache nobody
// can tell is stale is worse than no cache.
//
// The measurement is the GALLERY's: six songs, sampled parameters, real
// identities and palettes — a scene as it is actually cast, not as it renders
// at default parameters. The difference is not academic. Salt Flat paints 65%
// of the frame at defaults and 32% across six real songs, and the generator
// chooses grounds with this number.
import { Engine } from '../engine/Engine.js';
import { scenes } from '../scenes/registry.js';
import { galleryContexts, renderScene, THUMB } from './gallery.js';
import { readsHistory } from '../params/schema.js';
import { GROUND_BIAS, groundTemperamentFrom, groundPersonalityFrom } from '../scenes/surface.js';
import metadata from '../scenes/metadata.json';
import { METRIC_KEYS } from './scene-metrics.js';
/**
* What invalidates the measurements.
*
* Deliberately NOT every file under src/, which is what the gallery cache
* fingerprints: that changes when the UI changes, and it would mark the
* metadata stale for edits that cannot move a single number. What can move one
* is the scenes themselves, the contract they are compiled against, the
* identities and palettes they are handed, and the metric definitions — so
* those, and nothing else.
*
* Globbed rather than listed wherever a whole directory qualifies, because the
* file that invalidates a measurement is exactly the one nobody remembers to
* add to a list.
*/
const SOURCES = {
...import.meta.glob('/src/scenes/**/*.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/checks/variety/descriptors.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/checks/variety/signature.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/checks/gallery.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/checks/scene-metrics.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/checks/metadata.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/engine/shader-contract.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/engine/agitation.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/engine/flash.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/engine/Layer.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/comfort.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/scene-filter.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/composition-validator.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/scenes/surface.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/Identity.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/Personality.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/look/palette.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/actors/**/*.js', { query: '?raw', import: 'default', eager: true }),
...import.meta.glob('/src/audio/songbank.js', { query: '?raw', import: 'default', eager: true }),
};
/** The version of the measurement itself. Bump to force a refresh of everything. */
export const SCHEMA = 7;
export function metricsFingerprint() {
let h = 0x811c9dc5 >>> 0;
const mix = (str) => {
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
};
mix(`schema:${SCHEMA}`);
for (const path of Object.keys(SOURCES).sort()) {
mix(path);
mix(SOURCES[path]);
}
return h.toString(16).padStart(8, '0');
}
/** Whether the checked-in metadata was measured from the code that is here now. */
export function metadataIsFresh() {
return metadata.fingerprint === metricsFingerprint();
}
/** Anything a viewer would read as painted rather than as backdrop. */
function litFraction(pixels) {
let lit = 0;
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i] + pixels[i + 1] + pixels[i + 2] > 90) lit++;
}
return lit / (pixels.length / 4);
}
const round = (x, places = 4) => Number(x.toFixed(places));
/** Mean of the six per-context descriptors, block by block. */
function meanProfile(descriptors) {
const out = {};
for (const block of Object.keys(descriptors[0])) {
const length = descriptors[0][block].length;
const acc = new Array(length).fill(0);
for (const d of descriptors) {
for (let i = 0; i < length; i++) acc[i] += d[block][i] / descriptors.length;
}
out[block] = acc.map((v) => round(v));
}
return out;
}
/**
* Measure the whole library.
*
* Per scene: how much frame it paints, how much it changes between songs, and
* its mean structural profile — the descriptor the variety harness compares
* videos with, averaged over the six renders. The profile is what makes this
* more than a list of numbers: two profiles can be compared, so the generator
* can ask whether a shot and the thing under it are the same picture twice.
*
* @returns {object} the metadata file's contents
*/
export function measureLibrary({ onScene = null, contexts = null } = {}) {
const ctx = contexts || galleryContexts(6);
// The same six songs, sampled the way a BED is. Coverage is mostly a
// function of a scene's parameters, so "how much does this paint" has two
// answers and the generator needs both: one for the budget, and one for
// whether it may be a ground at all. See GROUND_BIAS.
const bedCtx = ctx.map((c) => ({
...c,
bias: { ...c.bias, ...GROUND_BIAS },
personality: {
...groundPersonalityFrom(c.personality),
temperament: groundTemperamentFrom(c.personality.temperament),
},
}));
const engine = new Engine({ ...THUMB });
const out = {};
try {
const list = scenes.filter((m) => m.kind === 'fragment' || m.kind === 'model');
for (const module of list) {
const { thumbs, variety, byBlock, descriptors, error, agitation, flashPeak, motionPeak, motionMean, structPeak, structMean, metrics } = renderScene(engine, module, ctx);
const bed = renderScene(engine, module, bedCtx);
// Round ranged metrics to 3dp (4dp for agitation trio) — small, stable diffs
const roundedMetrics = {};
if (metrics) {
for (const [k, r] of Object.entries(metrics)) {
roundedMetrics[k] = {
mean: round(r.mean, 3), min: round(r.min, 3), max: round(r.max, 3),
std: round(r.std, 3), p10: round(r.p10, 3), p90: round(r.p90, 3),
};
}
}
out[module.name] = {
coverage: round(thumbs.reduce((s, px) => s + litFraction(px), 0) / thumbs.length, 3),
// The WORST of the six, not the mean. What a ground has to
// promise is a filled frame in the video it lands in, and the
// spread across identities is enormous: a track whose ink
// treatment is `hollow` draws outlines instead of fills, so a
// scene that paints 61% averaged over six songs paints 2% in
// the one that asked for outlines — measured, and it is how a
// section with a ground under it still rendered near-black.
// A mean cannot make a promise; a minimum can.
bedCoverage: round(Math.min(...bed.thumbs.map(litFraction)), 3),
bedCoverageMean: round(
bed.thumbs.reduce((s, px) => s + litFraction(px), 0) / bed.thumbs.length, 3),
variety: round(variety, 3),
blocks: Object.fromEntries(
Object.entries(byBlock).map(([b, v]) => [b, round(v, 3)])),
// Legacy flat (busiest-context) — compat with existing comfort.js:agitationOf
agitation: round(agitation || 0, 3),
flashPeak: round(flashPeak || 0, 2),
motionPeak: round(motionPeak || 0, 3),
motionMean: round(motionMean || 0, 3),
structPeak: round(structPeak || 0, 3),
structMean: round(structMean || 0, 3),
// Ranged metrics — 12+ keys × {mean,min,max,std,p10,p90}
// Vast preliminary library the director filters before casting
metrics: roundedMetrics,
// Declared, not measured, and carried here anyway: it is a fact
// about the scene that the composition rules read, and having
// every compositional input in one file is the point.
readsHistory: readsHistory(module),
profile: meanProfile(descriptors),
...(error ? { error } : {}),
};
if (onScene) onScene(Object.keys(out).length, list.length, module.name);
}
} finally {
engine.dispose();
}
return {
fingerprint: metricsFingerprint(),
schema: SCHEMA,
measured: new Date().toISOString().slice(0, 10),
contexts: ctx.map((c) => c.name),
scenes: out,
};
}
/** What moved against the checked-in file. */
export function metadataDrift(fresh) {
// Tolerates a missing or half-written file on purpose: this runs on the way
// to REPLACING it, and refusing to report because the thing being replaced
// is malformed is the least useful moment to be strict.
const previous = (metadata && metadata.scenes) || {};
const moved = [];
for (const [name, row] of Object.entries(fresh.scenes)) {
const was = previous[name];
if (!was) {
moved.push({ name, note: 'new' });
continue;
}
const delta = row.coverage - was.coverage;
if (Math.abs(delta) > 0.02) {
moved.push({
name, note: `coverage ${(was.coverage * 100).toFixed(0)}% → ${(row.coverage * 100).toFixed(0)}%`,
delta,
});
}
const aNew = row.agitation ?? 0;
const aWas = was.agitation ?? 0;
const aDelta = aNew - aWas;
if (Math.abs(aDelta) > 0.08) {
moved.push({
name, note: `agitation ${aWas.toFixed(2)} → ${aNew.toFixed(2)}`,
delta: aDelta,
});
}
}
const gone = Object.keys(previous).filter((n) => !fresh.scenes[n]);
return {
moved: moved.sort((a, b) => Math.abs(b.delta || 0) - Math.abs(a.delta || 0)),
gone,
};
}
/**
* Ask the dev server to write the file back into the source tree.
*
* Dev-only by construction — the endpoint is a middleware in vite.config.js. A
* built page has no source tree to write to, and failing there is correct.
*/
export async function writeMetadata(fresh) {
const response = await fetch('/__metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fresh, null, 2) + '\n',
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
return response.text();
}