music-video-gen/flow-state/tools/lint-scenes.js
Dejvino 2806ef1386 Phase 10: variety, twelve scenes, and tooling to write the next one
Watching several finished tracks side by side turned up the problem neither
Phase 8 (too few cuts) nor Phase 9 (no through-line) addressed: the same
scene cast in two different videos looked like the same footage twice.
Section bias is nearly identical between two tracks' drops, so both sampled
their parameters around the same centre, and the library's own averageness
did the rest.

Three answers, none of them a new scene:

  Temperament — a per-track hand on every parameter dial: intensity, pace,
  detail, and an extremity that decides how far toward the ends of a range
  the track is willing to sample. Bias comes from the section and is shared
  between tracks; temperament comes from the track and is not.

  Overlays — sometimes a second full scene composited over the shot, from a
  different family, in a blend that preserves what is underneath and never
  above 0.6 opacity. Not always: a stack that always doubled up would read
  as permanently cluttered rather than as occasionally layered.

  A wider palette — hue now derives from SPECTRAL TILT, the log ratio of
  treble to body. The centroid is a number most masters sit in the middle
  of, and the plain body/(body+treble) fraction is worse: low frequencies
  carry most of the energy in all music, so it read 0.98-1.00 for
  everything and four different battery tracks came out within 0.02 of
  each other. The ratio is multiplicative, so its logarithm is what
  spreads — the same four measure -9.3, -5.0, -4.1, -3.8. Also both ways
  round the wheel (violet, magenta and pink were unreachable by
  construction), four new schemes, and seeded chroma profile and lightness
  curve. Closest battery pair went from 0.005 to 0.113.

Twelve scenes take the library to 36, six per family: Aurora Veil, Vortex
Drift, Tide Rings, Ink Bleed, Dust Chamber, Salt Flat, Cargo Belt, Gate
Corridor, Circuit Bloom, Truchet Fold, Signal Decay, Storm Rift. Weighted
toward the 'space' and 'shape' traits, which were thinnest and so the
signatures most likely to run a track out of cast — the Phase 9 casting
rule means the pool a track draws from is smaller than the library.

Also fixes a real one in shots.js: heavy LRU weighting was not enough to
make a section reach its whole roster, and a five-shot section still came
out 0,2,0,2,0 about a fifth of the time. An unseen companion now wins
outright; which one is still free, so only the coverage is guaranteed.

Block Mosh declared the camera trait, assigned sigCamera(p) to a p it then
never read, and passed the lint's evidence grep. The Phase 9 render gate
measured its response to the camera at exactly zero.

--- tooling ---

Adding a scene was mostly boilerplate and round-trips, which is expensive
in both senses. The irreducible cost is the shader body; everything around
it is now mechanical:

  npm run new:scene -- "Name" --family=... --traits=...

writes the module, registers it, and leaves a skeleton that already passes
every gate, with name-derived constants so two skeletons are not twins.

The lint grew the rules that previously needed a GPU to catch: the dead
camera above, prev() with no base image, and large loops with no early
break (with a `// lint: fixed-cost` opt-out for a genuinely fixed-cost
sampling loop). checks.html?scene=Name runs the per-scene acceptance
battery for one scene — ten lines and a verdict instead of rendering the
whole library to find out whether one shader is alive. The same procedure
is a repo skill under .claude/skills/build-visualizer/.

--- checks changed, with the measurements ---

P5 determinism compared two WebGL CONTEXTS, which is not what it is for.
Measured: one context is bit-exact over 40 frames with feedback at 0.6;
two contexts disagree by up to 2/255 whether feedback is on or off. It now
asserts generation is byte-identical (hard) and rendering within 2/255,
since feedback compounds single-level variance.

P10's cross-track comparison measures distance RELATIVE to how much image
there is. Most scenes are mostly dark, so two genuinely different renders
— 25 bars against 53 — scored under 0.02 absolute purely because the black
background agrees with itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:59:20 +02:00

265 lines
12 KiB
JavaScript

