// How a shot is FRAMED, as opposed to what it contains. // // Every scene in the library is a locked-off, full-frame wide, and always has // been. That is one shot type, held for the length of a song. Cutting between // two scenes therefore changes the subject and never the framing — and framing // is at least half of how a real edit holds attention. A wide answered by a // close reads as two shots of one thing; two wides read as two things. // // So a shot now carries a scale and a recentre, applied in SCENE coordinates // inside sigCamera. That distinction matters: a close-up is rendered close // rather than being a magnified 720p frame, which is why this is a coordinate // transform and not a post pass. It is also why it costs nothing at 4K. // // Shot SIZE is per shot and constant within it. A zoom that moves during a shot // is a different device — one that would fight the drift LFO and the slow axis, // both of which already own continuous motion. // // The RECENTRE is not: where the camera is looking moves during a shot, and // that lives in look/Camera.js. This module used to own both and treated them // the same way, which is how the recentre ended up as a per-shot constant of // about 3% of a half-frame at a random angle — a device that was present in // every frame of every video and visible in none of them. /** * The shot sizes, as multipliers on the scene's coordinate scale. * * Bounded much more tightly than a real camera would be. Past about 2.2 most * scenes in this library run out of detail and a close-up is just a blurry * wide; below about 0.55 the subject is a speck in an empty frame. Both were * measured by pushing until the image stopped being worth looking at. */ export const SHOT_SIZES = { wide: { scale: 0.62 }, normal: { scale: 1.0 }, close: { scale: 1.7 }, }; export const SHOT_SIZE_NAMES = Object.keys(SHOT_SIZES); /** * Whether this track uses framing at all, and how boldly. * * A track that never changes size is a legitimate look — locked-off is a style, * and it is the one the whole library was built in — so it stays reachable. * What is not acceptable is it being the only option, which is what it was. */ export function deriveFramingStyle(summary, rng) { const mode = rng.pickWeighted(['locked', 'gentle', 'edited'], [1, 2.5, 3]); return { mode, // How far from `normal` this track is willing to go. range: mode === 'locked' ? 0 : mode === 'gentle' ? 0.45 : 1, // Chance a cut also changes the shot size, rather than only the image. changeChance: mode === 'locked' ? 0 : mode === 'gentle' ? 0.35 : 0.6, }; } /** * Choose the framing for one shot. * * `previous` is the framing of the shot before it, and it is the whole point: * a size only means something relative to the size before it. The rule is that * a change of size must be a real change — a wide answered by a slightly less * wide is not a cut, it is a mistake — so sizes step rather than slide. * * @param {object} style from deriveFramingStyle * @param {object|null} previous the previous shot's framing * @param {number} energy section energy, 0..1 * @param {Rng} rng * @param {number} closeness 0..1 from the story — how near this point in the * video wants to be to its subject */ export function frameShot(style, previous, energy, rng, closeness = 0.5) { if (style.mode === 'locked') return neutralFraming(); const keep = previous && !rng.bool(style.changeChance); if (keep) return { ...previous }; // Loud material earns the close-ups; quiet material earns the wides. This // is a lean rather than a rule, so an intro can still land on a close and // read as intimate instead of empty. // // The story tilts the same draw across the video: a track that has been // approaching its subject for four minutes should not answer its climax // with a wide just because the dice said so. Still a tilt — both sizes stay // reachable everywhere, because a story told by never cutting wide again is // one shot type held for five minutes, which is what framing was added to // stop. const near = Math.max(0, Math.min(1, closeness)); const weights = [ (1 + (1 - energy) * 2.5) * (1.4 - near * 0.9), // wide 2, // normal (1 + energy * 2.5) * (0.6 + near * 0.9), // close ]; let size = rng.pickWeighted(SHOT_SIZE_NAMES, weights); // Never repeat the previous size when we have decided to change: repeating // it is what "no change" already means. if (previous && size === previous.size) { const others = SHOT_SIZE_NAMES.filter((n) => n !== size); size = rng.pick(others); } const spec = SHOT_SIZES[size]; // Scale toward 1 for a timid track, so `gentle` is genuinely gentle rather // than the same sizes drawn less often. const scale = 1 + (spec.scale - 1) * style.range; // Recentring used to be decided here, as `spec.drift * style.range` at a // uniform random angle — capped at 0.10 of a half-frame, with a fresh // direction every shot. Measured over 121 cues it moved the frame by a // median of 0.029 and successive shots mostly cancelled, so the device was // inert. It is now the camera's, planned as a continuous path across the // whole video and driven by the story. See look/Camera.js. // // `shift` stays on the returned framing so a look built without a camera — // a hand-made one, or a check constructing framings directly — still has // the field every consumer already reads. return { size, scale, shift: [0, 0] }; } export function neutralFraming() { return { size: 'normal', scale: 1, shift: [0, 0] }; } /** One line for the HUD and check output. */ export function describeFraming(style) { if (!style || style.mode === 'locked') return 'framing: locked off'; return `framing: ${style.mode}`; }