From a560830b6433bd3ce4078a46f7b234659e001df2 Mon Sep 17 00:00:00 2001 From: Oldrich333 <184516902+Oldrich333@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:32:51 +0000 Subject: [PATCH] CLI: render --out is relative to the working directory (#300) `effectcraft-cli render --out proj/out/d.mp4 --project proj/x.ecproj` wrote `proj/proj/out/d.mp4`: the Render Queue resolves a relative output against its own base, while `--project` and every other CLI path are relative to the working directory. The CLI now makes `--out` absolute against the working directory before queueing it, so the file lands where the shell user expects; an absolute path is unchanged, and `rendered[].output` in the JSON result is the absolute path. Render Queue outputs set inside a project (and `render --queue`) resolve as before: tokens expanded, then `export.defaultOutputFolder`, the project's folder, the working directory. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + apps/effectcraft-cli/src/main.rs | 9 ++++++ apps/effectcraft-cli/tests/cli.rs | 49 +++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index ac20f022..87efd67a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,6 @@ # Roadmap +- 2026-10-08: `effectcraft-cli render --out` resolves a relative path against the working directory, like `--project` (it used the project's folder, which doubled a path that already named it). Render Queue outputs set in a project are unchanged. - 2026-10-06: Settings ▸ General ▸ Language persists English/Japanese menu labels (`general.language`); native UI reuses installed Japanese font fallback, with no bundled CJK font. Dialog and panel contents remain English. EffectCraft aims to do what After Effects does, with the same panels, menus and behaviour, written diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 605db2fc..fdafd101 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -15,6 +15,7 @@ //! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off] //! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100] //! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice] +//! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings) //! effectcraft-cli render F.ecproj --queue render the project's Render Queue //! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] render timings //! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half) @@ -100,6 +101,7 @@ const USAGE: &str = "usage: effectcraft-cli ) -> Value { } } +/// A relative `--out` is relative to the working directory, like `--project` and every other CLI +/// path. The Render Queue alone would resolve it against the project's folder. +fn from_cwd(path: &str) -> Result { + std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).map_err(|e| Failure::Error(format!("render: cannot resolve --out {path}: {e}"))) +} + /// `render`: queue `--comp` (or the active comp) with the given settings unless `--queue`, then /// render the queue with a progress line on stderr. Fails if any item fails. fn render(args: &Args, json_out: bool) -> Result<(), Failure> { @@ -602,6 +610,7 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> { let err = |e: effectcraft_engine::EngineError| Failure::Error(e.to_string()); if !args.flag("--queue") { let Some(out) = args.opt("--out") else { return usage_err("render: --out FILE is required (or --queue)") }; + let out = &from_cwd(out)?; let mut p = json!({"output": out}); if let Some(c) = args.opt("--comp") { p["comp"] = json!(c); diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index c2827617..74d1fad7 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -247,3 +247,52 @@ fn script_file_and_eval() { let err = String::from_utf8_lossy(&out.stderr); assert!(err.contains(".jsxbin scripts are not supported: run the .jsx source") && !err.contains("SyntaxError"), "{err}"); } + +/// `render --out` (#300): a relative path is relative to the working directory like `--project`, +/// not to the project's folder (which doubled a path that already named it). +#[test] +fn render_out_is_relative_to_the_working_directory() { + let root = tmp("render-out"); + let _ = std::fs::remove_dir_all(&root); + let (proj, elsewhere) = (root.join("proj"), root.join("elsewhere")); + std::fs::create_dir_all(&proj).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + let project = proj.join("x.ecproj"); + let p = project.to_str().unwrap(); + ok_json(&[ + "run", + "comp.new", + r#"{"name":"T","width":64,"height":36,"duration":1}"#, + "layer.newSolid", + r#"{"width":64,"height":36}"#, + "--empty", + "--save-as", + p, + ]); + assert!(project.exists()); + let render = |cwd: &std::path::Path, project: &str, out: &str| { + let o = bin() + .current_dir(cwd) + .args(["render", "--project", project, "--format", "gif", "--start", "0", "--end", "0.1", "--resolution", "quarter", "--out", out, "--json"]) + .output() + .unwrap(); + assert!(o.status.success(), "{out}: {}", String::from_utf8_lossy(&o.stderr)); + let v: Value = serde_json::from_slice(&o.stdout).unwrap(); + PathBuf::from(v["rendered"][0]["output"].as_str().unwrap()) + }; + // A path that already names the project folder is not doubled. + let done = render(&root, "proj/x.ecproj", "proj/out/d.gif"); + assert_eq!(done, root.join("proj/out/d.gif")); + assert!(done.exists() && !proj.join("proj").exists()); + // Another working directory: the file lands there, not beside the project. + let done = render(&elsewhere, project.to_str().unwrap(), "out/card.gif"); + assert_eq!(done, elsewhere.join("out/card.gif")); + assert!(done.exists() && !proj.join("out/card.gif").exists()); + // `./f.gif` too. + assert_eq!(render(&elsewhere, project.to_str().unwrap(), "./f.gif"), elsewhere.join("./f.gif")); + // An absolute path is unchanged. + let abs = root.join("abs.gif"); + assert_eq!(render(&elsewhere, project.to_str().unwrap(), abs.to_str().unwrap()), abs); + assert!(abs.exists()); + let _ = std::fs::remove_dir_all(&root); +}