From d466e428b2ce115d0220771530e69960bf8a6c15 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 20:41:35 -0700 Subject: [PATCH] =?UTF-8?q?Scripts:=20File=20=E2=96=B8=20Scripts=20entries?= =?UTF-8?q?=20take=20keyboard=20shortcuts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit ▸ Keyboard Shortcuts now lists the scripts File ▸ Scripts lists, under File ▸ Scripts, and a key given to one runs it as its menu entry does. The key is bound to the script's file name (file.runScript {"name": ...}) and is saved with the preset. File ▸ Scripts and the macOS menu show it. The shortcut table lists the Scripts folder again when a script is installed or uninstalled, when the editor opens and when shortcuts.list runs. shortcuts.set and shortcuts.reset look a script up in File ▸ Scripts' list as it is now, and a script it doesn't list is an error instead of a key for Run Script File.... --- crates/engine/src/commands/scripts.rs | 62 ++++++++++++++++++++ crates/engine/src/commands/settings.rs | 18 ++++-- crates/engine/src/session_settings.rs | 14 ++++- crates/engine/src/shortcuts.rs | 35 ++++++++++- crates/ui-egui/src/menus.rs | 7 ++- crates/ui-egui/src/native_menu.rs | 5 +- crates/ui-egui/src/panels/shortcut_editor.rs | 2 + crates/ui-egui/tests/it/ui_scriptui.rs | 36 ++++++++++++ docs/plugins.md | 2 +- docs/preferences.md | 3 +- docs/scripting-parity.md | 2 +- 11 files changed, 172 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 24049459c..db4095493 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -203,6 +203,7 @@ fn install(s: &mut Session, p: &Value, panel: bool) -> Result { let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?; let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR }; cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?; + s.scripts_changed(); // After Effects asks for a restart; our menus pick it up at once. s.toast(if panel { format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu") @@ -231,6 +232,7 @@ fn uninstall(s: &mut Session, p: &Value) -> Result { removed = true; } } + s.scripts_changed(); if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) } } @@ -602,4 +604,64 @@ mod tests { assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": "Hello.jsx"}))), "no longer installed"); let _ = std::fs::remove_dir_all(&root); } + + /// Edit ▸ Keyboard Shortcuts lists the scripts File ▸ Scripts lists (not ScriptUI panels), + /// as soon as one is installed and until it is uninstalled; a key given to one is saved with + /// the preset. + #[test] + fn file_scripts_entries_take_keyboard_shortcuts() { + use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, binding_key}; + let (mut s, root) = session_in_tmp("shortcuts"); + let (script, panel) = (root.join("Hello.jsx"), root.join("Hello Panel.jsx")); + std::fs::write(&script, "hello").unwrap(); + std::fs::write(&panel, "panel").unwrap(); + let listed = |s: &Session, name: &str| { + s.shortcuts().find(&binding_key("file.runScript", &json!({"name": name}))).map(|b| (b.label.clone(), b.path.join(" ▸ "), b.defaults.clone())) + }; + assert_eq!(listed(&s, "Rename Layers.jsx"), Some(("Rename Layers.jsx".into(), "File ▸ Scripts".into(), vec![])), "the bundled samples"); + assert_eq!(listed(&s, "Hello.jsx"), None); + s.execute("file.installScript", json!({"path": script.to_string_lossy()})).unwrap(); + s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap(); + assert_eq!(listed(&s, "Hello.jsx"), Some(("Hello.jsx".into(), "File ▸ Scripts".into(), vec![]))); + assert_eq!(listed(&s, "Hello Panel.jsx"), None, "panels are in the Window menu"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Hello.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + let key = binding_key("file.runScript", &json!({"name": "Hello.jsx"})); + assert_eq!((&r["command"], &r["keys"]), (&json!(key), &json!(["Cmd+Alt+Shift+F7"])), "{r}"); + assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"name": "Hello.jsx"})), Some("Cmd+Alt+Shift+F7")); + let saved = Keymaps::from_json(&std::fs::read_to_string(root.join("settings").join(SHORTCUTS_FILE)).unwrap()); + assert_eq!(saved.active_preset().and_then(|p| p.overrides.get(&key)), Some(&vec!["Cmd+Alt+Shift+F7".to_string()])); + s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap(); + assert_eq!(listed(&s, "Hello.jsx"), None); + let _ = std::fs::remove_dir_all(&root); + } + + /// `shortcuts.list` and `shortcuts.set` see a script copied into the Scripts folder by hand + /// after the shortcut table was built; a script the folder doesn't hold (never there, or + /// removed by hand) can't take a key, and Run Script File… keeps its own keys. + #[test] + fn shortcut_commands_find_scripts_copied_into_the_folder() { + let (mut s, root) = session_in_tmp("copied"); + assert!(s.shortcuts().find("file.runScript").is_some(), "the table is built"); + std::fs::create_dir_all(root.join("settings").join(SCRIPTS_DIR)).unwrap(); + std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx"), "copied").unwrap(); + let list = s.execute("shortcuts.list", json!({"query": "copied.jsx"})).unwrap(); + assert_eq!(list["count"], 1, "{list}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Copied.jsx"}"#, "{r}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Missing.jsx"}, "keys": "Cmd+Alt+Shift+F8"})); + assert!(bad_params(&r), "{r:?}"); + // The binding-key form finds a script copied in since the last lookup, too. + std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx"), "second").unwrap(); + let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F10"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Second.jsx"}"#, "{r}"); + // Removed by hand: no longer in the folder. + std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Copied.jsx")).unwrap(); + std::fs::remove_file(root.join("settings").join(SCRIPTS_DIR).join("Second.jsx")).unwrap(); + let r = s.execute("shortcuts.set", json!({"command": r#"file.runScript {"name":"Second.jsx"}"#, "keys": "Cmd+Alt+Shift+F11"})); + assert!(bad_params(&r), "{r:?}"); + let r = s.execute("shortcuts.set", json!({"command": "file.runScript", "params": {"name": "Copied.jsx"}, "keys": "Cmd+Alt+Shift+F9"})); + assert!(bad_params(&r), "{r:?}"); + assert_eq!(s.shortcuts().shortcut_of("file.runScript", &Value::Null), None, "Run Script File… has no key"); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/crates/engine/src/commands/settings.rs b/crates/engine/src/commands/settings.rs index a6621c3f4..272903c69 100644 --- a/crates/engine/src/commands/settings.rs +++ b/crates/engine/src/commands/settings.rs @@ -73,13 +73,21 @@ fn prefs_pages(_: &mut Session, _: &Value) -> Result { // ---------------------------------------------------------------- shortcuts -/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). -fn bindable(s: &Session, p: &Value, cmd: &str) -> Result { +/// The bindable named by `command` (+ `params`), or by a binding key (`app.settings {...}`). A +/// File ▸ Scripts entry (`file.runScript {"name": …}`) is looked up in File ▸ Scripts' list as it +/// is now (the Scripts folder and the bundled scripts), and a script it doesn't list is an error, +/// not Run Script File…. +fn bindable(s: &mut Session, p: &Value, cmd: &str) -> Result { let c = str_p(p, "command").ok_or_else(|| bad(cmd, "missing `command`"))?; let params = p.get("params").cloned().unwrap_or(Value::Null); - let t = s.shortcuts(); let key = binding_key(c, ¶ms); - t.find(&key).or_else(|| t.find(c)).cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) + let script = (c == "file.runScript" && params.get("name").is_some()) || c.starts_with("file.runScript {"); + if script { + s.scripts_changed(); + } + let t = s.shortcuts(); + let found = if script { t.find(&key) } else { t.find(&key).or_else(|| t.find(c)) }; + found.cloned().ok_or_else(|| bad(cmd, format!("`{key}` can't have a shortcut (see shortcuts.list)"))) } fn keys_param(p: &Value, cmd: &str) -> Result> { @@ -93,6 +101,8 @@ fn keys_param(p: &Value, cmd: &str) -> Result> { } fn sc_list(s: &mut Session, p: &Value) -> Result { + // Scripts copied into the Scripts folder since the table was built are listed too. + s.scripts_changed(); let q = str_p(p, "query").unwrap_or("").to_lowercase(); let assigned = p.get("assigned").and_then(Value::as_bool).unwrap_or(false); let keys_q = str_p(p, "keys").and_then(normalize); diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index a56d1849b..ed4d0da74 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -171,9 +171,19 @@ impl Session { self.shortcut_table = std::sync::OnceLock::new(); } - /// The active shortcut preset resolved against every bindable command. + /// The scripts in the Scripts folder changed: list File ▸ Scripts' entries again in the + /// shortcut table. + pub fn scripts_changed(&mut self) { + self.shortcut_table = std::sync::OnceLock::new(); + } + + /// The active shortcut preset resolved against every bindable command, File ▸ Scripts' + /// entries included. pub fn shortcuts(&self) -> &ShortcutTable { - self.shortcut_table.get_or_init(|| ShortcutTable::build(&self.keymaps, &self.ui_commands)) + self.shortcut_table.get_or_init(|| { + let scripts: Vec = crate::commands::scripts::scripts(self).into_iter().filter(|e| !e.panel).map(|e| e.name).collect(); + ShortcutTable::build_with_scripts(&self.keymaps, &self.ui_commands, &scripts) + }) } /// Remember a project in File ▸ Open Recent and in the crash-recovery sentinel. diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index 3d4ab3661..ac453a635 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -1,6 +1,7 @@ //! Keyboard shortcut presets (Edit ▸ Keyboard Shortcuts). //! -//! Every menu entry, registry command and frontend command is *bindable*: it has a binding key +//! Every menu entry, registry command and frontend command, and every script File ▸ Scripts +//! lists, is *bindable*: it has a binding key //! (the command id, plus its bound parameters for entries such as `app.settings //! {"page":"general"}`), a scope (application-wide or a panel) and default keys. The built-in //! preset "EffectCraft Default" is After Effects' default keyboard layout and is read-only; custom @@ -142,6 +143,27 @@ pub fn bindables(ui: &[UiCommand]) -> Vec { out } +/// The File ▸ Scripts entries of the scripts `names` (file names, as the menu lists them), with +/// no default keys. A key is bound to the script's name, so it stays with that script when +/// scripts are added to or removed from the folder. +pub fn script_bindables(names: &[String]) -> Vec { + names + .iter() + .map(|n| { + let params = json!({"name": n}); + Bindable { + key: binding_key("file.runScript", ¶ms), + command: "file.runScript".into(), + params, + label: n.clone(), + path: vec!["File".into(), "Scripts".into()], + scope: APP_SCOPE.into(), + defaults: vec![], + } + }) + .collect() +} + /// Canonical shortcut text (`cmd+shift+k`, `Shift+Cmd+K`, `⌘⇧K` → `Cmd+Shift+K`); `None` if it /// has no key. pub fn normalize(s: &str) -> Option { @@ -421,7 +443,16 @@ pub struct ShortcutTable { impl ShortcutTable { pub fn build(maps: &Keymaps, ui: &[UiCommand]) -> ShortcutTable { - let bindables = bindables(ui); + Self::build_with_scripts(maps, ui, &[]) + } + + /// [`ShortcutTable::build`] with the File ▸ Scripts entries of the scripts `scripts`, listed + /// after the other File ▸ Scripts entries. + pub fn build_with_scripts(maps: &Keymaps, ui: &[UiCommand], scripts: &[String]) -> ShortcutTable { + let mut bindables = bindables(ui); + let at = bindables.iter().rposition(|b| b.path.starts_with(&["File".to_string(), "Scripts".to_string()])).map_or(bindables.len(), |i| i + 1); + let new: Vec = script_bindables(scripts).into_iter().filter(|s| !bindables.iter().any(|b| b.key == s.key)).collect(); + bindables.splice(at..at, new); let keys = bindables.iter().map(|b| (b.key.clone(), maps.keys_of(b))).collect(); ShortcutTable { bindables, keys } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ea9a6d91e..8f5df4d6c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2222,7 +2222,12 @@ fn menu_nodes( "recentPresets" => app.session.prefs.recent_presets.get(i).cloned(), _ => None, }; - let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str()))); + let mut b = egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str())); + // File ▸ Scripts entries show the key a script was given. + if let Some(s) = entry_shortcut(app, e) { + b = b.shortcut_text(shortcut_text(&s)); + } + let r = ui.add_enabled(entry_enabled(app, e), b); let r = match &tip { Some(t) => r.on_hover_text(t), None => r, diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 7d7046ed3..a843b818f 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -159,8 +159,9 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { label: e.label.clone(), command: e.command.clone(), params: e.params.clone(), - shortcut: None, - accelerator: None, + // File ▸ Scripts entries show the key a script was given. + shortcut: crate::menus::entry_shortcut(app, e), + accelerator: crate::menus::entry_shortcut(app, e).as_deref().and_then(accelerator), enabled: crate::menus::entry_enabled(app, e), checked: crate::menus::entry_checked(app, e), })); diff --git a/crates/ui-egui/src/panels/shortcut_editor.rs b/crates/ui-egui/src/panels/shortcut_editor.rs index 440ca6394..7c0de584e 100644 --- a/crates/ui-egui/src/panels/shortcut_editor.rs +++ b/crates/ui-egui/src/panels/shortcut_editor.rs @@ -49,6 +49,8 @@ const PANEL_COLOR: Color32 = Color32::from_rgb(0x3f, 0xa8, 0x6c); pub fn open(app: &mut EffectcraftApp) { app.dialog_state.shortcuts = EditorState::default(); + // Scripts copied into the Scripts folder since the table was built are listed too. + app.session.scripts_changed(); app.dialog = Some(Dialog::Shortcuts); } diff --git a/crates/ui-egui/tests/it/ui_scriptui.rs b/crates/ui-egui/tests/it/ui_scriptui.rs index 31fb555d3..2020928f0 100644 --- a/crates/ui-egui/tests/it/ui_scriptui.rs +++ b/crates/ui-egui/tests/it/ui_scriptui.rs @@ -112,6 +112,42 @@ fn panels_open_at_quit_dock_again_at_launch() { assert_eq!(store.read(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE).as_deref(), Some(r#"{"open":[]}"#)); } +/// A script copied into the Scripts folder while the app runs is listed in Edit ▸ Keyboard +/// Shortcuts and takes a key there: File ▸ Scripts shows the key next to the script, and +/// pressing it runs the script as its menu entry does. +#[test] +fn a_key_given_to_a_script_runs_it() { + use effectcraft_engine::config::{ConfigStore, MemoryConfig}; + let store = std::sync::Arc::new(MemoryConfig::default()); + let mut s = effectcraft_host::session(); + s.config = Some(store.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + assert!(h.state().session.shortcuts().find(r#"file.runScript {"name":"Rename Layers.jsx"}"#).is_some(), "the shortcut table is built"); + store.write("Scripts/Add Comp.jsx", "app.project.items.addComp('From Key', 64, 64, 1, 1, 25);").unwrap(); + effectcraft_ui_egui::panels::shortcut_editor::open(h.state_mut()); + let script = json!({"name": "Add Comp.jsx"}); + let r = h.state_mut().session.execute("shortcuts.set", json!({"command": "file.runScript", "params": script, "keys": "Cmd+Alt+Shift+F7"})).unwrap(); + assert_eq!(r["command"], r#"file.runScript {"name":"Add Comp.jsx"}"#, "{r}"); + assert_eq!(r["keys"], json!(["Cmd+Alt+Shift+F7"]), "{r}"); + assert_eq!(r["conflicts"], json!([]), "{r}"); + h.state_mut().dialog = None; + let spec = effectcraft_ui_egui::native_menu::build(h.state()); + let entry = spec.items().into_iter().find(|i| i.command == "file.runScript" && i.params == script).expect("the File ▸ Scripts entry"); + assert_eq!(entry.shortcut.as_deref(), Some("Cmd+Alt+Shift+F7")); + let (m, k, ..) = effectcraft_ui_egui::menus::bindings(&h.state().session) + .into_iter() + .find(|b| b.2 == "file.runScript" && b.3 == script) + .expect("the key is bound to the script"); + let made = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.items.values().filter(|i| i.name == "From Key").count(); + assert_eq!(made(&h), 0); + for pressed in [true, false] { + h.event(Event::Key { key: k, physical_key: Some(k), pressed, repeat: false, modifiers: m }); + h.step(); + } + assert_eq!(made(&h), 1, "the key runs the script"); +} + /// The bundled Ease Presets ScriptUI panel (Window ▸ Ease Presets.jsx) docks, and clicking a /// preset in its list eases the selected keyframes in one undo step. #[test] diff --git a/docs/plugins.md b/docs/plugins.md index a688a89b0..ef91b584b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -17,7 +17,7 @@ Guide): | ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same | | `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap | | `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches | -| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap | +| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts under File ▸ Scripts (`file.runScript {"name": ""}`); a key given to one is saved with the preset, File ▸ Scripts shows it, and pressing it runs the script as its menu entry does | Same. After Effects binds a key to a script's place in the alphabetical list (`ExecuteScriptMenuItem01`…), and EffectCraft binds it to the script's file name. The Window menu entries of ScriptUI panels aren't offered | | Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points | | `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same | | Allow Scripts to Write Files and Access Network | Settings ▸ Scripting & Expressions | Same | diff --git a/docs/preferences.md b/docs/preferences.md index ce96da0bf..df8313350 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -189,7 +189,8 @@ Effect Controls uses compact, fixed 20 pt parameter rows, as After Effects does: Edit ▸ Keyboard Shortcuts (`Cmd+Alt+'`) opens the shortcut editor: an on-screen keyboard showing which keys are assigned for the held modifiers (purple: application-wide, green: panel-specific, both: split), modifier toggles, a searchable list of every command (all engine commands, menu -entries with bound parameters and the frontend's tool / panel commands), and a preset menu. +entries with bound parameters, the scripts File ▸ Scripts lists and the frontend's tool / panel +commands), and a preset menu. - **Presets.** "EffectCraft Default" is After Effects' default layout and is read-only; editing it creates a "Custom" copy. Custom presets store only their differences from the default and diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md index 7ef7e9080..0907ba828 100644 --- a/docs/scripting-parity.md +++ b/docs/scripting-parity.md @@ -26,7 +26,7 @@ our own scripts; no real-world script library has been run against both apps. | Startup / Shutdown folders | yes | not run | missing | plugins.md | | `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths | | `.jsxbin` | yes | no | out of scope | No public specification | -| Keyboard shortcuts for scripts | yes | no | missing | plugins.md | +| Keyboard shortcuts for scripts | yes: the scripts of the Scripts folder take keys, by their place in the alphabetical list | Edit ▸ Keyboard Shortcuts lists the scripts of File ▸ Scripts; a key stays with the script's file name | same | `file_scripts_entries_take_keyboard_shortcuts`, `a_key_given_to_a_script_runs_it` ([plugins.md](plugins.md)); ScriptUI panels in the Window menu take no key | | Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` | | Essential Graphics scripting hooks | `addToMotionGraphicsTemplate…` | yes (M13.7) | good | — | | Agent control (MCP, control channel, every command) | none | yes | beyond | [agents.md](agents.md), [control-protocol.md](control-protocol.md); `list_effects` omits parameter types and ranges (#441) |