Media: apply the container's display rotation (portrait phone video)
Phones record portrait video as landscape pictures plus a rotation in the container: the MP4/MOV tkhd matrix (ISO/IEC 14496-12 §8.3.2) or the Matroska Projection pose roll (RFC 9559 §5.1.4.1.28.46). The matrix was parsed but never used, so every portrait iPhone clip imported sideways at its landscape size. - isobmff: Track::display_rotation() reads 0/90/180/270° from the matrix signs (mirrored / sheared matrices are left alone). - matroska: parse Projection (type, yaw, pitch, roll); VideoInfo::display_rotation() for rectangular projections. - frame: VideoFrame::rotated() turns every plane at its own resolution (4:2:2 chroma is widened to 4:4:4 for quarter turns) and swaps the PAR. - codecs: Mp4Source / MkvSource report the displayed size and PAR, and GopCache rotates decoded frames before caching them. - web: the WebCodecs source configures the decoder with the coded size and rotates its output the same way. Tests: a one-frame ProRes MOV built in the test with 0/90/180/270° matrices (reported size + which half ends up on top), plane rotation for RGBA / 4:2:0 / 4:2:2, and a synthetic Matroska file with a -90° roll. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
905ab40480
commit
27a1f78df7
|
|
@ -200,7 +200,9 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<R
|
|||
return Some(Ok(inner));
|
||||
}
|
||||
let info = inner.info().clone();
|
||||
let (w, h) = info.video.as_ref().map(|v| (v.width, v.height)).unwrap_or((0, 0));
|
||||
// the decoder wants the coded size; `info` reports the display size after the track's rotation
|
||||
let rotation = file.tracks[track].display_rotation().unwrap_or(0);
|
||||
let (w, h) = info.video.as_ref().map(|v| if rotation % 2 == 1 { (v.height, v.width) } else { (v.width, v.height) }).unwrap_or((0, 0));
|
||||
let id = NEXT_ID.with(|n| {
|
||||
let id = n.get();
|
||||
n.set(id + 1);
|
||||
|
|
@ -215,7 +217,7 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option<R
|
|||
file,
|
||||
track,
|
||||
id,
|
||||
config: Config { codec, description, width: w, height: h },
|
||||
config: Config { codec, description, width: w, height: h, rotation },
|
||||
state: Mutex::new(Cache::default()),
|
||||
})))
|
||||
}
|
||||
|
|
@ -225,6 +227,8 @@ struct Config {
|
|||
description: Option<Vec<u8>>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Clockwise quarter turns from the track matrix, applied to decoded frames.
|
||||
rotation: u8,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
|
@ -455,6 +459,7 @@ impl WcSource {
|
|||
std::mem::take(&mut *s.out.borrow_mut())
|
||||
});
|
||||
for (pts, f) in out {
|
||||
let f = if self.config.rotation != 0 { f.rotated(self.config.rotation) } else { f };
|
||||
c.frames.insert(pts, Arc::new(f));
|
||||
}
|
||||
while c.frames.len() > CACHE_FRAMES {
|
||||
|
|
|
|||
|
|
@ -172,6 +172,8 @@ pub struct GopCache {
|
|||
state: Mutex<State>,
|
||||
/// Colour signalled by the container, which wins over the bitstream's.
|
||||
explicit_color: Option<ColorInfo>,
|
||||
/// Clockwise quarter turns from the container's display matrix, applied to every frame.
|
||||
rotation: u8,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
|
|
@ -200,21 +202,31 @@ impl GopCache {
|
|||
bytes: 0,
|
||||
}),
|
||||
explicit_color,
|
||||
rotation: 0,
|
||||
budget: 384 << 20,
|
||||
}
|
||||
}
|
||||
|
||||
/// Colour signalled by the container wins over the bitstream's (YUV frames only).
|
||||
fn apply_color(&self, f: &mut VideoFrame) {
|
||||
/// Turn every decoded frame clockwise by `quarter_turns` × 90° (the container's display
|
||||
/// rotation, e.g. portrait phone video stored landscape).
|
||||
pub fn with_rotation(mut self, quarter_turns: u8) -> Self {
|
||||
self.rotation = quarter_turns % 4;
|
||||
self
|
||||
}
|
||||
|
||||
/// Colour signalled by the container wins over the bitstream's (YUV frames only); the
|
||||
/// container's display rotation is 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 self.rotation != 0 { f.rotated(self.rotation) } else { f }
|
||||
}
|
||||
|
||||
fn store(&self, st: &mut State, pts: i64, mut f: VideoFrame, draft: bool) {
|
||||
self.apply_color(&mut f);
|
||||
fn store(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) {
|
||||
let f = self.finish(f);
|
||||
if draft {
|
||||
st.drafts.insert(pts);
|
||||
DRAFT.fetch_add(1, Ordering::Relaxed);
|
||||
|
|
@ -361,14 +373,13 @@ impl GopCache {
|
|||
let mut d = s.make_decoder()?;
|
||||
let mut next = usize::MAX;
|
||||
let out = Self::decode_to(s, d.as_mut(), &mut next, i, want_pts, n)?;
|
||||
let mut f = out
|
||||
let f = out
|
||||
.into_iter()
|
||||
.filter(|p| p.pts <= want_pts)
|
||||
.max_by_key(|p| p.pts)
|
||||
.map(|p| p.frame)
|
||||
.ok_or_else(|| CodecError::Decode("frame not produced".into()))?;
|
||||
self.apply_color(&mut f);
|
||||
Ok(Arc::new(f))
|
||||
Ok(Arc::new(self.finish(f)))
|
||||
}
|
||||
|
||||
/// Decode with `d` (positioned at `next`) until the picture with `want_pts` (sample `i`) comes out.
|
||||
|
|
|
|||
|
|
@ -234,6 +234,8 @@ impl MkvSource {
|
|||
}
|
||||
let mut explicit_color = None;
|
||||
let mut ventry = None;
|
||||
// display rotation from the track's Projection (portrait phone video is stored landscape)
|
||||
let rotation = vtrack.and_then(|i| file.tracks[i].video.as_ref()).and_then(|v| v.display_rotation()).unwrap_or(0);
|
||||
let video = vtrack.map(|i| {
|
||||
let t = &file.tracks[i];
|
||||
let v = t.video.clone().unwrap_or_default();
|
||||
|
|
@ -255,11 +257,12 @@ impl MkvSource {
|
|||
ventry = sample_entry(&t.codec, &t.codec_private, t.video.as_ref(), w as u16, h as u16);
|
||||
let secs = file.duration_ns().unwrap_or(0) as f64 / 1e9;
|
||||
let bitrate = (secs > 0.0).then(|| (t.samples.iter().map(|s| s.size as u64).sum::<u64>() as f64 * 8.0 / secs) as u64);
|
||||
let ((w, h), par) = if rotation % 2 == 1 { ((h, w), (v.pixel_aspect().1, v.pixel_aspect().0)) } else { ((w, h), v.pixel_aspect()) };
|
||||
VideoStreamInfo {
|
||||
width: w,
|
||||
height: h,
|
||||
frame_rate: rate,
|
||||
par: v.pixel_aspect(),
|
||||
par,
|
||||
codec: codec_label(&t.codec),
|
||||
pixel_format: String::new(),
|
||||
color,
|
||||
|
|
@ -332,7 +335,7 @@ impl MkvSource {
|
|||
vtrack,
|
||||
atrack,
|
||||
ventry,
|
||||
video: GopCache::new(explicit_color),
|
||||
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_preroll,
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@ impl Mp4Source {
|
|||
}
|
||||
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];
|
||||
|
|
@ -145,6 +147,10 @@ impl Mp4Source {
|
|||
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];
|
||||
|
|
@ -235,7 +241,7 @@ impl Mp4Source {
|
|||
file,
|
||||
vtrack,
|
||||
atrack,
|
||||
video: GopCache::new(explicit_color),
|
||||
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,
|
||||
|
|
@ -477,3 +483,73 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedRe
|
|||
}
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,6 +143,41 @@ impl VideoFrame {
|
|||
Some(VideoFrame { width: ow as u32, height: oh 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
|
||||
/// [`Chroma`] variant). The pixel aspect ratio follows the turn.
|
||||
pub fn rotated(&self, quarter_turns: u8) -> VideoFrame {
|
||||
let q = quarter_turns % 4;
|
||||
if q == 0 {
|
||||
return self.clone();
|
||||
}
|
||||
let (w, h) = (self.width as usize, self.height as usize);
|
||||
let data = match &self.data {
|
||||
PixelData::Rgba8(d) => PixelData::Rgba8(Arc::new(rotate_plane(d, w, h, 4, q))),
|
||||
PixelData::RgbaF32(d) => PixelData::RgbaF32(Arc::new(rotate_plane(d, w, h, 4, q))),
|
||||
PixelData::Yuv8 { planes, chroma, alpha } => {
|
||||
let (c, chroma) = rotate_chroma(planes, *chroma, w, h, q);
|
||||
PixelData::Yuv8 {
|
||||
planes: [Arc::new(rotate_plane(&planes[0], w, h, 1, q)), Arc::new(c[0].clone()), Arc::new(c[1].clone())],
|
||||
chroma,
|
||||
alpha: alpha.as_ref().map(|a| Arc::new(rotate_plane(a, w, h, 1, q))),
|
||||
}
|
||||
}
|
||||
PixelData::Yuv16 { planes, chroma, bits, alpha } => {
|
||||
let (c, chroma) = rotate_chroma(planes, *chroma, w, h, q);
|
||||
PixelData::Yuv16 {
|
||||
planes: [Arc::new(rotate_plane(&planes[0], w, h, 1, q)), Arc::new(c[0].clone()), Arc::new(c[1].clone())],
|
||||
chroma,
|
||||
bits: *bits,
|
||||
alpha: alpha.as_ref().map(|a| Arc::new(rotate_plane(a, w, h, 1, q))),
|
||||
}
|
||||
}
|
||||
};
|
||||
let (width, height, par) = if q % 2 == 1 { (self.height, self.width, (self.par.1, self.par.0)) } else { (self.width, self.height, self.par) };
|
||||
VideoFrame { width, height, data, color: self.color, par, pts: self.pts }
|
||||
}
|
||||
|
||||
/// Convert to premultiplied linear RGBA f32 (the compositor's working format).
|
||||
pub fn to_linear_f32(&self) -> Vec<f32> {
|
||||
self.to_linear_f32_decimated(1).2
|
||||
|
|
@ -445,9 +480,87 @@ fn box_plane<T: Copy + Into<u32> + TryFrom<u32> + Send + Sync + Default>(src: &[
|
|||
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];
|
||||
if w == 0 || h == 0 || src.len() < w * h * n {
|
||||
return out;
|
||||
}
|
||||
// output width: h for a quarter / three-quarter turn
|
||||
let ow = if q % 2 == 1 { h } else { w };
|
||||
out.par_chunks_mut(ow * n).enumerate().for_each(|(oy, row)| {
|
||||
for ox in 0..ow {
|
||||
// the source pixel shown at (ox, oy)
|
||||
let (x, y) = match q {
|
||||
1 => (oy, h - 1 - ox),
|
||||
2 => (w - 1 - ox, h - 1 - oy),
|
||||
_ => (w - 1 - oy, ox),
|
||||
};
|
||||
let s = (y * w + x) * n;
|
||||
row[ox * n..ox * n + n].copy_from_slice(&src[s..s + n]);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The two chroma planes of a `w`×`h` picture turned by `q` quarter turns, and their new format.
|
||||
fn rotate_chroma<T: Copy + Default + Send + Sync>(planes: &[Arc<Vec<T>>; 3], chroma: Chroma, w: usize, h: usize, q: u8) -> ([Vec<T>; 2], Chroma) {
|
||||
let (sx, sy) = chroma.shifts();
|
||||
let (cw, ch) = (w.div_ceil(1 << sx), h.div_ceil(1 << sy));
|
||||
if chroma == Chroma::C422 && q % 2 == 1 {
|
||||
// widen to 4:4:4 (each chroma sample covers two luma columns), then turn
|
||||
let widen = |p: &[T]| -> Vec<T> {
|
||||
let mut o = vec![T::default(); w * h];
|
||||
if p.len() >= cw * h {
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
o[y * w + x] = p[y * cw + x / 2];
|
||||
}
|
||||
}
|
||||
}
|
||||
o
|
||||
};
|
||||
return ([rotate_plane(&widen(&planes[1]), w, h, 1, q), rotate_plane(&widen(&planes[2]), w, h, 1, q)], Chroma::C444);
|
||||
}
|
||||
([rotate_plane(&planes[1], cw, ch, 1, q), rotate_plane(&planes[2], cw, ch, 1, q)], chroma)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn rotation_turns_every_plane_clockwise() {
|
||||
// 4x2 RGBA8 with distinct pixels 0..8
|
||||
let px: Vec<u8> = (0..8u8).flat_map(|i| [i, 0, 0, 255]).collect();
|
||||
let f = VideoFrame { par: (4, 3), ..VideoFrame::rgba8(4, 2, px) };
|
||||
let r = f.rotated(1);
|
||||
assert_eq!((r.width, r.height, r.par), (2, 4, (3, 4)));
|
||||
let red: Vec<u8> = r.to_rgba8().chunks(4).map(|p| p[0]).collect();
|
||||
// source rows [0 1 2 3] / [4 5 6 7] turned clockwise: the bottom row becomes the left column
|
||||
assert_eq!(red, [4, 0, 5, 1, 6, 2, 7, 3]);
|
||||
let red180: Vec<u8> = f.rotated(2).to_rgba8().chunks(4).map(|p| p[0]).collect();
|
||||
assert_eq!(red180, [7, 6, 5, 4, 3, 2, 1, 0]);
|
||||
let red270: Vec<u8> = f.rotated(3).to_rgba8().chunks(4).map(|p| p[0]).collect();
|
||||
assert_eq!(red270, [3, 7, 2, 6, 1, 5, 0, 4]);
|
||||
assert_eq!(f.rotated(4).width, 4);
|
||||
|
||||
// 4:2:0 4x2: chroma 2x1 -> 1x2; 4:2:2 4x2: chroma 2x2 -> widened 4:4:4 2x4
|
||||
let yuv = |chroma: Chroma, c: Vec<u8>| VideoFrame {
|
||||
width: 4,
|
||||
height: 2,
|
||||
data: PixelData::Yuv8 { planes: [Arc::new((0..8).collect()), Arc::new(c.clone()), Arc::new(c)], chroma, alpha: None },
|
||||
color: ColorInfo::REC709,
|
||||
par: (1, 1),
|
||||
pts: Tick::ZERO,
|
||||
};
|
||||
let r = yuv(Chroma::C420, vec![10, 20]).rotated(1);
|
||||
let PixelData::Yuv8 { planes, chroma, .. } = &r.data else { panic!() };
|
||||
assert_eq!((*chroma, &planes[0][..], &planes[1][..]), (Chroma::C420, &[4, 0, 5, 1, 6, 2, 7, 3][..], &[10, 20][..]));
|
||||
let r = yuv(Chroma::C422, vec![10, 20, 30, 40]).rotated(1);
|
||||
let PixelData::Yuv8 { planes, chroma, .. } = &r.data else { panic!() };
|
||||
assert_eq!((*chroma, &planes[1][..]), (Chroma::C444, &[30, 10, 30, 10, 40, 20, 40, 20][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_decimation_averages_blocks_per_plane() {
|
||||
// 6x4 4:2:0: luma ramp, chroma 3x2
|
||||
|
|
|
|||
|
|
@ -107,6 +107,24 @@ impl Track {
|
|||
pub fn video(&self) -> Option<&crate::codec::VideoParams> {
|
||||
self.entries.first().and_then(|e| e.video.as_ref())
|
||||
}
|
||||
/// Clockwise quarter turns (0–3) the `tkhd` matrix applies for display, or None when it is not
|
||||
/// a pure 0/90/180/270° rotation (mirrored, scaled or sheared — left to the caller).
|
||||
///
|
||||
/// ISO/IEC 14496-12 §8.3.2: a sample point (p, q) is displayed at p′ = a·p + c·q + x,
|
||||
/// q′ = b·p + d·q + y for the matrix {a, b, u, c, d, v, x, y, w}, with y pointing down. So
|
||||
/// {0, 1, −1, 0} sends the top row to the right-hand column (90° clockwise — iPhone portrait
|
||||
/// video), {−1, 0, 0, −1} is 180° and {0, −1, 1, 0} is 270°. Only the signs matter: a·d − b·c
|
||||
/// carries the scale, which the presentation size already reflects.
|
||||
pub fn display_rotation(&self) -> Option<u8> {
|
||||
let [a, b, _, c, d, _, _, _, _] = self.matrix;
|
||||
match (a.signum(), b.signum(), c.signum(), d.signum()) {
|
||||
(1, 0, 0, 1) => Some(0),
|
||||
(0, 1, -1, 0) => Some(1),
|
||||
(-1, 0, 0, -1) => Some(2),
|
||||
(0, -1, 1, 0) => Some(3),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn audio(&self) -> Option<&crate::codec::AudioParams> {
|
||||
self.entries.first().and_then(|e| e.audio.as_ref())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,6 +106,11 @@ pub const DISPLAY_HEIGHT: u32 = 0x54BA;
|
|||
pub const DISPLAY_UNIT: u32 = 0x54B2;
|
||||
pub const COLOUR_SPACE: u32 = 0x2E_B524;
|
||||
pub const COLOUR: u32 = 0x55B0;
|
||||
pub const PROJECTION: u32 = 0x7670;
|
||||
pub const PROJECTION_TYPE: u32 = 0x7671;
|
||||
pub const PROJECTION_POSE_YAW: u32 = 0x7673;
|
||||
pub const PROJECTION_POSE_PITCH: u32 = 0x7674;
|
||||
pub const PROJECTION_POSE_ROLL: u32 = 0x7675;
|
||||
|
||||
// Colour
|
||||
pub const MATRIX_COEFFICIENTS: u32 = 0x55B1;
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ pub use error::{Error, Result};
|
|||
pub use meta::{Attachment, Chapter, ChapterDisplay, ClusterInfo, CuePoint, CuePosition, Edition, SeekEntry, SegmentInfo, SimpleTag, Tag, TagTargets};
|
||||
pub use mux::{LacingMode, MkvWriter, MuxOptions, TrackSpec};
|
||||
pub use source::{ByteSource, ReadSeekSource};
|
||||
pub use track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Sample, Track, TrackKind, VideoInfo};
|
||||
pub use track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Projection, Sample, Track, TrackKind, VideoInfo};
|
||||
|
||||
/// Element IDs used by the demuxer (marker bits included), for callers inspecting raw elements.
|
||||
pub mod element_ids {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
use crate::codec::map_codec;
|
||||
use crate::ebml::{Children, float, int, string, uint};
|
||||
use crate::ids::*;
|
||||
use crate::track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Track, TrackKind, VideoInfo, gcd};
|
||||
use crate::track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Projection, Track, TrackKind, VideoInfo, gcd};
|
||||
|
||||
/// `Info` element.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
|
@ -390,6 +390,19 @@ fn parse_video(d: &[u8]) -> VideoInfo {
|
|||
ALPHA_MODE => v.alpha_mode = u,
|
||||
COLOUR_SPACE => v.colour_space = e.data.try_into().ok(),
|
||||
COLOUR => v.colour = Some(parse_colour(e.data)),
|
||||
PROJECTION => {
|
||||
let mut p = Projection::default();
|
||||
for x in Children::new(e.data) {
|
||||
match x.id {
|
||||
PROJECTION_TYPE => p.projection_type = uint(x.data),
|
||||
PROJECTION_POSE_YAW => p.yaw = float(x.data),
|
||||
PROJECTION_POSE_PITCH => p.pitch = float(x.data),
|
||||
PROJECTION_POSE_ROLL => p.roll = float(x.data),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
v.projection = Some(p);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,11 @@ fn synthetic() -> Vec<u8> {
|
|||
el_uint(&mut vid, DISPLAY_WIDTH, 1920);
|
||||
el_uint(&mut vid, DISPLAY_HEIGHT, 1080);
|
||||
el(&mut vid, COLOUR, &col);
|
||||
// rectangular, a quarter turn clockwise (portrait phone video)
|
||||
let mut proj = Vec::new();
|
||||
el_uint(&mut proj, PROJECTION_TYPE, 0);
|
||||
el_float(&mut proj, PROJECTION_POSE_ROLL, -90.0);
|
||||
el(&mut vid, PROJECTION, &proj);
|
||||
el(&mut v, VIDEO, &vid);
|
||||
// header stripping: every frame starts with 00 00 00 01
|
||||
let mut comp = Vec::new();
|
||||
|
|
@ -152,6 +157,8 @@ fn synthetic_file() {
|
|||
assert!(matches!(&v.codec, Codec::Avc { avcc } if avcc[1] == 0x64));
|
||||
let vi = v.video.as_ref().unwrap();
|
||||
assert_eq!(vi.pixel_aspect(), (4, 3));
|
||||
assert_eq!(vi.projection.as_ref().map(|p| p.roll), Some(-90.0));
|
||||
assert_eq!(vi.display_rotation(), Some(1));
|
||||
let c = vi.colour.as_ref().unwrap();
|
||||
assert!(c.full_range());
|
||||
assert_eq!((c.matrix_coefficients, c.transfer_characteristics, c.primaries, c.max_cll, c.max_fall), (Some(9), Some(16), Some(9), Some(1000), Some(400)));
|
||||
|
|
|
|||
|
|
@ -96,9 +96,34 @@ pub struct VideoInfo {
|
|||
/// `ColourSpace` FourCC (uncompressed video).
|
||||
pub colour_space: Option<[u8; 4]>,
|
||||
pub colour: Option<Colour>,
|
||||
pub projection: Option<Projection>,
|
||||
}
|
||||
|
||||
/// `Projection` element (RFC 9559 §5.1.4.1.28.41): pose angles in degrees.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Projection {
|
||||
/// 0 rectangular, 1 equirectangular, 2 cubemap, 3 mesh.
|
||||
pub projection_type: u64,
|
||||
/// Clockwise around the up vector.
|
||||
pub yaw: f64,
|
||||
/// Counter-clockwise around the right vector.
|
||||
pub pitch: f64,
|
||||
/// Counter-clockwise around the forward vector.
|
||||
pub roll: f64,
|
||||
}
|
||||
|
||||
impl VideoInfo {
|
||||
/// Clockwise quarter turns (0–3) to display a rectangular video, from `ProjectionPoseRoll`
|
||||
/// (a counter-clockwise angle, so −90 is a quarter turn clockwise). None for other
|
||||
/// projections, flips (non-zero yaw / pitch) or angles that are not a multiple of 90°.
|
||||
pub fn display_rotation(&self) -> Option<u8> {
|
||||
let Some(p) = &self.projection else { return Some(0) };
|
||||
if p.projection_type != 0 || p.yaw != 0.0 || p.pitch != 0.0 || p.roll % 90.0 != 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some((-(p.roll / 90.0) as i64).rem_euclid(4) as u8)
|
||||
}
|
||||
|
||||
/// Pixel aspect ratio derived from the display size (pixels or aspect-ratio units), reduced.
|
||||
pub fn pixel_aspect(&self) -> (u32, u32) {
|
||||
let (pw, ph) = (self.pixel_width.saturating_sub(self.crop.2 + self.crop.3) as u64, self.pixel_height.saturating_sub(self.crop.0 + self.crop.1) as u64);
|
||||
|
|
|
|||
Loading…
Reference in New Issue