use std::ffi::OsString;
use std::path::Path;
use crate::encoder::Encoder;
const FPS_TOLERANCE: f64 = 0.5;
const MAX_CQ: u8 = 51;
const HVC1_CONTAINERS: [&str; 3] = ["mp4", "m4v", "mov"];
pub fn effective_cq(base: u8, source_kbps: u32) -> u8 {
let bump = match source_kbps {
0 => 0, 1..=2999 => 4,
3000..=5999 => 2,
_ => 0,
};
base.saturating_add(bump).min(MAX_CQ)
}
pub fn fps_change_needed(source: f64, target: f64) -> bool {
(source - target).abs() > FPS_TOLERANCE
}
pub struct Plan<'a> {
pub encoder: Encoder,
pub input: &'a Path,
pub output: &'a Path,
pub cq: u8,
pub filters: Vec<String>,
pub force_cfr: bool,
}
impl Plan<'_> {
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));
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");
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()));
}
}