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

June 28, 2026 · View on GitHub

zennode turns a plain Rust struct into a self-documenting pipeline node. Put #[derive(Node)] on a struct and you describe an operation's parameters once — ranges, defaults, units, slider mappings, UI sections, querystring keys, JSON field names, graph input ports — then get a zero-cost &'static schema, RIAPI-style querystring parsing, JSON (de)serialization, JSON Schema generation, and Markdown docs for free, all built for permanent backwards compatibility. zennode defines no nodes itself; nodes live in the crates that implement them. Pure Rust, #![forbid(unsafe_code)], no_std + alloc (std optional).

Quick start

[dependencies]
zennode = "0.1.1"

Derive Node on a struct and annotate its parameters:

use zennode::*;

#[derive(Node, Clone, Debug, Default)]
#[node(id = "filter.brightness", group = Tone, role = Filter)]
pub struct Brightness {
    /// Amount of brightness adjustment.
    #[param(range(-1.0..=1.0), default = 0.0, identity = 0.0, step = 0.05)]
    #[param(unit = "", section = "Main")]
    pub amount: f32,
}

#[derive(Node)] generates a NodeDef (the factory/schema holder), a NodeInstance (a live instance with parameter values), and a &'static singleton named <STRUCT_NAME>_NODE in screaming-snake-case — Brightness produces pub static BRIGHTNESS_NODE: BrightnessNodeDef. Doc comments on fields become parameter descriptions, and the full NodeSchema is reachable at zero cost through &'static references.

Each field's Rust type selects its ParamKind; #[param(...)] supplies the bounds and metadata:

