viser-ffmpeg 0.12.0

FFmpeg/FFprobe wrapper for viser
Documentation
use std::env;
use std::path::PathBuf;
use std::process::Command;

/// Returns the path to the ffmpeg binary.
///
/// Resolution order:
/// 1. `VISER_FFMPEG` environment variable
/// 2. `bin/ffmpeg/ffmpeg` relative to the working directory
/// 3. `"ffmpeg"` (system PATH)
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()
}

/// Returns the path to the ffprobe binary.
///
/// Resolution order:
/// 1. `VISER_FFPROBE` environment variable
/// 2. `bin/ffmpeg/ffprobe` relative to the working directory
/// 3. `"ffprobe"` (system PATH)
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()
}

/// Minimum FFmpeg version required (major).
const MIN_FFMPEG_MAJOR: u32 = 6;

/// Parsed FFmpeg version.
#[derive(Debug, Clone)]
pub struct FfmpegVersion {
    /// Path to the binary that reported this version.
    pub binary: String,
    /// Major version number.
    pub major: u32,
    /// Minor version number.
    pub minor: u32,
    /// Raw version string as parsed from the binary's output.
    pub raw: String,
}

/// Run `ffmpeg -version` and parse the version line. Returns an error if the
/// binary is not found or the version is too old.
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)
}

/// Run `ffprobe -version` and parse the version line. Returns an error if ffprobe
/// is not found.
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> {
    // Typical first line: "ffmpeg version 7.1.1 Copyright ..."
    // or "ffmpeg version n7.1.1-... Copyright ..."
    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() })
}

/// Known valid libvmaf model names. Models are resolved by FFmpeg's built-in
/// libvmaf library; these are the names FFmpeg recognizes.
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"];

/// File extensions that identify a VMAF model file path (vs. a built-in name).
const VMAF_MODEL_EXTENSIONS: &[&str] = &["cfg", "json", "model"];

/// Returns `true` when `model` looks like a file path to a custom VMAF model
/// (as opposed to a built-in model name).
pub fn is_vmaf_model_path(model: &str) -> bool {
    if model.is_empty() || model.contains('\0') {
        return false;
    }
    // Path separators strongly suggest a file path.
    if model.contains('/') || model.contains('\\') {
        return true;
    }
    // Known model-file extensions.
    if let Some(dot) = model.rfind('.')
        && let Some(ext) = model.get(dot + 1..)
    {
        return VMAF_MODEL_EXTENSIONS.contains(&ext);
    }
    false
}

/// Validate that the given VMAF model name is recognized by libvmaf, or refers
/// to a custom model file path.
pub fn validate_vmaf_model(model: &str) -> anyhow::Result<()> {
    if KNOWN_VMAF_MODELS.contains(&model) || is_vmaf_model_path(model) {
        return Ok(());
    }
    anyhow::bail!(
        "unknown VMAF model '{model}'. Known models: {}; or supply a .cfg/.json/.model file path",
        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::*;

    // ── VMAF model validation ──
    #[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_validate_vmaf_model_path_accepts_cfg() {
        assert!(validate_vmaf_model("/path/to/hdr_model.cfg").is_ok());
        assert!(validate_vmaf_model("./hdr_model.cfg").is_ok());
    }

    #[test]
    fn test_validate_vmaf_model_path_accepts_json() {
        assert!(validate_vmaf_model("custom_vmaf_model.json").is_ok());
    }

    #[test]
    fn test_validate_vmaf_model_path_accepts_model_ext() {
        assert!(validate_vmaf_model("model/hdr_vmaf.model").is_ok());
    }

    #[test]
    fn test_is_vmaf_model_path_detects_separators() {
        assert!(is_vmaf_model_path("/absolute/path.cfg"));
        assert!(is_vmaf_model_path("relative/path.cfg"));
        assert!(is_vmaf_model_path("C:\\windows\\path.cfg"));
    }

    #[test]
    fn test_is_vmaf_model_path_detects_extensions_without_separator() {
        assert!(is_vmaf_model_path("custom.cfg"));
        assert!(is_vmaf_model_path("hdr_model.json"));
        assert!(is_vmaf_model_path("vmaf.model"));
    }

    #[test]
    fn test_is_vmaf_model_path_rejects_builtin_names() {
        assert!(!is_vmaf_model_path("vmaf_v0.6.1"));
        assert!(!is_vmaf_model_path("vmaf_b_v0.6.3"));
    }

    #[test]
    fn test_is_vmaf_model_path_rejects_empty() {
        assert!(!is_vmaf_model_path(""));
    }

    // ── Version parsing ──
    #[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() {
        // "ffmpeg version abc" — "abc" is not a valid version
        assert!(parse_ffmpeg_version("ffmpeg version not-a-version", "ffmpeg".into()).is_err());
    }

    // ── Path functions ──
    #[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"),
            }
        }
    }
}