diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 8aa1381b2..2e3ff57fb 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -4,6 +4,7 @@ //! effectcraft-cli info project + engine summary //! effectcraft-cli commands [--filter TEXT] [--enabled] list engine commands //! effectcraft-cli exec [--params JSON] run one command +//! effectcraft-cli exec --list [--filter TEXT] [--schemas] list engine commands (as `commands`) //! effectcraft-cli run [ …] run several commands in order //! effectcraft-cli props [--flat] [--time S] a layer's property tree (with paths) //! effectcraft-cli get [--time S] read a property @@ -36,7 +37,7 @@ use serde_json::{Value, json}; const USAGE: &str = "usage: effectcraft-cli [args] [--json] info project + engine summary commands [--filter TEXT] [--enabled] list engine commands - exec [--params JSON] run one engine command + exec [--params JSON] run one engine command (exec --list: list them, as `commands`) run [ ...] run several commands in order props [--flat] [--time S] a layer's property tree with paths get [--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> { diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index d7c48762f..6ec339928 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -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"); diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 9504094a0..7e5fe9509 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -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:?}"); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 9e1bfee57..10b305092 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -117,7 +117,8 @@ fn params_of(v: Option<&Value>) -> Result { // ------------------------------------------------------------------ engine tools fn list_commands(b: &mut Backend, a: &Value) -> Result { - 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 = r .as_array() @@ -140,6 +141,53 @@ fn execute_command(b: &mut Backend, a: &Value) -> Result { json_reply(b.exec(id, params)?) } +fn describe_command(b: &mut Backend, a: &Value) -> Result { + 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 { + 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 { + 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 { + 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 { 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.", diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 6ce73ac30..873ead1a1 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -348,8 +348,12 @@ fn list(_: &mut Session, p: &Value) -> Result { let filter = str_p(p, "filter").map(str::to_ascii_lowercase); let v: Vec = 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::>()})) + .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::>()}) + }) .collect(); Ok(json!(v)) } diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 151727ea6..d70b80c19 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -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> = 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> { 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> { + 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")]; diff --git a/crates/engine/src/commands/query.rs b/crates/engine/src/commands/query.rs index 69a9dc3be..53a8dc72d 100644 --- a/crates/engine/src/commands/query.rs +++ b/crates/engine/src/commands/query.rs @@ -9,6 +9,7 @@ use crate::{EngineError, Result, Session, query}; fn list(s: &mut Session, p: &Value) -> Result { 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 = 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 { 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 { Ok(o) } +/// One command in full, with the JSON Schema of its parameters. +fn describe(s: &mut Session, p: &Value) -> Result { + 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 { + let fx = effectcraft_effects::registry(); + let formats: Vec = 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 { Ok(serde_json::to_value(&s.state).unwrap_or_default()) } pub fn specs() -> Vec { 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), diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 9b3d6aac4..06afd2b95 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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 }); diff --git a/crates/ui-egui/tests/ui_agent_audit.rs b/crates/ui-egui/tests/ui_agent_audit.rs new file mode 100644 index 000000000..246a00393 --- /dev/null +++ b/crates/ui-egui/tests/ui_agent_audit.rs @@ -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 = 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 { + 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 = 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 = + 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 = 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")); +}