Pure Rust AVIF image codec. Decodes and encodes AVIF images using rav1d-safe (AV1 decoder) and zenavif-parse (AVIF container parser).
- Decodes 8/10/12-bit AVIF with all chroma subsampling modes (4:2:0, 4:2:2, 4:4:4, monochrome)
- Handles alpha channels (straight and premultiplied)
- Supports full and limited color range, HDR color spaces (BT.2020, P3, etc.)
- Preserves EXIF, XMP, rotation, mirror, clean aperture, pixel aspect ratio, HDR metadata
- Decodes animated AVIF sequences with per-frame timing
- Decodes gain maps (ISO 21496-1) and depth auxiliary images from AVIF containers
- Encodes AVIF with optional gain map embedding via
GainMapConfig(requiresencodefeature) - Encodes AVIF via zenravif (optional
encodefeature) - 100% safe Rust by default. Zero
unsafein the decode path. - Cooperative cancellation via the
enoughcrate
decode returns a PixelBuffer (re-exported as
zenavif::PixelBuffer). It carries width, height, stride, bit depth, and color
metadata — it is not a flat Vec<u8>. See Getting RGBA8 pixels below to
get bytes.
use zenavif::decode;
let avif_data = std::fs::read("image.avif").unwrap();
let image = decode(&avif_data).unwrap();
println!("{}x{}", image.width(), image.height());To pull tightly-packed 8-bit RGBA bytes out of the decoded buffer (regardless of
the source's channel order, subsampling, or bit depth), normalize with
zenpixels-convert:
# Cargo.toml — add alongside zenavif
zenpixels-convert = { version = "0.2.13", features = ["rgb"] }use zenavif::decode;
use zenpixels_convert::PixelBufferConvertTypedExt; // brings to_rgba8() into scope
let image = decode(&avif_data).unwrap();
let rgba = image.to_rgba8(); // PixelBuffer<Rgba<u8>>, always 8-bit RGBA
let (w, h) = (rgba.width(), rgba.height());
let bytes: Vec<u8> = rgba.copy_to_contiguous_bytes(); // w * h * 4, no row paddingto_rgb8(), to_gray8(), and to_bgra8() are also available. If you instead
want to keep the buffer as imgref rows for re-encoding, add the imgref
feature and use try_as_imgref::<rgb::Rgba<u8>>() (returns None unless the
buffer is exactly RGBA8 — to_rgba8() is the conversion that never fails).
Bit depth: the decoder outputs the AV1 bitstream's native depth, so a 10-bit file yields a 16-bit
PixelBuffer.to_rgba8()downscales to 8-bit for you; if you want the decoder itself to emit 8-bit, setDecoderConfig::new().prefer_8bit(true)(see Decoder output depth).
use zenavif::{decode_with, DecoderConfig};
use enough::Unstoppable;
let config = DecoderConfig::new()
.threads(4)
.apply_grain(true)
.frame_size_limit(8192 * 8192); // tighten the 120 MP default — see note below
let avif_data = std::fs::read("image.avif").unwrap();
let image = decode_with(&avif_data, &config, &Unstoppable).unwrap();Server safety —
frame_size_limitdefaults to 120 MP (120_000_000). The baredecode()entry point and a defaultDecoderConfigenforce this cap pre-flight, before any frame allocation, so an out-of-range frame fails fast instead of forcing a large allocation. 120 MP admits ~108 MP phone photos (12000 * 9000) while still bounding untrusted input by default. To tighten, pass.frame_size_limit(max_w * max_h)sized to what your service accepts (e.g.8192 * 8192≈ 67 MP); to opt out entirely, pass.frame_size_limit(0)(unbounded).
decode*/encode* return Result<_, whereat::At<Error>>. The
At wrapper records the source location for
server-side logs — read it with .location() (returns Option<&Location>, with
file()/line()); call .error() (borrow) or .decompose().0 (owned) to get
the inner Error enum to match on, and map it to an HTTP status — malformed input
(Error::Parse, Error::Decode, ValidationError) is a client 4xx, while a
resource trip from your frame_size_limit should be a 413/422.
Cancellation differs by direction, by design:
- Decode takes
&(impl enough::Stop)— pass&enough::Unstoppablefor no cancellation, or a real, thread-safe stopper from thealmost-enoughcrate (cargo add almost-enough):let stop = almost_enough::Stopper::new();, thendecode_with(bytes, &config, &stop)?, and callstop.cancel()(it isClone) from a deadline/disconnect watcher thread. - Encode takes an
almost_enough::StopToken(the encoder's backend uses thealmost_enoughcrate) — build one withStopToken::new(enough::Unstoppable)for the no-op case, as the encode examples below show.
let avif_data = std::fs::read("animation.avif").unwrap();
let animation = zenavif::decode_animation(&avif_data).unwrap();
for frame in &animation.frames {
println!("{}x{} frame, {}ms",
frame.pixels.width(), frame.pixels.height(), frame.duration_ms);
}use zenavif::{encode, decode};
let image = decode(&std::fs::read("input.avif").unwrap()).unwrap();
let encoded = zenavif::encode(&image).unwrap();
std::fs::write("output.avif", &encoded.avif_file).unwrap();use zenavif::{EncoderConfig, encode_with, decode, Unstoppable};
use almost_enough::StopToken;
let image = decode(&std::fs::read("input.avif").unwrap()).unwrap();
let config = EncoderConfig::new()
.quality(80.0) // 1.0 (worst) to 100.0 (best)
.speed(4); // 1 (slowest) to 10 (fastest)
// StopToken::new(Unstoppable) is the no-op token; pass a real stopper to cancel.
let encoded = encode_with(&image, &config, StopToken::new(Unstoppable)).unwrap();
std::fs::write("output.avif", &encoded.avif_file).unwrap();encode_with takes a decoded &PixelBuffer. If you have raw pixels in hand,
use encode_rgb8 / encode_rgba8 / encode_rgb16 / encode_rgba16, which take
an imgref::ImgRef of the matching rgb pixel type (e.g.
encode_rgb8(img, &config, StopToken::new(Unstoppable))).
Speed controls how much time the encoder spends optimizing. Higher speeds produce slightly larger files but encode much faster. Quality is comparable across speeds — the main tradeoff is encode time vs file size, not visual quality.
Measured on a 512×512 photographic image (CID22 corpus), q80, 8-bit (full sweep data):
| Speed | Encode time | File size | Compression ratio | zensim |
|---|---|---|---|---|
| 1 | 1.1s | 55.9K | 14.1x | 85.4 |
| 2 | 1.1s | 55.9K | 14.1x | 85.4 |
| 4 | 0.8s | 56.5K | 13.9x | 85.5 |
| 6 | 0.2s | 56.8K | 13.8x | 85.5 |
| 10 | 78ms | 59.0K | 13.3x | 85.4 |
Speed 4 is a good default. Speed 6 gives 4x faster encoding with identical quality. Speed 10 is best for real-time/interactive use — still good quality at ~80ms per frame. Speed 1-2 produce marginally smaller files but take 5-14x longer than speed 4.
The quality parameter maps to an AV1 quantizer index. The default is 75
(high quality); the reference points below bracket the useful range:
| Quality | Use case | Typical compression |
|---|---|---|
| 30 | Thumbnails, previews | 100-120x |
| 50 | Web images (aggressive) | 40-45x |
| 65 | Web images (balanced) | 22-25x |
| 80 | High quality | 12-14x |
| 95 | Near-lossless | 5-6x |
| 100 | Lossless | 2-3x |
With the encode-imazen feature, quantization matrices are enabled by default.
QM applies frequency-dependent quantization weights that save 5-12% file size
at q≤50 and roughly break even on size at high quality. Quality impact is small:
within ±0.4 zensim of QM-off from q=70 onward, and ≤2 zensim points at low q
(measured across 5 CID22 test images at speed 6).
QM is gracefully disabled for lossless encoding (with_lossless(true)); at
quality=100 the underlying encoder also detects that all selected QM levels
collapse to identity and clears the QM signaling, producing output equivalent
to QM-off.
// QM is on by default. To disable:
let config = EncoderConfig::new()
.quality(80.0)
.with_qm(false);At speed ≥ 6 the encoder skips the per-block transform-type RDO search and uses DCT-DCT only for intra blocks. Forcing the full search back on is an opt-in quality knob:
let config = EncoderConfig::new()
.quality(95.0)
.with_qm(true)
.with_rdo_tx_decision(Some(true)); // archival / one-shot encodeMeasured trade on a 63-image stills corpus (CID22, speed 6, with QM on):
| Config | Mean BD-Rate vs upstream rav1e | Encode time |
|---|---|---|
with_qm(true) (default) |
−10.1 % | 1.0× |
with_qm(true).with_rdo_tx_decision(Some(true)) |
−10.3 % | ~3.0× |
Mean gain over QM-only is small (~0.2 % BD-rate), but per-image gains range up to −31 %. Recommended only for archival / one-shot encodes, not bulk web pipelines.
The encoder matches output bit depth to input type by default:
encode_rgb8/encode_rgba8→ 8-bit AV1encode_rgb16/encode_rgba16→ 10-bit AV1
Override with .bit_depth(EncodeBitDepth::Ten) if you want 10-bit output
from 8-bit input (slightly better quality at the cost of larger files and
wider decoder compatibility requirements).
The decoder outputs at the AV1 bitstream's native bit depth. Files encoded
at 10-bit (common from other encoders that default to 10-bit) produce 16-bit
PixelBuffer output. Use prefer_8bit(true) to downscale to 8-bit:
let config = DecoderConfig::new().prefer_8bit(true);
let image = decode_with(&avif_data, &config, &Unstoppable).unwrap();
// image is Rgb8 even if the AV1 bitstream was 10-bit| Feature | Description |
|---|---|
| (default) | Pure Rust decode via rav1d-safe. No unsafe code. |
encode |
AVIF encoding via zenravif (pure Rust) |
encode-asm |
Encoding with hand-written assembly (fastest, uses unsafe) |
encode-threading |
Multi-threaded encoding |
encode-imazen |
Encoding with zenrav1e fork extras (QM, lossless) |
unsafe-asm |
Decoding with hand-written assembly via C FFI (fastest, uses unsafe) |
zencodec |
Integration with zencodec trait hierarchy |
# Default safe decoder
cargo build --release
# With encoding
cargo build --release --features encode
# Fast assembly decoder (uses unsafe + C FFI)
cargo build --release --features unsafe-asm
# Run tests
cargo test
# Run with test vectors
just download-vectors
just test-integrationThis project builds on excellent work by others:
-
rav1d (BSD-2-Clause) — Pure Rust AV1 decoder (Rust port of dav1d). Provides the AV1 decoding backend via its managed safe API.
-
zenavif-parse (MIT/Apache-2.0) — AVIF container parser for extracting image items and metadata from the ISOBMFF container.
-
yuv (MIT) — YUV to RGB color conversion.
-
libavif (BSD-2-Clause) — Reference AVIF implementation used for pixel-level verification and behavioral reference.
| State of the art codecs* | zenjpeg · zenpng · zenwebp · zengif · zenavif (rav1d-safe · zenrav1e · zenavif-parse · zenavif-serialize) · zenjxl (jxl-encoder · zenjxl-decoder) · zentiff · zenbitmaps · heic · zenraw · zenpdf · ultrahdr · mozjpeg-rs · webpx |
| Compression | zenflate · zenzop |
| Processing | zenresize · zenfilters · zenquant · zenblend |
| Metrics | zensim · fast-ssim2 · butteraugli · resamplescope-rs · codec-eval · codec-corpus |
| Pixel types & color | zenpixels · zenpixels-convert · linear-srgb · garb |
| Pipeline | zenpipe · zencodec · zencodecs · zenlayout · zennode |
| ImageResizer | ImageResizer (C#) — 24M+ NuGet downloads across all packages |
| Imageflow | Image optimization engine (Rust) — .NET · node · go — 9M+ NuGet downloads across all packages |
| Imageflow Server | The fast, safe image server (Rust+C#) — 552K+ NuGet downloads, deployed by Fortune 500s and major brands |
* as of 2026
archmage · magetypes · enough · whereat · zenbench · cargo-copter
And other projects · GitHub @imazen · GitHub @lilith · lib.rs/~lilith · NuGet (over 30 million downloads / 87 packages)
Dual-licensed: AGPL-3.0 or commercial.
I've maintained and developed open-source image server software — and the 40+ library ecosystem it depends on — full-time since 2011. Fifteen years of continual maintenance, backwards compatibility, support, and the (very rare) security patch. That kind of stability requires sustainable funding, and dual-licensing is how we make it work without venture capital or rug-pulls. Support sustainable and secure software; swap patch tuesday for patch leap-year.
Your options:
- Startup license — $1 if your company has under $1M revenue and fewer than 5 employees. Get a key →
- Commercial subscription — Governed by the Imazen Site-wide Subscription License v1.1 or later. Apache 2.0-like terms, no source-sharing requirement. Sliding scale by company size. Pricing & 60-day free trial →
- AGPL v3 — Free and open. Share your source if you distribute.
See LICENSE-COMMERCIAL for details.
Developed with AI assistance (Claude, Anthropic). Not all code manually reviewed — review critical paths before production use.