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/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 {};