Skip to main content

oxideav_basic/
y4m.rs

1//! YUV4MPEG2 (`.y4m`) raw-video container.
2//!
3//! A `.y4m` file is the simplest possible video container: a single
4//! ASCII header line followed by a sequence of `FRAME\n`-prefixed raw
5//! planar YUV pictures. There is no index, no random-access support,
6//! and no in-stream codec tag — every payload is rawvideo.
7//!
8//! # Wire format
9//!
10//! ```text
11//! YUV4MPEG2 W<w> H<h> F<num>:<den> Ip A<n>:<d> C<colorspace> [X<k>=<v>...]\n
12//! FRAME [X<k>=<v>...]\n
13//! <Y plane><U plane><V plane>
14//! FRAME ...\n
15//! ...
16//! ```
17//!
18//! - The header begins with the literal `YUV4MPEG2 ` magic (10 bytes,
19//!   trailing space included).
20//! - Each parameter is a single ASCII letter followed by a value, with
21//!   space-separated tokens and a `\n` terminator.
22//! - `W` (width) and `H` (height) are required.
23//! - `F<num>:<den>` is the frame rate.
24//! - `I` is the interlace mode: `p` progressive (default), `t` top
25//!   first, `b` bottom first, `m` mixed.
26//! - `A<num>:<den>` is the pixel aspect ratio.
27//! - `C<colorspace>` selects chroma subsampling and bit depth. Common
28//!   values: `420jpeg`, `420mpeg2`, `420paldv`, `420`, `422`, `444`,
29//!   `mono`, with optional `p10`/`p12`/`p14`/`p16` suffix for higher
30//!   bit depths.
31//! - `X<key>=<val>` extension tokens are preserved verbatim in the
32//!   demuxer's [`metadata()`](Demuxer::metadata) output and ignored by
33//!   the muxer (per-frame extensions are simply dropped on decode and
34//!   never emitted on encode).
35//!
36//! # Plane sizes
37//!
38//! For an 8-bit stream:
39//!
40//! | colorspace | Y bytes | U bytes        | V bytes        |
41//! |------------|---------|----------------|----------------|
42//! | `420*`     | `w*h`   | `(w/2)*(h/2)`  | `(w/2)*(h/2)`  |
43//! | `422`      | `w*h`   | `(w/2)*h`      | `(w/2)*h`      |
44//! | `444`      | `w*h`   | `w*h`          | `w*h`          |
45//! | `mono`     | `w*h`   | 0              | 0              |
46//!
47//! `p10`/`p12`/`p14`/`p16` doubles every byte count (16-bit
48//! little-endian samples, per the ffmpeg convention).
49//!
50//! # Codec id
51//!
52//! Frames are emitted as packets carrying the `rawvideo` codec id.
53//! Pixel format and dimensions are exposed via the stream's
54//! [`CodecParameters`].
55
56use oxideav_core::{
57    CodecId, CodecParameters, CodecResolver, Error, MediaType, Packet, PixelFormat, Rational,
58    Result, StreamInfo, TimeBase,
59};
60use oxideav_core::{ContainerRegistry, Demuxer, Muxer, ProbeData, ReadSeek, WriteSeek};
61use std::io::{Read, Write};
62
63/// 10-byte magic prefix (the trailing space is part of the magic).
64pub(crate) const MAGIC: &[u8; 10] = b"YUV4MPEG2 ";
65
66pub fn register(reg: &mut ContainerRegistry) {
67    reg.register_demuxer("y4m", open_demuxer);
68    reg.register_muxer("y4m", open_muxer);
69    reg.register_extension("y4m", "y4m");
70    reg.register_extension("yuv4mpeg", "y4m");
71    reg.register_probe("y4m", probe);
72}
73
74/// `YUV4MPEG2 ` magic at offset 0 — unambiguous.
75fn probe(p: &ProbeData) -> u8 {
76    if p.buf.len() < MAGIC.len() {
77        return 0;
78    }
79    if &p.buf[..MAGIC.len()] == MAGIC.as_slice() {
80        100
81    } else {
82        0
83    }
84}
85
86// ───────────────────────── header parser ─────────────────────────
87
88/// Parsed header parameters.
89#[derive(Clone, Debug)]
90struct Y4mHeader {
91    width: u32,
92    height: u32,
93    frame_rate: Rational,
94    pixel_aspect: Rational,
95    interlace: u8,
96    colorspace: String,
97    /// Extension parameters (`X<k>=<v>`) preserved as `("X<k>", "<v>")`
98    /// pairs to round-trip them through `Demuxer::metadata`.
99    x_params: Vec<(String, String)>,
100}
101
102/// Map a Y4M `C<colorspace>` token to a [`PixelFormat`] plus the chroma
103/// subsampling (`hss`, `vss`) and bytes-per-sample. Returns `None` for
104/// formats this implementation can't represent in `oxideav-core`.
105fn pixel_format_for_colorspace(c: &str) -> Option<(PixelFormat, u32, u32, u32)> {
106    // (pf, horizontal_chroma_shift, vertical_chroma_shift, bytes_per_sample)
107    match c {
108        // 8-bit limited-range (TV-range) variants — there is no
109        // dedicated PixelFormat for paldv/mpeg2/jpeg colour matrices,
110        // so they all map to Yuv420P. Demuxer::metadata preserves the
111        // original tag for clients that care.
112        "420" | "420jpeg" | "420mpeg2" | "420paldv" => Some((PixelFormat::Yuv420P, 1, 1, 1)),
113        "422" => Some((PixelFormat::Yuv422P, 1, 0, 1)),
114        "444" => Some((PixelFormat::Yuv444P, 0, 0, 1)),
115        "mono" => Some((PixelFormat::Gray8, 0, 0, 1)),
116
117        // 10/12-bit variants. The Y4M convention is little-endian per ffmpeg.
118        "420p10" => Some((PixelFormat::Yuv420P10Le, 1, 1, 2)),
119        "422p10" => Some((PixelFormat::Yuv422P10Le, 1, 0, 2)),
120        "444p10" => Some((PixelFormat::Yuv444P10Le, 0, 0, 2)),
121        "420p12" => Some((PixelFormat::Yuv420P12Le, 1, 1, 2)),
122        "422p12" => Some((PixelFormat::Yuv422P12Le, 1, 0, 2)),
123        "444p12" => Some((PixelFormat::Yuv444P12Le, 0, 0, 2)),
124        "monop10" | "monop12" | "monop16" => Some((PixelFormat::Gray16Le, 0, 0, 2)),
125        _ => None,
126    }
127}
128
129/// Inverse of [`pixel_format_for_colorspace`] — pick a Y4M `C<...>`
130/// token that the muxer can write for a given pixel format. Defaults
131/// stay close to the ffmpeg convention (`420mpeg2` → `420mpeg2`,
132/// generic `Yuv420P` → `420jpeg`).
133fn colorspace_for_pixel_format(pf: PixelFormat) -> Result<&'static str> {
134    Ok(match pf {
135        PixelFormat::Yuv420P => "420mpeg2",
136        PixelFormat::YuvJ420P => "420jpeg",
137        PixelFormat::Yuv422P | PixelFormat::YuvJ422P => "422",
138        PixelFormat::Yuv444P | PixelFormat::YuvJ444P => "444",
139        PixelFormat::Gray8 => "mono",
140        PixelFormat::Yuv420P10Le => "420p10",
141        PixelFormat::Yuv422P10Le => "422p10",
142        PixelFormat::Yuv444P10Le => "444p10",
143        PixelFormat::Yuv420P12Le => "420p12",
144        PixelFormat::Yuv422P12Le => "422p12",
145        PixelFormat::Yuv444P12Le => "444p12",
146        PixelFormat::Gray16Le => "monop16",
147        other => {
148            return Err(Error::unsupported(format!(
149                "y4m muxer: pixel format {:?} cannot be expressed as a Y4M colorspace",
150                other
151            )));
152        }
153    })
154}
155
156/// Read a single line ending in `\n` from `input`. The terminating
157/// newline is consumed but not included in the returned bytes. Returns
158/// `Error::invalid` if the line exceeds `cap` bytes (DoS guard) or if
159/// the stream ends before a newline arrives.
160fn read_line(input: &mut dyn ReadSeek, cap: usize) -> Result<Vec<u8>> {
161    let mut buf = Vec::with_capacity(64);
162    let mut byte = [0u8; 1];
163    loop {
164        match input.read(&mut byte) {
165            Ok(0) => {
166                if buf.is_empty() {
167                    return Err(Error::Eof);
168                }
169                return Err(Error::invalid("y4m: unterminated header/FRAME line"));
170            }
171            Ok(_) => {}
172            Err(e) => return Err(Error::from(e)),
173        }
174        if byte[0] == b'\n' {
175            return Ok(buf);
176        }
177        if buf.len() >= cap {
178            return Err(Error::invalid("y4m: header/FRAME line exceeded sanity cap"));
179        }
180        buf.push(byte[0]);
181    }
182}
183
184/// Parse a `<num>:<den>` ratio. Both numerator and denominator must be
185/// non-negative `i64`s.
186fn parse_ratio(s: &str, ctx: &str) -> Result<Rational> {
187    let mut it = s.splitn(2, ':');
188    let num = it
189        .next()
190        .ok_or_else(|| Error::invalid(format!("y4m: malformed {ctx} '{s}'")))?
191        .parse::<i64>()
192        .map_err(|_| Error::invalid(format!("y4m: non-numeric {ctx} '{s}'")))?;
193    let den = it
194        .next()
195        .ok_or_else(|| Error::invalid(format!("y4m: malformed {ctx} '{s}'")))?
196        .parse::<i64>()
197        .map_err(|_| Error::invalid(format!("y4m: non-numeric {ctx} '{s}'")))?;
198    if num < 0 || den < 0 {
199        return Err(Error::invalid(format!("y4m: negative {ctx} '{s}'")));
200    }
201    Ok(Rational::new(num, den))
202}
203
204/// Parse the full ASCII header line (without the trailing `\n`). The
205/// magic prefix `YUV4MPEG2 ` must already be present.
206fn parse_header(line: &[u8]) -> Result<Y4mHeader> {
207    if line.len() < MAGIC.len() || &line[..MAGIC.len()] != MAGIC.as_slice() {
208        return Err(Error::invalid("y4m: missing YUV4MPEG2 magic"));
209    }
210    // Per the spec the header is plain ASCII; surface a clear error
211    // rather than letting String::from_utf8 panic on the rare bogus
212    // file that contains arbitrary bytes after the magic.
213    let body = std::str::from_utf8(&line[MAGIC.len()..])
214        .map_err(|_| Error::invalid("y4m: non-ASCII header"))?;
215
216    let mut width: Option<u32> = None;
217    let mut height: Option<u32> = None;
218    let mut frame_rate: Option<Rational> = None;
219    let mut pixel_aspect: Option<Rational> = None;
220    let mut interlace: u8 = b'p';
221    let mut colorspace: Option<String> = None;
222    let mut x_params: Vec<(String, String)> = Vec::new();
223
224    for tok in body.split(' ') {
225        if tok.is_empty() {
226            continue;
227        }
228        let (tag, val) = (tok.as_bytes()[0], &tok[1..]);
229        match tag {
230            b'W' => {
231                width = Some(
232                    val.parse()
233                        .map_err(|_| Error::invalid(format!("y4m: bad width '{val}'")))?,
234                );
235            }
236            b'H' => {
237                height = Some(
238                    val.parse()
239                        .map_err(|_| Error::invalid(format!("y4m: bad height '{val}'")))?,
240                );
241            }
242            b'F' => frame_rate = Some(parse_ratio(val, "frame rate")?),
243            b'A' => pixel_aspect = Some(parse_ratio(val, "pixel aspect")?),
244            b'I' => {
245                interlace = *val.as_bytes().first().unwrap_or(&b'p');
246                if !matches!(interlace, b'p' | b't' | b'b' | b'm' | b'?') {
247                    return Err(Error::invalid(format!(
248                        "y4m: unknown interlace mode '{val}'"
249                    )));
250                }
251            }
252            b'C' => colorspace = Some(val.to_string()),
253            b'X' => {
254                // Preserve the full token (key + '=' + value, or just
255                // a key) as ("X<key>", "<value-or-empty>") in metadata.
256                let (k, v) = match val.find('=') {
257                    Some(i) => (&val[..i], &val[i + 1..]),
258                    None => (val, ""),
259                };
260                x_params.push((format!("X{k}"), v.to_string()));
261            }
262            _ => {
263                return Err(Error::invalid(format!(
264                    "y4m: unknown header tag '{}' in '{tok}'",
265                    tag as char
266                )));
267            }
268        }
269    }
270
271    let width = width.ok_or_else(|| Error::invalid("y4m: header missing W (width)"))?;
272    let height = height.ok_or_else(|| Error::invalid("y4m: header missing H (height)"))?;
273    if width == 0 || height == 0 {
274        return Err(Error::invalid("y4m: zero width or height"));
275    }
276    let frame_rate = frame_rate.unwrap_or(Rational::new(25, 1));
277    let pixel_aspect = pixel_aspect.unwrap_or(Rational::new(0, 0));
278    // Y4M's historic default when C is absent is 420jpeg.
279    let colorspace = colorspace.unwrap_or_else(|| "420jpeg".to_string());
280
281    Ok(Y4mHeader {
282        width,
283        height,
284        frame_rate,
285        pixel_aspect,
286        interlace,
287        colorspace,
288        x_params,
289    })
290}
291
292/// Total bytes per decoded frame for the given header.
293fn frame_size(hdr: &Y4mHeader) -> Result<usize> {
294    let (_, hss, vss, bps) = pixel_format_for_colorspace(&hdr.colorspace).ok_or_else(|| {
295        Error::unsupported(format!(
296            "y4m: colorspace '{}' not supported",
297            hdr.colorspace
298        ))
299    })?;
300    let w = hdr.width as usize;
301    let h = hdr.height as usize;
302    let bps = bps as usize;
303    let y_bytes = w * h * bps;
304    if hdr.colorspace.starts_with("mono") {
305        return Ok(y_bytes);
306    }
307    let cw = w >> hss;
308    let ch = h >> vss;
309    let chroma = cw * ch * bps;
310    Ok(y_bytes + 2 * chroma)
311}
312
313// ───────────────────────── Demuxer ─────────────────────────
314
315/// Largest header / FRAME line we'll tolerate. Real-world headers are
316/// well under 1 KiB; this cap keeps a malicious or truncated file from
317/// burning unbounded memory in `read_line`.
318const LINE_CAP: usize = 16 * 1024;
319
320fn open_demuxer(
321    mut input: Box<dyn ReadSeek>,
322    _codecs: &dyn CodecResolver,
323) -> Result<Box<dyn Demuxer>> {
324    let line = read_line(&mut *input, LINE_CAP)?;
325    let hdr = parse_header(&line)?;
326    let pf_info = pixel_format_for_colorspace(&hdr.colorspace).ok_or_else(|| {
327        Error::unsupported(format!(
328            "y4m: colorspace '{}' not supported",
329            hdr.colorspace
330        ))
331    })?;
332    let (pix_fmt, _hss, _vss, _bps) = pf_info;
333    let fsize = frame_size(&hdr)?;
334
335    // Time base = inverse of frame rate. Defensive against `F0:0`.
336    let time_base = if hdr.frame_rate.num > 0 && hdr.frame_rate.den > 0 {
337        TimeBase::new(hdr.frame_rate.den, hdr.frame_rate.num)
338    } else {
339        TimeBase::new(1, 25)
340    };
341
342    let mut params = CodecParameters::video(CodecId::new("rawvideo"));
343    params.width = Some(hdr.width);
344    params.height = Some(hdr.height);
345    params.pixel_format = Some(pix_fmt);
346    params.frame_rate = Some(hdr.frame_rate);
347
348    // Surface the parsed header in metadata so callers can inspect
349    // colorspace / aspect / X-params without re-parsing.
350    let mut metadata: Vec<(String, String)> = Vec::with_capacity(4 + hdr.x_params.len());
351    metadata.push(("colorspace".into(), hdr.colorspace.clone()));
352    metadata.push(("interlace".into(), (hdr.interlace as char).to_string()));
353    metadata.push((
354        "pixel_aspect".into(),
355        format!("{}:{}", hdr.pixel_aspect.num, hdr.pixel_aspect.den),
356    ));
357    metadata.push((
358        "frame_rate".into(),
359        format!("{}:{}", hdr.frame_rate.num, hdr.frame_rate.den),
360    ));
361    metadata.extend(hdr.x_params);
362
363    let stream = StreamInfo {
364        index: 0,
365        time_base,
366        duration: None,
367        start_time: Some(0),
368        params,
369    };
370
371    Ok(Box::new(Y4mDemuxer {
372        input,
373        streams: vec![stream],
374        frame_size: fsize,
375        frames_emitted: 0,
376        metadata,
377    }))
378}
379
380struct Y4mDemuxer {
381    input: Box<dyn ReadSeek>,
382    streams: Vec<StreamInfo>,
383    frame_size: usize,
384    frames_emitted: i64,
385    metadata: Vec<(String, String)>,
386}
387
388impl Demuxer for Y4mDemuxer {
389    fn format_name(&self) -> &str {
390        "y4m"
391    }
392
393    fn streams(&self) -> &[StreamInfo] {
394        &self.streams
395    }
396
397    fn next_packet(&mut self) -> Result<Packet> {
398        // Each frame begins with `FRAME[ X...]\n`. Treat a clean EOF
399        // (no bytes left) as the end of stream.
400        let line = match read_line(&mut *self.input, LINE_CAP) {
401            Ok(v) => v,
402            Err(Error::Eof) => return Err(Error::Eof),
403            Err(e) => return Err(e),
404        };
405        if line.len() < 5 || &line[..5] != b"FRAME" {
406            return Err(Error::invalid(
407                "y4m: expected 'FRAME' marker between frames",
408            ));
409        }
410        // Per-frame X-params after FRAME are valid but not surfaced —
411        // the spec uses them for things like film-pulldown flags that
412        // a generic rawvideo consumer cannot act on. They are still
413        // syntactically tolerated.
414
415        let mut buf = vec![0u8; self.frame_size];
416        self.input.read_exact(&mut buf)?;
417        let stream = &self.streams[0];
418        let pts = self.frames_emitted;
419        self.frames_emitted += 1;
420
421        let mut pkt = Packet::new(0, stream.time_base, buf);
422        pkt.pts = Some(pts);
423        pkt.dts = Some(pts);
424        pkt.duration = Some(1);
425        pkt.flags.keyframe = true;
426        Ok(pkt)
427    }
428
429    fn metadata(&self) -> &[(String, String)] {
430        &self.metadata
431    }
432}
433
434// ───────────────────────── Muxer ─────────────────────────
435
436fn open_muxer(output: Box<dyn WriteSeek>, streams: &[StreamInfo]) -> Result<Box<dyn Muxer>> {
437    if streams.len() != 1 {
438        return Err(Error::unsupported("y4m supports exactly one video stream"));
439    }
440    let s = &streams[0];
441    if s.params.media_type != MediaType::Video {
442        return Err(Error::invalid("y4m stream must be video"));
443    }
444    let width = s
445        .params
446        .width
447        .ok_or_else(|| Error::invalid("y4m muxer: missing width"))?;
448    let height = s
449        .params
450        .height
451        .ok_or_else(|| Error::invalid("y4m muxer: missing height"))?;
452    let pix_fmt = s
453        .params
454        .pixel_format
455        .ok_or_else(|| Error::invalid("y4m muxer: missing pixel_format"))?;
456    let colorspace = colorspace_for_pixel_format(pix_fmt)?;
457    // Frame rate falls back to 25/1 when unset — same default as the
458    // demuxer chooses for a missing F<num>:<den> token.
459    let frame_rate = s.params.frame_rate.unwrap_or(Rational::new(25, 1));
460    if frame_rate.num <= 0 || frame_rate.den <= 0 {
461        return Err(Error::invalid("y4m muxer: invalid frame_rate"));
462    }
463
464    let (_, hss, vss, bps) = pixel_format_for_colorspace(colorspace).ok_or_else(|| {
465        Error::other(format!(
466            "y4m muxer: internal colorspace lookup failed for '{colorspace}'"
467        ))
468    })?;
469    let w = width as usize;
470    let h = height as usize;
471    let bps = bps as usize;
472    let chroma = if colorspace.starts_with("mono") {
473        0
474    } else {
475        let cw = w >> hss;
476        let ch = h >> vss;
477        cw * ch * bps
478    };
479    let frame_size = w * h * bps + 2 * chroma;
480
481    Ok(Box::new(Y4mMuxer {
482        output,
483        width,
484        height,
485        frame_rate,
486        colorspace,
487        frame_size,
488        header_written: false,
489        trailer_written: false,
490    }))
491}
492
493struct Y4mMuxer {
494    output: Box<dyn WriteSeek>,
495    width: u32,
496    height: u32,
497    frame_rate: Rational,
498    colorspace: &'static str,
499    frame_size: usize,
500    header_written: bool,
501    trailer_written: bool,
502}
503
504impl Muxer for Y4mMuxer {
505    fn format_name(&self) -> &str {
506        "y4m"
507    }
508
509    fn write_header(&mut self) -> Result<()> {
510        if self.header_written {
511            return Err(Error::other("y4m header already written"));
512        }
513        // YUV4MPEG2 W<w> H<h> F<num>:<den> Ip A0:0 C<colorspace>\n
514        let line = format!(
515            "YUV4MPEG2 W{w} H{h} F{fn_}:{fd} Ip A0:0 C{cs}\n",
516            w = self.width,
517            h = self.height,
518            fn_ = self.frame_rate.num,
519            fd = self.frame_rate.den,
520            cs = self.colorspace,
521        );
522        self.output.write_all(line.as_bytes())?;
523        self.header_written = true;
524        Ok(())
525    }
526
527    fn write_packet(&mut self, packet: &Packet) -> Result<()> {
528        if !self.header_written {
529            return Err(Error::other("y4m muxer: write_header not called"));
530        }
531        if packet.data.len() != self.frame_size {
532            return Err(Error::invalid(format!(
533                "y4m muxer: frame size mismatch (got {} bytes, expected {})",
534                packet.data.len(),
535                self.frame_size
536            )));
537        }
538        self.output.write_all(b"FRAME\n")?;
539        self.output.write_all(&packet.data)?;
540        Ok(())
541    }
542
543    fn write_trailer(&mut self) -> Result<()> {
544        if self.trailer_written {
545            return Ok(());
546        }
547        self.output.flush()?;
548        self.trailer_written = true;
549        Ok(())
550    }
551}
552
553// ───────────────────────── tests ─────────────────────────
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use oxideav_core::NullCodecResolver;
559
560    /// Build the smallest-possible Y4M file in memory: an 8×8 single-
561    /// frame yuv420p stream.
562    fn build_minimal_y4m_8x8(num_frames: usize) -> Vec<u8> {
563        let header = b"YUV4MPEG2 W8 H8 F30:1 Ip A1:1 C420mpeg2\n";
564        let frame_payload_len = 8 * 8 + 4 * 4 + 4 * 4;
565        let mut out = Vec::new();
566        out.extend_from_slice(header);
567        for f in 0..num_frames {
568            out.extend_from_slice(b"FRAME\n");
569            for i in 0..frame_payload_len {
570                out.push(((i + f * 13) & 0xff) as u8);
571            }
572        }
573        out
574    }
575
576    #[test]
577    fn probe_detects_magic() {
578        let probe_data = ProbeData {
579            buf: b"YUV4MPEG2 W16 H16 F25:1\n",
580            ext: None,
581        };
582        assert_eq!(probe(&probe_data), 100);
583        let probe_bad = ProbeData {
584            buf: b"NOTAMAGIC ",
585            ext: None,
586        };
587        assert_eq!(probe(&probe_bad), 0);
588    }
589
590    #[test]
591    fn parse_header_roundtrip_basic() {
592        let line = b"YUV4MPEG2 W64 H48 F30000:1001 Ip A1:1 C420jpeg XYSCSS=420JPEG";
593        let hdr = parse_header(line).unwrap();
594        assert_eq!(hdr.width, 64);
595        assert_eq!(hdr.height, 48);
596        assert_eq!(hdr.frame_rate, Rational::new(30000, 1001));
597        assert_eq!(hdr.pixel_aspect, Rational::new(1, 1));
598        assert_eq!(hdr.interlace, b'p');
599        assert_eq!(hdr.colorspace, "420jpeg");
600        assert_eq!(hdr.x_params.len(), 1);
601        assert_eq!(
602            hdr.x_params[0],
603            ("XYSCSS".to_string(), "420JPEG".to_string())
604        );
605    }
606
607    #[test]
608    fn parse_header_rejects_missing_dimensions() {
609        let no_w = b"YUV4MPEG2 H48 F25:1";
610        assert!(parse_header(no_w).is_err());
611        let no_h = b"YUV4MPEG2 W48 F25:1";
612        assert!(parse_header(no_h).is_err());
613    }
614
615    #[test]
616    fn frame_size_420_versus_422_versus_444() {
617        let mut hdr = Y4mHeader {
618            width: 16,
619            height: 16,
620            frame_rate: Rational::new(25, 1),
621            pixel_aspect: Rational::new(1, 1),
622            interlace: b'p',
623            colorspace: "420jpeg".to_string(),
624            x_params: Vec::new(),
625        };
626        assert_eq!(frame_size(&hdr).unwrap(), 16 * 16 + 8 * 8 + 8 * 8);
627        hdr.colorspace = "422".to_string();
628        assert_eq!(frame_size(&hdr).unwrap(), 16 * 16 + 8 * 16 + 8 * 16);
629        hdr.colorspace = "444".to_string();
630        assert_eq!(frame_size(&hdr).unwrap(), 16 * 16 * 3);
631        hdr.colorspace = "mono".to_string();
632        assert_eq!(frame_size(&hdr).unwrap(), 16 * 16);
633        hdr.colorspace = "420p10".to_string();
634        assert_eq!(frame_size(&hdr).unwrap(), (16 * 16 + 8 * 8 + 8 * 8) * 2);
635    }
636
637    #[test]
638    fn demux_minimal_y4m_two_frames() {
639        let bytes = build_minimal_y4m_8x8(2);
640        let cur = std::io::Cursor::new(bytes);
641        let mut dmx = open_demuxer(Box::new(cur), &NullCodecResolver).unwrap();
642        assert_eq!(dmx.format_name(), "y4m");
643        assert_eq!(dmx.streams().len(), 1);
644        let s = &dmx.streams()[0];
645        assert_eq!(s.params.codec_id, CodecId::new("rawvideo"));
646        assert_eq!(s.params.width, Some(8));
647        assert_eq!(s.params.height, Some(8));
648        assert_eq!(s.params.pixel_format, Some(PixelFormat::Yuv420P));
649        assert_eq!(s.params.frame_rate, Some(Rational::new(30, 1)));
650
651        let p1 = dmx.next_packet().unwrap();
652        assert_eq!(p1.pts, Some(0));
653        assert_eq!(p1.data.len(), 8 * 8 + 4 * 4 + 4 * 4);
654        let p2 = dmx.next_packet().unwrap();
655        assert_eq!(p2.pts, Some(1));
656        assert!(matches!(dmx.next_packet(), Err(Error::Eof)));
657    }
658
659    #[test]
660    fn demux_preserves_x_params_in_metadata() {
661        // Hand-built Y4M with an extension param.
662        let header = b"YUV4MPEG2 W8 H8 F25:1 Ip A1:1 C420jpeg XCOLORRANGE=LIMITED\n";
663        let mut bytes = Vec::new();
664        bytes.extend_from_slice(header);
665        bytes.extend_from_slice(b"FRAME\n");
666        bytes.resize(bytes.len() + 8 * 8 + 4 * 4 + 4 * 4, 0u8);
667        let cur = std::io::Cursor::new(bytes);
668        let dmx = open_demuxer(Box::new(cur), &NullCodecResolver).unwrap();
669        let md = dmx.metadata();
670        assert!(md.iter().any(|(k, v)| k == "XCOLORRANGE" && v == "LIMITED"));
671        assert!(md.iter().any(|(k, v)| k == "colorspace" && v == "420jpeg"));
672    }
673
674    #[test]
675    fn round_trip_8x8_yuv420p() {
676        // Build 4 distinct frames of synthetic 8×8 yuv420p, mux to
677        // memory, then demux and verify byte-exact equality.
678        let frames: Vec<Vec<u8>> = (0..4)
679            .map(|f| {
680                let mut v = Vec::with_capacity(8 * 8 + 4 * 4 + 4 * 4);
681                for y in 0..8 {
682                    for x in 0..8 {
683                        v.push((((x + y) * 7 + f * 11) & 0xff) as u8);
684                    }
685                }
686                for u in 0..4 {
687                    for x in 0..4 {
688                        v.push(((u * 13 + x * 19 + f * 23) & 0xff) as u8);
689                    }
690                }
691                for vv in 0..4 {
692                    for x in 0..4 {
693                        v.push(((vv * 17 + x * 29 + f * 5) & 0xff) as u8);
694                    }
695                }
696                v
697            })
698            .collect();
699
700        let mut params = CodecParameters::video(CodecId::new("rawvideo"));
701        params.width = Some(8);
702        params.height = Some(8);
703        params.pixel_format = Some(PixelFormat::Yuv420P);
704        params.frame_rate = Some(Rational::new(30, 1));
705        let stream = StreamInfo {
706            index: 0,
707            time_base: TimeBase::new(1, 30),
708            duration: None,
709            start_time: Some(0),
710            params,
711        };
712
713        // Mux to a temp file, then read it back. The muxer demands a
714        // `Box<dyn WriteSeek + 'static>`, which a borrowed in-memory
715        // Cursor can't satisfy.
716        let tmp = std::env::temp_dir().join("oxideav-basic-y4m-unit-420.y4m");
717        let _ = std::fs::remove_file(&tmp);
718        {
719            let f = std::fs::File::create(&tmp).unwrap();
720            let ws: Box<dyn WriteSeek> = Box::new(f);
721            let mut mux = open_muxer(ws, std::slice::from_ref(&stream)).unwrap();
722            mux.write_header().unwrap();
723            for f in &frames {
724                let pkt = Packet::new(0, stream.time_base, f.clone());
725                mux.write_packet(&pkt).unwrap();
726            }
727            mux.write_trailer().unwrap();
728        }
729
730        let muxed = std::fs::read(&tmp).unwrap();
731        // Expected on-the-wire shape. The muxer picks `420mpeg2` for
732        // the generic limited-range Yuv420P pixel format.
733        assert!(muxed.starts_with(b"YUV4MPEG2 W8 H8 F30:1 Ip A0:0 C420mpeg2\n"));
734
735        // Demux back and compare frame-for-frame.
736        let cur = std::io::Cursor::new(muxed);
737        let mut dmx = open_demuxer(Box::new(cur), &NullCodecResolver).unwrap();
738        for (i, want) in frames.iter().enumerate() {
739            let p = dmx.next_packet().unwrap();
740            assert_eq!(p.pts, Some(i as i64));
741            assert_eq!(p.data, *want, "frame {i} payload mismatch");
742        }
743        assert!(matches!(dmx.next_packet(), Err(Error::Eof)));
744        let _ = std::fs::remove_file(&tmp);
745    }
746
747    #[test]
748    fn round_trip_16x16_yuv444p() {
749        let mut frame = Vec::with_capacity(16 * 16 * 3);
750        for plane in 0..3 {
751            for y in 0..16 {
752                for x in 0..16 {
753                    frame.push(((plane * 31 + y * 7 + x * 11) & 0xff) as u8);
754                }
755            }
756        }
757        let mut params = CodecParameters::video(CodecId::new("rawvideo"));
758        params.width = Some(16);
759        params.height = Some(16);
760        params.pixel_format = Some(PixelFormat::Yuv444P);
761        params.frame_rate = Some(Rational::new(24, 1));
762        let stream = StreamInfo {
763            index: 0,
764            time_base: TimeBase::new(1, 24),
765            duration: None,
766            start_time: Some(0),
767            params,
768        };
769
770        let tmp = std::env::temp_dir().join("oxideav-basic-y4m-unit-444.y4m");
771        let _ = std::fs::remove_file(&tmp);
772        {
773            let f = std::fs::File::create(&tmp).unwrap();
774            let ws: Box<dyn WriteSeek> = Box::new(f);
775            let mut mux = open_muxer(ws, std::slice::from_ref(&stream)).unwrap();
776            mux.write_header().unwrap();
777            mux.write_packet(&Packet::new(0, stream.time_base, frame.clone()))
778                .unwrap();
779            mux.write_trailer().unwrap();
780        }
781        let muxed = std::fs::read(&tmp).unwrap();
782        assert!(muxed.starts_with(b"YUV4MPEG2 W16 H16 F24:1 Ip A0:0 C444\n"));
783
784        let cur = std::io::Cursor::new(muxed);
785        let mut dmx = open_demuxer(Box::new(cur), &NullCodecResolver).unwrap();
786        let p = dmx.next_packet().unwrap();
787        assert_eq!(p.data, frame);
788        let _ = std::fs::remove_file(&tmp);
789    }
790
791    #[test]
792    fn muxer_rejects_wrong_frame_size() {
793        let mut params = CodecParameters::video(CodecId::new("rawvideo"));
794        params.width = Some(8);
795        params.height = Some(8);
796        params.pixel_format = Some(PixelFormat::Yuv420P);
797        params.frame_rate = Some(Rational::new(25, 1));
798        let stream = StreamInfo {
799            index: 0,
800            time_base: TimeBase::new(1, 25),
801            duration: None,
802            start_time: Some(0),
803            params,
804        };
805        let tmp = std::env::temp_dir().join("oxideav-basic-y4m-unit-bad.y4m");
806        let _ = std::fs::remove_file(&tmp);
807        let f = std::fs::File::create(&tmp).unwrap();
808        let ws: Box<dyn WriteSeek> = Box::new(f);
809        let mut mux = open_muxer(ws, std::slice::from_ref(&stream)).unwrap();
810        mux.write_header().unwrap();
811        let pkt = Packet::new(0, stream.time_base, vec![0u8; 100]);
812        assert!(mux.write_packet(&pkt).is_err());
813        let _ = std::fs::remove_file(&tmp);
814    }
815}