music-video-gen/flow-state/tools/lint-scenes.js
Dejvino ec38def1a9 Epic 3, first slice: the song brings its own cast
Four stages, an identity layer, and an A/B that says the idea is right and the
reason it works is not the reason I expected.

A stage has no image of its own. It owns arrangement — a procession, a
constellation, a soloist, a swarm — and what it arranges comes from the track:
`castMain` and `castChorus` for the forms, `inkMask` and `inkValue` for the hand
they are drawn in. The identity generates a protagonist and a chorus with sides,
notches and hollows, plus an ink treatment of weight, edge, fill, outline and
posterisation. All of it travels as uniforms, so it is data rather than code and
a stage consumes it without knowing any other stage exists.

The protagonist IS the signature form rather than a second opinion about it.
They were separate draws in the first version, which let a track built on
hexagons put a round protagonist on screen — the signature said one thing and
the picture said another, and the shape trait stopped meaning anything for
stages. The cast now reads its geometry from the personality live and adds the
notches and hollows that turn a shape into a character.

Measured across seven songs, three arms, same instrument:

    arm                              floor   observed   spread
    stages, four of them            0.0801     0.1133   +0.0332
    legacy scenes, four of them     0.0830     0.1122   +0.0292
    the unrestricted generator      0.1058     0.1146   +0.0088

The prediction in EPIC-3 §7 was that sharing a cast would drop the floor
sharply. The floor did drop sharply — by a quarter — but nearly all of that came
from casting FEWER scenes per video, not from sharing content: the legacy arm,
four ordinary scenes with no cast at all, gets most of the same benefit. Stages
add about 14% on top of that.

That is worth knowing before building the other four registers. The single
largest available win was hiding in the roster size all along, and it costs one
number to take.

One result runs the wrong way and is recorded rather than explained: coupling is
+0.29 on the legacy arm and -0.18 on the stages. At twenty-one pairs neither is
distinguishable from zero, so it is a flag for a larger sample rather than a
finding.

Also fixed: a backtick inside the shader preamble's template literal, which
closed it and made every check page hang on "starting…" with no error in the
console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 21:17:56 +02:00

276 lines
13 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.
*/
// A stage that draws the song's CAST is expressing `shape` more completely than
// sigShape ever did — the form is the subject rather than a hint applied to one
// — so castMain/castChorus count as evidence. Likewise inkMask/inkValue are the
// style trait carried out in full. See scenes/stage/README.md.
const TRAIT_EVIDENCE = {
shape: /\b(sig(Shape|Form)|cast(Main|Chorus|SDF))\s*\(/,
camera: /\bsigCamera\s*\(/,
space: /\b(sigHorizonY|sigAir)\s*\(|\bu_sig(Horizon|Depth|Wash)\b/,
style: /\b(sigEdge|sigGrain|sigFolded|inkMask|inkValue|inkPattern)\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 || '';
// Comment-free copy for rules that ask "does the code do X". A scene
// that mentions prev() in a comment explaining why it does NOT rely on
// prev() should not be failed for relying on prev().
const code = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
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 = code.match(/(\w+)\s*=\s*sig(?:Camera|Folded)\s*\([^;]*\);/);
if (cameraAssign) {
const target = cameraAssign[1];
const after = code.slice(code.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(code)) {
const bodyBeforePrev = code.slice(0, code.search(/\bprev\s*\(/));
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 code.matchAll(/for\s*\(\s*int\s+\w+\s*=\s*0\s*;\s*\w+\s*<\s*(\d+)[^)]*\)/g)) {
const bound = Number(loop[1]);
const body = code.slice(code.indexOf(loop[0]) + loop[0].length, code.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 at = src.indexOf(loop[0].replace(/\s+/g, ' ')) >= 0
? src.indexOf(loop[0].replace(/\s+/g, ' '))
: src.search(new RegExp(`for\\s*\\(\\s*int\\s+\\w+\\s*=\\s*0\\s*;\\s*\\w+\\s*<\\s*${bound}\\b`));
const optOut = at >= 0 && /\/\/\s*lint:\s*fixed-cost/.test(
src.slice(Math.max(0, at - 220), at));
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');