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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
committed 2026-10-09 00:20:22 -07:00
1 parent 164538fecb
commit 7b632bdbc1
13 files changed
+531 -19

No files matched your search

+1
View File
@@ -19,6 +19,7 @@ mod autosave;
pub mod backend;
pub mod base64;
pub mod bridge;
mod long_job;
pub mod server;
pub mod tools;
+177
View File
@@ -0,0 +1,177 @@
//! Long renders over MCP (docs/mcp.md "Progress and cancellation"). While [`McpServer::serve`]
//! runs, a blocking `renderQueue.render` (`command_run`, headless) renders on the session's
//! background render job instead of inline: the server keeps reading stdin, answers every other
//! request in between (the job renders a snapshot of the project), reports progress as
//! `notifications/progress` when the request carried `_meta.progressToken`, and stops the render
//! on `notifications/cancelled` (deleting the partial output, sending no response).
use std::io::Write;
use std::sync::mpsc::{Receiver, RecvTimeoutError};
use std::time::Duration;
use serde_json::{Value, json};
use crate::server::{McpServer, call_result};
use crate::tools::{self, Reply};
/// A `tools/call` result for `call`, with `resultType` for modern clients.
fn result_for(call: &LongCall, r: Result<Reply, crate::Error>) -> Value {
let mut result = call_result(r);
if call.modern {
result["resultType"] = json!("complete");
}
result
}
/// How often the job is polled (and at most how often progress is reported).
const POLL: Duration = Duration::from_millis(100);
/// A `tools/call` that runs as a long job.
pub struct LongCall {
pub id: Value,
pub params: Value,
pub token: Option<Value>,
/// The request declared MCP 2026-07-28+ (its result then carries `resultType`).
pub modern: bool,
}
/// Whether `msg` is a blocking `renderQueue.render` call (valid arguments, headless backend).
pub fn long_call(server: &mut McpServer, msg: &Value) -> Option<LongCall> {
let id = msg.get("id").filter(|i| i.is_string() || i.is_number())?;
if msg.get("method").and_then(Value::as_str) != Some("tools/call") || server.backend().is_bridge() {
return None;
}
let p = msg.get("params")?;
let name = p.get("name")?.as_str()?;
let args = p.get("arguments").cloned().unwrap_or(json!({}));
if !matches!(name, "command_run" | "execute_command")
|| args.get("id").or_else(|| args.get("command")).and_then(Value::as_str) != Some("renderQueue.render")
{
return None;
}
if tools::find(name).and_then(|t| t.unknown_arg(&args)).is_some() {
return None; // the normal path reports it
}
let params = match args.get("params") {
None | Some(Value::Null) => json!({}),
Some(v) if v.is_object() => v.clone(),
_ => return None,
};
if params.get("wait").and_then(Value::as_bool) == Some(false) {
return None;
}
let token = p.get("_meta").and_then(|m| m.get("progressToken")).filter(|t| t.is_string() || t.is_number()).cloned();
Some(LongCall { id: id.clone(), params, token, modern: crate::server::is_modern(p) })
}
fn send(out: &mut impl Write, v: &Value) -> std::io::Result<()> {
out.write_all(v.to_string().as_bytes())?;
out.write_all(b"\n")?;
out.flush()
}
/// The `requestId` of a `notifications/cancelled` line.
fn cancelled_id(line: &str) -> Option<Value> {
let v: Value = serde_json::from_str(line).ok()?;
(v.get("method")?.as_str()? == "notifications/cancelled").then(|| v.get("params")?.get("requestId").cloned())?
}
impl McpServer {
/// Run `call` to completion or cancellation, handling the other messages from `inbox`
/// (`None` = stdin closed) meanwhile. Returns false when stdin closed.
pub(crate) fn run_long(&mut self, call: LongCall, inbox: &Receiver<Option<String>>, out: &mut impl Write) -> std::io::Result<bool> {
let mut params = call.params.clone();
params["wait"] = json!(false);
let start = self.guarded("command_run", &json!({"id": "renderQueue.render", "params": params}));
let items = match &start {
Ok(Reply::Json(v)) => v["items"].as_array().cloned().unwrap_or_default(),
_ => {
send(out, &json!({"jsonrpc": "2.0", "id": call.id, "result": result_for(&call, start)}))?;
return Ok(true);
}
};
let ids: Vec<u64> = items.iter().filter_map(|i| i["id"].as_u64()).collect();
let shared = self.backend().session().and_then(|s| s.render_job.as_ref().map(|job| job.shared.clone()));
let mut last = -1.0f64;
let mut last_report: Option<std::time::Instant> = None;
let mut open = true;
let mut cancelled = false;
loop {
let state = shared.as_ref().map(|job| job.snapshot());
let rendering = state.as_ref().is_some_and(|state| !state.finished);
if let (Some(token), Some(st)) = (&call.token, &state)
&& st.items_total > 0
&& !cancelled
{
let frac = if st.total > 0 { st.done as f64 / st.total as f64 } else { 0.0 };
// the current item's frames count until it is done
let progress = (st.items_done as f64 + if st.items_done < st.items_total { frac.min(1.0) } else { 0.0 }).min(st.items_total as f64);
if progress > last && (!rendering || last_report.is_none_or(|at| at.elapsed() >= POLL)) {
last = progress;
last_report = Some(std::time::Instant::now());
let message = format!(
"Rendering item {} of {}: frame {} of {}",
st.items_done.saturating_add(1).min(st.items_total),
st.items_total,
st.done,
st.total
);
send(
out,
&json!({"jsonrpc": "2.0", "method": "notifications/progress",
"params": {"progressToken": token, "progress": progress, "total": st.items_total, "message": message}}),
)?;
}
}
if !rendering {
break;
}
if !open {
// stdin closed: finish the render (a piped script expects its reply), read nothing more
std::thread::sleep(POLL);
continue;
}
match inbox.recv_timeout(POLL) {
Ok(Some(line)) => {
if cancelled_id(&line).as_ref() == Some(&call.id) {
cancelled = true;
if let Some(job) = &shared {
job.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
}
continue;
}
if let Some(other) = serde_json::from_str::<Value>(&line).ok().and_then(|msg| long_call(self, &msg)) {
let result = result_for(&other, Err(crate::Error::Other("a render is already in progress".into())));
send(out, &json!({"jsonrpc":"2.0", "id":other.id, "result":result}))?;
continue;
}
if let Some(reply) = self.handle_line(&line) {
out.write_all(reply.as_bytes())?;
out.write_all(b"\n")?;
out.flush()?;
}
}
Ok(None) | Err(RecvTimeoutError::Disconnected) => open = false,
Err(RecvTimeoutError::Timeout) => {}
}
}
let Some(s) = self.backend().session() else { return Ok(open) };
s.poll_render();
s.drain_events();
let list = self.guarded("command_run", &json!({"id": "renderQueue.list"}));
let items: Vec<Value> = match &list {
Ok(Reply::Json(v)) => {
v["items"].as_array().cloned().unwrap_or_default().into_iter().filter(|i| i["id"].as_u64().is_some_and(|id| ids.contains(&id))).collect()
}
_ => Vec::new(),
};
if cancelled {
// Exporters remove only files they actually opened for this interrupted output.
return Ok(open); // MCP: no response for a cancelled request
}
let failed = ids.len().saturating_sub(items.len()).saturating_add(items.iter().filter(|item| item["statusLabel"].as_str() != Some("Done")).count());
let result = result_for(&call, Ok(Reply::Json(json!({"rendering": false, "items": items, "failed": failed}))));
send(out, &json!({"jsonrpc": "2.0", "id": call.id, "result": result}))?;
Ok(open)
}
}
+39 -9
View File
@@ -1,5 +1,5 @@
//! MCP server: JSON-RPC 2.0, one message per line (the MCP stdio transport). Implements
//! `initialize`, `ping`, `tools/list` and `tools/call`; notifications are accepted and ignored.
//! tools and JSON resources. The stdio loop handles progress and cancellation for long renders.
use std::io::{BufRead, Write};
use std::path::Path;
@@ -101,18 +101,48 @@ impl McpServer {
Some(reply.to_string())
}
/// Serve until EOF on `input`.
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
let served = (|| {
for line in input.lines() {
if let Some(reply) = self.handle_line(&line?) {
/// Serve until EOF on `input`. Input is read on its own thread so a long render can report
/// progress, be cancelled and let other requests through while it runs (`long_job`).
pub fn serve(&mut self, input: impl BufRead + Send, mut output: impl Write) -> std::io::Result<()> {
let (tx, inbox) = std::sync::mpsc::channel::<Option<String>>();
// A read error ends the input like EOF and is returned once the queued requests are answered.
let read_error = std::sync::Mutex::new(None::<std::io::Error>);
let served = std::thread::scope(|scope| {
let read_error = &read_error;
std::thread::Builder::new().name("mcp-input".into()).spawn_scoped(scope, move || {
for line in input.lines() {
let line = match line {
Ok(line) => line,
Err(e) => {
*read_error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e);
break;
}
};
if tx.send(Some(line)).is_err() {
return;
}
}
let _ = tx.send(None);
})?;
while let Ok(Some(line)) = inbox.recv() {
let long = serde_json::from_str::<Value>(line.trim()).ok().and_then(|m| crate::long_job::long_call(self, &m));
if let Some(call) = long {
if !self.run_long(call, &inbox, &mut output)? {
break;
}
continue;
}
if let Some(reply) = self.handle_line(&line) {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
}
Ok(())
})();
match read_error.lock().unwrap_or_else(std::sync::PoisonError::into_inner).take() {
Some(e) => Err(e),
None => Ok(()),
}
});
match (served, self.checkpoint(true)) {
(r, Ok(())) => r,
(Ok(()), Err(e)) => Err(e),
@@ -122,7 +152,7 @@ impl McpServer {
/// Serve on stdin/stdout (the MCP stdio transport).
pub fn serve_stdio(&mut self) -> std::io::Result<()> {
self.serve(std::io::stdin().lock(), std::io::stdout().lock())
self.serve(std::io::BufReader::new(std::io::stdin()), std::io::stdout().lock())
}
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
+179
View File
@@ -874,3 +874,182 @@ fn render_job_state_recovers_a_poisoned_lock() {
.join();
assert_eq!(shared.snapshot().done, 3);
}
/// A render that takes ~2 ms per frame and writes its file first (so a cancel leaves a partial
/// one to clean up).
struct SlowExporter;
impl effectcraft_engine::Exporter for SlowExporter {
fn formats(&self) -> Vec<effectcraft_engine::project::render_queue::OutputFormat> {
effectcraft_engine::project::render_queue::OutputFormat::ALL.to_vec()
}
fn export(&self, job: &effectcraft_engine::ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<effectcraft_engine::ExportResult, String> {
std::fs::write(job.path, b"partial").map_err(|e| e.to_string())?;
let n = 2000;
for k in 0..=n {
if !progress(k, n) {
std::fs::remove_file(job.path).map_err(|e| e.to_string())?;
return Err(effectcraft_engine::render_queue::CANCELLED.into());
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
Ok(effectcraft_engine::ExportResult { path: job.path.to_string(), frames: n, ..Default::default() })
}
}
/// docs/mcp.md "Progress and cancellation" over the stdio loop: a blocking `renderQueue.render`
/// reports `notifications/progress` for its token, other requests are answered while it renders,
/// and `notifications/cancelled` stops it, deletes the partial file and sends no response.
#[test]
fn render_progress_and_cancel() {
let dir = std::env::temp_dir().join(format!("effectcraft-mcp-progress-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (in_r, mut in_w) = std::io::pipe().unwrap();
let (out_r, out_w) = std::io::pipe().unwrap();
let server = std::thread::spawn(move || {
let session = Session { exporter: Some(std::sync::Arc::new(SlowExporter)), ..Default::default() };
McpServer::new(Backend::headless(session)).serve(BufReader::new(in_r), out_w).unwrap();
});
let mut lines = BufReader::new(out_r).lines();
let mut next = || -> Value { serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap() };
let mut send = |v: Value| writeln!(in_w, "{v}").unwrap();
let run = |id: u64, cmd: &str, params: Value, token: Option<Value>| {
let mut p = json!({"name": "command_run", "arguments": {"id": cmd, "params": params}});
if let Some(t) = token {
p["_meta"] = json!({"progressToken": t, "io.modelcontextprotocol/protocolVersion":"2026-07-28"});
}
json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": p})
};
send(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}}));
next();
send(run(2, "comp.new", json!({"width": 64, "height": 64, "duration": 1}), None));
assert_eq!(next()["result"]["isError"], false);
let a = dir.join("a.mp4").to_string_lossy().to_string();
send(run(3, "renderQueue.add", json!({"output": a}), None));
assert_eq!(next()["result"]["isError"], false);
// progress for the token, strictly increasing; doc_inspect answered while rendering
send(run(4, "renderQueue.render", json!({}), Some(json!("tok"))));
let (mut last, mut notes, mut asked, mut answered) = (-1.0, 0, false, false);
let done = loop {
let m = next();
if m["method"] == "notifications/progress" {
assert_eq!(m["params"]["progressToken"], "tok", "{m}");
let p = m["params"]["progress"].as_f64().unwrap();
assert!(p > last && p <= m["params"]["total"].as_f64().unwrap(), "{m}");
(last, notes) = (p, notes + 1);
if !asked && p > 0.0 {
asked = true;
send(json!({"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "doc_inspect", "arguments": {}}}));
}
} else if m["id"] == 5 {
answered = true;
assert_eq!(m["result"]["isError"], false, "{m}");
} else {
assert_eq!(m["id"], 4, "{m}");
break m;
}
};
assert_eq!(done["result"]["isError"], false, "{done}");
assert_eq!(done["result"]["resultType"], "complete");
assert_eq!(last, 1.0, "final progress reaches the total");
let v: Value = serde_json::from_str(done["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(v["items"][0]["statusLabel"], "Done", "{v}");
assert!(notes >= 2 && answered, "{notes} notifications; doc_inspect answered during the render: {answered}");
// cancel after the first notification: no response, the partial file is gone
let b = dir.join("b.mp4");
send(run(6, "renderQueue.add", json!({"output": b.to_string_lossy()}), None));
assert_eq!(next()["result"]["isError"], false);
send(run(7, "renderQueue.render", json!({}), Some(json!(7))));
loop {
let m = next();
assert_ne!(m["id"], 7, "{m}");
if m["method"] == "notifications/progress" && m["params"]["progress"].as_f64().unwrap() > 0.0 {
break;
}
}
assert!(b.exists(), "the render started writing");
send(json!({"jsonrpc": "2.0", "method": "notifications/cancelled", "params": {"requestId": 7, "reason": "test"}}));
send(json!({"jsonrpc": "2.0", "id": 8, "method": "ping"}));
let m = loop {
let m = next();
if m["method"] != "notifications/progress" {
break m;
}
};
assert_eq!(m["id"], 8, "no response for the cancelled request: {m}");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
let v = loop {
send(run(9, "renderQueue.list", json!({}), None));
let v: Value = serde_json::from_str(next()["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
// the queue stops rendering a moment before the stopped item's status is updated
if v["rendering"] == false && v["items"][1]["statusLabel"] != "Rendering" {
break v;
}
assert!(std::time::Instant::now() < deadline, "cancel did not stop the job: {v}");
std::thread::sleep(std::time::Duration::from_millis(20));
};
assert!(!b.exists(), "partial output deleted");
assert!(std::path::Path::new(&a).exists(), "completed earlier queue item is retained");
assert_eq!(v["items"][1]["statusLabel"], "User Stopped", "{v}");
drop(send);
drop(in_w);
server.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn render_without_token_finishes_after_stdin_closes() {
let dir = std::env::temp_dir().join(format!("ec-mcp-eof-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("complete.mp4");
let mut s = McpServer::new(Backend::headless(Session { exporter: Some(std::sync::Arc::new(SlowExporter)), ..Default::default() }));
let mut input = String::new();
for (id, command, params) in
[(1, "comp.new", json!({"width":16,"height":16})), (2, "renderQueue.add", json!({"output":path})), (3, "renderQueue.render", json!({}))]
{
input.push_str(&format!(
"{}\n",
json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"command_run","arguments":{"id":command,"params":params}}})
));
}
let mut out = Vec::new();
s.serve(std::io::Cursor::new(input), &mut out).unwrap();
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert_eq!(replies.len(), 3, "no token means no notifications: {replies:?}");
assert_eq!(replies[2]["result"]["isError"], false);
assert!(path.exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn panicked_render_worker_finishes_with_an_error() {
struct BrokenExporter;
impl effectcraft_engine::Exporter for BrokenExporter {
fn formats(&self) -> Vec<effectcraft_engine::project::render_queue::OutputFormat> {
effectcraft_engine::project::render_queue::OutputFormat::ALL.to_vec()
}
fn export(&self, _: &effectcraft_engine::ExportJob, _: &mut dyn FnMut(u64, u64) -> bool) -> Result<effectcraft_engine::ExportResult, String> {
panic!("synthetic exporter failure");
}
}
let mut session = Session { exporter: Some(std::sync::Arc::new(BrokenExporter)), ..Default::default() };
session.execute("comp.new", json!({"width":16,"height":16})).unwrap();
session.execute("renderQueue.add", json!({"output":"unused.mp4"})).unwrap();
session.execute("renderQueue.render", json!({"wait":false})).unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while session.is_rendering() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(!session.is_rendering(), "a panicked worker must not leave an unfinishable job");
session.poll_render();
let queue = session.execute("renderQueue.list", json!({})).unwrap();
assert!(queue.to_string().contains("synthetic exporter failure"), "{queue}");
session.execute("renderQueue.add", json!({"output":"unused-again.mp4"})).unwrap();
let mut server = McpServer::new(Backend::headless(session));
let input =
json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"renderQueue.render"}}}).to_string() + "\n";
let mut out = Vec::new();
server.serve(std::io::Cursor::new(input), &mut out).unwrap();
let reply: Value = serde_json::from_slice(&out).unwrap();
assert_eq!(reply["result"]["isError"], true, "{reply}");
}
+18 -2
View File
@@ -58,7 +58,8 @@ pub trait Exporter: Send + Sync {
/// Formats this build can write.
fn formats(&self) -> Vec<OutputFormat>;
/// Render and write one item (blocking). `progress(done, total)` returns `false` to cancel;
/// a cancelled export returns `Err("cancelled")`.
/// a cancelled export removes the files it opened for that output and returns `Err("cancelled")`.
/// Completed earlier queue items and unrelated files must be preserved.
fn export(&self, job: &ExportJob, progress: &mut dyn FnMut(u64, u64) -> bool) -> Result<ExportResult, String>;
/// [`Exporter::export`] as a future: renders whose GPU readbacks are deferred (a browser
/// job worker's WebGPU device) await the device between a frame's passes. The default runs
@@ -494,7 +495,22 @@ impl Session {
let sh = shared.clone();
let thread = std::thread::Builder::new()
.name("render-queue".into())
.spawn(move || effectcraft_render::passes::block_on(run_work(work, &sh, &mut |_| {})))
.spawn(move || {
let pending: Vec<u64> = work.items.iter().filter_map(|modules| modules.first().map(|(item, _)| item.id)).collect();
if let Err(error) = crate::guard::guarded("render worker", || {
effectcraft_render::passes::block_on(run_work(work, &sh, &mut |_| {}));
Ok(())
}) {
let completed = lock(&sh.state).items_done;
let mut updates = lock(&sh.updates);
for id in pending.into_iter().skip(completed) {
if !updates.iter().any(|u| matches!(u, ItemUpdate::Finished { id: done, .. } if *done == id)) {
updates.push(ItemUpdate::Finished { id, status: RenderStatus::Failed(error.to_string()), seconds: 0.0, output: None });
}
}
lock(&sh.state).finished = true;
}
})
.map_err(|e| e.to_string())?;
self.render_job = Some(RenderJob { shared, thread: Some(thread), remote: None });
}
+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);
+58
View File
@@ -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();
}
+2
View File
@@ -73,6 +73,8 @@ Without `--autosave`, headless work is in memory until explicitly saved. The fla
only; a bridged desktop app owns its auto-saves. An edit interrupted before its reply is received
may be absent from the last checkpoint.
See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and render progress/cancellation.
### Tools
| Tool | What it does |
+20
View File
@@ -34,3 +34,23 @@ receive `resultType: complete` and list/read cache hints; document reads are nev
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"comp.new","params":{"width":640,"height":360}}}}
{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"effectcraft://document"}}
```
## Progress and cancellation
Headless `command_run` / `execute_command` calls to `renderQueue.render` (unless `wait: false`)
run the existing background job while the MCP server keeps reading stdin. A
`params._meta.progressToken` string or number opts into `notifications/progress`, at most ten
per second, strictly increasing with a queue-item total. No token means no notifications.
`ping`, inspection and other requests are served while rendering. The existing engine refuses
a second render while one is active. Closing stdin lets the pending render finish and reply.
Send `notifications/cancelled` with `params.requestId` to stop the matching request at the next
frame batch. No response is sent for that cancelled request. Unknown and completed ids are
ignored. The exporter records the exact files it opens, including storage overflow paths, and
removes those files on cancellation; it never scans for similarly named files. Finished earlier
queue items and skipped existing frames remain. Custom exporters implement the same cleanup
contract. A failed queue item gives `isError: true`.
Bridge calls, `wait: false`, batch tools and single-frame tools retain synchronous/polling
behaviour and do not report MCP progress or cancellation. Use a direct blocking command call
for an MCP-cancellable render.