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