// 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 }; }