use std::fs;
use std::path::Path;
use std::process::{Command, Stdio};
use owo_colors::OwoColorize;
use crate::cli::{AudioCodec, Codec};
use crate::encoder::Encoder;
use crate::job::{self, Plan};
use crate::probe::{self, DurationOrigin, VideoInfo};
use crate::progress::ProgressBars;
const MAX_ERROR_LINES: usize = 15;
pub(super) const BYTES_PER_MB: f64 = 1024.0 * 1024.0;
pub fn estimate_output_size(
input_bytes: u64,
source_bitrate_kbps: u32,
cq: u8,
codec: Codec,
source_width: u32,
target_width: Option<u32>,
source_fps: f64,
target_fps: Option<f64>,
) -> u64 {
if source_bitrate_kbps == 0 {
return input_bytes; }
let diff = cq as i16 - 26;
let cq_factor = (1.0 - (diff as f64 * 0.1)).clamp(0.1, 5.0);
let codec_factor = match codec {
Codec::Hevc => 0.8,
Codec::Av1 => 0.6,
};
let scale_factor = match target_width {
Some(tw) if source_width > 0 => (tw as f64 / source_width as f64).powi(2),
_ => 1.0,
};
let fps_factor = match target_fps {
Some(tf) if source_fps > 0.0 => tf / source_fps,
_ => 1.0,
};
let correction = 2.0;
(input_bytes as f64 * cq_factor * codec_factor * scale_factor * fps_factor * correction) as u64
}
pub(super) struct Settings {
pub(super) codec: Codec,
pub(super) encoder: Encoder,
pub(super) cq: u8,
pub(super) audio_codec: AudioCodec,
pub(super) no_audio: bool,
pub(super) dynamic_cq: bool,
pub(super) target_fps: Option<f64>,
pub(super) denoise: bool,
pub(super) skip_same_codec: bool,
pub(super) skip_existing: bool,
pub(super) scale: Option<u32>,
pub(super) dry_run: bool,
}
pub(super) enum Outcome {
Converted { input_bytes: u64, output_bytes: u64 },
Skipped,
Failed,
}
struct FileLog<'a> {
progress: &'a ProgressBars,
lines: Vec<String>,
}
impl<'a> FileLog<'a> {
fn new(progress: &'a ProgressBars) -> Self {
Self {
progress,
lines: Vec::new(),
}
}
fn println(&mut self, message: impl Into<String>) {
self.lines.push(message.into());
}
}
impl Drop for FileLog<'_> {
fn drop(&mut self) {
if !self.lines.is_empty() {
self.progress.println(self.lines.join("\n"));
}
}
}
pub(super) fn convert_one(
settings: &Settings,
input: &Path,
input_root: &Path,
output_dir: &Path,
progress: &ProgressBars,
file_name: &std::borrow::Cow<'_, str>,
) -> Outcome {
let mut log = FileLog::new(progress);
let output = match output_path(input, input_root, output_dir) {
Some(path) => path,
None => {
log.println(format!(
" {} cannot determine path relative to input",
"[ERR]".red()
));
return Outcome::Failed;
}
};
if !settings.dry_run
&& let Some(parent) = output.parent()
&& let Err(err) = fs::create_dir_all(parent)
{
log.println(format!(
" {} cannot create output directory {}: {err}",
"[ERR]".red(),
parent.display()
));
return Outcome::Failed;
}
log.println(format!("\n{} {}", "[>>]".cyan(), file_name.cyan()));
let info = match probe::probe(input) {
Ok(info) => info,
Err(err) => {
log.println(format!(" {} {err:#}", "[ERR]".red()));
return Outcome::Failed;
}
};
log.println(
format!(
" Codec: {} | Bitrate: {} kbps | FPS: {} | Duration: {:.1}s",
info.codec, info.bitrate_kbps, info.fps, info.duration
)
.bright_black()
.to_string(),
);
let target_codec_name = match settings.codec {
Codec::Hevc => "hevc",
Codec::Av1 => "av1",
};
if settings.skip_same_codec && info.codec == target_codec_name {
log.println(format!(
" {}",
format!("[SKIP] Already {target_codec_name}.").yellow()
));
return Outcome::Skipped;
}
if settings.skip_existing && output.exists() {
log.println(format!(" {}", "[SKIP] Output already exists.".yellow()));
return Outcome::Skipped;
}
let mut cq = settings.cq;
if settings.dynamic_cq {
cq = job::calculate_cq(settings.cq, info.bitrate_kbps);
}
log.println(
format!(
" CQ: {cq} (normalized){}",
if settings.dynamic_cq && cq != settings.cq {
format!(" (dynamic: {} -> {})", settings.cq, cq)
.bright_black()
.to_string()
} else {
"".to_string()
}
)
.bright_black()
.to_string(),
);
let mut filters = Vec::new();
let mut force_cfr = false;
if settings.denoise {
filters.push("hqdn3d=3:3:8:8".to_string());
}
if let Some(scale) = settings.scale {
filters.push(format!("scale={scale}:-2:flags=bicubic"));
}
if let Some(target) = settings.target_fps {
if job::fps_change_needed(info.fps, target) {
filters.push(format!("fps={target}"));
force_cfr = true;
log.println(
format!(" FPS: {} -> {target}", info.fps)
.bright_black()
.to_string(),
);
} else {
log.println(
format!(" FPS: already {}, no change", info.fps)
.bright_black()
.to_string(),
);
}
}
let plan = Plan {
codec: settings.codec,
encoder: settings.encoder,
input,
output: &output,
cq,
audio_codec: settings.audio_codec,
no_audio: settings.no_audio,
filters,
force_cfr,
};
let args = plan.ffmpeg_args();
if settings.dry_run {
let rendered: Vec<String> = args.iter().map(|a| quote(&a.to_string_lossy())).collect();
log.println(format!(
" {} ffmpeg {}",
"[DRY]".magenta(),
rendered.join(" ")
));
let input_bytes = fs::metadata(input).map(|m| m.len()).unwrap_or(0);
let estimated_bytes = estimate_output_size(
input_bytes,
info.bitrate_kbps,
cq,
settings.codec,
info.width,
settings.scale,
info.fps,
settings.target_fps,
);
log.println(
format!(
" {} estimated size: {:.1} MB -> {:.1} MB",
"[EST]".bright_black(),
input_bytes as f64 / BYTES_PER_MB,
estimated_bytes as f64 / BYTES_PER_MB
)
.bright_black()
.to_string(),
);
return Outcome::Converted {
input_bytes,
output_bytes: estimated_bytes,
};
}
let mut child = match Command::new("ffmpeg")
.args(&args)
.stdin(Stdio::null())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(err) => {
log.println(format!(
" {} could not start ffmpeg: {err}",
"[ERR]".red()
));
return Outcome::Failed;
}
};
let mut stderr = match child.stderr.take() {
Some(s) => s,
None => {
log.println(format!(" {} failed to take stderr", "[ERR]".red()));
return Outcome::Failed;
}
};
let mut buffer = [0u8; 8192];
let mut total_err_output = String::new();
let mut pending_progress = String::new();
let file_progress = progress.file(info.duration, file_name);
use std::io::Read;
loop {
let bytes_read = stderr.read(&mut buffer).unwrap_or(0);
if bytes_read == 0 {
break;
}
let chunk = String::from_utf8_lossy(&buffer[..bytes_read]);
total_err_output.push_str(&chunk);
pending_progress.push_str(&chunk);
while let Some(newline) = pending_progress.find('\n') {
let line = pending_progress[..newline].trim().to_owned();
pending_progress.drain(..=newline);
if let Some(time) = line.strip_prefix("out_time=") {
if let Some(seconds) = parse_ffmpeg_time(time.trim()) {
if let Some(ref p) = file_progress {
p.set_seconds(seconds);
}
}
}
}
}
if let Some(p) = file_progress {
p.finish();
}
let status = match child.wait() {
Ok(s) => s,
Err(err) => {
log.println(format!(
" {} failed to wait for ffmpeg: {err}",
"[ERR]".red()
));
return Outcome::Failed;
}
};
if status.success() {
if let Ok(out_info) = probe::probe(&output) {
if duration_mismatch(&info, &out_info) {
log.println(format!(
" {} output duration {:.1}s differs from source {:.1}s, something went wrong",
"[ERR]".red(),
out_info.duration,
info.duration
));
let _ = fs::remove_file(&output);
return Outcome::Failed;
}
} else {
log.println(format!(
" {} could not probe output for validation",
"[ERR]".red()
));
let _ = fs::remove_file(&output);
return Outcome::Failed;
}
let input_bytes = fs::metadata(input).map(|m| m.len()).unwrap_or(0);
let output_bytes = fs::metadata(&output).map(|m| m.len()).unwrap_or(0);
let ratio = if input_bytes > 0 {
output_bytes as f64 / input_bytes as f64 * 100.0
} else {
0.0
};
let line = format!(
" [OK] {:.1} MB -> {:.1} MB ({:.1}%)",
input_bytes as f64 / BYTES_PER_MB,
output_bytes as f64 / BYTES_PER_MB,
ratio
);
if ratio <= 100.0 {
log.println(line.green().to_string());
} else {
log.println(line.yellow().to_string());
}
Outcome::Converted {
input_bytes,
output_bytes,
}
} else {
log.println(format!(" {}", "[ERR] Conversion failed".red()));
let lines: Vec<_> = total_err_output.lines().collect();
for line in lines.iter().rev().take(MAX_ERROR_LINES).rev() {
log.println(format!(" {}", line.bright_black()));
}
let _ = fs::remove_file(&output);
Outcome::Failed
}
}
fn duration_mismatch(source: &VideoInfo, output: &VideoInfo) -> bool {
source.duration_origin != DurationOrigin::Unknown
&& source.duration_origin == output.duration_origin
&& source.duration.is_finite()
&& output.duration.is_finite()
&& source.duration > 0.0
&& output.duration > 0.0
&& (output.duration - source.duration).abs() > 0.5
}
pub(super) fn output_path(
input: &Path,
input_root: &Path,
output_root: &Path,
) -> Option<std::path::PathBuf> {
input
.strip_prefix(input_root)
.ok()
.map(|relative_path| output_root.join(relative_path))
}
fn parse_ffmpeg_time(s: &str) -> Option<f64> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 3 {
return None;
}
let h: f64 = parts[0].parse().ok()?;
let m: f64 = parts[1].parse().ok()?;
let s: f64 = parts[2].parse().ok()?;
Some(h * 3600.0 + m * 60.0 + s)
}
pub(super) fn quote(arg: &str) -> String {
let needs_quotes = arg.is_empty()
|| arg
.chars()
.any(|c| c.is_whitespace() || "\"'$&|;<>()*?[]{}!\\`".contains(c));
if needs_quotes {
format!("\"{}\"", arg.replace('"', "\\\""))
} else {
arg.to_string()
}
}
#[cfg(test)]
mod tests {
use super::{duration_mismatch, quote};
use crate::probe::{DurationOrigin, VideoInfo};
fn info(duration: f64, duration_origin: DurationOrigin) -> VideoInfo {
VideoInfo {
codec: String::new(),
bitrate_kbps: 0,
fps: 0.0,
duration,
duration_origin,
width: 0,
height: 0,
}
}
#[test]
fn quote_leaves_plain_arguments_alone() {
assert_eq!(quote("-hide_banner"), "-hide_banner");
assert_eq!(quote("0:a?"), "\"0:a?\"");
}
#[test]
fn quote_wraps_arguments_with_spaces() {
assert_eq!(quote("my video.mp4"), "\"my video.mp4\"");
assert_eq!(quote(""), "\"\"");
}
#[test]
fn duration_validation_requires_valid_matching_scopes() {
let stream_source = info(10.0, DurationOrigin::Stream);
assert!(!duration_mismatch(
&stream_source,
&info(10.6, DurationOrigin::Format)
));
assert!(!duration_mismatch(
&stream_source,
&info(0.0, DurationOrigin::Unknown)
));
assert!(!duration_mismatch(
&info(0.0, DurationOrigin::Unknown),
&info(20.0, DurationOrigin::Stream)
));
assert!(duration_mismatch(
&stream_source,
&info(10.6, DurationOrigin::Stream)
));
assert!(!duration_mismatch(
&stream_source,
&info(10.5, DurationOrigin::Stream)
));
}
}