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
parent b69eaa799d
commit f050eaaad1
3 changed files with 259 additions and 42 deletions

View File

@ -13,6 +13,7 @@ import './phase7.js';
import './phase8.js'; import './phase8.js';
import './phase9.js'; import './phase9.js';
import './phase10.js'; import './phase10.js';
import './phase11.js';
const out = document.getElementById('results'); const out = document.getElementById('results');
const summaryEl = document.getElementById('summary'); const summaryEl = document.getElementById('summary');

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%)`);
});

View File

@ -32,21 +32,78 @@ export const MAX_SHOT_SECONDS = 22;
/** Below this section energy, a shot change is always a dissolve, never a cut. */ /** Below this section energy, a shot change is always a dissolve, never a cut. */
export const HARD_CUT_ENERGY = 0.66; export const HARD_CUT_ENERGY = 0.66;
/** Phrase length for one shot, in bars, from the section's energy. */ /**
function shotBarsFor(energy, rng) { * The section's cutting RHYTHM, in bars per shot.
if (energy > 0.72) return rng.pick([4, 8, 8]); *
if (energy > 0.45) return rng.pick([8, 8, 16]); * This used to be a single bar count, and the section was then divided into
return rng.pick([8, 16, 16]); * 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 a metronome.
* Every cut landing on the same pulse for five minutes is the most fatiguing
* edit rhythm available, and no amount of variety in what the shots CONTAIN
* fixes it, because the fatigue is in the timing rather than in the images.
*
* So a section carries a repeating PATTERN instead: a long hold, two quick
* ones, a long hold. The pattern is walked in order and repeats, which is what
* makes it read as phrasing rather than as randomness random shot lengths
* would satisfy any "lengths must vary" test and would look worse, because the
* ear is following an eight-bar structure and the eye would not be.
*
* Every entry is a power-of-two bar count, so a cut is always on a phrase line
* of some depth even before it is snapped to a downbeat.
*/
function rhythmFor(energy, rng) {
if (energy > 0.72) {
// Loud material: quick cuts, but still answered by a longer hold.
return rng.pick([[4, 4, 8], [8, 4, 4], [4, 4, 4, 8], [8, 8, 4, 4], [4, 8, 4, 4]]);
}
if (energy > 0.45) {
return rng.pick([[8, 8, 16], [16, 8, 8], [8, 16, 8], [8, 8, 8, 16], [16, 8, 16, 8]]);
}
// Quiet material holds, and departs from the hold rather than the reverse.
return rng.pick([[16, 16, 8], [16, 8, 16], [16, 16, 16, 8], [8, 16, 16]]);
} }
/** Nearest downbeat to `time`, or null if none is close enough to be the same line. */ /**
function nearestDownbeat(time, downbeats, tolerance) { * Fit a bar pattern to real seconds.
*
* The ceiling is applied by scaling the WHOLE pattern rather than by clamping
* each entry, because clamping destroys exactly what the pattern is for: at 90
* BPM a 16-bar hold is 42 s and a 4-bar one is 10 s, and clamping both to 22
* turns 16/16/8 into a metronome again. Scaling keeps the 2:1 relationships
* that make the rhythm legible. Only after that is each entry clamped, to catch
* whatever the scale could not reconcile.
*/
function fitPattern(pattern, barSeconds) {
const unit = barSeconds > 0.2 ? barSeconds : 3;
const raw = pattern.map((bars) => bars * unit);
const hi = Math.max(...raw);
const scale = hi > MAX_SHOT_SECONDS ? MAX_SHOT_SECONDS / hi : 1;
return raw.map((s) => Math.min(MAX_SHOT_SECONDS, Math.max(MIN_SHOT_SECONDS, s * scale)));
}
/**
* The downbeat nearest `time` that also makes a LEGAL shot when measured from
* `from`, or null if there isn't one within tolerance.
*
* The legality bound is the point. Snapping to the merely-nearest downbeat can
* push a cut later than the ideal, and a 22-second shot snapped 0.75s late is a
* 22.75-second shot over the ceiling the pattern was fitted to respect. So the
* search is restricted to downbeats that keep the shot inside the floor and the
* ceiling, which usually means taking the downbeat just before the ideal rather
* than the one just after. Landing on the grid matters more than landing on the
* closest line.
*/
function snapCut(from, ideal, downbeats, tolerance) {
let best = null; let best = null;
let bestDist = Infinity; let bestDist = Infinity;
for (const d of downbeats) { for (const d of downbeats) {
const dist = Math.abs(d - time); if (d - from < MIN_SHOT_SECONDS) continue;
if (d - from > MAX_SHOT_SECONDS) break; // sorted: only gets worse
const dist = Math.abs(d - ideal);
if (dist < bestDist) { bestDist = dist; best = d; } if (dist < bestDist) { bestDist = dist; best = d; }
else if (d > time && dist > bestDist) break; // sorted: past the minimum else if (d > ideal) break; // past the minimum
} }
return best !== null && bestDist <= tolerance ? best : null; return best !== null && bestDist <= tolerance ? best : null;
} }
@ -66,44 +123,44 @@ export function planShots(section, track, bias, variantCount, rng) {
const duration = Math.max(0, section.end - section.start); const duration = Math.max(0, section.end - section.start);
const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / fps; const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / fps;
const bars = shotBarsFor(bias.energy, rng); const lengths = fitPattern(rhythmFor(bias.energy, rng), barSeconds);
const target = Math.min(MAX_SHOT_SECONDS, const shortest = Math.min(...lengths);
Math.max(MIN_SHOT_SECONDS, barSeconds > 0.2 ? bars * barSeconds : 12));
// Round to the nearest whole number of shots — a 40s section at a 12s target // A section with only one visual to show has nothing to cut to.
// gets three of 13s, not three of 12 and a stub — then force enough shots to const single = variantCount < 2;
// stay under the ceiling, and finally refuse any count that would push a
// shot below the floor. The floor wins if they ever disagree.
//
// The ceiling gets headroom because snapping moves a cut by up to the
// tolerance below, and a cut that snaps LATE would otherwise land just past
// the limit the count was chosen to respect.
let count = Math.max(
Math.round(duration / target),
Math.ceil(duration / (MAX_SHOT_SECONDS * 0.88)),
1,
);
count = Math.min(count, Math.max(1, Math.floor(duration / MIN_SHOT_SECONDS)));
if (variantCount < 2) count = 1;
// Cut times: evenly spaced, then pulled onto the nearest downbeat. The // Walk the pattern, laying shots end to end from the section start. Each cut
// tolerance is deliberately under half a shot, so a snap can never reorder // is then pulled onto the nearest downbeat; the tolerance stays under half
// two cuts or collapse one onto another. // the shortest shot so a snap can never reorder two cuts or collapse one
const tolerance = Math.min(barSeconds * 1.5, target * 0.35); // onto another. Drift from snapping does not accumulate, because the next
// shot is measured from the snapped time rather than from the ideal one.
const tolerance = Math.min(barSeconds * 1.5, shortest * 0.35);
const downbeats = track.tempo.downbeats || []; const downbeats = track.tempo.downbeats || [];
const cuts = []; const cuts = [];
for (let k = 1; k < count; k++) {
const raw = section.start + (duration * k) / count;
const snapped = nearestDownbeat(raw, downbeats, tolerance);
const previous = cuts.length ? cuts[cuts.length - 1] : section.start;
const fits = (t) => t - previous >= MIN_SHOT_SECONDS && section.end - t >= MIN_SHOT_SECONDS;
// Prefer the downbeat, but a snap that pushes the cut inside the floor if (!single) {
// is worse than an unsnapped cut: dropping it would leave the hold this let at = section.start;
// whole mechanism exists to break up. for (let k = 0; k < 512; k++) {
const time = snapped !== null && fits(snapped) ? snapped : raw; const raw = at + lengths[k % lengths.length];
if (!fits(time)) continue; const snapped = snapCut(at, raw, downbeats, tolerance);
const time = snapped !== null ? snapped : raw;
// Stop when the remainder would be shorter than a legal shot: the
// tail belongs to the shot already running rather than becoming a
// stub. This is also what ends the loop on any section length.
if (section.end - time < MIN_SHOT_SECONDS) break;
cuts.push(time); cuts.push(time);
at = time;
}
// Absorbing the tail can push the closing shot past the ceiling — a
// 22-second shot plus a 4-second remainder is 26. Split it. The span is
// over MAX by construction here, and MAX is more than twice MIN, so both
// halves are legal shots.
const lastCut = cuts.length ? cuts[cuts.length - 1] : section.start;
if (section.end - lastCut > MAX_SHOT_SECONDS) {
cuts.push(lastCut + (section.end - lastCut) / 2);
}
} }
const bounds = [section.start, ...cuts, section.end]; const bounds = [section.start, ...cuts, section.end];