M13.1: ScriptUI frontend (dialogs, palettes, dockable panels), script menus, example plug-in, docs

- 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 <config>/Plug-ins
- MCP: script_ui and history tools; docs/plugins.md, agents.md, parity, architecture, ATTRIBUTION

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 04:24:01 -04:00
1 parent 89c9b1b6ee
commit 00eb951711
26 files changed
+1026 -7

No files matched your search

+5
View File
@@ -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
+5
View File
@@ -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 <config>/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();
+26
View File
@@ -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();
+32
View File
@@ -315,6 +315,20 @@ fn redo(b: &mut Backend, a: &Value) -> Result<Reply> {
history(b, a, "edit.redo")
}
fn script_ui_tool(b: &mut Backend, a: &Value) -> Result<Reply> {
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<Reply> {
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.",
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+1 -1
View File
@@ -828,7 +828,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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
),
+1
View File
@@ -757,6 +757,7 @@ Effect
Remove All | effect.removeAll
---
Manage Effects... | effect.manage
Load Effect Plug-in... | effect.plugins.load
---
@effects
Animation
+106
View File
@@ -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<std::path::PathBuf> {
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")));
}
+3
View File
@@ -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 {
+13
View File
@@ -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) {
+2
View File
@@ -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);
}
+56 -1
View File
@@ -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 ▸ <ScriptUI panel>: 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<Res
"file.exportLottie" => ("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 });
+24
View File
@@ -154,7 +154,31 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu {
kids.push(NativeNode::Separator);
}
}
let dynamic = |kids: &mut Vec<NativeNode>, 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) => {
+2
View File
@@ -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),
}
}
+321
View File
@@ -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.<window>.<control>` (and `scriptui.<window>.<name>` 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<Act>) {
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<Act>) {
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::<String>(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::<f64>(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::<f64>(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<usize> = 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<usize> = 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<Act>) {
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<ScriptWindow> = 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 ▸ <panel>.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<String> {
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);
}
}
}
+112
View File
@@ -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<String> {
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 ▸ <panel>: 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"]);
}
+29
View File
@@ -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
+9 -5
View File
@@ -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 |
+126
View File
@@ -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.
+7
View File
@@ -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"
@@ -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"
@@ -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<Vec<u64>> = 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
}