music-video-gen/flow-state/src/look/ArcDriver.js
Dejvino 7aa60d7336 Epic 5 Phase 0 — actors have bodies; the stage has depth
ActorGenerator is the cast with bodies: generateActor{mpl} takes
{summary,rng,archetype,personality,identity} and returns a serialisable
ActorSpec — same audio-tilts-centre / seed-picks-within rule as
Personality/Identity, forked rng so adding an actor never shifts later
decisions. Five archetypes (monolith/swarm/walker/vehicle/structure),
per-track actor set on look.actors, HUD helper included. Stage C will
grow as a library on this without infra changes.

Mesh twin of Identity.form: actors/meshes.js builds BufferGeometry from
the same assembly (cast SDF → Shape → ExtrudeGeometry, box/capsule/
torus/sphere primitives, symmetry folding radial/mirror/stack). Shared
with the shader impostor path — one character, two projectors.

Renderer depth targets: createDepthTarget / createTarget{depthTexture}
for WebGL DepthTexture plumbing.

Compositor shared rig: one PerspectiveCamera + DepthTexture so a ground
mesh can occlude a subject mesh from another layer. 4/scale dolly,
Personality.camera drift/sway/spin, framing shift — matches particles.js
and shader epilogue behaviour. ModelLayer (kind:model) with
build/update(actorSpec) and sharedCamera injection; createLayer dispatches
on model. Shader contract gains MODEL_PREAMBLE.

LookGenerator now derives actors before scenes; ArcDriver._actorFor +
_layerFor wires ActorSpec into ModelLayer; schema validates kind:model
and actor archetype; lint determinism gate covers actors/.

Gate: lint 107 files clean, 70 shader literals, 68 scenes green; vite
build 294 modules; ActorGenerator determinism + mesh smoke tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-20 16:26:15 +02:00

833 lines
37 KiB
JavaScript

