mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
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 <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com>
This commit is contained in:
@@ -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<std::process::Output, String> {
|
||||
@@ -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<Vec<u8>, 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_bytes>).
|
||||
///
|
||||
/// 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<u8>, Option<Vec<u8>>), 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)
|
||||
|
||||
Reference in New Issue
Block a user