From 2e0bbc3a9cfd0724a27d6dbc7c1c459f92ee0893 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 18:44:44 -0500 Subject: [PATCH] craft-y4m: YUV4MPEG2 reader from FilmCraft (#933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * craft-y4m: YUV4MPEG2 reader FilmCraft's Y4M reader as a crate: the stream header, colour space layouts, a frame index built from the FRAME lines through a read callback, and plane ranges. The media source and frame types stay in the app. * craft-y4m: a header parameter starting with a multi-byte character is ignored Port of storytold/filmcraft#4562: the tag of a stream header parameter is its first character, not its first byte, so a parameter such as `éX` is skipped like any other unknown tag instead of being split inside the character. --------- Co-authored-by: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> --- Cargo.lock | 7 + Cargo.toml | 1 + README.md | 3 + crates/y4m/Cargo.toml | 14 ++ crates/y4m/src/lib.rs | 310 ++++++++++++++++++++++++++++++++++++++++ crates/y4m/src/tests.rs | 220 ++++++++++++++++++++++++++++ 6 files changed, 555 insertions(+) create mode 100644 crates/y4m/Cargo.toml create mode 100644 crates/y4m/src/lib.rs create mode 100644 crates/y4m/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 1b7bfd666..8121690c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1703,6 +1703,13 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-y4m" +version = "0.1.0" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "crc32fast" version = "1.5.2" diff --git a/Cargo.toml b/Cargo.toml index 301515bea..dbbaa40e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,6 +95,7 @@ craft-vectorcraft-format = { path = "crates/vectorcraft-format" } craft-vectordrawable = { path = "crates/vectordrawable" } craft-xls = { path = "crates/xls" } craft-xml = { path = "crates/xml" } +craft-y4m = { path = "crates/y4m" } craft-epub = { path = "crates/epub" } craft-video = { path = "crates/video" } craft-web-api = { path = "crates/web-api" } diff --git a/README.md b/README.md index 0cf34dace..37f11b033 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " = std::result::Result; + +/// Caps on what a file may ask for. `#[non_exhaustive]`: start from [`Limits::default`] and set +/// the fields you change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct Limits { + /// Most luma samples (width × height) a frame may have. Default 2^28. + pub max_pixels: u64, + /// Longest stream header or `FRAME` line read, in bytes. Default 64 KiB. + pub max_line: usize, + /// Most frames indexed; a longer file ends there. Each takes 8 bytes of index. Default 2^24. + pub max_frames: usize, +} + +impl Default for Limits { + fn default() -> Self { + Self { max_pixels: 1 << 28, max_line: 64 << 10, max_frames: 1 << 24 } + } +} + +/// Whether `head` starts like a YUV4MPEG2 file. +pub fn sniff(head: &[u8]) -> bool { + head.starts_with(SIGNATURE) && matches!(head.get(SIGNATURE.len()), Some(b' ' | b'\n')) +} + +/// Chroma subsampling. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Chroma { + /// Half width, half height. + C420, + /// Half width. + C422, + /// Full size. + C444, +} + +impl Chroma { + /// The horizontal and vertical subsampling as shifts. + pub fn shifts(self) -> (u32, u32) { + match self { + Chroma::C420 => (1, 1), + Chroma::C422 => (1, 0), + Chroma::C444 => (0, 0), + } + } +} + +/// The sample layout a `C` parameter names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Layout { + /// `None` for luma only (`mono`). + pub chroma: Option, + /// Bits per sample, 8 to 16. Above 8, samples are 16-bit little-endian. + pub bits: u32, + /// Whether an alpha plane follows the chroma planes (`444alpha`). + pub alpha: bool, +} + +impl Layout { + /// The layout of a colour space name (`420jpeg`, `422p10`, `444alpha`, `mono16`, …), `None` + /// for one this reader does not cover. + pub fn parse(c: &str) -> Option { + let (base, bits) = match c.split_once('p') { + Some((base, bits)) if !bits.is_empty() && bits.bytes().all(|b| b.is_ascii_digit()) => (base, bits.parse().ok()?), + _ => (c, 8), + }; + let (base, bits) = match base.strip_prefix("mono") { + Some("") => ("mono", bits), + Some(digits) if bits == 8 => ("mono", digits.parse().ok()?), + _ => (base, bits), + }; + if !(8..=16).contains(&bits) { + return None; + } + let (chroma, alpha) = match base { + "420" | "420jpeg" | "420paldv" | "420mpeg2" => (Some(Chroma::C420), false), + "422" => (Some(Chroma::C422), false), + "444" => (Some(Chroma::C444), false), + "444alpha" if bits == 8 => (Some(Chroma::C444), true), + "mono" => (None, false), + _ => return None, + }; + Some(Layout { chroma, bits, alpha }) + } + + /// Bytes of one stored sample: 1 at 8 bits, 2 above. + pub fn bytes_per_sample(self) -> usize { + if self.bits > 8 { 2 } else { 1 } + } +} + +/// The planes of one frame, as byte ranges within the frame's data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Planes { + pub y: Range, + /// Empty for `mono`. + pub cb: Range, + /// Empty for `mono`. + pub cr: Range, + /// Empty without an alpha plane. + pub alpha: Range, +} + +/// The stream header. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Header { + pub width: u32, + pub height: u32, + /// Frame rate as numerator and denominator, both positive; `None` when the file names none. + pub frame_rate: Option<(i64, i64)>, + /// Pixel aspect ratio, both parts positive; `None` when absent or unknown (`0:0`). + pub pixel_aspect: Option<(u32, u32)>, + pub layout: Layout, + /// Whether the file says `XCOLORRANGE=FULL`. + pub full_range: bool, +} + +fn parse_ratio(s: &str) -> Option<(i64, i64)> { + let (n, d) = s.split_once(':')?; + Some((n.parse().ok()?, d.parse().ok()?)) +} + +impl Header { + /// Parses the stream header line (without its newline). + pub fn parse(line: &[u8], limits: &Limits) -> Result
{ + let header = String::from_utf8_lossy(line); + let mut params = header.split(' '); + if params.next() != Some("YUV4MPEG2") { + return Err(Error::Malformed("not a YUV4MPEG2 file")); + } + let (mut width, mut height, mut frame_rate, mut pixel_aspect, mut colour, mut full_range) = (0u32, 0u32, None, None, "420jpeg", false); + for p in params { + // The tag is the first character, whatever its length in bytes; an unknown one is skipped. + let Some(tag) = p.chars().next() else { continue }; + let v = p.get(tag.len_utf8()..).unwrap_or_default(); + match tag { + 'W' => width = v.parse().map_err(|_| Error::Malformed("invalid width"))?, + 'H' => height = v.parse().map_err(|_| Error::Malformed("invalid height"))?, + 'F' => match parse_ratio(v) { + Some((n, d)) if n > 0 && d > 0 => frame_rate = Some((n, d)), + _ => return Err(Error::Malformed("invalid frame rate")), + }, + 'A' => { + if let Some((n, d)) = parse_ratio(v).filter(|&(n, d)| n > 0 && d > 0) + && let (Ok(n), Ok(d)) = (u32::try_from(n), u32::try_from(d)) + { + pixel_aspect = Some((n, d)); + } + } + 'C' => colour = v, + 'X' => full_range |= v.eq_ignore_ascii_case("COLORRANGE=FULL"), + _ => {} + } + } + if width == 0 || height == 0 { + return Err(Error::Malformed("missing or zero size")); + } + if u64::from(width) * u64::from(height) > limits.max_pixels { + return Err(Error::LimitExceeded("pixel count")); + } + let layout = Layout::parse(colour).ok_or_else(|| Error::UnsupportedColourSpace(colour.to_string()))?; + Ok(Header { width, height, frame_rate, pixel_aspect, layout, full_range }) + } + + /// Sizes of one frame's planes in samples: luma, each chroma plane (0 for mono), alpha. + pub fn plane_samples(&self) -> (usize, usize, usize) { + plane_samples(self.width, self.height, self.layout) + } + + /// Samples of a chroma plane of this size at `chroma` subsampling (what a caller needs to give + /// a `mono` file neutral chroma). + pub fn chroma_samples(&self, chroma: Chroma) -> usize { + plane_samples(self.width, self.height, Layout { chroma: Some(chroma), ..self.layout }).1 + } + + /// Bytes of one frame's planes. + pub fn frame_bytes(&self) -> usize { + let (luma, chroma, alpha) = self.plane_samples(); + luma.saturating_add(chroma.saturating_mul(2)).saturating_add(alpha).saturating_mul(self.layout.bytes_per_sample()) + } + + /// Where each plane is within one frame's data. + pub fn planes(&self) -> Planes { + let (luma, chroma, alpha) = self.plane_samples(); + let b = self.layout.bytes_per_sample(); + let (y_end, cb_end, cr_end) = (luma * b, (luma + chroma) * b, (luma + 2 * chroma) * b); + Planes { y: 0..y_end, cb: y_end..cb_end, cr: cb_end..cr_end, alpha: cr_end..cr_end + alpha * b } + } +} + +fn plane_samples(w: u32, h: u32, l: Layout) -> (usize, usize, usize) { + let luma = (w as usize).saturating_mul(h as usize); + let chroma = l.chroma.map_or(0, |c| { + let (sx, sy) = c.shifts(); + (w.div_ceil(1 << sx) as usize).saturating_mul(h.div_ceil(1 << sy) as usize) + }); + (luma, chroma, if l.alpha { luma } else { 0 }) +} + +/// The samples of a plane stored above 8 bits (16-bit little-endian), each clamped to the largest +/// value `bits` allows. +pub fn samples_u16(plane: &[u8], bits: u32) -> Vec { + let max = u16::MAX >> (16 - bits.clamp(1, 16)); + plane.as_chunks::<2>().0.iter().map(|&s| u16::from_le_bytes(s).min(max)).collect() +} + +/// An opened file: its header and where its frames are. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Stream { + pub header: Header, + /// File offset of each complete frame's planes, in order. + pub frames: Vec, + /// Bytes of one frame's planes ([`Header::frame_bytes`]). + pub frame_bytes: usize, +} + +/// The line starting at `pos` (without its newline) and the offset after it. +fn line_at(len: u64, read: &mut impl FnMut(u64, usize) -> Option>, pos: u64, max_line: usize) -> Option<(Vec, u64)> { + let mut want = 128usize.min(max_line.max(1)); + loop { + let take = len.saturating_sub(pos).min(want as u64) as usize; + let mut buf = read(pos, take)?; + buf.truncate(take); + if let Some(end) = buf.iter().position(|&b| b == b'\n') { + buf.truncate(end); + return Some((buf, pos.checked_add(end as u64 + 1)?)); + } + if take < want || want >= max_line { + return None; + } + want = want.saturating_mul(8).min(max_line); + } +} + +impl Stream { + /// Opens a file of `len` bytes through `read(offset, length)`, which returns that range of the + /// file (`None` on failure). Only the stream header and the `FRAME` lines are read. The index + /// ends at the first frame that is damaged or cut short, or at [`Limits::max_frames`]; a file + /// without one complete frame is an error. + pub fn open(len: u64, mut read: impl FnMut(u64, usize) -> Option>, limits: &Limits) -> Result { + let (line, mut pos) = line_at(len, &mut read, 0, limits.max_line).ok_or(Error::Malformed("no stream header"))?; + let header = Header::parse(&line, limits)?; + let frame_bytes = header.frame_bytes(); + let mut frames = Vec::new(); + while pos < len && frames.len() < limits.max_frames { + let Some((line, data)) = line_at(len, &mut read, pos, limits.max_line) else { break }; + if !line.starts_with(b"FRAME") { + break; + } + let Some(end) = data.checked_add(frame_bytes as u64).filter(|&e| e <= len) else { break }; + frames.push(data); + pos = end; + } + if frames.is_empty() { + return Err(Error::Malformed("no complete frames")); + } + Ok(Stream { header, frames, frame_bytes }) + } + + /// Opens a file held in memory. + pub fn from_bytes(bytes: &[u8], limits: &Limits) -> Result { + Self::open(bytes.len() as u64, |pos, n| usize::try_from(pos).ok().and_then(|p| bytes.get(p..p.checked_add(n)?)).map(<[u8]>::to_vec), limits) + } + + /// The file range of frame `index`'s planes, `None` past the last frame. + pub fn frame(&self, index: usize) -> Option> { + let at = *self.frames.get(index)?; + Some(at..at.checked_add(self.frame_bytes as u64)?) + } +} diff --git a/crates/y4m/src/tests.rs b/crates/y4m/src/tests.rs new file mode 100644 index 000000000..0afb59c8c --- /dev/null +++ b/crates/y4m/src/tests.rs @@ -0,0 +1,220 @@ +//! Ported from FilmCraft's Y4M tests (`crates/codecs/src/y4m.rs`, `tests/it/y4m_file.rs`). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +/// A Y4M file: `header` after the signature, `frames` as raw plane bytes, each after `frame_line`. +fn y4m(header: &str, frame_line: &str, frames: &[Vec]) -> Vec { + let mut out = format!("YUV4MPEG2 {header}\n").into_bytes(); + for f in frames { + out.extend_from_slice(frame_line.as_bytes()); + out.push(b'\n'); + out.extend_from_slice(f); + } + out +} + +/// One 8-bit frame: every Y sample `y`, every Cb `cb`, every Cr `cr`. +fn planes8(luma: usize, chroma: usize, y: u8, cb: u8, cr: u8) -> Vec { + [vec![y; luma], vec![cb; chroma], vec![cr; chroma]].concat() +} + +fn open(bytes: &[u8]) -> Result { + Stream::from_bytes(bytes, &Limits::default()) +} + +fn frame<'a>(s: &Stream, bytes: &'a [u8], index: usize) -> &'a [u8] { + let r = s.frame(index).unwrap(); + &bytes[r.start as usize..r.end as usize] +} + +#[test] +fn colour_space_names() { + let l = |c: Option, bits, alpha| Some(Layout { chroma: c, bits, alpha }); + assert_eq!(Layout::parse("420jpeg"), l(Some(Chroma::C420), 8, false)); + assert_eq!(Layout::parse("420p10"), l(Some(Chroma::C420), 10, false)); + assert_eq!(Layout::parse("422p12"), l(Some(Chroma::C422), 12, false)); + assert_eq!(Layout::parse("444alpha"), l(Some(Chroma::C444), 8, true)); + assert_eq!(Layout::parse("mono"), l(None, 8, false)); + assert_eq!(Layout::parse("mono16"), l(None, 16, false)); + for bad in ["411", "420p", "420p7", "420p17", "mono10p12", "444alphap10", "", "p10"] { + assert_eq!(Layout::parse(bad), None, "{bad}"); + } +} + +#[test] +fn a_420_file_is_indexed_frame_by_frame() { + // 6 × 4, 4:2:0 (chroma 3 × 2), three frames at 25 fps. + let frames: Vec> = (0..3).map(|k| planes8(24, 6, 16 + 50 * k, 100 + k, 200 - k)).collect(); + let bytes = y4m("W6 H4 F25:1 Ip A1:1 C420jpeg", "FRAME", &frames); + assert!(sniff(&bytes)); + let s = open(&bytes).unwrap(); + let h = &s.header; + assert_eq!((h.width, h.height, h.frame_rate, h.pixel_aspect, h.full_range), (6, 4, Some((25, 1)), Some((1, 1)), false)); + assert_eq!(h.layout, Layout { chroma: Some(Chroma::C420), bits: 8, alpha: false }); + assert_eq!((h.plane_samples(), s.frame_bytes, s.frames.len()), ((24, 6, 0), 36, 3)); + let p = h.planes(); + assert_eq!((p.y.clone(), p.cb.clone(), p.cr.clone(), p.alpha.clone()), (0..24, 24..30, 30..36, 36..36)); + for k in 0..3u8 { + let f = frame(&s, &bytes, usize::from(k)); + assert!( + f[p.y.clone()].iter().all(|&v| v == 16 + 50 * k) + && f[p.cb.clone()].iter().all(|&v| v == 100 + k) + && f[p.cr.clone()].iter().all(|&v| v == 200 - k) + ); + } + assert_eq!(s.frame(3), None); +} + +#[test] +fn high_bit_depth_frame_parameters_aspect_and_range() { + // 4 × 2, 4:2:2 10-bit (little-endian), 30000:1001, 4:3 pixels, full range, and frame lines + // carrying their own parameters. + let mut data = Vec::new(); + for (n, v) in [(8, 940u16), (4, 512), (4, 64)] { + for _ in 0..n { + data.extend(v.to_le_bytes()); + } + } + let bytes = y4m("W4 H2 F30000:1001 It A4:3 C422p10 XCOLORRANGE=FULL", "FRAME Ip XFOO=1", &[data.clone(), data]); + let s = open(&bytes).unwrap(); + let h = &s.header; + assert_eq!((h.frame_rate, h.pixel_aspect, h.full_range), (Some((30000, 1001)), Some((4, 3)), true)); + assert_eq!((h.layout, h.layout.bytes_per_sample(), s.frames.len()), (Layout { chroma: Some(Chroma::C422), bits: 10, alpha: false }, 2, 2)); + let (p, f) = (h.planes(), frame(&s, &bytes, 1)); + assert_eq!(samples_u16(&f[p.y], 10), [940; 8]); + assert_eq!(samples_u16(&f[p.cb], 10), [512; 4]); + assert_eq!(samples_u16(&f[p.cr], 10), [64; 4]); + // Samples above the depth's largest value are clamped. + assert_eq!(samples_u16(&[0xFF, 0xFF, 0x00, 0x04, 0x01], 10), [1023, 1023]); +} + +#[test] +fn mono_alpha_and_defaults() { + let bytes = y4m("W3 H3 Cmono", "FRAME", &[vec![77; 9]]); + let s = open(&bytes).unwrap(); + assert_eq!((s.header.layout.chroma, s.header.frame_rate, s.header.pixel_aspect), (None, None, None)); + assert_eq!((s.header.plane_samples(), s.header.chroma_samples(Chroma::C420), s.header.chroma_samples(Chroma::C444)), ((9, 0, 0), 4, 9)); + let p = s.header.planes(); + assert!(p.cb.is_empty() && p.cr.is_empty() && p.alpha.is_empty()); + // No colour space means 4:2:0; an unknown aspect (0:0) is none. + let s = open(&y4m("W2 H2 A0:0", "FRAME", &[vec![0; 6]])).unwrap(); + assert_eq!((s.header.layout.chroma, s.header.pixel_aspect), (Some(Chroma::C420), None)); + let bytes = y4m("W2 H1 C444alpha", "FRAME", &[vec![1, 1, 2, 2, 3, 3, 4, 4]]); + let s = open(&bytes).unwrap(); + let p = s.header.planes(); + assert_eq!((s.header.plane_samples(), p.alpha.clone()), ((2, 2, 2), 6..8)); + assert_eq!(&frame(&s, &bytes, 0)[p.alpha], &[4, 4]); +} + +#[test] +fn a_file_opens_through_a_reader_in_small_reads() { + let bytes = y4m("W2 H2 F25:1 C444", "FRAME", &[planes8(4, 4, 50, 60, 70), planes8(4, 4, 90, 60, 70)]); + let mut longest = 0; + let s = Stream::open( + bytes.len() as u64, + |pos, n| { + longest = longest.max(n); + bytes.get(pos as usize..pos as usize + n).map(<[u8]>::to_vec) + }, + &Limits::default(), + ) + .unwrap(); + assert_eq!(frame(&s, &bytes, 1)[0], 90); + // Only lines are read, never a frame's planes. + assert!(longest <= 128, "{longest}"); + // A read that fails ends the index, or the open when it is the header's. + assert_eq!(Stream::open(bytes.len() as u64, |_, _| None, &Limits::default()), Err(Error::Malformed("no stream header"))); + let first = s.frames[0]; + let s = Stream::open( + bytes.len() as u64, + |pos, n| if pos > first { None } else { bytes.get(pos as usize..pos as usize + n).map(<[u8]>::to_vec) }, + &Limits::default(), + ) + .unwrap(); + assert_eq!(s.frames.len(), 1); +} + +#[test] +fn a_cut_or_damaged_frame_ends_the_file() { + let frames = [planes8(4, 1, 1, 2, 3), planes8(4, 1, 4, 5, 6)]; + let good = y4m("W2 H2", "FRAME", &frames); + assert_eq!(open(&good).unwrap().frames.len(), 2); + assert_eq!(open(&good[..good.len() - 1]).unwrap().frames.len(), 1); + let mut junk = good.clone(); + let second = open(&good).unwrap().frames[1] as usize - 6; + junk[second] = b'X'; + assert_eq!(open(&junk).unwrap().frames.len(), 1); + assert_eq!(open(&y4m("W2 H2", "FRAME", &[vec![0; 5]])), Err(Error::Malformed("no complete frames"))); + assert_eq!(open(b"YUV4MPEG2 W2 H2"), Err(Error::Malformed("no stream header"))); +} + +#[test] +fn bad_headers_are_errors() { + let h = |header: &str| open(&y4m(header, "FRAME", &[vec![0; 36]])); + assert_eq!(h("W0 H4 F25:1"), Err(Error::Malformed("missing or zero size"))); + assert_eq!(h("H4 F25:1"), Err(Error::Malformed("missing or zero size"))); + assert_eq!(h("Wx H4"), Err(Error::Malformed("invalid width"))); + assert_eq!(h("W6 H-4"), Err(Error::Malformed("invalid height"))); + for rate in ["F0:0", "F25:0", "F-5:1", "F25", "Fa:b"] { + assert_eq!(h(&format!("W6 H4 {rate}")), Err(Error::Malformed("invalid frame rate")), "{rate}"); + } + assert_eq!(h("W6 H4 F25:1 C411"), Err(Error::UnsupportedColourSpace("411".into()))); + assert_eq!(h("W6 H4 F25:1 Cbogus"), Err(Error::UnsupportedColourSpace("bogus".into()))); + assert!(h("W6 H4 F1:9223372036854775807").is_ok()); + assert_eq!(Header::parse(b"YUV4MPEG W2 H2", &Limits::default()), Err(Error::Malformed("not a YUV4MPEG2 file"))); + assert!(!sniff(b"YUV4MPEG2X") && !sniff(b"YUV4MPEG") && sniff(b"YUV4MPEG2\n")); +} + +#[test] +fn limits() { + let h = |header: &str| open(&y4m(header, "FRAME", &[vec![0; 36]])); + assert_eq!(h("W99999999 H99999999 F25:1"), Err(Error::LimitExceeded("pixel count"))); + assert_eq!(h("W4294967295 H4294967295"), Err(Error::LimitExceeded("pixel count"))); + let bytes = y4m("W2 H2", "FRAME", &[vec![0; 6], vec![0; 6], vec![0; 6]]); + assert!(Stream::from_bytes(&bytes, &Limits { max_pixels: 4, ..Limits::default() }).is_ok()); + assert_eq!(Stream::from_bytes(&bytes, &Limits { max_pixels: 3, ..Limits::default() }), Err(Error::LimitExceeded("pixel count"))); + // The index stops at the frame limit. + assert_eq!(Stream::from_bytes(&bytes, &Limits { max_frames: 2, ..Limits::default() }).unwrap().frames.len(), 2); + // A header or frame line longer than the line limit is not read. + let long = y4m(&format!("W2 H2 X{}", "y".repeat(300)), "FRAME", &[vec![0; 6]]); + assert!(open(&long).is_ok()); + assert_eq!(Stream::from_bytes(&long, &Limits { max_line: 256, ..Limits::default() }), Err(Error::Malformed("no stream header"))); + let long_frame = y4m("W2 H2", &format!("FRAME X{}", "y".repeat(300)), &[vec![0; 6]]); + assert!(open(&long_frame).is_ok()); + assert_eq!(Stream::from_bytes(&long_frame, &Limits { max_line: 256, ..Limits::default() }), Err(Error::Malformed("no complete frames"))); + assert_eq!(Limits::default(), Limits { max_pixels: 1 << 28, max_line: 64 << 10, max_frames: 1 << 24 }); +} + +#[test] +fn damaged_and_hostile_files_never_panic() { + let good = y4m("W6 H4 F25:1 C420jpeg", "FRAME", &[planes8(24, 6, 1, 2, 3), planes8(24, 6, 4, 5, 6)]); + let mut cases: Vec> = (0..good.len()).map(|n| good[..n].to_vec()).collect(); + for i in 0..good.len() { + let mut b = good.clone(); + b[i] ^= 0x41; + cases.push(b); + } + for b in cases { + if let Ok(s) = open(&b) { + for k in 0..s.frames.len() { + let r = s.frame(k).unwrap(); + assert!(r.end <= b.len() as u64); + } + } + } +} + +/// A header parameter that starts with a multi-byte character is skipped like any unknown tag +/// (splitting it at byte 1 would cut the character in two). +#[test] +fn multibyte_header_param_is_ignored() { + let mut data = "YUV4MPEG2 W2 H2 éX\nFRAME\n".as_bytes().to_vec(); + data.extend([0u8; 6]); + let s = open(&data).unwrap(); + assert_eq!((s.header.width, s.header.height, s.frames.len()), (2, 2, 1)); + // Bytes that are not UTF-8 at all are skipped the same way. + let mut data = b"YUV4MPEG2 W2 H2 \xFF\xFEX\nFRAME\n".to_vec(); + data.extend([0u8; 6]); + assert!(open(&data).is_ok()); +}