dash_mpd/
media.rs

1// Common code for media handling.
2//
3// This file contains functions used both by the external subprocess muxing in ffmpeg.rs and the
4// libav muxing in libav.rs.
5
6
7// When building with the libav feature, several functions here are unused.
8#![allow(dead_code)]
9
10use std::path::{Path, PathBuf};
11use file_format::FileFormat;
12use tracing::warn;
13use crate::DashMpdError;
14use crate::fetch::DashDownloader;
15
16
17#[derive(Debug, Clone)]
18pub struct AudioTrack {
19    pub language: String,
20    pub path: PathBuf,
21}
22
23
24// Returns "mp4", "mkv", "avi" etc. Based on analyzing the media content rather than on the filename
25// extension.
26#[tracing::instrument(level="trace")]
27pub(crate) fn audio_container_type(container: &Path) -> Result<String, DashMpdError> {
28    let format = FileFormat::from_file(container)
29        .map_err(|e| DashMpdError::Io(e, String::from("determining audio container type")))?;
30    Ok(format.extension().to_string())
31}
32
33#[tracing::instrument(level="trace")]
34pub(crate) fn video_container_type(container: &Path) -> Result<String, DashMpdError> {
35    let format = FileFormat::from_file(container)
36        .map_err(|e| DashMpdError::Io(e, String::from("determining video container type")))?;
37    Ok(format.extension().to_string())
38}
39
40
41// This is the metainformation that we need in order to determine whether two video streams can be
42// concatenated using the ffmpeg concat filter.
43#[derive(Debug, Clone)]
44struct VideoMetainfo {
45    width: i64,
46    height: i64,
47    frame_rate: f64,
48    sar: Option<f64>,
49}
50
51impl PartialEq for VideoMetainfo {
52    fn eq(&self, other: &Self) -> bool {
53        if self.width != other.width {
54            return false;
55        }
56        if self.height != other.height {
57            return false;
58        }
59        if (self.frame_rate - other.frame_rate).abs() / self.frame_rate > 0.01 {
60            return false;
61        }
62        // We tolerate missing information concerning the aspect ratio, because in practice it's not
63        // always present in video metadata.
64        if let Some(sar1) = self.sar {
65            if let Some(sar2) = other.sar {
66                if (sar1 - sar2).abs() / sar1 > 0.01 {
67                    return false;
68                }
69            }
70        }
71        true
72    }
73}
74
75// Frame rate as returned by ffprobe is a rational number serialized as "24/1" for example.
76fn parse_frame_rate(s: &str) -> Option<f64> {
77    if let Some((num, den)) = s.split_once('/') {
78        if let Ok(numerator) = num.parse::<u64>() {
79            if let Ok(denominator) = den.parse::<u64>() {
80                return Some(numerator as f64 / denominator as f64);
81            }
82        }
83    }
84    None
85}
86
87// Aspect ratio as returned by ffprobe is a rational number serialized as "1:1" or "16:9" for example.
88fn parse_aspect_ratio(s: &str) -> Option<f64> {
89    if let Some((num, den)) = s.split_once(':') {
90        if let Ok(numerator) = num.parse::<u64>() {
91            if let Ok(denominator) = den.parse::<u64>() {
92                return Some(numerator as f64 / denominator as f64);
93            }
94        }
95    }
96    None
97}
98
99// Return metainformation concerning the first stream of the media content at path.
100// Uses ffprobe as a subprocess.
101#[tracing::instrument(level="trace")]
102fn video_container_metainfo(path: &PathBuf) -> Result<VideoMetainfo, DashMpdError> {
103    match ffprobe::ffprobe(path) {
104        Ok(meta) => {
105            if meta.streams.is_empty() {
106                return Err(DashMpdError::Muxing(String::from("reading video resolution")));
107            }
108            if let Some(s) = &meta.streams.iter().find(|s| s.width.is_some() && s.height.is_some()) {
109                if let Some(frame_rate) = parse_frame_rate(&s.avg_frame_rate) {
110                    let sar = s.sample_aspect_ratio.as_ref()
111                        .and_then(|sr| parse_aspect_ratio(sr));
112                    if let Some(width) = s.width {
113                        if let Some(height) = s.height {
114                            return Ok(VideoMetainfo { width, height, frame_rate, sar });
115                        }
116                    }
117                }
118            }
119        },
120        Err(e) => warn!("Error running ffprobe: {e}"),
121    }
122    Err(DashMpdError::Muxing(String::from("reading video metainformation")))
123}
124
125#[tracing::instrument(level="trace")]
126pub(crate) fn container_only_audio(path: &PathBuf) -> bool {
127    if let Ok(meta) =  ffprobe::ffprobe(path) {
128        return meta.streams.iter().all(|s| s.codec_type.as_ref().is_some_and(|typ| typ.eq("audio")));
129    }
130    false
131}
132
133
134// Does the media container at path contain an audio track (separate from the video track)?
135#[tracing::instrument(level="trace")]
136pub(crate) fn container_has_audio(path: &PathBuf) -> bool {
137    if let Ok(meta) =  ffprobe::ffprobe(path) {
138        return meta.streams.iter().any(|s| s.codec_type.as_ref().is_some_and(|typ| typ.eq("audio")));
139    }
140    false
141}
142
143// Does the media container at path contain a video track?
144#[tracing::instrument(level="trace")]
145pub(crate) fn container_has_video(path: &PathBuf) -> bool {
146    if let Ok(meta) =  ffprobe::ffprobe(path) {
147        return meta.streams.iter().any(|s| s.codec_type.as_ref().is_some_and(|typ| typ.eq("video")));
148    }
149    false
150}
151
152// Can the video streams in these containers be merged together using the ffmpeg concat filter
153// (concatenated, possibly reencoding if the codecs used are different)? They can if:
154//   - they have identical resolutions, frame rate and aspect ratio
155//   - they all only contain audio content
156#[tracing::instrument(level="trace", skip(_downloader))]
157pub(crate) fn video_containers_concatable(_downloader: &DashDownloader, paths: &[PathBuf]) -> bool {
158    if paths.is_empty() {
159        return false;
160    }
161    if let Some(p0) = &paths.first() {
162        if let Ok(p0m) = video_container_metainfo(p0) {
163            return paths.iter().all(
164                |p| video_container_metainfo(p).is_ok_and(|m| m == p0m));
165        }
166    }
167    paths.iter().all(container_only_audio)
168}
169
170// mkvmerge on Windows is compiled using MinGW and isn't able to handle native pathnames, so we
171// create the temporary file in the current directory.
172#[cfg(target_os = "windows")]
173pub fn temporary_outpath(suffix: &str) -> Result<String, DashMpdError> {
174    Ok(format!("dashmpdrs-tmp{suffix}"))
175}
176
177#[cfg(not(target_os = "windows"))]
178pub fn temporary_outpath(suffix: &str) -> Result<String, DashMpdError> {
179    let tmpout = tempfile::Builder::new()
180        .prefix("dashmpdrs")
181        .suffix(suffix)
182        .rand_bytes(5)
183        .tempfile()
184        .map_err(|e| DashMpdError::Io(e, String::from("creating temporary output file")))?;
185    match tmpout.path().to_str() {
186        Some(s) => Ok(s.to_string()),
187        None => Ok(format!("/tmp/dashmpdrs-tmp{suffix}")),
188    }
189}
190