diff --git a/apps/filmcraft-web/src/webcodecs.rs b/apps/filmcraft-web/src/webcodecs.rs index 3f5bee76..fae3e2f2 100644 --- a/apps/filmcraft-web/src/webcodecs.rs +++ b/apps/filmcraft-web/src/webcodecs.rs @@ -200,7 +200,9 @@ pub fn reader_opener(name: &str, head: &[u8], reader: &SharedReader) -> Option Option>, 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 { diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index 52c29ecb..e154476c 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -172,6 +172,8 @@ pub struct GopCache { state: Mutex, /// Colour signalled by the container, which wins over the bitstream's. explicit_color: Option, + /// 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. diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index a1bcddbb..ec337c06 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -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::() 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, diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index f206ded6..8b8a249f 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -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::() / 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))); + } +} diff --git a/crates/frame/src/lib.rs b/crates/frame/src/lib.rs index 6a667048..51c3dcbb 100644 --- a/crates/frame/src/lib.rs +++ b/crates/frame/src/lib.rs @@ -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 { self.to_linear_f32_decimated(1).2 @@ -445,9 +480,87 @@ fn box_plane + TryFrom + Send + Sync + Default>(src: &[ 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]; + 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(planes: &[Arc>; 3], chroma: Chroma, w: usize, h: usize, q: u8) -> ([Vec; 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 { + 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 = (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 = 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 = 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 = 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| 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 diff --git a/crates/isobmff/src/demux.rs b/crates/isobmff/src/demux.rs index c5f77863..be70d2e0 100644 --- a/crates/isobmff/src/demux.rs +++ b/crates/isobmff/src/demux.rs @@ -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 { + 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()) } diff --git a/crates/matroska/src/ids.rs b/crates/matroska/src/ids.rs index be1ef47e..6334f451 100644 --- a/crates/matroska/src/ids.rs +++ b/crates/matroska/src/ids.rs @@ -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; diff --git a/crates/matroska/src/lib.rs b/crates/matroska/src/lib.rs index 2e280200..6b0bf531 100644 --- a/crates/matroska/src/lib.rs +++ b/crates/matroska/src/lib.rs @@ -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 { diff --git a/crates/matroska/src/meta.rs b/crates/matroska/src/meta.rs index adac699f..09952fe1 100644 --- a/crates/matroska/src/meta.rs +++ b/crates/matroska/src/meta.rs @@ -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); + } _ => {} } } diff --git a/crates/matroska/src/synthetic_tests.rs b/crates/matroska/src/synthetic_tests.rs index e1c604c0..f10df3ba 100644 --- a/crates/matroska/src/synthetic_tests.rs +++ b/crates/matroska/src/synthetic_tests.rs @@ -63,6 +63,11 @@ fn synthetic() -> Vec { 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))); diff --git a/crates/matroska/src/track.rs b/crates/matroska/src/track.rs index 79fcc3c2..2ad1d52b 100644 --- a/crates/matroska/src/track.rs +++ b/crates/matroska/src/track.rs @@ -96,9 +96,34 @@ pub struct VideoInfo { /// `ColourSpace` FourCC (uncompressed video). pub colour_space: Option<[u8; 4]>, pub colour: Option, + pub projection: Option, +} + +/// `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 { + 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);