filmcraft/crates/codecs/src/mp4.rs

556 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! MP4/MOV media source with GOP-aware random access (see [`crate::gop`]).
//!
//! Opus (`Opus` sample entry + `dOps`): always 48 kHz output. Pre-skip is removed by the edit list
//! (`media_time` = pre-skip), or applied from `dOps` when a file has no edit list; random access
//! decodes [`crate::audio::OPUS_PRE_ROLL`] of preceding packets first (≥ the 80 ms `roll` distance).
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_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo};
use filmcraft_time::{FrameRate, Tick};
use crate::audio::{PacketDecoder, decode_pcm};
use crate::gop::{GopCache, VideoSamples};
use crate::video::VideoDecoder;
use crate::{CodecError, make_video_decoder};
struct AudioState {
decoder: Option<PacketDecoder>,
/// Decoded packets by sample index.
packets: HashMap<usize, Arc<Vec<Vec<f32>>>>,
order: Vec<usize>,
last_decoded: Option<usize>,
}
pub struct Mp4Source {
info: MediaInfo,
bytes: crate::Src,
file: Mp4File,
vtrack: Option<usize>,
atrack: Option<usize>,
video: GopCache,
audio: Mutex<AudioState>,
/// Cumulative sample start frames for the audio track (for packet lookup).
audio_starts: Vec<i64>,
/// Presentation offset of the audio track in its timescale (edit list, or Opus pre-skip).
audio_offset: i64,
/// Decoder pre-roll after a seek, in audio track timescale units (0: prime with one packet).
audio_preroll: i64,
}
pub fn sniff(b: &[u8]) -> bool {
b.len() >= 12 && matches!(&b[4..8], b"ftyp" | b"moov" | b"mdat" | b"wide" | b"free" | b"skip")
}
/// HDR static metadata from the sample entry's `mdcv` / `clli` boxes (0 = unknown).
fn hdr_metadata(md: Option<&filmcraft_isobmff::MasteringDisplay>, cll: Option<(u16, u16)>) -> Option<filmcraft_color::HdrMetadata> {
if md.is_none() && cll.is_none() {
return None;
}
let nz = |v: f32| (v > 0.0).then_some(v);
Some(filmcraft_color::HdrMetadata {
mastering_max_nits: md.and_then(|m| nz(m.max_nits() as f32)),
mastering_min_nits: md.map(|m| m.min_luminance as f32 / 10_000.0),
max_cll: cll.and_then(|c| nz(c.0 as f32)),
max_fall: cll.and_then(|c| nz(c.1 as f32)),
})
}
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 carries its colour description in vpcC (used when there is no colr box).
let vpc = match &entry.codec {
CodecConfig::Vp9(v) => Some(filmcraft_isobmff::ColorInfo::Nclx {
primaries: v.colour_primaries as u16,
transfer: v.transfer_characteristics as u16,
matrix: v.matrix_coefficients as u16,
full_range: v.full_range,
}),
_ => None,
};
if let Some(col) = entry.video.as_ref().and_then(|v| v.color.as_ref()).or(vpc.as_ref()) {
let (p, t, m, full) = match col {
filmcraft_isobmff::ColorInfo::Nclx { primaries, transfer, matrix, full_range } => (*primaries, *transfer, *matrix, *full_range),
filmcraft_isobmff::ColorInfo::Nclc { primaries, transfer, matrix } => (*primaries, *transfer, *matrix, false),
_ => return c,
};
if let Some(m) = Matrix::from_code(m as u8) {
c.matrix = m;
}
if let Some(t) = Transfer::from_code(t as u8) {
c.transfer = t;
}
c.primaries = match p {
9 => Primaries::Bt2020,
12 => Primaries::P3D65,
5 => Primaries::Bt601_625,
6 => Primaries::Bt601_525,
_ => Primaries::Bt709,
};
if full {
c.range = Range::Full;
}
}
c
}
impl Mp4Source {
pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result<Self> {
Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes)))
}
/// Open from a random-access reader: only the index is read now, samples on demand.
pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result<Self> {
let bytes = crate::Src(reader);
let file = filmcraft_isobmff::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?;
let vtrack = file.tracks.iter().position(|t| t.kind == TrackKind::Video && !t.samples.is_empty());
let atrack = file.tracks.iter().position(|t| t.kind == TrackKind::Audio && !t.samples.is_empty());
if vtrack.is_none() && atrack.is_none() {
return Err(CodecError::Unsupported("no playable tracks".into()));
}
let mut color = ColorInfo::REC709;
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);
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 });
// 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();
let d = durs.get(durs.len() / 2).copied().unwrap_or(1).max(1);
let rate = FrameRate::from_f64(t.timescale as f64 / d as f64);
color = color_from(entry, w, h);
if entry
.video
.as_ref()
.is_some_and(|v| matches!(v.color, Some(filmcraft_isobmff::ColorInfo::Nclx { .. } | filmcraft_isobmff::ColorInfo::Nclc { .. })))
{
explicit_color = Some(color);
}
let bitrate = (t.samples.iter().map(|s| s.size as u64).sum::<u64>() * 8 * t.timescale as u64).checked_div(t.duration);
let mut info = VideoStreamInfo {
width: w,
height: h,
frame_rate: rate,
par: vp.pixel_aspect.unwrap_or((1, 1)),
codec: codec_label(&entry.codec),
pixel_format: pixfmt_label(&entry.codec),
color,
has_alpha: matches!(&entry.codec, CodecConfig::ProRes { fourcc } if fourcc.0 == *b"ap4h" || fourcc.0 == *b"ap4x"),
bitrate,
hdr: entry.video.as_ref().and_then(|v| hdr_metadata(v.mastering_display.as_ref(), v.content_light)),
};
if rotation % 2 == 1 {
(info.width, info.height) = (info.height, info.width);
info.par = (info.par.1, info.par.0);
}
if matches!(entry.codec, CodecConfig::Dnx { .. }) {
// the sample entry doesn't say which VC-3 compression ID it is: read the first frame header
let s0 = &t.samples[0];
if let Some(h) = filmcraft_media::reader::read_range(&*bytes.0, s0.offset, s0.size as usize).ok().and_then(|d| filmcraft_dnx::probe(&d).ok()) {
info.codec = filmcraft_dnx::cid_name(h.cid);
let sub = match h.chroma {
filmcraft_dnx::ChromaFormat::Yuv420 => "YUV 4:2:0",
filmcraft_dnx::ChromaFormat::Yuv422 => "YUV 4:2:2",
filmcraft_dnx::ChromaFormat::Yuv444 => "4:4:4",
};
info.pixel_format = format!("{sub} {}-bit", h.bit_depth);
info.has_alpha = h.alpha;
}
}
info
});
let audio = atrack.map(|i| {
let t = &file.tracks[i];
let entry = &t.entries[0];
let ap = entry.audio.clone().unwrap_or_default();
let (rate, ch, bits) = match &entry.codec {
CodecConfig::Aac(a) => (
if a.sample_rate > 0 { a.sample_rate } else { ap.sample_rate as u32 },
if a.channel_config > 0 { a.channel_config as u32 } else { ap.channels },
None,
),
CodecConfig::Pcm(p) => (p.sample_rate as u32, p.channels, Some(p.bits as u32)),
CodecConfig::Opus(o) => (crate::audio::OPUS_RATE, (o.output_channels as u32).max(1), None),
_ => (if ap.sample_rate > 0.0 { ap.sample_rate as u32 } else { t.timescale }, ap.channels.max(1), None),
};
AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec: codec_label(&entry.codec), bits_per_sample: bits }
});
let duration = {
let v = vtrack.map(|i| {
let t = &file.tracks[i];
Tick::from_rational(t.duration as i64, 1, t.timescale as i64)
});
let a = atrack.map(|i| {
let t = &file.tracks[i];
Tick::from_rational(t.duration as i64, 1, t.timescale as i64)
});
v.or(a).unwrap_or_default()
};
let start_timecode = file.tracks.iter().find_map(|t| match t.codec() {
Some(CodecConfig::Timecode(tc)) => tc.start_frame.map(|f| f as i64),
_ => None,
});
let info = MediaInfo {
name: name.to_string(),
kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly },
duration,
video,
audio,
container: if file.is_quicktime { "QuickTime".into() } else { "MPEG-4".into() },
start_timecode,
file_size: Some(bytes.0.len()),
};
let audio_starts = atrack
.map(|i| {
let t = &file.tracks[i];
let mut acc = 0i64;
t.samples
.iter()
.map(|s| {
let st = acc;
acc += s.duration as i64;
st
})
.collect()
})
.unwrap_or_default();
let (audio_offset, audio_preroll) = atrack
.map(|i| {
let t = &file.tracks[i];
let ts = t.timescale.max(1) as i64;
match &t.entries[0].codec {
CodecConfig::Opus(o) => {
let off = if t.edit_offset != 0 { t.edit_offset } else { -(o.pre_skip as i64) * ts / 48_000 };
(off, (crate::audio::OPUS_PRE_ROLL as u64 * ts as u64).div_ceil(48_000) as i64)
}
_ => (t.edit_offset, 0),
}
})
.unwrap_or((0, 0));
Ok(Self {
info,
bytes,
file,
vtrack,
atrack,
video: GopCache::new(explicit_color).with_rotation(rotation),
audio: Mutex::new(AudioState { decoder: None, packets: HashMap::new(), order: Vec::new(), last_decoded: None }),
audio_starts,
audio_offset,
audio_preroll,
})
}
fn read(&self, track: usize, i: usize) -> crate::Result<Vec<u8>> {
self.file.read_sample(&self.bytes, track, i).map_err(|e| CodecError::Container(e.to_string()))
}
fn video_at(&self, t: Tick) -> crate::Result<Arc<VideoFrame>> {
let ti = self.vtrack.ok_or_else(|| CodecError::Unsupported("no video".into()))?;
let ts = self.file.tracks[ti].timescale as i64;
let late = filmcraft_media::cancel::catch_up().map(|m| (t - m).to_rational_floor(1, ts));
self.video.frame_late(&Mp4Video { src: self, track: ti }, t.to_rational_floor(1, ts), late)
}
fn audio_packet(&self, st: &mut AudioState, i: usize) -> crate::Result<Arc<Vec<Vec<f32>>>> {
if let Some(p) = st.packets.get(&i) {
return Ok(p.clone());
}
let ti = self.atrack.expect("audio");
let track = &self.file.tracks[ti];
let entry = &track.entries[0];
let data = self.read(ti, i)?;
let decoded = match &entry.codec {
CodecConfig::Pcm(p) => decode_pcm(&data, p),
c => {
if st.decoder.is_none() {
st.decoder = Some(PacketDecoder::for_isobmff(c, self.info.audio.as_ref().map_or(48_000, |a| a.sample_rate))?);
}
// Non-sequential access: reset and prime with the preceding packets (codec pre-roll:
// one packet, or `OPUS_PRE_ROLL` worth for Opus).
if st.last_decoded.is_none_or(|l| l + 1 != i) {
let d = st.decoder.as_mut().expect("decoder");
d.reset();
let from = if self.audio_preroll > 0 {
self.audio_starts.partition_point(|&x| x <= self.audio_starts[i] - self.audio_preroll).saturating_sub(1)
} else {
i.saturating_sub(1)
};
for j in from..i {
if let Ok(prev) = self.read(ti, j) {
let _ = d.decode(&prev, 0);
}
}
}
let r = st.decoder.as_mut().expect("decoder").decode(&data, self.audio_starts[i].max(0) as u64);
st.last_decoded = Some(i);
match r {
Ok(v) => v,
Err(_) => vec![vec![0.0; track.samples[i].duration as usize]; self.info.audio.as_ref().map_or(2, |a| a.channels as usize)],
}
}
};
let p = Arc::new(decoded);
st.packets.insert(i, p.clone());
st.order.push(i);
if st.order.len() > 4096 {
let old = st.order.remove(0);
st.packets.remove(&old);
}
Ok(p)
}
}
/// The MP4 video track as a [`VideoSamples`] table.
struct Mp4Video<'a> {
src: &'a Mp4Source,
track: usize,
}
impl VideoSamples for Mp4Video<'_> {
fn count(&self) -> usize {
self.src.file.tracks[self.track].samples.len()
}
fn pts(&self, i: usize) -> i64 {
self.src.file.tracks[self.track].samples[i].pts
}
fn sync_before(&self, i: usize) -> usize {
self.src.file.tracks[self.track].sync_sample_before(i)
}
fn sample_at(&self, t: i64) -> Option<usize> {
self.src.file.tracks[self.track].sample_at_presentation_time(t)
}
fn read(&self, i: usize) -> crate::Result<Vec<u8>> {
self.src.read(self.track, i)
}
fn make_decoder(&self) -> crate::Result<Box<dyn VideoDecoder>> {
make_video_decoder(&self.src.file.tracks[self.track].entries[0])
}
}
impl MediaSource for Mp4Source {
fn info(&self) -> &MediaInfo {
&self.info
}
fn video_frame(&self, req: FrameRequest) -> Result<Arc<VideoFrame>, MediaError> {
let f = self.video_at(req.time.max(Tick::ZERO))?;
Ok(f)
}
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer, MediaError> {
let ti = self.atrack.ok_or(MediaError::NoStream("audio"))?;
let track = &self.file.tracks[ti];
let ainfo = self.info.audio.as_ref().ok_or(MediaError::NoStream("audio"))?;
let src_rate = ainfo.sample_rate;
let ch = ainfo.channels.max(1) as usize;
// Map the requested window to source samples (edit list offset applied: presentation = pts + edit_offset).
let ratio = src_rate as f64 / sample_rate as f64;
let s0 = (start as f64 * ratio).floor() as i64 - self.audio_offset * src_rate as i64 / track.timescale.max(1) as i64;
let need = (frames as f64 * ratio).ceil() as i64 + 2;
// Samples-per-unit: the track timescale is usually the sample rate for audio.
let unit = src_rate as f64 / track.timescale.max(1) as f64;
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
let mut st = self.audio.lock().unwrap_or_else(|e| e.into_inner());
let first = self.audio_starts.partition_point(|&x| (x as f64 * unit) as i64 <= s0.max(0)).saturating_sub(1);
let mut i = first;
while i < track.samples.len() {
let pk_start = (self.audio_starts[i] as f64 * unit) as i64;
if pk_start >= s0 + need {
break;
}
let pk = self.audio_packet(&mut st, i)?;
for (c, dst) in src.iter_mut().enumerate() {
let Some(chan) = pk.get(c.min(pk.len().saturating_sub(1))) else { continue };
for (k, v) in chan.iter().enumerate() {
let pos = pk_start + k as i64 - s0;
if pos >= 0 && (pos as usize) < dst.len() {
dst[pos as usize] = *v;
}
}
}
i += 1;
}
drop(st);
if src_rate == sample_rate {
let mut out = AudioBuffer::silence(sample_rate, ch, frames);
for c in 0..ch {
out.channels[c]
.copy_from_slice(&src[c][..frames.min(src[c].len())].iter().copied().chain(std::iter::repeat(0.0)).take(frames).collect::<Vec<_>>());
}
return Ok(out);
}
// resample the local window
let frac = s0 as f64 - start as f64 * ratio;
let mut out = AudioBuffer::silence(sample_rate, ch, frames);
for k in 0..frames {
let pos = k as f64 * ratio - frac;
let i0 = pos.floor().max(0.0) as usize;
let f = (pos - i0 as f64) as f32;
for c in 0..ch {
let a = src[c].get(i0).copied().unwrap_or(0.0);
let b = src[c].get(i0 + 1).copied().unwrap_or(a);
out.channels[c][k] = a + (b - a) * f;
}
}
Ok(out)
}
}
fn codec_label(c: &CodecConfig) -> String {
match c {
CodecConfig::Avc(a) => format!(
"H.264 ({})",
match a.profile {
66 => "Baseline",
77 => "Main",
100 => "High",
110 => "High 10",
122 => "High 4:2:2",
244 => "High 4:4:4",
_ => "AVC",
}
),
CodecConfig::Hevc(_) => "HEVC".into(),
CodecConfig::Vp9(c) => format!("VP9 (Profile {})", c.profile),
CodecConfig::Av1(c) => format!("AV1 ({} Profile)", ["Main", "High", "Professional"].get(c.seq_profile as usize).unwrap_or(&"Main")),
CodecConfig::ProRes { fourcc } => match &fourcc.0 {
b"apco" => "Apple ProRes 422 Proxy".into(),
b"apcs" => "Apple ProRes 422 LT".into(),
b"apcn" => "Apple ProRes 422".into(),
b"apch" => "Apple ProRes 422 HQ".into(),
b"ap4h" => "Apple ProRes 4444".into(),
b"ap4x" => "Apple ProRes 4444 XQ".into(),
_ => "Apple ProRes".into(),
},
CodecConfig::Aac(a) => {
if a.object_type == 5 || a.object_type == 29 {
"HE-AAC".into()
} else {
"AAC".into()
}
}
CodecConfig::Pcm(p) => format!("PCM {}-bit{}", p.bits, if p.float { " float" } else { "" }),
CodecConfig::Opus(_) => "Opus".into(),
CodecConfig::Dnx { fourcc } if fourcc.0 == *b"AVdh" => "Avid DNxHR".into(),
CodecConfig::Dnx { .. } => "Avid DNxHD".into(),
other => other.name().to_string(),
}
}
fn pixfmt_label(c: &CodecConfig) -> String {
match c {
CodecConfig::Avc(a) => match a.profile {
110 => "YUV 4:2:0 10-bit".into(),
122 => "YUV 4:2:2".into(),
244 => "YUV 4:4:4".into(),
_ => "YUV 4:2:0 8-bit".into(),
},
CodecConfig::Vp9(c) => {
let sub = match c.chroma_subsampling {
2 => "4:2:2",
3 => "4:4:4",
_ => "4:2:0",
};
format!("YUV {sub} {}-bit", c.bit_depth.max(8))
}
CodecConfig::ProRes { fourcc } if fourcc.0[2] == b'4' => "YUVA 4:4:4 12-bit".into(),
CodecConfig::ProRes { .. } => "YUV 4:2:2 10-bit".into(),
CodecConfig::Jpeg { .. } => "YUV 4:2:x 8-bit".into(),
_ => String::new(),
}
}
pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option<Result<SharedSource, MediaError>> {
if !sniff(&bytes) {
return None;
}
Some(Mp4Source::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
}
/// [`filmcraft_media::ReaderOpener`] for MP4/MOV.
pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedReader) -> Option<Result<SharedSource, MediaError>> {
if !sniff(head) {
return None;
}
Some(Mp4Source::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into))
}
#[cfg(test)]
mod tests {
use super::*;
use filmcraft_isobmff::{Brand, FourCc, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
use filmcraft_media::FrameRequest;
/// A one-frame 64×32 ProRes MOV (bright left half, dark right half) whose `tkhd` matrix is
/// replaced by `matrix` — built here, no media files.
fn rotated_mov(matrix: [i32; 9]) -> 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 mux = Mp4Writer::new(std::io::Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).expect("writer");
let t = mux.add_track(TrackConfig::new(SampleEntry::prores(FourCc(*b"apch"), w as u16, h as u16), 25)).expect("track");
mux.write_sample(t, WriteSample { data: &data, duration: 1, composition_offset: 0, is_sync: true }).expect("sample");
let mut b = mux.finish().expect("finish").into_inner();
// tkhd (ISO/IEC 14496-12 §8.3.2): version/flags, then 20 (v0) or 32 (v1) bytes of
// times/id/duration, 8 reserved, layer, alternate_group, volume, reserved, matrix
let k = b.windows(4).position(|x| x == b"tkhd").expect("tkhd") + 4;
let m = k + 4 + if b[k] == 1 { 32 } else { 20 } + 16;
for (j, v) in matrix.iter().enumerate() {
b[m + j * 4..m + j * 4 + 4].copy_from_slice(&v.to_be_bytes());
}
b.into()
}
/// Mean luma of the top and bottom halves of the decoded frame, and its size.
fn halves(src: &Mp4Source) -> (u32, u32, u8, u8) {
let f = src.video_frame(FrameRequest::full(Tick::ZERO)).expect("frame");
let l = f.luma8();
let half = l.len() / 2;
let mean = |s: &[u8]| (s.iter().map(|&v| v as u32).sum::<u32>() / s.len() as u32) as u8;
(f.width, f.height, mean(&l[..half]), mean(&l[half..]))
}
#[test]
fn display_matrix_rotates_frames_and_reported_size() {
const ONE: i32 = 0x10000;
const W: i32 = 0x4000_0000;
// identity: landscape 64x32, top and bottom alike
let s = Mp4Source::open("id.mov", rotated_mov([ONE, 0, 0, 0, ONE, 0, 0, 0, W])).expect("open");
let v = s.info().video.as_ref().expect("video");
assert_eq!((v.width, v.height), (64, 32));
let (w, h, top, bottom) = halves(&s);
assert_eq!((w, h), (64, 32));
assert!(top.abs_diff(bottom) < 4);
// 90° clockwise (what an iPhone writes for portrait): the left half ends up on top
let s = Mp4Source::open("cw.mov", rotated_mov([0, ONE, 0, -ONE, 0, 0, 32 * ONE, 0, W])).expect("open");
let v = s.info().video.as_ref().expect("video");
assert_eq!((v.width, v.height), (32, 64));
let (w, h, top, bottom) = halves(&s);
assert_eq!((w, h), (32, 64));
assert!(top > bottom + 100, "top {top} bottom {bottom}");
// 270° clockwise: the left half ends up at the bottom
let s = Mp4Source::open("ccw.mov", rotated_mov([0, -ONE, 0, ONE, 0, 0, 0, 64 * ONE, W])).expect("open");
let (w, h, top, bottom) = halves(&s);
assert_eq!((w, h), (32, 64));
assert!(bottom > top + 100, "top {top} bottom {bottom}");
// 180°: still landscape
let s = Mp4Source::open("180.mov", rotated_mov([-ONE, 0, 0, 0, -ONE, 0, 64 * ONE, 32 * ONE, W])).expect("open");
assert_eq!(s.info().video.as_ref().map(|v| (v.width, v.height)), Some((64, 32)));
}
}