Skip to content
Merged
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
15 changes: 14 additions & 1 deletion src/compare/compare.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { LookSameService } from './libs/looks-same/looks-same.service';
import { OdiffService } from './libs/odiff/odiff.service';
import { VlmService } from './libs/vlm/vlm.service';
import { isHddStaticServiceConfigured } from '../static/utils';
import { DiffWorkerPool } from './diff-worker-pool';
import { SignatureJobInput, SignatureJobOutput } from './libs/pixelmatch/signature.core';

@Injectable()
export class CompareService {
Expand All @@ -19,7 +21,8 @@ export class CompareService {
private readonly lookSameService: LookSameService,
private readonly odiffService: OdiffService,
private readonly vlmService: VlmService,
private readonly prismaService: PrismaService
private readonly prismaService: PrismaService,
private readonly diffWorkerPool: DiffWorkerPool
) {}

async getDiff({ projectId, data }: { projectId: string; data: ImageCompareInput }): Promise<DiffResult> {
Expand All @@ -29,6 +32,16 @@ export class CompareService {
return comparator.getDiff(data, config);
}

/**
* Position-independent signature of what changed between a baseline and a
* screenshot, used to tell whether two screens carry the same change. Runs on
* the diff worker pool: decoding a pair of full-size screenshots takes long
* enough that doing it on the event loop stalls every other request.
*/
async getChangeSignature(input: Omit<SignatureJobInput, 'kind'>): Promise<SignatureJobOutput> {
return this.diffWorkerPool.run({ kind: 'signature', ...input });
}

getComparator(imageComparison: ImageComparison): ImageComparator {
switch (imageComparison) {
case ImageComparison.pixelmatch: {
Expand Down
57 changes: 57 additions & 0 deletions src/compare/diff-worker-pool.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { PNG } from 'pngjs';
import { DiffWorkerPool } from './diff-worker-pool';

// Opaque throughout: pixelmatch composites transparent pixels onto white, so a
// fully transparent image would read as equal to a white one.
const png = (width: number, height: number, rgb: number): Buffer => {
const image = new PNG({ width, height });
for (let i = 0; i < width * height; i++) {
image.data[i * 4] = rgb;
image.data[i * 4 + 1] = rgb;
image.data[i * 4 + 2] = rgb;
image.data[i * 4 + 3] = 255;
}
return PNG.sync.write(image);
};

describe('DiffWorkerPool', () => {
let pool: DiffWorkerPool;

beforeEach(() => {
pool = new DiffWorkerPool();
});

afterEach(async () => {
await pool.onModuleDestroy();
});

it('answers a diff job with the pixel mismatch it found', async () => {
const output = await pool.run({
kind: 'diff',
baseline: png(10, 10, 0),
image: png(10, 10, 255),
ignoreAreas: [],
threshold: 0.1,
includeAA: false,
allowDiffDimensions: false,
diffTolerancePercent: 0,
saveDiff: false,
});

expect(output).toMatchObject({ equal: false, pixelMisMatchCount: 100 });
});

it('answers a signature job with a change signature', async () => {
const output = await pool.run({
kind: 'signature',
baseline: png(10, 10, 0),
image: png(10, 10, 255),
ignoreAreas: [],
threshold: 0.1,
includeAA: false,
});

expect(output.signature).toHaveLength(64);
expect(output.signature.reduce((sum, value) => sum + value, 0)).toBeCloseTo(1);
});
});
29 changes: 17 additions & 12 deletions src/compare/diff-worker-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { Worker } from 'worker_threads';
import { availableParallelism } from 'os';
import { existsSync } from 'fs';
import { join } from 'path';
import { computePixelmatchDiff, PixelmatchJobInput, PixelmatchJobOutput } from './libs/pixelmatch/pixelmatch.core';
import { PixelmatchJobInput, PixelmatchJobOutput } from './libs/pixelmatch/pixelmatch.core';
import { SignatureJobInput, SignatureJobOutput } from './libs/pixelmatch/signature.core';
import { runWorkerJob, WorkerJobInput, WorkerJobOutput } from './libs/pixelmatch/worker-job';

const WORKER_FILE = join(__dirname, 'libs', 'pixelmatch', 'pixelmatch.worker.js');

Expand All @@ -13,17 +15,18 @@ const MAX_SPAWN_FAILURES = 3;
const MAX_JOB_ATTEMPTS = 2;

interface Job {
input: PixelmatchJobInput;
resolve: (output: PixelmatchJobOutput) => void;
input: WorkerJobInput;
resolve: (output: WorkerJobOutput) => void;
reject: (error: Error) => void;
attempts: number;
}

/**
* Fixed pool of worker threads for CPU-bound image diffing. Keeps the event
* loop free during build ingestion so the API stays responsive while
* screenshots are compared. Pool size: DIFF_WORKERS_COUNT env var, defaulting
* to cores - 1 (capped) so the main thread always has a core left.
* Fixed pool of worker threads for CPU-bound image work — diffing a screenshot
* against its baseline, and the change signatures the variations dialog
* compares. Keeps the event loop free so the API stays responsive while
* screenshots are decoded and compared. Pool size: DIFF_WORKERS_COUNT env var,
* defaulting to cores - 1 (capped) so the main thread always has a core left.
*/
@Injectable()
export class DiffWorkerPool implements OnModuleDestroy {
Expand All @@ -46,9 +49,11 @@ export class DiffWorkerPool implements OnModuleDestroy {
private destroyed = false;
private spawnFailures = 0;

async run(input: PixelmatchJobInput): Promise<PixelmatchJobOutput> {
async run(input: PixelmatchJobInput): Promise<PixelmatchJobOutput>;
async run(input: SignatureJobInput): Promise<SignatureJobOutput>;
async run(input: WorkerJobInput): Promise<WorkerJobOutput> {
if (this.inline) {
return computePixelmatchDiff(input);
return runWorkerJob(input);
}
if (this.destroyed) {
throw new Error('Image diff worker pool is shut down');
Expand All @@ -57,7 +62,7 @@ export class DiffWorkerPool implements OnModuleDestroy {
throw new Error(`Image diff queue is full (${this.queueLimit} jobs)`);
}
this.start();
return new Promise<PixelmatchJobOutput>((resolve, reject) => {
return new Promise<WorkerJobOutput>((resolve, reject) => {
this.queue.push({ input, resolve, reject, attempts: 0 });
this.dispatch();
});
Expand All @@ -74,7 +79,7 @@ export class DiffWorkerPool implements OnModuleDestroy {

private spawn(): void {
const worker = new Worker(WORKER_FILE);
worker.on('message', (output: PixelmatchJobOutput & { error?: string }) => {
worker.on('message', (output: WorkerJobOutput & { error?: string }) => {
const job = this.inFlight.get(worker);
this.inFlight.delete(worker);
// a message can arrive after the worker was dropped or the pool shut
Expand Down Expand Up @@ -144,7 +149,7 @@ export class DiffWorkerPool implements OnModuleDestroy {
this.queue = [];
for (const job of queued) {
try {
job.resolve(computePixelmatchDiff(job.input));
job.resolve(runWorkerJob(job.input));
} catch (error) {
job.reject(error instanceof Error ? error : new Error(String(error)));
}
Expand Down
1 change: 1 addition & 0 deletions src/compare/libs/pixelmatch/pixelmatch.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { applyIgnoreAreas, scaleImageToSize } from '../../utils';
* build ingestion. All input/output must stay structured-clone serializable.
*/
export interface PixelmatchJobInput {
kind: 'diff';
baseline: Buffer | Uint8Array;
image: Buffer | Uint8Array;
ignoreAreas: IgnoreAreaDto[];
Expand Down
1 change: 1 addition & 0 deletions src/compare/libs/pixelmatch/pixelmatch.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class PixelmatchService implements ImageComparator {

// decode + pixelmatch + diff encode run off the event loop
const output = await this.diffWorkerPool.run({
kind: 'diff',
baseline: baselineBuffer,
image: imageBuffer,
ignoreAreas: data.ignoreAreas,
Expand Down
6 changes: 3 additions & 3 deletions src/compare/libs/pixelmatch/pixelmatch.worker.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { parentPort } from 'worker_threads';
import { computePixelmatchDiff, PixelmatchJobInput } from './pixelmatch.core';
import { runWorkerJob, WorkerJobInput } from './worker-job';

parentPort.on('message', (input: PixelmatchJobInput) => {
parentPort.on('message', (input: WorkerJobInput) => {
try {
parentPort.postMessage(computePixelmatchDiff(input));
parentPort.postMessage(runWorkerJob(input));
} catch (error) {
parentPort.postMessage({ error: error instanceof Error ? error.message : String(error) });
}
Expand Down
89 changes: 89 additions & 0 deletions src/compare/libs/pixelmatch/signature.core.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { PNG } from 'pngjs';
import { computeChangeSignature } from './signature.core';

const WIDTH = 40;
const HEIGHT = 40;

const png = (paint: (set: (x: number, y: number, rgb: [number, number, number]) => void) => void): Buffer => {
const image = new PNG({ width: WIDTH, height: HEIGHT });
image.data.fill(255);
paint((x, y, [r, g, b]) => {
const index = (y * WIDTH + x) * 4;
image.data[index] = r;
image.data[index + 1] = g;
image.data[index + 2] = b;
image.data[index + 3] = 255;
});
return PNG.sync.write(image);
};

const block = (left: number, top: number, rgb: [number, number, number]): Buffer =>
png((set) => {
for (let y = top; y < top + 8; y++) {
for (let x = left; x < left + 8; x++) {
set(x, y, rgb);
}
}
});

const blank = png(() => undefined);

const RED: [number, number, number] = [255, 0, 0];
const BLUE: [number, number, number] = [0, 0, 255];

const signatureOf = (baseline: Buffer, image: Buffer, ignoreAreas = []): number[] | null =>
computeChangeSignature({
kind: 'signature',
baseline,
image,
ignoreAreas,
threshold: 0.1,
includeAA: false,
}).signature;

describe('computeChangeSignature', () => {
it('has no signature when the images have different dimensions', () => {
const taller = new PNG({ width: WIDTH, height: HEIGHT * 2 });
taller.data.fill(255);

expect(signatureOf(blank, PNG.sync.write(taller))).toBeNull();
});

it('has no signature when nothing changed', () => {
expect(signatureOf(blank, blank)).toBeNull();
});

// One unreadable screenshot must not reject the whole matching request: the
// siblings are signed in a single fan-out, so a throw here would take the
// variations dialog down with it rather than skipping that one candidate.
it('has no signature when the checkpoint cannot be decoded', () => {
expect(signatureOf(blank, Buffer.from('not a png'))).toBeNull();
});

it('has no signature when the baseline is truncated', () => {
expect(signatureOf(blank.subarray(0, 30), block(0, 0, RED))).toBeNull();
});

it('concentrates on the color the changed pixels took in the new image', () => {
const signature = signatureOf(blank, block(0, 0, RED));

const brightest = signature.indexOf(Math.max(...signature));
// 4 buckets per channel: pure red is the last red bucket, first green/blue
expect(brightest).toBe(3 * 16);
expect(signature.reduce((sum, value) => sum + value, 0)).toBeCloseTo(1);
});

it('is the same wherever the change sits, so per-locale reflow still matches', () => {
expect(signatureOf(blank, block(0, 0, RED))).toEqual(signatureOf(blank, block(24, 28, RED)));
});

it('tells a different color of change apart', () => {
expect(signatureOf(blank, block(0, 0, RED))).not.toEqual(signatureOf(blank, block(0, 0, BLUE)));
});

it('has no signature when the only change is inside an ignore area', () => {
const ignoreAreas = [{ x: 0, y: 0, width: 16, height: 16 }];

expect(signatureOf(blank, block(4, 4, RED), ignoreAreas)).toBeNull();
});
});
Loading
Loading