diff --git a/flow-state/src/checks/main.js b/flow-state/src/checks/main.js index ac7259f..989fdd7 100644 --- a/flow-state/src/checks/main.js +++ b/flow-state/src/checks/main.js @@ -13,6 +13,7 @@ import './phase7.js'; import './phase8.js'; import './phase9.js'; import './phase10.js'; +import './phase11.js'; const out = document.getElementById('results'); const summaryEl = document.getElementById('summary'); diff --git a/flow-state/src/checks/phase11.js b/flow-state/src/checks/phase11.js new file mode 100644 index 0000000..e6ed609 --- /dev/null +++ b/flow-state/src/checks/phase11.js @@ -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%)`); +}); diff --git a/flow-state/src/look/shots.js b/flow-state/src/look/shots.js index 077d237..8743c9d 100644 --- a/flow-state/src/look/shots.js +++ b/flow-state/src/look/shots.js @@ -32,21 +32,78 @@ 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; -/** Phrase length for one shot, in bars, from the section's energy. */ -function shotBarsFor(energy, rng) { - if (energy > 0.72) return rng.pick([4, 8, 8]); - if (energy > 0.45) return rng.pick([8, 8, 16]); - return rng.pick([8, 16, 16]); +/** + * 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]]); } -/** 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 bestDist = Infinity; 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; } - 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; } @@ -66,44 +123,44 @@ export function planShots(section, track, bias, variantCount, rng) { const duration = Math.max(0, section.end - section.start); const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / fps; - const bars = shotBarsFor(bias.energy, rng); - const target = Math.min(MAX_SHOT_SECONDS, - Math.max(MIN_SHOT_SECONDS, barSeconds > 0.2 ? bars * barSeconds : 12)); + const lengths = fitPattern(rhythmFor(bias.energy, rng), barSeconds); + const shortest = Math.min(...lengths); - // Round to the nearest whole number of shots — a 40s section at a 12s target - // gets three of 13s, not three of 12 and a stub — then force enough shots to - // 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; + // A section with only one visual to show has nothing to cut to. + const single = variantCount < 2; - // Cut times: evenly spaced, then pulled onto the nearest downbeat. The - // tolerance is deliberately under half a shot, so a snap can never reorder - // two cuts or collapse one onto another. - const tolerance = Math.min(barSeconds * 1.5, target * 0.35); + // 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 = []; - 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 - // is worse than an unsnapped cut: dropping it would leave the hold this - // whole mechanism exists to break up. - const time = snapped !== null && fits(snapped) ? snapped : raw; - if (!fits(time)) continue; - cuts.push(time); + 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];