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
121pub fn validate_vmaf_model(model: &str) -> anyhow::Result<()> {
123 if KNOWN_VMAF_MODELS.contains(&model) {
124 return Ok(());
125 }
126 anyhow::bail!("unknown VMAF model '{model}'. Known models: {}", KNOWN_VMAF_MODELS.join(", "));
127}
128
129fn local_binary(name: &str) -> Option<String> {
130 let mut path = PathBuf::from("bin").join("ffmpeg");
131 if cfg!(windows) {
132 path = path.join(format!("{name}.exe"));
133 } else {
134 path = path.join(name);
135 }
136 if path.exists() { Some(path.to_string_lossy().into_owned()) } else { None }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
145 fn test_validate_vmaf_model_known() {
146 assert!(validate_vmaf_model("vmaf_v0.6.1").is_ok());
147 assert!(validate_vmaf_model("vmaf_v0.6.1neg").is_ok());
148 assert!(validate_vmaf_model("vmaf_4k_v0.6.1").is_ok());
149 assert!(validate_vmaf_model("vmaf_b_v0.6.3").is_ok());
150 assert!(validate_vmaf_model("vmaf_4k_v0.6.1neg").is_ok());
151 }
152
153 #[test]
154 fn test_validate_vmaf_model_unknown() {
155 assert!(validate_vmaf_model("vmaf_v99.0").is_err());
156 assert!(validate_vmaf_model("unknown_model").is_err());
157 assert!(validate_vmaf_model("").is_err());
158 }
159
160 #[test]
162 fn test_parse_ffmpeg_version_standard() {
163 let v = parse_ffmpeg_version("ffmpeg version 7.1.1 Copyright", "ffmpeg".into()).unwrap();
164 assert_eq!(v.major, 7);
165 assert_eq!(v.minor, 1);
166 assert_eq!(v.raw, "7.1.1");
167 }
168
169 #[test]
170 fn test_parse_ffmpeg_version_ffprobe() {
171 let v = parse_ffmpeg_version("ffprobe version 6.0.0 Copyright", "ffprobe".into()).unwrap();
172 assert_eq!(v.major, 6);
173 assert_eq!(v.minor, 0);
174 assert_eq!(v.raw, "6.0.0");
175 }
176
177 #[test]
178 fn test_parse_ffmpeg_version_with_n_prefix() {
179 let v =
180 parse_ffmpeg_version("ffmpeg version n7.1.1-1234 Copyright", "ffmpeg".into()).unwrap();
181 assert_eq!(v.major, 7);
182 assert_eq!(v.minor, 1);
183 assert_eq!(v.raw, "7.1.1-1234");
184 }
185
186 #[test]
187 fn test_parse_ffmpeg_version_old() {
188 let v = parse_ffmpeg_version("ffmpeg version 4.4.0 Copyright", "ffmpeg".into()).unwrap();
189 assert_eq!(v.major, 4);
190 assert_eq!(v.minor, 4);
191 }
192
193 #[test]
194 fn test_parse_ffmpeg_version_major_only() {
195 let v = parse_ffmpeg_version("ffmpeg version 7 Copyright", "ffmpeg".into()).unwrap();
196 assert_eq!(v.major, 7);
197 assert_eq!(v.minor, 0);
198 }
199
200 #[test]
201 fn test_parse_ffmpeg_version_two_parts() {
202 let v = parse_ffmpeg_version("ffmpeg version 7.0 Copyright", "ffmpeg".into()).unwrap();
203 assert_eq!(v.major, 7);
204 assert_eq!(v.minor, 0);
205 }
206
207 #[test]
208 fn test_parse_ffmpeg_version_three_parts() {
209 let v = parse_ffmpeg_version("ffmpeg version 5.1.3 Copyright", "ffmpeg".into()).unwrap();
210 assert_eq!(v.major, 5);
211 assert_eq!(v.minor, 1);
212 }
213
214 #[test]
215 fn test_parse_ffmpeg_version_unrecognized_line() {
216 assert!(parse_ffmpeg_version("some random text", "ffmpeg".into()).is_err());
217 }
218
219 #[test]
220 fn test_parse_ffmpeg_version_empty() {
221 assert!(parse_ffmpeg_version("", "ffmpeg".into()).is_err());
222 }
223
224 #[test]
225 fn test_parse_ffmpeg_version_bogus_after_prefix() {
226 assert!(parse_ffmpeg_version("ffmpeg version not-a-version", "ffmpeg".into()).is_err());
228 }
229
230 #[test]
232 fn test_ffmpeg_path_returns_string() {
233 let path = ffmpeg_path();
234 assert!(!path.is_empty());
235 }
236
237 #[test]
238 fn test_ffprobe_path_returns_string() {
239 let path = ffprobe_path();
240 assert!(!path.is_empty());
241 }
242
243 #[test]
244 fn test_ffmpeg_path_respects_env() {
245 let old = std::env::var("VISER_FFMPEG").ok();
246 unsafe {
247 std::env::set_var("VISER_FFMPEG", "/custom/ffmpeg");
248 }
249 assert_eq!(ffmpeg_path(), "/custom/ffmpeg");
250 unsafe {
251 match old {
252 Some(v) => std::env::set_var("VISER_FFMPEG", v),
253 None => std::env::remove_var("VISER_FFMPEG"),
254 }
255 }
256 }
257
258 #[test]
259 fn test_ffprobe_path_respects_env() {
260 let old = std::env::var("VISER_FFPROBE").ok();
261 unsafe {
262 std::env::set_var("VISER_FFPROBE", "/custom/ffprobe");
263 }
264 assert_eq!(ffprobe_path(), "/custom/ffprobe");
265 unsafe {
266 match old {
267 Some(v) => std::env::set_var("VISER_FFPROBE", v),
268 None => std::env::remove_var("VISER_FFPROBE"),
269 }
270 }
271 }
272}