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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
"bench:ios-snapshot": "node --experimental-strip-types scripts/ios-snapshot-benchmark/run.ts",
"bench:ios-snapshot:deep-button": "node --experimental-strip-types scripts/ios-snapshot-benchmark/deep-button.ts",
"bench:ios-snapshot:evidence": "node --experimental-strip-types scripts/ios-snapshot-benchmark/evidence.ts",
"bench:png-crop": "node --experimental-strip-types scripts/png-crop-benchmark/run.ts",
"bench:ios-ax-bridge:targeted": "node --experimental-strip-types scripts/ios-ax-bridge-spike/targeted-run.ts",
"mutation:run": "node --experimental-strip-types scripts/mutation/run.ts",
"mutation:check": "node --experimental-strip-types scripts/mutation/run.ts --no-run",
Expand Down
51 changes: 51 additions & 0 deletions scripts/png-crop-benchmark/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# PNG crop benchmark

```sh
pnpm bench:png-crop -- --rounds 5
pnpm bench:png-crop -- --rounds 5 --file /path/to/real-capture.png
```

Compares the two ways this repository can crop a screenshot. Both run over the same bytes as PNG
worker jobs in one process, so what differs is the algorithm and not the thread it happens to land
on, or a file write on one side only:

- `whole-image` — the previous crop: one job decodes the whole capture to RGBA, the box rows are
copied out of that bitmap, and a second job encodes the box.
- `region` — the shipped crop: one job reads the box's rows and encodes them, as RGB rather than
RGBA whenever the cropped pixels are all opaque.

The corpus is generated, so a full run costs seconds and needs no device. Generated captures are
written to `.tmp/png-crop-benchmark/` and each one's compressed size is printed under the table:
a corpus that stops resembling a real capture becomes visible there instead of flattering the
result. Pass real captures with `--file` (repeatable) to put their numbers in the same table; the
real captures decide the verdict, since generated content cannot match a device's deflate stream.

## What is actually saved

Neither pipeline reads less of the file: a deflate stream has to be inflated to its end, so both
inflate the whole compressed image, and the region path inflates it into a buffer sized for every
filtered row in the capture. What the region path saves is the pixel work — reconstructing only down
to the box's last row and producing only the box's pixels, instead of a full RGBA bitmap for the
whole capture — plus one worker round trip, and the RGBA re-encode of the answer.

## What the measurements have said

Measured at that matched boundary over 7 rounds, on captures taken from an iOS Simulator and an
Android Emulator: the iOS captures go 2.5x to 5.6x faster and their crops come out 1.04x to 2.16x
smaller, mostly because an opaque crop is written as RGB instead of RGBA. A flat UI capture gains
the most, because the previous pipeline still expands the whole capture to an RGBA bitmap whatever
the filters look like, while the region path skips the pixels above the box.

The noisiest capture in that set — a full-screen Android `screencap`, 1.4 MB compressed — is a wash
on time (1.0x to 1.6x) and its crop comes out up to 1.13x *larger*. Inflating and reconstructing
that much entropy dominates both pipelines, and the region writer's `None` filter cannot beat the
general writer's filter search on content that noisy. Check your own captures with `--file` before
reading a win or a loss into any number here.

The encoder keeps the `None` filter on every scanline. Scoring the five PNG filters per row is
1.4x to 2.6x slower and produces a *larger* file on UI captures, where the smallest-sum-of-absolute-
differences heuristic prefers Sub or Up on text rows that deflate smaller unfiltered. On synthetic
low-frequency content — the `photo` captures here, which are smooth 8px blocks — `None` is still
faster but writes about 1.7x more bytes than a filtered encoding would. Real photo-filled captures
still come out smaller with `None` than with the general writer's own filter search, so the corpus
here overstates that case; check your own captures with `--file` before treating it as a limit.
41 changes: 41 additions & 0 deletions scripts/png-crop-benchmark/args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { parseBenchmarkArgs } from './args.ts';

test('an empty command line keeps the default round count', () => {
assert.deepEqual(parseBenchmarkArgs([]), {
rounds: 5,
jsonPath: undefined,
captureFiles: [],
});
});

test('rounds, json path, and every capture file are read from their flags', () => {
assert.deepEqual(
parseBenchmarkArgs([
'--rounds',
'9',
'--json',
'out/bench.json',
'--file',
'a.png',
'--file',
'b.png',
]),
{ rounds: 9, jsonPath: 'out/bench.json', captureFiles: ['a.png', 'b.png'] },
);
});

