diff --git a/flow-state/src/Show.js b/flow-state/src/Show.js index 8bdbb0c..df17211 100644 --- a/flow-state/src/Show.js +++ b/flow-state/src/Show.js @@ -125,6 +125,9 @@ export class Show { setPalette(palette) { this.look.palette = palette; + if (this.look.palettes && this.look.palettes.length) { + this.look.palettes[0] = palette; + } this.arc.setPalette(palette); this.osd.setPalette(palette); } diff --git a/flow-state/src/look/ArcDriver.js b/flow-state/src/look/ArcDriver.js index edec234..0cb25f3 100644 --- a/flow-state/src/look/ArcDriver.js +++ b/flow-state/src/look/ArcDriver.js @@ -2,12 +2,13 @@ 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'; +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. @@ -48,6 +49,7 @@ export class ArcDriver { ? look.framing : null; this._planFraming(); this._planGaze(); + this._planPalette(); this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null }; } @@ -96,6 +98,21 @@ export class ArcDriver { 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; @@ -450,18 +467,60 @@ export class ArcDriver { * scenes is a flash, and the flash meter is not decorative. */ /** - * The track's palette, moved to where this frame sits in the arc. + * The track's palette, moved to where this frame sits in the arc and in the + * palette plan. * - * 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. + * 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; - if (!arc || arc.mode === 'static') return this.look.palette; + 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), @@ -470,11 +529,11 @@ export class ArcDriver { story, }); - const key = `${shift.hue.toFixed(3)}|${shift.chroma.toFixed(3)}|${shift.lightness.toFixed(3)}`; + 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(this.look.palette, shift); + this._palette = shiftPalette(base, shift); return this._palette; } @@ -675,6 +734,8 @@ export class ArcDriver { this.cues = this._buildCues(); this._planFraming(); this._planGaze(); + this._planPalette(); + this._paletteKey = null; this._slopeCache = null; } @@ -684,6 +745,8 @@ export class ArcDriver { this.cues = this._buildCues(); this._planFraming(); this._planGaze(); + this._planPalette(); + this._paletteKey = null; this._slopeCache = null; } @@ -705,9 +768,49 @@ export class ArcDriver { 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. diff --git a/flow-state/src/look/LookGenerator.js b/flow-state/src/look/LookGenerator.js index 082a516..f008ef1 100644 --- a/flow-state/src/look/LookGenerator.js +++ b/flow-state/src/look/LookGenerator.js @@ -5,7 +5,7 @@ // the decoded audio, so a given file always renders the same video. import { Rng, hashSamples } from '../engine/rng.js'; -import { AudioPalette, generateUsablePalette } from './palette.js'; +import { AudioPalette, generateUsablePalette, rgbToOklch } from './palette.js'; import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.js'; import { sampleValues, defaultValues, canBackground } from '../params/schema.js'; import { @@ -86,6 +86,76 @@ function biasFor(section, summary, motion = null, story = null) { const clamp01 = (x) => Math.max(0, Math.min(1, x)); +/** + * Derive the set of palettes for this track. The director's palette.count + * names how many distinct schemes the video will cut between; the actual + * colours stay audio-tilted (same warmth/energy) so they read as one track's + * world rather than as unrelated palettes shuffled together. + */ +function derivePaletteSet(summary, rng, director) { + const cfg = (director && director.palette) || {}; + const spec = cfg.count; + let count = 1; + if (Array.isArray(spec)) { + const lo = Math.max(1, spec[0] | 0); + const hi = Math.max(lo, spec[1] | 0); + count = lo === hi ? lo : rng.int(lo, hi); + } else if (typeof spec === 'number') { + count = Math.max(1, spec | 0); + } else { + // No palette config — single palette, like before + count = 1; + } + count = Math.min(4, Math.max(1, count)); + + const palettes = []; + const paletteSchemes = []; + const hueRefs = []; + + for (let k = 0; k < count; k++) { + let palette = null; + let scheme = null; + let hue = 0; + // Try a few forks so the set is diverse in scheme and hue, not just + // repeated draws that happen to land on the same scheme. + for (let attempt = 0; attempt < 12; attempt++) { + const fork = rng.fork(`palette:${k}:${attempt}`); + const src = new AudioPalette(summary, fork); + const cand = generateUsablePalette(src, 6); + const candScheme = src.lastScheme || 'unknown'; + let candHue = 0; + try { candHue = rgbToOklch(cand[0])[2]; } catch { candHue = fork.range(0, Math.PI * 2); } + + const schemeDup = paletteSchemes.includes(candScheme); + let tooClose = false; + for (const h2 of hueRefs) { + let dh = Math.abs(candHue - h2) % (2 * Math.PI); + if (dh > Math.PI) dh = 2 * Math.PI - dh; + if (dh < 0.5) { tooClose = true; break; } + } + // Keep trying while we have attempts left and the candidate would + // make the set harder to tell apart. Relax after a few tries. + if (attempt < 8 && schemeDup) continue; + if (attempt < 6 && tooClose) continue; + palette = cand; + scheme = candScheme; + hue = candHue; + break; + } + if (!palette) { + const src = new AudioPalette(summary, rng.fork(`palette:${k}:final`)); + palette = generateUsablePalette(src, 6); + scheme = src.lastScheme || 'unknown'; + try { hue = rgbToOklch(palette[0])[2]; } catch { hue = 0; } + } + palettes.push(palette); + paletteSchemes.push(scheme); + hueRefs.push(hue); + } + + return { palettes, paletteSchemes }; +} + /** * The track's temperament, moved to where this section sits in the story. * @@ -698,9 +768,6 @@ export function generateLook(track, { const rng = new Rng(resolvedSeed); const summary = track.summary; - const paletteSource = new AudioPalette(summary, rng.fork('palette')); - const palette = generateUsablePalette(paletteSource, 6); - // The production design, decided before a single scene is cast — casting // depends on it. See look/Personality.js. const personality = generatePersonality(summary, rng.fork('personality'), (signature) => @@ -711,6 +778,12 @@ export function generateLook(track, { // scene is, because it decides which scenes are even candidates. const director = pickDirector(summary, rng.fork('director')); + const { palettes, paletteSchemes } = derivePaletteSet( + summary, rng.fork('palettes'), director); + // Back-compat alias: the first palette is still the track's palette. + const palette = palettes[0]; + const paletteSource = { lastScheme: paletteSchemes[0] }; + // This track's cast, drawn before any section is assigned. See castingPool. // // `poolOverride` exists for the variety harness, which needs reference @@ -804,6 +877,9 @@ export function generateLook(track, { const look = { seed: resolvedSeed, palette, + // The full set the director cuts between — ArcDriver reads this per cue. + palettes, + paletteSchemes, personality, paletteScheme: paletteSource.lastScheme, director: director.name, @@ -881,7 +957,20 @@ export function rerollLook(look, track, newSeed) { } function applyOverrides(look, overrides) { - if (overrides.palette) look.palette = overrides.palette; + if (overrides.palette) { + look.palette = overrides.palette; + // Keep the full set in sync if the caller replaced the single alias. + if (look.palettes && look.palettes.length) look.palettes[0] = overrides.palette; + if (overrides.palettes) { + look.palettes = overrides.palettes; + look.palette = look.palettes[0] || look.palette; + } + } + if (overrides.palettes) { + look.palettes = overrides.palettes; + look.palette = look.palettes[0] || look.palette; + } + if (overrides.paletteSchemes) look.paletteSchemes = overrides.paletteSchemes; if (overrides.post) look.post = { ...look.post, ...overrides.post }; if (overrides.feedback) look.feedback = { ...look.feedback, ...overrides.feedback }; if (overrides.sections) { @@ -897,10 +986,16 @@ function applyOverrides(look, overrides) { /** Compact description, used by the HUD and by check output. */ export function describeLook(look) { const kinds = look.sections.map((s) => `${s.kind}:${subjectOf(s.layers).module.name}`); - return `seed ${look.seed.toString(16)} · ${look.director} · ${look.paletteScheme} · ` + + const palTag = look.paletteSchemes && look.paletteSchemes.length > 1 + ? look.paletteSchemes.join('→') + : look.paletteScheme; + const planTag = look.palettePlan + ? ` · palettes:${look.palettePlan.progression}/${look.palettePlan.transition}` + : ''; + return `seed ${look.seed.toString(16)} · ${look.director} · ${palTag} · ` + `${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` + `${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` + - `${describeCamera(look.camera)} · ` + + `${describeCamera(look.camera)}${planTag} · ` + `${[...new Set(kinds)].join(', ')}`; } diff --git a/flow-state/src/look/directors.js b/flow-state/src/look/directors.js index a0303cb..5a4e11e 100644 --- a/flow-state/src/look/directors.js +++ b/flow-state/src/look/directors.js @@ -81,6 +81,8 @@ export const DIRECTORS = [ // look/Camera.js — a director's point of view now includes how it // shoots, not only what it points at. camera: 'contemplative', + // Colour stays slow — two palettes that blend into one another. + palette: { count: [2, 2], progression: 'sequential', transition: 'blend' }, // The original table. A drop resolves into geometry; everything quiet is // minimal. Still the most broadly applicable, so it keeps the most weight. weight: 3, @@ -102,6 +104,8 @@ export const DIRECTORS = [ crowd: 1.05, // Holds, then commits to one large move. Architecture is looked AT. camera: 'deliberate', + // Two palettes, story-led — the drop arrives in the second palette. + palette: { count: [2, 2], progression: 'storyLed', transition: 'cut' }, // Everything is architecture. Quiet means empty rather than soft, so it // leads on minimal and reaches for organic last. weight: 2, @@ -123,6 +127,8 @@ export const DIRECTORS = [ crowd: 1.45, // Never settles, because nothing here is ever finished settling. camera: 'roaming', + // Three palettes mapped by kind — each kind of section keeps a colour. + palette: { count: [3, 3], progression: 'kindLed', transition: 'blend' }, // Nothing is ever built; things grow and dissolve. Deliberately never // reaches for glitch — a point of view is defined by what it refuses. weight: 2, @@ -144,6 +150,8 @@ export const DIRECTORS = [ crowd: 1.7, // Cuts with the camera already moving. camera: 'kinetic', + // Most palettes, most contrast — the damage is legible as colour too. + palette: { count: [3, 4], progression: 'contrast', transition: 'cut' }, // The signal is damaged and the damage is the subject — everywhere the // damage is allowed to be. Its quiet sections lead on flow, so the calm // reads as signal drifting rather than as rest. @@ -167,6 +175,8 @@ export const DIRECTORS = [ // Small, exact, always arrives — the pattern is the subject and the // camera does not editorialise about it. camera: 'precise', + // Three palettes traversed as a pong — the pattern's colour answers its form. + palette: { count: [2, 3], progression: 'pong', transition: 'blend' }, // Pattern first, everywhere, at every energy. The drop is not an // explosion, it is the pattern at its densest. weight: 2, @@ -221,3 +231,138 @@ export function blazeOf(director) { export function directorByName(name) { return DIRECTORS.find((d) => d.name === name) || DIRECTORS[0]; } + +// ── palette progression ─────────────────────────────────────────────── + +/** + * Build a per-cue palette assignment. + * + * `cues` is ArcDriver's flat list of (section,shot) spans; `sections` carries + * the story state each cue belongs to. `palettes` is the set LookGenerator built. + * The director's `palette.progression` names which strategy to use. + * + * Pure in (cues, sections, director, palettes, rng) — the seed makes the same + * song always traverse its palettes the same way. + */ +export function derivePalettePlan(cues, sections, director, rng, palettes) { + const count = palettes ? palettes.length : 1; + if (!cues || !cues.length || count <= 1) { + return { + name: 'single', + paletteCount: Math.max(1, count), + progression: 'single', + transition: 'cut', + cues: (cues || []).map(() => 0), + }; + } + + const cfg = (director && director.palette) || {}; + const progression = cfg.progression || 'sequential'; + const transition = cfg.transition || 'blend'; + + const n = cues.length; + const indices = new Array(n); + + if (progression === 'sequential') { + for (let i = 0; i < n; i++) indices[i] = i % count; + } else if (progression === 'pong') { + const cycle = count > 1 ? 2 * count - 2 : 1; + for (let i = 0; i < n; i++) { + const k = i % cycle; + indices[i] = k < count ? k : cycle - k; + } + } else if (progression === 'kindLed') { + // Stable kind → palette map, shuffled per track so two directors with the + // same progression don't map identically. + const kinds = ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro']; + const offset = rng ? rng.int(0, Math.max(1, count) - 1) : 0; + // Seeded shuffle of palette indices for the kind mapping + const order = [...Array(count).keys()]; + if (rng) { + for (let i = order.length - 1; i > 0; i--) { + const j = rng.int(0, i); + const t = order[i]; order[i] = order[j]; order[j] = t; + } + } + const kindMap = new Map(); + for (let k = 0; k < kinds.length; k++) { + // Round-robin through shuffled palette order, with offset + kindMap.set(kinds[k], order[(k + offset) % count]); + } + for (let i = 0; i < n; i++) { + const cue = cues[i]; + const sec = sections[cue.sectionIndex]; + const kind = sec ? sec.kind : 'intro'; + indices[i] = kindMap.has(kind) ? kindMap.get(kind) : (i % count); + } + } else if (progression === 'storyLed') { + for (let i = 0; i < n; i++) { + const cue = cues[i]; + const sec = sections[cue.sectionIndex]; + const story = sec ? sec.story : null; + if (story) { + if (story.act === 'climax') indices[i] = count - 1; + else if (story.act === 'resolution') indices[i] = Math.max(0, Math.floor(count / 2)); + else { + const t = Math.max(0, Math.min(1, story.tension ?? 0.5)); + indices[i] = Math.min(count - 1, Math.floor(t * count)); + } + } else { + const p = n > 1 ? i / (n - 1) : 0.5; + indices[i] = Math.min(count - 1, Math.floor(p * count)); + } + } + // Ensure at least one switch happens — storyLed on flat material can collapse + const uniq = new Set(indices); + if (uniq.size < 2 && count >= 2) { + // Nudge the middle cue to the other palette + const mid = Math.floor(n / 2); + indices[mid] = count > 2 ? 1 : 1; + } + } else if (progression === 'contrast') { + // Start seeded, then always pick the palette furthest in hue from the + // previous. Hue of the first colour in OKLCH is the representative. + const toHue = ([r, g, b]) => { + const lin = (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 = lin(r), G = lin(g), B = lin(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 a = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s; + const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s; + return Math.atan2(bb, a); + }; + const hues = palettes.map((pal) => { try { return toHue(pal[0]); } catch { return 0; } }); + const hueDist = (a, b) => { + let dh = Math.abs(a - b) % (2 * Math.PI); + if (dh > Math.PI) dh = 2 * Math.PI - dh; + return dh; + }; + indices[0] = rng ? rng.int(0, count - 1) : 0; + for (let i = 1; i < n; i++) { + const prev = indices[i - 1]; + let best = (prev + 1) % count; + let bestD = -1; + for (let c = 0; c < count; c++) { + if (c === prev && count > 1) continue; + const d = hueDist(hues[c], hues[prev]); + if (d > bestD) { bestD = d; best = c; } + } + indices[i] = best; + } + } else { + for (let i = 0; i < n; i++) indices[i] = i % count; + } + + return { + name: progression, + paletteCount: count, + progression, + transition, + cues: indices, + }; +} diff --git a/flow-state/src/look/palette.js b/flow-state/src/look/palette.js index cfe5ee8..b480478 100644 --- a/flow-state/src/look/palette.js +++ b/flow-state/src/look/palette.js @@ -299,3 +299,30 @@ export function toHex([r, g, b]) { const c = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, '0'); return `#${c(r)}${c(g)}${c(b)}`; } + +/** + * Lerp two palettes per-colour in OKLCH, so hue travel stays perceptual and + * lightness doesn't wobble the way an RGB lerp does. Hue takes the short way + * round the wheel. + */ +export function lerpPalettes(a, b, t) { + const n = Math.max(a.length, b.length); + const tt = Math.max(0, Math.min(1, t)); + const out = []; + for (let i = 0; i < n; i++) { + const ca = a[i % a.length]; + const cb = b[i % b.length]; + const [La, Ca, ha] = rgbToOklch(ca); + const [Lb, Cb, hb] = rgbToOklch(cb); + let dh = hb - ha; + dh = ((dh + Math.PI) % (2 * Math.PI)) - Math.PI; + // Near-grey colours have unstable hue — fade hue influence with chroma. + // For now lerp directly; OKLCH hue of a near-zero chroma is still stable + // enough at 6-colour palette sizes. + const L = La + (Lb - La) * tt; + const C = Ca + (Cb - Ca) * tt; + const h = ha + dh * tt; + out.push(oklchToRgb(L, Math.max(0, C), h)); + } + return out; +} diff --git a/flow-state/src/main.js b/flow-state/src/main.js index 7f52943..c899f2c 100644 --- a/flow-state/src/main.js +++ b/flow-state/src/main.js @@ -343,6 +343,14 @@ function renderPanel() { // The track's production design. Scenes that cannot express what it is // built on were never cast — see look/Personality.js. const personality = show.look.personality; + const palettes = show.look.palettes || [show.look.palette]; + const schemes = show.look.paletteSchemes || [show.look.paletteScheme]; + const plan = show.look.palettePlan; + const activeIdx = show.arc ? show.arc.paletteIndexAt(show.timeline.frame) : 0; + const blend = show.arc ? show.arc.paletteBlendAt(show.timeline.frame) : null; + const planLabel = plan + ? `${plan.progression}/${plan.transition} · ${palettes.length} palettes` + : `${schemes[0] || ''}`; dom.panelBody.innerHTML = `