/** * Client-only (canvas + Image) — extracts the dominant, non-background colors * from a locally-selected logo file, to surface as "suggested" swatches in * the admin Branding tab. Runs off a local blob URL (URL.createObjectURL), * never a remote URL, so there's no cross-origin/tainted-canvas concern. */ function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error("Could not load image")); img.src = src; }); } function rgbToHex(r: number, g: number, b: number): string { const toHex = (v: number) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0"); return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } /** * Downscales the image to a small canvas and buckets pixels by a coarse RGB * quantization (histogram approach — cheap enough at this resolution that a * real k-means isn't needed). Near-white/near-black/low-saturation pixels are * discarded since those are almost always logo whitespace/background, not * the brand color someone would want suggested. */ export async function extractDominantColors(imageSrc: string, maxColors = 5): Promise { if (typeof document === "undefined") return []; let img: HTMLImageElement; try { img = await loadImage(imageSrc); } catch { return []; } const size = 50; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d"); if (!ctx) return []; ctx.drawImage(img, 0, 0, size, size); let data: Uint8ClampedArray; try { data = ctx.getImageData(0, 0, size, size).data; } catch { return []; } const BIN = 24; // quantization step per channel — coarse enough to merge near-duplicate shades, fine enough to keep distinct hues apart const buckets = new Map(); for (let i = 0; i < data.length; i += 4) { const r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3]; if (a < 200) continue; // transparent/near-transparent — not a real pixel const max = Math.max(r, g, b), min = Math.min(r, g, b); const lightness = (max + min) / 2 / 255; const sat = max === min ? 0 : (max - min) / (255 - Math.abs(max + min - 255)); if (lightness > 0.92 || lightness < 0.08) continue; // near-white / near-black if (sat < 0.15) continue; // low-saturation gray const key = `${Math.round(r / BIN)}_${Math.round(g / BIN)}_${Math.round(b / BIN)}`; const bucket = buckets.get(key); if (bucket) { bucket.count++; bucket.r += r; bucket.g += g; bucket.b += b; } else { buckets.set(key, { count: 1, r, g, b }); } } return Array.from(buckets.values()) .sort((a, b) => b.count - a.count) .slice(0, maxColors) .map(b => rgbToHex(b.r / b.count, b.g / b.count, b.b / b.count)); }