oimage
v2.7.3
Published
Interactive terminal UI to optimize images anywhere in your project. Same-format recompression (png→png, jpg→jpg), responsive srcset generation, an SSIM quality guard, incremental caching, and watch mode.
Downloads
164
Maintainers
Readme
oimage
An interactive terminal UI for optimizing images anywhere in your project.
Recompresses in the same format by default — png stays png, jpg stays jpg — so nothing in your markup breaks. Convert to WebP/AVIF only when you ask for it.
┌─ oimage ──────────────────────────────── 5 images · 917 KB · ./public ─┐
│ │
│ ❯ ▾ icons/ 2 img 3.8 KB │
│ [x] logo.png 3.2 KB │
│ [x] tiny.png 667 B │
│ ▾ images/ 3 img 913 KB │
│ [x] card.png 632 KB │
│ [x] hero.jpg 280 KB │
│ │
│ format same quality 80 max-w off out ./optimized/ jobs 8 │
│ selected 5 files · 917 KB │
│ │
│ ℹ format: same Keeps each file in its own format — png stays png, │
│ jpg stays jpg. Your existing <img> tags keep working. Safest choice. │
│ │
│ ↑↓ move · space pick · f format · [ ] quality · r resize · w srcset · │
│ s guard · m output · d preview · ↵ run · ? explain keys · q quit │
└────────────────────────────────────────────────────────────────────────┘Every time you change a setting, the ℹ line says what that setting will
actually do to your files — no need to remember what "ssim" or "palette" mean.
Press ? for a plain-language list of every key.
Install
npm install --save-dev oimage
npx oimgOr globally:
npm install -g oimage
oimgRequires Node.js 22+. The command is oimg; optimize-images and
optimize-public are aliases for the same binary.
Usage
oimg # full-screen UI, auto-detects where images live
oimg ./src/assets # scan a specific directory
oimg --dry-run # report projected savings, write nothingFolders nest properly: a deep asset tree opens as a handful of top-level rows,
each showing the recursive image count and byte total, and ticking a folder
ticks everything beneath it ([~] means partly selected). Single-child chains
are merged, so a/ > b/ > c/ shows as one a/b/c/ row.
Nothing is selected to begin with — pick files with space (on a folder it
takes everything beneath), or a for all. Fold folders with ←→, tune settings
live in the status bar, and press d to preview savings before ↵ commits
anything. Nothing is written until you press ↵, and
overwriting originals asks for confirmation first.
| Key | |
|---|---|
| ↑↓ ←→ space a | Move, open/close a folder, tick one, tick all |
| f | Output format — keep the same one, or convert to WebP/AVIF |
| [ ] | Quality down / up by 5 |
| r | Shrink anything wider than a chosen limit |
| w | Generate phone/tablet/desktop sizes and the srcset markup |
| s | Quality guard — auto-fix images a low quality ruins |
| p | PNG palette — big savings on flat graphics, banding on photos |
| m b n | Where files go, keep .bak copies, how many at once |
| c | Skip images already optimized, or re-do every one |
| d | Dry run — show what would be saved, change nothing |
| ↵ | Start optimizing |
| t | Tools — VAP recompression, and VAP → WebP / SVGA converters |
| ? | Explain every key |
| q | Quit |
Headless / CI
Pass --yes or --dry-run and it skips the UI entirely:
oimg ./public --format same -q 75 --max-width 1920 --yes
oimg --json > report.jsonExit codes: 0 success, 1 some files failed, 2 bad arguments or a refused run.
Options
| Option | Default | |
|---|---|---|
| [directory] | auto | Inside a project, scans public//static//assets//src/assets/ if present. Anywhere else, scans the current folder |
| --format <f> | same | same | webp | avif | jpeg | png |
| -q, --quality <n> | 80 | 1–100, for lossy formats |
| --max-width <n> | off | Downscale anything wider; aspect ratio preserved, never upscales |
| --widths <list> | off | Emit responsive variants, e.g. 640,1024,1920, and print the <img srcset> |
| --min-ssim <n> | off | Quality floor 0–1; auto-raises quality per file until it passes |
| --max-quality <n> | 95 | Ceiling the quality guard may raise to |
| --png-palette | off | Quantise PNGs to 256 colours — big wins on flat art, but lossy |
| --keep-metadata | off | Preserve EXIF/ICC (stripped by default) |
| --mode <m> | folder | folder | in-place | suffix |
| --out <dir> | optimized | Output folder for --mode folder |
| --suffix <s> | .min | Filename suffix for --mode suffix |
| --backup | off | Keep a .bak copy when overwriting in place |
| -j, --jobs <n> | cpus (max 8) | Parallel encodes |
| --no-gitignore | | Scan files .gitignore excludes |
| -y, --yes | | Headless |
| --dry-run | | Report only |
| --json | | Machine-readable output, implies headless |
| --watch | | Stay running and optimize images as they appear |
| --force, --redo | | Re-optimize every image, including already-done ones (alias: --no-cache) |
| --prune | | Delete outputs left by renamed or deleted sources |
| --config <path> | | Use a specific config file |
| --no-config | | Ignore any oimg.config.json / package.json "oimage" |
Tools
Press t for conversions that go beyond re-compressing an image. Pick one with
↑↓, open it with ↵, and it gets its own screen — its own inputs, its own
settings on the number keys, its own explanations.
| Tool | |
|---|---|
| VAP → smaller VAP | Re-encode a VAP .mp4 at a higher CRF, still playable by the same player |
| VAP → animated WebP | Turn a Tencent VAP .mp4 into an animated WebP with transparency |
| VAP → SVGA | Convert a VAP .mp4 into an SVGA 2.x animation |
VAP is Tencent's format for gift/effect animations: an ordinary H.264 MP4 that
carries its alpha channel packed beside the colour in each frame, described by a
vapc box. The two converters rebuild true RGBA frames from that, then
re-encode.
Output always goes to ./converted/ — nothing is overwritten. Only .mp4 files
that actually carry a vapc box are listed; a plain video has no alpha to
extract and is skipped.
These tools need ffmpeg on your PATH (brew install ffmpeg) to decode
H.264. The rest of oimage does not. If it is missing, the tool screen says so
instead of failing mid-conversion.
Staying in VAP
For a full-canvas animation, both conversions lose. VAP compresses across time,
and a full-screen effect has no empty margin for SVGA or WebP to trim — so
recompression usually beats converting. It keeps the same dimensions, frame
rate and vapc box, so nothing downstream changes: no new package, no widget
swap, no player.
vap_rocket_reward_4.mp4 10.7 MB 1136x1632, 200 frames, 20fps
→ CRF 26 (same geometry) 5.2 MB -51%, SSIM 0.987ffmpeg rewrites the container and silently drops the vapc box, which yields a
file that plays as video but renders opaque — the player can no longer find the
alpha region. The tool lifts the box out first and appends it afterwards, then
reads the config back and refuses to emit anything whose alpha geometry moved.
Watch the edges when raising CRF: this format packs the alpha matte into the frame, so artefacts show as fringing around the subject rather than as general softness.
Prefer WebP unless something downstream specifically demands SVGA. VAP compresses across time (H.264 inter-frame); SVGA compresses across structure (a few bitmaps reused via per-frame transforms). Converting VAP → SVGA discards the temporal compression and stores every frame as its own bitmap, so at full fidelity the output is many times the source. Trimming each frame to its visible bounds and reusing duplicate frames claws most of that back, and reducing size and frame rate does the rest — but WebP keeps inter-frame compression and needs none of it:
gift.mp4 2.9 MB 540x1168, 212 frames, 30fps
→ WebP (50%, 15fps) 1.8 MB
→ SVGA (50%, 15fps, 128c) 2.3 MB
→ SVGA (full fidelity) 32.3 MBResponsive images
Serving a 1920px hero to a phone wastes more bytes than any encoder setting can
recover. --widths emits one file per breakpoint and prints the markup:
oimg --widths 640,1024,1920 -y✓ images/hero.jpg 280 KB → 83 KB -70%
320w 16 KB hero-320.jpg
640w 46 KB hero-640.jpg
960w 83 KB hero-960.jpg
<img
src="/optimized/images/hero-640.jpg"
srcset="/optimized/images/hero-320.jpg 320w,
/optimized/images/hero-640.jpg 640w,
/optimized/images/hero-960.jpg 960w"
sizes="100vw"
width="960"
alt=""
/>Widths larger than the source are dropped rather than upscaled. Savings are
reported at the largest variant — what a full-width visitor actually
downloads — with the total disk cost shown separately. Animations stay
single-variant. Set sizes yourself: the browser cannot infer your layout, and
a wrong sizes quietly defeats the point of srcset.
Quality guard
One global quality is a blunt instrument — the setting that leaves a photo
pristine can visibly wreck a gradient or a logo. --min-ssim scores every
result against the original and raises quality per file until it passes:
oimg -q 60 --min-ssim 0.97 -y✓ icons/logo.png 3.2 KB → 271 B -92%
✓ images/hero.jpg 280 KB → 56 KB -80% q38 ssim 0.972
1 image auto-tuned upward to hold the quality floorOnly lossy encodes are scored — lossless PNG cannot lose structure, so it is
skipped. A deliberate resize is compared against the resized original, so
downscaling is never mistaken for encoder damage. If the floor is unreachable by
--max-quality, the run says so instead of looping.
Incremental cache
Re-runs skip untouched files, which is what makes this viable in a pre-commit hook or CI step rather than a once-in-a-while manual run.
$ oimg -y
Up to date — 5 files already optimized (728 KB saved, -79%)Turn it off to redo everything — c in the UI, or --redo / --force on the
command line. Useful after changing a file outside the tool, or to rebuild an
output folder from scratch.
It works in two tiers. First a cheap check — same path, same size, same
timestamp, outputs still on disk — which costs one stat and never opens the
image. If that misses, the file's contents are hashed and matched against what
has been encoded before, so these all skip the encoder too:
| | |
|---|---|
| git checkout / touch | Timestamp changed, bytes didn't |
| A renamed or moved image | The existing output is copied to the new name |
| A duplicate of an image already in the project | Encoded once, reused everywhere |
The cache lives in .oimg-cache.json at the scan root and stores only relative
paths, so you can commit it and let CI or a teammate skip the same work. It
invalidates when the source bytes change, when any setting that affects output
bytes changes, or when an output it claims to have written has gone missing.
What it deliberately does not do is inspect an arbitrary image and guess
whether someone already compressed it — that guess is unreliable, and guessing
wrong means silently shipping an unoptimized file. Instead there is a safety net
that needs no cache at all: if re-encoding would produce a larger file and the
format is unchanged, the original is kept and reported as already optimal. So
running against previously-optimized images is safe even on a fresh machine —
it just costs the encode time to find out.
Stale outputs
Renaming or deleting a source leaves its old output behind. Those are reported
after each run, and --prune removes them:
1 stale output (86 KB) left by renamed or deleted sources,
e.g. optimized/images/card.png — run with --prune to remove them.Watch mode
oimg --watchRe-runs on changes, debounced and serialised so a burst of saves cannot start overlapping runs. Combined with the cache, each trigger only touches what moved.
Config file
Drop an oimg.config.json (or .oimgrc.json, or an "oimage" key in
package.json) anywhere above the scan directory:
{
"format": "same",
"quality": 78,
"maxWidth": 1920,
"widths": [640, 1024, 1920],
"minSsim": 0.95,
"outputDir": "optimized"
}CLI flags win over the file, but only flags you actually typed — an untouched
default never silently overrides a value the project set. Unknown keys are
reported rather than ignored, so qualty is an error, not a no-op.
What it does that a plain sharp call doesn't
Never makes a file worse. Re-encoding isn't guaranteed to win — small PNGs and
already-optimised JPEGs routinely come out larger. When the format is unchanged
and the result would grow, the original is kept and reported as already optimal.
In folder mode the original is copied across so the output tree stays complete.
Same format means same format. JPEG goes through mozjpeg (10–20% smaller than
libjpeg at identical quality). PNG is a lossless recompression unless you opt into
--png-palette. Your <img src="hero.jpg"> keeps working.
Keeps animation. Animated GIFs keep every frame, and stay animated through a WebP conversion. Asking for a still-only format on an animated source keeps the original container rather than silently discarding all but frame one.
Applies EXIF orientation before stripping it. Stripping metadata without baking in the orientation tag rotates images for every viewer. Metadata is stripped by default, which also keeps GPS coordinates out of your public folder.
Refuses to clobber. hero.jpg and hero.png both become hero.webp — that's
silent data loss, so the run is refused with the conflicting paths listed.
Won't eat its own output. A second run ignores optimized/ and already-suffixed
files, so repeated runs don't compound lossy generations or produce hero.min.min.jpg.
The cache also remembers every path it has ever written, so renaming the output
folder doesn't turn the old one into scannable input.
Stays inside the folder you point it at. Symlinked directories are not
followed, and a symlink to a file outside the root is skipped rather than
read — otherwise in-place mode would write straight through it and modify a
file outside your project. An output folder that escapes the root
(--out ../elsewhere, or the same value from a config file) is refused.
Won't scan your home directory. Run it somewhere far too broad — ~, /,
/Volumes — and it stops and offers you a folder instead of walking photo
libraries, caches and network mounts. Headless refuses outright, since there is
nobody to ask. Naming a path explicitly always wins: oimg ~/whatever does what
you asked.
Won't wander into node_modules. Build output, VCS directories and
.gitignored paths are skipped. SVGs are never touched — sharp would rasterise
them into a fixed-size bitmap and destroy the vector. Scanning streams and
reports progress, so a large tree never looks like a hang.
Writes atomically. Encodes to a temp file and renames, so a crash or a full disk leaves the original intact rather than truncated.
Programmatic API
import { optimize } from 'oimage';
const { summary } = await optimize({
root: './public',
format: 'same',
quality: 75,
maxWidth: 1920,
dryRun: true,
onResult: (r) => console.log(r.file.relative, r.inputSize, '→', r.outputSize),
});
console.log(`saved ${summary.savedBytes} bytes across ${summary.done} files`);Also exported: scan, detectRoot, encode, plan, findCollisions, summarise, defaultSettings.
Development
npm install
npm run build # esbuild → dist/
npm run dev # watch mode
npm test # node:test, real images through real encodersMigrating from optimize-public-images
This package was previously published as optimize-public-images. Changes:
- Scans any directory, not just
public/. The old auto-detection is still the default. - Defaults to same-format recompression instead of always converting to WebP. Pass
--format webpfor the old behaviour. Replace original filesused to write a.webpalongside the original without replacing anything.--mode in-placenow genuinely overwrites, behind a confirmation.- The command is now
oimg, but the oldoptimize-publicbinary name still works.
License
MIT
