Skip to main content

deepshrink_ffmpeg/
lib.rs

1//! Discovery of the external `ffmpeg`/`ffprobe` binaries.
2//!
3//! Search order (see `docs/PRD/03-tech-stack.md`):
4//! 1. Explicit path from an env var (`DEEPSHRINK_FFMPEG` / `DEEPSHRINK_FFPROBE`).
5//! 2. A binary in `PATH`.
6//! 3. (later) a bundled binary next to the app — for the desktop layer.
7//!
8//! If not found, [`FfmpegError::NotFound`]; the CLI maps this to exit code 3
9//! with an install hint. ffprobe/progress parsing arrives in sessions 002/003.
10#![forbid(unsafe_code)]
11
12use std::ffi::OsString;
13use std::path::PathBuf;
14
15use thiserror::Error;
16
17pub mod caps;
18pub mod probe;
19pub mod progress;
20pub mod run;
21pub mod vmaf;
22
23pub use caps::{has_encoder, has_filter};
24pub use probe::{probe, Ffprobe};
25pub use run::{run_pass, run_pass_cancellable, CancelToken};
26pub use vmaf::{has_libvmaf, measure_vmaf};
27
28/// Errors from the ffmpeg layer.
29#[derive(Debug, Error)]
30pub enum FfmpegError {
31    #[error(
32        "`{tool}` not found in PATH. Install ffmpeg (macOS: `brew install ffmpeg`) \
33         or point {env} at the binary."
34    )]
35    NotFound {
36        tool: &'static str,
37        env: &'static str,
38    },
39    #[error("failed to spawn `{tool}`: {source}")]
40    Spawn {
41        tool: &'static str,
42        source: std::io::Error,
43    },
44    #[error("`{tool}` exited with {status}:\n{stderr}")]
45    CommandFailed {
46        tool: &'static str,
47        status: String,
48        stderr: String,
49    },
50    #[error("failed to parse ffprobe output: {0}")]
51    Parse(String),
52    /// The run was stopped through its [`CancelToken`]; ffmpeg was killed.
53    #[error("cancelled")]
54    Cancelled,
55}
56
57/// The located binaries.
58#[derive(Debug, Clone)]
59pub struct Tools {
60    pub ffmpeg: PathBuf,
61    pub ffprobe: PathBuf,
62    /// Stops every [`Tools::run_pass`] made with these tools (a new, never-set
63    /// token by default — see [`Tools::with_cancel`]).
64    pub cancel: CancelToken,
65}
66
67impl Tools {
68    /// These tools, with runs stopped by `cancel`.
69    pub fn with_cancel(mut self, cancel: CancelToken) -> Self {
70        self.cancel = cancel;
71        self
72    }
73
74    /// [`run_pass_cancellable`] with this `ffmpeg` and cancel token.
75    pub fn run_pass<S: AsRef<std::ffi::OsStr>>(
76        &self,
77        args: &[S],
78        total_secs: f64,
79        on_progress: &mut dyn FnMut(f64),
80    ) -> Result<(), FfmpegError> {
81        run_pass_cancellable(&self.ffmpeg, args, total_secs, on_progress, &self.cancel)
82    }
83}
84
85/// Locate `ffmpeg` and `ffprobe`. Returns the first missing tool as an error.
86pub fn locate() -> Result<Tools, FfmpegError> {
87    Ok(Tools {
88        ffmpeg: locate_one("ffmpeg", "DEEPSHRINK_FFMPEG")?,
89        ffprobe: locate_one("ffprobe", "DEEPSHRINK_FFPROBE")?,
90        cancel: CancelToken::default(),
91    })
92}
93
94/// Locate a single tool: first via its env var, then in `PATH`.
95pub fn locate_one(tool: &'static str, env: &'static str) -> Result<PathBuf, FfmpegError> {
96    if let Some(explicit) = std::env::var_os(env) {
97        if !explicit.is_empty() {
98            return Ok(PathBuf::from(explicit));
99        }
100    }
101    which_in_path(tool).ok_or(FfmpegError::NotFound { tool, env })
102}
103
104/// A simple executable lookup in `PATH` (no external dependencies).
105fn which_in_path(tool: &str) -> Option<PathBuf> {
106    let path = std::env::var_os("PATH")?;
107    let names = candidate_names(tool);
108    for dir in std::env::split_paths(&path) {
109        if dir.as_os_str().is_empty() {
110            continue;
111        }
112        for name in &names {
113            let candidate = dir.join(name);
114            if candidate.is_file() {
115                return Some(candidate);
116            }
117        }
118    }
119    None
120}
121
122#[cfg(windows)]
123fn candidate_names(tool: &str) -> Vec<OsString> {
124    // On Windows, account for the common executable extensions.
125    vec![
126        OsString::from(format!("{tool}.exe")),
127        OsString::from(format!("{tool}.bat")),
128        OsString::from(format!("{tool}.cmd")),
129        OsString::from(tool),
130    ]
131}
132
133#[cfg(not(windows))]
134fn candidate_names(tool: &str) -> Vec<OsString> {
135    vec![OsString::from(tool)]
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn missing_binary_is_none() {
144        assert!(which_in_path("deepshrink-definitely-not-a-real-binary-xyz").is_none());
145    }
146
147    #[test]
148    fn candidate_names_include_bare_tool() {
149        let names = candidate_names("ffmpeg");
150        assert!(names.contains(&OsString::from("ffmpeg")));
151    }
152}