# Ask me questions
npx @solithix/optimize
# Just do it
npx @solithix/optimize ./assets --profile web --out ./dist
# Show me what would happen, change nothing
npx @solithix/optimize ./assets --dry-runNo config. No setup. Point it at a folder.
Every number on this page came out of the tool. None of them are estimates.
| Input | Becomes | Handled by |
|---|---|---|
| JPEG, PNG, WebP, TIFF, AVIF, SVG | WebP, AVIF, JPEG, PNG | sharp |
| GIF | animated WebP, optimized GIF, MP4, WebM | sharp |
| MP4, MOV, MKV, AVI, WebM, and friends | H.264 MP4, VP9 WebM, plus a poster frame | ffmpeg |
GIFs never touch ffmpeg. sharp reads and writes animated GIF and WebP on its own, so the common path needs no external binary at all. Without ffmpeg installed you lose video and nothing else.
Pick one instead of answering twenty questions.
| Profile | What it does |
|---|---|
web |
The default. WebP at quality 80, capped at 1920 px. |
balanced |
Gentler. Higher quality, no resize. |
maximum |
Smallest files. Uses AVIF, so it is slow. |
lossless |
No quality loss. Files may grow, and the tool refuses when they do. |
npx @solithix/optimize ./img --profile maximumThe trick is not the pool size — it is when you set it. libuv's thread pool
caps how many images sharp can process at once, it defaults to 4, and it is
built during runtime start-up. Setting UV_THREADPOOL_SIZE from inside the app
does nothing at all. So the entry point re-execs once with the pool sized to
your core count, which costs about 85 ms and pays for itself on the first file.
Most of this exists because the previous version got it wrong.
- Originals are safe by default. Results go to an
optimized/folder. - Nothing is written in place. Every file lands beside its target and is renamed in. A crash, a cancel, or a failed encode leaves the original alone.
- It refuses to make files bigger. Larger output is thrown away and
reported.
--forceoverrides. - Collisions are numbered, not overwritten.
Photo 1.jpgandphoto-1.jpgboth sanitize to the same name; you get both files, not one. - Tiny files are skipped. Below 10 KB the encoding overhead beats the saving.
The engine emits a typed event stream. The wizard, the terminal reporter, and
--json are all just subscribers to it — which is the only reason a headless
mode is possible. Put the processing loop inside a React component and nothing
can run without a UI.
--out, -o <dir> Write results here
--suffix <text> Same folder, add this to the name
--replace Overwrite the originals (needs --force)
--dry-run Measure the saving, write nothing
--json One JSON document, nothing else
--profile, -p <name> web | balanced | maximum | lossless
--format, -f <fmt> webp | avif | jpeg | png | keep
--quality, -q <1-100>
--width, -w <px> Largest image width
--gif <fmt> webp | gif | mp4 | webm
--concurrency, -c <n> Files at once (default: core count)
--force Write even when the result is bigger
--yes, -y Skip confirmations
Exit code is 0 on success, 1 when a file failed, 2 for a bad command — so
CI can actually tell.
optimize ./public/img --profile web --out ./dist/img --json > report.jsonDrop an optimize.config.json beside your package.json, or add an optimize
key to it. Flags beat the config file; the config file beats the profile.
{
"profile": "web",
"image": { "format": "webp", "quality": 82, "maxWidth": 1600 },
"animation": { "output": "webp" },
"output": { "mode": "folder", "dir": "dist/assets" }
}- Node.js ≥ 20.9
- FFmpeg — only for video. A binary ships as a fallback, and the tool runs happily without one.
The illustrations above are generated, not drawn:
node docs/assets/generate.mjs docs/assetsMIT
{ "ok": true, "summary": { "total": 12, "succeeded": 11, "skipped": 1, "before": 2011882, "after": 812344 }, "skipped": [{ "name": "logo.png", "reason": "larger", "wouldBe": 431023 }] }