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:
storybored379
2026-10-09 00:20:22 -07:00
co-authored by OpenAI Codex Claude Opus 5.5
parent 164538fecb
commit 7b632bdbc1
13 changed files with 531 additions and 19 deletions
+1 -1
View File
@@ -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)?;
+6 -2
View File
@@ -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);
+27 -2
View File
@@ -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)
+2 -2
View File
@@ -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![] })
+1 -1
View File
@@ -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);