mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 11:37:02 -06:00
refactor(engine): move cmd.rs helpers to cmd/{schema,enabled,params}.rs
This commit is contained in:
+13
-344
@@ -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<Vec<CommandSpec>> = 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<Vec<String>> {
|
||||
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<String>| {
|
||||
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<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")];
|
||||
|
||||
/// 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::<Vec<_>>().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<Value> {
|
||||
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<Value> {
|
||||
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<String> {
|
||||
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/<match name>[#n]/<property>` (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::<usize>() {
|
||||
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<String>) -> 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<f64> {
|
||||
p.get(k).and_then(Value::as_f64)
|
||||
}
|
||||
pub(crate) fn b_p(p: &Value, k: &str) -> Option<bool> {
|
||||
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<ItemId> {
|
||||
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<u64> {
|
||||
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<LayerId> {
|
||||
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::<usize>().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<LayerId>)> {
|
||||
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::<Result<_>>()?));
|
||||
}
|
||||
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<LayerId>, cmd: &str) -> Result<Vec<LayerId>> {
|
||||
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<Vec<(LayerId, effectcraft_project::Uid)>> {
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -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<Value> {
|
||||
Err(EngineError::Other("not available yet in EffectCraft".into()))
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<String> {
|
||||
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/<match name>[#n]/<property>` (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::<usize>() {
|
||||
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<String>) -> 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<f64> {
|
||||
p.get(k).and_then(Value::as_f64)
|
||||
}
|
||||
|
||||
pub(crate) fn b_p(p: &Value, k: &str) -> Option<bool> {
|
||||
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<ItemId> {
|
||||
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<u64> {
|
||||
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<LayerId> {
|
||||
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::<usize>().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<LayerId>)> {
|
||||
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::<Result<_>>()?));
|
||||
}
|
||||
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<LayerId>, cmd: &str) -> Result<Vec<LayerId>> {
|
||||
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<Vec<(LayerId, effectcraft_project::Uid)>> {
|
||||
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)))
|
||||
}
|
||||
@@ -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<Vec<String>> {
|
||||
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<String>| {
|
||||
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<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")];
|
||||
|
||||
/// 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::<Vec<_>>().join(", "), spec.params),
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user