diff --git a/.changeset/swap-pixelmatch-blazediff-core.md b/.changeset/swap-pixelmatch-blazediff-core.md new file mode 100644 index 000000000..455b42669 --- /dev/null +++ b/.changeset/swap-pixelmatch-blazediff-core.md @@ -0,0 +1,10 @@ +--- +"@wdio/image-comparison-core": patch +--- + +perf: swap pixelmatch for @blazediff/core in the golden-PNG comparison path + +`@blazediff/core` is a drop-in replacement for pixelmatch: same call signature +(`img1, img2, output, width, height, options`), same options, and identical +diff output semantics. It produces the same pixel-diff results ~1.5x faster with +zero dependencies, so no comparison logic changes. diff --git a/packages/image-comparison-core/package.json b/packages/image-comparison-core/package.json index 063bf7baa..03c2c872f 100644 --- a/packages/image-comparison-core/package.json +++ b/packages/image-comparison-core/package.json @@ -34,8 +34,8 @@ "watch:tsc": "pnpm run build:tsc -w" }, "dependencies": { + "@blazediff/core": "^1.9.3", "fast-png": "^8.0.0", - "pixelmatch": "^7.2.0", "@wdio/logger": "^9.29.1", "@wdio/types": "^9.29.1" }, diff --git a/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts b/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts index 86d4e9831..09fa7141e 100644 --- a/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts +++ b/packages/image-comparison-core/src/pixelmatch/compareImages.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -vi.mock('pixelmatch', () => ({ - default: vi.fn() +vi.mock('@blazediff/core', () => ({ + diff: vi.fn() })) vi.mock('../utils/imageUtils.js', () => { @@ -19,10 +19,10 @@ vi.mock('../utils/imageUtils.js', () => { }) import compareImages from './compareImages.js' -import pixelmatch from 'pixelmatch' +import { diff } from '@blazediff/core' import * as imageUtils from '../utils/imageUtils.js' -const pixelmatchFn = vi.mocked(pixelmatch) +const diffFn = vi.mocked(diff) const decodeImageFn = vi.mocked(imageUtils.decodeImage) const resizeBilinearFn = vi.mocked(imageUtils.resizeBilinear) @@ -34,7 +34,7 @@ describe('pixelmatch adapter - compareImages', () => { describe('basic comparison', () => { it('returns zero mismatch percentage when images are identical', async () => { - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) @@ -45,7 +45,7 @@ describe('pixelmatch adapter - compareImages', () => { it('returns correct mismatch percentage for a known diff count', async () => { // 100x100 = 10000 total pixels, 100 diff pixels = 1% - pixelmatchFn.mockImplementation(() => 100) + diffFn.mockImplementation(() => 100) const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) @@ -54,7 +54,7 @@ describe('pixelmatch adapter - compareImages', () => { }) it('returns analysisTime greater than or equal to 0', async () => { - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) @@ -62,7 +62,7 @@ describe('pixelmatch adapter - compareImages', () => { }) it('resolves getBuffer to a Buffer', async () => { - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) const buf = await result.getBuffer() @@ -76,7 +76,7 @@ describe('pixelmatch adapter - compareImages', () => { .mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4).fill(128), width: 100, height: 100 }) // baseline .mockReturnValueOnce({ data: actual, width: 100, height: 100 }) // actual - pixelmatchFn.mockImplementation((_img1, _img2, output: Uint8Array) => { + diffFn.mockImplementation((_img1, _img2, output: Uint8Array) => { // Mark pixel at (0,0) as a diff (magenta) output[0] = 255 output[1] = 0 @@ -107,7 +107,7 @@ describe('pixelmatch adapter - compareImages', () => { describe('diffPixels and diffBounds', () => { it('collects magenta pixels as diff pixel coordinates', async () => { - pixelmatchFn.mockImplementation((_img1, _img2, output: Uint8Array, width: number) => { + diffFn.mockImplementation((_img1, _img2, output: Uint8Array, width: number) => { // Place a magenta pixel at x=5, y=3 const pos = (3 * width + 5) * 4 output[pos] = 255 @@ -124,7 +124,7 @@ describe('pixelmatch adapter - compareImages', () => { }) it('does not count grayscale matching pixels as diff pixels', async () => { - pixelmatchFn.mockImplementation((_img1, _img2, output: Uint8Array, width: number) => { + diffFn.mockImplementation((_img1, _img2, output: Uint8Array, width: number) => { const pos = (2 * width + 10) * 4 output[pos] = 200 output[pos + 1] = 200 @@ -139,7 +139,7 @@ describe('pixelmatch adapter - compareImages', () => { }) it('computes correct diffBounds from multiple diff pixels', async () => { - pixelmatchFn.mockImplementation((_img1, _img2, output: Uint8Array, width: number) => { + diffFn.mockImplementation((_img1, _img2, output: Uint8Array, width: number) => { const mark = (x: number, y: number) => { const pos = (y * width + x) * 4 output[pos] = 255 @@ -159,7 +159,7 @@ describe('pixelmatch adapter - compareImages', () => { }) it('returns sentinel diffBounds when there are no diff pixels', async () => { - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) const result = await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) @@ -176,11 +176,11 @@ describe('pixelmatch adapter - compareImages', () => { ['alpha', { threshold: 0.063, includeAA: true }], ['colors', { threshold: 0.063, includeAA: true }], ] as const)('passes preset settings for ignore: %s', async (ignore, expected) => { - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore }) - expect(pixelmatchFn).toHaveBeenCalledWith( + expect(diffFn).toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.anything(), expect.any(Number), expect.any(Number), expect.objectContaining(expected) @@ -188,11 +188,11 @@ describe('pixelmatch adapter - compareImages', () => { }) it('passes threshold=0.063 and includeAA=true when no ignore option is given', async () => { - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) - expect(pixelmatchFn).toHaveBeenCalledWith( + expect(diffFn).toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.anything(), expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0.063, includeAA: true }) @@ -200,13 +200,13 @@ describe('pixelmatch adapter - compareImages', () => { }) it('uses last-wins preset when ignoreLess follows ignoreAntialiasing', async () => { - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: ['antialiasing', 'less'] }) - expect(pixelmatchFn).toHaveBeenCalledWith( + expect(diffFn).toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.anything(), expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0.063, includeAA: true }) @@ -214,13 +214,13 @@ describe('pixelmatch adapter - compareImages', () => { }) it('uses last-wins preset when ignoreNothing follows other flags', async () => { - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: ['antialiasing', 'less', 'nothing'] }) - expect(pixelmatchFn).toHaveBeenCalledWith( + expect(diffFn).toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.anything(), expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0, includeAA: true }) @@ -228,13 +228,13 @@ describe('pixelmatch adapter - compareImages', () => { }) it('uses last-wins preset when ignoreLess follows ignoreAlpha', async () => { - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) await compareImages(Buffer.from('img1'), Buffer.from('img2'), { ignore: ['alpha', 'less'] }) - expect(pixelmatchFn).toHaveBeenCalledWith( + expect(diffFn).toHaveBeenCalledWith( expect.anything(), expect.anything(), expect.anything(), expect.any(Number), expect.any(Number), expect.objectContaining({ threshold: 0.063, includeAA: true }) @@ -251,7 +251,7 @@ describe('pixelmatch adapter - compareImages', () => { }) let capturedPixels1: Uint8Array | undefined - pixelmatchFn.mockImplementation((img1: Uint8Array) => { + diffFn.mockImplementation((img1: Uint8Array) => { capturedPixels1 = new Uint8Array(img1) return 0 }) @@ -272,7 +272,7 @@ describe('pixelmatch adapter - compareImages', () => { }) let capturedPixels1: Uint8Array | undefined - pixelmatchFn.mockImplementation((img1: Uint8Array) => { + diffFn.mockImplementation((img1: Uint8Array) => { capturedPixels1 = new Uint8Array(img1) return 0 }) @@ -288,12 +288,12 @@ describe('pixelmatch adapter - compareImages', () => { decodeImageFn .mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4).fill(128), width: 100, height: 100 }) .mockReturnValueOnce({ data: new Uint8Array(50 * 50 * 4).fill(64), width: 50, height: 50 }) - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) // Canvas is 100×100; img2 (50×50) is padded to fill it - expect(pixelmatchFn).toHaveBeenCalledWith( + expect(diffFn).toHaveBeenCalledWith( expect.any(Object), expect.any(Object), expect.any(Uint8Array), 100, 100, expect.any(Object) ) @@ -303,12 +303,12 @@ describe('pixelmatch adapter - compareImages', () => { decodeImageFn .mockReturnValueOnce({ data: new Uint8Array(50 * 50 * 4).fill(64), width: 50, height: 50 }) .mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4).fill(128), width: 100, height: 100 }) - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) await compareImages(Buffer.from('img1'), Buffer.from('img2'), {}) // Canvas is 100×100; img1 (50×50) is padded to fill it - expect(pixelmatchFn).toHaveBeenCalledWith( + expect(diffFn).toHaveBeenCalledWith( expect.any(Object), expect.any(Object), expect.any(Uint8Array), 100, 100, expect.any(Object) ) @@ -320,7 +320,7 @@ describe('pixelmatch adapter - compareImages', () => { let capturedImg1: Uint8Array | undefined let capturedImg2: Uint8Array | undefined - pixelmatchFn.mockImplementation((img1: Uint8Array, img2: Uint8Array) => { + diffFn.mockImplementation((img1: Uint8Array, img2: Uint8Array) => { capturedImg1 = new Uint8Array(img1) capturedImg2 = new Uint8Array(img2) return 0 @@ -345,7 +345,7 @@ describe('pixelmatch adapter - compareImages', () => { decodeImageFn .mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4), width: 100, height: 100 }) .mockReturnValueOnce({ data: new Uint8Array(50 * 50 * 4), width: 50, height: 50 }) - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) await compareImages(Buffer.from('img1'), Buffer.from('img2'), { scaleToSameSize: true }) @@ -360,7 +360,7 @@ describe('pixelmatch adapter - compareImages', () => { decodeImageFn .mockReturnValueOnce({ data: new Uint8Array(50 * 50 * 4), width: 50, height: 50 }) .mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4), width: 100, height: 100 }) - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) await compareImages(Buffer.from('img1'), Buffer.from('img2'), { scaleToSameSize: true }) @@ -372,7 +372,7 @@ describe('pixelmatch adapter - compareImages', () => { }) it('does not call resizeBilinear when scaleToSameSize is false', async () => { - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) await compareImages(Buffer.from('img1'), Buffer.from('img2'), { scaleToSameSize: false }) @@ -380,7 +380,7 @@ describe('pixelmatch adapter - compareImages', () => { }) it('does not call resizeBilinear when images are the same size', async () => { - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) await compareImages(Buffer.from('img1'), Buffer.from('img2'), { scaleToSameSize: true }) @@ -390,7 +390,7 @@ describe('pixelmatch adapter - compareImages', () => { describe('direct pixelmatch mode', () => { it('forwards resolved pixelmatch settings to pixelmatch', async () => { - pixelmatchFn.mockImplementation(() => 0) + diffFn.mockImplementation(() => 0) await compareImages(Buffer.from('img1'), Buffer.from('img2'), { pixelmatch: { @@ -405,7 +405,7 @@ describe('pixelmatch adapter - compareImages', () => { }, }) - expect(pixelmatchFn.mock.calls[0]?.[5]).toMatchSnapshot() + expect(diffFn.mock.calls[0]?.[5]).toMatchSnapshot() }) it('uses custom diffColor for diff pixel detection and compositing', async () => { @@ -414,7 +414,7 @@ describe('pixelmatch adapter - compareImages', () => { .mockReturnValueOnce({ data: new Uint8Array(100 * 100 * 4).fill(128), width: 100, height: 100 }) .mockReturnValueOnce({ data: actual, width: 100, height: 100 }) - pixelmatchFn.mockImplementation((_img1, _img2, output: Uint8Array) => { + diffFn.mockImplementation((_img1, _img2, output: Uint8Array) => { output[0] = 255 output[1] = 0 output[2] = 0 @@ -443,7 +443,7 @@ describe('pixelmatch adapter - compareImages', () => { }) it('uses pixelmatch output directly when diffMask is true', async () => { - pixelmatchFn.mockImplementation((_img1, _img2, output: Uint8Array) => { + diffFn.mockImplementation((_img1, _img2, output: Uint8Array) => { output[0] = 10 output[1] = 20 output[2] = 30 diff --git a/packages/image-comparison-core/src/pixelmatch/compareImages.ts b/packages/image-comparison-core/src/pixelmatch/compareImages.ts index 0508c08b7..a5fa215e1 100644 --- a/packages/image-comparison-core/src/pixelmatch/compareImages.ts +++ b/packages/image-comparison-core/src/pixelmatch/compareImages.ts @@ -1,4 +1,4 @@ -import pixelmatch from 'pixelmatch' +import { diff } from '@blazediff/core' import { resolveComparePreset } from '../helpers/options.js' import { DEFAULT_PIXELMATCH_OPTIONS } from '../helpers/constants.js' import { applyResembleGrayscale } from './compareBrightness.js' @@ -140,7 +140,7 @@ export default async function compareImages( const outputPixels = new Uint8Array(totalPixels * 4) - const diffCount: number = pixelmatch(pixels1, pixels2, outputPixels, width, height, { + const diffCount: number = diff(pixels1, pixels2, outputPixels, width, height, { threshold: resolvedPixelmatch.threshold, includeAA: resolvedPixelmatch.includeAA, diffColor: resolvedPixelmatch.diffColor, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74974b7c8..b2bdaaa61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: packages/image-comparison-core: dependencies: + '@blazediff/core': + specifier: ^1.9.3 + version: 1.9.3 '@wdio/logger': specifier: ^9.29.1 version: 9.29.1 @@ -134,9 +137,6 @@ importers: fast-png: specifier: ^8.0.0 version: 8.0.0 - pixelmatch: - specifier: ^7.2.0 - version: 7.2.0 devDependencies: webdriverio: specifier: ^9.29.1 @@ -452,6 +452,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blazediff/core@1.9.3': + resolution: {integrity: sha512-4XGs39o9rkkBMgyr7QtI+IRIN78wZa2423C0EbSLvXK58A3WivQCwXICGbvEqBucpSdCK4re3ezQ9ko3IYQMHg==} + '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} @@ -2754,7 +2757,7 @@ packages: basic-ftp@5.0.5: resolution: {integrity: sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==} engines: {node: '>=10.0.0'} - deprecated: Security vulnerability fixed in 5.2.0, please upgrade + deprecated: Security vulnerability fixed in 5.2.1, please upgrade before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -5884,10 +5887,6 @@ packages: resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==} hasBin: true - pixelmatch@7.2.0: - resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} - hasBin: true - pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -7007,6 +7006,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -7404,6 +7404,7 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -7861,6 +7862,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.9.3': {} + '@borewit/text-codec@0.2.2': {} '@browserstack/ai-sdk-node@1.5.17': @@ -14319,10 +14322,6 @@ snapshots: dependencies: pngjs: 6.0.0 - pixelmatch@7.2.0: - dependencies: - pngjs: 7.0.0 - pkg-types@1.3.1: dependencies: confbox: 0.1.8