From 86268fc4cc95cb161acb098cdf01186161faff5d Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 18:33:59 +0200 Subject: [PATCH] =?UTF-8?q?MCP:=20Text=20Path=20Options=20=E2=96=B8=20Path?= =?UTF-8?q?=20lists=20the=20layer's=20masks=20as=20its=20options,=20like?= =?UTF-8?q?=20the=20Timeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/automation/src/tests.rs | 10 ++++++++++ crates/automation/src/tools.rs | 4 ++-- crates/engine/src/cmd/effect.rs | 4 ++-- crates/engine/src/cmd/params.rs | 7 +++---- crates/engine/src/cmd/prop.rs | 4 ++-- crates/engine/src/cmd/query.rs | 4 ++-- crates/project/src/layer.rs | 16 +++++++++++++++- crates/ui-egui/src/panels/timeline.rs | 11 ++--------- docs/agents.md | 2 +- docs/control-protocol.md | 2 +- 10 files changed, 40 insertions(+), 24 deletions(-) diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 5522a45c0..69497f30e 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -89,6 +89,16 @@ fn enum_properties_list_their_options() { 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()); + + // Path Options ▸ Path offers None and the layer's masks, as the Timeline does. + for _ in 0..2 { + call_json(&mut s, "execute_command", json!({"command": "mask.new", "params": {"layer": "T", "vertices": [[0, 0], [40, 0], [40, 40]], "closed": true}})); + } + let masks = json!([{"value": 0, "label": "None"}, {"value": 1, "label": "Mask 1"}, {"value": 2, "label": "Mask 2"}]); + let p = call_json(&mut s, "get_property", json!({"layer": "T", "path": "text/pathOptions/path"})); + assert_eq!(p["options"], masks, "{p}"); + let tree = call_json(&mut s, "get_layer", json!({"layer": "T"})); + assert_eq!(find(&tree["properties"], "text/pathOptions/path").unwrap_or_else(|| panic!("{tree}"))["options"], masks); } #[test] diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 96c48ae50..df7eb133b 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -629,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; 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).", + 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; popup (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( @@ -646,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. Enum properties add `options`: [{value, label}], the values set_property takes.", + 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. Popup (enum) properties add `options`: [{value, label}], the values set_property takes.", bridge_only: false, schema: || { schema( diff --git a/crates/engine/src/cmd/effect.rs b/crates/engine/src/cmd/effect.rs index ec314ed53..7b64015cd 100644 --- a/crates/engine/src/cmd/effect.rs +++ b/crates/engine/src/cmd/effect.rs @@ -999,8 +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 { .. } => { - out["options"] = super::popup_options(&p.ui).unwrap_or_default(); + effectcraft_project::ParamUi::Popup { options } => { + out["options"] = super::popup_options(options); } 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 818b7d28a..ff4d443ea 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -58,11 +58,10 @@ 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. +/// A popup'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::>())) +pub(crate) fn popup_options(options: &[String]) -> Value { + serde_json::json!(options.iter().enumerate().map(|(value, label)| serde_json::json!({"value": value, "label": label})).collect::>()) } // ---------- params ---------- diff --git a/crates/engine/src/cmd/prop.rs b/crates/engine/src/cmd/prop.rs index 057291001..b1bfd8670 100644 --- a/crates/engine/src/cmd/prop.rs +++ b/crates/engine/src/cmd/prop.rs @@ -491,8 +491,8 @@ 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; + if let Some(o) = l.popup_options(pr) { + out["options"] = super::popup_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 diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 533bde831..1787f088a 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -129,8 +129,8 @@ 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; + if let Some(opts) = l.popup_options(p) { + o["options"] = super::popup_options(&opts); } o } diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index 21307583d..3d7676b28 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -1,6 +1,6 @@ use super::item::{ItemId, LayerId}; use super::settings::Marker; -use super::{BlendMode, Deserialize, Label, PropGroup, Serialize, Tick}; +use super::{BlendMode, Deserialize, Label, ParamUi, PropGroup, Property, Serialize, Tick}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum LightKind { @@ -357,6 +357,20 @@ impl Layer { self.props.sub("masks") } + /// The labels popup property `p` of this layer offers: Text Path Options ▸ Path lists None and + /// the layer's masks (value k = mask k), every other popup its own options. `None` when `p` is + /// not a popup. + pub fn popup_options<'a>(&'a self, p: &'a Property) -> Option> { + if self.props.prop("text/pathOptions/path").is_some_and(|q| q.uid == p.uid) { + let masks = self.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone())); + return Some(std::iter::once("None".to_string()).chain(masks).collect::>().into()); + } + match &p.ui { + ParamUi::Popup { options } => Some(options.as_slice().into()), + _ => None, + } + } + pub fn is_camera(&self) -> bool { matches!(self.source, LayerSource::Camera) } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 98bc4d8d2..3b7f2a6f8 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -5754,14 +5754,7 @@ fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid); let t = app.tokens; let x = at.x; - // Text Path Options ▸ Path lists the layer's masks. - let mask_opts: Option> = layer - .props - .prop("text/pathOptions/path") - .filter(|q| q.uid == uid) - .map(|_| std::iter::once("None".to_string()).chain(layer.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()))).collect()); - let ui_opts = mask_opts.map(|options| ParamUi::Popup { options }); - if let ParamUi::Popup { options } = ui_opts.as_ref().unwrap_or(&prop.ui) { + if let Some(options) = layer.popup_options(prop) { let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0)); let label = options.get(*i as usize).cloned().unwrap_or_default(); let pop = egui::Id::unique(("epop", uid)); @@ -5769,7 +5762,7 @@ fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, widgets::open_popup(ui, pop); } app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name); - if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), options, Some(*i as usize)) { + if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), &options, Some(*i as usize)) { ve.set(actions, json!(ni)); } } else { diff --git a/docs/agents.md b/docs/agents.md index b5a450392..ccd9cea6a 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; 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`. | +| `get_property {layer, path, comp?, time?}` | A leaf property's value at a time, keyframes and expression; a popup (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 42dd162e7..900d9518a 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?}` (an enum property also lists its `options`, `[{value, label}]`, as `layer.tree` does), `editor.state`, `command.list {filter?, enabledOnly?, schemas?}`, +`prop.get {layer, path, comp?, time?}` (a popup (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).