Epic 2.1: cut on a phrase, not on a metronome

planShots picked a bar count once per section and then divided the section
into that many equal pieces. Measured, a five-minute track at 90 BPM came out
as sixteen shots of 19.3, 18.7, 18.7, 18.7 ... 18.7, 19.3 seconds — every cut
for five minutes landing on the same pulse. No amount of variety in what the
shots contain fixes that, because the fatigue is in the timing.

A section now carries a repeating RHYTHM PATTERN in bars — [8,8,16],
[4,4,4,8] and friends, picked by energy — walked in order and repeated. The
same track now cuts 10.7, 10.7, 21.3, 10.7, 10.7, 21.3: two quick shots
answered by a hold. At 150 BPM the louder second section moves to
6.4, 6.4, 6.4, 12.8.

Repeating rather than random is the whole point, and it is why the gate comes
in two halves. Random shot lengths would satisfy "lengths must vary" and look
worse than a metronome, because the ear is following an eight-bar structure
and the eye would not be. So one check demands spread and a second demands
that the lengths come from a small recurring set.

The ceiling is applied by scaling the whole pattern rather than clamping each
entry: at 90 BPM a 16-bar hold is 42s and a 4-bar one is 10s, and clamping
both to 22 restores the metronome the pattern exists to break. Downbeat
snapping is now bounded to cuts that keep the shot legal — snapping to the
merely-nearest line pushed a 22s shot to 22.75s, over the ceiling the pattern
was fitted to respect. 366 of 370 cuts still land within three quarters of a
beat of a downbeat.

New Phase 11 gate (EPIC-2.md §4): metronome, phrasing, floor/ceiling, grid.
97/97 checks pass including the slow set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-08-06 07:29:10 +02:00
co-authored by Claude Opus 5
parent b69eaa799d
commit f050eaaad1
3 changed files with 259 additions and 42 deletions
+159
View File
@@ -0,0 +1,159 @@
// 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';
/** 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%)`);
});