// 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 || ''; 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; }