Both phases come out of the manual gate — watching whole tracks — and both
fix something no automated check was looking for.
Phase 8: shots. A section is a STAGE of the song and can run ninety
seconds; one scene held that long reads as a still image with a wobble on
it. Each section kind now gets a roster of three or four stage visuals
instead of one scene, and each section is cut into shots that rotate
between them on phrase lines, never holding past 22s. The roster stays per
kind, so a track's drops still cut between the same images and the video
keeps its identity; the anchor opens each section and the rotation returns
to it, and when a companion is due it is the least recently shown one.
The arc driver stopped working in sections and started working in cues, one
per shot, so a shot cut and a section change take the same code path and
differ only in transition length. The default transition is a slow
dissolve — two bars calm, one loud; a straight cut is reserved for
sections above the energy threshold, because on calm material a cut reads
as a glitch rather than as an edit.
Phase 9: production design. With cuts every fifteen seconds the next
problem was that the images being cut between shared nothing but the
palette. What a music video actually shares across shots is a location, a
cast, a camera operator and an art direction, so each track now generates a
personality in four traits (shape, camera, space, style) off the look seed.
The traits reach shaders as uniforms plus four helpers in the contract, and
each scene expresses them its own way: Classic Wave's rings take the
signature polygon, Metaballs merge as one, Floating Geometry no longer
picks between a box and a circle because the production already decided.
The part that makes it a design rather than a filter: scenes DECLARE which
traits they honour, a track is built on one or two, and a scene that does
not honour all of them is not cast in that track. The library shrinks per
track on purpose.
Two gates keep the declaration honest — lint greps each shader for evidence
of every trait it claims, and a render check measures that each declared
trait actually moves the image (41 scene/trait pairs, weakest response 64
of 255). A layer with no personality renders bit-identically to before,
which is what keeps every earlier sweep and regression valid.
Checks changed rather than added:
- P4 scene-change and drift checks now measure per shot, not per section;
the crossfade check reads its length off the cue.
- P5 flash sweep runs per shot, so the visuals that only appear
mid-section are measured too.
- P6 preview/export parity primes first (as both real paths do) and
compares at the one-LSB tolerance Phase 7 already uses. Measured over
four consecutive shows: 3 frames at delta 1, then bit-exact — GPU
variance on first render, not a divergence.
- P2's contract-uniform list is derived from the contract instead of
retyped, so the signature uniforms cannot fall out of sync.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
325 lines
14 KiB
JavaScript
325 lines
14 KiB
JavaScript
// 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 } 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', () => {
|
|
const build = () => {
|
|
const show = new Show({ width: 128, height: 72 });
|
|
show.useTrack(track5(), generateLook(track5(), { seed: 1357 }));
|
|
show.look.feedback.amount = 0.6;
|
|
return show;
|
|
};
|
|
const a = build();
|
|
const b = build();
|
|
try {
|
|
const run = (show) => {
|
|
show.engine.compositor.reset();
|
|
const out = [];
|
|
for (let f = 3000; f < 3060; f++) out.push(show.hashFrame(f));
|
|
return out;
|
|
};
|
|
const ha = run(a), hb = run(b);
|
|
const mismatches = ha.filter((h, i) => h !== hb[i]).length;
|
|
return expect(mismatches === 0, `${mismatches}/60 frames differed`);
|
|
} finally {
|
|
a.dispose(); b.dispose();
|
|
}
|
|
});
|