diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 23388594b..1058f4c54 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -152,6 +152,19 @@ macro_rules! query { }; } +mod enabled; +mod params; +mod schema; + +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, +}; +pub use schema::{accepted_params, check_params, params_schema}; + pub fn command_specs() -> &'static [CommandSpec] { static SPECS: OnceLock> = OnceLock::new(); SPECS.get_or_init(|| { @@ -267,347 +280,3 @@ pub fn find(id: &str) -> Option<&'static CommandSpec> { }); index.get(id).map(|&i| &command_specs()[i]) } - -// ---------- parameter validation (agents) ---------- - -/// Top-level keys accepted by a params doc such as `{layers: [id|name|#n], add?, toggle?}` or -/// `{time? (s) | frame? | timecode?}`; `None` when the doc isn't a `{…}` key list. -pub fn accepted_params(doc: &str) -> Option> { - let body = doc.trim().strip_prefix('{')?; - // Top-level pieces up to the matching close brace, split at `,` and `|`; `:` starts a value. - let (mut keys, mut cur, mut depth, mut in_value) = (Vec::new(), String::new(), 0i32, false); - let push = |cur: &mut String, keys: &mut Vec| { - let k: String = cur.trim().chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); - if !k.is_empty() && !keys.contains(&k) { - keys.push(k); - } - cur.clear(); - }; - for c in body.chars() { - match c { - '{' | '[' | '(' => depth += 1, - ']' | ')' => depth -= 1, - '}' if depth == 0 => { - push(&mut cur, &mut keys); - return Some(keys); - } - '}' => depth -= 1, - ',' if depth == 0 => { - push(&mut cur, &mut keys); - in_value = false; - } - '|' if depth == 0 && !in_value => push(&mut cur, &mut keys), - ':' if depth == 0 => { - push(&mut cur, &mut keys); - in_value = true; - } - _ if depth == 0 && !in_value => cur.push(c), - _ => {} - } - } - 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")]; - -/// Reject unknown top-level parameters (typos, guessed names) with the list of accepted keys. -pub fn check_params(spec: &CommandSpec, params: &Value) -> Result<()> { - let (Some(obj), Some(accepted)) = (params.as_object(), accepted_params(spec.params)) else { return Ok(()) }; - let ok = |k: &str| ALWAYS_OK.contains(&k) || accepted.iter().any(|a| a == k || ALIASES.iter().any(|(doc, alias)| doc == a && *alias == k)); - let unknown: Vec<&str> = obj.keys().map(String::as_str).filter(|k| !ok(k)).collect(); - if unknown.is_empty() { - return Ok(()); - } - let list = if accepted.is_empty() { "none".to_string() } else { accepted.join(", ") }; - Err(bad( - spec.id, - format!("unknown parameter(s) {}; accepted: {list} (doc: {})", unknown.iter().map(|k| format!("`{k}`")).collect::>().join(", "), spec.params), - )) -} - -// ---------- enablement ---------- - -pub fn always(_: &Session) -> std::result::Result<(), String> { - Ok(()) -} -pub(crate) fn has_comp(s: &Session) -> std::result::Result<(), String> { - s.active_comp().map(|_| ()).ok_or_else(|| "no composition is open".into()) -} -pub(crate) fn has_layers(s: &Session) -> std::result::Result<(), String> { - has_comp(s)?; - if s.state.selected_layers.is_empty() { Err("select a layer first".into()) } else { Ok(()) } -} -pub(crate) fn has_keys(s: &Session) -> std::result::Result<(), String> { - has_comp(s)?; - if s.state.selected_keys.is_empty() { Err("select keyframes first".into()) } else { Ok(()) } -} - -pub(crate) fn has_project_selection(s: &Session) -> std::result::Result<(), String> { - if s.state.project_selection.is_empty() { Err("select an item in the Project panel first".into()) } else { Ok(()) } -} - -/// Disabled-command enablement (menu entries whose implementation lands with another milestone; -/// none are left, kept for future stubs). -#[allow(dead_code)] -pub(crate) fn not_yet(_: &Session) -> std::result::Result<(), String> { - Err("not available yet in EffectCraft".into()) -} - -/// `run` of a not-yet-available command. -#[allow(dead_code)] -pub(crate) fn not_yet_run(_: &mut Session, _: &Value) -> Result { - Err(EngineError::Other("not available yet in EffectCraft".into())) -} - -/// Hand a frontend-only command to the UI ([`crate::Event::Frontend`]). -pub(crate) fn frontend(s: &mut Session, id: &str, p: &Value) -> Result { - s.events.push(crate::Event::Frontend { command: id.to_string(), params: p.clone() }); - Ok(serde_json::json!({"frontend": id})) -} - -/// Match path of a node (`transform/position`, `effects/#2/blurriness` style with `match#n` -/// occurrences) so it can be found again on another layer. -pub fn match_path_of(g: &effectcraft_project::PropGroup, uid: effectcraft_project::Uid) -> Option { - for c in &g.children { - let nth = g.children.iter().take_while(|x| !std::ptr::eq(*x, c)).filter(|x| x.match_id() == c.match_id()).count() + 1; - let seg = if nth == 1 { c.match_id().to_string() } else { format!("{}#{nth}", c.match_id()) }; - if c.uid() == uid { - return Some(seg); - } - if let effectcraft_project::Node::Group(sub) = c - && let Some(rest) = match_path_of(sub, uid) - { - return Some(format!("{seg}/{rest}")); - } - } - None -} - -/// Turn `effects/[#n]/` (as [`match_path_of`] writes it) into a uid path on -/// `l`. Path parsing splits on `.`, which would cut a match name like `ec.control.slider` apart. -/// Any other path, or an effect that is not on the layer, comes back unchanged. -pub(crate) fn resolve_effect_path(l: &Layer, path: &str) -> String { - let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() }; - let (match_id, nth) = match seg.rsplit_once('#') { - Some((m, n)) if !m.is_empty() => match n.parse::() { - Ok(n) if n >= 1 => (m, n), - _ => (seg, 1), - }, - _ => (seg, 1), - }; - match l.effects().and_then(|fx| fx.groups().filter(|g| g.match_id == match_id).nth(nth - 1)) { - Some(g) => format!("effects/@{}/{tail}", g.uid), - None => path.to_string(), - } -} - -/// Selected properties (leaves) of the active comp as (layer, uid); selected groups expand to -/// their properties. -pub(crate) fn selected_leaf_props(s: &Session) -> Vec<(LayerId, effectcraft_project::Uid)> { - let Some(comp) = s.active_comp() else { return vec![] }; - let mut out = vec![]; - for (lid, uid) in &s.state.selected_props { - let Some(l) = comp.layer(*lid) else { continue }; - if l.props.find(*uid).is_some() { - out.push((*lid, *uid)); - } else if let Some(g) = l.props.find_group(*uid) { - g.walk("", &mut |_, p| out.push((*lid, p.uid))); - } - } - out -} - -// ---------- params ---------- - -pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { - EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } -} -pub(crate) fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> { - p.get(k).and_then(Value::as_str) -} -pub(crate) fn f_p(p: &Value, k: &str) -> Option { - p.get(k).and_then(Value::as_f64) -} -pub(crate) fn b_p(p: &Value, k: &str) -> Option { - p.get(k).and_then(Value::as_bool) -} -pub(crate) fn merge_p(p: &Value) -> Option<&str> { - str_p(p, "merge") -} - -/// `comp`: item id or name (an all-digit string that names no comp is taken as an id, as MCP -/// clients often send numbers as strings); default the active comp. -pub(crate) fn comp_id(s: &Session, p: &Value) -> Result { - let missing = |v: &Value| EngineError::NoSuchComp(v.to_string()); - match p.get("comp") { - Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)), - Some(v @ Value::String(name)) => s - .project - .items - .values() - .find(|i| &i.name == name && i.as_comp().is_some()) - .map(|i| i.id) - .or_else(|| digits_id(name).map(ItemId).filter(|id| s.project.comp(*id).is_some())) - .ok_or_else(|| missing(v)), - _ => s.active_comp_id().ok_or(EngineError::NoComp), - } -} - -/// An all-ASCII-digit string as a number (`"98"` -> 98); anything else (names, `#n`, signs) is `None`. -fn digits_id(s: &str) -> Option { - if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { - return None; - } - s.parse().ok() -} - -/// Resolve a layer reference: id (number), `#n` / index (1-based), or name. An all-digit string -/// that names no layer is resolved as the number it spells. -pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option { - match v { - Value::Number(n) => { - let id = n.as_u64()?; - if comp.layer(LayerId(id)).is_some() { Some(LayerId(id)) } else { comp.layers.get((id as usize).checked_sub(1)?).map(|l| l.id) } - } - Value::String(s) => { - if let Some(i) = s.strip_prefix('#').and_then(|i| i.parse::().ok()) { - return comp.layers.get(i.checked_sub(1)?).map(|l| l.id); - } - comp.layers.iter().find(|l| &l.name == s).map(|l| l.id).or_else(|| digits_id(s).and_then(|id| resolve_layer(comp, &Value::from(id)))) - } - _ => None, - } -} - -/// `layer` param or the first selected layer. -pub(crate) fn layer_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId)> { - let cid = comp_id(s, p)?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - if let Some(v) = p.get("layer") { - return resolve_layer(comp, v).map(|l| (cid, l)).ok_or_else(|| bad(cmd, format!("no layer {v}"))); - } - s.state - .selected_layers - .first() - .copied() - .filter(|l| comp.layer(*l).is_some()) - .map(|l| (cid, l)) - .ok_or_else(|| bad(cmd, "no `layer` given and no layer selected")) -} - -/// `layers` param (array) or the selection. -pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec)> { - let cid = comp_id(s, p)?; - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - // Explicit references that match no layer are an error (agents otherwise get a silent no-op). - let resolve = - |v: &Value| resolve_layer(comp, v).ok_or_else(|| EngineError::Other(format!("no layer {v} (use a layer id, \"#n\" or a name from comp.info)"))); - if let Some(Value::Array(a)) = p.get("layers") { - return Ok((cid, a.iter().map(resolve).collect::>()?)); - } - if let Some(v) = p.get("layer").filter(|v| !v.is_null()) { - return Ok((cid, vec![resolve(v)?])); - } - Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect())) -} - -/// Whether a layer can be picked by the user (Select All, Ctrl+Up/Down, a Timeline click): not -/// locked, and not hidden by the composition's Hide Shy Layers. -pub(crate) fn selectable(comp: &Comp, l: &Layer) -> bool { - !(l.switches.locked || (comp.hide_shy && l.switches.shy)) -} - -/// The layers of `ids` a command may edit: locked layers are left alone, as in After Effects. -/// An error when every one of them is locked. -pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec, cmd: &str) -> Result> { - let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; - let locked = |id: &LayerId| comp.layer(*id).is_some_and(|l| l.switches.locked); - match ids.iter().find(|id| locked(id)) { - Some(first) if ids.iter().all(locked) => { - let name = comp.layer(*first).map(|l| l.name.clone()).unwrap_or_default(); - Err(bad(cmd, format!("“{name}” is locked (Layer ▸ Switches ▸ Unlock All Layers)"))) - } - _ => Ok(ids.into_iter().filter(|id| !locked(id)).collect()), - } -} - -/// `visible: [{layer, prop}]`: the properties the Timeline shows (J / K and Select All -/// Keyframes use only those, as in After Effects). `None` when not given. -pub(crate) fn visible_p(p: &Value) -> Option> { - let a = p.get("visible")?.as_array()?; - Some(a.iter().filter_map(|v| Some((LayerId(v.get("layer")?.as_u64()?), v.get("prop")?.as_u64()?))).collect()) -} - -/// `time` (seconds) or `frame` param, else the CTI. -pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick { - if let Some(t) = f_p(p, "time") { - return Tick::from_seconds_f64(t); - } - if let (Some(f), Some(c)) = (p.get("frame").and_then(Value::as_i64), comp) { - return c.frame_rate.tick_of(f); - } - s.time() -} - -pub(crate) fn layer_mut(p: &mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&mut Layer> { - p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid))) -} diff --git a/crates/engine/src/cmd/enabled.rs b/crates/engine/src/cmd/enabled.rs new file mode 100644 index 000000000..ba2631b20 --- /dev/null +++ b/crates/engine/src/cmd/enabled.rs @@ -0,0 +1,38 @@ +use super::*; + +// ---------- enablement ---------- + +pub fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + +pub(crate) fn has_comp(s: &Session) -> std::result::Result<(), String> { + s.active_comp().map(|_| ()).ok_or_else(|| "no composition is open".into()) +} + +pub(crate) fn has_layers(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + if s.state.selected_layers.is_empty() { Err("select a layer first".into()) } else { Ok(()) } +} + +pub(crate) fn has_keys(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + if s.state.selected_keys.is_empty() { Err("select keyframes first".into()) } else { Ok(()) } +} + +pub(crate) fn has_project_selection(s: &Session) -> std::result::Result<(), String> { + if s.state.project_selection.is_empty() { Err("select an item in the Project panel first".into()) } else { Ok(()) } +} + +/// Disabled-command enablement (menu entries whose implementation lands with another milestone; +/// none are left, kept for future stubs). +#[allow(dead_code)] +pub(crate) fn not_yet(_: &Session) -> std::result::Result<(), String> { + Err("not available yet in EffectCraft".into()) +} + +/// `run` of a not-yet-available command. +#[allow(dead_code)] +pub(crate) fn not_yet_run(_: &mut Session, _: &Value) -> Result { + Err(EngineError::Other("not available yet in EffectCraft".into())) +} diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs new file mode 100644 index 000000000..296ab6f6f --- /dev/null +++ b/crates/engine/src/cmd/params.rs @@ -0,0 +1,199 @@ +use super::*; + +/// Hand a frontend-only command to the UI ([`crate::Event::Frontend`]). +pub(crate) fn frontend(s: &mut Session, id: &str, p: &Value) -> Result { + s.events.push(crate::Event::Frontend { command: id.to_string(), params: p.clone() }); + Ok(serde_json::json!({"frontend": id})) +} + +/// Match path of a node (`transform/position`, `effects/#2/blurriness` style with `match#n` +/// occurrences) so it can be found again on another layer. +pub fn match_path_of(g: &effectcraft_project::PropGroup, uid: effectcraft_project::Uid) -> Option { + for c in &g.children { + let nth = g.children.iter().take_while(|x| !std::ptr::eq(*x, c)).filter(|x| x.match_id() == c.match_id()).count() + 1; + let seg = if nth == 1 { c.match_id().to_string() } else { format!("{}#{nth}", c.match_id()) }; + if c.uid() == uid { + return Some(seg); + } + if let effectcraft_project::Node::Group(sub) = c + && let Some(rest) = match_path_of(sub, uid) + { + return Some(format!("{seg}/{rest}")); + } + } + None +} + +/// Turn `effects/[#n]/` (as [`match_path_of`] writes it) into a uid path on +/// `l`. Path parsing splits on `.`, which would cut a match name like `ec.control.slider` apart. +/// Any other path, or an effect that is not on the layer, comes back unchanged. +pub(crate) fn resolve_effect_path(l: &Layer, path: &str) -> String { + let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() }; + let (match_id, nth) = match seg.rsplit_once('#') { + Some((m, n)) if !m.is_empty() => match n.parse::() { + Ok(n) if n >= 1 => (m, n), + _ => (seg, 1), + }, + _ => (seg, 1), + }; + match l.effects().and_then(|fx| fx.groups().filter(|g| g.match_id == match_id).nth(nth - 1)) { + Some(g) => format!("effects/@{}/{tail}", g.uid), + None => path.to_string(), + } +} + +/// Selected properties (leaves) of the active comp as (layer, uid); selected groups expand to +/// their properties. +pub(crate) fn selected_leaf_props(s: &Session) -> Vec<(LayerId, effectcraft_project::Uid)> { + let Some(comp) = s.active_comp() else { return vec![] }; + let mut out = vec![]; + for (lid, uid) in &s.state.selected_props { + let Some(l) = comp.layer(*lid) else { continue }; + if l.props.find(*uid).is_some() { + out.push((*lid, *uid)); + } else if let Some(g) = l.props.find_group(*uid) { + g.walk("", &mut |_, p| out.push((*lid, p.uid))); + } + } + out +} + +// ---------- params ---------- + +pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +pub(crate) fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> { + p.get(k).and_then(Value::as_str) +} + +pub(crate) fn f_p(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_f64) +} + +pub(crate) fn b_p(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_bool) +} + +pub(crate) fn merge_p(p: &Value) -> Option<&str> { + str_p(p, "merge") +} + +/// `comp`: item id or name (an all-digit string that names no comp is taken as an id, as MCP +/// clients often send numbers as strings); default the active comp. +pub(crate) fn comp_id(s: &Session, p: &Value) -> Result { + let missing = |v: &Value| EngineError::NoSuchComp(v.to_string()); + match p.get("comp") { + Some(v @ Value::Number(n)) => n.as_u64().map(ItemId).filter(|id| s.project.comp(*id).is_some()).ok_or_else(|| missing(v)), + Some(v @ Value::String(name)) => s + .project + .items + .values() + .find(|i| &i.name == name && i.as_comp().is_some()) + .map(|i| i.id) + .or_else(|| digits_id(name).map(ItemId).filter(|id| s.project.comp(*id).is_some())) + .ok_or_else(|| missing(v)), + _ => s.active_comp_id().ok_or(EngineError::NoComp), + } +} + +/// An all-ASCII-digit string as a number (`"98"` -> 98); anything else (names, `#n`, signs) is `None`. +fn digits_id(s: &str) -> Option { + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +/// Resolve a layer reference: id (number), `#n` / index (1-based), or name. An all-digit string +/// that names no layer is resolved as the number it spells. +pub(crate) fn resolve_layer(comp: &Comp, v: &Value) -> Option { + match v { + Value::Number(n) => { + let id = n.as_u64()?; + if comp.layer(LayerId(id)).is_some() { Some(LayerId(id)) } else { comp.layers.get((id as usize).checked_sub(1)?).map(|l| l.id) } + } + Value::String(s) => { + if let Some(i) = s.strip_prefix('#').and_then(|i| i.parse::().ok()) { + return comp.layers.get(i.checked_sub(1)?).map(|l| l.id); + } + comp.layers.iter().find(|l| &l.name == s).map(|l| l.id).or_else(|| digits_id(s).and_then(|id| resolve_layer(comp, &Value::from(id)))) + } + _ => None, + } +} + +/// `layer` param or the first selected layer. +pub(crate) fn layer_p(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId)> { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + if let Some(v) = p.get("layer") { + return resolve_layer(comp, v).map(|l| (cid, l)).ok_or_else(|| bad(cmd, format!("no layer {v}"))); + } + s.state + .selected_layers + .first() + .copied() + .filter(|l| comp.layer(*l).is_some()) + .map(|l| (cid, l)) + .ok_or_else(|| bad(cmd, "no `layer` given and no layer selected")) +} + +/// `layers` param (array) or the selection. +pub(crate) fn layers_p(s: &Session, p: &Value) -> Result<(ItemId, Vec)> { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + // Explicit references that match no layer are an error (agents otherwise get a silent no-op). + let resolve = + |v: &Value| resolve_layer(comp, v).ok_or_else(|| EngineError::Other(format!("no layer {v} (use a layer id, \"#n\" or a name from comp.info)"))); + if let Some(Value::Array(a)) = p.get("layers") { + return Ok((cid, a.iter().map(resolve).collect::>()?)); + } + if let Some(v) = p.get("layer").filter(|v| !v.is_null()) { + return Ok((cid, vec![resolve(v)?])); + } + Ok((cid, s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect())) +} + +/// Whether a layer can be picked by the user (Select All, Ctrl+Up/Down, a Timeline click): not +/// locked, and not hidden by the composition's Hide Shy Layers. +pub(crate) fn selectable(comp: &Comp, l: &Layer) -> bool { + !(l.switches.locked || (comp.hide_shy && l.switches.shy)) +} + +/// The layers of `ids` a command may edit: locked layers are left alone, as in After Effects. +/// An error when every one of them is locked. +pub(crate) fn unlocked(s: &Session, cid: ItemId, ids: Vec, cmd: &str) -> Result> { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let locked = |id: &LayerId| comp.layer(*id).is_some_and(|l| l.switches.locked); + match ids.iter().find(|id| locked(id)) { + Some(first) if ids.iter().all(locked) => { + let name = comp.layer(*first).map(|l| l.name.clone()).unwrap_or_default(); + Err(bad(cmd, format!("“{name}” is locked (Layer ▸ Switches ▸ Unlock All Layers)"))) + } + _ => Ok(ids.into_iter().filter(|id| !locked(id)).collect()), + } +} + +/// `visible: [{layer, prop}]`: the properties the Timeline shows (J / K and Select All +/// Keyframes use only those, as in After Effects). `None` when not given. +pub(crate) fn visible_p(p: &Value) -> Option> { + let a = p.get("visible")?.as_array()?; + Some(a.iter().filter_map(|v| Some((LayerId(v.get("layer")?.as_u64()?), v.get("prop")?.as_u64()?))).collect()) +} + +/// `time` (seconds) or `frame` param, else the CTI. +pub(crate) fn time_p(s: &Session, p: &Value, comp: Option<&Comp>) -> Tick { + if let Some(t) = f_p(p, "time") { + return Tick::from_seconds_f64(t); + } + if let (Some(f), Some(c)) = (p.get("frame").and_then(Value::as_i64), comp) { + return c.frame_rate.tick_of(f); + } + s.time() +} + +pub(crate) fn layer_mut(p: &mut effectcraft_project::Project, cid: ItemId, lid: LayerId) -> Result<&mut Layer> { + p.comp_mut(cid).ok_or(EngineError::NoComp)?.layer_mut(lid).ok_or(EngineError::Project(effectcraft_project::ProjectError::NoLayer(lid))) +} diff --git a/crates/engine/src/cmd/schema.rs b/crates/engine/src/cmd/schema.rs new file mode 100644 index 000000000..11e2ef9f2 --- /dev/null +++ b/crates/engine/src/cmd/schema.rs @@ -0,0 +1,119 @@ +use super::params::bad; +use super::*; + +// ---------- parameter validation (agents) ---------- + +/// Top-level keys accepted by a params doc such as `{layers: [id|name|#n], add?, toggle?}` or +/// `{time? (s) | frame? | timecode?}`; `None` when the doc isn't a `{…}` key list. +pub fn accepted_params(doc: &str) -> Option> { + let body = doc.trim().strip_prefix('{')?; + // Top-level pieces up to the matching close brace, split at `,` and `|`; `:` starts a value. + let (mut keys, mut cur, mut depth, mut in_value) = (Vec::new(), String::new(), 0i32, false); + let push = |cur: &mut String, keys: &mut Vec| { + let k: String = cur.trim().chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect(); + if !k.is_empty() && !keys.contains(&k) { + keys.push(k); + } + cur.clear(); + }; + for c in body.chars() { + match c { + '{' | '[' | '(' => depth += 1, + ']' | ')' => depth -= 1, + '}' if depth == 0 => { + push(&mut cur, &mut keys); + return Some(keys); + } + '}' => depth -= 1, + ',' if depth == 0 => { + push(&mut cur, &mut keys); + in_value = false; + } + '|' if depth == 0 && !in_value => push(&mut cur, &mut keys), + ':' if depth == 0 => { + push(&mut cur, &mut keys); + in_value = true; + } + _ if depth == 0 && !in_value => cur.push(c), + _ => {} + } + } + 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")]; + +/// Reject unknown top-level parameters (typos, guessed names) with the list of accepted keys. +pub fn check_params(spec: &CommandSpec, params: &Value) -> Result<()> { + let (Some(obj), Some(accepted)) = (params.as_object(), accepted_params(spec.params)) else { return Ok(()) }; + let ok = |k: &str| ALWAYS_OK.contains(&k) || accepted.iter().any(|a| a == k || ALIASES.iter().any(|(doc, alias)| doc == a && *alias == k)); + let unknown: Vec<&str> = obj.keys().map(String::as_str).filter(|k| !ok(k)).collect(); + if unknown.is_empty() { + return Ok(()); + } + let list = if accepted.is_empty() { "none".to_string() } else { accepted.join(", ") }; + Err(bad( + spec.id, + format!("unknown parameter(s) {}; accepted: {list} (doc: {})", unknown.iter().map(|k| format!("`{k}`")).collect::>().join(", "), spec.params), + )) +}