// Phase 2 gate — parameter schema, automatic binding, generated UI. // // The range sweep is the check that earns its keep: it renders every scene at // several points across every declared range and rejects frames that are black, // blown out or flat. Those are exactly the states a look generator will wander // into on some seed, and finding them here is far cheaper than finding them at // minute four of an export. import { check, expect } from './framework.js'; import { Engine } from '../engine/Engine.js'; import { scenes, FAMILIES } from '../scenes/registry.js'; import { defaultValues, sweepValues, validateModule } from '../params/schema.js'; import { serializeParams, deserializeParams } from '../params/serialize.js'; import { ParamPanel } from '../ui/ParamPanel.js'; import { frameLuminance, frameVariance } from '../engine/hash.js'; import { featureProviderFor } from '../audio/FeatureTrack.js'; import { testTrack } from './phase1.js'; const PALETTE = [ [0.06, 0.03, 0.16], [0.85, 0.15, 0.55], [0.15, 0.75, 0.95], [0.98, 0.85, 0.35], [0.55, 0.25, 0.85], [0.2, 0.95, 0.6], ]; function makeEngine(module, params, track) { const engine = new Engine({ width: 192, height: 108 }); engine.timeline.setDuration(track.duration); engine.setFeatureProvider(featureProviderFor(track)); engine.compositor.postEnabled = false; // sweep the scene, not the grade engine.setLayerSpecs([{ module, params, seed: 777, opacity: 1, blend: 'normal', palette: PALETTE, }]); return engine; } check(2, 'every scene schema validates', () => { const problems = []; for (const module of scenes) { problems.push(...validateModule(module)); if (!FAMILIES[module.family]) problems.push(`${module.name}: unknown family`); } return expect(problems.length === 0, problems.length ? problems.join(' · ') : `${scenes.length} scenes valid`); }); check(2, 'declared uniforms and shader sources agree both ways', () => { const CONTRACT = 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', ]); const problems = []; for (const module of scenes) { if (module.kind !== 'fragment') continue; const declared = new Map(); for (const [name, def] of Object.entries(module.params || {})) { if (def.uniform) declared.set(def.uniform, name); } for (const [uniform, param] of declared) { if (!new RegExp(`\\b${uniform}\\b`).test(module.shader)) { problems.push(`${module.name}: ${param} declares ${uniform}, never read`); } } for (const uniform of new Set(module.shader.match(/\bu_[A-Za-z0-9_]+\b/g) || [])) { if (!CONTRACT.has(uniform) && !declared.has(uniform)) { problems.push(`${module.name}: reads undeclared ${uniform}`); } } } return expect(problems.length === 0, problems.length ? problems.join(' · ') : 'all uniforms accounted for'); }); check(2, 'every scene compiles and renders', () => { const track = testTrack(); const problems = []; for (const module of scenes) { const engine = makeEngine(module, defaultValues(module), track); try { const pixels = engine.readPixels(engine.renderFrame(1200)); const lum = frameLuminance(pixels); const variance = frameVariance(pixels); // Accent scenes composite over a background; most of their frame is // legitimately black, so only variance is meaningful for them. if (module.role !== 'accent' && !(lum > 0.001)) problems.push(`${module.name}: black frame`); if (variance < 0.002) problems.push(`${module.name}: flat (var ${variance.toFixed(4)})`); } catch (err) { problems.push(`${module.name}: ${err.message}`); } finally { engine.dispose(); } } return expect(problems.length === 0, problems.length ? problems.join(' · ') : `${scenes.length} scenes render`); }); check(2, 'param range sweep produces no dead or blown frames', () => { const track = testTrack(); const problems = []; let rendered = 0; for (const module of scenes) { const base = defaultValues(module); for (const [name, def] of Object.entries(module.params || {})) { if (def.type === 'palette') continue; for (const value of sweepValues(def, 4)) { const params = { ...base, [name]: value }; const engine = makeEngine(module, params, track); try { const pixels = engine.readPixels(engine.renderFrame(1200)); rendered++; const lum = frameLuminance(pixels); const variance = frameVariance(pixels); const label = `${module.name}.${name}=${JSON.stringify(value)}`; const accent = module.role === 'accent'; // An accent at brightness 0 really is black, and that is a // legitimate value — judge those on variance alone. if (lum > 0.985) problems.push(`${label} blown (lum ${lum.toFixed(3)})`); if (!accent && lum < 0.0008) problems.push(`${label} black (lum ${lum.toFixed(5)})`); if (!accent && variance < 0.0015) problems.push(`${label} flat (var ${variance.toFixed(4)})`); } catch (err) { problems.push(`${module.name}.${name}: ${err.message}`); } finally { engine.dispose(); } } } } const shown = problems.slice(0, 6).join(' · '); return expect(problems.length === 0, problems.length ? shown + (problems.length > 6 ? ` · +${problems.length - 6} more` : '') : `${rendered} sweep frames across ${scenes.length} scenes, all live`); }, { slow: true }); check(2, 'every declared param gets a generated control', () => { const host = document.createElement('div'); const problems = []; for (const module of scenes) { const panel = new ParamPanel(host, () => {}); panel.build(module, defaultValues(module)); const expected = Object.entries(module.params || {}) .filter(([, def]) => def.type !== 'palette') .map(([name]) => name); const actual = panel.controlNames(); for (const name of expected) { if (!actual.includes(name)) problems.push(`${module.name}.${name} has no control`); } } return expect(problems.length === 0, problems.length ? problems.join(' · ') : 'all params exposed in the UI'); }); check(2, 'control edits emit clamped values', () => { const host = document.createElement('div'); const module = scenes.find((s) => Object.values(s.params).some((d) => d.type === 'float')); const received = []; const panel = new ParamPanel(host, (name, value) => received.push([name, value])); panel.build(module, defaultValues(module)); const [name, control] = [...panel.controls.entries()][0]; control.input.value = control.input.max; control.input.dispatchEvent(new Event('input')); const def = module.params[name]; const hi = def.range[1]; const last = received[received.length - 1]; const value = last && (Array.isArray(last[1]) ? last[1][0] : last[1]); return expect(last && last[0] === name && Math.abs(value - hi) < 1e-6, `emitted ${JSON.stringify(last)}, expected ${name}=${hi}`); }); check(2, 'params round-trip through serialisation unchanged', () => { const problems = []; for (const module of scenes) { const values = defaultValues(module); const stored = JSON.parse(JSON.stringify(serializeParams(module, values))); const restored = deserializeParams(module, stored); for (const [name, def] of Object.entries(module.params || {})) { if (def.type === 'palette') continue; const a = values[name], b = restored[name]; const same = def.type === 'vec2' ? a[0] === b[0] && a[1] === b[1] : a === b; if (!same) problems.push(`${module.name}.${name}: ${JSON.stringify(a)} → ${JSON.stringify(b)}`); } } return expect(problems.length === 0, problems.length ? problems.join(' · ') : `${scenes.length} scenes round-trip cleanly`); }); check(2, 'a preset missing or gaining keys still loads', () => { // Presets outlive schema edits; failing to load one is worse than losing a value. const module = scenes[0]; const partial = deserializeParams(module, { __gone__: 5 }); const defaults = defaultValues(module); const same = Object.keys(defaults).every((k) => { const a = defaults[k], b = partial[k]; return Array.isArray(a) ? a[0] === b[0] : a === b; }); return expect(same, 'unknown keys ignored, missing keys defaulted'); });