Skip to main content

oxideav_prores/
lib.rs

1// Parallel-array index loops are idiomatic in codec/DCT code; skip the lint.
2#![allow(clippy::needless_range_loop)]
3
4//! Apple ProRes codec — pure-Rust decoder + encoder following SMPTE
5//! RDD 36:2022.
6//!
7//! Scope: all six ProRes video profiles, dispatched by container FourCC:
8//!
9//! * 422 Proxy / LT / Standard / HQ for 4:2:2 Y'CbCr at 8-, 10-, 12-,
10//!   or 16-bit depth (`Yuv422P{,10Le,12Le,16Le}`, fourccs
11//!   `apco`/`apcs`/`apcn`/`apch`).
12//! * 4444 + 4444 XQ for 4:4:4 Y'CbCr at 8-/10-/12-/16-bit
13//!   (`Yuv444P{,10Le,12Le,16Le}`, fourccs `ap4h`/`ap4x`). These
14//!   profiles also carry an optional per-pixel alpha plane (RDD 36
15//!   §5.3.3) coded losslessly via the raster-scan run-length code from
16//!   §7.1.2 (Tables 12-14). The decoded alpha lands as a 4th
17//!   `VideoPlane` on the output `VideoFrame`; the encoder accepts the
18//!   same shape on input through
19//!   [`encoder::encode_frame_with_alpha`].
20//! * Alpha-typed surfaces on both codec directions: request
21//!   `PixelFormat::Yuva4(2|4)4P{,10Le,12Le,16Le}` in `CodecParameters`
22//!   to make the 4-plane layout part of the format contract — the
23//!   decoder then always emits 4 planes (coded alpha converted to the
24//!   surface depth per §7.5.2, exact at the 16-bit surfaces; a
25//!   no-alpha stream gets a synthesised opaque plane), and the encoder
26//!   requires 4-plane input and codes alpha on every frame (bitstream
27//!   version 1 per §6.4) — 16-bit coded alpha for the deep formats, so
28//!   no input precision is dropped on the wire. See
29//!   [`decoder::decode_packet_with_format`].
30//!
31//! ### Bitstream
32//!
33//! * `frame() { frame_size, 'icpf', frame_header(), picture()+ }` per
34//!   RDD 36 §5.1.
35//! * `picture() { picture_header(), slice_table(), slice()+ }` per §5.2.
36//! * `slice() { slice_header(), Y' data, Cb data, Cr data, [A' data] }`
37//!   per §5.3, with each color component coded by §7.1.1's run/level/
38//!   sign entropy coder and the optional alpha array coded by §7.1.2's
39//!   run-length / VLC code.
40//!
41//! Bitstream syntax, entropy coder, slice + block scans, alpha entropy
42//! coder, and inverse quantization are bit-exact with the spec. The
43//! IDCT is a textbook float implementation (§7.4 allows fixed- or
44//! floating-point, subject to Annex A accuracy) — sufficient for visual
45//! fidelity.
46//!
47//! ### Interlaced (RDD 36 §5.1, §6.2, §7.5.3)
48//!
49//! Interlaced frames carry two pictures (one per field) sharing one
50//! frame_header(). The encoder splits the source into top + bottom
51//! fields per §7.5.3 (top field = source rows 0, 2, 4, …; bottom field
52//! = rows 1, 3, 5, …) and emits them in the order indicated by
53//! `interlace_mode` (1 = top-field-first, 2 = bottom-field-first).
54//! Each picture uses the interlaced block scan (§7.2 Figure 5) instead
55//! of the progressive one (Figure 4). The decoder reverses the
56//! deinterleave on output. See [`encoder::encode_frame_interlaced`].
57//!
58//! ### Module layout
59//!
60//! * [`bitstream`] — MSB-first bit reader/writer.
61//! * [`entropy`]   — RDD 36 Golomb-Rice / exp-Golomb combination codes
62//!   plus the adaptive run/level/sign coefficient coder.
63//! * [`alpha`]     — Run-length + diff-VLC alpha entropy coder
64//!   (Tables 12-14) + alpha pixel-sample mapping (§7.5.2).
65//! * [`dct`]       — Textbook f32 8x8 forward/inverse DCT.
66//! * [`quant`]     — Default quant matrices, qScale table, block scans.
67//! * [`slice`]     — Per-slice pack/unpack: per-component encode +
68//!   inverse slice scan into natural-order blocks.
69//! * [`frame`]     — Frame / picture / slice header layouts.
70//! * [`decoder`]   — `Packet -> VideoFrame`
71//!   (Yuv4(2|4)4P{,10Le,12Le,16Le} / Yuva4(2|4)4P{,10Le,12Le,16Le},
72//!   optional 4th alpha plane).
73//! * [`encoder`]   — `VideoFrame` -> `Packet`, with optional alpha.
74
75pub mod alpha;
76pub mod bitstream;
77pub mod dct;
78pub mod decoder;
79pub mod encoder;
80pub mod entropy;
81pub mod frame;
82pub mod quant;
83pub mod slice;
84
85use oxideav_core::{CodecCapabilities, CodecId, CodecTag, PixelFormat};
86use oxideav_core::{CodecInfo, CodecRegistry, RuntimeContext};
87
88/// Public codec id.
89pub const CODEC_ID_STR: &str = "prores";
90
91/// All six MP4 / MOV `VisualSampleEntry` FourCCs that identify a
92/// ProRes bitstream. Canonical lower-case spelling as defined by
93/// Apple's ProRes white paper (April 2022):
94///
95/// | fourcc | profile                                     |
96/// |--------|---------------------------------------------|
97/// | apco   | Apple ProRes 422 Proxy                      |
98/// | apcs   | Apple ProRes 422 LT                         |
99/// | apcn   | Apple ProRes 422 (Standard)                 |
100/// | apch   | Apple ProRes 422 HQ                         |
101/// | ap4h   | Apple ProRes 4444                           |
102/// | ap4x   | Apple ProRes 4444 XQ                        |
103pub const PRORES_FOURCCS: [&[u8; 4]; 6] = [b"apco", b"apcs", b"apcn", b"apch", b"ap4h", b"ap4x"];
104
105/// MP4 / MOV `VisualSampleEntry` FourCCs that identify an Apple
106/// **ProRes RAW** bitstream. ProRes RAW is a *separate* Apple format
107/// that wraps single-plane Bayer/CFA sensor data; it is NOT one of the
108/// six RDD 36 YUV/RGB profiles this crate decodes and uses an
109/// incompatible sample structure. It has **no public bitstream
110/// specification**: no SMPTE-registered document covers it, and Apple's
111/// only published ProRes RAW document is a marketing white paper with
112/// no frame/slice syntax, entropy coding, transform, or quantisation
113/// description — there is no normative source a decoder could be
114/// implemented from. These FourCCs deliberately resolve to
115/// neither a [`CodecId`] nor a [`frame::Profile`] here —
116/// [`is_prores_raw_fourcc`] lets a caller tell "ProRes RAW, which we
117/// don't decode" apart from "not ProRes at all".
118///
119/// | fourcc | profile          |
120/// |--------|------------------|
121/// | aprn   | Apple ProRes RAW |
122/// | aprh   | Apple ProRes RAW HQ |
123pub const PRORES_RAW_FOURCCS: [&[u8; 4]; 2] = [b"aprn", b"aprh"];
124
125/// Returns `true` if `fourcc` (case-insensitive) is an Apple ProRes RAW
126/// `VisualSampleEntry` FourCC (`aprn` / `aprh`).
127///
128/// ProRes RAW is out of scope for this crate (see [`PRORES_RAW_FOURCCS`]).
129/// A demuxer/dispatcher that sees a ProRes RAW track should surface a
130/// clear unsupported-format error rather than route it to the standard
131/// ProRes decoder, whose bitstream layout is incompatible.
132pub fn is_prores_raw_fourcc(fourcc: &[u8; 4]) -> bool {
133    let mut upper = [0u8; 4];
134    for i in 0..4 {
135        upper[i] = fourcc[i].to_ascii_uppercase();
136    }
137    matches!(&upper, b"APRN" | b"APRH")
138}
139
140/// Returns `Some(CodecId::new("prores"))` if `fourcc` (case-insensitive)
141/// is one of the six ProRes MP4/MOV `VisualSampleEntry` FourCCs.
142pub fn codec_id_for_fourcc(fourcc: &[u8; 4]) -> Option<CodecId> {
143    let mut upper = [0u8; 4];
144    for i in 0..4 {
145        upper[i] = fourcc[i].to_ascii_uppercase();
146    }
147    match &upper {
148        b"APCO" | b"APCS" | b"APCN" | b"APCH" | b"AP4H" | b"AP4X" => {
149            Some(CodecId::new(CODEC_ID_STR))
150        }
151        _ => None,
152    }
153}
154
155/// Returns the matching [`frame::Profile`] for a given MP4/MOV FourCC.
156pub fn profile_for_fourcc(fourcc: &[u8; 4]) -> Option<frame::Profile> {
157    let mut upper = [0u8; 4];
158    for i in 0..4 {
159        upper[i] = fourcc[i].to_ascii_uppercase();
160    }
161    Some(match &upper {
162        b"APCO" => frame::Profile::Proxy,
163        b"APCS" => frame::Profile::Lt,
164        b"APCN" => frame::Profile::Standard,
165        b"APCH" => frame::Profile::Hq,
166        b"AP4H" => frame::Profile::Prores4444,
167        b"AP4X" => frame::Profile::Prores4444Xq,
168        _ => return None,
169    })
170}
171
172/// Returns the canonical (lowercase) MP4/MOV `VisualSampleEntry` FourCC for
173/// a given [`frame::Profile`] — the exact inverse of [`profile_for_fourcc`].
174///
175/// This completes the FourCC routing surface at the crate root: where a
176/// demuxer/dispatcher maps an on-wire FourCC to a profile via
177/// [`profile_for_fourcc`], a muxer assembling a sample entry (QuickTime
178/// `stsd` / MXF Picture Essence Descriptor) maps an encoder-selected
179/// profile back to the FourCC it must write. The encoder picks a profile
180/// from `(pixel_format, bit_rate)` via [`encoder::pick_profile`] (or an
181/// explicit [`encoder::EncoderConfig::with_profile`] override); the FourCC
182/// the wrapper carries is then `fourcc_for_profile(profile)`.
183///
184/// The six FourCCs are the same canonical-lowercase byte strings listed in
185/// [`PRORES_FOURCCS`] (and returned by [`frame::Profile::fourcc`]); pass
186/// the result through [`profile_for_fourcc`] — which is case-insensitive —
187/// to round-trip back to the original profile.
188pub fn fourcc_for_profile(profile: frame::Profile) -> &'static [u8; 4] {
189    profile.fourcc()
190}
191
192/// Register the ProRes decoder + encoder for all six profiles
193/// (422 Proxy/LT/Standard/HQ and 4444 / 4444 XQ).
194pub fn register_codecs(reg: &mut CodecRegistry) {
195    let caps = CodecCapabilities::video("prores_sw")
196        .with_lossy(true)
197        .with_intra_only(true)
198        .with_pixel_format(PixelFormat::Yuv422P)
199        .with_pixel_format(PixelFormat::Yuv444P)
200        .with_pixel_format(PixelFormat::Yuv422P10Le)
201        .with_pixel_format(PixelFormat::Yuv444P10Le)
202        .with_pixel_format(PixelFormat::Yuv422P12Le)
203        .with_pixel_format(PixelFormat::Yuv444P12Le)
204        .with_pixel_format(PixelFormat::Yuv422P16Le)
205        .with_pixel_format(PixelFormat::Yuv444P16Le)
206        .with_pixel_format(PixelFormat::Yuva422P)
207        .with_pixel_format(PixelFormat::Yuva444P)
208        .with_pixel_format(PixelFormat::Yuva422P10Le)
209        .with_pixel_format(PixelFormat::Yuva444P10Le)
210        .with_pixel_format(PixelFormat::Yuva422P12Le)
211        .with_pixel_format(PixelFormat::Yuva444P12Le)
212        .with_pixel_format(PixelFormat::Yuva422P16Le)
213        .with_pixel_format(PixelFormat::Yuva444P16Le);
214    reg.register(
215        CodecInfo::new(CodecId::new(CODEC_ID_STR))
216            .capabilities(caps)
217            .decoder(decoder::make_decoder)
218            .encoder(encoder::make_encoder)
219            .tags([
220                CodecTag::fourcc(b"APCO"),
221                CodecTag::fourcc(b"APCS"),
222                CodecTag::fourcc(b"APCN"),
223                CodecTag::fourcc(b"APCH"),
224                CodecTag::fourcc(b"AP4H"),
225                CodecTag::fourcc(b"AP4X"),
226            ]),
227    );
228}
229
230/// Unified registration entry point: install the ProRes codec
231/// factories into the codec sub-registry of a [`RuntimeContext`].
232///
233/// This is the preferred entry point for new code — it matches the
234/// convention every sibling crate now follows. Direct callers that need
235/// only the codec sub-registry can keep using [`register_codecs`].
236pub fn register(ctx: &mut RuntimeContext) {
237    register_codecs(&mut ctx.codecs);
238}
239
240oxideav_core::register!("prores", register);
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use oxideav_core::frame::VideoPlane;
246    use oxideav_core::{CodecId, CodecParameters, Frame, MediaType, PixelFormat, VideoFrame};
247
248    /// Build a 64x48 gradient in Yuv422P. Values are deliberately
249    /// smooth so the codec's lossy path can hit reasonable PSNR at
250    /// `quant_index = 4`.
251    fn synthetic_gradient(width: u32, height: u32) -> VideoFrame {
252        let w = width as usize;
253        let h = height as usize;
254        let cw = w / 2;
255        let mut y = vec![0u8; w * h];
256        let mut cb = vec![0u8; cw * h];
257        let mut cr = vec![0u8; cw * h];
258        for j in 0..h {
259            for i in 0..w {
260                y[j * w + i] = ((i + j) * 255 / (w + h)).min(255) as u8;
261            }
262        }
263        for j in 0..h {
264            for i in 0..cw {
265                cb[j * cw + i] = (128 + ((i as i32 - cw as i32 / 2) * 2).clamp(-64, 64)) as u8;
266                cr[j * cw + i] = (128 + ((j as i32 - h as i32 / 2) * 2).clamp(-64, 64)) as u8;
267            }
268        }
269        let _ = width;
270        let _ = height;
271        VideoFrame {
272            pts: Some(0),
273            planes: vec![
274                VideoPlane { stride: w, data: y },
275                VideoPlane {
276                    stride: cw,
277                    data: cb,
278                },
279                VideoPlane {
280                    stride: cw,
281                    data: cr,
282                },
283            ],
284        }
285    }
286
287    fn psnr(orig: &[u8], decoded: &[u8]) -> f64 {
288        assert_eq!(orig.len(), decoded.len());
289        let mut mse = 0.0f64;
290        for (a, b) in orig.iter().zip(decoded.iter()) {
291            let d = *a as f64 - *b as f64;
292            mse += d * d;
293        }
294        mse /= orig.len() as f64;
295        if mse == 0.0 {
296            return 120.0;
297        }
298        10.0 * (255.0f64 * 255.0 / mse).log10()
299    }
300
301    #[test]
302    fn rdd36_encoder_decoder_roundtrip_psnr() {
303        let width = 64u32;
304        let height = 48u32;
305        let original = synthetic_gradient(width, height);
306
307        let mut enc_params = CodecParameters::video(CodecId::new(CODEC_ID_STR));
308        enc_params.media_type = MediaType::Video;
309        enc_params.width = Some(width);
310        enc_params.height = Some(height);
311        enc_params.pixel_format = Some(PixelFormat::Yuv422P);
312
313        let mut reg = oxideav_core::CodecRegistry::new();
314        register_codecs(&mut reg);
315        let mut encoder = reg.first_encoder(&enc_params).expect("make_encoder");
316        encoder
317            .send_frame(&Frame::Video(original.clone()))
318            .expect("send_frame");
319        let pkt = encoder.receive_packet().expect("receive_packet");
320
321        let dec_params = enc_params.clone();
322        let mut decoder = reg.first_decoder(&dec_params).expect("make_decoder");
323        decoder.send_packet(&pkt).expect("send_packet");
324        let frame = decoder.receive_frame().expect("receive_frame");
325        let decoded = match frame {
326            Frame::Video(v) => v,
327            _ => panic!("expected video frame"),
328        };
329
330        assert_eq!(decoded.planes.len(), 3);
331        for (i, (o, d)) in original
332            .planes
333            .iter()
334            .zip(decoded.planes.iter())
335            .enumerate()
336        {
337            assert_eq!(o.data.len(), d.data.len(), "plane {i} size mismatch");
338            let p = psnr(&o.data, &d.data);
339            assert!(p > 30.0, "plane {i} PSNR too low: {p:.2} dB (want > 30)");
340            eprintln!("plane {i} PSNR = {p:.2} dB");
341        }
342    }
343
344    #[test]
345    fn registry_caps_advertise_every_supported_pixel_format() {
346        // The advertised capability list is the discovery surface a
347        // framework caller negotiates against — it must name every
348        // format the factories accept: the 8 colour-only requests plus
349        // the 8 alpha-typed ones (8/10/12/16-bit at 4:2:2 and 4:4:4).
350        let mut reg = oxideav_core::CodecRegistry::new();
351        register_codecs(&mut reg);
352        let impls = reg.implementations(&CodecId::new(CODEC_ID_STR));
353        assert_eq!(impls.len(), 1);
354        let advertised = &impls[0].caps.accepted_pixel_formats;
355        let expected = [
356            PixelFormat::Yuv422P,
357            PixelFormat::Yuv444P,
358            PixelFormat::Yuv422P10Le,
359            PixelFormat::Yuv444P10Le,
360            PixelFormat::Yuv422P12Le,
361            PixelFormat::Yuv444P12Le,
362            PixelFormat::Yuv422P16Le,
363            PixelFormat::Yuv444P16Le,
364            PixelFormat::Yuva422P,
365            PixelFormat::Yuva444P,
366            PixelFormat::Yuva422P10Le,
367            PixelFormat::Yuva444P10Le,
368            PixelFormat::Yuva422P12Le,
369            PixelFormat::Yuva444P12Le,
370            PixelFormat::Yuva422P16Le,
371            PixelFormat::Yuva444P16Le,
372        ];
373        assert_eq!(advertised.len(), expected.len());
374        for pf in expected {
375            assert!(
376                advertised.contains(&pf),
377                "capabilities must advertise {pf:?}"
378            );
379            // And every advertised format actually builds both factories.
380            let mut p = CodecParameters::video(CodecId::new(CODEC_ID_STR));
381            p.media_type = MediaType::Video;
382            p.width = Some(64);
383            p.height = Some(48);
384            p.pixel_format = Some(pf);
385            assert!(reg.first_decoder(&p).is_ok(), "decoder for {pf:?}");
386            assert!(reg.first_encoder(&p).is_ok(), "encoder for {pf:?}");
387        }
388    }
389
390    #[test]
391    fn decoder_registered() {
392        let mut reg = oxideav_core::CodecRegistry::new();
393        register_codecs(&mut reg);
394        assert!(reg.has_decoder(&CodecId::new(CODEC_ID_STR)));
395        assert!(reg.has_encoder(&CodecId::new(CODEC_ID_STR)));
396    }
397
398    #[test]
399    fn register_via_runtime_context_installs_codec_factory() {
400        let mut ctx = oxideav_core::RuntimeContext::new();
401        register(&mut ctx);
402        assert!(ctx.codecs.has_decoder(&CodecId::new(CODEC_ID_STR)));
403        assert!(ctx.codecs.has_encoder(&CodecId::new(CODEC_ID_STR)));
404    }
405
406    #[test]
407    fn codec_id_for_fourcc_maps_all_six() {
408        for fc in PRORES_FOURCCS {
409            assert_eq!(codec_id_for_fourcc(fc), Some(CodecId::new(CODEC_ID_STR)));
410        }
411    }
412
413    #[test]
414    fn codec_id_for_fourcc_is_case_insensitive() {
415        assert_eq!(
416            codec_id_for_fourcc(b"APCH"),
417            Some(CodecId::new(CODEC_ID_STR))
418        );
419        assert_eq!(
420            codec_id_for_fourcc(b"apch"),
421            Some(CodecId::new(CODEC_ID_STR))
422        );
423        assert_eq!(
424            codec_id_for_fourcc(b"ApCh"),
425            Some(CodecId::new(CODEC_ID_STR))
426        );
427    }
428
429    #[test]
430    fn codec_id_for_fourcc_rejects_non_prores() {
431        assert_eq!(codec_id_for_fourcc(b"avc1"), None);
432        assert_eq!(codec_id_for_fourcc(b"hvc1"), None);
433        assert_eq!(codec_id_for_fourcc(b"mp4v"), None);
434        assert_eq!(codec_id_for_fourcc(b"alac"), None);
435        assert_eq!(codec_id_for_fourcc(b"av01"), None);
436    }
437
438    #[test]
439    fn prores_raw_fourcc_does_not_resolve_to_standard_prores() {
440        // ProRes RAW is out of scope: aprn/aprh must NOT map to the
441        // standard prores codec id or any of the six RDD 36 profiles,
442        // so a dispatcher never routes a RAW sample to this decoder.
443        for fc in PRORES_RAW_FOURCCS {
444            assert_eq!(codec_id_for_fourcc(fc), None, "raw fourcc {fc:?}");
445            assert_eq!(profile_for_fourcc(fc), None, "raw fourcc {fc:?}");
446        }
447    }
448
449    #[test]
450    fn is_prores_raw_fourcc_detects_aprn_aprh_case_insensitive() {
451        for fc in PRORES_RAW_FOURCCS {
452            assert!(is_prores_raw_fourcc(fc), "lower {fc:?}");
453            let mut up = *fc;
454            up.make_ascii_uppercase();
455            assert!(is_prores_raw_fourcc(&up), "upper {up:?}");
456        }
457        assert!(is_prores_raw_fourcc(b"ApRh"));
458        // Standard ProRes FourCCs are not ProRes RAW.
459        for fc in PRORES_FOURCCS {
460            assert!(!is_prores_raw_fourcc(fc), "standard {fc:?}");
461        }
462        // Unrelated FourCCs are not ProRes RAW.
463        assert!(!is_prores_raw_fourcc(b"avc1"));
464        assert!(!is_prores_raw_fourcc(b"av01"));
465        // A near-miss that shares the `apr` prefix but is not a defined
466        // ProRes RAW tag must not be misclassified.
467        assert!(!is_prores_raw_fourcc(b"aprx"));
468    }
469
470    #[test]
471    fn decode_packet_rejects_prores_raw_marker_with_unsupported() {
472        // A sample whose in-stream marker is `aprh` (ProRes RAW) must
473        // produce a clear Unsupported error, distinct from the generic
474        // "magic mismatch" Invalid error for non-ProRes bytes.
475        let mut raw = Vec::new();
476        raw.extend_from_slice(&16u32.to_be_bytes()); // frame_size
477        raw.extend_from_slice(b"aprh"); // ProRes RAW in-stream marker
478        raw.extend_from_slice(&[0u8; 8]); // padding to frame_size
479        let err = decoder::decode_packet(&raw, None).expect_err("must reject ProRes RAW");
480        let msg = err.to_string();
481        assert!(
482            msg.contains("ProRes RAW"),
483            "error should name ProRes RAW, got: {msg}"
484        );
485
486        // Bytes that are neither ProRes nor ProRes RAW still get the
487        // generic magic-mismatch error (and must not mention ProRes RAW).
488        let mut other = Vec::new();
489        other.extend_from_slice(&16u32.to_be_bytes());
490        other.extend_from_slice(b"junk");
491        other.extend_from_slice(&[0u8; 8]);
492        let err2 = decoder::decode_packet(&other, None).expect_err("must reject non-ProRes");
493        assert!(
494            !err2.to_string().contains("ProRes RAW"),
495            "non-ProRes bytes should not be reported as ProRes RAW"
496        );
497    }
498
499    #[test]
500    fn profile_for_fourcc_roundtrips_via_profile_fourcc() {
501        for p in [
502            frame::Profile::Proxy,
503            frame::Profile::Lt,
504            frame::Profile::Standard,
505            frame::Profile::Hq,
506            frame::Profile::Prores4444,
507            frame::Profile::Prores4444Xq,
508        ] {
509            let fc = p.fourcc();
510            assert_eq!(profile_for_fourcc(fc), Some(p), "fourcc roundtrip");
511            let mut up = *fc;
512            up.make_ascii_uppercase();
513            assert_eq!(profile_for_fourcc(&up), Some(p));
514        }
515        assert_eq!(profile_for_fourcc(b"mp4v"), None);
516    }
517
518    #[test]
519    fn fourcc_for_profile_inverts_profile_for_fourcc() {
520        // Every profile maps to a FourCC that maps back to that profile —
521        // `fourcc_for_profile` is the exact inverse of `profile_for_fourcc`.
522        for p in [
523            frame::Profile::Proxy,
524            frame::Profile::Lt,
525            frame::Profile::Standard,
526            frame::Profile::Hq,
527            frame::Profile::Prores4444,
528            frame::Profile::Prores4444Xq,
529        ] {
530            let fc = fourcc_for_profile(p);
531            // Canonical (lowercase) form, identical to `Profile::fourcc()`
532            // and to the entries of `PRORES_FOURCCS`.
533            assert_eq!(fc, p.fourcc(), "canonical fourcc for {p:?}");
534            assert!(
535                PRORES_FOURCCS.contains(&fc),
536                "{fc:?} must be one of the six carriage FourCCs"
537            );
538            assert_eq!(
539                profile_for_fourcc(fc),
540                Some(p),
541                "fourcc_for_profile -> profile_for_fourcc round-trip for {p:?}"
542            );
543        }
544    }
545
546    #[test]
547    fn fourcc_for_profile_returns_lowercase_canonical_bytes() {
548        // The six canonical FourCCs are the lowercase byte strings the MOV
549        // sample entry / MXF descriptor carry (the corpus wrapper FourCCs).
550        assert_eq!(fourcc_for_profile(frame::Profile::Proxy), b"apco");
551        assert_eq!(fourcc_for_profile(frame::Profile::Lt), b"apcs");
552        assert_eq!(fourcc_for_profile(frame::Profile::Standard), b"apcn");
553        assert_eq!(fourcc_for_profile(frame::Profile::Hq), b"apch");
554        assert_eq!(fourcc_for_profile(frame::Profile::Prores4444), b"ap4h");
555        assert_eq!(fourcc_for_profile(frame::Profile::Prores4444Xq), b"ap4x");
556    }
557
558    #[test]
559    fn registry_recognizes_prores_fourcc_tags() {
560        use oxideav_core::stream::{CodecResolver, ProbeContext};
561        use oxideav_core::CodecTag;
562        let mut reg = oxideav_core::CodecRegistry::new();
563        register_codecs(&mut reg);
564        for fc in PRORES_FOURCCS {
565            let tag = CodecTag::fourcc(fc);
566            let ctx = ProbeContext::new(&tag);
567            let id = reg.resolve_tag(&ctx).expect("resolve_tag");
568            assert_eq!(id, CodecId::new(CODEC_ID_STR), "fourcc {fc:?}");
569        }
570    }
571
572    fn synthetic_gradient_444(width: u32, height: u32) -> VideoFrame {
573        let w = width as usize;
574        let h = height as usize;
575        let mut y = vec![0u8; w * h];
576        let mut cb = vec![0u8; w * h];
577        let mut cr = vec![0u8; w * h];
578        for j in 0..h {
579            for i in 0..w {
580                y[j * w + i] = ((i + j) * 255 / (w + h)).min(255) as u8;
581                cb[j * w + i] = (128 + ((i as i32 - w as i32 / 2) * 2).clamp(-64, 64)) as u8;
582                cr[j * w + i] = (128 + ((j as i32 - h as i32 / 2) * 2).clamp(-64, 64)) as u8;
583            }
584        }
585        VideoFrame {
586            pts: Some(0),
587            planes: vec![
588                VideoPlane { stride: w, data: y },
589                VideoPlane {
590                    stride: w,
591                    data: cb,
592                },
593                VideoPlane {
594                    stride: w,
595                    data: cr,
596                },
597            ],
598        }
599    }
600
601    #[test]
602    fn rdd36_encoder_decoder_roundtrip_psnr_4444() {
603        let width = 64u32;
604        let height = 48u32;
605        let original = synthetic_gradient_444(width, height);
606
607        let mut enc_params = CodecParameters::video(CodecId::new(CODEC_ID_STR));
608        enc_params.media_type = MediaType::Video;
609        enc_params.width = Some(width);
610        enc_params.height = Some(height);
611        enc_params.pixel_format = Some(PixelFormat::Yuv444P);
612
613        let mut reg = oxideav_core::CodecRegistry::new();
614        register_codecs(&mut reg);
615        let mut encoder = reg.first_encoder(&enc_params).expect("make_encoder");
616        encoder
617            .send_frame(&Frame::Video(original.clone()))
618            .expect("send_frame");
619        let pkt = encoder.receive_packet().expect("receive_packet");
620
621        let dec_params = enc_params.clone();
622        let mut decoder = reg.first_decoder(&dec_params).expect("make_decoder");
623        decoder.send_packet(&pkt).expect("send_packet");
624        let frame = decoder.receive_frame().expect("receive_frame");
625        let decoded = match frame {
626            Frame::Video(v) => v,
627            _ => panic!("expected video frame"),
628        };
629
630        assert_eq!(decoded.planes.len(), 3);
631        for (i, (o, d)) in original
632            .planes
633            .iter()
634            .zip(decoded.planes.iter())
635            .enumerate()
636        {
637            assert_eq!(o.data.len(), d.data.len(), "plane {i} size mismatch");
638            let p = psnr(&o.data, &d.data);
639            assert!(
640                p > 30.0,
641                "4444 plane {i} PSNR too low: {p:.2} dB (want > 30)"
642            );
643            eprintln!("4444 plane {i} PSNR = {p:.2} dB");
644        }
645    }
646}