Skip to content

Repository files navigation

squish

Super fast local file optimisation. Compresses images, video, and audio; minifies JS, TS, CSS, HTML, and JSON — all from one CLI, no servers, no uploads. Takes files or directories, writes *_squished.* siblings alongside the originals (or replaces in place with -o). Non-destructive by default — originals are never touched unless you ask.

squish demo

What to expect

Measured on representative samples with default settings (your mileage varies with content):

Input Saving
High-quality JPEG photo (4.4 MB) −69%
PNG screenshot/graphic −77%
WebP −44%
SVG (minified) −54%
TIFF (auto-converts to JPEG) −97%
MP4 with --target-size 1M (3.1 MB source) −68%, under budget
320 kbps MP3 −84%

Install

Homebrew (macOS — recommended)

brew install mikedre/tap/squish

Installs a prebuilt binary plus every system dependency (ffmpeg, gifsicle, libheif, dav1d). Nothing else to do.

Prebuilt binaries

Each release ships binaries for macOS (arm64/x64) and Linux (x64/arm64) on the releases page. Unpack and put squish on your PATH. Linux binaries need libheif (≥ 1.18) and dav1d (≥ 1.3) present at runtime, plus the subprocess dependencies below for full format coverage.

Install via cargo

If you have Rust installed (see step 1 below):

cargo install squish-media-cli --locked

This compiles squish from crates.io and places the squish binary in ~/.cargo/bin. You still need the system dependencies for full format support (see below).

Why --locked? It installs the exact dependency versions squish was tested and released with (from the published Cargo.lock). Without it, cargo re-resolves transitive dependencies, which can pick up a newer version before it's been tested against squish. Always prefer --locked when installing squish from crates.io.

