From 00eb951711f38459fb8e36d4e874c5b1cd82fc45 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:24:01 -0400 Subject: [PATCH] M13.1: ScriptUI frontend (dialogs, palettes, dockable panels), script menus, example plug-in, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui-egui: scriptui_view draws script windows at engine layout bounds; PanelKind::ScriptPanel; File ▸ Scripts and Window menu list scripts/panels (egui + native menus) - examples/plugins/posterize-bands (WebAssembly, API v1); host plug-in tests; desktop loads /Plug-ins - MCP: script_ui and history tools; docs/plugins.md, agents.md, parity, architecture, ATTRIBUTION Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 5 + apps/effectcraft/src/main.rs | 5 + crates/automation/src/tests.rs | 26 ++ crates/automation/src/tools.rs | 32 ++ ...te Null at Selected Layers.jsx.attribution | 7 + .../scripts/Rename Layers.jsx.attribution | 7 + .../Render Queue Batch.jsx.attribution | 7 + .../Layer Tools.jsx.attribution | 7 + .../Sort Layers by In Point.jsx.attribution | 7 + crates/engine/src/commands/file_more.rs | 2 +- crates/engine/src/menus.rs | 1 + crates/host/tests/plugins.rs | 106 ++++++ crates/ui-egui/src/dock.rs | 3 + crates/ui-egui/src/dock_ui.rs | 13 + crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 57 +++- crates/ui-egui/src/native_menu.rs | 24 ++ crates/ui-egui/src/panels/mod.rs | 2 + crates/ui-egui/src/panels/scriptui_view.rs | 321 ++++++++++++++++++ crates/ui-egui/tests/ui_scriptui.rs | 112 ++++++ docs/agents.md | 29 ++ docs/parity.md | 14 +- docs/plugins.md | 126 +++++++ examples/plugins/posterize-bands/Cargo.lock | 7 + examples/plugins/posterize-bands/Cargo.toml | 18 + examples/plugins/posterize-bands/src/lib.rs | 93 +++++ 26 files changed, 1026 insertions(+), 7 deletions(-) create mode 100644 crates/engine/scripts/Create Null at Selected Layers.jsx.attribution create mode 100644 crates/engine/scripts/Rename Layers.jsx.attribution create mode 100644 crates/engine/scripts/Render Queue Batch.jsx.attribution create mode 100644 crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx.attribution create mode 100644 crates/engine/scripts/Sort Layers by In Point.jsx.attribution create mode 100644 crates/host/tests/plugins.rs create mode 100644 crates/ui-egui/src/panels/scriptui_view.rs create mode 100644 crates/ui-egui/tests/ui_scriptui.rs create mode 100644 docs/plugins.md create mode 100644 examples/plugins/posterize-bands/Cargo.lock create mode 100644 examples/plugins/posterize-bands/Cargo.toml create mode 100644 examples/plugins/posterize-bands/src/lib.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index d52b19c7..71c54885 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -61,6 +61,11 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall - Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`. - Text animator presets (Typewriter, Fade Up Characters, Bounce In Words, Tracking In, Scramble, Blur In, Jitter, Drop In Lines): `crates/engine/presets/text_animators.json` (sidecar `.attribution`). +- Sample scripts (File ▸ Scripts) and ScriptUI panel: `crates/engine/scripts/Rename Layers.jsx`, + `crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`, + `crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx` + (sidecars `.attribution`). +- Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly). ## First-party brand marks diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index b6426f5f..ac7f1be6 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -64,6 +64,11 @@ fn main() -> eframe::Result { // Settings, shortcut presets and the crash-recovery sentinel live in the platform // config directory. Agent-driven runs (`--control`) skip crash recovery. if let Some(dir) = config_dir() { + // WebAssembly effect plug-ins in /Plug-ins load before the menus are built. + let plugins = dir.join("Plug-ins"); + if plugins.is_dir() { + let _ = session.execute("effect.plugins.load", json!({"folder": plugins.to_string_lossy()})); + } session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir))); } session.load_settings(); diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 848d3ee0..9504094a 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -265,6 +265,32 @@ fn bridge_rejects_non_loopback() { assert!(Backend::bridge("localhost:9877").is_ok()); } +#[test] +fn script_ui_and_history_tools() { + let mut sess = Session::default(); + effectcraft_script::install(&mut sess); + let mut s = McpServer::new(Backend::headless(sess)); + let code = "var d = new Window('dialog', 'Ask'); var g = d.add('group'); g.add('button', undefined, 'OK', {name: 'ok'}); \ + if (d.show() == 1) app.project.items.addComp('Answered', 64, 64, 1, 1, 24); 'done'"; + let r = call_json(&mut s, "run_script", json!({"code": code})); + assert_eq!(r["waiting"], json!(true), "{r}"); + let list = call_json(&mut s, "script_ui", json!({})); + assert_eq!(list[0]["title"], "Ask"); + let tree = call_json(&mut s, "script_ui", json!({"action": "get"})); + assert_eq!(tree["root"]["children"][0]["children"][0]["name"], "ok"); + let r = call_json(&mut s, "script_ui", json!({"action": "click", "widget": "ok"})); + assert_eq!(r["result"], json!("done"), "{r}"); + // History: undo the comp, make another, and jump back to the first on its branch. + call_json(&mut s, "undo", json!({})); + call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "Other", "width": 64, "height": 64}})); + let h = call_json(&mut s, "history", json!({})); + let branch = h["states"].as_array().unwrap().iter().find(|n| n["depth"] == 1).unwrap()["index"].clone(); + let h = call_json(&mut s, "history", json!({"goto": branch})); + let cur = h["current"].as_u64().unwrap() as usize; + assert_eq!(h["states"][cur]["depth"], 0, "the jumped-to state is now the working line"); + assert!(call_json(&mut s, "get_project", json!({})).to_string().contains("Answered")); +} + #[test] fn run_script_round_trip() { let mut sess = Session::default(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index f9034a38..9e1bfee5 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -315,6 +315,20 @@ fn redo(b: &mut Backend, a: &Value) -> Result { history(b, a, "edit.redo") } +fn script_ui_tool(b: &mut Backend, a: &Value) -> Result { + let action = get(a, "action").and_then(Value::as_str).unwrap_or("list"); + let id = match action { + "list" => "scriptui.list", + "get" => "scriptui.get", + "click" => "scriptui.click", + "set" => "scriptui.set", + "close" => "scriptui.close", + other => return Err(Error::BadArgs(format!("unknown action `{other}` (list | get | click | set | close)"))), + }; + let p = obj(&[("window", get(a, "window")), ("widget", get(a, "widget")), ("value", get(a, "value")), ("result", get(a, "result"))]); + json_reply(b.exec(id, p)?) +} + fn history_tool(b: &mut Backend, a: &Value) -> Result { if let Some(g) = get(a, "goto") { let p = match g { @@ -554,6 +568,24 @@ static TOOLS: &[ToolDef] = &[ schema: || schema(json!({"goto": {"type": ["integer", "string"], "description": "State index or id to jump to (edit.history.goto)."}}), &[]), run: history_tool, }, + ToolDef { + name: "script_ui", + description: "Drive ScriptUI windows that scripts opened (dialogs, palettes, dockable panels). action `list` (default): open windows; `get`: a window's control tree (type, name, text, value, checked, items, selection, bounds); `click`: press a button / toggle a checkbox / pick a radio button; `set`: type text, move a slider, pick a list item (index or text); `close`: close a window (`result` for a dialog's show(), default 2 = Cancel). Controls by id, \"#id\", properties.name or text. A dialog's script resumes when the dialog closes.", + bridge_only: false, + schema: || { + schema( + json!({ + "action": {"type": "string", "enum": ["list", "get", "click", "set", "close"]}, + "window": {"type": ["integer", "string"], "description": "Window id or title (optional when one is open)."}, + "widget": {"type": ["integer", "string"], "description": "Control id, \"#id\", properties.name or text."}, + "value": {"description": "For `set`: text, number, bool, item index or item text."}, + "result": {"type": "integer", "description": "For `close`: what the dialog's show() returns."} + }), + &[], + ) + }, + run: script_ui_tool, + }, ToolDef { name: "screenshot", description: "Screenshot of the live app window, or one panel (`Composition`, `Timeline`, `EffectControls`, `Project`, ...) or element id, as PNG (longest side `max_side`, default 1600). Use it to check what the UI looks like after an action.", diff --git a/crates/engine/scripts/Create Null at Selected Layers.jsx.attribution b/crates/engine/scripts/Create Null at Selected Layers.jsx.attribution new file mode 100644 index 00000000..ea166b70 --- /dev/null +++ b/crates/engine/scripts/Create Null at Selected Layers.jsx.attribution @@ -0,0 +1,7 @@ +asset: Create Null at Selected Layers.jsx +title: EffectCraft sample script (Create Null at Selected Layers) +author: EffectCraft contributors +source: original work +license: MIT OR Apache-2.0 +added: 2026-10-03 by EffectCraft contributors (M13.1) +notes: Written from scratch for EffectCraft against the documented scripting behaviour; not derived from any Adobe sample. diff --git a/crates/engine/scripts/Rename Layers.jsx.attribution b/crates/engine/scripts/Rename Layers.jsx.attribution new file mode 100644 index 00000000..bc07b909 --- /dev/null +++ b/crates/engine/scripts/Rename Layers.jsx.attribution @@ -0,0 +1,7 @@ +asset: Rename Layers.jsx +title: EffectCraft sample script (Rename Layers) +author: EffectCraft contributors +source: original work +license: MIT OR Apache-2.0 +added: 2026-10-03 by EffectCraft contributors (M13.1) +notes: Written from scratch for EffectCraft against the documented scripting behaviour; not derived from any Adobe sample. diff --git a/crates/engine/scripts/Render Queue Batch.jsx.attribution b/crates/engine/scripts/Render Queue Batch.jsx.attribution new file mode 100644 index 00000000..5ab7be71 --- /dev/null +++ b/crates/engine/scripts/Render Queue Batch.jsx.attribution @@ -0,0 +1,7 @@ +asset: Render Queue Batch.jsx +title: EffectCraft sample script (Render Queue Batch) +author: EffectCraft contributors +source: original work +license: MIT OR Apache-2.0 +added: 2026-10-03 by EffectCraft contributors (M13.1) +notes: Written from scratch for EffectCraft against the documented scripting behaviour; not derived from any Adobe sample. diff --git a/crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx.attribution b/crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx.attribution new file mode 100644 index 00000000..6d4d572f --- /dev/null +++ b/crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx.attribution @@ -0,0 +1,7 @@ +asset: Layer Tools.jsx +title: EffectCraft sample script (Layer Tools) +author: EffectCraft contributors +source: original work +license: MIT OR Apache-2.0 +added: 2026-10-03 by EffectCraft contributors (M13.1) +notes: Written from scratch for EffectCraft against the documented scripting behaviour; not derived from any Adobe sample. diff --git a/crates/engine/scripts/Sort Layers by In Point.jsx.attribution b/crates/engine/scripts/Sort Layers by In Point.jsx.attribution new file mode 100644 index 00000000..e744d662 --- /dev/null +++ b/crates/engine/scripts/Sort Layers by In Point.jsx.attribution @@ -0,0 +1,7 @@ +asset: Sort Layers by In Point.jsx +title: EffectCraft sample script (Sort Layers by In Point) +author: EffectCraft contributors +source: original work +license: MIT OR Apache-2.0 +added: 2026-10-03 by EffectCraft contributors (M13.1) +notes: Written from scratch for EffectCraft against the documented scripting behaviour; not derived from any Adobe sample. diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 9175af59..2041d400 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -828,7 +828,7 @@ pub fn specs() -> Vec { "Run Script File...", ["File", "Scripts"], None, - "{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | steps: [{command, params}] | name (an installed or sample script, see file.scripts.list)}", + "{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | steps: [{command, params}]}", always, run_script ), diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 7f14a988..84522c19 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -757,6 +757,7 @@ Effect Remove All | effect.removeAll --- Manage Effects... | effect.manage + Load Effect Plug-in... | effect.plugins.load --- @effects Animation diff --git a/crates/host/tests/plugins.rs b/crates/host/tests/plugins.rs new file mode 100644 index 00000000..c8a9b368 --- /dev/null +++ b/crates/host/tests/plugins.rs @@ -0,0 +1,106 @@ +//! Effect plug-ins in a full session: a Rust plug-in registered through the trait and the +//! example WebAssembly plug-in (examples/plugins/posterize-bands) loaded with +//! `effect.plugins.load`, both applied by id like built-ins and rendered. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::effects::plugin::{EffectPlugin, PluginFrame, PluginManifest, PluginParam, PluginParamKind, PluginParams, register_plugin}; +use effectcraft_engine::time::Tick; +use serde_json::json; + +struct Swap { + m: PluginManifest, +} + +impl EffectPlugin for Swap { + fn manifest(&self) -> &PluginManifest { + &self.m + } + fn render(&self, frame: &mut PluginFrame, params: &PluginParams, _time: f64) -> Result<(), String> { + if params.b("on") { + for p in frame.pixels.iter_mut() { + p.swap(0, 2); + } + } + Ok(()) + } +} + +fn setup() -> (Session, u64) { + let mut s = effectcraft_host::session(); + s.execute("comp.new", json!({"name": "P", "width": 40, "height": 20, "duration": 1})).unwrap(); + let l = s.execute("layer.newSolid", json!({"color": [1.0, 0.5, 0.0], "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap(); + (s, l) +} + +fn px(s: &Session) -> [f32; 4] { + let cid = s.active_comp_id().unwrap(); + s.render(cid, Tick::ZERO, Default::default()).get(20, 10) +} + +#[test] +fn rust_plugins_apply_by_id() { + let m = PluginManifest { + api: 1, + id: "org.test.swap".into(), + name: "Swap Red Blue".into(), + category: "Channel".into(), + version: "1".into(), + author: String::new(), + description: String::new(), + params: vec![PluginParam { id: "on".into(), name: "On".into(), kind: PluginParamKind::Checkbox { default: true } }], + }; + register_plugin(Arc::new(Swap { m })).unwrap(); + let (mut s, l) = setup(); + s.execute("effect.apply", json!({"layer": l, "effect": "org.test.swap"})).unwrap(); + let p = px(&s); + assert!(p[0] < 0.01 && (p[1] - 0.5).abs() < 0.01 && p[2] > 0.99, "{p:?}"); + // Its parameters are ordinary properties: set (and animate) them like any effect's. + s.execute("prop.set", json!({"layer": l, "path": "effects/#1/on", "value": false})).unwrap(); + let p = px(&s); + assert!(p[0] > 0.99 && p[2] < 0.01, "{p:?}"); + let list = s.execute("effect.plugins.list", json!({})).unwrap(); + assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == "org.test.swap" && p["category"] == "Channel")); + assert_eq!(list["api"], 1); + assert!(s.execute("effect.list", json!({"filter": "swap red"})).unwrap().as_array().unwrap().len() == 1); +} + +/// Build the example plug-in (skipped when the wasm32 target isn't installed). +fn build_example() -> Option { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/plugins/posterize-bands"); + let target = std::env::var_os("CARGO_TARGET_DIR").map(std::path::PathBuf::from).unwrap_or_else(|| root.join("../../../target")).join("plugin-example"); + let target = if target.is_absolute() { target } else { root.join("../../..").join(target) }; + let st = std::process::Command::new(env!("CARGO")) + .current_dir(&root) + .args(["build", "--release", "--target", "wasm32-unknown-unknown", "--target-dir"]) + .arg(&target) + .status() + .ok()?; + if !st.success() { + eprintln!("skipping: the example plug-in didn't build (rustup target add wasm32-unknown-unknown)"); + return None; + } + Some(target.join("wasm32-unknown-unknown/release/posterize_bands.wasm")) +} + +#[test] +fn example_wasm_plugin_loads_and_renders() { + let Some(wasm) = build_example() else { return }; + let (mut s, l) = setup(); + let r = s.execute("effect.plugins.load", json!({"path": wasm.to_string_lossy()})).unwrap(); + assert_eq!(r["loaded"][0]["id"], "org.effectcraft.example.posterize-bands", "{r}"); + s.execute("effect.apply", json!({"layer": l, "effect": "Posterize Bands"})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "effects/#1/mix", "value": 0})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "effects/#1/levels", "value": 2})).unwrap(); + // Luminance of (1, .5, 0) is ~0.57: with 2 levels it bands to 1, scaling the colour by 1/y + // (8 bpc: red clips at 1). + let p = px(&s); + let y = 0.2126 + 0.7152 * 0.5; + assert!((p[1] - 0.5 / y as f32).abs() < 0.01 && p[0] > 0.99 && p[2] < 0.01, "{p:?}"); + // Invert: band 0. + s.execute("prop.set", json!({"layer": l, "path": "effects/#1/invert", "value": true})).unwrap(); + assert_eq!(px(&s)[..3], [0.0, 0.0, 0.0]); + let list = s.execute("effect.plugins.list", json!({})).unwrap(); + assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["source"].as_str().unwrap_or("").ends_with(".wasm"))); +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 91d7c335..6f045101 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -36,6 +36,8 @@ pub enum PanelKind { MaskInterpolation, ScriptConsole, EssentialGraphics, + /// A dockable ScriptUI panel (a script from the ScriptUI Panels folder), by script window id. + ScriptPanel(u32), } impl PanelKind { @@ -95,6 +97,7 @@ impl PanelKind { PanelKind::MaskInterpolation => "Mask Interpolation", PanelKind::ScriptConsole => "Script Console", PanelKind::EssentialGraphics => "Essential Graphics", + PanelKind::ScriptPanel(_) => "ScriptUI Panel", } } pub fn id(self) -> String { diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 44900918..e72d3989 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -18,6 +18,12 @@ impl EffectcraftApp { /// "Effect Controls Title", "Properties: Title", and the Timeline tab named after its comp. fn tab_titles(&self) -> Vec<(PanelKind, String)> { let mut out = Vec::new(); + // ScriptUI panels are named after their script. + for w in self.session.script_ui.windows.iter().filter(|w| w.kind == effectcraft_engine::scriptui::WindowKind::Panel) { + if let Some(t) = panels::scriptui_view::panel_title(self, w.id) { + out.push((PanelKind::ScriptPanel(w.id), t)); + } + } let Some(comp) = self.session.active_comp() else { return out }; let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default(); out.push((PanelKind::Composition, format!("Composition {cname}"))); @@ -45,6 +51,13 @@ impl EffectcraftApp { /// Close a panel wherever it is (docked or floating). pub fn close_panel(&mut self, p: PanelKind) { + // Closing a ScriptUI panel closes its script window (its script's onClose runs). + if let PanelKind::ScriptPanel(id) = p + && self.session.script_ui.window(id).is_some() + && let Err(e) = self.session.execute("scriptui.close", serde_json::json!({"window": id})) + { + self.ui.status = e.to_string(); + } self.ui.dock.close(p); self.edit_layout(|l| l.unfloat(p)); if self.ui.maximized == Some(p) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 912f2e68..9cc2113a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -834,6 +834,8 @@ impl EffectcraftApp { panels::precomp::mini_flowchart(self, &ctx); panels::home::capture_thumbnail(self); panels::dialogs::show(self, &ctx); + panels::scriptui_view::sync_panels(self); + panels::scriptui_view::show_windows(self, &ctx); self.draw_toast(ui, full); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index eed7e639..a6ad8542 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -456,6 +456,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu app.dialog = Some(crate::Dialog::About); Value::Null } + // Window ▸ : dock (or bring forward) the panel the script built. + "window.scriptPanel" => { + let id = p.get("window").and_then(Value::as_u64).ok_or("no ScriptUI panel window")? as u32; + app.show_panel(PanelKind::ScriptPanel(id)); + json!({"window": id}) + } "layer.style.options" => { crate::panels::layer_styles_dialog::open(app, &p)?; Value::Null @@ -791,7 +797,9 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Save("Animation.json")), "file.importLottie" => ("path", Ask::Open(&["json", "lottie"])), "render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")), - "file.runScript" => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])), + "file.runScript" if params.get("name").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])), + "file.installScript" | "file.installScriptUIPanel" => ("path", Ask::Open(&["jsx", "js"])), + "effect.plugins.load" if params.get("folder").is_none() => ("path", Ask::Open(&["wasm", "wat"])), "file.replaceFootage" => ("path", Ask::Import), "file.collectFiles" => ("folder", Ask::Save("Collected Files")), "essential.exportTemplate" => ("path", Ask::Save("Template.ectemplate")), @@ -1106,6 +1114,39 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) { } } +/// Menu entries listed from the session: (label, command, params). File ▸ Scripts starts with +/// the installed and sample scripts; the Window menu ends with the ScriptUI panels (as in After +/// Effects, by file name). +pub fn dynamic_entries(app: &EffectcraftApp, menu: &str) -> Vec<(String, String, Value)> { + let panels = match menu { + "Scripts" => false, + "Window" => true, + _ => return vec![], + }; + effectcraft_engine::commands::scripts::scripts(&app.session) + .into_iter() + .filter(|e| e.panel == panels) + .map(|e| { + let cmd = if panels { "window.scriptPanel" } else { "file.runScript" }; + let params = json!({"name": e.name}); + (e.name, cmd.to_string(), params) + }) + .collect() +} + +fn dynamic_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, menu: &str, clicked: &mut Option<(String, Value)>) -> usize { + let rows = dynamic_entries(app, menu); + for (i, (label, cmd, params)) in rows.iter().enumerate() { + let r = ui.add(egui::Button::new((gutter(false), label.as_str()))); + app.auto.add(&format!("menu.{}.{i}", if menu == "Window" { "scriptPanels" } else { "scripts" }), r.rect, label); + if r.clicked() { + *clicked = Some((cmd.clone(), params.clone())); + ui.close(); + } + } + rows.len() +} + /// Draw the in-window menu bar (the engine's AE menu tree). pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -1118,6 +1159,10 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let r = ui.menu_button(label, |ui| { ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 }); menu_nodes(app, ui, children, &mut clicked); + if label == "Window" && !dynamic_entries(app, "Window").is_empty() { + ui.separator(); + dynamic_rows(app, ui, "Window", &mut clicked); + } }); app.auto.add(&format!("menu.{label}"), r.response.rect, label); } @@ -1158,6 +1203,16 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c menu_nodes(app, ui, children, clicked); }); } + MenuNode::Submenu { label, children } if label == "Scripts" => { + // File ▸ Scripts: installed and sample scripts, then Install… / Run Script File…. + ui.menu_button((gutter(false), label.as_str()), |ui| { + ui.set_min_width(280.0); + if dynamic_rows(app, ui, "Scripts", clicked) > 0 { + ui.separator(); + } + menu_nodes(app, ui, children, clicked); + }); + } MenuNode::Submenu { label, children } => { ui.menu_button((gutter(false), label.as_str()), |ui| { ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 }); diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index ef5070b3..4558b3e2 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -154,7 +154,31 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu { kids.push(NativeNode::Separator); } } + let dynamic = |kids: &mut Vec, menu: &str| { + for (k, (label, command, params)) in crate::menus::dynamic_entries(app, menu).into_iter().enumerate() { + kids.push(NativeNode::Item(NativeItem { + id: format!("{id}.d{k}"), + label, + command, + params, + shortcut: None, + accelerator: None, + enabled: true, + checked: None, + })); + } + }; + if label == "Scripts" { + dynamic(&mut kids, "Scripts"); + if !kids.is_empty() { + kids.push(NativeNode::Separator); + } + } rec(app, children, &id, &mut kids); + if label == "Window" && !crate::menus::dynamic_entries(app, "Window").is_empty() { + kids.push(NativeNode::Separator); + dynamic(&mut kids, "Window"); + } out.push(NativeNode::Submenu { label: label.clone(), children: kids }); } MenuNode::Item(e) => { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d0fb3a3e..16cc4d82 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -29,6 +29,7 @@ pub mod puppet_tool; pub mod render_queue; pub mod roto_tool; pub mod script_console; +pub mod scriptui_view; pub mod settings; pub mod shortcut_editor; pub mod text_panels; @@ -98,6 +99,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec PanelKind::Paint => paint_panels::paint(app, ui, rect), PanelKind::Brushes => paint_panels::brushes(app, ui, rect), PanelKind::EssentialGraphics => essential::show(app, ui, rect), + PanelKind::ScriptPanel(id) => scriptui_view::panel(app, ui, rect, id), PanelKind::Flowchart => flowchart::show(app, ui, rect), } } diff --git a/crates/ui-egui/src/panels/scriptui_view.rs b/crates/ui-egui/src/panels/scriptui_view.rs new file mode 100644 index 00000000..580e287b --- /dev/null +++ b/crates/ui-egui/src/panels/scriptui_view.rs @@ -0,0 +1,321 @@ +//! ScriptUI windows: the dialogs, palettes and dockable panels scripts build +//! (`Session::script_ui`). Each control is drawn at the bounds the engine's ScriptUI layout +//! computed; what the user does goes back through the `scriptui.click` / `scriptui.set` / +//! `scriptui.close` commands, which run the script's handlers. Every control registers an +//! automation id `scriptui..` (and `scriptui..` when the script +//! named it). + +use effectcraft_engine::scriptui::{ScriptWindow, Widget, WidgetKind, WindowKind, layout}; +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, UiBuilder, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::EffectcraftApp; +use crate::dock::PanelKind; +use crate::theme::Tokens; + +enum Act { + Click(u32, u32), + Set(u32, u32, Value), + Close(u32), +} + +fn rect_of(origin: egui::Pos2, b: [f64; 4]) -> Rect { + Rect::from_min_size(origin + vec2(b[0] as f32, b[1] as f32), vec2(b[2] as f32, b[3] as f32)) +} + +fn register(app: &mut EffectcraftApp, win: u32, w: &Widget, rect: Rect) { + let label = if w.text.is_empty() { format!("{:?}", w.kind) } else { w.text.clone() }; + app.auto.add(&format!("scriptui.{win}.{}", w.id), rect, &label); + if !w.name.is_empty() { + app.auto.add(&format!("scriptui.{win}.{}", w.name), rect, &label); + } +} + +/// Draw `w`'s children (and, for containers, their children) at `origin` + bounds. +fn draw_children(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, win: u32, w: &Widget, acts: &mut Vec) { + let t = app.tokens; + if w.kind == WidgetKind::TabbedPanel { + // Tab headers, then the active tab only. + let r = rect_of(origin, w.bounds); + let mut x = r.min.x; + for (i, tab) in w.children.iter().enumerate() { + let tw = (tab.text.chars().count() as f32 * 7.0 + 20.0).max(48.0); + let hr = Rect::from_min_size(pos2(x, r.min.y), vec2(tw, 22.0)); + let active = i == w.active_tab; + let resp = ui.interact(hr, egui::Id::new(("sui-tab", win, tab.id)), Sense::click()); + ui.painter().rect_filled( + hr, + 3.0, + if active { + t.row_selected + } else if resp.hovered() { + t.hover + } else { + t.field_bg + }, + ); + ui.painter().text(hr.center(), Align2::CENTER_CENTER, &tab.text, Tokens::ui(12.0), t.text); + register(app, win, tab, hr); + if resp.clicked() && !active && tab.enabled { + acts.push(Act::Click(win, tab.id)); + } + x += tw + 2.0; + } + ui.painter().rect_stroke(Rect::from_min_max(pos2(r.min.x, r.min.y + 22.0), r.max), 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + if let Some(tab) = w.children.get(w.active_tab) { + draw_children(app, ui, origin, win, tab, acts); + } + return; + } + for c in w.children.iter().filter(|c| c.visible) { + draw_widget(app, ui, origin, win, c, acts); + } +} + +fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, win: u32, w: &Widget, acts: &mut Vec) { + let t = app.tokens; + let rect = rect_of(origin, w.bounds); + register(app, win, w, rect); + let id = egui::Id::new(("sui", win, w.id)); + let tip = |r: egui::Response| if w.help_tip.is_empty() { r } else { r.on_hover_text(&w.help_tip) }; + match w.kind { + WidgetKind::Group | WidgetKind::Tab | WidgetKind::Window | WidgetKind::TabbedPanel => draw_children(app, ui, origin, win, w, acts), + WidgetKind::Panel => { + let p = ui.painter(); + p.rect_stroke(rect.shrink(1.0), 4.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + if !w.text.is_empty() { + let g = p.layout_no_wrap(w.text.clone(), Tokens::ui(11.5), t.text_dim); + let at = pos2(rect.min.x + 10.0, rect.min.y - 1.0); + p.rect_filled(Rect::from_min_size(at - vec2(3.0, 0.0), g.size() + vec2(6.0, 0.0)), 0.0, t.panel_bg); + p.galley(at, g, t.text_dim); + } + draw_children(app, ui, origin, win, w, acts); + } + WidgetKind::Button | WidgetKind::IconButton => { + let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(rect, egui::Button::new(&w.text))).inner; + if tip(r).clicked() { + acts.push(Act::Click(win, w.id)); + } + } + WidgetKind::StaticText => { + let col = if w.enabled { t.text } else { t.text_faint }; + ui.painter().with_clip_rect(rect.expand(2.0)).text(pos2(rect.min.x, rect.min.y + 1.0), Align2::LEFT_TOP, &w.text, Tokens::ui(12.0), col); + } + WidgetKind::EditText => { + // The text being typed lives here until the field loses focus (then onChange). + let mut buf: String = ui.data(|d| d.get_temp::(id)).unwrap_or_else(|| w.text.clone()); + let focused = ui.memory(|m| m.has_focus(id)); + if !focused { + buf = w.text.clone(); + } + let te = if w.multiline { egui::TextEdit::multiline(&mut buf) } else { egui::TextEdit::singleline(&mut buf) }; + let te = te.id(id).interactive(!w.read_only).font(Tokens::ui(12.0)); + let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(rect, te)).inner; + let r = tip(r); + if r.lost_focus() && buf != w.text { + acts.push(Act::Set(win, w.id, json!(buf))); + } + ui.data_mut(|d| d.insert_temp(id, buf)); + } + WidgetKind::Checkbox => { + let mut v = w.checked; + let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(snug(rect, &w.text), egui::Checkbox::new(&mut v, &w.text))).inner; + if tip(r).clicked() { + acts.push(Act::Click(win, w.id)); + } + } + WidgetKind::RadioButton => { + let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(snug(rect, &w.text), egui::RadioButton::new(w.checked, &w.text))).inner; + if tip(r).clicked() && !w.checked { + acts.push(Act::Click(win, w.id)); + } + } + WidgetKind::Slider | WidgetKind::Scrollbar => { + // While dragging, the value lives here; the release sends it (onChanging / onChange). + let mut v: f64 = ui.data(|d| d.get_temp::(id)).unwrap_or(w.value); + let (lo, hi) = if w.max > w.min { (w.min, w.max) } else { (0.0, 100.0) }; + let r = ui + .add_enabled_ui(w.enabled, |ui| { + ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| { + ui.spacing_mut().slider_width = rect.width() - 4.0; + ui.add(egui::Slider::new(&mut v, lo..=hi).show_value(false)) + }) + .inner + }) + .inner; + let r = tip(r); + if r.dragged() { + ui.data_mut(|d| d.insert_temp(id, v)); + } else { + ui.data_mut(|d| d.remove::(id)); + if r.drag_stopped() || r.changed() { + acts.push(Act::Set(win, w.id, json!(v))); + } + } + } + WidgetKind::Progressbar => { + let span = if w.max > w.min { w.max - w.min } else { 100.0 }; + let f = ((w.value - w.min) / span).clamp(0.0, 1.0) as f32; + ui.put(rect, egui::ProgressBar::new(f).desired_height(rect.height())); + } + WidgetKind::DropDownList => { + let cur = w.selection.first().and_then(|i| w.items.get(*i)).cloned().unwrap_or_default(); + let mut pick: Option = None; + ui.add_enabled_ui(w.enabled, |ui| { + ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| { + egui::ComboBox::from_id_salt(id).selected_text(cur).width(rect.width() - 8.0).show_ui(ui, |ui| { + for (i, it) in w.items.iter().enumerate() { + if it == "-" { + ui.separator(); + } else if ui.selectable_label(w.selection.contains(&i), it).clicked() { + pick = Some(i); + } + } + }); + }); + }); + if let Some(i) = pick + && !w.selection.contains(&i) + { + acts.push(Act::Set(win, w.id, json!(i))); + } + } + WidgetKind::ListBox => { + let mut pick: Option = None; + ui.painter().rect_filled(rect, 2.0, t.field_bg); + ui.add_enabled_ui(w.enabled, |ui| { + ui.scope_builder(UiBuilder::new().max_rect(rect.shrink(2.0)), |ui| { + egui::ScrollArea::vertical().id_salt(id).max_height(rect.height() - 4.0).show(ui, |ui| { + ui.set_width(rect.width() - 8.0); + for (i, it) in w.items.iter().enumerate() { + if ui.selectable_label(w.selection.contains(&i), it).clicked() { + pick = Some(i); + } + } + }); + }); + }); + if let Some(i) = pick { + acts.push(Act::Set(win, w.id, json!(i))); + } + } + WidgetKind::Image | WidgetKind::Unknown => { + ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + } +} + +/// Checkboxes and radio buttons sit at the left of their (possibly filled) bounds. +fn snug(r: Rect, text: &str) -> Rect { + let w = (text.chars().count() as f32 * 7.0 + 26.0).min(r.width()); + Rect::from_min_size(r.min, vec2(w, r.height())) +} + +fn perform(app: &mut EffectcraftApp, ctx: &egui::Context, acts: Vec) { + for a in acts { + let (cmd, p) = match a { + Act::Click(w, id) => ("scriptui.click", json!({"window": w, "widget": id})), + Act::Set(w, id, v) => ("scriptui.set", json!({"window": w, "widget": id, "value": v})), + Act::Close(w) => ("scriptui.close", json!({"window": w})), + }; + match crate::menus::invoke(app, ctx, cmd, p) { + Ok(r) => { + if let Some(e) = r.get("error").filter(|e| !e.is_null()) { + let msg = e["message"].as_str().unwrap_or("script error").to_string(); + app.toast = Some((msg, ctx.input(|i| i.time))); + } else if let Some(o) = r["output"].as_str().filter(|o| !o.is_empty()) { + app.ui.status = o.lines().last().unwrap_or_default().to_string(); + } + } + Err(e) => app.ui.status = e, + } + } +} + +/// The floating script windows (dialogs, palettes, windows), drawn over the app. +pub fn show_windows(app: &mut EffectcraftApp, ctx: &egui::Context) { + let windows: Vec = app.session.script_ui.windows.iter().filter(|w| w.kind != WindowKind::Panel).cloned().collect(); + if windows.is_empty() { + return; + } + let t = app.tokens; + let mut acts = vec![]; + let screen = ctx.content_rect(); + if windows.iter().any(|w| w.modal) { + // A modal dialog blocks the app behind it, as in After Effects. + let r = egui::Area::new(egui::Id::new("scriptui-modal-block")).order(egui::Order::Middle).fixed_pos(screen.min).show(ctx, |ui| { + ui.allocate_exact_size(screen.size(), Sense::click_and_drag()); + ui.painter().rect_filled(screen, 0.0, Color32::from_black_alpha(90)); + }); + let _ = r; + } + for w in &windows { + let size = vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32); + let mut open = true; + let order = if w.modal { egui::Order::Foreground } else { egui::Order::Middle }; + let resp = egui::Window::new(if w.title.is_empty() { &w.script } else { &w.title }) + .id(egui::Id::new(("scriptui-window", w.id))) + .order(order) + .collapsible(false) + .resizable(false) + .open(&mut open) + .default_pos(screen.center() - size / 2.0) + .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel_bg)) + .show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + draw_children(app, ui, rect.min, w.id, &w.root, &mut acts); + rect + }); + if let Some(r) = resp { + app.auto.add(&format!("scriptui.{}", w.id), r.response.rect, &w.title); + } + if !open { + acts.push(Act::Close(w.id)); + } + } + perform(app, ctx, acts); +} + +/// A dockable ScriptUI panel's content (Window ▸ .jsx). +pub fn panel(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, id: u32) { + let t = app.tokens; + let Some(mut w) = app.session.script_ui.window(id).cloned() else { + ui.painter().text(rect.center(), Align2::CENTER_CENTER, "This ScriptUI panel has closed", Tokens::ui(12.0), t.text_faint); + return; + }; + // Docked panels fill their frame (ScriptUI lays them out at the panel's size). + let (pw, ph) = (rect.width() as f64, rect.height() as f64); + w.root.fixed_bounds = Some([0.0, 0.0, pw.max(w.root.bounds[2]), ph.max(w.root.bounds[3])]); + layout(&mut w); + let mut acts = vec![]; + let content = rect; + egui::ScrollArea::both().id_salt(("scriptui-panel", id)).max_width(content.width()).max_height(content.height()).show(ui, |ui| { + let (r, _) = ui.allocate_exact_size(vec2(w.root.bounds[2] as f32, w.root.bounds[3] as f32), Sense::hover()); + draw_children(app, ui, r.min, id, &w.root, &mut acts); + }); + app.auto.add(&format!("scriptui.{id}"), rect, &w.title); + let ctx = ui.ctx().clone(); + perform(app, &ctx, acts); +} + +/// Tab title of a script panel. +pub fn panel_title(app: &EffectcraftApp, id: u32) -> Option { + app.session.script_ui.window(id).map(|w| if w.title.is_empty() { w.script.clone() } else { w.title.clone() }) +} + +/// Close the dock panels whose script window has gone (the script closed it, or a saved +/// workspace from an earlier run). +pub fn sync_panels(app: &mut EffectcraftApp) { + let mut all = vec![]; + app.ui.dock.panels(&mut all); + for f in &app.ui.floating { + all.extend(f.panels.iter().copied()); + } + for p in all { + if let PanelKind::ScriptPanel(id) = p + && app.session.script_ui.window(id).is_none() + { + app.close_panel(p); + } + } +} diff --git a/crates/ui-egui/tests/ui_scriptui.rs b/crates/ui-egui/tests/ui_scriptui.rs new file mode 100644 index 00000000..62102904 --- /dev/null +++ b/crates/ui-egui/tests/ui_scriptui.rs @@ -0,0 +1,112 @@ +//! M13.1 UI: ScriptUI dialogs, palettes and dockable panels drawn from the session's script +//! windows (clicks reach the script's handlers), the branching History panel, and the Window / +//! File ▸ Scripts menus listing scripts (egui_kittest, UI logic only). + +use effectcraft_ui_egui::EffectcraftApp; +use effectcraft_ui_egui::dock::PanelKind; +use egui::{Event, Rect, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn harness() -> Harness<'static, EffectcraftApp> { + let mut s = effectcraft_host::session(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let c = rect(h, id).center(); + h.input_mut().events.push(Event::PointerMoved(c)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: c, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + h.run_steps(3); +} + +fn layer_names(h: &Harness<'_, EffectcraftApp>) -> Vec { + h.state().session.active_comp().map(|c| c.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default() +} + +#[test] +fn script_windows_are_drawn_and_clickable() { + let mut h = harness(); + let code = r#" + var w = new Window("palette", "Quick Solid"); + var n = w.add("edittext", undefined, "Card"); + n.characters = 10; + var go = w.add("button", undefined, "Add", { name: "add" }); + go.onClick = function () { app.project.activeItem.layers.addSolid([0, 1, 0], n.text, 50, 50, 1); }; + w.show(); + "#; + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "quick.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(3); + let win = h.state().session.script_ui.windows[0].id; + // Every control has an automation id (by id and by name). + for id in [format!("scriptui.{win}"), format!("scriptui.{win}.1"), format!("scriptui.{win}.2"), format!("scriptui.{win}.add")] { + assert!(h.state().auto.find(&id).is_some(), "missing {id}"); + } + click(&mut h, &format!("scriptui.{win}.add")); + assert_eq!(layer_names(&h), ["Card"]); + // A modal dialog: the script waits in show() until OK is clicked. + let code = r#" + var d = new Window("dialog", "Confirm"); + d.add("statictext", undefined, "Make a null?"); + var g = d.add("group"); + g.add("button", undefined, "Cancel", { name: "cancel" }); + g.add("button", undefined, "OK", { name: "ok" }); + if (d.show() === 1) app.project.activeItem.layers.addNull().name = "Yes"; + "#; + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "confirm.jsx"})).unwrap(); + assert_eq!(r["waiting"], true, "{r}"); + h.run_steps(3); + let d = h.state().session.script_ui.windows.iter().find(|w| w.modal).unwrap().id; + click(&mut h, &format!("scriptui.{d}.ok")); + assert_eq!(layer_names(&h), ["Yes", "Card"]); + assert!(h.state().session.script_ui.windows.iter().all(|w| !w.modal)); + // Window ▸ : the sample ScriptUI panel docks as its own tab. + h.state_mut().session.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap(); + h.run_steps(3); + let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Layer Tools.jsx").unwrap().id; + assert!(h.state().ui.dock.contains(PanelKind::ScriptPanel(panel)), "the panel docks"); + assert!(h.state().auto.find(&format!("scriptui.{panel}")).is_some()); + // The menus list scripts and panels. + let window_menu = effectcraft_ui_egui::menus::dynamic_entries(h.state(), "Window"); + assert!(window_menu.iter().any(|(l, c, _)| l == "Layer Tools.jsx" && c == "window.scriptPanel")); + let scripts = effectcraft_ui_egui::menus::dynamic_entries(h.state(), "Scripts"); + assert!(scripts.iter().any(|(l, c, _)| l == "Rename Layers.jsx" && c == "file.runScript")); + // Closing the panel's tab closes its script window. + h.state_mut().close_panel(PanelKind::ScriptPanel(panel)); + h.run_steps(2); + assert!(h.state().session.script_ui.window(panel).is_none()); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); +} + +#[test] +fn history_panel_jumps_between_branches() { + let mut h = harness(); + for n in ["A", "B"] { + h.state_mut().session.execute("layer.newSolid", json!({"name": n, "color": "#ffffff", "width": 10, "height": 10})).unwrap(); + } + h.state_mut().session.undo(); + h.state_mut().session.execute("layer.newSolid", json!({"name": "C", "color": "#ffffff", "width": 10, "height": 10})).unwrap(); + assert_eq!(layer_names(&h), ["C", "A"]); + h.state_mut().show_panel(PanelKind::History); + h.run_steps(3); + // Original, New Composition, A, C, and B on its branch. + let states = h.state().session.history_tree(); + assert_eq!(states.len(), 5); + let b = states.iter().find(|n| n.depth == 1).unwrap().index; + click(&mut h, &format!("history.state.{b}")); + assert_eq!(layer_names(&h), ["B", "A"]); + let c = h.state().session.history_tree().iter().find(|n| n.depth == 1).unwrap().index; + click(&mut h, &format!("history.state.{c}")); + assert_eq!(layer_names(&h), ["C", "A"]); +} diff --git a/docs/agents.md b/docs/agents.md index 8689b9da..da0318c4 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -220,6 +220,35 @@ In expressions, `text.sourceText.style` / `getStyleAt(i, t)` read styles and the (`setFontSize(v, start?, count?)`, `setFillColor`, `setText`, `setJustification`…) return a styled document. +### Scripts and ScriptUI windows + +Scripts that build ScriptUI windows publish them to the session; agents drive them like a user: + +1. `scriptui.list` → `[{window, title, kind: dialog|palette|window|panel, script, modal, size}]`. +2. `scriptui.get {"window": id}` → the control tree (`type`, `name`, `text`, `value`, `checked`, + `items`, `selection`, laid-out `bounds`, `handlers`…). +3. `scriptui.click {"widget": "ok"}` presses a button / toggles a checkbox / picks a radio button or + tab; `scriptui.set {"widget": "#4", "value": "Shot_"}` types into edit text, moves a slider or + picks a list item (index or text); `scriptui.close {"result": 2}` closes. Controls are addressed + by id, `#id`, `properties.name` or text; `window` can be omitted when one window is open. Each + returns the handler run's `{ok, output, error}`. + +A dialog's `show()` waits for the user: the `script.run` / `file.runScript` reply carries +`"waiting": true`, and the script continues (its final output arrives in the reply of the click +that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript {"name": …}`, +`file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`. + +### History, puppet recording, plug-ins + +* `edit.history.list` lists every undo state as a tree (undoing then editing keeps the undone + states as a branch); `edit.history.goto {"index": n}` (or `id`, or `steps`) jumps to any of + them. MCP: the `history` tool. +* `puppet.recordPin {"layer": "#1", "pin": "Puppet Pin 1", "samples": [[t, x, y]…]}` records a + drag (t = seconds since it began, layer space) into Position keys at the comp frame rate from the + current time; `puppet.recordOptions {speed, smoothing, useDraftDeformation, showMesh}`. +* `effect.plugins.load {"path": "x.wasm"}` / `effect.plugins.list`: WebAssembly effect plug-ins + ([plugins.md](plugins.md)), then `effect.apply` by id like a built-in. + ## CLI Each invocation runs a headless engine with no window. It opens the demo project unless you pass diff --git a/docs/parity.md b/docs/parity.md index 4437bece..a75d37f5 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -22,9 +22,13 @@ Tracking 88%, Effects 85%, Web 65%. What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; Render Queue field render, crop/resize and templates; approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; OpenType features and variable -font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI; -Lumetri Scopes, Footage, Media Browser and Metadata panels; Content-Aware Fill; face tracking; and -the "better than After Effects" items (a plug-in API, branching history; GPU particles landed in M12.7). +font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; +Lumetri Scopes, Footage, Media Browser and Metadata panels; Content-Aware Fill; and face tracking. +ScriptUI (dialogs that block `show()`, palettes, dockable ScriptUI panels, File ▸ Scripts install +and sample scripts), puppet pin recording and the remaining "better than After Effects" items — a +versioned effect plug-in API with sandboxed WebAssembly plug-ins ([plugins.md](plugins.md)), +branching undo history in the History panel, and a bit-identical rendering test across runs and +thread counts — landed in M13.1 (GPU particles landed in M12.7). The sections below are the original audit (morning of 2 October, ≈ 64%) and its updates, kept for history. @@ -112,10 +116,10 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Output | 90% | 3.0 | multiple output modules, pre-render (WebM with VP9 alpha + Opus and WAV/AIFF audio-only landed: VP9 is intra-only, Opus CELT-only) | | Audio | 85% | 0.5 | audio to keyframes | | Import | 88% | 2.0 | AI/EPS/PDF vector footage, PSD smart objects and 3D layers (PSD as footage/composition/retain layer sizes, SVG footage and Create Shapes from Vector Layer landed) | -| Automation | ≈ 90% | 0.8 | scripting covers the documented core object model (AUT-2, M14.4: `app`, project items, comps, layers, properties and keyframes, text documents, markers, render queue, Script Console, `effectcraft-cli script`, MCP `run_script`); still missing: ScriptUI panels/dialogs, `.jsxbin`, sockets | +| Automation | ≈ 97% | 0.3 | `.jsxbin`, sockets, ScriptUI resource strings and custom `onDraw` graphics (the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) | | Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) | | Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' rare controls (font menus, mirrored properties) (the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | -| Animation | 67% | 10.0 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | +| Animation | 70% | 9.0 | puppet depth beyond pins and recording (puppet pin recording with Record Options landed in M13.1), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | | Text | ≈ 91% | 0.8 | no OpenType feature panel, no extruded strokes, variable-axis animation changes outlines but not advances (vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) | | 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer; stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 00000000..5d5b39c0 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,126 @@ +# Effect plug-ins + +EffectCraft loads third-party effects through a small, versioned plug-in API (**API version 1**). +A plug-in effect behaves exactly like a built-in one: it is listed in Effects & Presets (and the +Effect menu) under its category, applied by id (`effect.apply {"effect": "org.example.posterize"}`) +or display name, its parameters are ordinary properties (keyframes, expressions, Effect Controls, +`prop.set`), and it is saved in projects by id. + +There are two ways to write one: + +| | WebAssembly plug-in | Rust plug-in | +|---|---|---| +| What | a `.wasm` (or `.wat`) module | a type implementing `effectcraft_effects::plugin::EffectPlugin` | +| Loaded by | Effect ▸ Load Effect Plug-in…, `effect.plugins.load {path \| folder}`, or the `Plug-ins` folder next to the settings at start-up | `register_plugin(Arc::new(MyEffect))` in an app that links EffectCraft's crates | +| Sandbox | yes: no imports (no files, clock or network), fuel-limited, deterministic floats | none (it is your code) | +| Platforms | desktop and CLI (the wasmi interpreter, crate feature `effectcraft-plugin/wasm`); not the web build | everywhere | + +`effect.plugins.list` lists what is loaded: `{api, wasm, plugins: [{id, name, category, version, +author, source, params}]}`. + +## The contract + +* **Pixels** are premultiplied RGBA `f32` (0–1; values may exceed 1 in 32 bpc projects), row + major, processed **in place**. The buffer is the layer's (after masks and the effects above), + possibly at preview resolution (`scale` = buffer pixels per layer pixel) and padded. +* **Parameters** arrive flattened to `f64`s in the order they are declared: slider, angle, + checkbox (0 / 1) and popup (0-based index) take one value, point two (already in **buffer + pixels**), colour four (RGBA 0–1). +* **Time** is layer time in seconds. +* Rendering must be **deterministic** and may run on several threads at once (a WebAssembly + plug-in gets one instance per thread). +* A failed render (an error code, a trap, running out of fuel) leaves the frame unchanged. + +## The manifest + +Every plug-in describes itself with a JSON manifest: + +```json +{ + "api": 1, + "id": "org.effectcraft.example.posterize-bands", + "name": "Posterize Bands", + "category": "Stylize", + "version": "1.0.0", + "author": "EffectCraft contributors", + "description": "Quantizes luminance into tinted bands.", + "params": [ + {"id": "levels", "name": "Levels", "type": "slider", "default": 4, "min": 2, "max": 64, "sliderMax": 16, "decimals": 0}, + {"id": "mix", "name": "Tint Amount", "type": "slider", "default": 50, "min": 0, "max": 100}, + {"id": "tint", "name": "Tint", "type": "color", "default": [1.0, 0.55, 0.2, 1.0]}, + {"id": "invert", "name": "Invert Bands", "type": "checkbox", "default": false} + ] +} +``` + +* `id`: stable, reverse-domain; letters, digits, `.`, `_`, `-`. `ec.` is reserved for built-ins. + Ids must be unique (a second plug-in with a taken id is refused). +* `category`: one of the Effects & Presets categories (`Blur & Sharpen`, `Stylize`, …) or a new + one, which appears after the built-in categories. +* Parameter types: `slider` (`default`, `min`, `max`, optional `sliderMin`, `sliderMax`, + `decimals`), `angle` (`default` degrees), `checkbox` (`default` bool), `popup` (`options`, + `default` index), `point` (`default` as fractions of the layer size, `[0.5, 0.5]` = centre), + `color` (`default` RGBA 0–1). Parameter ids are letters, digits and `_`. + +## WebAssembly ABI (v1) + +A module with **no imports** that exports: + +| export | signature | | +|---|---|---| +| `memory` | memory | linear memory shared with the host | +| `ec_api_version` | `() -> i32` | must return `1` | +| `ec_manifest_ptr`, `ec_manifest_len` | `() -> i32` | the UTF-8 JSON manifest in memory | +| `ec_alloc` | `(bytes: i32) -> i32` | a buffer of at least `bytes` bytes, 8-byte aligned; it may reuse the previous one | +| `ec_render` | `(pixels: i32, width: i32, height: i32, params: i32, nparams: i32, time: f64, scale: f64) -> i32` | process the pixels in place; return 0 on success | + +For each frame the host calls `ec_alloc` once for a buffer holding the parameters (`nparams` +little-endian `f64`s) followed by the pixels (`width × height × 4` little-endian `f32`s), fills it, +calls `ec_render`, and reads the pixels back. Each call gets a fuel budget proportional to the +frame size; a module that loops forever stops with an error instead of hanging the app. + +### Example + +[`examples/plugins/posterize-bands`](../examples/plugins/posterize-bands) is a complete plug-in in +plain Rust (no dependencies): + +```sh +cd examples/plugins/posterize-bands +cargo build --release --target wasm32-unknown-unknown +# then, in EffectCraft: Effect ▸ Load Effect Plug-in… → target/wasm32-unknown-unknown/release/posterize_bands.wasm +# or headless: +effectcraft-cli exec effect.plugins.load '{"path": "target/wasm32-unknown-unknown/release/posterize_bands.wasm"}' +``` + +Any language that compiles to WebAssembly works (C, Zig, AssemblyScript…) as long as the module +has no imports. `crates/plugin/src/tests.rs` has a plug-in written directly in WebAssembly text. + +## Rust plug-ins + +```rust +use std::sync::Arc; +use effectcraft_effects::plugin::*; + +struct Swap(PluginManifest); + +impl EffectPlugin for Swap { + fn manifest(&self) -> &PluginManifest { &self.0 } + fn render(&self, frame: &mut PluginFrame, params: &PluginParams, _time: f64) -> Result<(), String> { + if params.b("on") { + for p in frame.pixels.iter_mut() { p.swap(0, 2); } + } + Ok(()) + } +} + +register_plugin(Arc::new(Swap(manifest)))?; // before the UI builds its menus +``` + +`PluginParams` reads values by parameter id (`f`, `b`, `point`, `color`). At most 64 plug-in +effects can be registered per process. + +## Compatibility + +The API version is checked when a plug-in loads: a manifest or module for another version is +refused with a message. Within a version the contract above does not change; additions (new +parameter types, new optional exports) bump the version. diff --git a/examples/plugins/posterize-bands/Cargo.lock b/examples/plugins/posterize-bands/Cargo.lock new file mode 100644 index 00000000..c1732e16 --- /dev/null +++ b/examples/plugins/posterize-bands/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "posterize-bands" +version = "1.0.0" diff --git a/examples/plugins/posterize-bands/Cargo.toml b/examples/plugins/posterize-bands/Cargo.toml new file mode 100644 index 00000000..825d704a --- /dev/null +++ b/examples/plugins/posterize-bands/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "posterize-bands" +description = "Example EffectCraft effect plug-in (WebAssembly, plug-in API v1): posterizes luminance into tinted bands" +version = "1.0.0" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false + +# Standalone: build with `cargo build --release --target wasm32-unknown-unknown` in this folder. +[workspace] + +[lib] +crate-type = ["cdylib"] + +[profile.release] +opt-level = "s" +lto = true +panic = "abort" diff --git a/examples/plugins/posterize-bands/src/lib.rs b/examples/plugins/posterize-bands/src/lib.rs new file mode 100644 index 00000000..9af70943 --- /dev/null +++ b/examples/plugins/posterize-bands/src/lib.rs @@ -0,0 +1,93 @@ +//! Posterize Bands: an example EffectCraft effect plug-in (plug-in API v1, see +//! `docs/plugins.md`). It quantizes each pixel's luminance into `levels` bands and mixes a tint +//! colour in by `mix` percent, optionally only above a `threshold`. +//! +//! Build: `cargo build --release --target wasm32-unknown-unknown`, then load +//! `target/wasm32-unknown-unknown/release/posterize_bands.wasm` with Effect ▸ Load Effect Plug-in +//! (or `effect.plugins.load {"path": …}`), or drop it in the settings folder's `Plug-ins`. + +use std::cell::RefCell; + +/// The manifest: id, name, Effects & Presets category, parameters (flattened in this order: +/// levels, mix, tint r g b a, invert). +const MANIFEST: &str = r#"{ + "api": 1, + "id": "org.effectcraft.example.posterize-bands", + "name": "Posterize Bands", + "category": "Stylize", + "version": "1.0.0", + "author": "EffectCraft contributors", + "description": "Quantizes luminance into tinted bands.", + "params": [ + {"id": "levels", "name": "Levels", "type": "slider", "default": 4, "min": 2, "max": 64, "sliderMax": 16, "decimals": 0}, + {"id": "mix", "name": "Tint Amount", "type": "slider", "default": 50, "min": 0, "max": 100, "decimals": 1}, + {"id": "tint", "name": "Tint", "type": "color", "default": [1.0, 0.55, 0.2, 1.0]}, + {"id": "invert", "name": "Invert Bands", "type": "checkbox", "default": false} + ] +}"#; + +thread_local! { + static BUF: RefCell> = const { RefCell::new(Vec::new()) }; +} + +#[unsafe(no_mangle)] +pub extern "C" fn ec_api_version() -> i32 { + 1 +} + +#[unsafe(no_mangle)] +pub extern "C" fn ec_manifest_ptr() -> i32 { + MANIFEST.as_ptr() as i32 +} + +#[unsafe(no_mangle)] +pub extern "C" fn ec_manifest_len() -> i32 { + MANIFEST.len() as i32 +} + +/// One reusable, 8-byte-aligned buffer for the parameters and pixels. +#[unsafe(no_mangle)] +pub extern "C" fn ec_alloc(bytes: i32) -> i32 { + BUF.with(|b| { + let mut b = b.borrow_mut(); + let words = (bytes.max(0) as usize).div_ceil(8); + if b.len() < words { + b.resize(words, 0); + } + b.as_mut_ptr() as i32 + }) +} + +/// # Safety +/// The host passes pointers into the buffer `ec_alloc` returned. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ec_render(pixels: i32, width: i32, height: i32, params: i32, nparams: i32, _time: f64, _scale: f64) -> i32 { + if nparams < 7 { + return 1; + } + let p = unsafe { std::slice::from_raw_parts(params as *const f64, nparams as usize) }; + let px = unsafe { std::slice::from_raw_parts_mut(pixels as *mut [f32; 4], (width.max(0) * height.max(0)) as usize) }; + let levels = p[0].round().max(2.0) as f32; + let mix = (p[1] / 100.0).clamp(0.0, 1.0) as f32; + let tint = [p[2] as f32, p[3] as f32, p[4] as f32]; + let invert = p[6] != 0.0; + for c in px.iter_mut() { + let a = c[3]; + if a <= 0.0 { + continue; + } + // Un-premultiply, band the luminance, re-premultiply. + let (r, g, b) = (c[0] / a, c[1] / a, c[2] / a); + let y = 0.2126 * r + 0.7152 * g + 0.0722 * b; + let mut band = (y * levels).floor().min(levels - 1.0) / (levels - 1.0); + if invert { + band = 1.0 - band; + } + for (i, ch) in [r, g, b].into_iter().enumerate() { + let banded = if y > 0.0 { ch * band / y.max(1e-6) } else { band }; + let v = banded * (1.0 - mix) + band * tint[i] * mix; + c[i] = v.max(0.0) * a; + } + } + 0 +}