1use std::env;
2use std::path::PathBuf;
3use std::process::Command;
4
5pub 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
23pub 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
41const MIN_FFMPEG_MAJOR: u32 = 6;
43
44#[derive(Debug, Clone)]
46pub struct FfmpegVersion {
47 pub binary: String,
49 pub major: u32,
51 pub minor: u32,
53 pub raw: String,
55}
56
57pub 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
82pub 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 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
116const 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
121const VMAF_MODEL_EXTENSIONS: &[&str] = &["cfg", "json", "model"];
123
124pub fn is_vmaf_model_path(model: &str) -> bool {
127 if model.is_empty() || model.contains('\0') {
128 return false;
129 }
130 if model.contains('/') || model.contains('\\') {
132 return true;
133 }
134 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
143pub 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 #[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 #[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 assert!(parse_ffmpeg_version("ffmpeg version not-a-version", "ffmpeg".into()).is_err());
295 }
296
297 #[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}