ffprobe/
lib.rs

1//! Simple wrapper for the [ffprobe](https://ffmpeg.org/ffprobe.html) CLI utility,
2//! which is part of the ffmpeg tool suite.
3//!
4//! This crate allows retrieving typed information about media files (images and videos)
5//! by invoking `ffprobe` with JSON output options and deserializing the data
6//! into convenient Rust types.
7//!
8//!
9//!
10//! ```rust
11//! match ffprobe::ffprobe("path/to/video.mp4") {
12//!    Ok(info) => {
13//!        dbg!(info);
14//!    },
15//!    Err(err) => {
16//!        eprintln!("Could not analyze file with ffprobe: {:?}", err);
17//!     },
18//! }
19//! ```
20
21/// Execute ffprobe with default settings and return the extracted data.
22///
23/// See [`ffprobe_config`] if you need to customize settings.
24pub fn ffprobe(path: impl AsRef<std::path::Path>) -> Result<FfProbe, FfProbeError> {
25    ffprobe_config(
26        Config {
27            count_frames: false,
28            ffprobe_bin: "ffprobe".into(),
29        },
30        path,
31    )
32}
33
34/// Run ffprobe with a custom config.
35/// See [`ConfigBuilder`] for more details.
36pub fn ffprobe_config(
37    config: Config,
38    path: impl AsRef<std::path::Path>,
39) -> Result<FfProbe, FfProbeError> {
40    let path = path.as_ref();
41
42    let mut cmd = std::process::Command::new(config.ffprobe_bin);
43
44    // Default args.
45    cmd.args([
46        "-v",
47        "quiet",
48        "-show_format",
49        "-show_streams",
50        "-print_format",
51        "json",
52    ]);
53
54    if config.count_frames {
55        cmd.arg("-count_frames");
56    }
57
58    cmd.arg(path);
59
60    let out = cmd.output().map_err(FfProbeError::Io)?;
61
62    if !out.status.success() {
63        return Err(FfProbeError::Status(out));
64    }
65
66    serde_json::from_slice::<FfProbe>(&out.stdout).map_err(FfProbeError::Deserialize)
67}
68
69/// ffprobe configuration.
70///
71/// Use [`Config::builder`] for constructing a new config.
72#[derive(Clone, Debug)]
73pub struct Config {
74    count_frames: bool,
75    ffprobe_bin: std::path::PathBuf,
76}
77
78impl Config {
79    /// Construct a new ConfigBuilder.
80    pub fn builder() -> ConfigBuilder {
81        ConfigBuilder::new()
82    }
83}
84
85/// Build the ffprobe configuration.
86pub struct ConfigBuilder {
87    config: Config,
88}
89
90impl ConfigBuilder {
91    pub fn new() -> Self {
92        Self {
93            config: Config {
94                count_frames: false,
95                ffprobe_bin: "ffprobe".into(),
96            },
97        }
98    }
99
100    /// Enable the -count_frames setting.
101    /// Will fully decode the file and count the frames.
102    /// Frame count will be available in [`Stream::nb_read_frames`].
103    pub fn count_frames(mut self, count_frames: bool) -> Self {
104        self.config.count_frames = count_frames;
105        self
106    }
107
108    /// Specify which binary name (e.g. `"ffprobe-6"`) or path (e.g. `"/opt/bin/ffprobe"`) to use
109    /// for executing `ffprobe`.
110    pub fn ffprobe_bin(mut self, ffprobe_bin: impl AsRef<std::path::Path>) -> Self {
111        self.config.ffprobe_bin = ffprobe_bin.as_ref().to_path_buf();
112        self
113    }
114
115    /// Finalize the builder into a [`Config`].
116    pub fn build(self) -> Config {
117        self.config
118    }
119
120    /// Run ffprobe with the config produced by this builder.
121    pub fn run(self, path: impl AsRef<std::path::Path>) -> Result<FfProbe, FfProbeError> {
122        ffprobe_config(self.config, path)
123    }
124}
125
126impl Default for ConfigBuilder {
127    fn default() -> Self {
128        Self::new()
129    }
130}
131
132#[derive(Debug)]
133#[non_exhaustive]
134pub enum FfProbeError {
135    Io(std::io::Error),
136    Status(std::process::Output),
137    Deserialize(serde_json::Error),
138}
139
140impl std::fmt::Display for FfProbeError {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        match self {
143            FfProbeError::Io(e) => e.fmt(f),
144            FfProbeError::Status(o) => {
145                write!(
146                    f,
147                    "ffprobe exited with status code {}: {}",
148                    o.status,
149                    String::from_utf8_lossy(&o.stderr)
150                )
151            }
152            FfProbeError::Deserialize(e) => e.fmt(f),
153        }
154    }
155}
156
157impl std::error::Error for FfProbeError {}
158
159#[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
160#[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))]
161pub struct FfProbe {
162    pub streams: Vec<Stream>,
163    pub format: Format,
164}
165
166#[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
167#[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))]
168pub struct Stream {
169    pub index: i64,
170    pub codec_name: Option<String>,
171    pub sample_aspect_ratio: Option<String>,
172    pub display_aspect_ratio: Option<String>,
173    pub color_range: Option<String>,
174    pub color_space: Option<String>,
175    pub bits_per_raw_sample: Option<String>,
176    pub channel_layout: Option<String>,
177    pub max_bit_rate: Option<String>,
178    pub nb_frames: Option<String>,
179    /// Number of frames seen by the decoder.
180    /// Requires full decoding and is only available if the 'count_frames'
181    /// setting was enabled.
182    pub nb_read_frames: Option<String>,
183    pub codec_long_name: Option<String>,
184    pub codec_type: Option<String>,
185    pub codec_time_base: Option<String>,
186    pub codec_tag_string: String,
187    pub codec_tag: String,
188    pub sample_fmt: Option<String>,
189    pub sample_rate: Option<String>,
190    pub channels: Option<i64>,
191    pub bits_per_sample: Option<i64>,
192    pub r_frame_rate: String,
193    pub avg_frame_rate: String,
194    pub time_base: String,
195    pub start_pts: Option<i64>,
196    pub start_time: Option<String>,
197    pub duration_ts: Option<i64>,
198    pub duration: Option<String>,
199    pub bit_rate: Option<String>,
200    pub disposition: Disposition,
201    pub tags: Option<StreamTags>,
202    pub profile: Option<String>,
203    pub width: Option<i64>,
204    pub height: Option<i64>,
205    pub coded_width: Option<i64>,
206    pub coded_height: Option<i64>,
207    pub closed_captions: Option<i64>,
208    pub has_b_frames: Option<i64>,
209    pub pix_fmt: Option<String>,
210    pub level: Option<i64>,
211    pub chroma_location: Option<String>,
212    pub refs: Option<i64>,
213    pub is_avc: Option<String>,
214    pub nal_length: Option<String>,
215    pub nal_length_size: Option<String>,
216    pub field_order: Option<String>,
217    pub id: Option<String>,
218    #[serde(default)]
219    pub side_data_list: Vec<SideData>,
220}
221
222#[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
223#[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))]
224// Allowed to prevent having to break compatibility of float fields are added.
225#[allow(clippy::derive_partial_eq_without_eq)]
226pub struct SideData {
227    pub side_data_type: String,
228}
229
230#[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
231#[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))]
232// Allowed to prevent having to break compatibility of float fields are added.
233#[allow(clippy::derive_partial_eq_without_eq)]
234pub struct Disposition {
235    pub default: i64,
236    pub dub: i64,
237    pub original: i64,
238    pub comment: i64,
239    pub lyrics: i64,
240    pub karaoke: i64,
241    pub forced: i64,
242    pub hearing_impaired: i64,
243    pub visual_impaired: i64,
244    pub clean_effects: i64,
245    pub attached_pic: i64,
246    pub timed_thumbnails: i64,
247}
248
249#[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
250#[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))]
251// Allowed to prevent having to break compatibility of float fields are added.
252#[allow(clippy::derive_partial_eq_without_eq)]
253pub struct StreamTags {
254    pub language: Option<String>,
255    pub creation_time: Option<String>,
256    pub handler_name: Option<String>,
257    pub encoder: Option<String>,
258    pub timecode: Option<String>,
259    pub reel_name: Option<String>,
260}
261
262#[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
263#[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))]
264pub struct Format {
265    pub filename: String,
266    pub nb_streams: i64,
267    pub nb_programs: i64,
268    pub format_name: String,
269    pub format_long_name: String,
270    pub start_time: Option<String>,
271    pub duration: Option<String>,
272    // FIXME: wrap with Option<_> on next semver breaking release.
273    #[serde(default)]
274    pub size: String,
275    pub bit_rate: Option<String>,
276    pub probe_score: i64,
277    pub tags: Option<FormatTags>,
278}
279
280impl Format {
281    /// Get the duration parsed into a [`std::time::Duration`].
282    pub fn try_get_duration(
283        &self,
284    ) -> Option<Result<std::time::Duration, std::num::ParseFloatError>> {
285        self.duration
286            .as_ref()
287            .map(|duration| match duration.parse::<f64>() {
288                Ok(num) => Ok(std::time::Duration::from_secs_f64(num)),
289                Err(error) => Err(error),
290            })
291    }
292
293    /// Get the duration parsed into a [`std::time::Duration`].
294    ///
295    /// Will return [`None`] if no duration is available, or if parsing fails.
296    /// See [`Self::try_get_duration`] for a method that returns an error.
297    pub fn get_duration(&self) -> Option<std::time::Duration> {
298        self.try_get_duration()?.ok()
299    }
300}
301
302#[derive(Default, Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
303#[cfg_attr(feature = "__internal_deny_unknown_fields", serde(deny_unknown_fields))]
304#[allow(clippy::derive_partial_eq_without_eq)]
305pub struct FormatTags {
306    #[serde(rename = "WMFSDKNeeded")]
307    pub wmfsdkneeded: Option<String>,
308    #[serde(rename = "DeviceConformanceTemplate")]
309    pub device_conformance_template: Option<String>,
310    #[serde(rename = "WMFSDKVersion")]
311    pub wmfsdkversion: Option<String>,
312    #[serde(rename = "IsVBR")]
313    pub is_vbr: Option<String>,
314    pub major_brand: Option<String>,
315    pub minor_version: Option<String>,
316    pub compatible_brands: Option<String>,
317    pub creation_time: Option<String>,
318    pub encoder: Option<String>,
319
320    #[serde(flatten)]
321    pub extra: std::collections::HashMap<String, serde_json::Value>,
322}