use std::fmt;
use std::path::{Path, PathBuf};
#[cfg(target_os = "macos")]
mod avf;
mod ffmpeg;
#[cfg(windows)]
mod mf;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Backend {
Auto,
Ffmpeg,
AvFoundation,
MediaFoundation,
}
impl Backend {
pub fn name(self) -> &'static str {
match self {
Backend::Auto => "auto",
Backend::Ffmpeg => "ffmpeg",
Backend::AvFoundation => "avfoundation",
Backend::MediaFoundation => "mediafoundation",
}
}
pub fn parse(name: &str) -> Option<Backend> {
[
Backend::Auto,
Backend::Ffmpeg,
Backend::AvFoundation,
Backend::MediaFoundation,
]
.into_iter()
.find(|b| b.name() == name)
}
pub fn available(self) -> bool {
match self {
Backend::Auto | Backend::Ffmpeg => true,
Backend::AvFoundation => cfg!(target_os = "macos"),
Backend::MediaFoundation => cfg!(windows),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PixelFormat {
I420,
Nv12,
I420P10,
P010,
}
impl PixelFormat {
pub fn bit_depth(self) -> u8 {
match self {
PixelFormat::I420 | PixelFormat::Nv12 => 8,
PixelFormat::I420P10 | PixelFormat::P010 => 10,
}
}
pub fn semi_planar(self) -> bool {
matches!(self, PixelFormat::Nv12 | PixelFormat::P010)
}
pub fn sample_bytes(self) -> usize {
if self.bit_depth() > 8 {
2
} else {
1
}
}
pub fn planes(self, width: usize, height: usize) -> Vec<(usize, usize)> {
let (cw, ch, b) = (width.div_ceil(2), height.div_ceil(2), self.sample_bytes());
if self.semi_planar() {
vec![(width * b, height), (cw * 2 * b, ch)]
} else {
vec![(width * b, height), (cw * b, ch), (cw * b, ch)]
}
}
pub fn frame_bytes(self, width: usize, height: usize) -> usize {
self.planes(width, height).iter().map(|(r, n)| r * n).sum()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Plane {
pub offset: usize,
pub stride: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Transfer {
Sdr,
Pq,
Hlg,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Matrix {
Bt709,
Bt601,
Bt2020,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Primaries {
Bt709,
Bt2020,
DisplayP3,
Bt601,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Colour {
pub transfer: Transfer,
pub matrix: Option<Matrix>,
pub primaries: Option<Primaries>,
pub full_range: bool,
}
impl Colour {
pub fn matrix_or_default(&self, height: usize) -> Matrix {
self.matrix.unwrap_or(if height >= 720 {
Matrix::Bt709
} else {
Matrix::Bt601
})
}
pub fn core_code(&self, height: usize) -> u32 {
let transfer = match self.transfer {
Transfer::Sdr => 0,
Transfer::Pq => 1,
Transfer::Hlg => 2,
};
let matrix = match self.matrix_or_default(height) {
Matrix::Bt709 => 0,
Matrix::Bt601 => 1,
Matrix::Bt2020 => 2,
};
transfer * 8 + matrix * 2 + self.full_range as u32
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct VideoInfo {
pub width: usize,
pub height: usize,
pub rotation: u16,
pub duration: Option<f64>,
pub frame_count: Option<u64>,
pub codec: String,
pub bit_depth: u8,
pub colour: Colour,
pub format: PixelFormat,
pub backend: Backend,
}
impl VideoInfo {
pub fn display_size(&self) -> (usize, usize) {
if self.rotation % 180 == 90 {
(self.height, self.width)
} else {
(self.width, self.height)
}
}
}
#[derive(Clone, Debug, Default)]
pub struct Frame {
pub index: u64,
pub timestamp: f64,
pub width: usize,
pub height: usize,
pub format: Option<PixelFormat>,
pub data: Vec<u8>,
pub planes: Vec<Plane>,
}
impl Frame {
pub fn row(&self, p: usize, y: usize, len: usize) -> &[u8] {
let plane = self.planes[p];
&self.data[plane.offset + y * plane.stride..][..len]
}
pub fn to_8bit(&self) -> Frame {
let format = self.format.expect("a decoded frame");
let out_format = match format {
PixelFormat::I420 | PixelFormat::I420P10 => PixelFormat::I420,
PixelFormat::Nv12 | PixelFormat::P010 => PixelFormat::Nv12,
};
let dims = out_format.planes(self.width, self.height);
let mut data = Vec::with_capacity(out_format.frame_bytes(self.width, self.height));
let mut planes = Vec::with_capacity(dims.len());
for (p, &(row_bytes, rows)) in dims.iter().enumerate() {
planes.push(Plane {
offset: data.len(),
stride: row_bytes,
});
for y in 0..rows {
match format {
PixelFormat::I420 | PixelFormat::Nv12 => {
data.extend_from_slice(self.row(p, y, row_bytes))
}
PixelFormat::I420P10 => data.extend(
self.row(p, y, row_bytes * 2)
.chunks_exact(2)
.map(|s| round10(u16::from_le_bytes([s[0], s[1]]))),
),
PixelFormat::P010 => data.extend(
self.row(p, y, row_bytes * 2)
.chunks_exact(2)
.map(|s| round10(u16::from_le_bytes([s[0], s[1]]) >> 6)),
),
}
}
}
Frame {
index: self.index,
timestamp: self.timestamp,
width: self.width,
height: self.height,
format: Some(out_format),
data,
planes,
}
}
}
#[inline]
fn round10(v: u16) -> u8 {
((v.min(1023) + 2) >> 2).min(255) as u8
}
pub(crate) fn pack_layout(frame: &mut Frame, format: PixelFormat, width: usize, height: usize) {
frame.width = width;
frame.height = height;
frame.format = Some(format);
frame.planes.clear();
let mut offset = 0;
for (row_bytes, rows) in format.planes(width, height) {
frame.planes.push(Plane {
offset,
stride: row_bytes,
});
offset += row_bytes * rows;
}
frame.data.resize(offset, 0);
}
pub trait Decoder: Send {
fn info(&self) -> &VideoInfo;
fn read_frame(&mut self, frame: &mut Frame) -> Result<bool, Error>;
fn next_frame(&mut self) -> Result<Option<Frame>, Error> {
let mut frame = Frame::default();
Ok(self.read_frame(&mut frame)?.then_some(frame))
}
}
#[derive(Clone, Debug)]
pub struct Options {
pub backend: Backend,
pub ffmpeg: Option<PathBuf>,
pub ffprobe: Option<PathBuf>,
}
impl Default for Options {
fn default() -> Self {
Options {
backend: Backend::Auto,
ffmpeg: None,
ffprobe: None,
}
}
}
impl Options {
pub fn with_backend(backend: Backend) -> Options {
Options {
backend,
..Options::default()
}
}
}
pub fn open(path: &Path, options: &Options) -> Result<Box<dyn Decoder>, Error> {
if !path.is_file() {
return Err(Error::Io(format!("{}: no such file", path.display())));
}
match options.backend {
Backend::Ffmpeg => Ok(Box::new(ffmpeg::FfmpegDecoder::open(path, options)?)),
Backend::AvFoundation => open_avf(path),
Backend::MediaFoundation => open_mf(path),
Backend::Auto => {
let system = if cfg!(target_os = "macos") {
Some(open_avf(path))
} else if cfg!(windows) {
Some(open_mf(path))
} else {
None
};
match system {
Some(Ok(decoder)) => Ok(decoder),
Some(Err(system_error)) => match ffmpeg::FfmpegDecoder::open(path, options) {
Ok(decoder) => Ok(Box::new(decoder)),
Err(Error::ToolMissing { .. }) => Err(system_error),
Err(error) => Err(error),
},
None => Ok(Box::new(ffmpeg::FfmpegDecoder::open(path, options)?)),
}
}
}
}
pub fn probe(path: &Path, options: &Options) -> Result<VideoInfo, Error> {
open(path, options).map(|decoder| decoder.info().clone())
}
#[cfg(target_os = "macos")]
fn open_avf(path: &Path) -> Result<Box<dyn Decoder>, Error> {
Ok(Box::new(avf::AvfDecoder::open(path)?))
}
#[cfg(not(target_os = "macos"))]
fn open_avf(_: &Path) -> Result<Box<dyn Decoder>, Error> {
Err(Error::BackendUnavailable(Backend::AvFoundation))
}
#[cfg(windows)]
fn open_mf(path: &Path) -> Result<Box<dyn Decoder>, Error> {
Ok(Box::new(mf::MfDecoder::open(path)?))
}
#[cfg(not(windows))]
fn open_mf(_: &Path) -> Result<Box<dyn Decoder>, Error> {
Err(Error::BackendUnavailable(Backend::MediaFoundation))
}
pub(crate) fn normalise_rotation(degrees: f64) -> Result<u16, Error> {
let quarter = (degrees / 90.0).round();
if (degrees - quarter * 90.0).abs() > 0.5 {
return Err(Error::Unsupported(format!(
"a rotation of {degrees}° (only multiples of 90° are supported)"
)));
}
Ok(((quarter as i64).rem_euclid(4) * 90) as u16)
}
#[derive(Debug, Clone, PartialEq)]
pub enum Error {
ToolMissing {
tool: String,
hint: String,
},
BackendUnavailable(Backend),
Unsupported(String),
Decode(String),
Io(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::ToolMissing { tool, hint } => {
write!(f, "`{tool}` could not be started. {hint}")
}
Error::BackendUnavailable(b) => {
write!(
f,
"the {} decoder is not available on this system",
b.name()
)
}
Error::Unsupported(what) => write!(f, "unsupported video: {what}"),
Error::Decode(what) => write!(f, "decoding failed: {what}"),
Error::Io(what) => write!(f, "{what}"),
}
}
}
impl std::error::Error for Error {}
pub(crate) fn install_hint() -> &'static str {
if cfg!(target_os = "macos") {
"Install ffmpeg (it provides ffmpeg and ffprobe), e.g. `brew install ffmpeg`, or set LONGSCREEN_FFMPEG and LONGSCREEN_FFPROBE to their paths."
} else if cfg!(windows) {
"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."
} else {
"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."
}
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(
format: PixelFormat,
width: usize,
height: usize,
fill: impl Fn(usize) -> u16,
) -> Frame {
let mut f = Frame::default();
pack_layout(&mut f, format, width, height);
let samples = f.data.len() / format.sample_bytes();
if format.sample_bytes() == 2 {
f.data = (0..samples).flat_map(|i| fill(i).to_le_bytes()).collect();
} else {
f.data = (0..samples).map(|i| fill(i) as u8).collect();
}
f
}
#[test]
fn plane_sizes_round_chroma_up_for_odd_dimensions() {
assert_eq!(PixelFormat::I420.planes(5, 3), vec![(5, 3), (3, 2), (3, 2)]);
assert_eq!(PixelFormat::P010.planes(5, 3), vec![(10, 3), (12, 2)]);
assert_eq!(PixelFormat::I420P10.frame_bytes(4, 4), (16 + 4 + 4) * 2);
}
#[test]
fn ten_bit_rounds_to_nearest_and_clamps_at_white() {
for (v10, v8) in [
(0, 0),
(1, 0),
(2, 1),
(5, 1),
(6, 2),
(940, 235),
(1021, 255),
(1023, 255),
] {
assert_eq!(round10(v10), v8, "{v10}");
}
}
#[test]
fn both_ten_bit_layouts_reduce_to_the_same_bytes() {
let value = |i: usize| ((i * 37) % 1024) as u16;
let planar = frame(PixelFormat::I420P10, 6, 4, value);
let (w, h) = (6, 4);
let mut p010 = frame(PixelFormat::P010, w, h, |_| 0);
let read = |f: &Frame, p: usize, i: usize| {
let o = f.planes[p].offset + i * 2;
u16::from_le_bytes([f.data[o], f.data[o + 1]])
};
let mut write = |p: usize, i: usize, v: u16| {
let o = p010.planes[p].offset + i * 2;
p010.data[o..o + 2].copy_from_slice(&(v << 6).to_le_bytes());
};
for i in 0..w * h {
write(0, i, read(&planar, 0, i));
}
for i in 0..(w / 2) * (h / 2) {
write(1, i * 2, read(&planar, 1, i));
write(1, i * 2 + 1, read(&planar, 2, i));
}
let (a, b) = (planar.to_8bit(), p010.to_8bit());
assert_eq!(a.format, Some(PixelFormat::I420));
assert_eq!(b.format, Some(PixelFormat::Nv12));
assert_eq!(a.data[..w * h], b.data[..w * h]);
let (us, vs) = (&a.data[a.planes[1].offset..], &a.data[a.planes[2].offset..]);
let uv = &b.data[b.planes[1].offset..];
for i in 0..(w / 2) * (h / 2) {
assert_eq!([us[i], vs[i]], [uv[i * 2], uv[i * 2 + 1]]);
}
}
#[test]
fn to_8bit_drops_row_padding() {
let f = Frame {
width: 2,
height: 2,
format: Some(PixelFormat::I420),
data: vec![1, 2, 0, 0, 3, 4, 0, 0, 5, 0, 0, 0, 6, 0, 0, 0],
planes: vec![
Plane {
offset: 0,
stride: 4,
},
Plane {
offset: 8,
stride: 4,
},
Plane {
offset: 12,
stride: 4,
},
],
..Frame::default()
};
assert_eq!(f.to_8bit().data, vec![1, 2, 3, 4, 5, 6]);
}
#[test]
fn rotation_normalises_to_quarter_turns() {
assert_eq!(normalise_rotation(-90.0), Ok(270));
assert_eq!(normalise_rotation(90.0), Ok(90));
assert_eq!(normalise_rotation(-180.0), Ok(180));
assert_eq!(normalise_rotation(360.0), Ok(0));
assert_eq!(normalise_rotation(89.99), Ok(90));
assert!(matches!(
normalise_rotation(45.0),
Err(Error::Unsupported(_))
));
}
#[test]
fn core_code_matches_the_core_signal_codes() {
let c = |transfer, matrix, full_range| Colour {
transfer,
matrix,
primaries: None,
full_range,
};
assert_eq!(
c(Transfer::Sdr, Some(Matrix::Bt709), false).core_code(1080),
0
);
assert_eq!(
c(Transfer::Sdr, Some(Matrix::Bt709), true).core_code(1080),
1
);
assert_eq!(c(Transfer::Sdr, None, false).core_code(480), 2);
assert_eq!(c(Transfer::Sdr, None, false).core_code(720), 0);
assert_eq!(
c(Transfer::Pq, Some(Matrix::Bt2020), false).core_code(2868),
8 + 4
);
assert_eq!(
c(Transfer::Hlg, Some(Matrix::Bt2020), true).core_code(2160),
16 + 4 + 1
);
}
#[test]
fn display_size_swaps_on_quarter_turns() {
let mut info = VideoInfo {
width: 1920,
height: 1080,
rotation: 90,
duration: None,
frame_count: None,
codec: "h264".into(),
bit_depth: 8,
colour: Colour {
transfer: Transfer::Sdr,
matrix: None,
primaries: None,
full_range: false,
},
format: PixelFormat::I420,
backend: Backend::Ffmpeg,
};
assert_eq!(info.display_size(), (1080, 1920));
info.rotation = 180;
assert_eq!(info.display_size(), (1920, 1080));
}
#[test]
fn missing_file_is_an_io_error_for_every_backend() {
for backend in [
Backend::Auto,
Backend::Ffmpeg,
Backend::AvFoundation,
Backend::MediaFoundation,
] {
let err = open(
Path::new("/nonexistent/clip.mov"),
&Options::with_backend(backend),
)
.err();
assert!(matches!(err, Some(Error::Io(_))), "{backend:?}: {err:?}");
}
}
#[test]
fn backends_parse_by_name() {
for b in [
Backend::Auto,
Backend::Ffmpeg,
Backend::AvFoundation,
Backend::MediaFoundation,
] {
assert_eq!(Backend::parse(b.name()), Some(b));
}
assert_eq!(Backend::parse("vlc"), None);
}
}