music-video-gen/flow-state/src/ui/ParamPanel.js
Dejvino c9b80308d2 Phase 2: param schema, auto-binding, generated UI
All five party-stage shaders now ported. Each declares its params
declaratively; uniform binding, UI controls, seeded sampling and arc
automation all derive from that one block, so a new scene costs a shader
and a schema and nothing else.

Port changes: LED-grid masks stripped, hardcoded colours replaced with
palette lookups, magic numbers lifted into params. Psychedelic Drift's
internal 15-second scene timer removed — keeping the visuals moving is the
arc driver's job, and it knows where the song's real transitions are.

ParamPanel generates controls from the schema alone and knows about no
specific scene; a hand-written control would be a bug.

Gate 8/8: schemas valid, uniforms accounted for in both directions, all
scenes compile and render, 128 range-sweep frames with no black, blown or
flat results, every param exposed in the UI, edits clamped, presets
round-trip and survive schema drift.

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

123 lines
4.7 KiB
JavaScript

// Controls generated from a scene's param schema.
//
// Nothing here knows about any specific scene. That is the point: a new scene
// gets a full editing UI for free, which is what makes "auto with override"
// affordable across a library of thirty. Adding a hand-written control for a
// scene would be a bug, not a feature.
const FORMATTERS = {
float: (v) => (typeof v === 'number' ? v.toFixed(2) : '—'),
int: (v) => String(v),
bool: (v) => (v ? 'on' : 'off'),
vec2: (v) => (Array.isArray(v) ? `${v[0].toFixed(2)}, ${v[1].toFixed(2)}` : '—'),
};
export class ParamPanel {
/**
* @param {HTMLElement} container
* @param {(name: string, value: any) => void} onChange
*/
constructor(container, onChange) {
this.container = container;
this.onChange = onChange;
this.controls = new Map();
this.module = null;
}
/** Rebuild for a scene module and its current values. */
build(module, values) {
this.container.innerHTML = '';
this.controls.clear();
this.module = module;
if (!module) return;
const heading = document.createElement('div');
heading.className = 'pp-heading';
heading.innerHTML = `<span class="pp-name">${module.name}</span>
<span class="pp-family">${module.family}</span>`;
this.container.appendChild(heading);
for (const [name, def] of Object.entries(module.params || {})) {
if (def.type === 'palette') continue; // edited in the look tab
const control = this._buildControl(name, def, values[name]);
if (control) {
this.container.appendChild(control.element);
this.controls.set(name, control);
}
}
const reactive = module.reactive || {};
if (Object.keys(reactive).length) {
const note = document.createElement('div');
note.className = 'pp-reactive';
note.innerHTML = '<div class="pp-sub">audio-driven</div>' +
Object.entries(reactive)
.map(([p, r]) => `<div class="pp-react-row"><span>${p}</span>` +
`<span class="pp-feature">${r.feature}</span>` +
`<span class="pp-amount">${r.amount > 0 ? '+' : ''}${r.amount}</span></div>`)
.join('');
this.container.appendChild(note);
}
}
_buildControl(name, def, value) {
const element = document.createElement('div');
element.className = 'pp-row';
const label = document.createElement('label');
label.className = 'pp-label';
label.textContent = name;
const readout = document.createElement('span');
readout.className = 'pp-value';
let input;
if (def.type === 'bool') {
input = document.createElement('input');
input.type = 'checkbox';
input.checked = !!value;
input.addEventListener('change', () => {
readout.textContent = FORMATTERS.bool(input.checked);
this.onChange(name, input.checked);
});
} else {
const [lo, hi] = def.range || [0, 1];
input = document.createElement('input');
input.type = 'range';
input.min = String(lo);
input.max = String(hi);
input.step = def.type === 'int' ? '1' : String((hi - lo) / 200);
input.value = String(def.type === 'vec2' ? (value ? value[0] : lo) : value ?? lo);
input.addEventListener('input', () => {
const v = def.type === 'int' ? Math.round(+input.value) : +input.value;
const out = def.type === 'vec2' ? [v, v] : v;
readout.textContent = (FORMATTERS[def.type] || FORMATTERS.float)(out);
this.onChange(name, out);
});
}
input.className = 'pp-input';
readout.textContent = (FORMATTERS[def.type] || FORMATTERS.float)(value);
element.append(label, input, readout);
return { element, input, readout, def };
}
/** Push new values into the controls without emitting change events. */
update(values) {
for (const [name, control] of this.controls) {
const v = values[name];
if (v === undefined) continue;
if (control.def.type === 'bool') control.input.checked = !!v;
else control.input.value = String(control.def.type === 'vec2' ? v[0] : v);
control.readout.textContent =
(FORMATTERS[control.def.type] || FORMATTERS.float)(v);
}
}
/** Names of every control currently rendered. Used by the Phase 2 gate. */
controlNames() {
return [...this.controls.keys()];
}
}