Skip to content
imazenPublic

About

Shared traits and types for zen* image codecs

Resources

Stars

3 stars

Watchers

1 watching

Forks

Repository files navigation

zencodec CI crates.io lib.rs docs.rs MSRV license

zencodec is the shared trait crate that defines the common API for all zen* image codecs.

zencodec contains no pixel encoding or decoding logic — that lives in the individual codec crates. It does include shared metadata parsing needed for nearly every image format: pixel-descriptor derivation from CICP/ICC metadata (with identification delegated to zenpixels::icc, covering 163 RGB + 18 grayscale web-corpus profiles), EXIF orientation extraction, ISO 21496-1 gain map parsing and serialization, and format detection via magic bytes. no_std compatible (requires alloc), forbid(unsafe_code).

Import as zencodec — zencodec::encode, zencodec::decode, and the shared types (metadata, limits, format detection, color emission) at the root.

Visual guide — consumer, adapter, and implementor journeys, with tested examples and an interactive transport lab. Guide source and checks.

Quick start

This checkout prepares the unpublished 0.2 API. The examples below use that API, including Pulse. Use the repository's pinned example workspaces while the paired codec and zenpixels releases are being qualified; their root manifests keep one copy of the shared types. For the published API, see the 0.1.27 documentation.

Current generated reference and code-first examples show the repository signatures. Release gates distinguish qualified paths from remaining integration work.

zencodec defines the traits; a concrete codec crate (here zenjpeg) supplies the pixels. The three-layer Config → Job → Encoder/Decoder shape is identical across every zen* codec, so swapping zenjpeg for zenwebp, zengif, zenavif, … changes only the config type:

use std::borrow::Cow;
use zenjpeg::{JpegEncoderConfig, JpegDecoderConfig};
use zencodec::encode::{EncoderConfig, EncodeJob, Encoder};
use zencodec::decode::{DecoderConfig, DecodeJob, Decode};

// Encode. `with_generic_quality` is the codec-agnostic knob on a calibrated
// 0.0..=100.0 scale (NOT 0.0..=1.0); higher is better. Read it back with
// `generic_quality()` — `None` means the codec has no quality dial.
let config = JpegEncoderConfig::new().with_generic_quality(85.0);
// (assuming pixels: PixelSlice from your pipeline)
let output = config.job().encoder()?.encode(pixels.as_slice())?;
let jpeg_bytes = output.into_vec();

// Decode
let config = JpegDecoderConfig::new();
let decoded = config.job().decoder(Cow::Borrowed(&jpeg_bytes), &[])?.decode()?;
let pixels = decoded.into_buffer();

For untrusted input, attach a resource limit and a cancellation token to the job — see Untrusted input below.

One-shots

When default job settings are fine, each config trait ships one-shot conveniences — EncoderConfig::encode, DecoderConfig::decode / probe:

let jpeg = JpegEncoderConfig::new().with_generic_quality(85.0).encode(pixels)?;
let info = JpegDecoderConfig::new().probe(jpeg.data())?;   // header only
let image = JpegDecoderConfig::new().decode(jpeg.data())?; // full decode

use zencodec::prelude::*; brings every encode/decode trait into scope in one line.

Multi-codec: CodecSet

To handle several formats behind one handle, register configs in a CodecSet: each decoder announces its own formats, magic-byte detection derives from what's registered (in the curated priority order — AVIF before HEIC, DNG before TIFF), and encoding is keyed by ImageFormat. The set is Send + Sync with &self operations, so build it once — in a LazyLock/OnceLock static or an Arc — and share it app-wide:

use std::sync::LazyLock;
use zencodec::{CodecSet, ImageFormat, ResourceLimits};

static CODECS: LazyLock<CodecSet> = LazyLock::new(|| {
    CodecSet::new()
        .with_limits(ResourceLimits::default())
        .with_decoder(zenjpeg::JpegDecoderConfig::new())
        .with_decoder(zenpng::PngDecoderConfig::new())
        .with_encoder(zenjpeg::JpegEncoderConfig::new().with_generic_quality(85.0))
});

