aadhar.sh/garage/thumbnail encoding study
Address
aadhar.shGarageThumbnail encoding study shipped

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 (tools/photos/add-photos.sh) encodes every thumbnail as AVIF-primary + a JPG fallback. The JPG encoder is now zenc (tools/photos/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. Being a Rust crate, it's dependabot-tracked and builds on any machine with 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 (imazen/zenjpeg). It ships no CLI, so the site checks in a small Rust wrapper (tools/photos/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-T50 + 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 and makes no promise of equal bytes.Calibrate by output size and ignore the 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 that saves no bytes: 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. The story is the trellis: 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 that 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 (tools/photos/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 with no measured loop behind them. 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. 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%

Progressive is a rendering choice as well as a byte one

Everything above treats progressive as a compression knob: it optimizes Huffman coding, and ProgressiveSearch over 64 scan candidates took the BD-rate from ~1.6% to ~4%. That is only half of what the setting does. Scan order also decides what the viewer sees while the file is still arriving, and on a big enough image that is the entire loading experience rather than a footnote to it. Kornel Lesiński made the case at perf.now() 2018: the same bytes, reordered, show a recognisable picture at a quarter of the file, and it costs nothing, because it needs no extra request and no placeholder tier.

What a scan actually is

A JPEG stores each 8×8 block as 64 quantized DCT coefficients: one DC term, the block's average, and 63 AC terms for finer and finer detail. Baseline writes every block complete in a single pass, top to bottom, so half the file is the top half of the picture. Progressive writes the same coefficients over several passes, each called a scan and each covering the whole frame. A scan header names its channels, a band of coefficients (Ss..Se: DC alone, then coarse AC, then fine) and a bit precision (Ah/Al: the high bits first, the low bits in later refinement scans). Nothing is quantized twice, so the finished pixels match.

The /lwe/encoding demo takes that last claim off the honour system. jpegtran rewrote the shipped q84 fixture into a baseline file and into libjpeg's standard 10-scan script without decoding it, all three decode to byte-identical pixels, and a slider hands each cut-off file to your browser's decoder. The shipped file's own scan headers read:

scanchannelscarriesstarts at
1YDC (block averages)2%
2YAC 1–211%
3YAC 3–6324%
4CbDC (block averages)83%
5 ★CrDC (block averages)85%
6CbAC 1–6386%
7CrAC 1–6392%

The three files are also the byte half of the story. A progressive AC scan can code a run of all-zero blocks as one symbol, and each scan carries Huffman tables fitted to its own statistics; refinement scans give some of that back in correction bits. So the libjpeg script lands 1.7% over baseline (16,609 against 16,324 bytes) while zenc's searched order lands 0.6% under it (16,234). A smaller progressive file is a property of a good script.

Chrome paints nothing until every channel has started measured

Cut a progressive JPEG anywhere and Chromium draws what the coefficients so far imply, missing rows and all, with one exception that decides everything: it paints nothing until Y, Cb and Cr have each begun a scan. The gate is exact to the byte. On the fixture the Cr DC scan header starts at byte 13,753 and its data at 13,763; a prefix ending anywhere up to 13,760 fails to decode, and one ending at 13,765 paints. ImageDecoder fed the same prefixes as a stream agrees, so this is the streaming path a visitor gets, and truncated blobs are a faithful stand-in for it. Measured in Chromium 152; the starred row above is the gate, and the LWE demo shows whether your engine keeps it.

zenc's search scores bytes and nothing else, and at thumbnail size the winning order often sends the whole luma plane before either chroma plane. On the fixture that puts the gate at 84.8%, so the shipped file paints once, at the end, like a slow baseline. Across the library's 255 colour JPG tiles the gate sits at a median 80% of the file: 99 paint within their first quarter, and the other 156 wait past halfway.

On a thumbnail that barely matters. The JPG tile is the fallback most browsers never fetch, and it is small: a median 48.7 KB, with the gate at a median 19.3 KB. A 10-packet initial congestion window carries about 14 KB, so 116 of the 255 reach their gate in the opening flight and the rest one round trip later. The scan search still earns its place at this size, purely for the bytes it saves.

It matters at the full-size archive. /images/full/ is the one image surface here with no AVIF tier and no <picture> around it: the worker streams a median 21.6 MB progressive JPEG from R2 (since October 2026 as its lossless JPEG XL copy, about 8% smaller), and that single file is the whole payload. That is also the awkward part of the format story, because AVIF structurally cannot do this. Its layered mode caps at four stages, is effectively Chromium-only, and is staged rather than incremental the way a JPEG scan script is; nothing here emits layered AVIF anyway. So the format that wins every byte comparison on this page loses the one property that matters when a single large image is the page. Both are true at once, which is why the grid is AVIF and the archive is a progressive JPEG.

A fifth of the archive had the order wrong fixed

The gate applies to the archive too, where it costs seconds. Each of the 258 files was streamed from R2 until every channel had started, giving up at 3 MB, then again after the repair below. Each cell is the group's median first paint, as a share of the file:

archive filescountbeforeafter
colour, all of luma before any chroma5393.4%0.023%
colour, zenc's DC-first script1543.9%0.022%
colour, baseline, top rows down10.080%0.084%
colour, JPG original through jpegtran480.53%unchanged
grayscale, one channel20.34%unchanged

All 53 were HIF re-encodes, and the cause is narrower than "the search picked badly". zenjpeg trial-encodes two scripts and keeps the smaller: jpegli's default, which opens with each channel's DC, and the winner of a mozjpeg-style search, which zenjpeg serializes with luma's DC and every luma AC scan ahead of chroma's DC. On 53 of 207 40 MP frames the second script won by bytes, and every one of them started colour between 66 and 99% of the file. XT508165 was the worst: 98.4% of 23.8 MB, about nine seconds of blank frame on a 20 Mbps link before a finished picture appeared in one step. The archive is progressive precisely to avoid that.

The repair never decodes a pixel. jpegtran -progressive rewrote the scan script of every HIF archive, all 207, around the same quantized coefficients: collapsing old and new to one baseline encoding gives byte-identical files for every one, each metadata segment survives byte for byte, and djpeg decodes both to the same pixels. mozjpeg's own search is free to interleave DC, so each file now opens with one scan that starts all three channels, and Chrome's first paint lands 4 to 6 KB in.

The bytes split by group. The 53 came out 0.11% smaller. The 154 that already painted by 5% came out 0.27% larger, a median 74 KB each, and that was a deliberate trade: 74 KB more on a ~22 MB download to start painting about 800 KB sooner. Across all 207 the pass costs 0.17%, and add-photos.sh now runs it after zenc on every new HIF.

The one baseline file, XT508887, is the camera's own JPEG, kept on purpose when the HIF archives were re-encoded in July. Fuji writes a single baseline scan with stock Huffman tables, so the same lossless pass made it progressive and 7.4% smaller, 16.4 to 15.2 MB, with its first paint at 0.084%.

What the archive tier actually costs

The archive is a second encode: a HIF is decoded to lossless PNG, then re-encoded at q100 4:2:2. Scored against that decode with SSIMULACRA2, one 7728×5152 frame reads ~88, which looks alarming for q100 until you sweep it. It is simply where JPEG tops out on 40 MP of sensor grain, and the metric is harsh on exactly the high-frequency noise JPEG discards first.

qualitySSIMULACRA2bytesb/px
q100 (shipped)88.1526.1 MB5.24
q9585.1316.3 MB3.28
q9080.7411.5 MB2.31
q8474.267.8 MB1.57

Two other suspects for that ceiling were checked rather than assumed, and both are clean. Re-encoding at 4:4:4 instead of 4:2:2 scores 88.28 against 88.15, so the chroma round-trip is not the limit and 4:2:2 keeps its ~13% byte saving for free. Flattening the reference to 8-bit first scores 88.08 against 88.15, so the 10-bit→8-bit step is not the limit either: JPEG is 8-bit, and a HIF's extra depth earns its keep in the edit.

That generation also costs far less than it looks once an image is resized for anywhere else. Comparing a direct downscale of the HIF against a downscale of the archive JPEG, the same pair that reads 88.15 at full resolution reads 94.62 at 1080px: averaging ~50 source pixels into one washes most encoding artifacts out. Delivery scale forgives the second encode. A 20 MB download does not, which is the whole reason the archive is progressive.

Chroma: hold the bytes, then compare shipped

The two AVIF q63 cards in the showdown put 4:4:4 at 11.3 KB against 4:2:0's 10.4 KB, and this page used to conclude from them that full chroma was a byte tax. Those cards hold the quality knob fixed, so the 4:4:4 card also comes out better, and the two halves of that conclusion were never measured at once. The fair comparison fixes the bytes and lets quality move. Scored that way, on the tiles the grid actually serves, the call reverses:

tiershipped 4:2:04:4:4, same bytes4:4:4 ahead on
600×60078.25+0.4518 of 24
400×40079.25+0.5216 of 24
200×20079.80+0.5915 of 24

SSIMULACRA2 over 24 photos from both cameras, and butteraugli agrees on every tier. In bytes, 4:2:0 needs a median 2.2% more on the 600 tile to reach 4:4:4's score.

Why more chroma helps when the camera recorded less. Every color original here is 4:2:2, but that describes the 7728×5152 frame. A 600px tile is the frame's centre square reduced 8.6×, so each tile pixel averages about four color samples across and nine down, and at its own size the tile carries full color. 4:2:0 then discards half of it in each direction. With no compression at all, halving the color and restoring it scores 93.1 to 93.5 on the three tiles against 96.3 on a crop at native resolution: the downscale is what makes subsampling expensive. It barely costs bytes to keep, because full-resolution color in a reduced photo is smooth, and AV1 codes smooth planes for very little.

It matters most under red light. Red carries only 0.299 of the brightness in the BT.601 matrix these files signal, so fine detail in a red scene lives mostly in the color planes. One night frame of bare branches against red windows needs 35% more bytes at 4:2:0 to match 4:4:4.

Each layout was scored at the shipped tile's exact byte count. avifenc's quality knob moves in steps of a few percent, so every score is interpolated between the two steps on either side of the budget, and the reproduced 4:2:0 tiles matched the shipped ones byte for byte (72 of 72) before anything was compared. tools/photos/avif-chroma-probe.ts reruns it.

JPEG XL: where it loses, and what it would buy

Safari has decoded JPEG XL since version 17, Chrome Canary turned it on by default at 155, and Firefox has it on the way. So the format this page never tested needs an answer. It could help this site in three places: replacing the AVIF tiles, repacking the archive JPEGs without loss, or decoding faster. All three were measured.

The tiles: AVIF wins every one

24 photos from both cameras. Each shipped AVIF tile was first reproduced byte for byte (72 of 72), then JPEG XL was searched onto exactly its byte count, at -e 9 with its gaborish and EPF filters off, the best of the settings tried:

tierAVIF q63 4:4:4JPEG XL, same bytesJPEG XL ahead on
600×60079.5073.650 of 24
400×40080.7073.500 of 24
200×20081.5474.240 of 24

SSIMULACRA2, and butteraugli agrees on 70 of the 72. To match the 600 tile, JPEG XL needs a median 36.6% more bytes (11% to 58%, over the 22 photos where the search reached a match). Every other setting trailed too: filters on by 6.09, -e 7 by 6.53, and modular mode by 4.08, ahead on 2 of 24 by SSIMULACRA2 and on none by butteraugli. Both metrics came out of the JPEG XL project, so if the scoring leans anywhere, it leans toward JPEG XL.

These tiles sit at 0.57 to 0.92 bits per pixel, which is AV1's home ground: its video tools code smooth areas like sky and out-of-focus background for almost nothing. JPEG XL's published advantage is at higher bitrates, which this page hasn't measured.

The archive: an 8% lossless repack

JPEG XL can repack an existing JPEG and give back the identical file on decode. Twelve archive originals from production (3 Leica Monochrom, 9 X-T50) went from 238.1 MB to 218.7 MB, 8.17% smaller (4.45% to 11.27% per file), and all 12 decoded back to the original bytes. -e 9 buys about 0.3% more. That's well under the ~20% usually quoted, probably because every file here already carries optimized Huffman tables and the X-T50 ones are q100. Which of the two matters hasn't been isolated.

Decode time

Chrome Canary 156 on an M3 Max: createImageBitmap on a fresh Blob, median of 40 runs per tile and 7 per frame, formats interleaved. The 40 MP AVIF and lossy JPEG XL files are matched to 3.37 MB.

format600 tile40 MP frame
JPEG (zenc q84 tile, progressive archive)1.6 ms716 ms
AVIF q63 4:4:44.1 ms228 ms
JPEG XL -e 74.6 ms151 ms
JPEG XL, filters off3.2 ms88 ms
JPEG XL, lossless copy of the JPEGn/a144 ms

On a tile everything lands under 5 ms, so the format costs a visitor nothing there. At 40 MP JPEG XL is fastest because it decodes in parallel: the image is cut into independent blocks. libjxl took 1.5 to 1.9 s on one thread for the same frame and about 0.25 s on all of them, so the lead is spare cores and should narrow on a machine with fewer. The two filters run while decoding, which is why switching them off speeds it up. The archive JPEG is slowest of all, since Chrome appears to decode a progressive JPEG on one thread.

Hardware AV1 decoding doesn't change any of this. The color tiers are 4:4:4, which is AV1's High profile, and most consumer hardware decoders implement only Main (4:2:0). As far as I know, Chrome and Firefox decode AVIF in software with dav1d anyway.

What paints before the download finishes

The progressive section above says AVIF can't render incrementally. Measured: a server sent Chrome the first part of a 40 MP file and held the connection open, and a screenshot recorded what had painted.

formatfirst paintafter that
progressive JPEG (the archive)part at 2%, all at 5%refines
JPEG XL, lossless copy of it5%refines
JPEG XL -p (+4.5% bytes)2%sharpens at every step
JPEG XL, defaultbetween 10% and 25%near-final at once
AVIF, layered5%unchanged until complete
AVIF (what the tiles ship)only when complete

The screenshots were 400×600, where a 1:8 preview already looks nearly final, so the early frames would read blurrier full-screen.

Where this lands: nothing changes. The tiles stay AVIF. Every browser that decodes JPEG XL also decodes AVIF, and AVIF wins at these sizes, so a JPEG XL <source> would either never be picked or make the tiles worse. The JPG fallback only reaches browsers without AVIF, which rules JPEG XL out there too. The archive is the one real opening: a lossless copy would be 8% smaller, decode about 5× faster in Chrome, and still paint early. It wasn't shipped at first, because a visitor saving the original would get JPEG XL bytes behind a .jpg link, and serving it meant two R2 objects per photo and a cache keyed on Accept.

Update, 2026-10-07: the archive is JPEG XL now. Chrome 155 shipped JPEG XL the day before, and both objections came from keeping the JPEG. Each original now lives in R2 as <stem>.jxl only, under its own URL, so a saved file is named for what it holds and nothing varies on Accept. The JPEG survives: djxl rebuilds it byte for byte, which every upload proves with cmp before it lands. The old .jpg links answer 301. The cost is a browser without JPEG XL, which can't open an original: Chrome before 155, and Firefox until it turns JPEG XL on (planned for 158).

What aadhar.sh actually ships

  • AVIF primary (q63, 4:4:4 color / 4:0:0 gray) + zenc JPG q84 (zenjpeg hybrid+scan) as the universal fallback, via <picture>.
  • Pre-cropped 600/400/200 squares, metadata-stripped: the file holds exactly the displayed pixels, so no off-square or EXIF bytes ride along.
  • 4:4:4 over 4:2:0 for color, since 2026-09-26: at equal bytes it scores higher on every tile size, because a downscaled photo has full color to keep (the chroma section above). The tiles ship at the same q63, where 4:4:4 lands about 6% heavier across the library (+5.6%, +6.5% and +7.0% on the 600, 400 and 200 tiles) and scores 1.3 to 1.7 higher: the equal-bytes win, plus a deliberate spend on quality. Holding bytes flat would have meant q62, at +0.4% to +1.7% and +0.5 to +1.0.
  • Full-res share copies in R2 at JPEG q100 4:2:2, progressive: 4:2:2 matches the HIF's native sensor chroma, and the scan order is the point at 20 MB. The true HEIF originals stay on local disk. A JPG-source original is uploaded byte-for-byte from the camera, rearranged to progressive by jpegtran on the way up: a lossless reorder of the same coefficients.

← back to the garage · aadhar.sh

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