jev-compare 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +365 -2
- package/THIRD_PARTY_LICENSES +109 -0
- package/dist/cli.mjs +223 -0
- package/dist/index.cjs +5992 -0
- package/dist/index.d.cts +470 -0
- package/dist/index.d.mts +470 -0
- package/dist/index.mjs +5971 -0
- package/package.json +82 -4
package/dist/cli.mjs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { annotateDifferences, compareImages, fileCache, listReports, pruneReports } from "./index.mjs";
|
|
5
|
+
//#region src/cli.ts
|
|
6
|
+
const USAGE = `Usage: jev-compare <reference> <candidate> [options]
|
|
7
|
+
jev-compare --list <dir>
|
|
8
|
+
jev-compare --prune <dir> [--keep-days <n>] [--keep-count <n>] [--keep-mb <n>] [--dry-run]
|
|
9
|
+
jev-compare --prune-cache <dir> [--cache-days <n>] [--cache-entries <n>] [--cache-mb <n>]
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
--json Print the full result as JSON
|
|
13
|
+
--svg <file> Write the candidate with differences outlined to an SVG file
|
|
14
|
+
--cache <dir> Cache API answers on disk; repeated comparisons are free
|
|
15
|
+
--cache-days <n> Cache limit: entries expire after n days
|
|
16
|
+
--cache-entries <n> Cache limit: keep at most n entries (least recently used go first)
|
|
17
|
+
--cache-mb <n> Cache limit: keep at most n MB (least recently used go first)
|
|
18
|
+
(with any cache limit, the cache prunes itself at most hourly)
|
|
19
|
+
--prune-cache <dir> Prune the cache under <dir> now by the --cache-* limits
|
|
20
|
+
--report <dir> Save a report (report.json, report.html, images) under <dir>
|
|
21
|
+
--report-format <f> full: report.json + report.html + images (default); json: report.json only
|
|
22
|
+
--meta <key=value> Metadata to keep with the report; repeatable
|
|
23
|
+
--list <dir> List the reports saved under <dir>, newest first
|
|
24
|
+
--prune <dir> Delete old reports under <dir> by the --keep-* limits
|
|
25
|
+
--keep-days <n> Retention: delete reports older than n days
|
|
26
|
+
--keep-count <n> Retention: keep at most n reports
|
|
27
|
+
--keep-mb <n> Retention: keep the newest reports that fit in n MB
|
|
28
|
+
--dry-run With --prune, show what would be deleted without deleting
|
|
29
|
+
(--keep-* also apply after --report saves a report)
|
|
30
|
+
--locale <zh|en> Language of the report (default: zh)
|
|
31
|
+
--max <n> Maximum differences to locate, 0-8 (default: 4)
|
|
32
|
+
--threshold <0-1> Minimum probability to report a difference (default: 0.5)
|
|
33
|
+
--no-refine Skip the second look at each difference (faster, coarser)
|
|
34
|
+
--max-size <px> Downscale so the longest side is at most this, up to 1568 (default: 1568)
|
|
35
|
+
--max-pixels <n> Downscale to at most this many pixels; lower uses fewer tokens (default: 1150000)
|
|
36
|
+
--no-resize Send the original images (the API rejects sides over 1568 px)
|
|
37
|
+
--context <text> Extra context for the model
|
|
38
|
+
--model <name> Model name (default: jev-preview)
|
|
39
|
+
-h, --help Show this help
|
|
40
|
+
|
|
41
|
+
The API key is read from JEV_KEY or TYPESAFE_API_KEY.
|
|
42
|
+
Behind an HTTP proxy, run with NODE_USE_ENV_PROXY=1 (Node.js 24+).`;
|
|
43
|
+
async function main() {
|
|
44
|
+
const { values, positionals } = parseArgs({
|
|
45
|
+
allowPositionals: true,
|
|
46
|
+
options: {
|
|
47
|
+
json: { type: "boolean" },
|
|
48
|
+
svg: { type: "string" },
|
|
49
|
+
report: { type: "string" },
|
|
50
|
+
cache: { type: "string" },
|
|
51
|
+
"cache-days": { type: "string" },
|
|
52
|
+
"cache-entries": { type: "string" },
|
|
53
|
+
"cache-mb": { type: "string" },
|
|
54
|
+
"prune-cache": { type: "string" },
|
|
55
|
+
meta: {
|
|
56
|
+
type: "string",
|
|
57
|
+
multiple: true
|
|
58
|
+
},
|
|
59
|
+
"report-format": { type: "string" },
|
|
60
|
+
list: { type: "string" },
|
|
61
|
+
prune: { type: "string" },
|
|
62
|
+
"keep-days": { type: "string" },
|
|
63
|
+
"keep-count": { type: "string" },
|
|
64
|
+
"keep-mb": { type: "string" },
|
|
65
|
+
"dry-run": { type: "boolean" },
|
|
66
|
+
locale: { type: "string" },
|
|
67
|
+
max: { type: "string" },
|
|
68
|
+
threshold: { type: "string" },
|
|
69
|
+
context: { type: "string" },
|
|
70
|
+
"no-refine": { type: "boolean" },
|
|
71
|
+
"max-size": { type: "string" },
|
|
72
|
+
"max-pixels": { type: "string" },
|
|
73
|
+
"no-resize": { type: "boolean" },
|
|
74
|
+
model: { type: "string" },
|
|
75
|
+
help: {
|
|
76
|
+
type: "boolean",
|
|
77
|
+
short: "h"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
if (values.list !== void 0) {
|
|
82
|
+
const reports = await listReports(values.list);
|
|
83
|
+
if (values.json) {
|
|
84
|
+
console.log(JSON.stringify(reports, null, 2));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!reports.length) console.log(`No reports in ${values.list}`);
|
|
88
|
+
for (const r of reports) console.log(`${r.id} ${String(r.score).padStart(4)}/10 ${r.differenceCount} diff ${r.candidate.path ?? r.candidate.sha256.slice(0, 12)}`);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const cacheLimits = cacheLimitsFrom(values);
|
|
92
|
+
if (values["prune-cache"] !== void 0) {
|
|
93
|
+
const result = await fileCache({
|
|
94
|
+
dir: values["prune-cache"],
|
|
95
|
+
...cacheLimits,
|
|
96
|
+
autoPrune: false
|
|
97
|
+
}).prune();
|
|
98
|
+
if (values.json) console.log(JSON.stringify(result, null, 2));
|
|
99
|
+
else printCachePrune(result);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const retention = retentionFrom(values);
|
|
103
|
+
if (values.prune !== void 0) {
|
|
104
|
+
if (!retention) throw new Error("--prune needs at least one of --keep-days, --keep-count or --keep-mb");
|
|
105
|
+
const result = await pruneReports(values.prune, {
|
|
106
|
+
...retention,
|
|
107
|
+
dryRun: values["dry-run"]
|
|
108
|
+
});
|
|
109
|
+
if (values.json) console.log(JSON.stringify(result, null, 2));
|
|
110
|
+
else printPrune(result, values["dry-run"] ?? false);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (values.help || positionals.length !== 2) {
|
|
114
|
+
console.log(USAGE);
|
|
115
|
+
process.exitCode = values.help ? 0 : 1;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (values["report-format"] !== void 0 && values.report === void 0) throw new Error("--report-format needs --report <dir>");
|
|
119
|
+
const [reference, candidate] = positionals;
|
|
120
|
+
const result = await compareImages(reference, candidate, {
|
|
121
|
+
locale: values.locale ?? "zh",
|
|
122
|
+
maxDifferences: values.max === void 0 ? void 0 : Number(values.max),
|
|
123
|
+
threshold: values.threshold === void 0 ? void 0 : Number(values.threshold),
|
|
124
|
+
refine: !values["no-refine"],
|
|
125
|
+
resize: values["no-resize"] ? false : {
|
|
126
|
+
maxDimension: values["max-size"] === void 0 ? void 0 : Number(values["max-size"]),
|
|
127
|
+
maxPixels: values["max-pixels"] === void 0 ? void 0 : Number(values["max-pixels"])
|
|
128
|
+
},
|
|
129
|
+
context: values.context,
|
|
130
|
+
model: values.model,
|
|
131
|
+
cache: values.cache === void 0 ? void 0 : fileCache({
|
|
132
|
+
dir: values.cache,
|
|
133
|
+
...cacheLimits
|
|
134
|
+
}),
|
|
135
|
+
report: values.report === void 0 ? void 0 : {
|
|
136
|
+
dir: values.report,
|
|
137
|
+
format: reportFormat(values["report-format"]),
|
|
138
|
+
meta: parseMeta(values.meta ?? []),
|
|
139
|
+
retention
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
if (values.svg) await writeFile(values.svg, await annotateDifferences(candidate, result));
|
|
143
|
+
if (values.json) {
|
|
144
|
+
console.log(JSON.stringify(result, null, 2));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
console.log(result.summary);
|
|
148
|
+
if (result.requests.cached) console.log(`(${result.requests.cached}/${result.requests.total} API requests served from cache)`);
|
|
149
|
+
for (const d of result.differences) {
|
|
150
|
+
const b = d.boxPx;
|
|
151
|
+
console.log(`\n${d.rank}. [${d.typeLabel}] ${d.elementLabel} p=${d.probability.toFixed(2)}\n box: x=${b.x} y=${b.y} w=${b.width} h=${b.height}\n ${d.suggestion}`);
|
|
152
|
+
}
|
|
153
|
+
if (result.issues.length) {
|
|
154
|
+
console.log("");
|
|
155
|
+
for (const issue of result.issues) console.log(`- ${issue.label} (${issue.probability.toFixed(2)}): ${issue.suggestion}`);
|
|
156
|
+
}
|
|
157
|
+
if (values.svg) console.log(`\nSVG: ${values.svg}`);
|
|
158
|
+
if (result.report) console.log(`\nReport: ${result.report.files.html ?? result.report.files.json}`);
|
|
159
|
+
if (result.report?.pruned?.removed.length) console.log(`Deleted ${result.report.pruned.removed.length} old report(s), freed ${formatBytes(result.report.pruned.freedBytes)}`);
|
|
160
|
+
}
|
|
161
|
+
function numberFlag(values, name) {
|
|
162
|
+
const raw = values[name];
|
|
163
|
+
if (raw === void 0) return void 0;
|
|
164
|
+
const n = Number(raw);
|
|
165
|
+
if (!Number.isFinite(n) || n < 0) throw new Error(`--${name} expects a non-negative number, got "${String(raw)}"`);
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
function cacheLimitsFrom(values) {
|
|
169
|
+
const days = numberFlag(values, "cache-days");
|
|
170
|
+
const entries = numberFlag(values, "cache-entries");
|
|
171
|
+
const mb = numberFlag(values, "cache-mb");
|
|
172
|
+
return {
|
|
173
|
+
ttl: days === void 0 ? void 0 : days * 864e5,
|
|
174
|
+
maxEntries: entries === void 0 ? void 0 : Math.floor(entries),
|
|
175
|
+
maxBytes: mb === void 0 ? void 0 : mb * 1024 * 1024
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function printCachePrune(r) {
|
|
179
|
+
console.log(`Removed ${r.expired} expired, ${r.evicted} evicted, ${r.invalid} invalid, ${r.temp} temp file(s); freed ${formatBytes(r.freedBytes)}. Kept ${r.keptEntries} entries (${formatBytes(r.keptBytes)}).`);
|
|
180
|
+
}
|
|
181
|
+
function retentionFrom(values) {
|
|
182
|
+
const num = (name) => numberFlag(values, name);
|
|
183
|
+
const days = num("keep-days");
|
|
184
|
+
const count = num("keep-count");
|
|
185
|
+
const mb = num("keep-mb");
|
|
186
|
+
if (days === void 0 && count === void 0 && mb === void 0) return void 0;
|
|
187
|
+
return {
|
|
188
|
+
maxAge: days === void 0 ? void 0 : days * 864e5,
|
|
189
|
+
maxCount: count === void 0 ? void 0 : Math.floor(count),
|
|
190
|
+
maxBytes: mb === void 0 ? void 0 : mb * 1024 * 1024
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function printPrune(result, dryRun) {
|
|
194
|
+
const verb = dryRun ? "Would delete" : "Deleted";
|
|
195
|
+
for (const r of result.removed) console.log(`${verb} ${r.id} (${r.reason}, ${formatBytes(r.bytes)})`);
|
|
196
|
+
console.log(`${verb} ${result.removed.length} report(s), ${dryRun ? "would free" : "freed"} ${formatBytes(result.freedBytes)}; kept ${result.kept}` + (result.removedTemp ? `; ${result.removedTemp} leftover temp dir(s)` : ""));
|
|
197
|
+
for (const e of result.errors) console.error(`Could not delete ${e.id}: ${e.message}`);
|
|
198
|
+
if (result.errors.length) process.exitCode = 1;
|
|
199
|
+
}
|
|
200
|
+
function formatBytes(bytes) {
|
|
201
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
202
|
+
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
203
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
204
|
+
}
|
|
205
|
+
function reportFormat(raw) {
|
|
206
|
+
if (raw === void 0 || raw === "full" || raw === "json") return raw;
|
|
207
|
+
throw new Error(`--report-format expects "full" or "json", got "${raw}"`);
|
|
208
|
+
}
|
|
209
|
+
function parseMeta(pairs) {
|
|
210
|
+
const meta = {};
|
|
211
|
+
for (const pair of pairs) {
|
|
212
|
+
const eq = pair.indexOf("=");
|
|
213
|
+
if (eq <= 0) throw new Error(`--meta expects key=value, got "${pair}"`);
|
|
214
|
+
meta[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
215
|
+
}
|
|
216
|
+
return meta;
|
|
217
|
+
}
|
|
218
|
+
main().catch((error) => {
|
|
219
|
+
console.error(error instanceof Error ? error.message : error);
|
|
220
|
+
process.exitCode = 1;
|
|
221
|
+
});
|
|
222
|
+
//#endregion
|
|
223
|
+
export {};
|