Files
music-video-gen/flow-state/src/checks/phase11.js
T
Dejvino a99a7cbe49 Continuity rule: discrete params hold per cue; add DESIGN.md + AGENTS.md
Actors and objects change only at camera cuts. int/bool/discrete
params skip drift, lookahead and reactivity; discrete slowAxis steps
on the section-held journey. Venues fade instead of toggling on
interpolated popMul; roadside density is arrangement-only.

DESIGN.md collects the project's design decisions; AGENTS.md points
agents at it and the build-visualizer loop.
2026-09-20 16:42:44 +02:00

1018 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Phase 11 gate — Epic 2. See EPIC-2.md.
//
// Phase 10 asked whether two tracks look different from each other. This phase
// asks the question that only shows up when you actually sit and watch one:
// does a single track hold five minutes?
//
// The failures it exists to catch were all measured on a working build, not
// imagined — a cut metronome, a palette that never moves, scenes that are
// violently animated and read as static, and a kind-to-family table that made
// every video make the same genre decisions.
import { check, expect } from './framework.js';
import { generateLook } from '../look/LookGenerator.js';
import { FeatureTrack } from '../audio/FeatureTrack.js';
import { synthesizeSectioned } from '../audio/synth.js';
import { MIN_SHOT_SECONDS, MAX_SHOT_SECONDS } from '../look/shots.js';
import { DIRECTORS, RESTFUL_FAMILIES, pickDirector } from '../look/directors.js';
import { scenes, scenesInFamily } from '../scenes/registry.js';
import { paletteShiftAt, MAX_HUE_ROTATION } from '../look/paletteArc.js';
import { shiftPalette, paletteContrast } from '../look/palette.js';
import { ArcDriver } from '../look/ArcDriver.js';
import { Engine } from '../engine/Engine.js';
import { featureProviderFor } from '../audio/FeatureTrack.js';
import { sampleValues, clampValue, canBackground, isDiscreteParam } from '../params/schema.js';
import { Rng } from '../engine/rng.js';
import { frameDistance, frameLuminance } from '../engine/hash.js';
import { SHOT_SIZES, SHOT_SIZE_NAMES, describeFraming } from '../look/framing.js';
import { reachFor, CURVE_NAMES } from '../look/Camera.js';
import { subjectOf } from '../look/stack.js';
/** Tracks at several tempos: shot length is measured in bars, so tempo is the axis. */
let cached = null;
function tempoBattery() {
if (!cached) {
cached = [
{ name: '90bpm', track: track(90) },
{ name: '124bpm', track: track(124) },
{ name: '150bpm', track: track(150) },
];
}
return cached;
}
function track(bpm) {
return FeatureTrack.fromAudioBuffer(
synthesizeSectioned({ bpm, duration: 300, changeAt: 150 }), { fps: 60 });
}
/** Every shot in a look, with its length in seconds, grouped by section. */
function shotLengths(look) {
return look.sections.map((s) => (s.shots || []).map((sh) => (sh.endFrame - sh.startFrame) / 60));
}
check(11, 'a section does not cut on a metronome', () => {
// The complaint, as a number. Before this, a five-minute track at 90 BPM
// was sixteen shots of 18.7 seconds — coefficient of variation about 0.01.
// A section has to show real spread between its shot lengths, or the eye
// starts predicting the cuts and the video reads as a slideshow.
const problems = [];
let measured = 0;
for (const { name, track: t } of tempoBattery()) {
for (let s = 0; s < 6; s++) {
const look = generateLook(t, { seed: 2100 + s * 7919 });
shotLengths(look).forEach((lengths, si) => {
if (lengths.length < 3) return; // too few shots to have a rhythm
measured++;
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
const sd = Math.sqrt(
lengths.reduce((a, b) => a + (b - mean) ** 2, 0) / lengths.length);
const cv = sd / Math.max(1e-6, mean);
if (cv < 0.12) {
problems.push(`${name} s${s}/§${si}: cv ${cv.toFixed(3)} over ` +
`${lengths.length} shots (${lengths.map((l) => l.toFixed(1)).join(', ')})`);
}
});
}
}
return expect(measured > 0 && problems.length === 0,
problems.length ? problems.slice(0, 3).join(' · ')
: `${measured} sections all cut with varying shot lengths`);
});
check(11, 'the cutting rhythm repeats rather than wandering', () => {
// The counter-check, and the reason the gate above is not sufficient on its
// own: RANDOM shot lengths would pass it and would look worse than a
// metronome. The ear is following an eight-bar structure; the eye has to be
// following one too. So a section's shot lengths must come from a small set
// of values that recur, not from a continuum.
const problems = [];
let measured = 0;
for (const { name, track: t } of tempoBattery()) {
for (let s = 0; s < 6; s++) {
const look = generateLook(t, { seed: 3300 + s * 6841 });
shotLengths(look).forEach((lengths, si) => {
if (lengths.length < 5) return;
measured++;
// Quantise to a quarter second: two shots from the same pattern
// entry differ only by downbeat snapping.
const buckets = new Set(lengths.map((l) => Math.round(l * 4)));
if (buckets.size > Math.ceil(lengths.length * 0.7)) {
problems.push(`${name} s${s}/§${si}: ${buckets.size} distinct lengths ` +
`over ${lengths.length} shots — wandering, not phrased`);
}
});
}
}
return expect(measured > 0 && problems.length === 0,
problems.length ? problems.slice(0, 3).join(' · ')
: `${measured} sections reuse a small set of shot lengths`);
});
check(11, 'shot length floor and ceiling still hold', () => {
// The rhythm work moves cuts around; these two limits are what keep it from
// producing a subliminal flash or a two-minute hold.
const problems = [];
let count = 0;
for (const { name, track: t } of tempoBattery()) {
for (let s = 0; s < 8; s++) {
const look = generateLook(t, { seed: 4400 + s * 15485863 });
shotLengths(look).forEach((lengths, si) => {
lengths.forEach((l, i) => {
count++;
// Half a frame of slack: shot bounds are rounded to frames.
if (l < MIN_SHOT_SECONDS - 0.02) problems.push(`${name} s${s}/§${si}#${i}: ${l.toFixed(2)}s under floor`);
if (l > MAX_SHOT_SECONDS + 0.02) problems.push(`${name} s${s}/§${si}#${i}: ${l.toFixed(2)}s over ceiling`);
});
});
}
}
return expect(problems.length === 0,
problems.length ? problems.slice(0, 4).join(' · ')
: `${count} shots all within ${MIN_SHOT_SECONDS}–${MAX_SHOT_SECONDS}s`);
});
check(11, 'cuts land on the beat grid', () => {
// A cut that lands across a phrase instead of on it reads as a mistake, and
// it is the thing most easily lost when shot timing stops being uniform.
const problems = [];
let total = 0;
let onGrid = 0;
for (const { name, track: t } of tempoBattery()) {
const beatSeconds = t.tempo.period / 60;
for (let s = 0; s < 6; s++) {
const look = generateLook(t, { seed: 5500 + s * 2654435761 });
look.sections.forEach((section) => {
(section.shots || []).forEach((shot, i) => {
if (i === 0) return; // section starts are not cuts
total++;
const at = shot.startFrame / 60;
// Distance to the nearest downbeat, in beats.
let best = Infinity;
for (const d of (t.tempo.downbeats || [])) {
best = Math.min(best, Math.abs(d - at));
}
if (best <= beatSeconds * 0.75) onGrid++;
});
});
}
if (!t.tempo.downbeats || !t.tempo.downbeats.length) problems.push(`${name}: no downbeat grid`);
}
const ratio = total ? onGrid / total : 0;
return expect(problems.length === 0 && total > 0 && ratio > 0.8,
`${onGrid}/${total} cuts within three quarters of a beat of a downbeat ` +
`(${(ratio * 100).toFixed(0)}%, floor 80%)`);
});
// --- the director ----------------------------------------------------------
// EPIC-2.md §3.2. The kind-to-family mapping used to be a module constant, so
// every video made the same genre decisions before a single seeded draw
// happened. It is now a per-track choice — see look/directors.js.
check(11, 'every scene in the library is reachable', () => {
// The number that made this worth doing: across twelve tracks, twenty-nine
// of forty-two scenes were cast in none of them. A scene no director can
// reach is dead weight, and the fault is in the mapping rather than in the
// scene, so this is asked of the mapping directly.
const reachable = new Set();
for (const d of DIRECTORS) {
for (const families of Object.values(d.families)) {
for (const f of families) {
for (const m of scenesInFamily(f)) reachable.add(m.name);
}
}
}
// Accents are cast by role rather than by family and are always eligible.
const missing = scenes.filter((m) => canBackground(m) && !reachable.has(m.name));
return expect(missing.length === 0,
missing.length ? `unreachable: ${missing.map((m) => m.name).join(', ')}`
: `all ${reachable.size} castable scenes reachable across ${DIRECTORS.length} directors`);
});
check(11, 'no director starves a section kind', () => {
// A director is a point of view, not a corner. Every kind it defines has to
// leave enough scenes to build a roster from and still have room for the
// signature filter to remove some.
const problems = [];
for (const d of DIRECTORS) {
for (const [kind, families] of Object.entries(d.families)) {
const pool = new Set();
for (const f of families) {
for (const m of scenesInFamily(f)) if (canBackground(m)) pool.add(m.name);
}
if (pool.size < 12) problems.push(`${d.name}/${kind}: only ${pool.size}`);
}
const kinds = Object.keys(d.families);
for (const k of ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro']) {
if (!kinds.includes(k)) problems.push(`${d.name}: no mapping for '${k}'`);
}
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ')
: `${DIRECTORS.length} directors, every kind backed by 12+ scenes`);
});
check(11, 'no director puts a loud family in a quiet section', () => {
// Phase 7 has enforced this since the minimal family existed, and the first
// draft of directors.js broke it — `corrupt` opened on glitch, `geometer`
// on geometric. An intro that opens strobing and a breakdown that answers a
// lull with a dense pattern are the two specific mistakes the family
// coupling exists to prevent, and a viewer meets them in the first fifteen
// seconds. Asserted against the mappings directly so a new director cannot
// reintroduce it without tripping this rather than a downstream render gate.
const problems = [];
for (const d of DIRECTORS) {
for (const kind of ['intro', 'breakdown', 'outro']) {
for (const f of d.families[kind] || []) {
if (!RESTFUL_FAMILIES.includes(f)) problems.push(`${d.name}/${kind}: ${f}`);
}
}
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ')
: `${DIRECTORS.length} directors keep intro/breakdown/outro on ${RESTFUL_FAMILIES.join('/')}`);
});
check(11, 'every director is castable', () => {
// Weighting tilts the odds by timbre; none of it may drive a weight to zero,
// or a director exists in the file and never in a video.
const probes = [
{ meanFlatness: 0.02, meanCentroid: 0.15 }, // tonal and dark
{ meanFlatness: 0.5, meanCentroid: 0.9 }, // noisy and bright
{ meanFlatness: 0.2, meanCentroid: 0.5 },
];
const rng = { pickWeighted: (arr, w) => w }; // capture the weights
const problems = [];
for (const p of probes) {
const weights = pickDirector(p, rng);
weights.forEach((w, i) => {
if (!(w > 0)) problems.push(`${DIRECTORS[i].name} unreachable at flatness ${p.meanFlatness}`);
});
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ')
: `all ${DIRECTORS.length} directors carry weight on every timbre probed`);
});
check(11, 'two tracks do not agree on what a section kind looks like', () => {
// The population question. Across a battery, the same section kind must be
// answered by more than one family, or the director layer is decorative.
const seen = new Map(); // kind -> Set of family names actually cast
const directors = new Set();
for (const { track: t } of tempoBattery()) {
for (let s = 0; s < 20; s++) {
const look = generateLook(t, { seed: 6600 + s * 7919 });
directors.add(look.director);
look.sections.forEach((section) => {
const set = seen.get(section.kind) || new Set();
(section.variants || [section.layers]).forEach((v) => set.add(subjectOf(v).module.family));
seen.set(section.kind, set);
});
}
}
const thin = [...seen.entries()].filter(([, set]) => set.size < 3)
.map(([kind, set]) => `${kind}: ${[...set].join('/')}`);
// Three of five in a population this size is a real spread; demanding all
// five would be demanding a particular draw rather than a working mechanism,
// and reachability is asserted directly by the check above.
return expect(directors.size >= 3 && thin.length === 0,
`${directors.size}/${DIRECTORS.length} directors cast · ` +
(thin.length ? `thin kinds — ${thin.join(' · ')}`
: [...seen.entries()].map(([k, v]) => `${k}:${v.size}fam`).join(' ')));
});
check(11, 'a track shows more of the library than it used to', () => {
// Twelve tracks used to reach thirteen distinct scenes between them. This
// asks the same question of the same size population.
const cast = new Set();
tempoBattery().forEach(({ track: t }, i) => {
for (let s = 0; s < 4; s++) {
const look = generateLook(t, { seed: (i * 2654435761 + s * 40503) >>> 0 });
look.sections.forEach((section) =>
(section.variants || [section.layers]).forEach((v) => cast.add(subjectOf(v).module.name)));
}
});
const pool = scenes.filter(canBackground).length;
return expect(cast.size >= pool * 0.4,
`${cast.size}/${pool} scenes cast across 12 tracks (floor ${Math.ceil(pool * 0.4)})`);
});
// --- colour movement -------------------------------------------------------
// EPIC-2.md §3.3. The palette was generated once and pushed to every layer of
// every section for the whole runtime — five minutes, one scheme, no movement,
// with colour being the strongest perceptual variable available.
check(11, 'the palette moves across a track', () => {
// The complaint as a number. Sample the arc from first frame to last and
// require the colours to actually end up somewhere else.
const moved = [];
const held = [];
for (const { track: t } of tempoBattery()) {
for (let s = 0; s < 8; s++) {
const look = generateLook(t, { seed: 7700 + s * 6841 });
const arc = look.paletteArc;
let worst = 0;
for (const kind of ['intro', 'drop', 'breakdown', 'outro']) {
for (const p of [0, 0.5, 1]) {
const shift = paletteShiftAt(arc, {
progress: p, sectionKind: kind,
features: { sectionEnergy: p, buildSlope: 0 },
});
const shifted = shiftPalette(look.palette, shift);
for (let i = 0; i < look.palette.length; i++) {
const a = look.palette[i], b = shifted[i];
worst = Math.max(worst, Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]));
}
}
}
(arc.mode === 'static' ? held : moved).push(worst);
}
}
const weak = moved.filter((d) => d < 0.03).length;
const ratio = moved.length / (moved.length + held.length);
return expect(weak === 0 && ratio > 0.6,
`${moved.length} moving / ${held.length} held · weakest move ` +
`${(moved.length ? Math.min(...moved) : 0).toFixed(3)} channel distance (floor 0.03)`);
});
check(11, 'colour movement never breaks the contrast floor', () => {
// The Phase 3 palette gate proves the palette is usable when it is
// generated. That says nothing about where the arc takes it — a saturation
// lift or a lightness shift can flatten a perfectly good set. So the floor
// is asserted at every sampled point of the movement rather than only at
// the two ends, which is where this would otherwise be silently violated.
const problems = [];
let sampled = 0;
for (const { track: t } of tempoBattery()) {
for (let s = 0; s < 6; s++) {
const look = generateLook(t, { seed: 8800 + s * 15485863 });
const base = paletteContrast(look.palette).luminanceSpread;
for (const kind of ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro']) {
for (let p = 0; p <= 1.001; p += 0.25) {
for (const energy of [0, 0.5, 1]) {
sampled++;
const shift = paletteShiftAt(look.paletteArc, {
progress: p, sectionKind: kind,
features: { sectionEnergy: energy, buildSlope: energy },
});
const spread = paletteContrast(
shiftPalette(look.palette, shift)).luminanceSpread;
// Held against the palette's OWN spread, not an absolute:
// the arc must not degrade what generation achieved.
if (spread < Math.min(0.18, base * 0.75)) {
problems.push(`seed ${s} ${kind}@${p.toFixed(2)}/e${energy}: ` +
`spread ${spread.toFixed(3)} from base ${base.toFixed(3)}`);
}
}
}
}
}
}
return expect(problems.length === 0,
problems.length ? `${problems.length} of ${sampled} points muddy — ${problems[0]}`
: `${sampled} points across the movement all clear the contrast floor`);
});
check(11, 'colour movement stays inside its identity', () => {
// The counter-check. A palette that moves far enough is a different
// palette, and then the track has no colour identity at all — which would
// pass the movement gate above with room to spare.
const problems = [];
for (const { track: t } of tempoBattery()) {
for (let s = 0; s < 8; s++) {
const look = generateLook(t, { seed: 9900 + s * 7919 });
for (const kind of ['intro', 'drop', 'breakdown']) {
for (const p of [0, 0.5, 1]) {
const shift = paletteShiftAt(look.paletteArc, {
progress: p, sectionKind: kind,
features: { sectionEnergy: p, buildSlope: p },
});
if (Math.abs(shift.hue) > MAX_HUE_ROTATION + 1e-6) {
problems.push(`seed ${s}: hue ${shift.hue.toFixed(2)} past the ceiling`);
}
}
}
}
}
return expect(problems.length === 0,
problems.length ? problems[0]
: `hue travel stays within ±${(MAX_HUE_ROTATION * 57.3).toFixed(0)}° of the track's palette`);
});
check(11, 'a moved palette is the same on a seek as on playback', () => {
// The movement is memoised on a rounded shift. Rounding is what keeps a
// seeked frame bit-identical to a played one rather than merely close, and
// getting that wrong would be a determinism fault that only shows up in an
// export.
const t = tempoBattery()[1].track;
const look = generateLook(t, { seed: 4321 });
const a = new ArcDriver(look, t);
const b = new ArcDriver(look, t);
const probes = [0, 900, 4500, 9000, 12000];
let worst = 0;
try {
// One driver plays through; the other jumps straight to each probe.
for (let f = 0; f <= 12000; f += 30) a.update(f, t.at(f));
for (const f of probes) {
const seq = a._paletteAt(f, t.at(f));
const jump = b._paletteAt(f, t.at(f));
for (let i = 0; i < seq.length; i++) {
for (let c = 0; c < 3; c++) worst = Math.max(worst, Math.abs(seq[i][c] - jump[i][c]));
}
}
} finally {
a.dispose(); b.dispose();
}
return expect(worst === 0,
`worst channel difference between seeked and played colours: ${worst}`);
});
// --- the slow axis ---------------------------------------------------------
// EPIC-2.md §3.4. Eleven scenes changed as much in half a second as in two
// minutes: everything moving in them was cyclic, so the eye adapted in about
// two seconds. Drift is a 20-70s LFO and an LFO returns; the slow axis is
// monotonic across the whole track. See ArcDriver._slowAxisFor.
check(11, 'every scene gets a slow axis with real travel', () => {
// Structural half of the gate, so a scene added later cannot quietly end up
// with nothing to evolve.
const t = tempoBattery()[1].track;
const look = generateLook(t, { seed: 20250 });
const arc = new ArcDriver(look, t);
const problems = [];
try {
for (const module of scenes) {
const axis = arc._slowAxisFor(module);
const eligible = Object.entries(module.params || {}).filter(([, d]) =>
d.type !== 'palette' && d.type !== 'bool' && !d.fixed && !d.rate && !d.noDrift && d.range);
if (!eligible.length) continue; // nothing it could legally move
if (!axis.length) { problems.push(`${module.name}: no axis`); continue; }
for (const item of axis) {
const [lo, hi] = item.def.range;
const fraction = Math.abs(item.travel) / (hi - lo);
if (fraction < 0.2) problems.push(`${module.name}.${item.name}: travels only ${(fraction * 100).toFixed(0)}%`);
if (item.def.rate) problems.push(`${module.name}.${item.name}: rate param on the axis`);
}
}
} finally {
arc.dispose();
}
return expect(problems.length === 0,
problems.length ? problems.slice(0, 3).join(' · ')
: `${scenes.length} scenes all carry a slow axis travelling 20%+ of range`);
});
check(11, 'the slow axis is a journey rather than a cycle', () => {
// The counter-check. An axis that returned to where it started would satisfy
// "params move" and would leave the churn exactly as it was — which is what
// the existing drift LFO already did.
const t = tempoBattery()[1].track;
const look = generateLook(t, { seed: 31337 });
const arc = new ArcDriver(look, t);
const problems = [];
try {
const cue = arc.cues[0];
// The shot, not the bed under it — the slow axis is a claim about the
// scene the section is about.
const slot = arc._subjectSlot(cue);
const at = (time) => arc._paramsAt(cue, slot, time, t.at(Math.round(time * 60)));
const spec = arc._specFor(cue.sectionIndex, cue.variant, slot);
const axis = arc._slowAxisFor(spec.module);
const start = at(t.duration * 0.05);
const mid = at(t.duration * 0.5);
const end = at(t.duration * 0.95);
for (const item of axis) {
// Discrete params step at cuts instead of travelling: held within
// the shot by construction (see schema.js isDiscreteParam), so the
// travel assertion below cannot apply. What is asserted instead is
// the hold itself — two samples inside one cue must agree exactly —
// while the travel magnitude is already covered structurally above.
if (isDiscreteParam(item.def)) {
const t0 = (cue.startFrame + 5) / 60;
const t1 = (cue.endFrame - 5) / 60;
if (t1 > t0) {
const va = at(t0)[item.name], vb = at(t1)[item.name];
if (va !== vb) {
problems.push(`${spec.module.name}.${item.name}: ${va} → ${vb} ` +
`mid-shot — discrete params change only at cuts`);
}
}
continue;
}
const [lo, hi] = item.def.range;
const span = hi - lo;
const a = start[item.name], m = mid[item.name], z = end[item.name];
// Monotonic in the sense that matters: the end is further from the
// start than the middle is, in the direction of travel.
const total = Math.abs(z - a) / span;
if (total < 0.12) {
problems.push(`${spec.module.name}.${item.name}: start ${a.toFixed(3)} ` +
`mid ${m.toFixed(3)} end ${z.toFixed(3)} — only ${(total * 100).toFixed(0)}% travelled`);
}
}
if (!axis.length) problems.push('no axis on the opening scene');
} finally {
arc.dispose();
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ') : 'the opening scene ends the track somewhere else');
});
/**
* Structure, with the churn averaged out.
*
* Single-frame distance cannot answer "did this develop?" for exactly the
* scenes that fail it: a churning scene's consecutive frames are already ~0.6
* apart, so every pair of its frames scores the same whether the structure
* moved or not. Averaging ten seconds of frames cancels the churn and leaves
* the structure — and is much closer to what a viewer perceives over seconds
* than any single frame is.
*
* Ten seconds was measured, not guessed. At a one-second window Moiré Grid's
* frozen-parameter control still read 0.024; at ten it reads 0.0095, while the
* axis-driven change stays at 0.037. The window has to be wide enough that the
* control collapses and the signal does not.
*/
function averagedFrame(engine, look, module, params, frame0, n = 90, step = 7) {
engine.setLayerSpecs([{ module, params, seed: 9, opacity: 1, blend: 'normal',
palette: look.palette, personality: look.personality }]);
engine.prime(frame0);
engine.compositor.reset();
let acc = null;
for (let k = 0; k < n; k++) {
const px = engine.readPixels(engine.renderFrame(frame0 + k * step));
if (!acc) acc = new Float64Array(px.length);
for (let i = 0; i < px.length; i++) acc[i] += px[i];
}
for (let i = 0; i < acc.length; i++) acc[i] /= n;
return acc;
}
function meanAbs(a, b) {
let sum = 0;
for (let i = 0; i < a.length; i++) sum += Math.abs(a[i] - b[i]);
return sum / a.length / 255;
}
/**
* How much the declared axis moves a scene, against the metric's own noise.
*
* The experiment holds TIME constant and varies only the axis, which the first
* version of this check did not — and getting that wrong made it measure
* something else entirely.
*
* That version compared the scene at thirty seconds against the scene at two
* and a half minutes, and divided by what the scene did between those points
* with its parameters frozen. But those are two different places in the song,
* so the denominator was full of audio reactivity: the check was really asking
* "does the slow axis move this scene MORE than the music does", which is a
* different and much harder question, and one no scene should have to pass.
* Circuit Bloom failed it at 0.92x while its mean luminance was moving 5.6x
* across the axis — a large structural change, scored as nothing.
*
* So: one frame window, two parameter sets, and a control that is the same
* parameters over the next window — the residual churn the ten-second average
* failed to cancel, which is exactly the noise floor this measurement has to
* beat. With the axis disabled the two parameter sets are identical and the
* ratio is 0.00 by construction, which is what makes the companion check below
* a real discriminator rather than a formality.
*/
function axisRatio(engine, arc, look, track, module, { withAxis }) {
const paramsAt = (seconds) => {
const out = sampleValues(module, new Rng(77), look.sections[0].bias,
look.personality.temperament);
if (!withAxis) return out;
const p = Math.min(1, seconds / track.duration);
const journey = p * p * (3 - 2 * p);
for (const item of arc._slowAxisFor(module)) {
if (typeof out[item.name] !== 'number') continue;
out[item.name] = clampValue(item.def, out[item.name] + item.travel * (journey - 0.5));
}
return out;
};
const FRAME = 1800;
const WINDOW = 90 * 7;
const early = averagedFrame(engine, look, module, paramsAt(0), FRAME);
const late = averagedFrame(engine, look, module, paramsAt(track.duration), FRAME);
const control = averagedFrame(engine, look, module, paramsAt(0), FRAME + WINDOW);
const moved = meanAbs(early, late);
const noise = meanAbs(early, control);
return { moved, noise, ratio: moved / Math.max(1e-6, noise) };
}
check(11, 'a declared slow axis actually changes the scene', () => {
// EPIC-2.md §3.4, and the honest scope of it. A scene that declares an axis
// is claiming that walking that param is what it looks like changing, so
// the claim is measured: the structural change with the axis has to beat
// what the scene does on its own by a real margin.
const t = tempoBattery()[1].track;
const look = generateLook(t, { seed: 5150 });
const engine = new Engine({ width: 128, height: 72 });
engine.timeline.setDuration(t.duration);
engine.setFeatureProvider(featureProviderFor(t));
const arc = new ArcDriver(look, t);
const problems = [];
const detail = [];
try {
const declared = scenes.filter((m) => Object.values(m.params || {}).some((d) => d.slowAxis));
for (const module of declared) {
const r = axisRatio(engine, arc, look, t, module, { withAxis: true });
detail.push(`${module.name} ${r.ratio.toFixed(2)}x`);
if (r.ratio < 1.25) {
problems.push(`${module.name}: axis moved ${r.moved.toFixed(4)} against a ` +
`${r.noise.toFixed(4)} noise floor — only ${r.ratio.toFixed(2)}x`);
}
}
if (!declared.length) problems.push('no scene declares a slow axis');
} finally {
arc.dispose();
engine.dispose();
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ') : `${detail.join(', ')}`);
}, { slow: true });
// --- framing ---------------------------------------------------------------
// EPIC-2.md §3.5. Every scene was a locked-off, full-frame wide, so a cut changed
// the subject and never the framing. A shot now carries a scale and a recentre,
// applied in scene coordinates inside sigCamera — see look/framing.js. These
// gates hold the layer honest: it must actually change, stay within headroom, and
// be the same on a seek as on playback.
check(11, 'a shot cut actually changes the framing', () => {
// The complaint as a number: before this, every shot in every video was the
// same full-frame wide, and a cut moved the image and not the camera. The
// gate is on the population — most tracks should be framed at all, and of
// those, the cuts must really change the shot size rather than cosmetically
// vary a number no eye can see.
const tracks = tempoBattery();
const modes = [];
let nonLocked = 0;
let withSizeChange = 0;
let transitions = 0;
let changed = 0;
// Two seed families so the draw is not at the mercy of one: the whole gate
// ran once against a single family whose first few seeds happened to land
// heavy on `locked`, and a sample that small has no business holding a
// population claim. Locked is ~15% of tracks by design; 24 seeds a family
// put it far from that.
for (const { track: t } of tracks) {
for (const family of [11100, 12200]) {
for (let s = 0; s < 12; s++) {
const look = generateLook(t, { seed: family + s * 7919 });
const style = look.framing;
modes.push(style.mode);
if (style.mode === 'locked') continue;
nonLocked++;
const arc = new ArcDriver(look, t);
try {
for (let i = 1; i < arc.cues.length; i++) {
const a = arc.cues[i - 1].framing;
const b = arc.cues[i].framing;
if (!a || !b) continue;
transitions++;
if (a.size !== b.size) changed++;
}
const sizes = new Set(arc.cues.map((c) => c.framing && c.framing.size));
if (sizes.size > 1) withSizeChange++;
} finally {
arc.dispose();
}
}
}
}
const lockedShare = modes.filter((m) => m === 'locked').length / modes.length;
const changeRate = transitions ? changed / transitions : 0;
return expect(
nonLocked > 0 && withSizeChange >= nonLocked * 0.6
&& lockedShare < 0.5 && changeRate >= 0.15,
`${modes.length} tracks · locked ${(lockedShare * 100).toFixed(0)}% · ` +
`${withSizeChange}/${nonLocked} framed tracks show 2+ sizes · ` +
`${changed}/${transitions} cuts change size (${(changeRate * 100).toFixed(0)}%)`);
});
check(11, 'framing stays inside the library\'s headroom', () => {
// A size is only worth using if the scene still has detail at that size.
// Past ~2.2 the library runs out and a close-up is a blurry wide; below
// ~0.55 the subject is a speck. Those bounds are the measured edges of what
// is watchable, so every framed shot has to respect them.
const problems = [];
let framed = 0;
for (const { track: t } of tempoBattery()) {
for (let s = 0; s < 6; s++) {
const look = generateLook(t, { seed: 12200 + s * 6841 });
if (look.framing.mode === 'locked') continue;
const arc = new ArcDriver(look, t);
try {
for (const cue of arc.cues) {
const f = cue.framing;
if (!f) continue;
framed++;
if (f.scale < 0.55 || f.scale > 2.2) {
problems.push(`§${cue.sectionIndex}#${cue.shotIndex}: scale ${f.scale.toFixed(2)}`);
}
// The gaze is live now — it is not on cue.framing, it is
// wherever the camera has travelled to by a given frame —
// so the bound has to be measured at frames rather than
// read off the plan. Its ceiling is the reach the shot's
// own size allows: see Camera.reachFor, which is why a
// close-up is permitted further off centre than a wide.
const ceiling = look.camera
? reachFor(f.scale, look.camera) + 1e-6 : 0.31;
for (const fr of [cue.startFrame, (cue.startFrame + cue.endFrame) >> 1,
cue.endFrame - 1]) {
const shift = arc._framingAt(cue.index, fr).shift;
if (Math.hypot(shift[0], shift[1]) > ceiling) {
problems.push(`§${cue.sectionIndex}#${cue.shotIndex}@${fr}: `
+ `|shift| ${Math.hypot(shift[0], shift[1]).toFixed(3)} > ${ceiling.toFixed(3)}`);
}
}
}
} finally {
arc.dispose();
}
}
}
return expect(problems.length === 0 && framed > 0,
problems.length ? problems.slice(0, 3).join(' · ')
: `${framed} framed shots all within scale headroom and off-centre`);
});
check(11, 'framing is identical on a seek and on playback', () => {
// Framing is planned once per cue, but a reroll re-plans it. The gate that
// matters is the same one every other layer honours: the same frame has to
// carry the same framing however you reach it. Two drivers over the same
// look must agree on every shot.
const t = tempoBattery()[1].track;
const look = generateLook(t, { seed: 31337 });
const a = new ArcDriver(look, t);
const b = new ArcDriver(look, t);
const problems = [];
try {
for (let i = 0; i < a.cues.length; i++) {
const fa = a.cues[i].framing;
const fb = b.cues[i].framing;
if (fa && fb && fa.scale !== fb.scale) {
problems.push(`cue ${i}: scale ${fa.scale} vs ${fb.scale}`);
}
// The gaze is a position in time, so agreeing on the plan is not
// enough — two drivers have to agree on where the camera IS at a
// frame. This is the property a seek depends on.
const cue = a.cues[i];
for (const fr of [cue.startFrame, (cue.startFrame + cue.endFrame) >> 1,
cue.endFrame - 1]) {
const sa = a._framingAt(i, fr).shift;
const sb = b._framingAt(i, fr).shift;
if (sa[0] !== sb[0] || sa[1] !== sb[1]) {
problems.push(`cue ${i}@${fr}: gaze ${sa} vs ${sb}`);
}
}
}
} finally {
a.dispose(); b.dispose();
}
return expect(problems.length === 0,
problems.length ? problems.slice(0, 3).join(' · ')
: `${a.cues.length} cues carry the same framing in both drivers`);
});
/**
* Everything the camera exists to do, measured on one sweep of the battery.
*
* Stated as numbers because the device this replaces LOOKED right in the source
* and did nothing on screen: a recentre of a median 0.029 of a half-frame, at a
* fresh random angle every shot so successive offsets cancelled. Nothing in the
* old gates caught that, because the only bound was a ceiling — and a device
* doing nothing passes a ceiling comfortably.
*
* So each of these has a FLOOR. That is the lesson from the old one.
*/
function gazeStats() {
const offsets = []; const jumps = []; const travels = [];
const cameras = new Set(); const curves = new Set();
for (const { track: t } of tempoBattery()) {
for (let s = 0; s < 6; s++) {
const look = generateLook(t, { seed: 21400 + s * 5077 });
if (!look.camera) continue;
cameras.add(look.camera.name);
const arc = new ArcDriver(look, t);
try {
let prevEnd = null;
arc.cues.forEach((cue, i) => {
curves.add(arc.gaze[i].curve);
const start = arc._framingAt(i, cue.startFrame).shift;
const end = arc._framingAt(i, cue.endFrame - 1).shift;
offsets.push(Math.hypot(end[0], end[1]));
travels.push(Math.hypot(end[0] - start[0], end[1] - start[1]));
if (prevEnd) {
jumps.push(Math.hypot(start[0] - prevEnd[0], start[1] - prevEnd[1]));
}
prevEnd = end;
});
} finally {
arc.dispose();
}
}
}
const median = (a) => {
const v = a.slice().sort((x, y) => x - y);
return v.length ? v[v.length >> 1] : 0;
};
return {
offsets, jumps, travels, cameras, curves,
medOffset: median(offsets), medJump: median(jumps), medTravel: median(travels),
};
}
check(11, 'the camera moves far enough at a cut to be seen', () => {
// The floor is the whole point. The device this replaces had a median jump
// of 0.014 of a half-frame — present in every video, visible in none.
//
// Measured over the REFRAMES rather than over every cut. A match cut is a
// deliberate zero and there are enough of them to drag a plain median down;
// averaging the cuts that chose not to move together with the ones that did
// would let the reframes shrink to nothing without the number noticing,
// which is the exact failure this whole gate exists to catch.
const r = gazeStats();
const reframes = r.jumps.filter((j) => j >= 0.01);
const sorted = reframes.slice().sort((a, b) => a - b);
const med = sorted.length ? sorted[sorted.length >> 1] : 0;
return expect(med > 0.09 && reframes.length > 0,
`median reframe ${med.toFixed(3)} of a half-frame across ${reframes.length} reframes ` +
`· median offset ${r.medOffset.toFixed(3)} ` +
`(the old device measured 0.014 jump / 0.029 offset)`);
});
check(11, 'the camera moves DURING a shot, not only at cuts', () => {
// Framing used to be constant within a shot by design. The recentre is not
// framing — a camera that only ever steps is a slideshow of stills.
const r = gazeStats();
const moving = r.travels.filter((t) => t > 0.02).length;
const share = moving / Math.max(1, r.travels.length);
return expect(r.medTravel > 0.02 && share > 0.5,
`${moving}/${r.travels.length} shots travel (${(share * 100).toFixed(0)}%) · ` +
`median travel ${r.medTravel.toFixed(3)}`);
});
check(11, 'the camera cuts through as well as jumping', () => {
// A camera that relocates at EVERY cut is as much a single rule as one that
// never does. The match cut — where the gaze walks through the change and
// two scenes read as one place — has to survive as a real minority.
const r = gazeStats();
const matched = r.jumps.filter((j) => j < 0.01).length;
const share = matched / Math.max(1, r.jumps.length);
return expect(share > 0.04 && share < 0.6,
`${matched}/${r.jumps.length} cuts are match cuts (${(share * 100).toFixed(0)}%)`);
});
check(11, 'the library uses more than one camera and more than one curve', () => {
// Same argument directors.js makes about families: one camera applied to
// every track is how a library ends up with one look.
const r = gazeStats();
return expect(r.cameras.size >= 3 && r.curves.size >= 3,
`${r.cameras.size} cameras (${[...r.cameras].join(', ')}) · ` +
`${r.curves.size}/${CURVE_NAMES.length} curves used`);
});
check(11, 'the axis measurement would notice if the axis stopped working', () => {
// EPIC-2.md §4 names this failure mode by name: a gate that measures the
// wrong thing. This one has already happened once here — the first version
// of the check above used single-frame distance, which is saturated on
// churning scenes, and passed while the axis was provably doing nothing.
//
// So the metric is verified by breaking what it is supposed to catch. Run
// the identical measurement with the axis disabled; it must come back at
// about 1.0 and fail the threshold the real check applies.
const t = tempoBattery()[1].track;
const look = generateLook(t, { seed: 5150 });
const engine = new Engine({ width: 128, height: 72 });
engine.timeline.setDuration(t.duration);
engine.setFeatureProvider(featureProviderFor(t));
const arc = new ArcDriver(look, t);
const problems = [];
const detail = [];
try {
const declared = scenes.filter((m) => Object.values(m.params || {}).some((d) => d.slowAxis));
for (const module of declared) {
const off = axisRatio(engine, arc, look, t, module, { withAxis: false });
detail.push(`${module.name} ${off.ratio.toFixed(2)}x`);
if (off.ratio >= 1.25) {
problems.push(`${module.name}: reads ${off.ratio.toFixed(2)}x with the axis ` +
`DISABLED — the measurement is not tracking the axis`);
}
}
} finally {
arc.dispose();
engine.dispose();
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ')
: `axis disabled reads ${detail.join(', ')} — the measurement tracks the axis`);
}, { slow: true });
check(11, 'framing visibly changes every scene in the library', () => {
// The gate the framing work shipped without, and the reason this revisit
// found anything. The other framing checks assert that the PLAN differs —
// cue sizes, distribution, headroom — and a plan that never reaches the
// image passes all of them. It did: framing was applied inside sigCamera,
// which is gated on the `camera` personality trait, so any scene that
// declined the track's drift silently declined the shot size too. Scan
// Tear, Pylon Grid, Pitch Shatter and the 3D layer rendered identically at
// every size and nothing noticed.
//
// Measured relative to how much image there is, for the reason Phase 10
// documents: most of this library is mostly dark.
const PALETTE = [[1, 0.3, 0.2], [0.2, 0.8, 1], [1, 0.9, 0.3],
[0.6, 0.3, 1], [0.2, 1, 0.6], [1, 1, 1]];
const PERSON = {
shape: { sides: 0, roundness: 0.25, elongation: 1, tilt: 0 },
camera: { driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0.1, spin: 0, breathe: 0 },
space: { horizon: 0.5, depth: 0.5, washAngle: 0, wash: 0.2 },
style: { lineWeight: 0.5, softness: 0.5, texture: 0, symmetry: 1 },
};
const engine = new Engine({ width: 160, height: 90 });
engine.timeline.setDuration(60);
const problems = [];
let weakest = Infinity;
let weakestName = '';
try {
const shot = (module, framing) => {
engine.setLayerSpecs([{
module, params: sampleValues(module, new Rng(31), {}, null),
seed: 9, opacity: 1, blend: 'normal', palette: PALETTE, personality: PERSON,
}]);
// Framing is pushed onto live layers, the way the arc driver does it.
const layers = engine.compositor.layers || [];
layers.forEach((l) => l.setFraming && l.setFraming(framing));
engine.prime(600);
engine.compositor.reset();
layers.forEach((l) => l.setFraming && l.setFraming(framing));
return Uint8Array.from(engine.readPixels(engine.renderFrame(600)));
};
for (const module of scenes) {
const normal = shot(module, { size: 'normal', scale: 1, shift: [0, 0] });
const close = shot(module, { size: 'close', scale: SHOT_SIZES.close.scale, shift: [0.05, 0.02] });
const wide = shot(module, { size: 'wide', scale: SHOT_SIZES.wide.scale, shift: [0.05, 0.02] });
const brightness = Math.max(1e-3,
(frameLuminance(normal) + frameLuminance(close)) * 0.5);
const best = Math.max(frameDistance(normal, close), frameDistance(normal, wide))
/ brightness;
if (best < weakest) { weakest = best; weakestName = module.name; }
if (best < 0.05) {
problems.push(`${module.name}${module.kind === 'layer3d' ? ' [3D]' : ''}: ` +
`${best.toFixed(3)} — renders the same at every shot size`);
}
}
} finally {
engine.dispose();
}
return expect(problems.length === 0,
problems.length ? problems.join(' · ')
: `${scenes.length} scenes all respond to framing · weakest ` +
`${weakestName} at ${weakest.toFixed(2)} of its own brightness`);
}, { slow: true });