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>
123 lines
4.7 KiB
JavaScript
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()];
|
|
}
|
|
}
|