Both phases come out of the manual gate — watching whole tracks — and both
fix something no automated check was looking for.
Phase 8: shots. A section is a STAGE of the song and can run ninety
seconds; one scene held that long reads as a still image with a wobble on
it. Each section kind now gets a roster of three or four stage visuals
instead of one scene, and each section is cut into shots that rotate
between them on phrase lines, never holding past 22s. The roster stays per
kind, so a track's drops still cut between the same images and the video
keeps its identity; the anchor opens each section and the rotation returns
to it, and when a companion is due it is the least recently shown one.
The arc driver stopped working in sections and started working in cues, one
per shot, so a shot cut and a section change take the same code path and
differ only in transition length. The default transition is a slow
dissolve — two bars calm, one loud; a straight cut is reserved for
sections above the energy threshold, because on calm material a cut reads
as a glitch rather than as an edit.
Phase 9: production design. With cuts every fifteen seconds the next
problem was that the images being cut between shared nothing but the
palette. What a music video actually shares across shots is a location, a
cast, a camera operator and an art direction, so each track now generates a
personality in four traits (shape, camera, space, style) off the look seed.
The traits reach shaders as uniforms plus four helpers in the contract, and
each scene expresses them its own way: Classic Wave's rings take the
signature polygon, Metaballs merge as one, Floating Geometry no longer
picks between a box and a circle because the production already decided.
The part that makes it a design rather than a filter: scenes DECLARE which
traits they honour, a track is built on one or two, and a scene that does
not honour all of them is not cast in that track. The library shrinks per
track on purpose.
Two gates keep the declaration honest — lint greps each shader for evidence
of every trait it claims, and a render check measures that each declared
trait actually moves the image (41 scene/trait pairs, weakest response 64
of 255). A layer with no personality renders bit-identically to before,
which is what keeps every earlier sweep and regression valid.
Checks changed rather than added:
- P4 scene-change and drift checks now measure per shot, not per section;
the crossfade check reads its length off the cue.
- P5 flash sweep runs per shot, so the visuals that only appear
mid-section are measured too.
- P6 preview/export parity primes first (as both real paths do) and
compares at the one-LSB tolerance Phase 7 already uses. Measured over
four consecutive shows: 3 frames at delta 1, then bit-exact — GPU
variance on first render, not a divergence.
- P2's contract-uniform list is derived from the contract instead of
retyped, so the signature uniforms cannot fall out of sync.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 lines
9.5 KiB
JavaScript
215 lines
9.5 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`);
|
|
}
|
|
}
|
|
|
|
// 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');
|