mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 16:33:01 -06:00
MCP: report render progress and handle cancellation
Keep serving during long renders, recover failed workers, and remove only files opened by the interrupted export. Cover progress, cancellation, EOF and output ownership with regression tests. Co-Authored-By: OpenAI Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
OpenAI Codex
Claude Opus 5.5
parent
164538fecb
commit
7b632bdbc1
@@ -277,7 +277,7 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
|
||||
st.advance(first_end)?;
|
||||
|
||||
let path = job.place(job.path, 1);
|
||||
let mut file = crate::out::create(job.sink, &path)?;
|
||||
let mut file = job.create(&path)?;
|
||||
let opts = WriterOptions::new(brand);
|
||||
let movie_ts = opts.movie_timescale.max(1) as i128;
|
||||
let mut mux: Writer = Mp4Writer::new(&mut file, opts).map_err(mux_err)?;
|
||||
|
||||
@@ -171,6 +171,10 @@ pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) ->
|
||||
let t0 = Instant::now();
|
||||
let cx = Cx::new(job);
|
||||
let mut r = export_with(&cx, progress, t0).await;
|
||||
if matches!(r, Err(ExportError::Cancelled)) {
|
||||
cx.remove_cancelled_outputs()?;
|
||||
return r;
|
||||
}
|
||||
let log = cx.write_log(&r, t0.elapsed().as_secs_f64());
|
||||
if let Ok(rep) = &mut r {
|
||||
rep.log = log;
|
||||
@@ -263,7 +267,7 @@ async fn sequence(cx: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>)
|
||||
write_still(cx, &mut buf, fmt, &img, comp, channels, w, h, cx.output.quality)?;
|
||||
let data = buf.into_inner();
|
||||
let path = cx.place(&path, data.len() as u64);
|
||||
let mut f = out::create(cx.sink, &path)?;
|
||||
let mut f = cx.create(&path)?;
|
||||
std::io::Write::write_all(&mut f, &data).map_err(io)?;
|
||||
f.finish()
|
||||
})
|
||||
@@ -350,7 +354,7 @@ async fn gif_export(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_
|
||||
return Err(ExportError::Unsupported("GIF frames are limited to 65535 px".into()));
|
||||
}
|
||||
let path = job.place(job.path, 1);
|
||||
let mut file = out::create(job.sink, &path)?;
|
||||
let mut file = job.create(&path)?;
|
||||
let mut enc = gif::Encoder::new(&mut file, w as u16, h as u16, &[]).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
enc.set_repeat(if job.output.gif_loop { gif::Repeat::Infinite } else { gif::Repeat::Finite(0) }).map_err(|e| ExportError::Encode(e.to_string()))?;
|
||||
let rate = job.settings.rate(comp);
|
||||
|
||||
@@ -24,6 +24,7 @@ pub(crate) struct Cx<'a> {
|
||||
pub frame_times: Option<Mutex<Vec<(u64, f64)>>>,
|
||||
/// Files written to an overflow folder.
|
||||
pub overflowed: Mutex<Vec<String>>,
|
||||
written: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl<'a> std::ops::Deref for Cx<'a> {
|
||||
@@ -68,7 +69,7 @@ impl<'a> Cx<'a> {
|
||||
let mut project = job.project.clone();
|
||||
apply_overrides(&mut project, job);
|
||||
let frame_times = (job.options.log == RenderLog::PlusPerFrameInfo).then(|| Mutex::new(Vec::new()));
|
||||
Cx { job, project, frame_times, overflowed: Mutex::new(vec![]) }
|
||||
Cx { job, project, frame_times, overflowed: Mutex::new(vec![]), written: Mutex::new(vec![]) }
|
||||
}
|
||||
|
||||
pub fn comp(&self) -> Option<&Comp> {
|
||||
@@ -187,6 +188,30 @@ impl<'a> Cx<'a> {
|
||||
crate::fit(px, img.width, img.height, w, h)
|
||||
}
|
||||
|
||||
/// Open a file and remember ownership for cancellation cleanup (including overflow paths).
|
||||
pub fn create(&self, path: &str) -> crate::Result<out::Out<'_>> {
|
||||
let file = out::create(self.sink, path)?;
|
||||
if self.sink.is_none() {
|
||||
self.written.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(path.to_owned());
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub fn remove_cancelled_outputs(&self) -> crate::Result<()> {
|
||||
let mut failure = None;
|
||||
for path in self.written.lock().unwrap_or_else(std::sync::PoisonError::into_inner).iter() {
|
||||
if let Err(e) = std::fs::remove_file(path)
|
||||
&& e.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
failure.get_or_insert_with(|| crate::ExportError::Io(format!("cannot remove cancelled output {path}: {e}")));
|
||||
}
|
||||
}
|
||||
match failure {
|
||||
Some(error) => Err(error),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a file goes: `path`, or (Use Storage Overflow) the first overflow folder with room
|
||||
/// for `bytes` when the quota hook says the output volume is full.
|
||||
pub fn place(&self, path: &str, bytes: u64) -> String {
|
||||
@@ -246,7 +271,7 @@ impl<'a> Cx<'a> {
|
||||
s.push_str(&format!(" Frame {} ({:.3} s): rendered in {:.1} ms\n", f0 + i as i64, t.seconds(), secs * 1000.0));
|
||||
}
|
||||
}
|
||||
let mut f = out::create(self.job.sink, &path).ok()?;
|
||||
let mut f = self.create(&path).ok()?;
|
||||
std::io::Write::write_all(&mut f, s.as_bytes()).ok()?;
|
||||
f.finish().ok()?;
|
||||
Some(path)
|
||||
|
||||
@@ -162,7 +162,7 @@ pub(crate) async fn webm(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut Sta
|
||||
let duration_ms = frame_ms(total) as f64;
|
||||
|
||||
let path = job.place(job.path, 1);
|
||||
let mut file = crate::out::create(job.sink, &path)?;
|
||||
let mut file = job.create(&path)?;
|
||||
// EBML header.
|
||||
let mut head = vec![];
|
||||
let mut eb = vec![];
|
||||
@@ -403,7 +403,7 @@ pub(crate) fn audio_file(job: &Cx, comp: &Comp, aiff: bool, st: &mut State) -> R
|
||||
}
|
||||
}
|
||||
let path = job.place(job.path, out.len() as u64);
|
||||
let mut file = crate::out::create(job.sink, &path)?;
|
||||
let mut file = job.create(&path)?;
|
||||
file.write_all(&out).map_err(io)?;
|
||||
let bytes = file.finish()?;
|
||||
Ok(Report { path, frames: 0, width: 0, height: 0, seconds: 0.0, bytes, audio: true, log: None, overflow: vec![] })
|
||||
|
||||
@@ -51,7 +51,7 @@ pub(crate) async fn webm_av1(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut
|
||||
let duration_ms = frame_ms(total) as f64;
|
||||
|
||||
let path = job.place(job.path, 1);
|
||||
let mut file = crate::out::create(job.sink, &path)?;
|
||||
let mut file = job.create(&path)?;
|
||||
let mut head = vec![];
|
||||
let mut eb = vec![];
|
||||
el_uint(&mut eb, 0x4286, 1);
|
||||
|
||||
@@ -327,3 +327,61 @@ fn wav_and_aiff_channels_and_formats() {
|
||||
assert_eq!(&d[8..12], b"AIFF");
|
||||
assert_eq!(u16::from_be_bytes([d[26], d[27]]), 24, "sample size");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_export_removes_only_files_it_opened() {
|
||||
use effectcraft_export::ExportError;
|
||||
let dir = std::env::temp_dir().join(format!("ec-cancel-owned-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (p, cid) = project(FrameRate::new(25, 1), |_, _| {});
|
||||
struct Overflow;
|
||||
impl StorageQuota for Overflow {
|
||||
fn has_room(&self, path: &str, _: u64) -> bool {
|
||||
path.contains("overflow")
|
||||
}
|
||||
}
|
||||
for overflow in [false, true] {
|
||||
let output_dir = if overflow { dir.join("overflow") } else { dir.clone() };
|
||||
std::fs::create_dir_all(&output_dir).unwrap();
|
||||
let settings = RenderSettings { skip_existing: true, storage_overflow: overflow, ..Default::default() };
|
||||
for format in [OutputFormat::PngSequence, OutputFormat::H264] {
|
||||
let om = OutputModule::for_format(format);
|
||||
let path = dir.join(if format.is_sequence() { "clip_[#####].png" } else { "clip.mp4" });
|
||||
let path = path.to_str().unwrap();
|
||||
let decoys = ["clip.aep", "clip2.psd", "clip000.mp4", "clip_99999.png", "clip_00000.png", "clip.txt", "clip_RenderLog.txt"];
|
||||
for name in decoys {
|
||||
std::fs::write(output_dir.join(name), b"keep").unwrap();
|
||||
}
|
||||
// Skip-existing checks the primary destination even when the new frames overflow.
|
||||
std::fs::write(dir.join("clip_00000.png"), b"keep").unwrap();
|
||||
let result = export(
|
||||
&Job {
|
||||
project: &p,
|
||||
footage: &NoFootage,
|
||||
expr: None,
|
||||
accel: None,
|
||||
comp: cid,
|
||||
settings: &settings,
|
||||
output: &om,
|
||||
path,
|
||||
sink: None,
|
||||
nested_switches: true,
|
||||
options: JobOptions {
|
||||
log: RenderLog::PlusPerFrameInfo,
|
||||
storage: Some(&Overflow),
|
||||
overflow: vec![output_dir.to_string_lossy().into_owned()],
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
&mut |progress| progress.done == 0,
|
||||
);
|
||||
assert!(matches!(result, Err(ExportError::Cancelled)), "{result:?}");
|
||||
for name in decoys {
|
||||
assert_eq!(std::fs::read(output_dir.join(name)).unwrap(), b"keep", "{name}");
|
||||
}
|
||||
let remaining: Vec<_> = std::fs::read_dir(&output_dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
|
||||
assert_eq!(remaining.len(), decoys.len(), "partial files remain: {remaining:?}");
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user