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>
833 lines
37 KiB
JavaScript
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);
|
|
}
|
|
}
|