jxl-encoder [](https://github.com/imazen/jxl-encoder/actions/workflows/ci.yml) [](https://crates.io/crates/jxl-encoder) [](https://lib.rs/crates/jxl-encoder) [](https://docs.rs/jxl-encoder) [](#license)

June 28, 2026 · View on GitHub

jxl-encoder is a pure-Rust JPEG XL encoder for both lossy (VarDCT) and lossless (Modular) images, built on the foundation of libjxl and targeting parity with it. #![forbid(unsafe_code)] by default; no_std + alloc capable (disable default features for the alloc-only build — std adds the encode_to() / finish_to() Write-target sinks).

The reference encoder, cjxl (libjxl), is a well-engineered, mature C++ codebase. This crate exists because we wanted a pure-Rust encoder we could embed in Imageflow with no C FFI, and a place to experiment with content-aware dispatch and alternative perceptual metrics. Our algorithms, quantization weights, cost models, and the bitstream format itself are derived from libjxl. Every encoded file in our test suite is verified against three independent decoders: jxl-rs, jxl-oxide, and djxl (libjxl).

On a strict per-cell Pareto scoreboard cjxl still wins more cells overall and is measurably faster; this crate leads on 8-bit lossless and a fair slice of SDR lossy, and is pure-Rust and embeddable. The honest, measured breakdown is in the benchmark index (and the scoreboard tables below on GitHub).

Where it stands vs cjxl (the honest version)

cjxl is the reference, and on a per-scenario basis it still wins more cells than we do today, and it is measurably faster. We track this with a strict per-cell Pareto scoreboard (docs/GOAL_BEAT_CJXL.md): a cell is "ours" only when we are no worse on bytes, no worse on perceptual quality, and within the wall budget. The numbers below are what that scoreboard reports — not aspirations.

Bytes + quality, 280 cells across SDR lossy, SDR lossless, HDR lossy, and a fixed-overhead size axis (measured 2026-06-12, binary 3f025244; cjxl = libjxl v0.12.0; reproduce with scripts/scoreboard/run_scoreboard.py, see benchmarks/README.md):

VerdictCellsShare
cjxl dominates10538 %
mixed (we win one axis, lose another)8932 %
we dominate7828 %
tie83 %

By content family (we-dominate / tie / mixed / cjxl-dominates), from benchmarks/scoreboard/scoreboard_2026-06-12_run4_summary.md:

FamilyWeTieMixedcjxl
SDR lossless304022
SDR lossy3304724
HDR lossy1013055
Size axis (64²/256²)53124

We lead on lossless graphics and photos at e7, and on a good chunk of SDR lossy content; HDR lossy is where cjxl wins most cells (small per-cell byte gaps at tied quality — the per-cell strictness counts every one). The smooth-gradient / HDR sky class is the historical loss locus and an open wedge.

Wall time is the weak axis, and there is no way to spin it. On a 40-cell quiet-box grid (5 strata × e{5,7} × {1,8} threads × {lossy, lossless}, measured 2026-06-12, binary a5a9e4d6, benchmarks/scoreboard/wall_grid_2026-06-12.tsv), 39 of 40 cells are over the ≤1.2× budget — cjxl is faster on all but one (plots lossy e7 1T, 1.12×):

Mode1 thread8 threads
lossy1.12–2.17× cjxl1.57–3.76× cjxl
lossless1.33–5.90× cjxl2.89–10.78× cjxl

Single-thread lossy is roughly competitive; everything else is slower, and the 8T gap is the largest. cjxl scales ~4.6–5.5× from 1T→8T while we scale ~1.8–2.5× — that's both a serial-speed gap and a parallel-coverage gap (our AC-tile search is parallel, but XYB / adaptive-quant / gaborish / transform / tokenize are still serial, and cjxl parallelizes per 256² group). Closing this is the active workstream.

Lossless, in more detail

8-bit lossless is where we are strongest. With the e5/e6 budgeted tree-learn lift (benchmarks/lossless_8bit_tree_lift_2026-06-12.tsv, 43 imazen-26 picks, djxl-verified pixel-exact on all 56 cells):

  • e5: vs cjxl mean −2.4 %, median −0.1 % bytes, 20/43 cells smaller (worst +36.9 % on a noaa-documents scan).
  • e6: vs cjxl mean −10.7 %, median −7.0 % bytes, 11/13 cells smaller.

16-bit lossless at low effort (e2/e4) still loses to cjxl on bytes, and the lossless wall gap is the widest of any mode (see the grid above).

HDR lossy, in more detail

On 12 PQ/HLG crops × e{5,7} × d{0.5,1,2,4} (benchmarks/hdr_lossy_parity_postdispatch_2026-06-12.tsv, PQ-EOTF butteraugli @ 1000 nits), median bytes run +1.2 % to +4.6 % over cjxl at quality at-or-better than cjxl on 7 of 8 measured points. The QuantizeWP DC-shaping dispatch (keyed on the resolved transfer function) closed roughly half the median HDR byte gap; the smooth-sky residual remains.

Quick start

[dependencies]
jxl-encoder = "0.3.1"
use jxl_encoder::{LossyConfig, LosslessConfig, PixelLayout};

// Lossy — distance 1.0 is visually lossless (lower distance = higher quality).
let jxl = LossyConfig::new(1.0)
    .encode(&pixels, width, height, PixelLayout::Rgb8)?;

// Lossless — exact reconstruction.
let jxl = LosslessConfig::new()
    .encode(&pixels, width, height, PixelLayout::Rgb8)?;

// Full control — per-knob overrides, then a request with limits / cancellation.
use jxl_encoder::Limits;
let jxl = LossyConfig::new(1.0)
    .with_ans(true)
    .with_gaborish(true)
    .encode_request(width, height, PixelLayout::Rgba8)
    .with_limits(&Limits::default())
    .encode(&pixels)?;

std is on by default; cargo add jxl-encoder pulls the latest release. MSRV is Rust 1.89.

Quality (distance) and effort

Distance is the butteraugli target passed to LossyConfig::new(distance). It is a perceptual error budget, so the scale runs opposite to a percent slider:

  • Lower distance = higher quality (and larger files).
  • Valid lossy range is 0.0 < distance <= 25.0.
  • 1.0 is the visually-lossless anchor — the libjxl default, indistinguishable from the source for most images. Go below 1.0 (e.g. 0.5) for near-transparent quality; raise it (2.0, 4.0, …) to trade quality for size.
  • 0.0 (mathematically lossless) is not accepted by LossyConfig — use LosslessConfig for exact reconstruction instead.

Effort trades encode time for compression. LossyConfig and LosslessConfig both default to effort 7; set it with with_effort(level):

use jxl_encoder::{LossyConfig, LosslessConfig, PixelLayout};

// Slower, smaller (effort 9 = Viterbi LZ77, 4 butteraugli iterations).
let jxl = LossyConfig::new(1.0)
    .with_effort(9)
    .encode(&pixels, width, height, PixelLayout::Rgb8)?;

// Fast preview.
let jxl = LosslessConfig::new()
    .with_effort(3)
    .encode(&pixels, width, height, PixelLayout::Rgb8)?;

Valid effort is 1..=12. 1..=9 mirrors libjxl's kFalcon..=kTortoise ladder; 10..=12 are this crate's extended search budgets (longer butteraugli / tree-learn seeds, still 100 %-spec-valid bitstreams). Higher effort = slower, better compression.

Cancellation

Encodes are cooperatively cancellable. Pass a stop token via EncodeRequest::with_stop(&dyn Stop) — the encoder checks it periodically and returns EncodeError::Cancelled if it fires. The Stop trait and the no-op Unstoppable token are re-exported from jxl_encoder (originally from the enough crate):

use jxl_encoder::{LossyConfig, PixelLayout, Unstoppable};

// No-op token — zero cost, never cancels (same as not passing one):
let jxl = LossyConfig::new(1.0)
    .encode_request(width, height, PixelLayout::Rgb8)
    .with_stop(&Unstoppable)
    .encode(&pixels)?;

For a token you can actually trigger (e.g. from another thread, a timeout, or a user "cancel" button), add almost-enough and use its Stopper — clone it to share, then call .cancel():

[dependencies]
almost-enough = "0.4.4"
use jxl_encoder::{LossyConfig, PixelLayout};
use almost_enough::Stopper;

let stop = Stopper::new();
let watcher = stop.clone();           // hand a clone to a watchdog / signal handler
// ... watcher.cancel() from elsewhere when the user aborts ...

let result = LossyConfig::new(1.0)
    .encode_request(width, height, PixelLayout::Rgb8)
    .with_stop(&stop)
    .encode(&pixels);
// If `cancel()` fired before the encode finished, `result` is `Err(e)` where
// `matches!(e.error(), EncodeError::Cancelled)` holds (see Errors below).

Errors

encode returns jxl_encoder::Result<Vec<u8>> = Result<Vec<u8>, whereat::At<EncodeError>>. The At<…> wrapper records a source location for logs (format!("{e}")); borrow the inner error with e.error() (or own it with e.decompose().0) to match. EncodeError is #[non_exhaustive], so keep a wildcard arm:

use jxl_encoder::{LossyConfig, EncodeError, PixelLayout};

match LossyConfig::new(1.0).encode(&pixels, width, height, PixelLayout::Rgb8) {
    Ok(_jxl) => { /* encoded bytes */ }
    Err(e) => match e.error() {
        EncodeError::Cancelled => { /* a Stop token requested cancellation */ }
        EncodeError::LimitExceeded { message } => eprintln!("limit: {message}"),
        EncodeError::Oom(_) => eprintln!("out of memory"),
        EncodeError::InvalidInput { message }
        | EncodeError::InvalidConfig { message } => eprintln!("bad input/config: {message}"),
        other => eprintln!("encode failed: {other:?}"),
    },
}

Pixel layouts

Integer and HDR/float layouts are accepted directly; the encoder converts to XYB (lossy) or the modular integer space (lossless) internally:

Rgb8, Rgba8, Bgr8, Bgra8, Gray8, GrayAlpha8, Rgb16, Rgba16, Gray16, GrayAlpha16, RgbLinearF32, RgbaLinearF32, GrayLinearF32, GrayAlphaLinearF32, RgbLinearF16, RgbaLinearF16, GrayLinearF16, GrayAlphaLinearF16, RgbPqF32, RgbaPqF32, RgbHlgF32, RgbaHlgF32, RgbBt709F32, RgbaBt709F32, Cmyk8, Cmyk16.

Lossy encoding supports all layouts including alpha (VarDCT for RGB + modular for the alpha channel). Lossless supports RGB, RGBA, grayscale, and gray+alpha. See HDR / wide-gamut for the PQ / HLG / BT.709 transfer-function variants.

Resource limits

EncodeRequest::with_limits(&Limits) bounds an encode against untrusted input. Limits primarily caps encoder working-set memory (it also exposes optional max_width / max_height / max_pixels / max_quant_loop_iters setters, all None by default):

use jxl_encoder::{LossyConfig, Limits, PixelLayout};

let limits = Limits::default()              // no explicit caps set …
    .with_max_memory_bytes(512 * 1024 * 1024); // … 512 MB hard ceiling

let jxl = LossyConfig::new(1.0)
    .encode_request(width, height, PixelLayout::Rgb8)
    .with_limits(&limits)
    .encode(&pixels)?;

Limits::default() sets no explicit memory bound, but the encoder still applies a soft default cap so an unconfigured image proxy can't be OOM'd: 4 GiB for lossy, 8 GiB for lossless (lossless tree-learning is a heavier memory regime). These defaults are fixed ceilings — they are deliberately not scaled with image dimensions, so an oversized untrusted upload is still bounded. For trusted batch work, raise the cap with with_max_memory_bytes(n) (or pass u64::MAX to opt out of the soft cap entirely). If an encode exceeds the cap it returns EncodeError::LimitExceeded. LossyConfig::estimate_peak_memory_bytes (and the LosslessConfig equivalent) let callers plan a budget up front.

HDR / wide-gamut

CapabilityEntry point
PQ / HLG / BT.709 f32 inputPass the matching PixelLayout variant (e.g. RgbPqF32, RgbHlgF32); the encoder inverts the transfer function before XYB.
BT.2100 PQ / HLG colour encodingColorEncoding::bt2100_pq() / bt2100_hlg(), via EncodeRequest::with_color_encoding(...).
intensity_target / min_nitsEncodeRequest::with_intensity_target(nits) / with_min_nits(nits).
HDR-aware perceptual loss in the quant loopLossyConfig::with_hdr_loss(HdrLoss::Auto) — auto-dispatches to a VDP2 path on PQ/HLG content, butteraugli elsewhere. SDR encodes stay byte-identical. Requires the butteraugli-loop feature.

Measured HDR bytes/quality vs cjxl are in the benchmark index.

Feature coverage vs libjxl

We implement all 19 AC strategies that libjxl evaluates through effort 9, all enabled. The remaining 8 are either commented out in libjxl (DCT32x8, DCT8x32) or experimental/unused (DCT128+) — cjxl never selects them either. Effort 9 adds fine-grained strategy search (step=1 for 32×32+ blocks).

Lossy (VarDCT)

Featurelibjxl e5libjxl e7jxl-encoder
AC strategies71919
ANS entropy coding (default-on)YesYesYes
Adaptive quantizationYesYesYes
Pixel-domain loss (default-on)YesYesYes
Chroma-from-luma (per-tile least-squares)YesYesYes
Gaborish inverse pre-filter (default-on)YesYesYes
Custom coefficient ordering (default-on)YesYesYes
Butteraugli quant loop (effort 8+)YesYesYes (2 iters at e8, 4 at e9+)
EPF per-block sharpnessYesYesYes
Content-adaptive block context mapYesYesYes
Error diffusion in AC quantizationNoNoYes (opt-in)
Noise synthesisYesYesYes (opt-in)
Lossy + alpha (VarDCT RGB + modular alpha)YesYesYes
JPEG transcode (byte-exact re-encode)YesYesYes (opt-in feature)
Animation (lossy + lossless)YesYesYes
16-bit / float inputYesYesYes (26 pixel layouts)
Patches / dictionary (default-on for screenshots)NoYesYes
Fine-grained AC strategy searchYesYesYes (effort 9+)
SplinesNoYesYes (opt-in API)
Dots detectionNoYesYes (opt-in)
Progressive VarDCT (2-pass / 3-pass)YesYesYes
Photon-noise simulationYesYesYes (with_photon_noise_iso)
Forced RCT colorspace (lossless)YesYesYes (with_force_rct)
Peak-memory estimate helperNoNoYes (`estimate_peak_memory_bytes$)

\text{Lossless} (\text{Modular})

\text{Feature}\text{libjxl}\text{jxl}-\text{encoder}
\text{RCT} (\text{all} 42 \text{variants})\text{Yes}\text{Yes}
\text{ANS} \text{entropy} \text{coding} (\text{default}-\text{on})\text{Yes}\text{Yes}
\text{Huffman} \text{entropy} \text{coding} (\text{fallback})\text{Yes}\text{Yes}
\text{LZ77} \text{RLE} / \text{greedy} / \text{optimal} \text{Viterbi} \text{DP}\text{Yes}\text{Yes} (\text{default}-\text{on} \text{at} \text{e7} / \text{e8} / \text{e9}+)
\text{MA} \text{tree} \text{learning} (14 \text{predictors}, 16 \text{properties})\text{Yes}\text{Yes}
\text{Weighted} \text{predictor}\text{Yes}\text{Yes} (\text{bit}-\text{exact} \text{match})
\text{Palette} \text{transform} (\text{auto}-\text{detect})\text{Yes}\text{Yes}
\text{Squeeze} \text{transform} (\text{Haar} \text{wavelet})\text{Yes}\text{Yes}
\text{Histogram} \text{clustering}\text{Full} (\text{kDefault})\text{Pair}-\text{merge} \text{refinement}
\text{Multi}-\text{group} \text{encoding} (\text{any} \text{image} \text{size})\text{Yes}\text{Yes}
\text{RGBA} / \text{grayscale} / \text{alpha}\text{Yes}\text{Yes}
\text{Lossy} \text{palette} / \text{delta} \text{palette}\text{Yes}\text{Yes} (\text{opt}-\text{in})
16-\text{bit} / \text{float} \text{input}\text{Yes}\text{Yes}
\text{Best}/\text{Variable} \text{predictors} (\text{effort} 8+)\text{Yes}\text{Tree} \text{learning} \text{is} \text{the} \text{Variable}-\text{mode} \text{equivalent}

\text{Container} / \text{metadata}

\text{Feature}\text{libjxl}\text{jxl}-\text{encoder}
\text{ICC} \text{profile} \text{embedding} (\text{PredictICC} + \text{entropy} \text{coded})\text{Yes}\text{Yes}
\text{EXIF} / \text{XMP} \text{metadata} (\text{container} \text{box})\text{Yes}\text{Yes}
\text{Animation} (\text{per}-\text{frame} \text{duration})\text{Yes}\text{Yes}
\text{Multi}-\text{group} \text{framing} (>256 \times 256)\text{Yes}\text{Yes}
\text{Cancellation} / \text{resource} \text{limits}\text{No}\text{Yes} ($&dyn Stop, Limits`)

Honest gaps

FeaturelibjxlNotes
Streaming frame encodingYesCurrent impl buffers the full image; estimate_peak_memory_bytes lets callers plan around this.
ec_distance (per-extra-channel quality)YesLossy alpha is currently encoded as a lossless modular extra channel.
decoding_speed_tierYesWe expose individual gating knobs that approximate the major effects.
Wall-time paritycjxl is faster on 39/40 measured cells; see the scoreboard on GitHub.

AC strategy coverage

StrategyBlockMin distancelibjxl effort
DCT88×8anye1+
DCT4x48×8 (4 sub)anye5+
DCT4x8, DCT8x48×8 (2 sub)anye6+
IDENTITY8×8 (pixel domain)anye5+
DCT2x28×8 (4 sub)anye5+
AFV0-38×8 (corner DCT)anye6+
DCT16x8, DCT8x1616×8anye5+
DCT16x1616×16anye5+
DCT32x16, DCT16x3232×16d ≥ 2.0e6+
DCT32x3232×32d ≥ 2.0e7+
DCT64x32, DCT32x6464×32d ≥ 3.0e7+
DCT64x6464×64d ≥ 3.0e7+

CLI

A command-line wrapper, cjxl-rs, ships in the jxl-encoder-cli crate:

cargo install jxl-encoder-cli

# Lossy (distance 1.0 = visually lossless)
cjxl-rs input.png output.jxl -d 1.0

# Lossless
cjxl-rs input.png output.jxl --lossless

# See all options
cjxl-rs --help

Experimental: CVVDP-driven quantization loop (opt-in)

The quant loop at effort ≥ 8 normally calls butteraugli once per iteration. An opt-in path drives it with ColorVideoVDP (cvvdp, Mantiuk et al. 2024) instead. Default OFF; butteraugli stays the production default, and EncoderStrategy::Libjxl forces cvvdp off regardless so cjxl-parity byte-locks hold.

cargo build --release --features cvvdp-loop       # GPU (needs CUDA)
cargo build --release --features cvvdp-loop-cpu    # pure-Rust CPU

It is opt-in because cvvdp ships an uncalibrated per-distance target table: at the same distance it converges to a tighter perceptual target than the distance knob currently implies, producing larger files. The full 1,134-cell tracking sweep is at benchmarks/cvvdp_vs_buttloop_tracking_2026-05-24.tsv; methodology and the ship-rule are in docs/CVVDP_FORK_DECISION.md. zensim is also available as a third quant-loop metric (zensim-loop / zensim-loop-gpu).

Reproducible benchmarks

Every comparison number above traces to a committed file under benchmarks/. benchmarks/README.md is the reproduction index: the exact command for each board, what corpus it uses, what it measures, and the quiet-box caveat for wall numbers. Bytes and quality are deterministic; wall-time numbers are only disposition-grade on a quiet box — the wall harness refuses to run under load for that reason.

Project structure

jxl-encoder/                    # workspace root (this repo)
├── jxl-encoder/               # the jxl-encoder library crate
│   └── src/
│       ├── api.rs                 # public API (LossyConfig, LosslessConfig, EncodeRequest)
│       ├── vardct/                # VarDCT (lossy) encoder
│       ├── modular/               # Modular (lossless) encoder
│       ├── entropy_coding/        # ANS, Huffman, HybridUint, LZ77
│       └── headers/               # file / frame headers
├── jxl-encoder-simd/          # SIMD primitives (jxl-encoder-simd on crates.io)
├── jxl-encoder-macros/        # internal proc-macros (jxl-encoder-macros on crates.io)
└── jxl-encoder-cli/           # CLI tool: cjxl-rs (jxl-encoder-cli on crates.io)

Building

cargo build                                # debug
cargo build --release -p jxl-encoder-cli   # release CLI
cargo test --workspace --lib --tests       # all tests
cargo clippy --workspace -- -D warnings    # lint

When to reach for cjxl instead

  • When encode speed matters more than the last few percent of bytes. cjxl is faster on nearly every measured cell, especially multi-threaded.
  • When you need streaming / bounded-memory encode of very large images — not yet implemented here; cjxl streams.
  • For HDR / smooth-gradient lossy at the byte minimum — cjxl wins most of those cells today.
  • This crate is pure-Rust, forbid(unsafe_code), embeddable with no C FFI, and ahead on 8-bit lossless and a fair slice of SDR lossy — reach for it when those matter.

Credits

  • libjxl (JPEG XL Project Authors, BSD-3-Clause) — the reference encoder and a well-engineered, battle-tested codebase. Our algorithms, quantization weights, cost models, and bitstream format are derived from it. libjxl-tiny was the initial porting target.
  • zune-jpegxl (Caleb Etemesi, MIT/Apache-2.0/Zlib) — a working pure-Rust JXL lossless encoder (~2.5k lines) that was the inspiration to extend into lossy encoding and the features above.
  • jxl-rs (BSD-3-Clause) — primary roundtrip validation decoder.
  • jxl-oxide — secondary validation decoder.
  • Claude (Anthropic) — AI-assisted development. Not all code has been manually reviewed; review critical paths before production use.

License

Dual-licensed: AGPL-3.0-or-later or commercial.

I've maintained open-source image 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.

Your options:

  • Startup license — $1 if your company has under $1M revenue and fewer than 5 employees. Get a key →
  • Commercial subscription — 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 are dual-licensed (AGPL-3.0-or-later or commercial) as above.

Image tech I maintain

Codecs ¹zenjpeg · zenpng · zenwebp · zengif · zenavif · zenjxl · zenbitmaps · heic · zentiff · zenpdf · zensvg · zenjp2 · zenraw · ultrahdr
Codec internalszenjxl-decoder · jxl-encoder · zenrav1e · rav1d-safe · zenavif-parse · zenavif-serialize
Compressionzenflate · zenzop · zenzstd
Processingzenresize · zenquant · zenblend · zenfilters · zensally · zentone
Pixels & colorzenpixels · zenpixels-convert · linear-srgb · garb
Pipeline & frameworkzenpipe · zencodec · zencodecs · zenlayout · zennode · zenwasm · zentract
Metricszensim · fast-ssim2 · butteraugli · zenmetrics · resamplescope-rs
Pickers & MLzenanalyze · zenpredict · zenpicker
ProductsImageflow image engine (.NET · Node · Go) · Imageflow Server · ImageResizer (C#)

¹ pure-Rust, #![forbid(unsafe_code)] codecs, as of 2026

General Rust awesomeness

zenbench · archmage · magetypes · enough · whereat · cargo-copter

Open source · @imazen · @lilith · lib.rs/~lilith