From ff1ce82f66dc993baf0e80deaf0dbe5333fa27b6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 8 Apr 2026 10:27:14 -0400 Subject: [PATCH] transcode quicktime (#1318) --- .../src/ffmpeg/ffmpeg_transcode_to_mp4.rs | 134 ++++++++++++++++++ crates/lib/ffmpeg_utils/src/ffmpeg/mod.rs | 1 + .../upload_new_video_media_file_handler.rs | 129 ++++++++++++++++- 3 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 crates/lib/ffmpeg_utils/src/ffmpeg/ffmpeg_transcode_to_mp4.rs diff --git a/crates/lib/ffmpeg_utils/src/ffmpeg/ffmpeg_transcode_to_mp4.rs b/crates/lib/ffmpeg_utils/src/ffmpeg/ffmpeg_transcode_to_mp4.rs new file mode 100644 index 0000000000..8cb51c7a32 --- /dev/null +++ b/crates/lib/ffmpeg_utils/src/ffmpeg/ffmpeg_transcode_to_mp4.rs @@ -0,0 +1,134 @@ +use anyhow::bail; +use log::{error, info}; +use std::io::Write; +use std::path::Path; +use std::process::Command; + +use errors::AnyhowResult; + +pub struct FfmpegTranscodeToMp4Args<'a> { + pub video_input_path: &'a Path, + pub video_output_path: &'a Path, +} + +/// Transcode an arbitrary input video into a streaming-friendly H.264 / AAC +/// MP4 file. +/// +/// Output container: MP4 (faststart-enabled so the moov atom lives at the +/// front, which makes the file usable by HTTP range-request video players +/// before the whole thing has downloaded). +/// +/// Output streams: +/// - video: H.264 (libx264), yuv420p, CRF 23, "fast" preset +/// - audio: AAC (libfdk-aac unavailable in stock ffmpeg → libmp3lame? no → +/// plain `aac`), 128 kbps +/// +/// Audio is transcoded rather than copied so the output is consistent +/// regardless of the input container's audio codec. +pub fn ffmpeg_transcode_to_mp4(args: FfmpegTranscodeToMp4Args) -> AnyhowResult<()> { + let mut command = Command::new("ffmpeg"); + + command + .arg("-nostdin") + .arg("-y") + .arg("-i").arg(args.video_input_path) + // Video: H.264, broadly compatible chroma subsampling. + .arg("-c:v").arg("libx264") + .arg("-preset").arg("fast") + .arg("-crf").arg("23") + .arg("-pix_fmt").arg("yuv420p") + // Audio: AAC at 128 kbps. + .arg("-c:a").arg("aac") + .arg("-b:a").arg("128k") + // MP4 container with the moov atom moved to the front for fast HTTP streaming. + .arg("-movflags").arg("+faststart") + .arg(args.video_output_path); + + info!("Calling ffmpeg (transcode to mp4)..."); + + let output = command.output()?; + + if !output.status.success() { + error!("bad exit status: {}", output.status); + + let _r = std::io::stdout().write_all(&output.stdout); + let _r = std::io::stderr().write_all(&output.stderr); + + bail!("ffmpeg transcode to mp4 failed: {:?}", output.status.to_string()); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempdir::TempDir; + use test_utils::test_file_path::test_file_path; + + #[test] + fn test_transcode_mov_to_mp4() { + let input_path = test_file_path("test_data/video/mov/majoras_mask_intro.mov") + .expect("test video should exist"); + + let temp_dir = TempDir::new_in("/tmp", "ffmpeg_transcode_to_mp4_test") + .expect("should create temp dir"); + + let output_path = temp_dir.path().join("transcoded.mp4"); + + ffmpeg_transcode_to_mp4(FfmpegTranscodeToMp4Args { + video_input_path: &input_path, + video_output_path: &output_path, + }).expect("ffmpeg should succeed"); + + // ── Output file exists and is non-empty ─────────────────────────────── + assert!(output_path.exists(), "output mp4 should exist"); + let metadata = std::fs::metadata(&output_path).expect("should stat output"); + assert!(metadata.len() > 0, "output mp4 should be non-empty"); + + // ── ffprobe the output and assert codecs / container ────────────────── + let probe = ffprobe::ffprobe(&output_path) + .expect("ffprobe should succeed on the transcoded file"); + + // Container is mp4 — ffprobe reports it as either "mov,mp4,m4a,3gp,3g2,mj2" + // or "mp4" depending on version. Both contain "mp4". + let format_name = &probe.format.format_name; + assert!( + format_name.contains("mp4"), + "expected mp4 container, got format_name = {:?}", + format_name, + ); + + // Video stream is H.264. + let video_stream = probe.streams.iter() + .find(|s| s.codec_type.as_deref() == Some("video")) + .expect("should have a video stream"); + assert_eq!( + video_stream.codec_name.as_deref(), + Some("h264"), + "expected H.264 video, got {:?}", + video_stream.codec_name, + ); + assert_eq!( + video_stream.pix_fmt.as_deref(), + Some("yuv420p"), + "expected yuv420p pixel format, got {:?}", + video_stream.pix_fmt, + ); + + // Audio stream is AAC (if the source has audio — Majora's Mask intro does). + let audio_stream = probe.streams.iter() + .find(|s| s.codec_type.as_deref() == Some("audio")) + .expect("should have an audio stream"); + assert_eq!( + audio_stream.codec_name.as_deref(), + Some("aac"), + "expected AAC audio, got {:?}", + audio_stream.codec_name, + ); + + // Sanity-check that the video has non-zero dimensions / duration. + assert!(video_stream.width.unwrap_or(0) > 0, "video should have width > 0"); + assert!(video_stream.height.unwrap_or(0) > 0, "video should have height > 0"); + } +} diff --git a/crates/lib/ffmpeg_utils/src/ffmpeg/mod.rs b/crates/lib/ffmpeg_utils/src/ffmpeg/mod.rs index ce074120e0..f4632b860d 100644 --- a/crates/lib/ffmpeg_utils/src/ffmpeg/mod.rs +++ b/crates/lib/ffmpeg_utils/src/ffmpeg/mod.rs @@ -1,4 +1,5 @@ pub mod ffmpeg_timestamp_from_duration; +pub mod ffmpeg_transcode_to_mp4; pub mod ffmpeg_trim_and_resample; pub mod ffmpeg_video_first_frame_to_jpg_thumbnail; pub mod ffmpeg_video_gif_preview; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_video_new/upload_new_video_media_file_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_video_new/upload_new_video_media_file_handler.rs index 07e1efdc01..6a035b4a2f 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_video_new/upload_new_video_media_file_handler.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/media_files/upload/upload_video_new/upload_new_video_media_file_handler.rs @@ -17,6 +17,7 @@ use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path: use enums::by_table::media_files::media_file_class::MediaFileClass; use enums::by_table::media_files::media_file_type::MediaFileType; use enums::common::visibility::Visibility; +use ffmpeg_utils::ffmpeg::ffmpeg_transcode_to_mp4::{ffmpeg_transcode_to_mp4, FfmpegTranscodeToMp4Args}; use ffmpeg_utils::ffmpeg::ffmpeg_trim_and_resample::{ffmpeg_trim_and_resample, Args}; use ffmpeg_utils::ffprobe::ffprobe_get_info::ffprobe_get_info; use filesys::file_read_bytes::file_read_bytes; @@ -114,6 +115,13 @@ static ALLOWED_MIME_TYPES : Lazy> = Lazy::new(|| { ]) }); +static TRANSCODE_MIME_TYPES : Lazy> = Lazy::new(|| { + HashSet::from([ + // Video + "video/quicktime", // .qt quicktime files + ]) +}); + /// This endpoint is for uploading video files. #[utoipa::path( post, @@ -238,7 +246,9 @@ pub async fn upload_new_video_media_file_handler( MediaFileUploadError::BadInput("Could not determine mimetype for file".to_string()) })?; - if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) { + let needs_transcode = TRANSCODE_MIME_TYPES.contains(mimetype.as_str()); + + if !ALLOWED_MIME_TYPES.contains(mimetype.as_str()) && !needs_transcode { // NB: Don't let our error message inject malicious strings let filtered_mimetype = mimetype .chars() @@ -248,16 +258,74 @@ pub async fn upload_new_video_media_file_handler( return Err(MediaFileUploadError::BadInput(format!("unpermitted mime type: {}", &filtered_mimetype))); } + // ==================== ORIGINAL FILE BOOKKEEPING ==================== // + // Hold on to the user's original file (path + mimetype + extension) so we + // can upload it as a `_original.{ext}` sibling later if we end up + // transcoding and/or resampling it. + + let original_file_path: PathBuf = form.file.file.path().to_path_buf(); + let original_mimetype: String = mimetype.clone(); + let original_filename_extension: Option = form.file.file_name.as_deref() + .and_then(|filename| std::path::Path::new(filename).extension()) + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_string()); + let original_extension: String = mimetype_to_extension(&original_mimetype) + .map(|ext| ext.to_string()) + .or(original_filename_extension) + .ok_or_else(|| { + warn!("Could not determine file extension for original mimetype: {}", &original_mimetype); + MediaFileUploadError::ServerError + })?; + + // ==================== OPTIONAL TRANSCODE TO MP4 ==================== // + // Some accepted source mime types (e.g. QuickTime .mov) are not the canonical + // distribution format we want for the media file record. Transcode them to + // mp4 in a tempdir so we can resample / upload mp4 instead. + + // Tempdirs we need to keep alive across the upload step. They get dropped at + // function exit which cleans up the on-disk files. + let mut transcode_tempdir_ref = None; + let mut resample_tempdir_ref = None; + + let mut final_upload_file_path = form.file.file.path().to_path_buf(); + + if needs_transcode { + let transcode_tempdir = server_state.temp_dir_creator.new_tempdir("ffmpeg_transcode") + .map_err(|err| { + error!("Problem creating transcode temp dir: {:?}", err); + MediaFileUploadError::ServerError + })?; + + let transcoded_path = transcode_tempdir.path().join("transcoded.mp4"); + + info!("Transcoding {} → mp4", &original_mimetype); + ffmpeg_transcode_to_mp4(FfmpegTranscodeToMp4Args { + video_input_path: form.file.file.path(), + video_output_path: &transcoded_path, + }).map_err(|err| { + error!("Problem transcoding video to mp4: {:?}", err); + MediaFileUploadError::ServerError + })?; + + // The canonical file is now the transcoded mp4. Read its bytes and pin + // its mimetype to mp4 so all the downstream metadata reflects the + // transcoded file rather than the user's original upload. + file_bytes = file_read_bytes(&transcoded_path) + .map_err(|e| { + error!("Problem reading transcoded mp4: {:?}", e); + MediaFileUploadError::ServerError + })?; + mimetype = "video/mp4".to_string(); + final_upload_file_path = transcoded_path; + transcode_tempdir_ref = Some(transcode_tempdir); + } + // ==================== OPTIONAL VIDEO RESAMPLE ==================== // let should_resample = form.maybe_resample_fps.is_some() || form.maybe_trim_start_millis.is_some() || form.maybe_trim_end_millis.is_some(); - let mut save_tempdir_ref = None; // NB: Save from Drop - - let mut final_upload_file_path = form.file.file.path().to_path_buf(); - if should_resample { // TODO(bt,2024-09-11): Should include entropy so concurrent requests don't overwrite let frame_temp_dir = server_state.temp_dir_creator.new_tempdir("ffmpeg") @@ -272,8 +340,10 @@ pub async fn upload_new_video_media_file_handler( let maybe_start_offset = form.maybe_trim_start_millis.map(|millis| Duration::from_millis(millis.0)); let maybe_end_offset = form.maybe_trim_end_millis.map(|millis| Duration::from_millis(millis.0)); + // NB: Resample reads from `final_upload_file_path` (which is the transcoded + // mp4 if we transcoded above, or the user's original upload otherwise). ffmpeg_trim_and_resample(Args { - video_input_path: form.file.file.path(), + video_input_path: &final_upload_file_path, video_output_path: &video_output_path, maybe_new_frame_rate, maybe_start_offset, @@ -297,9 +367,13 @@ pub async fn upload_new_video_media_file_handler( })?; final_upload_file_path = video_output_path; - save_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope + resample_tempdir_ref = Some(frame_temp_dir); // NB: Keep from going out of scope } + // True if we touched the user's file in any way (transcoded, resampled, or both). + // We'll upload their unmodified original alongside the canonical file when so. + let should_upload_original = needs_transcode || should_resample; + // ==================== OTHER FILE METADATA ==================== // let mut maybe_duration_millis = None; @@ -358,6 +432,47 @@ pub async fn upload_new_video_media_file_handler( MediaFileUploadError::ServerError })?; + // ==================== UPLOAD ORIGINAL SIBLING ==================== // + // If we transcoded and/or resampled the user's upload, also upload the + // unmodified original alongside the canonical file as `_original.{ext}` + // (sharing the same object hash / directory). The media file record points + // at the canonical file, not this sibling. + + if should_upload_original { + // Build a sibling path that lives in the same directory as the canonical + // file (same hash) but with `_original.{ext}` baked into the suffix. + let original_suffix = format!("_original.{}", original_extension); + let original_bucket_path = MediaFileBucketPath::from_object_hash( + public_upload_path.get_object_hash(), + PREFIX, + Some(&original_suffix), + ); + + info!( + "Uploading original sibling to bucket path: {}", + original_bucket_path.get_full_object_path_str(), + ); + + let original_bytes = file_read_bytes(&original_file_path) + .map_err(|e| { + error!("Problem reading original file for sibling upload: {:?}", e); + MediaFileUploadError::ServerError + })?; + + server_state.public_bucket_client.upload_file_with_content_type( + original_bucket_path.get_full_object_path_str(), + original_bytes.as_ref(), + &original_mimetype, + ) + .await + .map_err(|e| { + // NB: Sibling failure is non-fatal — the canonical upload already + // succeeded and the user can still use the media. Log loudly. + warn!("Upload original sibling to bucket error: {:?}", e); + }) + .ok(); + } + let maybe_scene_source_media_file_token = form.maybe_scene_source_media_file_token .as_ref() .map(|token| &token.0);