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:
storybored379 2026-10-03 12:47:36 -07:00 committed by Brandon Thomas
parent 905ab40480
commit 27a1f78df7
11 changed files with 290 additions and 14 deletions

View File

@ -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 {

View File

@ -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.

View File

@ -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,

View File

@ -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)));
}
}

View File

@ -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

View File

@ -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())
}

View File

@ -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;

View File

@ -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 {

View File

@ -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);
}
_ => {}
}
}

View File

@ -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)));

View File

@ -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);