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); } }