//! 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. Direct //! template rendering uses the same original, procedural fixture under the project licence. use std::path::PathBuf; use std::process::Command; use serde_json::Value; fn cli(dir: &PathBuf, args: &[&str]) -> (i32, Value) { let out = Command::new(env!("CARGO_BIN_EXE_effectcraft-cli")).current_dir(dir).args(args).arg("--json").output().unwrap(); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let v = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{args:?}: {e}: {stdout} {}", String::from_utf8_lossy(&out.stderr))); (out.status.code().unwrap_or(-1), v) } fn ok(dir: &PathBuf, args: &[&str]) -> Value { let (code, v) = cli(dir, args); assert_eq!(code, 0, "{args:?}: {v}"); v } const BUILD: &str = r#" app.beginUndoGroup("Build Template"); var comp = app.project.items.addComp("Lower Third", 320, 90, 1, 2, 12); var bar = comp.layers.addSolid([1, 1, 1], "Bar", 320, 40, 1, 2); bar.property("ADBE Transform Group").property("ADBE Position").setValue([160, 65]); var t = comp.layers.addText("Jane Doe"); t.name = "Name"; t.property("ADBE Transform Group").property("ADBE Position").setValue([160, 30]); var fill = bar.property("ADBE Effect Parade").addProperty("ADBE Fill"); t.property("ADBE Text Properties").property("ADBE Text Document").addToMotionGraphicsTemplateAs(comp, "Title"); fill.property("ADBE Fill-0002").addToMotionGraphicsTemplateAs(comp, "Bar Color"); bar.property("ADBE Transform Group").property("ADBE Opacity").addToMotionGraphicsTemplateAs(comp, "Bar Opacity"); comp.motionGraphicsTemplateName = "Lower Third"; app.endUndoGroup(); writeLn("controls: " + comp.motionGraphicsTemplateControllerCount); comp.motionGraphicsTemplateName; "#; #[test] fn essential_graphics_template_via_script_and_commands() { let dir = std::env::temp_dir().join(format!("ec-qa-template-{}", 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 r = ok(&dir, &["script", "build.jsx", "--save-as", "t.ecproj"]); assert_eq!(r["ok"], true, "{r}"); assert_eq!(r["output"], "controls: 3"); let list = ok(&dir, &["exec", "essential.list", r##"{"comp":"Lower Third"}"##, "t.ecproj"]); let controls: Vec<(String, String)> = list["controls"].as_array().unwrap().iter().map(|c| (c["name"].as_str().unwrap().to_string(), c["type"].as_str().unwrap().to_string())).collect(); assert_eq!( controls, [("Title".into(), "text".into()), ("Bar Color".into(), "color".into()), ("Bar Opacity".into(), "slider".into())], "ADBE Fill-0002 is the Fill's Color: {list}" ); // An edit comp with an instance; controls addressed by name. let r = ok( &dir, &[ "run", "t.ecproj", "comp.new", r##"{"name":"Edit","width":320,"height":90,"frameRate":12,"duration":2}"##, "layer.addItem", r##"{"item":"Lower Third"}"##, "comp.new", r##"{"name":"Plain","width":320,"height":90,"frameRate":12,"duration":2}"##, "layer.addItem", r##"{"item":"Lower Third"}"##, "comp.open", r##"{"comp":"Edit"}"##, "essential.set", r##"{"layer":"#1","control":"Title","value":"John Smith"}"##, "essential.set", r##"{"layer":"#1","control":"Bar Color","value":"#00c080"}"##, "essential.set", r##"{"layer":"#1","control":"bar opacity","value":50}"##, "--save", ], ); let inst = ok(&dir, &["exec", "essential.instance", r##"{"comp":"Edit","layer":"#1"}"##, "t.ecproj"]); assert!(inst["controls"].as_array().unwrap().iter().all(|c| c["overridden"] == true), "{inst}"); let (code, e) = cli(&dir, &["exec", "essential.set", r##"{"comp":"Edit","layer":"#1","control":"Subtitle","value":"x"}"##, "t.ecproj"]); assert_eq!(code, 1); assert!(e["error"].as_str().unwrap().contains("controls: Title, Bar Color, Bar Opacity"), "{e} ({r})"); // Render both instances and compare. ok(&dir, &["render-frame", "t.ecproj", "--comp", "Edit", "--time", "1", "--out", "edit.png"]); ok(&dir, &["render-frame", "t.ecproj", "--comp", "Plain", "--time", "1", "--out", "plain.png"]); let edit = image::open(dir.join("edit.png")).unwrap().to_rgba8(); let plain = image::open(dir.join("plain.png")).unwrap().to_rgba8(); let bar = edit.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:?}"); let pbar = plain.get_pixel(10, 75).0; assert!(pbar[0] > 240 && pbar[1] < 15, "the template's own red: {pbar:?}"); let diff = |y0: u32, y1: u32| -> u32 { (y0..y1).flat_map(|y| (0..320).map(move |x| (x, y))).filter(|(x, y)| edit.get_pixel(*x, *y).0 != plain.get_pixel(*x, *y).0).count() as u32 }; assert!(diff(10, 45) > 100, "the title text differs"); // Export the template and bring it into a fresh project. let x = ok(&dir, &["exec", "essential.exportTemplate", r##"{"comp":"Lower Third","path":"lt.ectemplate"}"##, "t.ecproj"]); assert_eq!(x["controls"], 3, "{x}"); let info = ok(&dir, &["exec", "essential.templateInfo", r##"{"path":"lt.ectemplate"}"##, "--empty"]); assert_eq!(info["name"], "Lower Third", "{info}"); let imp = ok(&dir, &["run", "--empty", "essential.importTemplate", r##"{"path":"lt.ectemplate"}"##, "--save-as", "fresh.ecproj"]); 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); }