From 5a0eba96557d7ab69d58f40aa8d8468772c46329 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:24:00 +0000 Subject: [PATCH 1/2] fix(export): keep cancelled GIF exports from leaving a partial file --- crates/export/src/gif.rs | 19 +++-- crates/export/src/lib.rs | 19 +++-- crates/export/src/mp4.rs | 33 +++++--- crates/export/tests/gif_cancellation.rs | 106 ++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 24 deletions(-) create mode 100644 crates/export/tests/gif_cancellation.rs diff --git a/crates/export/src/gif.rs b/crates/export/src/gif.rs index bb8880c4ac2..fcc473c124d 100644 --- a/crates/export/src/gif.rs +++ b/crates/export/src/gif.rs @@ -6,7 +6,7 @@ use specta::Type; use std::path::PathBuf; use tracing::trace; -use crate::{ExportError, ExporterBase}; +use crate::{ExportError, ExporterBase, prepare_new_output}; #[derive(Serialize, Deserialize, Clone, Copy, Debug, Type)] pub struct GifQuality { @@ -109,8 +109,11 @@ impl GifExportSettings { }) .unwrap_or_default(); + let temporary_output = prepare_new_output(&gif_output_path); let mut gif_encoder = cap_enc_gif::GifEncoderWrapper::new_with_quality( - &gif_output_path, + temporary_output + .as_ref() + .map_or(gif_output_path.as_path(), AsRef::as_ref), output_size.0, output_size.1, fps, @@ -151,7 +154,7 @@ impl GifExportSettings { return Err(ExportError::Other(format!("Failed to finish GIF: {e}"))); } - Ok(gif_output_path) + Ok(()) }) .then(|f| async { f.map_err(|e| e.to_string()) @@ -180,9 +183,13 @@ impl GifExportSettings { ) .then(|f| async { f.map_err(|v| v.to_string()) }); - let (output_path, _) = - tokio::try_join!(encoder_thread, render_video_task).map_err(|e| e.to_string())?; + tokio::try_join!(encoder_thread, render_video_task)?; + if let Some(temporary_output) = temporary_output { + temporary_output + .persist_noclobber(&gif_output_path) + .map_err(|e| e.to_string())?; + } - Ok(output_path) + Ok(gif_output_path) } } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 1a5667796f9..a2b5be57f68 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -356,14 +356,21 @@ pub fn make_cursor_only_project(mut project_config: ProjectConfiguration) -> Pro fn prepare_streaming_output( output: &std::path::Path, eligible: bool, -) -> Option { - if !eligible - || output.extension().and_then(|extension| extension.to_str()) != Some("mp4") - || !matches!(std::fs::symlink_metadata(output), Err(error) if error.kind() == std::io::ErrorKind::NotFound) +) -> Option { + if !eligible || output.extension().and_then(|extension| extension.to_str()) != Some("mp4") { + return None; + } + prepare_new_output(output) +} + +/// Stages a new export beside its destination so a failed or cancelled export +/// never leaves a partial file there. Existing destinations are written in place. +fn prepare_new_output(output: &std::path::Path) -> Option { + if !matches!(std::fs::symlink_metadata(output), Err(error) if error.kind() == std::io::ErrorKind::NotFound) { return None; } - mp4::temporary_mp4_output(output).ok() + mp4::temporary_export_output(output).ok() } struct ExportAudioCancellation { @@ -408,7 +415,7 @@ pub struct ExporterBase { segments: Vec, output_path: PathBuf, streaming_audio: Option, - streaming_output: Option, + streaming_output: Option, audio_cancellation: Option, sample_windows: Option, sample_timing: Option>, diff --git a/crates/export/src/mp4.rs b/crates/export/src/mp4.rs index f120f6cff10..16fb62ce9cb 100644 --- a/crates/export/src/mp4.rs +++ b/crates/export/src/mp4.rs @@ -1133,7 +1133,7 @@ fn join_audio_thread( } #[derive(Debug)] -pub(crate) struct TemporaryMp4Output { +pub(crate) struct TemporaryExportOutput { path: tempfile::TempPath, #[cfg(unix)] file: std::fs::File, @@ -1141,18 +1141,21 @@ pub(crate) struct TemporaryMp4Output { final_permissions: std::fs::Permissions, } -impl AsRef for TemporaryMp4Output { +impl AsRef for TemporaryExportOutput { fn as_ref(&self) -> &std::path::Path { self.path.as_ref() } } -impl TemporaryMp4Output { +impl TemporaryExportOutput { pub(crate) fn to_path_buf(&self) -> PathBuf { self.path.to_path_buf() } - fn persist_noclobber(self, output: &std::path::Path) -> Result<(), tempfile::PathPersistError> { + pub(crate) fn persist_noclobber( + self, + output: &std::path::Path, + ) -> Result<(), tempfile::PathPersistError> { #[cfg(unix)] let prepare_permissions = || { use std::os::unix::fs::MetadataExt; @@ -1177,11 +1180,17 @@ impl TemporaryMp4Output { } } -pub(crate) fn temporary_mp4_output(output: &std::path::Path) -> Result { +pub(crate) fn temporary_export_output( + output: &std::path::Path, +) -> Result { let parent = output.parent().unwrap_or_else(|| std::path::Path::new(".")); + let suffix = output + .extension() + .map(|extension| format!(".{}", extension.to_string_lossy())) + .unwrap_or_default(); let temporary = tempfile::Builder::new() .prefix(".cap-export-") - .suffix(".mp4") + .suffix(&suffix) .tempfile_in(parent) .map_err(|error| error.to_string())?; #[cfg(unix)] @@ -1203,7 +1212,7 @@ pub(crate) fn temporary_mp4_output(output: &std::path::Path) -> Result bool + Send + 'static, +) -> 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( + GifExportSettings { + fps: 10, + resolution_base: XY::new(64, 64), + quality: None, + } + .export(base, on_progress), + ) + .await +} + +#[tokio::test] +#[ignore = "requires ffmpeg and a wgpu adapter"] +async fn cancelled_gif_leaves_no_partial_destination() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project.cap"); + create_project(&project); + let output = directory.path().join("export.gif"); + + assert!(export(&project, &output, |frame| frame < 2).await.is_err()); + + assert!(!output.exists()); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); +} + +#[tokio::test] +#[ignore = "requires ffmpeg and a wgpu adapter"] +async fn finished_gif_lands_at_the_destination() { + let directory = tempfile::tempdir().unwrap(); + let project = directory.path().join("project.cap"); + create_project(&project); + let output = directory.path().join("export.gif"); + let destination = output.clone(); + + let result = export(&project, &output, move |_| { + assert!(!destination.exists()); + true + }) + .await + .unwrap(); + + assert_eq!(result, output); + let frames = image::codecs::gif::GifDecoder::new(std::io::BufReader::new( + fs::File::open(output).unwrap(), + )) + .unwrap() + .into_frames() + .collect_frames() + .unwrap(); + assert!(!frames.is_empty()); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 2); +} From 850dd91c06f29981a87b9712336740f99606dfb5 Mon Sep 17 00:00:00 2001 From: Aditya Garud <153842990+yashranaway@users.noreply.github.com> Date: Thu, 15 Oct 2026 06:37:43 +0000 Subject: [PATCH 2/2] fix(export): stop GIF exports when staging fails --- crates/export/src/gif.rs | 2 +- crates/export/src/lib.rs | 27 +++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/export/src/gif.rs b/crates/export/src/gif.rs index fcc473c124d..27439e6c21d 100644 --- a/crates/export/src/gif.rs +++ b/crates/export/src/gif.rs @@ -109,7 +109,7 @@ impl GifExportSettings { }) .unwrap_or_default(); - let temporary_output = prepare_new_output(&gif_output_path); + let temporary_output = prepare_new_output(&gif_output_path)?; let mut gif_encoder = cap_enc_gif::GifEncoderWrapper::new_with_quality( temporary_output .as_ref() diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index a2b5be57f68..a3c9b7420e7 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -360,17 +360,19 @@ fn prepare_streaming_output( if !eligible || output.extension().and_then(|extension| extension.to_str()) != Some("mp4") { return None; } - prepare_new_output(output) + prepare_new_output(output).ok().flatten() } /// Stages a new export beside its destination so a failed or cancelled export /// never leaves a partial file there. Existing destinations are written in place. -fn prepare_new_output(output: &std::path::Path) -> Option { +fn prepare_new_output( + output: &std::path::Path, +) -> Result, String> { if !matches!(std::fs::symlink_metadata(output), Err(error) if error.kind() == std::io::ErrorKind::NotFound) { - return None; + return Ok(None); } - mp4::temporary_export_output(output).ok() + mp4::temporary_export_output(output).map(Some) } struct ExportAudioCancellation { @@ -640,6 +642,23 @@ mod cancellation_tests { assert_eq!(std::fs::read(existing).unwrap(), b"existing"); } + #[test] + fn new_gif_destinations_are_staged_beside_the_output() { + let directory = tempfile::tempdir().unwrap(); + let output = directory.path().join("export.gif"); + let staged = prepare_new_output(&output).unwrap().unwrap(); + let staged_path = staged.to_path_buf(); + assert_eq!(staged_path.parent(), Some(directory.path())); + assert_eq!(staged_path.extension(), output.extension()); + assert!(!output.exists()); + staged.persist_noclobber(&output).unwrap(); + assert!(output.exists()); + assert!(!staged_path.exists()); + + assert!(prepare_new_output(&output).unwrap().is_none()); + assert!(prepare_new_output(&directory.path().join("missing/export.gif")).is_err()); + } + #[test] fn prepared_destination_is_removed_when_preparation_is_dropped() { let directory = tempfile::tempdir().unwrap();