Files
buzz/desktop/src-tauri/src/commands/media_transcode.rs
Taylor Ho d32f3c0a58 feat(media): transcode HEIC/HEIF to JPEG on desktop upload (#1257)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
2026-06-26 09:34:26 -07:00

539 lines
20 KiB
Rust

//! Video transcoding and poster-frame extraction via ffmpeg.
//!
//! Split out of `media.rs` to keep that file under the desktop line-size
//! limit. These helpers are used by the upload pipeline to normalize any
//! video to H.264/AAC/MP4/fast-start (guaranteed to pass the relay's
//! `validate_video_file()`) and to produce a JPEG poster frame.
use crate::managed_agents::resolve_command;
/// Locate ffmpeg using the same discovery logic as managed agents
/// (login shell PATH, /opt/homebrew/bin, /usr/local/bin, etc.).
/// Returns the resolved absolute path on success.
pub(super) fn find_ffmpeg() -> Result<std::path::PathBuf, String> {
let ffmpeg_path = resolve_command("ffmpeg").ok_or_else(|| {
"ffmpeg is required for video uploads but was not found.\n\n\
Install it:\n \
macOS: brew install ffmpeg\n \
Linux: sudo apt install ffmpeg\n \
Windows: winget install ffmpeg"
.to_string()
})?;
match std::process::Command::new(&ffmpeg_path)
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
Ok(s) if s.success() => Ok(ffmpeg_path),
Ok(_) => Err(
"ffmpeg was found but returned an error — it may be broken or misconfigured"
.to_string(),
),
Err(e) => Err(format!("failed to check for ffmpeg: {e}")),
}
}
/// Detect if a file is a video based on magic bytes.
pub(super) fn is_video_file(buf: &[u8]) -> bool {
infer::get(buf).is_some_and(|t| t.mime_type().starts_with("video/"))
}
/// HEIC/HEIF compatible-brand codes that mark an ISO-BMFF file as a still
/// HEIF image. Mirrors mobile's `_heicBrands` set in
/// `mobile/lib/shared/relay/media_upload.dart` so detection stays consistent
/// across platforms — deliberately broader than the `infer` crate, which only
/// recognizes `heic`/`heix` majors (or `mif1`/`msf1` with a `heic` compatible
/// brand) and would miss `hevc`/`hevx`/`heim`/`heis`.
const HEIC_BRANDS: &[&[u8; 4]] = &[
b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1",
];
/// Detect a HEIC/HEIF still image by magic bytes.
///
/// HEIC/HEIF is an ISO base media file (ISO-BMFF): a `ftyp` box at offset 4
/// followed by a major brand and a list of compatible brands. We scan the
/// major brand plus the compatible-brand list for any of `HEIC_BRANDS`.
///
/// Mirrors mobile's `_looksLikeHeicOrHeif`: requires the `ftyp` marker at
/// offset 4 and scans 4-byte brand codes at offsets 8, 12, 16, ... up to the
/// first 32 bytes. The Tauri webview / Chromium cannot decode HEIC, so any
/// match here is transcoded to JPEG before upload.
pub(super) fn is_heic_file(buf: &[u8]) -> bool {
// Need at least the 8-byte box header + 4-byte major brand.
if buf.len() < 12 || &buf[4..8] != b"ftyp" {
return false;
}
// Scan the major brand (offset 8) and each compatible brand, bounded to
// the first 32 bytes (matches mobile's window).
let upper = buf.len().min(32);
let mut offset = 8;
while offset + 4 <= upper {
let brand: &[u8; 4] = buf[offset..offset + 4].try_into().expect("4-byte slice");
if HEIC_BRANDS.contains(&brand) {
return true;
}
offset += 4;
}
false
}
/// True if a filename ends in `.heic` or `.heif` (case-insensitive).
///
/// Mirrors mobile's `_hasHeicFileExtension`. Used on the file-picker path as a
/// secondary signal — some HEIC files from non-Apple tooling carry brands not
/// in `HEIC_BRANDS`, but the extension still tells us the webview can't render
/// them. The byte-based path (paste/drag) has no filename and relies solely on
/// `is_heic_file`.
pub(super) fn has_heic_extension(path: &std::path::Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("heic") || ext.eq_ignore_ascii_case("heif"))
}
/// Maximum wall-clock time for an ffmpeg transcode before we kill it.
/// 10 minutes is generous for any reasonable video; pathological inputs
/// (crafted to cause exponential decode time) get killed instead of
/// 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.
///
/// 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.
pub(super) fn run_ffmpeg_with_timeout(
cmd: &mut std::process::Command,
timeout: std::time::Duration,
) -> Result<std::process::Output, String> {
let mut child = cmd
.spawn()
.map_err(|e| format!("failed to spawn ffmpeg: {e}"))?;
let deadline = std::time::Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => {
// Process exited — collect output.
let stdout = child.stdout.take().map_or_else(Vec::new, |mut s| {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut s, &mut buf);
buf
});
let stderr = child.stderr.take().map_or_else(Vec::new, |mut s| {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut s, &mut buf);
buf
});
return Ok(std::process::Output {
status,
stdout,
stderr,
});
}
Ok(None) => {
// Still running — check deadline.
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()));
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
Err(e) => return Err(format!("failed to wait on ffmpeg: {e}")),
}
}
}
/// Transcode any video file to H.264/AAC/MP4/fast-start via ffmpeg.
///
/// Always re-encodes — handles HEVC, VP9, ProRes, non-faststart MP4, 10-bit,
/// wrong pixel format, MOV containers, etc. Output is guaranteed to pass the
/// relay's `validate_video_file()`.
///
/// Returns the path to a temp file. Caller must clean up.
pub(super) fn transcode_to_mp4(
source: &std::path::Path,
ffmpeg: &std::path::Path,
) -> Result<std::path::PathBuf, String> {
// 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_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"]) // suppress progress spam — prevents stderr pipe deadlock
.arg("-i")
.arg(source) // OsStr — handles non-UTF-8 paths on Unix
.args([
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
FFMPEG_TIMEOUT,
)?;
if !result.status.success() {
let _ = std::fs::remove_file(&output);
let stderr = String::from_utf8_lossy(&result.stderr);
let detail = stderr
.lines()
.rev()
.find(|l| !l.is_empty() && !l.starts_with(" "))
.unwrap_or("unknown error");
return Err(format!("Video conversion failed: {detail}"));
}
Ok(output)
}
/// Transcode a HEIC/HEIF still image to JPEG via ffmpeg.
///
/// The Tauri webview / Chromium cannot decode HEIC, so iPhone photos uploaded
/// as-is render blank in the composer and are unviewable for everyone. This
/// normalizes them to JPEG (the same fix mobile applies before upload).
///
/// Uses `-frames:v 1` so multi-image HEIF containers (Live Photos, bursts)
/// yield a single still, and `-q:v 2` for high JPEG quality. Returns the path
/// to a temp file. Caller must clean up.
pub(super) fn transcode_heic_to_jpeg(
source: &std::path::Path,
ffmpeg: &std::path::Path,
) -> Result<std::path::PathBuf, String> {
// UUID-based temp path — unique across concurrent uploads.
let output = std::env::temp_dir().join(format!("buzz-heic-{}.jpg", uuid::Uuid::new_v4()));
// 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_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"]) // suppress progress spam — prevents stderr pipe deadlock
.arg("-i")
.arg(source) // OsStr — handles non-UTF-8 paths on Unix
.args(["-frames:v", "1", "-q:v", "2"])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
heic_timeout,
)?;
if !result.status.success() {
let _ = std::fs::remove_file(&output);
let stderr = String::from_utf8_lossy(&result.stderr);
let detail = stderr
.lines()
.rev()
.find(|l| !l.is_empty() && !l.starts_with(" "))
.unwrap_or("unknown error");
return Err(format!("HEIC conversion failed: {detail}"));
}
Ok(output)
}
/// Transcode a HEIC/HEIF still image (from a path) to JPEG bytes.
///
/// Resolves ffmpeg, transcodes, reads the JPEG bytes, and cleans up the temp
/// file. Mirrors `transcode_and_extract_poster` but for images (no poster).
pub(super) fn transcode_heic_path_to_jpeg_bytes(
source: &std::path::Path,
) -> Result<Vec<u8>, String> {
let ffmpeg_path = find_ffmpeg()?;
let jpeg_path = transcode_heic_to_jpeg(source, &ffmpeg_path)?;
let bytes =
std::fs::read(&jpeg_path).map_err(|e| format!("failed to read transcoded HEIC: {e}"));
let _ = std::fs::remove_file(&jpeg_path);
bytes
}
/// Extract a single JPEG poster frame from a transcoded MP4 via ffmpeg.
///
/// Seeks to 1 second (avoids black leader frames), falls back to first frame
/// for videos shorter than 1 second. Output is scaled to 640px wide with even
/// dimensions. Returns the path to a temp JPEG. Caller must clean up.
///
/// Best-effort: returns `Err` on failure — callers should log and continue
/// without a poster rather than failing the entire video upload.
pub(super) fn extract_poster_frame(
mp4_path: &std::path::Path,
ffmpeg: &std::path::Path,
) -> Result<std::path::PathBuf, String> {
let output = std::env::temp_dir().join(format!("buzz-poster-{}.jpg", uuid::Uuid::new_v4()));
// Poster extraction is a single-frame decode — 30s is generous.
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_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"])
.arg("-ss")
.arg("1")
.arg("-i")
.arg(mp4_path)
.args(["-vframes", "1", "-vf", "scale=640:-2", "-q:v", "2"])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
poster_timeout,
)?;
// If seek to 1s failed (video shorter than 1s), retry from first frame.
if !result.status.success()
|| !output.exists()
|| std::fs::metadata(&output).map_or(true, |m| m.len() == 0)
{
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
eprintln!("buzz-desktop: poster seek-to-1s failed, trying first frame: {stderr}");
}
let _ = std::fs::remove_file(&output);
let fallback = run_ffmpeg_with_timeout(
std::process::Command::new(ffmpeg)
.args(["-y", "-loglevel", "error"])
.arg("-i")
.arg(mp4_path)
.args(["-vframes", "1", "-vf", "scale=640:-2", "-q:v", "2"])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
poster_timeout,
)?;
if !fallback.status.success() || !output.exists() {
let stderr = String::from_utf8_lossy(&fallback.stderr);
eprintln!("buzz-desktop: poster frame extraction failed: {stderr}");
let _ = std::fs::remove_file(&output);
return Err("ffmpeg could not extract a poster frame".to_string());
}
}
Ok(output)
}
/// 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
/// and the video bytes are still valid. All temp files are cleaned up.
pub(super) fn transcode_and_extract_poster(
source: &std::path::Path,
) -> Result<(Vec<u8>, Option<Vec<u8>>), String> {
let ffmpeg_path = find_ffmpeg()?;
let transcoded = transcode_to_mp4(source, &ffmpeg_path)?;
// Extract poster from the transcoded file (not the original — guarantees decodability).
let poster_bytes = match extract_poster_frame(&transcoded, &ffmpeg_path) {
Ok(poster_path) => {
let bytes = std::fs::read(&poster_path).ok();
let _ = std::fs::remove_file(&poster_path);
bytes
}
Err(e) => {
eprintln!("buzz-desktop: poster extraction failed (non-fatal): {e}");
None
}
};
let video_bytes =
std::fs::read(&transcoded).map_err(|e| format!("failed to read transcoded file: {e}"));
let _ = std::fs::remove_file(&transcoded);
Ok((video_bytes?, poster_bytes))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_video_file_mp4() {
// Minimal ftyp box (MP4 magic bytes)
let ftyp: &[u8] = &[
0x00, 0x00, 0x00, 0x14, b'f', b't', b'y', b'p', b'i', b's', b'o', b'm', 0x00, 0x00,
0x00, 0x00, b'i', b's', b'o', b'm',
];
assert!(is_video_file(ftyp));
}
#[test]
fn test_is_video_file_jpeg_is_not_video() {
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0];
assert!(!is_video_file(&jpeg));
}
#[test]
fn test_is_video_file_empty() {
assert!(!is_video_file(&[]));
}
#[test]
fn test_find_ffmpeg_runs() {
// This test verifies the function doesn't panic.
// It may pass or fail depending on whether ffmpeg is installed.
let _ = find_ffmpeg();
}
/// Build a minimal ISO-BMFF `ftyp` box header with the given major brand
/// and optional compatible brands, suitable for `is_heic_file` testing.
fn ftyp_box(major: &[u8; 4], compatible: &[&[u8; 4]]) -> Vec<u8> {
let mut buf = vec![0x00, 0x00, 0x00, 0x00]; // box size (unused by detector)
buf.extend_from_slice(b"ftyp");
buf.extend_from_slice(major);
buf.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); // minor version
for brand in compatible {
buf.extend_from_slice(*brand);
}
buf
}
#[test]
fn test_is_heic_file_major_brands() {
// Every brand in HEIC_BRANDS should be detected as the major brand.
for brand in HEIC_BRANDS {
let buf = ftyp_box(brand, &[]);
assert!(is_heic_file(&buf), "major brand {brand:?} not detected");
}
}
#[test]
fn test_is_heic_file_variants_infer_misses() {
// These brands are detected by mobile but NOT by the `infer` crate's
// HEIC heuristic — the whole reason we mirror mobile's full set.
for brand in [b"hevc", b"hevx", b"heim", b"heis"] {
let buf = ftyp_box(brand, &[]);
assert!(is_heic_file(&buf), "variant brand {brand:?} not detected");
}
}
#[test]
fn test_is_heic_file_compatible_brand() {
// Major brand is generic (mif1), HEIC signaled via compatible brand.
let buf = ftyp_box(b"mif1", &[b"heic"]);
assert!(is_heic_file(&buf));
}
#[test]
fn test_is_heic_file_jpeg_is_not_heic() {
let jpeg = [
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, b'J', b'F', b'I', b'F', 0x00, 0x01,
];
assert!(!is_heic_file(&jpeg));
}
#[test]
fn test_is_heic_file_mp4_is_not_heic() {
// An MP4 ftyp box (isom) must not be misdetected as HEIC.
let mp4 = ftyp_box(b"isom", &[b"isom", b"iso2"]);
assert!(!is_heic_file(&mp4));
}
#[test]
fn test_is_heic_file_empty() {
assert!(!is_heic_file(&[]));
}
#[test]
fn test_is_heic_file_too_short() {
// Has `ftyp` marker but fewer than 12 bytes — below mobile's threshold.
let buf = [0x00, 0x00, 0x00, 0x00, b'f', b't', b'y', b'p'];
assert!(!is_heic_file(&buf));
}
#[test]
fn test_is_heic_file_no_ftyp_marker() {
// 12+ bytes containing a HEIC brand but no `ftyp` at offset 4.
let mut buf = vec![0u8; 16];
buf[8..12].copy_from_slice(b"heic");
assert!(!is_heic_file(&buf));
}
#[test]
fn test_is_heic_file_brand_past_window() {
// A HEIC brand sitting beyond the 32-byte scan window must not match,
// matching mobile's bounded scan. Use non-HEIC major + filler brands
// so the only HEIC brand present is the one pushed past offset 32.
let mut buf = ftyp_box(b"isom", &[b"iso2", b"iso4", b"avc1", b"mp41", b"mp42"]);
buf.extend_from_slice(b"heic"); // lands at offset 36, past the window
assert!(!is_heic_file(&buf));
}
#[test]
fn test_has_heic_extension() {
use std::path::Path;
assert!(has_heic_extension(Path::new("IMG_1234.HEIC")));
assert!(has_heic_extension(Path::new("photo.heic")));
assert!(has_heic_extension(Path::new("photo.heif")));
assert!(has_heic_extension(Path::new("photo.HEIF")));
assert!(!has_heic_extension(Path::new("photo.jpg")));
assert!(!has_heic_extension(Path::new("photo.png")));
assert!(!has_heic_extension(Path::new("noextension")));
}
/// Round-trip transcode test, gated on ffmpeg being present so CI without
/// ffmpeg doesn't fail. Generates a HEIC via ffmpeg, then transcodes it
/// back to JPEG and asserts the output is a valid JPEG.
#[test]
fn test_transcode_heic_round_trip() {
let Ok(ffmpeg) = find_ffmpeg() else {
eprintln!("skipping HEIC round-trip: ffmpeg not found");
return;
};
// Generate a small HEIC test image from a synthetic color source.
let heic_path =
std::env::temp_dir().join(format!("buzz-test-{}.heic", uuid::Uuid::new_v4()));
let gen = std::process::Command::new(&ffmpeg)
.args(["-y", "-loglevel", "error", "-f", "lavfi", "-i"])
.arg("color=c=red:s=64x64:d=1")
.args(["-frames:v", "1"])
.arg(&heic_path)
.output();
let gen = match gen {
Ok(o) if o.status.success() && heic_path.exists() => o,
other => {
// This ffmpeg build can't encode HEIC — skip rather than fail.
eprintln!("skipping HEIC round-trip: ffmpeg cannot encode HEIC: {other:?}");
let _ = std::fs::remove_file(&heic_path);
return;
}
};
drop(gen);
// Sanity: the generated file should be detected as HEIC.
let heic_bytes = std::fs::read(&heic_path).expect("read generated heic");
assert!(
is_heic_file(&heic_bytes),
"generated file not detected as HEIC"
);
// Transcode to JPEG bytes and verify the JPEG magic.
let jpeg = transcode_heic_path_to_jpeg_bytes(&heic_path).expect("transcode to jpeg");
let _ = std::fs::remove_file(&heic_path);
assert!(jpeg.len() > 2, "empty jpeg output");
assert_eq!(&jpeg[0..2], &[0xFF, 0xD8], "output is not a JPEG");
}
}