zengif [](https://github.com/imazen/zengif/actions/workflows/ci.yml) [](https://crates.io/crates/zengif) [](https://lib.rs/crates/zengif) [](https://docs.rs/zengif) [](#license) [](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field) [](https://codecov.io/gh/imazen/zengif)

July 6, 2026 · View on GitHub

zengif is a GIF codec built for servers: zero-trust decoding of untrusted uploads, bounded and tracked memory, cooperative cancellation, frame-by-frame streaming, and complete animation support (every disposal method, transparency, timing, and loop count). Pure Rust, #![forbid(unsafe_code)], no_std-compatible (core types without std), built on the well-maintained gif crate.

Licensing note: The default features include zenquant (AGPL-3.0-or-later). A plain cargo add zengif pulls in AGPL-licensed code. For MIT/Apache-2.0-only licensing, use default-features = false and select a permissive quantizer (e.g., quantette, quantizr, or color_quant). See Quantizer options.

Quick start

[dependencies]
zengif = "0.7"

Decode a GIF

use zengif::{decode_gif, Limits, Unstoppable};

fn main() -> zengif::Result<()> {
    let bytes = std::fs::read("animation.gif")?;

    // One-shot, in-memory decode. Every frame is composited to a full-canvas
    // RGBA buffer; Limits::default() applies server-safe ceilings (see below).
    let (meta, frames, _stats) = decode_gif(&bytes, Limits::default(), &Unstoppable)?;

    println!("{}x{}, {} frames", meta.width, meta.height, frames.len());
    for frame in &frames {
        // frame.pixels: Vec<Rgba> — full canvas, disposal + transparency applied
        // frame.delay:  u16       — frame delay in centiseconds (1/100 s)
    }
    Ok(())
}

For large or untrusted streams, decode frame-by-frame instead of materializing the whole VecDecoder::new takes any std::io::Read:

use zengif::{Decoder, Limits, Unstoppable};

fn main() -> zengif::Result<()> {
    let file = std::io::BufReader::new(std::fs::File::open("animation.gif")?);
    let mut decoder = Decoder::new(file, Limits::default(), &Unstoppable)?;

    while let Some(frame) = decoder.next_frame()? {
        // frame.index, frame.delay, frame.pixels (full-canvas Vec<Rgba>)
    }
    println!("peak buffer usage: {} bytes", decoder.stats().peak());
    Ok(())
}

Encode a GIF

use zengif::{EncodeRequest, EncoderConfig, FrameInput, Limits, Repeat, Rgba, Unstoppable};

fn main() -> zengif::Result<()> {
    let (width, height) = (100, 100);

    // Three solid-color frames.
    let red:   Vec<Rgba> = vec![Rgba::rgb(255, 0, 0); width as usize * height as usize];
    let green: Vec<Rgba> = vec![Rgba::rgb(0, 255, 0); width as usize * height as usize];
    let blue:  Vec<Rgba> = vec![Rgba::rgb(0, 0, 255); width as usize * height as usize];

    let config = EncoderConfig::new().repeat(Repeat::Infinite);
    let limits = Limits::default();

    let mut encoder = EncodeRequest::new(&config, width, height)
        .limits(&limits)
        .stop(&Unstoppable)
        .build()?;

    encoder.add_frame(FrameInput::new(width, height, 50, red))?;   // 500ms
    encoder.add_frame(FrameInput::new(width, height, 50, green))?; // 500ms
    encoder.add_frame(FrameInput::new(width, height, 50, blue))?;  // 500ms

    let output: Vec<u8> = encoder.finish()?;
    std::fs::write("output.gif", &output)?;
    Ok(())
}

The three layers — EncoderConfig (knobs) → EncodeRequest<'a> (dimensions + limits + cancellation) → Encoder<'a> (streaming add_frame/finish) — keep the common path short while exposing every knob on the config.

Decode → re-encode (transcode)

A server that re-compresses uploaded GIFs decodes to composited frames, optionally processes them, then re-encodes. The decoder hands you full-canvas RGBA frames and the loop count; you feed those straight back into the encoder. Loop count and per-frame timing carry across the round-trip by reading metadata.repeat and each frame's delay:

use zengif::{
    decode_gif, EncodeRequest, EncoderConfig, FrameInput, Limits, Unstoppable,
};

fn transcode(input: &[u8]) -> zengif::Result<Vec<u8>> {
    // Build one Limits posture and reuse it (Limits is Clone).
    let limits = Limits::default().max_dimensions(4096, 4096);

    // 1. Decode. `decode_gif` reads every frame, so `meta.repeat` (the loop
    //    count, parsed from the NETSCAPE extension during iteration) is final.
    let (meta, frames, _stats) = decode_gif(input, limits.clone(), &Unstoppable)?;

    // 2. Carry the source loop count into the encoder config.
    //    meta.repeat is a `Repeat` (Once | Infinite | Count(n)) — pass it directly.
    //    `.for_round_trip()` zeroes dithering + shares one palette to minimise bloat
    //    when re-encoding already-quantized content. (It needs a quantizer feature,
    //    which the default build has; drop it if you built `--no-default-features`.)
    let config = EncoderConfig::new()
        .repeat(meta.repeat)
        .for_round_trip();

    let mut encoder = EncodeRequest::new(&config, meta.width, meta.height)
        .limits(&limits)
        .stop(&Unstoppable)
        .build()?;

    // 3. Re-encode. Each `ComposedFrame` is already the full canvas size, so it
    //    maps 1:1 onto a full-canvas `FrameInput`. `frame.delay` (centiseconds)
    //    carries the original timing; the encoder recomputes frame differencing
    //    and offsets internally — you always supply whole-canvas frames.
    for frame in frames {
        encoder.add_frame(FrameInput::new(
            frame.width,   // == meta.width  (canvas dims)
            frame.height,  // == meta.height
            frame.delay,   // centiseconds, preserved from source
            frame.pixels,  // Vec<Rgba>, full-canvas composited
        ))?;
    }

    encoder.finish()
}

Key points for round-tripping correctly:

  • Feed composited (full-canvas) frames back, not sub-frames. ComposedFrame is the result after disposal + transparency are applied, so its pixels is always width * height for the full canvas. There is no offset field — and you don't need one. The encoder derives per-frame dirty rectangles and offsets itself from successive full-canvas frames. (If you only need the bytes, frame.as_bytes() gives a zero-copy &[u8] RGBA view.)
  • Loop count. Read it from meta.repeat after decode and pass it to EncoderConfig::repeat(..). In the streaming Decoder path the NETSCAPE loop extension is parsed during frame iteration, so decoder.metadata().repeat (and decoder.repeat()) is only final after you've read the frames; decode_gif reads them all for you, so its returned meta.repeat is already correct.
  • Timing. Each ComposedFrame.delay is in centiseconds (1/100 s); FrameInput.delay uses the same unit, so timing is preserved exactly when you copy the field across.
  • For large/streaming inputs, swap decode_gif for Decoder::new(reader, limits, &stop) and pull frames with next_frame() instead of materializing the whole Vec. Read the loop count after the iteration completes.

ComposedFrame fields

decoder.next_frame() / decode_gif yield ComposedFrame:

FieldTypeMeaning
indexusize0-based frame index
widthu16Canvas width (not a sub-frame width)
heightu16Canvas height
delayu16Frame delay in centiseconds (1/100 s)
pixelsVec<Rgba>Full-canvas composited RGBA, length width * height
paletteOption<Palette>Effective palette (local if present, else global) — handy for pass-through re-encoding

Because pixels is full-canvas (disposal + transparency already applied), you size a FrameInput with the same width/height and never deal with frame offsets on the re-encode side.

What zengif adds over gif

zengif wraps the well-maintained gif crate and layers on the pieces a service handling untrusted GIF uploads needs:

  • Frame compositing built in — every decoded frame arrives as a full-canvas RGBA buffer with disposal methods and 1-bit transparency already applied. (The gif crate exposes raw sub-frames; gif-dispose is the usual companion for compositing — zengif folds that step in.)
  • Bounded, tracked memory — header-first validation rejects oversized inputs before allocation, every large allocation is fallible, and usage is counted against a configurable ceiling and exposed via Stats.
  • Cooperative cancellation — stop a decode or encode mid-stream when a client disconnects, via the enough Stop trait.
  • Error tracing — every error carries its file:line capture site (via whereat) for structured production logs.
  • Quantized encoding — pluggable palette quantizers with frame differencing and shared-palette modes for small animated output, plus an automatic byte-exact fast path for grayscale content.

Memory protection

Limits::default() is bomb-protected, not unbounded. zengif's Limits::default() already enforces server-safe ceilings, so the quick-start examples above are guarded out of the box — you do not have to opt in to protection. The defaults are:

Limit`Limits::default()$ \text{value}
\text{Max} \text{dimensions}16384 \times 16384
\text{Max} \text{total} \text{pixels}120 \text{megapixels}
\text{Max} \text{frame} \text{count}10{,}000
\text{Max} \text{file} \text{size}100 \text{MB}
\text{Max} \text{memory}1 \text{GB}
\text{Max} \text{decompression} \text{ratio} (\text{zip}-\text{bomb} \text{guard})1000 \times
\text{Max} \text{animation} \text{duration}\text{unbounded} ($None`)
Max output bytesunbounded (None)

