mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 20:47:18 -06:00
#332: render Essential Graphics templates from CLI values
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user