Thumbnail encoding study shipped

Every grid thumbnail ends a chain of choices: format, quality, chroma subsampling, resolution. Here I run one photo through all of them with real byte counts, so you can see the tradeoffs instead of taking them on faith. The actual encoders the site uses (avifenc, zenc/zenjpeg, cwebp, sips) produced the shipped numbers; the corpus study that put zenjpeg in the pipeline is below.

The verdict shipped: the live pipeline (holding/scripts/add-photos.sh) encodes every thumbnail as AVIF-primary + a JPG fallback. The JPG encoder is now zenc (holding/scripts/zenc/, a zenjpeg wrapper: hybrid trellis + progressive scan search) at q84, which retired the from-source jpegli build in 2026-07 for a few percent fewer bytes at equal quality. The corpus comparison that drove that switch is below.

Format showdown

The same detailed color frame at 400×266px. PNG holds the lossless baseline; everything below it throws bits away. Eyeball the quality, then read the bytes. b/px = bytes per pixel, the size-density that actually scales.

PNG lossless178.7 KB1.72 b/px
JPG·sips q82 · 4:2:031.7 KB0.31 b/px
JPG·zenc q62 · 4:2:09.7 KB0.09 b/px
JPG·zenc q84 · 4:2:0 · shipped15.9 KB0.15 b/px
JPG·zenc q95 · 4:2:032.0 KB0.31 b/px
WebP q60 · 4:2:07.8 KB0.07 b/px
WebP q80 · 4:2:011.8 KB0.11 b/px
AVIF q40 · 4:2:04.6 KB0.04 b/px
AVIF q63 · 4:2:010.4 KB0.10 b/px
AVIF q85 · 4:2:020.1 KB0.19 b/px
AVIF q63 · 4:4:411.3 KB0.11 b/px

Why the site built jpegli from source (then moved past it)

Look at the two JPEGs above at the shipped quality: system/sips q82 lands at 31.7 KB, zenc q84 (the same visual quality) at 15.9 KB: ~50% fewer bytes. Google's jpegli first showed the win (re-implement libjpeg, tune it psychovisually); that is why the pipeline shelled out to a from-source cjpegli for years instead of the OS encoder. The site has since moved to zenc/zenjpeg (the corpus study below), which takes another few percent at equal quality and, being a Rust crate, is dependabot-tracked and builds on any machine, no cmake/ninja toolchain. AVIF beats both, but JPEG still backs up every <img> as the universal fallback, so shrinking it still pays off.

Zenjpeg won and shipped shipped

“Zenjpg” is a good shorthand, but the crate is published as zenjpeg. It ships no CLI, so the site checks in a small Rust wrapper (holding/scripts/zenc/) and the photo Action builds it with cargo; dependabot tracks the pin. That retired the from-source jpegli build in 2026-07. The corpus run below is the evidence that drove the switch.

Corpus verdict (158 photos): the single-fixture read below was a lead, so I ran the whole library through it (112 Fuji X-T5 + 46 Leica frames, resized to the 600px thumbnail tier, each normalized so both encoders and the metric see identical pixels). Across all 158, zenjpeg's realistic mode (hybrid trellis) needs about 1.6% fewer bytes than cjpegli -q82 -p2 at equal SSIMULACRA2; its aggressive mode (hybrid plus a 64-candidate scan search) about 4%. zenjpeg's fast path ties cjpegli. A real win, but a small one on a fallback format AVIF already beats by ~40%.

The knobs that matter

knobwhat it changesthumbnail read
qualityQuantization strength; zenjpeg's scale is jpegli-like, not a promise of equal bytes.Calibrate by output size, not by matching 82 on the label.
ChromaSubsamplingNone = 4:4:4; Quarter = 4:2:0.4:2:0 is the likely production candidate, but this first matched run used 4:4:4 because current cjpegli did.
progressiveProgressive versus baseline JPEG; progressive mode also optimizes Huffman coding.Use progressive for the jpegli comparison; baseline is a separate decode/compatibility tradeoff.
ProgressiveScanModejpegli script, MozJPEG script, or ProgressiveSearch over 64 scan candidates.On the corpus, scan search took the BD-rate from about 1.6% to 4%, at roughly 4× hybrid's encode time (13× cjpegli).
auto_optimize(true)Hybrid trellis: jpegli adaptive quantization plus rate-distortion optimization.The mode that actually beats cjpegli (+0.5 SSIMULACRA2 at matched bytes), about 3× the base encode time.
OptimizationPresetJpegli*, Mozjpeg*, and Hybrid* bundle quant tables, AQ/trellis, scan strategy, and deringing policy.Compare named profiles rather than accumulating unexplained flags in the Action.
deringing(true)Overshoot deringing around saturated, high-contrast edges; enabled by default for jpegli/hybrid paths.Useful protection, not a byte-saving knob: it changed nothing on this photo fixture, so it still needs an edge-heavy test image.
parallelOptional Rayon-backed multi-threaded encoding, with restart markers supporting larger-image parallel work.Moot at this scale: a 600px frame stays single-threaded, and the 158-photo batch already parallelizes at the job level. The whole library encodes in seconds either way.

