182 lines
5.1 KiB
TypeScript
182 lines
5.1 KiB
TypeScript
const QUANTIZE_SHIFT = 4;
|
||
/** Ignore only fully clear pixels so a translucent logo fill still counts. */
|
||
const MIN_ALPHA = 16;
|
||
/** Black card type needs a light fill. Dark logo colors are lifted to this. */
|
||
const CARD_FILL_LIGHTNESS = 0.82;
|
||
const CARD_FILL_MAX_SATURATION = 0.48;
|
||
|
||
function rgbToHsl(
|
||
r: number,
|
||
g: number,
|
||
b: number,
|
||
): { h: number; s: number; l: number } {
|
||
const rn = r / 255;
|
||
const gn = g / 255;
|
||
const bn = b / 255;
|
||
const max = Math.max(rn, gn, bn);
|
||
const min = Math.min(rn, gn, bn);
|
||
const l = (max + min) / 2;
|
||
if (max === min) return { h: 0, s: 0, l };
|
||
const d = max - min;
|
||
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||
let h = 0;
|
||
if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0);
|
||
else if (max === gn) h = (bn - rn) / d + 2;
|
||
else h = (rn - gn) / d + 4;
|
||
return { h: h / 6, s, l };
|
||
}
|
||
|
||
function hueChannel(p: number, q: number, t: number): number {
|
||
let channel = t;
|
||
if (channel < 0) channel += 1;
|
||
if (channel > 1) channel -= 1;
|
||
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
|
||
if (channel < 1 / 2) return q;
|
||
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
|
||
return p;
|
||
}
|
||
|
||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||
if (s === 0) {
|
||
const value = Math.round(l * 255);
|
||
return [value, value, value];
|
||
}
|
||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||
const p = 2 * l - q;
|
||
return [
|
||
Math.round(hueChannel(p, q, h + 1 / 3) * 255),
|
||
Math.round(hueChannel(p, q, h) * 255),
|
||
Math.round(hueChannel(p, q, h - 1 / 3) * 255),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Keep an already-light logo color. Lift a dark one to a pale tint of the
|
||
* same hue so the card’s black type stays readable.
|
||
*/
|
||
export function readableCardFill(r: number, g: number, b: number): string {
|
||
const { h, s, l } = rgbToHsl(r, g, b);
|
||
if (l >= 0.7) return `rgb(${r}, ${g}, ${b})`;
|
||
const [rr, gg, bb] = hslToRgb(
|
||
h,
|
||
Math.min(s, CARD_FILL_MAX_SATURATION),
|
||
CARD_FILL_LIGHTNESS,
|
||
);
|
||
return `rgb(${rr}, ${gg}, ${bb})`;
|
||
}
|
||
|
||
/**
|
||
* Most common opaque color in `data` (RGBA). Nearby shades share a bucket
|
||
* so a logo’s main fill wins over anti-aliased edges. Returns `null` when
|
||
* every pixel is transparent.
|
||
*/
|
||
export function mostCommonOpaqueColor(data: Uint8ClampedArray): string | null {
|
||
const buckets = new Map<
|
||
number,
|
||
{ count: number; r: number; g: number; b: number }
|
||
>();
|
||
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
const alpha = data[i + 3] ?? 0;
|
||
if (alpha < MIN_ALPHA) continue;
|
||
const r = data[i] ?? 0;
|
||
const g = data[i + 1] ?? 0;
|
||
const b = data[i + 2] ?? 0;
|
||
const key =
|
||
((r >> QUANTIZE_SHIFT) << 8) |
|
||
((g >> QUANTIZE_SHIFT) << 4) |
|
||
(b >> QUANTIZE_SHIFT);
|
||
const bucket = buckets.get(key);
|
||
if (bucket) {
|
||
bucket.count += 1;
|
||
bucket.r += r;
|
||
bucket.g += g;
|
||
bucket.b += b;
|
||
} else {
|
||
buckets.set(key, { count: 1, r, g, b });
|
||
}
|
||
}
|
||
|
||
let best: { count: number; r: number; g: number; b: number } | null = null;
|
||
for (const bucket of buckets.values()) {
|
||
if (!best || bucket.count > best.count) best = bucket;
|
||
}
|
||
if (!best || best.count === 0) return null;
|
||
|
||
const r = Math.round(best.r / best.count);
|
||
const g = Math.round(best.g / best.count);
|
||
const b = Math.round(best.b / best.count);
|
||
return `rgb(${r}, ${g}, ${b})`;
|
||
}
|
||
|
||
/**
|
||
* Light random fill. Hue is free; lightness stays high so the card’s black
|
||
* type remains readable.
|
||
*/
|
||
export function randomPreviewSurfaceColor(
|
||
random: () => number = Math.random,
|
||
): string {
|
||
const hue = Math.floor(random() * 360);
|
||
const saturation = 50 + Math.floor(random() * 35);
|
||
const lightness = 76 + Math.floor(random() * 12);
|
||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||
}
|
||
|
||
function imageNeedsCors(url: string): boolean {
|
||
if (
|
||
url.startsWith("/") ||
|
||
url.startsWith("blob:") ||
|
||
url.startsWith("data:")
|
||
) {
|
||
return false;
|
||
}
|
||
try {
|
||
return new URL(url, window.location.href).origin !== window.location.origin;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function loadImage(url: string): Promise<HTMLImageElement | null> {
|
||
return new Promise((resolve) => {
|
||
const img = new Image();
|
||
img.onload = () => resolve(img);
|
||
img.onerror = () => resolve(null);
|
||
if (imageNeedsCors(url)) img.crossOrigin = "anonymous";
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
/** Downscale `url` and return its most common opaque color, or `null`. */
|
||
export async function dominantColorFromImageUrl(
|
||
url: string,
|
||
): Promise<string | null> {
|
||
if (typeof window === "undefined") return null;
|
||
const img = await loadImage(url);
|
||
if (!img) return null;
|
||
|
||
const size = 32;
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = size;
|
||
canvas.height = size;
|
||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||
if (!context) return null;
|
||
context.drawImage(img, 0, 0, size, size);
|
||
|
||
try {
|
||
const raw = mostCommonOpaqueColor(
|
||
context.getImageData(0, 0, size, size).data,
|
||
);
|
||
if (!raw) return null;
|
||
const match = raw.match(/^rgb\((\d+), (\d+), (\d+)\)$/);
|
||
if (!match) return raw;
|
||
return readableCardFill(
|
||
Number(match[1]),
|
||
Number(match[2]),
|
||
Number(match[3]),
|
||
);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|