diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 71ea1ea3..53c19016 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -16,6 +16,8 @@ //! [--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 --template T.ectemplate [--values V.json] --out FILE +//! effectcraft-cli render-frame --template T.ectemplate [--values V.json] [--time S] [--out F.png] //! 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) @@ -39,6 +41,8 @@ use std::io::Write; +mod template; + use effectcraft_automation::tools::{self, Reply}; use effectcraft_automation::{Backend, McpServer}; use serde_json::{Value, json}; @@ -102,6 +106,7 @@ const USAGE: &str = "usage: effectcraft-cli Result<(), Failure> { if args.flag("--autosave") && cmd != "mcp" { return usage_err("--autosave is only supported by mcp"); } + template::validate(cmd, args)?; match cmd { "render" => render(args, json_out)?, "bench" => bench_cmd(args)?, @@ -550,7 +558,13 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> { emit(&with_saved(v, saved), json_out); } "render-frame" | "frame" => { - let mut b = backend(args, true)?; + let mut b = if args.opt("--template").is_some() { + let mut s = session(args)?; + template::prepare(&mut s, args)?; + Backend::headless(s) + } else { + backend(args, true)? + }; let comp = args.opt("--comp").and_then(reference); let mut time = args.num("--time")?; let mut max_side = args.num("--max-side")?.map(|m| m as u32).unwrap_or(0); @@ -629,10 +643,14 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> { return usage_err("render runs headless; use `exec renderQueue.add` / `renderQueue.render` with --bridge"); } let mut s = session(args)?; - match &args.project { - Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?, - None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?, - }; + if args.opt("--template").is_some() { + template::prepare(&mut s, args)?; + } else { + match &args.project { + Some(p) => s.execute("file.open", json!({"path": p})).map_err(|e| Failure::Error(e.to_string()))?, + None => s.execute("file.openDemoProject", json!({})).map_err(|e| Failure::Error(e.to_string()))?, + }; + } 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)") }; diff --git a/apps/effectcraft-cli/src/template.rs b/apps/effectcraft-cli/src/template.rs new file mode 100644 index 00000000..f4da23cd --- /dev/null +++ b/apps/effectcraft-cli/src/template.rs @@ -0,0 +1,138 @@ +//! CLI-only setup of an Essential Graphics template instance using engine commands. + +use std::io::Read; + +use effectcraft_engine::{ + EngineError, Session, + project::{ItemId, LayerId}, +}; +use serde_json::{Map, Value, json}; + +use crate::{Args, Failure, usage_err}; + +pub(super) fn validate(cmd: &str, args: &Args) -> Result<(), Failure> { + if args.opt("--values").is_some() && args.opt("--template").is_none() { + return usage_err("--values requires --template"); + } + if args.opt("--template").is_none() { + return Ok(()); + } + if !matches!(cmd, "render" | "render-frame" | "frame") { + return usage_err("--template is available for render and render-frame"); + } + if args.project.is_some() || args.flag("--demo") || args.opt("--bridge").is_some() || args.flag("--queue") || args.opt("--comp").is_some() { + return usage_err("--template renders a new instance; it cannot be combined with --project, --demo, --bridge, --queue or --comp"); + } + if !args.pos.is_empty() { + return usage_err("template rendering takes --template FILE.ectemplate, without positional arguments"); + } + if cmd == "render" && args.opt("--out").is_none() { + return usage_err("render: --out FILE is required (or --queue)"); + } + // Resolve existing outputs before importing or rendering can overwrite an input. + let out = args.opt("--out").or_else(|| matches!(cmd, "render-frame" | "frame").then_some("frame.png")); + if let Some(out) = out + && let Ok(output) = std::fs::canonicalize(out) + { + for input in ["--template", "--values"] { + if let Some(path) = args.opt(input) + && std::fs::canonicalize(path).is_ok_and(|path| path == output) + { + return Err(Failure::Error(format!("--out {out} would overwrite {input} {path}"))); + } + } + } + Ok(()) +} + +fn values(args: &Args) -> Result, Failure> { + let Some(path) = args.opt("--values") else { return Ok(Map::new()) }; + // A bounded read also handles a file that grows after opening. + const MAX_BYTES: u64 = 1024 * 1024; + let file = std::fs::File::open(path).map_err(|e| Failure::Error(format!("cannot read values {path}: {e}")))?; + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1).read_to_end(&mut bytes).map_err(|e| Failure::Error(format!("cannot read values {path}: {e}")))?; + if bytes.len() as u64 > MAX_BYTES { + return Err(Failure::Error(format!("values {path}: JSON exceeds 1 MiB"))); + } + let value: Value = serde_json::from_slice(&bytes).map_err(|e| Failure::Error(format!("values {path}: invalid JSON: {e}")))?; + match value { + Value::Object(values) => Ok(values), + _ => Err(Failure::Error(format!("values {path}: expected a JSON object mapping control names to values"))), + } +} + +fn execute(s: &mut Session, command: &str, params: Value) -> Result { + s.execute_checked(command, params).map_err(|e| Failure::Error(e.to_string())) +} + +// Use the same instance commands to reject invalid values before import extracts media. +fn preflight(s: &Session, path: &str, values: &Map) -> Result<(), Failure> { + let bytes = s.services.read_file(path).map_err(|e| Failure::Error(format!("cannot read {path}: {e}")))?; + effectcraft_engine::guard::guarded("template value validation", || { + let bad = |msg: String| EngineError::BadParams { cmd: "essential.importTemplate".into(), msg }; + let (manifest, mut project, _) = effectcraft_engine::commands::essential::read_template(&bytes).map_err(bad)?; + let source = manifest.get("comp").and_then(Value::as_u64).ok_or_else(|| bad("manifest has no `comp`".into()))?; + let base = s.project.next_id; + let source = source.checked_add(base).ok_or_else(|| bad("template composition ID overflows the import offset".into()))?; + effectcraft_engine::project::essential::offset_ids(&mut project, base); + project.fix_next_id(); + let mut check = Session::new(); + check.project = project.into(); + Ok(instantiate(&mut check, source, values)) + }) + .map_err(|e| Failure::Error(e.to_string()))? +} + +pub(super) fn prepare(s: &mut Session, args: &Args) -> Result<(), Failure> { + let Some(path) = args.opt("--template") else { return Ok(()) }; + let values = values(args)?; + if !values.is_empty() { + preflight(s, path, &values)?; + } + let imported = execute(s, "essential.importTemplate", json!({"path": path, "addToComp": false}))?; + let source = imported.get("comp").and_then(Value::as_u64).ok_or_else(|| Failure::Error("template import did not return a composition".into()))?; + instantiate(s, source, &values) +} + +fn instantiate(s: &mut Session, source: u64, values: &Map) -> Result<(), Failure> { + // Duplicate and empty the source with commands: this preserves exact rational frame rate, + // Tick duration, background, work area and renderer settings without round-tripping floats. + let duplicate = execute(s, "project.duplicate", json!({"items": [source]}))?; + let parent = duplicate + .get("items") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(Value::as_u64) + .ok_or_else(|| Failure::Error("cannot create template render composition".into()))?; + execute(s, "comp.open", json!({"comp": parent}))?; + let layers: Vec = s + .project + .comp(ItemId(parent)) + .ok_or_else(|| Failure::Error("template render composition is missing".into()))? + .layers + .iter() + .map(|layer| layer.id.0) + .collect(); + if !layers.is_empty() { + execute(s, "layer.setSwitch", json!({"comp": parent, "layers": layers, "switch": "lock", "value": false}))?; + execute(s, "edit.clear", json!({"comp": parent, "layers": layers}))?; + } + let added = execute(s, "layer.addItem", json!({"comp": parent, "item": source, "time": 0}))?; + let instance = added.get("layer").and_then(Value::as_u64).ok_or_else(|| Failure::Error("cannot create template instance".into()))?; + // Keep a partial final frame: layer.addItem snaps its default end to the frame grid. + s.edit("Template Duration", None, |project, _| { + let comp = project.comp_mut(ItemId(parent)).ok_or(EngineError::NoComp)?; + let duration = comp.duration; + let layer = comp.layer_mut(LayerId(instance)).ok_or_else(|| EngineError::Other("template instance is missing".into()))?; + layer.out_point = duration; + Ok(()) + }) + .map_err(|e| Failure::Error(e.to_string()))?; + // Let the wrapper carry the source composition's authored motion blur. + execute(s, "layer.setSwitch", json!({"comp": parent, "layers": [instance], "switch": "motionBlur", "value": true}))?; + for (control, value) in values { + execute(s, "essential.set", json!({"comp": parent, "layer": instance, "control": control, "value": value}))?; + } + Ok(()) +} diff --git a/apps/effectcraft-cli/tests/qa_template.rs b/apps/effectcraft-cli/tests/qa_template.rs index e6525561..ac67280e 100644 --- a/apps/effectcraft-cli/tests/qa_template.rs +++ b/apps/effectcraft-cli/tests/qa_template.rs @@ -1,6 +1,7 @@ //! M13.15 scenario 5 — Essential Graphics through the CLI: a MOGRT-style template built by an //! After Effects-style script (`effectcraft-cli script`), its controls changed with commands on -//! an instance, rendered, exported as a template and imported into a fresh project. +//! an instance, rendered, exported as a template and imported into a fresh project. Direct +//! template rendering uses the same original, procedural fixture under the project licence. use std::path::PathBuf; use std::process::Command; @@ -111,5 +112,543 @@ fn essential_graphics_template_via_script_and_commands() { assert!(imp["saved"].is_string(), "{imp}"); let list = ok(&dir, &["exec", "essential.list", r##"{"comp":"Lower Third"}"##, "fresh.ecproj"]); assert_eq!(list["controls"].as_array().unwrap().len(), 3, "{list}"); + + // One-shot rendering applies the JSON values to an instance, preserving the template defaults. + let original_template = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap(); + ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "direct.png"]); + assert_eq!(image::open(dir.join("direct.png")).unwrap().to_rgba8(), edit); + ok(&dir, &["frame", "--template", "lt.ectemplate", "--frame", "12", "--out", "defaults.png"]); + assert_eq!(image::open(dir.join("defaults.png")).unwrap().to_rgba8(), plain); + + let output = dir.join("sequence/direct_[#####].png"); + let rendered = ok( + &dir, + &[ + "render", + "--template", + "lt.ectemplate", + "--values", + "values.json", + "--out", + output.to_str().unwrap(), + "--format", + "png", + "--channels", + "rgb", + "--start", + "1", + "--end", + "1.0833333333333333", + ], + ); + assert_eq!(rendered["rendered"].as_array().unwrap().len(), 1, "{rendered}"); + let frames: Vec<_> = std::fs::read_dir(dir.join("sequence")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "png")) + .collect(); + assert_eq!(frames.len(), 1, "{frames:?}"); + assert_eq!(image::open(&frames[0]).unwrap().to_rgba8(), edit); + assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template); + + // Invalid values fail before the queue creates an output directory, or frame rendering + // overwrites an existing output. A JSON object and existing control names are required. + for (name, values, expected) in [ + ("malformed", "{", "invalid JSON"), + ("array", "[]", "JSON object"), + ("unknown", r#"{"Subtitle":"x"}"#, "controls: Title, Bar Color, Bar Opacity"), + ("invalid-value", r#"{"Bar Opacity":"bad"}"#, "can't use"), + ] { + let values_file = format!("{name}.json"); + std::fs::write(dir.join(&values_file), values).unwrap(); + let output_dir = dir.join(format!("failed-{name}")); + let out = output_dir.join("frame_[#####].png"); + let (code, error) = cli(&dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]); + assert_eq!(code, 1, "{name}: {error}"); + assert!(error["error"].as_str().unwrap().contains(expected), "{name}: {error}"); + assert!(!output_dir.exists(), "{name}: invalid input created render output"); + std::fs::write(dir.join("untouched.png"), b"existing output").unwrap(); + let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]); + assert_eq!(code, 1, "{name}: {error}"); + assert_eq!(std::fs::read(dir.join("untouched.png")).unwrap(), b"existing output"); + } + let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]); + assert_eq!(code, 1, "{error}"); + assert!(error["error"].as_str().unwrap().contains("cannot read values"), "{error}"); + assert!(!dir.join("missing.png").exists()); + std::fs::write(dir.join("large-values.json"), vec![b' '; 1024 * 1024 + 1]).unwrap(); + let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]); + assert_eq!(code, 1, "{error}"); + assert!(error["error"].as_str().unwrap().contains("exceeds 1 MiB"), "{error}"); + assert!(!dir.join("large.png").exists()); + + for args in [ + vec!["render-frame", "--values", "values.json"], + vec!["info", "--template", "lt.ectemplate"], + vec!["render-frame", "--template", "lt.ectemplate", "--comp", "Lower Third"], + vec!["render-frame", "t.ecproj", "--template", "lt.ectemplate"], + vec!["render", "--template", "lt.ectemplate", "--queue"], + vec!["render-frame", "--template", "lt.ectemplate", "--bridge", "1"], + ] { + let output = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(&dir).args(&args).output().unwrap(); + assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr)); + } + + // File-loaded templates can use exact rational rates that comp.new's float parser rounds. + // Preserve that rate, Tick duration, work area, background and locked source layers. + let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("t.ecproj")).unwrap()).unwrap(); + let source = project.find_by_name("Lower Third").unwrap().id; + let rate = effectcraft_time::FrameRate::new(24001, 1001); + let comp = project.comp_mut(source).unwrap(); + comp.frame_rate = rate; + comp.duration = rate.tick_of(24); + comp.work_area = (rate.tick_of(3), rate.tick_of(7)); + comp.background = [0.08, 0.16, 0.24]; + comp.layers.first_mut().unwrap().switches.locked = true; + std::fs::write(dir.join("rational.ecproj"), project.to_json()).unwrap(); + ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]); + let rational_template = std::fs::read(dir.join("rational.ectemplate")).unwrap(); + let rational = ok(&dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]); + assert!((rational["time"].as_f64().unwrap() - 5.0 / rate.as_f64()).abs() < 1e-9, "{rational}"); + assert_eq!((rational["width"].as_u64().unwrap(), rational["height"].as_u64().unwrap()), (320, 90)); + let output = dir.join("rational-work-area/frame_[#####].png"); + ok(&dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]); + let count = std::fs::read_dir(dir.join("rational-work-area")) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.path().extension().is_some_and(|extension| extension == "png")) + .count(); + assert_eq!(count, 4, "the template's exact work area contains frames 3 through 6"); + assert_eq!(std::fs::read(dir.join("rational.ectemplate")).unwrap(), rational_template); + assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template); + let _ = std::fs::remove_dir_all(&dir); +} +fn template_output_alias_fixture(case: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("ec-qa-template-output-{case}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("build.jsx"), BUILD).unwrap(); + let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]); + assert_eq!(built["output"], "controls: 3", "{built}"); + let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]); + assert_eq!(exported["controls"], 3, "{exported}"); + std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap(); + let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let values_before = std::fs::read(dir.join("values.json")).unwrap(); + + // The same valid inputs must still render to a distinct output before testing aliases. + ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]); + let image = image::open(dir.join("ordinary.png")).unwrap().to_rgba8(); + let bar = image.get_pixel(10, 75).0; + assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "50 % of #00c080 over black: {bar:?}"); + assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before); + assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), values_before); + dir +} + +fn assert_template_output_preserves_inputs(dir: &PathBuf, out: &str) { + let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let values_before = std::fs::read(dir.join("values.json")).unwrap(); + // Read the inputs before interpreting the subprocess status or error text. + let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + .current_dir(dir) + .args(["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", out, "--json"]) + .output() + .unwrap(); + let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let values_after = std::fs::read(dir.join("values.json")).unwrap(); + let template_preserved = template_after == template_before; + let values_preserved = values_after == values_before; + eprintln!( + "output={out:?} status={:?} template_preserved={template_preserved} values_preserved={values_preserved} template_bytes={}->{} values_bytes={}->{} stdout={} stderr={}", + result.status.code(), + template_before.len(), + template_after.len(), + values_before.len(), + values_after.len(), + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr), + ); + assert!(template_preserved, "template rendering must preserve its template input when --out aliases that file"); + assert!(values_preserved, "template rendering must preserve its values input when --out aliases that file"); + assert!(!result.status.success(), "an output alias must fail before rendering overwrites an input"); +} + +#[test] +fn template_frame_output_cannot_replace_its_template_input() { + let dir = template_output_alias_fixture("template"); + assert_template_output_preserves_inputs(&dir, "lt.ectemplate"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn template_frame_output_cannot_replace_its_values_input() { + let dir = template_output_alias_fixture("values"); + assert_template_output_preserves_inputs(&dir, "values.json"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn template_frame_output_cannot_alias_its_template_through_parent_components() { + let dir = template_output_alias_fixture("normalized-template"); + std::fs::create_dir(dir.join("alias")).unwrap(); + let out = "alias/../lt.ectemplate"; + assert_eq!(dir.join(out).canonicalize().unwrap(), dir.join("lt.ectemplate").canonicalize().unwrap()); + assert_template_output_preserves_inputs(&dir, out); + let _ = std::fs::remove_dir_all(&dir); +} + +fn timeline_tail_render_pair(dir: &PathBuf, name: &str, duration: effectcraft_time::Tick) { + let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("base.ecproj")).unwrap()).unwrap(); + let source = project.find_by_name("Lower Third").unwrap().id; + let comp = project.comp_mut(source).unwrap(); + assert_eq!(comp.frame_rate, effectcraft_time::FrameRate::new(12, 1)); + comp.duration = duration; + comp.work_area = (effectcraft_time::Tick::ZERO, duration); + for layer in &mut comp.layers { + layer.out_point = duration; + } + assert!(comp.layers.iter().all(|layer| layer.is_active_at(effectcraft_time::Tick::from_seconds_f64(1.0)))); + let project_name = format!("{name}.ecproj"); + let template_name = format!("{name}.ectemplate"); + let source_frame = format!("{name}-source.png"); + let template_frame = format!("{name}-template.png"); + std::fs::write(dir.join(&project_name), project.to_json()).unwrap(); + let params = serde_json::json!({"comp":"Lower Third", "path":template_name}).to_string(); + let exported = ok(dir, &["exec", "essential.exportTemplate", ¶ms, &project_name]); + assert_eq!(exported["controls"], 3, "{exported}"); + let project_before = std::fs::read(dir.join(&project_name)).unwrap(); + let template_before = std::fs::read(dir.join(&template_name)).unwrap(); + + let direct = ok(dir, &["render-frame", &project_name, "--comp", "Lower Third", "--time", "1", "--out", &source_frame]); + let instance = ok(dir, &["render-frame", "--template", &template_name, "--time", "1", "--out", &template_frame]); + for result in [&direct, &instance] { + assert_eq!(result["time"].as_f64(), Some(1.0), "{name}: {result}"); + assert_eq!((result["width"].as_u64(), result["height"].as_u64()), (Some(320), Some(90)), "{name}: {result}"); + } + assert_eq!(std::fs::read(dir.join(&project_name)).unwrap(), project_before, "{name}: rendering preserves the source project bytes"); + assert_eq!(std::fs::read(dir.join(&template_name)).unwrap(), template_before, "{name}: rendering preserves the exported template bytes"); + let direct = image::open(dir.join(&source_frame)).unwrap().to_rgba8(); + let instance = image::open(dir.join(&template_frame)).unwrap().to_rgba8(); + assert_eq!(direct.dimensions(), (320, 90)); + assert_eq!(instance.dimensions(), direct.dimensions()); + let direct_bar = direct.get_pixel(10, 75).0; + let instance_bar = instance.get_pixel(10, 75).0; + assert!( + direct_bar[0] > 240 && direct_bar[1] < 15 && direct_bar[2] < 15, + "{name}: direct source has the authored red bar at the requested valid time: {direct_bar:?}" + ); + let different = direct.pixels().zip(instance.pixels()).filter(|(a, b)| a != b).count(); + eprintln!("{name}: duration={duration:?}, direct_bar={direct_bar:?}, instance_bar={instance_bar:?}, different_pixels={different}"); + assert_eq!(different, 0, "{name}: the template instance must preserve every source pixel at the same valid time"); +} + +#[test] +fn template_instance_preserves_the_partial_final_source_frame() { + let dir = std::env::temp_dir().join(format!("ec-qa-template-partial-tail-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("build.jsx"), BUILD).unwrap(); + let built = ok(&dir, &["script", "build.jsx", "--save-as", "base.ecproj"]); + assert_eq!(built["output"], "controls: 3", "{built}"); + + // Establish ordinary instance rendering before testing the final partial source frame. + timeline_tail_render_pair(&dir, "aligned", effectcraft_time::Tick::from_seconds_f64(2.0)); + let partial = effectcraft_time::Tick(effectcraft_time::TICKS_PER_SECOND * 101 / 100); + assert_eq!(partial.seconds(), 1.01); + timeline_tail_render_pair(&dir, "partial", partial); + let _ = std::fs::remove_dir_all(&dir); +} + +// Original procedural motion: the renderer supplies the expected pixels for both states. +const GEOMETRY_BLUR_BUILD: &str = r#" +app.beginUndoGroup("Build Blur Template"); +var comp = app.project.items.addComp("Blur Template", 160, 64, 1, 2, 24); +var mover = comp.layers.addSolid([1, 0.3, 0.1], "Mover", 12, 16, 1, 2); +var position = mover.property("ADBE Transform Group").property("ADBE Position"); +position.setValueAtTime(0, [16, 32]); +position.setValueAtTime(1, [144, 32]); +position.setInterpolationTypeAtKey(1, KeyframeInterpolationType.LINEAR, KeyframeInterpolationType.LINEAR); +position.setInterpolationTypeAtKey(2, KeyframeInterpolationType.LINEAR, KeyframeInterpolationType.LINEAR); +mover.motionBlur = true; +comp.motionBlur = true; +comp.shutterAngle = 180; +comp.shutterPhase = -90; +mover.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraphicsTemplateAs(comp, "Opacity"); +app.endUndoGroup(); +"#; + +fn geometry_blur_difference(a: &image::RgbaImage, b: &image::RgbaImage) -> usize { + assert_eq!(a.dimensions(), b.dimensions()); + a.pixels().zip(b.pixels()).filter(|(a, b)| a != b).count() +} + +fn geometry_blur_assert_inputs(dir: &std::path::Path, inputs: &[(&str, Vec)]) { + for (name, before) in inputs { + assert_eq!(&std::fs::read(dir.join(name)).unwrap(), before, "template rendering preserves input {name}"); + } +} + +#[test] +fn template_wrapper_preserves_authored_motion_blur() { + let dir = std::env::temp_dir().join(format!("ec-qa-template-authored-blur-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("motion.jsx"), GEOMETRY_BLUR_BUILD).unwrap(); + let built = ok(&dir, &["script", "motion.jsx", "--save-as", "moving.ecproj"]); + assert_eq!(built["ok"], true, "{built}"); + + // Check the authored switch/key setup, then use an unchanged top-level composition as + // the production pixel oracle. No expected raster or threshold is synthesized here. + let project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("moving.ecproj")).unwrap()).unwrap(); + let source = project.find_by_name("Blur Template").unwrap().id; + let comp = project.comp(source).unwrap(); + assert!(comp.enable_motion_blur); + assert_eq!(comp.frame_rate, effectcraft_time::FrameRate::new(24, 1)); + assert_eq!(comp.layers.len(), 1); + assert!(comp.layers[0].switches.motion_blur); + assert_eq!(comp.layers[0].props.prop("transform/position").unwrap().keys.len(), 2); + let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Blur Template","path":"moving.ectemplate"}"#, "moving.ecproj"]); + assert_eq!(exported["controls"], 1, "{exported}"); + + // The positive control disables only the source layer's authored Motion Blur switch. + // It retains the same geometry, timing, source comp settings and template-control values. + ok( + &dir, + &[ + "run", + "moving.ecproj", + "layer.setSwitch", + r#"{"comp":"Blur Template","layers":["Mover"],"switch":"motionBlur","value":false}"#, + "--save-as", + "unblurred.ecproj", + ], + ); + let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Blur Template","path":"unblurred.ectemplate"}"#, "unblurred.ecproj"]); + assert_eq!(exported["controls"], 1, "{exported}"); + let inputs: Vec<_> = ["moving.ecproj", "moving.ectemplate", "unblurred.ecproj", "unblurred.ectemplate"] + .into_iter() + .map(|name| (name, std::fs::read(dir.join(name)).unwrap())) + .collect(); + + ok(&dir, &["render-frame", "moving.ecproj", "--comp", "Blur Template", "--time", "0.5", "--transparent", "--out", "direct-blur.png"]); + geometry_blur_assert_inputs(&dir, &inputs); + ok(&dir, &["render-frame", "unblurred.ecproj", "--comp", "Blur Template", "--time", "0.5", "--transparent", "--out", "direct-unblurred.png"]); + geometry_blur_assert_inputs(&dir, &inputs); + ok(&dir, &["render-frame", "--template", "unblurred.ectemplate", "--time", "0.5", "--transparent", "--out", "template-unblurred.png"]); + geometry_blur_assert_inputs(&dir, &inputs); + let blurred = image::open(dir.join("direct-blur.png")).unwrap().to_rgba8(); + let unblurred = image::open(dir.join("direct-unblurred.png")).unwrap().to_rgba8(); + let template_unblurred = image::open(dir.join("template-unblurred.png")).unwrap().to_rgba8(); + assert_eq!(blurred.dimensions(), (160, 64)); + assert_eq!(unblurred.dimensions(), (160, 64)); + assert!(blurred.pixels().any(|p| p.0[3] > 0), "the direct blurred source is visible"); + assert!(unblurred.pixels().any(|p| p.0[3] > 0), "the direct unblurred source is visible"); + let authored_blur_difference = geometry_blur_difference(&blurred, &unblurred); + assert!(authored_blur_difference > 0, "the real source renderer must show the authored motion blur"); + assert_eq!(template_unblurred, unblurred, "an unblurred template preserves its source pixels"); + eprintln!("CLI_TEMPLATE_BLUR_CONTROLS_PASSED source_blur_vs_source_unblurred={authored_blur_difference}"); + + ok(&dir, &["render-frame", "--template", "moving.ectemplate", "--time", "0.5", "--transparent", "--out", "template-blur.png"]); + geometry_blur_assert_inputs(&dir, &inputs); + let template_blurred = image::open(dir.join("template-blur.png")).unwrap().to_rgba8(); + let differing_pixels = geometry_blur_difference(&template_blurred, &blurred); + let unblurred_difference = geometry_blur_difference(&template_blurred, &unblurred); + eprintln!( + "template authored-blur result: source_vs_template_differing_pixels={differing_pixels}, template_vs_unblurred_differing_pixels={unblurred_difference}" + ); + assert_eq!(differing_pixels, 0, "template wrapper must preserve authored source motion blur"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn template_unknown_control_does_not_replace_extracted_media() { + let temp_root = std::env::temp_dir().canonicalize().unwrap(); + let dir = temp_root.join(format!("ec-qa-template-embedded-media-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("build.jsx"), BUILD).unwrap(); + let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]); + assert_eq!(built["output"], "controls: 3", "{built}"); + + // Both PNGs are generated here; the template uses an ordinary file-backed footage layer. + image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap(); + let imported_reply = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]); + let imported = &imported_reply["result"]; + assert!(imported["errors"].as_array().unwrap().is_empty(), "{imported}"); + let items = imported["items"].as_array().unwrap(); + assert_eq!(items.len(), 1, "{imported}"); + let item = items[0].as_u64().unwrap(); + let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string(); + ok(&dir, &["exec", "layer.addItem", ¶ms, "t.ecproj", "--save"]); + let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]); + assert_eq!(exported["controls"], 3, "{exported}"); + let info = ok(&dir, &["exec", "essential.templateInfo", r#"{"path":"lt.ectemplate"}"#, "--empty"]); + let media = info["media"].as_array().unwrap(); + assert_eq!(media.len(), 1, "{info}"); + let files = media[0]["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{info}"); + let entry = files[0].as_str().unwrap(); + let relative = std::path::Path::new(entry.strip_prefix("media/").unwrap()); + assert_eq!(relative.components().count(), 2, "{entry}"); + assert!(relative.components().all(|part| matches!(part, std::path::Component::Normal(_))), "{entry}"); + assert_eq!(relative.file_name().unwrap(), "source.png"); + let extracted = dir.join("lt Media").join(relative); + assert!(extracted.starts_with(&dir)); + + std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap(); + let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let valid_values_before = std::fs::read(dir.join("values.json")).unwrap(); + let source_before = std::fs::read(dir.join("source.png")).unwrap(); + ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]); + let ordinary = image::open(dir.join("ordinary.png")).unwrap().to_rgba8(); + let bar = ordinary.get_pixel(10, 75).0; + assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "known control values rendered: {bar:?}"); + let footage = ordinary.get_pixel(8, 8).0; + assert!( + (footage[0] as i32 - 30).abs() <= 2 && (footage[1] as i32 - 60).abs() <= 2 && (footage[2] as i32 - 90).abs() <= 2 && footage[3] == 255, + "embedded footage rendered: {footage:?}" + ); + assert_eq!(std::fs::read(&extracted).unwrap(), source_before, "ordinary render extracted the declared embedded PNG"); + assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before); + assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), valid_values_before); + assert_eq!(std::fs::read(dir.join("source.png")).unwrap(), source_before); + let ordinary_before = std::fs::read(dir.join("ordinary.png")).unwrap(); + + // Keep a different valid PNG at the exact extraction path and an existing render output. + image::RgbaImage::from_pixel(2, 2, image::Rgba([220, 10, 20, 255])).save(&extracted).unwrap(); + let sentinel_before = std::fs::read(&extracted).unwrap(); + assert_ne!(sentinel_before, source_before); + std::fs::copy(dir.join("ordinary.png"), dir.join("failed.png")).unwrap(); + let output_before = std::fs::read(dir.join("failed.png")).unwrap(); + std::fs::write(dir.join("bad-values.json"), r#"{"Subtitle":"x"}"#).unwrap(); + let invalid_values_before = std::fs::read(dir.join("bad-values.json")).unwrap(); + + // Capture every file before interpreting status/JSON, so a failed command cannot hide a write. + let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + .current_dir(&dir) + .args(["render-frame", "--template", "lt.ectemplate", "--values", "bad-values.json", "--time", "1", "--out", "failed.png", "--json"]) + .output() + .unwrap(); + let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let valid_values_after = std::fs::read(dir.join("values.json")).unwrap(); + let invalid_values_after = std::fs::read(dir.join("bad-values.json")).unwrap(); + let source_after = std::fs::read(dir.join("source.png")).unwrap(); + let ordinary_after = std::fs::read(dir.join("ordinary.png")).unwrap(); + let output_after = std::fs::read(dir.join("failed.png")).unwrap(); + let sentinel_after = std::fs::read(&extracted).unwrap(); + let media_preserved = sentinel_after == sentinel_before; + eprintln!( + "EMBEDDED_MEDIA_OBSERVATION status={:?} media_preserved={media_preserved} source_preserved={} output_preserved={} stdout={} stderr={}", + result.status.code(), + source_after == source_before, + output_after == output_before, + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!(result.status.code(), Some(1)); + let reply: Value = serde_json::from_slice(&result.stdout).unwrap(); + let error = reply["error"].as_str().unwrap(); + assert!(error.contains("no control named `Subtitle`") && error.contains("controls: Title, Bar Color, Bar Opacity"), "{reply}"); + assert_eq!(template_after, template_before); + assert_eq!(valid_values_after, valid_values_before); + assert_eq!(invalid_values_after, invalid_values_before); + assert_eq!(source_after, source_before); + assert_eq!(ordinary_after, ordinary_before); + assert_eq!(output_after, output_before); + eprintln!("EMBEDDED_MEDIA_UNKNOWN_CONTROL: valid ordinary render and actual unknown-control failure verified"); + assert!(media_preserved, "invalid template values must fail before overwriting previously extracted media"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn template_missing_output_does_not_replace_extracted_media() { + let temp_root = std::env::temp_dir().canonicalize().unwrap(); + let dir = temp_root.join(format!("ec-qa-template-missing-output-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("build.jsx"), BUILD).unwrap(); + let built = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]); + assert_eq!(built["output"], "controls: 3", "{built}"); + + // Both PNGs are generated here; the template uses an ordinary file-backed footage layer. + image::RgbaImage::from_pixel(2, 2, image::Rgba([30, 60, 90, 255])).save(dir.join("source.png")).unwrap(); + let imported_reply = ok(&dir, &["exec", "file.import", r#"{"paths":["source.png"]}"#, "t.ecproj", "--save"]); + let imported = &imported_reply["result"]; + assert!(imported["errors"].as_array().unwrap().is_empty(), "{imported}"); + let items = imported["items"].as_array().unwrap(); + assert_eq!(items.len(), 1, "{imported}"); + let item = items[0].as_u64().unwrap(); + let params = serde_json::json!({"comp": "Lower Third", "item": item, "position": [8, 8], "time": 0}).to_string(); + ok(&dir, &["exec", "layer.addItem", ¶ms, "t.ecproj", "--save"]); + let exported = ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"lt.ectemplate"}"#, "t.ecproj"]); + assert_eq!(exported["controls"], 3, "{exported}"); + let info = ok(&dir, &["exec", "essential.templateInfo", r#"{"path":"lt.ectemplate"}"#, "--empty"]); + let media = info["media"].as_array().unwrap(); + assert_eq!(media.len(), 1, "{info}"); + let files = media[0]["files"].as_array().unwrap(); + assert_eq!(files.len(), 1, "{info}"); + let entry = files[0].as_str().unwrap(); + let relative = std::path::Path::new(entry.strip_prefix("media/").unwrap()); + assert_eq!(relative.components().count(), 2, "{entry}"); + assert!(relative.components().all(|part| matches!(part, std::path::Component::Normal(_))), "{entry}"); + assert_eq!(relative.file_name().unwrap(), "source.png"); + let extracted = dir.join("lt Media").join(relative); + assert!(extracted.starts_with(&dir)); + + std::fs::write(dir.join("values.json"), r##"{"Title":"John Smith","Bar Color":"#00c080","bar opacity":50}"##).unwrap(); + let template_before = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let valid_values_before = std::fs::read(dir.join("values.json")).unwrap(); + let source_before = std::fs::read(dir.join("source.png")).unwrap(); + ok(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "values.json", "--time", "1", "--out", "ordinary.png"]); + let ordinary = image::open(dir.join("ordinary.png")).unwrap().to_rgba8(); + let bar = ordinary.get_pixel(10, 75).0; + assert!(bar[0] < 15 && (bar[1] as i32 - 96).abs() < 8 && (bar[2] as i32 - 64).abs() < 8, "known control values rendered: {bar:?}"); + let footage = ordinary.get_pixel(8, 8).0; + assert!( + (footage[0] as i32 - 30).abs() <= 2 && (footage[1] as i32 - 60).abs() <= 2 && (footage[2] as i32 - 90).abs() <= 2 && footage[3] == 255, + "embedded footage rendered: {footage:?}" + ); + assert_eq!(std::fs::read(&extracted).unwrap(), source_before, "ordinary render extracted the declared embedded PNG"); + assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), template_before); + assert_eq!(std::fs::read(dir.join("values.json")).unwrap(), valid_values_before); + assert_eq!(std::fs::read(dir.join("source.png")).unwrap(), source_before); + let ordinary_before = std::fs::read(dir.join("ordinary.png")).unwrap(); + + // Keep a different valid PNG at the normal extraction path before a usage error. + image::RgbaImage::from_pixel(2, 2, image::Rgba([220, 10, 20, 255])).save(&extracted).unwrap(); + let sentinel_before = std::fs::read(&extracted).unwrap(); + assert_ne!(sentinel_before, source_before); + + // Observe writes before interpreting the actual CLI usage exit. + let result = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")) + .current_dir(&dir) + .args(["render", "--template", "lt.ectemplate", "--values", "values.json", "--json"]) + .output() + .unwrap(); + let template_after = std::fs::read(dir.join("lt.ectemplate")).unwrap(); + let values_after = std::fs::read(dir.join("values.json")).unwrap(); + let source_after = std::fs::read(dir.join("source.png")).unwrap(); + let output_after = std::fs::read(dir.join("ordinary.png")).unwrap(); + let sentinel_after = std::fs::read(&extracted).unwrap(); + let media_preserved = sentinel_after == sentinel_before; + let stderr = String::from_utf8_lossy(&result.stderr); + eprintln!( + "MISSING_OUTPUT_OBSERVATION status={:?} media_preserved={media_preserved} source_preserved={} output_preserved={} stdout={} stderr={stderr}", + result.status.code(), + source_after == source_before, + output_after == ordinary_before, + String::from_utf8_lossy(&result.stdout), + ); + assert_eq!(result.status.code(), Some(2), "{stderr}"); + assert!(stderr.contains("render: --out FILE is required (or --queue)"), "{stderr}"); + assert_eq!(template_after, template_before); + assert_eq!(values_after, valid_values_before); + assert_eq!(source_after, source_before); + assert_eq!(output_after, ordinary_before); + assert!(!dir.join("frame.png").exists(), "a missing render output must not create a default frame"); + eprintln!("MISSING_OUTPUT_REQUIRED_OUT: valid ordinary render/extraction and actual required-output usage failure verified"); + assert!(media_preserved, "missing --out must fail before overwriting previously extracted media"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/docs/agents.md b/docs/agents.md index 207406f5..cc4e5c2b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -232,6 +232,14 @@ build real projects through these interfaces; they are worked examples of everyt * **Essential Graphics**: controls can be addressed by name (`essential.set {"layer":"#1", "control":"Title","value":"John Smith"}`); a command that targets an explicit `comp` runs even when the active comp would disable it (`essential.exportTemplate {"comp":"Lower Third", …}`). + The CLI also renders an exported template directly: + `effectcraft-cli render --template lt.ectemplate --values values.json --out 'title_[#####].png'`. + `values.json` is an object such as `{"Title":"John Smith","Bar Color":"#00c080","Bar Opacity":50}`; + names follow `essential.set`, including its case-insensitive matching. Values override one + imported instance; the template file and its source controls keep their defaults. Omit + `--values` to render those defaults, or use the same options with `render-frame` for a PNG. + Values must fit in 1 MiB. Invalid JSON, unknown controls and invalid values fail before rendering. + Template mode cannot be combined with `--project`, `--demo`, `--bridge`, `--queue` or `--comp`. * **History**: Levels of Undo defaults to 32 (`prefs.set {"key":"general.undoLevels","value":99}`); `history` lists branches and `history {"goto": id}` jumps between them. A command that changes nothing (a value set to what it already is) records no undo step. `get_state` reports `dirty`