// Colour movement across a track. // // The palette was generated once and pushed to every layer of every section for // the entire runtime. Five minutes, one colour scheme, no movement — and colour // is the strongest perceptual variable the system has, so freezing it wastes // the biggest lever available for making a long video feel like it is going // somewhere. // // The fix is deliberately NOT "a new palette per section". A track has one // identity and the palette is most of it; replacing it mid-video reads as a // different video. What moves is the palette itself — rotated, warmed, opened // up — so at four minutes the image is somewhere the first minute implied. // // Everything here is bounded on purpose. A full hue rotation would destroy the // identity as surely as a new palette; the movement has to be the kind you // notice on a rewatch rather than the kind you notice as an effect. /** * Ceilings on the whole mechanism. Nothing downstream may exceed these, and the * Phase 11 gate samples the movement against the palette contrast floor rather * than trusting them. */ export const MAX_HUE_ROTATION = 0.6; // radians, ~34 degrees export const MAX_CHROMA_SCALE = 0.35; // ±35% saturation export const MAX_LIGHT_SHIFT = 0.07; // OKLCH lightness export const ARC_MODES = ['static', 'drift', 'sections', 'lift']; /** * How this track's colour moves. * * 'static' stays rare. This layer exists because nothing moved, and a library * where most tracks still do not move would not have fixed anything — but a * track whose colour holds is a legitimate choice and one in six or so gets it. */ export function derivePaletteArc(summary, rng) { const mode = rng.pickWeighted(ARC_MODES, [1.5, 3, 3, 2.5]); const dir = rng.bool() ? 1 : -1; return { mode, // Total hue travel from the first frame to the last, for 'drift'. hueTravel: dir * rng.range(0.25, MAX_HUE_ROTATION), // Per-section-kind offsets, for 'sections': drops consistently warmer // or cooler than breakdowns, so the colour tells you where you are. kindHue: { intro: rng.range(-0.2, 0.2), build: rng.range(-0.3, 0.3), drop: dir * rng.range(0.2, MAX_HUE_ROTATION), sustain: rng.range(-0.25, 0.25), breakdown: -dir * rng.range(0.1, 0.4), outro: rng.range(-0.3, 0.3), }, // For 'lift': how much energy opens the colour up. Saturation rising // into a drop is the single most legible colour gesture available. chromaLift: rng.range(0.12, MAX_CHROMA_SCALE), lightLift: rng.range(0.02, MAX_LIGHT_SHIFT), // Every mode carries a little of the slow drift underneath, so even a // 'sections' track is not the same colour at the end as at the start. underDrift: dir * rng.range(0.05, 0.2), }; } /** * The colour shift for one frame, as arguments to palette.shiftPalette. * * Deterministic in progress and features only — no state, no random source — * the same rule the rest of the render path follows. * * @param {object} arc from derivePaletteArc * @param {object} ctx * @param {number} ctx.progress 0..1 through the track * @param {string} ctx.sectionKind kind of the section this frame is in * @param {object} ctx.features FeatureTrack row */ export function paletteShiftAt(arc, { progress = 0, sectionKind = '', features = null } = {}) { if (!arc) return { hue: 0, chroma: 1, lightness: 0 }; // The slow underlying travel, present in every mode. Eased rather than // linear so the move is least visible at the ends, where a cut to the // opening image would otherwise expose it. const eased = progress * progress * (3 - 2 * progress); let hue = arc.underDrift * eased; let chroma = 1; let lightness = 0; if (arc.mode === 'drift') { hue = arc.hueTravel * eased; } else if (arc.mode === 'sections') { // Held per section rather than ramped: the colour changing AT the cut // is the point, and a ramp would smear it into nothing. hue += arc.kindHue[sectionKind] ?? 0; } else if (arc.mode === 'lift') { const energy = features ? (features.sectionEnergy ?? 0) : 0; const build = features ? (features.buildSlope ?? 0) : 0; const drive = Math.min(1, energy * 0.7 + build * 0.6); chroma = 1 + arc.chromaLift * (drive * 2 - 1); lightness = arc.lightLift * (drive - 0.35); hue += arc.kindHue[sectionKind] * 0.4 || 0; } return { hue: clamp(hue, -MAX_HUE_ROTATION, MAX_HUE_ROTATION), chroma: clamp(chroma, 1 - MAX_CHROMA_SCALE, 1 + MAX_CHROMA_SCALE), lightness: clamp(lightness, -MAX_LIGHT_SHIFT, MAX_LIGHT_SHIFT), }; } function clamp(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); } /** One line for the HUD and check output. */ export function describePaletteArc(arc) { if (!arc || arc.mode === 'static') return 'colour: held'; if (arc.mode === 'drift') return `colour: drift ${(arc.hueTravel * 57.3).toFixed(0)}°`; if (arc.mode === 'lift') return `colour: lift ±${(arc.chromaLift * 100).toFixed(0)}% sat`; return `colour: per-section ±${(Math.max(...Object.values(arc.kindHue).map(Math.abs)) * 57.3).toFixed(0)}°`; }