music-video-gen/flow-state/src/look/paletteArc.js
Dejvino acc33cedd7 Epic 2.3: let the colour move across a track
The palette was generated once and pushed to every layer of every section for
the whole runtime. Five minutes, one scheme, no movement — and colour is the
strongest perceptual variable the system has, so freezing it wasted the
biggest lever available for making a long video feel like it is going
somewhere.

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. Four modes: static
(one in six or so, a held colour is a legitimate choice), drift (slow hue
travel across the whole track), sections (each kind gets its own offset, so
the colour tells you where you are), and lift (saturation and lightness
rising into a drop).

Movement happens in OKLCH, which needed the inverse of the existing
conversion: rotating hue in RGB changes brightness as a side effect, and that
artefact is the reason this project picked OKLCH in the first place. The
rotation is applied to every colour equally, so the scheme and the spread
that made the palette a palette survive the move.

Bounded on purpose at ±34° hue, ±35% saturation, ±0.07 lightness. A full
rotation would destroy the identity as surely as a new palette; the movement
has to be the kind you notice on a rewatch, not the kind you notice as an
effect.

Four new Phase 11 checks, two of which are the counter-checks that keep this
honest. Colour must actually move (weakest 0.232 channel distance across 18
moving tracks) AND must stay inside its identity. The contrast floor is
asserted at 1620 sampled points across the movement rather than at the two
ends, because a saturation lift can flatten a perfectly good palette
somewhere in the middle. The moved palette is memoised on a rounded shift,
and a fourth check proves a seeked frame gets bit-identical colours to a
played one rather than merely similar — that would have been an export-only
determinism fault.

47/48 on phases 7-11 slow; the one failure is the known load-dependent
Horizon Lines flake already recorded in d14d473. All determinism and
preview/export-agreement checks pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 07:45:12 +02:00

118 lines
5.2 KiB
JavaScript

// 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)}°`;
}