Pin the review card description under the sections and tint the card from the uploaded logo.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
2653ae4602
commit
56adcd59fd
10 files changed
+508
-42
No files matched your search
@@ -0,0 +1,181 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user