fix: harmonize wave colors from album covers
This commit is contained in:
1 parent
02307802bc
commit
bfcf6e48b2
7 files changed
+100
-15
No files matched your search
@@ -24,7 +24,7 @@ export {
|
||||
export { normalizeGain, TARGET_LUFS } from './gain';
|
||||
export { trackShareUrl } from './share';
|
||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||
|
||||
export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave';
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { rgb, wavePalette, WAVE_COLORS } from './wave-anim';
|
||||
|
||||
function hsl(color: string) {
|
||||
const [r, g, b] = rgb(color).map((v) => v / 255) as [number, number, number];
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const c = max - min;
|
||||
const l = (max + min) / 2;
|
||||
const h = c === 0 ? 0 : max === r ? (g - b) / c : max === g ? (b - r) / c + 2 : (r - g) / c + 4;
|
||||
return { h: (h * 60 + 360) % 360, s: c === 0 ? 0 : c / (1 - Math.abs(2 * l - 1)), l };
|
||||
}
|
||||
|
||||
describe('wave cover palette', () => {
|
||||
it.each([
|
||||
undefined, [], ['#fff'], ['#000', '#888', '#fff'],
|
||||
['#010203', '#fefdfc', '#808182'], ['#ff0000', 'invalid', '#0000ff'],
|
||||
])('uses the default palette for missing, neutral or malformed colors: %j', (colors) => {
|
||||
expect(wavePalette(colors)).toEqual(WAVE_COLORS);
|
||||
});
|
||||
|
||||
it('ignores neutral colors and preserves the strongest cover hue', () => {
|
||||
const colors = ['#ffffff', '#007733', '#222222'];
|
||||
const before = [...colors];
|
||||
expect(hsl(wavePalette(colors)[0]).h).toBeCloseTo(hsl('#007733').h, 0);
|
||||
expect(wavePalette(colors)).toEqual(wavePalette(['#007733', '#222222', '#ffffff']));
|
||||
expect(colors).toEqual(before);
|
||||
expect(wavePalette(['#fff', '#073', '#222'])).toEqual(wavePalette(colors));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['#040e08', '#18281d', '#374450'], // dark, muted cover like the reported case
|
||||
['#ff0000', '#00ff00', '#0000ff'], // opposing hues must not mix to gray
|
||||
['#ffff00', '#ffffff', '#000000'],
|
||||
['#ff0080', '#540029', '#fefefe'], // hue offsets wrap around red
|
||||
])('keeps a visible, saturated, harmonious palette for %j', (...colors) => {
|
||||
const palette = wavePalette(colors);
|
||||
const base = hsl(palette[0]);
|
||||
for (const color of palette) {
|
||||
expect(color).toMatch(/^#[\da-f]{6}$/i);
|
||||
const { h, s, l } = hsl(color);
|
||||
expect(s).toBeGreaterThanOrEqual(0.70);
|
||||
expect(s).toBeLessThanOrEqual(0.84);
|
||||
expect(l).toBeGreaterThanOrEqual(0.45);
|
||||
expect(l).toBeLessThanOrEqual(0.59);
|
||||
const distance = Math.abs(h - base.h);
|
||||
expect(Math.min(distance, 360 - distance)).toBeLessThan(29);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,39 @@ export interface WaveState {
|
||||
|
||||
export const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||
|
||||
/** Keep the cover's strongest hue, but avoid muddy or complementary additive blends. */
|
||||
export function wavePalette(colors?: readonly string[]): [string, string, string] {
|
||||
if (colors?.length !== 3 || !colors.every((c) => /^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(c))) {
|
||||
return [...WAVE_COLORS];
|
||||
}
|
||||
let hue = 0;
|
||||
let strongest = 0;
|
||||
for (const color of colors) {
|
||||
const [r, g, b] = rgb(color).map((v) => v / 255) as Rgb;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const chroma = max - min;
|
||||
// Absolute chroma rejects both near-black noise and nearly white/gray covers.
|
||||
if (chroma <= Math.max(0.08, strongest)) continue;
|
||||
strongest = chroma;
|
||||
hue = max === r ? ((g - b) / chroma) % 6 : max === g ? (b - r) / chroma + 2 : (r - g) / chroma + 4;
|
||||
hue = (hue * 60 + 360) % 360;
|
||||
}
|
||||
if (!strongest) return [...WAVE_COLORS];
|
||||
// A narrow hue range retains color when the translucent layers overlap.
|
||||
return [paletteColor(hue, 0.78, 0.50), paletteColor(hue + 28, 0.72, 0.46), paletteColor(hue - 28, 0.82, 0.58)];
|
||||
}
|
||||
|
||||
function paletteColor(hue: number, saturation: number, lightness: number): string {
|
||||
const h = ((hue % 360) + 360) % 360 / 60;
|
||||
const c = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
||||
const x = c * (1 - Math.abs(h % 2 - 1));
|
||||
const m = lightness - c / 2;
|
||||
const channels = h < 1 ? [c, x, 0] : h < 2 ? [x, c, 0] : h < 3 ? [0, c, x]
|
||||
: h < 4 ? [0, x, c] : h < 5 ? [x, 0, c] : [c, 0, x];
|
||||
return `#${channels.map((v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
export function initialWaveState(size: number): WaveState {
|
||||
return {
|
||||
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
|
||||
|
||||
Reference in new issue
Block a user