craft-y4m: YUV4MPEG2 reader from FilmCraft (#933)

* 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>
This commit is contained in:
Dylan M. TaylorandNetterSilver authored and GitHub committed 2026-11-11 18:44:44 -05:00
1 parent 86e59ebc08
commit 2e0bbc3a9c
6 files changed
+555

No files matched your search

Generated
+7
View File
@@ -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"
+1
View File
@@ -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" }
+3
View File
@@ -256,6 +256,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
JSON control channel.
- [`craft-web-host`](crates/web-host/README.md): the host messages between a Craft app's web build and the page that hosts it (`?host=parent` or `data-host="page"`): one contract and one bridge for every app.
- [`craft-xls`](crates/xls/README.md): clean-room Excel 97-2003 workbook (`.xls`, BIFF8) reader: worksheets, cell values, formulas as A1 text (shared and array formulas included), cell formats with resolved palette colours, merged cells, column and row records, defined names, print areas and calculation settings, with limits on records, cells and text.
- [`craft-y4m`](crates/y4m/src/lib.rs): YUV4MPEG2 (`.y4m`) reader, from FilmCraft: the stream
header, colour space layouts at 8 to 16 bits, a frame index built from the `FRAME` lines without
loading the file, and plane access. Bounded on hostile input.
- [`craft-kra`](crates/kra/README.md): Krita documents (`.kra`, `.krz`), read: the ZIP archive, the `maindoc.xml` node tree as written, and the tiled pixel files with their LZF compression, a tile at a time. From PhotoCraft.
- [`craft-xml`](crates/xml/README.md): bounded XML for hostile input: an owned element tree with limits on size, depth, nodes and attributes, streaming of repeated elements, namespace handling (as written, by local name, or canonical prefixes by URI), escaping and a writer. No DTDs and no entity expansion.
- [Shared UI component audit](docs/ui-components-audit.md): source inventory, extraction priorities
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "craft-y4m"
description = "YUV4MPEG2 (.y4m) reader: stream header, colour space layouts, a frame index built without loading the file, and plane access."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
thiserror = { workspace = true }
+310
View File
@@ -0,0 +1,310 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! YUV4MPEG2 (`.y4m`) files: uncompressed planar video, as described by the format's public
//! documentation (the `yuv4mpeg(5)` manual page of mjpegtools, and the `C` colour space names
//! other tools added for high bit depths).
//!
//! The file starts with `YUV4MPEG2` and space-separated tagged parameters up to a newline: `W` and
//! `H` (size), `F` (frame rate, `n:d`), `A` (pixel aspect, `n:d`, `0:0` unknown), `I` (interlacing),
//! `C` (colour space; `420jpeg` when absent) and `X` (extensions; `XCOLORRANGE=FULL` is read). Each
//! frame is a `FRAME` line (with optional parameters of its own) followed by the Y, Cb and Cr planes
//! (and an alpha plane for `444alpha`); samples above 8 bits are 16-bit little-endian.
//!
//! [`Stream::open`] reads the stream header and finds the frames (their `FRAME` lines only)
//! through a read callback, so a large file is never loaded whole; [`Stream::frame`] then says
//! where one frame's planes are, and [`Header::planes`] splits them. Every number comes from the
//! file and is checked before use: the picture size, the line length and the number of frames
//! indexed are capped by [`Limits`].
use std::ops::Range;
#[cfg(test)]
mod tests;
const SIGNATURE: &[u8] = b"YUV4MPEG2";
/// Why a file could not be read.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Error {
/// The stream header or the frames are missing or damaged.
#[error("Y4M: {0}")]
Malformed(&'static str),
/// A colour space this reader does not cover.
#[error("unsupported Y4M colour space {0}")]
UnsupportedColourSpace(String),
/// The file declares more than [`Limits`] allow.
#[error("Y4M exceeds a limit: {0}")]
LimitExceeded(&'static str),
}
/// Result of this crate's functions.
pub type Result<T> = std::result::Result<T, Error>;
/// 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<Chroma>,
/// 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<Layout> {
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<usize>,
/// Empty for `mono`.
pub cb: Range<usize>,
/// Empty for `mono`.
pub cr: Range<usize>,
/// Empty without an alpha plane.
pub alpha: Range<usize>,
}
/// 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<Header> {
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<u16> {
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<u64>,
/// 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<Vec<u8>>, pos: u64, max_line: usize) -> Option<(Vec<u8>, 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<Vec<u8>>, limits: &Limits) -> Result<Stream> {
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<Stream> {
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<Range<u64>> {
let at = *self.frames.get(index)?;
Some(at..at.checked_add(self.frame_bytes as u64)?)
}
}
+220
View File
@@ -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<u8>]) -> Vec<u8> {
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<u8> {
[vec![y; luma], vec![cb; chroma], vec![cr; chroma]].concat()
}
fn open(bytes: &[u8]) -> Result<Stream> {
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<Chroma>, 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<Vec<u8>> = (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<Vec<u8>> = (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());
}