music-video-gen/flow-state/src/checks/phase5.js
Dejvino 2806ef1386 Phase 10: variety, twelve scenes, and tooling to write the next one
Watching several finished tracks side by side turned up the problem neither
Phase 8 (too few cuts) nor Phase 9 (no through-line) addressed: the same
scene cast in two different videos looked like the same footage twice.
Section bias is nearly identical between two tracks' drops, so both sampled
their parameters around the same centre, and the library's own averageness
did the rest.

Three answers, none of them a new scene:

  Temperament — a per-track hand on every parameter dial: intensity, pace,
  detail, and an extremity that decides how far toward the ends of a range
  the track is willing to sample. Bias comes from the section and is shared
  between tracks; temperament comes from the track and is not.

  Overlays — sometimes a second full scene composited over the shot, from a
  different family, in a blend that preserves what is underneath and never
  above 0.6 opacity. Not always: a stack that always doubled up would read
  as permanently cluttered rather than as occasionally layered.

  A wider palette — hue now derives from SPECTRAL TILT, the log ratio of
  treble to body. The centroid is a number most masters sit in the middle
  of, and the plain body/(body+treble) fraction is worse: low frequencies
  carry most of the energy in all music, so it read 0.98-1.00 for
  everything and four different battery tracks came out within 0.02 of
  each other. The ratio is multiplicative, so its logarithm is what
  spreads — the same four measure -9.3, -5.0, -4.1, -3.8. Also both ways
  round the wheel (violet, magenta and pink were unreachable by
  construction), four new schemes, and seeded chroma profile and lightness
  curve. Closest battery pair went from 0.005 to 0.113.

Twelve scenes take the library to 36, six per family: Aurora Veil, Vortex
Drift, Tide Rings, Ink Bleed, Dust Chamber, Salt Flat, Cargo Belt, Gate
Corridor, Circuit Bloom, Truchet Fold, Signal Decay, Storm Rift. Weighted
toward the 'space' and 'shape' traits, which were thinnest and so the
signatures most likely to run a track out of cast — the Phase 9 casting
rule means the pool a track draws from is smaller than the library.

Also fixes a real one in shots.js: heavy LRU weighting was not enough to
make a section reach its whole roster, and a five-shot section still came
out 0,2,0,2,0 about a fifth of the time. An unseen companion now wins
outright; which one is still free, so only the coverage is guaranteed.

Block Mosh declared the camera trait, assigned sigCamera(p) to a p it then
never read, and passed the lint's evidence grep. The Phase 9 render gate
measured its response to the camera at exactly zero.

--- tooling ---

Adding a scene was mostly boilerplate and round-trips, which is expensive
in both senses. The irreducible cost is the shader body; everything around
it is now mechanical:

  npm run new:scene -- "Name" --family=... --traits=...

writes the module, registers it, and leaves a skeleton that already passes
every gate, with name-derived constants so two skeletons are not twins.

The lint grew the rules that previously needed a GPU to catch: the dead
camera above, prev() with no base image, and large loops with no early
break (with a `// lint: fixed-cost` opt-out for a genuinely fixed-cost
sampling loop). checks.html?scene=Name runs the per-scene acceptance
battery for one scene — ten lines and a verdict instead of rendering the
whole library to find out whether one shader is alive. The same procedure
is a repo skill under .claude/skills/build-visualizer/.

--- checks changed, with the measurements ---

P5 determinism compared two WebGL CONTEXTS, which is not what it is for.
Measured: one context is bit-exact over 40 frames with feedback at 0.6;
two contexts disagree by up to 2/255 whether feedback is on or off. It now
asserts generation is byte-identical (hard) and rendering within 2/255,
since feedback compounds single-level variance.

P10's cross-track comparison measures distance RELATIVE to how much image
there is. Most scenes are mostly dark, so two genuinely different renders
— 25 bars against 53 — scored under 0.02 absolute purely because the black
background agrees with itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:59:20 +02:00

361 lines
16 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, 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();
}
});