let image = CODECS.decode(&bytes)?;                            // detect → decode
let jpeg  = CODECS.encode(ImageFormat::Jpeg, image.pixels())?; // format-keyed

Limits, stop tokens, and policies set on the set are stamped onto every job it creates; decode_job / encode_job expose the underlying job for per-operation control (decode hints, metadata, animation). Per-call quality goes through encode_with(format, Fidelity, pixels), which clones the registered template.

Crates in the zen* family

Crate Format Repo
zenjpeg JPEG imazen/zenjpeg
zenwebp WebP imazen/zenwebp
zenpng PNG imazen/zenpng
zengif GIF imazen/zengif
zenavif AVIF imazen/zenavif
zenjxl JPEG XL imazen/zenjxl
zenbitmaps PNM/BMP/Farbfeld imazen/zenbitmaps
heic HEIC/HEIF imazen/heic
zentiff TIFF (experimental) imazen/zentiff
zenpdf PDF (experimental) imazen/zenpdf

Architecture

Every codec follows a three-layer pattern:

Config     →  reusable, Clone + Send + Sync, 'static — consumed by job()
Job        →  per-operation, owns config + stop token + limits + metadata
Executor   →  borrows pixel data or file bytes, consumes self to produce output
ENCODE:  EncoderConfig → EncodeJob → Encoder / AnimationFrameEncoder
DECODE:  DecoderConfig → DecodeJob<'a> → Decode / StreamingDecode / AnimationFrameDecoder

Config lives in a struct and gets shared across threads. A web server keeps one JpegEncoderConfig at quality 85 for all requests and clones it per-request. Calling job() consumes the config — clone first if you need it again. Job owns its config, cancellation token, resource limits, and metadata. Executor borrows pixels or bytes and consumes itself to produce output.

Each layer also has object-safe Dyn* variants for codec-agnostic dispatch:

DynEncoderConfig → DynEncodeJob → DynEncoder / DynAnimationFrameEncoder
DynDecoderConfig → DynDecodeJob → DynDecoder / DynStreamingDecoder / DynAnimationFrameDecoder

Blanket impls generate the dyn API automatically — codec authors implement the generic traits and get dyn dispatch for free.

Untrusted input: limits, cancellation, errors

Attach ResourceLimits and a SharedPulse to the job with .with_limits(limits).with_pulse(pulse). The job retains its operation controls; the shared codec configuration stays unchanged. Pulse carries cancellation, progress and phase planning. Calls that only borrow the control accept Option<&dyn Pulse>: pass None unless you want additional per-call control. None never disables a job’s retained cancellation or mandatory limits. Jobs that retain controls use SharedPulse. The old with_stop method is a deprecated cancellation-only adapter.

Constructing ResourceLimits

ResourceLimits is a plain struct with pub Option<_> fields — build it three ways:

use zencodec::ResourceLimits;

// 1. Server preset. `for_untrusted_input()` fills generous-but-bounded caps:
//    max_pixels 120 MP/frame, max_total_pixels 200 MP (all frames),
//    max_width/max_height 16384 each, max_memory_bytes 1 GiB,
//    max_input_bytes 256 MiB, max_frames 65 536, max_animation_ms 1 hour.
//    (`Default`/`none()` is the OPPOSITE — every field `None`, i.e. UNLIMITED;
//    use that only for trusted input.) Tighten any field with a `with_*` builder:
let limits = ResourceLimits::for_untrusted_input()
    .with_max_pixels(4_000_000)          // 4 MP cap for a thumbnail service
    .with_max_memory(256 * 1024 * 1024); // 256 MiB

// 2. From scratch off the unlimited default, set only what you need:
let limits = ResourceLimits::none()
    .with_max_pixels(16_000_000)         // pixels = width × height (per frame)
    .with_max_input_bytes(8 * 1024 * 1024); // bytes of encoded input (decode)

// 3. Direct field set (every field is public):
let mut limits = ResourceLimits::default();
limits.max_width = Some(8192);           // pixels
limits.max_height = Some(8192);          // pixels

