From e09a87d2a61ddb7effbdf007046174f08f667d62 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 1 Nov 2026 09:51:47 -0800 Subject: [PATCH] Site colours: palette extraction + same-origin image sampling utils Co-Authored-By: Claude Sonnet 5.5 --- craft/src/utils/image-sample.test.ts | 86 ++++++++++++++++++++ craft/src/utils/image-sample.ts | 104 ++++++++++++++++++++++++ craft/src/utils/palette-extract.test.ts | 63 ++++++++++++++ craft/src/utils/palette-extract.ts | 99 ++++++++++++++++++++++ 4 files changed, 352 insertions(+) create mode 100644 craft/src/utils/image-sample.test.ts create mode 100644 craft/src/utils/image-sample.ts create mode 100644 craft/src/utils/palette-extract.test.ts create mode 100644 craft/src/utils/palette-extract.ts diff --git a/craft/src/utils/image-sample.test.ts b/craft/src/utils/image-sample.test.ts new file mode 100644 index 0000000..cb854df --- /dev/null +++ b/craft/src/utils/image-sample.test.ts @@ -0,0 +1,86 @@ +import { describe, test, expect } from 'vitest'; +import { loadImageForSampling, sampleAt, isSameOrigin, CROSS_ORIGIN_MESSAGE } from './image-sample'; + +const ORIGIN = 'http://site.test'; + +function fakeImg(w: number, h: number, fail = false) { + const img: any = { naturalWidth: w, naturalHeight: h, width: w, height: h, onload: null, onerror: null }; + Object.defineProperty(img, 'src', { set() { setTimeout(() => (fail ? img.onerror?.() : img.onload?.()), 0); } }); + return img as HTMLImageElement; +} +function fakeCanvas(opts: { throwOnRead?: string; noCtx?: boolean } = {}) { + const log: any = {}; + const c: any = { + width: 0, height: 0, + getContext: () => opts.noCtx ? null : ({ + drawImage: (...a: unknown[]) => { log.draw = a; }, + getImageData: (_x: number, _y: number, w: number, h: number) => { + if (opts.throwOnRead) { const e: any = new Error('tainted'); e.name = opts.throwOnRead; throw e; } + return { width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }; + }, + }), + }; + return { c: c as HTMLCanvasElement, log }; +} + +describe('isSameOrigin', () => { + test('relative, absolute same, other origin, data', () => { + expect(isSameOrigin('/a.png', ORIGIN)).toBe(true); + expect(isSameOrigin('a.png', ORIGIN)).toBe(true); + expect(isSameOrigin('http://site.test/x/a.png', ORIGIN)).toBe(true); + expect(isSameOrigin('http://site.test:81/a.png', ORIGIN)).toBe(false); + expect(isSameOrigin('https://cdn.example.com/a.png', ORIGIN)).toBe(false); + expect(isSameOrigin('//cdn.example.com/a.png', ORIGIN)).toBe(false); + expect(isSameOrigin('data:image/png;base64,AAAA', ORIGIN)).toBe(true); + }); +}); + +describe('sampleAt', () => { + const data = new Uint8ClampedArray([255, 0, 16, 255, 1, 2, 3, 0]); + const id = { data, width: 2, height: 1 }; + test('hex of pixel', () => expect(sampleAt(id, 0, 0)).toBe('#ff0010')); + test('transparent -> empty', () => expect(sampleAt(id, 1, 0)).toBe('')); + test('out of bounds -> empty', () => { expect(sampleAt(id, 2, 0)).toBe(''); expect(sampleAt(id, -1, 0)).toBe(''); }); +}); + +describe('loadImageForSampling', () => { + test('cross-origin refused without loading', async () => { + let created = false; + const r = await loadImageForSampling('https://other.test/a.png', { origin: ORIGIN, createImage: () => { created = true; return fakeImg(1, 1); } }); + expect(r).toEqual({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE }); + expect(created).toBe(false); + }); + test('downscales longest side to 512', async () => { + const { c } = fakeCanvas(); + const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(2048, 1024), createCanvas: () => c }); + expect(r.ok && [r.width, r.height]).toEqual([512, 256]); + }); + test('small image keeps its size; draw sizes target canvas', async () => { + const { c } = fakeCanvas(); + const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(40, 30), createCanvas: () => c }); + expect(r.ok).toBe(true); + if (r.ok) { + const t = fakeCanvas().c; + r.draw(t); + expect([t.width, t.height]).toEqual([40, 30]); + } + }); + test('SecurityError -> cross-origin', async () => { + const { c } = fakeCanvas({ throwOnRead: 'SecurityError' }); + const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(4, 4), createCanvas: () => c }); + expect(r).toMatchObject({ ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE }); + }); + test('other read error -> error', async () => { + const { c } = fakeCanvas({ throwOnRead: 'Weird' }); + const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(4, 4), createCanvas: () => c }); + expect(r).toMatchObject({ ok: false, reason: 'error' }); + }); + test('load error -> error', async () => { + const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(4, 4, true), createCanvas: () => fakeCanvas().c }); + expect(r).toMatchObject({ ok: false, reason: 'error' }); + }); + test('no 2d context -> error', async () => { + const r = await loadImageForSampling('/a.png', { origin: ORIGIN, createImage: () => fakeImg(4, 4), createCanvas: () => fakeCanvas({ noCtx: true }).c }); + expect(r).toMatchObject({ ok: false, reason: 'error' }); + }); +}); diff --git a/craft/src/utils/image-sample.ts b/craft/src/utils/image-sample.ts new file mode 100644 index 0000000..e10505b --- /dev/null +++ b/craft/src/utils/image-sample.ts @@ -0,0 +1,104 @@ +/* Read pixel colours out of an image via canvas. Same-origin only: a cross-origin + image would taint the canvas, so we refuse up front with a plain-English message + instead of trying. */ + +export type PickFailReason = 'cancelled' | 'unsupported' | 'cross-origin' | 'error'; +export type PickResult = + | { ok: true; hex: string; source: 'canvas' | 'image' | 'eyedropper' | 'tab-capture' } + | { ok: false; reason: PickFailReason; message: string }; + +export const CROSS_ORIGIN_MESSAGE = + 'That image comes from another website, so the browser will not let the builder read its colours. Upload it to this site first, then pick from the uploaded copy.'; + +const MAX_SIDE = 512; + +export type SampledImage = { + ok: true; + width: number; + height: number; + imageData: ImageData; + /** Paint the (downscaled) image onto a canvas, resizing the canvas to match. */ + draw(canvas: HTMLCanvasElement): void; +}; +export type SampleFailure = { ok: false; reason: 'cross-origin' | 'error'; message: string }; + +/** Injection points so the logic is testable where jsdom has no canvas. */ +export interface SamplingDeps { + origin?: string; + createImage?: () => HTMLImageElement; + createCanvas?: () => HTMLCanvasElement; +} + +function resolve(src: string, origin: string): URL | null { + try { return new URL(src, origin + '/'); } catch { return null; } +} + +/** True when `src` (resolved against the page) shares the page's origin. data:/blob: count as same-origin. */ +export function isSameOrigin(src: string, origin: string = location.origin): boolean { + if (/^(data|blob):/i.test(src.trim())) return true; + const u = resolve(src.trim(), origin); + return !!u && u.origin === origin; +} + +function isSecurityError(e: unknown): boolean { + return !!e && typeof e === 'object' && (e as { name?: string }).name === 'SecurityError'; +} + +export async function loadImageForSampling(src: string, deps: SamplingDeps = {}): Promise { + const origin = deps.origin ?? location.origin; + if (!isSameOrigin(src, origin)) { + return { ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE }; + } + const img = deps.createImage ? deps.createImage() : new Image(); + try { + await new Promise((res, rej) => { + img.onload = () => res(); + img.onerror = () => rej(new Error('load')); + img.src = src; + }); + } catch { + return { ok: false, reason: 'error', message: 'That image could not be loaded.' }; + } + const nw = img.naturalWidth || img.width; + const nh = img.naturalHeight || img.height; + if (!nw || !nh) return { ok: false, reason: 'error', message: 'That image has no size, so its colours cannot be read.' }; + const scale = Math.min(1, MAX_SIDE / Math.max(nw, nh)); + const width = Math.max(1, Math.round(nw * scale)); + const height = Math.max(1, Math.round(nh * scale)); + const canvas = deps.createCanvas ? deps.createCanvas() : document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d', { willReadFrequently: true } as CanvasRenderingContext2DSettings); + if (!ctx) return { ok: false, reason: 'error', message: 'This browser cannot read image colours.' }; + let imageData: ImageData; + try { + ctx.drawImage(img, 0, 0, width, height); + imageData = ctx.getImageData(0, 0, width, height); + } catch (e) { + if (isSecurityError(e)) return { ok: false, reason: 'cross-origin', message: CROSS_ORIGIN_MESSAGE }; + return { ok: false, reason: 'error', message: 'The image colours could not be read.' }; + } + return { + ok: true, + width, + height, + imageData, + draw(target) { + target.width = width; + target.height = height; + target.getContext('2d')?.drawImage(canvas, 0, 0); + }, + }; +} + +const hex2 = (n: number) => n.toString(16).padStart(2, '0'); + +/** Lowercase #rrggbb of the pixel at (x, y); '' when out of bounds or transparent (alpha < 128). */ +export function sampleAt(imageData: Pick, x: number, y: number): string { + const px = Math.floor(x), py = Math.floor(y); + if (px < 0 || py < 0 || px >= imageData.width || py >= imageData.height) return ''; + const i = (py * imageData.width + px) * 4; + const d = imageData.data; + if (d[i + 3] < 128) return ''; + return `#${hex2(d[i])}${hex2(d[i + 1])}${hex2(d[i + 2])}`; +} diff --git a/craft/src/utils/palette-extract.test.ts b/craft/src/utils/palette-extract.test.ts new file mode 100644 index 0000000..5f57247 --- /dev/null +++ b/craft/src/utils/palette-extract.test.ts @@ -0,0 +1,63 @@ +import { describe, test, expect } from 'vitest'; +import { extractPalette } from './palette-extract'; + +type RGBA = [number, number, number, number]; +function buf(runs: Array<[RGBA, number]>): Uint8ClampedArray { + const total = runs.reduce((n, [, c]) => n + c, 0); + const out = new Uint8ClampedArray(total * 4); + let i = 0; + for (const [px, c] of runs) for (let k = 0; k < c; k++) { out.set(px, i); i += 4; } + return out; +} + +describe('extractPalette', () => { + test('three flat bands -> those colours in population order', () => { + const p = extractPalette(buf([[[0, 0, 255, 255], 100], [[255, 0, 0, 255], 500], [[0, 200, 0, 255], 300]])); + expect(p).toEqual(['#ff0000', '#00c800', '#0000ff']); + }); + test('single colour -> one entry, never fabricates', () => { + expect(extractPalette(buf([[[10, 20, 30, 255], 50]]))).toEqual(['#0a141e']); + }); + test('two colours -> two entries', () => { + expect(extractPalette(buf([[[255, 255, 255, 255], 50], [[0, 0, 0, 255], 20]], ))).toEqual(['#ffffff', '#000000']); + }); + test('near-duplicates are merged, keeping the more populous', () => { + const p = extractPalette(buf([[[200, 30, 30, 255], 400], [[203, 32, 29, 255], 100], [[20, 20, 200, 255], 200]])); + expect(p).toHaveLength(2); + expect(p[0]).toMatch(/^#c[89a-f]/); + expect(p[1]).toBe('#1414c8'); + }); + test('all transparent -> []', () => { + expect(extractPalette(buf([[[255, 0, 0, 0], 100]]))).toEqual([]); + expect(extractPalette([])).toEqual([]); + }); + test('transparent pixels (alpha<128) are ignored', () => { + expect(extractPalette(buf([[[255, 0, 0, 10], 1000], [[0, 128, 0, 255], 5]]))).toEqual(['#008000']); + }); + test('gradient -> at most count distinct valid hexes', () => { + const px: number[] = []; + for (let i = 0; i < 256; i++) px.push(i, 255 - i, 128, 255); + const p = extractPalette(px, { count: 6 }); + expect(p.length).toBeGreaterThan(1); + expect(p.length).toBeLessThanOrEqual(6); + expect(new Set(p).size).toBe(p.length); + p.forEach((h) => expect(h).toMatch(/^#[0-9a-f]{6}$/)); + }); + test('deterministic', () => { + const px = new Uint8ClampedArray(4 * 5000); + for (let i = 0; i < 5000; i++) { px[i * 4] = (i * 37) % 256; px[i * 4 + 1] = (i * 91) % 256; px[i * 4 + 2] = (i * 13) % 256; px[i * 4 + 3] = 255; } + expect(extractPalette(px)).toEqual(extractPalette(px)); + }); + test('count is clamped to 5..8 (default 6)', () => { + const px = new Uint8ClampedArray(4 * 4000); + for (let i = 0; i < 4000; i++) { px[i * 4] = (i * 53) % 256; px[i * 4 + 1] = (i * 97) % 256; px[i * 4 + 2] = (i * 29) % 256; px[i * 4 + 3] = 255; } + expect(extractPalette(px).length).toBeLessThanOrEqual(6); + expect(extractPalette(px, { count: 100 }).length).toBeLessThanOrEqual(8); + expect(extractPalette(px, { count: 100 }).length).toBeGreaterThan(6); + expect(extractPalette(px, { count: 1 }).length).toBeLessThanOrEqual(5); + expect(extractPalette(px, { count: 1 }).length).toBeGreaterThanOrEqual(5); + }); + test('ignoreTransparent:false keeps low-alpha pixels', () => { + expect(extractPalette(buf([[[255, 0, 0, 10], 10]]), { ignoreTransparent: false })).toEqual(['#ff0000']); + }); +}); diff --git a/craft/src/utils/palette-extract.ts b/craft/src/utils/palette-extract.ts new file mode 100644 index 0000000..b2e6028 --- /dev/null +++ b/craft/src/utils/palette-extract.ts @@ -0,0 +1,99 @@ +/* Dominant-colour extraction (median cut). Pure and DOM-free; deterministic. */ + +export interface ExtractPaletteOptions { + /** Wanted colours, clamped to 5..8. Default 6. */ + count?: number; + /** Skip pixels with alpha < 128. Default true. */ + ignoreTransparent?: boolean; +} + +const MAX_SAMPLES = 40000; +const MIN_DELTA_E = 12; + +const hex2 = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'); + +function toLab(r: number, g: number, b: number): [number, number, number] { + const lin = (c: number) => { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; + const R = lin(r), G = lin(g), B = lin(b); + const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); + const x = f((0.4124 * R + 0.3576 * G + 0.1805 * B) / 0.95047); + const y = f(0.2126 * R + 0.7152 * G + 0.0722 * B); + const z = f((0.0193 * R + 0.1192 * G + 0.9505 * B) / 1.08883); + return [116 * y - 16, 500 * (x - y), 200 * (y - z)]; +} + +interface Entry { rgb: number; n: number } +interface Box { e: Entry[]; pop: number } + +function channel(rgb: number, ch: number): number { return (rgb >> (16 - ch * 8)) & 255; } + +function range(box: Box): { ch: number; span: number } { + const lo = [255, 255, 255], hi = [0, 0, 0]; + for (const { rgb } of box.e) for (let c = 0; c < 3; c++) { + const v = channel(rgb, c); + if (v < lo[c]) lo[c] = v; + if (v > hi[c]) hi[c] = v; + } + let ch = 0, span = -1; + for (let c = 0; c < 3; c++) if (hi[c] - lo[c] > span) { span = hi[c] - lo[c]; ch = c; } + return { ch, span }; +} + +function split(box: Box): [Box, Box] { + const { ch } = range(box); + const sorted = [...box.e].sort((a, b) => channel(a.rgb, ch) - channel(b.rgb, ch) || a.rgb - b.rgb); + let acc = 0; + let cut = 1; + for (let i = 0; i < sorted.length - 1; i++) { + acc += sorted[i].n; + cut = i + 1; + if (acc * 2 >= box.pop) break; + } + const a = sorted.slice(0, cut), b = sorted.slice(cut); + const sum = (x: Entry[]) => x.reduce((s, y) => s + y.n, 0); + return [{ e: a, pop: sum(a) }, { e: b, pop: sum(b) }]; +} + +/** Dominant colours as lowercase #rrggbb, most dominant first. Returns fewer than + * `count` for flat/transparent images; never invents colours. */ +export function extractPalette(rgba: Uint8ClampedArray | number[], opts: ExtractPaletteOptions = {}): string[] { + const want = Math.max(5, Math.min(8, Math.round(opts.count ?? 6))); + const ignoreT = opts.ignoreTransparent !== false; + const pixels = Math.floor(rgba.length / 4); + const step = Math.max(1, Math.ceil(pixels / MAX_SAMPLES)); + const hist = new Map(); + for (let p = 0; p < pixels; p += step) { + const i = p * 4; + if (ignoreT && rgba[i + 3] < 128) continue; + const key = ((rgba[i] & 255) << 16) | ((rgba[i + 1] & 255) << 8) | (rgba[i + 2] & 255); + hist.set(key, (hist.get(key) || 0) + 1); + } + if (hist.size === 0) return []; + const entries: Entry[] = [...hist].map(([rgb, n]) => ({ rgb, n })).sort((a, b) => a.rgb - b.rgb); + let boxes: Box[] = [{ e: entries, pop: entries.reduce((s, x) => s + x.n, 0) }]; + const target = want * 2; + while (boxes.length < target) { + let best = -1, bestScore = -1; + boxes.forEach((b, i) => { + if (b.e.length < 2) return; + const score = b.pop * (range(b).span > 0 ? 1 : 0); + if (score > bestScore) { bestScore = score; best = i; } + }); + if (best < 0 || bestScore <= 0) break; + const [a, b] = split(boxes[best]); + boxes.splice(best, 1, a, b); + } + const cols = boxes.map((b) => { + let r = 0, g = 0, bl = 0; + for (const { rgb, n } of b.e) { r += channel(rgb, 0) * n; g += channel(rgb, 1) * n; bl += channel(rgb, 2) * n; } + const R = Math.round(r / b.pop), G = Math.round(g / b.pop), B = Math.round(bl / b.pop); + return { hex: `#${hex2(R)}${hex2(G)}${hex2(B)}`, pop: b.pop, lab: toLab(R, G, B) }; + }).sort((a, b) => b.pop - a.pop || (a.hex < b.hex ? -1 : 1)); + const kept: typeof cols = []; + for (const c of cols) { + const dup = kept.some((k) => Math.hypot(k.lab[0] - c.lab[0], k.lab[1] - c.lab[1], k.lab[2] - c.lab[2]) < MIN_DELTA_E); + if (!dup) kept.push(c); + if (kept.length >= want) break; + } + return kept.map((k) => k.hex); +}