scripts/chromadiff.mjs
scripts/chromadiff.mjsBrowse 27 files
2,357 tokens
7,761 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1#!/usr/bin/env node2/**3 * chromadiff.mjs — the cheerful-drift gate.4 * Given a near-monochrome reference, a model still returns a friendlier, bluer, more saturated5 * version of it, and it does so with the reference on screen. Eyes forgive that drift because each6 * frame looks fine alone; side by side it is the difference between a photograph and game graphics.7 * This measures it: mean OKLCH chroma of your screenshot vs the reference that set the direction.8 * Second mode, one file + --target-l: check the page against the background lightness the commit-sheet9 * committed to. This skill drifts to near-black across unrelated projects (taste.md §2.5) and every10 * such page argues convincingly for itself, so the number goes in the sheet BEFORE the build and gets11 * checked after, when "it just felt right dark" is no longer admissible evidence.12 * Feed --target-l a FULL-PAGE capture (shoot.mjs --full). The sheet commits to the page's mean13 * lightness, and the first viewport is never the mean: measured on one build, the hero frame read14 * 0.876 against a page that actually sat at 0.740 — a false FAIL produced entirely by the crop.15 * Usage: node chromadiff.mjs <yours.png> <reference.png|jpg> [--max-delta 0.02] [--json]16 * node chromadiff.mjs <full-page.png> --target-l 0.55 [--tolerance 0.12] [--json]17 * ponytail: decodes via the playwright chromium that shoot.mjs already installs — no image deps.18 */19import { chromium } from 'playwright';20import { resolve, extname } from 'path';21import { existsSync } from 'fs';22import { readFile } from 'fs/promises';23 24const argv = process.argv.slice(2);25const VALUE_FLAGS = new Set(['--max-delta', '--target-l', '--tolerance']);26const files = argv.filter((a, i) => !a.startsWith('--') && !VALUE_FLAGS.has(argv[i - 1]));27const jsonMode = argv.includes('--json');28const num = (flag, def) => { const i = argv.indexOf(flag); return i >= 0 && argv[i + 1] ? +argv[i + 1] : def; };29const MAX_DELTA = num('--max-delta', 0.02); // 0.02 OKLCH C is a visible step30const TARGET_L = argv.includes('--target-l') ? num('--target-l', NaN) : null;31const TOLERANCE = num('--tolerance', 0.12); // wide on purpose: this catches drift, not art direction32 33if (TARGET_L !== null && !(TARGET_L >= 0 && TARGET_L <= 1)) {34 process.stderr.write('--target-l takes the committed mean background lightness, 0..1\n');35 process.exit(2);36}37if (!(files.length === 2 || (files.length === 1 && TARGET_L !== null))) {38 process.stderr.write('Usage: node chromadiff.mjs <yours.png> <reference.png> [--max-delta 0.02] [--json]\n' +39 ' node chromadiff.mjs <yours.png> --target-l 0.55 [--tolerance 0.12] [--json]\n');40 process.exit(2);41}42const [mine, ref] = files.map(f => resolve(f));43for (const f of [mine, ref].filter(Boolean)) if (!existsSync(f)) { process.stderr.write(`not found: ${f}\n`); process.exit(2); }44 45// A file:// image will not decode inside an about:blank page (opaque origin), so the bytes travel46// as a data URL instead — which also keeps this working against any page the browser is showing.47const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.avif': 'image/avif' };48const toUrl = async p => {49 const mime = MIME[extname(p).toLowerCase()];50 if (!mime) { process.stderr.write(`unsupported image type: ${p}\n`); process.exit(2); }51 return `data:${mime};base64,${(await readFile(p)).toString('base64')}`;52};53 54const browser = await chromium.launch({ headless: true });55const page = await browser.newPage();56await page.goto('about:blank');57 58// One decode pass per image, downsampled to 256px on the long edge: chroma is a distribution59// statistic, and averaging 65k pixels answers it as well as averaging 4 million.60const measure = url => page.evaluate(async src => {61 const img = new Image();62 img.src = src;63 await img.decode();64 const scale = 256 / Math.max(img.width, img.height);65 const w = Math.max(1, Math.round(img.width * scale)), h = Math.max(1, Math.round(img.height * scale));66 const cv = document.createElement('canvas');67 cv.width = w; cv.height = h;68 const cx = cv.getContext('2d', { willReadFrequently: true });69 cx.drawImage(img, 0, 0, w, h);70 const d = cx.getImageData(0, 0, w, h).data;71 72 const lin = c => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);73 let sumC = 0, sumL = 0, saturated = 0, dark = 0, n = 0;74 for (let p = 0; p < d.length; p += 4) {75 const r = lin(d[p] / 255), g = lin(d[p + 1] / 255), b = lin(d[p + 2] / 255);76 const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);77 const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);78 const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);79 const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;80 const A = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;81 const B = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;82 const C = Math.hypot(A, B);83 sumC += C; sumL += L; if (C > 0.1) saturated++; if (L < 0.5) dark++; n++;84 }85 return { chroma: sumC / n, lightness: sumL / n, saturatedShare: saturated / n, darkShare: dark / n };86}, url);87 88const a = await measure(await toUrl(mine));89const b = ref ? await measure(await toUrl(ref)) : null;90await browser.close();91 92const r3 = x => +x.toFixed(3);93const fails = [];94 95// Lightness mode: one frame against the number the commit-sheet committed to.96if (TARGET_L !== null) {97 const dl = a.lightness - TARGET_L;98 if (Math.abs(dl) > TOLERANCE)99 fails.push(`background lightness ${r3(a.lightness)} vs committed ${TARGET_L} (${dl > 0 ? '+' : ''}${r3(dl)}, tolerance ±${TOLERANCE})` +100 (dl < 0 ? ' — the page went darker than the sheet says; taste.md §2.5 tell #1' : ''));101 const summaryL = { yours: { chroma: r3(a.chroma), lightness: r3(a.lightness), darkShare: r3(a.darkShare) }, targetL: TARGET_L, tolerance: TOLERANCE, fails };102 if (jsonMode) process.stdout.write(JSON.stringify(summaryL, null, 2) + '\n');103 else {104 process.stdout.write(`chromadiff: L ${r3(a.lightness)} (committed ${TARGET_L}) - C ${r3(a.chroma)} - ${Math.round(a.darkShare * 100)}% dark pixels\n`);105 for (const f of fails) process.stdout.write(`FAIL ${f}\n`);106 process.stdout.write(fails.length ? 'FAIL — decide it or change the sheet; do not let it drift\n' : 'PASS\n');107 }108 process.exit(fails.length ? 1 : 0);109}110 111const delta = a.chroma - b.chroma;112// Only the upward direction fails. Ending up quieter than the reference is a defensible choice113// (and often the right one); ending up more colourful than it is the reflex this gate exists for.114if (delta > MAX_DELTA) fails.push(`chroma +${r3(delta)} over reference (${r3(a.chroma)} vs ${r3(b.chroma)}, limit +${MAX_DELTA})`);115if (a.saturatedShare - b.saturatedShare > 0.15)116 fails.push(`${Math.round((a.saturatedShare - b.saturatedShare) * 100)}pp more saturated pixels than the reference (C>0.1)`);117 118const summary = {119 yours: { chroma: r3(a.chroma), lightness: r3(a.lightness), saturatedShare: r3(a.saturatedShare) },120 reference: { chroma: r3(b.chroma), lightness: r3(b.lightness), saturatedShare: r3(b.saturatedShare) },121 chromaDelta: r3(delta), maxDelta: MAX_DELTA, fails,122};123if (jsonMode) process.stdout.write(JSON.stringify(summary, null, 2) + '\n');124else {125 process.stdout.write(`chromadiff: yours C ${r3(a.chroma)} L ${r3(a.lightness)} - reference C ${r3(b.chroma)} L ${r3(b.lightness)} - delta ${delta >= 0 ? '+' : ''}${r3(delta)}\n`);126 for (const f of fails) process.stdout.write(`FAIL ${f}\n`);127 process.stdout.write(fails.length ? 'FAIL — this is the drift, not a lighting difference: pull chroma back to the committed palette\n' : 'PASS\n');128}129process.exit(fails.length ? 1 : 0);130