// Phase 7 gate — the library. // // Everything here is per-scene rather than per-phase, and it is the gate every // future scene has to clear too. The static half (schema/shader agreement, rate // params) runs in tools/lint-scenes.js; the range sweep is Phase 2's and the // flash sweep is Phase 5's — both automatically cover new scenes because they // iterate the registry. import { check, expect, expectBelow } from './framework.js'; import { Engine } from '../engine/Engine.js'; import { Show } from '../Show.js'; import { scenes, FAMILIES, scenesInFamily } from '../scenes/registry.js'; import { defaultValues, sampleValues, canBackground } from '../params/schema.js'; import { Rng } from '../engine/rng.js'; import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js'; import { synthesizeSectioned } from '../audio/synth.js'; import { generateLook } from '../look/LookGenerator.js'; import { frameDistance, frameMaxDelta, frameLuminance, frameVariance } from '../engine/hash.js'; import { subjectOf } from '../look/stack.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], ]; let cached = null; function track7() { if (!cached) { cached = FeatureTrack.fromAudioBuffer( synthesizeSectioned({ bpm: 128, duration: 120, changeAt: 60 }), { fps: 60 }); } return cached; } function makeEngine(width = 192, height = 108) { const engine = new Engine({ width, height }); const track = track7(); engine.timeline.setDuration(track.duration); engine.setFeatureProvider(featureProviderFor(track)); return engine; } check(7, 'every family has enough scenes to choose between', () => { const counts = Object.keys(FAMILIES).map((f) => [f, scenesInFamily(f).length]); const thin = counts.filter(([, n]) => n < 2); return expect(thin.length === 0, counts.map(([f, n]) => `${f}:${n}`).join(' ') + (thin.length ? ` — too thin: ${thin.map(([f]) => f).join(', ')}` : ` · ${scenes.length} total`)); }); check(7, 'no two scenes render the same image', () => { // Catches a copy-paste scene whose shader was never actually changed, and // accidental near-duplicates that would waste a library slot. const engine = makeEngine(); try { const frames = scenes.map((module) => { engine.setLayerSpecs([{ module, params: defaultValues(module), seed: 99, opacity: 1, blend: 'normal', palette: PALETTE, }]); engine.compositor.reset(); return { name: module.name, pixels: Uint8Array.from(engine.readPixels(engine.renderFrame(1200))) }; }); // Largest single-channel difference, not the mean: two sparse scenes are // both mostly black, so their MEAN distance is tiny even when they look // nothing alike. Identical scenes score 0 here; different ones score high. let closest = 255; let pair = ''; for (let i = 0; i < frames.length; i++) { for (let j = i + 1; j < frames.length; j++) { const d = frameMaxDelta(frames[i].pixels, frames[j].pixels); if (d < closest) { closest = d; pair = `${frames[i].name} / ${frames[j].name}`; } } } return expect(closest > 24, `closest pair ${pair} at max delta ${closest} (floor 24)`); } finally { engine.dispose(); } }, { slow: true }); check(7, 'every scene stays live across seeds and section energies', () => { // The per-scene acceptance run: several seeds, both a quiet and a loud // context, checking nothing goes black, blows out or freezes flat. const track = track7(); const quiet = track.sections.reduce((a, b) => (a.energy < b.energy ? a : b)); const loud = track.sections.reduce((a, b) => (a.energy > b.energy ? a : b)); const problems = []; let rendered = 0; for (const module of scenes) { const engine = makeEngine(); try { for (let s = 0; s < 3; s++) { const rng = new Rng(4200 + s * 7919); const bias = s === 0 ? { energy: 0.15, density: 0.2, motion: 0.2 } : s === 1 ? { energy: 0.5, density: 0.5, motion: 0.5 } : { energy: 0.95, density: 0.9, motion: 0.9 }; engine.setLayerSpecs([{ module, params: sampleValues(module, rng, bias), seed: s * 31 + 5, opacity: 1, blend: 'normal', palette: PALETTE, }]); for (const section of [quiet, loud]) { const frame = section.startFrame + 120; engine.compositor.reset(); const pixels = engine.readPixels(engine.renderFrame(frame)); rendered++; const lum = frameLuminance(pixels); const variance = frameVariance(pixels); const accent = !canBackground(module); const dead = accent ? variance < 0.0008 : (lum < 0.0008 || lum > 0.99 || variance < 0.0015); if (dead) { problems.push(`${module.name} s${s} ${section.kind}: ` + `lum ${lum.toFixed(4)} var ${variance.toFixed(4)}`); } } } } catch (err) { problems.push(`${module.name}: ${err.message}`); } finally { engine.dispose(); } } return expect(problems.length === 0, problems.length ? problems.slice(0, 5).join(' · ') : `${rendered} frames across ${scenes.length} scenes, all live`); }, { slow: true }); check(7, 'every scene animates rather than sitting still', () => { // A scene that renders a beautiful static frame passes every other check and // is useless. Compare frames two seconds apart. const engine = makeEngine(); const problems = []; try { for (const module of scenes) { engine.setLayerSpecs([{ module, params: defaultValues(module), seed: 1234, opacity: 1, blend: 'normal', palette: PALETTE, }]); engine.compositor.reset(); const a = Uint8Array.from(engine.readPixels(engine.renderFrame(1200))); engine.compositor.reset(); const b = Uint8Array.from(engine.readPixels(engine.renderFrame(1320))); // Measured as the largest single-channel change, not the mean: a // sparse scene (thin bars on black) moves few pixels, so a mean-based // threshold fails it for being tasteful rather than for being static. const d = frameMaxDelta(a, b); if (d < 12) problems.push(`${module.name}: max channel delta only ${d} over 2s`); } return expect(problems.length === 0, problems.length ? problems.join(' · ') : `${scenes.length} scenes all move`); } finally { engine.dispose(); } }, { slow: true }); check(7, 'every scene is deterministic', () => { // Judged on a one-LSB tolerance rather than bit-exact hashes. // // With the engine primed, most scenes reproduce byte-for-byte. The heaviest // shaders do not quite: they come back with a handful of pixels differing by // 1/255, which is GPU floating-point variance under differing load, not a // logic fault. Demanding bit-exactness of them would be demanding something // the hardware does not offer, so the criterion is "no visible difference" // — and 1/255 is comfortably below that. Anything with a real bug scores in // the tens or hundreds here, not 1. See PLAN.md §1. const problems = []; let worst = 0; let worstScene = ''; for (const module of scenes) { const engine = makeEngine(128, 72); try { engine.setLayerSpecs([{ module, params: defaultValues(module), seed: 4242, opacity: 1, blend: 'normal', palette: PALETTE, }]); engine.prime(600); const capture = () => { engine.compositor.reset(); const out = []; for (let f = 600; f < 620; f++) { out.push(Uint8Array.from(engine.readPixels(engine.renderFrame(f)))); } return out; }; const a = capture(); const b = capture(); let sceneWorst = 0; for (let i = 0; i < a.length; i++) { sceneWorst = Math.max(sceneWorst, frameMaxDelta(a[i], b[i])); } if (sceneWorst > worst) { worst = sceneWorst; worstScene = module.name; } if (sceneWorst > 1) problems.push(`${module.name}: max delta ${sceneWorst}`); } finally { engine.dispose(); } } return expect(problems.length === 0, problems.length ? problems.join(' · ') : `${scenes.length} scenes reproducible · worst ${worst}/255 (${worstScene || 'none'})`); }, { slow: true }); check(7, 'every scene stays within the 4K frame budget', () => { // 16.7ms is the realtime bar; at 4K a scene is allowed more, but a scene an // order of magnitude over would make a six-minute export unreasonable. const engine = makeEngine(3840, 2160); const timings = []; try { for (const module of scenes) { engine.setLayerSpecs([{ module, params: defaultValues(module), seed: 7, opacity: 1, blend: 'normal', palette: PALETTE, }]); engine.renderFrame(1200); // compile and warm const started = performance.now(); for (let f = 1200; f < 1210; f++) engine.renderFrame(f); engine.readPixels(engine.compositor.outputTarget); // force the GPU to finish timings.push({ name: module.name, ms: (performance.now() - started) / 10 }); } timings.sort((a, b) => b.ms - a.ms); const worst = timings[0]; return expectBelow(worst.ms, 60, `worst ${worst.name} ${worst.ms.toFixed(1)}ms/frame at 3840x2160 · ` + timings.slice(0, 3).map((t) => `${t.name} ${t.ms.toFixed(1)}`).join(', ')); } finally { engine.dispose(); } }, { slow: true }); check(7, 'quiet sections now get minimal scenes', () => { // The concrete payoff of filling the family. Before Phase 7 there were no // 'minimal' scenes, so intros and breakdowns fell through to flow/organic // and every track opened at full density. const track = track7(); const kinds = { intro: 0, breakdown: 0, outro: 0 }; const restful = new Set(['minimal', 'flow', 'organic']); let total = 0; let restfulCount = 0; let minimalCount = 0; for (let s = 0; s < 24; s++) { const look = generateLook(track, { seed: 11000 + s * 104729 }); for (const section of look.sections) { if (!(section.kind in kinds)) continue; total++; const family = subjectOf(section.layers).module.family; if (restful.has(family)) restfulCount++; if (family === 'minimal') minimalCount++; } } return expect(total > 0 && restfulCount === total && minimalCount > 0, `${restfulCount}/${total} quiet sections got a restful family, ` + `${minimalCount} of them minimal, across 24 seeds`); }); check(7, 'the library still renders whole looks end to end', () => { const track = track7(); const problems = []; let sections = 0; for (let s = 0; s < 6; s++) { const show = new Show({ width: 160, height: 90 }); try { show.useTrack(track, generateLook(track, { seed: 21000 + s * 15485863 })); for (const section of show.look.sections) { sections++; const frame = section.startFrame + Math.floor((section.endFrame - section.startFrame) / 2); show.engine.compositor.reset(); const pixels = show.readPixels(show.renderFrame(frame)); const lum = frameLuminance(pixels); const variance = frameVariance(pixels); if (lum < 0.0008 || lum > 0.99 || variance < 0.0015) { problems.push(`seed ${s} ${section.kind} ` + `[${section.layers.map((l) => l.module.name).join(' + ')}]`); } } } catch (err) { problems.push(`seed ${s}: ${err.message}`); } finally { show.dispose(); } } return expect(problems.length === 0, problems.length ? problems.slice(0, 4).join(' · ') : `${sections} sections across 6 seeds, all live`); }, { slow: true });