From 7304ba1a02c27403edaa7705f63c7d1f51a5fb7e Mon Sep 17 00:00:00 2001 From: Paddy <89168564+patrickballard@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:02:03 +0000 Subject: [PATCH] fix(export): return the .mp4 path an MP4 export writes MP4File always writes to an .mp4 path, but the MP4 exporter returned the path it was given, so `cap export -o out.gif` reported out.gif while writing out.mp4. Give the output path the .mp4 extension up front, as the GIF exporter does for .gif. --- crates/export/src/mp4.rs | 5 +- crates/export/tests/mp4_output_path.rs | 83 ++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 crates/export/tests/mp4_output_path.rs diff --git a/crates/export/src/mp4.rs b/crates/export/src/mp4.rs index be25ee2e62f..14c8f25d283 100644 --- a/crates/export/src/mp4.rs +++ b/crates/export/src/mp4.rs @@ -245,9 +245,12 @@ impl Mp4ExportSettings { async fn export_inner( self, - base: ExporterBase, + mut base: ExporterBase, on_progress: impl FnMut(u32) -> bool + Send + 'static, ) -> Result { + // `MP4File` writes to an `.mp4` path whatever extension it is given, so + // the path returned to the caller has to carry it too. + base.output_path.set_extension("mp4"); info!("Exporting mp4 with settings: {:?}", &self); info!("Expected to render {} frames", base.total_frames(self.fps)); diff --git a/crates/export/tests/mp4_output_path.rs b/crates/export/tests/mp4_output_path.rs new file mode 100644 index 00000000000..84be3daa737 --- /dev/null +++ b/crates/export/tests/mp4_output_path.rs @@ -0,0 +1,83 @@ +use cap_export::{ + ExporterBase, + mp4::{ExportCompression, Mp4ExportSettings}, +}; +use cap_project::{ProjectConfiguration, XY}; +use std::{fs, path::Path, process::Command}; + +fn create_project(path: &Path) { + fs::create_dir_all(path).unwrap(); + let video = Command::new("ffmpeg") + .args([ + "-v", + "error", + "-f", + "lavfi", + "-i", + "testsrc2=size=64x64:rate=10:duration=1", + "-c:v", + "libx264", + "-preset", + "ultrafast", + "-threads", + "1", + ]) + .arg(path.join("display.mp4")) + .output() + .unwrap(); + assert!( + video.status.success(), + "{}", + String::from_utf8_lossy(&video.stderr) + ); + fs::write( + path.join("recording-meta.json"), + serde_json::to_vec(&serde_json::json!({ + "pretty_name": "MP4 output path", + "display": { "path": "display.mp4", "fps": 10 } + })) + .unwrap(), + ) + .unwrap(); +} + +async fn export(project: &Path, output: &Path) -> Result { + let base = Box::pin( + ExporterBase::builder(project.to_path_buf()) + .with_config(ProjectConfiguration::default()) + .with_output_path(output.to_path_buf()) + .build(), + ) + .await + .unwrap(); + Box::pin( + Mp4ExportSettings { + fps: 10, + resolution_base: XY::new(64, 64), + compression: ExportCompression::Maximum, + custom_bpp: None, + force_ffmpeg_decoder: false, + optimize_filesize: false, + } + .export(base, |_| true), + ) + .await +} + +#[tokio::test] +#[ignore = "requires ffmpeg and a wgpu adapter"] +async fn mp4_export_returns_the_path_it_wrote() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project.cap"); + create_project(&project); + + for requested in ["export.gif", "export.webm", "export"] { + let written = export(&project, &directory.path().join(requested)) + .await + .unwrap(); + assert_eq!(written, directory.path().join("export.mp4"), "{requested}"); + assert!(written.is_file(), "{requested}"); + assert!(!directory.path().join(requested).exists(), "{requested}"); + fs::remove_file(&written).unwrap(); + } +}