import sharp from 'sharp'; import type { OverlayOptions } from 'sharp'; /** * Composites neutral grey (128,128,128) over each ignoredRegion (fractional * 0-1 coordinates) so masked content — timestamps, live counters, ads — * contributes nothing to any downstream pixel comparison. Shared by every * comparison path (whole-image hash, per-cell grid hash, AI vision) so a * region masked once is actually ignored everywhere, not just in the * cheapest check. */ export async function applyMask( imagePath: string, ignoredRegions: Array<{ x: number; y: number; width: number; height: number }>, ): Promise { const meta = await sharp(imagePath).metadata(); const W = meta.width ?? 1200; const H = meta.height ?? 800; const overlays: OverlayOptions[] = await Promise.all( ignoredRegions.map(async (r) => { const rW = Math.max(1, Math.round(r.width * W)); const rH = Math.max(1, Math.round(r.height * H)); const buf = await sharp({ create: { width: rW, height: rH, channels: 3, background: { r: 128, g: 128, b: 128 } }, }).png().toBuffer(); return { input: buf, top: Math.round(r.y * H), left: Math.round(r.x * W) } as OverlayOptions; }), ); return sharp(imagePath).composite(overlays).png().toBuffer(); } /** * Compute an 8x8 average hash (aHash) of an image file, with optional mask overlays. * ignoredRegions are fractional (0-1 range), converted to pixels here. * Masked regions are filled with neutral grey (128,128,128) before hashing. */ async function hashWithMasks( imagePath: string, ignoredRegions: Array<{ x: number; y: number; width: number; height: number }> = [], ): Promise { const pipeline = ignoredRegions.length > 0 ? sharp(await applyMask(imagePath, ignoredRegions)) : sharp(imagePath); const buf = await pipeline.resize(8, 8, { fit: 'fill' }).greyscale().raw().toBuffer(); const pixels = Array.from(buf); const mean = pixels.reduce((s, p) => s + p, 0) / pixels.length; return pixels.reduce((h, p, i) => (p >= mean ? h | (1n << BigInt(63 - i)) : h), 0n); } function hammingDistance(a: bigint, b: bigint): number { let xor = a ^ b; let count = 0; while (xor !== 0n) { count += Number(xor & 1n); xor >>= 1n; } return count; } /** * Generates a pixelmatch diff image highlighting changed pixels in red. * Applies the same ignoredRegions mask as screenshotDiffPercent so masked * zones don't show as "changed" in the visual output. * Returns a PNG Buffer suitable for upload to S3. */ export async function generatePixelmatchDiff( pathA: string, pathB: string, ignoredRegions: Array<{ x: number; y: number; width: number; height: number }> = [], ): Promise { const metaA = await sharp(pathA).metadata(); const W = metaA.width ?? 1200; const H = metaA.height ?? 800; const toRawRGBA = async (path: string): Promise => { const base = ignoredRegions.length > 0 ? sharp(await applyMask(path, ignoredRegions)) : sharp(path); const { data } = await base.resize(W, H, { fit: 'fill' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); return data; }; const [rawA, rawB] = await Promise.all([toRawRGBA(pathA), toRawRGBA(pathB)]); const { default: pixelmatch } = await import('pixelmatch'); const diffData = Buffer.alloc(W * H * 4); pixelmatch(rawA, rawB, diffData, W, H, { threshold: 0.1, alpha: 0.3, diffColor: [255, 0, 0], aaColor: [255, 255, 0], }); return sharp(diffData, { raw: { width: W, height: H, channels: 4 } }).png().toBuffer(); } export async function screenshotDiffPercent( pathA: string, pathB: string, ignoredRegions: Array<{ x: number; y: number; width: number; height: number }> = [], ): Promise<{ pHashDistance: number; changeLevel: 'IDENTICAL' | 'MINOR' | 'MODERATE' | 'SIGNIFICANT' }> { const [hashA, hashB] = await Promise.all([ hashWithMasks(pathA, ignoredRegions), hashWithMasks(pathB, ignoredRegions), ]); let xor = hashA ^ hashB; let dist = 0; while (xor !== 0n) { dist += Number(xor & 1n); xor >>= 1n; } const pct = parseFloat(((dist / 64) * 100).toFixed(1)); const changeLevel = pct === 0 ? 'IDENTICAL' : pct < 10 ? 'MINOR' : pct < 30 ? 'MODERATE' : 'SIGNIFICANT'; return { pHashDistance: pct, changeLevel }; } /** * Grid-based layout analysis: divides screenshots into a 3×3 grid and computes * pHash per cell. Returns which regions changed and where, for the VisualDiff.changes field. * Uses sharp's `extract` to crop each cell before hashing. */ export async function gridLayoutDiff( pathA: string, pathB: string, ignoredRegions: Array<{ x: number; y: number; width: number; height: number }> = [], ): Promise> { const GRID_LABELS = [ 'top-left', 'top-center', 'top-right', 'mid-left', 'mid-center', 'mid-right', 'bottom-left', 'bottom-center', 'bottom-right', ]; try { // Mask before cropping into cells — a masked region (e.g. a timestamp) // then contributes nothing to any cell's hash, instead of only being // ignored by the separate whole-image hash in screenshotDiffPercent(). const [sourceA, sourceB] = ignoredRegions.length > 0 ? await Promise.all([applyMask(pathA, ignoredRegions), applyMask(pathB, ignoredRegions)]) : [pathA, pathB]; const [metaA, metaB] = await Promise.all([ sharp(sourceA).metadata(), sharp(sourceB).metadata(), ]); const W = Math.min(metaA.width ?? 1200, metaB.width ?? 1200); const H = Math.min(metaA.height ?? 800, metaB.height ?? 800); const cW = Math.floor(W / 3); const cH = Math.floor(H / 3); const changes: Array<{ location: string; severity: string; description: string }> = []; for (let row = 0; row < 3; row++) { for (let col = 0; col < 3; col++) { const region = { left: col * cW, top: row * cH, width: cW, height: cH }; const label = GRID_LABELS[row * 3 + col]; const [hashA, hashB] = await Promise.all([ sharp(sourceA).extract(region).resize(8, 8, { fit: 'fill' }).greyscale().raw().toBuffer() .then((buf) => { const pixels = Array.from(buf); const mean = pixels.reduce((s, p) => s + p, 0) / pixels.length; return pixels.reduce((h, p, i) => p >= mean ? h | (1n << BigInt(63 - i)) : h, 0n); }), sharp(sourceB).extract(region).resize(8, 8, { fit: 'fill' }).greyscale().raw().toBuffer() .then((buf) => { const pixels = Array.from(buf); const mean = pixels.reduce((s, p) => s + p, 0) / pixels.length; return pixels.reduce((h, p, i) => p >= mean ? h | (1n << BigInt(63 - i)) : h, 0n); }), ]); let xor = hashA ^ hashB; let dist = 0; while (xor !== 0n) { dist += Number(xor & 1n); xor >>= 1n; } const pct = (dist / 64) * 100; if (pct >= 5) { const severity = pct >= 20 ? 'HIGH' : pct >= 10 ? 'MEDIUM' : 'LOW'; changes.push({ location: label, severity, description: `${Math.round(pct)}% visual change in ${label.replace('-', ' ')} region`, }); } } } return changes; } catch { return []; } }