Full-corpus read

158 real frames resized to 600px, each normalized to one canonical RGB buffer so both encoders and the metric read identical pixels (the grayscale frames carry a Gray ICC that different decoders expand differently, which will fake a quality collapse if you skip this). Quality is SSIMULACRA2, the perceptual metric zenjpeg's own tuning targets, cross-checked with PSNR/SSIM. BD-rate is the average byte difference at equal quality over a 5-point sweep; negative means fewer bytes for the same quality. Single thread, Apple Silicon.

encoder / modeBD-rate vs cjpegli 4:2:0encode speedwhole library
cjpegli 4:2:0 (incumbent)baseline123.5 MP/s0.32 s
zenjpeg base (no trellis)+2.8%111.9 MP/s0.35 s
zenjpeg hybrid (trellis)−1.6%38.1 MP/s1.02 s
zenjpeg hybrid + scan search−4.1%9.3 MP/s4.23 s

Read together: zenjpeg's base path is basically cjpegli, a hair slower and a hair larger, so the C-to-Rust rewrite is not the story. The trellis is: it buys the byte win by spending 3× to 13× the encode time. At matched bytes that lands as +0.5 SSIMULACRA2 (hybrid) to +0.8 (scan search), real but below the threshold I would trust without a visual pass. And even the 13× mode encodes the whole library in about 4 seconds, so encode speed does not gate this decision at thumbnail scale.

Where this landed: shipped. A 1.6% to 4% win is marginal, and on a JPEG fallback AVIF already beats by ~40% the number alone does not force the move. But the site already maintains a whole toolchain for marginally-superior output (jpegli-from-source, the minified build, dependabot on a one-worker repo), so marginal-superior clears the bar here. zenjpeg is AGPL-3.0-or-later (or a $1 startup license), a paper cut for a build-time encoder whose JPEG output is not a derivative work of it; the Rust wrapper (holding/scripts/zenc/) is dependabot-tracked and builds anywhere, no cmake/ninja. So it replaced the from-source cjpegli in the pipeline.

The knob I hoped would change the answer, and did not: "encode every photo to a fixed perceptual score instead of a fixed q" would be worth real effort, because a fixed q scatters wildly. On these 158 photos, cjpegli -q84 lands anywhere from SSIMULACRA2 66.8 to 85.5. But zenjpeg 0.8.4's score targets (Quality::ApproxSsim2, ApproxButteraugli) are open-loop approximations, not a measured loop. Asked for SSIMULACRA2 85, the corpus landed at a mean of 77 with roughly the same spread as fixed q (1 photo of 158 inside ±2 of the target). It is a relabeled quality scale, not consistency. The measured closed loop that would actually hold a score is not in the forward encoder in this release. Revisit if that ships.

The single-frame lead that prompted the corpus run

One 400×266 color fixture, zenjpeg 0.8.4 progressive 4:4:4 vs cjpegli -q82 -p2. Directional only; the corpus above supersedes it.

encoder / modebytesSSIMPSNR
cjpegli q8217,234.96479140.7492 dB
zenjpeg baseline q8217,252.96455040.7186 dB
zenjpeg hybrid q8017,286.96768441.2312 dB
zenjpeg hybrid q8218,317.96924041.5352 dB

Grayscale: drop the chroma planes

A black-and-white Leica frame (400×267). In a grayscale image the two chroma planes carry almost nothing, so AVIF 4:0:0 (luma only) both encodes it honestly and rules out any faint color cast. Dropping to 4:0:0 saves barely any bytes over 4:2:0 because nothing lived in those planes to begin with. Correctness is the reason here, since the byte savings are negligible. The pipeline detects sips -g space → Gray and switches to --yuv 400.

PNG lossless51.0 KB0.49 b/px
JPG·zenc q849.3 KB0.09 b/px
AVIF q63 · 4:2:010.6 KB0.10 b/px
AVIF q63 · 4:0:010.5 KB0.10 b/px

Resolution scales bytes ~quadratically

The color frame at three long edges, AVIF q63 vs zenc q84. Doubling the long edge roughly triples the bytes, and AVIF holds a steady ~34–36% lead over JPEG at every size, which is why the grid stays AVIF-primary and small and square.

resolutionAVIF q63zenc q84AVIF saves
400×26610.4 KB15.9 KB34%
800×53333.3 KB52.1 KB36%
1200×80073.1 KB110.2 KB34%

What aadhar.sh actually ships

← back to the garage · aadhar.sh

shipped byte counts are real output from avifenc · zenc · cwebp · sips; the corpus table is a reproducible zenjpeg 0.8.4 run.