// Palette generation. // // Colours are built in OKLCH rather than HSL. HSL's lightness is not perceptual — // pure yellow and pure blue at the same "lightness" differ enormously in how // bright they look — so an HSL palette with even lightness steps produces a set // where some colours vanish and others dominate. OKLCH steps look even because // they are even, which matters a lot when the generator is choosing palettes // unsupervised and nobody is there to correct a bad one. // // Cover art is not available (PLAN.md §Decisions), so everything here derives // from the audio. `PaletteSource` is the seam: adding a CoverArtPalette later is // a new implementation of this interface and one line of config, with no change // to any scene. /** OKLCH -> sRGB, components 0..1. h in radians. */ export function oklchToRgb(L, C, h) { const a = C * Math.cos(h); const b = C * Math.sin(h); const l_ = L + 0.3963377774 * a + 0.2158037573 * b; const m_ = L - 0.1055613458 * a - 0.0638541728 * b; const s_ = L - 0.0894841775 * a - 1.2914855480 * b; const l = l_ * l_ * l_; const m = m_ * m_ * m_; const s = s_ * s_ * s_; const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; const lb = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s; const gamma = (x) => { const v = Math.max(0, Math.min(1, x)); return v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055; }; return [gamma(lr), gamma(lg), gamma(lb)]; } export function relativeLuminance([r, g, b]) { return 0.2126 * r + 0.7152 * g + 0.0722 * b; } /** * Spread of a palette's luminance and hue. The Phase 3 gate uses this to reject * muddy sets — palettes where everything sits at the same brightness read as a * single colour once they are composited and bloomed. */ export function paletteContrast(colors) { if (!colors || colors.length < 2) return { luminanceSpread: 0, chromaSpread: 0 }; const lums = colors.map(relativeLuminance); const luminanceSpread = Math.max(...lums) - Math.min(...lums); let chromaSpread = 0; for (let i = 0; i < colors.length; i++) { for (let j = i + 1; j < colors.length; j++) { const d = Math.hypot( colors[i][0] - colors[j][0], colors[i][1] - colors[j][1], colors[i][2] - colors[j][2], ); chromaSpread = Math.max(chromaSpread, d); } } return { luminanceSpread, chromaSpread }; } const SCHEMES = { analogous: (h, rng) => [h, h + 0.35, h - 0.35, h + 0.7, h - 0.6, h + 1.0], complement: (h) => [h, h + Math.PI, h + 0.4, h + Math.PI - 0.4, h + 0.8, h + Math.PI + 0.3], triad: (h) => [h, h + 2.094, h + 4.189, h + 0.5, h + 2.6, h + 4.7], split: (h) => [h, h + 2.6, h + 3.7, h + 0.35, h + 2.9, h + 3.4], duo: (h) => [h, h + 1.9, h + 0.2, h + 2.1, h - 0.25, h + 1.7], }; export const SCHEME_NAMES = Object.keys(SCHEMES); /** The interface a palette source implements. */ export class PaletteSource { /** @returns {number[][]} array of [r,g,b] in 0..1 */ generate() { throw new Error('PaletteSource.generate not implemented'); } } /** * Derives a palette from what the track actually sounds like. * * - spectral centroid -> hue family. A bass-heavy track lands in deep blues and * violets; a bright one moves toward cyan, green and amber. This is the single * strongest differentiator between two tracks, because it tracks the thing a * listener would call the track's colour anyway. * - flatness (noisy vs tonal) -> chroma. Noisy material gets desaturated so it * doesn't turn to mud once bloom is applied. * - dynamic range -> lightness spread. A dynamic track earns a wider range * between its darkest and brightest colour. */ export class AudioPalette extends PaletteSource { constructor(summary, rng) { super(); this.summary = summary; this.rng = rng; } generate(count = 6) { const { meanCentroid = 0.5, meanFlatness = 0.2, dynamicRange = 0.5 } = this.summary; const rng = this.rng; // Centroid 0..1 mapped onto roughly violet -> blue -> cyan -> green -> amber. // Offset by a seeded jitter so two tracks with similar spectra still differ. const baseHue = (4.9 - meanCentroid * 3.6) + rng.range(-0.45, 0.45); const schemeName = rng.pick(SCHEME_NAMES); const hues = SCHEMES[schemeName](baseHue, rng); // Noisy material desaturates; tonal material is allowed to sing. const chromaBase = 0.10 + (1 - Math.min(1, meanFlatness * 3)) * 0.11; // A dynamic track gets a wider light-to-dark range. const spread = 0.30 + Math.min(1, dynamicRange) * 0.34; const anchor = 0.36 + rng.range(-0.05, 0.10); const colors = []; for (let i = 0; i < count; i++) { const t = count > 1 ? i / (count - 1) : 0; // Deliberately non-linear: most entries mid-dark, one or two bright. // Scenes use pal(0) as a base and higher indices as accents. const L = Math.max(0.06, Math.min(0.95, anchor + Math.pow(t, 1.7) * spread)); const C = chromaBase * (0.55 + Math.sin(t * Math.PI) * 0.75) + rng.range(-0.012, 0.012); const h = hues[i % hues.length] + rng.range(-0.08, 0.08); colors.push(oklchToRgb(L, Math.max(0, C), h)); } this.lastScheme = schemeName; return colors; } } /** * Retry until the palette clears the contrast floor. Unsupervised generation * will occasionally land on a muddy set; regenerating is cheap and beats * shipping a video where every colour is the same grey-violet. */ export function generateUsablePalette(source, count = 6, { minLuminanceSpread = 0.22, attempts = 12 } = {}) { let best = null; let bestScore = -1; for (let i = 0; i < attempts; i++) { const colors = source.generate(count); const { luminanceSpread, chromaSpread } = paletteContrast(colors); const score = luminanceSpread + chromaSpread * 0.4; if (score > bestScore) { bestScore = score; best = colors; } if (luminanceSpread >= minLuminanceSpread) return colors; } return best; } 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)}`; }