diff.ts
5.7 kB · typescript · 144 lines
1import { readdirSync, statSync } from "node:fs";2import { availableParallelism } from "node:os";3import { basename, extname, join, resolve } from "node:path";4import sharp from "sharp";56/* ARGS */78const USAGE = "usage: bun diff.ts <a> <b> [--levels N], two images or two folders";9const IMAGE = new Set([".png", ".webp", ".svg", ".jpg", ".jpeg", ".gif", ".avif", ".tif", ".tiff"]);1011type Pair = { label: string; a: string; b: string };12type Raw = { data: Buffer; width: number; height: number };13type Row = { label: string; max: number; count: number; share: number; size: string };1415function args(argv: string[]) {16 const paths: string[] = [];17 let levels = 0;18 for (let i = 0; i < argv.length; i++) {19 if (argv[i] === "--levels") levels = Number(argv[++i]);20 else paths.push(resolve(argv[i]));21 }22 if (paths.length !== 2 || !Number.isInteger(levels) || levels < 0) throw new Error(USAGE);23 return { a: paths[0], b: paths[1], levels };24}2526/* PAIRS */2728const isImage = (name: string) => IMAGE.has(extname(name).toLowerCase());29const stem = (name: string) => name.slice(0, name.length - extname(name).length);30const images = (dir: string) => readdirSync(dir).filter(isImage).sort();3132function byStem(names: string[]) {33 const found = new Map<string, string[]>();34 for (const name of names) found.set(stem(name), [...(found.get(stem(name)) ?? []), name]);35 return found;36}3738function match(a: string, b: string) {39 const folders = [statSync(a).isDirectory(), statSync(b).isDirectory()];40 if (!folders[0] && !folders[1]) {41 const label = basename(a) === basename(b) ? basename(a) : `${basename(a)} ${basename(b)}`;42 return { pairs: [{ label, a, b }], lone: [] as string[] };43 }44 if (!folders[0] || !folders[1]) throw new Error(USAGE);45 const left = images(a);46 const right = images(b);47 const shared = new Set(left.filter((name) => right.includes(name)));48 const pairs: Pair[] = [...shared].map((name) => ({ label: name, a: join(a, name), b: join(b, name) }));49 const restLeft = byStem(left.filter((name) => !shared.has(name)));50 const restRight = byStem(right.filter((name) => !shared.has(name)));51 const lone: string[] = [];52 for (const [key, names] of restLeft) {53 const other = restRight.get(key);54 if (names.length === 1 && other?.length === 1) {55 pairs.push({ label: `${names[0]} ${other[0]}`, a: join(a, names[0]), b: join(b, other[0]) });56 restRight.delete(key);57 } else lone.push(...names.map((name) => `only a ${name}`));58 }59 for (const names of restRight.values()) lone.push(...names.map((name) => `only b ${name}`));60 return { pairs: pairs.sort((x, y) => (x.label < y.label ? -1 : 1)), lone: lone.sort() };61}6263/* PIXELS */6465const isSvg = (path: string) => extname(path).toLowerCase() === ".svg";6667async function decode(path: string, width?: number): Promise<Raw> {68 let options: sharp.SharpOptions = {};69 if (width && isSvg(path)) {70 const meta = await sharp(path).metadata();71 if (meta.width && meta.width !== width) options = { density: (72 * width) / meta.width };72 }73 const { data, info } = await sharp(path, options).toColourspace("srgb").ensureAlpha().raw().toBuffer({ resolveWithObject: true });74 if (info.channels !== 4) throw new Error(`${path}: ${info.channels} channels after decode, want 4`);75 return { data, width: info.width, height: info.height };76}7778async function load(a: string, b: string) {79 if (isSvg(a) && !isSvg(b)) {80 const right = await decode(b);81 return [await decode(a, right.width), right];82 }83 if (isSvg(b) && !isSvg(a)) {84 const left = await decode(a);85 return [left, await decode(b, left.width)];86 }87 return Promise.all([decode(a), decode(b)]);88}8990function compare(x: Buffer, y: Buffer) {91 if (x.equals(y)) return { max: 0, count: 0 };92 let max = 0;93 let count = 0;94 for (let i = 0; i < x.length; i += 4) {95 const delta = Math.max(Math.abs(x[i] - y[i]), Math.abs(x[i + 1] - y[i + 1]), Math.abs(x[i + 2] - y[i + 2]), Math.abs(x[i + 3] - y[i + 3]));96 if (delta === 0) continue;97 count++;98 if (delta > max) max = delta;99 }100 return { max, count };101}102103async function measure(pair: Pair): Promise<Row> {104 const [left, right] = await load(pair.a, pair.b);105 if (left.width !== right.width || left.height !== right.height) {106 return { label: pair.label, max: -1, count: 0, share: 0, size: `${left.width}x${left.height} ${right.width}x${right.height}` };107 }108 const { max, count } = compare(left.data, right.data);109 return { label: pair.label, max, count, share: count / (left.width * left.height), size: "" };110}111112async function pool<T, R>(items: T[], run: (item: T) => Promise<R>) {113 const out: R[] = new Array(items.length);114 let next = 0;115 const lane = async () => {116 while (next < items.length) {117 const i = next++;118 out[i] = await run(items[i]);119 }120 };121 await Promise.all(Array.from({ length: Math.min(availableParallelism(), items.length) }, lane));122 return out;123}124125/* MAIN */126127async function main() {128 const { a, b, levels } = args(process.argv.slice(2));129 const { pairs, lone } = match(a, b);130 const rows = await pool(pairs, measure);131 for (const row of rows) {132 if (row.size) console.log(`size ${row.size} ${row.label}`);133 else console.log(`${String(row.max).padStart(3)} ${(row.share * 100).toFixed(4).padStart(8)}% ${row.label}`);134 }135 for (const line of lone) console.log(line);136 const sized = rows.filter((row) => row.size).length;137 const over = rows.filter((row) => row.max > levels).length;138 const worst = rows.reduce((top, row) => (row.max > top.max ? row : top), { label: "", max: 0 });139 const at = worst.max > 0 ? ` at ${worst.label}` : "";140 console.log(`${rows.length} pairs, ${over} over ${levels} levels, ${sized} size and ${lone.length} name mismatches, worst ${worst.max}${at}`);141 process.exit(over + sized + lone.length > 0 ? 1 : 0);142}143144if (import.meta.main) await main();