mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-24 01:58:19 -06:00
#288: MP4 / MOV pictures are cropped as the file says (H.264 / HEVC cropping, clean aperture)
The MP4 source reported the sample entry's size, only swapped for the display rotation, so two crops were lost: the codec's own (H.264 SPS frame cropping, HEVC conformance window), which the decoder applies but the reported size and sequences made from the clip didn't, and the clean aperture (`clap`), parsed but never used. An iPhone clip (1920x1440 coded, 1744x1308 clean aperture, -90 degree display matrix) showed 1440x1920 instead of 1308x1744. H.264 / HEVC sizes now come from the stream's parameter sets; the clean aperture is a rectangle inside that picture (ignored when it makes no sense), reported as the size and cut from every frame by the GOP cache before the display rotation, through the new VideoFrame::cropped. Tests: bit-exact against ffmpeg (which applies both crops) for an H.264 SPS crop, an HEVC conformance window, centred and off-centre clean apertures and a clean aperture with a rotation; clean apertures that make no sense are ignored without a panic; VideoFrame::cropped on every pixel format with odd sizes and misaligned corners. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7b6c134472
commit
e90508f16f
@@ -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<ColorInfo>,
|
||||
/// 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<Region>,
|
||||
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<Region>) -> 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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Region> {
|
||||
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<u32> = 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<Vec<u8>> {
|
||||
@@ -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::<u32>() / 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;
|
||||
|
||||
@@ -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<u8> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T: Copy + Into<u32> + TryFrom<u32> + 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<T: Copy + Default>(src: &[T], w: usize, h: usize, n: usize, r: Region) -> Vec<T> {
|
||||
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<T: Copy + Default + Send + Sync>(src: &[T], w: usize, h: usize, n: usize, q: u8) -> Vec<T> {
|
||||
let mut out = vec![T::default(); w * h * n];
|
||||
@@ -643,6 +712,65 @@ fn rotate_chroma<T: Copy + Default + Send + Sync>(planes: &[Arc<Vec<T>>; 3], chr
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn cropping_cuts_every_plane_at_the_same_place() {
|
||||
// 6x4 RGBA8, red = index
|
||||
let px: Vec<u8> = (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::<Vec<_>>(), [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::<Vec<_>>(), [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<u8> = (0..35u8).collect();
|
||||
let cb: Vec<u8> = (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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user