#!/usr/bin/env node
// Static gates that don't need a GPU:
//
// 1. Determinism grep — no wall-clock or unseeded randomness in engine/scene code.
// 2. Scene schema lint — the params block and the shader source must agree,
// in BOTH directions. A declared param that no shader reads is dead weight;
// a uniform the shader reads that nothing declares is a silent zero, which
// is the single most annoying way for a scene to look subtly wrong.
//
// Run: npm run lint:scenes
import { readdirSync, readFileSync, statSync } from 'fs';
import { join, relative, dirname } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const SRC = join(root, 'src');
let failures = 0;
const fail = (msg) => { console.error(`${msg}`); failures++; };
const ok = (msg) => console.log(`${msg}`);
function walk(dir, out = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full, out);
else if (entry.endsWith('.js')) out.push(full);
}
return out;
}
// ---------------------------------------------------------------- grep gate
const FORBIDDEN = [
{ pattern: /\bMath\.random\s*\(/, name: 'Math.random()', why: 'use Rng — unseeded randomness breaks reproducibility' },
{ pattern: /\bperformance\.now\s*\(/, name: 'performance.now()', why: 'use Timeline — wall clock breaks preview/export parity' },
{ pattern: /\bDate\.now\s*\(/, name: 'Date.now()', why: 'use Timeline' },
{ pattern: /\bnew Date\s*\(/, name: 'new Date()', why: 'use Timeline' },
];
// Directories whose output must be a pure function of (seed, params, frame).
const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params'];
// Files legitimately allowed a wall clock: perf measurement, not image content.
const ALLOWED = new Set(['engine/perf.js']);
console.log('\ndeterminism grep');
{
let checked = 0;
for (const dir of DETERMINISTIC_DIRS) {
const full = join(SRC, dir);
let files;
try { files = walk(full); } catch { continue; }
for (const file of files) {
const rel = relative(SRC, file).replace(/\\/g, '/');
if (ALLOWED.has(rel)) continue;
checked++;
const source = readFileSync(file, 'utf8');
const lines = source.split('\n');
lines.forEach((line, i) => {
if (/^\s*(\/\/|\*)/.test(line)) return; // comments may name them
for (const f of FORBIDDEN) {
if (f.pattern.test(line)) fail(`${rel}:${i + 1} uses ${f.name}${f.why}`);
}
});
}
}
if (!failures) ok(`${checked} files clean of wall-clock and unseeded randomness`);
}
// ------------------------------------------------------------- scene lint
const CONTRACT_UNIFORMS = new Set([
'u_resolution', 'u_aspect', 'u_pixelScale', 'u_time', 'u_frame', 'u_progress',
'u_seed', 'u_opacity', 'u_colors', 'u_colorCount', 'u_prev', 'u_hasPrev',
'u_loudness', 'u_rms', 'u_bandSub', 'u_bandLow', 'u_bandMid', 'u_bandHigh',
'u_bandAir', 'u_flux', 'u_centroid', 'u_flatness', 'u_width', 'u_beat',
'u_beatPhase', 'u_barPhase', 'u_phrasePhase', 'u_sectionProgress',
'u_sectionEnergy', 'u_buildSlope',
'u_sigSides', 'u_sigRound', 'u_sigElong', 'u_sigTilt',
'u_sigDrift', 'u_sigSway', 'u_sigSwayRate', 'u_sigSpin', 'u_sigBreathe',
'u_sigHorizon', 'u_sigDepth', 'u_sigWash',
'u_sigLine', 'u_sigSoft', 'u_sigTexture', 'u_sigFold',
]);
/**
* What counts as honouring a personality trait, in shader source.
*
* The disqualification rule in look/Personality.js is only as good as these
* declarations: a scene that claims `shape` and draws circles anyway will be
* cast in the hexagon video and be the one shot that looks filmed elsewhere.
* So the claim is machine-checked against the source rather than trusted.
*/
const TRAIT_EVIDENCE = {
shape: /\bsig(Shape|Form)\s*\(/,
camera: /\bsigCamera\s*\(/,
space: /\b(sigHorizonY|sigAir)\s*\(|\bu_sig(Horizon|Depth|Wash)\b/,
style: /\b(sigEdge|sigGrain|sigFolded)\s*\(|\bu_sig(Line|Soft|Texture|Fold)\b/,
};
console.log('\nscene schema lint');
{
const { scenes, FAMILIES } = await import(pathToFileURL(join(SRC, 'scenes/registry.js')).href);
const { validateModule } = await import(pathToFileURL(join(SRC, 'params/schema.js')).href);
if (!scenes.length) fail('no scenes registered');
const seenNames = new Set();
for (const module of scenes) {
const id = module.name || '<unnamed>';
for (const err of validateModule(module)) fail(err);
if (seenNames.has(id)) fail(`duplicate scene name '${id}'`);
seenNames.add(id);
if (module.family && !FAMILIES[module.family]) {
fail(`${id}: unknown family '${module.family}'`);
}
if (module.kind !== 'fragment') {
// 3D modules honour traits in JS, against the `personality` handed
// to update(); there is no shader source to grep, so the evidence
// check is just that they read it at all.
if ((module.traits || []).length && !/personality/.test(String(module.update))) {
fail(`${id}: declares traits but update() never reads \`personality\``);
}
continue;
}
const src = module.shader || '';
const declared = new Map();
for (const [name, def] of Object.entries(module.params || {})) {
if (def.uniform) declared.set(def.uniform, name);
}
// Collision with the shader contract. A param that reuses a contract
// uniform name (u_width, u_time, u_seed...) is a GLSL redefinition error,
// and the whole scene renders as a black frame with no other symptom.
for (const [uniform, param] of declared) {
if (CONTRACT_UNIFORMS.has(uniform)) {
fail(`${id}: param '${param}' uses '${uniform}', which the shader ` +
`contract already declares — pick another name`);
}
}
// Direction 1: every declared uniform is actually read by the shader.
for (const [uniform, param] of declared) {
const used = new RegExp(`\\b${uniform}\\b`).test(src);
if (!used) fail(`${id}: param '${param}' declares ${uniform}, but the shader never reads it`);
}
// Direction 2: every u_* the shader reads is declared somewhere.
const referenced = new Set(src.match(/\bu_[A-Za-z0-9_]+\b/g) || []);
for (const uniform of referenced) {
if (CONTRACT_UNIFORMS.has(uniform)) continue;
if (declared.has(uniform)) continue;
fail(`${id}: shader reads ${uniform}, which no param declares (it will silently be 0)`);
}
// Personality traits: declaring one is a promise to express it.
for (const trait of module.traits || []) {
const evidence = TRAIT_EVIDENCE[trait];
if (evidence && !evidence.test(src)) {
fail(`${id}: declares trait '${trait}' but the shader never uses it — ` +
`either express it or drop the claim, or the scene will be cast ` +
`in tracks built on something it ignores`);
}
}
// A dead camera: `p = sigCamera(p)` and then nothing reads p again.
// This passed the evidence grep above, passed review, and shipped — the
// Phase 9 render gate later measured the scene's response to the camera
// at exactly zero. Cheaper to catch here than on a GPU.
const cameraAssign = src.match(/(\w+)\s*=\s*sig(?:Camera|Folded)\s*\([^;]*\);/);
if (cameraAssign) {
const target = cameraAssign[1];
const after = src.slice(src.indexOf(cameraAssign[0]) + cameraAssign[0].length);
const reads = new RegExp(`\\b${target}\\b`).test(after);
if (!reads) {
fail(`${id}: assigns sigCamera to '${target}' and never reads it again — ` +
`the trait is declared but the image cannot change`);
}
}
// A scene whose only content is the previous frame is black on its first
// frames and different after a seek than after playback.
if (/\bprev\s*\(/.test(src)) {
const bodyBeforePrev = src.slice(0, src.indexOf('prev('));
if (!/\b(pal|palRamp|fbm|vnoise|hash1[12])\s*\(/.test(bodyBeforePrev)) {
fail(`${id}: reads prev() without generating a base image first — ` +
`it will be black until feedback converges and will not survive a seek`);
}
}
// Loop cost. GLSL needs a constant bound, so the pattern here is a
// generous fixed bound plus an early break on the param that actually
// decides the count — that break is what keeps the cost proportional to
// what the look asked for. A big bound WITHOUT one runs every iteration
// on every pixel at 4K, which the budget check will catch on a GPU and
// this catches in a second.
for (const loop of src.matchAll(/for\s*\(\s*int\s+\w+\s*=\s*0\s*;\s*\w+\s*<\s*(\d+)[^)]*\)/g)) {
const bound = Number(loop[1]);
const body = src.slice(src.indexOf(loop[0]) + loop[0].length, src.indexOf(loop[0]) + loop[0].length + 600);
const breaksEarly = /\bbreak\s*;/.test(body);
// Opt-out for a genuinely fixed-cost loop — sampling a curve at a
// fixed resolution has nothing to break on. The author states it,
// and the measured 4K budget check still governs.
const optOut = /\/\/\s*lint:\s*fixed-cost/.test(
src.slice(Math.max(0, src.indexOf(loop[0]) - 200), src.indexOf(loop[0])));
if (optOut) continue;
if (bound > 64) {
fail(`${id}: fixed loop bound ${bound} is too large whatever it breaks on`);
} else if (bound > 16 && !breaksEarly) {
fail(`${id}: loop of ${bound} with no early break — bound it on the param ` +
`(\`if (i >= u_count) break;\`) so the cost follows what the look asked for`);
}
}
// Rate params: anything the shader multiplies absolute time by must be
// flagged `rate: true`, which stops reactivity and drift from touching it.
// Modulating such a param jumps the phase by elapsed*delta — sixty seconds
// in, a wobble of 0.05 throws the phase by three units between frames.
// That measured as 6 flashes/second on Classic Wave, twice the WCAG 2.3.1
// ceiling, and it gets worse the longer the track runs.
const timeProducts = [
...src.matchAll(/u_time\s*\*\s*(u_[A-Za-z0-9_]+)/g),
...src.matchAll(/(u_[A-Za-z0-9_]+)\s*\*\s*u_time/g),
];
for (const match of timeProducts) {
const uniform = match[1];
const paramName = declared.get(uniform);
if (!paramName) continue;
if (!module.params[paramName].rate) {
fail(`${id}: '${paramName}' (${uniform}) multiplies u_time but is not marked ` +
`\`rate: true\` — reactivity or drift on it will cause phase jumps`);
}
}
// A scene with a palette param should actually use the palette helpers,
// otherwise the look generator cannot recolour it.
const hasPalette = Object.values(module.params || {}).some((d) => d.type === 'palette');
if (hasPalette && !/\b(pal|palRamp)\s*\(/.test(src)) {
fail(`${id}: declares a palette but never calls pal()/palRamp()`);
}
// Hardcoded saturated colours are the thing that made the party-stage
// shaders resist recolouring; flag the obvious cases.
const literalColors = src.match(/vec3\s*\(\s*[01]\.\d+\s*,\s*[01]\.\d+\s*,\s*[01]\.\d+\s*\)/g) || [];
const suspicious = literalColors.filter((c) => !/vec3\s*\(\s*0\.0*\s*,\s*0\.0*\s*,\s*0\.0*\s*\)/.test(c));
if (hasPalette && suspicious.length > 2) {
fail(`${id}: ${suspicious.length} hardcoded vec3 colour literals — use pal()`);
}
}
if (!failures) ok(`${scenes.length} scenes: schemas and shaders agree both ways`);
else console.log(` (${scenes.length} scenes checked)`);
}
console.log('');
if (failures) {
console.error(`FAILED — ${failures} problem${failures === 1 ? '' : 's'}\n`);
process.exit(1);
}
console.log('all static gates passed\n');