mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
1 parent
89c9b1b6ee
commit
00eb951711
26 files changed
+1026
-7
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -757,6 +757,7 @@ Effect
|
||||
Remove All | effect.removeAll
|
||||
---
|
||||
Manage Effects... | effect.manage
|
||||
Load Effect Plug-in... | effect.plugins.load
|
||||
---
|
||||
@effects
|
||||
Animation
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user