diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index a6a93733..9a8bebdb 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -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 { - 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> { @@ -143,6 +146,7 @@ fn reader_registry() -> &'static RwLock> { ogg::reader_opener, mpeg::reader_opener, apv::reader_opener, + y4m::reader_opener, avi::reader_opener, ]) }) diff --git a/crates/codecs/src/y4m.rs b/crates/codecs/src/y4m.rs new file mode 100644 index 00000000..76937e17 --- /dev/null +++ b/crates/codecs/src/y4m.rs @@ -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, + bits: u32, + alpha: bool, +} + +impl Layout { + 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 }) + } + + 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, + 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, 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::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes))) + } + + pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result { + 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 { + 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, 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 { + Err(MediaError::NoStream("audio")) + } +} + +pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option> { + 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> { + 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, 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}"); + } + } +} diff --git a/crates/codecs/tests/y4m_file.rs b/crates/codecs/tests/y4m_file.rs new file mode 100644 index 00000000..f9aa1e86 --- /dev/null +++ b/crates/codecs/tests/y4m_file.rs @@ -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]) -> 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(name: &str, bytes: Vec) -> 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> = (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> = (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 = 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)"); + } + } +} diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index b5a4d5da..d1e65f81 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -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; diff --git a/crates/engine/src/media_browser_tests.rs b/crates/engine/src/media_browser_tests.rs index 3c026a34..b67a1d92 100644 --- a/crates/engine/src/media_browser_tests.rs +++ b/crates/engine/src/media_browser_tests.rs @@ -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")); +}