From 78053191ce91a3ecd42bce985f79cc3e2183c66a Mon Sep 17 00:00:00 2001 From: storybored379 Date: Thu, 8 Oct 2026 21:05:31 -0700 Subject: [PATCH] MCP: cancelling a movie export keeps numbered files beside it remove_partial treated any `.` file as a still of an image sequence, so cancelling an export to `clip.mp4` also deleted a `clip1.mp4` written in the same second (or during the export). Only PNG/TIFF/BMP sequence exports write numbered stills, and their frame numbers have at least three digits (`image_sequence_path`); match only those. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/automation/src/long_job.rs | 65 ++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/crates/automation/src/long_job.rs b/crates/automation/src/long_job.rs index d03c4067..8d4035ab 100644 --- a/crates/automation/src/long_job.rs +++ b/crates/automation/src/long_job.rs @@ -107,10 +107,11 @@ impl FilmcraftMcp { } /// Delete what an interrupted export wrote, and nothing else: the output file itself, the numbered -/// stills of an image sequence (`.`) and the caption sidecar +/// stills of an image sequence (`<3+ digits>.`, only when the output is a +/// PNG/TIFF/BMP sequence) and the caption sidecar /// (`.srt` / `.vtt`), each only if it was written since `since`. Other files that -/// merely share the name's stem (`.aep`, `2.psd`, …) are never touched, even when they -/// were saved during the export. +/// merely share the name's stem (`.aep`, `2.psd`, `1.mp4` beside a movie, …) are +/// never touched, even when they were saved during the export. pub fn remove_partial(path: &str, since: SystemTime) { let p = Path::new(path); let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(|s| s.to_str()), p.file_name().and_then(|s| s.to_str())) else { @@ -139,9 +140,19 @@ fn is_export_output(name: &str, file: &str, stem: &str, ext: &str) -> bool { if let Some(sidecar) = rest.strip_prefix('.') { return sidecar.eq_ignore_ascii_case("srt") || sidecar.eq_ignore_ascii_case("vtt"); } - // image sequence: one or more digits, then the output's own extension + // image sequence: the frame number (at least three digits, like `image_sequence_path`), then the + // output's own extension; movies and audio are a single file, so numbered neighbours are not theirs + if !is_sequence_extension(ext) { + return false; + } let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len(); - digits > 0 && !ext.is_empty() && rest.get(digits..).is_some_and(|r| r.strip_prefix('.').is_some_and(|x| x == ext)) + digits >= 3 && rest.get(digits..).is_some_and(|r| r.strip_prefix('.').is_some_and(|x| x == ext)) +} + +/// Whether an export to a file with extension `ext` writes an image sequence (one still per frame). +fn is_sequence_extension(ext: &str) -> bool { + use filmcraft_engine::export::Format; + Format::from_name(ext).is_some_and(|f| matches!(f, Format::PngSequence | Format::TiffSequence | Format::BmpSequence)) } #[cfg(test)] @@ -170,6 +181,50 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn numbered_files_beside_a_movie_are_kept() { + let dir = std::env::temp_dir().join(format!("fc-remove-partial-movie-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let started = SystemTime::now(); + // a movie export writes one file: `clip1.mp4` and `clip002.mp4` are other exports + let ours = ["clip.mp4", "clip.srt"]; + let theirs = ["clip1.mp4", "clip002.mp4", "clip2.mov"]; + for f in ours.iter().chain(theirs.iter()) { + std::fs::write(dir.join(f), b"x").unwrap(); + } + remove_partial(&dir.join("clip.mp4").to_string_lossy(), started); + for f in ours { + assert!(!dir.join(f).exists(), "{f} should be removed"); + } + for f in theirs { + assert!(dir.join(f).exists(), "{f} must not be removed"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn sequence_frames_have_at_least_three_digits() { + let dir = std::env::temp_dir().join(format!("fc-remove-partial-digits-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let started = SystemTime::now(); + // frames are numbered with at least three digits (`shot000.tiff`); `shot2.tiff` is not one + let ours = ["shot.tiff", "shot000.tiff", "shot1234.tiff"]; + let theirs = ["shot2.tiff", "shot12.tiff"]; + for f in ours.iter().chain(theirs.iter()) { + std::fs::write(dir.join(f), b"x").unwrap(); + } + remove_partial(&dir.join("shot.tiff").to_string_lossy(), started); + for f in ours { + assert!(!dir.join(f).exists(), "{f} should be removed"); + } + for f in theirs { + assert!(dir.join(f).exists(), "{f} must not be removed"); + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn files_older_than_the_export_are_kept() { let dir = std::env::temp_dir().join(format!("fc-remove-partial-old-{}", std::process::id()));