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 probe;
18pub mod progress;
19pub mod run;
20pub mod vmaf;
21
22pub use probe::{probe, Ffprobe};
23pub use run::run_pass;
24pub use vmaf::{has_libvmaf, measure_vmaf};
25
26/// Errors from the ffmpeg layer.
27#[derive(Debug, Error)]
28pub enum FfmpegError {
29    #[error(
30        "`{tool}` not found in PATH. Install ffmpeg (macOS: `brew install ffmpeg`) \
31         or point {env} at the binary."
32    )]
33    NotFound {
34        tool: &'static str,
35        env: &'static str,
36    },
37    #[error("failed to spawn `{tool}`: {source}")]
38    Spawn {
39        tool: &'static str,
40        source: std::io::Error,
41    },
42    #[error("`{tool}` exited with {status}:\n{stderr}")]
43    CommandFailed {
44        tool: &'static str,
45        status: String,
46        stderr: String,
47    },
48    #[error("failed to parse ffprobe output: {0}")]
49    Parse(String),
50}
51
52/// The located binaries.
53#[derive(Debug, Clone)]
54pub struct Tools {
55    pub ffmpeg: PathBuf,
56    pub ffprobe: PathBuf,
57}
58
59/// Locate `ffmpeg` and `ffprobe`. Returns the first missing tool as an error.
60pub fn locate() -> Result<Tools, FfmpegError> {
61    Ok(Tools {
62        ffmpeg: locate_one("ffmpeg", "DEEPSHRINK_FFMPEG")?,
63        ffprobe: locate_one("ffprobe", "DEEPSHRINK_FFPROBE")?,
64    })
65}
66
67/// Locate a single tool: first via its env var, then in `PATH`.
68pub fn locate_one(tool: &'static str, env: &'static str) -> Result<PathBuf, FfmpegError> {
69    if let Some(explicit) = std::env::var_os(env) {
70        if !explicit.is_empty() {
71            return Ok(PathBuf::from(explicit));
72        }
73    }
74    which_in_path(tool).ok_or(FfmpegError::NotFound { tool, env })
75}
76
77/// A simple executable lookup in `PATH` (no external dependencies).
78fn which_in_path(tool: &str) -> Option<PathBuf> {
79    let path = std::env::var_os("PATH")?;
80    let names = candidate_names(tool);
81    for dir in std::env::split_paths(&path) {
82        if dir.as_os_str().is_empty() {
83            continue;
84        }
85        for name in &names {
86            let candidate = dir.join(name);
87            if candidate.is_file() {
88                return Some(candidate);
89            }
90        }
91    }
92    None
93}
94
95#[cfg(windows)]
96fn candidate_names(tool: &str) -> Vec<OsString> {
97    // On Windows, account for the common executable extensions.
98    vec![
99        OsString::from(format!("{tool}.exe")),
100        OsString::from(format!("{tool}.bat")),
101        OsString::from(format!("{tool}.cmd")),
102        OsString::from(tool),
103    ]
104}
105
106#[cfg(not(windows))]
107fn candidate_names(tool: &str) -> Vec<OsString> {
108    vec![OsString::from(tool)]
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn missing_binary_is_none() {
117        assert!(which_in_path("deepshrink-definitely-not-a-real-binary-xyz").is_none());
118    }
119
120    #[test]
121    fn candidate_names_include_bare_tool() {
122        let names = candidate_names("ffmpeg");
123        assert!(names.contains(&OsString::from("ffmpeg")));
124    }
125}