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();