An AV1 decoder with a native Rust API: Decoder, Settings, Frame, and
borrowed pixel-plane views are available directly from rav1d_safe. Forked from
rav1d, with checked safe Rust SIMD enabled
by default. Rust callers need neither a C FFI wrapper nor the c-ffi feature.
Use zenrav1e to encode raw AV1 and zenavif for complete AVIF files, including container handling and color conversion. See the compiled Rust round-trip examples.
Add to your Cargo.toml:
[dependencies]
rav1d-safe = "0.6.0"Decode an AV1 bitstream:
use rav1d_safe::{Decoder, Planes};
fn decode(obu_data: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
let mut decoder = Decoder::new()?;
// Feed raw OBU data (not IVF/WebM containers)
if let Some(frame) = decoder.decode(obu_data)? {
println!("{}x{} @ {}bpc", frame.width(), frame.height(), frame.bit_depth());
match frame.planes() {
Planes::Depth8(planes) => {
for row in planes.y().rows() {
// row is &[u8] — zero-copy, no allocation
}
}
Planes::Depth16(planes) => {
let px = planes.y().pixel(0, 0); // 10 or 12-bit value
}
}
}
// Drain any buffered frames
for frame in decoder.flush()? {
// ...
}
Ok(())
}The reference below describes the 0.6.0 API, including Strictness and
cooperative cancellation. The historical runnable codec examples
remain pinned to the published versions used for their recorded validation.
The public API lives in src/managed.rs and is re-exported at the crate root, so every public type is reachable directly from rav1d_safe — no src::managed:: path needed. One canonical import covering the whole surface:
use rav1d_safe::{
Decoder, Settings, Strictness, CpuLevel, Error, Frame, Result,
Planes, // enum; you match Planes::Depth8(_) / Planes::Depth16(_)
Planes8, Planes16, // the inner per-bit-depth plane sets the variants wrap
PlaneView8, PlaneView16, // zero-copy 2D plane views
PixelLayout, // I400 / I420 / I422 / I444 (chroma subsampling)
DecodeFrameType, InloopFilters,
// HDR / color metadata:
ColorInfo, ColorPrimaries, TransferCharacteristics, MatrixCoefficients,
ColorRange, ContentLightLevel, MasteringDisplay,
enabled_features,
};Configure the conformance policy by modifying the default settings:
use rav1d_safe::{Decoder, Settings, Strictness};
# fn main() -> rav1d_safe::Result<()> {
let mut settings = Settings::default();
settings.strictness = Strictness::Lenient;
let decoder = Decoder::with_settings(settings)?;
# Ok(())
# }The default is Strictness::Strict. Settings remains non-exhaustive, so
downstream callers use field assignment rather than a struct literal.
Core types:
| Type | Purpose |
|---|---|
Decoder |
Decodes AV1 OBU data into frames |
Frame |
Decoded frame with metadata (cloneable, Arc-backed) |
Planes |
Enum with variants Planes::Depth8(Planes8) / Planes::Depth16(Planes16), dispatched by bit depth |
Planes8 / Planes16 |
Per-bit-depth plane set: .y() → luma view, .u() / .v() → Option<view> (None for I400 monochrome) |
PlaneView8 / PlaneView16 |
Zero-copy 2D view: row(y), pixel(x, y), rows(), width(), height(), stride() |
Settings |
Thread count, film grain, frame size limit, inloop filters, CPU level, etc. |
CpuLevel |
SIMD dispatch level (Scalar, X86V2, X86V3, X86V4, Neon, Native) |
Error |
Enum: InvalidData, OutOfMemory, NeedMoreData, InvalidSettings(&str), InitFailed, Cancelled, Other(String) |
Naming note (reconciles the example above):
Depth8/Depth16are the variants of thePlanesenum — you writePlanes::Depth8(..)in amatch.Planes8/Planes16are the struct types those variants wrap, and are also re-exported at the crate root so you can name them in signatures. Both are correct; they refer to different things.
Metadata types: ColorInfo, ColorPrimaries, TransferCharacteristics, MatrixCoefficients, ColorRange, ContentLightLevel, MasteringDisplay, PixelLayout
The decoder expects raw AV1 Open Bitstream Unit (OBU) data. If you have IVF or WebM containers, strip the container framing first and pass the OBU payload. See tests/ivf_parser.rs for an IVF parser example. For complete AVIF images, use zenavif, which uses rav1d-safe’s Rust API and handles the container, alpha, and color conversion. Use zenavif-parse directly only when implementing that container layer yourself; AVIF files can contain multiple image items or tiles, not just one OBU payload.
decode() / flush() yield a Frame of planar YUV pixels — there is no built-in RGB conversion (do that downstream, e.g. with zenpixels-convert, or via zenavif for AVIF). Read the planes through the bit-depth-dispatched Planes enum:
let layout = frame.pixel_layout(); // PixelLayout: I400 | I420 | I422 | I444
let bpc = frame.bit_depth(); // 8, 10, or 12
match frame.planes() {
Planes::Depth8(p) => { // 8-bit content: planes are u8
let y = p.y(); // PlaneView8 (luma, always present)
let u = p.u(); // Option<PlaneView8> — None for I400 monochrome
let v = p.v(); // Option<PlaneView8>
let (w, h, stride) = (y.width(), y.height(), y.stride());
for row in y.rows() { /* row: &[u8], one luma scanline, zero-copy */ }
let _ = (u, v, w, h, stride);
}
Planes::Depth16(p) => { // 10/12-bit content: planes are u16
let _y = p.y(); // PlaneView16; pixel(x, y) -> u16
}
}- Subsampling is reported by
frame.pixel_layout():I420(4:2:0, chroma half-width & half-height),I422(4:2:2, half-width),I444(4:4:4, full-res chroma),I400(monochrome —u()/v()returnNone). - Bit depth is
frame.bit_depth()(8 / 10 / 12).Planes::Depth8carriesu8samples;Planes::Depth16carriesu16(the 10/12-bit value is in the low bits). - Each
PlaneView{8,16}is a strided 2D view:row(y)borrows one scanline,rows()iterates them,pixel(x, y)reads one sample, andwidth()/height()/stride()give its geometry. All accessors are zero-copy borrows into the decoder's frame buffer (held alive by theFrame).
use rav1d_safe::{Decoder, Settings, CpuLevel};
// Single-threaded (default) — synchronous, deterministic
let decoder = Decoder::new()?;
// Tile threading within a frame; safe checked mode supports this.
let mut settings = Settings::default();
settings.threads = 4;
settings.max_frame_delay = 1;
let decoder = Decoder::with_settings(settings)?;
// Constrained decoding — limit frame size and CPU features
let mut settings = Settings::default();
settings.frame_size_limit = 3840 * 2160; // total pixels, not bytes
settings.cpu_level = CpuLevel::Native;
let decoder = Decoder::with_settings(settings)?;This is the only resource bound applied before a frame is decoded, so it is the knob that matters for untrusted input. Verified against src/managed.rs and src/obu.rs:
- Unit: the maximum
width * heightin pixels (total luma sample count). Not bytes, not the longest dimension. The check is literallywidth * height > frame_size_limitduring OBU header parsing — a frame is rejected before any pixel buffer is allocated or decoded. - Default:
120_000_000(120 megapixels) — chosen to admit ~108 MP phone photos. It is not unlimited by default, but 120 MP is large; set it lower for untrusted servers (e.g.7680 * 4320for an 8K cap, or3840 * 2160for 4K). - Disable: set
frame_size_limit: 0to turn the limit off entirely (no cap). - On rejection: the offending OBU fails parsing and
decode()returns anErr(surfaced asError::Othercarrying the internalERANGErange error — notError::InvalidData). The decoder logsFrame size WxH exceeds limit N. - 32-bit hosts: on targets where
usizeis < 64 bits, the effective cap is additionally clamped to8192 * 8192regardless of the value you set (a non-zero larger value is silently reduced;0/unlimited still means unlimited).
Caveat for servers:
frame_size_limitbounds the declared frame dimensions only. It does not bound total decode time, memory of a large-but-legal frame, or the number of OBUs/frames in a stream. There is no per-decode time budget — see Cancellation below.
With threads >= 2 or threads == 0, the decoder uses tile threading to parallelize decode within each frame. decode() may return None for complete frames because processing is asynchronous — call it repeatedly or use flush() to drain.
Tile threading works under forbid(unsafe_code) without the unchecked feature. No special feature flags needed:
rav1d-safe = { version = "0.6.0", features = ["bitdepth_8", "bitdepth_16"] }Still-image scaling depends on the encoded tile layout. A single-tile image
may gain little from additional workers; tiled images can benefit substantially.
Measure the intended workload and set max_frame_delay = 1 when comparing
single-frame latency. Frame threading (max_frame_delay > 1) still requires
the unchecked feature. See the 2K/4K/8K still investigation
and the performance parity goal.
AV1 leaves the handling of non-conforming streams to the decoder. dav1d's library default conceals and keeps going (a player would rather show a damaged frame than stop); the AV1 reference decoder aomdec rejects the frame. Settings::strictness picks between the two:
Strictness::Strict(default) rejects what libaom rejects: dav1d'sstrict_std_compliancechecks (OBU trailing bits, the padding after each tile's symbol decoder, the OBU forbidden bit, zerotiming_info, …) plus the spec's requirement that every decodedsegment_idstays withinLastActiveSegId. A desynchronised symbol stream is anError::InvalidDatainstead of garbage pixels. These are compares at points the decoder already evaluates; none is in a per-pixel loop.Strictness::Lenientis dav1d's behaviour, bit-exact with dav1d even on corrupt input. Use it for differential testing against dav1d, or when a best-effort frame beats an error.
Why Strict is the default: this decoder's main job is decoding still images and verifying encoder output. An encoder bug that desynchronised the symbol stream (zenrav1e#35) passed every encode→decode round trip for weeks because the decoder concealed it (#422). The 766-vector dav1d md5 gate decodes identically under both settings, and across the AVIF corpora in benchmarks/strictness_2026-08-28.meta the only file Strict rejects that Lenient accepts is a deliberately corrupted conformance-suite sample. cargo run --release --example strictness_sweep -- <dir> reports the two verdicts side by side for your own corpus.
if let Some(cll) = frame.content_light() {
println!("MaxCLL: {} nits", cll.max_content_light_level);
}
if let Some(mdcv) = frame.mastering_display() {
println!("Peak: {} nits", mdcv.max_luminance_nits());
}
let color = frame.color_info();
// color.primaries, color.transfer_characteristics, color.matrix_coefficientsFallible operations return the crate's Result<T> alias, which is Result<T, whereat::At<Error>> — the Error is wrapped in [whereat]'s At<…>, recording the source location where the failure surfaced (handy for server logs). Unwrap the inner Error with err.error() (borrow) or err.decompose().0 (owned). Error variants: InvalidData, OutOfMemory, NeedMoreData, InitFailed, InvalidSettings(&str), Cancelled, Other(String). (From<Rav1dError> maps the internal EAGAIN → NeedMoreData, ENOMEM → OutOfMemory, EINVAL → InvalidData, and everything else — including the frame_size_limit ERANGE — to Other.)
The 0.6.0 Rust API provides Decoder::set_stop, accepting an
Option<Arc<dyn enough::Stop>>. Single-threaded decoding checks at superblock-row
boundaries; tile workers also check for cancellation. A triggered token returns
Error::Cancelled. None disables cancellation checks. This is cooperative
cancellation, not a hard wall-clock deadline; frame_size_limit remains useful
as a separate bound on declared dimensions. This API is not in published 0.5.7.
The default library compiles under crate-wide forbid(unsafe_code) and uses
runtime borrow-overlap checks. Unsafe implementations behind dependency APIs
remain part of the trust boundary, including rav1d-disjoint-mut, alignment
helpers, archmage, and SIMD memory-access helpers. This is not a proof of the
whole decoder or its dependencies.
Enabling unchecked, c-ffi, or asm removes the crate-wide prohibition.
c-ffi implies unchecked; asm implies c-ffi; partial_asm also implies
unchecked. In unchecked SIMD paths, local exceptions allow raw loads/stores,
and untracked buffer constructors bypass runtime overlap tracking. Some modules
retain their own prohibitions. Normal Rust callers need none of these features.
The default SIMD path uses archmage for feature-token dispatch and
safe_unaligned_simd for reference-based loads/stores, with slice-based kernel
interfaces.
Verify at runtime with rav1d_safe::enabled_features() — returns a comma-delimited list including the active safety level (e.g. "bitdepth_8, bitdepth_16, safety:forbid-unsafe").
The default build compiles under forbid(unsafe_code) in the main crate. SIMD kernels live in src/safe_simd/, with additional entropy specialization in src/msac.rs.
Every DSP kernel family has a safe Rust SIMD implementation that compiles under forbid(unsafe_code):
| Module | x86 ASM replaced | ARM ASM replaced | Safe Rust |
|---|---|---|---|
| mc (motion compensation) | 3 files (SSE/AVX2/AVX-512) x2 bitdepths | 4 files (32+64-bit) x2 bitdepths + SVE/dotprod | mc.rs + mc_arm.rs |
| itx (inverse transforms) | 3 files x2 bitdepths | 2 files x2 bitdepths | itx.rs + itx_arm.rs |
| ipred (intra prediction) | 3 files x2 bitdepths | 2 files x2 bitdepths | ipred.rs + ipred_arm.rs |
| cdef (directional enhancement) | 3 files x2 bitdepths | 2+tmpl files x2 bitdepths | cdef.rs + cdef_arm.rs |
| loopfilter | 3 files x2 bitdepths | 2 files x2 bitdepths | loopfilter.rs + loopfilter_arm.rs |
| looprestoration (Wiener + SGR) | 3 files x2 bitdepths | 2+common+tmpl files x2 bitdepths | looprestoration.rs + looprestoration_arm.rs |
| filmgrain | 3+common files x2 bitdepths | 2 files x2 bitdepths | filmgrain.rs + filmgrain_arm.rs |
| pal (palette) | 1 file | (none — ARM uses scalar) | pal.rs |
| refmvs (reference MVs) | 1 file | 2 files (32+64-bit) | refmvs.rs + refmvs_arm.rs |
| msac (entropy decoder) | 1 file (shared) | 1 file (shared) | inline in msac.rs |
| cpuid | 1 file (55 lines) | — | replaced by std::arch detection in cpu.rs |
The entropy decoder combines scalar routines with selected safe SIMD specialization; see the measured entropy experiments.
Scaled MC (put_8tap_scaled, prep_8tap_scaled, put_bilin_scaled, prep_bilin_scaled) — These functions use per-pixel variable step sizes with per-pixel filter selection, making them fundamentally different from fixed-block MC. The ASM versions are heavily register-scheduled for this pattern. Falls back to scalar Rust. ~2% of profile on inter-frame content.
SSE-only paths — 14 files, ~52k lines. The safe SIMD dispatch jumps straight to AVX2 when available. On pre-AVX2 hardware (pre-Haswell, 2013), the decoder falls back to scalar Rust rather than SSE intrinsics. SSE-only x86 hardware is rare enough that maintaining a second intrinsics tier isn't worth the code.
ARM SVE2, dotprod, i8mm extensions — mc_dotprod.S (1,880 lines) and mc16_sve.S (1,649 lines) are optional fast paths for newer ARM cores. The safe SIMD covers baseline NEON; these extension paths fall back to the NEON implementation.
Remaining AVX-512 paths — Some AVX-512 paths have been ported (itx, mc, ipred, looprestoration Wiener), but others remain unported. Falls back to AVX2 where not implemented.
ASM infrastructure files — x86inc.asm (1,983 lines), asm.S, util.S, *_tmpl.S, *_common.S are macro libraries and constants that only exist to support the raw assembly. No independent functionality to port.
For the current assembly-disabled upstream comparison, see the matched 2K/4K/8K benchmark. Upstream rav1d has no memory-safe feature mode; turning off its assembly selects its Rust implementation, which still contains unsafe code. Checked rav1d-safe retains safe SIMD and overlap checks.
The following older measurements compare rav1d-safe's own feature modes. They are not the new upstream no-assembly comparison.
Historical benchmark setup: x86_64 (Zen 4, AVX2), single-threaded, Rust 1.93+, fat LTO. Run with just profile (500 iterations for IVF, 20 iterations for AVIF).
Single still images at web-typical quality (YUV420, q60). Source: Google-native 8K photo, downscaled with ImageMagick. These numbers reflect real-world AVIF decode performance where SIMD kernels dominate.
| Resolution | ASM | Safe (checked) | Safe (unchecked) | Safe vs ASM |
|---|---|---|---|---|
| 4K (3840x2561) | 120.7 ms | 187.8 ms | 179.5 ms | 1.56x |
| 8K (8192x5464) | 714.1 ms | 1103.2 ms | 1066.9 ms | 1.54x |
(Measured at v0.5.6. The 0.5.6 SIMD work — i16-packed pmaddwd transform row+col passes
and YMM-widened loopfilter — brought the 4K checked ratio from 2.0× down to 1.56×.)
dav1d-test-data allintra 352x288 (39 frames). Entropy-heavy bitstream where the serial msac decoder dominates, which compresses the ratio compared to pixel-heavy workloads.
| Build | ms/iter | ms/frame | vs ASM |
|---|---|---|---|
| ASM | 104.3 | 2.67 | 1.0x |
| Safe (checked) | 158.6 | 4.07 | 1.52x |
| Safe (unchecked) | 153.3 | 3.93 | 1.47x |
| Partial ASM | 141.2 | 3.62 | 1.35x |
The safe build is ~1.56x slower on 4K real images and ~1.52x on entropy-heavy vectors. The gap breaks down:
- Entropy decoder (msac): ~45% of decode time. Serial dependency chain — the core symbol decode loop can't be parallelized; ~95% of
decode_coefsis irreducible algorithmic cost (only ~3-5% is Rust-specific bounds-check/index overhead). Thepartial_asmfeature uses hand-tuned ASM for msac and loopfilter, bringing 4K photo decodes to 1.25x vs full ASM. - Calling conventions: The ASM kernels use custom register allocation across function boundaries. Rust's ABI reloads registers at each call site.
- Scaled MC: Falls back to scalar Rust (~2% of inter-frame content). The ASM version uses per-pixel variable-step register scheduling that doesn't map cleanly to safe intrinsics.
- Bounds checking: The
uncheckedfeature skips DisjointMut borrow tracking. This saves ~5% on photos, confirming the tracking overhead is modest.
just generate-bench-avif # create 4K/8K AVIF test images (requires avifdec + avifenc)
just profile # all four modes side-by-side (ASM, partial ASM, checked, unchecked)
just profile-quick # same with fewer iterationsTested against the dav1d-test-data suite. MD5 hashes verified at all CPU dispatch levels (scalar, SSE4.2, AVX2, native).
784 of 803 test vectors pass across all levels.
19 vectors are not exercised by the test harness:
| Category | Count | Reason |
|---|---|---|
| sframe | 1 | Requires S-frame support |
| svc (operating points) | 6 | Always decodes at default operating point |
| argon (vq_suite) | 12 | Various decode modes and operating point selection tests |
Run conformance tests with cargo test --release --test decode_cpu_levels.
Use a current stable Rust toolchain. The default checked library is tested on its declared Rust 1.89 minimum; the workflow examples and benchmarks record their exact compiler versions. Install via rustup.rs.
# Default safe-SIMD build (recommended)
cargo build --release
# With original hand-written assembly (for benchmarking)
cargo build --features asm --release
# Run tests
cargo test --release| Feature | Default | Description |
|---|---|---|
bitdepth_8 |
on | 8-bit pixel support |
bitdepth_16 |
on | 10/12-bit pixel support |
unchecked |
off | Skip DisjointMut borrow tracking; enables frame threading and SSE2 msac on x86_64 |
partial_asm |
off | ASM for entropy decoding (msac) and loopfilter only; safe SIMD everything else. Implies unchecked |
c-ffi |
off | C API entry points (dav1d_* symbols). Implies unchecked |
asm |
off | Full hand-written assembly. Implies c-ffi |
Safety chain: default (forbid(unsafe_code), tile threading) -> unchecked (frame threading) -> c-ffi -> asm. Each level relaxes the safety constraint.
# aarch64
RUSTFLAGS="-C linker=aarch64-linux-gnu-gcc" \
cargo build --target aarch64-unknown-linux-gnu --release
# Verify aarch64 NEON compiles
cargo check --target aarch64-unknown-linux-gnuSupported targets: x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu, i686-unknown-linux-gnu, armv7-unknown-linux-gnueabihf, riscv64gc-unknown-linux-gnu.
| 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
zenbench · archmage · magetypes · enough · whereat · cargo-copter · zenutils