Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
authors = ["LongYinan <lynweklm@gmail.com>"]
edition = "2021"
name = "mask_fingerprint"
version = "0.1.0"
version = "0.3.0"

[lib]
crate-type = ["cdylib"]
Expand All @@ -12,6 +12,7 @@ crate-type = ["cdylib"]
napi = "3.0.0"
napi-derive = "3.0.0"
png = "0.18"
rayon = "1"

[build-dependencies]
napi-build = "2"
Expand Down
189 changes: 189 additions & 0 deletions __test__/diff.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { PNG } from 'pngjs'
import { describe, expect, it } from 'vitest'

import { diffScreenshots } from '../index'

/**
* Build a synthetic screenshot-like PNG made of horizontal bands.
* Rows vary with the band-local index so a band renders identically
* wherever it sits in the page (like real shifted content).
*/
function buildPng(width: number, bands: Array<[number, [number, number, number]]>): Buffer {
const height = bands.reduce((acc, [h]) => acc + h, 0)
const png = new PNG({ width, height })

let y = 0
for (const [bandHeight, [r, g, b]] of bands) {
for (let i = 0; i < bandHeight; i++) {
for (let x = 0; x < width; x++) {
const idx = ((y + i) * width + x) * 4
png.data[idx] = (r + (i % 16) * 16) % 256
png.data[idx + 1] = (g + Math.floor(i / 16) * 16) % 256
png.data[idx + 2] = (b + x) % 256
png.data[idx + 3] = 255
}
}
y += bandHeight
}

return PNG.sync.write(png)
}

const WHITE: [number, number, number] = [255, 255, 255]
const BLUE: [number, number, number] = [40, 80, 200]
const GREEN: [number, number, number] = [30, 180, 90]
const RED: [number, number, number] = [220, 50, 50]

function tmpPaths() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'diff-screenshots-'))
return {
baseDiffPath: path.join(dir, 'base-diff.png'),
colorDiffPath: path.join(dir, 'color-diff.png'),
}
}

const THRESHOLDS = { baseThreshold: 0.15, colorThreshold: 0.0225 }
const LAYOUT_SHIFT = {
minRun: 8,
minEqualCoverage: 0.3,
maxSegments: 32,
minClassicScore: 0.05,
maxResidualRatio: 0.5,
}

describe('diffScreenshots', () => {
it('reports no diff for identical images', async () => {
const image = buildPng(100, [
[50, WHITE],
[50, BLUE],
])
const paths = tmpPaths()

const result = await diffScreenshots(image, image, { ...THRESHOLDS, ...paths })

expect(result.baseScore).toBe(0)
expect(result.colorScore).toBe(0)
expect(result.baseDiffWritten).toBe(false)
expect(fs.existsSync(paths.baseDiffPath)).toBe(false)
})

it('scores a changed area and writes the masks', async () => {
const base = buildPng(100, [
[50, WHITE],
[50, BLUE],
])
const head = buildPng(100, [
[50, WHITE],
[50, RED],
])
const paths = tmpPaths()

const result = await diffScreenshots(base, head, { ...THRESHOLDS, ...paths })

expect(result.baseScore).toBeGreaterThan(0.4)
expect(result.layoutShiftApplied).toBe(false)
expect(result.baseDiffWritten).toBe(true)
expect(fs.existsSync(paths.baseDiffPath)).toBe(true)

const mask = PNG.sync.read(fs.readFileSync(paths.baseDiffPath))
expect(mask.width).toBe(100)
expect(mask.height).toBe(100)
// Red pixel inside the changed band, transparent above it.
const at = (x: number, y: number) => Array.from(mask.data.subarray((y * 100 + x) * 4, (y * 100 + x) * 4 + 4))
expect(at(50, 75)).toEqual([255, 0, 0, 255])
expect(at(50, 25)[3]).toBe(0)
})

it('applies layout-shift compensation for an inserted section', async () => {
const base = buildPng(100, [
[60, WHITE],
[80, BLUE],
[100, GREEN],
])
// 50 red rows inserted between blue and green.
const head = buildPng(100, [
[60, WHITE],
[80, BLUE],
[50, RED],
[100, GREEN],
])
const paths = tmpPaths()

const result = await diffScreenshots(base, head, {
...THRESHOLDS,
...paths,
layoutShift: LAYOUT_SHIFT,
})

expect(result.layoutShiftApplied).toBe(true)
expect(result.insertedRows).toBe(50)
expect(result.deletedRows).toBe(0)
// Residual pixel diff is zero: the shift explains everything.
expect(result.baseScore).toBe(0)
expect(result.classicBaseScore).toBeGreaterThan(0.1)
// The mask is still written with the painted inserted band.
expect(result.baseDiffWritten).toBe(true)

const mask = PNG.sync.read(fs.readFileSync(paths.baseDiffPath))
const at = (x: number, y: number) => Array.from(mask.data.subarray((y * 100 + x) * 4, (y * 100 + x) * 4 + 4))
// Inside the inserted band (rows 140..190): painted red.
expect(at(50, 160)).toEqual([255, 0, 0, 255])
// Below the insertion (shifted but identical content): clean.
expect(at(50, 220)[3]).toBe(0)
})

it('keeps the classic result when no trustworthy shift exists', async () => {
const base = buildPng(100, [
[100, WHITE],
[100, BLUE],
])
// Completely different content, same height.
const head = buildPng(100, [
[100, GREEN],
[100, RED],
])
const paths = tmpPaths()

const result = await diffScreenshots(base, head, {
...THRESHOLDS,
...paths,
layoutShift: LAYOUT_SHIFT,
})

expect(result.layoutShiftApplied).toBe(false)
expect(result.baseScore).toBe(result.classicBaseScore)
expect(result.baseScore).toBeGreaterThan(0.9)
})

it('pads images of different sizes with transparency', async () => {
const base = buildPng(100, [[100, WHITE]])
const head = buildPng(100, [
[100, WHITE],
[50, RED],
])
const paths = tmpPaths()

const result = await diffScreenshots(base, head, { ...THRESHOLDS, ...paths })

expect(result.width).toBe(100)
expect(result.height).toBe(150)
// The red extra band differs from the (white-blended) transparent padding.
expect(result.baseScore).toBeGreaterThan(0.25)
})

it('accepts file paths as input', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'diff-inputs-'))
const basePath = path.join(dir, 'base.png')
const headPath = path.join(dir, 'head.png')
fs.writeFileSync(basePath, buildPng(50, [[50, WHITE]]))
fs.writeFileSync(headPath, buildPng(50, [[50, BLUE]]))
const paths = tmpPaths()

const result = await diffScreenshots(basePath, headPath, { ...THRESHOLDS, ...paths })

expect(result.baseScore).toBeGreaterThan(0.9)
})
})
144 changes: 144 additions & 0 deletions __test__/row-alignment.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { PNG } from 'pngjs'
import { describe, expect, it } from 'vitest'

