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