music-video-gen/flow-state/tools/lint-scenes.js
Dejvino 59180948d0 Phase 5: compositing depth ("C" complete)
Multi-layer stacks with blend modes, feedback, post chain, and a 3D
particle layer proving the compositor is genuinely hybrid. Looks now
generate accent layers from a different family, composited additively at
low opacity, weighted by section energy so intros stay sparse.

Adds flash-rate safety (engine/flash.js), which was not in the original
plan and should have been. This generates beat-reactive video for
publication, and rapid light-dark cycling is the photosensitive-epilepsy
trigger; WCAG 2.3.1 caps it at three flashes per second. Classic Wave
measured 7-8/s at every output resolution from 96x54 to 1920x1080, so it
was a real hazard rather than a sampling artefact.

Root cause was general, not one bad shader: `u_time * u_speed` where speed
is reactively modulated. Phase is elapsed*rate, so changing the rate at
time T jumps phase by T*delta — sixty seconds in, a 0.05 wobble throws the
phase three whole units between consecutive frames, and it worsens as the
track runs. Fixed by introducing rate params:

- schema flag `rate: true` documents and marks them
- Layer.resolveParams skips reactivity on them
- ArcDriver skips drift on them
- validateModule rejects a reactive entry on one
- lint-scenes greps shaders for `u_time * u_X` and fails if X is unmarked,
  so no future scene can reintroduce it

Every rate param across the six scenes is now marked. Two checks were
needed to find this: a per-look flash check, and a per-SCENE sweep at
aggressive params, since the look generator only samples part of the space
and a scene can hide an unsafe region for a long time.

Gate 10/10. Worst flash rate now 1/s. Feedback stable over 10,000 frames
(luminance 0.17-0.59, no saturation or decay). 0.17ms/frame at 1280x720.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:38:26 +02:00

168 lines
7.2 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',
]);
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') 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);
}
// 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)`);
}
// 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');