Skip to main content

long_screen/decode/
mod.rs

1//! Native video decoding for the command line, the Python module and desktop builds.
2//!
3//! A [`Decoder`] yields every frame of a file's first video track once, in presentation order, with the timestamp
4//! the container gives it. Screen recordings are variable frame rate, so nothing here resamples to a constant rate:
5//! a frame is never duplicated or dropped. Frames keep the decoder's own 4:2:0 planes ([`PixelFormat`]) and coded
6//! orientation; [`VideoInfo::rotation`] says how to turn them for display, and the colour tags say how to read the
7//! planes, so an HDR (PQ or HLG) recording reaches the kernels' converter with its signal untouched.
8//!
9//! Backends: the system decoder on macOS (AVFoundation) and Windows (Media Foundation), and a user-installed
10//! `ffmpeg`/`ffprobe` run as a child process on every system. Linux has no single system decoder API, so it uses
11//! ffmpeg. ffmpeg is never linked or bundled; [`Error::ToolMissing`] says how to install it.
12//!
13//! The driver reads videos through this module (`VideoSource`); use it directly when you only need the frames. It
14//! is an implementation layer like [`kernels`](crate::kernels) and [`engine`](crate::engine): its API may change in
15//! minor versions. [`Options`] and [`Backend`] are re-exported at the crate root as `DecodeOptions` and
16//! `DecodeBackend`.
17//!
18//! ```no_run
19//! use long_screen::decode::{open, Frame, Options};
20//! use std::path::Path;
21//!
22//! let mut decoder = open(Path::new("recording.mov"), &Options::default())?;
23//! println!("{:?}", decoder.info());
24//! let mut frame = Frame::default();
25//! while decoder.read_frame(&mut frame)? {
26//!     println!("frame {} at {:.3} s", frame.index, frame.timestamp);
27//! }
28//! # Ok::<(), long_screen::decode::Error>(())
29//! ```
30
31use std::fmt;
32use std::path::{Path, PathBuf};
33
34#[cfg(target_os = "macos")]
35mod avf;
36mod ffmpeg;
37#[cfg(windows)]
38mod mf;
39
40/// Which decoder reads the file.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum Backend {
43    /// The system decoder where there is one (AVFoundation on macOS, Media Foundation on Windows), else ffmpeg.
44    /// A file the system decoder cannot open falls back to ffmpeg when ffmpeg is installed.
45    Auto,
46    Ffmpeg,
47    AvFoundation,
48    MediaFoundation,
49}
50
51impl Backend {
52    pub fn name(self) -> &'static str {
53        match self {
54            Backend::Auto => "auto",
55            Backend::Ffmpeg => "ffmpeg",
56            Backend::AvFoundation => "avfoundation",
57            Backend::MediaFoundation => "mediafoundation",
58        }
59    }
60
61    pub fn parse(name: &str) -> Option<Backend> {
62        [
63            Backend::Auto,
64            Backend::Ffmpeg,
65            Backend::AvFoundation,
66            Backend::MediaFoundation,
67        ]
68        .into_iter()
69        .find(|b| b.name() == name)
70    }
71
72    /// Whether this build has the backend at all (the system ones are per operating system).
73    pub fn available(self) -> bool {
74        match self {
75            Backend::Auto | Backend::Ffmpeg => true,
76            Backend::AvFoundation => cfg!(target_os = "macos"),
77            Backend::MediaFoundation => cfg!(windows),
78        }
79    }
80}
81
82/// How a frame's bytes are laid out. Every format is 4:2:0 with chroma planes of `ceil(w/2) × ceil(h/2)` samples.
83/// 10-bit formats store each sample in a little-endian `u16`.
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum PixelFormat {
86    /// 8-bit Y, U and V planes.
87    I420,
88    /// 8-bit Y plane and one interleaved UV plane.
89    Nv12,
90    /// 10-bit Y, U and V planes, value in the low ten bits (ffmpeg's `yuv420p10le`).
91    I420P10,
92    /// 10-bit Y plane and one interleaved UV plane, value in the high ten bits (`P010`, `x420`).
93    P010,
94}
95
96impl PixelFormat {
97    pub fn bit_depth(self) -> u8 {
98        match self {
99            PixelFormat::I420 | PixelFormat::Nv12 => 8,
100            PixelFormat::I420P10 | PixelFormat::P010 => 10,
101        }
102    }
103
104    pub fn semi_planar(self) -> bool {
105        matches!(self, PixelFormat::Nv12 | PixelFormat::P010)
106    }
107
108    /// Bytes per sample.
109    pub fn sample_bytes(self) -> usize {
110        if self.bit_depth() > 8 {
111            2
112        } else {
113            1
114        }
115    }
116
117    /// Per plane, tightly packed: (bytes per row, rows).
118    pub fn planes(self, width: usize, height: usize) -> Vec<(usize, usize)> {
119        let (cw, ch, b) = (width.div_ceil(2), height.div_ceil(2), self.sample_bytes());
120        if self.semi_planar() {
121            vec![(width * b, height), (cw * 2 * b, ch)]
122        } else {
123            vec![(width * b, height), (cw * b, ch), (cw * b, ch)]
124        }
125    }
126
127    /// Bytes of a tightly packed frame.
128    pub fn frame_bytes(self, width: usize, height: usize) -> usize {
129        self.planes(width, height).iter().map(|(r, n)| r * n).sum()
130    }
131}
132
133/// One plane of a frame: byte offset of its first row in [`Frame::data`] and bytes between rows.
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub struct Plane {
136    pub offset: usize,
137    pub stride: usize,
138}
139
140/// The transfer function the samples code.
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub enum Transfer {
143    /// Any SDR transfer (BT.709, sRGB, BT.601 …); untagged streams are SDR.
144    Sdr,
145    /// SMPTE ST 2084.
146    Pq,
147    /// ARIB STD-B67.
148    Hlg,
149}
150
151/// The YUV matrix of the planes.
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub enum Matrix {
154    Bt709,
155    Bt601,
156    Bt2020,
157}
158
159/// The colour primaries.
160#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum Primaries {
162    Bt709,
163    Bt2020,
164    DisplayP3,
165    Bt601,
166}
167
168/// The stream's colour tags. `None` means untagged.
169#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170pub struct Colour {
171    pub transfer: Transfer,
172    pub matrix: Option<Matrix>,
173    pub primaries: Option<Primaries>,
174    /// Full-range (0–255) samples rather than limited (16–235).
175    pub full_range: bool,
176}
177
178impl Colour {
179    /// The matrix to convert with: the tag, else BT.709 from 720 rows up and BT.601 below (as browsers assume).
180    pub fn matrix_or_default(&self, height: usize) -> Matrix {
181        self.matrix.unwrap_or(if height >= 720 {
182            Matrix::Bt709
183        } else {
184            Matrix::Bt601
185        })
186    }
187
188    /// The colour code of the kernels' `yuv::Signal::from_code` (transfer × 8 + matrix × 2 + full range).
189    pub fn core_code(&self, height: usize) -> u32 {
190        let transfer = match self.transfer {
191            Transfer::Sdr => 0,
192            Transfer::Pq => 1,
193            Transfer::Hlg => 2,
194        };
195        let matrix = match self.matrix_or_default(height) {
196            Matrix::Bt709 => 0,
197            Matrix::Bt601 => 1,
198            Matrix::Bt2020 => 2,
199        };
200        transfer * 8 + matrix * 2 + self.full_range as u32
201    }
202}
203
204/// What the file holds, as the chosen backend reads it.
205#[derive(Clone, Debug, PartialEq)]
206pub struct VideoInfo {
207    /// Size of the decoded frames, before [`VideoInfo::rotation`].
208    pub width: usize,
209    pub height: usize,
210    /// Degrees (0, 90, 180 or 270) to turn the frames clockwise for display.
211    pub rotation: u16,
212    /// Seconds, when the container says.
213    pub duration: Option<f64>,
214    /// Frames this backend will present (decode and output), counted before decoding: the track's samples inside its
215    /// edit list, as the backend applies it. This can be fewer than the samples the container lists: iPhone and macOS
216    /// recordings commonly list one sample starting where the edit ends, which the backend does not present. A run
217    /// that decodes fewer frames than this has lost some. `None` when the backend cannot tell in advance (Media Foundation; an
218    /// AVFoundation track with several edits; ffprobe unable to list the packets).
219    pub frame_count: Option<u64>,
220    /// The codec's short name: `h264`, `hevc`, `vp9`, `av1`, `prores` …
221    pub codec: String,
222    /// Bits per sample of the coded stream (the frames' format may hold them in wider samples).
223    pub bit_depth: u8,
224    pub colour: Colour,
225    /// The format every frame comes in.
226    pub format: PixelFormat,
227    /// The backend that read the file (never `Auto`).
228    pub backend: Backend,
229}
230
231impl VideoInfo {
232    /// Size after rotation, as the recording is shown.
233    pub fn display_size(&self) -> (usize, usize) {
234        if self.rotation % 180 == 90 {
235            (self.height, self.width)
236        } else {
237            (self.width, self.height)
238        }
239    }
240}
241
242/// One decoded frame.
243#[derive(Clone, Debug, Default)]
244pub struct Frame {
245    /// Position in presentation order, from 0.
246    pub index: u64,
247    /// Presentation time in seconds, from the container's timestamps (after its edit list).
248    pub timestamp: f64,
249    pub width: usize,
250    pub height: usize,
251    /// `None` only on a `Frame::default()` not yet filled by [`Decoder::read_frame`].
252    pub format: Option<PixelFormat>,
253    pub data: Vec<u8>,
254    /// One entry per plane of `format` (Y, U, V or Y, UV).
255    pub planes: Vec<Plane>,
256}
257
258impl Frame {
259    /// Row `y` of plane `p`, `len` bytes long.
260    pub fn row(&self, p: usize, y: usize, len: usize) -> &[u8] {
261        let plane = self.planes[p];
262        &self.data[plane.offset + y * plane.stride..][..len]
263    }
264
265    /// An 8-bit copy of a 10-bit frame (`I420P10` → `I420`, `P010` → `Nv12`), each sample rounded to the nearest 8-bit
266    /// code (`(v + 2) >> 2`, clamped); an 8-bit frame is returned as a packed copy. The core converts 8-bit planes,
267    /// and both 10-bit layouts round to the same bytes.
268    pub fn to_8bit(&self) -> Frame {
269        let format = self.format.expect("a decoded frame");
270        let out_format = match format {
271            PixelFormat::I420 | PixelFormat::I420P10 => PixelFormat::I420,
272            PixelFormat::Nv12 | PixelFormat::P010 => PixelFormat::Nv12,
273        };
274        let dims = out_format.planes(self.width, self.height);
275        let mut data = Vec::with_capacity(out_format.frame_bytes(self.width, self.height));
276        let mut planes = Vec::with_capacity(dims.len());
277        for (p, &(row_bytes, rows)) in dims.iter().enumerate() {
278            planes.push(Plane {
279                offset: data.len(),
280                stride: row_bytes,
281            });
282            for y in 0..rows {
283                match format {
284                    PixelFormat::I420 | PixelFormat::Nv12 => {
285                        data.extend_from_slice(self.row(p, y, row_bytes))
286                    }
287                    PixelFormat::I420P10 => data.extend(
288                        self.row(p, y, row_bytes * 2)
289                            .chunks_exact(2)
290                            .map(|s| round10(u16::from_le_bytes([s[0], s[1]]))),
291                    ),
292                    PixelFormat::P010 => data.extend(
293                        self.row(p, y, row_bytes * 2)
294                            .chunks_exact(2)
295                            .map(|s| round10(u16::from_le_bytes([s[0], s[1]]) >> 6)),
296                    ),
297                }
298            }
299        }
300        Frame {
301            index: self.index,
302            timestamp: self.timestamp,
303            width: self.width,
304            height: self.height,
305            format: Some(out_format),
306            data,
307            planes,
308        }
309    }
310}
311
312#[inline]
313fn round10(v: u16) -> u8 {
314    ((v.min(1023) + 2) >> 2).min(255) as u8
315}
316
317/// Lays out `frame` as a tightly packed `format` frame of `width × height`, reusing its allocation.
318pub(crate) fn pack_layout(frame: &mut Frame, format: PixelFormat, width: usize, height: usize) {
319    frame.width = width;
320    frame.height = height;
321    frame.format = Some(format);
322    frame.planes.clear();
323    let mut offset = 0;
324    for (row_bytes, rows) in format.planes(width, height) {
325        frame.planes.push(Plane {
326            offset,
327            stride: row_bytes,
328        });
329        offset += row_bytes * rows;
330    }
331    frame.data.resize(offset, 0);
332}
333
334/// Reads a file's frames in presentation order.
335pub trait Decoder: Send {
336    fn info(&self) -> &VideoInfo;
337
338    /// Decodes the next frame into `frame`, reusing its buffer. `Ok(false)` at the end of the stream.
339    fn read_frame(&mut self, frame: &mut Frame) -> Result<bool, Error>;
340
341    /// The next frame in a fresh buffer, `Ok(None)` at the end.
342    fn next_frame(&mut self) -> Result<Option<Frame>, Error> {
343        let mut frame = Frame::default();
344        Ok(self.read_frame(&mut frame)?.then_some(frame))
345    }
346}
347
348/// How to open a file.
349#[derive(Clone, Debug)]
350pub struct Options {
351    pub backend: Backend,
352    /// The `ffmpeg` executable; `None` reads `LONGSCREEN_FFMPEG`, else `ffmpeg` on `PATH`.
353    pub ffmpeg: Option<PathBuf>,
354    /// The `ffprobe` executable; `None` reads `LONGSCREEN_FFPROBE`, else `ffprobe` on `PATH`.
355    pub ffprobe: Option<PathBuf>,
356}
357
358impl Default for Options {
359    fn default() -> Self {
360        Options {
361            backend: Backend::Auto,
362            ffmpeg: None,
363            ffprobe: None,
364        }
365    }
366}
367
368impl Options {
369    pub fn with_backend(backend: Backend) -> Options {
370        Options {
371            backend,
372            ..Options::default()
373        }
374    }
375}
376
377/// Opens `path` for decoding with `options.backend`.
378pub fn open(path: &Path, options: &Options) -> Result<Box<dyn Decoder>, Error> {
379    if !path.is_file() {
380        return Err(Error::Io(format!("{}: no such file", path.display())));
381    }
382    match options.backend {
383        Backend::Ffmpeg => Ok(Box::new(ffmpeg::FfmpegDecoder::open(path, options)?)),
384        Backend::AvFoundation => open_avf(path),
385        Backend::MediaFoundation => open_mf(path),
386        Backend::Auto => {
387            let system = if cfg!(target_os = "macos") {
388                Some(open_avf(path))
389            } else if cfg!(windows) {
390                Some(open_mf(path))
391            } else {
392                None
393            };
394            match system {
395                Some(Ok(decoder)) => Ok(decoder),
396                Some(Err(system_error)) => match ffmpeg::FfmpegDecoder::open(path, options) {
397                    Ok(decoder) => Ok(Box::new(decoder)),
398                    // Neither works: the system decoder's reason is the more useful one unless ffmpeg is simply absent.
399                    Err(Error::ToolMissing { .. }) => Err(system_error),
400                    Err(error) => Err(error),
401                },
402                None => Ok(Box::new(ffmpeg::FfmpegDecoder::open(path, options)?)),
403            }
404        }
405    }
406}
407
408/// The file's [`VideoInfo`] as `options.backend` would decode it, without decoding.
409pub fn probe(path: &Path, options: &Options) -> Result<VideoInfo, Error> {
410    open(path, options).map(|decoder| decoder.info().clone())
411}
412
413#[cfg(target_os = "macos")]
414fn open_avf(path: &Path) -> Result<Box<dyn Decoder>, Error> {
415    Ok(Box::new(avf::AvfDecoder::open(path)?))
416}
417
418#[cfg(not(target_os = "macos"))]
419fn open_avf(_: &Path) -> Result<Box<dyn Decoder>, Error> {
420    Err(Error::BackendUnavailable(Backend::AvFoundation))
421}
422
423#[cfg(windows)]
424fn open_mf(path: &Path) -> Result<Box<dyn Decoder>, Error> {
425    Ok(Box::new(mf::MfDecoder::open(path)?))
426}
427
428#[cfg(not(windows))]
429fn open_mf(_: &Path) -> Result<Box<dyn Decoder>, Error> {
430    Err(Error::BackendUnavailable(Backend::MediaFoundation))
431}
432
433/// Normalises a rotation in degrees (any sign, any multiple of 90) to 0, 90, 180 or 270.
434pub(crate) fn normalise_rotation(degrees: f64) -> Result<u16, Error> {
435    let quarter = (degrees / 90.0).round();
436    if (degrees - quarter * 90.0).abs() > 0.5 {
437        return Err(Error::Unsupported(format!(
438            "a rotation of {degrees}° (only multiples of 90° are supported)"
439        )));
440    }
441    Ok(((quarter as i64).rem_euclid(4) * 90) as u16)
442}
443
444/// Why a file could not be read.
445#[derive(Debug, Clone, PartialEq)]
446pub enum Error {
447    /// `ffmpeg` or `ffprobe` could not be started.
448    ToolMissing {
449        tool: String,
450        hint: String,
451    },
452    /// The backend does not exist on this operating system.
453    BackendUnavailable(Backend),
454    /// The file or its stream is something this crate does not read (no video track, an unsupported layout …).
455    Unsupported(String),
456    /// The decoder failed partway, or a backend call failed.
457    Decode(String),
458    Io(String),
459}
460
461impl fmt::Display for Error {
462    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
463        match self {
464            Error::ToolMissing { tool, hint } => {
465                write!(f, "`{tool}` could not be started. {hint}")
466            }
467            Error::BackendUnavailable(b) => {
468                write!(
469                    f,
470                    "the {} decoder is not available on this system",
471                    b.name()
472                )
473            }
474            Error::Unsupported(what) => write!(f, "unsupported video: {what}"),
475            Error::Decode(what) => write!(f, "decoding failed: {what}"),
476            Error::Io(what) => write!(f, "{what}"),
477        }
478    }
479}
480
481impl std::error::Error for Error {}
482
483/// How to install ffmpeg on this system, for [`Error::ToolMissing`].
484pub(crate) fn install_hint() -> &'static str {
485    if cfg!(target_os = "macos") {
486        "Install ffmpeg (it provides ffmpeg and ffprobe), e.g. `brew install ffmpeg`, or set LONGSCREEN_FFMPEG and LONGSCREEN_FFPROBE to their paths."
487    } else if cfg!(windows) {
488        "Install ffmpeg (it provides ffmpeg and ffprobe), e.g. `winget install Gyan.FFmpeg`, and make sure it is on PATH, or set LONGSCREEN_FFMPEG and LONGSCREEN_FFPROBE to their paths."
489    } else {
490        "Install ffmpeg (it provides ffmpeg and ffprobe) with your package manager, e.g. `sudo apt install ffmpeg` or `sudo dnf install ffmpeg`, or set LONGSCREEN_FFMPEG and LONGSCREEN_FFPROBE to their paths."
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    fn frame(
499        format: PixelFormat,
500        width: usize,
501        height: usize,
502        fill: impl Fn(usize) -> u16,
503    ) -> Frame {
504        let mut f = Frame::default();
505        pack_layout(&mut f, format, width, height);
506        let samples = f.data.len() / format.sample_bytes();
507        if format.sample_bytes() == 2 {
508            f.data = (0..samples).flat_map(|i| fill(i).to_le_bytes()).collect();
509        } else {
510            f.data = (0..samples).map(|i| fill(i) as u8).collect();
511        }
512        f
513    }
514
515    #[test]
516    fn plane_sizes_round_chroma_up_for_odd_dimensions() {
517        assert_eq!(PixelFormat::I420.planes(5, 3), vec![(5, 3), (3, 2), (3, 2)]);
518        assert_eq!(PixelFormat::P010.planes(5, 3), vec![(10, 3), (12, 2)]);
519        assert_eq!(PixelFormat::I420P10.frame_bytes(4, 4), (16 + 4 + 4) * 2);
520    }
521
522    #[test]
523    fn ten_bit_rounds_to_nearest_and_clamps_at_white() {
524        for (v10, v8) in [
525            (0, 0),
526            (1, 0),
527            (2, 1),
528            (5, 1),
529            (6, 2),
530            (940, 235),
531            (1021, 255),
532            (1023, 255),
533        ] {
534            assert_eq!(round10(v10), v8, "{v10}");
535        }
536    }
537
538    #[test]
539    fn both_ten_bit_layouts_reduce_to_the_same_bytes() {
540        let value = |i: usize| ((i * 37) % 1024) as u16;
541        let planar = frame(PixelFormat::I420P10, 6, 4, value);
542        // The same samples in P010: luma identical, chroma interleaved, each value shifted into the high bits.
543        let (w, h) = (6, 4);
544        let mut p010 = frame(PixelFormat::P010, w, h, |_| 0);
545        let read = |f: &Frame, p: usize, i: usize| {
546            let o = f.planes[p].offset + i * 2;
547            u16::from_le_bytes([f.data[o], f.data[o + 1]])
548        };
549        let mut write = |p: usize, i: usize, v: u16| {
550            let o = p010.planes[p].offset + i * 2;
551            p010.data[o..o + 2].copy_from_slice(&(v << 6).to_le_bytes());
552        };
553        for i in 0..w * h {
554            write(0, i, read(&planar, 0, i));
555        }
556        for i in 0..(w / 2) * (h / 2) {
557            write(1, i * 2, read(&planar, 1, i));
558            write(1, i * 2 + 1, read(&planar, 2, i));
559        }
560        let (a, b) = (planar.to_8bit(), p010.to_8bit());
561        assert_eq!(a.format, Some(PixelFormat::I420));
562        assert_eq!(b.format, Some(PixelFormat::Nv12));
563        assert_eq!(a.data[..w * h], b.data[..w * h]);
564        let (us, vs) = (&a.data[a.planes[1].offset..], &a.data[a.planes[2].offset..]);
565        let uv = &b.data[b.planes[1].offset..];
566        for i in 0..(w / 2) * (h / 2) {
567            assert_eq!([us[i], vs[i]], [uv[i * 2], uv[i * 2 + 1]]);
568        }
569    }
570
571    #[test]
572    fn to_8bit_drops_row_padding() {
573        // A 2×2 I420 frame whose rows are padded to 4 bytes.
574        let f = Frame {
575            width: 2,
576            height: 2,
577            format: Some(PixelFormat::I420),
578            data: vec![1, 2, 0, 0, 3, 4, 0, 0, 5, 0, 0, 0, 6, 0, 0, 0],
579            planes: vec![
580                Plane {
581                    offset: 0,
582                    stride: 4,
583                },
584                Plane {
585                    offset: 8,
586                    stride: 4,
587                },
588                Plane {
589                    offset: 12,
590                    stride: 4,
591                },
592            ],
593            ..Frame::default()
594        };
595        assert_eq!(f.to_8bit().data, vec![1, 2, 3, 4, 5, 6]);
596    }
597
598    #[test]
599    fn rotation_normalises_to_quarter_turns() {
600        assert_eq!(normalise_rotation(-90.0), Ok(270));
601        assert_eq!(normalise_rotation(90.0), Ok(90));
602        assert_eq!(normalise_rotation(-180.0), Ok(180));
603        assert_eq!(normalise_rotation(360.0), Ok(0));
604        assert_eq!(normalise_rotation(89.99), Ok(90));
605        assert!(matches!(
606            normalise_rotation(45.0),
607            Err(Error::Unsupported(_))
608        ));
609    }
610
611    #[test]
612    fn core_code_matches_the_core_signal_codes() {
613        let c = |transfer, matrix, full_range| Colour {
614            transfer,
615            matrix,
616            primaries: None,
617            full_range,
618        };
619        assert_eq!(
620            c(Transfer::Sdr, Some(Matrix::Bt709), false).core_code(1080),
621            0
622        );
623        assert_eq!(
624            c(Transfer::Sdr, Some(Matrix::Bt709), true).core_code(1080),
625            1
626        );
627        assert_eq!(c(Transfer::Sdr, None, false).core_code(480), 2);
628        assert_eq!(c(Transfer::Sdr, None, false).core_code(720), 0);
629        assert_eq!(
630            c(Transfer::Pq, Some(Matrix::Bt2020), false).core_code(2868),
631            8 + 4
632        );
633        assert_eq!(
634            c(Transfer::Hlg, Some(Matrix::Bt2020), true).core_code(2160),
635            16 + 4 + 1
636        );
637    }
638
639    #[test]
640    fn display_size_swaps_on_quarter_turns() {
641        let mut info = VideoInfo {
642            width: 1920,
643            height: 1080,
644            rotation: 90,
645            duration: None,
646            frame_count: None,
647            codec: "h264".into(),
648            bit_depth: 8,
649            colour: Colour {
650                transfer: Transfer::Sdr,
651                matrix: None,
652                primaries: None,
653                full_range: false,
654            },
655            format: PixelFormat::I420,
656            backend: Backend::Ffmpeg,
657        };
658        assert_eq!(info.display_size(), (1080, 1920));
659        info.rotation = 180;
660        assert_eq!(info.display_size(), (1920, 1080));
661    }
662
663    #[test]
664    fn missing_file_is_an_io_error_for_every_backend() {
665        for backend in [
666            Backend::Auto,
667            Backend::Ffmpeg,
668            Backend::AvFoundation,
669            Backend::MediaFoundation,
670        ] {
671            let err = open(
672                Path::new("/nonexistent/clip.mov"),
673                &Options::with_backend(backend),
674            )
675            .err();
676            assert!(matches!(err, Some(Error::Io(_))), "{backend:?}: {err:?}");
677        }
678    }
679
680    #[test]
681    fn backends_parse_by_name() {
682        for b in [
683            Backend::Auto,
684            Backend::Ffmpeg,
685            Backend::AvFoundation,
686            Backend::MediaFoundation,
687        ] {
688            assert_eq!(Backend::parse(b.name()), Some(b));
689        }
690        assert_eq!(Backend::parse("vlc"), None);
691    }
692}