8.8 KiB
filmcraft-dnx
Clean-room Avid DNxHD / DNxHR (VC-3) decoder and encoder in pure Rust, written from the public
SMPTE ST 2019-1:2016 VC-3 Picture Compression and Data Stream Format and its
Amendment 1:2023 (both free from pub.smpte.org). Layer L0: depends only on
filmcraft-bitstream, thiserror and (optional, feature threads, default on) rayon. Builds
for wasm32-unknown-unknown; no unsafe.
The constant tables (Annex D quantisation weights, Annex E VLC tables) were extracted from the
standard's text by tools/extract_tables.py into
src/spec_tables.rs; the script checks every table's size and that every VLC table is a
complete prefix code. No code was taken from FFmpeg or any other implementation; ffmpeg is used
only as an external fixture generator and test oracle.
Status
| Area | Status |
|---|---|
| Header (§7.2): all fields, HVN 1–4, RI header growth above 1088 lines, time code | done |
| DNxHD (HD profile) CIDs 1235, 1237, 1238, 1241–1244, 1250–1253, 1258, 1259 | done, oracle-tested |
| CID 1256 (HD RGB 4:4:4) and 1260 (macroblock-adaptive field/frame, 1440×1080i) | done, not oracle-tested (ffmpeg can't encode them) |
| DNxHR (RI profile) 1270–1274: LB, SQ, HQ, HQX, 444; any raster 1…16384 | done, oracle-tested |
| 8-, 10-, 12-bit; 4:2:2, 4:4:4; Y′CbCr and RGB (per-macroblock ACF) | done (12-bit: our encoder's streams, compared with ffmpeg's decode) |
| 4:2:0 (RI only) | done, not oracle-tested (ffmpeg can't encode it) |
| Interlaced: field-coded (two coding units) and frame-coded MACF | done |
| Alpha: DCT-coded and lossless differential RLE (Amendment 1 for HD CIDs) | done, not oracle-tested (no encoder produces it) |
| CBR padding, VBR, scan-line indices, EOF signature / CRC flag | done |
| Encoder: DNxHR LB, SQ, HQ (8-bit), HQX (10/12-bit), 444 (10/12-bit); CBR | done |
| Encoder: DNxHD (HD CIDs), interlaced, alpha, VBR | not yet |
API
use filmcraft_dnx::*;
let frame: Frame = decode_frame(&sample)?; // one or two coding units
let f = decode_frame_with(&sample, &DecodeOptions { threads: false, ..Default::default() })?;
let hdr: FrameHeader = probe(&sample)?; // cid, raster, depth, chroma…
let name = cid_name(hdr.cid); // "DNxHR HQX", "DNxHD 1080i 8-bit (CID 1242)"
let mut enc = Encoder::new(Profile::Hq, 1920, 1080); // or Encoder::with_config(EncoderConfig { .. })
let sample: Vec<u8> = enc.encode(&frame)?; // exactly enc.frame_size() bytes (CBR)
Frame { width, height, chroma, rgb, bit_depth, y, cb, cr: Vec<u16>, alpha, interlace, color_volume, cid, par } holds planar samples without row padding. Interlaced frames are woven
(field 1 = top field on even lines). RGB frames hold G, B, R in y, cb, cr. In MOV the
sample entry is AVdn (DNxHD) or AVdh (DNxHR); filmcraft-codecs reads the CID from the
first frame header to label the stream.
Decoder
- Every coding unit's scan-line indices give each macroblock row's byte offset, so rows decode independently and in parallel, writing straight into their 16-line bands.
- VLCs are decoded with two-level lookup tables (11-bit primary) built from the Annex E lists.
- Inverse quantisation with the CID's Annex D weights; IDCT: the exact orthonormal 8×8 inverse DCT
of equation 8.3, evaluated separably in
f32(shared design withfilmcraft-prores). Output:floor(x·s + 2^(b−1) + ½)clipped to[0, 2^b − 1], wheres = 4at 12 bits (the 12-bit coefficient domain is ¼ of the orthonormal DCT, as Table 14's 13-bit IDCT input range for 12-bit samples implies) and 1 otherwise. - Robustness: every offset and size is validated, run lengths past coefficient 63 are rejected,
and corrupted or truncated streams return
Errwithout panicking (tests/roundtrip.rs).
Where we follow ffmpeg rather than the letter of the standard
ffmpeg's decoder is the reference everyone interoperates with (and the oracle of our tests). In
three places it differs from the text of ST 2019-1:2016; black-box probes (src/ffmpeg_probe.rs:
streams with a single coefficient, decoded by ffmpeg, measured with a forward DCT) pinned down
its behaviour, and we match it by default:
- Reconstruction offset. Equation 8.1 reconstructs a quantised amplitude
Aat ≈(A + ½)·W·qsf/p. ffmpeg reconstructs at(A + ¾)·W·qsf/p(fitted intercept 0.750 ± 0.01 steps for every CID family and step size). The decoder does the same;DecodeOptions::spec_dequantselects equation 8.1 instead. The encoder picks the amplitude nearest to the(A + ¾)reconstruction. - CID 1271 (DNxHR HQX) weights. Table C.2 assigns Table D.1 to CID 1271. ffmpeg's decoder and encoder use Table D.4 (the CID 1241 weights) — fitted RMS error 0.65 against D.4, > 2.6 against every other table. We use D.4.
- 4:4:4 signalling. ffmpeg writes RGB DNxHR 444 with
CLF = 0, and Y′CbCr 444 withCLF = 1plusACF = 1on every macroblock; its channel order is G, B, R (Ch1 = G). The decoder treats every 4:4:4 stream as RGB except macroblocks flaggedACF = 1(a stream that is entirely ACF = 1 is returned as Y′CbCr; mixed streams are converted to RGB macroblock by macroblock as §8.1.1 describes), and the encoder writes the same signalling.
Also noted: the RLE alpha pseudo code (Figure 50) tests the header bit the opposite way from the prose of §7.3.1.2; we follow the prose (normative text takes precedence).
Encoder
- DNxHR LB/SQ/HQ (8-bit 4:2:2), HQX (10- or 12-bit 4:2:2) and 444 (10- or 12-bit 4:4:4, Y′CbCr or RGB), progressive, CBR at exactly the equation 7.1 frame size (e.g. 917 504 bytes for 1080p HQ, 606 208 for SQ). Input at any depth 8–16 is rescaled to the coded depth.
- Every macroblock is transformed once (orthonormal FDCT) and stored pre-divided by its weights.
Rate control binary-searches the smallest uniform
qsfwhose frame fits the payload (including the 4-byte scan-line alignment), starting from the previous frame's value, then gives macroblocks one finer step, spread evenly over the frame, while the budget allows. export::Format::DnxHr(file.exportMedia {"format":"dnxhr","dnxProfile":"lb|sq|hq|hqx"}) writes QuickTimeAVdh+ PCM. The Avid-specificACLR/ADHRsample-entry atoms are not written (ffmpeg and our reader don't need them).
Accuracy (vs ffmpeg as an external oracle)
tests/oracle_decode.rs decodes 22 fixtures made by ffmpeg's VC-3 encoder: CIDs 1235, 1237,
1238, 1241, 1242, 1243, 1244, 1250, 1251, 1252, 1253, 1258, 1259 and DNxHR LB/SQ/HQ/HQX/444
(Y′CbCr and RGB), with testsrc2, mandelbrot and heavy noise, at 1920×1080, 1280×720, 960×720,
1440×1080 (thin raster, interlaced), 1000×562, 640×360, 256×120 and 3840×2160. Compared with
ffmpeg's decode at the native depth, every fixture is within ±2 LSB, with at most ~50 samples
per million at ±2 (typically 1–30 per fixture); 87–99% of samples are bit-identical and the
mean absolute error is 0.01–0.13 LSB. The remaining differences are ffmpeg's integer IDCT; this
decoder evaluates the exact IDCT of equation 8.3, so decode is not bit-exact, but within IDCT
precision of ffmpeg. (ffmpeg wraps rather than clips 10-bit reconstructions that overflow
1023, so the noise fixtures keep their samples inside 24…231 before encoding.)
tests/oracle_encode.rs encodes 1920×1080 frames with every profile and writes them into a
minimal test-only MOV. ffmpeg decodes them with -xerror, without errors, and agrees with our
decoder within ±2 (90–99% identical). Luma PSNR against the source: HQ 58.8 dB (mandelbrot) and
66.3 dB (testsrc2), SQ 57.5 dB, LB 56.1 dB, HQX 10-bit 67.5 dB, HQX 12-bit 80.2 dB, 444 10-bit
79.7 dB — all above the 45 dB HQ target.
Performance
cargo test --release -p filmcraft-dnx --test perf -- --ignored --nocapture on an M4 Pro
(14 cores) while the machine was heavily overloaded by other jobs (load average ≈ 190).
Under the same load filmcraft-prores' perf test measured 30 fps threaded / 11.8 fps single
for a 1080p HQ frame, against 318 / 53 fps in its README on a less loaded machine, so these are
lower bounds by a factor of roughly 4–10:
| Frame (busy synthetic content) | Decode, threaded | Decode, 1 thread | Encode |
|---|---|---|---|
| DNxHR HQ 1080p (896 KB) | 41 fps | 13.2 fps | 4.1 fps |
| DNxHR HQX 10-bit 1080p (896 KB) | 39 fps | 8.9 fps | 3.6 fps |
| DNxHR SQ 2160p (2352 KB) | 21 fps | 15.6 fps | 1.1 fps |
| DNxHR HQX 10-bit 2160p (3556 KB) | 16 fps | 12.2 fps | 1.1 fps |
Gaps
- Decode is within IDCT precision of ffmpeg, not bit-exact (see above).
- Encoder: no DNxHD (HD CIDs), interlaced, alpha or VBR output; no RDO/trellis.
- CIDs 1256/1260 and alpha decoding are implemented from the standard but have no oracle fixtures, because no available encoder produces them.
- No MXF; DNx in MOV only (via
filmcraft-isobmff/filmcraft-codecs).