Plug-ins: ScriptUI Panels folder scripts dock as panels and reopen, as in After Effects

EffectCraft's extension points compared with After Effects' (public
user guide and Scripting Guide) are now in docs/plugins.md, which
becomes "Plug-ins and extensions": scripts, ScriptUI panels and effect
plug-ins, with a short guide and an example panel for writing your own.

ScriptUI panels already came from `Scripts/ScriptUI Panels/` in the
settings folder, were listed at the bottom of the Window menu and
docked as tabs. What was missing: After Effects reopens the panels
that were open when it quit. Now the open panels are remembered in
`scriptui_panels.json` in the settings folder (a panel is added when
it opens and dropped when its tab or its script closes it; quitting
leaves the list), and `window.restoreScriptPanels` opens them again;
the desktop and web apps run it at start-up. A panel whose script is
gone or fails is reported and forgotten, and the others still open.
Where a panel was docked isn't kept, as workspace layouts aren't saved
between launches.

Gaps recorded in plugins.md and gaps.md G8: the Startup and Shutdown
script folders, keyboard shortcuts for scripts, ExtendScript's
`#include` / `#target` directives and `.jsxbin`.

Test: open_panels_open_again_at_the_next_launch (fails before: no
restore command), a_panel_dropped_in_the_folder_is_listed_and_opens_as_a_panel
(script), panels_open_at_quit_dock_again_at_launch (UI: the restored
panel docks with its controls; closing the tab forgets it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-09 21:03:12 +07:00
co-authored by Claude Opus 5.5
parent 6ce58040fd
commit 9da33c157e
9 changed files with 302 additions and 8 deletions
+2
View File
@@ -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;
+3
View File
@@ -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;
+76
View File
@@ -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<String> {
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<String> {
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<String>)) {
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<Value> {
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 ▸ <ScriptUI panel>: run the panel script (or bring its panel forward).
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
let c = "window.scriptPanel";
@@ -150,6 +210,13 @@ fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
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<CommandSpec> {
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",
+5 -1
View File
@@ -629,7 +629,11 @@ fn widget_p(s: &Session, win: u32, p: &Value, cmd: &str) -> Result<u32> {
fn dispatch(s: &mut Session, ev: ScriptUiEvent) -> Result<Value> {
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.
+2
View File
@@ -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;
+88
View File
@@ -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<MemoryConfig>) -> Session {
let mut s = Session::default();
crate::install(&mut s);
s.config = Some(store.clone());
s
}
fn panel_window(s: &Session, name: &str) -> Option<u32> {
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": []}));
}
+22
View File
@@ -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();
+6
View File
@@ -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)).
+98 -7
View File
@@ -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.<window>.<control id or name>`. 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