Units: max_pixels / max_total_pixels are pixel counts (width × height, the latter ×frame_count); max_width / max_height are pixels; max_memory_bytes, max_input_bytes, max_output_bytes are bytes; max_frames a count; max_animation_ms milliseconds. A None field means that dimension is unchecked.

ResourceLimits also carries an allocation-fallibility preference, prefer_fallible_allocations (an AllocPreference: CodecDefault / Fallible / Infallible). CodecDefault (the default) lets each codec choose — decoders favour the fallible try_reserve path on untrusted input, encoders the faster infallible vec!; for_untrusted_input() presets Fallible. Override with .with_prefer_fallible_allocations(AllocPreference::Fallible).

Start with cancellation, then add progress

An existing cancellation source can participate through StopOnly. This example uses almost-enough; keep the stopper to signal cancellation and give the job the owned pulse. A reporting pulse can later replace this adapter without changing the job call.

use zencodec::{SharedPulse, StopToken, how_far::StopOnly};

let stopper = almost_enough::Stopper::new();
let pulse = SharedPulse::new(StopOnly::new(StopToken::new(stopper.clone())));

// Fire from a deadline / client-disconnect watcher — `cancel()` signals every clone:
std::thread::spawn({
    let stopper = stopper.clone();
    move || { /* on timeout or disconnect: */ stopper.cancel(); }
});
// Use SharedPulse::default() for an owned no-op, or &zencodec::NoPulse for a call.

End-to-end: decode with limits and Pulse

.with_limits() and .with_pulse() chain onto the job before creating the decoder. A header probe lets the caller reject reported dimensions early; it does not establish complete metadata or a whole-operation memory bound. Attach the same controls to the probe and the decode:

use std::borrow::Cow;
use zencodec::{ResourceLimits, SharedPulse, StopToken, how_far::StopOnly};
use zencodec::decode::{DecoderConfig, DecodeJob, Decode};

let limits = ResourceLimits::for_untrusted_input().with_max_memory(256 * 1024 * 1024);
let stopper = almost_enough::Stopper::new();
let pulse = SharedPulse::new(StopOnly::new(StopToken::new(stopper.clone())));

// 1. Reject reported dimensions before requesting decoded pixels.
let info = config.clone().job()
    .with_limits(limits)
    .with_pulse(pulse.clone())
    .probe(bytes)?;
limits.check_image_info(&info)?; // -> Err(LimitExceeded) if too big

// 2. Attach BOTH to the job, then decode. Order is free; `decoder()` consumes the job.
let decoded = config.job()
    .with_limits(limits)
    .with_pulse(pulse)
    .decoder(Cow::Borrowed(bytes), &[])? // &[] = native pixel format
    .decode()?;
let pixels = decoded.into_buffer();

Cancellation remains cooperative: check the selected codec's qualified behavior and checkpoint granularity. Pulse retention alone does not establish progress reporting or a deadline bound. check_image_info checks reported facts, and max_memory_bytes requires codec enforcement; neither bounds arbitrary native, source or caller allocations. Checked operation policies refuse an unknown workspace bound when a hard bound is required. See the Pulse migration and row-driver admission.

Classifying a codec's error for an HTTP response

