shrivel 0.1.3

Cross-platform FFmpeg orchestrator that batch re-encodes videos to H.265/HEVC
//! Per-file conversion decisions and FFmpeg argument construction.

use std::ffi::OsString;
use std::path::Path;

use crate::encoder::Encoder;

/// A frame-rate difference at or below this value is not worth resampling.
const FPS_TOLERANCE: f64 = 0.5;

/// Highest quality value accepted by the supported encoders.
const MAX_CQ: u8 = 51;

/// Containers that need the `hvc1` tag for HEVC to play on Apple devices.
const HVC1_CONTAINERS: [&str; 3] = ["mp4", "m4v", "mov"];

/// Raises the quality value for low-bitrate sources.
///
/// Sources that are already heavily compressed gain little from a strict
/// quality target, so the target is relaxed to avoid inflating the output.
pub fn effective_cq(base: u8, source_kbps: u32) -> u8 {
    let bump = match source_kbps {
        0 => 0, // unknown bitrate: leave the value untouched
        1..=2999 => 4,
        3000..=5999 => 2,
        _ => 0,
    };
    base.saturating_add(bump).min(MAX_CQ)
}

/// Returns true when the source frame rate differs enough from the target
/// to justify resampling.
pub fn fps_change_needed(source: f64, target: f64) -> bool {
    (source - target).abs() > FPS_TOLERANCE
}

/// Everything needed to build one FFmpeg invocation.
pub struct Plan<'a> {
    pub encoder: Encoder,
    pub input: &'a Path,
    pub output: &'a Path,
    pub cq: u8,
    /// Video filters, applied in order.
    pub filters: Vec<String>,
    /// Force constant frame rate output (used when resampling).
    pub force_cfr: bool,
}

impl Plan<'_> {
    /// Builds the full FFmpeg argument list (without the program name).
    pub fn ffmpeg_args(&self) -> Vec<OsString> {
        let mut args: Vec<OsString> = Vec::new();
        let mut push = |s: &str| args.push(OsString::from(s));

        for a in [
            "-hide_banner",
            "-loglevel",
            "error",
            "-nostdin",
            "-nostats",
            "-progress",
            "pipe:2",
        ] {
            push(a);
        }
        for a in self.encoder.input_args() {
            push(a);
        }
        push("-i");
        args.push(self.input.as_os_str().to_owned());

        let mut push = |s: &str| args.push(OsString::from(s));
        for a in ["-map", "0:v:0", "-map", "0:a?"] {
            push(a);
        }
        for a in self.encoder.video_args(self.cq) {
            push(&a);
        }
        if !self.filters.is_empty() {
            push("-vf");
            push(&self.filters.join(","));
        }
        if self.wants_hvc1_tag() {
            push("-tag:v");
            push("hvc1");
        }
        for a in ["-c:a", "copy"] {
            push(a);
        }
        if self.force_cfr {
            push("-fps_mode");
            push("cfr");
        }
        push("-y");
        args.push(self.output.as_os_str().to_owned());
        args
    }

    fn wants_hvc1_tag(&self) -> bool {
        self.output
            .extension()
            .and_then(|e| e.to_str())
            .is_some_and(|ext| HVC1_CONTAINERS.iter().any(|c| c.eq_ignore_ascii_case(ext)))
    }
}

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

    #[test]
    fn cq_is_relaxed_for_low_bitrates() {
        assert_eq!(effective_cq(30, 0), 30);
        assert_eq!(effective_cq(30, 2999), 34);
        assert_eq!(effective_cq(30, 3000), 32);
        assert_eq!(effective_cq(30, 5999), 32);
        assert_eq!(effective_cq(30, 6000), 30);
    }

    #[test]
    fn cq_never_exceeds_the_maximum() {
        assert_eq!(effective_cq(50, 1000), 51);
        assert_eq!(effective_cq(51, 1000), 51);
    }

    #[test]
    fn fps_tolerance() {
        assert!(!fps_change_needed(30.0, 30.0));
        assert!(!fps_change_needed(29.97, 30.0));
        assert!(fps_change_needed(60.0, 30.0));
        // An unknown source rate (0) always triggers a change.
        assert!(fps_change_needed(0.0, 30.0));
    }

    fn plan<'a>(input: &'a PathBuf, output: &'a PathBuf) -> Plan<'a> {
        Plan {
            encoder: Encoder::Software,
            input,
            output,
            cq: 30,
            filters: vec![],
            force_cfr: false,
        }
    }

    fn strings(args: &[OsString]) -> Vec<String> {
        args.iter()
            .map(|a| a.to_string_lossy().into_owned())
            .collect()
    }

    #[test]
    fn basic_args_end_with_overwrite_and_output() {
        let (i, o) = (PathBuf::from("in.mp4"), PathBuf::from("out.mp4"));
        let args = strings(&plan(&i, &o).ffmpeg_args());
        assert_eq!(&args[args.len() - 2..], ["-y", "out.mp4"]);
        assert!(args.windows(2).any(|w| w == ["-i", "in.mp4"]));
        assert!(args.windows(2).any(|w| w == ["-tag:v", "hvc1"]));
        assert!(!args.contains(&"-vf".to_string()));
        assert!(!args.contains(&"-fps_mode".to_string()));
    }

    #[test]
    fn filters_and_cfr_are_included() {
        let (i, o) = (PathBuf::from("in.mp4"), PathBuf::from("out.mp4"));
        let mut p = plan(&i, &o);
        p.filters = vec!["hqdn3d=3:3:8:8".into(), "fps=30".into()];
        p.force_cfr = true;
        let args = strings(&p.ffmpeg_args());
        assert!(
            args.windows(2)
                .any(|w| w == ["-vf", "hqdn3d=3:3:8:8,fps=30"])
        );
        let cfr = args.iter().position(|a| a == "-fps_mode").unwrap();
        assert_eq!(args[cfr + 1], "cfr");
        // Output options must come before the output path.
        assert!(cfr < args.len() - 1);
    }

    #[test]
    fn hvc1_tag_is_skipped_for_matroska() {
        let (i, o) = (PathBuf::from("in.mkv"), PathBuf::from("out.mkv"));
        let args = strings(&plan(&i, &o).ffmpeg_args());
        assert!(!args.contains(&"-tag:v".to_string()));
    }
}