diff --git a/apps/effectcraft-web/src/app.rs b/apps/effectcraft-web/src/app.rs index 48f296c9f..1a6952408 100644 --- a/apps/effectcraft-web/src/app.rs +++ b/apps/effectcraft-web/src/app.rs @@ -241,6 +241,8 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> { let _ = session.execute("file.openDemoProject", json!({})); } api::set_info("restored", json!(restored)); + // The ScriptUI panels open on the last visit open again. + let _ = session.execute("window.restoreScriptPanels", json!({})); let mut app = EffectcraftApp::new(session); app.set_gpu_failure_bridge(gpu_failures); app.ui.start_screen = home; diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 113e38c5f..72afab5ae 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -158,6 +158,9 @@ fn main() -> eframe::Result { eprintln!("effectcraft: {e}"); } let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none()); + // The ScriptUI panels open when the app last quit open again (a failing one is + // reported in a toast and forgotten). + let _ = session.execute("window.restoreScriptPanels", json!({})); let mut app = EffectcraftApp::new(session); app.set_gpu_failure_bridge(gpu_failures); app.ui.start_screen = show_home; diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index 49884bc0a..b3847b6b5 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -18,6 +18,8 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui}; /// The settings-store folders of installed scripts. pub const SCRIPTS_DIR: &str = "Scripts"; pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels"; +/// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch. +pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json"; /// Sample scripts that ship with EffectCraft (original work): (file name, ScriptUI panel, code). pub const SAMPLES: &[(&str, bool, &str)] = &[ @@ -134,6 +136,64 @@ pub fn panel_wrapper(title: &str, code: &str) -> String { format!("(function () {{ {code}\n}}).call(__uiDockPanel({}));", Value::String(title.to_string())) } +/// The ScriptUI panels open now (script names). +pub(crate) fn open_panel_names(s: &Session) -> Vec { + s.script_ui.windows.iter().filter(|w| w.kind == scriptui::WindowKind::Panel).map(|w| w.script.clone()).collect() +} + +/// The panels open when EffectCraft last quit (`{"open": [names]}`); a missing or unreadable +/// file is none. +fn remembered_panels(s: &Session) -> Vec { + let text = s.config.as_ref().and_then(|c| c.read(OPEN_PANELS_FILE)).unwrap_or_default(); + let doc: Value = serde_json::from_str(&text).unwrap_or(Value::Null); + doc.get("open").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).filter(|n| is_script(n)).map(str::to_string).collect() +} + +/// Change the remembered panels with `f` (written only when it changed). The list is kept so +/// the next launch reopens them, as After Effects reopens the panels of its workspace; quitting +/// leaves it as it is. +fn edit_remembered_panels(s: &Session, f: impl FnOnce(&mut Vec)) { + let Some(cfg) = &s.config else { return }; + let before = remembered_panels(s); + let mut list = before.clone(); + f(&mut list); + if list != before + && let Err(e) = cfg.write(OPEN_PANELS_FILE, &json!({"open": list}).to_string()) + { + log::warn!("cannot remember the open ScriptUI panels: {e}"); + } +} + +/// Forget the panels of `before` (open before a script window event) that are closed now. +pub(crate) fn forget_closed_panels(s: &Session, before: &[String]) { + let now = open_panel_names(s); + let closed: Vec<&String> = before.iter().filter(|n| !now.contains(n)).collect(); + if !closed.is_empty() { + edit_remembered_panels(s, |l| l.retain(|n| !closed.contains(&n))); + } +} + +/// `window.restoreScriptPanels`: open the ScriptUI panels that were open when EffectCraft last +/// quit (frontends call it once at launch). Panels whose script is gone or fails are reported +/// and forgotten; the others still open. +fn restore_panels(s: &mut Session, _: &Value) -> Result { + let (mut opened, mut failed) = (vec![], vec![]); + for name in remembered_panels(s) { + let r = match script_code(s, &name, Some(true)) { + Some(_) => open_panel(s, &json!({"name": name})).map_err(|e| e.to_string()), + None => Err("no such ScriptUI panel".to_string()), + }; + match r { + Ok(_) => opened.push(name), + Err(error) => { + edit_remembered_panels(s, |l| l.retain(|n| *n != name)); + failed.push(json!({"name": name, "error": error})); + } + } + } + Ok(json!({"opened": opened, "failed": failed})) +} + /// Window ▸ : run the panel script (or bring its panel forward). fn open_panel(s: &mut Session, p: &Value) -> Result { let c = "window.scriptPanel"; @@ -150,6 +210,13 @@ fn open_panel(s: &mut Session, p: &Value) -> Result { if let Some(id) = id { s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) }); } + if id.is_some() { + edit_remembered_panels(s, |l| { + if !l.contains(&name) { + l.push(name.clone()); + } + }); + } Ok(json!({"window": id, "result": out})) } @@ -184,6 +251,15 @@ pub fn specs() -> Vec { always, open_panel ), + cmd!( + "window.restoreScriptPanels", + "Restore ScriptUI Panels", + [], + None, + "{} → reopens the ScriptUI panels open when EffectCraft last quit (frontends call it at launch) → {opened: [name], failed: [{name, error}]}", + always, + restore_panels + ), crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list), crate::query!( "scriptui.get", diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 6ef64920b..88450c990 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -629,7 +629,11 @@ fn widget_p(s: &Session, win: u32, p: &Value, cmd: &str) -> Result { fn dispatch(s: &mut Session, ev: ScriptUiEvent) -> Result { let f = s.script_ui.dispatch.ok_or_else(|| EngineError::Other("scripting is not available in this build".into()))?; - f(s, &ev).map_err(EngineError::Other) + // The event may close a ScriptUI panel (its tab, or the script itself): forget it then. + let open = crate::commands::scripts::open_panel_names(s); + let r = f(s, &ev).map_err(EngineError::Other); + crate::commands::scripts::forget_closed_panels(s, &open); + r } /// `scriptui.list`: the open script windows. diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index 092bd02bc..df2d2dcdf 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -82,6 +82,8 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_panels; +#[cfg(test)] mod tests_ui; #[cfg(test)] mod tests_ui_more; diff --git a/crates/script/src/tests_panels.rs b/crates/script/src/tests_panels.rs new file mode 100644 index 000000000..c398a3a3f --- /dev/null +++ b/crates/script/src/tests_panels.rs @@ -0,0 +1,88 @@ +//! ScriptUI Panels, as in After Effects: scripts in the user's `Scripts/ScriptUI Panels` folder +//! (in the settings folder) are listed at the bottom of the Window menu, open as dockable panels +//! with `this` = the panel, and the panels open when the app quits open again at the next launch. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::commands::scripts::{OPEN_PANELS_FILE, PANELS_DIR}; +use effectcraft_engine::config::{ConfigStore, MemoryConfig}; +use effectcraft_engine::scriptui::WindowKind; +use serde_json::json; + +const PANEL: &str = "var p = this;\np.add('statictext', undefined, 'Hello from a panel');\np.layout.layout(true);\n"; + +fn session(store: &Arc) -> Session { + let mut s = Session::default(); + crate::install(&mut s); + s.config = Some(store.clone()); + s +} + +fn panel_window(s: &Session, name: &str) -> Option { + s.script_ui.windows.iter().find(|w| w.kind == WindowKind::Panel && w.script == name).map(|w| w.id) +} + +fn remembered(store: &MemoryConfig) -> serde_json::Value { + serde_json::from_str(&store.read(OPEN_PANELS_FILE).unwrap_or_default()).unwrap_or_default() +} + +#[test] +fn a_panel_dropped_in_the_folder_is_listed_and_opens_as_a_panel() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{PANELS_DIR}/Hello Panel.jsx"), PANEL).unwrap(); + // Not a script: not listed. + store.write(&format!("{PANELS_DIR}/notes.txt"), "x").unwrap(); + let mut s = session(&store); + let cx = effectcraft_engine::menus::DynCtx::default(); + let (entries, _) = effectcraft_engine::menus::dynamic(&s, "scriptPanels", &cx); + let names: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect(); + assert!(names.contains(&"Hello Panel.jsx") && !names.contains(&"notes.txt"), "{names:?}"); + let e = entries.iter().find(|e| e.label == "Hello Panel.jsx").unwrap(); + assert_eq!((e.command.as_str(), &e.params), ("window.scriptPanel", &json!({"name": "Hello Panel.jsx"}))); + let r = s.execute("window.scriptPanel", json!({"name": "Hello Panel.jsx"})).unwrap(); + let id = r["window"].as_u64().unwrap() as u32; + let w = s.script_ui.window(id).unwrap(); + assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Hello Panel")); + assert_eq!(w.root.children[0].text, "Hello from a panel"); + // Frontends dock it (`window.scriptPanel {window}` for the UI). + let events = s.drain_events(); + assert!( + events + .iter() + .any(|e| matches!(e, effectcraft_engine::Event::Frontend { command, params } if command == "window.scriptPanel" && params["window"] == id)) + ); +} + +#[test] +fn open_panels_open_again_at_the_next_launch() { + let store = Arc::new(MemoryConfig::default()); + store.write(&format!("{PANELS_DIR}/Hello Panel.jsx"), PANEL).unwrap(); + let mut s = session(&store); + s.execute("window.scriptPanel", json!({"name": "Hello Panel.jsx"})).unwrap(); + s.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap(); + assert_eq!(remembered(&store), json!({"open": ["Hello Panel.jsx", "Layer Tools.jsx"]})); + // Quit with both open; the next launch opens them again. + drop(s); + let mut s = session(&store); + let r = s.execute("window.restoreScriptPanels", json!({})).unwrap(); + assert_eq!(r, json!({"opened": ["Hello Panel.jsx", "Layer Tools.jsx"], "failed": []})); + assert!(panel_window(&s, "Hello Panel.jsx").is_some() && panel_window(&s, "Layer Tools.jsx").is_some()); + // Closing a panel (its tab) forgets it. + let id = panel_window(&s, "Layer Tools.jsx").unwrap(); + s.execute("scriptui.close", json!({"window": id})).unwrap(); + assert_eq!(remembered(&store), json!({"open": ["Hello Panel.jsx"]})); + // A panel whose script was removed, or that fails, is reported and forgotten; the others open. + store.write(OPEN_PANELS_FILE, &json!({"open": ["Gone.jsx", "Broken.jsx", "Hello Panel.jsx"]}).to_string()).unwrap(); + store.write(&format!("{PANELS_DIR}/Broken.jsx"), "throw new Error('nope');").unwrap(); + let mut s = session(&store); + let r = s.execute("window.restoreScriptPanels", json!({})).unwrap(); + assert_eq!(r["opened"], json!(["Hello Panel.jsx"])); + let failed: Vec<&str> = r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(failed, ["Gone.jsx", "Broken.jsx"]); + assert_eq!(remembered(&store), json!({"open": ["Hello Panel.jsx"]})); + // A corrupt list opens nothing (and is no error). + store.write(OPEN_PANELS_FILE, "{not json").unwrap(); + let mut s = session(&store); + assert_eq!(s.execute("window.restoreScriptPanels", json!({})).unwrap(), json!({"opened": [], "failed": []})); +} diff --git a/crates/ui-egui/tests/ui_scriptui.rs b/crates/ui-egui/tests/ui_scriptui.rs index 9ec9c5ee0..c48ed36cd 100644 --- a/crates/ui-egui/tests/ui_scriptui.rs +++ b/crates/ui-egui/tests/ui_scriptui.rs @@ -90,6 +90,28 @@ fn script_windows_are_drawn_and_clickable() { h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); } +/// A ScriptUI panel open when the app quit opens again, docked, at the next launch (the +/// frontends call `window.restoreScriptPanels` before building the app). +#[test] +fn panels_open_at_quit_dock_again_at_launch() { + use effectcraft_engine::config::{ConfigStore, MemoryConfig}; + let store = std::sync::Arc::new(MemoryConfig::default()); + store.write("Scripts/ScriptUI Panels/Hello Panel.jsx", "this.add('button', undefined, 'Hi', { name: 'hi' });").unwrap(); + store.write(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE, r#"{"open": ["Hello Panel.jsx"]}"#).unwrap(); + let mut s = effectcraft_host::session(); + s.config = Some(store.clone()); + s.execute("window.restoreScriptPanels", json!({})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Hello Panel.jsx").expect("the panel's window").id; + assert!(h.state().ui.dock.contains(PanelKind::ScriptPanel(panel)), "the panel docks"); + assert!(h.state().auto.find(&format!("scriptui.{panel}.hi")).is_some(), "its controls are drawn"); + // Closing its tab forgets it for the next launch. + h.state_mut().close_panel(PanelKind::ScriptPanel(panel)); + h.run_steps(2); + assert_eq!(store.read(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE).as_deref(), Some(r#"{"open":[]}"#)); +} + #[test] fn history_panel_jumps_between_branches() { let mut h = harness(); diff --git a/docs/gaps.md b/docs/gaps.md index 4e64c2c8b..4673245d1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -194,6 +194,12 @@ The most important missing piece: it turns every other estimate here into a meas - After Effects SDK plug-ins cannot run in EffectCraft. Our own WebAssembly plug-in API exists ([plugins.md](plugins.md)). Grow it: documentation, examples, and original effects that cover what the most common third-party plug-ins are used for (particles, glows, 3D objects, sabers). +- Features that come from third parties in After Effects are extensions in EffectCraft too, built + on the same extension points as After Effects: scripts, ScriptUI panels and effect plug-ins + ([plugins.md](plugins.md#extension-points-compared-with-after-effects) compares them). ScriptUI + panels in the user's ScriptUI Panels folder are listed in the Window menu, dock like any panel + and open again at the next launch. Missing: the Startup and Shutdown script folders, keyboard + shortcuts for scripts, ExtendScript's `#include` / `#target` directives and `.jsxbin`. - Done so far: ease presets (Window ▸ Ease Presets, `keys.easePreset.*`, #254) cover what ease-curve plug-ins are used for: easing curves kept by name, drawn and edited in a value graph, and applied to pairs of keyframes ([parity.md](parity.md#update-ease-presets-254)). diff --git a/docs/plugins.md b/docs/plugins.md index 47ce5caa6..2e5423024 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -1,4 +1,95 @@ -# Effect plug-ins +# Plug-ins and extensions + +EffectCraft's core aims at After Effects parity. What After Effects users get from third parties +(effect plug-ins, scripts, ScriptUI panels) is an extension in EffectCraft too, and EffectCraft +offers the same kinds of extension points as After Effects so that anyone can write their own. + +## Extension points compared with After Effects + +From After Effects' public documentation (the user guide's scripting pages and the Scripting +Guide): + +| After Effects | EffectCraft | | +|---|---|---| +| Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (plug-in API v1) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) | +| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same | +| File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) | +| ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | Not understood: a script that starts with one doesn't parse | Gap | +| `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 | +| 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 for the run only | Gap | +| Allow Scripts to Write Files and Access Network | Settings ▸ Scripting & Expressions | Same | + +The settings folder is `~/Library/Application Support/EffectCraft` on macOS, `%APPDATA%\EffectCraft` +on Windows and `$XDG_CONFIG_HOME/effectcraft` (or `~/.config/effectcraft`) on Linux +([preferences.md](preferences.md)); the web app keeps it in browser storage. + +## Scripts + +Scripts use After Effects' scripting object model (`app`, `app.project`, `CompItem`, layers, +`Property` with keyframes and eases, `app.beginUndoGroup`…), so most scripts written for After +Effects' documented API run unchanged. Every edit a script makes is an engine command: undoable, +journaled and the same as the UI's action. The `effectcraft-script` crate's docs +(`crates/script/src/lib.rs`) list the object model. + +* Run one: File ▸ Scripts ▸ Run Script File…, the Script Console panel, `effectcraft-cli script + file.jsx`, or `script.run {code, name}` over the control channel and MCP. +* Install one: File ▸ Scripts ▸ Install Script File… (or copy it into `Scripts/` in the settings + folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart). +* `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs + one; `file.uninstallScript {name}` removes an installed one. + +## ScriptUI panels + +A ScriptUI panel is a script that builds its user interface into the panel it is given as `this`. +Put it in `Scripts/ScriptUI Panels/` in the settings folder (or use File ▸ Scripts ▸ Install +ScriptUI Panel…) and it is listed at the bottom of the Window menu under its file name; choosing it +runs the script and docks its panel as a tab, which you can move, float, maximize and close like any +other panel. Closing the tab closes the panel (its `onClose` runs). Panels open when EffectCraft +quits open again at the next launch (`window.restoreScriptPanels`, which the desktop and web apps +run at start-up; the list is `scriptui_panels.json` in the settings folder). + +Run from File ▸ Scripts instead, the same script gets no panel, so it should make a floating +palette itself. A minimal panel: + +```js +// Nudge.jsx: moves the selected layers 10 px right, as a dockable panel. +(function (thisObj) { + var ui = thisObj instanceof Panel ? thisObj : new Window("palette", "Nudge", undefined, { resizeable: true }); + ui.orientation = "column"; + ui.alignChildren = ["fill", "top"]; + var go = ui.add("button", undefined, "Nudge Right", { name: "nudge" }); + var status = ui.add("statictext", undefined, "Select layers, then click."); + go.onClick = function () { + var comp = app.project.activeItem; + if (!(comp instanceof CompItem)) return; + var layers = comp.selectedLayers; + app.beginUndoGroup("Nudge Right"); + for (var i = 0; i < layers.length; i++) { + var p = layers[i].transform.position; + var v = p.value; + if (p.numKeys === 0) p.setValue([v[0] + 10, v[1]]); + } + app.endUndoGroup(); + status.text = "Moved " + layers.length + " layer(s)."; + }; + if (ui instanceof Window) ui.show(); else ui.layout.layout(true); +})(this); +``` + +Supported controls: `panel`, `group`, `button`, `iconbutton`, `statictext`, `edittext`, +`checkbox`, `radiobutton`, `slider`, `scrollbar`, `progressbar`, `dropdownlist`, `listbox`, +`tabbedpanel` / `tab` and `image`, with ScriptUI's automatic layout (`orientation`, +`alignChildren`, `alignment`, `margins`, `spacing`, `preferredSize`), resource strings, event +handlers (`onClick`, `onChange`, `onChanging`, `onClose`, `addEventListener`) and `onDraw` with +ScriptUIGraphics. Agents use a panel like a user: `scriptui.list`, `scriptui.get` (its controls), +`scriptui.click`, `scriptui.set` and `scriptui.close`; every control has the automation id +`scriptui..`. The bundled sample `Layer Tools.jsx` +(`crates/engine/scripts/ScriptUI Panels/`) is a longer example. + +## 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 @@ -18,7 +109,7 @@ There are two ways to write one: `effect.plugins.list` lists what is loaded: `{api, wasm, plugins: [{id, name, category, version, author, source, params}]}`. -## The contract +### 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), @@ -31,7 +122,7 @@ author, source, params}]}`. 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 +### The manifest Every plug-in describes itself with a JSON manifest: @@ -64,7 +155,7 @@ Every plug-in describes itself with a JSON manifest: parameters; a slider needs `min ≤ default ≤ max` (and `sliderMin ≤ sliderMax`); the manifest is at most 1 MiB. -## WebAssembly ABI (v1) +### WebAssembly ABI (v1) A module with **no imports** that exports: @@ -81,7 +172,7 @@ little-endian `f64`s) followed by the pixels (`width × height × 4` little-endi 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 +#### Example [`examples/plugins/posterize-bands`](../examples/plugins/posterize-bands) is a complete plug-in in plain Rust (no dependencies): @@ -97,7 +188,7 @@ effectcraft-cli exec effect.plugins.load '{"path": "target/wasm32-unknown-unknow 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 plug-ins ```rust use std::sync::Arc; @@ -121,7 +212,7 @@ 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 +### 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