mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #451 from sanyamsingh001/fix/cli-template-rendering
This commit is contained in:
4 files changed
+709
-6
No files matched your search
@@ -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 <info|commands|exec|run|props|get|se
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
|
||||
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
|
||||
a relative --out is relative to the working directory
|
||||
render/render-frame --template T.ectemplate [--values V.json] render one template instance
|
||||
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] per-layer/effect render timings;
|
||||
--play N renders N consecutive frames with/without the layer cache;
|
||||
--gpu compares CPU and GPU ms/frame for every comp at Full and Half
|
||||
@@ -137,6 +142,8 @@ const VALUED: &[&str] = &[
|
||||
"--depth",
|
||||
"--eval",
|
||||
// render
|
||||
"--template",
|
||||
"--values",
|
||||
"--format",
|
||||
"--start",
|
||||
"--end",
|
||||
@@ -419,6 +426,7 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> 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)") };
|
||||
|
||||
@@ -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<Map<String, Value>, 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<Value, Failure> {
|
||||
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<String, Value>) -> 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<String, Value>) -> 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<u64> = 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(())
|
||||
}
|
||||
@@ -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<u8>)]) {
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user