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>
This commit is contained in:
@@ -4,6 +4,8 @@ import { clampValue } from '../params/schema.js';
|
||||
import { paletteShiftAt } from './paletteArc.js';
|
||||
import { shiftPalette } from './palette.js';
|
||||
import { frameShot, neutralFraming } from './framing.js';
|
||||
import { planGaze, gazeAt } from './Camera.js';
|
||||
import { storyStateAt, NEUTRAL_STATE } from './Story.js';
|
||||
|
||||
/**
|
||||
* Drives the look across the song.
|
||||
@@ -43,6 +45,7 @@ export class ArcDriver {
|
||||
this.framingStyle = (look.framing && look.framing.mode !== 'locked')
|
||||
? look.framing : null;
|
||||
this._planFraming();
|
||||
this._planGaze();
|
||||
this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null };
|
||||
}
|
||||
|
||||
@@ -63,12 +66,60 @@ export class ArcDriver {
|
||||
for (const cue of this.cues) {
|
||||
const section = this.look.sections[cue.sectionIndex];
|
||||
const energy = (section.bias && section.bias.energy) || 0;
|
||||
const framing = frameShot(this.framingStyle, previous, energy, rng);
|
||||
// Shot size is where the story's `closeness` lands: a video that is
|
||||
// approaching its subject does it at the cuts, because that is the
|
||||
// only place a size is allowed to change. See look/framing.js.
|
||||
const closeness = (section.story || NEUTRAL_STATE).closeness;
|
||||
const framing = frameShot(this.framingStyle, previous, energy, rng, closeness);
|
||||
cue.framing = framing;
|
||||
previous = framing;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan where the camera looks, across the whole video.
|
||||
*
|
||||
* After framing, because the reach available to a move depends on the shot
|
||||
* size it is made at — a close-up is inside the composition and can travel
|
||||
* across it; a wide already sees the whole thing. See Camera.reachFor.
|
||||
*
|
||||
* Unlike framing this does NOT stop at a locked-off track: locked is a
|
||||
* decision about SIZE, and a video that never changes size can still be one
|
||||
* whose attention moves. Only a look with no camera at all — hand-built, or
|
||||
* a check constructing sections directly — goes without.
|
||||
*/
|
||||
_planGaze() {
|
||||
if (!this.look.camera) return;
|
||||
const rng = new Rng((this.look.seed ^ 0x2f9c1d4b) >>> 0);
|
||||
this.gaze = planGaze(this.cues, this.look.sections, this.look.camera, rng);
|
||||
}
|
||||
|
||||
/** Where the camera is looking at `frame`, for the cue at `cueIndex`. */
|
||||
_gazeAt(cueIndex, frame) {
|
||||
if (!this.gaze) return null;
|
||||
const cue = this.cues[cueIndex];
|
||||
const move = this.gaze[cueIndex];
|
||||
if (!cue || !move) return null;
|
||||
return gazeAt(move, frame - cue.startFrame);
|
||||
}
|
||||
|
||||
/**
|
||||
* The framing a cue is played with at `frame` — its size, plus wherever the
|
||||
* gaze has travelled to by now.
|
||||
*
|
||||
* Size comes off the plan and never changes within the shot; the shift is
|
||||
* live. Returning a fresh object each call is deliberate: Layer copies the
|
||||
* values into uniforms immediately, and a shared mutable framing would make
|
||||
* the outgoing half of a crossfade read the incoming half's position.
|
||||
*/
|
||||
_framingAt(cueIndex, frame) {
|
||||
const cue = this.cues[cueIndex];
|
||||
const base = (cue && cue.framing) || neutralFraming();
|
||||
const shift = this._gazeAt(cueIndex, frame);
|
||||
if (!shift) return base;
|
||||
return { size: base.size, scale: base.scale, shift };
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (const layer of this.layerCache.values()) layer.dispose();
|
||||
this.layerCache.clear();
|
||||
@@ -100,6 +151,10 @@ export class ArcDriver {
|
||||
startFrame: shot.startFrame,
|
||||
endFrame: shot.endFrame,
|
||||
atSectionStart,
|
||||
// Carried onto the cue as well as folded into fadeFrames:
|
||||
// the camera reads it, because a straight cut earns a
|
||||
// bigger reframe than a dissolve. See Camera.jumpFor.
|
||||
hardCut: !!shot.hardCut && !atSectionStart,
|
||||
fadeFrames: shot.hardCut && !atSectionStart
|
||||
? Math.max(2, Math.round(this.track.fps * 0.06))
|
||||
: this._dissolveFrames(energy, span),
|
||||
@@ -245,6 +300,15 @@ export class ArcDriver {
|
||||
// a random draw finds the second kind almost every time.
|
||||
const declared = eligible.filter(([, def]) => def.slowAxis);
|
||||
|
||||
// Which WAY the video travels is the track's decision, not the scene's.
|
||||
//
|
||||
// The sign used to be an independent coin flip per scene, so a five
|
||||
// minute video routinely had one scene growing denser while the next one
|
||||
// thinned out — movement with no direction, which is the difference
|
||||
// between a video that goes somewhere and one that merely changes.
|
||||
// Magnitude stays per scene; the sign is shared. See look/Story.js.
|
||||
const sign = this.look.story ? this.look.story.axisSign : (rng.bool() ? 1 : -1);
|
||||
|
||||
const axis = [];
|
||||
if (declared.length) {
|
||||
for (const [name, def] of declared) {
|
||||
@@ -256,7 +320,7 @@ export class ArcDriver {
|
||||
// Most of the range. This param was chosen because moving it
|
||||
// is what the scene looks like changing, so a timid walk
|
||||
// wastes the one lever that works.
|
||||
travel: (hi - lo) * rng.range(0.45, 0.7) * (rng.bool() ? 1 : -1),
|
||||
travel: (hi - lo) * rng.range(0.45, 0.7) * sign,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -272,7 +336,7 @@ export class ArcDriver {
|
||||
name,
|
||||
def,
|
||||
declared: false,
|
||||
travel: (hi - lo) * rng.range(0.25, 0.5) * (rng.bool() ? 1 : -1),
|
||||
travel: (hi - lo) * rng.range(0.25, 0.5) * sign,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -284,16 +348,28 @@ export class ArcDriver {
|
||||
* Base params for a section at a given time: the look's sampled values, plus
|
||||
* the slow axis, plus drift, plus the lookahead ramp toward what comes next.
|
||||
*/
|
||||
_paramsAt(cue, slot, time, features) {
|
||||
_paramsAt(cue, slot, time, features, story = null) {
|
||||
const spec = this._specFor(cue.sectionIndex, cue.variant, slot);
|
||||
const out = { ...spec.params };
|
||||
|
||||
// --- slow axis ------------------------------------------------------
|
||||
// Eased rather than linear, so the travel is slowest at the head and
|
||||
// tail. A video should not open mid-move.
|
||||
const duration = Math.max(1e-6, this.track.duration);
|
||||
const p = Math.max(0, Math.min(1, time / duration));
|
||||
const journey = p * p * (3 - 2 * p);
|
||||
// How far along the journey this frame is. The story owns this: its
|
||||
// curve holds inside a section and moves at the boundary, so the axis
|
||||
// travels in STAGES rather than sliding continuously for five minutes —
|
||||
// a scene of a story rather than a slow zoom. See look/Story.js.
|
||||
//
|
||||
// With no story it falls back to the eased progress ramp this was
|
||||
// before, which is also what Story.js emits for a track too short to
|
||||
// carry one: slowest at the head and tail, because a video should not
|
||||
// open mid-move.
|
||||
let journey;
|
||||
if (story) {
|
||||
journey = story.journey;
|
||||
} else {
|
||||
const duration = Math.max(1e-6, this.track.duration);
|
||||
const p = Math.max(0, Math.min(1, time / duration));
|
||||
journey = p * p * (3 - 2 * p);
|
||||
}
|
||||
for (const item of this._slowAxisFor(spec.module)) {
|
||||
const base = out[item.name];
|
||||
if (typeof base !== 'number') continue;
|
||||
@@ -381,7 +457,7 @@ export class ArcDriver {
|
||||
* the frame-exactness guarantee — a seeked frame gets bit-identical colours
|
||||
* to a played one rather than merely similar ones.
|
||||
*/
|
||||
_paletteAt(frame, features) {
|
||||
_paletteAt(frame, features, story) {
|
||||
const arc = this.look.paletteArc;
|
||||
if (!arc || arc.mode === 'static') return this.look.palette;
|
||||
|
||||
@@ -389,6 +465,7 @@ export class ArcDriver {
|
||||
progress: frame / Math.max(1, this.track.frameCount - 1),
|
||||
sectionKind: this.track.sectionAt(frame).kind,
|
||||
features,
|
||||
story,
|
||||
});
|
||||
|
||||
const key = `${shift.hue.toFixed(3)}|${shift.chroma.toFixed(3)}|${shift.lightness.toFixed(3)}`;
|
||||
@@ -399,9 +476,81 @@ export class ArcDriver {
|
||||
return this._palette;
|
||||
}
|
||||
|
||||
/**
|
||||
* The track's personality, with as much of its identity SHOWN as the story
|
||||
* has reached.
|
||||
*
|
||||
* This is where a story reaches Epic 3's content registers, and it needed no
|
||||
* new uniform to do it: `setPersonality` is already called on every layer
|
||||
* every frame, and the cast/ink/lattice uniforms are derived from the object
|
||||
* it is handed. Scaling the features that make the song's forms specific —
|
||||
* the notches, the hole through the middle, the outline, the size hierarchy
|
||||
* — means the cast literally ARRIVES over the video instead of being fully
|
||||
* stated in the first shot and merely repeated after that.
|
||||
*
|
||||
* Only the specificity moves, never the identity itself: the protagonist has
|
||||
* the same number of sides at thirty seconds as at four minutes. A form that
|
||||
* changed its shape would be a different character rather than the same one
|
||||
* seen more clearly.
|
||||
*
|
||||
* Memoised on the rounded reveal, exactly as _paletteAt memoises on the
|
||||
* rounded shift and for the same two reasons: consecutive frames want the
|
||||
* same value, and rounding is what keeps a seeked frame bit-identical to a
|
||||
* played one rather than merely close.
|
||||
*/
|
||||
_personalityAt(story) {
|
||||
const base = this.look.personality;
|
||||
if (!base || !base.identity || !story) return base;
|
||||
|
||||
const reveal = Math.max(0, Math.min(1, story.reveal));
|
||||
const key = Math.round(reveal * 50);
|
||||
if (!this._personalityCache) this._personalityCache = new Map();
|
||||
const cached = this._personalityCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
// Never all the way to nothing. A cast erased to plain circles is a
|
||||
// different track's cast, not this one's withheld — the video still has
|
||||
// to look like itself in its first thirty seconds.
|
||||
const shown = 0.35 + (key / 50) * 0.65;
|
||||
const id = base.identity;
|
||||
const member = (m) => ({
|
||||
...m,
|
||||
notchDepth: m.notchDepth * shown,
|
||||
hollow: m.hollow * shown,
|
||||
});
|
||||
|
||||
const moved = {
|
||||
...base,
|
||||
identity: {
|
||||
...id,
|
||||
cast: { protagonist: member(id.cast.protagonist), chorus: member(id.cast.chorus) },
|
||||
ink: {
|
||||
...id.ink,
|
||||
outline: id.ink.outline * shown,
|
||||
// Posterisation is a value structure rather than an amount,
|
||||
// so it arrives whole at a threshold instead of fading in.
|
||||
posterize: shown > 0.6 ? id.ink.posterize : 0,
|
||||
},
|
||||
lattice: { ...id.lattice, scaleSpread: id.lattice.scaleSpread * shown },
|
||||
},
|
||||
};
|
||||
this._personalityCache.set(key, moved);
|
||||
return moved;
|
||||
}
|
||||
|
||||
update(frame, features) {
|
||||
const time = frame / this.track.fps;
|
||||
const palette = this._paletteAt(frame, features);
|
||||
// Where the video is in its story. One lookup per frame, handed to
|
||||
// everything below rather than recomputed — and a pure function of the
|
||||
// frame, so a seek lands on the same story position as playback.
|
||||
// A look with no story at all — hand-built by a check, or generated
|
||||
// before this existed — passes null rather than the neutral state, so
|
||||
// everything below takes its own pre-story path. The neutral state's
|
||||
// `journey` is 0.5, and handing that to the slow axis would park it at
|
||||
// the middle of its travel for the whole video rather than ramping.
|
||||
const story = this.look.story ? storyStateAt(this.look.story, frame) : null;
|
||||
const palette = this._paletteAt(frame, features, story);
|
||||
const personality = this._personalityAt(story);
|
||||
const cueIndex = this._cueIndexAt(frame);
|
||||
const cue = this.cues[cueIndex];
|
||||
if (!cue) return this.activeLayers;
|
||||
@@ -416,8 +565,15 @@ export class ArcDriver {
|
||||
// The shot being played INTO carries its own framing; the shot fading
|
||||
// out keeps the framing it was filmed with, so a cut changes the size
|
||||
// exactly when the cut changes the image rather than half a beat after.
|
||||
const framing = cue.framing || neutralFraming();
|
||||
const outgoingFraming = previous ? (previous.framing || neutralFraming()) : framing;
|
||||
//
|
||||
// The outgoing shot is evaluated at the SAME frame, on its own move —
|
||||
// it is still on screen, and freezing its gaze at the cut would stop
|
||||
// the old image dead half a second before it disappears. A camera that
|
||||
// was travelling when the edit arrived keeps travelling as it fades.
|
||||
const framing = this._framingAt(cueIndex, frame);
|
||||
const outgoingFraming = previous
|
||||
? this._framingAt(cueIndex - 1, frame)
|
||||
: framing;
|
||||
|
||||
const layers = [];
|
||||
|
||||
@@ -437,11 +593,11 @@ export class ArcDriver {
|
||||
for (let slot = 0; slot < this._stackSize(previous); slot++) {
|
||||
const spec = this._specFor(previous.sectionIndex, previous.variant, slot);
|
||||
const layer = this._layerFor(previous.sectionIndex, previous.variant, slot);
|
||||
layer.setParams(this._paramsAt(previous, slot, time, outgoingFeatures));
|
||||
layer.setParams(this._paramsAt(previous, slot, time, outgoingFeatures, story));
|
||||
layer.opacity = slot === 0 ? 1 : spec.opacity;
|
||||
layer.blend = slot === 0 ? 'normal' : spec.blend;
|
||||
layer.setPalette(palette);
|
||||
layer.setPersonality(this.look.personality);
|
||||
layer.setPersonality(personality);
|
||||
layer.setFraming(outgoingFraming);
|
||||
layers.push(layer);
|
||||
}
|
||||
@@ -450,11 +606,11 @@ export class ArcDriver {
|
||||
for (let slot = 0; slot < this._stackSize(cue); slot++) {
|
||||
const spec = this._specFor(cue.sectionIndex, cue.variant, slot);
|
||||
const layer = this._layerFor(cue.sectionIndex, cue.variant, slot);
|
||||
layer.setParams(this._paramsAt(cue, slot, time, features));
|
||||
layer.setParams(this._paramsAt(cue, slot, time, features, story));
|
||||
layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1);
|
||||
layer.blend = slot === 0 ? 'normal' : spec.blend;
|
||||
layer.setPalette(palette);
|
||||
layer.setPersonality(this.look.personality);
|
||||
layer.setPersonality(personality);
|
||||
layer.setFraming(framing);
|
||||
layers.push(layer);
|
||||
}
|
||||
@@ -468,6 +624,12 @@ export class ArcDriver {
|
||||
crossfade: fading ? eased : 0,
|
||||
sceneName: this._specFor(cue.sectionIndex, cue.variant, 0).module.name,
|
||||
buildSlope: features ? features.buildSlope || 0 : 0,
|
||||
// Where the story is, for the HUD and the checks. A video that is
|
||||
// supposed to be going somewhere should be able to say where.
|
||||
act: story.act,
|
||||
tension: story.tension,
|
||||
reveal: story.reveal,
|
||||
journey: story.journey,
|
||||
};
|
||||
|
||||
this.activeLayers = layers;
|
||||
@@ -498,6 +660,7 @@ export class ArcDriver {
|
||||
// A reroll re-plans the section's shots, so the cue list is stale too.
|
||||
this.cues = this._buildCues();
|
||||
this._planFraming();
|
||||
this._planGaze();
|
||||
this._slopeCache = null;
|
||||
}
|
||||
|
||||
@@ -506,6 +669,7 @@ export class ArcDriver {
|
||||
this.driftPlans.clear();
|
||||
this.cues = this._buildCues();
|
||||
this._planFraming();
|
||||
this._planGaze();
|
||||
this._slopeCache = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
// The GAZE: where in the scene the frame is looking, and how it gets there.
|
||||
//
|
||||
// This is the director's camera department. `Story.js` says what the video is
|
||||
// doing — tension, closeness, order, which act a section is in — in terms that
|
||||
// are deliberately imagery-free. Something has to turn that into a picture, and
|
||||
// until now every consumer did its own ad-hoc translation: shots.js reads
|
||||
// tension for the cut rate, framing.js reads closeness for the shot size,
|
||||
// LookGenerator reads population for the overlay chance. Nobody owned the
|
||||
// camera, and it showed.
|
||||
//
|
||||
// WHAT WAS WRONG
|
||||
//
|
||||
// The recentre existed — `framing.shift`, applied in the shader epilogue as
|
||||
// `p / scale + shift` — and it was inert. Measured across 121 cues:
|
||||
//
|
||||
// median |shift| from centre 0.029 (2.9% of a half-frame)
|
||||
// median |jump| at a cut 0.014 (1.4%)
|
||||
// largest jump seen 0.120
|
||||
//
|
||||
// Three causes, and the amplitude was only one of them:
|
||||
//
|
||||
// 1. `amount = spec.drift * style.range * rng.range(0.3, 1)` capped the offset
|
||||
// at 0.10, because a comment worried that pushing off centre would find the
|
||||
// scenes' empty corners.
|
||||
// 2. The direction was `rng.range(0, 2π)` — a fresh uniform angle every shot.
|
||||
// No axis, no continuity, no intent. That is why the median JUMP is smaller
|
||||
// than the median OFFSET: consecutive shots mostly cancelled each other.
|
||||
// 3. Nothing about the song reached it at all. Shot SIZE got the story's
|
||||
// `closeness`; the recentre got a per-track constant and a die roll.
|
||||
//
|
||||
// WHAT THIS DOES INSTEAD
|
||||
//
|
||||
// A gaze is a path, planned once over the whole cue list, so the video's
|
||||
// attention travels rather than jittering. Per cue it carries a move:
|
||||
//
|
||||
// from → to the two points, in scene units
|
||||
// delay how long the shot holds before it starts moving
|
||||
// travel how long the move takes
|
||||
// curve how it accelerates
|
||||
//
|
||||
// The story decides all four. A climax jumps far and arrives hard; a resolution
|
||||
// drifts a short way back toward centre and never quite stops; a low-`order`
|
||||
// section throws the gaze off its axis. Between two points the gaze is a pure
|
||||
// function of (plan, cue, frames-into-cue), the same rule the rest of the render
|
||||
// path follows — a seek lands on the frame playback would have shown.
|
||||
//
|
||||
// NOTE ON THE PER-SHOT RULE. framing.js says framing is constant within a shot,
|
||||
// and that a move inside a shot "would fight the drift LFO and the slow axis,
|
||||
// both of which already own continuous motion". That was right about SIZE and
|
||||
// wrong about the recentre: a zoom that creeps during a shot is an effect, but a
|
||||
// camera that settles onto its subject is how shots have always worked. Size
|
||||
// still steps at the cut and only at the cut. The gaze moves.
|
||||
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
|
||||
/**
|
||||
* How the gaze accelerates between two points.
|
||||
*
|
||||
* These are the reason a move reads as a decision rather than as a tween. A
|
||||
* `snap` and a `glide` cover the same distance in the same time and say
|
||||
* completely different things about the section they are in.
|
||||
*/
|
||||
export const CURVES = {
|
||||
// Hard out of the gate, decelerating into the target. The edit-room move:
|
||||
// it feels like the camera was already going when the cut happened.
|
||||
snap: (t) => 1 - (1 - t) ** 3,
|
||||
// Slow away, slow in. The default, and what a calm section wants.
|
||||
glide: (t) => t * t * (3 - 2 * t),
|
||||
// Constant speed, and it does NOT arrive — see `travel` below, which is
|
||||
// allowed to exceed the shot. A drifting camera that gets cut away from
|
||||
// mid-move is the most alive of these.
|
||||
drift: (t) => t,
|
||||
// Overshoots and comes back. Used sparingly: it is the only curve here that
|
||||
// is legible as a flourish, so it belongs at moments and not everywhere.
|
||||
//
|
||||
// The standard back-ease-out, written out rather than improvised. The
|
||||
// improvised version evaluated to 2.0 at t=0 instead of 0 — every `settle`
|
||||
// shot therefore STARTED at twice its target, well outside the reach the
|
||||
// plan had clamped it to, and the headroom gate caught offsets of 0.61
|
||||
// against a 0.385 ceiling. An easing curve has two values that are not
|
||||
// negotiable, f(0)=0 and f(1)=1, and this one had neither.
|
||||
settle: (t) => {
|
||||
const c1 = 1.70158;
|
||||
const u = t - 1;
|
||||
return 1 + (c1 + 1) * u * u * u + c1 * u * u;
|
||||
},
|
||||
};
|
||||
|
||||
export const CURVE_NAMES = Object.keys(CURVES);
|
||||
|
||||
/**
|
||||
* A director's point of view about its camera.
|
||||
*
|
||||
* Same rationale as the family table in directors.js: a fixed rule applied to
|
||||
* every track is how a library ends up with one camera, and the camera is
|
||||
* exactly the register a viewer reads as "who shot this".
|
||||
*
|
||||
* reach multiplier on how far the gaze is allowed from centre
|
||||
* pace multiplier on travel time — low is restless, high is patient
|
||||
* curves weights over CURVE_NAMES, in order
|
||||
* axial how much the gaze prefers to move along one axis rather than
|
||||
* anywhere. High reads as composed; low reads as searching.
|
||||
*/
|
||||
export const CAMERAS = {
|
||||
// Patient and composed. Long moves, mostly horizontal, rarely hurried.
|
||||
contemplative: { reach: 0.85, pace: 1.45, curves: [1, 4, 3, 1], axial: 0.75 },
|
||||
// Cuts with the camera already moving. The closest to an edited music video.
|
||||
kinetic: { reach: 1.20, pace: 0.60, curves: [5, 2, 1, 1], axial: 0.35 },
|
||||
// Locked-off until it is not. Holds, then commits to one large move.
|
||||
deliberate: { reach: 1.10, pace: 0.85, curves: [3, 3, 1, 2], axial: 0.85 },
|
||||
// Never settles. Long drifts that get cut away from mid-travel.
|
||||
roaming: { reach: 0.95, pace: 1.70, curves: [1, 2, 5, 1], axial: 0.25 },
|
||||
// Small, exact, and it always arrives. The one that stays near centre.
|
||||
precise: { reach: 0.70, pace: 1.00, curves: [2, 5, 1, 2], axial: 0.90 },
|
||||
};
|
||||
|
||||
export const CAMERA_NAMES = Object.keys(CAMERAS);
|
||||
|
||||
/**
|
||||
* The furthest the gaze may sit from centre, in scene units.
|
||||
*
|
||||
* The old comment's worry was real but it was stated as a constant when it is a
|
||||
* function of the shot size. The visible half-frame at scale s is 1/s scene
|
||||
* units, so a close-up is looking at a small piece of the composition and can
|
||||
* move a long way across it before reaching anywhere empty; a wide is already
|
||||
* seeing everything there is and moving off centre only finds the edges.
|
||||
*
|
||||
* The scale term is the ADDITION rather than the whole thing, which the first
|
||||
* version got wrong: `(0.12 + 0.56 * headroom)` gave a close-up 0.35 and every
|
||||
* other shot 0.12, and since headroom is zero at any scale ≤ 1 that meant the
|
||||
* normal and wide shots — most of the video — were still capped at barely more
|
||||
* than the old inert 0.10. The base has to be worth seeing on its own.
|
||||
*
|
||||
* Now: `normal` and `wide` allow 0.20–0.34 depending on the camera, `close`
|
||||
* 0.32–0.45. Against a measured median offset of 0.029 and a hard old ceiling
|
||||
* of 0.10, a typical move is roughly six times what it was.
|
||||
*
|
||||
* The absolute cap is what keeps this inside the answer to "how far should it
|
||||
* roam" — moderate, with close-ups furthest.
|
||||
*/
|
||||
export const MAX_REACH = 0.45;
|
||||
|
||||
export function reachFor(scale, camera) {
|
||||
const headroom = Math.max(0, 1 - 1 / Math.max(scale, 0.05));
|
||||
return Math.min(MAX_REACH, (0.28 + 0.42 * headroom) * camera.reach);
|
||||
}
|
||||
|
||||
/** Pick a camera for a track. The director leans, the seed decides. */
|
||||
export function deriveCamera(director, summary, rng) {
|
||||
const preferred = (director && director.camera) || null;
|
||||
const weights = CAMERA_NAMES.map((name) => (name === preferred ? 4 : 1));
|
||||
const name = rng.pickWeighted(CAMERA_NAMES, weights);
|
||||
return {
|
||||
name,
|
||||
...CAMERAS[name],
|
||||
// The track's own axis. A video whose gaze moves along one line reads as
|
||||
// composed even when the line is arbitrary — what reads as sloppy is a
|
||||
// different direction every time, which is precisely what the uniform
|
||||
// random angle was doing.
|
||||
axis: rng.range(0, Math.PI * 2),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How far this cut should jump, 0..1 of the available reach.
|
||||
*
|
||||
* This is the whole "driven by the song" requirement in one function, so the
|
||||
* mapping is stated rather than buried:
|
||||
*
|
||||
* tension the main term. A wound-up section reframes hard.
|
||||
* act the climax gets the biggest move in the video, and the
|
||||
* resolution gets the smallest — a video that keeps flinging its
|
||||
* camera after the peak has nothing left to say with it.
|
||||
* hardCut a straight cut earns a bigger reframe than a dissolve. The two
|
||||
* devices are already gated on energy together (shots.js), so this
|
||||
* compounds deliberately.
|
||||
* order low order widens the spread, so a section coming apart is also
|
||||
* less predictable about where it looks.
|
||||
*/
|
||||
function jumpFor(story, hardCut, rng) {
|
||||
const tension = story ? story.tension : 0.5;
|
||||
const order = story ? story.order : 0.5;
|
||||
|
||||
let base = 0.25 + tension * 0.55;
|
||||
if (story) {
|
||||
if (story.act === 'climax') base = Math.max(base, 0.85);
|
||||
else if (story.act === 'turn') base = Math.max(base, 0.6);
|
||||
else if (story.act === 'resolution') base = Math.min(base, 0.3);
|
||||
else if (story.act === 'setup') base = Math.min(base, 0.45);
|
||||
}
|
||||
if (hardCut) base = Math.min(1, base * 1.25);
|
||||
|
||||
// Disorder widens the draw rather than raising it: a broken section is less
|
||||
// predictable, not uniformly bigger.
|
||||
const spread = 0.2 + (1 - order) * 0.55;
|
||||
return clamp01(base * rng.range(1 - spread, 1 + spread * 0.6));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the gaze goes next.
|
||||
*
|
||||
* Direction is the track's axis, plus a wander that `order` controls and the
|
||||
* camera's `axial` bounds. The one hard rule is that a move must not simply
|
||||
* undo the last one — reversing along the same line is how the old uniform
|
||||
* angle produced offsets that cancelled, and it is why nothing appeared to
|
||||
* move even at the amplitudes it did reach.
|
||||
*/
|
||||
function targetFor(from, distance, camera, story, rng, previousDir) {
|
||||
const order = story ? story.order : 0.5;
|
||||
const wander = (1 - camera.axial) * (0.35 + (1 - order) * 0.65);
|
||||
|
||||
// Both ends of the axis are legitimate; which one is a coin flip biased
|
||||
// away from wherever we already are, so the gaze crosses the frame rather
|
||||
// than orbiting one side of it.
|
||||
const along = camera.axis + (rng.bool() ? 0 : Math.PI);
|
||||
let dir = along + rng.range(-Math.PI, Math.PI) * wander;
|
||||
|
||||
if (previousDir !== null) {
|
||||
// Within 35° of a straight reversal, nudge it off. A reversal is a
|
||||
// legitimate move; an exact retrace is the thing that reads as jitter.
|
||||
const delta = Math.abs(normalizeAngle(dir - (previousDir + Math.PI)));
|
||||
if (delta < 0.6) dir += (delta < 0.3 ? 1 : -1) * 0.9;
|
||||
}
|
||||
|
||||
return {
|
||||
point: [from[0] + Math.cos(dir) * distance, from[1] + Math.sin(dir) * distance],
|
||||
dir,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAngle(a) {
|
||||
let x = a % (Math.PI * 2);
|
||||
if (x > Math.PI) x -= Math.PI * 2;
|
||||
if (x < -Math.PI) x += Math.PI * 2;
|
||||
return x;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the move takes, and how long the shot waits first.
|
||||
*
|
||||
* The "different speeds in different sections" requirement. A loud section
|
||||
* moves fast and is done; a quiet one takes most of the shot to arrive. Travel
|
||||
* is allowed to exceed the shot length — that is not a bug, it is what makes a
|
||||
* `drift` read as a camera that was going somewhere when the edit cut away.
|
||||
*/
|
||||
function timingFor(spanFrames, story, energy, camera, curve, rng) {
|
||||
const tension = story ? story.tension : 0.5;
|
||||
const urgency = clamp01(energy * 0.6 + tension * 0.4);
|
||||
|
||||
// Fraction of the shot spent moving. Fast material arrives in the first
|
||||
// third; slow material is still arriving at the cut.
|
||||
let travel = lerp(1.15, 0.28, urgency) * camera.pace * rng.range(0.8, 1.25);
|
||||
// A drift is defined by not arriving, so it always outruns its shot.
|
||||
if (curve === 'drift') travel = Math.max(travel, 1.2);
|
||||
|
||||
// How long it holds first. A `deliberate` camera earns its name here: the
|
||||
// hold is what makes the move that follows read as a decision.
|
||||
const delay = lerp(0.22, 0.02, urgency) * rng.range(0.4, 1.3);
|
||||
|
||||
const usable = Math.max(1, spanFrames);
|
||||
return {
|
||||
delayFrames: Math.round(clamp01(delay) * usable),
|
||||
travelFrames: Math.max(1, Math.round(travel * usable)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cut RELOCATES the camera or the camera walks through it.
|
||||
*
|
||||
* Both are real edits and they say opposite things. A cut that lands on a new
|
||||
* part of the scene is a reframe — the loud one, the one you notice. A cut the
|
||||
* camera walks through is a match cut, and it is what makes two scenes read as
|
||||
* one continuous place, which is the effect worth keeping.
|
||||
*
|
||||
* So this is a decision per cut, not a mode. Hard cuts relocate, dissolves
|
||||
* mostly do not, and tension raises the odds everywhere: a wound-up section
|
||||
* jumps around inside itself, a resolution stops doing that.
|
||||
*/
|
||||
function relocatesAt(cue, story, rng) {
|
||||
if (!cue.hardCut && cue.atSectionStart) return true; // a new section is a new place
|
||||
const tension = story ? story.tension : 0.5;
|
||||
let chance = 0.12 + tension * 0.45;
|
||||
if (cue.hardCut) chance += 0.3;
|
||||
if (story) {
|
||||
if (story.act === 'climax') chance += 0.2;
|
||||
else if (story.act === 'resolution') chance *= 0.4;
|
||||
}
|
||||
return rng.bool(clamp01(chance));
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan the gaze across a whole video.
|
||||
*
|
||||
* Walks the cues in order. Each move starts from where the gaze ACTUALLY was
|
||||
* when the cut arrived — not from the target of the previous move, which may
|
||||
* never have been reached: a `drift` is defined by outrunning its shot, and
|
||||
* resuming from its unreached target is a silent teleport at every cut. That
|
||||
* was measurable as a median jump of 0.000 with a p90 of 0.274, which is a
|
||||
* camera that mostly does nothing and occasionally lurches.
|
||||
*
|
||||
* @param {Array} cues ArcDriver cues, each already carrying `framing`
|
||||
* @param {Array} sections look.sections, for bias and story state
|
||||
* @param {object} camera from deriveCamera
|
||||
* @param {Rng} rng
|
||||
* @returns {Array} one move per cue, index-aligned
|
||||
*/
|
||||
export function planGaze(cues, sections, camera, rng) {
|
||||
const moves = [];
|
||||
let at = [0, 0];
|
||||
let previousDir = null;
|
||||
|
||||
for (const cue of cues) {
|
||||
const section = sections[cue.sectionIndex] || {};
|
||||
const story = section.story || null;
|
||||
const energy = (section.bias && section.bias.energy) || 0;
|
||||
const scale = (cue.framing && cue.framing.scale) || 1;
|
||||
const reach = reachFor(scale, camera);
|
||||
const span = Math.max(1, cue.endFrame - cue.startFrame);
|
||||
|
||||
const curve = rng.pickWeighted(CURVE_NAMES, camera.curves);
|
||||
|
||||
// THE CUT. Either the camera is somewhere new when the image changes,
|
||||
// or it walks through the change and the two shots read as one place.
|
||||
//
|
||||
// Carried-over positions are re-clamped, because reach belongs to the
|
||||
// SHOT: walking a close-up's 0.45 offset into a wide would start that
|
||||
// wide further off centre than a wide is ever allowed to be. The small
|
||||
// snap this causes lands exactly on a cut, which is where the eye is
|
||||
// least able to see it.
|
||||
let from = clampToReach(at, reach);
|
||||
if (moves.length && relocatesAt(cue, story, rng)) {
|
||||
const hop = jumpFor(story, !!cue.hardCut, rng) * reach;
|
||||
const jumped = targetFor(from, hop, camera, story, rng, previousDir);
|
||||
from = clampToReach(jumped.point, reach);
|
||||
previousDir = jumped.dir;
|
||||
}
|
||||
|
||||
// THE MOVE. Where it travels during the shot, from wherever the cut
|
||||
// left it. Deliberately smaller than the cut's hop — the reframe is the
|
||||
// statement and the move is the camera living inside it.
|
||||
const distance = jumpFor(story, false, rng) * reach * 0.7;
|
||||
const { point, dir } = targetFor(from, distance, camera, story, rng, previousDir);
|
||||
|
||||
// Clamp the TARGET to the reach disc rather than the step, so a move
|
||||
// that would leave the frame is shortened instead of being redirected —
|
||||
// redirecting is what makes a bounded random walk orbit its boundary.
|
||||
const to = clampToReach(point, reach);
|
||||
const { delayFrames, travelFrames } = timingFor(span, story, energy, camera, curve, rng);
|
||||
|
||||
const move = { from, to, curve, delayFrames, travelFrames, reach };
|
||||
moves.push(move);
|
||||
|
||||
// Where the gaze actually ends up when this shot is cut away from.
|
||||
at = gazeAt(move, span);
|
||||
previousDir = dir;
|
||||
}
|
||||
|
||||
return moves;
|
||||
}
|
||||
|
||||
function clampToReach(p, reach) {
|
||||
const d = Math.hypot(p[0], p[1]);
|
||||
if (d <= reach || d === 0) return p;
|
||||
const k = reach / d;
|
||||
return [p[0] * k, p[1] * k];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the gaze is, `frames` into a cue.
|
||||
*
|
||||
* Pure in (move, frames): this is what keeps a seek frame-identical to
|
||||
* playback, and it is why the plan holds points and durations rather than a
|
||||
* running position.
|
||||
*/
|
||||
export function gazeAt(move, frames) {
|
||||
if (!move) return [0, 0];
|
||||
const t = clamp01((frames - move.delayFrames) / move.travelFrames);
|
||||
const eased = CURVES[move.curve] ? CURVES[move.curve](t) : t;
|
||||
const p = [
|
||||
lerp(move.from[0], move.to[0], eased),
|
||||
lerp(move.from[1], move.to[1], eased),
|
||||
];
|
||||
// The reach bound is enforced HERE and not only on the endpoints, because a
|
||||
// curve is allowed to leave the segment between them: `settle` overshoots
|
||||
// its target by about 10% on purpose. Clamping the plan is not the same as
|
||||
// clamping the path, and only the path is what reaches the screen.
|
||||
return move.reach ? clampToReach(p, move.reach) : p;
|
||||
}
|
||||
|
||||
/** One line for the HUD, the look panel and check output. */
|
||||
export function describeCamera(camera) {
|
||||
if (!camera) return 'camera: locked';
|
||||
return `camera: ${camera.name} (reach ${camera.reach.toFixed(2)}, `
|
||||
+ `pace ${camera.pace.toFixed(2)})`;
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
import { Rng, hashSamples } from '../engine/rng.js';
|
||||
import { AudioPalette, generateUsablePalette } from './palette.js';
|
||||
import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.js';
|
||||
import { sampleValues, defaultValues, surfaceOf } from '../params/schema.js';
|
||||
import { sampleValues, defaultValues, surfaceOf, canBackground } from '../params/schema.js';
|
||||
import { planShots } from './shots.js';
|
||||
import {
|
||||
generatePersonality, sceneHonours, signatureWeight, describePersonality,
|
||||
@@ -16,6 +16,8 @@ import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js';
|
||||
import { pickDirector, directorByName } from './directors.js';
|
||||
import { derivePaletteArc, describePaletteArc } from './paletteArc.js';
|
||||
import { deriveFramingStyle, describeFraming } from './framing.js';
|
||||
import { deriveCamera, describeCamera } from './Camera.js';
|
||||
import { deriveStory, storyForSection, NEUTRAL_STATE } from './Story.js';
|
||||
|
||||
// Which families suit which section kind now comes from the track's DIRECTOR
|
||||
// (look/directors.js) rather than from a constant here. The coupling it
|
||||
@@ -35,10 +37,18 @@ const KIND_ENERGY = {
|
||||
* and `energy` up; a breakdown pulls them down. Seed variation still dominates,
|
||||
* so two tracks with the same structure do not converge on the same look.
|
||||
*/
|
||||
function biasFor(section, summary, motion = null) {
|
||||
function biasFor(section, summary, motion = null, story = null) {
|
||||
const kindEnergy = KIND_ENERGY[section.kind] ?? 0.5;
|
||||
const measured = Math.min(1, section.energy / Math.max(1e-6, summary.meanLoudness * 1.6));
|
||||
const energy = kindEnergy * 0.6 + measured * 0.4;
|
||||
// Where this section sits in the story moves the bias, and is deliberately
|
||||
// the smallest term in it. The kind decides what a section IS — the spread
|
||||
// between an intro and a drop is 0.7 of the range — and the story decides
|
||||
// which drop this is, worth a tenth of that. Bounded rather than trusted:
|
||||
// a breakdown at maximum tension is still, unambiguously, a breakdown, and
|
||||
// the quiet-kind coupling in directors.js depends on it staying that way.
|
||||
const tension = story ? story.tension : 0.5;
|
||||
const population = story ? story.population : 0.5;
|
||||
const energy = clamp01(kindEnergy * 0.6 + measured * 0.4 + (tension - 0.5) * 0.16);
|
||||
|
||||
// 60bpm → 0, 180bpm → 1. Tempo, not energy, is what a viewer reads as
|
||||
// "this is moving too fast for the song": a slow track can have a huge drop
|
||||
@@ -58,7 +68,7 @@ function biasFor(section, summary, motion = null) {
|
||||
|
||||
return {
|
||||
energy,
|
||||
density: Math.min(1, energy * 0.7 + section.flux * 1.2),
|
||||
density: clamp01(energy * 0.7 + section.flux * 1.2 + (population - 0.5) * 0.2),
|
||||
motion: clamp01(0.12 + tempo * 0.4 + energy * 0.2 + (1 - still) * 0.35),
|
||||
// Applied on top of every `rate: true` param, so absolute animation
|
||||
// speed scales with the song rather than only its sampled position in
|
||||
@@ -69,6 +79,26 @@ function biasFor(section, summary, motion = null) {
|
||||
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
|
||||
/**
|
||||
* The track's temperament, moved to where this section sits in the story.
|
||||
*
|
||||
* Temperament is the hand on every parameter dial and it was constant for the
|
||||
* whole video, which is why two occurrences of a kind sampled around the same
|
||||
* point however far apart they were. Scaling `extremity` by tension is the
|
||||
* ratchet: the same scene, sampled nearer the ends of its own ranges the later
|
||||
* it appears. Bounded by the range extremity is drawn from — this moves where a
|
||||
* track sits inside its own character, it does not give it a different one.
|
||||
*/
|
||||
function temperamentFor(temperament, state) {
|
||||
if (!temperament || !state) return temperament;
|
||||
const tension = state.tension;
|
||||
return {
|
||||
...temperament,
|
||||
intensity: Math.max(-1, Math.min(1, temperament.intensity + (tension - 0.5) * 0.5)),
|
||||
extremity: clamp01(temperament.extremity * (0.82 + tension * 0.4)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scenes eligible for a section kind, weighted by how well the family fits.
|
||||
*
|
||||
@@ -120,7 +150,7 @@ const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
export const POOL_SIZE = 8;
|
||||
|
||||
function castingPool(rng, signature, size = POOL_SIZE) {
|
||||
const pool = scenes.filter((m) => m.role !== 'accent');
|
||||
const pool = scenes.filter(canBackground);
|
||||
const remaining = pool.slice();
|
||||
const weights = remaining.map((m) => signatureWeight(m, signature));
|
||||
const picked = [];
|
||||
@@ -141,7 +171,7 @@ function candidatesForKind(kind, used, signature = [], director, pool = null) {
|
||||
const candidates = [];
|
||||
for (const family of families) {
|
||||
const inFamily = scenesInFamily(family)
|
||||
.filter((m) => m.role !== 'accent' && (!allowed || allowed.has(m.name)));
|
||||
.filter((m) => canBackground(m) && (!allowed || allowed.has(m.name)));
|
||||
// Weight by family preference order, and push down anything already
|
||||
// used so a five-section track doesn't show one scene five times.
|
||||
const weight = families.length - families.indexOf(family);
|
||||
@@ -159,7 +189,7 @@ function candidatesForKind(kind, used, signature = [], director, pool = null) {
|
||||
// This track's pool holds nothing in the families the director wants for
|
||||
// this kind. Widen to the pool, then to the library — the track keeps
|
||||
// its scenes either way.
|
||||
const fallback = (pool && pool.length) ? pool : scenes.filter((m) => m.role !== 'accent');
|
||||
const fallback = (pool && pool.length) ? pool : scenes.filter(canBackground);
|
||||
return fallback.map((scene) => ({ scene, weight: signatureWeight(scene, signature) }));
|
||||
}
|
||||
return candidates;
|
||||
@@ -169,7 +199,7 @@ function candidatesForKind(kind, used, signature = [], director, pool = null) {
|
||||
* How many stage visuals a kind rotates between. Busy material takes more.
|
||||
*
|
||||
* Sized against the library rather than picked out of the air: a kind draws
|
||||
* from three families, which is seven to nine non-accent scenes, so a roster of
|
||||
* from three families, which is seven to nine castable scenes, so a roster of
|
||||
* four still leaves the weighting room to avoid what other kinds already took.
|
||||
* Variants a section never reaches cost nothing — layers are built per cue, so
|
||||
* only the ones its shots actually show are ever compiled.
|
||||
@@ -228,6 +258,50 @@ function assignRostersByKind(sections, rng, signature = [], director, pool = nul
|
||||
return byKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* The RECAPITULATION: the outro re-casts what the intro opened on.
|
||||
*
|
||||
* The oldest device in the form and the cheapest one available here — the scene
|
||||
* is already in the roster and already compiled, and all that changes is which
|
||||
* member of it anchors. What makes it read as a return rather than as a repeat
|
||||
* is that the outro plays it with the parameters the story has arrived at:
|
||||
* the same scene, four minutes further along its slow axis, at the story's
|
||||
* closing tension. See look/Story.js.
|
||||
*/
|
||||
function applyRecap(rosterByKind) {
|
||||
const intro = rosterByKind.get('intro');
|
||||
const outro = rosterByKind.get('outro');
|
||||
if (!intro || !outro || !intro.length || !outro.length) return;
|
||||
const opener = intro[0];
|
||||
// Keep the outro's own roster behind the recapped anchor, minus a duplicate:
|
||||
// the section still cuts away from it, it just opens and closes there.
|
||||
const rest = outro.filter((m) => m.name !== opener.name);
|
||||
rosterByKind.set('outro', [opener, ...rest]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which member of the kind's roster anchors THIS section.
|
||||
*
|
||||
* `roster[0]` opened every section of its kind, so a track's biggest visual was
|
||||
* spent in the first fifteen seconds of the first drop and then spent again,
|
||||
* identically, at every drop after it. Reserving it makes the anchor something
|
||||
* the video arrives at: earlier occurrences open on a companion, and the
|
||||
* anchor's own section is the one the story calls the climax.
|
||||
*
|
||||
* The roster itself does not change — the section still cuts between all of it,
|
||||
* which is what keeps the kind's identity — only which member it opens on.
|
||||
*/
|
||||
function anchorOrder(roster, state) {
|
||||
if (roster.length < 2 || !state) return roster;
|
||||
// The climax, the resolution and any single occurrence get the real anchor.
|
||||
const earned = state.act === 'climax' || state.act === 'resolution'
|
||||
|| state.ordinalOf < 2 || state.reveal > 0.66;
|
||||
if (earned) return roster;
|
||||
|
||||
const companion = 1 + (state.ordinal % (roster.length - 1));
|
||||
return [roster[companion], ...roster.filter((_, i) => i !== companion)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-processing and feedback derived from track character.
|
||||
* Ambient material gets more feedback and bloom; dense club material gets
|
||||
@@ -267,23 +341,26 @@ function derivePost(summary, rng, grain) {
|
||||
}
|
||||
|
||||
/**
|
||||
* One layer stack: a background scene, sometimes a second scene composited over
|
||||
* it, sometimes an accent on top of that.
|
||||
*
|
||||
* Three deliberately different jobs:
|
||||
* One layer stack: a background scene, and sometimes one or two composable
|
||||
* scenes composited over it.
|
||||
*
|
||||
* background — the shot. Always present, always opaque.
|
||||
* overlay — a SECOND full scene at partial opacity. Not always: this is the
|
||||
* overlay — a composable scene at partial opacity. Not always: this is the
|
||||
* variation valve, and a stack that always doubled up would read
|
||||
* as permanently cluttered rather than as occasionally layered.
|
||||
* Drawn from a different family so the two images argue instead
|
||||
* of blurring, and kept off scenes that are already busy.
|
||||
* accent — the depth pass. Mostly-empty by design (role: 'accent'),
|
||||
* additive, low opacity.
|
||||
*
|
||||
* Quiet material mostly goes without either — an intro is supposed to be sparse.
|
||||
* There used to be a third slot, `accent`, reserved for scenes declaring
|
||||
* `role: 'accent'`. Exactly one scene ever declared it, and the overlay path
|
||||
* above required a `composable` label no scene carried — so the reserved slot
|
||||
* was the only layering that ever happened, and every layered stack in every
|
||||
* song was the same particle field. One path, one roster: what goes on top is
|
||||
* whatever is composable, which is now a third of the library.
|
||||
*
|
||||
* Quiet material mostly goes without any — an intro is supposed to be sparse.
|
||||
*/
|
||||
function buildStack(module, accentRoster, overlayRoster, bias, rng, temperament) {
|
||||
function buildStack(module, overlayRoster, bias, rng, temperament, story = null) {
|
||||
const layers = [{
|
||||
module,
|
||||
params: sampleValues(module, rng, bias, temperament),
|
||||
@@ -298,9 +375,21 @@ function buildStack(module, accentRoster, overlayRoster, bias, rng, temperament)
|
||||
// corruption passes is noise, not depth.
|
||||
// Layering is much more likely now that what goes on top is guaranteed to
|
||||
// leave the shot underneath visible.
|
||||
// `population` is how crowded the story wants this point in the video to
|
||||
// be, and layering is the only lever on that which does not need the scene's
|
||||
// cooperation: a lone form in an empty frame and the same form under two
|
||||
// more passes are the sparse and crowded ends of one video.
|
||||
const crowd = story ? (story.population - 0.5) * 0.5 : 0;
|
||||
// The base rate was tuned when this branch was dead and layering only ever
|
||||
// came from the reserved accent slot. With a third of the library eligible
|
||||
// it lands on half of all stacks, which is the "permanently cluttered" the
|
||||
// comment above warns about — and it costs seed separation, because a video
|
||||
// where everything is doubled up looks like every other video where
|
||||
// everything is doubled up.
|
||||
const overlayChance = module.family === 'glitch'
|
||||
? 0.1
|
||||
: 0.3 + bias.energy * 0.4 + (temperament ? Math.max(0, temperament.detail) * 0.2 : 0);
|
||||
: 0.2 + bias.energy * 0.35 + crowd
|
||||
+ (temperament ? Math.max(0, temperament.detail) * 0.2 : 0);
|
||||
|
||||
// Only COMPOSABLE scenes go on top. A second canvas over the first is two
|
||||
// pictures fighting rather than one picture with depth, and it is what the
|
||||
@@ -309,17 +398,34 @@ function buildStack(module, accentRoster, overlayRoster, bias, rng, temperament)
|
||||
// The other half of the trade: a composable scene alone is a few bright
|
||||
// things on black, which scores well for variety and is thin to watch.
|
||||
// Layering is what turns both halves into one image.
|
||||
const overlays = overlayRoster.filter((m) => m.name !== module.name
|
||||
let available = overlayRoster.filter((m) => m.name !== module.name
|
||||
&& surfaceOf(m) === 'composable');
|
||||
if (overlays.length && rng.bool(Math.min(0.8, overlayChance))) {
|
||||
const overlay = rng.pick(overlays);
|
||||
|
||||
// Two passes at the same slot rather than two differently-named slots. The
|
||||
// second is rarer and only on loud, crowded material — that is where the
|
||||
// old accent pass used to land, and it is the difference between a shot with
|
||||
// something over it and a shot with a texture and a shimmer over it.
|
||||
const chances = [
|
||||
Math.min(0.65, overlayChance),
|
||||
Math.min(0.25, overlayChance * bias.energy * 0.5),
|
||||
];
|
||||
for (const [pass, chance] of chances.entries()) {
|
||||
if (!available.length || !rng.bool(chance)) break;
|
||||
// Prefer a different family so the images argue instead of blurring.
|
||||
const offFamily = available.filter((m) => m.family !== module.family);
|
||||
const overlay = rng.pick(offFamily.length ? offFamily : available);
|
||||
available = available.filter((m) => m.name !== overlay.name
|
||||
&& m.family !== overlay.family);
|
||||
// Screen and add keep the background readable underneath; softlight and
|
||||
// overlay tint it instead. All four preserve the shot; 'normal' would
|
||||
// simply replace it, which is what the shot cut is for.
|
||||
const blend = rng.pickWeighted(['screen', 'add', 'softlight', 'overlay'], [3, 2, 2, 1]);
|
||||
// A second pass sits lighter than the first, so what accumulates is
|
||||
// depth rather than a third opaque picture.
|
||||
const fade = pass === 0 ? 1 : 0.6;
|
||||
layers.push({
|
||||
module: overlay,
|
||||
params: sampleValues(overlay, rng.fork(`overlay:${overlay.name}`), {
|
||||
params: sampleValues(overlay, rng.fork(`overlay:${pass}:${overlay.name}`), {
|
||||
// An overlay reads as texture over the shot, so it is sampled
|
||||
// sparser and calmer than it would be as a background.
|
||||
...bias,
|
||||
@@ -328,20 +434,7 @@ function buildStack(module, accentRoster, overlayRoster, bias, rng, temperament)
|
||||
}, temperament),
|
||||
seed: rng.int(0, 0x7fffffff),
|
||||
blend,
|
||||
opacity: blend === 'add' ? rng.range(0.18, 0.42) : rng.range(0.25, 0.55),
|
||||
});
|
||||
}
|
||||
|
||||
// --- accent ---------------------------------------------------------
|
||||
if (accentRoster.length && rng.bool(bias.energy * 0.8)) {
|
||||
const eligible = accentRoster.filter((m) => m.family !== module.family);
|
||||
const accent = rng.pick(eligible.length ? eligible : accentRoster);
|
||||
layers.push({
|
||||
module: accent,
|
||||
params: sampleValues(accent, rng.fork('accent'), bias, temperament),
|
||||
seed: rng.int(0, 0x7fffffff),
|
||||
blend: rng.pickWeighted(['add', 'screen'], [2, 1]),
|
||||
opacity: rng.range(0.18, 0.5),
|
||||
opacity: (blend === 'add' ? rng.range(0.18, 0.42) : rng.range(0.25, 0.55)) * fade,
|
||||
});
|
||||
}
|
||||
return layers;
|
||||
@@ -369,7 +462,7 @@ export function generateLook(track, {
|
||||
// The production design, decided before a single scene is cast — casting
|
||||
// depends on it. See look/Personality.js.
|
||||
const personality = generatePersonality(summary, rng.fork('personality'), (signature) =>
|
||||
scenes.filter((m) => m.role !== 'accent' && sceneHonours(m, signature)).length,
|
||||
scenes.filter((m) => canBackground(m) && sceneHonours(m, signature)).length,
|
||||
track.sections.length);
|
||||
|
||||
// The track's point of view about what a song looks like. Cast before any
|
||||
@@ -385,8 +478,14 @@ export function generateLook(track, {
|
||||
const pool = poolOverride && poolOverride.length
|
||||
? poolOverride
|
||||
: castingPool(rng.fork('pool'), personality.signature, poolSize);
|
||||
// What HAPPENS over the track, as opposed to what it is made of. Derived
|
||||
// before casting because it decides which member of a roster anchors which
|
||||
// section, and whether the outro answers the intro. See look/Story.js.
|
||||
const story = deriveStory(track, summary, rng.fork('story'));
|
||||
|
||||
const rosterByKind = assignRostersByKind(
|
||||
track.sections, rng.fork('scenes'), personality.signature, director, pool);
|
||||
if (story.recap) applyRecap(rosterByKind);
|
||||
// The grain treatment: usually none, and when present described rather than
|
||||
// dialled. See look/grain.js.
|
||||
const grain = deriveGrain(summary, rng.fork('grain'));
|
||||
@@ -394,38 +493,50 @@ export function generateLook(track, {
|
||||
const paletteArc = derivePaletteArc(summary, rng.fork('paletteArc'));
|
||||
// Whether shots change SIZE at the cut, and how boldly. See look/framing.js.
|
||||
const framing = deriveFramingStyle(summary, rng.fork('framing'));
|
||||
// WHERE the camera looks, and how it travels there. The director leans
|
||||
// toward one camera the way it leans toward one family per kind, and the
|
||||
// seed decides — see look/Camera.js.
|
||||
const camera = deriveCamera(director, summary, rng.fork('camera'));
|
||||
const { post, feedback } = derivePost(summary, rng.fork('post'), grain);
|
||||
|
||||
// Scenes that declare role 'accent' composite over a background rather than
|
||||
// being one — most of their frame is empty by design. They are never chosen
|
||||
// as a section's primary scene.
|
||||
// Accents honour the signature too where they can. If none can, the track
|
||||
// goes without depth layers rather than putting an off-design element into
|
||||
// every stack.
|
||||
const accentRoster = scenes.filter((m) => m.role === 'accent');
|
||||
|
||||
// Scenes eligible to be composited OVER a background. Same casting rule as
|
||||
// everything else — an overlay is on screen as much as the shot under it,
|
||||
// so an off-design one would be just as visible.
|
||||
const overlayRoster = pool.length >= 4 ? pool : scenes.filter((m) => m.role !== 'accent');
|
||||
//
|
||||
// Widened past the casting pool with the scenes that exist only to sit on
|
||||
// top: those are never drawn as a section's primary scene, so the pool —
|
||||
// which is built out of background candidates — would never contain them.
|
||||
const overlayOnly = scenes.filter((m) => !canBackground(m));
|
||||
const overlayRoster = (pool.length >= 4 ? pool : scenes.filter(canBackground))
|
||||
.concat(overlayOnly);
|
||||
|
||||
const sections = track.sections.map((section) => {
|
||||
const roster = rosterByKind.get(section.kind) || [scenes[0]];
|
||||
const state = storyForSection(story, section.index);
|
||||
const kindRoster = rosterByKind.get(section.kind) || [scenes[0]];
|
||||
// The kind's roster, opened on the member this point in the story has
|
||||
// earned. Same set, different anchor. See anchorOrder.
|
||||
const roster = anchorOrder(kindRoster, state);
|
||||
const sectionRng = rng.fork(`section:${section.index}:${roster[0].name}`);
|
||||
const bias = biasFor(section, summary, personality.motion);
|
||||
const bias = biasFor(section, summary, personality.motion, state);
|
||||
// The RATCHET: how hard the track pushes its dials is a property of the
|
||||
// track (Personality.temperament) scaled by where in the story it is,
|
||||
// so the last occurrence of a kind samples further out than the first.
|
||||
const temperament = temperamentFor(personality.temperament, state);
|
||||
|
||||
const variants = roster.map((module, v) => buildStack(
|
||||
module, accentRoster, overlayRoster, bias,
|
||||
sectionRng.fork(`variant:${section.index}:${v}`), personality.temperament,
|
||||
module, overlayRoster, bias,
|
||||
sectionRng.fork(`variant:${section.index}:${v}`), temperament, state,
|
||||
));
|
||||
|
||||
const shots = planShots(
|
||||
section, track, bias, variants.length, sectionRng.fork(`shots:${section.index}`),
|
||||
section, track, bias, variants.length,
|
||||
sectionRng.fork(`shots:${section.index}`), state,
|
||||
);
|
||||
|
||||
return {
|
||||
index: section.index,
|
||||
kind: section.kind,
|
||||
story: state,
|
||||
startFrame: section.startFrame,
|
||||
endFrame: section.endFrame,
|
||||
start: section.start,
|
||||
@@ -447,8 +558,10 @@ export function generateLook(track, {
|
||||
personality,
|
||||
paletteScheme: paletteSource.lastScheme,
|
||||
director: director.name,
|
||||
story,
|
||||
paletteArc,
|
||||
framing,
|
||||
camera,
|
||||
grain,
|
||||
post,
|
||||
feedback,
|
||||
@@ -470,8 +583,8 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
|
||||
// A reroll re-draws this section's cast from the same kind of pool the track
|
||||
// was built with, weighted by the signature rather than filtered by it.
|
||||
let candidates = families.flatMap((f) => scenesInFamily(f))
|
||||
.filter((m) => m.role !== 'accent');
|
||||
if (!candidates.length) candidates = scenes.filter((m) => m.role !== 'accent');
|
||||
.filter(canBackground);
|
||||
if (!candidates.length) candidates = scenes.filter(canBackground);
|
||||
|
||||
// Re-roll the whole roster, not just the anchor: the section's shots cut
|
||||
// between all of them, so replacing one would leave the section half old.
|
||||
@@ -484,14 +597,17 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
|
||||
}
|
||||
if (!roster.length) roster.push(scenes[0]);
|
||||
|
||||
const accentRoster = scenes.filter((m) => m.role === 'accent');
|
||||
const overlayRoster = castingPool(rng.fork('pool'), signature);
|
||||
const overlayRoster = castingPool(rng.fork('pool'), signature)
|
||||
.concat(scenes.filter((m) => !canBackground(m)));
|
||||
// A reroll changes what this section is made of. Where it sits in the story
|
||||
// is a property of the song, so it survives untouched.
|
||||
const state = section.story || NEUTRAL_STATE;
|
||||
section.variants = roster.map((module, v) => buildStack(
|
||||
module, accentRoster, overlayRoster, section.bias, rng.fork(`variant:${v}`),
|
||||
look.personality && look.personality.temperament,
|
||||
module, overlayRoster, section.bias, rng.fork(`variant:${v}`),
|
||||
temperamentFor(look.personality && look.personality.temperament, state), state,
|
||||
));
|
||||
section.shots = planShots(
|
||||
section, track, section.bias, section.variants.length, rng.fork('shots'),
|
||||
section, track, section.bias, section.variants.length, rng.fork('shots'), state,
|
||||
);
|
||||
section.layers = section.variants[0];
|
||||
return look;
|
||||
@@ -529,6 +645,7 @@ export function describeLook(look) {
|
||||
return `seed ${look.seed.toString(16)} · ${look.director} · ${look.paletteScheme} · ` +
|
||||
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
|
||||
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
|
||||
`${describeCamera(look.camera)} · ` +
|
||||
`${[...new Set(kinds)].join(', ')}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
// The song's STORY: where a section sits in the video, as opposed to what kind
|
||||
// of section it is.
|
||||
//
|
||||
// Everything that decides what this generator puts on screen is keyed on
|
||||
// section KIND — the roster (LookGenerator.assignRostersByKind), the parameter
|
||||
// bias (KIND_ENERGY), the cutting rhythm (shots.rhythmFor), the colour offset
|
||||
// (paletteArc.kindHue). Kinds recur. So the fourth drop is cast from the same
|
||||
// roster, biased to the same energy, cut at the same rate and tinted the same
|
||||
// hue as the first one, and nothing in the video can tell you which of them you
|
||||
// are watching.
|
||||
//
|
||||
// The one exception was ArcDriver's slow axis, which travels one way across the
|
||||
// whole track — and it is blind: it does not know where the drop is, and its
|
||||
// direction is a coin flip per scene, so two scenes in one video routinely
|
||||
// travel against each other.
|
||||
//
|
||||
// That is a song structure without a story: recurrence without consequence.
|
||||
// This module adds the missing coordinate.
|
||||
//
|
||||
// POSITION which occurrence of its kind a section is, and which act it is in.
|
||||
// `ordinal` alone — "the 3rd of 4 drops" — unlocks most of what
|
||||
// follows, and nothing downstream had it.
|
||||
// MOMENTS the four frames the video turns on, FOUND in the audio rather
|
||||
// than placed by the seed. A plot that declares a climax where the
|
||||
// track is quiet is worse than no plot at all.
|
||||
// PLOT what the track does with them, as one of a few coherent narrative
|
||||
// shapes, chosen the way look/directors.js chooses a director.
|
||||
//
|
||||
// The plot is expressed as five variables that everything downstream reads.
|
||||
// They are STAGED, not ramped: they hold flat inside a section and move at its
|
||||
// boundary. A story advances in scenes; a smooth ramp over five minutes is a
|
||||
// slow zoom, which is an effect rather than a narrative.
|
||||
//
|
||||
// Everything here is a pure function of (story, frame). No state, no random
|
||||
// source past derivation — the same rule the rest of the render path follows,
|
||||
// and what keeps a seek frame-identical to playback.
|
||||
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
const smooth = (x) => { const t = clamp01(x); return t * t * (3 - 2 * t); };
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
|
||||
/** The variables a plot moves. Neutral is 0.5 for all of them. */
|
||||
export const STORY_VARS = ['tension', 'reveal', 'closeness', 'population', 'order'];
|
||||
|
||||
/**
|
||||
* What the story reads as when there is none — a look built by hand, or a check
|
||||
* that constructs sections directly. Everything downstream must render exactly
|
||||
* what it rendered before this module existed when it sees these.
|
||||
*/
|
||||
export const NEUTRAL_STATE = {
|
||||
tension: 0.5, reveal: 0.5, closeness: 0.5, population: 0.5, order: 0.5,
|
||||
journey: 0.5, act: 'development',
|
||||
};
|
||||
|
||||
/**
|
||||
* The narrative shapes.
|
||||
*
|
||||
* One coherent point of view about what happens over a song, the same way a
|
||||
* director is one point of view about what a song looks like. Weighted, with
|
||||
* the audio tilting the odds and never deciding — a fixed mapping from measured
|
||||
* features to narrative is how a library ends up with one story per genre.
|
||||
*
|
||||
* `curve` receives a section's narrative position and the track's seeded curve
|
||||
* shape, and returns the five variables plus the journey the slow axis follows.
|
||||
*
|
||||
* ctx:
|
||||
* p 0..1 through the section list
|
||||
* rise 0..1 approach to the climax (1 from the climax onward)
|
||||
* after 0..1 through the aftermath (0 up to the climax)
|
||||
* ord 0..1 which occurrence of this kind it is
|
||||
* energy 0..1 the section's measured energy, against the track's loudest
|
||||
*/
|
||||
export const PLOTS = [
|
||||
{
|
||||
name: 'emergence',
|
||||
weight: 3,
|
||||
// Almost nothing, then something. The reveal rises and stays up: what
|
||||
// was uncovered does not go back in the box.
|
||||
curve: (ctx, sh) => {
|
||||
const r = smooth(ctx.rise * 0.85 + ctx.p * 0.15);
|
||||
return {
|
||||
tension: 0.2 + 0.7 * r,
|
||||
reveal: lerp(0.12, 1, r),
|
||||
population: 0.25 + 0.6 * r,
|
||||
closeness: 0.35 + 0.4 * r * sh.closeDir,
|
||||
order: 0.6 - 0.1 * r,
|
||||
journey: r,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'escalation',
|
||||
weight: 3,
|
||||
// Each recurrence goes further than the last. A ratchet rather than a
|
||||
// curve — this is the plot that needs `ordinal`, and the one a viewer
|
||||
// names as "it kept going somewhere".
|
||||
curve: (ctx, sh) => {
|
||||
const r = clamp01(ctx.rise * 0.5 + ctx.ord * 0.5);
|
||||
return {
|
||||
tension: 0.25 + 0.75 * r,
|
||||
reveal: 0.3 + 0.6 * r,
|
||||
population: 0.3 + 0.6 * r,
|
||||
closeness: 0.35 + 0.45 * r * sh.closeDir,
|
||||
order: 0.7 - 0.35 * r,
|
||||
journey: clamp01(r * 0.8 + ctx.p * 0.2),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'collapse',
|
||||
weight: 2,
|
||||
// Order into entropy. The climax is the thing coming apart rather than
|
||||
// the thing at its peak, so `order` is monotone down and everything else
|
||||
// follows the energy.
|
||||
curve: (ctx, sh) => {
|
||||
const r = smooth(ctx.rise);
|
||||
return {
|
||||
tension: 0.3 + 0.6 * r,
|
||||
reveal: 0.35 + 0.5 * ctx.p,
|
||||
population: clamp01(0.35 + 0.5 * r - 0.35 * ctx.after),
|
||||
closeness: 0.3 + 0.45 * ctx.p * sh.closeDir,
|
||||
order: 0.9 - 0.8 * smooth(ctx.p * 0.7 + ctx.rise * 0.3),
|
||||
journey: ctx.p,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'return',
|
||||
weight: 2,
|
||||
// ABA. Everything arches up to the climax and comes back down — but not
|
||||
// all the way, and not to the same place: the small residue on `after`
|
||||
// is what makes it a return rather than a loop. The outro re-casts the
|
||||
// intro's scene; see LookGenerator and `recap`.
|
||||
curve: (ctx, sh) => {
|
||||
const arch = clamp01(smooth(ctx.rise) * (1 - smooth(ctx.after)));
|
||||
return {
|
||||
tension: 0.2 + 0.7 * arch,
|
||||
reveal: clamp01(0.25 + 0.6 * arch + 0.18 * ctx.after),
|
||||
population: 0.3 + 0.55 * arch,
|
||||
closeness: 0.35 + 0.45 * arch * sh.closeDir,
|
||||
order: 0.65 - 0.2 * arch,
|
||||
journey: clamp01(arch * 0.85 + ctx.p * 0.15),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'unveiling',
|
||||
weight: 2,
|
||||
// The song's protagonist is withheld and then it is all there is.
|
||||
// Population falls as reveal rises for that reason: a frame full of
|
||||
// chorus is what was hiding it.
|
||||
curve: (ctx, sh) => {
|
||||
const shown = smooth((ctx.rise - sh.holdOut) / Math.max(0.15, 1 - sh.holdOut));
|
||||
return {
|
||||
tension: 0.25 + 0.6 * smooth(ctx.rise),
|
||||
reveal: 0.1 + 0.9 * shown,
|
||||
population: 0.6 - 0.35 * shown,
|
||||
closeness: 0.3 + 0.6 * shown * sh.closeDir,
|
||||
order: 0.55 + 0.15 * shown,
|
||||
journey: clamp01(shown * 0.75 + ctx.p * 0.25),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const PLOT_NAMES = PLOTS.map((p) => p.name);
|
||||
|
||||
export function plotByName(name) {
|
||||
return PLOTS.find((p) => p.name === name) || PLOTS[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast the plot.
|
||||
*
|
||||
* The tilts are deliberately loose: a track that keeps changing what it is
|
||||
* doing has recurrences to ratchet, a dynamic one has somewhere to come back
|
||||
* from, a noisy one comes apart more readily than it builds. Every plot stays
|
||||
* reachable for every track.
|
||||
*/
|
||||
function pickPlot(summary, sectionCount, rng) {
|
||||
const noisy = Math.min(1, (summary.meanFlatness ?? 0.2) * 3);
|
||||
const dynamic = clamp01(summary.dynamicRange ?? 0.5);
|
||||
const busy = clamp01((sectionCount - 2) / 5);
|
||||
|
||||
const weights = PLOTS.map((plot) => {
|
||||
let w = plot.weight;
|
||||
if (plot.name === 'escalation') w *= 0.5 + busy * 1.8;
|
||||
if (plot.name === 'collapse') w *= 0.5 + noisy * 1.8;
|
||||
if (plot.name === 'return') w *= 0.5 + dynamic * 1.4;
|
||||
if (plot.name === 'emergence') w *= 0.6 + (1 - busy) * 1.2;
|
||||
if (plot.name === 'unveiling') w *= 0.6 + (1 - noisy) * 1.0;
|
||||
return w;
|
||||
});
|
||||
return rng.pickWeighted(PLOTS, weights);
|
||||
}
|
||||
|
||||
/**
|
||||
* The frames the story turns on.
|
||||
*
|
||||
* Every one of these is read off the section energies `segment.js` already
|
||||
* produced. Nothing here may invent a moment the audio does not have: the story
|
||||
* follows the song, and the one failure mode worse than no story is a story
|
||||
* that declares its climax where the track is quiet.
|
||||
*
|
||||
* A short or flat track collapses several of these onto the same section, which
|
||||
* is correct rather than degenerate — a song with no structure gets no story.
|
||||
*/
|
||||
function findMoments(sections) {
|
||||
const n = sections.length;
|
||||
const energies = sections.map((s) => s.energy || 0);
|
||||
const peak = Math.max(...energies, 1e-6);
|
||||
|
||||
// The climax is the loudest section, ties broken toward the later one: when
|
||||
// a track states the same peak twice, the second one is the one that means
|
||||
// something, because the first has already happened.
|
||||
let climax = 0;
|
||||
for (let i = 0; i < n; i++) if (energies[i] >= energies[climax]) climax = i;
|
||||
|
||||
// The arrival is the video's first "here it is" — the first section that
|
||||
// clears most of the way to the peak.
|
||||
let arrival = null;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (energies[i] >= peak * 0.6) { arrival = i; break; }
|
||||
}
|
||||
if (arrival === null) arrival = Math.min(climax, n - 1);
|
||||
|
||||
// The turn is the largest fall, and it has to happen after something has
|
||||
// arrived — a quiet opening followed by a quieter one is not a reversal.
|
||||
let turn = null;
|
||||
let worst = 0;
|
||||
for (let i = arrival + 1; i < n; i++) {
|
||||
const fall = energies[i - 1] - energies[i];
|
||||
if (fall > worst) { worst = fall; turn = i; }
|
||||
}
|
||||
|
||||
// The resolution is where the track stops trying to top itself.
|
||||
let resolution = null;
|
||||
for (let i = climax + 1; i < n; i++) {
|
||||
if (energies[i] < energies[climax] * 0.85) { resolution = i; break; }
|
||||
}
|
||||
if (resolution === null && climax < n - 1) resolution = n - 1;
|
||||
|
||||
return { arrival, turn, climax, resolution };
|
||||
}
|
||||
|
||||
function actFor(index, moments) {
|
||||
if (index === moments.climax) return 'climax';
|
||||
if (moments.resolution !== null && index >= moments.resolution) return 'resolution';
|
||||
if (moments.turn !== null && index === moments.turn) return 'turn';
|
||||
if (moments.arrival !== null && index < moments.arrival) return 'setup';
|
||||
return 'development';
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the story for a track.
|
||||
*
|
||||
* @param {FeatureTrack} track
|
||||
* @param {object} summary track.summary
|
||||
* @param {Rng} rng
|
||||
* @returns {object} plain data — no functions, so a look stays serialisable
|
||||
*/
|
||||
export function deriveStory(track, summary, rng) {
|
||||
const sections = track.sections || [];
|
||||
const n = sections.length;
|
||||
const plot = pickPlot(summary, n, rng);
|
||||
|
||||
// The track's own version of its plot. Two tracks telling the same story
|
||||
// still have to differ, for the reason directors.js gives about house
|
||||
// styles — so the shape of the curves is seeded even when the plot is not.
|
||||
const shape = {
|
||||
// Whether this video moves toward its subject or pulls away from it.
|
||||
// Toward, usually: a video that ends further away than it started is a
|
||||
// real choice and a rarer one.
|
||||
closeDir: rng.bool(0.75) ? 1 : -0.6,
|
||||
// How long `unveiling` keeps its protagonist back.
|
||||
holdOut: rng.range(0.45, 0.8),
|
||||
// How hard this track commits to its plot at all.
|
||||
bite: rng.range(0.7, 1.0),
|
||||
};
|
||||
|
||||
const moments = findMoments(sections);
|
||||
|
||||
// How much story a track has room for. Under about four sections the curves
|
||||
// have nowhere to travel, and forcing them produces a video that lurches
|
||||
// rather than one that progresses, so the whole layer fades toward neutral.
|
||||
const strength = clamp01((n - 1) / 3) * shape.bite;
|
||||
|
||||
const peak = Math.max(...sections.map((s) => s.energy || 0), 1e-6);
|
||||
const climax = moments.climax;
|
||||
const seenKind = new Map();
|
||||
const countKind = new Map();
|
||||
for (const s of sections) countKind.set(s.kind, (countKind.get(s.kind) || 0) + 1);
|
||||
|
||||
const states = sections.map((section, i) => {
|
||||
const ordinal = seenKind.get(section.kind) || 0;
|
||||
seenKind.set(section.kind, ordinal + 1);
|
||||
const ordinalOf = countKind.get(section.kind) || 1;
|
||||
|
||||
const ctx = {
|
||||
p: n > 1 ? i / (n - 1) : 0.5,
|
||||
rise: climax > 0 ? clamp01(i / climax) : 1,
|
||||
after: i > climax && n - 1 > climax ? clamp01((i - climax) / (n - 1 - climax)) : 0,
|
||||
ord: ordinalOf > 1 ? ordinal / (ordinalOf - 1) : (n > 1 ? i / (n - 1) : 0.5),
|
||||
energy: clamp01((section.energy || 0) / peak),
|
||||
};
|
||||
|
||||
const raw = plot.curve(ctx, shape);
|
||||
|
||||
// The plot proposes and the song disposes. Blending the section's
|
||||
// measured energy back in is what stops a story-driven tension from
|
||||
// overriding a quiet section — the narrative may say "further than
|
||||
// before", it may not say "loud" where the track is not.
|
||||
raw.tension = raw.tension * 0.75 + ctx.energy * 0.25;
|
||||
|
||||
const state = { index: i, kind: section.kind, ordinal, ordinalOf, act: actFor(i, moments) };
|
||||
for (const key of STORY_VARS) {
|
||||
state[key] = clamp01(lerp(0.5, clamp01(raw[key]), strength));
|
||||
}
|
||||
// The journey keeps its full travel — it is the axis the whole video
|
||||
// moves along, and halving it on a four-section track is the same as
|
||||
// not having it. Its NEUTRAL is the plain progress ramp the slow axis
|
||||
// used before this module existed, so a thin track degrades to exactly
|
||||
// the old behaviour rather than to a flat line.
|
||||
state.journey = clamp01(lerp(ctx.p, clamp01(raw.journey), strength));
|
||||
return state;
|
||||
});
|
||||
|
||||
// How long a story variable takes to arrive at its new value. Roughly a
|
||||
// bar: long enough that nothing pops mid-crossfade (the failure buildSlope
|
||||
// already taught us about at a boundary), short enough that against a
|
||||
// five-minute video it still reads as a step rather than a ramp.
|
||||
const barSeconds = track.tempo
|
||||
? (track.tempo.period * track.tempo.beatsPerBar) / track.fps : 2;
|
||||
const blendFrames = Math.max(6, Math.round(barSeconds * track.fps));
|
||||
|
||||
return {
|
||||
plot: plot.name,
|
||||
shape,
|
||||
moments,
|
||||
strength,
|
||||
blendFrames,
|
||||
// All of a video's scenes should travel the same WAY. Magnitude stays
|
||||
// per scene, but the sign is the track's, because two scenes drifting
|
||||
// against each other is what made the slow axis read as wobble rather
|
||||
// than as a direction.
|
||||
axisSign: rng.bool() ? 1 : -1,
|
||||
// The oldest device available and the cheapest one here: the outro
|
||||
// re-casts the intro's scene, played with the parameters the story has
|
||||
// arrived at rather than the ones it opened with.
|
||||
recap: plot.name === 'return' || rng.bool(0.15),
|
||||
sections: states,
|
||||
frames: sections.map((s) => ({ startFrame: s.startFrame, endFrame: s.endFrame })),
|
||||
};
|
||||
}
|
||||
|
||||
/** The story state of one section, or the neutral read. */
|
||||
export function storyForSection(story, index) {
|
||||
if (!story || !story.sections || !story.sections[index]) return { ...NEUTRAL_STATE };
|
||||
return story.sections[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* The story state at a frame.
|
||||
*
|
||||
* Held flat inside a section and blended over `blendFrames` after each boundary.
|
||||
* Continuous in frame on purpose: the outgoing layer of a crossfade is still on
|
||||
* screen when the boundary passes, and stepping its inputs there is a visible
|
||||
* pop at exactly the moment the edit is trying to hide.
|
||||
*/
|
||||
export function storyStateAt(story, frame) {
|
||||
if (!story || !story.sections || !story.sections.length) return { ...NEUTRAL_STATE };
|
||||
|
||||
const frames = story.frames;
|
||||
let lo = 0;
|
||||
let hi = frames.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (frames[mid].startFrame <= frame) lo = mid; else hi = mid - 1;
|
||||
}
|
||||
|
||||
const current = story.sections[lo];
|
||||
if (lo === 0) return current;
|
||||
|
||||
const into = frame - frames[lo].startFrame;
|
||||
if (into >= story.blendFrames) return current;
|
||||
|
||||
const previous = story.sections[lo - 1];
|
||||
const t = smooth(into / story.blendFrames);
|
||||
const out = { index: current.index, kind: current.kind, act: current.act,
|
||||
ordinal: current.ordinal, ordinalOf: current.ordinalOf };
|
||||
for (const key of STORY_VARS) out[key] = lerp(previous[key], current[key], t);
|
||||
out.journey = lerp(previous.journey, current.journey, t);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One line for the HUD, the look panel and check output. */
|
||||
export function describeStory(story) {
|
||||
if (!story) return 'story: none';
|
||||
const m = story.moments;
|
||||
const at = (i) => (i === null || i === undefined ? '–' : `§${i}`);
|
||||
return `story: ${story.plot}${story.recap ? ' + recap' : ''} · `
|
||||
+ `arrival ${at(m.arrival)} turn ${at(m.turn)} climax ${at(m.climax)} `
|
||||
+ `resolution ${at(m.resolution)}`;
|
||||
}
|
||||
@@ -45,6 +45,10 @@ const QUIET_KINDS = ['intro', 'breakdown', 'outro'];
|
||||
export const DIRECTORS = [
|
||||
{
|
||||
name: 'ambient',
|
||||
// Patient camera to match: long moves, mostly along one line. See
|
||||
// look/Camera.js — a director's point of view now includes how it
|
||||
// shoots, not only what it points at.
|
||||
camera: 'contemplative',
|
||||
// The original table. A drop resolves into geometry; everything quiet is
|
||||
// minimal. Still the most broadly applicable, so it keeps the most weight.
|
||||
weight: 3,
|
||||
@@ -59,6 +63,8 @@ export const DIRECTORS = [
|
||||
},
|
||||
{
|
||||
name: 'brutalist',
|
||||
// Holds, then commits to one large move. Architecture is looked AT.
|
||||
camera: 'deliberate',
|
||||
// Everything is architecture. Quiet means empty rather than soft, so it
|
||||
// leads on minimal and reaches for organic last.
|
||||
weight: 2,
|
||||
@@ -73,6 +79,8 @@ export const DIRECTORS = [
|
||||
},
|
||||
{
|
||||
name: 'organicist',
|
||||
// Never settles, because nothing here is ever finished settling.
|
||||
camera: 'roaming',
|
||||
// Nothing is ever built; things grow and dissolve. Deliberately never
|
||||
// reaches for glitch — a point of view is defined by what it refuses.
|
||||
weight: 2,
|
||||
@@ -87,6 +95,8 @@ export const DIRECTORS = [
|
||||
},
|
||||
{
|
||||
name: 'corrupt',
|
||||
// Cuts with the camera already moving.
|
||||
camera: 'kinetic',
|
||||
// The signal is damaged and the damage is the subject — everywhere the
|
||||
// damage is allowed to be. Its quiet sections lead on flow, so the calm
|
||||
// reads as signal drifting rather than as rest.
|
||||
@@ -102,6 +112,9 @@ export const DIRECTORS = [
|
||||
},
|
||||
{
|
||||
name: 'geometer',
|
||||
// Small, exact, always arrives — the pattern is the subject and the
|
||||
// camera does not editorialise about it.
|
||||
camera: 'precise',
|
||||
// Pattern first, everywhere, at every energy. The drop is not an
|
||||
// explosion, it is the pattern at its densest.
|
||||
weight: 2,
|
||||
|
||||
@@ -11,9 +11,15 @@
|
||||
// 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.
|
||||
//
|
||||
// Framing is per shot and constant within it. A zoom that moves during a shot
|
||||
// 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.
|
||||
@@ -24,9 +30,9 @@
|
||||
* measured by pushing until the image stopped being worth looking at.
|
||||
*/
|
||||
export const SHOT_SIZES = {
|
||||
wide: { scale: 0.62, drift: 0.06 },
|
||||
normal: { scale: 1.0, drift: 0.05 },
|
||||
close: { scale: 1.7, drift: 0.10 },
|
||||
wide: { scale: 0.62 },
|
||||
normal: { scale: 1.0 },
|
||||
close: { scale: 1.7 },
|
||||
};
|
||||
|
||||
export const SHOT_SIZE_NAMES = Object.keys(SHOT_SIZES);
|
||||
@@ -61,8 +67,10 @@ export function deriveFramingStyle(summary, rng) {
|
||||
* @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) {
|
||||
export function frameShot(style, previous, energy, rng, closeness = 0.5) {
|
||||
if (style.mode === 'locked') return neutralFraming();
|
||||
|
||||
const keep = previous && !rng.bool(style.changeChance);
|
||||
@@ -71,10 +79,18 @@ export function frameShot(style, previous, energy, rng) {
|
||||
// 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, // wide
|
||||
2, // normal
|
||||
1 + energy * 2.5, // close
|
||||
(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);
|
||||
|
||||
@@ -90,17 +106,17 @@ export function frameShot(style, previous, energy, rng) {
|
||||
// than the same sizes drawn less often.
|
||||
const scale = 1 + (spec.scale - 1) * style.range;
|
||||
|
||||
// Recentring is what stops a close-up being a centre crop of the wide. Held
|
||||
// small: the scenes are centred compositions and pushing far off centre
|
||||
// finds their empty corners.
|
||||
const angle = rng.range(0, Math.PI * 2);
|
||||
const amount = spec.drift * style.range * rng.range(0.3, 1);
|
||||
|
||||
return {
|
||||
size,
|
||||
scale,
|
||||
shift: [Math.cos(angle) * amount, Math.sin(angle) * amount],
|
||||
};
|
||||
// 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() {
|
||||
|
||||
@@ -24,7 +24,7 @@ export const MAX_HUE_ROTATION = 0.6; // radians, ~34 degrees
|
||||
export const MAX_CHROMA_SCALE = 0.35; // ±35% saturation
|
||||
export const MAX_LIGHT_SHIFT = 0.07; // OKLCH lightness
|
||||
|
||||
export const ARC_MODES = ['static', 'drift', 'sections', 'lift'];
|
||||
export const ARC_MODES = ['static', 'drift', 'sections', 'lift', 'narrative'];
|
||||
|
||||
/**
|
||||
* How this track's colour moves.
|
||||
@@ -34,7 +34,12 @@ export const ARC_MODES = ['static', 'drift', 'sections', 'lift'];
|
||||
* track whose colour holds is a legitimate choice and one in six or so gets it.
|
||||
*/
|
||||
export function derivePaletteArc(summary, rng) {
|
||||
const mode = rng.pickWeighted(ARC_MODES, [1.5, 3, 3, 2.5]);
|
||||
// 'narrative' is the same movement as 'drift', keyed on where the story is
|
||||
// rather than on how much of the file has elapsed. It is weighted highest
|
||||
// because it is the only mode whose colour change lands WITH something: a
|
||||
// clock has no reason to turn the picture warmer at three minutes, and the
|
||||
// arrival of a climax does.
|
||||
const mode = rng.pickWeighted(ARC_MODES, [1.5, 2.5, 3, 2.5, 3.5]);
|
||||
const dir = rng.bool() ? 1 : -1;
|
||||
|
||||
return {
|
||||
@@ -72,8 +77,11 @@ export function derivePaletteArc(summary, rng) {
|
||||
* @param {number} ctx.progress 0..1 through the track
|
||||
* @param {string} ctx.sectionKind kind of the section this frame is in
|
||||
* @param {object} ctx.features FeatureTrack row
|
||||
* @param {object} ctx.story story state for this frame, see look/Story.js
|
||||
*/
|
||||
export function paletteShiftAt(arc, { progress = 0, sectionKind = '', features = null } = {}) {
|
||||
export function paletteShiftAt(arc, {
|
||||
progress = 0, sectionKind = '', features = null, story = null,
|
||||
} = {}) {
|
||||
if (!arc) return { hue: 0, chroma: 1, lightness: 0 };
|
||||
|
||||
// The slow underlying travel, present in every mode. Eased rather than
|
||||
@@ -97,6 +105,20 @@ export function paletteShiftAt(arc, { progress = 0, sectionKind = '', features =
|
||||
chroma = 1 + arc.chromaLift * (drive * 2 - 1);
|
||||
lightness = arc.lightLift * (drive - 0.35);
|
||||
hue += arc.kindHue[sectionKind] * 0.4 || 0;
|
||||
} else if (arc.mode === 'narrative') {
|
||||
// Colour follows the STORY rather than the clock. The journey carries
|
||||
// the hue, tension opens and closes the saturation, and what has been
|
||||
// revealed lifts the value — so the frame at the climax is a colour the
|
||||
// opening implied and the resolution comes back off it.
|
||||
//
|
||||
// A track with no story degrades to 'drift': journey is the plain
|
||||
// progress ramp when Story.js has nothing to work with, and the two
|
||||
// expressions are then identical.
|
||||
const s = story || {};
|
||||
const journey = s.journey ?? eased;
|
||||
hue = arc.hueTravel * journey;
|
||||
chroma = 1 + arc.chromaLift * ((s.tension ?? 0.5) * 2 - 1);
|
||||
lightness = arc.lightLift * ((s.reveal ?? 0.5) - 0.4);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -113,5 +135,9 @@ export function describePaletteArc(arc) {
|
||||
if (!arc || arc.mode === 'static') return 'colour: held';
|
||||
if (arc.mode === 'drift') return `colour: drift ${(arc.hueTravel * 57.3).toFixed(0)}°`;
|
||||
if (arc.mode === 'lift') return `colour: lift ±${(arc.chromaLift * 100).toFixed(0)}% sat`;
|
||||
if (arc.mode === 'narrative') {
|
||||
return `colour: narrative ${(arc.hueTravel * 57.3).toFixed(0)}° / `
|
||||
+ `±${(arc.chromaLift * 100).toFixed(0)}% sat`;
|
||||
}
|
||||
return `colour: per-section ±${(Math.max(...Object.values(arc.kindHue).map(Math.abs)) * 57.3).toFixed(0)}°`;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,17 @@ export const HARD_CUT_ENERGY = 0.66;
|
||||
* 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) {
|
||||
function rhythmFor(energy, rng, story = null) {
|
||||
// Where the section sits in the story shifts which band it cuts in. A song
|
||||
// does not only get louder toward its climax, it gets more urgent, and edit
|
||||
// rate is the one register that says urgency without changing the image at
|
||||
// all. The resolution goes the other way and holds — the last thing a video
|
||||
// should do is keep cutting at the pace of the thing that just ended.
|
||||
if (story) {
|
||||
const urgency = (story.tension - 0.5) * 0.22;
|
||||
energy = Math.max(0, Math.min(1, energy + urgency));
|
||||
if (story.act === 'resolution') energy = Math.min(energy, 0.44);
|
||||
}
|
||||
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]]);
|
||||
@@ -116,14 +126,15 @@ function snapCut(from, ideal, downbeats, tolerance) {
|
||||
* @param {object} bias the section's bias, for energy
|
||||
* @param {number} variantCount how many stage visuals the section has
|
||||
* @param {Rng} rng
|
||||
* @param {object|null} story this section's story state, see look/Story.js
|
||||
* @returns {Array<{index,startFrame,endFrame,variant,hardCut}>}
|
||||
*/
|
||||
export function planShots(section, track, bias, variantCount, rng) {
|
||||
export function planShots(section, track, bias, variantCount, rng, story = null) {
|
||||
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 lengths = fitPattern(rhythmFor(bias.energy, rng, story), barSeconds);
|
||||
const shortest = Math.min(...lengths);
|
||||
|
||||
// A section with only one visual to show has nothing to cut to.
|
||||
|
||||
Reference in New Issue
Block a user