diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index 3972998ef..b23b130e5 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -12,7 +12,7 @@ use std::time::Duration; use web_time::Instant; use filmcraft_color::ColorInfo; -use filmcraft_frame::VideoFrame; +use filmcraft_frame::{Region, VideoFrame}; use crate::CodecError; use crate::video::VideoDecoder; @@ -410,6 +410,8 @@ pub struct GopCache { explicit_color: Option, /// Clockwise quarter turns from the container's display matrix, applied to every frame. rotation: u8, + /// The container's clean aperture, cut from every frame before the rotation. + crop: Option, budget: usize, } @@ -448,7 +450,7 @@ impl GopCache { pool: pool.clone(), }); pool.register(&shared); - Self { shared, explicit_color, rotation: 0, budget: 384 << 20 } + Self { shared, explicit_color, rotation: 0, crop: None, budget: 384 << 20 } } /// Turn every decoded frame clockwise by `quarter_turns` × 90° (the container's display @@ -458,14 +460,24 @@ impl GopCache { self } + /// Cut every decoded frame to `region` (the container's clean aperture, in stored picture + /// coordinates) before the rotation, as cropping and the display matrix compose. + pub fn with_crop(mut self, region: Option) -> Self { + self.crop = region; + self + } + /// Colour signalled by the container wins over the bitstream's (YUV frames only); the - /// container's display rotation is applied. + /// container's clean aperture and then its display rotation are applied. fn finish(&self, mut f: VideoFrame) -> VideoFrame { if let Some(c) = self.explicit_color && !matches!(f.data, filmcraft_frame::PixelData::Rgba8(_) | filmcraft_frame::PixelData::RgbaF32(_)) { f.color = c; } + if let Some(r) = self.crop { + f = f.cropped(r); + } if self.rotation != 0 { f.rotated(self.rotation) } else { f } } diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index e48670dfa..b24d98672 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -8,8 +8,8 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; use filmcraft_color::{ColorInfo, Matrix, Primaries, Range, Transfer}; -use filmcraft_frame::{AudioBuffer, VideoFrame}; -use filmcraft_isobmff::{CodecConfig, Mp4File, TrackKind}; +use filmcraft_frame::{AudioBuffer, Region, VideoFrame}; +use filmcraft_isobmff::{CleanAperture, CodecConfig, Mp4File, TrackKind}; use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo}; use filmcraft_time::{FrameRate, Tick}; @@ -104,6 +104,28 @@ fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u }) } +/// The clean aperture (`clap`, ISO/IEC 14496-12 / QuickTime) of a `w`×`h` decoded picture as a +/// pixel rectangle: its size, centred on the picture and moved by its offsets, rounded to whole +/// samples. `None` (the whole picture is shown) when it is the whole picture or makes no sense: a +/// zero denominator, an empty or larger-than-the-picture size, a rectangle outside the picture. +/// [`VideoFrame::cropped`] moves an odd corner onto the chroma grid. +fn clean_aperture(c: CleanAperture, (w, h): (u32, u32)) -> Option { + let q = |(n, d): (u32, u32)| (d != 0).then(|| f64::from(n) / f64::from(d)); + let qs = |(n, d): (i32, u32)| (d != 0).then(|| f64::from(n) / f64::from(d)); + let (cw, ch, dx, dy) = (q(c.width)?.round(), q(c.height)?.round(), qs(c.horiz_offset)?, qs(c.vert_offset)?); + let (fw, fh) = (f64::from(w), f64::from(h)); + if !(cw >= 1.0 && ch >= 1.0 && cw <= fw && ch <= fh) { + return None; + } + let (x, y) = (((fw - cw) / 2.0 + dx).round(), ((fh - ch) / 2.0 + dy).round()); + // also false for NaN + if !(x >= 0.0 && y >= 0.0 && x + cw <= fw && y + ch <= fh) { + return None; + } + let r = Region { x: x as usize, y: y as usize, w: cw as usize, h: ch as usize }; + (!r.is_full(w as usize, h as usize)).then_some(r) +} + fn color_from(entry: &filmcraft_isobmff::SampleEntry, w: u32, h: u32) -> ColorInfo { let mut c = ColorInfo { matrix: filmcraft_frame::default_matrix(w, h), transfer: Transfer::Bt709, primaries: Primaries::Bt709, range: Range::Limited }; // VP9 and APV carry their colour description in vpcC / apvC (used when there is no colr box). @@ -171,11 +193,21 @@ impl Mp4Source { let mut explicit_color = None; // display rotation from the track matrix (portrait phone video is stored landscape) let rotation = vtrack.and_then(|i| file.tracks[i].display_rotation()).unwrap_or(0); + // the clean aperture ('clap') cut from every frame, before the rotation + let mut aperture = None; let video = vtrack.map(|i| { let t = &file.tracks[i]; let entry = &t.entries[0]; let vp = entry.video.clone().unwrap_or_default(); let (w, h) = (if vp.width > 0 { vp.width as u32 } else { t.width }, if vp.height > 0 { vp.height as u32 } else { t.height }); + // H.264 and HEVC say their own output size (frame cropping / conformance window), which + // is what the decoder returns; a sample entry can give the coded size instead (#288) + let (w, h) = match crate::hw::NalStreamInfo::from_entry(entry) { + Some(Ok(n)) if n.crop.2 > 0 && n.crop.3 > 0 => (n.crop.2, n.crop.3), + _ => (w, h), + }; + aperture = vp.clean_aperture.and_then(|c| clean_aperture(c, (w, h))); + let (w, h) = aperture.map_or((w, h), |r| (r.w as u32, r.h as u32)); // frame rate from the median sample duration let mut durs: Vec = t.samples.iter().take(240).map(|s| s.duration).collect(); durs.sort_unstable(); @@ -276,7 +308,7 @@ impl Mp4Source { Some(Mp4Audio { track: i, state, starts, offset, preroll }) }) .collect(); - Ok(Self { info, bytes, file, vtrack, video: GopCache::new(explicit_color).with_rotation(rotation), audios }) + Ok(Self { info, bytes, file, vtrack, video: GopCache::new(explicit_color).with_rotation(rotation).with_crop(aperture), audios }) } fn read(&self, track: usize, i: usize) -> crate::Result> { @@ -743,6 +775,60 @@ mod tests { assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((64, 32))); } + /// A one-frame 25 fps ProRes MOV (64×32, left half bright) with clean aperture `clap`. + fn clap_mov(clap: filmcraft_isobmff::CleanAperture) -> Arc<[u8]> { + let (w, h) = (64u32, 32u32); + let mut fr = filmcraft_prores::Frame::new(w, h, filmcraft_prores::ChromaFormat::Yuv422, 10, false); + for (i, y) in fr.y.iter_mut().enumerate() { + *y = if (i as u32 % w) < w / 2 { 800 } else { 100 }; + } + let data = filmcraft_prores::Encoder::new(filmcraft_prores::Profile::Hq, w, h).encode(&fr).expect("encode"); + let mut entry = SampleEntry::prores(FourCc(*b"apch"), w as u16, h as u16); + entry.video.as_mut().expect("video").clean_aperture = Some(clap); + let mut mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).expect("writer"); + let t = mux.add_track(TrackConfig::new(entry, 25)).expect("track"); + mux.write_sample(t, WriteSample { data: &data, duration: 1, composition_offset: 0, is_sync: true }).expect("sample"); + mux.finish().expect("finish").into_inner().into() + } + + #[test] + fn clean_aperture_crops_frames_and_reported_size() { + use filmcraft_isobmff::CleanAperture; + let clap = |w: (u32, u32), h: (u32, u32), dx: (i32, u32), dy: (i32, u32)| CleanAperture { width: w, height: h, horiz_offset: dx, vert_offset: dy }; + // 32x16 moved 8 left of centre: columns 8..40 (24 bright, then 8 dark), rows 8..24 + let s = Mp4Source::open("clap.mov", clap_mov(clap((32, 1), (16, 1), (-8, 1), (0, 1)))).expect("open"); + assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((32, 16))); + let f = s.video_frame(FrameRequest::full(Tick::ZERO)).expect("frame"); + assert_eq!((f.width, f.height), (32, 16)); + let l = f.luma8(); + let col = |x: usize| (0..16).map(|y| u32::from(l[y * 32 + x])).sum::() / 16; + assert!(col(0) > col(31) + 100 && col(23) > col(24) + 100, "bright up to column 23: {} {} {} {}", col(0), col(23), col(24), col(31)); + + // rational sizes and offsets (64/2 = 32): the same rectangle + let s = Mp4Source::open("clap2.mov", clap_mov(clap((64, 2), (48, 3), (-16, 2), (0, 7)))).expect("open"); + assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((32, 16))); + + // a clean aperture that makes no sense is ignored (the whole picture), never a panic + for c in [ + clap((0, 1), (16, 1), (0, 1), (0, 1)), + clap((32, 0), (16, 1), (0, 1), (0, 1)), + clap((32, 1), (16, 1), (0, 0), (0, 1)), + clap((65, 1), (16, 1), (0, 1), (0, 1)), + clap((32, 1), (33, 1), (0, 1), (0, 1)), + clap((32, 1), (16, 1), (17, 1), (0, 1)), + clap((32, 1), (16, 1), (0, 1), (-9, 1)), + clap((32, 1), (16, 1), (i32::MIN, 1), (i32::MAX, 1)), + clap((u32::MAX, 1), (u32::MAX, 1), (0, 1), (0, 1)), + clap((1, u32::MAX), (16, 1), (0, u32::MAX), (0, 1)), + clap((64, 1), (32, 1), (0, 1), (0, 1)), + ] { + let s = Mp4Source::open("bad.mov", clap_mov(c)).expect("open"); + assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((64, 32)), "{c:?}"); + let f = s.video_frame(FrameRequest::full(Tick::ZERO)).expect("frame"); + assert_eq!((f.width, f.height), (64, 32), "{c:?}"); + } + } + #[test] fn mirrored_or_scaled_matrices_are_not_mistaken_for_rotations() { const ONE: i32 = 0x10000; diff --git a/crates/codecs/tests/mp4_oracle.rs b/crates/codecs/tests/mp4_oracle.rs index 94ce49d40..403da23f7 100644 --- a/crates/codecs/tests/mp4_oracle.rs +++ b/crates/codecs/tests/mp4_oracle.rs @@ -53,3 +53,103 @@ fn bframe_mp4_duration_is_the_decoded_frame_count() { let src = filmcraft_codecs::open_bytes("bframes_x264.mp4", bytes(&f)).unwrap(); assert_eq!(src.info().duration, filmcraft_time::FrameRate::FPS_30.tick_of(n as i64)); } + +/// `mp4` (moov after mdat, as ffmpeg writes it) with a clean aperture (`clap`) of `w`×`h` moved by +/// (`dx`, `dy`) from the centre, inserted at the end of the first video sample entry. +fn with_clap(mp4: &[u8], (w, h, dx, dy): (u32, u32, i32, i32)) -> Vec { + let be = |b: &[u8], at: usize| u32::from_be_bytes([b[at], b[at + 1], b[at + 2], b[at + 3]]) as usize; + // the chain moov > trak > mdia > minf > stbl > stsd > avc1 (stsd has 8 bytes before its entries) + let mut chain = Vec::new(); + let (mut off, mut end) = (0, mp4.len()); + for (want, skip) in [(b"moov", 8), (b"trak", 8), (b"mdia", 8), (b"minf", 8), (b"stbl", 8), (b"stsd", 16), (b"avc1", 0)] { + loop { + assert!(off + 8 <= end, "no {}", String::from_utf8_lossy(want)); + let size = be(mp4, off); + if &mp4[off + 4..off + 8] == want { + chain.push(off); + end = off + size; + off += skip; + break; + } + off += size; + } + } + let entry = *chain.last().unwrap(); + assert!(mp4.windows(4).position(|x| x == b"mdat").unwrap() < chain[0], "moov must follow mdat"); + let mut clap = Vec::new(); + for v in [40u32, u32::from_be_bytes(*b"clap"), w, 1, h, 1, dx as u32, 1, dy as u32, 1] { + clap.extend_from_slice(&v.to_be_bytes()); + } + let mut out = mp4.to_vec(); + let at = entry + be(mp4, entry); + out.splice(at..at, clap); + for &b in &chain { + let size = (be(&out, b) + 40) as u32; + out[b..b + 4].copy_from_slice(&size.to_be_bytes()); + } + out +} + +/// Cropped pictures are the size and the samples ffmpeg decodes (#288): H.264 frame cropping (SPS) +/// and an HEVC conformance window while the sample entry gives the uncropped size, and a clean +/// aperture (`clap`, as iPhones write), centred, off-centre, and with a 90° display rotation +/// applied after it. +#[test] +fn cropped_pictures_match_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let src = ["-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=1,noise=alls=10:allf=t", "-c:v", "libx264", "-bf", "2", "-pix_fmt", "yuv420p"]; + let Some(plain) = fixture(&ff, "crop_plain_x264.mp4", &src) else { + eprintln!("SKIPPED: ffmpeg without libx264"); + return; + }; + let sps = + fixture(&ff, "crop_sps_x264.mp4", &[&src[..], &["-bsf:v", "h264_metadata=crop_left=16:crop_right=16:crop_top=8:crop_bottom=8"]].concat()).unwrap(); + // the display matrix added without re-encoding (an encode would turn the pixels instead) + let rotated = fixture(&ff, "crop_rotated_x264.mp4", &["-display_rotation", "90", "-i", plain.to_str().unwrap(), "-c", "copy"]).unwrap(); + let dir = dir(); + let mut cases = vec![("SPS crop", sps, (288, 224))]; + // HEVC: the conformance window + let hevc = [ + "-f", + "lavfi", + "-i", + "testsrc2=size=320x240:rate=30:duration=1,noise=alls=10:allf=t", + "-c:v", + "libx265", + "-x265-params", + "log-level=error:bframes=3", + "-tag:v", + "hvc1", + "-pix_fmt", + "yuv420p", + ]; + match fixture(&ff, "crop_conformance_x265.mp4", &[&hevc[..], &["-bsf:v", "hevc_metadata=crop_left=16:crop_right=16:crop_top=8:crop_bottom=8"]].concat()) { + Some(p) => cases.push(("HEVC conformance window", p, (288, 224))), + None => eprintln!("SKIPPED (HEVC case): ffmpeg without libx265"), + } + for (name, base, clap, size) in [ + ("centred clap", &plain, (280, 200, 0, 0), (280, 200)), + ("off-centre clap", &plain, (200, 150, -30, 22), (200, 150)), + ("clap then rotation", &rotated, (280, 200, 0, 0), (200, 280)), + ] { + let path = dir.join(format!("crop_{}.mp4", name.replace(' ', "_"))); + std::fs::write(&path, with_clap(&bytes(base), clap)).unwrap(); + cases.push((name, path, size)); + } + for (name, path, (w, h)) in cases { + let src = filmcraft_codecs::open_bytes(path.file_name().unwrap().to_str().unwrap(), bytes(&path)).unwrap(); + let v = src.info().video.clone().unwrap(); + assert_eq!((v.width, v.height), (w, h), "{name}: reported size"); + let (cw, ch) = (w.div_ceil(2) as usize, h.div_ceil(2) as usize); + let raw = ffmpeg_frames(&ff, &path, "yuv420p"); + let frame_bytes = (w * h) as usize + 2 * cw * ch; + assert_eq!(raw.len() % frame_bytes, 0, "{name}: ffmpeg's frames are {w}x{h}"); + let n = raw.len() / frame_bytes; + assert_eq!(n, 30, "{name}"); + for (i, want) in raw.chunks(frame_bytes).enumerate() { + let f = src.video_frame(filmcraft_media::FrameRequest::full(v.frame_rate.tick_of(i as i64))).unwrap(); + assert_eq!((f.width, f.height), (w, h), "{name}: frame {i} size"); + assert!(planes(&f) == raw_planes(want, w as usize, h as usize, cw, ch, 1), "{name}: frame {i} differs from ffmpeg's"); + } + } +} diff --git a/crates/frame/src/lib.rs b/crates/frame/src/lib.rs index 0b4afcf36..4be8cfb1f 100644 --- a/crates/frame/src/lib.rs +++ b/crates/frame/src/lib.rs @@ -179,6 +179,61 @@ impl VideoFrame { Some(VideoFrame { width: ow as u32, height: oh as u32, data, color: self.color, par: self.par, pts: self.pts }) } + /// The part of the frame inside `region` (a container's clean aperture). The region is clipped + /// to the frame; on Y'CbCr frames its corner is moved up / left onto the chroma grid (at most + /// one sample, keeping its size where the frame allows), so chroma is cut where luma is. An empty + /// region, or one covering the whole frame, leaves the frame as it is. + pub fn cropped(&self, region: Region) -> VideoFrame { + let (w, h) = (self.width as usize, self.height as usize); + let (sx, sy) = match &self.data { + PixelData::Yuv8 { chroma, .. } | PixelData::Yuv16 { chroma, .. } => chroma.shifts(), + PixelData::Rgba8(_) | PixelData::RgbaF32(_) => (0, 0), + }; + let (mx, my) = ((1usize << sx) - 1, (1usize << sy) - 1); + let (x, y) = (region.x.min(w) & !mx, region.y.min(h) & !my); + let r = Region { x, y, w: region.w, h: region.h }.clip(w, h); + if r.w == 0 || r.h == 0 || (r.w == w && r.h == h) { + return self.clone(); + } + let chroma_rect = |cw: usize, ch: usize| { + (r.x >> sx, r.y >> sy, r.w.div_ceil(1 << sx).min(cw - (r.x >> sx).min(cw)), r.h.div_ceil(1 << sy).min(ch - (r.y >> sy).min(ch))) + }; + let data = match &self.data { + PixelData::Rgba8(d) => PixelData::Rgba8(Arc::new(crop_plane(d, w, h, 4, r))), + PixelData::RgbaF32(d) => PixelData::RgbaF32(Arc::new(crop_plane(d, w, h, 4, r))), + PixelData::Yuv8 { planes, chroma, alpha } => { + let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy)); + let (cx, cy, cr_w, cr_h) = chroma_rect(cw, ch); + let c = Region { x: cx, y: cy, w: cr_w, h: cr_h }; + PixelData::Yuv8 { + planes: [ + Arc::new(crop_plane(&planes[0], w, h, 1, r)), + Arc::new(crop_plane(&planes[1], cw, ch, 1, c)), + Arc::new(crop_plane(&planes[2], cw, ch, 1, c)), + ], + chroma: *chroma, + alpha: alpha.as_ref().map(|a| Arc::new(crop_plane(a, w, h, 1, r))), + } + } + PixelData::Yuv16 { planes, chroma, bits, alpha } => { + let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy)); + let (cx, cy, cr_w, cr_h) = chroma_rect(cw, ch); + let c = Region { x: cx, y: cy, w: cr_w, h: cr_h }; + PixelData::Yuv16 { + planes: [ + Arc::new(crop_plane(&planes[0], w, h, 1, r)), + Arc::new(crop_plane(&planes[1], cw, ch, 1, c)), + Arc::new(crop_plane(&planes[2], cw, ch, 1, c)), + ], + chroma: *chroma, + bits: *bits, + alpha: alpha.as_ref().map(|a| Arc::new(crop_plane(a, w, h, 1, r))), + } + } + }; + VideoFrame { width: r.w as u32, height: r.h as u32, data, color: self.color, par: self.par, pts: self.pts } + } + /// The frame turned clockwise by `quarter_turns` × 90° (a container's display rotation). /// Planes rotate at their own resolution; 4:2:2 chroma is widened to 4:4:4 first for a quarter /// or three-quarter turn (its half-width chroma would become half-height, which has no @@ -595,6 +650,20 @@ fn box_plane + TryFrom + Send + Sync + Default>(src: &[ out } +/// The `r` part of a `w`×`h` plane of `n`-element pixels (`r` lies inside the plane). A plane too +/// short for its size gives a blank result, as in [`rotate_plane`]. +fn crop_plane(src: &[T], w: usize, h: usize, n: usize, r: Region) -> Vec { + let mut out = vec![T::default(); r.w * r.h * n]; + if src.len() < w * h * n || r.x + r.w > w || r.y + r.h > h { + return out; + } + for (row, o) in out.chunks_exact_mut((r.w * n).max(1)).enumerate().take(r.h) { + let s = ((r.y + row) * w + r.x) * n; + o.copy_from_slice(&src[s..s + r.w * n]); + } + out +} + /// A `w`×`h` plane of `n`-element pixels turned clockwise by `q` (1–3) quarter turns. fn rotate_plane(src: &[T], w: usize, h: usize, n: usize, q: u8) -> Vec { let mut out = vec![T::default(); w * h * n]; @@ -643,6 +712,65 @@ fn rotate_chroma(planes: &[Arc>; 3], chr #[cfg(test)] mod tests { + #[test] + fn cropping_cuts_every_plane_at_the_same_place() { + // 6x4 RGBA8, red = index + let px: Vec = (0..24u8).flat_map(|i| [i, 0, 0, 255]).collect(); + let f = VideoFrame { par: (4, 3), ..VideoFrame::rgba8(6, 4, px) }; + let c = f.cropped(Region { x: 1, y: 1, w: 3, h: 2 }); + assert_eq!((c.width, c.height, c.par), (3, 2, (4, 3))); + assert_eq!(c.to_rgba8().chunks(4).map(|p| p[0]).collect::>(), [7, 8, 9, 13, 14, 15]); + // a region reaching past the frame is clipped to it; an empty or full one changes nothing + let c = f.cropped(Region { x: 4, y: 3, w: 100, h: 100 }); + assert_eq!((c.width, c.height), (2, 1)); + assert_eq!(c.to_rgba8().chunks(4).map(|p| p[0]).collect::>(), [22, 23]); + for r in [ + Region { x: 0, y: 0, w: 0, h: 2 }, + Region { x: 9, y: 9, w: 2, h: 2 }, + Region { x: 0, y: 0, w: 6, h: 4 }, + Region { x: 0, y: 0, w: usize::MAX, h: usize::MAX }, + ] { + let c = f.cropped(r); + assert_eq!((c.width, c.height), (6, 4), "{r:?}"); + } + + // 4:2:0, odd sizes (7x5: chroma 4x3), with alpha: luma = index, chroma = 100 + index + let luma: Vec = (0..35u8).collect(); + let cb: Vec = (100..112u8).collect(); + let yuv = |chroma| VideoFrame { + width: 7, + height: 5, + data: PixelData::Yuv8 { planes: [Arc::new(luma.clone()), Arc::new(cb.clone()), Arc::new(cb.clone())], chroma, alpha: Some(Arc::new(luma.clone())) }, + color: Default::default(), + par: (1, 1), + pts: Default::default(), + }; + // an odd corner moves onto the chroma grid (1,1 -> 0,0), keeping the size + let c = yuv(Chroma::C420).cropped(Region { x: 1, y: 1, w: 3, h: 3 }); + assert_eq!((c.width, c.height), (3, 3)); + let PixelData::Yuv8 { planes, alpha, .. } = &c.data else { panic!() }; + assert_eq!(*planes[0], [0, 1, 2, 7, 8, 9, 14, 15, 16]); + assert_eq!(**alpha.as_ref().unwrap(), *planes[0]); + assert_eq!(*planes[1], [100, 101, 104, 105], "chroma 2x2 from (0,0)"); + // even corner: chroma starts at (1,1); the last chroma column of an odd width is kept + let c = yuv(Chroma::C420).cropped(Region { x: 2, y: 2, w: 5, h: 3 }); + let PixelData::Yuv8 { planes, .. } = &c.data else { panic!() }; + assert_eq!((c.width, c.height), (5, 3)); + assert_eq!(*planes[1], [105, 106, 107, 109, 110, 111], "chroma 3x2 from (1,1)"); + // 4:4:4 needs no alignment + let c = yuv(Chroma::C444).cropped(Region { x: 1, y: 1, w: 2, h: 1 }); + let PixelData::Yuv8 { planes, .. } = &c.data else { panic!() }; + assert_eq!(*planes[0], [8, 9]); + // a plane shorter than its size gives a blank picture, never a panic + let mut short = yuv(Chroma::C420); + if let PixelData::Yuv8 { planes, .. } = &mut short.data { + planes[0] = Arc::new(vec![1; 3]); + } + let c = short.cropped(Region { x: 2, y: 2, w: 2, h: 2 }); + let PixelData::Yuv8 { planes, .. } = &c.data else { panic!() }; + assert_eq!(*planes[0], [0, 0, 0, 0]); + } + #[test] fn rotation_turns_every_plane_clockwise() { // 4x2 RGBA8 with distinct pixels 0..8 diff --git a/crates/isobmff/README.md b/crates/isobmff/README.md index 3ada2adee..d74ae54a3 100644 --- a/crates/isobmff/README.md +++ b/crates/isobmff/README.md @@ -69,7 +69,8 @@ Boxes with 64-bit sizes and with size 0 (runs to the end of the file) are suppor | anything else | `Unknown { fourcc, raw }` | Video entries also parse `colr` (`nclx`/`nclc`/ICC), `pasp`, `clap`, `fiel`, `gama` and `btrt` into -`VideoParams`. Audio entries parse QuickTime sound description v0/v1/v2 into `AudioParams`. +`VideoParams`. The MP4 source in `filmcraft-codecs` crops every frame to `clap`'s clean aperture +before the track's display rotation, and reports that size. Audio entries parse QuickTime sound description v0/v1/v2 into `AudioParams`. ### Robustness The parser does not panic on malformed input; every error comes back as an `Error`. Table counts are