diff --git a/package-lock.json b/package-lock.json index bf73e760db..84c4f02ea0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,6 @@ "@types/minimatch": "^3.0.5", "@types/ms": "^0.7.31", "@types/node": "=14.17.15", - "@types/pixelmatch": "^5.2.4", "@types/pngjs": "^6.0.1", "@types/progress": "^2.0.5", "@types/proper-lockfile": "^4.1.2", @@ -1322,15 +1321,6 @@ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, - "node_modules/@types/pixelmatch": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@types/pixelmatch/-/pixelmatch-5.2.4.tgz", - "integrity": "sha512-HDaSHIAv9kwpMN7zlmwfTv6gax0PiporJOipcrGsVNF3Ba+kryOZc0Pio5pn6NhisgWr7TaajlPEKTbTAypIBQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/pngjs": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.1.tgz", @@ -4923,25 +4913,6 @@ "node": ">=6" } }, - "node_modules/pixelmatch": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.2.1.tgz", - "integrity": "sha512-WjcAdYSnKrrdDdqTcVEY7aB7UhhwjYQKYhHiBXdJef0MOaQeYpUdQ+iVyBLa5YBKS8MPVPPMX7rpOByISLpeEQ==", - "dependencies": { - "pngjs": "^4.0.1" - }, - "bin": { - "pixelmatch": "bin/pixelmatch" - } - }, - "node_modules/pixelmatch/node_modules/pngjs": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-4.0.1.tgz", - "integrity": "sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg==", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/pkginfo": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz", @@ -6214,7 +6185,6 @@ "https-proxy-agent": "5.0.0", "jpeg-js": "0.4.3", "mime": "3.0.0", - "pixelmatch": "5.2.1", "pngjs": "6.0.0", "progress": "2.0.3", "proper-lockfile": "4.1.2", @@ -6243,7 +6213,7 @@ }, "packages/playwright-ct-react": { "name": "@playwright/experimental-ct-react", - "version": "0.0.2", + "version": "0.0.3", "license": "Apache-2.0", "devDependencies": { "@playwright/test": "1.22.0-next" @@ -6254,7 +6224,7 @@ }, "packages/playwright-ct-svelte": { "name": "@playwright/experimental-ct-svelte", - "version": "0.0.2", + "version": "0.0.3", "license": "Apache-2.0", "devDependencies": { "@playwright/test": "1.22.0-next" @@ -6265,7 +6235,7 @@ }, "packages/playwright-ct-vue": { "name": "@playwright/experimental-ct-vue", - "version": "0.0.2", + "version": "0.0.3", "license": "Apache-2.0", "devDependencies": { "@playwright/test": "1.22.0-next" @@ -7370,15 +7340,6 @@ "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==", "dev": true }, - "@types/pixelmatch": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@types/pixelmatch/-/pixelmatch-5.2.4.tgz", - "integrity": "sha512-HDaSHIAv9kwpMN7zlmwfTv6gax0PiporJOipcrGsVNF3Ba+kryOZc0Pio5pn6NhisgWr7TaajlPEKTbTAypIBQ==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/pngjs": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.1.tgz", @@ -9992,21 +9953,6 @@ "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", "dev": true }, - "pixelmatch": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.2.1.tgz", - "integrity": "sha512-WjcAdYSnKrrdDdqTcVEY7aB7UhhwjYQKYhHiBXdJef0MOaQeYpUdQ+iVyBLa5YBKS8MPVPPMX7rpOByISLpeEQ==", - "requires": { - "pngjs": "^4.0.1" - }, - "dependencies": { - "pngjs": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-4.0.1.tgz", - "integrity": "sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg==" - } - } - }, "pkginfo": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/pkginfo/-/pkginfo-0.3.1.tgz", @@ -10035,7 +9981,6 @@ "https-proxy-agent": "5.0.0", "jpeg-js": "0.4.3", "mime": "3.0.0", - "pixelmatch": "5.2.1", "pngjs": "6.0.0", "progress": "2.0.3", "proper-lockfile": "4.1.2", diff --git a/package.json b/package.json index ce82f8de0f..a264f30995 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "@types/minimatch": "^3.0.5", "@types/ms": "^0.7.31", "@types/node": "=14.17.15", - "@types/pixelmatch": "^5.2.4", "@types/pngjs": "^6.0.1", "@types/progress": "^2.0.5", "@types/proper-lockfile": "^4.1.2", diff --git a/packages/playwright-core/package.json b/packages/playwright-core/package.json index c4e9cc05a4..9f910d890c 100644 --- a/packages/playwright-core/package.json +++ b/packages/playwright-core/package.json @@ -49,7 +49,6 @@ "https-proxy-agent": "5.0.0", "jpeg-js": "0.4.3", "mime": "3.0.0", - "pixelmatch": "5.2.1", "pngjs": "6.0.0", "progress": "2.0.3", "proper-lockfile": "4.1.2", diff --git a/packages/playwright-core/src/third_party/pixelmatch.js b/packages/playwright-core/src/third_party/pixelmatch.js new file mode 100644 index 0000000000..152fe64561 --- /dev/null +++ b/packages/playwright-core/src/third_party/pixelmatch.js @@ -0,0 +1,255 @@ +/** + * + * ISC License + * + * Copyright (c) 2019, Mapbox + + * Permission to use, copy, modify, and/or distribute this software for any purpose + * with or without fee is hereby granted, provided that the above copyright notice + * and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + * FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS + * OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER + * TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF + * THIS SOFTWARE. + */ + +'use strict'; + +module.exports = pixelmatch; + +const defaultOptions = { + threshold: 0.1, // matching threshold (0 to 1); smaller is more sensitive + includeAA: false, // whether to skip anti-aliasing detection + alpha: 0.1, // opacity of original image in diff output + aaColor: [255, 255, 0], // color of anti-aliased pixels in diff output + diffColor: [255, 0, 0], // color of different pixels in diff output + diffColorAlt: null, // whether to detect dark on light differences between img1 and img2 and set an alternative color to differentiate between the two + diffMask: false // draw the diff over a transparent background (a mask) +}; + +function pixelmatch(img1, img2, output, width, height, options) { + + if (!isPixelData(img1) || !isPixelData(img2) || (output && !isPixelData(output))) + throw new Error('Image data: Uint8Array, Uint8ClampedArray or Buffer expected.'); + + if (img1.length !== img2.length || (output && output.length !== img1.length)) + throw new Error('Image sizes do not match.'); + + if (img1.length !== width * height * 4) throw new Error('Image data size does not match width/height.'); + + options = Object.assign({}, defaultOptions, options); + + // check if images are identical + const len = width * height; + const a32 = new Uint32Array(img1.buffer, img1.byteOffset, len); + const b32 = new Uint32Array(img2.buffer, img2.byteOffset, len); + let identical = true; + + for (let i = 0; i < len; i++) { + if (a32[i] !== b32[i]) { identical = false; break; } + } + if (identical) { // fast path if identical + if (output && !options.diffMask) { + for (let i = 0; i < len; i++) drawGrayPixel(img1, 4 * i, options.alpha, output); + } + return 0; + } + + // maximum acceptable square distance between two colors; + // 35215 is the maximum possible value for the YIQ difference metric + const maxDelta = 35215 * options.threshold * options.threshold; + let diff = 0; + + // compare each pixel of one image against the other one + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + + const pos = (y * width + x) * 4; + + // squared YUV distance between colors at this pixel position, negative if the img2 pixel is darker + const delta = colorDelta(img1, img2, pos, pos); + + // the color difference is above the threshold + if (Math.abs(delta) > maxDelta) { + // check it's a real rendering difference or just anti-aliasing + if (!options.includeAA && (antialiased(img1, x, y, width, height, img2) || + antialiased(img2, x, y, width, height, img1))) { + // one of the pixels is anti-aliasing; draw as yellow and do not count as difference + // note that we do not include such pixels in a mask + if (output && !options.diffMask) drawPixel(output, pos, ...options.aaColor); + + } else { + // found substantial difference not caused by anti-aliasing; draw it as such + if (output) { + drawPixel(output, pos, ...(delta < 0 && options.diffColorAlt || options.diffColor)); + } + diff++; + } + + } else if (output) { + // pixels are similar; draw background as grayscale image blended with white + if (!options.diffMask) drawGrayPixel(img1, pos, options.alpha, output); + } + } + } + + // return the number of different pixels + return diff; +} + +function isPixelData(arr) { + // work around instanceof Uint8Array not working properly in some Jest environments + return ArrayBuffer.isView(arr) && arr.constructor.BYTES_PER_ELEMENT === 1; +} + +// check if a pixel is likely a part of anti-aliasing; +// based on "Anti-aliased Pixel and Intensity Slope Detector" paper by V. Vysniauskas, 2009 + +function antialiased(img, x1, y1, width, height, img2) { + const x0 = Math.max(x1 - 1, 0); + const y0 = Math.max(y1 - 1, 0); + const x2 = Math.min(x1 + 1, width - 1); + const y2 = Math.min(y1 + 1, height - 1); + const pos = (y1 * width + x1) * 4; + let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0; + let min = 0; + let max = 0; + let minX, minY, maxX, maxY; + + // go through 8 adjacent pixels + for (let x = x0; x <= x2; x++) { + for (let y = y0; y <= y2; y++) { + if (x === x1 && y === y1) continue; + + // brightness delta between the center pixel and adjacent one + const delta = colorDelta(img, img, pos, (y * width + x) * 4, true); + + // count the number of equal, darker and brighter adjacent pixels + if (delta === 0) { + zeroes++; + // if found more than 2 equal siblings, it's definitely not anti-aliasing + if (zeroes > 2) return false; + + // remember the darkest pixel + } else if (delta < min) { + min = delta; + minX = x; + minY = y; + + // remember the brightest pixel + } else if (delta > max) { + max = delta; + maxX = x; + maxY = y; + } + } + } + + // if there are no both darker and brighter pixels among siblings, it's not anti-aliasing + if (min === 0 || max === 0) return false; + + // if either the darkest or the brightest pixel has 3+ equal siblings in both images + // (definitely not anti-aliased), this pixel is anti-aliased + return (hasManySiblings(img, minX, minY, width, height) && hasManySiblings(img2, minX, minY, width, height)) || + (hasManySiblings(img, maxX, maxY, width, height) && hasManySiblings(img2, maxX, maxY, width, height)); +} + +// check if a pixel has 3+ adjacent pixels of the same color. +function hasManySiblings(img, x1, y1, width, height) { + const x0 = Math.max(x1 - 1, 0); + const y0 = Math.max(y1 - 1, 0); + const x2 = Math.min(x1 + 1, width - 1); + const y2 = Math.min(y1 + 1, height - 1); + const pos = (y1 * width + x1) * 4; + let zeroes = x1 === x0 || x1 === x2 || y1 === y0 || y1 === y2 ? 1 : 0; + + // go through 8 adjacent pixels + for (let x = x0; x <= x2; x++) { + for (let y = y0; y <= y2; y++) { + if (x === x1 && y === y1) continue; + + const pos2 = (y * width + x) * 4; + if (img[pos] === img[pos2] && + img[pos + 1] === img[pos2 + 1] && + img[pos + 2] === img[pos2 + 2] && + img[pos + 3] === img[pos2 + 3]) zeroes++; + + if (zeroes > 2) return true; + } + } + + return false; +} + +// calculate color difference according to the paper "Measuring perceived color difference +// using YIQ NTSC transmission color space in mobile applications" by Y. Kotsarenko and F. Ramos + +function colorDelta(img1, img2, k, m, yOnly) { + let r1 = img1[k + 0]; + let g1 = img1[k + 1]; + let b1 = img1[k + 2]; + let a1 = img1[k + 3]; + + let r2 = img2[m + 0]; + let g2 = img2[m + 1]; + let b2 = img2[m + 2]; + let a2 = img2[m + 3]; + + if (a1 === a2 && r1 === r2 && g1 === g2 && b1 === b2) return 0; + + if (a1 < 255) { + a1 /= 255; + r1 = blend(r1, a1); + g1 = blend(g1, a1); + b1 = blend(b1, a1); + } + + if (a2 < 255) { + a2 /= 255; + r2 = blend(r2, a2); + g2 = blend(g2, a2); + b2 = blend(b2, a2); + } + + const y1 = rgb2y(r1, g1, b1); + const y2 = rgb2y(r2, g2, b2); + const y = y1 - y2; + + if (yOnly) return y; // brightness difference only + + const i = rgb2i(r1, g1, b1) - rgb2i(r2, g2, b2); + const q = rgb2q(r1, g1, b1) - rgb2q(r2, g2, b2); + + const delta = 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q; + + // encode whether the pixel lightens or darkens in the sign + return y1 > y2 ? -delta : delta; +} + +function rgb2y(r, g, b) { return r * 0.29889531 + g * 0.58662247 + b * 0.11448223; } +function rgb2i(r, g, b) { return r * 0.59597799 - g * 0.27417610 - b * 0.32180189; } +function rgb2q(r, g, b) { return r * 0.21147017 - g * 0.52261711 + b * 0.31114694; } + +// blend semi-transparent color with white +function blend(c, a) { + return 255 + (c - 255) * a; +} + +function drawPixel(output, pos, r, g, b) { + output[pos + 0] = r; + output[pos + 1] = g; + output[pos + 2] = b; + output[pos + 3] = 255; +} + +function drawGrayPixel(img, i, alpha, output) { + const r = img[i + 0]; + const g = img[i + 1]; + const b = img[i + 2]; + const val = blend(rgb2y(r, g, b), alpha * img[i + 3] / 255); + drawPixel(output, i, val, val, val); +} diff --git a/packages/playwright-core/src/utils/DEPS.list b/packages/playwright-core/src/utils/DEPS.list index dec0c0933d..a6ed4de4ac 100644 --- a/packages/playwright-core/src/utils/DEPS.list +++ b/packages/playwright-core/src/utils/DEPS.list @@ -1,3 +1,4 @@ [*] ./ ../third_party/diff_match_patch +../third_party/pixelmatch diff --git a/packages/playwright-core/src/utils/comparators.ts b/packages/playwright-core/src/utils/comparators.ts index aab483ccd3..f0ed60be01 100644 --- a/packages/playwright-core/src/utils/comparators.ts +++ b/packages/playwright-core/src/utils/comparators.ts @@ -17,11 +17,9 @@ import colors from 'colors/safe'; import jpeg from 'jpeg-js'; -import pixelmatch from 'pixelmatch'; +import pixelmatch from '../third_party/pixelmatch'; import { diff_match_patch, DIFF_INSERT, DIFF_DELETE, DIFF_EQUAL } from '../third_party/diff_match_patch'; - -// Note: we require the pngjs version of pixelmatch to avoid version mismatches. -const { PNG } = require(require.resolve('pngjs', { paths: [require.resolve('pixelmatch')] })) as typeof import('pngjs'); +import { PNG } from 'pngjs'; export type ImageComparatorOptions = { threshold?: number, maxDiffPixels?: number, maxDiffPixelRatio?: number }; export type ComparatorResult = { diff?: Buffer; errorMessage: string; } | null; diff --git a/tests/library/headful.spec.ts b/tests/library/headful.spec.ts index b362e7d912..b5fcf0ab88 100644 --- a/tests/library/headful.spec.ts +++ b/tests/library/headful.spec.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import pixelmatch from 'pixelmatch'; +import pixelmatch from '../../packages/playwright-core/src/third_party/pixelmatch'; import { PNG } from 'pngjs'; import { expect, playwrightTest as it } from '../config/browserTest';