Site colours: palette extraction + same-origin image sampling utils

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 09:51:47 -08:00
1 parent 1190a98265
commit e09a87d2a6
4 files changed
+352

No files matched your search

+86
View File
@@ -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' });
});
});
+104
View File
@@ -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<SampledImage | SampleFailure> {
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<void>((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<ImageData, 'data' | 'width' | 'height'>, 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])}`;
}
+63
View File
@@ -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']);
});
});
+99
View File
@@ -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<number, number>();
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);
}