diff --git a/Cargo.lock b/Cargo.lock index 07564b7df..091a6ebf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1458,6 +1458,7 @@ dependencies = [ "same-file", "serde", "serde_json", + "tempfile", "thiserror 2.0.21", "web-time", ] diff --git a/Cargo.toml b/Cargo.toml index afc59257b..e105557ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,6 +76,7 @@ harfrust = { version = "0.12", default-features = false, features = ["std"] } exr = "1.74" image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } same-file = "1.0" +tempfile = "3.27" # media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001). # Only `crates/media`, `crates/export` and `crates/interchange` may depend on these. diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 2befb5c4b..816186bed 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -726,12 +726,9 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { } } let out = args.opt("--out").unwrap_or("frame.png").to_string(); - // Never over footage, a proxy or the project file (checked before rendering). - b.check_output(&out)?; let t0 = std::time::Instant::now(); - let f = b.render_with(comp.as_ref(), time, max_side, args.flag("--transparent"))?; + let f = b.render_to_file(comp.as_ref(), time, max_side, args.flag("--transparent"), &out)?; let ms = t0.elapsed().as_secs_f64() * 1000.0; - std::fs::write(&out, &f.png).map_err(|e| format!("cannot write {out}: {e}"))?; let info = json!({"path": out, "comp": f.comp, "time": f.time, "width": f.width, "height": f.height, "ms": (ms * 10.0).round() / 10.0}); if json_out { emit(&info, true); diff --git a/crates/automation/src/backend.rs b/crates/automation/src/backend.rs index c0dca6c9a..e0116403a 100644 --- a/crates/automation/src/backend.rs +++ b/crates/automation/src/backend.rs @@ -85,10 +85,9 @@ impl Backend { } } - /// Refuse `path`, a file this caller is about to write itself (a frame, a screenshot), when - /// the open project reads it: footage, a proxy or the project file. The project is the - /// session's or the app's, so both are asked through `file.checkOutput`; a relative path is - /// made absolute here, against this process's folder, because the app has its own. + /// Query whether the current project protects `path`. This is an advisory snapshot, not a + /// reservation: use [`Self::render_to_file`] to validate and write in the owning session. + /// Relative paths are resolved against this process's folder, because the app has its own. pub fn check_output(&mut self, path: &str) -> Result<()> { let absolute = std::path::absolute(path).map_err(|e| Error::BadArgs(format!("cannot resolve {path}: {e}")))?; let found = self.exec("file.checkOutput", json!({"path": absolute.to_string_lossy()}))?; @@ -115,16 +114,40 @@ impl Backend { /// [`Backend::render`]; `transparent` keeps the frame's alpha instead of compositing it over /// the comp's background colour. pub fn render_with(&mut self, comp: Option<&Value>, time: Option, max_side: u32, transparent: bool) -> Result { + self.render_output(comp, time, max_side, transparent, None) + } + + /// Render and save in the owning session. In bridge mode, checking and writing are one + /// desktop operation, so another command cannot make the destination protected in between. + pub fn render_to_file(&mut self, comp: Option<&Value>, time: Option, max_side: u32, transparent: bool, path: &str) -> Result { + if let Some(confinement) = self.confinement() { + confinement.check(path, crate::confine::Access::Write)?; + } + let absolute = std::path::absolute(path).map_err(|e| Error::BadArgs(format!("cannot resolve {path}: {e}")))?; + self.render_output(comp, time, max_side, transparent, Some(&absolute.to_string_lossy())) + } + + fn render_output(&mut self, comp: Option<&Value>, time: Option, max_side: u32, transparent: bool, path: Option<&str>) -> Result { match &mut self.target { Target::Headless(s) => { + if let Some(path) = path { + s.refuse_export(path)?; + } let cid = s.resolve_comp(comp).map_err(|_| no_comp(comp))?; let t = time.map(Tick::from_seconds_f64).unwrap_or_else(|| s.time()); let (w, h, rgba) = s.render_rgba8_alpha(cid, t, max_side, transparent)?; let png = encode_png(w, h, rgba, max_side)?; + if let Some(path) = path { + s.write_output(path, &png)?; + } Ok(Frame { png, comp: json!(cid.0), time: t.seconds(), width: w, height: h }) } Target::Bridge(b) => { let mut p = json!({"max_side": max_side, "base64": true}); + if let Some(path) = path { + p["path"] = json!(path); + p["write"] = json!(true); + } if transparent { p["transparent"] = json!(true); } @@ -135,6 +158,11 @@ impl Backend { p["comp"] = c.clone(); } let r = b.call("render.frame", p)?; + if let Some(path) = path + && r.get("path").and_then(Value::as_str) != Some(path) + { + return Err(Error::App("render.frame did not confirm the saved path; update the connected app before saving frames".into())); + } let data = r.get("png").and_then(Value::as_str).ok_or_else(|| Error::App("render.frame returned no `png`".into()))?; let raw = base64::decode(data).ok_or_else(|| Error::App("render.frame: bad base64".into()))?; let (png, width, height) = recompress_png(&raw, max_side)?; diff --git a/crates/automation/src/confine.rs b/crates/automation/src/confine.rs index 0e9d79d4b..1cdd1b29d 100644 --- a/crates/automation/src/confine.rs +++ b/crates/automation/src/confine.rs @@ -103,6 +103,8 @@ const WRITES: &[(&str, &[&str])] = &[ ("file.saveCopy", &["path"]), ("file.saveCopyAsXml", &["path"]), ("file.collectFiles", &["folder"]), + // This query checks a proposed write destination, so it follows write confinement. + ("file.checkOutput", &["path"]), ("file.exportLottie", &["path"]), ("comp.saveFrameAs", &["path"]), ("comp.saveFrameAsPsd", &["path"]), @@ -472,8 +474,12 @@ mod tests { let steps = serde_json::json!({"steps": [{"command": "essential.templateInfo", "params": {"path": "t.ectemplate"}}]}); assert!(c.check_command("engine.batch", &steps).is_err()); // Writes: into a write root only. - assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&read, "cut.xml")})).is_err()); - assert!(c.check_command("file.exportTimeline", &serde_json::json!({"path": p(&write, "cut.xml")})).is_ok()); + for id in ["file.exportTimeline", "file.checkOutput"] { + for bad in ["cut.xml".to_string(), p(&read, "cut.xml"), p(&outside, "cut.xml")] { + assert!(c.check_command(id, &serde_json::json!({"path": bad})).is_err(), "{id} {bad}"); + } + assert!(c.check_command(id, &serde_json::json!({"path": p(&write, "cut.xml")})).is_ok()); + } let create = |project: String| serde_json::json!({"path": p(&read, "t.ectemplate"), "projectPath": project}); assert!(c.check_command("templates.create", &create(p(&read, "new.ecproj"))).is_err(), "templates.create writes projectPath"); assert!(c.check_command("templates.create", &create(p(&write, "new.ecproj"))).is_ok()); diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 5e846dd3a..497e3e303 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -486,6 +486,21 @@ fn autosave_flushes_on_read_errors_and_broken_output() { std::fs::remove_dir_all(dir).unwrap(); } +fn fake_render_frame(session: &Session, params: &Value) -> Value { + let png = crate::encode_png(4, 2, vec![255; 32], 0).unwrap(); + if params["write"] == true { + let Some(path) = params["path"].as_str() else { return json!({"ok": false, "error": "missing output path"}) }; + if let Err(error) = session.write_output(path, &png) { + return json!({"ok": false, "error": error.to_string()}); + } + } + let mut result = json!({"comp": 1, "time": 0.5, "width": 4, "height": 2, "png": base64::encode(&png)}); + if params["write"] == true { + result["path"] = params["path"].clone(); + } + json!({"ok": true, "result": result}) +} + /// A fake desktop control channel: answers `engine.execute` from a real session, and canned /// `render.frame` / `ui.screenshot` replies. fn fake_app() -> u16 { @@ -503,10 +518,7 @@ fn fake_app() -> u16 { Ok(v) => json!({"ok": true, "result": v}), Err(e) => json!({"ok": false, "error": e.to_string()}), }, - "render.frame" => { - let png = crate::encode_png(4, 2, vec![255; 32], 0).unwrap(); - json!({"ok": true, "result": {"comp": 1, "time": 0.5, "width": 4, "height": 2, "png": base64::encode(&png)}}) - } + "render.frame" => fake_render_frame(&session, p), "ui.screenshot" => { let png = crate::encode_png(8, 8, vec![128; 256], 0).unwrap(); std::fs::write(p["path"].as_str().unwrap(), png).unwrap(); @@ -1695,10 +1707,8 @@ fn bridged_render_frame_asks_the_app_before_it_writes() { Ok(v) => json!({"ok": true, "result": v}), Err(e) => json!({"ok": false, "error": e.to_string()}), }, - "render.frame" => { - let png = crate::encode_png(4, 2, vec![255; 32], 0).unwrap(); - json!({"ok": true, "result": {"comp": 1, "time": 0.5, "width": 4, "height": 2, "png": base64::encode(&png)}}) - } + "render.frame" if p["write"] == true && p["path"].is_string() => fake_render_frame(&session, p), + "render.frame" => json!({"ok": false, "error": "a frame save must be one app-owned write"}), m => json!({"ok": false, "error": format!("unknown method `{m}`")}), }; reply["id"] = msg["id"].clone(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index aeffa464c..cb1a3cdab 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -407,17 +407,14 @@ fn render_frame(b: &mut Backend, a: &Value) -> Result { let max_side = get(a, "max_side").and_then(Value::as_u64).unwrap_or(960) as u32; let transparent = get(a, "transparent").and_then(Value::as_bool).unwrap_or(false); let path = get(a, "path").and_then(Value::as_str); - if let Some(p) = path { - if let Some(c) = b.confinement() { - c.check(p, crate::confine::Access::Write)?; - } - // Before the frame is rendered: never over footage, a proxy or the project file. - b.check_output(p)?; - } - let f = b.render_with(get(a, "comp"), get(a, "time").and_then(Value::as_f64), max_side, transparent)?; + let comp = get(a, "comp"); + let time = get(a, "time").and_then(Value::as_f64); + let f = match path { + Some(path) => b.render_to_file(comp, time, max_side, transparent, path)?, + None => b.render_with(comp, time, max_side, transparent)?, + }; let mut info = json!({"comp": f.comp, "time": f.time, "width": f.width, "height": f.height}); if let Some(p) = path { - std::fs::write(p, &f.png).map_err(|e| Error::Other(format!("cannot write {p}: {e}")))?; info["path"] = json!(p); } if get(a, "inline").and_then(Value::as_bool) == Some(false) { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 4bb488569..8e0f23711 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -43,3 +43,4 @@ exr = { workspace = true } # Native only: the volume's own identity for a file, to tell that a hard link is the footage it leads to. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] same-file = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/engine/src/cmd/timeline.rs b/crates/engine/src/cmd/timeline.rs index f607a36dc..fbc11654f 100644 --- a/crates/engine/src/cmd/timeline.rs +++ b/crates/engine/src/cmd/timeline.rs @@ -16,6 +16,8 @@ use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, str_p}; use crate::jobs::TaskCtl; use crate::{EngineError, Exporter, Result, Session, cmd}; +mod staging; + fn format_p(p: &Value, cmd: &str) -> Result> { match str_p(p, "format") { None | Some("") | Some("auto") => Ok(None), @@ -178,7 +180,6 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result { let relative_to = Some(dir.to_string_lossy().to_string()).filter(|d| !d.is_empty()); let opts = ExportOptions { format, prerender: prerender_mode, precomps, relative_to }; let project = s.project.clone(); - let services = s.services.clone(); let cx = PrerenderCx { protected: crate::output_guard::Protected::of(s), exporter: s.exporter.clone(), @@ -191,17 +192,21 @@ fn export_timeline(s: &mut Session, p: &Value) -> Result { // in Window ▸ Progress instead of with the window waiting (#2269). let wait = !b_p(p, "background").unwrap_or(false); s.spawn_task("exportTimeline", format!("Export {}", format.label()), wait, move |ctl| { - let (bytes, mut result) = export_timeline_work(&cx, &project, cid, &opts, &dir, &stem, ctl)?; + let (bytes, mut result, staged) = export_timeline_work(&cx, &project, cid, &opts, &dir, &stem, ctl)?; if let Some(source) = cx.protected.find(std::path::Path::new(&path)) { return Err(source.refusal(&path, crate::output_guard::Use::Export)); } - services.write_file(&path, &bytes).map_err(|e| format!("cannot write {path}: {e}"))?; result["path"] = json!(path); result["toast"] = json!(match format { TimelineFormat::Fcp7Xml => format!("Exported {path} (Final Cut Pro XML; import it in Premiere Pro with File ▸ Import)"), _ => format!("Exported {path} ({})", format.label()), }); - Ok(Box::new(move |_: &mut Session| Ok(result))) + Ok(Box::new(move |s: &mut Session| { + s.refuse_export(&path)?; + staging::commit(staged, s)?; + s.write_output(&path, &bytes)?; + Ok(result) + })) }) } @@ -215,11 +220,15 @@ fn export_timeline_work( dir: &std::path::Path, stem: &str, ctl: &TaskCtl, -) -> std::result::Result<(Vec, Value), String> { +) -> std::result::Result<(Vec, Value, Vec), String> { let mut pre: HashMap<(ItemId, LayerId), Prerendered> = HashMap::new(); let mut rendered = vec![]; let mut errors = vec![]; + let mut staged = vec![]; let plan = effectcraft_interchange::plan_prerender(project, cid, opts); + if plan.len() > staging::MAX_MOVIES { + return Err(format!("too many pre-render movies (limit {})", staging::MAX_MOVIES)); + } let n = plan.len() as u64; for (i, (c, l)) in (0u64..).zip(plan) { let comp_name = project.item(c).map(|it| it.name.clone()).unwrap_or_default(); @@ -233,10 +242,13 @@ fn export_timeline_work( } // Progress over all the layers, each one's frames a share of it. let mut progress = |done: u64, total: u64| ctl.progress(i * 1000 + done.saturating_mul(1000) / total.max(1), n * 1000); - match prerender(cx, project, c, l, &file, &mut progress) { - Ok(r) => { + let movie = staging::Movie::new(&file)?; + match prerender(cx, project, c, l, &movie.path, &mut progress) { + Ok(mut r) => { + r.path = file; rendered.push(r.path.clone()); pre.insert((c, l), r); + staged.push(movie); } Err(_) if ctl.cancelled() => return Err(crate::render_queue::CANCELLED.into()), Err(e) => errors.push(format!("{comp_name} ▸ {layer_name}: {e}")), @@ -255,7 +267,7 @@ fn export_timeline_work( "prerendered": rendered, "warnings": warnings, }); - Ok((out.bytes, result)) + Ok((out.bytes, result, staged)) } /// The formats, for agents and the export dialog. diff --git a/crates/engine/src/cmd/timeline/staging.rs b/crates/engine/src/cmd/timeline/staging.rs new file mode 100644 index 000000000..103371cd2 --- /dev/null +++ b/crates/engine/src/cmd/timeline/staging.rs @@ -0,0 +1,86 @@ +//! Native pre-render files stay private until the app can check the current project. + +#[cfg(not(target_arch = "wasm32"))] +use crate::EngineError; +use crate::Session; + +pub(super) const MAX_MOVIES: usize = 4096; + +pub(super) struct Movie { + pub path: String, + #[cfg(not(target_arch = "wasm32"))] + directory: tempfile::TempDir, + #[cfg(not(target_arch = "wasm32"))] + destination: std::path::PathBuf, +} + +impl Movie { + pub fn new(path: &str) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + let path = std::path::Path::new(path); + let parent = path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(std::path::Path::new(".")); + std::fs::create_dir_all(parent).map_err(|e| format!("cannot create pre-render folder {}: {e}", parent.display()))?; + let directory = + tempfile::Builder::new().prefix(".effectcraft-prerender-").tempdir_in(parent).map_err(|e| format!("cannot stage pre-render: {e}"))?; + let name = path.file_name().ok_or_else(|| format!("pre-render has no file name: {}", path.display()))?; + let staged = directory.path().join(name).to_string_lossy().into_owned(); + Ok(Self { path: staged, directory, destination: parent.to_path_buf() }) + } + #[cfg(target_arch = "wasm32")] + { + // spawn_task is synchronous on WASM, so session mutations cannot interleave. + Ok(Self { path: path.to_string() }) + } + } +} + +pub(super) fn commit(movies: Vec, session: &Session) -> crate::Result<()> { + #[cfg(not(target_arch = "wasm32"))] + { + const FILES_PER_MOVIE: usize = 8; + if movies.len() > MAX_MOVIES { + return Err(EngineError::Other("too many staged pre-render movies".into())); + } + let mut files = Vec::new(); + let mut path_bytes = 0usize; + for movie in &movies { + let entries = std::fs::read_dir(movie.directory.path()).map_err(|e| EngineError::Other(format!("cannot read staged pre-render: {e}")))?; + for (index, entry) in entries.take(FILES_PER_MOVIE + 1).enumerate() { + if index == FILES_PER_MOVIE { + return Err(EngineError::Other("too many files in a staged pre-render".into())); + } + let entry = entry.map_err(|e| EngineError::Other(format!("cannot read staged pre-render entry: {e}")))?; + let kind = entry.file_type().map_err(|e| EngineError::Other(format!("cannot inspect staged pre-render: {e}")))?; + if !kind.is_file() { + return Err(EngineError::Other("a staged pre-render contains an unexpected directory or link".into())); + } + let source = entry.path(); + let destination = movie.destination.join(entry.file_name()); + path_bytes = path_bytes.saturating_add(source.as_os_str().len()).saturating_add(destination.as_os_str().len()); + if path_bytes > 16 * 1024 * 1024 { + return Err(EngineError::Other("staged pre-render paths exceed the 16 MiB limit".into())); + } + files.push((source, destination)); + } + } + // If a user imported a private staging file, leave the staging folders intact. + if let Some((source, _)) = files.iter().find(|(source, _)| session.refuse_export(&source.to_string_lossy()).is_err()) { + let source = source.display().to_string(); + for movie in movies { + let _kept = movie.directory.keep(); + } + return Err(EngineError::Other(format!("staged pre-render {source} is now used by the project; its temporary folder was kept"))); + } + // Preflight every destination before replacing any output, including render logs. + for (_, destination) in &files { + session.refuse_export(&destination.to_string_lossy())?; + } + for (source, destination) in files { + std::fs::rename(&source, &destination).map_err(|e| EngineError::Other(format!("cannot publish pre-render {}: {e}", destination.display())))?; + } + } + #[cfg(target_arch = "wasm32")] + let _ = (movies, session); + Ok(()) +} diff --git a/crates/engine/src/output_guard.rs b/crates/engine/src/output_guard.rs index 40dcc1e56..ca667cd99 100644 --- a/crates/engine/src/output_guard.rs +++ b/crates/engine/src/output_guard.rs @@ -12,7 +12,9 @@ //! write the project's own file and do not. use std::collections::{HashMap, HashSet}; -use std::path::{Component, Path, PathBuf}; +#[cfg(not(target_arch = "wasm32"))] +use std::path::Component; +use std::path::{Path, PathBuf}; use std::sync::Arc; use effectcraft_project::render_queue::{RenderQueueItem, log_path, numbered_template, sequence_frame, sequence_path}; @@ -180,6 +182,7 @@ fn identity_hash(handle: &same_file::Handle) -> u64 { } /// `path` made absolute, with `.` and `..` resolved by name alone (no file is looked at). +#[cfg(not(target_arch = "wasm32"))] fn lexical(path: &Path) -> PathBuf { let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()); let mut out = PathBuf::new(); @@ -200,11 +203,19 @@ fn lexical(path: &Path) -> PathBuf { } /// The name a path is compared by: absolute, resolved by name, without case where volumes ignore it. +#[cfg(not(target_arch = "wasm32"))] fn key(path: &Path) -> String { let parts: Vec = lexical(path).components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect(); fold(&parts.join("/")) } +/// Browser file-table keys distinguish case and literal dot segments, but not a leading slash. +#[cfg(target_arch = "wasm32")] +fn key(path: &Path) -> String { + let path = path.to_string_lossy(); + if path.starts_with('/') { path.into_owned() } else { format!("/{path}") } +} + fn fold(name: &str) -> String { if IGNORES_CASE { name.to_lowercase() } else { name.to_string() } } @@ -274,9 +285,6 @@ impl Session { /// What reads the file at `path`, if the project does. fn blocking(&self, path: &str) -> Option { - if cfg!(target_arch = "wasm32") { - return None; - } self.protected_files().find(Path::new(path)).cloned() } @@ -300,9 +308,7 @@ impl Session { /// [`Session::write_output`] against files already found ([`Session::protected_files`]), /// for a command that writes many. pub(crate) fn write_output_with(&self, protected: &Protected, path: &str, bytes: &[u8]) -> crate::Result<()> { - if !cfg!(target_arch = "wasm32") - && let Some(source) = protected.find(Path::new(path)) - { + if let Some(source) = protected.find(Path::new(path)) { return Err(crate::EngineError::Other(source.refusal(path, Use::Export))); } self.services.write_file(path, bytes).map_err(|e| crate::EngineError::Other(format!("cannot write {path}: {e}"))) @@ -452,6 +458,7 @@ mod tests { } #[test] + #[cfg(not(target_arch = "wasm32"))] fn paths_compare_by_name_once_resolved() { let here = std::env::current_dir().unwrap(); assert_eq!(key(Path::new("a/b/../c.mp4")), key(Path::new("a/c.mp4"))); diff --git a/crates/engine/src/tests/export_overwrite.rs b/crates/engine/src/tests/export_overwrite.rs index 50f5d2ff8..5cc65b1d5 100644 --- a/crates/engine/src/tests/export_overwrite.rs +++ b/crates/engine/src/tests/export_overwrite.rs @@ -255,8 +255,8 @@ fn a_timeline_export_does_not_pre_render_over_footage() { } } -/// `file.checkOutput` answers before anything is written, for callers that write the file -/// themselves (the CLI, MCP's `render_frame`). +/// `file.checkOutput` answers before anything is written. Its reply describes current state; +/// callers still need a guarded write in the owning session, as CLI/MCP frame saves use. #[test] fn check_output_names_the_files_an_export_may_not_use() { let mut f = reading("check-output", "png"); @@ -283,3 +283,51 @@ fn a_timeline_export_does_not_write_over_the_project_file() { assert!(e.contains("Edit.xml") && e.contains("project"), "{e}"); assert_eq!(f.read("Edit.xml"), saved); } + +#[test] +fn a_background_timeline_export_rechecks_its_document_before_completion() { + let mut f = Fixture::new("timeline-current-project"); + f.comp("Main"); + let path = f.text("Pending.xml"); + let job = f.s.execute("file.exportTimeline", json!({"path": path, "background": true, "prerender": "none"})).unwrap(); + assert!(job["job"].is_string()); + // The app may save the project while a background export is waiting to finish. + f.s.execute("file.saveAs", json!({"path": path})).unwrap(); + let saved = f.read("Pending.xml"); + f.s.wait_jobs(); + let done = f.s.job_log.last().unwrap(); + assert_eq!(done.status, "failed", "the destination became the project: {}", done.message); + assert!(done.message.contains("project")); + assert_eq!(f.read("Pending.xml"), saved); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn background_pre_render_movies_are_checked_again_before_publication() { + for source in ["footage", "project"] { + let mut f = Fixture::new(&format!("timeline-staged-{source}")); + f.comp("Main"); + f.s.execute("layer.newSolid", json!({"name": "Red"})).unwrap(); + let params = json!({"path": f.text("Main.otio"), "prerender": "unsupported"}); + let first = f.s.execute("file.exportTimeline", params.clone()).unwrap(); + let movie = first["prerendered"][0].as_str().unwrap().to_string(); + let filename = std::path::Path::new(&movie).file_name().unwrap().to_string_lossy().into_owned(); + let document = f.read("Main.otio"); + let mut background = params; + background["background"] = json!(true); + f.s.execute("file.exportTimeline", background).unwrap(); + if source == "footage" { + f.footage(&filename); + } else { + f.s.execute("file.saveAs", json!({"path": movie})).unwrap(); + } + let protected = f.read(&filename); + f.s.wait_jobs(); + let done = f.s.job_log.last().unwrap(); + assert_eq!(done.status, "failed", "{}", done.message); + assert!(done.message.contains(source), "{}", done.message); + assert_eq!(f.read(&filename), protected); + assert_eq!(f.read("Main.otio"), document); + assert!(listing(&f.dir).iter().all(|name| !name.starts_with(".effectcraft-prerender-"))); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 023bfa8b3..86bd20c93 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -673,9 +673,9 @@ fn quit(app: &mut EffectcraftApp, ctx: &egui::Context, p: &Value) -> Outcome { ok(Value::Null) } -/// `render.frame {comp?, time?, max_side?, path?, base64?}`: render a comp frame through the session +/// `render.frame {comp?, time?, max_side?, path?, base64?, write?}`: render a comp frame through the session /// (independent of the viewer zoom/resolution) and return it as PNG, written to `path` (default a -/// temp file) or inline as base64 when `base64: true`. +/// temp file) or inline as base64 when `base64: true`. `write: true` with `base64: true` does both. fn render_frame(app: &EffectcraftApp, p: &Value) -> Outcome { let s = &app.session; // A file the project reads is never the output (checked before rendering). @@ -702,7 +702,7 @@ fn render_frame(app: &EffectcraftApp, p: &Value) -> Outcome { let mut out = json!({"comp": cid.0, "time": t.seconds(), "width": w, "height": h}); // (the web has no file system: inline unless a path is given) let inline = p.get("base64").and_then(Value::as_bool).unwrap_or(cfg!(target_arch = "wasm32") && p.get("path").is_none()); - if inline { + if inline && !p.get("write").and_then(Value::as_bool).unwrap_or(false) { out["png"] = json!(base64(&png)); return ok(out); } @@ -711,7 +711,10 @@ fn render_frame(app: &EffectcraftApp, p: &Value) -> Outcome { .and_then(Value::as_str) .map(str::to_string) .unwrap_or_else(|| std::env::temp_dir().join(format!("effectcraft-frame-{}.png", std::process::id())).to_string_lossy().to_string()); - match std::fs::write(&path, png) { + if inline { + out["png"] = json!(base64(&png)); + } + match s.write_output(&path, &png) { Ok(()) => { out["path"] = json!(path); ok(out) diff --git a/crates/ui-egui/tests/it/ui_export_overwrite.rs b/crates/ui-egui/tests/it/ui_export_overwrite.rs index e14d9a7e8..997359479 100644 --- a/crates/ui-egui/tests/it/ui_export_overwrite.rs +++ b/crates/ui-egui/tests/it/ui_export_overwrite.rs @@ -89,6 +89,9 @@ fn render_frame_never_writes_over_a_file_the_project_reads() { let v = r.reply("render.frame", json!({"path": r.text("frame.png")})); assert_eq!(v["ok"], true, "{v}"); assert_eq!(&r.read("frame.png")[..8], b"\x89PNG\r\n\x1a\n"); + let v = r.reply("render.frame", json!({"path": r.text("inline.png"), "base64": true, "write": true})); + assert_eq!(v["ok"], true, "{v}"); + assert_eq!(v["result"]["png"].as_str(), Some(control::base64(&r.read("inline.png")).as_str())); } #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 900d9518a..60328099e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -41,7 +41,7 @@ printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params | UI commands (via either) | | `timeline.column {column, visible?}` (av, keys, label, num, comment, switches, modes, parent, in, out, duration, stretch), `timeline.sourceName {value?}`, `timeline.search {query}`, `timeline.reveal.` / `timeline.revealAdd.` (the property shortcuts and Shift+shortcut: position, scale, rotation, opacity, anchor, effects, masks, feather, levels, animated, and the double-press sets maskPath, maskOpacity, timeRemap, expressions, material, paint, selected, missingEffects, waveform), `timeline.keyAt.` (Alt+Shift+A/P/S/R/T), `timeline.twirlSelected` ``(Ctrl/Cmd+`)``, `timeline.collapseAll`, `flowchart.options {layers?, effects?, solids?, direction?: lr\|tb, comp?}`, `flowchart.graph {comp?}` → `{nodes, edges}`, `window.workspace {name}` (all After Effects workspaces). | | `engine.commands` | `{filter?, enabledOnly?}` | `[{id, label, menu, shortcut, params, enabled, why}]` | | `ui.menu.list` | `{}` | The menu tree with enablement. | -| `render.frame` | `{comp?, time?, max_side?, path?, base64?}` | Renders a comp frame through the session, independent of the viewer zoom or resolution. `comp` is an id or name (default: the active comp), `time` is in comp seconds (default: the CTI), `max_side` caps the longest side (0 = full size). Writes a PNG to `path` (default: a temp file) and returns `{comp, time, width, height, path}`. With `base64: true` it returns `{…, png: ""}` and writes no file. | +| `render.frame` | `{comp?, time?, max_side?, path?, base64?, write?}` | Renders a comp frame through the session, independent of the viewer zoom or resolution. `comp` is an id or name (default: the active comp), `time` is in comp seconds (default: the CTI), `max_side` caps the longest side (0 = full size). Writes a PNG to `path` (default: a temp file) and returns `{comp, time, width, height, path}`. With `base64: true` it returns `{…, png: ""}` and writes no file unless `write: true` also requests a guarded write in the same app operation. | Useful query commands for `engine.execute` (they aren't journaled or undoable): `project.summary`, `comp.info {comp?}`, `layer.tree {layer, comp?, depth?, time?}` (every node has a `path`),