mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M13.14: agent-interface audit (every command over MCP, control channel and exec --list; automation ids for every widget)
- command.describe / list_commands {schemas} give each command's params JSON Schema;
MCP describe_command, add_effect, list_effects and get_state (app.capabilities)
- effectcraft-cli exec --list [--schemas]
- headless UI audit: every panel kind's interactive egui widgets are covered by
automation ids (fixed: timeline navigator, label swatch, bar trim edges, group and
property name cells)
- command lookup is indexed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
5cf37921ef
commit
0d5f60c4b7
9 files changed
+493
-35
No files matched your search
@@ -4,6 +4,7 @@
|
||||
//! effectcraft-cli info project + engine summary
|
||||
//! effectcraft-cli commands [--filter TEXT] [--enabled] list engine commands
|
||||
//! effectcraft-cli exec <command-id> [--params JSON] run one command
|
||||
//! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`)
|
||||
//! effectcraft-cli run <id> <json> [<id> <json> …] run several commands in order
|
||||
//! effectcraft-cli props <comp> <layer> [--flat] [--time S] a layer's property tree (with paths)
|
||||
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
|
||||
@@ -36,7 +37,7 @@ use serde_json::{Value, json};
|
||||
const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|set|render-frame|render|script|mcp> [args] [--json]
|
||||
info project + engine summary
|
||||
commands [--filter TEXT] [--enabled] list engine commands
|
||||
exec <command-id> [--params JSON] run one engine command
|
||||
exec <command-id> [--params JSON] run one engine command (exec --list: list them, as `commands`)
|
||||
run <id> <json> [<id> <json> ...] run several commands in order
|
||||
props <comp> <layer> [--flat] [--time S] a layer's property tree with paths
|
||||
get <comp> <layer> <path> [--time S] read a property
|
||||
@@ -346,23 +347,8 @@ fn run(cmd: &str, args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
}
|
||||
}
|
||||
}
|
||||
"commands" => {
|
||||
let mut b = backend(args, false)?;
|
||||
let v = tool(&mut b, "list_commands", json!({"filter": args.opt("--filter"), "enabled_only": args.flag("--enabled")}))?;
|
||||
if json_out {
|
||||
emit(&v, true);
|
||||
} else {
|
||||
for c in v.as_array().into_iter().flatten() {
|
||||
println!(
|
||||
"{:<36} {:<36} {:<16} {}",
|
||||
c["id"].as_str().unwrap_or(""),
|
||||
c["label"].as_str().unwrap_or(""),
|
||||
c["shortcut"].as_str().unwrap_or(""),
|
||||
c["params"].as_str().unwrap_or("")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"commands" => list_commands_cmd(args, json_out)?,
|
||||
"exec" if args.flag("--list") => list_commands_cmd(args, json_out)?,
|
||||
"script" => script_cmd(args, json_out)?,
|
||||
"exec" => {
|
||||
let Some(id) = args.pos.first().cloned() else { return usage_err("exec needs a command id") };
|
||||
@@ -605,6 +591,27 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `commands` / `exec --list`: every engine command (id, label, shortcut, params doc; with
|
||||
/// `--schemas` also each command's params JSON Schema).
|
||||
fn list_commands_cmd(args: &Args, json_out: bool) -> Result<(), Failure> {
|
||||
let mut b = backend(args, false)?;
|
||||
let v = tool(&mut b, "list_commands", json!({"filter": args.opt("--filter"), "enabled_only": args.flag("--enabled"), "schemas": args.flag("--schemas")}))?;
|
||||
if json_out {
|
||||
emit(&v, true);
|
||||
} else {
|
||||
for c in v.as_array().into_iter().flatten() {
|
||||
println!(
|
||||
"{:<36} {:<36} {:<16} {}",
|
||||
c["id"].as_str().unwrap_or(""),
|
||||
c["label"].as_str().unwrap_or(""),
|
||||
c["shortcut"].as_str().unwrap_or(""),
|
||||
c["params"].as_str().unwrap_or("")
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `bench`: time one frame N times without the layer cache (per-layer and per-effect breakdown),
|
||||
/// and optionally `--play N` consecutive frames with and without the cache.
|
||||
fn bench_cmd(args: &Args) -> Result<(), Failure> {
|
||||
|
||||
@@ -44,6 +44,30 @@ fn info_and_commands() {
|
||||
assert!(ids.iter().all(|i| i.contains("layer.new") || !i.is_empty()));
|
||||
}
|
||||
|
||||
/// The agent-interface audit (CLI side): `exec --list` lists every registered engine command
|
||||
/// with its label, params doc and (with `--schemas`) params JSON Schema, and `exec` routes every
|
||||
/// id to its command (an unknown parameter is rejected by that command).
|
||||
#[test]
|
||||
fn exec_list_covers_every_engine_command() {
|
||||
let v = ok_json(&["exec", "--list", "--schemas", "--empty"]);
|
||||
let listed: std::collections::HashMap<&str, &Value> = v.as_array().unwrap().iter().map(|c| (c["id"].as_str().unwrap(), c)).collect();
|
||||
let specs = effectcraft_engine::command_specs();
|
||||
assert_eq!(listed.len(), specs.len());
|
||||
for spec in specs {
|
||||
let c = listed.get(spec.id).unwrap_or_else(|| panic!("`exec --list` misses {}", spec.id));
|
||||
assert_eq!(c["label"], spec.label);
|
||||
// Empty docs (`{}`) are dropped from the compact listing.
|
||||
assert!(c["params"] == spec.params || (spec.params == "{}" && c["params"].is_null()), "{}: {}", spec.id, c["params"]);
|
||||
assert_eq!(c["schema"]["type"], "object", "{}", spec.id);
|
||||
}
|
||||
// Routing: a few ids through a real process (all ids are checked in-process by the MCP audit).
|
||||
for id in ["comp.new", "layer.newText", "effect.apply", "file.saveAs", "render.frame"].into_iter().filter(|i| listed.contains_key(i)) {
|
||||
let (code, v) = run_json(&["exec", id, r#"{"__audit":1}"#, "--empty"]);
|
||||
assert_eq!(code, 1, "{id}: {v}");
|
||||
assert!(v["error"].as_str().unwrap().contains("unknown parameter"), "{id}: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_set_get_with_saved_project() {
|
||||
let proj = tmp("edit.ecproj");
|
||||
|
||||
@@ -317,3 +317,58 @@ fn run_script_round_trip() {
|
||||
let (c, err) = call(&mut bare, "run_script", json!({"code": "1"}));
|
||||
assert!(err && c[0]["text"].as_str().unwrap().contains("not available"), "{c:?}");
|
||||
}
|
||||
|
||||
/// The agent-interface audit (MCP side): every registered engine command is listed by
|
||||
/// `list_commands` with a label, params doc and JSON Schema, described by `describe_command`,
|
||||
/// and routed by `execute_command` (an unknown parameter is rejected by that very command
|
||||
/// without running it).
|
||||
#[test]
|
||||
fn every_engine_command_is_reachable_over_mcp() {
|
||||
let mut s = server();
|
||||
let tools = rpc(&mut s, 1, "tools/list", json!({}))["tools"].as_array().unwrap().clone();
|
||||
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
for want in ["list_commands", "describe_command", "execute_command", "add_effect", "list_effects", "get_state", "render_frame", "get_layer", "set_property"]
|
||||
{
|
||||
assert!(names.contains(&want), "{want}");
|
||||
}
|
||||
let listed = call_json(&mut s, "list_commands", json!({"schemas": true}));
|
||||
let listed: std::collections::HashMap<&str, &Value> = listed.as_array().unwrap().iter().map(|c| (c["id"].as_str().unwrap(), c)).collect();
|
||||
let specs = effectcraft_engine::command_specs();
|
||||
assert_eq!(listed.len(), specs.len());
|
||||
for spec in specs {
|
||||
let c = listed.get(spec.id).unwrap_or_else(|| panic!("{} not listed", spec.id));
|
||||
assert!(!spec.label.trim().is_empty(), "{}: no label", spec.id);
|
||||
assert!(spec.params.trim_start().starts_with('{'), "{}: params doc {:?}", spec.id, spec.params);
|
||||
assert_eq!(c["schema"]["type"], "object", "{}", spec.id);
|
||||
assert!(c["schema"]["properties"].is_object(), "{}", spec.id);
|
||||
let d = call_json(&mut s, "describe_command", json!({"command": spec.id}));
|
||||
assert_eq!(d["schema"], c["schema"], "{}", spec.id);
|
||||
let (content, err) = call(&mut s, "execute_command", json!({"command": spec.id, "params": {"__audit": 1}}));
|
||||
let text = content[0]["text"].as_str().unwrap_or_default();
|
||||
assert!(err && text.contains("unknown parameter") && text.contains(spec.id), "{}: {text}", spec.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_level_tools_add_effect_and_report_state() {
|
||||
let mut s = server();
|
||||
call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "A", "width": 64, "height": 48, "duration": 1}}));
|
||||
let l = call_json(&mut s, "execute_command", json!({"command": "layer.newSolid", "params": {"color": "#808080"}}))["layer"].clone();
|
||||
let fx = call_json(&mut s, "list_effects", json!({"filter": "gaussian"}));
|
||||
assert!(fx.as_array().unwrap().iter().any(|e| e["id"] == "ec.blur.gaussian" && e["gpu"].is_boolean()), "{fx}");
|
||||
let r = call_json(&mut s, "add_effect", json!({"layer": l, "effect": "Gaussian Blur", "values": {"blurriness": 7}}));
|
||||
assert_eq!(r["path"], "effects/#1", "{r}");
|
||||
assert!(r["params"].as_array().unwrap().iter().any(|p| p["path"] == "effects/#1/blurriness" && p["value"] == 7.0), "{r}");
|
||||
// A second effect lands at #2.
|
||||
let r = call_json(&mut s, "add_effect", json!({"layer": l, "effect": "ec.color.levels"}));
|
||||
assert_eq!(r["path"], "effects/#2", "{r}");
|
||||
let p = call_json(&mut s, "get_property", json!({"layer": l, "path": "effects/#1/blurriness"}));
|
||||
assert_eq!(p["value"], 7.0, "{p}");
|
||||
let st = call_json(&mut s, "get_state", json!({}));
|
||||
assert!(st["state"].is_object() && st["app"]["commands"].as_u64().unwrap() > 500, "{st}");
|
||||
assert!(st["app"]["effects"]["total"].as_u64().unwrap() > 200, "{st}");
|
||||
assert!(st["app"]["parity"]["summary"].as_str().unwrap().contains("parity"), "{st}");
|
||||
// Unknown effects fail cleanly.
|
||||
let (c, err) = call(&mut s, "add_effect", json!({"layer": l, "effect": "No Such Effect"}));
|
||||
assert!(err && c[0]["text"].as_str().unwrap().contains("unknown effect"), "{c:?}");
|
||||
}
|
||||
@@ -117,7 +117,8 @@ fn params_of(v: Option<&Value>) -> Result<Value> {
|
||||
// ------------------------------------------------------------------ engine tools
|
||||
|
||||
fn list_commands(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only"))]))?;
|
||||
let schemas = get(a, "schemas").and_then(Value::as_bool).unwrap_or(false).then_some(json!(true));
|
||||
let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only")), ("schemas", schemas.as_ref())]))?;
|
||||
// Drop empty fields to keep the listing compact.
|
||||
let list: Vec<Value> = r
|
||||
.as_array()
|
||||
@@ -140,6 +141,53 @@ fn execute_command(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec(id, params)?)
|
||||
}
|
||||
|
||||
fn describe_command(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
let id = get(a, "command").or(get(a, "id")).and_then(Value::as_str).ok_or_else(|| Error::BadArgs("missing `command` (e.g. `layer.newText`)".into()))?;
|
||||
json_reply(b.exec("command.describe", json!({"command": id}))?)
|
||||
}
|
||||
|
||||
fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?)
|
||||
}
|
||||
|
||||
/// `effect.apply` on one layer, then set parameters on the new instance by param id.
|
||||
fn add_effect(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
let (layer, effect, comp) = (need(a, "layer")?, need(a, "effect")?, get(a, "comp"));
|
||||
let count = |t: &Value| effects_group(t).and_then(|g| g.get("children")).and_then(Value::as_array).map_or(0, Vec::len);
|
||||
let before = b.exec("layer.tree", obj(&[("layer", Some(layer)), ("comp", comp)]))?;
|
||||
let index = count(&before) + 1;
|
||||
let mut p = obj(&[("effect", Some(effect)), ("comp", comp)]);
|
||||
p["layers"] = json!([layer]);
|
||||
b.exec("effect.apply", p)?;
|
||||
let prefix = format!("effects/#{index}");
|
||||
let mut set = vec![];
|
||||
if let Some(vals) = get(a, "values").and_then(Value::as_object) {
|
||||
for (k, v) in vals {
|
||||
let path = format!("{prefix}/{k}");
|
||||
b.exec("prop.set", obj(&[("layer", Some(layer)), ("comp", comp), ("path", Some(&json!(path))), ("value", Some(v))]))?;
|
||||
set.push(path);
|
||||
}
|
||||
}
|
||||
let tree = b.exec("layer.tree", obj(&[("layer", Some(layer)), ("comp", comp)]))?;
|
||||
let inst = effects_group(&tree).and_then(|g| g.get("children")).and_then(Value::as_array).and_then(|c| c.get(index - 1)).cloned();
|
||||
let mut params = vec![];
|
||||
if let Some(i) = &inst {
|
||||
flatten(i, &mut params);
|
||||
}
|
||||
json_reply(json!({"layer": tree["id"], "index": index, "path": prefix, "name": inst.as_ref().map(|i| i["name"].clone()), "set": set, "params": params}))
|
||||
}
|
||||
|
||||
/// The `effects` group of a `layer.tree` reply.
|
||||
fn effects_group(tree: &Value) -> Option<&Value> {
|
||||
tree["properties"]["children"].as_array()?.iter().find(|c| c["path"] == "effects")
|
||||
}
|
||||
|
||||
fn get_state(b: &mut Backend, _: &Value) -> Result<Reply> {
|
||||
let state = b.exec("editor.state", json!({}))?;
|
||||
let caps = b.exec("app.capabilities", json!({}))?;
|
||||
json_reply(json!({"state": state, "app": caps}))
|
||||
}
|
||||
|
||||
fn run_script(b: &mut Backend, a: &Value) -> Result<Reply> {
|
||||
let code = get(a, "code").and_then(Value::as_str).ok_or_else(|| Error::BadArgs("missing `code` (JavaScript)".into()))?;
|
||||
let mut p = json!({"code": code});
|
||||
@@ -399,13 +447,21 @@ static TOOLS: &[ToolDef] = &[
|
||||
schema(
|
||||
json!({
|
||||
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."},
|
||||
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."}
|
||||
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."},
|
||||
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}
|
||||
}),
|
||||
&[],
|
||||
)
|
||||
},
|
||||
run: list_commands,
|
||||
},
|
||||
ToolDef {
|
||||
name: "describe_command",
|
||||
description: "One command in full: id, label, menu path, shortcut, the `params` doc, a JSON Schema of its parameters (`schema`: properties, required, additionalProperties) and whether it can run now (`why` if not). Use before execute_command when unsure of the parameters; execute_command rejects unknown keys.",
|
||||
bridge_only: false,
|
||||
schema: || schema(json!({"command": {"type": "string", "description": "Command id, e.g. `layer.newText`."}}), &["command"]),
|
||||
run: describe_command,
|
||||
},
|
||||
ToolDef {
|
||||
name: "execute_command",
|
||||
description: "Run any engine command by id with JSON params; edits are undoable. Examples: comp.new {\"name\":\"Main\",\"width\":1920,\"height\":1080,\"frameRate\":30,\"duration\":5}; layer.newText {\"text\":\"Hello\",\"size\":120,\"fill\":\"#ffffff\"} (returns {\"layer\":id}); layer.newSolid {\"color\":\"#3366ff\"}; layer.newShape; effect.apply {\"layer\":3,\"effect\":\"Gaussian Blur\"}; time.set {\"time\":2.5}; layer.select {\"layers\":[3]}. Layers are referenced by id, \"#n\" or name; times are seconds.",
|
||||
@@ -513,6 +569,36 @@ static TOOLS: &[ToolDef] = &[
|
||||
},
|
||||
run: add_keyframe,
|
||||
},
|
||||
ToolDef {
|
||||
name: "add_effect",
|
||||
description: "Apply an effect to a layer and optionally set its parameters in one undoable call. `effect` is an id (`ec.blur.gaussian`) or After Effects name (`Gaussian Blur`; see list_effects); `values` maps parameter ids to values, e.g. {\"blurriness\": 12}. Returns the instance `path` (`effects/#n`, for set_property / add_keyframe) and its parameters with their paths and current values.",
|
||||
bridge_only: false,
|
||||
schema: || {
|
||||
schema(
|
||||
json!({
|
||||
"layer": layer_s(), "comp": comp_s(),
|
||||
"effect": {"type": "string", "description": "Effect id or name, e.g. `Gaussian Blur`, `Glow`, `Curves`, `ec.color.levels`."},
|
||||
"values": {"type": "object", "description": "Parameter id -> value (ids are in the returned `params` paths), e.g. {\"blurriness\": 12}.", "additionalProperties": true}
|
||||
}),
|
||||
&["layer", "effect"],
|
||||
)
|
||||
},
|
||||
run: add_effect,
|
||||
},
|
||||
ToolDef {
|
||||
name: "list_effects",
|
||||
description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text). Apply with add_effect.",
|
||||
bridge_only: false,
|
||||
schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]),
|
||||
run: list_effects,
|
||||
},
|
||||
ToolDef {
|
||||
name: "get_state",
|
||||
description: "Where things stand: the editor state (active comp, current time, selected layers/keys/items, open comps, tool, work area) plus what this build can do (`app`: version, command and effect counts, GPU effects, import/export formats, After Effects parity summary). Cheap; call it to re-orient after a series of edits.",
|
||||
bridge_only: false,
|
||||
schema: || schema(json!({}), &[]),
|
||||
run: get_state,
|
||||
},
|
||||
ToolDef {
|
||||
name: "render_frame",
|
||||
description: "Render a composition frame (default: the active comp at the current time) and return it as a PNG image, longest side `max_side` (default 960, 0 = full size). `path` also writes the PNG to disk; `inline: false` returns only the metadata. Use it to check your work.",
|
||||
|
||||
@@ -348,8 +348,12 @@ fn list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
|
||||
let v: Vec<Value> = effectcraft_effects::all()
|
||||
.into_iter()
|
||||
.filter(|e| filter.as_ref().is_none_or(|f| e.name.to_ascii_lowercase().contains(f) || e.id.contains(f.as_str())))
|
||||
.map(|e| json!({"id": e.id, "name": e.name, "category": e.category, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::<Vec<_>>()}))
|
||||
.filter(|e| {
|
||||
filter.as_ref().is_none_or(|f| e.name.to_ascii_lowercase().contains(f) || e.id.contains(f.as_str()) || e.category.to_ascii_lowercase().contains(f))
|
||||
})
|
||||
.map(|e| {
|
||||
json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::<Vec<_>>()})
|
||||
})
|
||||
.collect();
|
||||
Ok(json!(v))
|
||||
}
|
||||
|
||||
@@ -213,7 +213,16 @@ pub fn text_preset_list() -> Vec<(String, String)> {
|
||||
}
|
||||
|
||||
pub fn find(id: &str) -> Option<&'static CommandSpec> {
|
||||
command_specs().iter().find(|c| c.id == id)
|
||||
// Every command execution looks its spec up: index the ~2,000 ids once.
|
||||
static INDEX: OnceLock<std::collections::HashMap<&'static str, usize>> = OnceLock::new();
|
||||
let index = INDEX.get_or_init(|| {
|
||||
let mut m = std::collections::HashMap::new();
|
||||
for (i, c) in command_specs().iter().enumerate() {
|
||||
m.entry(c.id).or_insert(i);
|
||||
}
|
||||
m
|
||||
});
|
||||
index.get(id).map(|&i| &command_specs()[i])
|
||||
}
|
||||
|
||||
// ---------- parameter validation (agents) ----------
|
||||
@@ -256,6 +265,63 @@ pub fn accepted_params(doc: &str) -> Option<Vec<String>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Top-level pieces of a params doc (split at depth-0 commas), e.g. `{layer?, path|prop, value}`
|
||||
/// → `["layer?", "path|prop", "value"]`.
|
||||
fn doc_pieces(doc: &str) -> Option<Vec<String>> {
|
||||
let body = doc.trim().strip_prefix('{')?;
|
||||
let (mut out, mut cur, mut depth) = (Vec::new(), String::new(), 0i32);
|
||||
for c in body.chars() {
|
||||
match c {
|
||||
'{' | '[' | '(' => depth += 1,
|
||||
']' | ')' => depth -= 1,
|
||||
'}' if depth == 0 => {
|
||||
if !cur.trim().is_empty() {
|
||||
out.push(cur.trim().to_string());
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
'}' => depth -= 1,
|
||||
',' if depth == 0 => {
|
||||
if !cur.trim().is_empty() {
|
||||
out.push(cur.trim().to_string());
|
||||
}
|
||||
cur.clear();
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
cur.push(c);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The JSON Schema of a command's parameters, derived from its params doc: one property per
|
||||
/// accepted key (described by its piece of the doc), no others. MCP `describe_command` and
|
||||
/// `list_commands {schemas: true}` serve it.
|
||||
pub fn params_schema(spec: &CommandSpec) -> Value {
|
||||
let mut props = serde_json::Map::new();
|
||||
let mut required = vec![];
|
||||
for piece in doc_pieces(spec.params).unwrap_or_default() {
|
||||
let keys = accepted_params(&format!("{{{piece}}}")).unwrap_or_default();
|
||||
let alternatives = keys.len() > 1;
|
||||
for k in keys {
|
||||
// `key?` is optional; so is any key with alternatives (`path|prop`).
|
||||
let optional = alternatives || piece.contains(&format!("{k}?"));
|
||||
if !optional && !required.contains(&k) {
|
||||
required.push(k.clone());
|
||||
}
|
||||
props.insert(k, serde_json::json!({"description": piece}));
|
||||
}
|
||||
}
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"description": spec.params,
|
||||
"properties": props,
|
||||
"required": required,
|
||||
"additionalProperties": accepted_params(spec.params).is_none(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Keys every command understands (comp targeting, gesture merging) and accepted aliases.
|
||||
const ALWAYS_OK: &[&str] = &["comp", "merge"];
|
||||
const ALIASES: &[(&str, &str)] = &[("layers", "layer"), ("layer", "layers"), ("time", "frame"), ("frameRate", "fps"), ("properties", "property")];
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::{EngineError, Result, Session, query};
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
|
||||
let only = b_p(p, "enabledOnly").unwrap_or(false);
|
||||
let schemas = b_p(p, "schemas").unwrap_or(false);
|
||||
let v: Vec<Value> = super::command_specs()
|
||||
.iter()
|
||||
.filter(|c| filter.as_ref().is_none_or(|f| c.id.to_ascii_lowercase().contains(f) || c.label.to_ascii_lowercase().contains(f)))
|
||||
@@ -17,7 +18,12 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if only && en.is_err() {
|
||||
return None;
|
||||
}
|
||||
Some(json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": en.is_ok(), "why": en.err()}))
|
||||
let mut o =
|
||||
json!({"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params, "enabled": en.is_ok(), "why": en.err()});
|
||||
if schemas {
|
||||
o["schema"] = super::params_schema(c);
|
||||
}
|
||||
Some(o)
|
||||
})
|
||||
.collect();
|
||||
Ok(json!(v))
|
||||
@@ -147,13 +153,50 @@ fn layer_tree(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// One command in full, with the JSON Schema of its parameters.
|
||||
fn describe(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_p(p, "command").or(str_p(p, "id")).ok_or_else(|| super::bad("command.describe", "missing `command`"))?;
|
||||
let c = super::find(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
let en = (c.enabled)(s);
|
||||
Ok(json!({
|
||||
"id": c.id, "label": c.label, "menu": c.menu, "shortcut": c.shortcut, "params": c.params,
|
||||
"schema": super::params_schema(c), "undoable": c.journal, "enabled": en.is_ok(), "why": en.err(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// What this build can do: counts agents use to orient themselves, and the parity summary.
|
||||
fn capabilities(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let fx = effectcraft_effects::registry();
|
||||
let formats: Vec<Value> = s.exporter.as_ref().map(|e| e.formats().iter().map(|f| json!(format!("{f:?}"))).collect()).unwrap_or_default();
|
||||
Ok(json!({
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"commands": super::command_specs().len(),
|
||||
"effects": {"total": fx.len(), "gpu": fx.iter().filter(|e| e.gpu).count(), "float": fx.iter().filter(|e| e.float).count()},
|
||||
"export": formats,
|
||||
"import": s.importer.is_some(),
|
||||
"expressions": s.expr.is_some(),
|
||||
"scripting": s.script.is_some(),
|
||||
"gpu": s.accel.is_some(),
|
||||
"parity": {
|
||||
"target": "Adobe After Effects 2026",
|
||||
"summary": PARITY,
|
||||
"doc": "docs/parity.md",
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// The headline of `docs/parity.md` (kept in sync by hand at each audit).
|
||||
const PARITY: &str = "≈94% weighted feature parity (80 done / 12 partial / 0 missing of 92 features); 298 of 298 After Effects 2026 effects present";
|
||||
|
||||
fn state(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(serde_json::to_value(&s.state).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
query!("command.list", "List Commands", "{filter?, enabledOnly?}", list),
|
||||
query!("command.list", "List Commands", "{filter?, enabledOnly?, schemas? (add each command's params JSON Schema)}", list),
|
||||
query!("command.describe", "Describe Command", "{command} → id, label, menu, shortcut, params doc, JSON `schema`, enabled", describe),
|
||||
query!("app.capabilities", "App Capabilities", "{} → version, command/effect counts, export formats, parity summary", capabilities),
|
||||
query!("project.summary", "Project Summary", "{}", summary),
|
||||
query!("comp.info", "Composition Info", "{comp?}", comp_info),
|
||||
query!("layer.tree", "Layer Property Tree", "{layer?, comp?, depth?, time? (comp s)} → nodes with `path`", layer_tree),
|
||||
|
||||
@@ -839,6 +839,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
p.rect_filled(Rect::from_min_max(pos2(x - 3.0, nav.min.y - 1.0), pos2(x + 3.0, nav.max.y + 1.0)), 3.0, t.accent);
|
||||
}
|
||||
let nresp = ui.interact(nav, egui::Id::new("tl-nav"), Sense::drag());
|
||||
app.auto.add("timeline.navigator", nav, "Time navigator");
|
||||
if nresp.dragged() {
|
||||
let d = nresp.drag_delta().x as f64 / nav.width() as f64 * comp.duration.seconds();
|
||||
app.ui.timeline.start = (app.ui.timeline.start + d).clamp(0.0, comp.duration.seconds());
|
||||
@@ -1159,6 +1160,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0));
|
||||
lp.rect_filled(lr, 2.0, t.label(layer.label));
|
||||
let lresp = ui.interact(lr, egui::Id::new(("label", layer.id.0)), Sense::click());
|
||||
app.auto.add(&format!("timeline.layer.{}.label", layer.id.0), lr, layer.label.name());
|
||||
lresp.context_menu(|ui| {
|
||||
for lab in effectcraft_engine::color::Label::ALL {
|
||||
let name = if lab == effectcraft_engine::color::Label::None { lab.name().to_string() } else { app.session.prefs.label_name(lab) };
|
||||
@@ -1403,10 +1405,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Interactions: move body, trim edges.
|
||||
let edge_w = 6.0;
|
||||
let body = ui.interact(bar.shrink2(vec2(edge_w, 0.0)), egui::Id::new(("bar", layer.id.0)), Sense::click_and_drag());
|
||||
let lin =
|
||||
ui.interact(Rect::from_min_max(bar.min, pos2(bar.min.x + edge_w, bar.max.y)), egui::Id::new(("bar-in", layer.id.0)), Sense::drag());
|
||||
let lout =
|
||||
ui.interact(Rect::from_min_max(pos2(bar.max.x - edge_w, bar.min.y), bar.max), egui::Id::new(("bar-out", layer.id.0)), Sense::drag());
|
||||
let (in_r, out_r) =
|
||||
(Rect::from_min_max(bar.min, pos2(bar.min.x + edge_w, bar.max.y)), Rect::from_min_max(pos2(bar.max.x - edge_w, bar.min.y), bar.max));
|
||||
let lin = ui.interact(in_r, egui::Id::new(("bar-in", layer.id.0)), Sense::drag());
|
||||
let lout = ui.interact(out_r, egui::Id::new(("bar-out", layer.id.0)), Sense::drag());
|
||||
app.auto.add(&format!("timeline.layer.{}.bar.in", layer.id.0), in_r, "Trim In");
|
||||
app.auto.add(&format!("timeline.layer.{}.bar.out", layer.id.0), out_r, "Trim Out");
|
||||
if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
@@ -1496,7 +1500,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, "Inverted", Tokens::ui(11.0), t.text_dim);
|
||||
}
|
||||
let gr = ui.interact(Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y)), egui::Id::new(("grow", uid)), Sense::click());
|
||||
let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
|
||||
let gr = ui.interact(gr_rect, egui::Id::new(("grow", uid)), Sense::click());
|
||||
app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
|
||||
if gr.clicked() && uid & WAVE_BIT == 0 {
|
||||
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
|
||||
}
|
||||
@@ -1710,11 +1716,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
Tokens::ui(12.0),
|
||||
if prop.has_expression() { Color32::from_rgb(0xe8, 0x7c, 0x5c) } else { t.text },
|
||||
);
|
||||
let name_resp = ui.interact(
|
||||
Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y)),
|
||||
egui::Id::new(("pname", uid)),
|
||||
Sense::click_and_drag(),
|
||||
);
|
||||
let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y));
|
||||
let name_resp = ui.interact(name_rect, egui::Id::new(("pname", uid)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.prop.{uid}.name"), name_rect, &prop.name);
|
||||
if name_resp.drag_started() {
|
||||
// Drop on the Essential Graphics panel to expose the property.
|
||||
egui::DragAndDrop::set_payload(&ctx, crate::panels::DragPayload::Property { layer: layer.id.0, prop: *uid });
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! The agent-interface audit (UI side, M13.14):
|
||||
//!
|
||||
//! - every registered engine command is reachable over the control channel (`engine.commands`
|
||||
//! lists it; `engine.execute` routes it: an unknown parameter is rejected by that command);
|
||||
//! - every panel kind, opened headless, registers an automation id for every interactive widget
|
||||
//! it draws: egui's interactive widget rects of the frame are matched against the registered
|
||||
//! automation elements, and any widget no element covers is a gap.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::control::{self, ControlRequest, Outcome};
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use egui::Rect;
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn harness(s: Session) -> Harness<'static, EffectcraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1680.0, 1020.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn send(h: &mut Harness<'_, EffectcraftApp>, method: &str, params: Value) -> Value {
|
||||
let ctx = h.ctx.clone();
|
||||
let (req, _rx) = ControlRequest::new(method, params);
|
||||
match control::handle(h.state_mut(), &ctx, &req) {
|
||||
Outcome::Done(v) => v,
|
||||
_ => panic!("{method}: no immediate reply"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_engine_command_is_reachable_over_the_control_channel() {
|
||||
let mut h = harness(session());
|
||||
let listed = send(&mut h, "engine.commands", json!({}));
|
||||
let ids: std::collections::HashSet<String> = listed["result"].as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap().to_string()).collect();
|
||||
for spec in effectcraft_engine::command_specs() {
|
||||
assert!(ids.contains(spec.id), "engine.commands misses {}", spec.id);
|
||||
let v = send(&mut h, "engine.execute", json!({"command": spec.id, "params": {"__audit": 1}}));
|
||||
let e = v["error"].as_str().unwrap_or_default();
|
||||
assert!(v["ok"] == false && e.contains("unknown parameter") && e.contains(spec.id), "{}: {v}", spec.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Interactive widgets (click or drag sense, enabled, visible) of the last frame, in screen
|
||||
/// points, with their accessibility label when egui knows one.
|
||||
fn interactive(ctx: &egui::Context) -> Vec<(Rect, String)> {
|
||||
let screen = ctx.content_rect();
|
||||
ctx.viewport(|v| {
|
||||
let w = &v.this_pass.widgets;
|
||||
let mut out = vec![];
|
||||
for (layer, rects) in w.layers() {
|
||||
if layer.order == egui::Order::Tooltip || layer.order == egui::Order::Debug {
|
||||
continue;
|
||||
}
|
||||
for r in rects {
|
||||
let sense = r.sense;
|
||||
if !(sense.senses_click() || sense.senses_drag()) || !r.enabled {
|
||||
continue;
|
||||
}
|
||||
let ir = r.interact_rect.intersect(screen);
|
||||
if ir.width() < 2.0 || ir.height() < 2.0 {
|
||||
continue;
|
||||
}
|
||||
let info = w.info(r.id);
|
||||
// Selectable text (labels sense drags to select their text) is not a control.
|
||||
if info.is_some_and(|i| i.typ == egui::WidgetType::Label) {
|
||||
continue;
|
||||
}
|
||||
out.push((ir, info.and_then(|i| i.label.clone()).unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// Automation elements that cover a widget: overlapping at least half of the smaller of the two,
|
||||
/// and not a container many times the widget's size (a panel or a whole row).
|
||||
fn covered(w: Rect, elements: &[Rect]) -> bool {
|
||||
elements.iter().any(|e| {
|
||||
let i = e.intersect(w);
|
||||
if i.width() <= 0.0 || i.height() <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let (aw, ae) = (w.area(), e.area());
|
||||
i.area() >= 0.5 * aw.min(ae) && ae <= 40.0 * aw.max(64.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// (panel, widget rect, label) of every interactive widget no automation element covers, for
|
||||
/// one panel shown maximized.
|
||||
fn gaps_of(h: &mut Harness<'_, EffectcraftApp>, p: PanelKind) -> Vec<String> {
|
||||
h.state_mut().show_panel(p);
|
||||
h.state_mut().ui.maximized = Some(p);
|
||||
h.run_steps(4);
|
||||
let ctx = h.ctx.clone();
|
||||
let widgets = interactive(&ctx);
|
||||
let app = h.state();
|
||||
let elements: Vec<Rect> = app
|
||||
.auto
|
||||
.previous
|
||||
.iter()
|
||||
.chain(app.auto.elements.iter())
|
||||
.map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])))
|
||||
.collect();
|
||||
let Some(panel) =
|
||||
app.auto.find(&format!("panel.{}", p.id())).map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])))
|
||||
else {
|
||||
return vec![format!("{}: panel.{} not registered", p.title(), p.id())];
|
||||
};
|
||||
widgets
|
||||
.into_iter()
|
||||
.filter(|(r, _)| panel.contains(r.center()))
|
||||
.filter(|(r, _)| !covered(*r, &elements))
|
||||
.map(|(r, l)| format!("{}: [{:.0},{:.0} {:.0}x{:.0}] {l:?}", p.title(), r.min.x, r.min.y, r.width(), r.height()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_panel_registers_automation_ids_for_its_widgets() {
|
||||
let mut s = session();
|
||||
// Content for the selection-driven panels: the title (text with effects), a Render Queue
|
||||
// item, a comp marker.
|
||||
s.execute("layer.select", json!({"layers": ["EFFECTCRAFT"]})).unwrap();
|
||||
s.execute("renderQueue.add", json!({"format": "png", "output": "/tmp/ec-audit-[#####].png"})).unwrap();
|
||||
s.execute("markers.add", json!({})).ok();
|
||||
let title = s.active_comp().unwrap().layers.iter().find(|l| l.name == "EFFECTCRAFT").map(|l| l.id.0).unwrap();
|
||||
let mut h = harness(s);
|
||||
h.state_mut().ui.layer_panel = Some(title);
|
||||
// egui records widget types (to tell labels from controls) with this debug option.
|
||||
h.ctx.all_styles_mut(|st| st.debug.show_interactive_widgets = true);
|
||||
let mut gaps = vec![];
|
||||
for p in PanelKind::ALL {
|
||||
gaps.extend(gaps_of(&mut h, p));
|
||||
}
|
||||
// The timeline again with the title twirled open (properties, keys) and in the Graph Editor.
|
||||
send(&mut h, "ui.set", json!({"timeline": {"openLayers": [title]}}));
|
||||
gaps.extend(gaps_of(&mut h, PanelKind::Timeline));
|
||||
send(&mut h, "ui.set", json!({"timeline": {"graphEditor": true}}));
|
||||
gaps.extend(gaps_of(&mut h, PanelKind::Timeline));
|
||||
send(&mut h, "ui.set", json!({"timeline": {"graphEditor": false}}));
|
||||
// Outside the panels: the header and the menu bar.
|
||||
h.state_mut().ui.maximized = None;
|
||||
h.run_steps(3);
|
||||
let ctx = h.ctx.clone();
|
||||
let widgets = interactive(&ctx);
|
||||
let app = h.state();
|
||||
let elements: Vec<Rect> =
|
||||
app.auto.previous.iter().map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))).collect();
|
||||
let panels: Vec<Rect> = app
|
||||
.auto
|
||||
.previous
|
||||
.iter()
|
||||
.filter(|e| e.id.starts_with("panel.") && e.id.matches('.').count() == 1)
|
||||
.map(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])))
|
||||
.collect();
|
||||
for (r, l) in widgets {
|
||||
if !panels.iter().any(|p| p.contains(r.center())) && !covered(r, &elements) {
|
||||
gaps.push(format!("chrome: [{:.0},{:.0} {:.0}x{:.0}] {l:?}", r.min.x, r.min.y, r.width(), r.height()));
|
||||
}
|
||||
}
|
||||
assert!(gaps.is_empty(), "{} interactive widgets without an automation id:\n{}", gaps.len(), gaps.join("\n"));
|
||||
}
|
||||
Reference in new issue
Block a user