use anyhow::{bail, Context, Result};
use std::io::BufRead;
use std::os::unix::fs::{chown, MetadataExt};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::config::TargetConfig;
use crate::gpu::{GpuInfo, GpuKind};
use crate::probe;
use crate::util::format_size;
pub const MARKER_SUFFIX: &str = ".hvac.complete";
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct CompletionMarker {
pub source_size: u64,
pub duration_secs: f64,
pub completed_at: String,
}
pub fn marker_path(output: &Path) -> PathBuf {
let mut s = output.as_os_str().to_owned();
s.push(MARKER_SUFFIX);
PathBuf::from(s)
}
pub fn write_marker(output: &Path, source_size: u64, duration_secs: f64) -> Result<()> {
let marker = CompletionMarker {
source_size,
duration_secs,
completed_at: iso8601_now(),
};
let path = marker_path(output);
let json = serde_json::to_string(&marker).context("serialize completion marker")?;
std::fs::write(&path, json).with_context(|| format!("write completion marker {:?}", path))?;
Ok(())
}
pub fn read_marker(output: &Path) -> Option<CompletionMarker> {
let path = marker_path(output);
let bytes = std::fs::read(&path).ok()?;
serde_json::from_slice::<CompletionMarker>(&bytes).ok()
}
pub fn marker_valid_for_source(output: &Path, source: &Path) -> bool {
if !output.exists() {
return false;
}
let Some(marker) = read_marker(output) else {
return false;
};
let Ok(meta) = std::fs::metadata(source) else {
return false;
};
marker.source_size == meta.len()
}
pub fn remove_marker(output: &Path) {
let _ = std::fs::remove_file(marker_path(output));
}
fn iso8601_now() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let (y, mo, d, h, mi, s) = unix_to_ymdhms(secs);
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, mo, d, h, mi, s)
}
fn unix_to_ymdhms(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400) as u32;
let h = tod / 3600;
let mi = (tod % 3600) / 60;
let s = tod % 60;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097) as u32;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let year = if mo <= 2 { y + 1 } else { y };
(year as i32, mo, d, h, mi, s)
}
#[derive(Debug, Clone, Default)]
pub struct ColorMetadata {
pub color_primaries: Option<String>,
pub color_transfer: Option<String>,
pub color_space: Option<String>,
pub color_range: Option<String>,
pub master_display: Option<String>,
pub max_cll: Option<String>,
}
impl ColorMetadata {
pub fn from_media_info(info: &probe::MediaInfo) -> Self {
ColorMetadata {
color_primaries: info.color_primaries.clone(),
color_transfer: info.color_transfer.clone(),
color_space: info.color_space.clone(),
color_range: info.color_range.clone(),
master_display: info.master_display.clone(),
max_cll: info.max_cll.clone(),
}
}
}
fn build_color_args(color: &ColorMetadata, gpu_kind: &GpuKind) -> Vec<String> {
let mut args = Vec::new();
if let Some(v) = color.color_primaries.as_deref() {
args.push("-color_primaries".to_string());
args.push(v.to_string());
}
if let Some(v) = color.color_transfer.as_deref() {
args.push("-color_trc".to_string());
args.push(v.to_string());
}
if let Some(v) = color.color_space.as_deref() {
args.push("-colorspace".to_string());
args.push(v.to_string());
}
if let Some(v) = color.color_range.as_deref() {
args.push("-color_range".to_string());
args.push(v.to_string());
}
if matches!(gpu_kind, GpuKind::Nvidia) {
if let Some(v) = color.master_display.as_deref() {
args.push("-master_display".to_string());
args.push(v.to_string());
}
if let Some(v) = color.max_cll.as_deref() {
args.push("-max_cll".to_string());
args.push(v.to_string());
}
}
args
}
fn append_color_args(cmd: &mut Command, color: &ColorMetadata, gpu_kind: &GpuKind) {
for a in build_color_args(color, gpu_kind) {
cmd.arg(a);
}
}
struct ChildGuard(Child);
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
pub fn output_path(source: &Path, output_dir: Option<&Path>, container: &str) -> Result<PathBuf> {
let stem = source
.file_stem()
.context("source file has no stem")?
.to_string_lossy();
if let Some(dir) = output_dir {
std::fs::create_dir_all(dir)?;
Ok(dir.join(format!("{}.{}", stem, container)))
} else {
let parent = source.parent().context("source has no parent directory")?;
Ok(parent.join(format!("{}.transcoded.{}", stem, container)))
}
}
pub fn output_already_valid(output: &Path, source: &Path, source_duration_secs: f64) -> bool {
if !output.exists() {
return false;
}
validate_output(output, source, source_duration_secs).is_ok()
}
#[allow(clippy::too_many_arguments)]
pub fn transcode(
source: &Path,
output: Option<&Path>,
target: &TargetConfig,
gpu: &GpuInfo,
source_bitrate_kbps: u32,
source_duration_secs: f64,
source_pix_fmt: &str,
color: &ColorMetadata,
progress: Option<&AtomicU64>,
speed: Option<&AtomicU64>,
skip_subs: bool,
force_reencode_audio: bool,
subtitle_codec_override: Option<&str>,
) -> Result<PathBuf> {
let final_output = match output {
Some(p) => p.to_path_buf(),
None => {
let parent = source.parent().context("source has no parent")?;
parent.join(format!(
".hvac_tmp_{}.{}",
source.file_stem().unwrap_or_default().to_string_lossy(),
target.container
))
}
};
let source_size = std::fs::metadata(source).map(|m| m.len()).unwrap_or(0);
let mut cmd = Command::new("ffmpeg");
cmd.args(["-hide_banner", "-y"]);
cmd.args(["-i"]).arg(source);
let is_10bit = probe::is_10bit(source_pix_fmt);
match gpu.kind {
GpuKind::Nvidia => {
cmd.args(["-c:v", "hevc_nvenc"]);
let nvenc_preset = match target.preset.as_str() {
"slow" | "slower" | "veryslow" => "p7",
"medium" => "p4",
"fast" | "faster" | "veryfast" => "p1",
other => other,
};
cmd.args(["-preset", nvenc_preset]);
cmd.args(["-rc", "vbr"]);
cmd.args(["-cq", &target.quality.to_string()]);
if source_bitrate_kbps > 0 {
cmd.args(["-maxrate", &format!("{}k", source_bitrate_kbps)]);
cmd.args(["-bufsize", &format!("{}k", source_bitrate_kbps * 2)]);
}
cmd.args(["-b:v", "0"]);
if is_10bit {
cmd.args(["-pix_fmt", "p010le"]);
}
}
GpuKind::Intel => {
cmd.args(["-vaapi_device", "/dev/dri/renderD128"]);
cmd.args(["-c:v", "hevc_vaapi"]);
cmd.args(["-global_quality", &target.quality.to_string()]);
if source_bitrate_kbps > 0 {
cmd.args(["-maxrate", &format!("{}k", source_bitrate_kbps)]);
cmd.args(["-bufsize", &format!("{}k", source_bitrate_kbps * 2)]);
}
if is_10bit {
cmd.args(["-vf", "format=p010le,hwupload"]);
} else {
cmd.args(["-vf", "format=nv12,hwupload"]);
}
}
GpuKind::Apple => {
cmd.args(["-c:v", "hevc_videotoolbox"]);
cmd.args(["-q:v", &target.quality.to_string()]);
if source_bitrate_kbps > 0 {
cmd.args(["-maxrate", &format!("{}k", source_bitrate_kbps)]);
cmd.args(["-bufsize", &format!("{}k", source_bitrate_kbps * 2)]);
}
}
}
append_color_args(&mut cmd, color, &gpu.kind);
let audio_codec = if force_reencode_audio && target.audio_codec == "copy" {
AUDIO_REENCODE_FALLBACK
} else {
target.audio_codec.as_str()
};
cmd.args(["-c:a", audio_codec]);
apply_subtitle_args(&mut cmd, target, skip_subs, subtitle_codec_override);
cmd.args(["-map", "0:v:0"]);
cmd.args(["-map", "0:a?"]);
if !skip_subs {
cmd.args(["-map", "0:s?"]);
}
if progress.is_some() {
cmd.args(["-progress", "pipe:1", "-nostats"]);
}
cmd.arg(&final_output);
log::debug!(
"Running{}{}{}: {:?}",
if skip_subs { " (no subs)" } else { "" },
if force_reencode_audio {
" (audio re-encode)"
} else {
""
},
if let Some(c) = subtitle_codec_override {
format!(" (sub re-encode {})", c)
} else {
String::new()
},
cmd
);
cmd.stderr(Stdio::piped());
if progress.is_some() {
cmd.stdout(Stdio::piped());
} else {
cmd.stdout(Stdio::null());
}
let mut guard = ChildGuard(cmd.spawn().context("Failed to execute ffmpeg")?);
let stderr = guard.0.stderr.take().unwrap();
let stderr_handle = std::thread::spawn(move || {
let reader = std::io::BufReader::new(stderr);
let mut buf = String::new();
for line in reader.lines().map_while(Result::ok) {
for part in line.split('\r') {
let trimmed = part.trim_end();
if !trimmed.is_empty() {
buf.push_str(trimmed);
buf.push('\n');
}
}
}
buf
});
if let Some(prog) = progress {
let stdout = guard.0.stdout.take().unwrap();
let reader = std::io::BufReader::new(stdout);
let duration_us = (source_duration_secs * 1_000_000.0) as i64;
for line in reader.lines().map_while(Result::ok) {
if let Some(time_str) = line.strip_prefix("out_time_us=") {
if let Ok(us) = time_str.parse::<i64>() {
if duration_us > 0 && us > 0 {
let pos =
((us as f64 / duration_us as f64) * 1000.0).clamp(0.0, 1000.0) as u64;
prog.store(pos, Ordering::Relaxed);
}
}
} else if let Some(speed_str) = line.strip_prefix("speed=") {
if let Some(spd) = speed {
let trimmed = speed_str.trim_end_matches('x');
if let Ok(v) = trimmed.parse::<f64>() {
spd.store((v * 100.0) as u64, Ordering::Relaxed);
}
}
}
}
}
let status = guard.0.wait().context("Failed to wait for ffmpeg")?;
let stderr_output = stderr_handle.join().unwrap_or_default();
std::mem::forget(guard);
if !status.success() {
let _ = std::fs::remove_file(&final_output);
let context = summarize_ffmpeg_error(&stderr_output);
bail!("ffmpeg failed ({}): {}", status, context);
}
if let Err(e) = validate_output(&final_output, source, source_duration_secs) {
let _ = std::fs::remove_file(&final_output);
bail!("Output validation failed: {}", e);
}
copy_permissions(source, &final_output)?;
let output_size = std::fs::metadata(&final_output)
.map(|m| m.len())
.unwrap_or(0);
if source_size > 0 && output_size > 0 {
let saved = source_size as i64 - output_size as i64;
let pct = (saved as f64 / source_size as f64) * 100.0;
log::debug!(
"Size: {} -> {} ({:+.1}%)",
format_size(source_size),
format_size(output_size),
-pct,
);
}
if output.is_none() {
std::fs::rename(&final_output, source)
.context("Failed to replace original file with transcoded version")?;
let final_size = std::fs::metadata(source).map(|m| m.len()).unwrap_or(0);
if let Err(e) = write_marker(source, final_size, source_duration_secs) {
log::warn!("Failed to write completion marker for {:?}: {}", source, e);
}
return Ok(source.to_path_buf());
}
if let Err(e) = write_marker(&final_output, source_size, source_duration_secs) {
log::warn!(
"Failed to write completion marker for {:?}: {}",
final_output,
e
);
}
Ok(final_output)
}
#[allow(clippy::too_many_arguments)]
pub fn transcode_iso(
iso_path: &Path,
inner_paths: &[String],
output: &Path,
target: &TargetConfig,
gpu: &GpuInfo,
source_bitrate_kbps: u32,
source_duration_secs: f64,
source_pix_fmt: &str,
color: &ColorMetadata,
progress: Option<&AtomicU64>,
speed: Option<&AtomicU64>,
skip_subs: bool,
force_reencode_audio: bool,
subtitle_codec_override: Option<&str>,
) -> Result<PathBuf> {
let final_output = output.to_path_buf();
let mut cmd = Command::new("ffmpeg");
cmd.args(["-hide_banner", "-y"]);
cmd.args(["-i", "pipe:0"]);
let is_10bit = probe::is_10bit(source_pix_fmt);
match gpu.kind {
GpuKind::Nvidia => {
cmd.args(["-c:v", "hevc_nvenc"]);
let nvenc_preset = match target.preset.as_str() {
"slow" | "slower" | "veryslow" => "p7",
"medium" => "p4",
"fast" | "faster" | "veryfast" => "p1",
other => other,
};
cmd.args(["-preset", nvenc_preset]);
cmd.args(["-rc", "vbr"]);
cmd.args(["-cq", &target.quality.to_string()]);
if source_bitrate_kbps > 0 {
cmd.args(["-maxrate", &format!("{}k", source_bitrate_kbps)]);
cmd.args(["-bufsize", &format!("{}k", source_bitrate_kbps * 2)]);
}
cmd.args(["-b:v", "0"]);
if is_10bit {
cmd.args(["-pix_fmt", "p010le"]);
}
}
GpuKind::Intel => {
cmd.args(["-vaapi_device", "/dev/dri/renderD128"]);
cmd.args(["-c:v", "hevc_vaapi"]);
cmd.args(["-global_quality", &target.quality.to_string()]);
if source_bitrate_kbps > 0 {
cmd.args(["-maxrate", &format!("{}k", source_bitrate_kbps)]);
cmd.args(["-bufsize", &format!("{}k", source_bitrate_kbps * 2)]);
}
if is_10bit {
cmd.args(["-vf", "format=p010le,hwupload"]);
} else {
cmd.args(["-vf", "format=nv12,hwupload"]);
}
}
GpuKind::Apple => {
cmd.args(["-c:v", "hevc_videotoolbox"]);
cmd.args(["-q:v", &target.quality.to_string()]);
if source_bitrate_kbps > 0 {
cmd.args(["-maxrate", &format!("{}k", source_bitrate_kbps)]);
cmd.args(["-bufsize", &format!("{}k", source_bitrate_kbps * 2)]);
}
}
}
append_color_args(&mut cmd, color, &gpu.kind);
let audio_codec = if force_reencode_audio && target.audio_codec == "copy" {
AUDIO_REENCODE_FALLBACK
} else {
target.audio_codec.as_str()
};
cmd.args(["-c:a", audio_codec]);
apply_subtitle_args(&mut cmd, target, skip_subs, subtitle_codec_override);
cmd.args(["-map", "0:v:0"]);
cmd.args(["-map", "0:a?"]);
if !skip_subs {
cmd.args(["-map", "0:s?"]);
}
if progress.is_some() {
cmd.args(["-progress", "pipe:1", "-nostats"]);
}
cmd.arg(&final_output);
log::debug!(
"Running (piped from ISO{}{}{}): {:?}",
if skip_subs { ", no subs" } else { "" },
if force_reencode_audio {
", audio re-encode"
} else {
""
},
if let Some(c) = subtitle_codec_override {
format!(", sub re-encode {}", c)
} else {
String::new()
},
cmd
);
cmd.stderr(Stdio::piped());
cmd.stdin(Stdio::piped());
if progress.is_some() {
cmd.stdout(Stdio::piped());
} else {
cmd.stdout(Stdio::null());
}
let mut guard = ChildGuard(cmd.spawn().context("Failed to execute ffmpeg")?);
let stderr = guard.0.stderr.take().unwrap();
let stdin = guard.0.stdin.take().unwrap();
let iso = iso_path.to_path_buf();
let paths = inner_paths.to_vec();
let stdin_handle = std::thread::spawn(move || {
let mut stdin = stdin;
let _ = crate::iso::cat_files(&iso, &paths, &mut stdin);
});
let stderr_handle = std::thread::spawn(move || {
let reader = std::io::BufReader::new(stderr);
let mut buf = String::new();
for line in reader.lines().map_while(Result::ok) {
for part in line.split('\r') {
let trimmed = part.trim_end();
if !trimmed.is_empty() {
buf.push_str(trimmed);
buf.push('\n');
}
}
}
buf
});
if let Some(prog) = progress {
let stdout = guard.0.stdout.take().unwrap();
let reader = std::io::BufReader::new(stdout);
let duration_us = (source_duration_secs * 1_000_000.0) as i64;
for line in reader.lines().map_while(Result::ok) {
if let Some(time_str) = line.strip_prefix("out_time_us=") {
if let Ok(us) = time_str.parse::<i64>() {
if duration_us > 0 && us > 0 {
let pos =
((us as f64 / duration_us as f64) * 1000.0).clamp(0.0, 1000.0) as u64;
prog.store(pos, Ordering::Relaxed);
}
}
} else if let Some(speed_str) = line.strip_prefix("speed=") {
if let Some(spd) = speed {
let trimmed = speed_str.trim_end_matches('x');
if let Ok(v) = trimmed.parse::<f64>() {
spd.store((v * 100.0) as u64, Ordering::Relaxed);
}
}
}
}
}
let status = guard.0.wait().context("Failed to wait for ffmpeg")?;
let stderr_output = stderr_handle.join().unwrap_or_default();
let _ = stdin_handle.join();
std::mem::forget(guard);
if !status.success() {
let _ = std::fs::remove_file(&final_output);
let context = summarize_ffmpeg_error(&stderr_output);
bail!("ffmpeg failed ({}): {}", status, context);
}
let out_meta = std::fs::metadata(&final_output).context("Output file does not exist")?;
if out_meta.len() == 0 {
let _ = std::fs::remove_file(&final_output);
bail!("Output file is empty");
}
let out_info = probe::probe_file(&final_output).context("ffprobe cannot read output file")?;
if out_info.codec == "unknown" {
let _ = std::fs::remove_file(&final_output);
bail!("Output has no recognizable video codec");
}
if source_duration_secs > 0.0 && out_info.duration_secs > 0.0 {
let diff = (source_duration_secs - out_info.duration_secs).abs();
if diff > 5.0 {
let _ = std::fs::remove_file(&final_output);
bail!(
"Duration mismatch: source {:.1}s vs output {:.1}s (diff {:.1}s)",
source_duration_secs,
out_info.duration_secs,
diff
);
}
}
log::debug!(
"ISO transcode done: {} codec, {:.1}s, {} bytes",
out_info.codec,
out_info.duration_secs,
out_meta.len()
);
let iso_size = std::fs::metadata(iso_path).map(|m| m.len()).unwrap_or(0);
if let Err(e) = write_marker(&final_output, iso_size, source_duration_secs) {
log::warn!(
"Failed to write completion marker for {:?}: {}",
final_output,
e
);
}
Ok(final_output)
}
pub fn output_size(path: &Path) -> u64 {
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
}
fn copy_permissions(source: &Path, dest: &Path) -> Result<()> {
let src_meta = std::fs::metadata(source).context("Failed to read source metadata")?;
std::fs::set_permissions(dest, src_meta.permissions())
.context("Failed to set file permissions")?;
let uid = src_meta.uid();
let gid = src_meta.gid();
if let Err(e) = chown(dest, Some(uid), Some(gid)) {
log::debug!("Could not chown {:?}: {} (requires root)", dest, e);
}
Ok(())
}
fn validate_output(output: &Path, source: &Path, source_duration_secs: f64) -> Result<()> {
let out_meta = std::fs::metadata(output).context("Output file does not exist")?;
if out_meta.len() == 0 {
bail!("Output file is empty");
}
let src_meta = std::fs::metadata(source).context("Source file disappeared")?;
if out_meta.len() < src_meta.len() / 100 {
bail!(
"Output file is suspiciously small ({} bytes vs {} bytes source)",
out_meta.len(),
src_meta.len()
);
}
let out_info = probe::probe_file(output).context("ffprobe cannot read output file")?;
if out_info.codec == "unknown" {
bail!("Output has no recognizable video codec");
}
if source_duration_secs > 0.0 && out_info.duration_secs > 0.0 {
let diff = (source_duration_secs - out_info.duration_secs).abs();
if diff > 5.0 {
bail!(
"Duration mismatch: source {:.1}s vs output {:.1}s (diff {:.1}s)",
source_duration_secs,
out_info.duration_secs,
diff
);
}
}
log::debug!(
"Validation passed: {} codec, {:.1}s duration, {} bytes",
out_info.codec,
out_info.duration_secs,
out_meta.len()
);
Ok(())
}
const AUDIO_REENCODE_FALLBACK: &str = "aac";
pub fn subtitle_reencode_fallback(container: &str) -> &'static str {
match container {
"mp4" | "m4v" | "mov" => "mov_text",
_ => "srt",
}
}
fn apply_subtitle_args(
cmd: &mut Command,
target: &TargetConfig,
skip_subs: bool,
subtitle_codec_override: Option<&str>,
) {
if skip_subs {
return;
}
if let Some(codec) = subtitle_codec_override {
cmd.args(["-c:s", codec]);
return;
}
if target.subtitle_codec == "copy" {
cmd.args(["-c:s", "copy"]);
} else if !target.subtitle_codec.is_empty() {
cmd.args(["-c:s", target.subtitle_codec.as_str()]);
}
}
pub fn is_session_limit_error(error_msg: &str) -> bool {
error_msg.contains("out of memory")
|| error_msg.contains("InitializeEncoder failed")
|| error_msg.contains("Cannot init NVENC")
|| error_msg.contains("OpenEncodeSessionEx failed")
|| error_msg.contains("No capable devices found")
|| error_msg.contains("Nothing was written into output file")
}
pub fn is_subtitle_error(error_msg: &str) -> bool {
let lower = error_msg.to_lowercase();
lower.contains("subtitle encoding currently only possible from text to text or bitmap to bitmap")
|| lower.contains("subtitle codec not supported")
|| lower.contains("subtitle codec") && lower.contains("is not supported")
|| lower.contains("error while opening encoder for output stream")
&& lower.contains("subtitle")
|| lower.contains("could not find tag for codec")
&& (lower.contains("subtitle") || lower.contains("hdmv_pgs"))
|| lower.contains("unknown encoder") && lower.contains("subtitle")
|| lower.contains("codec not currently supported in container")
}
pub fn is_disk_space_error(error_msg: &str) -> bool {
error_msg.contains("Disk quota exceeded")
|| error_msg.contains("No space left on device")
|| error_msg.contains("ENOSPC")
}
pub fn is_audio_copy_error(error_msg: &str) -> bool {
let lower = error_msg.to_lowercase();
lower.contains("no wav codec tag found")
|| (lower.contains("could not write header")
&& lower.contains("incorrect codec parameters"))
}
fn summarize_ffmpeg_error(stderr: &str) -> String {
let lines: Vec<&str> = stderr
.lines()
.map(|l| l.trim_end())
.filter(|l| !l.is_empty())
.collect();
let is_noise = |l: &str| {
let t = l.trim_start();
t.starts_with("frame=")
|| t.starts_with("size=")
|| t.starts_with("Last message repeated")
|| t == "Conversion failed!"
};
let is_error = |l: &str| {
let lower = l.to_lowercase();
lower.contains("error")
|| lower.contains("invalid")
|| lower.contains("could not")
|| lower.contains("failed")
|| lower.contains("cannot")
|| lower.contains("not supported")
|| lower.contains("unsupported")
|| lower.contains("no wav codec")
|| lower.contains("no such")
|| lower.contains("incorrect codec")
|| lower.contains("nothing was written")
};
fn body(line: &str) -> &str {
if let Some(rest) = line.strip_prefix('[') {
if let Some(end) = rest.find("] ") {
return rest[end + 2..].trim_start();
}
}
line
}
let mut seen = std::collections::HashSet::new();
let mut picked: Vec<&str> = Vec::new();
for line in &lines {
if is_noise(line) || !is_error(line) {
continue;
}
if seen.insert(body(line).to_string()) {
picked.push(line);
if picked.len() >= 4 {
break;
}
}
}
if !picked.is_empty() {
return picked.join(" | ");
}
let tail: Vec<&str> = lines.iter().copied().filter(|l| !is_noise(l)).collect();
let start = tail.len().saturating_sub(3);
let result = tail[start..].join(" | ");
if result.is_empty() {
"unknown error".to_string()
} else {
result
}
}
pub fn replace_original(
original: &Path,
transcoded: &Path,
source_duration_secs: f64,
) -> Result<u64> {
if !transcoded.exists() {
bail!("Transcoded file does not exist: {:?}", transcoded);
}
validate_output(transcoded, original, source_duration_secs)?;
let original_size = std::fs::metadata(original).map(|m| m.len()).unwrap_or(0);
let transcoded_size = std::fs::metadata(transcoded).map(|m| m.len()).unwrap_or(0);
copy_permissions(original, transcoded)?;
std::fs::rename(transcoded, original)
.with_context(|| format!("Failed to replace {:?} with transcoded version", original))?;
remove_marker(transcoded);
let final_size = std::fs::metadata(original).map(|m| m.len()).unwrap_or(0);
if let Err(e) = write_marker(original, final_size, source_duration_secs) {
log::warn!(
"Failed to write completion marker for {:?}: {}",
original,
e
);
}
Ok(original_size.saturating_sub(transcoded_size))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_output_path_next_to_source() {
let source = Path::new("/mnt/media/show/episode.mkv");
let result = output_path(source, None, "mkv").unwrap();
assert_eq!(
result,
PathBuf::from("/mnt/media/show/episode.transcoded.mkv")
);
}
#[test]
fn test_output_path_custom_dir() {
let dir = tempfile::tempdir().unwrap();
let source = Path::new("/mnt/media/show/episode.mkv");
let result = output_path(source, Some(dir.path()), "mkv").unwrap();
assert_eq!(result, dir.path().join("episode.mkv"));
}
#[test]
fn test_output_path_different_container() {
let source = Path::new("/mnt/media/show/episode.avi");
let result = output_path(source, None, "mkv").unwrap();
assert_eq!(
result,
PathBuf::from("/mnt/media/show/episode.transcoded.mkv")
);
}
#[test]
fn test_is_session_limit_error() {
assert!(is_session_limit_error("Cannot init NVENC encoder"));
assert!(is_session_limit_error("OpenEncodeSessionEx failed"));
assert!(is_session_limit_error(
"InitializeEncoder failed: out of memory"
));
assert!(!is_session_limit_error("some other error"));
assert!(is_session_limit_error(
"Nothing was written into output file"
));
assert!(!is_session_limit_error(
"ffmpeg exited with status exit status: 69"
));
}
#[test]
fn test_is_subtitle_error() {
assert!(is_subtitle_error(
"Subtitle encoding currently only possible from text to text or bitmap to bitmap"
));
assert!(is_subtitle_error(
"Could not find tag for codec hdmv_pgs_subtitle"
));
assert!(is_subtitle_error(
"Could not find tag for codec mov_text in stream #0:2, codec not currently supported in container"
));
assert!(!is_subtitle_error("some other error"));
}
#[test]
fn test_is_disk_space_error() {
assert!(is_disk_space_error(
"Error opening output file: Disk quota exceeded"
));
assert!(is_disk_space_error("No space left on device"));
assert!(!is_disk_space_error("some other ffmpeg error"));
}
#[test]
fn test_is_audio_copy_error() {
assert!(is_audio_copy_error(
"[matroska @ 0x123] No wav codec tag found for codec pcm_dvd"
));
assert!(is_audio_copy_error(
"[out#0/matroska @ 0x123] Could not write header (incorrect codec parameters ?): Invalid argument"
));
assert!(!is_audio_copy_error(
"Could not find tag for codec hdmv_pgs_subtitle"
));
assert!(!is_audio_copy_error(
"Could not find tag for codec mov_text in stream #0:2, codec not currently supported in container"
));
assert!(!is_audio_copy_error("some unrelated ffmpeg error"));
}
#[test]
fn test_summarize_extracts_root_cause_not_cascade() {
let stderr = "\
Input #0, mpeg, from 'pipe:0':
Duration: N/A, start: 0.287267, bitrate: N/A
Stream #0:2[0xa0]: Audio: pcm_dvd, 48000 Hz, stereo, s16, 1536 kb/s
Stream mapping:
Stream #0:1 -> #0:0 (mpeg2video (native) -> hevc (hevc_nvenc))
Stream #0:2 -> #0:1 (copy)
[matroska @ 0x5ed165ece880] No wav codec tag found for codec pcm_dvd
[out#0/matroska @ 0x5ed165ece780] Could not write header (incorrect codec parameters ?): Invalid argument
[vf#0:0 @ 0x5ed165d3e0c0] Error sending frames to consumers: Invalid argument
[vf#0:0 @ 0x5ed165d3e0c0] Task finished with error code: -22 (Invalid argument)
[vf#0:0 @ 0x5ed165d3e0c0] Terminating thread with return code -22 (Invalid argument)
[mpeg @ 0x5ed165d2e940] Packet corrupt (stream = 1, dts = NOPTS).
[out#0/matroska @ 0x5ed165ece780] Nothing was written into output file, because at least one of its streams received no packets.
frame= 0 fps=0.0 q=0.0 Lsize= 0KiB time=N/A bitrate=N/A speed=N/A
Conversion failed!
";
let summary = summarize_ffmpeg_error(stderr);
assert!(
summary.contains("No wav codec tag found for codec pcm_dvd"),
"Summary must surface the root cause; got: {}",
summary
);
assert!(
!summary.contains("Conversion failed!"),
"Summary must drop the trivial trailing 'Conversion failed!'; got: {}",
summary
);
assert!(
!summary.contains("frame="),
"Summary must drop progress lines; got: {}",
summary
);
}
#[test]
fn test_summarize_dedupes_cascade_with_different_module_prefixes() {
let stderr = "\
[vf#0:0 @ 0x111] Error sending frames to consumers: Invalid argument
[vf#0:1 @ 0x222] Error sending frames to consumers: Invalid argument
[vf#0:2 @ 0x333] Error sending frames to consumers: Invalid argument
";
let summary = summarize_ffmpeg_error(stderr);
assert_eq!(
summary.matches("Error sending frames to consumers").count(),
1,
"Repeated body should appear once; got: {}",
summary
);
}
#[test]
fn test_summarize_falls_back_to_tail_when_no_keywords() {
let stderr = "step one\nstep two\nstep three\nstep four\n";
let summary = summarize_ffmpeg_error(stderr);
assert_eq!(summary, "step two | step three | step four");
}
#[test]
fn test_summarize_handles_empty() {
assert_eq!(summarize_ffmpeg_error(""), "unknown error");
assert_eq!(summarize_ffmpeg_error("\n\n \n"), "unknown error");
}
fn target_with(container: &str, subtitle_codec: &str) -> TargetConfig {
TargetConfig {
codec: "hevc".to_string(),
quality: 28,
preset: "slow".to_string(),
max_width: 3840,
max_height: 2160,
max_bitrate_kbps: 0,
container: container.to_string(),
audio_codec: "copy".to_string(),
subtitle_codec: subtitle_codec.to_string(),
}
}
fn cmd_args(cmd: &Command) -> Vec<String> {
cmd.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
#[test]
fn test_subtitle_reencode_fallback_picks_per_container() {
assert_eq!(subtitle_reencode_fallback("mkv"), "srt");
assert_eq!(subtitle_reencode_fallback("webm"), "srt");
assert_eq!(subtitle_reencode_fallback("mp4"), "mov_text");
assert_eq!(subtitle_reencode_fallback("m4v"), "mov_text");
assert_eq!(subtitle_reencode_fallback("mov"), "mov_text");
assert_eq!(subtitle_reencode_fallback("avi"), "srt");
}
#[test]
fn test_apply_subtitle_args_default_copy() {
let target = target_with("mkv", "copy");
let mut cmd = Command::new("ffmpeg");
apply_subtitle_args(&mut cmd, &target, false, None);
let args = cmd_args(&cmd);
let pos = args.iter().position(|a| a == "-c:s").expect("has -c:s");
assert_eq!(args[pos + 1], "copy");
}
#[test]
fn test_apply_subtitle_args_skip_subs_emits_nothing() {
let target = target_with("mkv", "copy");
let mut cmd = Command::new("ffmpeg");
apply_subtitle_args(&mut cmd, &target, true, None);
let args = cmd_args(&cmd);
assert!(
!args.iter().any(|a| a == "-c:s"),
"skip_subs should emit no -c:s; got {:?}",
args
);
}
#[test]
fn test_apply_subtitle_args_override_replaces_copy() {
let target = target_with("mkv", "copy");
let mut cmd = Command::new("ffmpeg");
apply_subtitle_args(&mut cmd, &target, false, Some("srt"));
let args = cmd_args(&cmd);
let pos = args.iter().position(|a| a == "-c:s").expect("has -c:s");
assert_eq!(args[pos + 1], "srt");
assert_eq!(args.iter().filter(|a| *a == "-c:s").count(), 1);
}
#[test]
fn test_apply_subtitle_args_override_for_mp4() {
let target = target_with("mp4", "copy");
let mut cmd = Command::new("ffmpeg");
apply_subtitle_args(
&mut cmd,
&target,
false,
Some(subtitle_reencode_fallback(&target.container)),
);
let args = cmd_args(&cmd);
let pos = args.iter().position(|a| a == "-c:s").expect("has -c:s");
assert_eq!(args[pos + 1], "mov_text");
}
#[test]
fn test_apply_subtitle_args_override_wins_over_skip_subs_false_path() {
let target = target_with("mkv", "copy");
let mut cmd = Command::new("ffmpeg");
apply_subtitle_args(&mut cmd, &target, true, Some("srt"));
let args = cmd_args(&cmd);
assert!(!args.iter().any(|a| a == "-c:s"));
}
#[test]
fn test_build_color_args_empty_when_no_metadata() {
let color = ColorMetadata::default();
let args = build_color_args(&color, &GpuKind::Nvidia);
assert!(args.is_empty());
}
#[test]
fn test_build_color_args_basic_tags_emitted() {
let color = ColorMetadata {
color_primaries: Some("bt2020".to_string()),
color_transfer: Some("smpte2084".to_string()),
color_space: Some("bt2020nc".to_string()),
color_range: Some("tv".to_string()),
..ColorMetadata::default()
};
let args = build_color_args(&color, &GpuKind::Nvidia);
assert_eq!(
args,
vec![
"-color_primaries",
"bt2020",
"-color_trc",
"smpte2084",
"-colorspace",
"bt2020nc",
"-color_range",
"tv",
]
);
}
#[test]
fn test_build_color_args_nvenc_emits_hdr10() {
let color = ColorMetadata {
color_primaries: Some("bt2020".to_string()),
color_transfer: Some("smpte2084".to_string()),
color_space: Some("bt2020nc".to_string()),
master_display: Some(
"G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(40000000,1)".to_string(),
),
max_cll: Some("1000,400".to_string()),
..ColorMetadata::default()
};
let args = build_color_args(&color, &GpuKind::Nvidia);
assert!(args.iter().any(|a| a == "-master_display"));
assert!(args.iter().any(|a| a == "-max_cll"));
assert!(args.iter().any(|a| a == "1000,400"));
}
#[test]
fn test_build_color_args_vaapi_skips_hdr10_flags() {
let color = ColorMetadata {
color_primaries: Some("bt2020".to_string()),
master_display: Some("G(0,0)B(0,0)R(0,0)WP(0,0)L(1,0)".to_string()),
max_cll: Some("1000,400".to_string()),
..ColorMetadata::default()
};
let intel_args = build_color_args(&color, &GpuKind::Intel);
assert!(intel_args.iter().any(|a| a == "-color_primaries"));
assert!(!intel_args.iter().any(|a| a == "-master_display"));
assert!(!intel_args.iter().any(|a| a == "-max_cll"));
let apple_args = build_color_args(&color, &GpuKind::Apple);
assert!(!apple_args.iter().any(|a| a == "-master_display"));
assert!(!apple_args.iter().any(|a| a == "-max_cll"));
}
#[test]
fn test_build_color_args_partial_metadata() {
let color = ColorMetadata {
color_primaries: Some("bt709".to_string()),
..ColorMetadata::default()
};
let args = build_color_args(&color, &GpuKind::Nvidia);
assert_eq!(args, vec!["-color_primaries", "bt709"]);
}
#[test]
fn test_color_metadata_from_media_info() {
let info = probe::MediaInfo {
codec: "hevc".to_string(),
width: 3840,
height: 2160,
bitrate_kbps: 0,
duration_secs: 0.0,
pix_fmt: "yuv420p10le".to_string(),
has_audio: false,
has_subtitles: false,
color_primaries: Some("bt2020".to_string()),
color_transfer: Some("smpte2084".to_string()),
color_space: Some("bt2020nc".to_string()),
color_range: Some("tv".to_string()),
master_display: Some("G(0,0)B(0,0)R(0,0)WP(0,0)L(1,0)".to_string()),
max_cll: Some("1000,400".to_string()),
};
let cm = ColorMetadata::from_media_info(&info);
assert_eq!(cm.color_primaries.as_deref(), Some("bt2020"));
assert_eq!(cm.color_transfer.as_deref(), Some("smpte2084"));
assert_eq!(cm.color_space.as_deref(), Some("bt2020nc"));
assert_eq!(cm.color_range.as_deref(), Some("tv"));
assert_eq!(cm.max_cll.as_deref(), Some("1000,400"));
}
}