With cargo binstall installed, cargo binstall squish-media-cli fetches the prebuilt release binary instead of compiling (macOS and Linux only — Windows isn't published yet, see Roadmap).

Build from source

1. Install Rust (skip if rustc --version already works):

curl --proto '=https' --tlsv1.2 -sSf https://rustup.rs | sh

Once the installer finishes, open a new terminal (or run source ~/.cargo/env) so that cargo is available on your PATH.

Note: squish builds on stable Rust (1.95 or newer). If your toolchain is older, run rustup update first.

2. Install system deps and build:

./scripts/setup.sh    # installs system deps via Homebrew (macOS) or apt (Linux)
cargo install --path crates/squish-cli --locked

3. Make sure squish is on your PATH:

cargo install places the binary in ~/.cargo/bin. If squish isn't found after installation, add that directory to your shell profile and reload it:

# bash
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.bashrc && source ~/.bashrc

# zsh
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc

Then verify with squish --version.

System dependencies

GIF and HEIC support require external libraries. Install them for full format coverage:

  • gifsicle (required for GIF compression)
    • macOS: brew install gifsicle
    • Linux: apt install gifsicle
  • libheif + x265 (required for HEIC/HEIF)
    • macOS: brew install libheif x265
    • Linux: apt install libheif-dev libx265-dev
  • dav1d (required for AVIF decoding)
    • macOS: brew install dav1d
    • Linux: apt install libdav1d-dev
  • ffmpeg (required for video compression)
    • macOS: brew install ffmpeg
    • Linux: apt install ffmpeg

If a dependency is missing when you need it, squish tells you exactly what to install.

Shell completions

squish completions <shell> prints a completion script to stdout for bash, zsh, or fish, generated from the CLI's own flag definitions so it never drifts out of sync.

# zsh
squish completions zsh > "${fpath[1]}/_squish"

# bash (requires bash-completion)
squish completions bash > /usr/local/etc/bash_completion.d/squish   # macOS (Homebrew)
squish completions bash | sudo tee /etc/bash_completion.d/squish    # Linux

# fish
squish completions fish > ~/.config/fish/completions/squish.fish

Open a new shell (or exec $SHELL) afterward. The Homebrew tap installs completions automatically — see below.

Man page

The Homebrew tap installs man squish automatically. Building from source or installing via cargo install, generate it yourself:

squish man > /usr/local/share/man/man1/squish.1   # then `man squish`

Use

Images

# Single file
squish dog.png
# → dog_squished.png

# Whole folder, recursively
squish ./assets/ -r

# Convert format while compressing
squish photos/ -r --format webp --quality 75

# Preserve every bit (lossless)
squish logo.svg --lossless

# Resize while compressing (never upscales)
squish photos/ -r --max-width 2000

# Fit within a box
squish hero.jpg --max-width 1920 --max-height 1080

# Crop to a preset aspect ratio (largest centred fit), then compress
squish hero.jpg --crop 16:9

# Square-crop a whole folder, anchored to the top of each image
squish avatars/ -r --crop 1:1 --gravity north

# Crop an exact pixel region: WxH+X+Y
squish scan.png --crop 800x600+120+40

# Pick the crop region by eye, in your browser
squish hero.jpg --select

# Place a 16:9 crop by hand instead of relying on --gravity
squish hero.jpg --crop 16:9 --select

# Compress to a size budget (highest quality that fits)
squish hero.jpg --target-size 500k

# Compress as hard as possible with no visible loss (perceptual auto-quality)
squish photo.jpg --quality auto

# Web-optimize: resize to fit within 1920x1920 (portrait or landscape), convert to WebP, visually-lossless quality (H.264 for video)
squish ./assets -r --preset web

# Preview without writing
squish ./big-folder/ -r --dry-run

# Keep watching a folder, squishing files as they land
squish ./assets/ -r --watch

Converting SVG to a raster format

# Render at a pixel width — height follows the SVG's own aspect ratio
squish logo.svg --format png --width 512

# Render at a pixel height instead
squish icon.svg --format webp --height 64

# A whole folder of vector assets, rasterised in one pass
squish assets/ --format png --width 256

A vector has no pixel size of its own, so converting an SVG to PNG, JPEG, WebP, AVIF, GIF, HEIC, or TIFF needs --width or --height to say how big to render it — leaving both off is an error (other files in the same run still process). Give both and squish fits the render inside that box at the SVG's own aspect ratio, without stretching it; unlike --max-width/--max-height, these flags upscale, since there's no native resolution to scale down from. An SVG with no viewBox and no absolute width/height has nothing to scale from either way, so it's also an error. Text renders with whatever fonts are installed on the machine, and squish warns you by name if one the file asks for isn't there.

--crop and --select operate on the rendered pixels, so --width sizes the artboard first — cropping a 512-wide render still yields something smaller than 512. --quality auto and --target-size work the same way they do for any other raster output.

Note that the never-grow guarantee doesn't apply to this conversion: rasterising is a change of representation, not a re-encode, so a compact vector routinely comes out larger once it's pixels — a 2 KB icon rendered at 512×512 is expected to grow, not a bug. Going the other way, raster to SVG, remains unsupported.

Picking a crop by eye

--select opens a local page in your browser — nothing is uploaded, and the server is bound to 127.0.0.1 with a single-use token, shutting down the moment you confirm or cancel. Drag out a region and the readout shows its exact size in source pixels, its offset, its aspect ratio, and the real compressed size it will produce. Ratio presets, eight resize handles, arrow-key nudging (1 px, or 10 px with shift), and zoom/pan are all there.

It takes exactly one image. The rect is echoed when the run finishes:

$ squish hero.jpg --select
crop: 1440x810+240+120
hero_squished.jpg  4.4 MB → 812 KB (-82%)

Reuse that spec to repeat the crop non-interactively — handy in scripts:

squish hero.jpg --crop 1440x810+240+120

Pair it with --dry-run to pick a region without writing anything, and --crop 1440x810+240+120 --select to reopen a previous crop and adjust it.

Video

# Compress a video (defaults to H.265)
squish video.mp4
# → video_squished.mp4

# Use H.264 instead
squish video.mp4 --codec h264

# Fast mode — optimise without re-encoding
squish video.mp4 --fast

# Compress as hard as possible with no visible loss (perceptual auto-quality, requires libvmaf)
squish video.mp4 --quality auto

# Fit a clip under an upload limit (two-pass ABR for H.264/H.265/VP9)
squish clip.mp4 --target-size 8M

# Mixed batch — images and videos together
squish ./media/ -r
# → Squished 8 files (5 images, 3 videos) · 120.3 MB → 34.1 MB (-71.7%)

# Convert a .mov to .mp4 (re-encodes with the container default codec)
squish trailer.mov --format mp4
# → trailer_squished.mp4

Audio

# Single file — re-encode at the same codec with sensible quality
squish track.mp3

# Convert a lossless file to Opus (~50% size reduction)
squish --codec opus song.flac

# Pick a specific bitrate
squish --bitrate 192k podcast.mp3

# Strip ID3 tags and album art
squish --strip-tags album/*.mp3

# Fit a podcast under a size budget (bitrate computed from duration)
squish episode.mp3 --target-size 25M

# Convert lossless to a specific container/codec
squish song.flac --format opus
# → song_squished.opus

Code

# Minify everything in dist/ recursively
squish dist/ -r
# → app.js → app.min.js, style.css → style.min.css, …

# Safe mode — whitespace-only, no identifier mangling
squish --safe app.js

# Emit a source map alongside the minified output
squish --source-map app.js style.css

Usage report

# How much have I saved this month + all-time?
squish --stats

# Skip recording this run (also: SQUISH_NO_STATS=1)
squish photos/ -r --no-stats

Check your setup

See which formats work on this machine and whether the optional tools are installed:

squish doctor

Images and code minification work out of the box. Video and audio need ffmpeg, and GIF needs gifsicle; doctor shows what's present (with versions) and how to install anything missing. It always exits 0.

Scripting

--json prints a single machine-readable report to stdout instead of the human summary — nothing else goes to stdout, so it's safe to pipe straight into jq or parse in CI:

squish photos/ -r --json | jq '.totals'
# { "files": 8, "bytes_in": 12345678, "bytes_out": 3456789, "saving_pct": 72.0, "by_kind": {...} }

squish photos/ -r --json | jq -r '.files[] | select(.status == "squished") | "\(.input) -> \(.output)"'

# Exit code is unaffected by --json: 0 clean, 1 if any file errored.
squish photos/ -r --json > report.json || jq '.errors' report.json

Works with --dry-run too (every planned file reports status: "skipped" with no output, since nothing is written).

Formats

Images

Supported as input and output: PNG, JPEG, WebP, AVIF, SVG, GIF, HEIC, TIFF.

Format Library Metadata
PNG oxipng + imagequant EXIF stripped by default, ICC always kept; --keep-metadata preserves EXIF too
JPEG mozjpeg (progressive, optimised Huffman) EXIF orientation always applied to pixels before re-encoding; EXIF stripped by default, ICC always kept; --keep-metadata preserves EXIF too (with the now-redundant orientation tag reset)
WebP libwebp (static); animated WebP copies through unchanged Always stripped (not yet supported)
AVIF ravif (encode) + dav1d (decode) Always stripped (not yet supported)
SVG oxvg_optimiser (SVGO-equivalent: comments, default attrs, relative path coords) as input/output; resvg to render to any raster format below (--format png etc, needs --width/--height) N/A (vector format); raster output follows that format's own metadata rules
GIF (static + animated) gifsicle -O3 Whatever gifsicle does by default
HEIC libheif-rs Always stripped (not yet supported)
TIFF input only — defaults to re-encoding as JPEG; use --format tiff to keep TIFF output Always stripped (not yet supported)

JPEG has no alpha channel, so converting a transparent image to it — png → jpg, or any SVG rendered to JPEG — composites the transparency onto a white background rather than discarding it (which used to leave black where the transparent pixels were). There's deliberately no --background flag to change the colour.

Video

Supported containers: MP4, WebM, MOV, AVI, MKV, FLV, DV (→ mp4). Requires system ffmpeg.

.dv/.dif is a transcode-only input: it is always re-encoded to an .mp4 (H.265 by default), and --fast (copy) is ignored for DV sources.

Codec Flag Notes
H.265 (HEVC) --codec h265 (default) ~50% smaller than H.264
H.264 (AVC) --codec h264 Maximum compatibility
AV1 --codec av1 Best compression, slower encode
VP9 auto for .webm Selected automatically for WebM containers
Copy --fast No re-encode, strips metadata only

Audio streams are copied as-is (no audio re-encoding).

Audio

Supported via ffmpeg + ffprobe: MP3, AAC/M4A, WAV, FLAC, OGG, Opus, AIFF, WebM-audio. Tags and album art are preserved by default.

Codec Flag Notes
MP3 --codec mp3 LAME VBR quality scale
AAC --codec aac Bitrate ladder (default 192 kbps at q=80)
Opus --codec opus Modern lossy codec; default for lossless inputs in non-interactive mode
Vorbis --codec vorbis Quality scale, in .ogg
FLAC --codec flac Lossless re-encode
ALAC --codec alac Lossless, in .m4a

By default, lossy inputs (MP3/AAC/etc) re-encode to the same codec; lossless inputs (FLAC/WAV/AIFF) prompt once for a target codec (defaults to Opus in non-interactive mode).

# Default: same codec re-encoded with sensible quality
squish track.mp3

# Convert lossless to Opus (lossy, ~50% size reduction)
squish --codec opus song.flac

# Pick a specific bitrate
squish --bitrate 192k podcast.mp3

# Strip ID3 tags and album art
squish --strip-tags album/*.mp3

Code

Minifies JavaScript, TypeScript, CSS, HTML, and JSON via pure-Rust libraries — no Node runtime required.

Language Library Default behavior
JS / TS oxc_minifier Mangle + DCE; --safe for whitespace-only
CSS lightningcss Whitespace + comment removal, normalization
HTML minify-html Whitespace + comment removal; preserves <script> content
JSON serde_json Whitespace removal; rejects JSON5/JSONC

Output uses .min suffix with . separator (industry convention): app.jsapp.min.js. TypeScript and JSX inputs become .js (types are erased; JSX is compiled).

# Default — minify everything in dist/
squish dist/ -r

# Safe mode for JS (no identifier mangling)
squish --safe app.js

# Emit source maps for debugging in browser DevTools
squish --source-map app.js style.css

# Custom suffix
squish --suffix tiny app.js   # → app.tiny.js

SVG continues to be handled as an image (structural compaction via oxvg_optimiser, an SVGO-equivalent).

Known limitations:

  • IE conditional comments (<!--[if IE]>...<![endif]-->) are stripped along with regular comments. Pass --source-map if you need to preserve comments in JS/CSS for debugging.

Flags

  -q, --quality <0-100|auto>  Quality: 0-100, or `auto` for the lowest visually-lossless
                              quality (images: SSIMULACRA2; video: VMAF, requires
                              libvmaf). Conflicts with --target-size; for video also
                              conflicts with --fast/--codec copy
      --lossless             Lossless compression (overrides --quality)
  -f, --format <FORMAT>      Output format (image/video/audio); applied per input kind
      --max-width <PIXELS>   Scale down images wider than this (preserves aspect ratio)
      --max-height <PIXELS>  Scale down images taller than this (preserves aspect ratio)
      --width <PIXELS>       Render vector (SVG) input at this pixel width. Unlike
                              --max-width this upscales — a vector has no native
                              resolution. Ignored for raster input
      --height <PIXELS>      Render vector (SVG) input at this pixel height. See --width
      --crop <SPEC>          Crop images before compressing (applied before
                             max-width/height). Aspect ratio like 16:9 or 1:1
                             (largest fit, anchored by --gravity), or an exact
                             pixel rect WxH+X+Y, e.g. 800x600+120+40. Images
                             only; GIF crops preserve animation. Not read from
                             squish.toml
      --gravity <POS>        Anchor for an aspect-ratio --crop: center
                             (default), north, south, east, west, northwest,
                             northeast, southwest, southeast
      --select               Pick the crop region interactively in your browser
                             (single image only; seeded by --crop/--gravity).
                             Shows the selection's size in source pixels and the
                             exact output size it will produce. Conflicts with
                             --json/--watch/--stats
      --target-size <SIZE>   Per-file output size budget, e.g. 500k, 1.5M, 2g (decimal
                             units). Images pick the highest quality that fits; video/
                             audio compute a bitrate from the input's duration. Conflicts
                             with --quality/--lossless/--bitrate/--fast; not applicable
                             to code files or lossless audio codecs
  -r, --recursive            Recurse into directories
      --force                Overwrite existing _squished files
  -o, --overwrite            Replace each input file in place (skips files whose
                             squish would change the extension, e.g. .dv→.mp4)
      --suffix <NAME>        Custom output filename suffix (default: squished)
      --dry-run              Show what would happen; don't write
      --watch                Keep running: watch the given paths and squish files
                             as they appear or change (Ctrl-C to stop). Never
                             re-squishes its own outputs
      --no-config            Ignore squish.toml config files for this run
      --kinds <KINDS>        Restrict the run to these file kinds, comma-
                             separated: image, video, audio, code (default: all)
      --exclude <GLOB>       Skip files/dirs matching this glob during a directory
                             walk (repeatable). Relative to each input path's own
                             root. Explicit file arguments are never excluded
      --gitignore            Also respect .gitignore (and .git/info/exclude, and
                             the global gitignore) while walking directories.
                             Off by default
      --no-default-excludes  Don't prune .git, node_modules, and target while
                             walking directories (pruned by default)
      --stats                Print usage report (this month + all-time) and exit
      --no-stats             Skip recording this run (also: SQUISH_NO_STATS=1)
      --json                 Print a single machine-readable JSON report to stdout
                             instead of the human summary. Conflicts with
                             --verbose/--quiet/--watch/--stats; works with --dry-run
  -j, --jobs <N>             Parallelism (default: num CPUs)
  -v, --verbose              Per-file output
      --quiet                Errors only
      --codec <CODEC>        Codec: video=h264|h265|av1|vp9, audio=mp3|aac|opus|vorbis|flac|alac
      --fast                 Video: optimise without re-encoding
      --bitrate <BITRATE>    Audio bitrate, e.g. 128k, 192k. Overrides --quality for lossy audio
      --strip-tags           Strip audio metadata (ID3 tags, album art). Default: preserved
      --keep-metadata        Preserve EXIF and the ICC colour profile in image output
                             (default: EXIF stripped, ICC always kept). Orientation is
                             always applied to pixels either way. JPEG/PNG only
      --preset <web>         Apply a destination preset of sensible defaults
                             (overridable by explicit flags). Currently: web
      --safe                 Code: skip mangling and DCE (whitespace-only minification)
      --source-map           Code: emit a .map file alongside output (JS/TS/CSS only)

  completions <bash|zsh|fish>  Print a shell completion script to stdout

Config file

squish reads defaults from the nearest squish.toml (walking up from the current directory) and from a global config at ~/Library/Application Support/squish/config.toml (macOS) or ~/.config/squish/config.toml (Linux). Precedence: CLI flags > project squish.toml > global config. Pass --no-config to ignore both. Keys mirror the CLI flag names:

# squish.toml
quality = 75          # or "auto" — perceptual visually-lossless (images only)
format = "webp"
recursive = true
max-width = 2000
exclude = ["*.min.js", "vendor/**"]
# Replace originals in place instead of writing _squished siblings.
# Destructive — no copy is kept. CLI flags still override this.
overwrite = false

[video]
codec = "h264"

[audio]
codec = "opus"
strip-tags = true

[code]
safe = true

quality accepts a number (0–100) or the string "auto" (perceptual visually-lossless, images only; conflicts with target-size). The squish config wizard offers auto as an option.

Rate control is all-or-nothing: passing any of --quality/--lossless/--bitrate/--fast/--target-size on the command line disables all of those keys from config for that run, so a config target-size can never override an explicit --quality. Unknown keys are an error — typos fail loudly.

Interactive setup

Don't want to hand-edit TOML? Run the wizard:

squish config            # edit the global config
squish config --local    # edit ./squish.toml for this project instead

It walks through quality, format, suffix, recursive, strip-tags, and overwrite, pre-filling whatever is already set (press Enter to keep a value, - to clear it). Note: the file is rewritten, so any hand-written comments in it are not preserved.

GitHub Action

Squish assets in CI with the bundled action — handy before deploys, or paired with a commit-back step:

- uses: MikeDre/squish@v0.10.1
  with:
    paths: public/images
    args: "--recursive --overwrite --quality 75"
Input Default Notes
paths (required) Files or directories, space-separated
args "" Any squish CLI flags
version latest Release tag of the binary to download
install-deps true apt/brew runtime deps (libheif, dav1d, ffmpeg, gifsicle)

Runs on ubuntu and macos runners (x64 + arm64).

Finder Quick Action (macOS)

Add a "Squish" entry to Finder's right-click menu — for the folks who never open a terminal:

squish finder-action install

Select files or folders in Finder → right-click → Quick Actions → Squish. It squishes media (images, video, audio — never code) with your usual defaults: _squished siblings, originals untouched, squish.toml respected. A notification reports progress and the final savings. Remove it any time with squish finder-action uninstall; re-run install after moving or reinstalling squish.

Collision behavior

If dog_squished.png already exists, squish writes dog_squished_2.png, then _3, etc. Pass --force to overwrite instead.

Never-grow guarantee

squish never writes an output larger than its input — if encoding wouldn't help (the file is already optimal) and no --format conversion, resize, or codec change was requested, the encode is discarded and the output is left byte-identical to the input, reported as "skipped (already optimal)" rather than a (non-)saving. This applies with --overwrite too: the original is safely preserved even though the encoder writes in place. When a conversion is requested, growth is allowed (a tiny PNG icon converted to AVIF can legitimately grow) — --verbose prints a note when that happens.

The guarantee does not apply to SVG → raster conversion at all: rasterising a vector is a change of representation, not a re-encode, so the output is routinely — and expectedly — larger than the source.

Development

cargo test              # run all tests
cargo build --release   # optimised binary

Test fixtures are in crates/squish-core/tests/fixtures/ (images) and crates/squish-video/tests/fixtures/ (videos). See the README in each for sources.

Cutting a release

  1. Bump the workspace version in Cargo.toml, add a CHANGELOG.md entry, commit, then tag and push: git tag vX.Y.Z && git push origin main vX.Y.Z. The Release workflow builds and attaches binaries for macOS (arm64/x64) and Linux (x64/arm64), then updates the Homebrew tap formula automatically.
  2. Publish to crates.io in dependency order: for p in squish-core squish-media squish-video squish-audio squish-code squish-media-cli; do cargo publish -p $p; done

The tap step needs a HOMEBREW_TAP_TOKEN repository secret — a fine-grained PAT with Contents: write on MikeDre/homebrew-tap, since the workflow's default token cannot push to another repository. If it is missing the tap job fails loudly rather than shipping binaries with a stale formula; recover by adding the secret and re-running the job, or by running ./scripts/release-tap.sh vX.Y.Z by hand against a sibling clone of the tap.

Installed copies can lag behind a release even once the tap is current, because Homebrew only refreshes taps on its own schedule. brew update && brew upgrade squish picks it up.

Roadmap

Planned work is tracked in ROADMAP.md.

License

MIT.

Releases

Packages

Contributors

Languages