The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
131 lines
4.5 KiB
JavaScript
131 lines
4.5 KiB
JavaScript
// Dominant-color extraction from album art, tuned to produce a usable
|
|
// accent on a dark UI: prefers saturated mid-lightness pixels, then clamps
|
|
// the result into a safe saturation/lightness band.
|
|
|
|
export function hexToRgb(hex) {
|
|
const n = parseInt(hex.slice(1), 16);
|
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
}
|
|
|
|
export function rgbToHex([r, g, b]) {
|
|
const c = (v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0");
|
|
return `#${c(r)}${c(g)}${c(b)}`;
|
|
}
|
|
|
|
export function lerpRgb(a, b, t) {
|
|
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
|
|
}
|
|
|
|
function rgbToHsl(r, g, b) {
|
|
r /= 255; g /= 255; b /= 255;
|
|
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
const l = (max + min) / 2;
|
|
if (max === min) return [0, 0, l];
|
|
const d = max - min;
|
|
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
let h;
|
|
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
else if (max === g) h = ((b - r) / d + 2) / 6;
|
|
else h = ((r - g) / d + 4) / 6;
|
|
return [h, s, l];
|
|
}
|
|
|
|
function hslToRgb(h, s, l) {
|
|
if (s === 0) return [l * 255, l * 255, l * 255];
|
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
const p = 2 * l - q;
|
|
const f = (t) => {
|
|
if (t < 0) t += 1;
|
|
if (t > 1) t -= 1;
|
|
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
if (t < 1 / 2) return q;
|
|
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
return p;
|
|
};
|
|
return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255];
|
|
}
|
|
|
|
/**
|
|
* Extract an accent color from an image data URL.
|
|
* Returns { accent, hover } hex strings, or null if nothing usable.
|
|
*/
|
|
export async function accentFromImage(dataUrl) {
|
|
const img = new Image();
|
|
img.src = dataUrl;
|
|
try {
|
|
await img.decode();
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const S = 48;
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = S;
|
|
canvas.height = S;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.drawImage(img, 0, 0, S, S);
|
|
let data;
|
|
try {
|
|
data = ctx.getImageData(0, 0, S, S).data;
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
// "True tone": the color FAMILY that covers the most of the artwork wins
|
|
// (chroma-weighted hue histogram), and the result keeps that family's
|
|
// real saturation/lightness character — a mossy cover yields moss, not
|
|
// neon. Legibility clamps are gentle floors, not vibrancy boosts.
|
|
const BINS = 24;
|
|
const binWeight = new Array(BINS).fill(0);
|
|
const samples = [];
|
|
const pixelCount = data.length / 4;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
const [h, s, l] = rgbToHsl(data[i], data[i + 1], data[i + 2]);
|
|
if (l < 0.08 || l > 0.95 || s < 0.1) continue;
|
|
const w = s * (1 - Math.abs(2 * l - 1)); // ~chroma
|
|
if (w <= 0) continue;
|
|
const bin = Math.floor(h * BINS) % BINS;
|
|
binWeight[bin] += w;
|
|
samples.push({ h, s, l, w, bin });
|
|
}
|
|
|
|
// Neutral guard: effectively grayscale art (or art with only a faint
|
|
// color cast) stays a deliberate silver, not an invented hue.
|
|
const totalWeight = samples.reduce((sum, p) => sum + p.w, 0);
|
|
if (!samples.length || totalWeight / pixelCount < 0.03) {
|
|
return {
|
|
accent: rgbToHex(hslToRgb(0, 0.05, 0.72)),
|
|
hover: rgbToHex(hslToRgb(0, 0.05, 0.8)),
|
|
};
|
|
}
|
|
|
|
// Smooth adjacent bins so a family split across a boundary still wins.
|
|
let bestBin = 0;
|
|
let bestScore = -1;
|
|
for (let i = 0; i < BINS; i++) {
|
|
const score = binWeight[i]
|
|
+ 0.5 * (binWeight[(i + 1) % BINS] + binWeight[(i + BINS - 1) % BINS]);
|
|
if (score > bestScore) { bestScore = score; bestBin = i; }
|
|
}
|
|
|
|
// Weighted circular mean over the winning family (bin ± 1).
|
|
let x = 0, y = 0, ws = 0, sSum = 0, lSum = 0;
|
|
for (const p of samples) {
|
|
const d = Math.min((p.bin - bestBin + BINS) % BINS, (bestBin - p.bin + BINS) % BINS);
|
|
if (d > 1) continue;
|
|
x += Math.cos(p.h * 2 * Math.PI) * p.w;
|
|
y += Math.sin(p.h * 2 * Math.PI) * p.w;
|
|
sSum += p.s * p.w;
|
|
lSum += p.l * p.w;
|
|
ws += p.w;
|
|
}
|
|
if (!ws) return null;
|
|
let h = Math.atan2(y, x) / (2 * Math.PI);
|
|
if (h < 0) h += 1;
|
|
const s = Math.min(0.88, Math.max(0.3, (sSum / ws) * 1.05));
|
|
const l = Math.min(0.66, Math.max(0.46, lSum / ws));
|
|
const accent = rgbToHex(hslToRgb(h, s, l));
|
|
const hover = rgbToHex(hslToRgb(h, s, Math.min(0.74, l + 0.08)));
|
|
return { accent, hover };
|
|
}
|