A track now yields a complete, coherent look with no input: palette, per-section scene assignments, parameter sets, post and feedback settings. Seeded from a hash of the decoded PCM, so a file always renders identically. - palette.js builds in OKLCH, not HSL. HSL lightness is not perceptual, so evenly-stepped HSL palettes have colours that vanish and colours that dominate — which matters when nobody is supervising the choice. Regenerates until the contrast floor is cleared. - Scenes are assigned per section KIND, not per section: a track's drops share a scene and the video reads as one piece instead of a shuffle. - Family preference per kind keeps breakdowns off strobing glitch scenes. - Section bias (energy/density/motion) carries track character into params without scenes knowing anything about audio. - PaletteSource is the seam for cover art later; no scene would change. Gate 9/9, including the look-space spread measurement (mean pairwise distance 0.168 against a 0.08 floor) — the one check that catches a generator that is deterministic and valid but visually collapsed. Known gap, not a regression: all four battery tracks currently choose the same two scenes. There are no 'minimal' family scenes yet, so intro and outro sections fall through to flow/organic. Differentiation is presently carried by palette alone. Phase 7 grows the library to fix it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
6.4 KiB
JavaScript
157 lines
6.4 KiB
JavaScript
// 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)}`;
|
|
}
|