Repository navigation
Expand file tree
/
Copy pathdominantColor.ts
More file actions
144 lines (135 loc) Β· 4.21 KB
/
Copy pathdominantColor.ts
File metadata and controls
144 lines (135 loc) Β· 4.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
/**
* Extract the dominant colour of an image via canvas pixel sampling.
*
* Loads the URL into an `Image` (cross-origin ok for `tauri://` since
* everything is local), draws it onto a tiny offscreen canvas, then
* averages every Nth pixel skipping near-black / near-white runs so
* the result reflects the cover's "feel" rather than its corners.
*
* Cheap enough (~5 ms on a 64Γ64 sample) to run on every track
* change without throttling.
*/
export async function dominantColor(
url: string,
strategy: "average" | "vibrant" = "average",
): Promise<{ r: number; g: number; b: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const SIZE = 64;
const canvas = document.createElement("canvas");
canvas.width = SIZE;
canvas.height = SIZE;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) {
reject(new Error("canvas 2d context unavailable"));
return;
}
ctx.drawImage(img, 0, 0, SIZE, SIZE);
const { data } = ctx.getImageData(0, 0, SIZE, SIZE);
if (strategy === "vibrant") {
const vibrant = vibrantColorFromPixels(data);
if (vibrant) {
resolve(vibrant);
} else {
reject(new Error("No colourful pixels in artwork"));
}
return;
}
let r = 0;
let g = 0;
let b = 0;
let count = 0;
// Sample every 4th pixel β at 64Γ64 that's still 1024 samples.
for (let i = 0; i < data.length; i += 16) {
const pr = data[i];
const pg = data[i + 1];
const pb = data[i + 2];
const pa = data[i + 3];
if (pa < 128) continue;
// Skip near-monochrome pixels (white margins, black bars) so
// they don't flatten the average toward grey.
const max = Math.max(pr, pg, pb);
const min = Math.min(pr, pg, pb);
if (max - min < 24 && (max < 32 || max > 224)) continue;
r += pr;
g += pg;
b += pb;
count += 1;
}
if (count === 0) {
resolve({ r: 39, g: 39, b: 42 }); // tailwind zinc-800 fallback
return;
}
resolve({
r: Math.round(r / count),
g: Math.round(g / count),
b: Math.round(b / count),
});
};
img.onerror = (e) => reject(e);
img.src = url;
});
}
/** Select a prominent hue instead of averaging a multicolour collage to grey. */
export function vibrantColorFromPixels(
data: ArrayLike<number>,
): { r: number; g: number; b: number } | null {
const buckets = Array.from({ length: 8 }, () => ({
score: 0,
r: 0,
g: 0,
b: 0,
}));
for (let i = 0; i < data.length; i += 16) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
if (data[i + 3] < 128) continue;
const max = Math.max(r, g, b);
const chroma = max - Math.min(r, g, b);
if (chroma < 40 || max < 64) continue;
const sector =
max === r
? (g - b) / chroma
: max === g
? (b - r) / chroma + 2
: (r - g) / chroma + 4;
const hue = (((sector * 60) % 360) + 360) % 360;
const bucket = buckets[Math.floor(((hue + 22.5) % 360) / 45)];
const weight = chroma * (max / 255);
bucket.score += weight;
bucket.r += r * weight;
bucket.g += g * weight;
bucket.b += b * weight;
}
const best = buckets.reduce((winner, bucket) =>
bucket.score > winner.score ? bucket : winner,
);
if (best.score === 0) return null;
return {
r: Math.round(best.r / best.score),
g: Math.round(best.g / best.score),
b: Math.round(best.b / best.score),
};
}
/**
* Darken an `(r,g,b)` triple by a uniform factor so the resulting
* gradient stays legible behind white text. Used for the bottom
* stop of the mini-player background gradient.
*/
export function darken(
rgb: { r: number; g: number; b: number },
factor: number,
): { r: number; g: number; b: number } {
const f = Math.max(0, Math.min(1, factor));
return {
r: Math.round(rgb.r * f),
g: Math.round(rgb.g * f),
b: Math.round(rgb.b * f),
};
}
export function rgb({ r, g, b }: { r: number; g: number; b: number }): string {
return `rgb(${r}, ${g}, ${b})`;
}