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}%)`; } const STORED_PREVIEW_SURFACE_COLOR = /^hsl\(\d{1,3}, \d{1,3}%, \d{1,3}%\)$/; /** True when `value` is a draft-stored review-card fallback fill. */ export function isPreviewSurfaceColor(value: string): boolean { return STORED_PREVIEW_SURFACE_COLOR.test(value); } 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 { 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 { 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; } }