music-video-gen/flow-state/src/look/shots.js
Dejvino f050eaaad1 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>
2026-08-06 07:29:10 +02:00

231 lines
10 KiB
JavaScript

// Shot planning: the level of hierarchy between a song section and a frame.
//
// A section is a STAGE of the song (intro, build, drop, …) and can easily run
// ninety seconds. One scene held for ninety seconds reads as a still image with
// a wobble on it, no matter how much per-frame reactivity is underneath. So a
// section is cut into SHOTS, each showing one of the section's few "stage
// visuals" — the roster the look generator picked for that kind of section.
//
// Two rules keep this from turning into a shuffle:
//
// * the roster is per section KIND, not per section, so all of a track's drops
// still cut between the same two or three visuals and the video keeps an
// identity;
// * cuts land on phrase lines, so a change of image lands with the music
// instead of across it.
//
// Shot length follows energy: a drop cuts every four to eight bars, an intro
// holds for eight to sixteen, and nothing holds past the ceiling below.
// Everything here is seeded, so a track always cuts in the same places.
/** Never cut faster than this, whatever the tempo or the energy says. */
export const MIN_SHOT_SECONDS = 5;
/**
* And never hold longer than this either. Half a minute of one image is the
* complaint this whole level of hierarchy exists to answer, so it is a hard
* ceiling rather than something the bar maths is trusted to stay under: at a
* slow tempo sixteen bars is already past it.
*/
export const MAX_SHOT_SECONDS = 22;
/** Below this section energy, a shot change is always a dissolve, never a cut. */
export const HARD_CUT_ENERGY = 0.66;
/**
* The section's cutting RHYTHM, in bars per shot.
*
* This used to be a single bar count, and the section was then divided 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 — 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]]);
}
/**
* 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 bestDist = Infinity;
for (const d of downbeats) {
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; }
else if (d > ideal) break; // past the minimum
}
return best !== null && bestDist <= tolerance ? best : null;
}
/**
* Divide a section into shots.
*
* @param {object} section a track section (start/end/startFrame/endFrame)
* @param {object} track FeatureTrack, for fps and the bar grid
* @param {object} bias the section's bias, for energy
* @param {number} variantCount how many stage visuals the section has
* @param {Rng} rng
* @returns {Array<{index,startFrame,endFrame,variant,hardCut}>}
*/
export function planShots(section, track, bias, variantCount, rng) {
const fps = track.fps;
const duration = Math.max(0, section.end - section.start);
const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / fps;
const lengths = fitPattern(rhythmFor(bias.energy, rng), barSeconds);
const shortest = Math.min(...lengths);
// A section with only one visual to show has nothing to cut to.
const single = variantCount < 2;
// Walk the pattern, laying shots end to end from the section start. Each cut
// is then pulled onto the nearest downbeat; the tolerance stays under half
// the shortest shot so a snap can never reorder two cuts or collapse one
// 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 cuts = [];
if (!single) {
let at = section.start;
for (let k = 0; k < 512; k++) {
const raw = at + lengths[k % lengths.length];
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);
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 shots = [];
const lastSeen = new Array(variantCount).fill(-1);
let previousVariant = -1;
for (let i = 0; i < bounds.length - 1; i++) {
const variant = i === 0 ? 0 : pickVariant(variantCount, previousVariant, lastSeen, i, rng);
lastSeen[variant] = i;
previousVariant = variant;
shots.push({
index: i,
startFrame: i === 0 ? section.startFrame : Math.round(bounds[i] * fps),
endFrame: i === bounds.length - 2 ? section.endFrame : Math.round(bounds[i + 1] * fps),
start: bounds[i],
end: bounds[i + 1],
variant,
// A dissolve is the default. A straight cut is what makes a drop feel
// edited, but on anything calmer it reads as a glitch, so cuts are
// gated on real energy rather than sprinkled everywhere: nothing below
// the threshold ever cuts, and only the loudest material cuts often.
hardCut: bias.energy > HARD_CUT_ENERGY
&& rng.bool(Math.min(0.85, (bias.energy - HARD_CUT_ENERGY) * 2.5)),
});
}
return shots;
}
/**
* Next visual in the rotation.
*
* The shape is A B A C A D: the anchor comes back between companions, so the
* section reads as one idea with departures from it rather than as a playlist.
* It is a strong tendency and not a rule — strict alternation is audible as a
* pattern within about three cycles.
*
* When a companion is due, the LEAST RECENTLY SHOWN one wins. With a roster of
* four that is the difference between a section showing B, C, D and a section
* showing B twice and never reaching D.
*/
function pickVariant(variantCount, previous, lastSeen, shotIndex, rng) {
if (previous !== 0 && rng.bool(0.75)) return 0;
// A companion this section has not shown yet wins outright. Weighting it
// heavily was not enough — measured, a five-shot section still came out
// 0,2,0,2,0 about a fifth of the time, so the roster existed and the shots
// never reached it. Which unseen one is still a free choice, so the order
// varies between sections; only the coverage is guaranteed.
const unseen = [];
for (let v = 1; v < variantCount; v++) {
if (v !== previous && lastSeen[v] < 0) unseen.push(v);
}
if (unseen.length) return rng.pick(unseen);
const options = [];
const weights = [];
for (let v = 0; v < variantCount; v++) {
if (v === previous) continue;
options.push(v);
// Everything has been shown at least once: fall back to least recently
// seen, with the anchor kept in the draw so the rotation cannot become
// a rigid cycle.
weights.push(v === 0 ? 1 : 2 + (shotIndex - lastSeen[v]));
}
if (!options.length) return 0;
return rng.pickWeighted(options, weights);
}