Skip to main content

viser_ffmpeg/
path.rs

1use std::env;
2use std::path::PathBuf;
3use std::process::Command;
4
5/// Returns the path to the ffmpeg binary.
6///
7/// Resolution order:
8/// 1. `VISER_FFMPEG` environment variable
9/// 2. `bin/ffmpeg/ffmpeg` relative to the working directory
10/// 3. `"ffmpeg"` (system PATH)
11pub fn ffmpeg_path() -> String {
12    if let Ok(p) = env::var("VISER_FFMPEG")
13        && !p.is_empty()
14    {
15        return p;
16    }
17    if let Some(p) = local_binary("ffmpeg") {
18        return p;
19    }
20    "ffmpeg".into()
21}
22
23/// Returns the path to the ffprobe binary.
24///
25/// Resolution order:
26/// 1. `VISER_FFPROBE` environment variable
27/// 2. `bin/ffmpeg/ffprobe` relative to the working directory
28/// 3. `"ffprobe"` (system PATH)
29pub fn ffprobe_path() -> String {
30    if let Ok(p) = env::var("VISER_FFPROBE")
31        && !p.is_empty()
32    {
33        return p;
34    }
35    if let Some(p) = local_binary("ffprobe") {
36        return p;
37    }
38    "ffprobe".into()
39}
40
41/// Minimum FFmpeg version required (major).
42const MIN_FFMPEG_MAJOR: u32 = 6;
43
44/// Parsed FFmpeg version.
45#[derive(Debug, Clone)]
46pub struct FfmpegVersion {
47    /// Path to the binary that reported this version.
48    pub binary: String,
49    /// Major version number.
50    pub major: u32,
51    /// Minor version number.
52    pub minor: u32,
53    /// Raw version string as parsed from the binary's output.
54    pub raw: String,
55}
56
57/// Run `ffmpeg -version` and parse the version line. Returns an error if the
58/// binary is not found or the version is too old.
59pub fn check_ffmpeg() -> anyhow::Result<FfmpegVersion> {
60    let path = ffmpeg_path();
61    let output = Command::new(&path)
62        .arg("-version")
63        .output()
64        .map_err(|e| anyhow::anyhow!("ffmpeg not found at '{path}': {e}"))?;
65    if !output.status.success() {
66        anyhow::bail!("ffmpeg at '{path}' exited with error");
67    }
68    let stdout = String::from_utf8_lossy(&output.stdout);
69    let first_line = stdout.lines().next().unwrap_or("").trim().to_string();
70    let version = parse_ffmpeg_version(&first_line, path)?;
71    if version.major < MIN_FFMPEG_MAJOR {
72        anyhow::bail!(
73            "ffmpeg {}.{} is too old — viser requires FFmpeg >= {MIN_FFMPEG_MAJOR}.0 (found {})",
74            version.major,
75            version.minor,
76            version.raw,
77        );
78    }
79    Ok(version)
80}
81
82/// Run `ffprobe -version` and parse the version line. Returns an error if ffprobe
83/// is not found.
84pub fn check_ffprobe() -> anyhow::Result<FfmpegVersion> {
85    let path = ffprobe_path();
86    let output = Command::new(&path)
87        .arg("-version")
88        .output()
89        .map_err(|e| anyhow::anyhow!("ffprobe not found at '{path}': {e}"))?;
90    if !output.status.success() {
91        anyhow::bail!("ffprobe at '{path}' exited with error");
92    }
93    let stdout = String::from_utf8_lossy(&output.stdout);
94    let first_line = stdout.lines().next().unwrap_or("").trim().to_string();
95    parse_ffmpeg_version(&first_line, path)
96}
97
98fn parse_ffmpeg_version(line: &str, path: String) -> anyhow::Result<FfmpegVersion> {
99    // Typical first line: "ffmpeg version 7.1.1 Copyright ..."
100    // or "ffmpeg version n7.1.1-... Copyright ..."
101    let version_str = line
102        .strip_prefix("ffmpeg version ")
103        .or_else(|| line.strip_prefix("ffprobe version "))
104        .and_then(|s| s.split_whitespace().next())
105        .map(|s| s.trim_start_matches('n'))
106        .ok_or_else(|| anyhow::anyhow!("could not parse version from: {line}"))?;
107    let parts: Vec<&str> = version_str.split('.').collect();
108    let major: u32 = parts
109        .first()
110        .and_then(|s| s.parse().ok())
111        .ok_or_else(|| anyhow::anyhow!("could not parse major version from: {version_str}"))?;
112    let minor: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
113    Ok(FfmpegVersion { binary: path, major, minor, raw: version_str.to_string() })
114}
115
116/// Known valid libvmaf model names. Models are resolved by FFmpeg's built-in
117/// libvmaf library; these are the names FFmpeg recognizes.
118const KNOWN_VMAF_MODELS: &[&str] =
119    &["vmaf_v0.6.1", "vmaf_v0.6.1neg", "vmaf_4k_v0.6.1", "vmaf_b_v0.6.3", "vmaf_4k_v0.6.1neg"];
120
121/// File extensions that identify a VMAF model file path (vs. a built-in name).
122const VMAF_MODEL_EXTENSIONS: &[&str] = &["cfg", "json", "model"];
123
124/// Returns `true` when `model` looks like a file path to a custom VMAF model
125/// (as opposed to a built-in model name).
126pub fn is_vmaf_model_path(model: &str) -> bool {
127    if model.is_empty() || model.contains('\0') {
128        return false;
129    }
130    // Path separators strongly suggest a file path.
131    if model.contains('/') || model.contains('\\') {
132        return true;
133    }
134    // Known model-file extensions.
135    if let Some(dot) = model.rfind('.')
136        && let Some(ext) = model.get(dot + 1..)
137    {
138        return VMAF_MODEL_EXTENSIONS.contains(&ext);
139    }
140    false
141}
142
143/// Validate that the given VMAF model name is recognized by libvmaf, or refers
144/// to a custom model file path.
145pub fn validate_vmaf_model(model: &str) -> anyhow::Result<()> {
146    if KNOWN_VMAF_MODELS.contains(&model) || is_vmaf_model_path(model) {
147        return Ok(());
148    }
149    anyhow::bail!(
150        "unknown VMAF model '{model}'. Known models: {}; or supply a .cfg/.json/.model file path",
151        KNOWN_VMAF_MODELS.join(", ")
152    );
153}
154
155fn local_binary(name: &str) -> Option<String> {
156    let mut path = PathBuf::from("bin").join("ffmpeg");
157    if cfg!(windows) {
158        path = path.join(format!("{name}.exe"));
159    } else {
160        path = path.join(name);
161    }
162    if path.exists() { Some(path.to_string_lossy().into_owned()) } else { None }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    // ── VMAF model validation ──
170    #[test]
171    fn test_validate_vmaf_model_known() {
172        assert!(validate_vmaf_model("vmaf_v0.6.1").is_ok());
173        assert!(validate_vmaf_model("vmaf_v0.6.1neg").is_ok());
174        assert!(validate_vmaf_model("vmaf_4k_v0.6.1").is_ok());
175        assert!(validate_vmaf_model("vmaf_b_v0.6.3").is_ok());
176        assert!(validate_vmaf_model("vmaf_4k_v0.6.1neg").is_ok());
177    }
178
179    #[test]
180    fn test_validate_vmaf_model_unknown() {
181        assert!(validate_vmaf_model("vmaf_v99.0").is_err());
182        assert!(validate_vmaf_model("unknown_model").is_err());
183        assert!(validate_vmaf_model("").is_err());
184    }
185
186    #[test]
187    fn test_validate_vmaf_model_path_accepts_cfg() {
188        assert!(validate_vmaf_model("/path/to/hdr_model.cfg").is_ok());
189        assert!(validate_vmaf_model("./hdr_model.cfg").is_ok());
190    }
191
192    #[test]
193    fn test_validate_vmaf_model_path_accepts_json() {
194        assert!(validate_vmaf_model("custom_vmaf_model.json").is_ok());
195    }
196
197    #[test]
198    fn test_validate_vmaf_model_path_accepts_model_ext() {
199        assert!(validate_vmaf_model("model/hdr_vmaf.model").is_ok());
200    }
201
202    #[test]
203    fn test_is_vmaf_model_path_detects_separators() {
204        assert!(is_vmaf_model_path("/absolute/path.cfg"));
205        assert!(is_vmaf_model_path("relative/path.cfg"));
206        assert!(is_vmaf_model_path("C:\\windows\\path.cfg"));
207    }
208
209    #[test]
210    fn test_is_vmaf_model_path_detects_extensions_without_separator() {
211        assert!(is_vmaf_model_path("custom.cfg"));
212        assert!(is_vmaf_model_path("hdr_model.json"));
213        assert!(is_vmaf_model_path("vmaf.model"));
214    }
215
216    #[test]
217    fn test_is_vmaf_model_path_rejects_builtin_names() {
218        assert!(!is_vmaf_model_path("vmaf_v0.6.1"));
219        assert!(!is_vmaf_model_path("vmaf_b_v0.6.3"));
220    }
221
222    #[test]
223    fn test_is_vmaf_model_path_rejects_empty() {
224        assert!(!is_vmaf_model_path(""));
225    }
226
227    // ── Version parsing ──
228    #[test]
229    fn test_parse_ffmpeg_version_standard() {
230        let v = parse_ffmpeg_version("ffmpeg version 7.1.1 Copyright", "ffmpeg".into()).unwrap();
231        assert_eq!(v.major, 7);
232        assert_eq!(v.minor, 1);
233        assert_eq!(v.raw, "7.1.1");
234    }
235
236    #[test]
237    fn test_parse_ffmpeg_version_ffprobe() {
238        let v = parse_ffmpeg_version("ffprobe version 6.0.0 Copyright", "ffprobe".into()).unwrap();
239        assert_eq!(v.major, 6);
240        assert_eq!(v.minor, 0);
241        assert_eq!(v.raw, "6.0.0");
242    }
243
244    #[test]
245    fn test_parse_ffmpeg_version_with_n_prefix() {
246        let v =
247            parse_ffmpeg_version("ffmpeg version n7.1.1-1234 Copyright", "ffmpeg".into()).unwrap();
248        assert_eq!(v.major, 7);
249        assert_eq!(v.minor, 1);
250        assert_eq!(v.raw, "7.1.1-1234");
251    }
252
253    #[test]
254    fn test_parse_ffmpeg_version_old() {
255        let v = parse_ffmpeg_version("ffmpeg version 4.4.0 Copyright", "ffmpeg".into()).unwrap();
256        assert_eq!(v.major, 4);
257        assert_eq!(v.minor, 4);
258    }
259
260    #[test]
261    fn test_parse_ffmpeg_version_major_only() {
262        let v = parse_ffmpeg_version("ffmpeg version 7 Copyright", "ffmpeg".into()).unwrap();
263        assert_eq!(v.major, 7);
264        assert_eq!(v.minor, 0);
265    }
266
267    #[test]
268    fn test_parse_ffmpeg_version_two_parts() {
269        let v = parse_ffmpeg_version("ffmpeg version 7.0 Copyright", "ffmpeg".into()).unwrap();
270        assert_eq!(v.major, 7);
271        assert_eq!(v.minor, 0);
272    }
273
274    #[test]
275    fn test_parse_ffmpeg_version_three_parts() {
276        let v = parse_ffmpeg_version("ffmpeg version 5.1.3 Copyright", "ffmpeg".into()).unwrap();
277        assert_eq!(v.major, 5);
278        assert_eq!(v.minor, 1);
279    }
280
281    #[test]
282    fn test_parse_ffmpeg_version_unrecognized_line() {
283        assert!(parse_ffmpeg_version("some random text", "ffmpeg".into()).is_err());
284    }
285
286    #[test]
287    fn test_parse_ffmpeg_version_empty() {
288        assert!(parse_ffmpeg_version("", "ffmpeg".into()).is_err());
289    }
290
291    #[test]
292    fn test_parse_ffmpeg_version_bogus_after_prefix() {
293        // "ffmpeg version abc" — "abc" is not a valid version
294        assert!(parse_ffmpeg_version("ffmpeg version not-a-version", "ffmpeg".into()).is_err());
295    }
296
297    // ── Path functions ──
298    #[test]
299    fn test_ffmpeg_path_returns_string() {
300        let path = ffmpeg_path();
301        assert!(!path.is_empty());
302    }
303
304    #[test]
305    fn test_ffprobe_path_returns_string() {
306        let path = ffprobe_path();
307        assert!(!path.is_empty());
308    }
309
310    #[test]
311    fn test_ffmpeg_path_respects_env() {
312        let old = std::env::var("VISER_FFMPEG").ok();
313        unsafe {
314            std::env::set_var("VISER_FFMPEG", "/custom/ffmpeg");
315        }
316        assert_eq!(ffmpeg_path(), "/custom/ffmpeg");
317        unsafe {
318            match old {
319                Some(v) => std::env::set_var("VISER_FFMPEG", v),
320                None => std::env::remove_var("VISER_FFMPEG"),
321            }
322        }
323    }
324
325    #[test]
326    fn test_ffprobe_path_respects_env() {
327        let old = std::env::var("VISER_FFPROBE").ok();
328        unsafe {
329            std::env::set_var("VISER_FFPROBE", "/custom/ffprobe");
330        }
331        assert_eq!(ffprobe_path(), "/custom/ffprobe");
332        unsafe {
333            match old {
334                Some(v) => std::env::set_var("VISER_FFPROBE", v),
335                None => std::env::remove_var("VISER_FFPROBE"),
336            }
337        }
338    }
339}