UNPKG

12.7 kBJavaScriptView Raw
1/*
2 * Copyright (c) 2017 American Express Travel Related Services Company, Inc.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
5 * in compliance with the License. You may obtain a copy of the License at
6 *
7 * http://www.apache.org/licenses/LICENSE-2.0
8 *
9 * Unless required by applicable law or agreed to in writing, software distributed under the License
10 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
11 * or implied. See the License for the specific language governing permissions and limitations under
12 * the License.
13 */
14
15const childProcess = require('child_process');
16const fs = require('fs');
17const path = require('path');
18const mkdirp = require('mkdirp');
19const pixelmatch = require('pixelmatch');
20const ssim = require('ssim.js');
21const { PNG } = require('pngjs');
22const rimraf = require('rimraf');
23const glur = require('glur');
24const ImageComposer = require('./image-composer');
25
26/**
27 * Helper function to create reusable image resizer
28 */
29const createImageResizer = (width, height) => (source) => {
30 const resized = new PNG({ width, height, fill: true });
31 PNG.bitblt(source, resized, 0, 0, source.width, source.height, 0, 0);
32 return resized;
33};
34
35/**
36 * Fills diff area with black transparent color for meaningful diff
37 */
38/* eslint-disable no-plusplus, no-param-reassign, no-bitwise */
39const fillSizeDifference = (width, height) => (image) => {
40 const inArea = (x, y) => y > height || x > width;
41 for (let y = 0; y < image.height; y++) {
42 for (let x = 0; x < image.width; x++) {
43 if (inArea(x, y)) {
44 const idx = ((image.width * y) + x) << 2;
45 image.data[idx] = 0;
46 image.data[idx + 1] = 0;
47 image.data[idx + 2] = 0;
48 image.data[idx + 3] = 64;
49 }
50 }
51 }
52 return image;
53};
54/* eslint-enabled */
55/**
56 * This was originally embedded in diffImageToSnapshot
57 * when it only worked with pixelmatch. It has a default
58 * threshold of 0.01 defined in terms of what it means to pixelmatch.
59 * It has been moved here as part of the SSIM implementation to make it
60 * a little easier to read and find.
61 * More information about this can be found under the options section listed
62 * in https://github.com/mapbox/pixelmatch/README.md and in the original pixelmatch
63 * code. There is also some documentation on this in our README.md under the
64 * customDiffConfig option.
65 * @type {{threshold: number}}
66 */
67const defaultPixelmatchDiffConfig = {
68 threshold: 0.01,
69};
70/**
71 * This is the default SSIM diff configuration
72 * for the jest-image-snapshot's use of the ssim.js
73 * library. Bezkrovny is a specific SSIM algorithm optimized
74 * for speed by downsampling the origin image into a smaller image.
75 * For the small loss in precision, it is roughly 9x faster than the
76 * SSIM preset 'fast' -- which is modeled after the original SSIM whitepaper.
77 * Wang, et al. 2004 on "Image Quality Assessment: From Error Visibility to Structural Similarity"
78 * (https://github.com/obartra/ssim/blob/master/assets/ssim.pdf)
79 * Most users will never need or want to change this -- unless --
80 * they want to get a better quality generated diff.
81 * @type {{ssim: string}}
82 */
83const defaultSSIMDiffConfig = { ssim: 'bezkrovny' };
84
85/**
86 * Helper function for SSIM comparison that allows us to use the existing diff
87 * config that works with jest-image-snapshot to pass parameters
88 * that will work with SSIM. It also transforms the parameters to match the spec
89 * required by the SSIM library.
90 */
91const ssimMatch = (
92 newImageData,
93 baselineImageData,
94 diffImageData,
95 imageWidth,
96 imageHeight,
97 diffConfig
98) => {
99 const newImage = { data: newImageData, width: imageWidth, height: imageHeight };
100 const baselineImage = { data: baselineImageData, width: imageWidth, height: imageHeight };
101 // eslint-disable-next-line camelcase
102 const { ssim_map, mssim } = ssim.ssim(newImage, baselineImage, diffConfig);
103 // Converts the SSIM value to different pixels based on image width and height
104 // conforms to how pixelmatch works.
105 const diffPixels = (1 - mssim) * imageWidth * imageHeight;
106 const diffRgbaPixels = new DataView(diffImageData.buffer, diffImageData.byteOffset);
107 for (let ln = 0; ln !== imageHeight; ++ln) {
108 for (let pos = 0; pos !== imageWidth; ++pos) {
109 const rpos = (ln * imageWidth) + pos;
110 // initial value is transparent. We'll add in the SSIM offset.
111 // red (ff) green (00) blue (00) alpha (00)
112 const diffValue = 0xff000000 + Math.floor(0xff *
113 (1 - ssim_map.data[
114 // eslint-disable-next-line no-mixed-operators
115 (ssim_map.width * Math.round(ssim_map.height * ln / imageHeight)) +
116 // eslint-disable-next-line no-mixed-operators
117 Math.round(ssim_map.width * pos / imageWidth)]));
118 diffRgbaPixels.setUint32(rpos * 4, diffValue);
119 }
120 }
121 return diffPixels;
122};
123
124/**
125 * Aligns images sizes to biggest common value
126 * and fills new pixels with transparent pixels
127 */
128const alignImagesToSameSize = (firstImage, secondImage) => {
129 // Keep original sizes to fill extended area later
130 const firstImageWidth = firstImage.width;
131 const firstImageHeight = firstImage.height;
132 const secondImageWidth = secondImage.width;
133 const secondImageHeight = secondImage.height;
134 // Calculate biggest common values
135 const resizeToSameSize = createImageResizer(
136 Math.max(firstImageWidth, secondImageWidth),
137 Math.max(firstImageHeight, secondImageHeight)
138 );
139 // Resize both images
140 const resizedFirst = resizeToSameSize(firstImage);
141 const resizedSecond = resizeToSameSize(secondImage);
142 // Fill resized area with black transparent pixels
143 return [
144 fillSizeDifference(firstImageWidth, firstImageHeight)(resizedFirst),
145 fillSizeDifference(secondImageWidth, secondImageHeight)(resizedSecond),
146 ];
147};
148
149const isFailure = ({ pass, updateSnapshot }) => !pass && !updateSnapshot;
150
151const shouldUpdate = ({ pass, updateSnapshot, updatePassedSnapshot }) => (
152 (!pass && updateSnapshot) || (pass && updatePassedSnapshot)
153);
154
155const shouldFail = ({
156 totalPixels,
157 diffPixelCount,
158 hasSizeMismatch,
159 allowSizeMismatch,
160 failureThresholdType,
161 failureThreshold,
162}) => {
163 let pass = false;
164 let diffSize = false;
165 const diffRatio = diffPixelCount / totalPixels;
166 if (hasSizeMismatch) {
167 // do not fail if allowSizeMismatch is set
168 pass = allowSizeMismatch;
169 diffSize = true;
170 }
171 if (!diffSize || pass === true) {
172 if (failureThresholdType === 'pixel') {
173 pass = diffPixelCount <= failureThreshold;
174 } else if (failureThresholdType === 'percent') {
175 pass = diffRatio <= failureThreshold;
176 } else {
177 throw new Error(`Unknown failureThresholdType: ${failureThresholdType}. Valid options are "pixel" or "percent".`);
178 }
179 }
180 return {
181 pass,
182 diffSize,
183 diffRatio,
184 };
185};
186
187function diffImageToSnapshot(options) {
188 const {
189 receivedImageBuffer,
190 snapshotIdentifier,
191 snapshotsDir,
192 storeReceivedOnFailure,
193 receivedDir = path.join(options.snapshotsDir, '__received_output__'),
194 diffDir = path.join(options.snapshotsDir, '__diff_output__'),
195 diffDirection,
196 updateSnapshot = false,
197 updatePassedSnapshot = false,
198 customDiffConfig = {},
199 failureThreshold,
200 failureThresholdType,
201 blur,
202 allowSizeMismatch = false,
203 comparisonMethod = 'pixelmatch',
204 } = options;
205
206 const comparisonFn = comparisonMethod === 'ssim' ? ssimMatch : pixelmatch;
207 let result = {};
208 const baselineSnapshotPath = path.join(snapshotsDir, `${snapshotIdentifier}-snap.png`);
209 if (!fs.existsSync(baselineSnapshotPath)) {
210 mkdirp.sync(path.dirname(baselineSnapshotPath));
211 fs.writeFileSync(baselineSnapshotPath, receivedImageBuffer);
212 result = { added: true };
213 } else {
214 const receivedSnapshotPath = path.join(receivedDir, `${snapshotIdentifier}-received.png`);
215 rimraf.sync(receivedSnapshotPath);
216
217 const diffOutputPath = path.join(diffDir, `${snapshotIdentifier}-diff.png`);
218 rimraf.sync(diffOutputPath);
219
220 const defaultDiffConfig = comparisonMethod !== 'ssim' ? defaultPixelmatchDiffConfig : defaultSSIMDiffConfig;
221
222 const diffConfig = Object.assign({}, defaultDiffConfig, customDiffConfig);
223
224 const rawReceivedImage = PNG.sync.read(receivedImageBuffer);
225 const rawBaselineImage = PNG.sync.read(fs.readFileSync(baselineSnapshotPath));
226 const hasSizeMismatch = (
227 rawReceivedImage.height !== rawBaselineImage.height ||
228 rawReceivedImage.width !== rawBaselineImage.width
229 );
230 const imageDimensions = {
231 receivedHeight: rawReceivedImage.height,
232 receivedWidth: rawReceivedImage.width,
233 baselineHeight: rawBaselineImage.height,
234 baselineWidth: rawBaselineImage.width,
235 };
236 // Align images in size if different
237 const [receivedImage, baselineImage] = hasSizeMismatch
238 ? alignImagesToSameSize(rawReceivedImage, rawBaselineImage)
239 : [rawReceivedImage, rawBaselineImage];
240 const imageWidth = receivedImage.width;
241 const imageHeight = receivedImage.height;
242
243 if (typeof blur === 'number' && blur > 0) {
244 glur(receivedImage.data, imageWidth, imageHeight, blur);
245 glur(baselineImage.data, imageWidth, imageHeight, blur);
246 }
247
248 const diffImage = new PNG({ width: imageWidth, height: imageHeight });
249
250 let diffPixelCount = 0;
251
252 diffPixelCount = comparisonFn(
253 receivedImage.data,
254 baselineImage.data,
255 diffImage.data,
256 imageWidth,
257 imageHeight,
258 diffConfig
259 );
260
261 const totalPixels = imageWidth * imageHeight;
262
263 const {
264 pass,
265 diffSize,
266 diffRatio,
267 } = shouldFail({
268 totalPixels,
269 diffPixelCount,
270 hasSizeMismatch,
271 allowSizeMismatch,
272 failureThresholdType,
273 failureThreshold,
274 });
275
276 if (isFailure({ pass, updateSnapshot })) {
277 if (storeReceivedOnFailure) {
278 mkdirp.sync(path.dirname(receivedSnapshotPath));
279 fs.writeFileSync(receivedSnapshotPath, receivedImageBuffer);
280 result = { receivedSnapshotPath };
281 }
282
283 mkdirp.sync(path.dirname(diffOutputPath));
284 const composer = new ImageComposer({
285 direction: diffDirection,
286 });
287
288 composer.addImage(baselineImage, imageWidth, imageHeight);
289 composer.addImage(diffImage, imageWidth, imageHeight);
290 composer.addImage(receivedImage, imageWidth, imageHeight);
291
292 const composerParams = composer.getParams();
293
294 const compositeResultImage = new PNG({
295 width: composerParams.compositeWidth,
296 height: composerParams.compositeHeight,
297 });
298
299 // copy baseline, diff, and received images into composite result image
300 composerParams.images.forEach((image, index) => {
301 PNG.bitblt(
302 image.imageData, compositeResultImage, 0, 0, image.imageWidth, image.imageHeight,
303 composerParams.offsetX * index, composerParams.offsetY * index
304 );
305 });
306 // Set filter type to Paeth to avoid expensive auto scanline filter detection
307 // For more information see https://www.w3.org/TR/PNG-Filters.html
308 const pngBuffer = PNG.sync.write(compositeResultImage, { filterType: 4 });
309 fs.writeFileSync(diffOutputPath, pngBuffer);
310
311 result = {
312 ...result,
313 pass: false,
314 diffSize,
315 imageDimensions,
316 diffOutputPath,
317 diffRatio,
318 diffPixelCount,
319 imgSrcString: `data:image/png;base64,${pngBuffer.toString('base64')}`,
320 };
321 } else if (shouldUpdate({ pass, updateSnapshot, updatePassedSnapshot })) {
322 mkdirp.sync(path.dirname(baselineSnapshotPath));
323 fs.writeFileSync(baselineSnapshotPath, receivedImageBuffer);
324 result = { updated: true };
325 } else {
326 result = {
327 pass,
328 diffSize,
329 diffRatio,
330 diffPixelCount,
331 diffOutputPath,
332 };
333 }
334 }
335 return result;
336}
337
338function runDiffImageToSnapshot(options) {
339 options.receivedImageBuffer = options.receivedImageBuffer.toString('base64');
340
341 const serializedInput = JSON.stringify(options);
342
343 let result = {};
344
345 const writeDiffProcess = childProcess.spawnSync(
346 process.execPath, [`${__dirname}/diff-process.js`],
347 {
348 input: Buffer.from(serializedInput),
349 stdio: ['pipe', 'inherit', 'inherit', 'pipe'],
350 maxBuffer: 10 * 1024 * 1024, // 10 MB
351 }
352 );
353
354 if (writeDiffProcess.status === 0) {
355 const output = writeDiffProcess.output[3].toString();
356 result = JSON.parse(output);
357 } else {
358 throw new Error(`Error running image diff: ${(writeDiffProcess.error && writeDiffProcess.error.message) || 'Unknown Error'}`);
359 }
360
361 return result;
362}
363
364module.exports = {
365 diffImageToSnapshot,
366 runDiffImageToSnapshot,
367};