Files
cinch/src-tauri/src/ffmpeg/runner.rs

304 lines
9.9 KiB
Rust

use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Instant;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use tauri::{Emitter, AppHandle};
use crate::config;
use crate::ffmpeg::commands::{self, FfmpegCommand};
use crate::types::*;
const CREATE_NO_WINDOW: u32 = 0x08000000;
pub type JobMap = Arc<Mutex<HashMap<String, Child>>>;
pub fn new_job_map() -> JobMap {
Arc::new(Mutex::new(HashMap::new()))
}
pub struct RunResult {
pub success: bool,
}
fn spawn_hidden(cmd: &mut Command) -> std::io::Result<Child> {
#[cfg(windows)]
{
cmd.creation_flags(CREATE_NO_WINDOW);
}
cmd.spawn()
}
pub fn run_ffmpeg(
ffmpeg_path: &str,
cmd: &FfmpegCommand,
job_id: &str,
total_duration: f64,
phase: &str,
app: &AppHandle,
jobs: &JobMap,
) -> Result<RunResult, String> {
let mut proc = Command::new(ffmpeg_path);
proc.args(&cmd.args)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = spawn_hidden(&mut proc)
.map_err(|e| format!("Failed to spawn ffmpeg: {}", e))?;
let stdout = child.stdout.take();
{
let mut map = jobs.lock().map_err(|e| e.to_string())?;
map.insert(job_id.to_string(), child);
}
let start = Instant::now();
let mut last_progress = ProgressEvent {
job_id: job_id.to_string(),
percent: 0.0,
fps: 0.0,
bitrate: String::new(),
size_current: 0,
time_elapsed: 0.0,
eta_seconds: 0.0,
phase: phase.to_string(),
message: None,
};
if let Some(out) = stdout {
let reader = BufReader::new(out);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
let parts: Vec<&str> = line.splitn(2, '=').collect();
if parts.len() != 2 {
continue;
}
let key = parts[0].trim();
let val = parts[1].trim();
match key {
"out_time_us" => {
if let Ok(us) = val.parse::<f64>() {
let secs = us / 1_000_000.0;
let elapsed = start.elapsed().as_secs_f64();
last_progress.time_elapsed = elapsed;
if total_duration > 0.0 {
let pct = (secs / total_duration * 100.0).min(100.0).max(0.0);
last_progress.percent = pct;
if pct > 0.0 {
let remaining = elapsed / pct * (100.0 - pct);
last_progress.eta_seconds = remaining;
}
}
}
}
"fps" => {
last_progress.fps = val.parse::<f64>().unwrap_or(0.0);
}
"bitrate" => {
last_progress.bitrate = val.to_string();
}
"total_size" => {
last_progress.size_current = val.parse::<u64>().unwrap_or(0);
}
"progress" => {
let _ = app.emit("progress", &last_progress);
if val == "end" {
last_progress.percent = 100.0;
last_progress.phase = "done".into();
let _ = app.emit("progress", &last_progress);
}
}
_ => {}
}
}
}
let status = {
let mut map = jobs.lock().map_err(|e| e.to_string())?;
if let Some(mut child) = map.remove(job_id) {
child.wait().map_err(|e| e.to_string())?
} else {
return Err("Job was cancelled".into());
}
};
Ok(RunResult {
success: status.success(),
})
}
pub fn run_ffmpeg_silent(
ffmpeg_path: &str,
cmd: &FfmpegCommand,
) -> Result<(), String> {
let mut proc = Command::new(ffmpeg_path);
proc.args(&cmd.args)
.stdout(Stdio::null())
.stderr(Stdio::piped());
let child = spawn_hidden(&mut proc)
.map_err(|e| format!("Failed to spawn ffmpeg: {}", e))?;
let status = child.wait_with_output().map_err(|e| e.to_string())?;
if status.status.success() {
Ok(())
} else {
Err("FFmpeg process failed".into())
}
}
pub fn cancel_job(job_id: &str, jobs: &JobMap) -> Result<(), String> {
let mut map = jobs.lock().map_err(|e| e.to_string())?;
if let Some(mut child) = map.remove(job_id) {
let _ = child.kill();
let _ = child.wait();
Ok(())
} else {
Err("No active job found".into())
}
}
pub fn run_compress_with_retry(
ffmpeg_path: &str,
input: &str,
output: &str,
settings: &CompressSettings,
trim: Option<&TrimRange>,
hw_info: &HardwareInfo,
app_config: &AppConfig,
job_id: &str,
total_duration: f64,
has_audio: bool,
app: &AppHandle,
jobs: &JobMap,
) -> Result<(String, u32), String> {
let encoder = commands::select_encoder(&settings.video_codec, &settings.hw_accel, hw_info);
let is_hw = commands::is_hw_encoder(&encoder);
match &settings.strategy {
SizingStrategy::CRF { value } => {
let cmd = commands::build_crf_command(
input, output, settings, *value, &encoder, trim, has_audio,
);
let result = run_ffmpeg(ffmpeg_path, &cmd, job_id, total_duration, "encoding", app, jobs)?;
if !result.success {
return Err("Encoding failed".into());
}
Ok((output.to_string(), 1))
}
SizingStrategy::TargetBitrate { kbps } => {
let cmd = commands::build_bitrate_command(
input, output, settings, *kbps, &encoder, trim, has_audio,
);
let result = run_ffmpeg(ffmpeg_path, &cmd, job_id, total_duration, "encoding", app, jobs)?;
if !result.success {
return Err("Encoding failed".into());
}
Ok((output.to_string(), 1))
}
SizingStrategy::TargetSize { mb } => {
let audio_kbps = match settings.audio_codec {
AudioCodec::None => 0,
_ => settings.audio_bitrate,
};
let mut bitrate_kbps = commands::calculate_bitrate(*mb, total_duration, audio_kbps);
let target_bytes = (*mb * 1024.0 * 1024.0) as u64;
let max_attempts = app_config.max_retry_attempts;
let threshold = app_config.retry_threshold_percent;
for attempt in 1..=max_attempts {
let phase = if attempt == 1 { "encoding" } else { "retrying" };
if is_hw {
let cmd = commands::build_compress_hw(
input, output, settings, bitrate_kbps, &encoder, trim, has_audio,
);
let result = run_ffmpeg(ffmpeg_path, &cmd, job_id, total_duration, phase, app, jobs)?;
if !result.success {
return Err("Encoding failed".into());
}
} else {
let passlog_dir = config::ensure_temp_subdir(&format!("passlog/{}", job_id));
let passlog_prefix = passlog_dir.join("ffmpeg2pass").to_string_lossy().to_string();
let pass1 = commands::build_compress_pass1(
input, settings, bitrate_kbps, &passlog_prefix,
);
let _ = app.emit("progress", &ProgressEvent {
job_id: job_id.to_string(),
percent: 0.0,
fps: 0.0,
bitrate: String::new(),
size_current: 0,
time_elapsed: 0.0,
eta_seconds: 0.0,
phase: "analyzing".into(),
message: Some(format!("Pass 1 of 2 (attempt {})", attempt)),
});
run_ffmpeg_silent(ffmpeg_path, &pass1)?;
let pass2 = commands::build_compress_pass2(
input, output, settings, bitrate_kbps, &passlog_prefix, trim, has_audio,
);
let result = run_ffmpeg(ffmpeg_path, &pass2, job_id, total_duration, phase, app, jobs)?;
if !result.success {
return Err("Encoding failed".into());
}
}
let output_size = std::fs::metadata(output)
.map(|m| m.len())
.unwrap_or(0);
if output_size <= target_bytes {
return Ok((output.to_string(), attempt));
}
let overshoot_pct = ((output_size as f64 - target_bytes as f64) / target_bytes as f64) * 100.0;
if overshoot_pct <= threshold {
return Ok((output.to_string(), attempt));
}
if attempt < max_attempts {
let _ = app.emit("compress-retry", &serde_json::json!({
"job_id": job_id,
"attempt": attempt + 1,
"reason": format!(
"Output {:.1}MB exceeds {:.1}MB target by {:.1}%",
output_size as f64 / 1024.0 / 1024.0,
mb,
overshoot_pct
),
"adjusted_bitrate": bitrate_kbps
}));
let ratio = target_bytes as f64 / output_size as f64;
bitrate_kbps = ((bitrate_kbps as f64) * ratio * 0.95) as u32;
bitrate_kbps = bitrate_kbps.max(10);
}
}
Ok((output.to_string(), max_attempts))
}
}
}