zenjxl is a JPEG XL encoding and decoding library combining zenjxl-decoder and jxl-encoder with resource limits, cancellation, and gain map support.
zenjxl-decoder is Imazen's fork of jxl-rs with additional metadata extraction, gain map parsing, and resource limiting. jxl-encoder is a pure Rust JPEG XL encoder supporting both lossless (modular) and lossy (VarDCT) modes. zenjxl wraps both behind a unified API and provides zencodec integration for use in zenpipe pipelines.
#![forbid(unsafe_code)], no_std + alloc, edition 2024.
[dependencies]
zenjxl = "0.2.1"To read decoded pixels back out as packed RGBA8 bytes (see the Decode example),
also add the pixel crates and — for cancellation — almost-enough:
zenpixels = "0.2.13" # PixelBuffer / PixelDescriptor (returned by decode)
zenpixels-convert = "0.2.13" # the `.to_rgba8()` extension trait
almost-enough = "0.4.4" # a ready-made cancellation token (optional)use zenjxl::{decode, probe, JxlLimits};
use zenpixels::PixelDescriptor;
let jxl_bytes: &[u8] = &std::fs::read("photo.jxl").unwrap();
// Metadata-only probe (no pixel decode).
let info = probe(jxl_bytes).unwrap();
println!("{}x{}, alpha={}, gray={}", info.width, info.height, info.has_alpha, info.is_gray);
// Full decode with resource limits. The 3rd arg is a pixel-format preference
// list (`&[zenpixels::PixelDescriptor]`); `&[]` lets the decoder pick natively.
let limits = JxlLimits {
max_pixels: Some(120_000_000), // 108 MP photos are common
max_memory_bytes: Some(2 * 1024 * 1024 * 1024),
};
let output = decode(jxl_bytes, Some(&limits), &[]).unwrap();
// `output.pixels` is a `zenpixels::PixelBuffer` in the image's NATIVE format
// (opaque → RGB8, with alpha → RGBA8). Normalize to packed RGBA8 bytes with the
// `zenpixels-convert` extension trait:
use zenpixels_convert::PixelBufferConvertTypedExt;
let rgba: Vec<u8> = output.pixels.to_rgba8().copy_to_contiguous_bytes(); // w*h*4, R,G,B,A
// Need the bytes in their native layout instead? Read them straight off the
// PixelBuffer — no conversion crate required. `descriptor()` tells you the layout
// (e.g. RGB8 vs RGBA8); `width()`/`height()` give the dimensions.
let (w, h) = (output.pixels.width(), output.pixels.height());
let desc = output.pixels.descriptor(); // PixelDescriptor: channels + bit depth
let native: Vec<u8> = output.pixels.into_vec(); // owned, tightly packed, w*h*desc.bytes_per_pixel()
// (use `output.pixels.contiguous_bytes()` for a borrowing `Cow<[u8]>` instead of an owned Vec.)Dependencies & errors. Besides zenjxl, add zenpixels (PixelBuffer/
PixelDescriptor), zenpixels-convert (the .to_rgba8() trait), and enough
(cancellation). decode/probe/encode_* return Result<_, whereat::At<E>>
(At<JxlError>): the At<…> adds a build-time source location for logs — read
it with err.location() (Option<&Location>, giving file()/line()). Get the
underlying error with err.error() (borrow) or err.decompose().0 (owned), then
match the [JxlError] enum (it is #[non_exhaustive], so keep a wildcard arm).
Cancellation. decode_with_options(data, limits, preferred, parallel, stop)
adds a cancellation token. parallel: Option<bool> toggles multithreaded decode
(None = default); stop: Option<Arc<dyn enough::Stop>> is the token. Build a
real one with almost_enough::Stopper (cargo add almost-enough):
use std::sync::Arc;
use zenjxl::{decode_with_options, JxlLimits};
let stopper = almost_enough::Stopper::new();
let watcher = stopper.clone(); // Stopper is Clone; shares the cancel flag
std::thread::spawn(move || watcher.cancel()); // e.g. on a deadline / client disconnect
let limits = JxlLimits { max_pixels: Some(120_000_000), max_memory_bytes: Some(2 * 1024 * 1024 * 1024) };
let stop: Arc<dyn enough::Stop> = Arc::new(stopper);
let output = decode_with_options(jxl_bytes, Some(&limits), &[], None, Some(stop))?;use zenjxl::{LossyConfig, LosslessConfig, PixelLayout, calibrated_jxl_quality, quality_to_distance};
let rgb: &[u8] = &[0u8; 256 * 256 * 3]; // packed RGB8 pixels
// Lossy. JXL is parameterized by butteraugli *distance*, where LOWER = better
// (0.0 = mathematically lossless, ~1.0 = visually lossless, larger = smaller file).
// Map a 0..=100 quality to a distance with the calibrated chain:
// calibrated_jxl_quality(generic_q) -> native JXL quality (0..=100),
// quality_to_distance(native_q) -> butteraugli distance.
let distance = quality_to_distance(calibrated_jxl_quality(85.0));
let lossy = LossyConfig::new(distance)
.encode(rgb, 256, 256, PixelLayout::Rgb8)
.unwrap();
// Lossless.
let lossless = LosslessConfig::new()
.encode(rgb, 256, 256, PixelLayout::Rgb8)
.unwrap();PixelLayout also covers Rgba8, Bgra8, and Gray8 (plus 16-bit and
linear-f32 variants). quality_to_distance alone maps quality straight to
distance; calibrated_jxl_quality first re-maps a libjpeg-turbo-style quality
onto JXL's native quality scale so a given number lands at the same perceptual
level it would in a JPEG encoder. Convenience wrappers
(encode_rgb8/encode_rgba8/…) exist for the imgref
ImgRef<rgb::Rgb<u8>> pixel types if you already hold those.
Decode -- probe() returns JxlInfo with dimensions, bit depth, ICC profile, CICP signaling, EXIF orientation, raw EXIF/XMP bytes, extra channel metadata, HDR tone mapping fields, preview size, and gain map bundles. decode() returns a PixelBuffer with automatic format negotiation. decode_with_parallel() enables multithreaded decoding; decode_with_options() adds cancellation via enough::Stop.
Encode -- Convenience functions for RGB, RGBA, BGRA, and grayscale u8 data in both lossy and lossless modes. calibrated_jxl_quality() maps a 0--100 quality scale to JXL distance. Container utilities (append_gain_map_box, is_bare_codestream) for gain map authoring.
Gain maps -- Decode extracts GainMapBundle from jhgm container boxes (ISO 21496-1). Encode can append gain map boxes to existing codestreams.
HDR metadata -- JxlInfo exposes intensity_target, min_nits, relative_to_max_display, and linear_below from the JXL tone mapping header.
| Flag | Default | Description |
|---|---|---|
decode |
yes | JPEG XL decoding via zenjxl-decoder |
encode |
yes | JPEG XL encoding via jxl-encoder |
threads |
no | Multithreaded decoding via rayon (requires decode) |
parallel |
no | Per-frame parallelism inside the encoder via rayon (requires encode) |
butteraugli-loop |
no | Perceptual quality tuning (requires encode) |
zencodec |
no | Config/Job/Executor trait integration for zen codec pipelines |
- The
encode_rgb8/encode_rgba8/… convenience wrappers are u8-only. TheLossyConfig/LosslessConfigencode()path and the zencodec adapter support wider bit depths (16-bit and linear-f32 layouts viaPixelLayout). - zenjxl-decoder does not yet support all JPEG XL features (e.g., some edge cases in progressive decoding).
| 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.
Upstream code from libjxl/libjxl is licensed under BSD-3-Clause. Our additions and improvements are dual-licensed (AGPL-3.0 or commercial) as above.
We are willing to release our improvements under the original BSD-3-Clause license if upstream takes over maintenance of those improvements. We'd rather contribute back than maintain a parallel codebase. Open an issue or reach out.