From acc33cedd78b49ea967bd0bc998ca13bbf6fe21a Mon Sep 17 00:00:00 2001 From: Dejvino Date: Thu, 6 Aug 2026 07:45:12 +0200 Subject: [PATCH] Epic 2.3: let the colour move across a track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- flow-state/src/checks/phase11.js | 139 +++++++++++++++++++++++++++ flow-state/src/look/ArcDriver.js | 39 +++++++- flow-state/src/look/LookGenerator.js | 5 + flow-state/src/look/palette.js | 48 +++++++++ flow-state/src/look/paletteArc.js | 117 ++++++++++++++++++++++ 5 files changed, 346 insertions(+), 2 deletions(-) create mode 100644 flow-state/src/look/paletteArc.js diff --git a/flow-state/src/checks/phase11.js b/flow-state/src/checks/phase11.js index a8a8fda..6e1e75e 100644 --- a/flow-state/src/checks/phase11.js +++ b/flow-state/src/checks/phase11.js @@ -16,6 +16,9 @@ import { synthesizeSectioned } from '../audio/synth.js'; import { MIN_SHOT_SECONDS, MAX_SHOT_SECONDS } from '../look/shots.js'; import { DIRECTORS, RESTFUL_FAMILIES, pickDirector } from '../look/directors.js'; import { scenes, scenesInFamily } from '../scenes/registry.js'; +import { paletteShiftAt, MAX_HUE_ROTATION } from '../look/paletteArc.js'; +import { shiftPalette, paletteContrast } from '../look/palette.js'; +import { ArcDriver } from '../look/ArcDriver.js'; /** Tracks at several tempos: shot length is measured in bars, so tempo is the axis. */ let cached = null; @@ -296,3 +299,139 @@ check(11, 'a track shows more of the library than it used to', () => { return expect(cast.size >= pool * 0.4, `${cast.size}/${pool} scenes cast across 12 tracks (floor ${Math.ceil(pool * 0.4)})`); }); + +// --- colour movement ------------------------------------------------------- +// EPIC-2.md §3.3. The palette was generated once and pushed to every layer of +// every section for the whole runtime — five minutes, one scheme, no movement, +// with colour being the strongest perceptual variable available. + +check(11, 'the palette moves across a track', () => { + // The complaint as a number. Sample the arc from first frame to last and + // require the colours to actually end up somewhere else. + const moved = []; + const held = []; + + for (const { track: t } of tempoBattery()) { + for (let s = 0; s < 8; s++) { + const look = generateLook(t, { seed: 7700 + s * 6841 }); + const arc = look.paletteArc; + let worst = 0; + for (const kind of ['intro', 'drop', 'breakdown', 'outro']) { + for (const p of [0, 0.5, 1]) { + const shift = paletteShiftAt(arc, { + progress: p, sectionKind: kind, + features: { sectionEnergy: p, buildSlope: 0 }, + }); + const shifted = shiftPalette(look.palette, shift); + for (let i = 0; i < look.palette.length; i++) { + const a = look.palette[i], b = shifted[i]; + worst = Math.max(worst, Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2])); + } + } + } + (arc.mode === 'static' ? held : moved).push(worst); + } + } + + const weak = moved.filter((d) => d < 0.03).length; + const ratio = moved.length / (moved.length + held.length); + return expect(weak === 0 && ratio > 0.6, + `${moved.length} moving / ${held.length} held · weakest move ` + + `${(moved.length ? Math.min(...moved) : 0).toFixed(3)} channel distance (floor 0.03)`); +}); + +check(11, 'colour movement never breaks the contrast floor', () => { + // The Phase 3 palette gate proves the palette is usable when it is + // generated. That says nothing about where the arc takes it — a saturation + // lift or a lightness shift can flatten a perfectly good set. So the floor + // is asserted at every sampled point of the movement rather than only at + // the two ends, which is where this would otherwise be silently violated. + const problems = []; + let sampled = 0; + + for (const { track: t } of tempoBattery()) { + for (let s = 0; s < 6; s++) { + const look = generateLook(t, { seed: 8800 + s * 15485863 }); + const base = paletteContrast(look.palette).luminanceSpread; + for (const kind of ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro']) { + for (let p = 0; p <= 1.001; p += 0.25) { + for (const energy of [0, 0.5, 1]) { + sampled++; + const shift = paletteShiftAt(look.paletteArc, { + progress: p, sectionKind: kind, + features: { sectionEnergy: energy, buildSlope: energy }, + }); + const spread = paletteContrast( + shiftPalette(look.palette, shift)).luminanceSpread; + // Held against the palette's OWN spread, not an absolute: + // the arc must not degrade what generation achieved. + if (spread < Math.min(0.18, base * 0.75)) { + problems.push(`seed ${s} ${kind}@${p.toFixed(2)}/e${energy}: ` + + `spread ${spread.toFixed(3)} from base ${base.toFixed(3)}`); + } + } + } + } + } + } + + return expect(problems.length === 0, + problems.length ? `${problems.length} of ${sampled} points muddy — ${problems[0]}` + : `${sampled} points across the movement all clear the contrast floor`); +}); + +check(11, 'colour movement stays inside its identity', () => { + // The counter-check. A palette that moves far enough is a different + // palette, and then the track has no colour identity at all — which would + // pass the movement gate above with room to spare. + const problems = []; + for (const { track: t } of tempoBattery()) { + for (let s = 0; s < 8; s++) { + const look = generateLook(t, { seed: 9900 + s * 7919 }); + for (const kind of ['intro', 'drop', 'breakdown']) { + for (const p of [0, 0.5, 1]) { + const shift = paletteShiftAt(look.paletteArc, { + progress: p, sectionKind: kind, + features: { sectionEnergy: p, buildSlope: p }, + }); + if (Math.abs(shift.hue) > MAX_HUE_ROTATION + 1e-6) { + problems.push(`seed ${s}: hue ${shift.hue.toFixed(2)} past the ceiling`); + } + } + } + } + } + return expect(problems.length === 0, + problems.length ? problems[0] + : `hue travel stays within ±${(MAX_HUE_ROTATION * 57.3).toFixed(0)}° of the track's palette`); +}); + +check(11, 'a moved palette is the same on a seek as on playback', () => { + // The movement is memoised on a rounded shift. Rounding is what keeps a + // seeked frame bit-identical to a played one rather than merely close, and + // getting that wrong would be a determinism fault that only shows up in an + // export. + const t = tempoBattery()[1].track; + const look = generateLook(t, { seed: 4321 }); + const a = new ArcDriver(look, t); + const b = new ArcDriver(look, t); + + const probes = [0, 900, 4500, 9000, 12000]; + let worst = 0; + try { + // One driver plays through; the other jumps straight to each probe. + for (let f = 0; f <= 12000; f += 30) a.update(f, t.at(f)); + for (const f of probes) { + const seq = a._paletteAt(f, t.at(f)); + const jump = b._paletteAt(f, t.at(f)); + for (let i = 0; i < seq.length; i++) { + for (let c = 0; c < 3; c++) worst = Math.max(worst, Math.abs(seq[i][c] - jump[i][c])); + } + } + } finally { + a.dispose(); b.dispose(); + } + + return expect(worst === 0, + `worst channel difference between seeked and played colours: ${worst}`); +}); diff --git a/flow-state/src/look/ArcDriver.js b/flow-state/src/look/ArcDriver.js index b6cb63b..265ffae 100644 --- a/flow-state/src/look/ArcDriver.js +++ b/flow-state/src/look/ArcDriver.js @@ -1,6 +1,8 @@ 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 } from './palette.js'; /** * Drives the look across the song. @@ -244,8 +246,37 @@ export class ArcDriver { * 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. + * + * Recomputed once per frame rather than once per layer, and memoised on the + * rounded shift: the movement is slow by design, so consecutive frames + * almost always want the same colours and the OKLCH round trip is wasted + * work. Rounding also makes the cache key stable under a seek, which keeps + * the frame-exactness guarantee — a seeked frame gets bit-identical colours + * to a played one rather than merely similar ones. + */ + _paletteAt(frame, features) { + const arc = this.look.paletteArc; + if (!arc || arc.mode === 'static') return this.look.palette; + + const shift = paletteShiftAt(arc, { + progress: frame / Math.max(1, this.track.frameCount - 1), + sectionKind: this.track.sectionAt(frame).kind, + features, + }); + + const key = `${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(this.look.palette, shift); + return this._palette; + } + update(frame, features) { const time = frame / this.track.fps; + const palette = this._paletteAt(frame, features); const cueIndex = this._cueIndexAt(frame); const cue = this.cues[cueIndex]; if (!cue) return this.activeLayers; @@ -278,7 +309,7 @@ export class ArcDriver { layer.setParams(this._paramsAt(previous, slot, time, outgoingFeatures)); layer.opacity = slot === 0 ? 1 : spec.opacity; layer.blend = slot === 0 ? 'normal' : spec.blend; - layer.setPalette(this.look.palette); + layer.setPalette(palette); layer.setPersonality(this.look.personality); layers.push(layer); } @@ -290,7 +321,7 @@ export class ArcDriver { layer.setParams(this._paramsAt(cue, slot, time, features)); layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1); layer.blend = slot === 0 ? 'normal' : spec.blend; - layer.setPalette(this.look.palette); + layer.setPalette(palette); layer.setPersonality(this.look.personality); layers.push(layer); } @@ -364,6 +395,10 @@ export class ArcDriver { /** Push a palette change through without rebuilding layers. */ setPalette(palette) { this.look.palette = 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); } } diff --git a/flow-state/src/look/LookGenerator.js b/flow-state/src/look/LookGenerator.js index 6d51f24..0664e2d 100644 --- a/flow-state/src/look/LookGenerator.js +++ b/flow-state/src/look/LookGenerator.js @@ -12,6 +12,7 @@ import { planShots } from './shots.js'; import { generatePersonality, sceneHonours, describePersonality } from './Personality.js'; import { deriveGrain, describeGrain, applyGrainToPost } from './grain.js'; import { pickDirector, directorByName } from './directors.js'; +import { derivePaletteArc, describePaletteArc } from './paletteArc.js'; // Which families suit which section kind now comes from the track's DIRECTOR // (look/directors.js) rather than from a constant here. The coupling it @@ -289,6 +290,8 @@ export function generateLook(track, { seed = null, samples = null, overrides = n // The grain treatment: usually none, and when present described rather than // dialled. See look/grain.js. const grain = deriveGrain(summary, rng.fork('grain')); + // How the palette MOVES over the track. See look/paletteArc.js. + const paletteArc = derivePaletteArc(summary, rng.fork('paletteArc')); const { post, feedback } = derivePost(summary, rng.fork('post'), grain); // Scenes that declare role 'accent' composite over a background rather than @@ -344,6 +347,7 @@ export function generateLook(track, { seed = null, samples = null, overrides = n personality, paletteScheme: paletteSource.lastScheme, director: director.name, + paletteArc, grain, post, feedback, @@ -423,6 +427,7 @@ export function describeLook(look) { const kinds = look.sections.map((s) => `${s.kind}:${s.layers[0].module.name}`); return `seed ${look.seed.toString(16)} · ${look.director} · ${look.paletteScheme} · ` + `${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` + + `${describePaletteArc(look.paletteArc)} · ` + `${[...new Set(kinds)].join(', ')}`; } diff --git a/flow-state/src/look/palette.js b/flow-state/src/look/palette.js index cbe864e..cfe5ee8 100644 --- a/flow-state/src/look/palette.js +++ b/flow-state/src/look/palette.js @@ -36,6 +36,54 @@ export function oklchToRgb(L, C, h) { return [gamma(lr), gamma(lg), gamma(lb)]; } +/** + * sRGB -> OKLCH, the exact inverse of the above. + * + * Needed because a palette is stored as RGB but has to be MOVED perceptually: + * rotating hue in RGB space changes brightness as a side effect, which is + * exactly the artefact OKLCH was chosen to avoid in the first place. See + * look/paletteArc.js. + */ +export function rgbToOklch([r, g, b]) { + const linear = (x) => { + const v = Math.max(0, Math.min(1, x)); + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }; + const R = linear(r), G = linear(g), B = linear(b); + + const l = 0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B; + const m = 0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B; + const s = 0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B; + + const l_ = Math.cbrt(l), m_ = Math.cbrt(m), s_ = Math.cbrt(s); + + const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_; + const a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_; + const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_; + + return [L, Math.hypot(a, bb), Math.atan2(bb, a)]; +} + +/** + * Move a whole palette in OKLCH: rotate hue, scale chroma, offset lightness. + * + * The rotation is applied to every colour equally, so the relationships that + * made the palette a palette — its scheme, its spread — survive the move. A + * per-colour rotation would be a different palette rather than the same one + * somewhere else. + */ +export function shiftPalette(colors, { hue = 0, chroma = 1, lightness = 0 } = {}) { + if (!hue && chroma === 1 && !lightness) return colors; + return colors.map((c) => { + const [L, C, h] = rgbToOklch(c); + return oklchToRgb( + Math.max(0, Math.min(1, L + lightness)), + Math.max(0, C * chroma), + h + hue, + ); + }); +} + export function relativeLuminance([r, g, b]) { return 0.2126 * r + 0.7152 * g + 0.0722 * b; } diff --git a/flow-state/src/look/paletteArc.js b/flow-state/src/look/paletteArc.js new file mode 100644 index 0000000..f1ca877 --- /dev/null +++ b/flow-state/src/look/paletteArc.js @@ -0,0 +1,117 @@ +// 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)}°`; +}