Site colours: palette extraction + same-origin image sampling utils
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1190a98265
commit
e09a87d2a6
4 files changed
+352
No files matched your search
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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])}`;
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user