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:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 09:59:23 -04:00
1 parent 5cf37921ef
commit 0d5f60c4b7
9 files changed
+493 -35

No files matched your search

+25 -18
View File
@@ -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> {
+24
View File
@@ -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");
+55
View File
@@ -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:?}");
}
+88 -2
View File
@@ -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.",
+6 -2
View File
@@ -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))
}
+67 -1
View File
@@ -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")];
+45 -2
View File
@@ -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),
+14 -10
View File
@@ -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 });
+169
View File
@@ -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"));
}