1use std::fmt;
32use std::path::{Path, PathBuf};
33
34#[cfg(target_os = "macos")]
35mod avf;
36mod ffmpeg;
37#[cfg(windows)]
38mod mf;
39
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum Backend {
43 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum PixelFormat {
86 I420,
88 Nv12,
90 I420P10,
92 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 pub fn sample_bytes(self) -> usize {
110 if self.bit_depth() > 8 {
111 2
112 } else {
113 1
114 }
115 }
116
117 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
135pub struct Plane {
136 pub offset: usize,
137 pub stride: usize,
138}
139
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142pub enum Transfer {
143 Sdr,
145 Pq,
147 Hlg,
149}
150
151#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153pub enum Matrix {
154 Bt709,
155 Bt601,
156 Bt2020,
157}
158
159#[derive(Clone, Copy, Debug, PartialEq, Eq)]
161pub enum Primaries {
162 Bt709,
163 Bt2020,
164 DisplayP3,
165 Bt601,
166}
167
168#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170pub struct Colour {
171 pub transfer: Transfer,
172 pub matrix: Option<Matrix>,
173 pub primaries: Option<Primaries>,
174 pub full_range: bool,
176}
177
178impl Colour {
179 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 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#[derive(Clone, Debug, PartialEq)]
206pub struct VideoInfo {
207 pub width: usize,
209 pub height: usize,
210 pub rotation: u16,
212 pub duration: Option<f64>,
214 pub frame_count: Option<u64>,
220 pub codec: String,
222 pub bit_depth: u8,
224 pub colour: Colour,
225 pub format: PixelFormat,
227 pub backend: Backend,
229}
230
231impl VideoInfo {
232 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#[derive(Clone, Debug, Default)]
244pub struct Frame {
245 pub index: u64,
247 pub timestamp: f64,
249 pub width: usize,
250 pub height: usize,
251 pub format: Option<PixelFormat>,
253 pub data: Vec<u8>,
254 pub planes: Vec<Plane>,
256}
257
258impl Frame {
259 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 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
317pub(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
334pub trait Decoder: Send {
336 fn info(&self) -> &VideoInfo;
337
338 fn read_frame(&mut self, frame: &mut Frame) -> Result<bool, Error>;
340
341 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#[derive(Clone, Debug)]
350pub struct Options {
351 pub backend: Backend,
352 pub ffmpeg: Option<PathBuf>,
354 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
377pub 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 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
408pub 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
433pub(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#[derive(Debug, Clone, PartialEq)]
446pub enum Error {
447 ToolMissing {
449 tool: String,
450 hint: String,
451 },
452 BackendUnavailable(Backend),
454 Unsupported(String),
456 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
483pub(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 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 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}