fuzzyworld

nuts / files / scripts / diff.ts

diff.ts

3.6 KB · history · blame

1/**
2 * scripts/diff.ts
3 *
4 * Compares screenshots/composite.png against screenshots/golden/composite.png
5 * using pixelmatch.
6 *
7 * Exit codes:
8 * 0 — images match (within threshold)
9 * 1 — images differ (above threshold) — diff saved to screenshots/diff.png
10 * 2 — golden file missing (run `pnpm screenshot --update` first)
11 *
12 * Usage (standalone):
13 * pnpm diff-screenshots
14 *
15 * The screenshot.ts script calls compareWithGolden() directly.
16 */
17
18import pixelmatch from 'pixelmatch'
19import { PNG } from 'pngjs'
20import { existsSync, readFileSync, writeFileSync } from 'node:fs'
21import { join } from 'node:path'
22import { fileURLToPath } from 'node:url'
23
24const ROOT = fileURLToPath(new URL('..', import.meta.url))
25const OUT_DIR = join(ROOT, 'screenshots')
26const DIFF_FILE = join(OUT_DIR, 'diff.png')
27
28/** Fraction of pixels that must change before we consider the images different. */
29const THRESHOLD = 0.005 // 0.5 %
30
31/**
32 * Compare `composite.png` against the golden copy.
33 * Returns `null` when there is no golden to compare against.
34 * Otherwise returns an object with the diff result.
35 */
36export async function compareWithGolden(): Promise<{
37 changed: number
38 total: number
39 pct: number
40 pass: boolean
41 diffFile: string | null
42} | null> {
43 const current = join(OUT_DIR, 'composite.png')
44 const golden = join(OUT_DIR, 'golden', 'composite.png')
45
46 if (!existsSync(golden)) return null
47 if (!existsSync(current)) throw new Error(`Missing composite: ${current}`)
48
49 const imgA = PNG.sync.read(readFileSync(golden))
50 const imgB = PNG.sync.read(readFileSync(current))
51
52 // If dimensions differ, treat every pixel as changed
53 if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
54 const total = Math.max(imgA.width * imgA.height, imgB.width * imgB.height)
55 return { changed: total, total, pct: 1, pass: false, diffFile: null }
56 }
57
58 const { width, height } = imgA
59 const diff = new PNG({ width, height })
60
61 const changed = pixelmatch(
62 imgA.data, imgB.data, diff.data,
63 width, height,
64 { threshold: 0.1, alpha: 0.3 },
65 )
66
67 const total = width * height
68 const pct = changed / total
69 const pass = pct <= THRESHOLD
70
71 let diffFile: string | null = null
72 if (!pass) {
73 writeFileSync(DIFF_FILE, PNG.sync.write(diff))
74 diffFile = DIFF_FILE
75 }
76
77 return { changed, total, pct, pass, diffFile }
78}
79
80// ── Run standalone ────────────────────────────────────────────────────────
81// Only execute when this file is the entry point, not when imported as a module.
82const isMain = process.argv[1] &&
83 (process.argv[1].endsWith('diff.ts') || process.argv[1].endsWith('diff.js'))
84
85if (!isMain) { /* imported — skip standalone logic */ }
86else {
87
88 const result = await compareWithGolden()
89
90 if (result === null) {
91 console.log('No golden composite found.')
92 console.log('Run `pnpm screenshot --update` to create one.')
93 process.exit(2)
94 }
95
96 const pctStr = (result.pct * 100).toFixed(2)
97 const thStr = (THRESHOLD * 100).toFixed(1)
98
99 if (result.pass) {
100 console.log(`✓ Images match (${pctStr}% changed, threshold ${thStr}%)`)
101 process.exit(0)
102 } else {
103 console.error(`✗ Images differ: ${pctStr}% of pixels changed (threshold ${thStr}%)`)
104 if (result.diffFile) {
105 console.error(` Diff saved to ${result.diffFile}`)
106 }
107 console.error(' Run `pnpm screenshot --update` to accept the new output as golden.')
108 process.exit(1)
109 }
110
111} // end isMain