music-video-gen/flow-state/src/params/schema.js
Dejvino 189328587d Epic 2.4: a slow axis, and an honest account of what it fixes
Eleven scenes changed as much in half a second as in two minutes. The cause
was that everything moving in them was cyclic: ArcDriver's drift is a 20-70
second LFO, and an LFO returns, so ten cycles of it over five minutes is not
five minutes of anything. The eye adapts in about two seconds and then there
is nothing left to find — violently animated, and reads as static.

ArcDriver._slowAxisFor adds the missing timescale: a param that travels ONE
WAY across the whole track, keyed on the module so a scene returning in the
last section arrives further along rather than resetting. Rate params are
excluded for the reason schema.js already gives.

Two things were learned by getting them wrong first, and both are recorded
where the next person will hit them.

The first version chose the param at random from everything eligible and
measured as doing NOTHING — identical structural change with the axis applied
and with it disabled. Which param you move decides everything: sweeping Moiré
Grid's `width` moves its time-averaged structure by 0.110 and its `offset` by
0.002, and a random draw finds the second kind almost every time. So the axis
is now DECLARED, `slowAxis: true`, validated by the schema (and rejected on
rate params, where walking one would jump the animation phase).

The second is that single-frame distance cannot measure this at all. A
churning scene's consecutive frames are already ~0.6 apart, so every pair of
its frames scores the same whether the structure moved or not — the metric is
saturated by the churn it exists to see through, and the first gate passed
while the mechanism was provably inert. The gate now compares TEN-SECOND
time-averaged frames. The window was measured rather than guessed: at one
second Moiré Grid's frozen control still reads 0.024, at ten it reads 0.0095
while the signal holds at 0.037.

Per EPIC-2.md §4, the metric is verified by breaking what it should catch:
a second check runs the identical measurement with the axis disabled and
requires it to read ~1.0. It reads exactly 1.00x on all three scenes.

The honest scope, measured across ten candidates and written into EPIC-2.md
§3.4: the axis works on Moiré Grid (3.93x), Gate Corridor (2.88x) and Truchet
Fold (1.40x). Curl Flow, Signal Decay and Circuit Bloom have NO parameter that
changes their structure and still need shader work — parameter automation
cannot substitute for structure a scene does not have. Four more already
develop on their own and were never the problem. The flag is set only where it
is proven, so it means something.

69/70 on phases 3,4,7-11 slow; the one failure is the known load-dependent
Horizon Lines flake recorded in d14d473.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 07:54:35 +02:00

301 lines
13 KiB
JavaScript

