Baseline: full tree snapshot after git repository loss
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.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,130 @@
|
||||
// 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 };
|
||||
}
|
||||
Reference in new issue
Block a user