clippr 0.1.1

Convert MP4 to chunked GitHub-friendly GIFs
Documentation
use crate::error::{Error, Result};
use serde::Deserialize;
use std::path::Path;
use std::process::Command;

pub struct VideoInfo {
    pub width: u32,
    pub height: u32,
    pub duration_secs: f64,
    pub framerate: f64,
}

#[derive(Deserialize)]
struct FfprobeOutput {
    streams: Vec<StreamInfo>,
    format: FormatInfo,
}

#[derive(Deserialize)]
struct StreamInfo {
    width: Option<u32>,
    height: Option<u32>,
    r_frame_rate: Option<String>,
}

#[derive(Deserialize)]
struct FormatInfo {
    duration: Option<String>,
}

fn parse_frame_rate(raw: &str) -> Option<f64> {
    let parts: Vec<&str> = raw.split('/').collect();
    if parts.len() == 2 {
        let numerator: f64 = parts[0].parse().ok()?;
        let denominator: f64 = parts[1].parse().ok()?;
        if denominator > 0.0 {
            return Some(numerator / denominator);
        }
    }
    raw.parse().ok()
}

pub fn probe(path: &Path) -> Result<VideoInfo> {
    let output = Command::new("ffprobe")
        .args([
            "-v",
            "quiet",
            "-print_format",
            "json",
            "-select_streams",
            "v:0",
            "-show_entries",
            "stream=width,height,r_frame_rate",
            "-show_entries",
            "format=duration",
        ])
        .arg(path)
        .output()
        .map_err(|_| Error::FfprobeNotFound)?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        return Err(Error::ProbeFailed(stderr.into_owned()));
    }

    let parsed: FfprobeOutput = serde_json::from_slice(&output.stdout)?;

    let stream = parsed
        .streams
        .first()
        .ok_or_else(|| Error::ProbeFailed("no video stream found".into()))?;

    let width = stream
        .width
        .ok_or_else(|| Error::ProbeFailed("missing width".into()))?;

    let height = stream
        .height
        .ok_or_else(|| Error::ProbeFailed("missing height".into()))?;

    let framerate = stream
        .r_frame_rate
        .as_deref()
        .and_then(parse_frame_rate)
        .unwrap_or(30.0);

    let duration_secs = parsed
        .format
        .duration
        .as_deref()
        .and_then(|duration| duration.parse::<f64>().ok())
        .ok_or_else(|| Error::ProbeFailed("missing duration".into()))?;

    Ok(VideoInfo {
        width,
        height,
        duration_secs,
        framerate,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_integer_fraction() {
        let result = parse_frame_rate("30/1").unwrap();
        assert!((result - 30.0).abs() < 0.001);
    }

    #[test]
    fn parse_ntsc_fraction() {
        let result = parse_frame_rate("30000/1001").unwrap();
        assert!((result - 29.97).abs() < 0.01);
    }

    #[test]
    fn parse_plain_float() {
        let result = parse_frame_rate("29.97").unwrap();
        assert!((result - 29.97).abs() < 0.001);
    }

    #[test]
    fn parse_plain_integer() {
        let result = parse_frame_rate("60").unwrap();
        assert!((result - 60.0).abs() < 0.001);
    }

    #[test]
    fn parse_zero_denominator_falls_back() {
        assert!(parse_frame_rate("30/0").is_none());
    }

    #[test]
    fn parse_garbage_returns_none() {
        assert!(parse_frame_rate("not_a_number").is_none());
    }

    #[test]
    fn parse_empty_returns_none() {
        assert!(parse_frame_rate("").is_none());
    }

    #[test]
    fn parse_fraction_with_garbage_numerator() {
        assert!(parse_frame_rate("abc/1").is_none());
    }

    #[test]
    fn parse_fraction_with_garbage_denominator() {
        assert!(parse_frame_rate("30/abc").is_none());
    }
}