I'm a photographer. Well, more of a hobbyist, but still. However, my main interest in broad computer science invades my photographic choices heavily.
From very early on I used webp, as an alternative to JPEG, then I jumped to AVIF when it became widely supported.
It beats JPEG on quality per byte by a wide margin.
The problem is: AVIF encoders suck. Well, they were never designed for still images, after all AVIF is a byproduct of the video world.
Most AVIF encoders are slow, weirdly tuned out of the box for images, and not ergonomic at all.
This is the story of avif-converter, the tool that came out of it.
First generation
My first attempt was very simple.
I gambled that the encoder is not parallel enough to saturate the CPU, so I wrote a simple PowerShell script that ran a pool of avifEnc encoders in parallel and had better defaults set-up. It worked quite well, if you ignored some less important issues.
I used it for a long time, but what in the end nudged me to write a second generation - I really like wide-gamut photos.
When viewed on a right display, they can be much richer in color than standard ones.
The problem is that AVIF is a byproduct of the video world. Depending on where I opened a file, colors came out slightly or very wrong.
Two ways to describe color
A pixel is just numbers. Numbers mean nothing until you know the coordinate system they live in: what is "red", how a stored value maps to actual color.
Unfortunately, two systems emerged:
- ICC profile — the print/imaging world's answer. A full binary blob describing the color space in detail. Flexible and widely supported in images.
- CICP — the video world's answer. Described by three integers: RGB primaries ID (how colorful is color), which brightness curve (how to map values to brightness), and how color was converted to the YUV values the codec actually stores. Small, but has fixed vocabulary from ISO/IEC 23091-2 / ITU-T H.273.
ICC profiles are like verbose config files, while CICP is like a tuple of predefined values.
Both can describe the same thing (*sometimes). However, we do not care what they can, we care about what they do.
The issues arise here for AVIF, since because it came from a video codec, CICP is the de-facto standard for it.
While you can embed an ICC profile, you never were meant to do that. This would not be a problem if not for the lack of support for CICP in image viewers.
Some ignore it, some prefer it over ICC, and some do something totally different.
From testing my files across the software I use:
| Viewer | Behavior with AVIF color |
|---|---|
| Google Drive thumbnails | ignore both ICC and CICP |
| Windows Photo Viewer (pre-2026) | prefers CICP over ICC |
| Windows Photo Viewer (2026+) | applies both CICP and ICC → double correction |
| Chrome | prefers CICP over ICC |
| Immich (sharp / Node.js) | ignores CICP, uses only ICC |
No single file can satisfy every viewer.
In the end, back when I wrote the tool (in 2025), I chose to support the majority of viewers I tested for.
It targets pre-2026 Windows, Chrome, and Immich.
What the tool does
From my experimentation, AVIF (and AV1 for that matter) is tuned for 10-bit, so no matter the input, the output is always 10-bit.
Additionally, as I do not do HDR for now, the sRGB transfer curve is always used (this also has to do with the fact that some viewers, even when properly supporting wide gamut primaries, always expected the standard sRGB curve).
However, for color itself, I decided to have two separate paths:
- sRGB input → signal CICP (1, 13, 1). No ICC profile. Best support.
-
Everything else → libvips transforms the pixels to BT.2020 primaries, keeping the sRGB transfer curve; signal CICP (9, 13, 1) and inject a Rec2020 ICC profile into the container. No encoder supports that, so the tool does the injection itself: hand-written ISO-BMFF surgery that appends a
colrbox and re-points the item's associations and offsets.
Which path a file takes is decided by ExifTool: the ICC profile description first, then CICP primaries, then the EXIF color-space tag.
If no metadata exists, sRGB is assumed, the same assumption every viewer makes.
I decided this way since sRGB is the old standard. Old, but still the standard.
Any other profile is a safe bet that the photo is wide-gamut (better to widen than to cut-off).
Rec.2020 was the natural container: big enough to hold the majority of colors modern displays support.
Rec.2020 also represents cleanly on both sides of the fence: CICP (9, 13, 1) and a matching ICC profile.
It's the variant pre-2026 Windows renders correctly, and the injected profile (Rec2020-elle-V4-srgbtrc.icc) comes from elles_icc_profiles.
The injection trick is borrowed. Adobe Photoshop also does some surgical modifications on AVIF and studying its output is where the whole idea came from. If Adobe ships files this way, the structure is clearly somewhat needed, even though no encoder CLI will write it for you.
Encoder settings
Encoding runs in Python. This was not my first choice, but the availability of libraries and relative simplicity were very useful.
Because we use a real programming language we can optimize the script, so that the pixels rarely touch the disk: the decoded pixels are piped straight from memory into SvtAv1EncApp over stdin, unlike in the PowerShell version where every step required writing to a temp file.
Why SvtAv1EncApp now? I switched from avifEnc because it is much more configurable and has lot of recent improvements.
Since SVT-AV1 has many forks with features added, I landed on SVT-AV1-Tritium eventually for its psycho-visual features; the tool also works on upstream builds (more on that below). The settings and why:
| Setting | Why |
|---|---|
--tune 3 |
The fork's still image tune (tuned on SSIMULACRA2 against the CID22 set). The fork's own recipe for still coding is exactly this plus --avif 1. |
--crf 23 |
The quality dial. Fork docs suggest starting at 30 for Tune IQ; I spend the extra bits, stills deserve them. |
--preset 5 |
Inside the fork's recommended 2–6 band for stills; quality has converged, one frame encodes fast regardless. |
--input-depth 10 |
See above. |
--color-primaries, --transfer-characteristics, --matrix-coefficients, --color-range 1
|
The CICP signaling itself. Fulle range because the default double-scales levels. |
-n 1 --avif 1 |
A single-frame AVIF encode |
Plus the fork-only psycho-visual flags, used when the local encoder supports them:
-
--tx-bias 1— biases transform decisions toward sharpness and detail retention (fork docs warn of possible blocking/banding in exchange; at CRF 23 there's headroom). -
--complex-hvs 1— swaps in a more elaborate visual model during mode decisions, so the encoder optimizes for what eyes notice. -
--noise-adaptive-filtering 1— detects noise and backs off the smoothing filters (CDEF/restoration), keeping photographic grain instead of smearing it. -
--hbd-mds 1— forces the internal mode decisions themselves to 10-bit, on the same logic as the input depth.
Those flags don't exist upstream: a stock SvtAv1EncApp rejects them outright. So the tool parses the encoder's --help once, uses every supported flag, and warns about anything dropped. Same command line, upstream or fork: never fails, just different quality.
An end to the madness
The clean fix is switching to a modern format where color signaling is more standardized or just uses ICC. Chrome 155 (stable October 6, 2026) finally shipped JPEG XL decoding enabled by default; a format designed with wide gamut, HDR, and high bit depth as first-class citizens. This is the biggest step forward for JPEG XL adoption and may finally make it a viable alternative to AVIF for still images.
Once it's widespread enough, I'm migrating my tools to it and deleting most of these hacky workarounds.
Full source and usage: github.com/Miosp/avif-converter.
Top comments (0)