import { computeRowAlignment } from '../index'

/**
* Build a synthetic screenshot-like PNG made of horizontal bands.
* Each band is a list of [height, [r, g, b]] entries.
*/
function buildPng(width: number, bands: Array<[number, [number, number, number]]>): Buffer {
const height = bands.reduce((acc, [h]) => acc + h, 0)
const png = new PNG({ width, height })

let y = 0
for (const [bandHeight, [r, g, b]] of bands) {
for (let i = 0; i < bandHeight; i++) {
for (let x = 0; x < width; x++) {
const idx = ((y + i) * width + x) * 4
// Vary rows using the band-local index (not the absolute y) so the
// same band produces identical pixels wherever it sits in the page,
// like real shifted content. Steps of 16 survive 4-bit quantization,
// making each row of a band unique for alignment anchors.
png.data[idx] = (r + (i % 16) * 16) % 256
png.data[idx + 1] = (g + Math.floor(i / 16) * 16) % 256
png.data[idx + 2] = (b + x) % 256
png.data[idx + 3] = 255
}
}
y += bandHeight
}

return PNG.sync.write(png)
}

const WHITE: [number, number, number] = [255, 255, 255]
const BLUE: [number, number, number] = [40, 80, 200]
const GREEN: [number, number, number] = [30, 180, 90]
const RED: [number, number, number] = [220, 50, 50]

describe('computeRowAlignment', () => {
it('detects a vertical layout shift caused by an inserted section', async () => {
const base = buildPng(200, [
[100, WHITE],
[150, BLUE],
[200, GREEN],
])
// Same page with a 120px red section inserted between blue and green.
const head = buildPng(200, [
[100, WHITE],
[150, BLUE],
[120, RED],
[200, GREEN],
])

const result = await computeRowAlignment(base, head)

expect(result.baseHeight).toBe(450)
expect(result.headHeight).toBe(570)
expect(result.shifted).toBe(true)
expect(result.equalCoverage).toBeGreaterThan(0.7)

const inserts = result.segments.filter((s) => s.op === 'insert')
expect(inserts).toHaveLength(1)
expect(inserts[0].headLength).toBe(120)

// Content below the insertion must be matched, shifted by 120px.
const shiftedEquals = result.segments.filter((s) => s.op === 'equal' && s.headStart - s.baseStart === 120)
expect(shiftedEquals.length).toBeGreaterThan(0)
})

it('detects a removed section as a delete segment', async () => {
const base = buildPng(200, [
[100, WHITE],
[120, RED],
[200, GREEN],
])
const head = buildPng(200, [
[100, WHITE],
[200, GREEN],
])

const result = await computeRowAlignment(base, head)

expect(result.shifted).toBe(true)
const deletes = result.segments.filter((s) => s.op === 'delete')
expect(deletes).toHaveLength(1)
expect(deletes[0].baseLength).toBe(120)
})

it('reports no shift for identical images', async () => {
const image = buildPng(200, [
[100, WHITE],
[150, BLUE],
])

const result = await computeRowAlignment(image, image)

expect(result.shifted).toBe(false)
expect(result.equalCoverage).toBe(1)
expect(result.segments).toHaveLength(1)
expect(result.segments[0].op).toBe('equal')
})

it('refuses alignment when widths differ', async () => {
const base = buildPng(200, [[100, WHITE]])
const head = buildPng(220, [[100, WHITE]])

const result = await computeRowAlignment(base, head)

expect(result.shifted).toBe(false)
expect(result.equalCoverage).toBe(0)
})

it('accepts file paths as input', async () => {
const fs = await import('node:fs')
const os = await import('node:os')
const path = await import('node:path')

const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'row-alignment-'))
const basePath = path.join(dir, 'base.png')
const headPath = path.join(dir, 'head.png')

fs.writeFileSync(
basePath,
buildPng(100, [
[50, WHITE],
[50, BLUE],
]),
)
fs.writeFileSync(
headPath,
buildPng(100, [
[50, WHITE],
[30, RED],
[50, BLUE],
]),
)

const result = await computeRowAlignment(basePath, headPath)

expect(result.shifted).toBe(true)
expect(result.segments.filter((s) => s.op === 'insert')).toHaveLength(1)
})
})
Loading
Loading