For an untrusted-GIF proxy you almost certainly want tighter caps than the defaults. Start from Limits::default() and clamp down — every setter is a #[must_use] chainable builder:

use zengif::Limits;

let limits = Limits::default()
    .max_dimensions(4096, 4096)       // Reject huge canvases
    .max_frame_count(1000)            // Limit animation length
    .max_memory(256 * 1024 * 1024)    // 256 MB peak memory
    .max_animation_ms(30_000);        // Reject >30s animations (off by default)

The decoder rejects oversized dimensions from the header, before allocating (via pre_validate_header), and enforces the memory/frame-count/decompression-ratio caps as each frame is read.

Limits::none() opts out of all bounds — only for trusted inputs. Never hand Limits::none() to data you didn't produce.

Limits is #[derive(Clone)] (and Debug, #[non_exhaustive]), so you can build one posture once and reuse it for both decode and encode:

use zengif::Limits;

let limits = Limits::default().max_dimensions(4096, 4096);
let decode_limits = limits.clone();   // for Decoder::new / decode_gif
let encode_limits = limits;           // for EncodeRequest::limits

Cancellation

For web servers, you often need to stop processing if the client disconnects:

// `almost-enough` provides a thread-safe Stopper (add it separately: cargo add almost-enough)
use almost_enough::Stopper;
use zengif::{Decoder, Limits};

