1use std::io::{Read, Write};
33use std::path::{Path, PathBuf};
34use std::process::{Child, Command, Stdio};
35use std::sync::mpsc;
36use std::thread;
37use std::time::{Duration, Instant};
38
39use uuid::Uuid;
40
41use crate::error::Error;
42
43pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(180);
45
46#[derive(Debug, Clone)]
48pub struct FfmpegResult {
49 pub output_path: PathBuf,
51 pub output_bytes: u64,
53 pub elapsed: Duration,
55}
56
57pub trait FfmpegRunner: Send + Sync {
62 fn is_available(&self) -> bool;
64
65 fn extract_audio_wav(&self, input: &Path) -> Result<FfmpegResult, Error>;
74}
75
76#[derive(Debug, Clone)]
83pub struct RealFfmpeg {
84 binary: String,
85 timeout: Duration,
86}
87
88impl RealFfmpeg {
89 pub fn new(binary: impl Into<String>) -> Self {
92 Self {
93 binary: binary.into(),
94 timeout: DEFAULT_TIMEOUT,
95 }
96 }
97
98 #[must_use]
100 pub fn with_timeout(mut self, timeout: Duration) -> Self {
101 self.timeout = timeout;
102 self
103 }
104
105 #[must_use]
107 pub fn binary(&self) -> &str {
108 &self.binary
109 }
110}
111
112impl FfmpegRunner for RealFfmpeg {
113 fn is_available(&self) -> bool {
114 let mut cmd = Command::new(&self.binary);
115 cmd.arg("-version")
116 .stdin(Stdio::null())
117 .stdout(Stdio::piped())
118 .stderr(Stdio::piped());
119 configure_secure_subprocess(&mut cmd);
120 match cmd.status() {
121 Ok(s) => s.success(),
122 Err(_) => false,
123 }
124 }
125
126 fn extract_audio_wav(&self, input: &Path) -> Result<FfmpegResult, Error> {
127 if !self.is_available() {
128 return Err(Error::FfmpegNotFound);
129 }
130
131 let output_path = temp_wav_path();
132 let started = Instant::now();
133
134 let mut cmd = Command::new(&self.binary);
135 cmd.arg("-y")
136 .arg("-nostdin")
137 .arg("-hide_banner")
138 .arg("-loglevel")
139 .arg("error")
140 .arg("-nostats")
141 .arg("-i")
142 .arg(input)
143 .arg("-vn")
144 .arg("-acodec")
145 .arg("pcm_s16le")
146 .arg("-ac")
147 .arg("1")
148 .arg("-ar")
149 .arg("16000")
150 .arg("-f")
151 .arg("wav")
152 .arg(&output_path)
153 .stdin(Stdio::null())
154 .stdout(Stdio::piped())
155 .stderr(Stdio::piped());
156 configure_secure_subprocess(&mut cmd);
157
158 let child = cmd.spawn().map_err(|e| {
159 tracing::error!(binary = %self.binary, error = %e, "ffmpeg spawn failed");
160 if e.kind() == std::io::ErrorKind::NotFound {
161 Error::FfmpegNotFound
162 } else {
163 Error::Io(e)
164 }
165 })?;
166 let mut safe_child = SafeChild::new(child);
167
168 let (tx, rx) = mpsc::channel();
169 let stderr_handle = safe_child.inner().stderr.take().map(|mut stderr| {
170 let tx = tx.clone();
171 thread::spawn(move || {
172 let mut buf = Vec::with_capacity(4096);
173 let _ = stderr.read_to_end(&mut buf);
174 let _ = tx.send(StderrOrStatus::Stderr(buf));
175 })
176 });
177 let _ = tx; let status_result = wait_with_timeout(safe_child.inner(), self.timeout);
181
182 if let Some(handle) = stderr_handle {
183 let _ = handle.join();
184 }
185 let stderr_text = match rx.recv_timeout(Duration::from_millis(50)) {
186 Ok(StderrOrStatus::Stderr(buf)) => String::from_utf8_lossy(&buf).into_owned(),
187 _ => String::new(),
188 };
189
190 let status = match status_result {
191 Ok(s) => s,
192 Err(WaitError::Timeout) => {
193 safe_child.kill_now();
194 return Err(Error::VideoExtractionFailed {
195 path: input.display().to_string(),
196 ffmpeg_stderr: format!("timeout after {:?}", self.timeout),
197 });
198 }
199 Err(WaitError::Io(e)) => return Err(Error::Io(e)),
200 };
201
202 if !status.success() {
203 return Err(Error::VideoExtractionFailed {
204 path: input.display().to_string(),
205 ffmpeg_stderr: stderr_text,
206 });
207 }
208
209 validate_wav(&output_path)?;
212
213 let output_bytes = std::fs::metadata(&output_path).map_err(Error::Io)?.len();
214
215 Ok(FfmpegResult {
216 output_path,
217 output_bytes,
218 elapsed: started.elapsed(),
219 })
220 }
221}
222
223enum StderrOrStatus {
228 Stderr(Vec<u8>),
229}
230
231enum WaitError {
232 Timeout,
233 Io(std::io::Error),
234}
235
236fn wait_with_timeout(
242 child: &mut Child,
243 timeout: Duration,
244) -> Result<std::process::ExitStatus, WaitError> {
245 let deadline = Instant::now() + timeout;
246 loop {
247 match child.try_wait() {
248 Ok(Some(status)) => return Ok(status),
249 Ok(None) => {
250 if Instant::now() >= deadline {
251 return Err(WaitError::Timeout);
252 }
253 thread::sleep(Duration::from_millis(50));
254 }
255 Err(e) => return Err(WaitError::Io(e)),
256 }
257 }
258}
259
260pub struct SafeChild {
270 child: Option<Child>,
271 killed: bool,
272}
273
274impl SafeChild {
275 fn new(child: Child) -> Self {
276 Self {
277 child: Some(child),
278 killed: false,
279 }
280 }
281
282 fn inner(&mut self) -> &mut Child {
283 self.child
284 .as_mut()
285 .expect("SafeChild child is always Some until kill_now is called")
286 }
287
288 fn kill_now(&mut self) {
290 if let Some(mut child) = self.child.take() {
291 let _ = child.kill();
292 let _ = child.wait();
293 self.killed = true;
294 }
295 }
296}
297
298impl Drop for SafeChild {
299 fn drop(&mut self) {
300 if !self.killed {
301 if let Some(mut child) = self.child.take() {
304 let _ = child.kill();
305 let _ = child.wait();
306 }
307 }
308 }
309}
310
311fn configure_secure_subprocess(cmd: &mut Command) {
326 cmd.env_clear();
327 cmd.env("PATH", std::env::var("PATH").unwrap_or_default());
328 cmd.env("HOME", std::env::var("HOME").unwrap_or_default());
329 cmd.env("TMPDIR", std::env::temp_dir().display().to_string());
330 cmd.env("LANG", "en_US.UTF-8");
331 cmd.env("LC_ALL", "en_US.UTF-8");
332
333 #[cfg(unix)]
334 {
335 use std::os::unix::process::CommandExt;
336 unsafe {
340 cmd.pre_exec(|| {
341 libc::setsid();
342 Ok(())
343 });
344 }
345 }
346
347 #[cfg(windows)]
348 {
349 use std::os::windows::process::CommandExt;
350 const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
351 cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
352 }
353}
354
355fn validate_wav(path: &Path) -> Result<(), Error> {
361 let mut file = std::fs::File::open(path).map_err(|e| {
362 if e.kind() == std::io::ErrorKind::NotFound {
363 Error::VideoExtractionFailed {
364 path: path.display().to_string(),
365 ffmpeg_stderr: "ffmpeg exited 0 but output file is missing".into(),
366 }
367 } else {
368 Error::Io(e)
369 }
370 })?;
371
372 let mut header = [0u8; 44];
373 let n = file.read(&mut header).map_err(Error::Io)?;
374 if n < 12 {
375 return Err(Error::VideoExtractionFailed {
376 path: path.display().to_string(),
377 ffmpeg_stderr: format!("output WAV too small ({n} bytes)"),
378 });
379 }
380 if &header[..4] != b"RIFF" || &header[8..12] != b"WAVE" {
381 return Err(Error::VideoExtractionFailed {
382 path: path.display().to_string(),
383 ffmpeg_stderr: "output file is not RIFF WAVE format".into(),
384 });
385 }
386
387 let claimed_size = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
389 let actual_size = std::fs::metadata(path).map_err(Error::Io)?.len();
390 if actual_size < 44 {
391 return Err(Error::VideoExtractionFailed {
392 path: path.display().to_string(),
393 ffmpeg_stderr: format!("WAV too small: actual {actual_size} bytes"),
394 });
395 }
396 let expected_size = actual_size - 8;
397 if claimed_size as u64 != expected_size {
398 return Err(Error::VideoExtractionFailed {
399 path: path.display().to_string(),
400 ffmpeg_stderr: format!(
401 "WAV chunk size mismatch: claimed {claimed_size} vs actual {expected_size}"
402 ),
403 });
404 }
405
406 Ok(())
407}
408
409fn temp_wav_path() -> PathBuf {
412 let mut path = std::env::temp_dir();
413 let id = Uuid::now_v7();
414 path.push(format!("whisper-macos-cli-{id}.wav"));
415 path
416}
417
418pub fn remove_temp_file(path: &Path) {
421 if let Err(e) = std::fs::remove_file(path) {
422 if e.kind() != std::io::ErrorKind::NotFound {
423 tracing::warn!(path = %path.display(), error = %e, "failed to remove temp file");
424 }
425 }
426}
427
428pub struct TempOutputGuard {
430 path: Option<PathBuf>,
431}
432
433impl TempOutputGuard {
434 #[must_use]
437 pub fn new(path: PathBuf) -> Self {
438 Self { path: Some(path) }
439 }
440
441 pub fn into_inner(mut self) -> PathBuf {
443 self.path
444 .take()
445 .expect("TempOutputGuard path is taken once")
446 }
447}
448
449impl Drop for TempOutputGuard {
450 fn drop(&mut self) {
451 if let Some(p) = self.path.take() {
452 remove_temp_file(&p);
453 }
454 }
455}
456
457pub struct MockFfmpeg {
467 wav_bytes: Vec<u8>,
470 available: bool,
472 error_override: Option<Error>,
474 call_count: std::sync::atomic::AtomicUsize,
476 last_input: std::sync::Mutex<Option<PathBuf>>,
478}
479
480impl MockFfmpeg {
481 #[must_use]
484 pub fn new() -> Self {
485 Self {
486 wav_bytes: minimal_silent_wav(),
487 available: true,
488 error_override: None,
489 call_count: std::sync::atomic::AtomicUsize::new(0),
490 last_input: std::sync::Mutex::new(None),
491 }
492 }
493
494 #[must_use]
496 pub fn with_wav_bytes(mut self, bytes: Vec<u8>) -> Self {
497 self.wav_bytes = bytes;
498 self
499 }
500
501 #[must_use]
503 pub fn unavailable(mut self) -> Self {
504 self.available = false;
505 self
506 }
507
508 #[must_use]
510 pub fn with_error(mut self, err: Error) -> Self {
511 self.error_override = Some(err);
512 self
513 }
514
515 #[must_use]
517 pub fn call_count(&self) -> usize {
518 self.call_count.load(std::sync::atomic::Ordering::Relaxed)
519 }
520
521 #[must_use]
523 pub fn last_input(&self) -> Option<PathBuf> {
524 self.last_input.lock().ok().and_then(|g| g.clone())
525 }
526}
527
528impl Default for MockFfmpeg {
529 fn default() -> Self {
530 Self::new()
531 }
532}
533
534impl FfmpegRunner for MockFfmpeg {
535 fn is_available(&self) -> bool {
536 self.available
537 }
538
539 fn extract_audio_wav(&self, input: &Path) -> Result<FfmpegResult, Error> {
540 self.call_count
541 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
542 if let Ok(mut g) = self.last_input.lock() {
543 *g = Some(input.to_path_buf());
544 }
545 if let Some(err) = &self.error_override {
546 return Err(match err {
547 Error::VideoExtractionFailed {
548 path,
549 ffmpeg_stderr,
550 } => Error::VideoExtractionFailed {
551 path: path.clone(),
552 ffmpeg_stderr: ffmpeg_stderr.clone(),
553 },
554 Error::FfmpegNotFound => Error::FfmpegNotFound,
555 other => other.clone_with_source(),
556 });
557 }
558 let output_path = temp_wav_path();
559 let started = Instant::now();
560 let mut f = std::fs::File::create(&output_path).map_err(Error::Io)?;
561 f.write_all(&self.wav_bytes).map_err(Error::Io)?;
562 f.sync_all().map_err(Error::Io)?;
563 drop(f);
564 Ok(FfmpegResult {
565 output_path,
566 output_bytes: self.wav_bytes.len() as u64,
567 elapsed: started.elapsed(),
568 })
569 }
570}
571
572impl Error {
573 fn clone_with_source(&self) -> Self {
576 match self {
577 Self::NoInput => Self::NoInput,
578 Self::InputNotFound { path } => Self::InputNotFound { path: path.clone() },
579 Self::AudioDecode(e) => Self::AudioDecode(anyhow::anyhow!("{e}")),
580 Self::UnsupportedFormat { format } => Self::UnsupportedFormat {
581 format: format.clone(),
582 },
583 Self::ModelNotFound { name } => Self::ModelNotFound { name: name.clone() },
584 Self::ModelDownload(e) => Self::ModelDownload(anyhow::anyhow!("{e}")),
585 Self::WhisperInference(s) => Self::WhisperInference(s.clone()),
586 Self::UnsupportedPlatform => Self::UnsupportedPlatform,
587 Self::Io(e) => Self::Io(std::io::Error::new(e.kind(), e.to_string())),
588 Self::Config(s) => Self::Config(s.clone()),
589 Self::VideoExtractionFailed {
590 path,
591 ffmpeg_stderr,
592 } => Self::VideoExtractionFailed {
593 path: path.clone(),
594 ffmpeg_stderr: ffmpeg_stderr.clone(),
595 },
596 Self::FfmpegNotFound => Self::FfmpegNotFound,
597 Self::UnsupportedVideoFormat { format } => Self::UnsupportedVideoFormat {
598 format: format.clone(),
599 },
600 }
601 }
602}
603
604fn minimal_silent_wav() -> Vec<u8> {
608 let data_size: u32 = 16000 * 2; let file_size: u32 = 36 + data_size;
610 let mut v = Vec::with_capacity(44 + data_size as usize);
611 v.extend_from_slice(b"RIFF");
612 v.extend_from_slice(&file_size.to_le_bytes());
613 v.extend_from_slice(b"WAVE");
614 v.extend_from_slice(b"fmt ");
615 v.extend_from_slice(&16u32.to_le_bytes()); v.extend_from_slice(&1u16.to_le_bytes()); v.extend_from_slice(&1u16.to_le_bytes()); v.extend_from_slice(&16000u32.to_le_bytes()); v.extend_from_slice(&32000u32.to_le_bytes()); v.extend_from_slice(&2u16.to_le_bytes()); v.extend_from_slice(&16u16.to_le_bytes()); v.extend_from_slice(b"data");
623 v.extend_from_slice(&data_size.to_le_bytes());
624 v.resize(44 + data_size as usize, 0);
625 v
626}
627
628#[cfg(test)]
633mod tests {
634 use super::*;
635 use std::io::Cursor;
636
637 #[test]
638 fn is_video_magic_bytes_is_re_exported() {
639 assert!(crate::video::is_video_magic_bytes(
641 b"RIFF\x00\x00\x00\x00AVI "
642 ));
643 }
644
645 #[test]
646 fn mock_ffmpeg_is_available_by_default() {
647 let m = MockFfmpeg::new();
648 assert!(m.is_available());
649 assert_eq!(m.call_count(), 0);
650 }
651
652 #[test]
653 fn mock_ffmpeg_unavailable_returns_false() {
654 let m = MockFfmpeg::new().unavailable();
655 assert!(!m.is_available());
656 }
657
658 #[test]
659 fn mock_ffmpeg_extract_writes_minimal_wav() {
660 let m = MockFfmpeg::new();
661 let result = m
662 .extract_audio_wav(Path::new("/tmp/fake.mp4"))
663 .expect("mock should succeed");
664 assert!(result.output_path.exists());
665 assert!(result.output_bytes > 44, "minimal WAV must have audio data");
666 assert_eq!(m.call_count(), 1);
667 assert_eq!(m.last_input(), Some(PathBuf::from("/tmp/fake.mp4")));
668 let _ = std::fs::remove_file(&result.output_path);
669 }
670
671 #[test]
672 fn mock_ffmpeg_returns_overridden_error() {
673 let m = MockFfmpeg::new().with_error(Error::FfmpegNotFound);
674 let err = m.extract_audio_wav(Path::new("/tmp/x.mp4")).unwrap_err();
675 assert!(matches!(err, Error::FfmpegNotFound));
676 }
677
678 #[test]
679 fn mock_ffmpeg_returns_video_extraction_error() {
680 let m = MockFfmpeg::new().with_error(Error::VideoExtractionFailed {
681 path: "video.mp4".into(),
682 ffmpeg_stderr: "Invalid data found".into(),
683 });
684 let err = m.extract_audio_wav(Path::new("video.mp4")).unwrap_err();
685 match err {
686 Error::VideoExtractionFailed {
687 path,
688 ffmpeg_stderr,
689 } => {
690 assert_eq!(path, "video.mp4");
691 assert_eq!(ffmpeg_stderr, "Invalid data found");
692 }
693 other => panic!("expected VideoExtractionFailed, got {other:?}"),
694 }
695 }
696
697 #[test]
698 fn mock_ffmpeg_writes_custom_wav_bytes() {
699 let bytes = vec![0xAA; 100];
700 let m = MockFfmpeg::new().with_wav_bytes(bytes.clone());
701 let result = m.extract_audio_wav(Path::new("x.mp4")).unwrap();
702 let read = std::fs::read(&result.output_path).unwrap();
703 assert_eq!(read, bytes);
704 let _ = std::fs::remove_file(&result.output_path);
705 }
706
707 #[test]
708 fn validate_wav_accepts_valid_wav() {
709 let path = temp_wav_path();
710 std::fs::write(&path, minimal_silent_wav()).unwrap();
711 validate_wav(&path).expect("valid wav should validate");
712 let _ = std::fs::remove_file(&path);
713 }
714
715 #[test]
716 fn validate_wav_rejects_missing_file() {
717 let err = validate_wav(Path::new("/tmp/this/does/not/exist.wav")).unwrap_err();
718 assert!(matches!(err, Error::VideoExtractionFailed { .. }));
719 }
720
721 #[test]
722 fn validate_wav_rejects_too_small() {
723 let path = temp_wav_path();
724 std::fs::write(&path, b"RIFF").unwrap();
725 let err = validate_wav(&path).unwrap_err();
726 assert!(matches!(err, Error::VideoExtractionFailed { .. }));
727 let _ = std::fs::remove_file(&path);
728 }
729
730 #[test]
731 fn validate_wav_rejects_non_wav() {
732 let path = temp_wav_path();
733 let mut bytes = vec![0u8; 44];
734 bytes[..4].copy_from_slice(b"RIFF");
735 bytes[8..12].copy_from_slice(b"OGG ");
736 std::fs::write(&path, bytes).unwrap();
737 let err = validate_wav(&path).unwrap_err();
738 assert!(matches!(err, Error::VideoExtractionFailed { .. }));
739 let _ = std::fs::remove_file(&path);
740 }
741
742 #[test]
743 fn temp_output_guard_removes_file_on_drop() {
744 let path = temp_wav_path();
745 std::fs::write(&path, b"temporary").unwrap();
746 assert!(path.exists());
747 {
748 let _g = TempOutputGuard::new(path.clone());
749 }
750 assert!(!path.exists());
751 }
752
753 #[test]
754 fn temp_output_guard_into_inner_keeps_file() {
755 let path = temp_wav_path();
756 std::fs::write(&path, b"keep me").unwrap();
757 let path2 = {
758 let g = TempOutputGuard::new(path.clone());
759 g.into_inner()
760 };
761 assert!(path2.exists());
762 let _ = std::fs::remove_file(&path2);
763 }
764
765 #[test]
766 fn minimal_silent_wav_has_1_second_of_data() {
767 let v = minimal_silent_wav();
768 assert_eq!(v.len(), 44 + 16000 * 2, "1 second of mono 16-bit at 16kHz");
769 }
770
771 #[test]
772 fn minimal_silent_wav_has_valid_riff_header() {
773 let v = minimal_silent_wav();
774 assert_eq!(&v[..4], b"RIFF");
775 assert_eq!(&v[8..12], b"WAVE");
776 assert_eq!(&v[12..16], b"fmt ");
777 assert_eq!(&v[36..40], b"data");
778 }
779
780 #[test]
781 fn real_ffmpeg_new_uses_default_binary_name() {
782 let f = RealFfmpeg::new("ffmpeg");
783 assert_eq!(f.binary(), "ffmpeg");
784 }
785
786 #[test]
787 fn real_ffmpeg_with_timeout_overrides() {
788 let f = RealFfmpeg::new("ffmpeg").with_timeout(Duration::from_secs(5));
789 let _: RealFfmpeg = f;
791 }
792
793 #[test]
794 fn env_clear_does_not_leak_proxy() {
795 let mut cmd = Command::new("true");
801 configure_secure_subprocess(&mut cmd);
802 let _ = cmd;
805 }
806
807 #[test]
808 fn wait_with_timeout_returns_status_quickly() {
809 let mut cmd = Command::new("true");
810 cmd.env_clear()
811 .stdin(Stdio::null())
812 .stdout(Stdio::null())
813 .stderr(Stdio::null());
814 let child = cmd.spawn().expect("spawn true");
815 let result = wait_with_timeout(&mut { child }, Duration::from_secs(5));
816 let mut cmd2 = Command::new("true");
819 cmd2.env_clear()
820 .stdin(Stdio::null())
821 .stdout(Stdio::null())
822 .stderr(Stdio::null());
823 let mut child2 = cmd2.spawn().expect("spawn true 2");
824 let r = wait_with_timeout(&mut child2, Duration::from_secs(5));
825 assert!(r.is_ok());
826 let _ = result;
828 }
829
830 #[test]
831 fn minimal_silent_wav_roundtrip_through_validate() {
832 let path = temp_wav_path();
833 std::fs::write(&path, minimal_silent_wav()).unwrap();
834 validate_wav(&path).expect("minimal wav should validate");
835 let _ = std::fs::remove_file(&path);
836 }
837
838 #[test]
839 fn cursor_drop_test_unchanged() {
840 let mut c = Cursor::new(vec![0u8; 16]);
842 let mut buf = [0u8; 4];
843 c.read_exact(&mut buf).unwrap();
844 assert_eq!(buf, [0, 0, 0, 0]);
845 }
846}