use std::env;
use std::path::PathBuf;
use std::process::Command;
pub fn ffmpeg_path() -> String {
if let Ok(p) = env::var("VISER_FFMPEG")
&& !p.is_empty()
{
return p;
}
if let Some(p) = local_binary("ffmpeg") {
return p;
}
"ffmpeg".into()
}
pub fn ffprobe_path() -> String {
if let Ok(p) = env::var("VISER_FFPROBE")
&& !p.is_empty()
{
return p;
}
if let Some(p) = local_binary("ffprobe") {
return p;
}
"ffprobe".into()
}
const MIN_FFMPEG_MAJOR: u32 = 6;
#[derive(Debug, Clone)]
pub struct FfmpegVersion {
pub binary: String,
pub major: u32,
pub minor: u32,
pub raw: String,
}
pub fn check_ffmpeg() -> anyhow::Result<FfmpegVersion> {
let path = ffmpeg_path();
let output = Command::new(&path)
.arg("-version")
.output()
.map_err(|e| anyhow::anyhow!("ffmpeg not found at '{path}': {e}"))?;
if !output.status.success() {
anyhow::bail!("ffmpeg at '{path}' exited with error");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let first_line = stdout.lines().next().unwrap_or("").trim().to_string();
let version = parse_ffmpeg_version(&first_line, path)?;
if version.major < MIN_FFMPEG_MAJOR {
anyhow::bail!(
"ffmpeg {}.{} is too old — viser requires FFmpeg >= {MIN_FFMPEG_MAJOR}.0 (found {})",
version.major,
version.minor,
version.raw,
);
}
Ok(version)
}
pub fn check_ffprobe() -> anyhow::Result<FfmpegVersion> {
let path = ffprobe_path();
let output = Command::new(&path)
.arg("-version")
.output()
.map_err(|e| anyhow::anyhow!("ffprobe not found at '{path}': {e}"))?;
if !output.status.success() {
anyhow::bail!("ffprobe at '{path}' exited with error");
}
let stdout = String::from_utf8_lossy(&output.stdout);
let first_line = stdout.lines().next().unwrap_or("").trim().to_string();
parse_ffmpeg_version(&first_line, path)
}
fn parse_ffmpeg_version(line: &str, path: String) -> anyhow::Result<FfmpegVersion> {
let version_str = line
.strip_prefix("ffmpeg version ")
.or_else(|| line.strip_prefix("ffprobe version "))
.and_then(|s| s.split_whitespace().next())
.map(|s| s.trim_start_matches('n'))
.ok_or_else(|| anyhow::anyhow!("could not parse version from: {line}"))?;
let parts: Vec<&str> = version_str.split('.').collect();
let major: u32 = parts
.first()
.and_then(|s| s.parse().ok())
.ok_or_else(|| anyhow::anyhow!("could not parse major version from: {version_str}"))?;
let minor: u32 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
Ok(FfmpegVersion { binary: path, major, minor, raw: version_str.to_string() })
}
const KNOWN_VMAF_MODELS: &[&str] =
&["vmaf_v0.6.1", "vmaf_v0.6.1neg", "vmaf_4k_v0.6.1", "vmaf_b_v0.6.3", "vmaf_4k_v0.6.1neg"];
pub fn validate_vmaf_model(model: &str) -> anyhow::Result<()> {
if KNOWN_VMAF_MODELS.contains(&model) {
return Ok(());
}
anyhow::bail!("unknown VMAF model '{model}'. Known models: {}", KNOWN_VMAF_MODELS.join(", "));
}
fn local_binary(name: &str) -> Option<String> {
let mut path = PathBuf::from("bin").join("ffmpeg");
if cfg!(windows) {
path = path.join(format!("{name}.exe"));
} else {
path = path.join(name);
}
if path.exists() { Some(path.to_string_lossy().into_owned()) } else { None }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_vmaf_model_known() {
assert!(validate_vmaf_model("vmaf_v0.6.1").is_ok());
assert!(validate_vmaf_model("vmaf_v0.6.1neg").is_ok());
assert!(validate_vmaf_model("vmaf_4k_v0.6.1").is_ok());
assert!(validate_vmaf_model("vmaf_b_v0.6.3").is_ok());
assert!(validate_vmaf_model("vmaf_4k_v0.6.1neg").is_ok());
}
#[test]
fn test_validate_vmaf_model_unknown() {
assert!(validate_vmaf_model("vmaf_v99.0").is_err());
assert!(validate_vmaf_model("unknown_model").is_err());
assert!(validate_vmaf_model("").is_err());
}
#[test]
fn test_parse_ffmpeg_version_standard() {
let v = parse_ffmpeg_version("ffmpeg version 7.1.1 Copyright", "ffmpeg".into()).unwrap();
assert_eq!(v.major, 7);
assert_eq!(v.minor, 1);
assert_eq!(v.raw, "7.1.1");
}
#[test]
fn test_parse_ffmpeg_version_ffprobe() {
let v = parse_ffmpeg_version("ffprobe version 6.0.0 Copyright", "ffprobe".into()).unwrap();
assert_eq!(v.major, 6);
assert_eq!(v.minor, 0);
assert_eq!(v.raw, "6.0.0");
}
#[test]
fn test_parse_ffmpeg_version_with_n_prefix() {
let v =
parse_ffmpeg_version("ffmpeg version n7.1.1-1234 Copyright", "ffmpeg".into()).unwrap();
assert_eq!(v.major, 7);
assert_eq!(v.minor, 1);
assert_eq!(v.raw, "7.1.1-1234");
}
#[test]
fn test_parse_ffmpeg_version_old() {
let v = parse_ffmpeg_version("ffmpeg version 4.4.0 Copyright", "ffmpeg".into()).unwrap();
assert_eq!(v.major, 4);
assert_eq!(v.minor, 4);
}
#[test]
fn test_parse_ffmpeg_version_major_only() {
let v = parse_ffmpeg_version("ffmpeg version 7 Copyright", "ffmpeg".into()).unwrap();
assert_eq!(v.major, 7);
assert_eq!(v.minor, 0);
}
#[test]
fn test_parse_ffmpeg_version_two_parts() {
let v = parse_ffmpeg_version("ffmpeg version 7.0 Copyright", "ffmpeg".into()).unwrap();
assert_eq!(v.major, 7);
assert_eq!(v.minor, 0);
}
#[test]
fn test_parse_ffmpeg_version_three_parts() {
let v = parse_ffmpeg_version("ffmpeg version 5.1.3 Copyright", "ffmpeg".into()).unwrap();
assert_eq!(v.major, 5);
assert_eq!(v.minor, 1);
}
#[test]
fn test_parse_ffmpeg_version_unrecognized_line() {
assert!(parse_ffmpeg_version("some random text", "ffmpeg".into()).is_err());
}
#[test]
fn test_parse_ffmpeg_version_empty() {
assert!(parse_ffmpeg_version("", "ffmpeg".into()).is_err());
}
#[test]
fn test_parse_ffmpeg_version_bogus_after_prefix() {
assert!(parse_ffmpeg_version("ffmpeg version not-a-version", "ffmpeg".into()).is_err());
}
#[test]
fn test_ffmpeg_path_returns_string() {
let path = ffmpeg_path();
assert!(!path.is_empty());
}
#[test]
fn test_ffprobe_path_returns_string() {
let path = ffprobe_path();
assert!(!path.is_empty());
}
#[test]
fn test_ffmpeg_path_respects_env() {
let old = std::env::var("VISER_FFMPEG").ok();
unsafe {
std::env::set_var("VISER_FFMPEG", "/custom/ffmpeg");
}
assert_eq!(ffmpeg_path(), "/custom/ffmpeg");
unsafe {
match old {
Some(v) => std::env::set_var("VISER_FFMPEG", v),
None => std::env::remove_var("VISER_FFMPEG"),
}
}
}
#[test]
fn test_ffprobe_path_respects_env() {
let old = std::env::var("VISER_FFPROBE").ok();
unsafe {
std::env::set_var("VISER_FFPROBE", "/custom/ffprobe");
}
assert_eq!(ffprobe_path(), "/custom/ffprobe");
unsafe {
match old {
Some(v) => std::env::set_var("VISER_FFPROBE", v),
None => std::env::remove_var("VISER_FFPROBE"),
}
}
}
}