import { createLayer } from '../engine/Layer.js';
import { Rng } from '../engine/rng.js';
import { clampValue } from '../params/schema.js';
import { paletteShiftAt } from './paletteArc.js';
import { shiftPalette, lerpPalettes } from './palette.js';
import { frameShot, neutralFraming } from './framing.js';
import { planGaze, gazeAt } from './Camera.js';
import { storyStateAt, NEUTRAL_STATE } from './Story.js';
import { subjectIndexOf, isGround } from './stack.js';
import { groundPersonalityFrom } from '../scenes/surface.js';
import { derivePalettePlan, directorByName } from './directors.js';
/**
* Drives the look across the song.
*
* Four timescales are stacked here, and it takes all four to keep six minutes
* from reading as a loop:
*
* per frame — reactive mappings (handled in Layer, from the feature row)
* per shot — cuts between the section's stage visuals, on phrase lines
* per section — seeded LFO drift, so nothing sits still during a long sustain
* whole song — scene changes at real boundaries, plus lookahead ramps that
* build INTO a drop rather than reacting after it lands
*
* The shot level is what stops a ninety-second sustain from being one held
* image. Everything below works in CUES — a flat list of (section, shot) spans
* built from the look, so a shot cut and a section change take exactly the same
* code path and differ only in how long the transition is. See look/shots.js.
*
* Layer instances are created once per (section, variant) and reused across
* every shot that shows that variant. Rebuilding them per shot would recompile
* shaders at every cut, which is the obvious way to make this unusably slow.
*/
export class ArcDriver {
constructor(look, track, { crossfadeBars = 1, driftAmount = 0.09 } = {}) {
this.look = look;
this.track = track;
this.driftAmount = driftAmount;
const barSeconds = (track.tempo.period * track.tempo.beatsPerBar) / track.fps;
this.crossfadeFrames = Math.max(12, Math.round(barSeconds * crossfadeBars * track.fps));
this.barFrames = Math.max(1, Math.round(barSeconds * track.fps));
this.layerCache = new Map();
this.driftPlans = new Map();
this.activeLayers = [];
this.cues = this._buildCues();
this.framingStyle = (look.framing && look.framing.mode !== 'locked')
? look.framing : null;
this._planFraming();
this._planGaze();
this._planPalette();
this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null };
}
/**
* Give every cue the framing its shot will be played with.
*
* Framing is the one device that lives at the SHOT level — a wide answered
* by a close is the whole point of it, and both belong to the same scene.
* Planned here, once, walking the cues in order, so the whole video gets a
* consistent hand and a seek always finds the same framing as playback.
* A track that decided to stay locked-off (or a hand-built look with no
* framing) gets the neutral, unframed read everywhere.
*/
_planFraming() {
if (!this.framingStyle) return;
const rng = new Rng((this.look.seed ^ 0x517cc1b7) >>> 0);
let previous = null;
for (const cue of this.cues) {
const section = this.look.sections[cue.sectionIndex];
const energy = (section.bias && section.bias.energy) || 0;
// 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);
}
/**
* Plan which palette is on screen for each cue.
*
* The director picks the progression; the palette set determines how many
* choices there are. Stored on the look so it survives serialisation and so
* the HUD can describe it.
*/
_planPalette() {
const director = directorByName(this.look.director);
const palettes = this.look.palettes || [this.look.palette];
const rng = new Rng((this.look.seed ^ 0x7a1b3c9d) >>> 0);
this.look.palettePlan = derivePalettePlan(
this.cues, this.look.sections, director, rng, palettes);
}
/** 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();
}
/**
* Flatten the look into cues: one per shot, in playback order.
*
* A look generated before shots existed (or hand-built by a check) has no
* `shots` array; it degrades to exactly one cue per section, which is the
* old behaviour.
*/
_buildCues() {
const cues = [];
this.look.sections.forEach((section, sectionIndex) => {
const shots = (section.shots && section.shots.length) ? section.shots : [{
startFrame: section.startFrame, endFrame: section.endFrame,
variant: 0, hardCut: false,
}];
const energy = (section.bias && section.bias.energy) || 0;
shots.forEach((shot, shotIndex) => {
const atSectionStart = shotIndex === 0;
const span = shot.endFrame - shot.startFrame;
cues.push({
index: cues.length,
sectionIndex,
shotIndex,
variant: shot.variant || 0,
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),
});
});
});
return cues;
}
/**
* How long a dissolve takes: the default transition, and deliberately slow.
*
* Two bars on calm material, one on loud — a long dissolve between two
* quiet scenes reads as the image evolving, while the same length under a
* drop reads as mush, because both images are moving too fast to overlay.
* Capped at 40% of the incoming shot so a transition never occupies most of
* the shot it is transitioning into.
*/
_dissolveFrames(energy, spanFrames) {
const bars = energy > 0.6 ? 1 : 2;
const wanted = Math.max(this.crossfadeFrames, this.barFrames * bars);
return Math.max(12, Math.round(Math.min(wanted, spanFrames * 0.4)));
}
/** Cue covering a frame. Binary search — a seek can land anywhere. */
_cueIndexAt(frame) {
const cues = this.cues;
let lo = 0;
let hi = cues.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (cues[mid].startFrame <= frame) lo = mid; else hi = mid - 1;
}
return lo;
}
/** The cue on screen at a frame. For the UI and the checks. */
cueAt(frame) {
return this.cues[this._cueIndexAt(frame)];
}
_specFor(sectionIndex, variant, slot) {
const section = this.look.sections[sectionIndex];
const stack = (section.variants && section.variants[variant]) || section.layers;
return stack[slot] || null;
}
/** Resolve the ActorSpec for a layer, if the module requests one. */
_actorFor(module) {
const actors = this.look.actors;
if (!actors || !module || !module.actor) return null;
return actors[module.actor] || null;
}
/** One Layer per (section, variant, layer slot), built lazily and kept. */
_layerFor(sectionIndex, variant, slot = 0) {
const key = `${sectionIndex}:${variant}:${slot}`;
let layer = this.layerCache.get(key);
if (!layer) {
const spec = this._specFor(sectionIndex, variant, slot);
const actorSpec = this._actorFor(spec.module);
layer = createLayer(spec.module, {
params: spec.params,
seed: spec.seed,
opacity: spec.opacity,
blend: spec.blend,
actorSpec,
});
layer.setPalette(this.look.palette);
layer.setPersonality(this.look.personality);
// ModelLayers can have their actor swapped without being rebuilt — the
// mesh is imposter-free so the geometry can be re-bound live.
if (actorSpec && layer.setActor) layer.setActor(actorSpec);
this.layerCache.set(key, layer);
}
return layer;
}
/**
* Per-param LFO plan for a section: amplitude, period and phase, all seeded.
* Slow enough to read as evolution rather than wobble — 20 to 70 seconds.
*/
_driftPlan(sectionIndex, variant, slot = 0) {
const key = `${sectionIndex}:${variant}:${slot}`;
let plan = this.driftPlans.get(key);
if (plan) return plan;
const spec = this._specFor(sectionIndex, variant, slot);
const rng = new Rng(spec.seed ^ 0x5bf03635);
plan = [];
for (const [name, def] of Object.entries(spec.module.params || {})) {
if (def.type === 'palette' || def.type === 'bool' || def.fixed) continue;
if (def.noDrift || def.rate) continue; // see schema.js RATE_FLAG
const [lo, hi] = def.range || [0, 1];
plan.push({
name,
def,
amplitude: (hi - lo) * this.driftAmount * rng.range(0.4, 1.3),
period: rng.range(20, 70),
phase: rng.next(),
});
}
this.driftPlans.set(key, plan);
return plan;
}
/**
* The scene's SLOW AXIS: one or two params that travel one way across the
* whole track.
*
* Drift above is an LFO with a 20-70 second period, and an LFO returns.
* Measured over the library, that is exactly what several scenes' problem
* was: they change as much in half a second as in two minutes, because
* everything moving in them is cyclic, so the eye adapts in about two
* seconds and then there is nothing left to find. Violently animated and
* read as static. Ten cycles of a 30-second wobble is not five minutes of
* anything.
*
* So this is deliberately monotonic. Where drift is the wobble, this is the
* journey: the frame at four minutes has a different STRUCTURE — density,
* scale, count — from the frame at thirty seconds, and no amount of
* per-frame reactivity substitutes for that.
*
* Keyed on the module rather than on the section, so a scene that comes back
* in the last section arrives further along its own axis rather than
* resetting. Rate params are excluded for the reason schema.js gives: they
* multiply absolute time, so moving one jumps the phase.
*/
_slowAxisFor(module) {
if (!this._slowAxes) this._slowAxes = new Map();
const cached = this._slowAxes.get(module.name);
if (cached) return cached;
// Stable per (track, scene): the same scene evolves the same way
// wherever it appears in this video, and differently in the next one.
let h = (this.look.seed || 1) >>> 0;
for (let i = 0; i < module.name.length; i++) {
h = (Math.imul(h ^ module.name.charCodeAt(i), 0x01000193) >>> 0);
}
const rng = new Rng(h);
const eligible = Object.entries(module.params || {}).filter(([, def]) =>
def.type !== 'palette' && def.type !== 'bool' && !def.fixed
&& !def.rate && !def.noDrift && def.range);
// A param the scene DECLARES as its axis wins outright, and travels
// much further than a guessed one.
//
// The first version of this picked at random from everything eligible
// and measured as doing nothing whatsoever: the time-averaged image at
// thirty seconds and at two and a half minutes differed by the same
// amount with the axis applied as without it. The reason is that which
// param you move decides everything. Sweeping Moiré Grid's `width`
// moves its averaged structure by 0.110 and its `offset` by 0.002, and
// 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) {
const [lo, hi] = def.range;
axis.push({
name,
def,
declared: true,
// 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) * sign,
});
}
} else {
// Nothing declared: fall back to a guess. Worth keeping — it costs
// nothing and occasionally lands on something structural — but it is
// not what makes this mechanism work, and no gate should rely on it.
const count = Math.min(eligible.length, rng.bool(0.45) ? 2 : 1);
const pool = rng.shuffle(eligible.slice());
for (let i = 0; i < count; i++) {
const [name, def] = pool[i];
const [lo, hi] = def.range;
axis.push({
name,
def,
declared: false,
travel: (hi - lo) * rng.range(0.25, 0.5) * sign,
});
}
}
this._slowAxes.set(module.name, axis);
return axis;
}
/**
* 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, story = null) {
const spec = this._specFor(cue.sectionIndex, cue.variant, slot);
const out = { ...spec.params };
// --- slow axis ------------------------------------------------------
// 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;
out[item.name] = clampValue(item.def, base + item.travel * (journey - 0.5));
}
for (const item of this._driftPlan(cue.sectionIndex, cue.variant, slot)) {
const base = out[item.name];
if (typeof base !== 'number') continue;
const wave = Math.sin(2 * Math.PI * (time / item.period + item.phase));
out[item.name] = clampValue(item.def, base + wave * item.amplitude);
}
// --- lookahead ------------------------------------------------------
// buildSlope rises through the bars before a higher-energy section. This
// is the payoff of analysing offline: the visuals arrive at the drop
// already at tension instead of catching up afterwards.
const slope = features ? features.buildSlope || 0 : 0;
if (slope > 0.001) {
const nextCue = this.cues[cue.index + 1];
const next = nextCue
? this._specFor(nextCue.sectionIndex, nextCue.variant, slot)
: null;
if (next && next.module === spec.module) {
// Same scene either side: ramp the actual target values.
const target = next.params;
for (const [name, def] of Object.entries(spec.module.params || {})) {
if (def.type === 'palette' || typeof out[name] !== 'number') continue;
if (typeof target[name] !== 'number') continue;
out[name] = clampValue(def, out[name] + (target[name] - out[name]) * slope);
}
} else {
// Different scene: push the intensity-ish params toward the top
// of their range so the build still reads as a build.
for (const [name, def] of Object.entries(spec.module.params || {})) {
if (typeof out[name] !== 'number') continue;
if (def.bias !== 'energy' && def.bias !== 'density') continue;
const hi = (def.range || [0, 1])[1];
out[name] = clampValue(def, out[name] + (hi - out[name]) * slope * 0.5);
}
}
}
return out;
}
/**
* The buildSlope value on the frame before a boundary. Read from the table
* rather than remembered, so a seek and playback agree.
*
* Indexes the typed array DIRECTLY rather than calling track.at(). at()
* returns a single reused row object, so calling it here — mid-render, while
* the caller is still holding the row for the current frame — silently
* rewrites the features the layer is about to read. That produced a render
* that was correct on every repeat but wrong the first time through, which is
* exactly the kind of fault the determinism checks exist to surface.
*/
_boundarySlope(cue) {
if (!this._slopeCache) this._slopeCache = new Map();
if (this._slopeCache.has(cue.index)) return this._slopeCache.get(cue.index);
const frame = Math.max(0, cue.startFrame - 1);
const value = this.track.tracks.buildSlope[frame] || 0;
this._slopeCache.set(cue.index, value);
return value;
}
/**
* Compute the active layer stack for a frame.
*
* The transition runs FORWARD from a cue: the outgoing image holds at full
* opacity while the incoming one fades in over it. That keeps the cue frame
* itself a clean state, which is what makes a boundary seek exact without
* warm-up. A hard cut is the same path with a two-frame fade — it is still
* a ramp rather than a jump, because a single-frame swap of two bright
* scenes is a flash, and the flash meter is not decorative.
*/
/**
* The track's palette, moved to where this frame sits in the arc and in the
* palette plan.
*
* Two levels: the director's coarse choice of which base palette is on screen
* for this cue (from `look.palettes` / `look.palettePlan`), plus the fine
* `paletteArc` drift inside that palette. Blends between two palettes in
* OKLCH when the plan's transition is `blend`, timed to the cue's own
* crossfade so colour and image move together.
*
* Recomputed once per frame and memoised on the rounded state.
*/
_paletteAt(frame, features, story) {
const arc = this.look.paletteArc;
const plan = this.look.palettePlan;
const palettes = this.look.palettes || [this.look.palette];
const cueIndex = this._cueIndexAt(frame);
const cue = this.cues[cueIndex] || null;
// Coarse palette for this cue (fallback to the single palette when no plan)
let baseIndex = 0;
if (plan && plan.cues && plan.cues.length > cueIndex) {
baseIndex = plan.cues[cueIndex];
}
baseIndex = Math.max(0, Math.min(palettes.length - 1, baseIndex | 0));
// During a blend transition, lerp from the previous cue's palette to this
// one's over the cue's fadeFrames, in OKLCH so hue travel stays perceptual.
let base = palettes[baseIndex] || palettes[0];
let blendT = 0;
let blendFrom = -1;
if (plan && plan.transition === 'blend' && cue && cueIndex > 0) {
const prevIndex = plan.cues[cueIndex - 1];
if (prevIndex !== baseIndex) {
const into = frame - cue.startFrame;
if (into >= 0 && into < cue.fadeFrames) {
const t = into / Math.max(1, cue.fadeFrames);
blendT = t * t * (3 - 2 * t);
blendFrom = prevIndex;
const a = palettes[prevIndex] || palettes[0];
const b = base;
base = lerpPalettes(a, b, blendT);
}
}
}
if (!arc || arc.mode === 'static') {
// Still memoise so a seek returns the same object identity for the
// compositor's layer-change check, but key off palette selection too.
const key = `p${baseIndex}|f${blendFrom}:${blendT.toFixed(3)}`;
if (this._paletteKey === key) return this._palette;
this._paletteKey = key;
this._palette = base;
return this._palette;
}
const shift = paletteShiftAt(arc, {
progress: frame / Math.max(1, this.track.frameCount - 1),
sectionKind: this.track.sectionAt(frame).kind,
features,
story,
});
const key = `p${baseIndex}|f${blendFrom}:${blendT.toFixed(3)}|${shift.hue.toFixed(3)}|${shift.chroma.toFixed(3)}|${shift.lightness.toFixed(3)}`;
if (this._paletteKey === key) return this._palette;
this._paletteKey = key;
this._palette = shiftPalette(base, shift);
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;
// 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;
const section = this.look.sections[cue.sectionIndex];
const framesIntoCue = frame - cue.startFrame;
const previous = cueIndex > 0 ? this.cues[cueIndex - 1] : null;
const fading = !!previous && framesIntoCue < cue.fadeFrames;
const t = fading ? framesIntoCue / cue.fadeFrames : 1;
const eased = t * t * (3 - 2 * t);
// 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.
//
// 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 = [];
if (fading) {
// buildSlope is discontinuous at a section boundary by construction:
// it ramps to ~1 through the bars before the change and is 0
// immediately after. The outgoing layer is still on screen when that
// happens, so feeding it the new section's features collapses its
// lookahead ramp in a single frame — a visible pop precisely at the
// transition. Hold the slope it had going into the boundary; it
// finished its build, and it stays there while it fades out. Within a
// section the slope is continuous, so the live value is correct there.
const outgoingFeatures = cue.atSectionStart
? { ...features, buildSlope: this._boundarySlope(cue) }
: features;
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, story));
layer.opacity = slot === 0 ? 1 : spec.opacity;
layer.blend = slot === 0 ? 'normal' : spec.blend;
layer.setPalette(palette);
layer.setPersonality(isGround(spec) ? groundPersonalityFrom(personality) : personality);
layer.setFraming(outgoingFraming);
layers.push(layer);
}
}
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, story));
layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1);
layer.blend = slot === 0 ? 'normal' : spec.blend;
layer.setPalette(palette);
layer.setPersonality(isGround(spec) ? groundPersonalityFrom(personality) : personality);
layer.setFraming(framing);
layers.push(layer);
}
this.state = {
sectionIndex: cue.sectionIndex,
shotIndex: cue.shotIndex,
shotCount: section.shots ? section.shots.length : 1,
variant: cue.variant,
kind: section.kind,
crossfade: fading ? eased : 0,
// The SHOT's name, not the ground's. Every stack starts with a bed
// now, and the HUD naming it would report the same handful of
// canvases for every section of every video.
sceneName: this._specFor(
cue.sectionIndex, cue.variant, this._subjectSlot(cue)).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;
return layers;
}
/** Which slot of a cue's stack is the shot. See look/stack.js. */
_subjectSlot(cue) {
return subjectIndexOf(this._stackFor(cue));
}
_stackFor(cue) {
const section = this.look.sections[cue.sectionIndex];
return (section.variants && section.variants[cue.variant]) || section.layers;
}
_stackSize(cue) {
return this._stackFor(cue).length;
}
/** Layers changed identity — the compositor needs the new list. */
layersChanged(previous) {
if (!previous || previous.length !== this.activeLayers.length) return true;
return this.activeLayers.some((l, i) => l !== previous[i]);
}
/** Invalidate caches for one section after an edit or reroll. */
invalidateSection(sectionIndex) {
for (const key of [...this.layerCache.keys()]) {
if (key.startsWith(`${sectionIndex}:`)) {
this.layerCache.get(key).dispose();
this.layerCache.delete(key);
this.driftPlans.delete(key);
}
}
// A reroll re-plans the section's shots, so the cue list is stale too.
this.cues = this._buildCues();
this._planFraming();
this._planGaze();
this._planPalette();
this._paletteKey = null;
this._slopeCache = null;
}
invalidateAll() {
this.dispose();
this.driftPlans.clear();
this.cues = this._buildCues();
this._planFraming();
this._planGaze();
this._planPalette();
this._paletteKey = null;
this._slopeCache = null;
}
/**
* Create and compile every layer the look will ever show.
*
* Layers are otherwise built on first use, which means a shader compile on
* the frame of a cut — a visible hitch, and there are now many more cuts
* than there were sections. Paying for all of them once at load is cheaper
* than paying for one at every transition.
*/
prewarm(onLayer = null) {
for (const cue of this.cues) {
for (let slot = 0; slot < this._stackSize(cue); slot++) {
const layer = this._layerFor(cue.sectionIndex, cue.variant, slot);
if (onLayer) onLayer(layer);
}
}
return this;
}
/**
* Which palette of the set is on screen for a frame. Mirrors the coarse
* selection in _paletteAt without the OKLCH lerp or the fine shift — what
* the panel highlights as "active".
*/
paletteIndexAt(frame) {
const plan = this.look.palettePlan;
const palettes = this.look.palettes || [this.look.palette];
if (!plan || !plan.cues || !plan.cues.length) return 0;
const cueIndex = this._cueIndexAt(frame);
let idx = plan.cues[cueIndex];
if (idx === undefined) idx = plan.cues[plan.cues.length - 1] || 0;
return Math.max(0, Math.min(palettes.length - 1, idx | 0));
}
/**
* Whether a frame is inside a palette blend window.
* Returns {from,to,t} while the two palettes are interpolating, else null.
*/
paletteBlendAt(frame) {
const plan = this.look.palettePlan;
if (!plan || plan.transition !== 'blend') return null;
const cueIndex = this._cueIndexAt(frame);
if (cueIndex === 0) return null;
const cue = this.cues[cueIndex];
if (!cue) return null;
const cur = plan.cues[cueIndex];
const prev = plan.cues[cueIndex - 1];
if (cur === prev) return null;
const into = frame - cue.startFrame;
if (into >= 0 && into < cue.fadeFrames) {
return { from: prev, to: cur, t: into / Math.max(1, cue.fadeFrames) };
}
return null;
}
/** Push a palette change through without rebuilding layers. */
setPalette(palette) {
this.look.palette = palette;
// Keep the full set's alias in sync — `look.palette` is always palettes[0].
if (this.look.palettes && this.look.palettes.length) {
this.look.palettes[0] = palette;
}
// The moved palette is memoised on the SHIFT, so a new base palette at
// an unchanged point in the arc would otherwise keep serving the old
// colours until the arc happened to move.
this._paletteKey = null;
for (const layer of this.layerCache.values()) layer.setPalette(palette);
}
}