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 , 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 — 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: , 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, because until then it's just a JSON plan being validated.
SOCIAL SHARE CARD GENERATOR