test('a round count that is not a positive number falls back to the default', () => {
assert.equal(parseBenchmarkArgs(['--rounds', '0']).rounds, 5);
assert.equal(parseBenchmarkArgs(['--rounds', 'many']).rounds, 5);
assert.equal(parseBenchmarkArgs(['--rounds', '2.4']).rounds, 2);
});

test('a flag with no value after it is ignored', () => {
assert.deepEqual(parseBenchmarkArgs(['--json']), {
rounds: 5,
jsonPath: undefined,
captureFiles: [],
});
});
36 changes: 36 additions & 0 deletions scripts/png-crop-benchmark/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/** The command line `pnpm bench:png-crop` accepts. */

export type BenchmarkOptions = Readonly<{
rounds: number;
jsonPath: string | undefined;
captureFiles: readonly string[];
}>;

const DEFAULT_ROUNDS = 5;

export function parseBenchmarkArgs(argv: readonly string[]): BenchmarkOptions {
return {
rounds: readNumber(argv, '--rounds') ?? DEFAULT_ROUNDS,
jsonPath: readString(argv, '--json'),
captureFiles: readAll(argv, '--file'),
};
}

function readNumber(argv: readonly string[], flag: string): number | undefined {
const value = readString(argv, flag);
const parsed = value === undefined ? Number.NaN : Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
}

function readString(argv: readonly string[], flag: string): string | undefined {
const index = argv.indexOf(flag);
return index >= 0 ? argv[index + 1] : undefined;
}

function readAll(argv: readonly string[], flag: string): string[] {
const values: string[] = [];
argv.forEach((entry, index) => {
if (entry === flag && argv[index + 1] !== undefined) values.push(argv[index + 1]!);
});
return values;
}
68 changes: 68 additions & 0 deletions scripts/png-crop-benchmark/corpus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PNG } from '@agent-device/capture-kit/png';
import { buildCorpus, cropBoxOf, CROP_SCENARIOS } from './corpus.ts';

const SCENARIO_BY_NAME = new Map(CROP_SCENARIOS.map((scenario) => [scenario.name, scenario]));
const CAPTURE = {
name: 'phone',
label: 'phone',
width: 1200,
height: 2400,
bytes: Buffer.alloc(0),
};
const SMALL = [{ name: 'tiny', label: 'tiny', width: 24, height: 40 }];

test('a card crop keeps the framed fraction of the capture', () => {
const box = cropBoxOf(CAPTURE, SCENARIO_BY_NAME.get('card')!);

assert.deepEqual(box, { x: 96, y: 600, width: 960, height: 480 });
});

test('a full-bleed header is clamped to the image, never one pixel past it', () => {
const box = cropBoxOf(CAPTURE, SCENARIO_BY_NAME.get('header')!);

assert.equal(box.x, 0);
assert.equal(box.width, CAPTURE.width);
assert.equal(box.y + box.height, 288);
});

