Created
August 20, 2024 11:17
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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