vitest-screenshot 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,12 +1,7 @@
1
- ## [vitest-screenshot-v1.0.0](https://github.com/bhouston/vitest-gpu/compare/vitest-screenshot-v0.1.0...vitest-screenshot-v1.0.0) (2026-09-18)
1
+ ## [vitest-screenshot-v1.0.1](https://github.com/bhouston/vitest-gpu/compare/vitest-screenshot-v1.0.0...vitest-screenshot-v1.0.1) (2026-09-18)
2
2
 
3
- ### ⚠ BREAKING CHANGES
3
+ ### Bug Fixes
4
4
 
5
- * **screenshot:** `threshold` and `maxDiffRatio` are no longer top-level options. Use
6
- `comparatorOptions: { threshold, allowedMismatchedPixelRatio }` (Vitest's names) instead.
7
- * **screenshot:** the matcher is async and must be awaited; reference paths need an image extension.
8
-
9
- ### Features
10
-
11
- * **screenshot:** accept canvases, images and any image format in toMatchScreenshot ([edaabc4](https://github.com/bhouston/vitest-gpu/commit/edaabc43ac0ab895601565129a8db38121cfa5d3)), closes [#1](https://github.com/bhouston/vitest-gpu/issues/1)
12
- * **screenshot:** vitest-style comparators with an imagemagick metrics comparator ([fd37a14](https://github.com/bhouston/vitest-gpu/commit/fd37a14651b3281ec3cdeec9cfd8f434a4233570)), closes [#3](https://github.com/bhouston/vitest-gpu/issues/3)
5
+ * **screenshot:** enforce comparator option types ([1af7d9c](https://github.com/bhouston/vitest-gpu/commit/1af7d9cefe5531e0d5ed160455aa02418de39491)), closes [#28](https://github.com/bhouston/vitest-gpu/issues/28)
6
+ * **screenshot:** reject negated assertions ([0753f31](https://github.com/bhouston/vitest-gpu/commit/0753f317972e73d9e50d7e594d1ed22db6359c4a)), closes [#22](https://github.com/bhouston/vitest-gpu/issues/22)
7
+ * ship sources referenced by package maps ([8db482e](https://github.com/bhouston/vitest-gpu/commit/8db482ed56714f375b53d9a30df3f080a86fb81d)), closes [#23](https://github.com/bhouston/vitest-gpu/issues/23)
package/README.md CHANGED
@@ -47,6 +47,7 @@ relative to `baselineDir` unless absolute.
47
47
  limited to 256 colours, so loosen the comparator if you use them.
48
48
  - A missing baseline is written on first run, except when `CI` is set, where it fails.
49
49
  - `UPDATE_SCREENSHOTS=1 vitest` (or `update: true`) rewrites baselines.
50
+ - `.not.toMatchScreenshot()` is unsupported and fails without reading pixels or writing baseline files.
50
51
  - On failure `<name>.actual.png` and `<name>.diff.png` are written to `baselineDir` (named after the test when the
51
52
  reference is in memory). Gitignore them.
52
53
 
@@ -60,11 +61,12 @@ comparators, go to `extendMatchers()` (the equivalent of `test.browser.expect.to
60
61
  extendMatchers({
61
62
  comparatorName: 'pixelmatch',
62
63
  comparatorOptions: { threshold: 0.2, allowedMismatchedPixelRatio: 0.01 },
63
- comparators: { 'my-comparator': (reference, actual, { createDiff, ...options }) => ({ pass, diff, message }) },
64
64
  });
65
65
  ```
66
66
 
67
- Per-assertion `comparatorOptions` are merged over the global ones when they name the same comparator.
67
+ Per-assertion `comparatorOptions` are merged over the global ones when they name the same comparator. When an
68
+ assertion omits `comparatorName`, its options may match any registered comparator because the global default selects
69
+ their meaning at runtime. Supplying `comparatorName` checks the options strictly against that comparator.
68
70
 
69
71
  ### `pixelmatch` (default)
70
72
 
@@ -119,6 +121,29 @@ knot.png: AE 760 (fuzz 0), PAE 0.1569, MAE 0.01042, MSE 0.0007709, RMSE 0.02777,
119
121
  A comparator has Vitest's signature: `(reference, actual, options) => { pass, diff, message }`, where `reference`
120
122
  and `actual` are `{ width, height, data }`, `options` carries `comparatorOptions` plus `createDiff`, and `diff`
121
123
  (an RGBA image or `null`) is written as `<name>.diff.png` on failure. Register it under `comparators` and select
122
- it with `comparatorName`.
124
+ it with `comparatorName`. Add its option type to `ComparatorRegistry` so registration and assertions check the
125
+ custom name and options:
126
+
127
+ ```ts
128
+ import { type Comparator, extendMatchers } from 'vitest-screenshot';
129
+
130
+ declare module 'vitest-screenshot' {
131
+ interface ComparatorRegistry {
132
+ 'red-channel': { tolerance?: number };
133
+ }
134
+ }
135
+
136
+ const redChannel: Comparator<{ tolerance?: number }> = (reference, actual, { tolerance = 0 }) => {
137
+ const difference = Math.abs(reference.data[0]! - actual.data[0]!);
138
+ return { pass: difference <= tolerance, diff: null, message: `red channel differs by ${difference}` };
139
+ };
140
+
141
+ extendMatchers({ comparators: { 'red-channel': redChannel } });
142
+
143
+ await expect(image).toMatchScreenshot('baseline.png', {
144
+ comparatorName: 'red-channel',
145
+ comparatorOptions: { tolerance: 2 },
146
+ });
147
+ ```
123
148
 
124
149
  See [`demo/test/screenshots`](../../demo/test/screenshots) for every source, format and comparator combination.
package/dist/index.d.ts CHANGED
@@ -37,16 +37,22 @@ export type PixelmatchOptions = NonNullable<Parameters<typeof pixelmatch>[5]> &
37
37
  /** Number of pixels allowed to differ. With both limits given the stricter wins; with neither, none may differ. */
38
38
  allowedMismatchedPixels?: number;
39
39
  };
40
- /** Which comparator judges the images. `pixelmatch` (default) and `metrics` are built in; others come from `extendMatchers({ comparators })`. */
40
+ /** Comparator names and their options. Augment this interface when registering a custom comparator. */
41
+ export interface ComparatorRegistry {
42
+ pixelmatch: PixelmatchOptions;
43
+ metrics: MetricsOptions;
44
+ }
45
+ type ComparatorName = Extract<keyof ComparatorRegistry, string>;
46
+ type ComparatorOptions<Name extends ComparatorName> = Extract<ComparatorRegistry[Name], object>;
47
+ /** Which registered comparator judges the images. `pixelmatch` is the default. */
41
48
  export type ComparatorSelection = {
42
- comparatorName?: 'pixelmatch';
43
- comparatorOptions?: PixelmatchOptions;
44
- } | {
45
- comparatorName: 'metrics';
46
- comparatorOptions?: MetricsOptions;
47
- } | {
48
- comparatorName: string;
49
- comparatorOptions?: Record<string, unknown>;
49
+ [Name in ComparatorName]: {
50
+ comparatorName: Name;
51
+ comparatorOptions?: ComparatorOptions<Name>;
52
+ };
53
+ }[ComparatorName] | {
54
+ comparatorName?: undefined;
55
+ comparatorOptions?: ComparatorOptions<ComparatorName>;
50
56
  };
51
57
  export type ScreenshotOptions = ComparatorSelection & {
52
58
  /** Directory relative reference paths and diff images live in. Default: `__screenshots__` beside the test file. */
@@ -56,7 +62,9 @@ export type ScreenshotOptions = ComparatorSelection & {
56
62
  };
57
63
  /** Defaults for every assertion plus custom comparators, like `test.browser.expect.toMatchScreenshot` in Vitest config. */
58
64
  export type ScreenshotConfig = ComparatorSelection & {
59
- comparators?: Record<string, Comparator<never>>;
65
+ comparators?: {
66
+ [Name in ComparatorName]?: Comparator<ComparatorOptions<Name>>;
67
+ };
60
68
  };
61
69
  declare module 'vitest' {
62
70
  interface Assertion {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,UAAU,MAAM,YAAY,CAAC;AAGpC,OAAO,EAAqB,KAAK,cAAc,EAAE,MAAM,cAAc,CAAC;AAEtE,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAE3D,0HAA0H;AAC1H,MAAM,MAAM,SAAS,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,UAAU,GAAG,iBAAiB,CAAA;CAAE,CAAC;AAEhG;;;GAGG;AACH,MAAM,MAAM,WAAW,GACnB,SAAS,GACT;IAAE,UAAU,IAAI,OAAO,CAAC,SAAS,CAAC,CAAA;CAAE,GACpC;IAAE,YAAY,IAAI,SAAS,CAAA;CAAE,GAC7B;IAAE,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE,CAAC;AAEhC,qHAAqH;AACrH,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,WAAW,CAAC;AAE7C,+GAA+G;AAC/G,MAAM,MAAM,gBAAgB,GAAG;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,SAAS,GAAG,IAAI,CAAC;IAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAAC;AAEjG,mFAAmF;AACnF,MAAM,MAAM,UAAU,CAAC,OAAO,SAAS,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,IAAI,CACzE,SAAS,EAAE,SAAS,EACpB,MAAM,EAAE,SAAS,EACjB,OAAO,EAAE,OAAO,GAAG;IAAE,UAAU,EAAE,OAAO,CAAA;CAAE,KACvC,gBAAgB,CAAC;AAEtB,uFAAuF;AACvF,MAAM,MAAM,iBAAiB,GAAG,WAAW,CAAC,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG;IAC9E,mDAAmD;IACnD,2BAA2B,CAAC,EAAE,MAAM,CAAC;IACrC,mHAAmH;IACnH,uBAAuB,CAAC,EAAE,MAAM,CAAC;CAClC,CAAC;AAEF,iJAAiJ;AACjJ,MAAM,MAAM,mBAAmB,GAC3B;IAAE,cAAc,CAAC,EAAE,YAAY,CAAC;IAAC,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;CAAE,GACxE;IAAE,cAAc,EAAE,SAAS,CAAC;IAAC,iBAAiB,CAAC,EAAE,cAAc,CAAA;CAAE,GACjE;IAAE,cAAc,EAAE,MAAM,CAAC;IAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC;AAE5E,MAAM,MAAM,iBAAiB,GAAG,mBAAmB,GAAG;IACpD,mHAAmH;IACnH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iFAAiF;IACjF,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAEF,2HAA2H;AAC3H,MAAM,MAAM,gBAAgB,GAAG,mBAAmB,GAAG;IAAE,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,KAAK,CAAC,CAAC,CAAA;CAAE,CAAC;AAEzG,OAAO,QAAQ,QAAQ,CAAC;IACtB,UAAU,SAAS;QACjB,iBAAiB,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;KACrF;CACF;AA+BD,2FAA2F;AAC3F,eAAO,MAAM,oBAAoB,EAAE,UAAU,CAAC,iBAAiB,CAgB9D,CAAC;AAEF,wBAAgB,cAAc,CAAC,MAAM,GAAE,gBAAqB,GAAG,IAAI,CAiDlE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -54,6 +54,8 @@ export function extendMatchers(config = {}) {
54
54
  };
55
55
  expect.extend({
56
56
  async toMatchScreenshot(received, reference, options = {}) {
57
+ if (this.isNot)
58
+ throw new Error('`.not.toMatchScreenshot()` is not supported');
57
59
  const dir = options.baselineDir ?? join(dirname(this.testPath), '__screenshots__');
58
60
  const actual = await toRgba(received);
59
61
  let file;
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitest-screenshot",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "toMatchScreenshot(): pixel-diff a canvas, Image or ImageData against a committed png/jpg/gif/webp baseline in Vitest",
5
5
  "keywords": [
6
6
  "matcher",
@@ -27,6 +27,8 @@
27
27
  "dist/**/*.js.map",
28
28
  "dist/**/*.d.ts",
29
29
  "dist/**/*.d.ts.map",
30
+ "src/**/*.ts",
31
+ "!src/**/*.test.ts",
30
32
  "CHANGELOG.md"
31
33
  ],
32
34
  "type": "module",
@@ -43,7 +45,7 @@
43
45
  },
44
46
  "scripts": {
45
47
  "build": "tsc",
46
- "tsc": "tsc"
48
+ "tsc": "tsc && tsc -p tsconfig.type-tests.json"
47
49
  },
48
50
  "dependencies": {
49
51
  "pixelmatch": "^7.1.0",
package/src/index.ts ADDED
@@ -0,0 +1,179 @@
1
+ import { existsSync, mkdirSync } from 'node:fs';
2
+ import { basename, dirname, extname, isAbsolute, join } from 'node:path';
3
+ import pixelmatch from 'pixelmatch';
4
+ import sharp, { type FormatEnum } from 'sharp';
5
+ import { expect } from 'vitest';
6
+ import { metricsComparator, type MetricsOptions } from './metrics.js';
7
+
8
+ export type { Metric, MetricsOptions } from './metrics.js';
9
+
10
+ /** Anything with RGBA8 pixels, top row first: `ImageData`, node-webgl's `canvas.getImageData()`, or a WebGPU readback. */
11
+ export type RgbaImage = { width: number; height: number; data: Uint8Array | Uint8ClampedArray };
12
+
13
+ /**
14
+ * A source of pixels: an `RgbaImage` / `ImageData`, a WebGPU `HeadlessCanvas` (`readPixels()`), a node-webgl
15
+ * `Canvas` (`getImageData()`), or a node-webgl `Image` (decoded with `decode()`).
16
+ */
17
+ export type ImageSource =
18
+ | RgbaImage
19
+ | { readPixels(): Promise<RgbaImage> }
20
+ | { getImageData(): RgbaImage }
21
+ | { decode(): Promise<void> };
22
+
23
+ /** A baseline: a file path with extension (relative to `baselineDir` or absolute), or an in-memory `ImageSource`. */
24
+ export type Reference = string | ImageSource;
25
+
26
+ /** What a comparator returns, as in Vitest browser mode: `diff` is written next to the baseline on failure. */
27
+ export type ComparatorResult = { pass: boolean; diff: RgbaImage | null; message: string | null };
28
+
29
+ /** A comparator, with the signature Vitest browser mode uses for `comparators`. */
30
+ export type Comparator<Options extends object = Record<string, unknown>> = (
31
+ reference: RgbaImage,
32
+ actual: RgbaImage,
33
+ options: Options & { createDiff: boolean },
34
+ ) => ComparatorResult;
35
+
36
+ /** Options of the default `pixelmatch` comparator, named as in Vitest browser mode. */
37
+ export type PixelmatchOptions = NonNullable<Parameters<typeof pixelmatch>[5]> & {
38
+ /** Fraction (0..1) of pixels allowed to differ. */
39
+ allowedMismatchedPixelRatio?: number;
40
+ /** Number of pixels allowed to differ. With both limits given the stricter wins; with neither, none may differ. */
41
+ allowedMismatchedPixels?: number;
42
+ };
43
+
44
+ /** Comparator names and their options. Augment this interface when registering a custom comparator. */
45
+ export interface ComparatorRegistry {
46
+ pixelmatch: PixelmatchOptions;
47
+ metrics: MetricsOptions;
48
+ }
49
+
50
+ type ComparatorName = Extract<keyof ComparatorRegistry, string>;
51
+ type ComparatorOptions<Name extends ComparatorName> = Extract<ComparatorRegistry[Name], object>;
52
+
53
+ /** Which registered comparator judges the images. `pixelmatch` is the default. */
54
+ export type ComparatorSelection =
55
+ | {
56
+ [Name in ComparatorName]: {
57
+ comparatorName: Name;
58
+ comparatorOptions?: ComparatorOptions<Name>;
59
+ };
60
+ }[ComparatorName]
61
+ | { comparatorName?: undefined; comparatorOptions?: ComparatorOptions<ComparatorName> };
62
+
63
+ export type ScreenshotOptions = ComparatorSelection & {
64
+ /** Directory relative reference paths and diff images live in. Default: `__screenshots__` beside the test file. */
65
+ baselineDir?: string;
66
+ /** Overwrite the baseline file. Default: `UPDATE_SCREENSHOTS` env var is set. */
67
+ update?: boolean;
68
+ };
69
+
70
+ /** Defaults for every assertion plus custom comparators, like `test.browser.expect.toMatchScreenshot` in Vitest config. */
71
+ export type ScreenshotConfig = ComparatorSelection & {
72
+ comparators?: { [Name in ComparatorName]?: Comparator<ComparatorOptions<Name>> };
73
+ };
74
+
75
+ declare module 'vitest' {
76
+ interface Assertion {
77
+ toMatchScreenshot(reference: Reference, options?: ScreenshotOptions): Promise<void>;
78
+ }
79
+ }
80
+
81
+ const result = (pass: boolean, message: string) => ({ pass, message: () => message });
82
+
83
+ const raw = ({ width, height, data }: RgbaImage) => sharp(data, { raw: { width, height, channels: 4 } });
84
+
85
+ /** Decodes any format sharp reads (png, jpg, gif, webp, ...) into RGBA8. */
86
+ const readImage = async (file: string): Promise<RgbaImage> => {
87
+ const { data, info } = await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
88
+ return { width: info.width, height: info.height, data };
89
+ };
90
+
91
+ /** Encodes by file extension; lossy formats (jpg, webp) alter pixels on write. */
92
+ const writeImage = (file: string, image: RgbaImage) => {
93
+ mkdirSync(dirname(file), { recursive: true });
94
+ return raw(image)
95
+ .toFormat(extname(file).slice(1) as keyof FormatEnum)
96
+ .toFile(file);
97
+ };
98
+
99
+ const toRgba = async (source: ImageSource): Promise<RgbaImage> => {
100
+ if ('data' in source) return source;
101
+ if ('readPixels' in source) return source.readPixels();
102
+ if ('getImageData' in source) return source.getImageData();
103
+ await source.decode();
104
+ // node-webgl's Image and ImageBitmap expose decoded pixels this way (it is what their own texImage2D uses).
105
+ const rgba = (source as { _toRGBA8?: () => RgbaImage })._toRGBA8?.();
106
+ if (!rgba) throw new Error('unsupported image source: expected RGBA data, readPixels(), getImageData() or an Image');
107
+ return rgba;
108
+ };
109
+
110
+ /** `comparatorName: 'pixelmatch'` (default): Vitest browser mode's built-in comparison. */
111
+ export const pixelmatchComparator: Comparator<PixelmatchOptions> = (
112
+ reference,
113
+ actual,
114
+ { createDiff, allowedMismatchedPixelRatio, allowedMismatchedPixels, ...pixelmatchOptions },
115
+ ) => {
116
+ const { width, height } = actual;
117
+ const total = width * height;
118
+ const diff = createDiff ? new Uint8Array(total * 4) : undefined;
119
+ const count = pixelmatch(reference.data, actual.data, diff, width, height, pixelmatchOptions);
120
+ const limits = [allowedMismatchedPixels, allowedMismatchedPixelRatio && allowedMismatchedPixelRatio * total];
121
+ const allowed = limits.some((l) => l !== undefined) ? Math.min(...limits.filter((l) => l !== undefined)) : 0;
122
+ return {
123
+ pass: count <= allowed,
124
+ diff: diff ? { width, height, data: diff } : null,
125
+ message: `${count} pixels (${((count / total) * 100).toFixed(3)}%) differ, ${Math.floor(allowed)} allowed`,
126
+ };
127
+ };
128
+
129
+ export function extendMatchers(config: ScreenshotConfig = {}): void {
130
+ const comparators = {
131
+ pixelmatch: pixelmatchComparator,
132
+ metrics: metricsComparator,
133
+ ...config.comparators,
134
+ } as unknown as Record<string, Comparator>;
135
+ expect.extend({
136
+ async toMatchScreenshot(received: ImageSource, reference: Reference, options: ScreenshotOptions = {}) {
137
+ if (this.isNot) throw new Error('`.not.toMatchScreenshot()` is not supported');
138
+ const dir = options.baselineDir ?? join(dirname(this.testPath!), '__screenshots__');
139
+ const actual = await toRgba(received);
140
+ let file: string | undefined;
141
+ let baseline: RgbaImage;
142
+ if (typeof reference === 'string') {
143
+ if (!extname(reference))
144
+ throw new Error(`reference "${reference}" needs an image extension, e.g. "${reference}.png"`);
145
+ file = isAbsolute(reference) ? reference : join(dir, reference);
146
+ const update = options.update ?? Boolean(process.env.UPDATE_SCREENSHOTS);
147
+ if (update || (!existsSync(file) && !process.env.CI)) {
148
+ await writeImage(file, actual);
149
+ return result(true, `wrote baseline ${file}`);
150
+ }
151
+ if (!existsSync(file)) return result(false, `missing baseline ${file}; run with UPDATE_SCREENSHOTS=1`);
152
+ baseline = await readImage(file);
153
+ } else {
154
+ baseline = await toRgba(reference);
155
+ }
156
+ const label = file ? basename(file) : this.currentTestName!;
157
+ if (baseline.width !== actual.width || baseline.height !== actual.height) {
158
+ return result(
159
+ false,
160
+ `${label}: size mismatch: baseline ${baseline.width}x${baseline.height}, actual ${actual.width}x${actual.height}`,
161
+ );
162
+ }
163
+ const name = options.comparatorName ?? config.comparatorName ?? 'pixelmatch';
164
+ const comparator = comparators[name];
165
+ if (!comparator) throw new Error(`unknown comparator "${name}"; known: ${Object.keys(comparators).join(', ')}`);
166
+ const inherited = name === (config.comparatorName ?? 'pixelmatch') ? config.comparatorOptions : undefined;
167
+ const { pass, diff, message } = comparator(baseline, actual, {
168
+ ...inherited,
169
+ ...options.comparatorOptions,
170
+ createDiff: true,
171
+ });
172
+ const stem = join(dir, file ? basename(file, extname(file)) : label.replace(/[^\w-]+/g, '_'));
173
+ const seeDiff = !pass && diff ? `; see ${stem}.diff.png` : '';
174
+ if (!pass)
175
+ await Promise.all([writeImage(`${stem}.actual.png`, actual), diff && writeImage(`${stem}.diff.png`, diff)]);
176
+ return result(pass, `${label}: ${message ?? (pass ? 'matches' : 'differs')}${seeDiff}`);
177
+ },
178
+ });
179
+ }
package/src/metrics.ts ADDED
@@ -0,0 +1,187 @@
1
+ import type { Comparator, RgbaImage } from './index.js';
2
+
3
+ /** ImageMagick `compare -metric` names. Upper bounds: AE PAE MAE MSE RMSE DSSIM. Lower bounds: PSNR SSIM. */
4
+ export type Metric = 'AE' | 'PAE' | 'MAE' | 'MSE' | 'RMSE' | 'PSNR' | 'SSIM' | 'DSSIM';
5
+
6
+ export type MetricsOptions = {
7
+ /** ImageMagick `-fuzz`: per-channel RMS colour distance (0..1) within which two pixels count as equal. Default 0. */
8
+ fuzz?: number;
9
+ /** Absolute error: pixels differing by more than `fuzz`. Default bound when no metric is given: 0. */
10
+ AE?: number;
11
+ /** Peak absolute error, 0..1. */
12
+ PAE?: number;
13
+ /** Mean absolute error, 0..1. */
14
+ MAE?: number;
15
+ /** Mean squared error, 0..1. */
16
+ MSE?: number;
17
+ /** Root mean squared error, 0..1. */
18
+ RMSE?: number;
19
+ /** Peak signal-to-noise ratio in dB (lower bound). `Infinity` for identical images. */
20
+ PSNR?: number;
21
+ /** Structural similarity, 1 = identical (lower bound). Gaussian 11x11 window, sigma 1.5, over RGB. */
22
+ SSIM?: number;
23
+ /** Structural dissimilarity, `(1 - SSIM) / 2`. */
24
+ DSSIM?: number;
25
+ };
26
+
27
+ const LOWER_IS_WORSE = new Set<Metric>(['PSNR', 'SSIM']);
28
+ const METRICS = new Set<Metric>(['AE', 'PAE', 'MAE', 'MSE', 'RMSE', 'PSNR', 'SSIM', 'DSSIM']);
29
+
30
+ /**
31
+ * ImageMagick's IsFuzzyEquivalencePixel: alpha must be within `fuzz`, then the colour distance, scaled by both
32
+ * alphas (a 4D cone: transparent pixels always match), must be within `fuzz` per channel RMS.
33
+ */
34
+ const fuzzyEqual = (a: RgbaImage['data'], b: RgbaImage['data'], i: number, fuzz2: number) => {
35
+ const sa = a[i + 3]! / 255;
36
+ const sb = b[i + 3]! / 255;
37
+ const dAlpha = (sa - sb) ** 2;
38
+ if (dAlpha > fuzz2) return false;
39
+ const scale = sa * sb;
40
+ if (scale <= Number.EPSILON) return true;
41
+ let d = 3 * dAlpha;
42
+ for (let c = 0; c < 3; c++) d += scale * ((a[i + c]! - b[i + c]!) / 255) ** 2;
43
+ return d <= 3 * fuzz2;
44
+ };
45
+
46
+ /**
47
+ * AE, PAE, MAE, MSE, RMSE and PSNR in one pass, matching `magick compare` for RGBA images: colour channels are
48
+ * alpha-premultiplied and all four channels are averaged. Also returns which pixels differ by more than `fuzz`.
49
+ */
50
+ export function distortion(a: RgbaImage, b: RgbaImage, fuzz: number) {
51
+ const n = a.width * a.height;
52
+ const mask = new Uint8Array(n);
53
+ const fuzz2 = fuzz * fuzz;
54
+ let AE = 0;
55
+ let PAE = 0;
56
+ let sumAbs = 0;
57
+ let sumSq = 0;
58
+ for (let p = 0, i = 0; p < n; p++, i += 4) {
59
+ if (!fuzzyEqual(a.data, b.data, i, fuzz2)) {
60
+ mask[p] = 1;
61
+ AE++;
62
+ }
63
+ const sa = a.data[i + 3]! / 255;
64
+ const sb = b.data[i + 3]! / 255;
65
+ for (let c = 0; c < 4; c++) {
66
+ const d = c === 3 ? Math.abs(sa - sb) : Math.abs((a.data[i + c]! / 255) * sa - (b.data[i + c]! / 255) * sb);
67
+ if (d > PAE) PAE = d;
68
+ sumAbs += d;
69
+ sumSq += d * d;
70
+ }
71
+ }
72
+ const MSE = sumSq / (n * 4);
73
+ return {
74
+ AE,
75
+ PAE,
76
+ MAE: sumAbs / (n * 4),
77
+ MSE,
78
+ RMSE: Math.sqrt(MSE),
79
+ PSNR: MSE > 0 ? 10 * Math.log10(1 / MSE) : Infinity,
80
+ mask,
81
+ };
82
+ }
83
+
84
+ /** Gaussian taps for an 11x11 window with sigma 1.5, normalised to sum 1 (Wang et al. 2004). */
85
+ const KERNEL = (() => {
86
+ const k = Float64Array.from({ length: 11 }, (_, i) => Math.exp(-((i - 5) ** 2) / (2 * 1.5 * 1.5)));
87
+ const sum = k.reduce((s, v) => s + v, 0);
88
+ return k.map((v) => v / sum);
89
+ })();
90
+
91
+ /** Separable Gaussian blur of a 3-channel float image, clamping to edge pixels. */
92
+ const blur = (src: Float32Array, width: number, height: number) => {
93
+ const pass = (input: Float32Array, dx: number, dy: number) => {
94
+ const out = new Float32Array(input.length);
95
+ for (let y = 0; y < height; y++)
96
+ for (let x = 0; x < width; x++)
97
+ for (let c = 0; c < 3; c++) {
98
+ let s = 0;
99
+ for (let k = -5; k <= 5; k++) {
100
+ const sx = Math.min(width - 1, Math.max(0, x + k * dx));
101
+ const sy = Math.min(height - 1, Math.max(0, y + k * dy));
102
+ s += KERNEL[k + 5]! * input[(sy * width + sx) * 3 + c]!;
103
+ }
104
+ out[(y * width + x) * 3 + c] = s;
105
+ }
106
+ return out;
107
+ };
108
+ return pass(pass(src, 1, 0), 0, 1);
109
+ };
110
+
111
+ const mul = (x: Float32Array, y: Float32Array) => x.map((v, i) => v * y[i]!);
112
+
113
+ /** Mean SSIM over RGB (K1 0.01, K2 0.03), the textbook definition also used by ssim.js and Playwright. */
114
+ export function ssim(a: RgbaImage, b: RgbaImage): number {
115
+ const { width, height } = a;
116
+ const rgb = (img: RgbaImage) =>
117
+ Float32Array.from({ length: width * height * 3 }, (_, i) => img.data[i + Math.floor(i / 3)]! / 255);
118
+ const fa = rgb(a);
119
+ const fb = rgb(b);
120
+ const [ma, mb, maa, mbb, mab] = [fa, fb, mul(fa, fa), mul(fb, fb), mul(fa, fb)].map((img) =>
121
+ blur(img, width, height),
122
+ ) as Float32Array[];
123
+ const c1 = 0.01 ** 2;
124
+ const c2 = 0.03 ** 2;
125
+ let sum = 0;
126
+ for (let i = 0; i < fa.length; i++) {
127
+ const va = maa![i]! - ma![i]! ** 2;
128
+ const vb = mbb![i]! - mb![i]! ** 2;
129
+ const cov = mab![i]! - ma![i]! * mb![i]!;
130
+ sum += ((2 * ma![i]! * mb![i]! + c1) * (2 * cov + c2)) / ((ma![i]! ** 2 + mb![i]! ** 2 + c1) * (va + vb + c2));
131
+ }
132
+ return sum / fa.length;
133
+ }
134
+
135
+ /** Diff image in pixelmatch's style: the actual image faded towards white, differing pixels in red. */
136
+ const diffImage = (actual: RgbaImage, mask: Uint8Array): RgbaImage => {
137
+ const data = new Uint8Array(actual.data.length);
138
+ for (let p = 0, i = 0; p < mask.length; p++, i += 4) {
139
+ if (mask[p]) data.set([255, 0, 0, 255], i);
140
+ else {
141
+ const grey =
142
+ 255 - 0.1 * (255 - (0.299 * actual.data[i]! + 0.587 * actual.data[i + 1]! + 0.114 * actual.data[i + 2]!));
143
+ data.set([grey, grey, grey, 255], i);
144
+ }
145
+ }
146
+ return { width: actual.width, height: actual.height, data };
147
+ };
148
+
149
+ const fmt = (n: number) => (Number.isFinite(n) ? String(Number(n.toPrecision(4))) : String(n));
150
+
151
+ /**
152
+ * `comparatorName: 'metrics'`: ImageMagick `compare -metric` bounds. Give any subset; all must pass. With no
153
+ * bound given, requires `AE: 0` (an exact match, like `compare -metric AE`).
154
+ */
155
+ export const metricsComparator: Comparator<MetricsOptions> = (
156
+ reference,
157
+ actual,
158
+ { createDiff, fuzz = 0, ...bounds },
159
+ ) => {
160
+ for (const key of Object.keys(bounds)) {
161
+ if (!METRICS.has(key as Metric))
162
+ throw new Error(`unknown metric "${key}"; expected one of ${[...METRICS].join(', ')}`);
163
+ }
164
+ const wanted = (Object.keys(bounds) as Metric[]).filter((k) => bounds[k] !== undefined);
165
+ if (wanted.length === 0) {
166
+ bounds.AE = 0;
167
+ wanted.push('AE');
168
+ }
169
+ const values: Partial<Record<Metric, number>> & ReturnType<typeof distortion> = distortion(reference, actual, fuzz);
170
+ if (wanted.includes('SSIM') || wanted.includes('DSSIM')) {
171
+ values.SSIM = ssim(reference, actual);
172
+ values.DSSIM = (1 - values.SSIM) / 2;
173
+ }
174
+ const failed = wanted.filter((k) => (LOWER_IS_WORSE.has(k) ? values[k]! < bounds[k]! : values[k]! > bounds[k]!));
175
+ const report = [...METRICS]
176
+ .filter((k) => values[k] !== undefined)
177
+ .map((k) => `${k} ${fmt(values[k]!)}${k === 'AE' ? ` (fuzz ${fmt(fuzz)})` : ''}`)
178
+ .join(', ');
179
+ const why = failed
180
+ .map((k) => `${k} ${fmt(values[k]!)} ${LOWER_IS_WORSE.has(k) ? '<' : '>'} ${fmt(bounds[k]!)}`)
181
+ .join(', ');
182
+ return {
183
+ pass: failed.length === 0,
184
+ diff: createDiff ? diffImage(actual, values.mask) : null,
185
+ message: failed.length ? `${report}; failed: ${why}` : report,
186
+ };
187
+ };