test('the same generated capture comes out byte for byte every run', () => {
const directory = mkdtempSync(path.join(os.tmpdir(), 'png-crop-corpus-'));
try {
const first = buildCorpus(path.join(directory, 'first'), SMALL);
const second = buildCorpus(path.join(directory, 'second'), SMALL);

assert.equal(first.length, 2);
assert.deepEqual(
first.map((capture) => capture.bytes),
second.map((capture) => capture.bytes),
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});

test('interface and photo captures differ, and both decode at their declared size', () => {
const directory = mkdtempSync(path.join(os.tmpdir(), 'png-crop-corpus-'));
try {
const [interfaceCapture, photoCapture] = buildCorpus(directory, SMALL);

const decoded = [interfaceCapture, photoCapture].map((capture) =>
PNG.sync.read(capture!.bytes),
);
assert.deepEqual(
decoded.map((png) => [png.width, png.height]),
[
[24, 40],
[24, 40],
],
);
assert.notDeepEqual(decoded[0]!.data, decoded[1]!.data);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
155 changes: 155 additions & 0 deletions scripts/png-crop-benchmark/corpus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { PNG } from '@agent-device/capture-kit/png';

/**
* The captures and crop boxes this benchmark measures. They are generated rather than recorded,
* so a run costs seconds and needs no device; each one reports its own compressed size, which is
* how a corpus that stopped resembling a real capture becomes visible instead of flattering.
* Pass real captures with `--file` to put their numbers in the same table.
*/

export type CaptureResolution = Readonly<{
name: string;
label: string;
width: number;
height: number;
}>;

const RESOLUTIONS: readonly CaptureResolution[] = [
{ name: 'ios-phone', label: 'iPhone-class 1206x2622', width: 1206, height: 2622 },
{ name: 'android-phone', label: 'Android-class 1080x2400', width: 1080, height: 2400 },
{ name: 'ios-pad', label: 'iPad-class 2048x2732', width: 2048, height: 2732 },
];

export type Capture = Readonly<{
name: string;
label: string;
width: number;
height: number;
bytes: Buffer;
}>;

export type CropScenario = Readonly<{
name: string;
/** Fractions of the capture, so one scenario reads well at every resolution. */
fraction: Readonly<{ x: number; y: number; width: number; height: number }>;
}>;

/** The box shapes `--crop-on` resolves: a card, a full-bleed header, and a small control. */
export const CROP_SCENARIOS: readonly CropScenario[] = [
{ name: 'card', fraction: { x: 0.08, y: 0.25, width: 0.8, height: 0.2 } },
{ name: 'header', fraction: { x: 0, y: 0, width: 1, height: 0.12 } },
{ name: 'control', fraction: { x: 0.3, y: 0.6, width: 0.3, height: 0.04 } },
];

export function buildCorpus(
outDir: string,
resolutions: readonly CaptureResolution[] = RESOLUTIONS,
): Capture[] {
mkdirSync(outDir, { recursive: true });
return resolutions.flatMap(({ name, label, width, height }) =>
(['interface', 'photo'] as const).map((kind) => {
const bytes = PNG.sync.write(encodeCapture(width, height, kind));
writeFileSync(path.join(outDir, `${name}-${kind}.png`), bytes);
return { name: `${name}-${kind}`, label: `${label} ${kind}`, width, height, bytes };
}),
);
}

export function readCaptureFile(filePath: string, index: number): Capture {
const bytes = readFileSync(filePath);
const png = PNG.sync.read(bytes);
return {
name: `capture-${index}`,
label: path.basename(filePath),
width: png.width,
height: png.height,
bytes,
};
}

type Content = 'interface' | 'photo';

/**
* A capture a device could have produced. `interface` is flat panels with high-frequency text
* where the copy is drawn, which is what a settings or list screen looks like. `photo` is
* low-frequency detail in 8px blocks plus a gradient, which is what a photo, artwork, or a blurred
* background does to the deflate stream.
*/
function encodeCapture(width: number, height: number, content: Content): PNG {
const png = new PNG({ width, height });
const tones = content === 'photo' ? photoToneField(width, height) : null;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const offset = (y * width + x) * 4;
const [red, green, blue] = tones ? photoPixel(tones, x, y) : interfacePixel(x, y);
png.data[offset] = red;
png.data[offset + 1] = green;
png.data[offset + 2] = blue;
png.data[offset + 3] = 255;
}
}
return png;
}

/** Flat panels with high-frequency text where the copy is drawn. */
function interfacePixel(x: number, y: number): readonly [number, number, number] {
const ink = x % 320 < 2 || y % 96 < 2 || (y % 24 > 6 && y % 24 < 18 && x % 7 < 3);
if (ink) return [24, 26, 30];
const band = Math.floor(y / 96);
return [(band * 11) % 240, (band * 17) % 244, (band * 23) % 248];
}

/** The 8px-block detail and soft gradient of a photo, scaled by the pixel position. */
function photoPixel(
tones: readonly (readonly number[])[],
x: number,
y: number,
): readonly [number, number, number] {
const tone = tones[y >> 3]?.[x >> 3] ?? 0;
return [
clampByte(tone + y / 6),
clampByte(tone * 0.8 + x / 9),
clampByte(tone * 0.6 + (x + y) / 24),
];
}

/** Smooth 8px-block luminance in [40, 215], so a photo corpus compresses like a real photo. */
function photoToneField(width: number, height: number): number[][] {
let state = 0x9e3779b9;
const next = () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 0x100000000;
};
return Array.from({ length: Math.ceil(height / 8) }, (_row, blockRow) =>
Array.from({ length: Math.ceil(width / 8) }, (_column, blockColumn) => {
const drift = Math.sin(blockRow / 26) * 45 + Math.cos(blockColumn / 19) * 45;
return clampByte(128 + drift + (next() - 0.5) * 70);
}),
);
}

function clampByte(value: number): number {
return Math.max(0, Math.min(255, Math.round(value)));
}

export function cropBoxOf(
capture: Capture,
scenario: CropScenario,
): {
x: number;
y: number;
width: number;
height: number;
} {
const { fraction } = scenario;
const x = Math.round(capture.width * fraction.x);
const y = Math.round(capture.height * fraction.y);
return {
x,
y,
width: Math.max(1, Math.min(capture.width - x, Math.round(capture.width * fraction.width))),
height: Math.max(1, Math.min(capture.height - y, Math.round(capture.height * fraction.height))),
};
}
Loading
Loading