mirror of
https://github.com/longbridge/developers.git
synced 2026-09-19 03:34:09 +08:00
189 lines
5.5 KiB
TypeScript
189 lines
5.5 KiB
TypeScript
|
|
#!/usr/bin/env bun
|
|||
|
|
/**
|
|||
|
|
* visual-diff.ts — Pixel-level visual comparison via odiff-bin.
|
|||
|
|
*
|
|||
|
|
* Wraps the `odiff` binary (from odiff-bin devDependency) using execSync.
|
|||
|
|
* Returns pixel difference percentage and path to the diff PNG.
|
|||
|
|
*
|
|||
|
|
* Usage:
|
|||
|
|
* bun run scripts/opencli/visual-diff.ts <old.png> <new.png> [--diff <diff.png>]
|
|||
|
|
* bun run scripts/opencli/visual-diff.ts --help
|
|||
|
|
*
|
|||
|
|
* Exit codes:
|
|||
|
|
* 0 Images are identical or below threshold (default: 0.1%)
|
|||
|
|
* 1 Visual difference exceeds threshold or tool error
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { execSync } from "node:child_process";
|
|||
|
|
import { existsSync, mkdirSync } from "node:fs";
|
|||
|
|
import { dirname, join, resolve } from "node:path";
|
|||
|
|
import { createRequire } from "node:module";
|
|||
|
|
|
|||
|
|
const DEFAULT_THRESHOLD_PERCENT = 0.1; // 0.1% pixel diff allowed
|
|||
|
|
|
|||
|
|
export interface VisualDiffResult {
|
|||
|
|
changed: number; // % pixel difference (0–100)
|
|||
|
|
diffPngPath: string;
|
|||
|
|
pass: boolean;
|
|||
|
|
error?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Resolve the odiff binary path from node_modules. */
|
|||
|
|
function resolveOdiffBin(): string {
|
|||
|
|
// odiff-bin ships the binary at odiff-bin/bin/odiff (linux/mac/win)
|
|||
|
|
try {
|
|||
|
|
const require = createRequire(import.meta.url);
|
|||
|
|
const pkg = require.resolve("odiff-bin/package.json");
|
|||
|
|
const pkgDir = dirname(pkg);
|
|||
|
|
// Try common paths: bin/odiff, odiff
|
|||
|
|
for (const rel of ["bin/odiff", "odiff"]) {
|
|||
|
|
const candidate = join(pkgDir, rel);
|
|||
|
|
if (existsSync(candidate)) return candidate;
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
// fall through to PATH
|
|||
|
|
}
|
|||
|
|
// Fall back to PATH
|
|||
|
|
return "odiff";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Run a pixel-level visual diff between two PNG files.
|
|||
|
|
* Uses odiff-bin binary via execSync.
|
|||
|
|
*
|
|||
|
|
* @param oldPng Path to baseline screenshot
|
|||
|
|
* @param newPng Path to candidate screenshot
|
|||
|
|
* @param diffPng Path to write the diff overlay PNG
|
|||
|
|
* @param threshold % pixel diff to consider a pass (0–100, default 0.1)
|
|||
|
|
*/
|
|||
|
|
export function visualDiff(
|
|||
|
|
oldPng: string,
|
|||
|
|
newPng: string,
|
|||
|
|
diffPng: string,
|
|||
|
|
threshold = DEFAULT_THRESHOLD_PERCENT,
|
|||
|
|
): VisualDiffResult {
|
|||
|
|
// Ensure diff output directory exists
|
|||
|
|
mkdirSync(dirname(resolve(diffPng)), { recursive: true });
|
|||
|
|
|
|||
|
|
const bin = resolveOdiffBin();
|
|||
|
|
// odiff CLI: odiff <base> <compare> <diff> [options]
|
|||
|
|
// --threshold: per-pixel color threshold (0–1 float)
|
|||
|
|
const cmd = `${JSON.stringify(bin)} ${JSON.stringify(resolve(oldPng))} ${JSON.stringify(resolve(newPng))} ${JSON.stringify(resolve(diffPng))} --threshold 0.1 --antialiasing`;
|
|||
|
|
|
|||
|
|
let stdout = "";
|
|||
|
|
let exitCode = 0;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
stdout = execSync(cmd, { encoding: "utf-8" }).toString();
|
|||
|
|
} catch (err: unknown) {
|
|||
|
|
const execErr = err as { stdout?: string; stderr?: string; status?: number };
|
|||
|
|
stdout = execErr.stdout ?? "";
|
|||
|
|
exitCode = execErr.status ?? 1;
|
|||
|
|
// odiff exits 1 when diff found — not a fatal error
|
|||
|
|
if (exitCode !== 1) {
|
|||
|
|
return {
|
|||
|
|
changed: 100,
|
|||
|
|
diffPngPath: diffPng,
|
|||
|
|
pass: false,
|
|||
|
|
error: `odiff failed (exit ${exitCode}): ${execErr.stderr ?? ""}`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Parse odiff output: "Pixels changed: 1234 (0.12%)" or "Images are equal!"
|
|||
|
|
let changed = 0;
|
|||
|
|
|
|||
|
|
const equalMatch = /Images are equal/i.test(stdout);
|
|||
|
|
const percentMatch = stdout.match(/\(([0-9.]+)%\)/);
|
|||
|
|
|
|||
|
|
if (equalMatch) {
|
|||
|
|
changed = 0;
|
|||
|
|
} else if (percentMatch) {
|
|||
|
|
changed = parseFloat(percentMatch[1]);
|
|||
|
|
} else if (exitCode === 0) {
|
|||
|
|
changed = 0;
|
|||
|
|
} else {
|
|||
|
|
// Fallback: treat as 100% diff if we can't parse
|
|||
|
|
changed = 100;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
changed,
|
|||
|
|
diffPngPath: diffPng,
|
|||
|
|
pass: changed <= threshold,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- CLI main ----
|
|||
|
|
|
|||
|
|
function printHelp() {
|
|||
|
|
console.log(`
|
|||
|
|
visual-diff.ts — pixel-level visual comparison between two PNG screenshots.
|
|||
|
|
Wraps odiff-bin binary.
|
|||
|
|
|
|||
|
|
Usage:
|
|||
|
|
bun run scripts/opencli/visual-diff.ts <old.png> <new.png> [options]
|
|||
|
|
bun run scripts/opencli/visual-diff.ts --help
|
|||
|
|
|
|||
|
|
Arguments:
|
|||
|
|
<old.png> Baseline screenshot (vitepress)
|
|||
|
|
<new.png> Candidate screenshot (astro)
|
|||
|
|
|
|||
|
|
Options:
|
|||
|
|
--diff <path> Output diff PNG path (default: dist-diff/visual/<slug>.diff.png)
|
|||
|
|
--threshold <pct> % pixel difference to pass (default: ${DEFAULT_THRESHOLD_PERCENT})
|
|||
|
|
--json Output machine-readable JSON
|
|||
|
|
--help Show this help
|
|||
|
|
|
|||
|
|
Exit codes:
|
|||
|
|
0 Pass (diff ≤ threshold)
|
|||
|
|
1 Fail (diff > threshold) or odiff error
|
|||
|
|
`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function main() {
|
|||
|
|
const args = process.argv.slice(2);
|
|||
|
|
|
|||
|
|
if (args.includes("--help") || args.length === 0) {
|
|||
|
|
printHelp();
|
|||
|
|
process.exit(0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const jsonMode = args.includes("--json");
|
|||
|
|
const diffIdx = args.indexOf("--diff");
|
|||
|
|
const threshIdx = args.indexOf("--threshold");
|
|||
|
|
const threshold = threshIdx !== -1 ? parseFloat(args[threshIdx + 1]) : DEFAULT_THRESHOLD_PERCENT;
|
|||
|
|
const positional = args.filter((a) => !a.startsWith("--"));
|
|||
|
|
|
|||
|
|
if (positional.length < 2) {
|
|||
|
|
console.error("Error: two PNG paths required. Run --help for usage.");
|
|||
|
|
process.exit(1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const [oldPng, newPng] = positional;
|
|||
|
|
const diffPng = diffIdx !== -1 ? args[diffIdx + 1] : `dist-diff/visual/${Date.now()}.diff.png`;
|
|||
|
|
|
|||
|
|
const result = visualDiff(oldPng, newPng, diffPng, threshold);
|
|||
|
|
|
|||
|
|
if (jsonMode) {
|
|||
|
|
console.log(JSON.stringify(result, null, 2));
|
|||
|
|
} else {
|
|||
|
|
if (result.error) {
|
|||
|
|
console.error(`Error: ${result.error}`);
|
|||
|
|
} else {
|
|||
|
|
console.log(`Pixel difference: ${result.changed.toFixed(2)}%`);
|
|||
|
|
console.log(`Threshold: ${threshold}% — ${result.pass ? "PASS" : "FAIL"}`);
|
|||
|
|
if (!result.pass) {
|
|||
|
|
console.log(`Diff PNG: ${result.diffPngPath}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
process.exit(result.pass ? 0 : 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
main().catch((e) => {
|
|||
|
|
console.error(e);
|
|||
|
|
process.exit(1);
|
|||
|
|
});
|