diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 560a527c7..5522a45c0 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -57,6 +57,40 @@ fn property_group_error_points_to_layer_inspection() { assert!(call_json(&mut s, "get_property", json!({"layer": l, "path": "geometryOptions/extrusionDepth"}))["value"].is_number()); } +/// Enum properties list their values and labels, so a client can set one without guessing (#2085). +#[test] +fn enum_properties_list_their_options() { + let mut s = server(); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "probe"}})); + call_json(&mut s, "execute_command", json!({"command": "layer.newText", "params": {"name": "T", "text": "Hello"}})); + call_json(&mut s, "execute_command", json!({"command": "layer.addTextAnimator", "params": {"layer": "T", "properties": ["opacity"], "name": "A"}})); + let path = "text/animators/animator/selectors/rangeSelector/advanced/shape"; + let shapes = ["Square", "Ramp Up", "Ramp Down", "Triangle", "Round", "Smooth"]; + let expected = json!(shapes.iter().enumerate().map(|(value, label)| json!({"value": value, "label": label})).collect::>()); + + let p = call_json(&mut s, "get_property", json!({"layer": "T", "path": path})); + assert_eq!(p["type"], "enum"); + assert_eq!(p["options"], expected, "{p}"); + let opacity = call_json(&mut s, "get_property", json!({"layer": "T", "path": "transform/opacity"})); + assert!(opacity.get("options").is_none(), "{opacity}"); + + fn find<'a>(node: &'a Value, path: &str) -> Option<&'a Value> { + if node["path"] == path { + return Some(node); + } + node["children"].as_array()?.iter().find_map(|c| find(c, path)) + } + let tree = call_json(&mut s, "get_layer", json!({"layer": "T"})); + let node = find(&tree["properties"], path).unwrap_or_else(|| panic!("{tree}")); + assert_eq!(node["options"], expected); + assert!(find(&tree["properties"], "transform/opacity").unwrap()["options"].is_null()); + + let flat = call_json(&mut s, "get_layer", json!({"layer": "T", "flat": true})); + let props = flat["properties"].as_array().unwrap(); + assert_eq!(props.iter().find(|p| p["path"] == path).unwrap()["options"], expected); + assert!(props.iter().find(|p| p["path"] == "transform/opacity").unwrap().get("options").is_none()); +} + #[test] fn initialize_and_list_tools() { let mut s = server(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 1d6074247..96c48ae50 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -285,7 +285,7 @@ fn get_comp(b: &mut Backend, a: &Value) -> Result { json_reply(b.exec("comp.info", obj(&[("comp", get(a, "comp"))]))?) } -/// Flatten a `layer.tree` property tree into `[{path, type, value, keys?, expression?}]`. +/// Flatten a `layer.tree` property tree into `[{path, type, value, keys?, expression?, options?}]`. fn flatten(node: &Value, out: &mut Vec) { // A group cut off by `depth` stays visible (instead of vanishing from the flat list). if node.get("truncated").and_then(Value::as_bool) == Some(true) { @@ -302,6 +302,9 @@ fn flatten(node: &Value, out: &mut Vec) { if let Some(e) = node.get("expression") { o["expression"] = e.clone(); } + if let Some(opts) = node.get("options") { + o["options"] = opts.clone(); + } out.push(o); } } @@ -626,7 +629,7 @@ static TOOLS: &[ToolDef] = &[ }, ToolDef { name: "get_layer", - description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): leaf properties have a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression. Groups contain children; inspect them here, then select a leaf path. `flat: true` returns just a list of {path, type, value, keys, expression} (compact).", + description: "A layer's property tree (transform, masks, effects, text, shape contents, ...): leaf properties have a `path` usable with get_property/set_property/add_keyframe, plus uid, match id, name, type, value at `time`, keyframes and expression; enum properties add `options` ([{value, label}]). Groups contain children; inspect them here, then select a leaf path. `flat: true` returns just a list of {path, type, value, keys, expression, options} (compact).", bridge_only: false, schema: || { schema( @@ -643,7 +646,7 @@ static TOOLS: &[ToolDef] = &[ }, ToolDef { name: "get_property", - description: "Read one property: type, value at `time` (comp seconds; default the current time), whether it is animated, its keyframes (time, value, in/out interpolation) and expression.", + description: "Read one property: type, value at `time` (comp seconds; default the current time), whether it is animated, its keyframes (time, value, in/out interpolation) and expression. Enum properties add `options`: [{value, label}], the values set_property takes.", bridge_only: false, schema: || { schema( diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index cba6df6ef..23e2a67fd 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -163,8 +163,8 @@ pub use enabled::always; pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run}; pub use params::match_path_of; pub(crate) use params::{ - b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_p, merge_p, resolve_effect_path, resolve_layer, selectable, selected_leaf_props, str_p, - time_p, unlocked, visible_p, + b_p, bad, comp_id, f_p, frontend, layer_mut, layer_p, layers_p, merge_p, popup_options, resolve_effect_path, resolve_layer, selectable, + selected_leaf_props, str_p, time_p, unlocked, visible_p, }; pub use schema::{accepted_params, check_params, params_schema}; diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index 32b0aaa2b..ec314ed53 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -999,9 +999,8 @@ fn list(_: &mut Session, p: &Value) -> Result { json!({"id": e.id, "name": e.name, "category": e.category, "gpu": e.gpu, "float": e.float, "params": e.params.iter().map(|p| { let mut out = json!({"id": p.id, "name": p.name, "type": p.default.kind_name(), "default": p.default.to_json()}); match &p.ui { - effectcraft_project::ParamUi::Popup { options } => { - // Engine/MCP popup values are zero-based; JSX exposes one-based values. - out["options"] = json!(options.iter().enumerate().map(|(value, label)| json!({"value": value, "label": label})).collect::>()); + effectcraft_project::ParamUi::Popup { .. } => { + out["options"] = super::popup_options(&p.ui).unwrap_or_default(); } effectcraft_project::ParamUi::Slider { min, max, slider_min, slider_max, decimals } => { out["min"] = json!(min); diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 7c4e1c292..818b7d28a 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -58,6 +58,13 @@ pub(crate) fn selected_leaf_props(s: &Session) -> Vec<(LayerId, effectcraft_proj out } +/// A popup property's `[{value, label}]`, as `effect.list`, `prop.get` and `layer.tree` report it. +/// Engine/MCP popup values are zero-based; JSX exposes one-based values. +pub(crate) fn popup_options(ui: &effectcraft_project::ParamUi) -> Option { + let effectcraft_project::ParamUi::Popup { options } = ui else { return None }; + Some(serde_json::json!(options.iter().enumerate().map(|(value, label)| serde_json::json!({"value": value, "label": label})).collect::>())) +} + // ---------- params ---------- pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index ab2f0a1e2..057291001 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -491,6 +491,9 @@ fn get(s: &mut Session, p: &Value) -> Result { "time": t.seconds(), "value": raw.to_json(), "animated": !pr.keys.is_empty(), "keys": keys, "expression": pr.expr.as_ref().map(|e| e.text.clone()), }); + if let Some(o) = super::popup_options(&pr.ui) { + out["options"] = o; + } // With an expression, `value` is the keyframed (pre-expression) value; also report what the // expression makes of it (what renders), or why it fails. A disabled one renders `value`; one // disabled by its syntax error (prop.setExpression) reports that error. diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 2ea8048fa..533bde831 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -129,6 +129,9 @@ fn group_json(g: &PropGroup, path: &str, l: &Layer, t: effectcraft_time::Tick, d if let Some(e) = &p.expr { o["expression"] = json!(e.text); } + if let Some(opts) = super::popup_options(&p.ui) { + o["options"] = opts; + } o } Node::Group(g) => group_json(g, &cpath, l, t, depth + 1, max_depth), diff --git a/docs/agents.md b/docs/agents.md index 172e5c4c6..b5a450392 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -108,7 +108,7 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r | `run_script {code, name?}` | Run JavaScript with the After Effects-style scripting object model (`app.project`, `comp.layers.addText(…)`, `layer.property("ADBE Transform Group").property("ADBE Position").setValueAtTime(…)`…). Returns `{ok, result, output, error: {message, line, column}}`; edits are undoable. | | `get_project` / `get_comp {comp?}` | Project items, comp settings and layers. | | `get_layer {layer, comp?, time?, depth?, flat?}` | A layer's property tree (`depth` limits how many group levels expand). Every node has a `path`. | -| `get_property {layer, path, comp?, time?}` | A leaf property's value at a time, keyframes and expression. Group paths (for example `geometryOptions`) contain children: inspect them with `get_layer`, then query a leaf such as `geometryOptions/extrusionDepth`. | +| `get_property {layer, path, comp?, time?}` | A leaf property's value at a time, keyframes and expression; an enum property adds `options` (`[{value, label}]`, zero-based values, also in `get_layer`). Group paths (for example `geometryOptions`) contain children: inspect them with `get_layer`, then query a leaf such as `geometryOptions/extrusionDepth`. | | `set_property {layer, path, value?, time?, timeBase?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe (`timeBase: "comp"` for comp seconds); with `expression` it sets an expression. | | `add_keyframe {layer, path, time+value \| keys:[...], timeBase?, interpolation?, comp?}` | Adds keys (layer seconds, or comp seconds with `timeBase: "comp"`), then optionally applies linear/bezier/hold/easyEase. | | `list_effects {filter?, compact?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters; `compact` lists only ids, names and categories. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d9049306c..42dd162e7 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -45,7 +45,7 @@ printf '{"id":1,"method":"engine.execute","params":{"command":"comp.new","params Useful query commands for `engine.execute` (they aren't journaled or undoable): `project.summary`, `comp.info {comp?}`, `layer.tree {layer, comp?, depth?, time?}` (every node has a `path`), -`prop.get {layer, path, comp?, time?}`, `editor.state`, `command.list {filter?, enabledOnly?, schemas?}`, +`prop.get {layer, path, comp?, time?}` (an enum property also lists its `options`, `[{value, label}]`, as `layer.tree` does), `editor.state`, `command.list {filter?, enabledOnly?, schemas?}`, `command.describe {command}` (params doc and JSON Schema), `app.capabilities` (version, counts, export formats, parity summary), `jobs.list` (Progress panel jobs, e.g. the background `footage.check` after File ▸ Open).