diff --git a/flow-state/src/audio/synth.js b/flow-state/src/audio/synth.js index 3cb9bd3..4399739 100644 --- a/flow-state/src/audio/synth.js +++ b/flow-state/src/audio/synth.js @@ -65,6 +65,9 @@ export function synthesizeBeat({ hats = true, pad = true, kickGain = 1, + hatGain = 0.25, + padRoot = 110, + padGain = 0.12, } = {}) { const length = Math.round(duration * sampleRate); const buffer = new MockAudioBuffer(2, length, sampleRate); @@ -76,9 +79,9 @@ export function synthesizeBeat({ for (let t = 0; t < duration; t += beat, index++) { // Accent the downbeat so the bar phase is detectable. addKick(left, sampleRate, t, kickGain * (index % 4 === 0 ? 1.0 : 0.8)); - if (hats) addHat(left, sampleRate, t + beat / 2, 0.25, index + 1); + if (hats) addHat(left, sampleRate, t + beat / 2, hatGain, index + 1); } - if (pad) addPad(left, sampleRate, 0, duration, 0.12); + if (pad) addPad(left, sampleRate, 0, duration, padGain, padRoot); for (let i = 0; i < length; i++) right[i] = left[i] * 0.98; return buffer; diff --git a/flow-state/src/checks/phase3.js b/flow-state/src/checks/phase3.js index 05432dd..c793f85 100644 --- a/flow-state/src/checks/phase3.js +++ b/flow-state/src/checks/phase3.js @@ -1 +1,223 @@ -// Phase 3 gate — filled in when the phase lands. +// Phase 3 gate — look generation. "A" complete: a track in, a coherent video out. +// +// The load-bearing check here is look-space spread. It is entirely possible to +// build a generator that is deterministic, valid and well-typed and that produces +// visually identical output for every seed — failing the whole premise of the +// project (PLAN.md, "sameness across tracks") while passing every other test. +// The contact sheet measures it directly. + +import { check, expect } from './framework.js'; +import { Engine } from '../engine/Engine.js'; +import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js'; +import { synthesizeBeat, synthesizeSectioned } from '../audio/synth.js'; +import { generateLook, rerollSection, rerollLook, describeLook } from '../look/LookGenerator.js'; +import { paletteContrast, relativeLuminance } from '../look/palette.js'; +import { frameDistance, frameLuminance, frameVariance } from '../engine/hash.js'; +import { testTrack } from './phase1.js'; + +/** Four deliberately different tracks: the differentiation gate needs real spread. */ +let cachedBattery = null; +export function battery() { + if (cachedBattery) return cachedBattery; + const specs = [ + { name: 'dark ambient', buffer: synthesizeBeat({ bpm: 92, duration: 60, hats: false, kickGain: 0.4, padRoot: 55, padGain: 0.22 }) }, + { name: 'mid house', buffer: synthesizeBeat({ bpm: 124, duration: 60, hatGain: 0.25, padRoot: 165 }) }, + { name: 'bright techno', buffer: synthesizeBeat({ bpm: 140, duration: 60, hatGain: 0.5, padRoot: 440, padGain: 0.2 }) }, + { name: 'structured', buffer: synthesizeSectioned({ bpm: 128, duration: 120, changeAt: 60 }) }, + ]; + cachedBattery = specs.map((s) => ({ + name: s.name, + track: FeatureTrack.fromAudioBuffer(s.buffer, { fps: 60 }), + })); + return cachedBattery; +} + +export function renderLookFrame(engine, track, look, frame) { + const section = look.sections[track.sectionIndexAt(frame)] || look.sections[0]; + const layer = section.layers[0]; + engine.setLayerSpecs([{ + module: layer.module, + params: layer.params, + seed: layer.seed, + opacity: layer.opacity, + blend: layer.blend, + palette: look.palette, + }]); + engine.compositor.setPost(look.post).setFeedback(look.feedback); + engine.compositor.reset(); + return Uint8Array.from(engine.readPixels(engine.renderFrame(frame))); +} + +check(3, 'the same audio always produces the same look', () => { + const track = testTrack(); + const samples = new Float32Array(2048).map((_, i) => Math.sin(i * 0.01)); + const a = generateLook(track, { samples }); + const b = generateLook(track, { samples }); + + if (a.seed !== b.seed) return expect(false, `seeds differ: ${a.seed} vs ${b.seed}`); + const sameScenes = a.sections.every((s, i) => s.layers[0].module.name === b.sections[i].layers[0].module.name); + const sameParams = JSON.stringify(a.sections.map((s) => s.layers[0].params)) + === JSON.stringify(b.sections.map((s) => s.layers[0].params)); + const samePalette = JSON.stringify(a.palette) === JSON.stringify(b.palette); + + return expect(sameScenes && sameParams && samePalette, + `seed ${a.seed.toString(16)} · scenes ${sameScenes} · params ${sameParams} · palette ${samePalette}`); +}); + +check(3, 'different audio content produces a different seed', () => { + const track = testTrack(); + const a = generateLook(track, { samples: new Float32Array(4096).map((_, i) => Math.sin(i * 0.01)) }); + const b = generateLook(track, { samples: new Float32Array(4096).map((_, i) => Math.sin(i * 0.013)) }); + return expect(a.seed !== b.seed, `${a.seed.toString(16)} vs ${b.seed.toString(16)}`); +}); + +check(3, 'palettes clear the contrast floor', () => { + const problems = []; + for (const { name, track } of battery()) { + for (let s = 0; s < 12; s++) { + const look = generateLook(track, { seed: 1000 + s * 7919 }); + const { luminanceSpread, chromaSpread } = paletteContrast(look.palette); + if (luminanceSpread < 0.18) { + problems.push(`${name} seed ${s}: luminance spread ${luminanceSpread.toFixed(3)}`); + } + if (chromaSpread < 0.20) { + problems.push(`${name} seed ${s}: chroma spread ${chromaSpread.toFixed(3)}`); + } + if (look.palette.some((c) => c.some((v) => !Number.isFinite(v) || v < 0 || v > 1))) { + problems.push(`${name} seed ${s}: colour out of gamut`); + } + } + } + return expect(problems.length === 0, + problems.length ? problems.slice(0, 4).join(' · ') : '48 palettes all usable'); +}); + +check(3, 'look space is genuinely wide across seeds', () => { + // The seed contact sheet, measured. One frame per seed, mean pairwise + // distance. A collapsed generator fails here and nowhere else. + const track = testTrack(); + const engine = new Engine({ width: 160, height: 90 }); + try { + engine.timeline.setDuration(track.duration); + engine.setFeatureProvider(featureProviderFor(track)); + + const frames = []; + for (let s = 0; s < 16; s++) { + const look = generateLook(track, { seed: 7000 + s * 104729 }); + frames.push(renderLookFrame(engine, track, look, 1500)); + } + + let sum = 0; + let pairs = 0; + let minDistance = 1; + for (let i = 0; i < frames.length; i++) { + for (let j = i + 1; j < frames.length; j++) { + const d = frameDistance(frames[i], frames[j]); + sum += d; pairs++; + minDistance = Math.min(minDistance, d); + } + } + const mean = sum / pairs; + return expect(mean > 0.08 && minDistance > 0.01, + `mean pairwise distance ${mean.toFixed(4)} (floor 0.08), closest pair ${minDistance.toFixed(4)} (floor 0.01)`); + } finally { + engine.dispose(); + } +}, { slow: true }); + +check(3, 'different tracks get different looks at the same seed', () => { + const looks = battery().map(({ name, track }) => ({ + name, + look: generateLook(track, { seed: 42 }), // fixed seed: the difference must come from the audio + })); + + const palettes = looks.map(({ look }) => look.palette.map(relativeLuminance).join(',')); + const uniquePalettes = new Set(palettes).size; + const sceneSets = looks.map(({ look }) => + [...new Set(look.sections.map((s) => s.layers[0].module.name))].sort().join('+')); + + return expect(uniquePalettes === looks.length, + `${uniquePalettes}/${looks.length} distinct palettes at a fixed seed · ` + + looks.map((l, i) => `${l.name}→${sceneSets[i]}`).join(' · ')); +}); + +check(3, 'every generated look renders a live frame on every track', () => { + // The generator wanders into corners of the parameter space that the Phase 2 + // sweep only tests one axis at a time; this tests them in combination. + const problems = []; + let rendered = 0; + + for (const { name, track } of battery()) { + const engine = new Engine({ width: 160, height: 90 }); + try { + engine.timeline.setDuration(track.duration); + engine.setFeatureProvider(featureProviderFor(track)); + + for (let s = 0; s < 6; s++) { + const look = generateLook(track, { seed: 300 + s * 15485863 }); + for (const section of look.sections) { + const frame = Math.min(track.frameCount - 1, + section.startFrame + Math.floor((section.endFrame - section.startFrame) / 2)); + const pixels = renderLookFrame(engine, track, look, frame); + rendered++; + const lum = frameLuminance(pixels); + const variance = frameVariance(pixels); + if (lum < 0.0008 || lum > 0.99 || variance < 0.0015) { + problems.push(`${name} s${s} ${section.kind}/${section.layers[0].module.name}: ` + + `lum ${lum.toFixed(4)} var ${variance.toFixed(4)}`); + } + } + } + } finally { + engine.dispose(); + } + } + return expect(problems.length === 0, + problems.length ? problems.slice(0, 5).join(' · ') : `${rendered} generated frames, all live`); +}, { slow: true }); + +check(3, 'sections of the same kind share a scene', () => { + // Coherence: a track's drops should look like each other, or the video reads + // as a shuffle rather than as one piece. + const { track } = battery()[3]; + const look = generateLook(track, { seed: 99 }); + const byKind = new Map(); + let violations = 0; + for (const s of look.sections) { + const name = s.layers[0].module.name; + if (byKind.has(s.kind) && byKind.get(s.kind) !== name) violations++; + byKind.set(s.kind, name); + } + return expect(violations === 0, + violations ? `${violations} kind(s) using multiple scenes` : describeLook(look)); +}); + +check(3, 'reroll changes a section and respects locks', () => { + const track = testTrack(); + const look = generateLook(track, { seed: 555 }); + const before = JSON.stringify(look.sections[0].layers[0].params); + + rerollSection(look, track, 0, 1); + const afterUnlocked = JSON.stringify(look.sections[0].layers[0].params); + + look.sections[0].locked = true; + rerollSection(look, track, 0, 2); + const afterLocked = JSON.stringify(look.sections[0].layers[0].params); + + return expect(before !== afterUnlocked && afterUnlocked === afterLocked, + `changed when unlocked: ${before !== afterUnlocked}, held when locked: ${afterUnlocked === afterLocked}`); +}); + +check(3, 'a whole-track reroll preserves locked sections', () => { + const track = testTrack(); + const look = generateLook(track, { seed: 777 }); + look.sections[0].locked = true; + const lockedScene = look.sections[0].layers[0].module.name; + const lockedParams = JSON.stringify(look.sections[0].layers[0].params); + + const next = rerollLook(look, track, 888); + return expect( + next.sections[0].layers[0].module.name === lockedScene && + JSON.stringify(next.sections[0].layers[0].params) === lockedParams, + `locked section survived a full reroll (${lockedScene})`); +}); diff --git a/flow-state/src/look/LookGenerator.js b/flow-state/src/look/LookGenerator.js new file mode 100644 index 0000000..eceb3e4 --- /dev/null +++ b/flow-state/src/look/LookGenerator.js @@ -0,0 +1,236 @@ +// Turns a FeatureTrack into a complete LookSpec: palette, per-section scene +// assignments, parameter sets, and the post/feedback settings. +// +// Runs once per track. Deterministic in the seed, and the seed is derived from +// 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 { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.js'; +import { sampleValues, defaultValues } from '../params/schema.js'; + +/** + * Which families suit which section kind, in preference order. + * + * This is the coupling that stops a breakdown landing on a strobing glitch scene + * and an intro opening at full density. It is also why families exist at all. + */ +const FAMILY_BY_KIND = { + intro: ['minimal', 'flow', 'organic'], + build: ['structural', 'geometric', 'flow'], + drop: ['geometric', 'glitch', 'structural'], + sustain: ['organic', 'flow', 'geometric'], + breakdown: ['minimal', 'organic', 'flow'], + outro: ['minimal', 'flow', 'organic'], +}; + +const KIND_ENERGY = { + intro: 0.25, build: 0.55, drop: 0.95, sustain: 0.6, breakdown: 0.25, outro: 0.2, +}; + +/** + * Parameter bias per section: the values scenes declare a `bias` key against. + * + * This is how a track's measured character reaches a scene's parameters without + * the scene knowing anything about audio. A dense, loud drop pushes `density` + * and `energy` up; a breakdown pulls them down. Seed variation still dominates, + * so two tracks with the same structure do not converge on the same look. + */ +function biasFor(section, summary) { + const kindEnergy = KIND_ENERGY[section.kind] ?? 0.5; + const measured = Math.min(1, section.energy / Math.max(1e-6, summary.meanLoudness * 1.6)); + const energy = kindEnergy * 0.6 + measured * 0.4; + + return { + energy, + density: Math.min(1, energy * 0.7 + section.flux * 1.2), + motion: Math.min(1, 0.25 + energy * 0.5 + (summary.bpm - 90) / 180), + }; +} + +/** + * Scenes are chosen per section KIND, not per section. + * + * All of a track's drops therefore share a scene, all its breakdowns share + * another, and the video acquires an identity instead of reading as a shuffle. + * Variation between two sections of the same kind comes from their parameter + * sets and from the arc driver's drift, which is enough to keep them distinct + * without losing the through-line. + */ +function assignScenesByKind(sections, rng) { + const byKind = new Map(); + const used = new Set(); + + const kinds = [...new Set(sections.map((s) => s.kind))]; + // Order matters for variety: assign the high-impact kinds first so they get + // first pick of the library rather than whatever is left. + const priority = ['drop', 'sustain', 'build', 'breakdown', 'intro', 'outro']; + kinds.sort((a, b) => priority.indexOf(a) - priority.indexOf(b)); + + for (const kind of kinds) { + const families = FAMILY_BY_KIND[kind] || Object.keys(FAMILIES); + let candidates = []; + for (const family of families) { + const inFamily = scenesInFamily(family); + // Weight by family preference order, and push down anything already + // used so a five-section track doesn't show one scene five times. + const weight = families.length - families.indexOf(family); + for (const scene of inFamily) { + candidates.push({ scene, weight: weight * (used.has(scene.name) ? 0.15 : 1) }); + } + } + if (!candidates.length) candidates = scenes.map((scene) => ({ scene, weight: 1 })); + + const chosen = rng.pickWeighted( + candidates.map((c) => c.scene), + candidates.map((c) => c.weight), + ); + byKind.set(kind, chosen); + used.add(chosen.name); + } + return byKind; +} + +/** + * Post-processing and feedback derived from track character. + * Ambient material gets more feedback and bloom and less grain; dense club + * material gets tighter, punchier settings. + */ +function derivePost(summary, rng) { + const bright = summary.meanCentroid; + const noisy = Math.min(1, summary.meanFlatness * 3); + const dynamic = Math.min(1, summary.dynamicRange); + + return { + post: { + bloom: 0.25 + (1 - noisy) * 0.35 + rng.range(-0.05, 0.05), + bloomThreshold: 0.45 + bright * 0.25, + bloomKnee: 0.25, + chroma: 0.05 + noisy * 0.25 + rng.range(0, 0.08), + grain: 0.02 + noisy * 0.05, + vignette: 0.25 + (1 - bright) * 0.25, + contrast: 1.0 + dynamic * 0.15, + saturation: 1.0 + (1 - noisy) * 0.25, + lift: 0.0, + exposure: 1.0, + }, + feedback: { + // Dynamic, spacious material tolerates long trails; dense material + // turns to smear, so it gets much less. + amount: Math.min(0.75, 0.15 + dynamic * 0.5), + decay: 0.86 + dynamic * 0.08, + zoom: 1.0 + rng.range(-0.006, 0.006), + rotate: rng.range(-0.004, 0.004), + }, + }; +} + +/** + * @param {FeatureTrack} track + * @param {object} options + * @returns {object} LookSpec + */ +export function generateLook(track, { seed = null, samples = null, overrides = null } = {}) { + const resolvedSeed = seed !== null + ? seed >>> 0 + : samples ? hashSamples(samples) : 0x9e3779b9; + + const rng = new Rng(resolvedSeed); + const summary = track.summary; + + const paletteSource = new AudioPalette(summary, rng.fork('palette')); + const palette = generateUsablePalette(paletteSource, 6); + + const sceneByKind = assignScenesByKind(track.sections, rng.fork('scenes')); + const { post, feedback } = derivePost(summary, rng.fork('post')); + + const sections = track.sections.map((section) => { + const module = sceneByKind.get(section.kind) || scenes[0]; + const sectionRng = rng.fork(`section:${section.index}:${module.name}`); + const bias = biasFor(section, summary); + + return { + index: section.index, + kind: section.kind, + startFrame: section.startFrame, + endFrame: section.endFrame, + start: section.start, + end: section.end, + locked: false, + bias, + layers: [{ + module, + params: sampleValues(module, sectionRng, bias), + seed: sectionRng.int(0, 0x7fffffff), + blend: 'normal', + opacity: 1, + }], + }; + }); + + const look = { + seed: resolvedSeed, + palette, + paletteScheme: paletteSource.lastScheme, + post, + feedback, + sections, + summary, + }; + + return overrides ? applyOverrides(look, overrides) : look; +} + +/** Re-roll one section, leaving everything else — and locked sections — alone. */ +export function rerollSection(look, track, sectionIndex, salt = 0) { + const section = look.sections[sectionIndex]; + if (!section || section.locked) return look; + + const rng = new Rng((look.seed ^ (sectionIndex * 0x9e3779b9) ^ (salt * 0x85ebca6b)) >>> 0); + const families = FAMILY_BY_KIND[section.kind] || Object.keys(FAMILIES); + const candidates = families.flatMap((f) => scenesInFamily(f)); + const module = candidates.length ? rng.pick(candidates) : scenes[0]; + + section.layers = [{ + module, + params: sampleValues(module, rng, section.bias), + seed: rng.int(0, 0x7fffffff), + blend: 'normal', + opacity: 1, + }]; + return look; +} + +/** Reroll the whole track with a new seed, preserving locked sections. */ +export function rerollLook(look, track, newSeed) { + const locked = new Map(); + look.sections.forEach((s) => { if (s.locked) locked.set(s.index, s); }); + + const next = generateLook(track, { seed: newSeed >>> 0 }); + next.sections.forEach((s, i) => { + if (locked.has(i)) next.sections[i] = locked.get(i); + }); + return next; +} + +function applyOverrides(look, overrides) { + if (overrides.palette) look.palette = overrides.palette; + if (overrides.post) look.post = { ...look.post, ...overrides.post }; + if (overrides.feedback) look.feedback = { ...look.feedback, ...overrides.feedback }; + if (overrides.sections) { + overrides.sections.forEach((o, i) => { + if (!look.sections[i]) return; + if (o.locked !== undefined) look.sections[i].locked = o.locked; + if (o.params) Object.assign(look.sections[i].layers[0].params, o.params); + }); + } + return look; +} + +/** Compact description, used by the HUD and by check output. */ +export function describeLook(look) { + const kinds = look.sections.map((s) => `${s.kind}:${s.layers[0].module.name}`); + return `seed ${look.seed.toString(16)} · ${look.paletteScheme} · ${[...new Set(kinds)].join(', ')}`; +} + +export { defaultValues }; diff --git a/flow-state/src/look/palette.js b/flow-state/src/look/palette.js new file mode 100644 index 0000000..64eeadd --- /dev/null +++ b/flow-state/src/look/palette.js @@ -0,0 +1,156 @@ +// 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)}`; +}