Field typeParamKindTypical #[param(...)] keys
f32Floatrange(min..=max), default, identity, step
i32Intrange(min..=max), default
u32U32range(min..=max), default
boolBooldefault
StringStrdefault
[f32; N]FloatArray (or Color with #[param(color)])default, labels(...)
a struct/enum deriving #[derive(Node)]Object / TaggedUnion(auto-detected from the field type)
any type with #[param(json_schema = "...")]Jsonjson_default

Metadata keys allowed on any #[param(...)]: unit, section, label, slider, since, visible_when, json_name, json_alias — plus #[kv("w", "width")] for the RIAPI querystring keys that map to a field. Struct-level #[node(...)] takes id (required), group, role (a.k.a. phase), and optional label, version / compat_version, coalesce / fusable / coalesce_target, neighborhood, changes_dimensions, format(preferred = …, alpha = …), tags(...), json_key, deny_unknown_fields, and inputs(...) (see Graph topology).

Parameter enums get their own derive. Variant labels are set with #[variant(label = "…")], descriptions come from doc comments, and #[variant(alias = "…")] adds extra FromStr spellings:

#[derive(NodeEnum, Clone, Debug, Default)]
pub enum FitMode {
    /// Fit entirely within the bounds, preserving aspect ratio.
    #[default]
    #[variant(label = "Max")]
    Max,
    /// Fill the bounds, cropping any excess.
    #[variant(label = "Crop", alias = "cover")]
    Crop,
}

#[derive(NodeEnum)] generates Display, FromStr, and a &'static [EnumVariant] table (FitMode::zennode_variants()) for the snake_case variant names. To use an enum as a structured node parameter, derive Node on it instead — that yields a ParamKind::TaggedUnion.

Using the registry

NodeRegistry aggregates node definitions from across the ecosystem, then parses querystrings, instantiates nodes, and emits schemas and docs:

use zennode::{NodeRegistry, ParamMap, ParamValue, NodeGroup};

let mut registry = NodeRegistry::new();
registry.register(&BRIGHTNESS_NODE);          // or register_all(&[&A_NODE, &B_NODE])

// Parse a RIAPI-style querystring into node instances + non-fatal warnings.
let parsed = registry.from_querystring("brightness.amount=0.2");
for warning in &parsed.warnings {
    eprintln!("querystring: {warning:?}");    // unknown / out-of-range keys degrade gracefully
}

// Or build an instance from explicit params (ParamMap = BTreeMap<String, ParamValue>).
let mut params = ParamMap::new();
params.insert("amount".into(), ParamValue::F32(0.2));
let node = registry.create("filter.brightness", &params).unwrap();

// Discover nodes by group, or render Markdown docs for the whole registry.
let tone_nodes = registry.by_group(NodeGroup::Tone);
let docs = registry.to_markdown();

Querystring parsing is lenient: it reports problems via KvWarning instead of failing hard, so an unknown or out-of-range key degrades gracefully rather than rejecting the whole request — the right default for a public image-URL API.

What it provides

Schema introspection. Every node carries a NodeSchema with 14 fields (id, label, description, group, role, params, tags, coalesce info, format hints, version, compat_version, json_key, deny_unknown_fields, inputs). Every parameter carries a ParamDesc with 13 fields (name, label, description, kind, unit, section, slider mapping, kv_keys, since_version, visible_when, optional, json_name, json_aliases). Parameter types span 11 ParamKind variants (Float, Int, U32, Bool, Str, Enum, FloatArray, Color, Json, Object, TaggedUnion), and nodes file under 18 NodeGroup categories (Decode through Other).

Node registry. NodeRegistry looks up nodes by id (get), group (by_group), or tag (by_tag); creates instances from a ParamMap (create); parses RIAPI querystrings against every registered node (from_querystring, with consumption tracking and warnings for unrecognized keys); and renders Markdown docs for one node or the whole registry (to_markdown).

RIAPI querystring parsing. KvPairs is a consumption-tracking parser — several node definitions each claim their keys from one querystring, and unconsumed keys surface as warnings. Typed accessors (take_f32, take_i32, take_u32, take_bool, take) handle parsing and reporting, and snapshot() traces which consumer took each key.

Operation coalescing. NodeRole (9 roles: Decode, Geometry, Orient, Resize, Filter, Composite, Analysis, Quantize, Encode; aliased as Phase) tells the pipeline bridge which planner a node feeds into. CoalesceInfo marks adjacent compatible nodes for fusion into a single operation — always equivalent to sequential execution, never reordering.

JSON round-trip. With the serde feature, the registry serializes and deserializes individual nodes and whole pipelines. Nodes serialize as {"json_key": {...params...}}, pipelines as arrays. Unknown-field rejection is opt-in per node via #[node(deny_unknown_fields)]; field renames and back-compat aliases come from #[param(json_name = …)] / #[param(json_alias = …)].

JSON Schema generation. With the json-schema feature, emit JSON Schema 2020-12 documents with x-zennode-* extensions for slider mappings, units, sections, and identity values: node_to_json_schema / registry_to_json_schema for per-node and full-pipeline schemas (registry_to_openapi_schemas packages them for OpenAPI 3.1), and querystring_to_json_schema / registry_querystring_keys for the flat RIAPI querystring surface.

Graph topology

Most nodes have a single implicit input — the previous node's output. Multi-input nodes (compositing, montage, watermarking) declare their ports with #[node(inputs(...))], which the derive turns into the NodeSchema::inputs slice of InputPort descriptors:

#[derive(Node, Clone, Debug, Default)]
#[node(id = "compose.over", group = Composite, role = Composite)]
#[node(inputs(canvas("Background"), input("Foreground")))]
pub struct Over {
    #[param(range(0.0..=1.0), default = 1.0, identity = 1.0)]
    pub opacity: f32,
}

Port kinds: canvas("…") (the background/canvas edge), input("…") (a normal data edge), variadic("…") (N-way fan-in), and from_io("…") (a source referenced by io_id rather than a graph edge — e.g. a watermark loaded from a separate buffer). Each InputPort records a name, label, EdgeKind (Input or Canvas), and the required / variadic / from_io_id flags, so a downstream code generator can emit correct call signatures (DrawImage(other, x, y) vs Exposure(stops)).

Key types

TypePurpose
#[derive(Node)] / #[derive(NodeEnum)]generate the schema + NodeDef from a struct / enum
NodeDef / NodeInstancethe static definition vs. a constructed, parameterized node
NodeSchema / ParamDesc / ParamKindthe schema a node exposes
NodeRegistryregister nodes; from_querystring, create, get, by_group, by_tag, to_markdown
ParamMap / ParamValueruntime parameter values
NodeRole (= Phase) / NodeGrouppipeline + UI classification
InputPort / EdgeKindgraph input ports
VersionSetper-id schema versioning for backwards compatibility
NodeError / KvPairs / KvWarningtyped errors and non-fatal parse warnings

Feature flags

FeatureDefaultDescription
stdyesstd::error::Error impl on NodeError
deriveyes#[derive(Node)] and #[derive(NodeEnum)] macros
serdenoSerialize/Deserialize on param + schema types; JSON node/pipeline round-trip (implies std)
json-schemanoJSON Schema 2020-12 generation (implies serde)

The library is no_std + alloc compatible when both std and derive are disabled.

Integration pattern

Each sibling crate in the zen ecosystem wires its nodes in the same way:

  1. Depend on zennode behind a feature flag:
    [dependencies]
    zennode = { version = "0.1.1", optional = true }
    
  2. Define nodes in a feature-gated module:
    #[cfg(feature = "zennode")]
    pub mod zennode_defs;
    
  3. That module declares node structs with #[derive(Node)] and exposes a register() function:
    pub fn register(registry: &mut zennode::NodeRegistry) {
        registry.register(&ENCODE_JPEG_NODE);
        registry.register(&DECODE_JPEG_NODE);
    }
    
  4. An aggregator crate (zenpipe) calls each crate's register() behind feature flags to build one registry of every available node.

This pattern is used across the zen codecs and processing crates — zenjpeg, zenpng, zenwebp, zengif, zenavif, zenjxl, zentiff, zenbitmaps, zenfilters, zenresize, zenlayout, zenquant, zencodecs, and more.

Workspace structure

zennode/          # workspace root + GitHub README
  zennode/        # library crate (traits, schema, registry, KV parsing)
  zennode-derive/ # proc-macro crate (#[derive(Node)], #[derive(NodeEnum)])

License

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

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