// Declarative parameter schema.
//
// This is the load-bearing abstraction for library scale. One declaration drives:
// 1. uniform binding (Layer)
// 2. generated UI controls (ui/ParamPanel)
// 3. seeded per-track sampling (look/LookGenerator)
// 4. arc automation (look/ArcDriver)
// 5. save/load of presets
//
// Adding a scene therefore costs a shader plus a params block, and nothing else.
// tools/lint-scenes.js machine-checks every declaration against its shader source.
export const PARAM_TYPES = ['float', 'int', 'bool', 'vec2', 'palette'];
// RATE PARAMS
//
// A param flagged `rate: true` is one the shader multiplies absolute time by —
// `u_time * u_speed` and friends. Those must never be modulated per frame, by
// audio reactivity or by LFO drift, and the engine enforces it.
//
// The reason is that phase is `u_time * rate`, so changing the rate at time T
// jumps the phase by `T * Δrate`. Sixty seconds into a track, a wobble of 0.05
// throws the phase by three whole units between one frame and the next — and it
// gets worse the longer the track runs. The visible result is high-frequency
// flicker that looks like the scene is broken, and which measured 6 flashes per
// second on Classic Wave, twice the WCAG 2.3.1 ceiling.
//
// A scene that wants audio-driven motion should add a bounded term rather than
// scaling the clock: `u_time * u_speed + u_bandLow * 2.0` is continuous;
// `u_time * (u_speed + u_bandLow)` is not.
export const RATE_FLAG = 'rate';
// A param flagged `slowAxis: true` is the one the arc driver walks from one end
// of its range to the other across the WHOLE track — the scene's long journey,
// as opposed to the drift LFO's wobble. See ArcDriver._slowAxisFor.
//
// It has to be declared rather than guessed. Measured across the churning
// scenes, which param you pick decides everything: moving Moiré Grid's `width`
// changes its time-averaged structure by 0.110, and moving its `offset` by
// 0.002. A randomly chosen param is overwhelmingly likely to be the second kind,
// which is why the first version of the slow axis measured as doing nothing at
// all.
export const SLOW_AXIS_FLAG = 'slowAxis';
/** Valid feature names a `reactive` entry may reference. Lint enforces this. */
export const REACTIVE_FEATURES = [
'loudness', 'rms',
'bandSub', 'bandLow', 'bandMid', 'bandHigh', 'bandAir',
'flux', 'centroid', 'flatness', 'width',
'beat', 'beatPhase', 'barPhase', 'phrasePhase',
'sectionProgress', 'sectionEnergy', 'buildSlope',
];
export const REACTIVE_RESPONSES = ['linear', 'spike', 'smooth', 'inverse'];
/**
* Personality traits a scene can honour. See look/Personality.js.
*
* This is a CONTRACT, not a hint: a track built on `shape` will only cast scenes
* that declare `shape`, and it will cast them believing they actually stamp the
* signature form. Declaring a trait a scene ignores is worse than declaring
* none, because the disqualification rule is the only thing keeping off-design
* scenes out of a track.
*/
export const TRAIT_NAMES = ['shape', 'camera', 'space', 'style'];
export function defaultValue(def) {
if (def.default !== undefined) return def.default;
switch (def.type) {
case 'bool': return false;
case 'int': return Math.round(def.range ? def.range[0] : 0);
case 'vec2': return [0, 0];
case 'palette': return null; // supplied by the look, not sampled here
default: return def.range ? def.range[0] : 0;
}
}
export function defaultValues(module) {
const out = {};
for (const [name, def] of Object.entries(module.params || {})) {
out[name] = defaultValue(def);
}
return out;
}
export function clampValue(def, value) {
if (def.type === 'bool') return !!value;
if (def.type === 'palette') return value;
if (def.type === 'vec2') {
const [lo, hi] = def.range || [0, 1];
return [Math.min(hi, Math.max(lo, value[0])), Math.min(hi, Math.max(lo, value[1]))];
}
const [lo, hi] = def.range || [0, 1];
let v = Math.min(hi, Math.max(lo, value));
if (def.type === 'int') v = Math.round(v);
return v;
}
/**
* Sample a full parameter set from the declared ranges.
*
* `bias` (0..1 per key, optional) nudges sampling toward the top of a range —
* this is how a track's measured character reaches the parameters without every
* scene needing to know about audio features. `energy: 0.8` on a hard track
* pushes density-ish params up without pinning them, so seed variation survives.
*
* `temperament` is the track's own hand on the same dials — see
* look/Personality.js. Bias comes from the SECTION and is therefore nearly the
* same for every track's drop; temperament comes from the TRACK and is not.
* Without it, one scene cast in two different videos sampled around the same
* centre both times and the two videos looked like the same video, which is
* exactly the complaint temperament exists to answer.
*/
export function sampleValues(module, rng, bias = {}, temperament = null) {
const out = {};
for (const [name, def] of Object.entries(module.params || {})) {
if (def.type === 'palette') { out[name] = null; continue; }
if (def.fixed) { out[name] = defaultValue(def); continue; }
let b = def.bias && bias[def.bias] !== undefined ? bias[def.bias] : 0.5;
if (temperament) b = clamp01(b + temperamentShift(def.bias, temperament));
if (def.type === 'bool') {
out[name] = rng.bool(0.25 + b * 0.5);
continue;
}
const [lo, hi] = def.range || [0, 1];
// How far this track is willing to push a param toward its limits. A
// timid track samples near the middle of everything and reads as the
// library's average; a bold one commits. This is the difference between
// "the same scene again" and "that scene, but this video's version".
const extremity = temperament ? temperament.extremity : 0.5;
// Bias still moves the centre of mass, but a bold track overrides more
// of it — otherwise every drop in every video converges on one point.
const mixAmount = (def.biasStrength !== undefined ? def.biasStrength : 0.45)
* (1 - extremity * 0.45);
const u = boldUniform(rng.next(), extremity);
const target = lo + (hi - lo) * b;
let v = (lo + (hi - lo) * u) * (1 - mixAmount) + target * mixAmount;
if (def.type === 'vec2') {
const u2 = boldUniform(rng.next(), extremity);
const v2 = (lo + (hi - lo) * u2) * (1 - mixAmount) + target * mixAmount;
out[name] = [clampValue(def, [v, v2])[0], clampValue(def, [v, v2])[1]];
continue;
}
// Absolute animation speed follows the song, not the scene's taste. A
// rate param sampled at 0.7 of its range means the same visual speed
// whether the track is 70bpm or 170, which is how slow songs ended up
// with scenes skittering over them. See look/LookGenerator biasFor.
if (def[RATE_FLAG] && bias.rateScale) v *= bias.rateScale;
if (def.type === 'int') v = Math.round(v);
out[name] = clampValue(def, v);
}
return out;
}
const clamp01 = (x) => Math.max(0, Math.min(1, x));
/**
* Reshape a uniform draw so a bold track reaches the ends of a range.
*
* At extremity 0 this is unchanged. As it rises the distribution hollows out:
* the same draw lands further from the centre, so a track that wants density
* gets scenes at their dense end rather than at a polite 60%.
*
* The exponent floor is low on purpose. A param range is the scene author's
* statement of what the scene can survive, so the ends of it are supposed to be
* usable — a library that samples the middle of every range is a library where
* every scene shows its default.
*
* There is a ceiling on this, found by overshooting it. Pushed harder (0.82),
* enough draws piled onto the range ends that two different seeds started
* producing near-identical frames — the Phase 3 look-space check caught a
* closest pair at 0.0096 against a floor of 0.01 — and a sparse scene sampled
* at its low end rendered as effectively black. Extremes are where the variety
* is; the extremes are also where every scene collapses onto the same extreme.
*/
function boldUniform(u, extremity) {
const signed = (u - 0.5) * 2;
const shaped = Math.sign(signed) * Math.pow(Math.abs(signed), 1 - clamp01(extremity) * 0.72);
return clamp01(0.5 + shaped * 0.5);
}
/** Which way this track leans on each of the three bias axes. */
function temperamentShift(axis, temperament) {
switch (axis) {
case 'energy': return temperament.intensity * 0.3;
case 'density': return temperament.intensity * 0.2 + temperament.detail * 0.3;
case 'motion': return temperament.pace * 0.35;
default: return 0;
}
}
/** Evenly spaced probe values across a param's range, for the range-sweep check. */
export function sweepValues(def, steps = 5) {
if (def.type === 'bool') return [false, true];
if (def.type === 'palette') return [null];
const [lo, hi] = def.range || [0, 1];
const out = [];
for (let i = 0; i < steps; i++) {
let v = lo + ((hi - lo) * i) / (steps - 1);
if (def.type === 'int') v = Math.round(v);
out.push(def.type === 'vec2' ? [v, v] : v);
}
return out;
}
/**
* Structural validation of a scene module. Returns an array of human-readable
* problems; empty means clean. Shared by the lint tool and the runtime registry,
* so a malformed scene can't reach the compositor.
*/
export function validateModule(module) {
const errors = [];
const id = module?.name || '<unnamed>';
if (!module.name) errors.push('missing `name`');
if (!module.family) errors.push(`${id}: missing \`family\``);
if (!module.kind) errors.push(`${id}: missing \`kind\``);
if (!Array.isArray(module.traits)) {
errors.push(`${id}: missing \`traits\` — declare which personality traits it honours ` +
`(any of ${TRAIT_NAMES.join(', ')}, or [] for none)`);
} else {
for (const t of module.traits) {
if (!TRAIT_NAMES.includes(t)) errors.push(`${id}: unknown trait '${t}'`);
}
}
if (module.texture !== undefined
&& (typeof module.texture !== 'number' || module.texture < 0 || module.texture > 2)) {
errors.push(`${id}: \`texture\` must be a number 0..2 — how much of the track's ` +
`surface grain this scene takes (1 = all, 0 = none)`);
}
if (module.kind === 'fragment' && !module.shader) errors.push(`${id}: kind 'fragment' but no \`shader\``);
if (module.kind === 'fragment' && module.shader && !/vec4\s+scene\s*\(/.test(module.shader)) {
errors.push(`${id}: shader must define \`vec4 scene(vec2 uv, vec2 p)\``);
}
const params = module.params || {};
const uniformNames = new Set();
for (const [name, def] of Object.entries(params)) {
const where = `${id}.${name}`;
if (!def.type) errors.push(`${where}: missing \`type\``);
else if (!PARAM_TYPES.includes(def.type)) errors.push(`${where}: unknown type '${def.type}'`);
if (def.type !== 'palette' && def.type !== 'bool' && !def.range) {
errors.push(`${where}: numeric param needs a \`range\``);
}
if (def.range && (def.range.length !== 2 || def.range[0] >= def.range[1])) {
errors.push(`${where}: \`range\` must be [min, max] with min < max`);
}
if (def.default !== undefined && def.range && typeof def.default === 'number') {
if (def.default < def.range[0] || def.default > def.range[1]) {
errors.push(`${where}: default ${def.default} outside range [${def.range}]`);
}
}
if (def.uniform) {
if (uniformNames.has(def.uniform)) errors.push(`${where}: duplicate uniform '${def.uniform}'`);
uniformNames.add(def.uniform);
if (!/^u_[A-Za-z0-9_]+$/.test(def.uniform)) {
errors.push(`${where}: uniform '${def.uniform}' should be named u_*`);
}
}
if (def.bias && typeof def.bias !== 'string') errors.push(`${where}: \`bias\` must be a key name`);
if (def[SLOW_AXIS_FLAG]) {
if (def[RATE_FLAG]) {
errors.push(`${where}: cannot be both \`rate\` and \`slowAxis\` — walking a rate ` +
`param jumps the animation phase (see the RATE_FLAG note above)`);
}
if (def.type === 'palette' || def.type === 'bool' || def.fixed) {
errors.push(`${where}: \`slowAxis\` needs a numeric range to walk`);
}
}
}
for (const [name, r] of Object.entries(module.reactive || {})) {
const where = `${id}.reactive.${name}`;
if (!params[name]) errors.push(`${where}: no such param`);
if (!r.feature) errors.push(`${where}: missing \`feature\``);
else if (!REACTIVE_FEATURES.includes(r.feature)) {
errors.push(`${where}: unknown feature '${r.feature}'`);
}
if (r.response && !REACTIVE_RESPONSES.includes(r.response)) {
errors.push(`${where}: unknown response '${r.response}'`);
}
if (typeof r.amount !== 'number') errors.push(`${where}: missing numeric \`amount\``);
if (params[name] && params[name].rate) {
errors.push(`${where}: '${name}' is a rate param and cannot be reactive — ` +
`modulating it jumps phase by elapsed*delta (see RATE_FLAG)`);
}
}
return errors;
}