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>>; 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 { #[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 { 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::() { 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::().unwrap_or(0.0); } "bitrate" => { last_progress.bitrate = val.to_string(); } "total_size" => { last_progress.size_current = val.parse::().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)) } } }