A scriptable image CLI (crop, resize, convert, annotate, diff) paired with a local web viewer for side-by-side comparison of image sets. Built to be a general-purpose tool for any project on this machine, not tied to any one project's data.
pnpm install
pnpm build
Consumed by another local project as a file: or git dependency, e.g.:
pnpm add file:../comparo
Every command accepts --json for machine-readable output (a single JSON object on stdout),
useful when calling this CLI from a script or an agent. Without --json, output is a
human-readable one-line summary.
Crops to the given rectangular region (x,y = top-left corner, non-negative; w,h = size,
positive).
Resizes, preserving aspect ratio if only one of --width/--height is given. At least one is
required.
Converts image format. --format is one of jpeg, png, webp, gif, avif, tiff; if
omitted, it's inferred from <output>'s extension.
Draws boxes, arrows, and text onto an image. --shapes (or --shapes-file, for a longer spec) is
a JSON array of shapes:
[
{ "type": "box", "x": 10, "y": 10, "w": 100, "h": 80, "color": "#ff0000", "strokeWidth": 3 },
{ "type": "arrow", "x1": 0, "y1": 0, "x2": 50, "y2": 50, "color": "#00ff00" },
{ "type": "text", "x": 5, "y": 20, "text": "note", "color": "#000000", "fontSize": 16 }
]color must be a hex color (#rgb, #rrggbb, or #rrggbbaa); strokeWidth/fontSize default
to 3/16 if omitted. Output format is inferred from <output>'s extension, same as convert.
Pixel-compares two same-dimension images via pixelmatch. Always reports mismatchedPixels,
totalPixels, and score (0 = identical, 1 = fully different). With --out-dir, also writes a
visual diff image (<a-name>-vs-<b-name>.diff.png) and reports its path as diffPath — this is
the field the viewer picks up to render a diff column (see below).
Starts a local viewer server (binds to 127.0.0.1 only) at http://127.0.0.1:<port>, rendering
a grid of the images in <dir>.
serve looks for a manifest.json in <dir>. If one exists, it's used as-is. Otherwise <dir>
is auto-scanned: only immediate (one level deep) subfolders become groups, files inside a
group become items and are labeled by filename (minus extension); loose files sitting directly in
<dir> are skipped and reported as a startup warning, not silently dropped.
{
"rows": [
{
"id": "lanco-plumbing/photo-1",
"group": "lanco-plumbing",
"label": "Storefront",
"images": [
{ "label": "Original", "path": "/abs/path/original.jpg" },
{ "label": "FLUX", "path": "/abs/path/flux.jpg" },
{ "label": "Gemini", "path": "/abs/path/gemini.jpg" }
],
"diffPath": "/abs/path/original-vs-flux.diff.png"
}
]
}groupbecomes a section heading in the viewer (e.g. one per business/vertical).imagesis an ordered array — as many columns as you like, each with its ownlabel.diffPathis optional; when present, the viewer renders it as an extra "diff" column.path/diffPathvalues may be absolute, or relative — relative paths are resolved against the directory containingmanifest.jsonitself (not the directoryserveis invoked from), so a manifest authored alongside its images stays portable. The viewer only serves files that actually appear in the current manifest (see Security below) — it is not a general file server.
comparo diff original.jpg flux.jpg --out-dir diffs --json
# {"ok":true,"command":"diff","a":"original.jpg","b":"flux.jpg","mismatchedPixels":1234,
# "totalPixels":60000,"score":0.02,"diffPath":"diffs/original-vs-flux.diff.png"}Reference that diffPath in a manifest row's diffPath field (relative to wherever you put
manifest.json, or copy the absolute path diff --json reported), then:
comparo serve ./my-comparison-dirOpen the printed URL to see the original, FLUX output, and diff image side by side.
serve binds to 127.0.0.1 only and has no authentication — it's meant for personal, local use.
The /file route only serves paths that appear in the manifest currently being served (recomputed
per request), not an arbitrary filesystem read, to limit exposure if a browser tab visits a
malicious page while the server happens to be running.
No EXIF/metadata management, no photo-library organization (albums/tags/search), no ML/embedding features, no batch/watch modes, no cloud storage integration. This stays a thin, scriptable local tool.