//! ffmpeg 리먹스/트랜스코딩 스트림 잡 관리. //! //! 웹뷰가 재생 못 하는 컨테이너/코덱은 ffmpeg로 fMP4를 만들어 chunked로 //! 흘려보낸다. 시킹은 잡 재생성(-ss 입력 시킹) + 프론트 seekBase 오프셋 방식. use std::collections::HashMap; use std::path::PathBuf; use std::process::Stdio; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum VideoMode { /// 스트림 카피 (컨테이너만 교체) Copy, /// 카피 + hvc1 태그 (macOS HEVC) CopyHvc1, /// H.264 하드웨어 인코더로 변환 Transcode, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum AudioMode { Copy, /// AAC 변환 (AC-3/DTS/Vorbis 등) Aac, } #[derive(Debug, Clone)] pub struct StreamSpec { pub input: PathBuf, pub start_seconds: f64, pub video: VideoMode, pub audio: AudioMode, } pub struct StreamManager { ffmpeg: Option, specs: Mutex>, procs: Mutex>, next_id: AtomicU64, } impl StreamManager { pub fn new(ffmpeg: Option) -> Self { StreamManager { ffmpeg, specs: Mutex::new(HashMap::new()), procs: Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), } } pub fn available(&self) -> bool { self.ffmpeg.is_some() } /// 잡을 등록하고 id를 돌려준다 (프로세스는 GET 시점에 기동). pub fn create(&self, spec: StreamSpec) -> u64 { let id = self.next_id.fetch_add(1, Ordering::SeqCst); self.specs.lock().unwrap().insert(id, spec); id } pub fn stop(&self, id: u64) { self.specs.lock().unwrap().remove(&id); if let Some(mut child) = self.procs.lock().unwrap().remove(&id) { let _ = child.start_kill(); } } pub fn stop_all(&self) { self.specs.lock().unwrap().clear(); let mut procs = self.procs.lock().unwrap(); for (_, mut child) in procs.drain() { let _ = child.start_kill(); } } /// GET 핸들러에서 호출 — ffmpeg를 기동하고 stdout을 돌려준다. pub(crate) fn spawn(&self, id: u64) -> Option { let spec = self.specs.lock().unwrap().get(&id).cloned()?; let ffmpeg = self.ffmpeg.clone()?; // 같은 잡의 이전 프로세스는 종료 (시킹 재요청 등) if let Some(mut old) = self.procs.lock().unwrap().remove(&id) { let _ = old.start_kill(); } let mut cmd = tokio::process::Command::new(&ffmpeg); cmd.args(["-v", "error"]); if spec.start_seconds > 0.01 { cmd.args(["-ss", &format!("{:.3}", spec.start_seconds)]); } cmd.arg("-i").arg(&spec.input); cmd.args(["-map", "0:v:0", "-map", "0:a:0?"]); match spec.video { VideoMode::Copy => { cmd.args(["-c:v", "copy"]); } VideoMode::CopyHvc1 => { cmd.args(["-c:v", "copy", "-tag:v", "hvc1"]); } VideoMode::Transcode => { #[cfg(windows)] cmd.args(["-c:v", "h264_mf", "-b:v", "6M"]); #[cfg(target_os = "macos")] cmd.args(["-c:v", "h264_videotoolbox", "-b:v", "6M"]); #[cfg(not(any(windows, target_os = "macos")))] cmd.args(["-c:v", "libx264", "-preset", "veryfast", "-crf", "23"]); // H.264는 짝수 해상도 필수 cmd.args(["-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2"]); } } match spec.audio { AudioMode::Copy => { cmd.args(["-c:a", "copy"]); } AudioMode::Aac => { cmd.args(["-c:a", "aac", "-b:a", "192k", "-ac", "2"]); } } cmd.args([ "-movflags", "frag_keyframe+empty_moov+default_base_moof", "-f", "mp4", "pipe:1", ]); cmd.stdout(Stdio::piped()).stderr(Stdio::null()).stdin(Stdio::null()); cmd.kill_on_drop(true); #[cfg(windows)] { cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW } let mut child = match cmd.spawn() { Ok(c) => c, Err(e) => { tracing::error!("ffmpeg 스트림 기동 실패: {e}"); return None; } }; let stdout = child.stdout.take()?; self.procs.lock().unwrap().insert(id, child); Some(stdout) } } impl Drop for StreamManager { fn drop(&mut self) { let mut procs = self.procs.lock().unwrap(); for (_, mut child) in procs.drain() { let _ = child.start_kill(); } } }