From 6fb63e9cc9754f2ca9e75596017af854f301e761 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 21 Aug 2026 18:32:00 -0600 Subject: [PATCH] fix(desktop): transcode videos without ffmpeg on macOS Use macOS's built-in AVFoundation converter when ffmpeg is unavailable, including the extensionless temp-file shape used by deferred uploads. The fallback strips source metadata by default and produces fast-start H.264 MP4 accepted by the relay. Co-authored-by: Wes Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Signed-off-by: Wes --- .../src-tauri/src/commands/media_transcode.rs | 199 ++++++++++++++++-- 1 file changed, 184 insertions(+), 15 deletions(-) diff --git a/desktop/src-tauri/src/commands/media_transcode.rs b/desktop/src-tauri/src/commands/media_transcode.rs index 3fb7eda5f0..601529a3e1 100644 --- a/desktop/src-tauri/src/commands/media_transcode.rs +++ b/desktop/src-tauri/src/commands/media_transcode.rs @@ -122,18 +122,17 @@ pub(super) fn has_heic_extension(path: &std::path::Path) -> bool { /// blocking a Tokio worker thread indefinitely. const FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); -/// Run an ffmpeg command with a wall-clock timeout and optional cancellation. +/// Run a child process with a wall-clock timeout and optional cancellation. /// /// Spawns the child process, polls `try_wait()` every 500ms, and kills it /// if the deadline is exceeded. Returns the same `Output` as `Command::output()`. /// -/// **IMPORTANT**: callers MUST pass `-loglevel error` (or `quiet`) to ffmpeg. -/// This function reads stderr only after the child exits. If ffmpeg writes -/// enough progress/diagnostic output to fill the OS pipe buffer (~64 KiB), -/// the child blocks on write() and never exits — causing a false timeout. -/// `-loglevel error` suppresses progress spam, keeping stderr small. -fn run_ffmpeg_with_cancellation( +/// **IMPORTANT**: callers must keep piped output bounded. This function reads +/// stderr only after the child exits; enough output to fill the OS pipe would +/// block the child and cause a false timeout. +fn run_process_with_cancellation( cmd: &mut std::process::Command, + process_name: &str, timeout: std::time::Duration, cancellation: Option<&CancellationToken>, ) -> Result { @@ -142,7 +141,7 @@ fn run_ffmpeg_with_cancellation( } let mut child = cmd .spawn() - .map_err(|e| format!("failed to spawn ffmpeg: {e}"))?; + .map_err(|e| format!("failed to spawn {process_name}: {e}"))?; let deadline = std::time::Instant::now() + timeout; loop { @@ -175,11 +174,14 @@ fn run_ffmpeg_with_cancellation( if std::time::Instant::now() > deadline { let _ = child.kill(); let _ = child.wait(); // reap zombie - return Err(format!("ffmpeg timed out after {}s", timeout.as_secs())); + return Err(format!( + "{process_name} timed out after {}s", + timeout.as_secs() + )); } std::thread::sleep(std::time::Duration::from_millis(500)); } - Err(e) => return Err(format!("failed to wait on ffmpeg: {e}")), + Err(e) => return Err(format!("failed to wait on {process_name}: {e}")), } } } @@ -199,7 +201,7 @@ fn transcode_to_mp4_with_cancellation( // UUID-based temp path — unique across concurrent uploads. let output = std::env::temp_dir().join(format!("buzz-transcode-{}.mp4", uuid::Uuid::new_v4())); - let result = run_ffmpeg_with_cancellation( + let result = run_process_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -250,6 +252,7 @@ fn transcode_to_mp4_with_cancellation( .arg(&output) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), + "ffmpeg", FFMPEG_TIMEOUT, cancellation, ) @@ -291,7 +294,7 @@ fn transcode_heic_to_jpeg( // Single-frame image decode — 60s is generous even for large HEICs. let heic_timeout = std::time::Duration::from_secs(60); - let result = run_ffmpeg_with_cancellation( + let result = run_process_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -316,6 +319,7 @@ fn transcode_heic_to_jpeg( .arg(&output) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), + "ffmpeg", heic_timeout, cancellation, ) @@ -378,7 +382,7 @@ fn extract_poster_frame_with_cancellation( let poster_timeout = std::time::Duration::from_secs(30); // Try seeking to 1s first (avoids black first frames from fade-ins). - let result = run_ffmpeg_with_cancellation( + let result = run_process_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -396,6 +400,7 @@ fn extract_poster_frame_with_cancellation( .arg(&output) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), + "ffmpeg", poster_timeout, cancellation, )?; @@ -410,7 +415,7 @@ fn extract_poster_frame_with_cancellation( eprintln!("buzz-desktop: poster seek-to-1s failed, trying first frame: {stderr}"); } let _ = std::fs::remove_file(&output); - let fallback = run_ffmpeg_with_cancellation( + let fallback = run_process_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -426,6 +431,7 @@ fn extract_poster_frame_with_cancellation( .arg(&output) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), + "ffmpeg", poster_timeout, cancellation, )?; @@ -441,6 +447,96 @@ fn extract_poster_frame_with_cancellation( Ok(output) } +/// Give `avconvert` a source path whose extension matches its magic bytes. +/// +/// The deferred-upload path starts from raw bytes and therefore uses an +/// extensionless temp file. AVFoundation can identify those bytes, but the +/// `avconvert` CLI refuses to open the file unless its path has a recognized +/// video extension. Stage only mismatched paths, preferring a hard link to +/// avoid copying large uploads. +#[cfg(target_os = "macos")] +fn prepare_avconvert_source( + source: &std::path::Path, +) -> Result<(std::path::PathBuf, bool), String> { + let extension = infer::get_from_path(source) + .map_err(|error| format!("failed to inspect video for conversion: {error}"))? + .filter(|kind| kind.matcher_type() == infer::MatcherType::Video) + .map_or("mov", |kind| kind.extension()); + + if source + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case(extension)) + { + return Ok((source.to_path_buf(), false)); + } + + let staged = std::env::temp_dir().join(format!( + "buzz-avconvert-source-{}.{}", + uuid::Uuid::new_v4(), + extension + )); + if std::fs::hard_link(source, &staged).is_err() { + std::fs::copy(source, &staged) + .map_err(|error| format!("failed to stage video for conversion: {error}"))?; + } + Ok((staged, true)) +} + +/// Transcode a video with macOS's built-in AVFoundation converter. +/// +/// Release builds do not bundle ffmpeg, and a stock macOS installation does +/// not provide it. `avconvert` normalizes QuickTime screen recordings to an +/// MP4 that the relay accepts while applying Apple's default metadata filter. +#[cfg(target_os = "macos")] +fn transcode_with_avconvert( + source: &std::path::Path, + cancellation: Option<&CancellationToken>, +) -> Result, String> { + let output = std::env::temp_dir().join(format!("buzz-avconvert-{}.mp4", uuid::Uuid::new_v4())); + let (prepared_source, remove_prepared_source) = prepare_avconvert_source(source)?; + + let result = (|| { + let mut command = std::process::Command::new("/usr/bin/avconvert"); + command + .env_clear() + .env("LANG", "C") + .args(["--source"]) + .arg(&prepared_source) + .args(["--preset", "PresetHighestQuality", "--output"]) + .arg(&output) + .arg("--replace") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()); + crate::util::configure_no_window(&mut command); + + let process = + run_process_with_cancellation(&mut command, "avconvert", FFMPEG_TIMEOUT, cancellation)?; + + if !process.status.success() || !output.exists() { + let stderr = String::from_utf8_lossy(&process.stderr); + let detail = stderr + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("unknown error"); + return Err(format!("Video conversion failed: {detail}")); + } + + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("upload cancelled".to_string()); + } + + std::fs::read(&output).map_err(|error| format!("failed to read converted video: {error}")) + })(); + + let _ = std::fs::remove_file(&output); + if remove_prepared_source { + let _ = std::fs::remove_file(&prepared_source); + } + result +} + /// Transcode video and extract poster frame. Returns (video_bytes, Option). /// /// Poster extraction is best-effort — if it fails, returns `None` for the poster @@ -455,7 +551,13 @@ pub(super) fn transcode_and_extract_poster_with_cancellation( source: &std::path::Path, cancellation: Option<&CancellationToken>, ) -> Result<(Vec, Option>), String> { - let ffmpeg_path = find_ffmpeg()?; + let ffmpeg_path = match find_ffmpeg() { + Ok(path) => path, + #[cfg(target_os = "macos")] + Err(_) => return transcode_with_avconvert(source, cancellation).map(|video| (video, None)), + #[cfg(not(target_os = "macos"))] + Err(error) => return Err(error), + }; let transcoded = transcode_to_mp4_with_cancellation(source, &ffmpeg_path, cancellation)?; // Extract poster from the transcoded file (not the original — guarantees decodability). @@ -488,6 +590,73 @@ pub(super) fn transcode_and_extract_poster_with_cancellation( mod tests { use super::*; + #[cfg(target_os = "macos")] + #[test] + fn test_avconvert_stages_extensionless_quicktime_source() { + let source = std::env::temp_dir().join(format!( + "buzz-avconvert-extensionless-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::write(&source, b"\0\0\0\x14ftypqt \0\0\0\0qt ").expect("write QuickTime header"); + + let (prepared, remove_prepared) = + prepare_avconvert_source(&source).expect("stage QuickTime source"); + assert!(remove_prepared); + assert_eq!( + prepared.extension().and_then(|value| value.to_str()), + Some("mov") + ); + assert_eq!( + std::fs::read(&prepared).expect("read staged source"), + std::fs::read(&source).expect("read original source") + ); + + let _ = std::fs::remove_file(source); + let _ = std::fs::remove_file(prepared); + } + + #[cfg(target_os = "macos")] + #[test] + fn test_avconvert_normalizes_configured_quicktime_fixture() { + let Some(source) = std::env::var_os("BUZZ_TEST_QUICKTIME_PATH") else { + eprintln!("skipping avconvert round-trip: BUZZ_TEST_QUICKTIME_PATH is unset"); + return; + }; + let staged_source = std::env::temp_dir().join(format!( + "buzz-avconvert-configured-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::copy(&source, &staged_source).expect("stage extensionless QuickTime fixture"); + + let video = + transcode_with_avconvert(&staged_source, None).expect("convert QuickTime fixture"); + let _ = std::fs::remove_file(staged_source); + let converted = std::env::temp_dir().join(format!( + "buzz-avconvert-configured-output-{}.mp4", + uuid::Uuid::new_v4() + )); + std::fs::write(&converted, &video).expect("write converted fixture"); + let relay_config = buzz_media_pkg::MediaConfig { + s3_endpoint: String::new(), + s3_access_key: String::new(), + s3_secret_key: String::new(), + s3_bucket: String::new(), + s3_region: "us-east-1".to_string(), + s3_addressing_style: buzz_media_pkg::S3AddressingStyle::Path, + max_image_bytes: 50 * 1024 * 1024, + max_gif_bytes: 10 * 1024 * 1024, + max_video_bytes: 500 * 1024 * 1024, + max_file_bytes: 100 * 1024 * 1024, + public_base_url: String::new(), + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + }; + buzz_media_pkg::validation::validate_video_file(&converted, &relay_config) + .expect("relay rejected avconvert output"); + let _ = std::fs::remove_file(converted); + } + #[test] fn test_is_video_file_mp4() { // Minimal ftyp box (MP4 magic bytes)