A codec surfaces errors one of two ways; both let you route on a coarse ErrorCategory instead of naming the concrete enum:

  • Native enum + CategorizedError. The codec keeps its own error type and maps every variant to one category. e.category() classifies it on the typed path, and a located whereat::At<E> keeps the inner error's category (the impl forwards through At).
  • The shared CodecError envelope. The codec sets type Error = whereat::At<CodecError>. Because that is a single concrete type, the category — and the originating codec name ("zenjpeg", so you can tell codecs apart) — survive type erasure: after dyn dispatch hands back a Box<dyn Error> — or you map into anyhow — e.error_category() / e.codec_error() recover them by downcast (None only when the error isn't from a zen codec). A native enum can't be recovered once erased — the erased value is a dyn Error, not a dyn CategorizedError — so the envelope is what a codec-agnostic pipeline reaches for. CodecError can also be used with no detail at all (CodecError::new(Some("zenpng"), ErrorCategory::Image(ImageError::Malformed))) by a codec that has no error enum of its own. At is re-exported (zencodec::At<zencodec::CodecError>), so adopting it needs no direct whereat dependency.
use zencodec::enough::StopReason;
use zencodec::{
    CategorizedError, CodecErrorExt, ErrorCategory, ImageError, InvalidKind, RequestError,
    ResourceError,
};

// Typed path shown here via `category()`; on a `Box<dyn Error>` from dyn dispatch
// use `e.error_category()` and match `Some(..)` / `None` instead. The origin-first
// shape lets you route on the outer arm (`Image` / `Request` / `Resource` /
// `Stopped`) and only destructure when a sub-kind changes the answer.
let http = match config.job().with_pulse(pulse).decoder(Cow::Borrowed(bytes), &[]) {
    Ok(_decoder) => { /* _decoder.decode()? */ 200 }
    Err(e) => match e.category() {
        // Stopped through the operation pulse.
        ErrorCategory::Stopped(StopReason::Cancelled) => 499, // client went away
        ErrorCategory::Stopped(StopReason::TimedOut)  => 504,
        // A configured resource cap was hit.
        ErrorCategory::Resource(ResourceError::Limits(_)) => 413,
        // "Can't process this" — the bytes need a different codec, or the request
        // asked for an op / pixel format / CMS transform this codec doesn't do.
        ErrorCategory::Image(ImageError::Unsupported(_))
        | ErrorCategory::Request(RequestError::Unsupported(_))
        | ErrorCategory::Request(RequestError::CmsRequired) => 415,
        // Valid input, but a policy declined it — Decode vs Encode only matters
        // if you want to word the message differently; both are 422 here.
        ErrorCategory::Policy(_) => 422,
        // Bad client-supplied bytes (malformed / truncated), or a bad request.
        ErrorCategory::Image(_) => 400,
        ErrorCategory::Request(RequestError::Invalid(
            InvalidKind::Parameters | InvalidKind::Buffer,
        )) => 400,
        // Server-side: API misuse (out-of-sequence call), OOM, I/O, or a bug.
        // `Internal(Bug)` vs `Internal(Dependency)` both still read as 500 for
        // HTTP purposes; the split pays off in telemetry, not routing.
        ErrorCategory::Request(RequestError::Invalid(InvalidKind::State))
        | ErrorCategory::Resource(ResourceError::OutOfMemory)
        | ErrorCategory::Io(_)
        | ErrorCategory::Internal(_) => 500,
        _ => 500, // ErrorCategory is #[non_exhaustive]
    },
};

ErrorCategory is the coarse routing axis (it's Copy, and ResourceError::Limits(LimitKind) even tells you which cap). When you need more detail than the category, the typed extractors on CodecErrorExt recover the cause from the source() chain — e.limit_exceeded() for the exact limit values, e.unsupported_operation() for which operation, or e.find_cause::<T>() for a codec-specific type. category() is available once a codec implements CategorizedError; the zencodec cause types (LimitExceeded, UnsupportedOperation, enough::StopReason) implement it, so a codec's mapping is usually a one-line delegation per variant. CodecErrorExt::error_category() is the same answer recovered through erasure — it works on any Box<dyn Error> holding a CodecError (bare or At-wrapped), so the routing survives dyn dispatch and anyhow.

Controlling decode parallelism

Zen codecs parallelize with rayon's ambient pool. The responsibility splits cleanly: the codec chooses sequential vs parallel (ResourceLimits::threading → ThreadingPolicy::{Sequential, Parallel}); the caller chooses the thread count by sizing a pool and running the decode inside rayon::ThreadPool::install — you don't ask the codec to cap threads from the inside.

DynDecoder (one-shot) is intentionally not Send — it may borrow your input zero-copy. That is not an obstacle to capping threads: decode() returns an owned, Send DecodeOutput, so construct and consume the decoder inside the closure and only the result crosses back out. The codec's internal rayon work then runs on your sized pool:

use std::borrow::Cow;
use rayon::ThreadPoolBuilder;
use zencodec::decode::{DecodeOutput, DynDecoderConfig};

# fn decode_in_pool(config: &dyn DynDecoderConfig, bytes: &[u8]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let pool = ThreadPoolBuilder::new().num_threads(2).build()?;
let out: DecodeOutput = pool.install(|| {
    config.dyn_job()
        .into_decoder(Cow::Borrowed(&bytes), &[])?   // Box<dyn DynDecoder> — not Send, but local
        .decode()                                     // -> DecodeOutput (Send), consumes the decoder
})?;
// the non-Send decoder lived and died on a pool worker; only owned pixels came back
# Ok(())
# }

Run many decodes under one capped pool the same way: pool.install(|| inputs.par_iter().map(decode_one).collect()), each decoder built inside its own task. Need a live decoder on another thread (not just thread-capping)? Use the streaming path: DynStreamingDecoder is Send by contract (it owns/copies its data), so it can move across a thread boundary — one-shot trades that for zero-copy borrowing.

Exception — native-threaded codecs. AV1 (rav1d / zenrav1e / AVIF) spawns OS threads, not rayon, so install() has no effect on them; cap those with codec-specific config (e.g. AvifEncoderConfig::with_threads(4)). See ThreadingPolicy for the full model.

These patterns are exercised end-to-end in zencodec-testkit/tests/decode_parallelism.rs.

Decoding as bytes arrive, and streaming transcode

A codec that implements IncrementalDecode performs no I/O: whoever holds the bytes offers them to step, the decoder decodes them in place, writes rows straight into a DecodeRowSink, and says what it needs next (AtLeast a span from an offset, an exact Range, or the total Len). The same decoder runs over a file, an HTTP body, an object store with range requests, an FFI host that pushes buffers, or a single-threaded WASM loop, and starts emitting rows before the input is complete. Pull-native codecs bind to a random-access source instead (DecodeJob::push_decoder_from_source). A codec whose core needs the complete file still serves every driver through helpers::WholeFileIncremental: it reports the header as soon as a prefix probes and writes its rows once the input is complete, with pixels identical to its push path. zenbitmaps decodes rows as bytes arrive; codecs built on WholeFileIncremental emit rows only at the end.

Encoder::begin declares the image size up front and returns an OutputTiming: whether this encoder, with its configuration, hands out encoded bytes through pending_output as rows arrive, and how many rows it holds back. A streaming decode-to-encode transcode holds one strip and a bounded window rather than two whole images. JPEG with fixed Huffman tables, PNM, QOI and IMIJ row groups stream; optimized-table JPEG and bottom-up BMP answer AT_FINISH.

The loops live outside this crate, in the workspace's zencodec-io: drive_from_slice, drive_from_read_at (batched range reads), pump_read_to_sink (std::io::Read), and under its tokio feature decode_async / transcode_async, which keep the decoder in the task and run decode batches on a thread pool so no thread waits on the network. zencodec-coroutine lets a container walk be written as straight-line async code behind the same contract. Capabilities: DecodeCapabilities::{incremental, seekable, access_pattern}. Rules, drivers, decisions and measurements: docs/range-decoding-contract.md.

Encode fidelity

EncoderConfig::with_fidelity is the codec-agnostic way to ask for a quality level — infallible and best-effort: the codec does what it can and substitutes the rest.

use zencodec::encode::{EncoderConfig, Fidelity};

# fn choose_fidelity(my_encoder_config: impl EncoderConfig) {
let cfg = my_encoder_config
    .with_fidelity(Fidelity::Lossless);              // mathematically exact
//  .with_fidelity(Fidelity::ssim2(90.0))            // aim at SSIMULACRA2 ~= 90
//  .with_fidelity(Fidelity::butteraugli(1.0))       // aim at butteraugli max-norm ~= 1.0
//  .with_fidelity(Fidelity::zensim_b(90.0))         // aim at zensim (ZensimProfile::B) ~= 90
//  .with_fidelity(Fidelity::codec_quality(85.0));   // the codec's own native dial
# }

Fidelity is either Lossless or Lossy(LossyTarget), where a LossyTarget is:

  • ApproxSsim2(score) — a one-shot SSIMULACRA2 target (a real cross-codec metric).
  • ApproxButteraugli(distance) — a one-shot butteraugli max-norm distance.
  • ApproxZensimB(score) — a one-shot zensim target, pinned to ZensimProfile::B.
  • CodecSpecificQuality(q) — the codec's own native quality scale (meaning differs per codec).

These are blind, single-pass: the target maps to a native dial in one encode, no re-encode loop. A codec that hasn't implemented native fidelity still behaves sensibly — the default bridges to the legacy with_lossless / with_generic_quality setters. Read back what the codec resolved to with resolved_target_fidelity() -> Option<Fidelity>.

A LosslessMode container variant (lossless coding within a loss budget — the screen-content path) remains designed but deferred; see the reserved blocks in src/fidelity.rs and imazen/zencodec#104.

EncoderConfig::try_with_fidelity is available for checked configuration target selection. It does not guarantee a measured metric score or pixel losslessness.

Key Design Decisions

Color management is not the codec's job. Decoders return native pixels with ICC/CICP metadata. Encoders accept pixels as-is and embed the provided metadata. The caller handles CMS transforms.

Format negotiation over conversion. Decoders take a ranked &[PixelDescriptor] preference list and pick the first they can produce without lossy conversion. Pass &[] for native format.

Capabilities over try/catch. Codecs declare their capabilities as const EncodeCapabilities / DecodeCapabilities structs. Check before calling instead of catching UnsupportedOperation errors.

Pixel types from zenpixels. All pixel interchange types (PixelSlice, PixelBuffer, PixelDescriptor, etc.) are defined in the zenpixels crate. All zen* crates depend on zenpixels directly.

Metadata Retention

Re-encode and recompress pipelines need to decide what metadata survives. Metadata::filtered applies a MetadataPolicy, so callers never hand-parse EXIF:

use zencodec::{MetadataPolicy, MetadataFields, IccRetention, exif::{ExifPolicy, Retention}};

// Decode → filter → re-encode. `Web` (recommended for publishing) keeps the ICC profile
// (unless a redundant sRGB), EXIF orientation + rights, and CICP/HDR color
// signaling — and strips GPS, timestamps, camera info, thumbnail, and XMP.
let kept = decoded_meta.filtered(&MetadataPolicy::Web);

// Presets: PreserveExact (keep all, incl. duplicate sRGB), Preserve (drop dup
// sRGB), Web, ColorAndRotation (only what places pixels), Custom.
let minimal = decoded_meta.filtered(&MetadataPolicy::ColorAndRotation);

// Per-field control — drop only the thumbnail, keep everything else:
let policy = MetadataPolicy::Custom(
    MetadataFields::KEEP_ALL.with_exif(ExifPolicy::KEEP_ALL.with_thumbnail(Retention::Discard)),
);
let no_thumb = decoded_meta.filtered(&policy);

MetadataFields encapsulates EXIF in an ExifPolicy with twelve keep/discard categories — orientation, rights, thumbnail, gps, datetimes, time_offsets, camera, device_ids, camera_owner, image_unique_id, color, other — and ICC retention through IccRetention. EXIF passes through byte-unchanged when no category is dropped and its orientation agrees with Metadata::orientation; filtering may otherwise rewrite it, including reconciling orientation. CICP/HDR are color signaling (dropping them changes displayed pixels), so the presets keep them; a Custom policy can drop them. The structured parser/editor is public as zencodec::exif::Exif (parse → filtered/edit → to_bytes) for direct EXIF work — including setting Copyright/Artist (set_copyright / set_artist, with a TextEncoding choice of Exif 2.x ASCII or Exif 3.0 UTF-8) and Orientation (set_orientation, insert-or-replace).

Choose metadata retention explicitly. job.with_metadata_policy(meta, MetadataPolicy::Web) filters the supplied metadata before passing it to the codec. Web drops XMP and disallowed EXIF fields while retaining attribution, orientation and color signaling. Attribution can contain names or contact details; retained ICC bytes are opaque. This is not a guarantee that a whole container contains no PII. ColorAndRotation removes attribution but still retains the color signaling needed to interpret pixels.

The unfiltered with_metadata method is deprecated. Its compiler warning is a migration aid, not enforcement: callers can still invoke it. Likewise, PreserveExact asks the compatibility filter to keep metadata; this setter cannot prove the destination codec will carry every field. Required retention needs a checked executing adapter with suitable evidence, or an explicit refusal. See metadata/transcoding policy.

To stamp rights in one line — Metadata::none().with_copyright("© 2026 You") builds (or merges into) the EXIF blob (ASCII); or build it directly with Exif::new(TextEncoding::Ascii).set_copyright(…) → to_bytes() — Exif::new requires the Exif 2.x-vs-3.0 field-type choice (type 129 is read by almost nothing, so it's never a silent default).

Metadata retention, color emission, and orientation are the three correctness signals an encode has to get right; docs/correctness-model.md describes how the framework resolves each one before the codec runs so a codec can't quietly clobber it. The zencodec-testkit crate verifies a codec honors that contract — check_metadata_no_leak re-parses the embedded EXIF to prove a policy's drops actually happened, and check_cross_path_pixel_equivalence diffs every feeding mode.

Color Emission

The encode-side dual of color resolution: which color carriers (ICC vs CICP) should an encode write? resolve_color_emit decides — a pure, no_std, CMS-free function of the source color, the target's carrier capabilities, and a policy:

use zencodec::{resolve_color_emit, ColorEmitPolicy, IccDisposition};

let plan = resolve_color_emit(&source_color, &target_caps, ColorEmitPolicy::Balanced);
// plan.cicp: Option<Cicp>   — write this CICP (JXL/AVIF/HEIC nclx, PNG cICP) if the format carries it
// plan.icc:  IccDisposition — KeepSource | SynthesizeFrom(Cicp) | Drop

ColorEmitPolicy picks the tradeoff: Compatibility (widest reader support), Balanced (default — CICP where it's a spec-mandated safe sole carrier, an ICC companion otherwise), Compact (smallest — prefer CICP, drop the ICC), Verbatim (carry the source's signals unchanged), or Custom(ColorEmitFields). A target advertises its carriers via EncodeCapabilities::{cicp_is_valid_carrier, cicp_safe_sole_carrier}. The plan never emits a redundant SynthesizeFrom(sRGB); a codec lowers a SynthesizeFrom through zenpixels-convert's transfer-aware synthesize_icc_for_cicp (a bundled const profile, or — with its cms-moxcms feature — a generated one) so an HDR transfer is not mis-tagged with an SDR profile. ICC synthesis is best-effort: callers must handle unavailable synthesis or container carriers rather than assume every plan preserves the color description. The names carry the emit direction so they can't be confused with the decode-side SourceColor. Design + rejected alternatives: docs/color-emit-model.md.

What's in this crate

Module Contents
zencodec::encode EncoderConfig, EncodeJob, Encoder, AnimationFrameEncoder, EncodeOutput, EncodeCapabilities, EncodePolicy, best_encode_format, dyn dispatch traits (DynEncoderConfig, DynEncodeJob, DynEncoder, DynAnimationFrameEncoder)
zencodec::decode DecoderConfig, DecodeJob, Decode, StreamingDecode, AnimationFrameDecoder, sans-I/O IncrementalDecode (Input, InputRequest, Event, Progress, StepError, Fused, erase_incremental, DynIncrementalDecoder), DecodeOutput, DecodeCapabilities, DecodePolicy, DecodeRowSink, SinkError, OutputInfo, SourceEncodingDetails, negotiate_pixel_format, is_format_available, dyn dispatch traits (DynDecoderConfig, DynDecodeJob, DynDecoder, DynStreamingDecoder, DynAnimationFrameDecoder)
zencodec::source ReadAt (random-access source: read_at, batched read_many_at, as_slice), object-safe DynReadAt, SliceReadAt, ReadAtShim, SourceError, AccessPattern
zencodec::estimate ResourceEstimate (predicted peak memory / wall + CPU time / core-scaling, all Option), ComputeEnvironment (cores, RAM, SimdTier), ImageCharacteristics, SimdTier, ThreadingInformation — codec-agnostic resource estimation, surfaced via EncoderConfig::estimate_encode_resources / DecoderConfig::estimate_decode_resources
zencodec::gainmap GainMapInfo, GainMapParams, GainMapChannel, GainMapDirection, GainMapPresence, Iso21496Format (wire-format variant: AvifTmap, JxlJhgm, JpegApp2BodyWithUrn; the original JpegApp2 is deprecated since 0.1.20), ISO_21496_1_URN, ISO_21496_1_PRIMARY_APP2_BODY, serialize_iso21496_fmt / serialize_iso21496_fmt_into / parse_iso21496_fmt, GainMapParseError — cross-codec gain map types and wire-format helpers (ISO 21496-1)
zencodec::exif Structured EXIF/TIFF: Exif (borrowing parse → prune → serialize), ExifPolicy (7 keep/discard categories), Retention, ByteOrder, retain
zencodec::helpers Codec implementation helpers (not consumer API) — shared boilerplate for trait implementors, plus the lightweight parse_exif_orientation accessor
zencodec::prelude One-import bundle of every encode/decode trait (generic + dyn)
root CodecSet / CodecSetError (multi-codec registry: registration-derived detection, format-keyed encode, shareable app-wide), ImageFormat, ImageFormatDefinition, ImageFormatRegistry (format detection via ImageFormatRegistry::detect()), ImageInfo, Metadata, MetadataPolicy, MetadataFields, IccRetention, Exif, ExifPolicy, Retention, ByteOrder, Orientation, OrientationHint, ResourceLimits, AllocPreference, LimitExceeded, ThreadingPolicy, UnsupportedOperation, CodecErrorExt, find_cause, Unsupported, Extensions, AnimationFrame, OwnedAnimationFrame, resolve_color_emit, ColorEmitPolicy, ColorEmitPlan, ColorEmitFields, IccDisposition, CicpEmission, ColorAuthority, Cicp, ContentLightLevel, MasteringDisplay, Pulse, SharedPulse, NoPulse (legacy StopToken / Unstoppable)

The full API is always available. The only feature, std, lets ErrorCategory::Io carry a std::io::ErrorKind.

Workspace helpers include zencodec-io (drivers for slices, ReadAt sources, std::io::Read streams and tokio), zencodec-coroutine (async-authored incremental decoders) and zencodec-testkit (conformance checks for codec crates). I/O and coroutine are unpublished; testkit has a published 0.1.0 and prepares an independently versioned 0.2.0.

Limitations

  • Contains no codec logic — traits, types, and format detection only.
  • ImageFormat enum is not extensible at runtime (the Custom variant requires a &'static definition).
  • no_std + alloc by default; the optional std feature carries I/O error kinds.

MSRV

Rust 1.88+, 2024 edition.

License

Licensed under either of Apache-2.0 or MIT at your option.

Image tech I maintain

Codecs ¹ zenjpeg · zenpng · zenwebp · zengif · zenavif · zenjxl · zenjxl-decoder · jxl-encoder · zenbitmaps · heic · zentiff · zenpdf · zensvg · zenjp2 · zenraw · ultrahdr
Codec internals zenrav1e · rav1d-safe · zenravif · zenavif-parse · zenavif-serialize
Compression zenflate · zenzop · zenzstd
Processing zenresize · zenquant · zenblend · zenfilters · zensally · zentone
Pixels & color zenpixels · zenpixels-convert · linear-srgb · garb · zenyuv
Pipeline & framework zenpipe · zencodec · zencodecs · zenlayout · zennode · zenwasm · zentract
Metrics zensim · fast-ssim2 · butteraugli · zenmetrics · resamplescope-rs
Pickers & ML zenanalyze · zenpredict · zenpicker · zenanalyze-api
Test corpora codec-corpus · imazen-26
Products Imageflow 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 · zenutils

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

About

Shared traits and types for zen* image codecs

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages