Ten new scenes, with 'minimal' first: the family was empty, so intros and breakdowns fell through to flow/organic and every track opened at full density. Quiet sections now land on a restful family 48/48 times across 24 seeds, 28 of them minimal. New: Horizon Lines, Spectrum Sculpture, Slow Orb (minimal); Curl Flow (flow); Plasma Bloom, Metaballs (organic); Kaleido Tunnel, Moiré Grid (geometric); Ridge Terrain (structural); Scan Tear (glitch). Four real bugs, three of which the existing gates could not have caught: 1. SHADER PROGRAMS LINK ASYNCHRONOUSLY. three.js uses KHR_parallel_shader_compile, so draws against an unlinked program render wrong. The heaviest scene had its first TEN frames differ from every later render of the same frames. Preview hides this entirely; export renders each frame once, so those frames would ship broken. Added Engine.prime() — WebGLRenderer.compile() plus a discarded warm frame — and the exporter now primes before encoding. Rendering a throwaway frame and reading it back is NOT sufficient; measured, it left 3-5 frames wrong. 2. Moiré Grid declared a param on u_width, which the shader contract already uses for stereo width. GLSL redefinition, and the only symptom was a black frame. Lint now rejects any param uniform colliding with the contract. 3. Spectrum Sculpture strobed at 4 flashes/s. Two causes: rotation measured in turns meant bar-crossing frequency was bars x rate (82 bars put a slow-looking 0.12 turns/s at 10 Hz), and hard band-tier boundaries made every bar switch band simultaneously. Rotation is now in segment units so the rate IS the crossing frequency, bands interpolate, and the range is capped where the flash meter measures zero. 4. Particle Field was being chosen as a primary background despite being mostly empty by design. Scenes now declare role: 'accent'; those are never primary and are judged on variance rather than luminance. Three checks were themselves wrong and were rebuilt: mean-distance metrics unfairly fail sparse scenes for being tasteful rather than static, so "animates" and "no duplicates" now use max channel delta. PLAN.md §1 gains two refinements: programs must be primed before the first frame, and even same-machine the heaviest shaders vary by one LSB under differing GPU load — so the per-scene criterion is max delta <= 1 rather than an identical hash. A real bug scores in the tens there. Full suite 67/67 across all seven phases. Worst 4K frame 3.8ms, worst flash rate 0/s, worst determinism delta 1/255. Adds README.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
9.2 KiB
JavaScript
203 lines
9.2 KiB
JavaScript
// 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');
|
|
});
|