A shell-pipe CLI that makes batch image editing memorable - and ~10x faster than ImageMagick
I have never once typed this correctly from memory: magick mogrify -path out -format webp -quality 80 -resize 1600x1600\> -unsharp 0x1 photos/*.jpg The 1600x1600\> to avoid upscaling, the -unsharp 0x1 incantation, the -path that has to exist first - I look it up every single time. So when I ran into a tool on a Show HN thread where the same job reads the way I actually think about it, it stuck with me:
photu read "photos/*.jpg" | photu resize 1600 | photu sharpen | photu write "out/{name}.webp" quality=80
It's called photu (เชซเซเชเซ - Gujarati slang for "a photo"), built by u84u. It's on npm, and there's a browser playground. But the syntax isn't actually what kept me reading - it was the one design decision underneath it that makes it fast, and that turned out to be more interesting than I expected. So this is a walk through how it works, from someone who just found it and got nerd-sniped.
The obvious problem with pipes
The natural objection to "image processing as shell pipes" is performance. If every | actually streamed decoded pixels from one process to the next, you'd pay to decode and re-encode the image at every stage. Eight stages, eight round trips through the codec. That's slower than the monolithic ImageMagick command, not faster.
What actually flows through the pipe
Each stage appends its operation to a small JSON plan and passes that along. Nothing touches image data until the very end. read starts the plan with a list of files; resize, crop, sharpen and friends each tack one entry onto the ops array; and only write hands the finished plan to libvips, which runs the whole chain as a single fused operation.
You can watch this happen. Pipe any pipeline into photu explain instead of write:
$ photu read "photos/*.jpg" | photu resize 800x600 fit=cover | photu explain
photu plan (protocol 1)
files (50): ...
ops (1):
1. resize width=800 height=600 fit="cover" upscale=false
Because the thing crossing each pipe boundary is plain text, you can chain as many stages as you like and every image is still only decoded and encoded once. Here's an eight-stage pipeline - resize, crop to aspect ratio, nudge the framing, punch the color, sharpen, drop a watermark, add a border, write WebP - that is still one libvips pass and one JSON plan changing hands the whole way:
photu read "photos/*.jpg" \
| photu resize 1600 \
| photu crop 1200x630 gravity=center \
| photu rotate -3 background=white \
| photu adjust saturation=1.3 hue=15 \
| photu sharpen \
| photu overlay logo.png gravity=southeast opacity=0.35 \
| photu pad 24 color=white \
| photu write "social/{name}.webp" quality=82
A nice side effect of "the wire format is just text": pipelines behave identically in bash, zsh, PowerShell and cmd. There's no binary stream to get mangled by a shell that thinks it knows better.
Does it actually go faster?
Yes - and the author is upfront that the speed is libvips' speed, not theirs. libvips decodes JPEGs at a reduced scale when the pipeline allows it, and it threads well. photu's job is just to hand it one clean plan instead of making it run one process per operation.
The benchmark from the README: 50 public-domain images from the Met Museum's Open Access collection (1,130-4,000px on the long edge, 116 MB of JPEGs), resized to 800px wide and written as WebP at quality 80. Measured with hyperfine on Windows 10 against ImageMagick 7.1.2-27:
| Time | |
|---|---|
| photu | 2.0 s |
| ImageMagick Q8 with -define jpeg:size=1600x1200 | 12.6 s |
| ImageMagick Q8 | 22.2 s |
| ImageMagick Q16-HDRI (the default download) | 23.0 s |
That jpeg:size row is the fastest ImageMagick configuration in the comparison, and photu is still ~6x quicker. Against the build most people actually download, it's more like 11x. And photu's number includes the cost of launching four separate Node processes - one per pipeline stage.
"Isn't this just a libvips wrapper?"
The README asks this itself, and answers "yes, completely" - which is the part that made me trust the rest of it. libvips does all the pixel work and deserves the credit for the speed. What photu adds is the interface. The vips CLI runs one operation per process, so chaining operations natively means writing intermediate files or pushing raw pixels through a pipe - exactly the slow thing from the top of this post. photu passes a plan instead and runs the whole chain fused. On top of that it adds globs, http(s):// URL sources mixed freely with local files, output templates like {name} and {i}, overwrite and filename-collision guards, and an install that's a single npm command on any OS. If vipsthumbnail already covers your workflow, it's excellent and you should just use it. photu is for when you want to compose operations without memorizing a monolithic command.
A couple of details worth calling out
Remote images cost exactly one round trip. read accepts URLs, but at read time a URL is only checked for valid syntax. The actual fetch happens later, per file, inside write - in the same worker pool that already decodes local files. The response is pulled straight into memory and handed to libvips as a buffer, so a remote image never touches disk. (Fetches are capped at 50 MB and 15 seconds, http/https only.)
It tries hard to fail before touching any pixels. Bad arguments, empty globs, malformed URLs, output collisions, and attempts to overwrite an input file are all caught up front. When a stage mid-pipeline does fail, it passes a structured error down the pipe instead of pixels, so the pipeline exits nonzero in any shell without needing set -o pipefail. stdout is reserved for plans; everything human-readable goes to stderr.
Animation just works. Animated GIF, WebP and TIFF are processed frame-by-frame, and every stage applies per frame - not just resize and crop. Blur, sharpen, overlay, all of it. Frame delay and loop count carry through. Writing an animated source to a format with no notion of frames (jpeg, png, avif) is refused rather than silently flattened into one tall still.
Try it yourself
There's a browser playground at tryphotu.vercel.app - it runs the same parser and the same libvips compiled to WebAssembly, and your images never leave your machine.
To install locally (needs Node 22+; libvips ships prebuilt with the sharp dependency, so there's nothing to compile):
npm i -g photu
Optional bash completions for commands, options and fit= / gravity= values:
source <(photu completion)
Code and the full command reference are on GitHub: github.com/u84u/photu (MIT licensed).
I found this on its Show HN thread, which is where the author is answering questions about the plan-passing approach and the benchmark methodology - worth a read if you want to get into the weeds, and worth an upvote if you like it. I left a comment there myself, basically "I'm amazed by the response time." Turns out the reason is exactly the plan-passing trick above: nothing gets decoded until the final write , bec
Comments
No comments yet. Start the discussion.