mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-14 19:26:59 -04:00
Merge pull request #1303 from LloydNicholson/fix-1292-y4m-ale
Import YUV4MPEG2 (.y4m) video; stop offering .ale for import (#1292)
This commit is contained in:
commit
81e2d083fc
5 files changed
+488
-3
No files matched your search
@@ -16,6 +16,7 @@
|
||||
//! - [`AviSource`]: AVI (OpenDML included): Motion JPEG, H.264, HEVC and uncompressed video; PCM,
|
||||
//! MP3 / MP2 and AC-3 audio.
|
||||
//! - [`AudioFileSource`]: standalone compressed audio files (MP3, FLAC, AIFF, …).
|
||||
//! - [`Y4mSource`]: uncompressed YUV4MPEG2 (`.y4m`) video, frames read on demand.
|
||||
//! - [`openers`]: the openers to register with the engine's media pool.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
@@ -34,6 +35,7 @@ pub mod ogg;
|
||||
mod stream_color;
|
||||
pub mod video;
|
||||
mod webm_alpha;
|
||||
pub mod y4m;
|
||||
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
@@ -47,6 +49,7 @@ pub use mpeg::MpegSource;
|
||||
pub use mxf::MxfSource;
|
||||
pub use ogg::OggSource;
|
||||
pub use video::{DecodedFrame, VideoDecoder, VideoDecoderFactory};
|
||||
pub use y4m::Y4mSource;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CodecError {
|
||||
@@ -128,9 +131,9 @@ pub fn software_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result<
|
||||
}
|
||||
|
||||
/// Openers for the engine's media pool (MP4/MOV, Matroska/WebM, MXF, Ogg Opus/Vorbis, MPEG TS/PS and
|
||||
/// MPEG-1/2 video elementary streams, APV raw bitstreams, standalone audio).
|
||||
/// MPEG-1/2 video elementary streams, APV raw bitstreams, YUV4MPEG2, standalone audio).
|
||||
pub fn openers() -> Vec<filmcraft_media::Opener> {
|
||||
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, audio::opener]
|
||||
vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, y4m::opener, audio::opener]
|
||||
}
|
||||
|
||||
fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
|
||||
@@ -143,6 +146,7 @@ fn reader_registry() -> &'static RwLock<Vec<filmcraft_media::ReaderOpener>> {
|
||||
ogg::reader_opener,
|
||||
mpeg::reader_opener,
|
||||
apv::reader_opener,
|
||||
y4m::reader_opener,
|
||||
avi::reader_opener,
|
||||
])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Frames are found once at open (their header lines only) and read on demand, so a large file
|
||||
//! is never loaded whole. Every number comes from the file and is checked before use.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_color::{ColorInfo, Range};
|
||||
use filmcraft_frame::{AudioBuffer, Chroma, PixelData, VideoFrame};
|
||||
use filmcraft_media::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
|
||||
use filmcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::CodecError;
|
||||
|
||||
const SIGNATURE: &[u8] = b"YUV4MPEG2";
|
||||
/// Longest stream header or `FRAME` line read.
|
||||
const MAX_LINE: usize = 64 * 1024;
|
||||
/// Largest picture (luma samples) accepted.
|
||||
const MAX_PIXELS: u64 = 1 << 28;
|
||||
|
||||
pub fn sniff(head: &[u8]) -> bool {
|
||||
head.starts_with(SIGNATURE) && matches!(head.get(SIGNATURE.len()), Some(b' ' | b'\n'))
|
||||
}
|
||||
|
||||
/// The sample layout a `C` parameter names.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Layout {
|
||||
/// `None` for luma only (`mono`).
|
||||
chroma: Option<Chroma>,
|
||||
bits: u32,
|
||||
alpha: bool,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
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 })
|
||||
}
|
||||
|
||||
fn label(self) -> String {
|
||||
let sub = match (self.chroma, self.alpha) {
|
||||
(None, _) => "Monochrome",
|
||||
(Some(_), true) => "YUVA 4:4:4:4",
|
||||
(Some(c), false) => return format!("YUV {} {}-bit", c.label(), self.bits),
|
||||
};
|
||||
format!("{sub} {}-bit", self.bits)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sizes of one frame's planes in samples: luma, each chroma plane (0 for mono), alpha.
|
||||
fn plane_samples(w: u32, h: u32, l: Layout) -> (usize, usize, usize) {
|
||||
let luma = w as usize * h as usize;
|
||||
let chroma = l.chroma.map_or(0, |c| {
|
||||
let (sx, sy) = c.shifts();
|
||||
w.div_ceil(1 << sx) as usize * h.div_ceil(1 << sy) as usize
|
||||
});
|
||||
(luma, chroma, if l.alpha { luma } else { 0 })
|
||||
}
|
||||
|
||||
pub struct Y4mSource {
|
||||
info: MediaInfo,
|
||||
reader: filmcraft_media::SharedReader,
|
||||
/// File offset of each frame's planes.
|
||||
frames: Vec<u64>,
|
||||
frame_bytes: usize,
|
||||
layout: Layout,
|
||||
rate: FrameRate,
|
||||
}
|
||||
|
||||
fn parse_ratio(s: &str) -> Option<(i64, i64)> {
|
||||
let (n, d) = s.split_once(':')?;
|
||||
Some((n.parse().ok()?, d.parse().ok()?))
|
||||
}
|
||||
|
||||
/// The line starting at `pos` (without its newline) and the offset after it.
|
||||
fn line_at(reader: &dyn filmcraft_media::reader::ByteReader, pos: u64) -> Option<(Vec<u8>, u64)> {
|
||||
let mut want = 128usize;
|
||||
loop {
|
||||
let len = (reader.len().saturating_sub(pos)).min(want as u64) as usize;
|
||||
let buf = filmcraft_media::reader::read_range(reader, pos, len).ok()?;
|
||||
if let Some(end) = buf.iter().position(|&b| b == b'\n') {
|
||||
let mut line = buf;
|
||||
line.truncate(end);
|
||||
return Some((line, pos + end as u64 + 1));
|
||||
}
|
||||
if len < want || want >= MAX_LINE {
|
||||
return None;
|
||||
}
|
||||
want = (want * 8).min(MAX_LINE);
|
||||
}
|
||||
}
|
||||
|
||||
impl Y4mSource {
|
||||
pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result<Self> {
|
||||
Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes)))
|
||||
}
|
||||
|
||||
pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result<Self> {
|
||||
let bad = |what: &str| CodecError::Container(format!("{name}: Y4M: {what}"));
|
||||
let (header, mut pos) = line_at(&*reader, 0).ok_or_else(|| bad("no stream header"))?;
|
||||
let header = String::from_utf8_lossy(&header).into_owned();
|
||||
let mut params = header.split(' ');
|
||||
if params.next() != Some("YUV4MPEG2") {
|
||||
return Err(bad("not a YUV4MPEG2 file"));
|
||||
}
|
||||
let (mut w, mut h, mut rate, mut par, mut colour, mut full) = (0u32, 0u32, None, (1u32, 1u32), "420jpeg".to_string(), false);
|
||||
for p in params.filter(|p| !p.is_empty()) {
|
||||
let (tag, v) = p.split_at(1);
|
||||
match tag {
|
||||
"W" => w = v.parse().map_err(|_| bad("invalid width"))?,
|
||||
"H" => h = v.parse().map_err(|_| bad("invalid height"))?,
|
||||
"F" => match parse_ratio(v) {
|
||||
Some((n, d)) if n > 0 && d > 0 => rate = Some(FrameRate::new(n, d)),
|
||||
_ => return Err(bad("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))
|
||||
{
|
||||
par = (n, d);
|
||||
}
|
||||
}
|
||||
"C" => colour = v.to_string(),
|
||||
"X" => full |= v.eq_ignore_ascii_case("COLORRANGE=FULL"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if w == 0 || h == 0 {
|
||||
return Err(bad("missing or zero size"));
|
||||
}
|
||||
if u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
return Err(CodecError::Unsupported(format!("{name}: Y4M frames larger than 268 million pixels")));
|
||||
}
|
||||
let layout = Layout::parse(&colour).ok_or_else(|| CodecError::Unsupported(format!("{name}: Y4M colour space {colour}")))?;
|
||||
// ffmpeg's default when the file names no rate
|
||||
let rate = rate.unwrap_or(FrameRate::FPS_25);
|
||||
let (luma, chroma, alpha) = plane_samples(w, h, layout);
|
||||
let per_sample = if layout.bits > 8 { 2 } else { 1 };
|
||||
let frame_bytes = (luma + 2 * chroma + alpha) * per_sample;
|
||||
|
||||
let len = reader.len();
|
||||
let mut frames = Vec::new();
|
||||
while pos < len {
|
||||
let Some((line, data)) = line_at(&*reader, pos) 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(bad("no complete frames"));
|
||||
}
|
||||
let color = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), range: if full { Range::Full } else { Range::Limited }, ..ColorInfo::REC709 };
|
||||
let duration = Tick::from_rational(frames.len() as i64, rate.den, rate.num);
|
||||
let bitrate = (frame_bytes as u64 * 8).checked_mul(rate.num as u64).map(|b| b / rate.den as u64);
|
||||
let info = MediaInfo {
|
||||
name: name.to_string(),
|
||||
kind: MediaKind::Movie,
|
||||
duration,
|
||||
video: Some(VideoStreamInfo {
|
||||
width: w,
|
||||
height: h,
|
||||
frame_rate: rate,
|
||||
par,
|
||||
codec: "Uncompressed YUV".into(),
|
||||
pixel_format: layout.label(),
|
||||
color,
|
||||
has_alpha: layout.alpha,
|
||||
bitrate,
|
||||
hdr: None,
|
||||
}),
|
||||
audio_streams: Vec::new(),
|
||||
container: "Y4M".into(),
|
||||
start_timecode: None,
|
||||
file_size: Some(len),
|
||||
};
|
||||
Ok(Self { info, reader, frames, frame_bytes, layout, rate })
|
||||
}
|
||||
|
||||
fn frame(&self, idx: usize) -> crate::Result<VideoFrame> {
|
||||
let (Some(&at), Some(v)) = (self.frames.get(idx), self.info.video.as_ref()) else {
|
||||
return Err(CodecError::Container("Y4M: frame index out of range".into()));
|
||||
};
|
||||
let data = filmcraft_media::reader::read_range(&*self.reader, at, self.frame_bytes).map_err(|e| CodecError::Container(e.to_string()))?;
|
||||
let (luma, chroma, alpha) = plane_samples(v.width, v.height, self.layout);
|
||||
// mono: neutral chroma at 4:2:0
|
||||
let mono = Layout { chroma: Some(Chroma::C420), ..self.layout };
|
||||
let grey = plane_samples(v.width, v.height, mono).1;
|
||||
let pixels = if self.layout.bits == 8 {
|
||||
let plane = |i: usize, n: usize| Arc::new(data.get(i..i + n).map(<[u8]>::to_vec).unwrap_or_else(|| vec![0; n]));
|
||||
let (y, cb, cr) = match self.layout.chroma {
|
||||
Some(_) => (plane(0, luma), plane(luma, chroma), plane(luma + chroma, chroma)),
|
||||
None => (plane(0, luma), Arc::new(vec![128; grey]), Arc::new(vec![128; grey])),
|
||||
};
|
||||
let a = self.layout.alpha.then(|| plane(luma + 2 * chroma, alpha));
|
||||
PixelData::Yuv8 { planes: [y, cb, cr], chroma: self.layout.chroma.unwrap_or(Chroma::C420), alpha: a }
|
||||
} else {
|
||||
let max = (1u32 << self.layout.bits) - 1;
|
||||
let plane = |i: usize, n: usize| {
|
||||
Arc::new(match data.get(i * 2..(i + n) * 2) {
|
||||
Some(b) => b.as_chunks::<2>().0.iter().map(|&s| u32::from(u16::from_le_bytes(s)).min(max) as u16).collect(),
|
||||
None => vec![0; n],
|
||||
})
|
||||
};
|
||||
let mid = 1u16 << (self.layout.bits - 1);
|
||||
let (y, cb, cr) = match self.layout.chroma {
|
||||
Some(_) => (plane(0, luma), plane(luma, chroma), plane(luma + chroma, chroma)),
|
||||
None => (plane(0, luma), Arc::new(vec![mid; grey]), Arc::new(vec![mid; grey])),
|
||||
};
|
||||
PixelData::Yuv16 { planes: [y, cb, cr], chroma: self.layout.chroma.unwrap_or(Chroma::C420), bits: self.layout.bits, alpha: None }
|
||||
};
|
||||
Ok(VideoFrame {
|
||||
width: v.width,
|
||||
height: v.height,
|
||||
data: pixels,
|
||||
color: v.color,
|
||||
par: v.par,
|
||||
pts: Tick::from_rational(idx as i64, self.rate.den, self.rate.num),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl MediaSource for Y4mSource {
|
||||
fn info(&self) -> &MediaInfo {
|
||||
&self.info
|
||||
}
|
||||
|
||||
fn video_frame(&self, req: FrameRequest) -> Result<Arc<VideoFrame>, MediaError> {
|
||||
let last = self.frames.len().saturating_sub(1);
|
||||
let idx = usize::try_from(self.rate.frame_at(req.time.max(Tick::ZERO))).unwrap_or(last).min(last);
|
||||
Ok(Arc::new(self.frame(idx)?))
|
||||
}
|
||||
|
||||
fn audio(&self, _start: i64, _frames: usize, _sample_rate: u32) -> Result<AudioBuffer, MediaError> {
|
||||
Err(MediaError::NoStream("audio"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, MediaError>> {
|
||||
if !sniff(&bytes) {
|
||||
return None;
|
||||
}
|
||||
Some(Y4mSource::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||
}
|
||||
|
||||
pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedReader) -> Option<Result<SharedSource, MediaError>> {
|
||||
if !sniff(head) {
|
||||
return None;
|
||||
}
|
||||
Some(Y4mSource::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! YUV4MPEG2 (`.y4m`) files (#1292): File ▸ Import and the Media Browser offer them, so they must
|
||||
//! open. Files are built here: a stream header of tagged parameters, then `FRAME` lines each
|
||||
//! followed by the planes (Y, Cb, Cr, 16-bit little-endian above 8 bits).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use filmcraft_color::Range;
|
||||
use filmcraft_frame::{Chroma, PixelData};
|
||||
use filmcraft_media::{FrameRequest, MediaKind, SharedSource};
|
||||
use filmcraft_time::{FrameRate, Tick};
|
||||
|
||||
/// 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(name: &str, bytes: Vec<u8>) -> SharedSource {
|
||||
filmcraft_codecs::open_bytes(name, Arc::from(bytes)).unwrap()
|
||||
}
|
||||
|
||||
/// The middle of frame `k` at `fps`.
|
||||
fn at(k: i64, fps: i64) -> FrameRequest {
|
||||
FrameRequest::full(Tick::from_rational(2 * k + 1, 1, 2 * fps))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn y4m_420_opens_as_a_movie_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 src = open("clip.y4m", y4m("W6 H4 F25:1 Ip A1:1 C420jpeg", "FRAME", &frames));
|
||||
let info = src.info();
|
||||
assert_eq!(info.kind, MediaKind::Movie);
|
||||
let v = info.video.as_ref().unwrap();
|
||||
assert_eq!((v.width, v.height, v.frame_rate), (6, 4, FrameRate::new(25, 1)));
|
||||
assert_eq!(info.duration, Tick::from_units(3, 25));
|
||||
for k in 0..3u8 {
|
||||
let f = src.video_frame(at(i64::from(k), 25)).unwrap();
|
||||
let PixelData::Yuv8 { planes, chroma, .. } = &f.data else { panic!("{:?}", f.data) };
|
||||
assert_eq!(*chroma, Chroma::C420);
|
||||
assert_eq!((planes[0].len(), planes[1].len(), planes[2].len()), (24, 6, 6));
|
||||
assert!(planes[0].iter().all(|&s| s == 16 + 50 * k) && planes[1].iter().all(|&s| s == 100 + k) && planes[2].iter().all(|&s| s == 200 - k));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn y4m_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 frame = Vec::new();
|
||||
for (n, v) in [(8, 940u16), (4, 512), (4, 64)] {
|
||||
for _ in 0..n {
|
||||
frame.extend(v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let src = open("ten.y4m", y4m("W4 H2 F30000:1001 It A4:3 C422p10 XCOLORRANGE=FULL", "FRAME Ip XFOO=1", &[frame.clone(), frame]));
|
||||
let v = src.info().video.clone().unwrap();
|
||||
assert_eq!((v.frame_rate, v.par, v.color.range), (FrameRate::new(30000, 1001), (4, 3), Range::Full));
|
||||
let f = src.video_frame(at(1, 30)).unwrap();
|
||||
let PixelData::Yuv16 { planes, chroma, bits, .. } = &f.data else { panic!("{:?}", f.data) };
|
||||
assert_eq!((*chroma, *bits), (Chroma::C422, 10));
|
||||
assert!(planes[0].iter().all(|&s| s == 940) && planes[1].iter().all(|&s| s == 512) && planes[2].iter().all(|&s| s == 64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn y4m_mono_has_neutral_chroma() {
|
||||
let src = open("grey.y4m", y4m("W2 H2 F24:1 Cmono", "FRAME", &[vec![77; 4]]));
|
||||
let f = src.video_frame(at(0, 24)).unwrap();
|
||||
let PixelData::Yuv8 { planes, .. } = &f.data else { panic!("{:?}", f.data) };
|
||||
assert!(planes[0].iter().all(|&s| s == 77) && planes[1].iter().chain(planes[2].iter()).all(|&s| s == 128));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_y4m_files_open_from_a_reader() {
|
||||
let bytes: Arc<[u8]> = Arc::from(y4m("W2 H2 F25:1 C444", "FRAME", &[planes8(4, 4, 50, 60, 70), planes8(4, 4, 90, 60, 70)]));
|
||||
let reader: filmcraft_media::SharedReader = Arc::new(filmcraft_media::reader::MemReader(bytes));
|
||||
let src = filmcraft_media::reader::open_reader("big.y4m", reader, &filmcraft_codecs::reader_openers(), &filmcraft_codecs::openers()).unwrap();
|
||||
let f = src.video_frame(at(1, 25)).unwrap();
|
||||
let PixelData::Yuv8 { planes, chroma, .. } = &f.data else { panic!("{:?}", f.data) };
|
||||
assert_eq!((*chroma, planes[0][0]), (Chroma::C444, 90));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_and_hostile_y4m_files_fail_without_panicking() {
|
||||
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 header in [
|
||||
"W99999999 H99999999 F25:1",
|
||||
"W0 H4 F25:1",
|
||||
"W6 H4 F0:0",
|
||||
"W6 H4 F25:0",
|
||||
"W6 H4 F25:1 C411",
|
||||
"W6 H4 F25:1 Cbogus",
|
||||
"W6 H4 A0:0 F-5:1",
|
||||
"H4 F25:1",
|
||||
"W6 H4 F1:9223372036854775807",
|
||||
] {
|
||||
cases.push(y4m(header, "FRAME", &[vec![0; 36]]));
|
||||
}
|
||||
for (i, b) in cases.into_iter().enumerate() {
|
||||
let r = std::panic::catch_unwind(|| {
|
||||
filmcraft_codecs::open_bytes("damaged.y4m", Arc::from(b)).map(|s| (0..3).filter(|&k| s.video_frame(at(k, 25)).is_ok()).count())
|
||||
});
|
||||
assert!(r.is_ok(), "case {i} panicked");
|
||||
}
|
||||
}
|
||||
|
||||
/// ffmpeg (an external oracle) writes `.y4m` files; every frame we read must equal, byte for byte,
|
||||
/// the planes ffmpeg's own rawvideo output gives for the same file.
|
||||
#[test]
|
||||
fn ffmpeg_y4m_files_match_ffmpeg_rawvideo() {
|
||||
let Some(ff) = filmcraft_testkit::oracle::ffmpeg_or_skip("Y4M oracle") else { return };
|
||||
let dir = filmcraft_testkit::fixtures::fixtures_dir("codecs/y4m");
|
||||
for (pix_fmt, bytes_per_sample) in [("yuv420p", 1usize), ("yuv422p10le", 2)] {
|
||||
let y4m = dir.join(format!("testsrc2_{pix_fmt}.y4m"));
|
||||
let made = filmcraft_testkit::fixtures::generate(&y4m, |tmp| {
|
||||
std::process::Command::new(&ff)
|
||||
.args([
|
||||
"-y",
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=94x62:rate=25",
|
||||
"-frames:v",
|
||||
"5",
|
||||
"-pix_fmt",
|
||||
pix_fmt,
|
||||
"-strict",
|
||||
"-1",
|
||||
"-f",
|
||||
"yuv4mpegpipe",
|
||||
])
|
||||
.arg(tmp)
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
});
|
||||
let Some(y4m) = made else { continue };
|
||||
let raw =
|
||||
std::process::Command::new(&ff).args(["-v", "error", "-i"]).arg(&y4m).args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"]).output().unwrap().stdout;
|
||||
let src = open("oracle.y4m", std::fs::read(&y4m).unwrap());
|
||||
assert_eq!(src.info().duration, Tick::from_units(5, 25), "{pix_fmt}");
|
||||
let frame_len = raw.len() / 5;
|
||||
for k in 0..5 {
|
||||
let f = src.video_frame(at(k as i64, 25)).unwrap();
|
||||
let ours: Vec<u8> = match &f.data {
|
||||
PixelData::Yuv8 { planes, .. } => planes.iter().flat_map(|p| p.iter().copied()).collect(),
|
||||
PixelData::Yuv16 { planes, .. } => planes.iter().flat_map(|p| p.iter().flat_map(|s| s.to_le_bytes())).collect(),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(ours.len(), frame_len, "{pix_fmt} frame {k}");
|
||||
assert!(ours == raw[k * frame_len..(k + 1) * frame_len], "{pix_fmt} frame {k} differs ({bytes_per_sample} bytes/sample)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,9 @@ pub const ALL_COLUMNS: [&str; 10] =
|
||||
["Name", "Media Type", "Size", "Date Modified", "Frame Rate", "Media Duration", "Video Info", "Audio Info", "Video Codec", "Audio Codec"];
|
||||
pub const DEFAULT_COLUMNS: [&str; 5] = ["Name", "Frame Rate", "Media Duration", "Video Info", "Size"];
|
||||
|
||||
const PROJECT_EXTENSIONS: [&str; 6] = ["fcproj", "edl", "xml", "fcpxml", "otio", "ale"];
|
||||
/// Project and interchange files the browser can import. Avid Log Exchange (`.ale`) is left out:
|
||||
/// FilmCraft writes it (File ▸ Export) but, like Premiere, does not read it (#1292).
|
||||
const PROJECT_EXTENSIONS: [&str; 5] = ["fcproj", "edl", "xml", "fcpxml", "otio"];
|
||||
const CAPTION_EXTENSIONS: [&str; 3] = ["srt", "vtt", "scc"];
|
||||
const RECENT_MAX: usize = 10;
|
||||
|
||||
|
||||
@@ -318,3 +318,12 @@ fn probing_large_unstreamable_files_does_not_read_them_whole() {
|
||||
assert_eq!(r["items"].as_array().unwrap().len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #1292: an Avid Log Exchange file is something FilmCraft writes (File ▸ Export), not a project
|
||||
/// it can import, so the browser doesn't list it with the project files.
|
||||
#[test]
|
||||
fn ale_logs_are_not_offered_for_import() {
|
||||
assert_eq!(media_browser::kind_of("clips.ale"), None);
|
||||
assert!(!media_browser::extensions().contains(&"ale"));
|
||||
assert_eq!(media_browser::kind_of("cut.edl"), Some("project"));
|
||||
}
|
||||
Reference in new issue
Block a user