// Phase 5 gate — compositing depth. "C" complete. // // Multi-layer stacks, blend modes, feedback, the post chain and 3D layers. The // feedback stability run and the flash-rate check matter most: both fail // silently, slowly, and only in the finished file. import { check, expect, expectBelow } from './framework.js'; import { Engine } from '../engine/Engine.js'; import { Show } from '../Show.js'; import { generateLook } from '../look/LookGenerator.js'; import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js'; import { synthesizeSectioned } from '../audio/synth.js'; import { defaultValues, sampleValues } from '../params/schema.js'; import { scenes } from '../scenes/registry.js'; import { Rng } from '../engine/rng.js'; import { frameLuminance, frameVariance, frameMaxDelta } from '../engine/hash.js'; import { peakFlashRate } from '../engine/flash.js'; import { particleField } from '../scenes/layers3d/particles.js'; import { nebula } from '../scenes/shader/nebula.js'; import { BLEND_MODES } from '../engine/Layer.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 track5() { 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 = track5(); engine.timeline.setDuration(track.duration); engine.setFeatureProvider(featureProviderFor(track)); return engine; } check(5, 'a 3D layer renders and composites', () => { const engine = makeEngine(); try { engine.setLayerSpecs([{ module: particleField, params: defaultValues(particleField), seed: 31337, opacity: 1, blend: 'normal', palette: PALETTE, }]); const pixels = engine.readPixels(engine.renderFrame(1200)); const lum = frameLuminance(pixels); const variance = frameVariance(pixels); // Judged on variance, not mean luminance: this is an additive accent over // a dark field, so most of the frame is legitimately black and a mean of // ~0.001 is the correct result rather than a dead render. return expect(variance > 0.005 && lum > 0.0002, `particle field: variance ${variance.toFixed(4)}, mean luminance ${lum.toFixed(4)}`); } finally { engine.dispose(); } }); check(5, '3D layer motion is analytic, so a seek matches sequential playback', () => { // The rule 3D layers must obey: no integrated state. An integrated particle // system drifts apart between a seek and playback and silently breaks export // parity. const engine = makeEngine(); try { engine.setLayerSpecs([{ module: particleField, params: defaultValues(particleField), seed: 31337, opacity: 1, blend: 'normal', palette: PALETTE, }]); const sequential = engine.hashRun(0, 200); engine.compositor.reset(); const direct = engine.hashCurrent(engine.renderFrame(199)); return expect(direct === sequential[199], `seek→199 ${direct} vs sequential ${sequential[199]}`); } finally { engine.dispose(); } }); check(5, 'every blend mode composites two layers distinctly', () => { const engine = makeEngine(); try { const seen = new Map(); for (const blend of BLEND_MODES) { engine.setLayerSpecs([ { module: nebula, params: defaultValues(nebula), seed: 11, opacity: 1, blend: 'normal', palette: PALETTE }, { module: particleField, params: defaultValues(particleField), seed: 22, opacity: 0.6, blend, palette: PALETTE }, ]); engine.compositor.reset(); seen.set(blend, engine.hashCurrent(engine.renderFrame(1200))); } const unique = new Set(seen.values()).size; return expect(unique === BLEND_MODES.length, `${unique}/${BLEND_MODES.length} blend modes produced distinct output`); } finally { engine.dispose(); } }); check(5, 'layer solo isolates each layer', () => { const engine = makeEngine(); try { engine.setLayerSpecs([ { module: nebula, params: defaultValues(nebula), seed: 11, opacity: 1, blend: 'normal', palette: PALETTE }, { module: particleField, params: defaultValues(particleField), seed: 22, opacity: 0.8, blend: 'add', palette: PALETTE }, ]); engine.compositor.reset(); const both = engine.hashCurrent(engine.renderFrame(1200)); engine.compositor.soloIndex = 0; engine.compositor.reset(); const first = engine.hashCurrent(engine.renderFrame(1200)); engine.compositor.soloIndex = 1; engine.compositor.reset(); const second = engine.hashCurrent(engine.renderFrame(1200)); engine.compositor.soloIndex = -1; return expect(new Set([both, first, second]).size === 3, `combined ${both}, solo-0 ${first}, solo-1 ${second}`); } finally { engine.dispose(); } }); check(5, 'feedback stays stable over a long run', () => { // A feedback loop with gain at or above 1 saturates to white; too much decay // and it dies to black. Either takes thousands of frames to show, so it never // appears in a short check — and always appears in a six-minute export. const engine = makeEngine(128, 72); try { engine.setLayerSpecs([{ module: nebula, params: defaultValues(nebula), seed: 4242, opacity: 1, blend: 'normal', palette: PALETTE, }]); engine.compositor.setFeedback({ amount: 0.75, decay: 0.94, zoom: 1.006, rotate: 0.003 }); engine.compositor.reset(); const track = track5(); const samples = []; for (let f = 0; f < 10000; f++) { engine.timeline.seek(f % track.frameCount); const target = engine.compositor.render({ timeline: engine.timeline, features: track.at(f % track.frameCount), }); if (f % 500 === 0 || f === 9999) samples.push(frameLuminance(engine.readPixels(target))); } const min = Math.min(...samples); const max = Math.max(...samples); const tail = samples.slice(-4); return expect(max < 0.97 && min > 0.003, `10000 frames · luminance ${min.toFixed(4)}..${max.toFixed(4)} · ` + `tail ${tail.map((v) => v.toFixed(3)).join(', ')}`); } finally { engine.dispose(); } }, { slow: true }); check(5, 'generated looks stay within the flash-rate ceiling', () => { // WCAG 2.3.1 / Harding: at most three light-dark cycles per second. An // unsupervised generator finds unsafe states on its own, and this is the only // thing between one of them and a published video. const track = track5(); const problems = []; let worst = 0; let worstLabel = ''; for (let s = 0; s < 5; s++) { const show = new Show({ width: 96, height: 54 }); try { show.useTrack(track, generateLook(track, { seed: 5000 + s * 7919 })); // Per SHOT, not per section: a section rotates between two or three // stage visuals and only the first of them sits at the section start, // so sweeping sections would leave most of what ships unmeasured. The // window opens before the cut so the cut itself is inside it. for (const cue of show.arc.cues.slice(0, 8)) { const start = Math.max(0, cue.startFrame - 20); const end = Math.min(cue.endFrame, start + 300); // 5 seconds if (end - start < 120) continue; show.engine.compositor.reset(); for (let f = Math.max(0, start - 40); f < start; f++) show.renderFrame(f); const luminance = []; for (let f = start; f < end; f++) { luminance.push(frameLuminance(show.readPixels(show.renderFrame(f)))); } const rate = peakFlashRate(luminance, 60); const section = show.look.sections[cue.sectionIndex]; const scene = (section.variants[cue.variant] || section.layers)[0].module.name; const label = `seed ${s} ${section.kind}/${scene}`; if (rate > worst) { worst = rate; worstLabel = label; } if (rate > 3) problems.push(`${label}: ${rate}/s`); } } finally { show.dispose(); } } return expect(problems.length === 0, problems.length ? problems.slice(0, 4).join(' · ') : `peak ${worst} flashes/s (ceiling 3) at ${worstLabel}`); }, { slow: true }); check(5, 'no scene in the library strobes at aggressive settings', () => { // Per-scene rather than per-look: the look generator only samples a slice of // the parameter space, so a scene can hide an unsafe region for a long time. // This drives every scene to high energy/density/motion across several seeds, // which is where strobing lives. Phase 7 runs the same check for each new // scene before it joins the library. const track = track5(); const problems = []; let worst = 0; let worstScene = ''; for (const module of scenes) { for (let s = 0; s < 4; s++) { const engine = makeEngine(240, 135); try { const rng = new Rng(1000 + s * 7919); engine.setLayerSpecs([{ module, params: sampleValues(module, rng, { energy: 0.9, density: 0.9, motion: 0.9 }), seed: s * 31 + 7, opacity: 1, blend: 'normal', palette: PALETTE, }]); engine.compositor.reset(); const luminance = []; for (let f = 4000; f < 4240; f++) { luminance.push(frameLuminance(engine.readPixels(engine.renderFrame(f)))); } const rate = peakFlashRate(luminance, 60); if (rate > worst) { worst = rate; worstScene = `${module.name} seed ${s}`; } if (rate > 3) problems.push(`${module.name} seed ${s}: ${rate}/s`); } finally { engine.dispose(); } } } return expect(problems.length === 0, problems.length ? problems.slice(0, 4).join(' · ') : `${scenes.length} scenes, worst ${worst} flashes/s (ceiling 3) at ${worstScene}`); }, { slow: true }); check(5, 'the full stack holds interactive frame rates at preview resolution', () => { const track = track5(); const show = new Show({ width: 1280, height: 720 }); try { show.useTrack(track, generateLook(track, { seed: 2468 })); show.look.feedback.amount = Math.max(0.4, show.look.feedback.amount); for (let f = 1200; f < 1230; f++) show.renderFrame(f); // warm caches const started = performance.now(); const frames = 60; for (let f = 1300; f < 1300 + frames; f++) show.renderFrame(f); const perFrame = (performance.now() - started) / frames; const layers = show.arc.activeLayers.length; return expectBelow(perFrame, 16.7, `${perFrame.toFixed(2)}ms/frame at 1280x720 with ${layers} layer(s) + feedback + post`); } finally { show.dispose(); } }, { slow: true }); check(5, 'multi-layer looks render live frames across the library', () => { const track = track5(); const problems = []; let stacks = 0; for (let s = 0; s < 8; s++) { const show = new Show({ width: 160, height: 90 }); try { show.useTrack(track, generateLook(track, { seed: 9000 + s * 104729 })); for (const section of show.look.sections) { if (section.layers.length > 1) stacks++; 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(' + ')}]: ` + `lum ${lum.toFixed(4)} var ${variance.toFixed(4)}`); } } } finally { show.dispose(); } } return expect(problems.length === 0, problems.length ? problems.slice(0, 4).join(' · ') : `${stacks} multi-layer section(s) across 8 seeds, all live`); }, { slow: true }); check(5, 'determinism survives feedback, post and 3D layers together', () => { // Two INDEPENDENTLY GENERATED looks, rendered through ONE engine. // // This used to build two Shows and compare them, which also compared two // WebGL contexts — and once the library grew heavier scenes that started // failing at 1-2/255 with nothing wrong: measured, the same context renders // the same frames bit-exactly (delta 0 over 40 frames, feedback at 0.6), // while two contexts on the same GPU disagree by up to 2/255 whether // feedback is on or off. That is driver-level variance between contexts, and // it is not what this check is for. // // Sharing the engine isolates the question that matters — does generating // the look twice, and driving layers, feedback, post and a 3D layer twice, // produce the same images — and lets it stay bit-exact rather than // acquiring a tolerance that would hide a real fault. // The two halves are asked separately, because only one of them can be // answered bit-exactly. Generation is pure JS and must match EXACTLY — // anything else is a real fault. Rendering the same look twice comes back // within 1/255 but not always at 0: measured, rebuilding a look recompiles // its programs, and a freshly linked program can differ from the previous // one by a single level on the heavier scenes. That is the same GPU variance // Phase 7 and PLAN.md §1 already account for, and hashing cannot express it. const show = new Show({ width: 128, height: 72 }); try { const shape = (look) => JSON.stringify(look.sections.map((s) => (s.variants || [s.layers]).map((v) => v.map((l) => [l.module.name, l.blend, l.opacity, l.seed, l.params])))); const lookA = generateLook(track5(), { seed: 1357 }); const lookB = generateLook(track5(), { seed: 1357 }); const generationMatches = shape(lookA) === shape(lookB) && JSON.stringify(lookA.personality) === JSON.stringify(lookB.personality); const run = (look) => { show.setLook(look); show.look.feedback.amount = 0.6; show.engine.compositor.reset(); const out = []; for (let f = 3000; f < 3060; f++) { out.push(Uint8Array.from(show.readPixels(show.renderFrame(f)))); } return out; }; show.useTrack(track5(), lookA); const fa = run(lookA); const fb = run(lookB); const worst = Math.max(...fa.map((frame, i) => frameMaxDelta(frame, fb[i]))); // Two levels rather than one, and only because feedback is on: the loop // re-reads its own output at 0.6 gain every frame, so a single-level // difference on frame n is still a fraction of a level on frame n+5. // Measured at 2/255 over 60 frames; a real fault scores in the tens. return expect(generationMatches && worst <= 2, `generation identical: ${generationMatches} · worst render delta ${worst}/255 over 60 frames`); } finally { show.dispose(); } });