music-video-gen/flow-state/src/look/framing.js
Dejvino 1ca9fa2f1f Let scenes layer on each other, and give the camera somewhere to look
Two devices that were built, wired up, and doing nothing.

OVERLAYS. buildStack gated layering on `surfaceOf(m) === 'composable'`, and
no scene in the library declared `surface` — so the only thing that could ever
sit on top was the one scene declaring `role: 'accent'`, and the overlay roster
excluded accents by construction. Empty intersection, every time: 0/144 stacks
carried an overlay. Every layered frame in every song was the same particle
field.

Labelled the library from the phase 12 coverage gate rather than by eye: the
37 scenes painting under 30% of the frame are composable. Particle Field loses
its privileged slot and becomes one of them, keeping only `background: false`,
which is the honest part — points in empty space cannot carry a section alone.
The reserved accent slot is gone; one roster, two passes at it. 1 distinct
overlay scene becomes 27, and the rate lands at 34% of stacks after trimming a
base chance that had been tuned while the branch was dead.

THE CAMERA. framing.shift moved the frame by a median of 0.029 of a half-frame
at a fresh random angle every shot, so successive offsets cancelled and the
median jump at a cut was 0.014. Present in every frame, visible in none.

look/Camera.js is the director's camera department: the story says tension,
order and which act a section is in, and this turns that into where the frame
looks and how it travels there. Each director names a camera. Jump distance
follows tension and act, speed follows energy, and the curve is one of four.
Cuts choose between reframing and matching, so two scenes can still read as one
place. Median offset 0.190, median reframe 0.135, and 94% of shots now move
during the shot rather than only at the cut.

Four new gates, each with a FLOOR — the device this replaces passed every
existing check while doing nothing, because the only bound was a ceiling.

Also lands the in-progress Epic 4 story layer it builds on: Story.js, phase 13,
and the direction statistic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 06:48:13 +02:00

131 lines
5.9 KiB
JavaScript

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