let stop = Stopper::new();
let stop_for_handler = stop.clone();

// In your request handler, if client disconnects:
stop_for_handler.cancel();

// The decoder will return GifError::Cancelled at the next check point
let mut decoder = Decoder::new(reader, Limits::default(), &stop)?;

Any type implementing enough::Stop works here. zengif re-exports Unstoppable for cases where cancellation isn't needed.

Error diagnostics

When something goes wrong, you get the full story:

Error: InvalidFrameBounds { frame_left: 0, frame_top: 0, frame_width: 5000,
                            frame_height: 5000, canvas_width: 100, canvas_height: 100 }
   at src/decode/frame.rs:142:9
      ╰─ validating frame 3
   at src/decode/mod.rs:89:5
      ╰─ in decode_frame

To branch on the failure in code, borrow the inner error with e.error() and read the capture site with e.location() (GifError is #[non_exhaustive], so keep a wildcard arm):

use zengif::{decode_gif, GifError, Limits, Unstoppable};

// `decode_gif` is the one-shot for in-memory `&[u8]`; the streaming `Decoder::new`
// above takes any `std::io::Read` (wrap a slice with `std::io::Cursor::new(bytes)`).
match decode_gif(gif_bytes, Limits::default(), &Unstoppable) {
    Ok((meta, frames, _stats)) => { /* meta.width, meta.height, frames: Vec<ComposedFrame> */ }
    Err(e) => {
        if let Some(loc) = e.location() {       // whereat capture site (file:line)
            eprintln!("gif decode failed at {}:{}", loc.file(), loc.line());
        }
        match e.error() {
            GifError::Cancelled(_) => { /* a Stop token fired — HTTP 499 */ }
            GifError::DimensionsTooLarge { .. }
            | GifError::TotalPixelsTooLarge { .. }
            | GifError::FileTooLarge { .. }
            | GifError::MemoryLimitExceeded { .. }
            | GifError::DecompressionRatioExceeded { .. }
            | GifError::TooManyFrames { .. } => { /* resource limit — HTTP 413 */ }
            other => eprintln!("malformed GIF: {other:?}"), // HTTP 400
        }
    }
}

Encoding and quantizers

With default features, zenquant is enabled and selected automatically:

use zengif::{EncoderConfig, Quantizer};

let config = EncoderConfig::new()
    .quantizer(Quantizer::auto());  // Picks best available (zenquant by default)

To use a specific quantizer, enable its feature and select it explicitly:

cargo add zengif --no-default-features --features std,imagequant
let config = EncoderConfig::new()
    .quantizer(Quantizer::imagequant());

When every opaque pixel of a frame is gray (R == G == B — document scans, plots, line art), the encoder takes an automatic, byte-exact fast path: it builds the exact 8-bit gray palette directly and skips the general histogram + k-means search. It engages only at lossless intent (quality == 100 / with_lossless(true)), is content-detected with one early-exiting scan, and never costs bytes — color frames fall straight through to the configured quantizer. See the grayscale rate/distortion analysis.

Quantizer options

Auto-selection priority (top to bottom):

FeatureLicenseQualitySpeedNotes
zenquant (default)AGPL-3.0Best perceptualMediumButteraugli/SSIMULACRA2 metrics
quantetteMIT/Apache-2.0Very goodFastOklab k-means
imagequantGPL-3.0*Good, smallest filesMediumCompressible dithering patterns
quantizrMITGoodFast
color_quantMITAcceptableFastestGood for high-throughput

*imagequant is GPL-3.0-or-later. Commercial license available from upstream.

Default features include AGPL code. cargo add zengif enables zenquant, which is AGPL-3.0-or-later. For permissive-only licensing, disable default features and pick a quantizer:

zengif = { version = "0.7", default-features = false, features = ["std", "quantette"] }

Without any quantizer feature, zengif is MIT/Apache-2.0 but encoding requires pre-indexed frames.

no_std / WASM

For WASM or embedded, disable the default std feature:

zengif = { version = "0.7", default-features = false }

You get core types (Rgba, Limits, GifError, etc.) but not the codec (decode/encode need std::io). Useful when you need to share types between WASM and native code. The core types are verified to compile for wasm32-unknown-unknown.

Benchmarks

Benchmark sources live in benches/ (codec.rs, decode_bench.rs); committed results and their provenance are under benchmarks/ — see benchmarks/README.md for the index and reproduction steps. All runs use runtime SIMD dispatch (no -C target-cpu=native).

Measured, committed findings:

  • Grayscale fast path (grayscale_rd_2026-06-13.md) — on the 28 strictly-grayscale images of the imazen-26 corpus, the gray fast path is byte-for-byte identical to the best lossless backend on all 28 and ~8.5× faster (mean) than the zenquant baseline, with byte-exact round-trips. It already sits on the lossless optimum (LZW size is invariant to palette order), so there is no lossless byte left to recover.
  • Encode peak memory (zengif_encode_mem_2026-06-23.tsv) — single-frame VmHWM scales as roughly 1.6 MB + 41.5 B/px (zenquant / imagequant resource profile), the model the heuristics resource estimator is calibrated against.

Reproduce the microbenchmarks:

git clone https://github.com/imazen/zengif && cd zengif
cargo bench --bench codec          # encode/decode groups (zenbench, criterion-compat)
cargo bench --bench decode_bench   # decode-focused groups

AI-generated code notice

Developed with Claude (Anthropic). Not all code has been manually reviewed. Review critical paths before production use.

License

zengif itself is MIT OR Apache-2.0, at your option.

Default features pull in AGPL code. The zenquant quantizer (enabled by default) is AGPL-3.0-or-later. The imagequant quantizer is GPL-3.0-or-later (commercial license available from upstream). For fully permissive licensing, disable defaults and use quantette, quantizr, or color_quant:

zengif = { version = "0.7", default-features = false, features = ["std", "quantette"] }

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