Skip to content

Instantly share code, notes, and snippets.

@mariuseis
Created August 20, 2024 11:17
Show Gist options
  • Save mariuseis/a66bc32df6af58eb14700bd7b49d7390 to your computer and use it in GitHub Desktop.
Save mariuseis/a66bc32df6af58eb14700bd7b49d7390 to your computer and use it in GitHub Desktop.
Azure DevOps - PR Image Comparison. Display pixel differences when clicking on a modified image in Azure Devops pull-requests view. Uses pixelmatch library.
// ==UserScript==
// @name Azure DevOps - PR Image Comparison
// @version 2024-08-20
// @description Display pixel differences when clicking on a modified image in Azure Devops pull-requests view. Uses pixelmatch library.
// @author Marius Eismantas
// @match https://dev.azure.com/*
// @icon https://cdn.vsassets.io/content/icons/favicon.ico
// @grant none
// ==/UserScript==
const DEFAULT_MATCH_THRESHOLD = 0.1;
const DEFAULT_ADDED_PIXELS_COLOR = [0, 255, 0];
const DEFAULT_REMOVED_PIXELS_COLOR = [255, 0, 0];
const AZURE_IMAGE_SECTION_SELECTOR = '.bolt-card-content';
const AZURE_IMAGE_SELECTOR = '.bolt-card-content img';
(function () {
'use strict';
const observableElement = document.body;
const observer = new MutationObserver(() => handleDiffableImages(observableElement));
observer.observe(observableElement, { childList: true, subtree: true });
})();
function handleDiffableImages(element) {
const images = element.querySelectorAll(AZURE_IMAGE_SELECTOR);
images.forEach((img) => {
if (!img.onload) {
img.onload = () => attachOnClickHandlerToImages();
}
});
}
function attachOnClickHandlerToImages() {
const processedAttributeName = 'data-pixelmatch-processed';
const sections = document.querySelectorAll(AZURE_IMAGE_SECTION_SELECTOR);
sections.forEach((section) => {
const pngImages = section.querySelectorAll(
`.repos-editor-image:not([${processedAttributeName}])`
);
if (pngImages.length % 2 !== 0) {
return;
}
waitForImagesToLoad(pngImages, () => {
pngImages.forEach((img, index) => {
let overlay = null;
img.style.cursor = 'pointer';
img.setAttribute(processedAttributeName, 'true');
const handleImageClick = function () {
if (!overlay) {
const nextImg = pngImages[index + (index % 2 === 0 ? 1 : -1)];
overlay = compareImagesAndHighlight(img, nextImg);
overlay.style.position = 'absolute';
overlay.style.left = img.offsetLeft + 'px';
overlay.style.top = img.offsetTop + 'px';
overlay.style.pointerEvents = 'none';
img.parentElement.prepend(overlay);
} else {
overlay.remove();
overlay = null;
}
};
img.onclick = handleImageClick;
});
});
});
}
function compareImagesAndHighlight(img1, img2) {
const { ctx: ctx1 } = createCanvasAndContext(img1.width, img1.height);
const { ctx: ctx2 } = createCanvasAndContext(img2.width, img2.height);
const imgData1 = drawImageToCanvas(ctx1, img1);
const imgData2 = drawImageToCanvas(ctx2, img2);
const { ctx: diffCtx, canvas: diffCanvas } = createCanvasAndContext(img1.width, img1.height);
const diffImageData = diffCtx.createImageData(img1.width, img1.height);
pixelmatch(imgData1.data, imgData2.data, diffImageData.data, img1.width, img1.height, {
threshold: DEFAULT_MATCH_THRESHOLD,
includeAA: true,
diffColor: DEFAULT_REMOVED_PIXELS_COLOR,
diffColorAlt: DEFAULT_ADDED_PIXELS_COLOR,
});
diffCtx.putImageData(diffImageData, 0, 0);
return diffCanvas;
}
function createCanvasAndContext(width, height) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = width;
canvas.height = height;
return { canvas, ctx };
}
function drawImageToCanvas(ctx, img) {
ctx.drawImage(img, 0, 0, img.width, img.height);
return ctx.getImageData(0, 0, img.width, img.height);
}
function waitForImagesToLoad(images, callback) {
let imageCount = images.length;
if (imageCount === 0) {
callback();
return;
}
images.forEach((img) => {
if (img.complete) {
imageLoaded();
} else {
img.addEventListener('load', imageLoaded, { once: true });
}
});
function imageLoaded() {
imageCount--;
if (imageCount === 0) {
callback();
}
}
}
// Source - https://cdnjs.cloudflare.com/ajax/libs/pixelmatch/6.0.0/index.min.js
// * Removed "export default" for pixelmatch function
// BEGIN - External library code
const defaultOptions={threshold:.1,includeAA:!1,alpha:.1,aaColor:[255,255,0],diffColor:[255,0,0],diffColorAlt:null,diffMask:!1};function pixelmatch(e,a,i,n,l,f){if(!isPixelData(e)||!isPixelData(a)||i&&!isPixelData(i))throw new Error("Image data: Uint8Array, Uint8ClampedArray or Buffer expected.");if(e.length!==a.length||i&&i.length!==e.length)throw new Error("Image sizes do not match.");if(e.length!==n*l*4)throw new Error("Image data size does not match width/height.");f=Object.assign({},defaultOptions,f);var t=n*l,o=new Uint32Array(e.buffer,e.byteOffset,t),d=new Uint32Array(a.buffer,a.byteOffset,t);let s=!0;for(let r=0;r<t;r++)if(o[r]!==d[r]){s=!1;break}if(s){if(i&&!f.diffMask)for(let r=0;r<t;r++)drawGrayPixel(e,4*r,f.alpha,i);return 0}var h=35215*f.threshold*f.threshold;let u=0;for(let t=0;t<l;t++)for(let r=0;r<n;r++){var b=4*(t*n+r),g=colorDelta(e,a,b,b);Math.abs(g)>h?f.includeAA||!antialiased(e,r,t,n,l,a)&&!antialiased(a,r,t,n,l,e)?(i&&drawPixel(i,b,...g<0&&f.diffColorAlt||f.diffColor),u++):i&&!f.diffMask&&drawPixel(i,b,...f.aaColor):i&&!f.diffMask&&drawGrayPixel(e,b,f.alpha,i)}return u}function isPixelData(r){return ArrayBuffer.isView(r)&&1===r.constructor.BYTES_PER_ELEMENT}function antialiased(e,a,i,n,r,t){var l=Math.max(a-1,0),f=Math.max(i-1,0),o=Math.min(a+1,n-1),d=Math.min(i+1,r-1),s=4*(i*n+a);let h=a===l||a===o||i===f||i===d?1:0,u=0,b=0,g,c,y,M;for(let t=l;t<=o;t++)for(let r=f;r<=d;r++)if(t!==a||r!==i){var x=colorDelta(e,e,s,4*(r*n+t),!0);if(0===x){if(2<++h)return!1}else x<u?(u=x,g=t,c=r):x>b&&(b=x,y=t,M=r)}return 0!==u&&0!==b&&(hasManySiblings(e,g,c,n,r)&&hasManySiblings(t,g,c,n,r)||hasManySiblings(e,y,M,n,r)&&hasManySiblings(t,y,M,n,r))}function hasManySiblings(e,a,i,n,r){var l=Math.max(a-1,0),f=Math.max(i-1,0),o=Math.min(a+1,n-1),d=Math.min(i+1,r-1),s=4*(i*n+a);let h=a===l||a===o||i===f||i===d?1:0;for(let t=l;t<=o;t++)for(let r=f;r<=d;r++)if(t!==a||r!==i){var u=4*(r*n+t);if(e[s]===e[u]&&e[1+s]===e[1+u]&&e[2+s]===e[2+u]&&e[3+s]===e[3+u]&&h++,2<h)return!0}return!1}function colorDelta(r,t,e,a,i){let n=r[e+0],l=r[e+1],f=r[e+2];r=r[e+3];let o=t[a+0],d=t[a+1],s=t[a+2];e=t[a+3];if(r===e&&n===o&&l===d&&f===s)return 0;r<255&&(r/=255,n=blend(n,r),l=blend(l,r),f=blend(f,r)),e<255&&(e/=255,o=blend(o,e),d=blend(d,e),s=blend(s,e));t=rgb2y(n,l,f),a=rgb2y(o,d,s),r=t-a;if(i)return r;e=rgb2i(n,l,f)-rgb2i(o,d,s),i=rgb2q(n,l,f)-rgb2q(o,d,s),r=.5053*r*r+.299*e*e+.1957*i*i;return a<t?-r:r}function rgb2y(r,t,e){return.29889531*r+.58662247*t+.11448223*e}function rgb2i(r,t,e){return.59597799*r-.2741761*t-.32180189*e}function rgb2q(r,t,e){return.21147017*r-.52261711*t+.31114694*e}function blend(r,t){return 255+(r-255)*t}function drawPixel(r,t,e,a,i){r[t+0]=e,r[t+1]=a,r[t+2]=i,r[t+3]=255}function drawGrayPixel(r,t,e,a){e=blend(rgb2y(r[t+0],r[t+1],r[t+2]),e*r[t+3]/255);drawPixel(a,t,e,e,e)}
// END - External library code
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment