use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use uuid::Uuid;
use crate::error::Error;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(180);
#[derive(Debug, Clone)]
pub struct FfmpegResult {
pub output_path: PathBuf,
pub output_bytes: u64,
pub elapsed: Duration,
}
pub trait FfmpegRunner: Send + Sync {
fn is_available(&self) -> bool;
fn extract_audio_wav(&self, input: &Path) -> Result<FfmpegResult, Error>;
}
#[derive(Debug, Clone)]
pub struct RealFfmpeg {
binary: String,
timeout: Duration,
}
impl RealFfmpeg {
pub fn new(binary: impl Into<String>) -> Self {
Self {
binary: binary.into(),
timeout: DEFAULT_TIMEOUT,
}
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn binary(&self) -> &str {
&self.binary
}
}
impl FfmpegRunner for RealFfmpeg {
fn is_available(&self) -> bool {
let mut cmd = Command::new(&self.binary);
cmd.arg("-version")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
configure_secure_subprocess(&mut cmd);
match cmd.status() {
Ok(s) => s.success(),
Err(_) => false,
}
}
fn extract_audio_wav(&self, input: &Path) -> Result<FfmpegResult, Error> {
if !self.is_available() {
return Err(Error::FfmpegNotFound);
}
let output_path = temp_wav_path();
let started = Instant::now();
let mut cmd = Command::new(&self.binary);
cmd.arg("-y")
.arg("-nostdin")
.arg("-hide_banner")
.arg("-loglevel")
.arg("error")
.arg("-nostats")
.arg("-i")
.arg(input)
.arg("-vn")
.arg("-acodec")
.arg("pcm_s16le")
.arg("-ac")
.arg("1")
.arg("-ar")
.arg("16000")
.arg("-f")
.arg("wav")
.arg(&output_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
configure_secure_subprocess(&mut cmd);
let child = cmd.spawn().map_err(|e| {
tracing::error!(binary = %self.binary, error = %e, "ffmpeg spawn failed");
if e.kind() == std::io::ErrorKind::NotFound {
Error::FfmpegNotFound
} else {
Error::Io(e)
}
})?;
let mut safe_child = SafeChild::new(child);
let (tx, rx) = mpsc::channel();
let stderr_handle = safe_child.inner().stderr.take().map(|mut stderr| {
let tx = tx.clone();
thread::spawn(move || {
let mut buf = Vec::with_capacity(4096);
let _ = stderr.read_to_end(&mut buf);
let _ = tx.send(StderrOrStatus::Stderr(buf));
})
});
let _ = tx;
let status_result = wait_with_timeout(safe_child.inner(), self.timeout);
if let Some(handle) = stderr_handle {
let _ = handle.join();
}
let stderr_text = match rx.recv_timeout(Duration::from_millis(50)) {
Ok(StderrOrStatus::Stderr(buf)) => String::from_utf8_lossy(&buf).into_owned(),
_ => String::new(),
};
let status = match status_result {
Ok(s) => s,
Err(WaitError::Timeout) => {
safe_child.kill_now();
return Err(Error::VideoExtractionFailed {
path: input.display().to_string(),
ffmpeg_stderr: format!("timeout after {:?}", self.timeout),
});
}
Err(WaitError::Io(e)) => return Err(Error::Io(e)),
};
if !status.success() {
return Err(Error::VideoExtractionFailed {
path: input.display().to_string(),
ffmpeg_stderr: stderr_text,
});
}
validate_wav(&output_path)?;
let output_bytes = std::fs::metadata(&output_path).map_err(Error::Io)?.len();
Ok(FfmpegResult {
output_path,
output_bytes,
elapsed: started.elapsed(),
})
}
}
enum StderrOrStatus {
Stderr(Vec<u8>),
}
enum WaitError {
Timeout,
Io(std::io::Error),
}
fn wait_with_timeout(
child: &mut Child,
timeout: Duration,
) -> Result<std::process::ExitStatus, WaitError> {
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => return Ok(status),
Ok(None) => {
if Instant::now() >= deadline {
return Err(WaitError::Timeout);
}
thread::sleep(Duration::from_millis(50));
}
Err(e) => return Err(WaitError::Io(e)),
}
}
}
pub struct SafeChild {
child: Option<Child>,
killed: bool,
}
impl SafeChild {
fn new(child: Child) -> Self {
Self {
child: Some(child),
killed: false,
}
}
fn inner(&mut self) -> &mut Child {
self.child
.as_mut()
.expect("SafeChild child is always Some until kill_now is called")
}
fn kill_now(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
self.killed = true;
}
}
}
impl Drop for SafeChild {
fn drop(&mut self) {
if !self.killed {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
}
fn configure_secure_subprocess(cmd: &mut Command) {
cmd.env_clear();
cmd.env("PATH", std::env::var("PATH").unwrap_or_default());
cmd.env("HOME", std::env::var("HOME").unwrap_or_default());
cmd.env("TMPDIR", std::env::temp_dir().display().to_string());
cmd.env("LANG", "en_US.UTF-8");
cmd.env("LC_ALL", "en_US.UTF-8");
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
}
}
fn validate_wav(path: &Path) -> Result<(), Error> {
let mut file = std::fs::File::open(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::VideoExtractionFailed {
path: path.display().to_string(),
ffmpeg_stderr: "ffmpeg exited 0 but output file is missing".into(),
}
} else {
Error::Io(e)
}
})?;
let mut header = [0u8; 44];
let n = file.read(&mut header).map_err(Error::Io)?;
if n < 12 {
return Err(Error::VideoExtractionFailed {
path: path.display().to_string(),
ffmpeg_stderr: format!("output WAV too small ({n} bytes)"),
});
}
if &header[..4] != b"RIFF" || &header[8..12] != b"WAVE" {
return Err(Error::VideoExtractionFailed {
path: path.display().to_string(),
ffmpeg_stderr: "output file is not RIFF WAVE format".into(),
});
}
let claimed_size = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
let actual_size = std::fs::metadata(path).map_err(Error::Io)?.len();
if actual_size < 44 {
return Err(Error::VideoExtractionFailed {
path: path.display().to_string(),
ffmpeg_stderr: format!("WAV too small: actual {actual_size} bytes"),
});
}
let expected_size = actual_size - 8;
if claimed_size as u64 != expected_size {
return Err(Error::VideoExtractionFailed {
path: path.display().to_string(),
ffmpeg_stderr: format!(
"WAV chunk size mismatch: claimed {claimed_size} vs actual {expected_size}"
),
});
}
Ok(())
}
fn temp_wav_path() -> PathBuf {
let mut path = std::env::temp_dir();
let id = Uuid::now_v7();
path.push(format!("whisper-macos-cli-{id}.wav"));
path
}
pub fn remove_temp_file(path: &Path) {
if let Err(e) = std::fs::remove_file(path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(path = %path.display(), error = %e, "failed to remove temp file");
}
}
}
pub struct TempOutputGuard {
path: Option<PathBuf>,
}
impl TempOutputGuard {
#[must_use]
pub fn new(path: PathBuf) -> Self {
Self { path: Some(path) }
}
pub fn into_inner(mut self) -> PathBuf {
self.path
.take()
.expect("TempOutputGuard path is taken once")
}
}
impl Drop for TempOutputGuard {
fn drop(&mut self) {
if let Some(p) = self.path.take() {
remove_temp_file(&p);
}
}
}
pub struct MockFfmpeg {
wav_bytes: Vec<u8>,
available: bool,
error_override: Option<Error>,
call_count: std::sync::atomic::AtomicUsize,
last_input: std::sync::Mutex<Option<PathBuf>>,
}
impl MockFfmpeg {
#[must_use]
pub fn new() -> Self {
Self {
wav_bytes: minimal_silent_wav(),
available: true,
error_override: None,
call_count: std::sync::atomic::AtomicUsize::new(0),
last_input: std::sync::Mutex::new(None),
}
}
#[must_use]
pub fn with_wav_bytes(mut self, bytes: Vec<u8>) -> Self {
self.wav_bytes = bytes;
self
}
#[must_use]
pub fn unavailable(mut self) -> Self {
self.available = false;
self
}
#[must_use]
pub fn with_error(mut self, err: Error) -> Self {
self.error_override = Some(err);
self
}
#[must_use]
pub fn call_count(&self) -> usize {
self.call_count.load(std::sync::atomic::Ordering::Relaxed)
}
#[must_use]
pub fn last_input(&self) -> Option<PathBuf> {
self.last_input.lock().ok().and_then(|g| g.clone())
}
}
impl Default for MockFfmpeg {
fn default() -> Self {
Self::new()
}
}
impl FfmpegRunner for MockFfmpeg {
fn is_available(&self) -> bool {
self.available
}
fn extract_audio_wav(&self, input: &Path) -> Result<FfmpegResult, Error> {
self.call_count
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if let Ok(mut g) = self.last_input.lock() {
*g = Some(input.to_path_buf());
}
if let Some(err) = &self.error_override {
return Err(match err {
Error::VideoExtractionFailed {
path,
ffmpeg_stderr,
} => Error::VideoExtractionFailed {
path: path.clone(),
ffmpeg_stderr: ffmpeg_stderr.clone(),
},
Error::FfmpegNotFound => Error::FfmpegNotFound,
other => other.clone_with_source(),
});
}
let output_path = temp_wav_path();
let started = Instant::now();
let mut f = std::fs::File::create(&output_path).map_err(Error::Io)?;
f.write_all(&self.wav_bytes).map_err(Error::Io)?;
f.sync_all().map_err(Error::Io)?;
drop(f);
Ok(FfmpegResult {
output_path,
output_bytes: self.wav_bytes.len() as u64,
elapsed: started.elapsed(),
})
}
}
impl Error {
fn clone_with_source(&self) -> Self {
match self {
Self::NoInput => Self::NoInput,
Self::InputNotFound { path } => Self::InputNotFound { path: path.clone() },
Self::AudioDecode(e) => Self::AudioDecode(anyhow::anyhow!("{e}")),
Self::UnsupportedFormat { format } => Self::UnsupportedFormat {
format: format.clone(),
},
Self::ModelNotFound { name } => Self::ModelNotFound { name: name.clone() },
Self::ModelDownload(e) => Self::ModelDownload(anyhow::anyhow!("{e}")),
Self::WhisperInference(s) => Self::WhisperInference(s.clone()),
Self::UnsupportedPlatform => Self::UnsupportedPlatform,
Self::Io(e) => Self::Io(std::io::Error::new(e.kind(), e.to_string())),
Self::Config(s) => Self::Config(s.clone()),
Self::VideoExtractionFailed {
path,
ffmpeg_stderr,
} => Self::VideoExtractionFailed {
path: path.clone(),
ffmpeg_stderr: ffmpeg_stderr.clone(),
},
Self::FfmpegNotFound => Self::FfmpegNotFound,
Self::UnsupportedVideoFormat { format } => Self::UnsupportedVideoFormat {
format: format.clone(),
},
}
}
}
fn minimal_silent_wav() -> Vec<u8> {
let data_size: u32 = 16000 * 2; let file_size: u32 = 36 + data_size;
let mut v = Vec::with_capacity(44 + data_size as usize);
v.extend_from_slice(b"RIFF");
v.extend_from_slice(&file_size.to_le_bytes());
v.extend_from_slice(b"WAVE");
v.extend_from_slice(b"fmt ");
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");
v.extend_from_slice(&data_size.to_le_bytes());
v.resize(44 + data_size as usize, 0);
v
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn is_video_magic_bytes_is_re_exported() {
assert!(crate::video::is_video_magic_bytes(
b"RIFF\x00\x00\x00\x00AVI "
));
}
#[test]
fn mock_ffmpeg_is_available_by_default() {
let m = MockFfmpeg::new();
assert!(m.is_available());
assert_eq!(m.call_count(), 0);
}
#[test]
fn mock_ffmpeg_unavailable_returns_false() {
let m = MockFfmpeg::new().unavailable();
assert!(!m.is_available());
}
#[test]
fn mock_ffmpeg_extract_writes_minimal_wav() {
let m = MockFfmpeg::new();
let result = m
.extract_audio_wav(Path::new("/tmp/fake.mp4"))
.expect("mock should succeed");
assert!(result.output_path.exists());
assert!(result.output_bytes > 44, "minimal WAV must have audio data");
assert_eq!(m.call_count(), 1);
assert_eq!(m.last_input(), Some(PathBuf::from("/tmp/fake.mp4")));
let _ = std::fs::remove_file(&result.output_path);
}
#[test]
fn mock_ffmpeg_returns_overridden_error() {
let m = MockFfmpeg::new().with_error(Error::FfmpegNotFound);
let err = m.extract_audio_wav(Path::new("/tmp/x.mp4")).unwrap_err();
assert!(matches!(err, Error::FfmpegNotFound));
}
#[test]
fn mock_ffmpeg_returns_video_extraction_error() {
let m = MockFfmpeg::new().with_error(Error::VideoExtractionFailed {
path: "video.mp4".into(),
ffmpeg_stderr: "Invalid data found".into(),
});
let err = m.extract_audio_wav(Path::new("video.mp4")).unwrap_err();
match err {
Error::VideoExtractionFailed {
path,
ffmpeg_stderr,
} => {
assert_eq!(path, "video.mp4");
assert_eq!(ffmpeg_stderr, "Invalid data found");
}
other => panic!("expected VideoExtractionFailed, got {other:?}"),
}
}
#[test]
fn mock_ffmpeg_writes_custom_wav_bytes() {
let bytes = vec![0xAA; 100];
let m = MockFfmpeg::new().with_wav_bytes(bytes.clone());
let result = m.extract_audio_wav(Path::new("x.mp4")).unwrap();
let read = std::fs::read(&result.output_path).unwrap();
assert_eq!(read, bytes);
let _ = std::fs::remove_file(&result.output_path);
}
#[test]
fn validate_wav_accepts_valid_wav() {
let path = temp_wav_path();
std::fs::write(&path, minimal_silent_wav()).unwrap();
validate_wav(&path).expect("valid wav should validate");
let _ = std::fs::remove_file(&path);
}
#[test]
fn validate_wav_rejects_missing_file() {
let err = validate_wav(Path::new("/tmp/this/does/not/exist.wav")).unwrap_err();
assert!(matches!(err, Error::VideoExtractionFailed { .. }));
}
#[test]
fn validate_wav_rejects_too_small() {
let path = temp_wav_path();
std::fs::write(&path, b"RIFF").unwrap();
let err = validate_wav(&path).unwrap_err();
assert!(matches!(err, Error::VideoExtractionFailed { .. }));
let _ = std::fs::remove_file(&path);
}
#[test]
fn validate_wav_rejects_non_wav() {
let path = temp_wav_path();
let mut bytes = vec![0u8; 44];
bytes[..4].copy_from_slice(b"RIFF");
bytes[8..12].copy_from_slice(b"OGG ");
std::fs::write(&path, bytes).unwrap();
let err = validate_wav(&path).unwrap_err();
assert!(matches!(err, Error::VideoExtractionFailed { .. }));
let _ = std::fs::remove_file(&path);
}
#[test]
fn temp_output_guard_removes_file_on_drop() {
let path = temp_wav_path();
std::fs::write(&path, b"temporary").unwrap();
assert!(path.exists());
{
let _g = TempOutputGuard::new(path.clone());
}
assert!(!path.exists());
}
#[test]
fn temp_output_guard_into_inner_keeps_file() {
let path = temp_wav_path();
std::fs::write(&path, b"keep me").unwrap();
let path2 = {
let g = TempOutputGuard::new(path.clone());
g.into_inner()
};
assert!(path2.exists());
let _ = std::fs::remove_file(&path2);
}
#[test]
fn minimal_silent_wav_has_1_second_of_data() {
let v = minimal_silent_wav();
assert_eq!(v.len(), 44 + 16000 * 2, "1 second of mono 16-bit at 16kHz");
}
#[test]
fn minimal_silent_wav_has_valid_riff_header() {
let v = minimal_silent_wav();
assert_eq!(&v[..4], b"RIFF");
assert_eq!(&v[8..12], b"WAVE");
assert_eq!(&v[12..16], b"fmt ");
assert_eq!(&v[36..40], b"data");
}
#[test]
fn real_ffmpeg_new_uses_default_binary_name() {
let f = RealFfmpeg::new("ffmpeg");
assert_eq!(f.binary(), "ffmpeg");
}
#[test]
fn real_ffmpeg_with_timeout_overrides() {
let f = RealFfmpeg::new("ffmpeg").with_timeout(Duration::from_secs(5));
let _: RealFfmpeg = f;
}
#[test]
fn env_clear_does_not_leak_proxy() {
let mut cmd = Command::new("true");
configure_secure_subprocess(&mut cmd);
let _ = cmd;
}
#[test]
fn wait_with_timeout_returns_status_quickly() {
let mut cmd = Command::new("true");
cmd.env_clear()
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let child = cmd.spawn().expect("spawn true");
let result = wait_with_timeout(&mut { child }, Duration::from_secs(5));
let mut cmd2 = Command::new("true");
cmd2.env_clear()
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut child2 = cmd2.spawn().expect("spawn true 2");
let r = wait_with_timeout(&mut child2, Duration::from_secs(5));
assert!(r.is_ok());
let _ = result;
}
#[test]
fn minimal_silent_wav_roundtrip_through_validate() {
let path = temp_wav_path();
std::fs::write(&path, minimal_silent_wav()).unwrap();
validate_wav(&path).expect("minimal wav should validate");
let _ = std::fs::remove_file(&path);
}
#[test]
fn cursor_drop_test_unchanged() {
let mut c = Cursor::new(vec![0u8; 16]);
let mut buf = [0u8; 4];
c.read_exact(&mut buf).unwrap();
assert_eq!(buf, [0, 0, 0, 0]);
}
}