// 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 });