A section used to be one scene, and two thirds of the library is composable —
sparse by design, elements ON something. Cast as backgrounds anyway, they left
9 of 40 sampled frames under 20% painted, the darkest at 0.3%: a minute and a
half of a few bright things on black, invisible to every gate because every
gate on the stack was a limit rather than a floor.
Every section now stands on a GROUND: a canvas that fills the frame, cast per
section kind so a shot cut changes the shot and not the world. When the shot
fills the frame itself it IS the ground — two canvases stacked is two pictures
fighting. Above that, a coverage BUDGET: director appetite times the section's
energy times where the story is, capped at two frames' worth of material.
The measured facts move into the repo. scenes/metadata.json is generated from
the gallery — coverage as a shot, coverage as a bed, variety, the structural
profile — tracked in git, stamped with a fingerprint of the scenes and the
metric definitions, and refreshed from gallery.html. `surface` is derived from
it rather than declared; nine scenes claimed `canvas` while painting under a
third of the frame, and declaring it is now a lint error. The generator weights
every layering choice by measured structural distance, because family labels
and the render disagree: two `geometric` scenes can be 0.31 apart and a `flow`
and an `organic` scene 0.04.
The gallery's 0.1 red line is gone. It was right when a section was one scene
and wrong now — nineteen scenes were failing a bar for being consistent, which
is a virtue in an ingredient.
Chasing the numbers turned up four real faults:
* A scene that reads prev() cannot be a ground. It returns the whole
composited frame including the layers above it, so a datamosh under a shot
is eating it: the render stopped reproducing from a seek and two WebGL
contexts diverged by 91/255 against a tolerance of 4.
* Screen was the wrong operator for a shot over a bed. It lightens, so a
median quarter of every frame clipped to paper and whole sections rendered
100% white. Replaced by a lumakey — the shot's brightness is its alpha.
* Feedback was an accumulator: a still image settled at 2.3x its own
brightness. Fine over black, fatal over a filled ground. Normalised at 0.6,
plus a highlight shoulder so the top rolls off instead of clipping.
* useTrack never prewarmed, so a fresh Show's first frame differed from every
later render of it — the export-breaking hazard Compositor.prime documents.
Blazing is a decision now, not a side effect: directors declare an appetite for
it, a section must be loud and late in the story to earn one, and quiet kinds
never do. The ceiling gate matches that — a hard cap per section, and no more
than a fifth of them hot at all.
Rendered across twelve videos, middle of every section:
painted 51% mean, darkest 0.3% -> 87% mean, darkest 43%
clipped white 24% median, worst 100% -> 1% mean, worst 30%
separation 0.10 -> 0.44
Seven scenes can ground a section — five geometric, two organic — so every
quiet section of every video stands on one of two beds. That is the library's
largest hole and it is scene work: there is no minimal or flow canvas that
fills half the frame without reading prev().
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
292 lines
13 KiB
JavaScript
292 lines
13 KiB
JavaScript
// 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 });
|