From 89c9b1b6eefb7a652a6ef1b2561d236b2520227b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 04:07:20 -0400 Subject: [PATCH] =?UTF-8?q?M13.1:=20branching=20history,=20puppet=20pin=20?= =?UTF-8?q?recording,=20WebAssembly=20plug-in=20API,=20ScriptUI=20model=20?= =?UTF-8?q?and=20hosts,=20File=20=E2=96=B8=20Scripts,=20deterministic=20re?= =?UTF-8?q?nder=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - engine: History keeps undone lines as branches; edit.history.list/goto; History panel tree; MCP history tool - puppet.recordPin / puppet.recordOptions; viewer Cmd-drag records; Record Options form - effects::plugin (EffectPlugin trait, register_plugin, API v1); effectcraft-plugin (wasmi, sandboxed, fuel-limited); effect.plugins.load/list - ScriptUI: engine scriptui model + layout + scriptui.* commands; script crate scriptui.js and script-host threads (dialogs block show()) - File ▸ Scripts: install script / ScriptUI panel, sample scripts, window.scriptPanel - host test: bit-identical renders across runs and thread counts Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 151 ++++- Cargo.toml | 3 + apps/effectcraft-web/src/store.rs | 12 + crates/automation/src/tools.rs | 19 + crates/effects/Cargo.toml | 1 + crates/effects/src/lib.rs | 28 +- crates/effects/src/plugin.rs | 338 +++++++++++ .../Create Null at Selected Layers.jsx | 41 ++ crates/engine/scripts/Rename Layers.jsx | 71 +++ crates/engine/scripts/Render Queue Batch.jsx | 52 ++ .../scripts/ScriptUI Panels/Layer Tools.jsx | 99 +++ .../scripts/Sort Layers by In Point.jsx | 37 ++ crates/engine/src/commands/edit.rs | 54 +- crates/engine/src/commands/effect.rs | 81 ++- crates/engine/src/commands/file_more.rs | 35 +- crates/engine/src/commands/mod.rs | 2 + crates/engine/src/commands/puppet.rs | 157 ++++- crates/engine/src/commands/scripts.rs | 222 +++++++ crates/engine/src/config.rs | 22 +- crates/engine/src/history.rs | 346 +++++++++++ crates/engine/src/lib.rs | 35 +- crates/engine/src/mask_track.rs | 5 +- crates/engine/src/menus.rs | 4 +- crates/engine/src/scriptui.rs | 572 ++++++++++++++++++ crates/engine/src/session_settings.rs | 6 +- crates/engine/src/tests_history.rs | 135 +++++ crates/engine/src/tests_paint.rs | 53 ++ crates/engine/src/tracking.rs | 5 +- crates/host/Cargo.toml | 6 + crates/host/src/lib.rs | 2 + crates/host/tests/determinism.rs | 74 +++ crates/plugin/Cargo.toml | 25 + crates/plugin/src/lib.rs | 66 ++ crates/plugin/src/tests.rs | 164 +++++ crates/plugin/src/wasm.rs | 151 +++++ crates/script/src/lib.rs | 15 +- crates/script/src/model.rs | 4 +- crates/script/src/runtime.rs | 170 ++++-- crates/script/src/scriptui.js | 544 +++++++++++++++++ crates/script/src/tests_ui.rs | 299 +++++++++ crates/script/src/ui.rs | 339 +++++++++++ crates/ui-egui/src/header.rs | 16 +- crates/ui-egui/src/panels/effects_presets.rs | 4 +- crates/ui-egui/src/panels/forms.rs | 13 + crates/ui-egui/src/panels/misc.rs | 49 +- crates/ui-egui/src/panels/viewer.rs | 39 +- docs/architecture.md | 7 +- xtask/src/main.rs | 1 + 48 files changed, 4440 insertions(+), 134 deletions(-) create mode 100644 crates/effects/src/plugin.rs create mode 100644 crates/engine/scripts/Create Null at Selected Layers.jsx create mode 100644 crates/engine/scripts/Rename Layers.jsx create mode 100644 crates/engine/scripts/Render Queue Batch.jsx create mode 100644 crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx create mode 100644 crates/engine/scripts/Sort Layers by In Point.jsx create mode 100644 crates/engine/src/commands/scripts.rs create mode 100644 crates/engine/src/history.rs create mode 100644 crates/engine/src/scriptui.rs create mode 100644 crates/engine/src/tests_history.rs create mode 100644 crates/host/tests/determinism.rs create mode 100644 crates/plugin/Cargo.toml create mode 100644 crates/plugin/src/lib.rs create mode 100644 crates/plugin/src/tests.rs create mode 100644 crates/plugin/src/wasm.rs create mode 100644 crates/script/src/scriptui.js create mode 100644 crates/script/src/tests_ui.rs create mode 100644 crates/script/src/ui.rs diff --git a/Cargo.lock b/Cargo.lock index c79341009..3aba66953 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1197,6 +1197,7 @@ dependencies = [ "effectcraft-project", "effectcraft-raster", "effectcraft-track", + "log", "rayon", "serde", "serde_json", @@ -1309,12 +1310,14 @@ dependencies = [ "effectcraft-export", "effectcraft-expr", "effectcraft-media", + "effectcraft-plugin", "effectcraft-project", "effectcraft-psd", "effectcraft-raster", "effectcraft-script", "effectcraft-svg", "effectcraft-time", + "rayon", "serde_json", ] @@ -1398,6 +1401,18 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "effectcraft-plugin" +version = "0.1.0" +dependencies = [ + "effectcraft-effects", + "effectcraft-keyframe", + "effectcraft-project", + "effectcraft-raster", + "serde_json", + "wasmi", +] + [[package]] name = "effectcraft-project" version = "0.1.0" @@ -2102,6 +2117,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2276,7 +2297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -2416,6 +2437,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -2424,7 +2454,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2435,7 +2465,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2753,6 +2783,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "lebe" version = "0.5.3" @@ -4432,6 +4468,12 @@ dependencies = [ "serde", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -4459,6 +4501,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "string-interner" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23de088478b31c349c9ba67816fa55d9355232d63c3afea8bf513e31f0f1d2c0" +dependencies = [ + "hashbrown 0.15.5", + "serde", +] + [[package]] name = "strum" version = "0.28.0" @@ -5127,6 +5179,99 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22" +dependencies = [ + "leb128fmt", + "wasmparser 0.261.0", +] + +[[package]] +name = "wasmi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78693fcdd618e0fc34af59c6b8efa9ac5d58c68df940beff4bedddb6acfe7c27" +dependencies = [ + "spin", + "wasmi_collections", + "wasmi_core", + "wasmi_ir", + "wasmparser 0.228.0", + "wat", +] + +[[package]] +name = "wasmi_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a8be2aa467cf2d29e96ff759472c36eeb44a3c81c67fc9cb76c9a24c519c557" +dependencies = [ + "string-interner", +] + +[[package]] +name = "wasmi_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69372d5fda3ea3d1e0aa6603c7888110e0187e88ea17cd8fc2e2df0a0e1f37fa" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmi_ir" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f17b774caa13c618c7244f1ee51fe23c5e7b8538a471fa46d9949779758aed6" +dependencies = [ + "wasmi_core", +] + +[[package]] +name = "wasmparser" +version = "0.228.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4abf1132c1fdf747d56bbc1bb52152400c70f336870f968b85e89ea422198ae3" +dependencies = [ + "bitflags 2.13.2", + "indexmap", +] + +[[package]] +name = "wasmparser" +version = "0.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8" +dependencies = [ + "bitflags 2.13.2", + "indexmap", +] + +[[package]] +name = "wast" +version = "261.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder", +] + +[[package]] +name = "wat" +version = "1.261.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f" +dependencies = [ + "wast", +] + [[package]] name = "wayland-backend" version = "0.3.17" diff --git a/Cargo.toml b/Cargo.toml index 22709f60d..51a2c8efe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ effectcraft-ui-egui = { path = "crates/ui-egui" } effectcraft-automation = { path = "crates/automation" } effectcraft-opusenc = { path = "crates/opusenc" } effectcraft-script = { path = "crates/script" } +effectcraft-plugin = { path = "crates/plugin" } # foundation thiserror = "2" @@ -52,6 +53,8 @@ proptest = "1" # scripting (expressions) boa_engine = { version = "0.22", default-features = false } +# WebAssembly effect plug-ins (sandboxed interpreter; MIT/Apache-2.0, pure Rust) +wasmi = { version = "2", default-features = false, features = ["std", "validate", "wat", "deterministic", "portable-dispatch"] } # geometry / fonts kurbo = { version = "0.13", features = ["serde"] } diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs index 0e4ea0295..471d96b52 100644 --- a/apps/effectcraft-web/src/store.rs +++ b/apps/effectcraft-web/src/store.rs @@ -216,6 +216,18 @@ impl ConfigStore for WebConfig { self.store.changed(); Ok(()) } + fn list(&self, dir: &str) -> Vec { + let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/')); + let mut v: Vec = self + .store + .lock() + .list(&pre) + .into_iter() + .filter_map(|(k, ..)| k.strip_prefix(&pre).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string)) + .collect(); + v.sort(); + v + } fn dir(&self) -> Option { Some(PathBuf::from("/")) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 739c5524b..f9034a38a 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -315,6 +315,18 @@ fn redo(b: &mut Backend, a: &Value) -> Result { history(b, a, "edit.redo") } +fn history_tool(b: &mut Backend, a: &Value) -> Result { + if let Some(g) = get(a, "goto") { + let p = match g { + Value::String(id) if id.parse::().is_err() => json!({"id": id}), + Value::String(n) => json!({"index": n.parse::().unwrap_or(0)}), + v => json!({"index": v}), + }; + b.exec("edit.history.goto", p)?; + } + json_reply(b.exec("edit.history.list", json!({}))?) +} + // ------------------------------------------------------------------ bridge-only tools fn screenshot(b: &mut Backend, a: &Value) -> Result { @@ -535,6 +547,13 @@ static TOOLS: &[ToolDef] = &[ schema: || schema(json!({"steps": {"type": "integer", "minimum": 1}}), &[]), run: redo, }, + ToolDef { + name: "history", + description: "The branching undo history (History panel): without arguments lists every state {index, id, label, parent, depth (0 = working line, >0 = branch), current, future}; undoing and then editing keeps the undone states as a branch. With `goto` (an index or id from the listing) jumps to that state, on any branch, without losing the others.", + bridge_only: false, + schema: || schema(json!({"goto": {"type": ["integer", "string"], "description": "State index or id to jump to (edit.history.goto)."}}), &[]), + run: history_tool, + }, ToolDef { name: "screenshot", description: "Screenshot of the live app window, or one panel (`Composition`, `Timeline`, `EffectControls`, `Project`, ...) or element id, as PNG (longest side `max_side`, default 1600). Use it to check what the UI looks like after an action.", diff --git a/crates/effects/Cargo.toml b/crates/effects/Cargo.toml index 965cdbd22..9b0653609 100644 --- a/crates/effects/Cargo.toml +++ b/crates/effects/Cargo.toml @@ -19,4 +19,5 @@ effectcraft-track = { workspace = true } rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +log = { workspace = true } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c4a47508b..663b4bf79 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -41,6 +41,7 @@ pub mod ocio_config; pub mod paint; mod perspective; mod perspective2; +pub mod plugin; pub mod psim; pub mod puppet; pub mod roto; @@ -423,14 +424,37 @@ pub fn registry() -> &'static [EffectSpec] { }) } +/// A built-in effect or a registered plug-in by id. pub fn find(id: &str) -> Option<&'static EffectSpec> { - registry().iter().find(|s| s.id == id) + registry().iter().find(|s| s.id == id).or_else(|| plugin::plugins().into_iter().find(|s| s.id == id)) +} + +/// Every effect: the built-ins ([`registry`]) and the registered plug-ins +/// ([`plugin::register_plugin`]), sorted by category then name. +pub fn all() -> Vec<&'static EffectSpec> { + let plugins = plugin::plugins(); + let mut v: Vec<&'static EffectSpec> = registry().iter().collect(); + if !plugins.is_empty() { + v.extend(plugins); + v.sort_by(|a, b| a.category.cmp(b.category).then(a.name.cmp(b.name))); + } + v +} + +/// Effects & Presets categories: [`CATEGORIES`] then the plug-ins' own, alphabetically. +pub fn categories() -> Vec<&'static str> { + let mut v: Vec<&'static str> = CATEGORIES.to_vec(); + let mut extra: Vec<&'static str> = plugin::plugins().iter().map(|s| s.category).filter(|c| !CATEGORIES.contains(c)).collect(); + extra.sort_unstable(); + extra.dedup(); + v.extend(extra); + v } /// Find by id or (case-insensitive) display name. pub fn lookup(name_or_id: &str) -> Option<&'static EffectSpec> { find(name_or_id) - .or_else(|| registry().iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id))) + .or_else(|| all().into_iter().find(|s| s.name.eq_ignore_ascii_case(name_or_id))) .or_else(|| { // After Effects' third-party display names we register under a generic name. keylight::KEYLIGHT_ALIASES.iter().any(|a| a.eq_ignore_ascii_case(name_or_id)).then(|| find("ec.keying.keylight")).flatten() diff --git a/crates/effects/src/plugin.rs b/crates/effects/src/plugin.rs new file mode 100644 index 000000000..242f52b9e --- /dev/null +++ b/crates/effects/src/plugin.rs @@ -0,0 +1,338 @@ +//! The effect plug-in API, version [`PLUGIN_API_VERSION`]. +//! +//! A plug-in is anything implementing [`EffectPlugin`]: a [`PluginManifest`] (stable id, +//! display name, Effects & Presets category, typed parameters) and a `render` that processes a +//! [`PluginFrame`] in place. [`register_plugin`] turns it into an ordinary [`EffectSpec`]: +//! instances are property groups like every built-in effect, so plug-in parameters animate, +//! take expressions, show in Effect Controls and are addressable by agents, and the effect is +//! applied by id (`effect.apply {"effect": "org.example.posterize"}`) like a built-in. +//! +//! Plug-ins come from Rust code (implement the trait and register it at start-up) or from +//! sandboxed WebAssembly modules (`effectcraft-plugin`, see `docs/plugins.md`), which implement +//! the same contract through a small C-like ABI. +//! +//! The contract (stable within an API version): +//! * pixels are premultiplied RGBA `f32` (0–1, may exceed 1 in 32 bpc projects), row-major, +//! in the layer's buffer, which may be scaled (preview resolution) and padded; +//! * parameters are flattened to `f64`s in declaration order: slider / angle / checkbox (0 or 1) +//! / popup (0-based index) take one value, point two (**frame pixels**, already mapped to the +//! buffer), colour four (RGBA 0–1); +//! * `time` is layer time in seconds; `scale` is the buffer's pixels per layer pixel (scale +//! pixel distances like blur radii by it); +//! * rendering must be deterministic (same inputs, same output) and may run on several threads +//! at once. + +use std::sync::{Arc, OnceLock, RwLock}; + +use effectcraft_keyframe::Value; +use effectcraft_project::ParamUi; +use serde::{Deserialize, Serialize}; + +use crate::{Buf, EffectCtx, EffectSpec, ParamSpec, RenderFn}; + +/// The plug-in API version this build implements. +pub const PLUGIN_API_VERSION: u32 = 1; + +/// The most plug-in effects one process can register. +pub const MAX_PLUGINS: usize = 64; + +/// A plug-in parameter's kind, default and range. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum PluginParamKind { + Slider { + default: f64, + #[serde(default)] + min: f64, + #[serde(default = "hundred")] + max: f64, + /// Slider range (defaults to min..max). + #[serde(default, rename = "sliderMin")] + slider_min: Option, + #[serde(default, rename = "sliderMax")] + slider_max: Option, + #[serde(default = "two")] + decimals: u8, + }, + Angle { + #[serde(default)] + default: f64, + }, + Checkbox { + #[serde(default)] + default: bool, + }, + Popup { + options: Vec, + /// 0-based index. + #[serde(default)] + default: u32, + }, + /// Default as fractions of the layer size (`[0.5, 0.5]` = centre). + Point { + #[serde(default = "centre")] + default: [f64; 2], + }, + /// RGBA, 0–1. + Color { + #[serde(default = "white")] + default: [f64; 4], + }, +} + +fn hundred() -> f64 { + 100.0 +} +fn two() -> u8 { + 2 +} +fn centre() -> [f64; 2] { + [0.5, 0.5] +} +fn white() -> [f64; 4] { + [1.0; 4] +} + +impl PluginParamKind { + /// How many `f64`s the parameter flattens to. + pub fn width(&self) -> usize { + match self { + PluginParamKind::Point { .. } => 2, + PluginParamKind::Color { .. } => 4, + _ => 1, + } + } +} + +/// One parameter of a plug-in effect. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PluginParam { + /// Stable id (the property's match name; letters, digits, `_`). + pub id: String, + pub name: String, + #[serde(flatten)] + pub kind: PluginParamKind, +} + +/// What a plug-in declares about itself. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PluginManifest { + /// The plug-in API version it was written for (must be [`PLUGIN_API_VERSION`]). + pub api: u32, + /// Stable, reverse-domain effect id (`org.example.posterize`); `ec.` is reserved. + pub id: String, + pub name: String, + /// Effects & Presets category: one of [`crate::CATEGORIES`] or a new one. + pub category: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub author: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub params: Vec, +} + +impl PluginManifest { + /// Check the manifest against this API version. + pub fn validate(&self) -> Result<(), String> { + if self.api != PLUGIN_API_VERSION { + return Err(format!("plug-in `{}` targets plug-in API {} but this build implements API {PLUGIN_API_VERSION}", self.id, self.api)); + } + let ok_id = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')); + if !ok_id(&self.id) || self.id.starts_with("ec.") { + return Err(format!("bad plug-in id `{}` (reverse-domain, letters, digits, `.`, `_`, `-`; `ec.` is reserved)", self.id)); + } + if self.name.trim().is_empty() || self.category.trim().is_empty() { + return Err(format!("plug-in `{}` needs a name and a category", self.id)); + } + let mut seen = std::collections::HashSet::new(); + for p in &self.params { + if p.id.is_empty() || !p.id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') || !seen.insert(p.id.as_str()) { + return Err(format!("plug-in `{}`: bad or duplicate parameter id `{}`", self.id, p.id)); + } + if let PluginParamKind::Popup { options, default } = &p.kind + && (options.is_empty() || *default as usize >= options.len()) + { + return Err(format!("plug-in `{}`: popup `{}` needs options and a default index in range", self.id, p.id)); + } + } + Ok(()) + } + + /// Total flattened parameter count. + pub fn flat_len(&self) -> usize { + self.params.iter().map(|p| p.kind.width()).sum() + } +} + +/// The pixels a plug-in renders, in place. +pub struct PluginFrame<'a> { + pub width: u32, + pub height: u32, + /// Premultiplied RGBA, row-major, `width * height` pixels. + pub pixels: &'a mut [[f32; 4]], + /// Buffer pixels per layer pixel (preview resolution). + pub scale: f64, +} + +/// Parameter values for one render, flattened (see the module docs) with typed accessors. +pub struct PluginParams<'a> { + pub manifest: &'a PluginManifest, + pub flat: &'a [f64], +} + +impl PluginParams<'_> { + fn slot(&self, id: &str) -> Option<&[f64]> { + let mut at = 0; + for p in &self.manifest.params { + let w = p.kind.width(); + if p.id == id { + return self.flat.get(at..at + w); + } + at += w; + } + None + } + /// Slider / angle / popup index / checkbox (0 or 1). + pub fn f(&self, id: &str) -> f64 { + self.slot(id).and_then(|s| s.first().copied()).unwrap_or(0.0) + } + pub fn b(&self, id: &str) -> bool { + self.f(id) != 0.0 + } + /// Point in frame pixels. + pub fn point(&self, id: &str) -> [f64; 2] { + self.slot(id).map(|s| [s[0], s[1]]).unwrap_or([0.0; 2]) + } + pub fn color(&self, id: &str) -> [f64; 4] { + self.slot(id).map(|s| [s[0], s[1], s[2], s[3]]).unwrap_or([1.0; 4]) + } +} + +/// An effect plug-in. +pub trait EffectPlugin: Send + Sync { + fn manifest(&self) -> &PluginManifest; + /// Process `frame` in place. `time` is layer time in seconds. An error leaves the frame as + /// it was (the error is logged once). + fn render(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String>; + /// Where the plug-in came from (a file path, `built-in`…), for listings. + fn source(&self) -> String { + "built-in".into() + } +} + +static SLOTS: [OnceLock>; MAX_PLUGINS] = [const { OnceLock::new() }; MAX_PLUGINS]; + +/// Registered plug-in effects, in registration order. +fn plugin_specs() -> &'static RwLock> { + static P: OnceLock>> = OnceLock::new(); + P.get_or_init(|| RwLock::new(vec![])) +} + +/// The registered plug-in effects. +pub fn plugins() -> Vec<&'static EffectSpec> { + plugin_specs().read().map(|v| v.clone()).unwrap_or_default() +} + +/// The plug-in behind a registered effect id. +pub fn plugin(id: &str) -> Option> { + SLOTS.iter().filter_map(|s| s.get()).find(|p| p.manifest().id == id).cloned() +} + +fn leak(s: &str) -> &'static str { + Box::leak(s.to_string().into_boxed_str()) +} + +fn param_spec(p: &PluginParam) -> ParamSpec { + let (default, ui) = match &p.kind { + PluginParamKind::Slider { default, min, max, slider_min, slider_max, decimals } => ( + Value::Scalar(*default), + ParamUi::Slider { min: *min, max: *max, slider_min: slider_min.unwrap_or(*min), slider_max: slider_max.unwrap_or(*max), decimals: *decimals }, + ), + PluginParamKind::Angle { default } => (Value::Scalar(*default), ParamUi::Angle), + PluginParamKind::Checkbox { default } => (Value::Bool(*default), ParamUi::Checkbox), + PluginParamKind::Popup { options, default } => (Value::Enum(*default), ParamUi::Popup { options: options.clone() }), + PluginParamKind::Point { default } => (Value::Vec2(*default), ParamUi::Point), + PluginParamKind::Color { default } => (Value::Color(*default), ParamUi::Color), + }; + ParamSpec { id: leak(&p.id), name: leak(&p.name), default, ui } +} + +/// Register a plug-in effect: it appears in Effects & Presets under its category and is +/// applied by id like a built-in. Fails for a bad manifest, a taken id or when +/// [`MAX_PLUGINS`] are registered. +pub fn register_plugin(plugin: Arc) -> Result<&'static EffectSpec, String> { + let m = plugin.manifest().clone(); + m.validate()?; + let mut specs = plugin_specs().write().map_err(|_| "plug-in registry poisoned".to_string())?; + if crate::registry().iter().any(|s| s.id == m.id) || specs.iter().any(|s| s.id == m.id) { + return Err(format!("an effect with id `{}` is already registered", m.id)); + } + let slot = SLOTS.iter().position(|s| s.get().is_none()).ok_or_else(|| format!("too many plug-ins (at most {MAX_PLUGINS})"))?; + SLOTS[slot].set(plugin).map_err(|_| "plug-in slot taken".to_string())?; + let spec: &'static EffectSpec = Box::leak(Box::new(EffectSpec { + id: leak(&m.id), + name: leak(&m.name), + category: leak(&m.category), + params: m.params.iter().map(param_spec).collect(), + render: TRAMPOLINES[slot], + gpu: false, + float: true, + })); + specs.push(spec); + Ok(spec) +} + +/// Each slot needs its own `fn` (effect render functions are plain function pointers). +fn trampoline(ctx: &EffectCtx, buf: Buf) -> Buf { + run_slot(I, ctx, buf) +} + +macro_rules! trampolines { + ($($i:literal)*) => { [$(trampoline::<$i> as RenderFn),*] }; +} + +const TRAMPOLINES: [RenderFn; MAX_PLUGINS] = trampolines!( + 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 + 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 +); + +/// Flatten an instance's evaluated parameters for the plug-in (points mapped to frame pixels). +pub fn flatten(m: &PluginManifest, ctx: &EffectCtx, buf: &Buf) -> Vec { + let mut out = Vec::with_capacity(m.flat_len()); + for p in &m.params { + let v = ctx.params.get(&p.id); + match &p.kind { + PluginParamKind::Point { .. } => { + let q = v.map(Value::as_vec2).unwrap_or([0.0; 2]); + let (x, y) = buf.to_px(q); + out.extend([x, y]); + } + PluginParamKind::Color { .. } => { + let c = v.map(Value::as_color).unwrap_or([1.0; 4]); + out.extend(c.iter().map(|x| *x as f64)); + } + PluginParamKind::Checkbox { .. } => out.push(if v.map(Value::as_bool).unwrap_or(false) { 1.0 } else { 0.0 }), + PluginParamKind::Popup { .. } => out.push(v.map(Value::as_enum).unwrap_or(0) as f64), + _ => out.push(v.map(Value::as_f64).unwrap_or(0.0)), + } + } + out +} + +fn run_slot(slot: usize, ctx: &EffectCtx, mut buf: Buf) -> Buf { + let Some(plugin) = SLOTS[slot].get() else { return buf }; + let m = plugin.manifest(); + let flat = flatten(m, ctx, &buf); + let params = PluginParams { manifest: m, flat: &flat }; + let (width, height) = (buf.img.width, buf.img.height); + let before = buf.img.data.clone(); + let mut frame = PluginFrame { width, height, pixels: &mut buf.img.data, scale: buf.scale }; + if let Err(e) = plugin.render(&mut frame, ¶ms, ctx.time) { + log::warn!("plug-in `{}`: {e}", m.id); + buf.img.data = before; + } + buf +} diff --git a/crates/engine/scripts/Create Null at Selected Layers.jsx b/crates/engine/scripts/Create Null at Selected Layers.jsx new file mode 100644 index 000000000..44442c059 --- /dev/null +++ b/crates/engine/scripts/Create Null at Selected Layers.jsx @@ -0,0 +1,41 @@ +// Create Null at Selected Layers — an EffectCraft sample script (original work, MIT OR +// Apache-2.0). +// +// Adds a null at the centroid of the selected layers' positions (at the current time) and +// parents the layers that have no parent to it (set PARENT below to false to skip that). +// One undo step. + +(function createNullAtCentroid() { + var PARENT = true; + var comp = app.project.activeItem; + if (!(comp instanceof CompItem)) { + alert("Create Null: open a composition first."); + return; + } + var layers = comp.selectedLayers; + if (layers.length === 0) { + alert("Create Null: select one or more layers."); + return; + } + var t = comp.time; + var sum = [0, 0, 0]; + for (var i = 0; i < layers.length; i++) { + var p = layers[i].transform.position.valueAtTime(t, false); + sum[0] += p[0]; + sum[1] += p[1]; + sum[2] += p.length > 2 ? p[2] : 0; + } + var c = [sum[0] / layers.length, sum[1] / layers.length, sum[2] / layers.length]; + + app.beginUndoGroup("Create Null at Selected Layers"); + var nul = comp.layers.addNull(); + nul.name = "Centroid"; + nul.transform.position.setValue(c); + if (PARENT) { + for (var k = 0; k < layers.length; k++) { + if (!layers[k].parent) layers[k].setParentWithJump(nul); + } + } + app.endUndoGroup(); + writeLn("Null at [" + c[0].toFixed(1) + ", " + c[1].toFixed(1) + "]"); +})(); diff --git a/crates/engine/scripts/Rename Layers.jsx b/crates/engine/scripts/Rename Layers.jsx new file mode 100644 index 000000000..6de9d4d45 --- /dev/null +++ b/crates/engine/scripts/Rename Layers.jsx @@ -0,0 +1,71 @@ +// Rename Layers — an EffectCraft sample script (original work, MIT OR Apache-2.0). +// +// Renames the selected layers of the active composition (all layers when none are selected): +// find & replace, then an optional prefix, suffix and running number. One undo step. + +(function renameLayers() { + var comp = app.project.activeItem; + if (!(comp instanceof CompItem)) { + alert("Rename Layers: open a composition first."); + return; + } + var layers = comp.selectedLayers; + if (layers.length === 0) { + layers = []; + for (var i = 1; i <= comp.numLayers; i++) layers.push(comp.layer(i)); + } + + var dlg = new Window("dialog", "Rename Layers"); + dlg.orientation = "column"; + dlg.alignChildren = ["fill", "top"]; + + var find = dlg.add("panel", undefined, "Find and Replace"); + find.alignChildren = ["fill", "top"]; + var row1 = find.add("group"); + row1.add("statictext", undefined, "Find:"); + var findText = row1.add("edittext", undefined, ""); + findText.characters = 20; + var row2 = find.add("group"); + row2.add("statictext", undefined, "Replace:"); + var replaceText = row2.add("edittext", undefined, ""); + replaceText.characters = 20; + + var extra = dlg.add("panel", undefined, "Add"); + extra.alignChildren = ["fill", "top"]; + var row3 = extra.add("group"); + row3.add("statictext", undefined, "Prefix:"); + var prefix = row3.add("edittext", undefined, ""); + prefix.characters = 12; + row3.add("statictext", undefined, "Suffix:"); + var suffix = row3.add("edittext", undefined, ""); + suffix.characters = 12; + var number = extra.add("checkbox", undefined, "Number the layers"); + var row4 = extra.add("group"); + row4.add("statictext", undefined, "Start at:"); + var start = row4.add("edittext", undefined, "1"); + start.characters = 5; + start.enabled = false; + number.onClick = function () { + start.enabled = number.value; + }; + + var buttons = dlg.add("group"); + buttons.alignment = "right"; + buttons.add("button", undefined, "Cancel", { name: "cancel" }); + buttons.add("button", undefined, "OK", { name: "ok" }); + + if (dlg.show() !== 1) return; + + app.beginUndoGroup("Rename Layers"); + var n = parseInt(start.text, 10); + if (isNaN(n)) n = 1; + for (var k = 0; k < layers.length; k++) { + var name = layers[k].name; + if (findText.text !== "") name = name.split(findText.text).join(replaceText.text); + name = prefix.text + name + suffix.text; + if (number.value) name += " " + (n + k); + layers[k].name = name; + } + app.endUndoGroup(); + writeLn("Renamed " + layers.length + " layer(s)."); +})(); diff --git a/crates/engine/scripts/Render Queue Batch.jsx b/crates/engine/scripts/Render Queue Batch.jsx new file mode 100644 index 000000000..1dc5ca4cf --- /dev/null +++ b/crates/engine/scripts/Render Queue Batch.jsx @@ -0,0 +1,52 @@ +// Render Queue Batch — an EffectCraft sample script (original work, MIT OR Apache-2.0). +// +// Adds the compositions selected in the Project panel (every composition when none are +// selected) to the Render Queue, each with an output file named after the comp in a folder you +// choose, and optionally starts rendering. + +(function renderQueueBatch() { + var comps = []; + var sel = app.project.selection; + for (var i = 0; i < sel.length; i++) if (sel[i] instanceof CompItem) comps.push(sel[i]); + if (comps.length === 0) { + for (var j = 1; j <= app.project.numItems; j++) { + var it = app.project.item(j); + if (it instanceof CompItem) comps.push(it); + } + } + if (comps.length === 0) { + alert("Render Queue Batch: the project has no compositions."); + return; + } + + var dlg = new Window("dialog", "Render Queue Batch"); + dlg.alignChildren = ["fill", "top"]; + dlg.add("statictext", undefined, comps.length + " composition(s) will be queued."); + var row = dlg.add("group"); + row.add("statictext", undefined, "Output folder:"); + var folder = row.add("edittext", undefined, Folder.desktop ? Folder.desktop.fsName : ""); + folder.characters = 30; + var fmtRow = dlg.add("group"); + fmtRow.add("statictext", undefined, "Format:"); + var format = fmtRow.add("dropdownlist", undefined, ["H.264 (.mp4)", "PNG Sequence"]); + format.selection = 0; + var go = dlg.add("checkbox", undefined, "Start rendering"); + var buttons = dlg.add("group"); + buttons.alignment = "right"; + buttons.add("button", undefined, "Cancel", { name: "cancel" }); + buttons.add("button", undefined, "Queue", { name: "ok" }); + if (dlg.show() !== 1) return; + + var png = format.selection && format.selection.index === 1; + app.beginUndoGroup("Render Queue Batch"); + for (var k = 0; k < comps.length; k++) { + var rq = app.project.renderQueue.items.add(comps[k]); + var name = comps[k].name.replace(/[\\\/:*?"<>|]/g, "_"); + var om = rq.outputModule(1); + if (png) om.format = "png"; + om.file = new File(folder.text + "/" + name + (png ? "_[#####].png" : ".mp4")); + } + app.endUndoGroup(); + writeLn("Queued " + comps.length + " composition(s)."); + if (go.value) app.project.renderQueue.render(); +})(); diff --git a/crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx b/crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx new file mode 100644 index 000000000..28991849c --- /dev/null +++ b/crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx @@ -0,0 +1,99 @@ +// Layer Tools — an EffectCraft sample ScriptUI panel (original work, MIT OR Apache-2.0). +// +// Installed in the ScriptUI Panels folder it docks like any panel (Window ▸ Layer Tools.jsx); +// run from File ▸ Scripts it opens as a floating palette. Buttons act on the selected layers of +// the active composition. + +(function layerTools(thisObj) { + var ui = thisObj instanceof Panel ? thisObj : new Window("palette", "Layer Tools", undefined, { resizeable: true }); + ui.orientation = "column"; + ui.alignChildren = ["fill", "top"]; + ui.spacing = 6; + + function comp() { + var c = app.project.activeItem; + if (!(c instanceof CompItem)) { + status.text = "Open a composition first."; + return null; + } + return c; + } + function selected(c) { + var l = c.selectedLayers; + if (l.length === 0) status.text = "Select some layers."; + return l; + } + + var labelRow = ui.add("group"); + labelRow.add("statictext", undefined, "Opacity:"); + var opacity = labelRow.add("slider", undefined, 100, 0, 100); + opacity.preferredSize.width = 120; + var opacityValue = labelRow.add("statictext", undefined, "100%"); + opacityValue.characters = 5; + opacity.onChanging = function () { + opacityValue.text = Math.round(opacity.value) + "%"; + }; + opacity.onChange = function () { + var c = comp(); + if (!c) return; + var layers = selected(c); + app.beginUndoGroup("Set Opacity"); + for (var i = 0; i < layers.length; i++) layers[i].transform.opacity.setValue(Math.round(opacity.value)); + app.endUndoGroup(); + status.text = "Opacity " + Math.round(opacity.value) + "% on " + layers.length + " layer(s)."; + }; + + var shy = ui.add("checkbox", undefined, "Shy"); + shy.onClick = function () { + var c = comp(); + if (!c) return; + var layers = selected(c); + app.beginUndoGroup("Shy Layers"); + for (var i = 0; i < layers.length; i++) layers[i].shy = shy.value; + app.endUndoGroup(); + }; + + var nameRow = ui.add("group"); + nameRow.alignChildren = ["fill", "center"]; + var newName = nameRow.add("edittext", undefined, "Layer"); + newName.characters = 14; + var renameBtn = nameRow.add("button", undefined, "Rename"); + renameBtn.onClick = function () { + var c = comp(); + if (!c) return; + var layers = selected(c); + app.beginUndoGroup("Rename Layers"); + for (var i = 0; i < layers.length; i++) layers[i].name = layers.length > 1 ? newName.text + " " + (i + 1) : newName.text; + app.endUndoGroup(); + status.text = "Renamed " + layers.length + " layer(s)."; + }; + + var nullBtn = ui.add("button", undefined, "Null at Centroid"); + nullBtn.onClick = function () { + var c = comp(); + if (!c) return; + var layers = selected(c); + if (layers.length === 0) return; + var x = 0, y = 0; + for (var i = 0; i < layers.length; i++) { + var p = layers[i].transform.position.value; + x += p[0]; + y += p[1]; + } + app.beginUndoGroup("Null at Centroid"); + var n = c.layers.addNull(); + n.transform.position.setValue([x / layers.length, y / layers.length]); + app.endUndoGroup(); + status.text = "Added " + n.name + "."; + }; + + var status = ui.add("statictext", undefined, "Select layers, then pick a tool."); + status.characters = 30; + + if (ui instanceof Window) { + ui.center(); + ui.show(); + } else { + ui.layout.layout(true); + } +})(this); diff --git a/crates/engine/scripts/Sort Layers by In Point.jsx b/crates/engine/scripts/Sort Layers by In Point.jsx new file mode 100644 index 000000000..c59473d71 --- /dev/null +++ b/crates/engine/scripts/Sort Layers by In Point.jsx @@ -0,0 +1,37 @@ +// Sort Layers by In Point — an EffectCraft sample script (original work, MIT OR Apache-2.0). +// +// Reorders the selected layers of the active composition (all layers when none are selected) +// so the layer that starts first is on top. Layers with the same in point keep their order. +// One undo step. + +(function sortLayersByInPoint() { + var comp = app.project.activeItem; + if (!(comp instanceof CompItem)) { + alert("Sort Layers by In Point: open a composition first."); + return; + } + var layers = comp.selectedLayers; + if (layers.length === 0) { + layers = []; + for (var i = 1; i <= comp.numLayers; i++) layers.push(comp.layer(i)); + } + if (layers.length < 2) return; + + // The slots the layers occupy now (stack indices, top first). + var slots = layers.map(function (l) { return l.index; }).sort(function (a, b) { return a - b; }); + var order = layers + .map(function (l, k) { return { layer: l, inPoint: l.inPoint, k: k }; }) + .sort(function (a, b) { return a.inPoint - b.inPoint || a.k - b.k; }); + + app.beginUndoGroup("Sort Layers by In Point"); + // Place each layer, earliest first, in the next slot from the top. + for (var j = 0; j < order.length; j++) { + var target = slots[j]; + var layer = order[j].layer; + if (layer.index === target) continue; + var anchor = comp.layer(target); + layer.moveBefore(anchor); + } + app.endUndoGroup(); + writeLn("Sorted " + layers.length + " layer(s) by in point."); +})(); diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 03cd80870..a76c2a273 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -584,8 +584,7 @@ fn edit_original(s: &mut Session, _: &Value) -> Result { } fn purge(s: &mut Session, _: &Value) -> Result { - s.history.undo.clear(); - s.history.redo.clear(); + s.history.clear(); s.toast("Purged undo history"); Ok(Value::Null) } @@ -630,9 +629,60 @@ pub fn specs() -> Vec { cmd!("edit.purge", "Purge", [], None, "{what?: all|memoryAndDisk|memory|disk|3d|image|snapshot}", always_ok, purge_caches), crate::query!("cache.diskStats", "Disk Cache Statistics", "{}", disk_stats), cmd!("edit.editOriginal", "Edit Original...", ["Edit"], Some("Cmd+E"), "{}", has_selected_footage, edit_original), + crate::query!( + "edit.history.list", + "History", + "{} → {states: [{index, id, label, parent, depth, current, line, future}], current, branches}", + history_list + ), + cmd!( + "edit.history.goto", + "Go to History State", + [], + None, + "{index? (from edit.history.list) | id? | steps? (negative = back along the line)}", + always_ok, + history_goto + ), ] } +// ---------------------------------------------------------------- History panel + +/// `edit.history.list`: every state of the (branching) undo history. +fn history_list(s: &mut Session, _: &Value) -> Result { + let nodes = s.history_tree(); + let current = nodes.iter().position(|n| n.current); + Ok(json!({"states": nodes, "current": current, "branches": s.history.branches.len()})) +} + +/// `edit.history.goto`: jump to a state by `index` (from `edit.history.list`), `id`, or +/// `steps` along the working line (negative = back, like Undo). +fn history_goto(s: &mut Session, p: &Value) -> Result { + let c = "edit.history.goto"; + if let Some(n) = p.get("steps").and_then(Value::as_i64) { + for _ in 0..n.unsigned_abs() { + let moved = if n < 0 { s.undo() } else { s.redo() }; + if !moved { + break; + } + } + } else { + let nodes = s.history_tree(); + let id = match (p.get("id").and_then(Value::as_str), p.get("index").and_then(Value::as_u64)) { + (Some(id), _) => id.to_string(), + (None, Some(i)) => nodes.get(i as usize).map(|n| n.id.clone()).ok_or_else(|| super::bad(c, format!("no state {i} (0..{})", nodes.len())))?, + _ => return Err(super::bad(c, "give `index`, `id` or `steps`")), + }; + if !s.goto_history(&id) { + return Err(super::bad(c, format!("no history state `{id}`"))); + } + } + let nodes = s.history_tree(); + let cur = nodes.iter().find(|n| n.current).cloned(); + Ok(json!({"current": cur.as_ref().map(|n| n.index), "label": cur.map(|n| n.label)})) +} + fn layers_or_keys(s: &Session) -> std::result::Result<(), String> { if !s.state.selected_keys.is_empty() || !s.state.selected_vertices.is_empty() { super::has_comp(s) } else { has_layers(s) } } diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index b0fb8c314..6ce73ac30 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -7,6 +7,68 @@ use serde_json::{Value, json}; use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layers_p, str_p}; use crate::{EngineError, Result, Session, cmd}; +/// `effect.plugins.list`: the registered plug-in effects (WebAssembly or Rust). +fn plugins_list(s: &mut Session, _: &Value) -> Result { + use effectcraft_effects::plugin; + let list: Vec = plugin::plugins() + .into_iter() + .map(|spec| { + let p = plugin::plugin(spec.id); + let m = p.as_ref().map(|p| p.manifest().clone()); + json!({ + "id": spec.id, + "name": spec.name, + "category": spec.category, + "version": m.as_ref().map(|m| m.version.clone()), + "author": m.as_ref().map(|m| m.author.clone()), + "description": m.as_ref().map(|m| m.description.clone()), + "source": p.map(|p| p.source()), + "params": spec.params.iter().map(|p| p.id).collect::>(), + }) + }) + .collect(); + Ok(json!({"api": plugin::PLUGIN_API_VERSION, "wasm": s.plugin_loader.is_some(), "plugins": list})) +} + +/// `effect.plugins.load`: load WebAssembly effect plug-ins (a file, or every `.wasm` in a +/// folder) through the host's loader ([`Session::plugin_loader`]). +fn plugins_load(s: &mut Session, p: &Value) -> Result { + let c = "effect.plugins.load"; + let loader = s.plugin_loader.ok_or_else(|| EngineError::Other("WebAssembly effect plug-ins are not available in this build".into()))?; + let files: Vec = match (str_p(p, "path"), str_p(p, "folder")) { + (Some(path), _) => vec![path.to_string()], + (None, Some(dir)) => { + let mut v: Vec = std::fs::read_dir(dir) + .map_err(|e| bad(c, format!("cannot list {dir}: {e}")))? + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))) + .map(|p| p.to_string_lossy().to_string()) + .collect(); + v.sort(); + v + } + _ => return Err(bad(c, "give `path` or `folder`")), + }; + let (mut loaded, mut errors) = (vec![], vec![]); + for f in &files { + let r = s.services.read_file(f).map_err(|e| format!("cannot read {f}: {e}")).and_then(|bytes| loader(&bytes, f)); + match r { + Ok(v) => loaded.push(v), + Err(e) => errors.push(json!({"path": f, "error": e})), + } + } + if str_p(p, "path").is_some() + && let Some(e) = errors.first() + { + return Err(EngineError::Other(format!("{}: {}", files[0], e["error"].as_str().unwrap_or("failed")))); + } + if !loaded.is_empty() { + s.toast(format!("Loaded {} effect plug-in(s)", loaded.len())); + } + Ok(json!({"loaded": loaded, "errors": errors})) +} + fn apply(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "effect").ok_or_else(|| bad("effect.apply", "missing `effect` (id or name)"))?; let spec = effectcraft_effects::lookup(name).ok_or_else(|| bad("effect.apply", format!("unknown effect `{name}`")))?; @@ -284,8 +346,8 @@ fn last(s: &mut Session, p: &Value) -> Result { fn list(_: &mut Session, p: &Value) -> Result { let filter = str_p(p, "filter").map(str::to_ascii_lowercase); - let v: Vec = effectcraft_effects::registry() - .iter() + let v: Vec = effectcraft_effects::all() + .into_iter() .filter(|e| filter.as_ref().is_none_or(|f| e.name.to_ascii_lowercase().contains(f) || e.id.contains(f.as_str()))) .map(|e| json!({"id": e.id, "name": e.name, "category": e.category, "params": e.params.iter().map(|p| json!({"id": p.id, "name": p.name, "default": p.default.to_json()})).collect::>()})) .collect(); @@ -305,5 +367,20 @@ pub fn specs() -> Vec { cmd!("effect.paste", "Paste Effects", [], None, "{layers?} — adds the copied effects to the layers", has_layers, paste), cmd!("effect.reset", "Reset Effect", [], None, "{layer?, effect}", has_layers, reset), crate::query!("effect.list", "List Effects", "{filter?}", list), + crate::query!( + "effect.plugins.list", + "List Effect Plug-ins", + "{} → {api, wasm, plugins: [{id, name, category, version, author, source, params}]}", + plugins_list + ), + cmd!( + "effect.plugins.load", + "Load Effect Plug-in...", + [], + None, + "{path (.wasm / .wat plug-in, API v1) | folder (loads every .wasm in it)}", + super::always, + plugins_load + ), ] } diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 6908bcd56..9175af599 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -364,6 +364,14 @@ fn collect_files(s: &mut Session, p: &Value) -> Result { /// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's /// `{"method": "engine.execute", "params": {"id", "params"}}`). fn run_script(s: &mut Session, p: &Value) -> Result { + // File ▸ Scripts ▸ . + if let Some(name) = str_p(p, "name") + && p.get("path").is_none() + && p.get("steps").is_none() + { + let name = name.to_string(); + return super::scripts::run_named(s, &name); + } if let Some(path) = str_p(p, "path") && p.get("steps").is_none() && is_js_script(path) @@ -375,13 +383,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result { let code = String::from_utf8_lossy(&bytes).to_string(); let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let out = run_js(s, &code, path, false)?; - if let Some(e) = out.get("error").filter(|e| !e.is_null()) { - let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default(); - let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error"); - s.events.push(crate::Event::Toast { message: format!("{name}{line}: {msg}"), error: true }); - return Err(EngineError::Other(format!("{name}{line}: {msg}"))); - } - return Ok(out); + return report_js(s, &name, out); } let steps: Vec = match (p.get("steps"), str_p(p, "path")) { (Some(Value::Array(a)), _) => a.clone(), @@ -426,6 +428,23 @@ fn is_js_script(path: &str) -> bool { l.ends_with(".jsx") || l.ends_with(".js") || l.ends_with(".jsxbin") } +/// A script error becomes a toast and a command error. +fn report_js(s: &mut Session, name: &str, out: Value) -> Result { + if let Some(e) = out.get("error").filter(|e| !e.is_null()) { + let line = e.get("line").and_then(Value::as_u64).map(|l| format!(" (line {l})")).unwrap_or_default(); + let msg = e.get("message").and_then(Value::as_str).unwrap_or("script error"); + s.events.push(crate::Event::Toast { message: format!("{name}{line}: {msg}"), error: true }); + return Err(EngineError::Other(format!("{name}{line}: {msg}"))); + } + Ok(out) +} + +/// Run a script's code under its file name (installed / sample scripts, ScriptUI panels). +pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result { + let out = run_js(s, code, name, false)?; + report_js(s, name, out) +} + /// Run JavaScript through the session's scripting engine. fn run_js(s: &mut Session, code: &str, name: &str, console: bool) -> Result { let runner = s.script.ok_or_else(|| EngineError::Other("JavaScript scripting is not available in this build".into()))?; @@ -809,7 +828,7 @@ pub fn specs() -> Vec { "Run Script File...", ["File", "Scripts"], None, - "{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | steps: [{command, params}]}", + "{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | steps: [{command, params}] | name (an installed or sample script, see file.scripts.list)}", always, run_script ), diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 4112cf6cc..16afc125d 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -40,6 +40,7 @@ mod query; mod render_queue; pub(crate) mod rig3d; pub mod roto_cmds; +pub mod scripts; mod settings; mod shape_stroke; mod stubs; @@ -158,6 +159,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(essential::specs()); v.extend(expr_tools::specs()); v.extend(proxy::specs()); + v.extend(scripts::specs()); v.extend(stubs::specs()); v }) diff --git a/crates/engine/src/commands/puppet.rs b/crates/engine/src/commands/puppet.rs index 4ada1dcd0..890e1091e 100644 --- a/crates/engine/src/commands/puppet.rs +++ b/crates/engine/src/commands/puppet.rs @@ -7,7 +7,7 @@ use effectcraft_effects::puppet::{self, MeshOpts, PinKind}; use effectcraft_effects::{Buf, Params}; -use effectcraft_keyframe::Value as KV; +use effectcraft_keyframe::{Keyframe, Value as KV}; use effectcraft_project::build::Ids; use effectcraft_project::{ItemId, LayerId, Node, PropGroup, Uid}; use effectcraft_render::{EvalCtx, RenderOpts, Renderer}; @@ -309,6 +309,122 @@ fn mesh_opts(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Parse recorded samples `[[t, x, y]…]` (t = seconds since the drag began, layer space). +fn samples(p: &Value, cmd: &str) -> Result> { + let arr = p + .get("samples") + .and_then(Value::as_array) + .ok_or_else(|| bad(cmd, "missing `samples`: [[t, x, y]…] (t = seconds since the drag began, layer space)"))?; + let mut rec = Vec::with_capacity(arr.len()); + for (i, v) in arr.iter().enumerate() { + let a: Vec = v.as_array().map(|a| a.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + if a.len() < 3 || a.iter().any(|x| !x.is_finite()) { + return Err(bad(cmd, format!("sample {i}: expected [t, x, y]"))); + } + rec.push((a[0], [a[1], a[2]])); + } + if rec.is_empty() { + return Err(bad(cmd, "need at least one sample")); + } + rec.sort_by(|a, b| a.0.total_cmp(&b.0)); + Ok(rec) +} + +/// Puppet tool Record mode (⌘/Ctrl-drag a pin): the pin's drag, sampled in real time, becomes +/// Position keyframes at the comp frame rate from the current time on, replacing the keys in +/// the recorded span. Record Options: `speed` (%: 100 plays back at the speed of the drag, +/// 200 twice as slow) and `smoothing` (removes extraneous keys like the Smoother; 0 = a key +/// on every frame). The current time stays where recording began. +fn record_pin(s: &mut Session, p: &Value) -> Result { + let cmd = "puppet.recordPin"; + let (cid, lid) = layer_p(s, p, cmd)?; + let key = p.get("pin").cloned().ok_or_else(|| bad(cmd, "missing `pin` (uid or name)"))?; + let uid = pin_uid(s, cid, lid, &key, cmd)?; + let rec = samples(p, cmd)?; + let opts = &s.state.puppet; + let speed = f_p(p, "speed").unwrap_or(opts.record_speed).clamp(1.0, 10_000.0) / 100.0; + // Smoothing 10 (the default) keeps the path within 1 layer pixel of the drag. + let smoothing = f_p(p, "smoothing").unwrap_or(opts.record_smoothing).max(0.0) / 10.0; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let fr = comp.frame_rate; + let fd = fr.frame_duration().seconds(); + let start = match p.get("start").and_then(Value::as_f64) { + Some(t) => fr.snap_nearest(Tick::from_seconds_f64(t)), + None => s.time_of(cid), + }; + let f0 = fr.frame_at(start); + let end_frame = fr.frame_at(comp.duration - comp.frame_duration()); + let t_rec0 = rec[0].0; + let span = (rec[rec.len() - 1].0 - t_rec0) * speed; + let nframes = (span / fd).round().max(0.0) as i64; + let at = |secs: f64| { + let i = rec.partition_point(|r| r.0 <= secs).clamp(1, rec.len().max(2) - 1); + if rec.len() == 1 { + return rec[0].1; + } + let (a, b) = (rec[i - 1], rec[i]); + let f = if b.0 > a.0 { ((secs - a.0) / (b.0 - a.0)).clamp(0.0, 1.0) } else { 1.0 }; + [a.1[0] + (b.1[0] - a.1[0]) * f, a.1[1] + (b.1[1] - a.1[1]) * f] + }; + let n = s.edit("Record Puppet Pin", None, |proj, st| { + let l = layer_mut(proj, cid, lid)?; + let lc = l.clone(); + let g = l.props.find_group_mut(uid).ok_or_else(|| bad(cmd, "pin gone"))?; + let kind = PinKind::from_index(g.get("kind").map(|k| k.value.as_enum()).unwrap_or(0)); + if !kind.moves() { + return Err(bad(cmd, "only Position and Advanced pins record motion")); + } + let pos = g.get_mut("position").ok_or_else(|| bad(cmd, "the pin has no Position"))?; + let mut new: Vec = vec![]; + for k in 0..=nframes { + let f = f0 + k; + if f > end_frame { + break; + } + let q = at(t_rec0 + k as f64 * fd / speed); + new.push(Keyframe::new(lc.layer_time(fr.tick_of(f)), KV::Vec2(q))); + } + let (Some(t0), Some(t1)) = (new.first().map(|k| k.time), new.last().map(|k| k.time)) else { return Ok(0) }; + let mut keys: Vec = pos.keys.iter().filter(|k| k.time < t0 || k.time > t1).cloned().collect(); + keys.extend(new); + keys.sort_by_key(|k| k.time); + let (a, b) = (keys.iter().position(|k| k.time == t0).unwrap_or(0), keys.iter().position(|k| k.time == t1).unwrap_or(0)); + if smoothing > 0.0 && b > a + 1 { + let samples: Vec<(Tick, Vec)> = keys[a..=b].iter().map(|k| (k.time, k.value.components())).collect(); + keys = super::anim_tools::smooth_keys(&keys, a, b, &samples, smoothing, true); + } + pos.keys = keys; + pos.value = KV::Vec2(rec[0].1); + let puid = pos.uid; + st.selected_props = vec![(lid, uid)]; + st.selected_keys = pos.keys.iter().filter(|k| k.time >= t0 && k.time <= t1).map(|k| crate::KeyRef { layer: lid, prop: puid, time: k.time }).collect(); + Ok(st.selected_keys.len()) + })?; + // The current time returns to where recording began. + s.state.times.insert(cid, start); + let end = fr.tick_of((f0 + nframes).min(end_frame)); + Ok(json!({"keys": n, "start": start.seconds(), "end": end.seconds(), "frames": nframes + 1})) +} + +/// Record Options (the Puppet tool's Record Options… dialog): `speed`, `smoothing`, +/// `useDraftDeformation`, `showMesh`. Returns the options. +fn record_options(s: &mut Session, p: &Value) -> Result { + let o = &mut s.state.puppet; + if let Some(v) = f_p(p, "speed") { + o.record_speed = v.clamp(1.0, 10_000.0); + } + if let Some(v) = f_p(p, "smoothing") { + o.record_smoothing = v.clamp(0.0, 100.0); + } + if let Some(v) = p.get("useDraftDeformation").and_then(Value::as_bool) { + o.record_draft = v; + } + if let Some(v) = p.get("showMesh").and_then(Value::as_bool) { + o.record_show_mesh = v; + } + Ok(json!({"speed": o.record_speed, "smoothing": o.record_smoothing, "useDraftDeformation": o.record_draft, "showMesh": o.record_show_mesh})) +} + /// Meshes, pins and the deformed mesh at the current time (layer space). fn info(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "puppet.info")?; @@ -364,6 +480,24 @@ pub fn specs() -> Vec { mesh_opts ), crate::query!("puppet.info", "Puppet Mesh Info", "{layer?, time? | frame?}", info), + cmd!( + "puppet.recordPin", + "Record Puppet Pin", + [], + None, + "{layer?, pin: uid | name, samples: [[t, x, y]…] (t = seconds since the drag began, layer space), start? (s, default current time), speed? (%, Record Options), smoothing? (Record Options)}", + has_comp, + record_pin + ), + cmd!( + "puppet.recordOptions", + "Record Options...", + [], + None, + "{speed? (%, 100), smoothing? (0-100), useDraftDeformation?, showMesh?}", + super::always, + record_options + ), ] } @@ -376,11 +510,30 @@ pub struct PuppetOptions { pub triangles: f64, /// Show the mesh in the viewer. pub show_mesh: bool, + /// Record Options ▸ Speed (%): 100 plays a recording back at the speed of the drag. + pub record_speed: f64, + /// Record Options ▸ Smoothing: removes extraneous keys from recorded motion (0 = a key per + /// frame). + pub record_smoothing: f64, + /// Record Options ▸ Use Draft Deformation: the viewer shows the draft (unexpanded) mesh + /// while recording. + pub record_draft: bool, + /// Record Options ▸ Show Mesh while recording. + pub record_show_mesh: bool, } impl Default for PuppetOptions { fn default() -> Self { let d = MeshOpts::default(); - PuppetOptions { density: d.density, expansion: d.expansion, triangles: d.triangles, show_mesh: false } + PuppetOptions { + density: d.density, + expansion: d.expansion, + triangles: d.triangles, + show_mesh: false, + record_speed: 100.0, + record_smoothing: 10.0, + record_draft: false, + record_show_mesh: false, + } } } diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs new file mode 100644 index 000000000..628533afe --- /dev/null +++ b/crates/engine/src/commands/scripts.rs @@ -0,0 +1,222 @@ +//! File ▸ Scripts and ScriptUI. +//! +//! * **Installed scripts** live in the settings store under `Scripts/` (File ▸ Scripts ▸ Install +//! Script File…) and `Scripts/ScriptUI Panels/` (Install ScriptUI Panel…), next to the +//! settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts +//! lists them with the bundled sample scripts ([`SAMPLES`]) and runs them by name; ScriptUI +//! panels appear at the bottom of the Window menu and open as dockable panels +//! (`window.scriptPanel`), running with `this` = the panel as in After Effects. +//! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control +//! trees, click buttons and set values like a user, close them (see [`crate::scriptui`]). + +use serde::Serialize; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, str_p}; +use crate::{EngineError, Event, Result, Session, cmd, scriptui}; + +/// The settings-store folders of installed scripts. +pub const SCRIPTS_DIR: &str = "Scripts"; +pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels"; + +/// Sample scripts that ship with EffectCraft (original work): (file name, ScriptUI panel, code). +pub const SAMPLES: &[(&str, bool, &str)] = &[ + ("Create Null at Selected Layers.jsx", false, include_str!("../../scripts/Create Null at Selected Layers.jsx")), + ("Rename Layers.jsx", false, include_str!("../../scripts/Rename Layers.jsx")), + ("Render Queue Batch.jsx", false, include_str!("../../scripts/Render Queue Batch.jsx")), + ("Sort Layers by In Point.jsx", false, include_str!("../../scripts/Sort Layers by In Point.jsx")), + ("Layer Tools.jsx", true, include_str!("../../scripts/ScriptUI Panels/Layer Tools.jsx")), +]; + +/// A script File ▸ Scripts (or the Window menu, for panels) offers. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct ScriptEntry { + /// File name (`Rename Layers.jsx`): what the menus show and commands take. + pub name: String, + /// In the ScriptUI Panels folder: opens as a dockable panel. + pub panel: bool, + /// `installed` or `sample`. + pub source: &'static str, +} + +fn is_script(name: &str) -> bool { + let l = name.to_ascii_lowercase(); + l.ends_with(".jsx") || l.ends_with(".js") +} + +/// Installed scripts and panels, then the samples not shadowed by an installed one, by name. +pub fn scripts(s: &Session) -> Vec { + let mut v: Vec = vec![]; + if let Some(cfg) = &s.config { + for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] { + for n in cfg.list(dir).into_iter().filter(|n| is_script(n)) { + v.push(ScriptEntry { name: n, panel, source: "installed" }); + } + } + } + for (name, panel, _) in SAMPLES { + if !v.iter().any(|e| e.name == *name && e.panel == *panel) { + v.push(ScriptEntry { name: name.to_string(), panel: *panel, source: "sample" }); + } + } + v.sort_by(|a, b| a.panel.cmp(&b.panel).then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))); + v +} + +/// The code of an installed or sample script / panel. +pub fn script_code(s: &Session, name: &str, panel: Option) -> Option<(bool, String)> { + if let Some(cfg) = &s.config { + for (dir, is_panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] { + if panel.is_some_and(|p| p != is_panel) { + continue; + } + if let Some(code) = cfg.read(&format!("{dir}/{name}")) { + return Some((is_panel, code)); + } + } + } + SAMPLES.iter().find(|(n, p, _)| *n == name && panel.is_none_or(|w| w == *p)).map(|(_, p, c)| (*p, c.to_string())) +} + +fn install(s: &mut Session, p: &Value, panel: bool) -> Result { + let c = if panel { "file.installScriptUIPanel" } else { "file.installScript" }; + let path = str_p(p, "path").ok_or_else(|| bad(c, "missing `path` (a .jsx / .js script)"))?; + let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + if !is_script(&name) { + return Err(bad(c, "scripts are .jsx or .js files (.jsxbin is not supported)")); + } + let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; + let code = String::from_utf8(bytes).map_err(|_| bad(c, "the script is not UTF-8 text"))?; + let cfg = s.config.clone().ok_or_else(|| EngineError::Other("this session has no settings folder to install scripts into".into()))?; + let dir = if panel { PANELS_DIR } else { SCRIPTS_DIR }; + cfg.write(&format!("{dir}/{name}"), &code).map_err(|e| EngineError::Other(format!("cannot install {name}: {e}")))?; + // After Effects asks for a restart; our menus pick it up at once. + s.toast(if panel { + format!("Installed ScriptUI panel {name}: find it at the bottom of the Window menu") + } else { + format!("Installed script {name}: find it in File ▸ Scripts") + }); + Ok(json!({"name": name, "panel": panel})) +} + +fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!(scripts(s))) +} + +fn uninstall(s: &mut Session, p: &Value) -> Result { + let c = "file.uninstallScript"; + let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?; + let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?; + let mut removed = false; + for dir in [SCRIPTS_DIR, PANELS_DIR] { + let key = format!("{dir}/{name}"); + if cfg.read(&key).is_some() { + cfg.remove(&key).map_err(|e| EngineError::Other(e.to_string()))?; + removed = true; + } + } + if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) } +} + +/// Run an installed or sample script by name (File ▸ Scripts ▸ ). A ScriptUI panel run +/// this way gets `this` = a floating palette-like panel window. +pub(crate) fn run_named(s: &mut Session, name: &str) -> Result { + let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or sample script `{name}`")))?; + if panel { + return open_panel(s, &json!({"name": name})); + } + super::file_more::run_js_named(s, &code, name) +} + +/// The code a ScriptUI panel runs with: `this` is a dockable Panel titled after the script. +pub fn panel_wrapper(title: &str, code: &str) -> String { + // On one line, so line numbers in errors stay those of the file. + format!("(function () {{ {code}\n}}).call(__uiDockPanel({}));", Value::String(title.to_string())) +} + +/// Window ▸ : run the panel script (or bring its panel forward). +fn open_panel(s: &mut Session, p: &Value) -> Result { + let c = "window.scriptPanel"; + let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string(); + let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string(); + if let Some(w) = s.script_ui.windows.iter().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name) { + let id = w.id; + s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) }); + return Ok(json!({"window": id, "open": true})); + } + let (_, code) = script_code(s, &name, Some(true)).or_else(|| script_code(s, &name, None)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?; + let out = super::file_more::run_js_named(s, &panel_wrapper(&title, &code), &name)?; + let id = s.script_ui.windows.iter().rev().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name).map(|w| w.id); + if let Some(id) = id { + s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) }); + } + Ok(json!({"window": id, "result": out})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "file.installScript", + "Install Script File...", + ["File", "Scripts"], + None, + "{path (.jsx / .js)} → copies it to the Scripts folder; it appears in File ▸ Scripts", + always, + |s, p| install(s, p, false) + ), + cmd!( + "file.installScriptUIPanel", + "Install ScriptUI Panel...", + ["File", "Scripts"], + None, + "{path (.jsx / .js)} → copies it to the ScriptUI Panels folder; it appears in the Window menu", + always, + |s, p| install(s, p, true) + ), + cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall), + crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample}]", list), + cmd!( + "window.scriptPanel", + "ScriptUI Panel", + [], + None, + "{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`)} → opens it as a dockable panel", + always, + open_panel + ), + crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list), + crate::query!( + "scriptui.get", + "Script Window Controls", + "{window?: id | title} → {id, title, kind, root: {id, type, name, text, value, checked, items, selection, bounds, enabled, children…}}", + scriptui::get + ), + cmd!( + "scriptui.click", + "Click Script Window Control", + [], + None, + "{window?: id | title, widget: id | \"#id\" | properties.name | text} (buttons, checkboxes, radio buttons, tabs)", + always, + scriptui::click + ), + cmd!( + "scriptui.set", + "Set Script Window Control", + [], + None, + "{window?, widget, value: text | number | bool | item index | item text} (edit text, sliders, checkboxes, lists) → fires onChanging / onChange", + always, + scriptui::set + ), + cmd!( + "scriptui.close", + "Close Script Window", + [], + None, + "{window?, result? (a dialog's show() returns it; default 2 = Cancel)}", + always, + scriptui::close + ), + ] +} diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs index ae7cfe09f..0608dc6cf 100644 --- a/crates/engine/src/config.rs +++ b/crates/engine/src/config.rs @@ -16,6 +16,12 @@ pub trait ConfigStore: Send + Sync { fn read(&self, name: &str) -> Option; fn write(&self, name: &str, data: &str) -> std::io::Result<()>; fn remove(&self, name: &str) -> std::io::Result<()>; + /// Names of the entries directly in `dir` (a `/`-separated name prefix such as `Scripts`), + /// sorted. Stores that can't list return nothing. + fn list(&self, dir: &str) -> Vec { + let _ = dir; + vec![] + } /// The folder next to the configuration (default Auto-Save folder for untitled projects), /// in the namespace of [`ConfigStore::files`]; `None` when there is none. fn dir(&self) -> Option { @@ -86,6 +92,11 @@ impl ConfigStore for MemoryConfig { } Ok(()) } + fn list(&self, dir: &str) -> Vec { + let pre = format!("{}/", dir.trim_end_matches('/')); + let Ok(m) = self.files.lock() else { return vec![] }; + m.keys().filter_map(|k| k.strip_prefix(&pre)).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string).collect() + } } /// Files in one directory, written atomically. @@ -104,8 +115,15 @@ impl ConfigStore for DirConfig { std::fs::read_to_string(self.dir.join(name)).ok() } fn write(&self, name: &str, data: &str) -> std::io::Result<()> { - std::fs::create_dir_all(&self.dir)?; - atomic_write(&self.dir.join(name), data.as_bytes()) + let path = self.dir.join(name); + std::fs::create_dir_all(path.parent().unwrap_or(&self.dir))?; + atomic_write(&path, data.as_bytes()) + } + fn list(&self, dir: &str) -> Vec { + let Ok(rd) = std::fs::read_dir(self.dir.join(dir)) else { return vec![] }; + let mut v: Vec = rd.flatten().filter(|e| e.path().is_file()).map(|e| e.file_name().to_string_lossy().to_string()).collect(); + v.sort(); + v } fn remove(&self, name: &str) -> std::io::Result<()> { match std::fs::remove_file(self.dir.join(name)) { diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs new file mode 100644 index 000000000..366309693 --- /dev/null +++ b/crates/engine/src/history.rs @@ -0,0 +1,346 @@ +//! Branching undo history (the History panel; better than After Effects, whose undo is a +//! straight line). +//! +//! [`History`] keeps After Effects' linear model as its working line — `undo` (the states +//! before each step, oldest first) and `redo` (undone states, next first) — and adds +//! **branches**: when you undo and then do something new, the undone states are not thrown +//! away but kept as a [`Branch`] hanging off the state they grew from. [`History::tree`] lists +//! every state as a tree and [`Session::goto_history`] jumps to any of them, on any branch: the +//! path to the target becomes the working line, and the line you left becomes a branch. +//! +//! States are whole-project snapshots (`Arc`), identified by their address while the +//! history holds them ([`HistoryNode::id`]), so ids stay valid across undo, redo and jumps. + +use std::collections::HashMap; +use std::sync::Arc; + +use effectcraft_project::Project; +use serde::Serialize; + +use crate::Session; + +/// Label of the oldest state in the history (the project as opened, or the oldest state kept). +pub const ROOT_LABEL: &str = "Original"; + +/// Undo history of whole-project snapshots. +#[derive(Clone, Default)] +pub struct History { + /// The states before each step on the working line, oldest first: (step label, state). + pub undo: Vec<(String, Arc)>, + /// Undone steps, the next one last: (step label, the state it leads to). + pub redo: Vec<(String, Arc)>, + /// Key of the last merged step: a continuous gesture with the same key folds into one step. + pub merge_key: Option, + /// Alternative futures kept when a new step was made after undoing. + pub branches: Vec, +} + +/// A line of states that grows from another state of the history. +#[derive(Clone)] +pub struct Branch { + /// The state the branch grows from. + pub parent: Arc, + /// The branch's states in order: (label of the step that made it, state). + pub steps: Vec<(String, Arc)>, +} + +/// One state of the history tree (`edit.history.list`). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HistoryNode { + /// Position in the listing (depth-first, the working line's continuation first). + pub index: usize, + /// Stable id of the state while the history keeps it. + pub id: String, + /// The step that made this state ([`ROOT_LABEL`] for the oldest). + pub label: String, + /// Index of the state it was made from. + pub parent: Option, + /// Branch nesting (0 = the working line). + pub depth: usize, + /// The current state. + pub current: bool, + /// On the working line (Undo / Redo walk it). + pub line: bool, + /// On the working line after the current state (Redo reaches it). + pub future: bool, +} + +fn key(p: &Arc) -> usize { + Arc::as_ptr(p) as usize +} + +/// Id of a state (its address while the history holds it). +pub fn state_id(p: &Arc) -> String { + format!("s{:x}", key(p)) +} + +/// The whole tree as an arena (internal). +struct Arena { + label: Vec, + state: Vec>, + parent: Vec>, + children: Vec>, + /// Index of each working-line state, oldest first, and the current state's position in it. + line: Vec, + current: usize, +} + +impl Arena { + fn build(h: &History, current: &Arc) -> Arena { + let mut a = Arena { label: vec![], state: vec![], parent: vec![], children: vec![], line: vec![], current: 0 }; + let add = |a: &mut Arena, label: String, st: Arc, parent: Option| { + let i = a.state.len(); + a.label.push(label); + a.state.push(st); + a.parent.push(parent); + a.children.push(vec![]); + if let Some(p) = parent { + a.children[p].push(i); + } + i + }; + // The working line: undo states, the current state, then the redo states. + let mut prev: Option = None; + let mut next_label = ROOT_LABEL.to_string(); + for (label, st) in &h.undo { + let i = add(&mut a, std::mem::take(&mut next_label), st.clone(), prev); + a.line.push(i); + prev = Some(i); + next_label = label.clone(); + } + let cur = add(&mut a, next_label, current.clone(), prev); + a.line.push(cur); + a.current = cur; + prev = Some(cur); + for (label, st) in h.redo.iter().rev() { + let i = add(&mut a, label.clone(), st.clone(), prev); + a.line.push(i); + prev = Some(i); + } + // Branches, attached to whichever state they grow from (branches of branches need the + // parent branch first, hence the passes). Orphans (their state was dropped) are skipped. + let mut index: HashMap = a.state.iter().enumerate().map(|(i, s)| (key(s), i)).collect(); + let mut pending: Vec<&Branch> = h.branches.iter().collect(); + loop { + let before = pending.len(); + pending.retain(|b| { + let Some(&p) = index.get(&key(&b.parent)) else { return true }; + let mut prev = p; + for (label, st) in &b.steps { + if index.contains_key(&key(st)) { + // Already in the tree (shared state): continue from it. + prev = index[&key(st)]; + continue; + } + let i = add(&mut a, label.clone(), st.clone(), Some(prev)); + index.insert(key(st), i); + prev = i; + } + false + }); + if pending.is_empty() || pending.len() == before { + break; + } + } + a + } + + fn on_line(&self) -> Vec { + let mut v = vec![false; self.state.len()]; + for &i in &self.line { + v[i] = true; + } + v + } + + /// Depth-first listing: the working line's child first, then branches in creation order. + fn listing(&self) -> Vec { + let on_line = self.on_line(); + let pos_in_line: HashMap = self.line.iter().enumerate().map(|(k, &i)| (i, k)).collect(); + let cur_pos = pos_in_line[&self.current]; + let mut out: Vec = vec![]; + let mut order: HashMap = HashMap::new(); + // (node, depth) + let mut stack: Vec<(usize, usize)> = vec![(self.line[0], 0)]; + while let Some((n, depth)) = stack.pop() { + order.insert(n, out.len()); + let line = on_line[n]; + out.push(HistoryNode { + index: out.len(), + id: state_id(&self.state[n]), + label: self.label[n].clone(), + parent: self.parent[n].and_then(|p| order.get(&p).copied()), + depth, + current: n == self.current, + line, + future: line && pos_in_line[&n] > cur_pos, + }); + // Push in reverse so the first child pops first; the line child continues at the + // same depth, branch children one deeper; a branch's own continuation (its first + // child) stays at the branch's depth. + for (k, &c) in self.children[n].iter().enumerate().rev() { + let cont = if line { on_line[c] } else { k == 0 }; + stack.push((c, if cont { depth } else { depth + 1 })); + } + } + out + } +} + +impl History { + /// Record a new step made from `before`: the redo line (if any) is kept as a branch. + pub fn record(&mut self, label: &str, before: Arc, levels: usize) { + self.abandon_redo(&before); + self.undo.push((label.to_string(), before.clone())); + self.merge_key = None; + self.trim(levels, &before); + } + + /// Keep the undone states as a branch growing from `from` (the state a new step starts from). + pub fn abandon_redo(&mut self, from: &Arc) { + if self.redo.is_empty() { + return; + } + let steps: Vec<(String, Arc)> = self.redo.drain(..).rev().collect(); + self.branches.push(Branch { parent: from.clone(), steps }); + } + + /// Apply the undo-levels limit: drop the oldest working-line states, the branches that grew + /// from them, and the oldest branches when branches hold more states than `levels`. + pub fn trim(&mut self, levels: usize, current: &Arc) { + let levels = levels.max(1); + if self.undo.len() > levels { + let extra = self.undo.len() - levels; + self.undo.drain(..extra); + } + let mut total: usize = self.branches.iter().map(|b| b.steps.len()).sum(); + while total > levels && !self.branches.is_empty() { + total -= self.branches.remove(0).steps.len(); + } + self.prune(current); + } + + /// Drop branches whose parent state is no longer in the history (`current` = the current + /// state, which the history doesn't store). + pub fn prune(&mut self, current: &Arc) { + if self.branches.is_empty() { + return; + } + let mut live: std::collections::HashSet = self.undo.iter().chain(self.redo.iter()).map(|(_, p)| key(p)).collect(); + live.insert(key(current)); + let mut keep = vec![false; self.branches.len()]; + loop { + let mut changed = false; + for (i, b) in self.branches.iter().enumerate() { + if !keep[i] && live.contains(&key(&b.parent)) { + keep[i] = true; + live.extend(b.steps.iter().map(|(_, p)| key(p))); + changed = true; + } + } + if !changed { + break; + } + } + let mut k = keep.into_iter(); + self.branches.retain(|_| k.next().unwrap_or(false)); + } + + /// Drop everything (Edit ▸ Purge ▸ Undo, New / Open Project). + pub fn clear(&mut self) { + *self = History::default(); + } + + /// The history as a tree, given the current state. + pub fn tree(&self, current: &Arc) -> Vec { + Arena::build(self, current).listing() + } + + /// Number of states on branches (not on the working line). + pub fn branch_states(&self) -> usize { + self.branches.iter().map(|b| b.steps.len()).sum() + } +} + +impl Session { + /// The undo history as a tree (the History panel, `edit.history.list`). + pub fn history_tree(&self) -> Vec { + self.history.tree(&self.project) + } + + /// Jump to any state of the history (`id` from [`HistoryNode::id`]), including states on + /// other branches: the path to it becomes the working line (its undone continuation the + /// redo line), and the line left behind is kept as a branch. Returns false for an unknown + /// id. + pub fn goto_history(&mut self, id: &str) -> bool { + let a = Arena::build(&self.history, &self.project); + let Some(target) = a.state.iter().position(|s| state_id(s) == id) else { return false }; + if target == a.current { + return true; + } + let on_line = a.on_line(); + // Path root → target. + let mut path = vec![target]; + while let Some(p) = a.parent[*path.last().unwrap_or(&target)] { + path.push(p); + } + path.reverse(); + // Continue past the target: the working line where it runs on, else each branch's own + // continuation (its first child). + let mut new_line = path.clone(); + let mut n = target; + loop { + let kids = &a.children[n]; + let next = if on_line[n] { kids.iter().copied().find(|&c| on_line[c]) } else { kids.first().copied() }; + match next { + Some(c) => { + new_line.push(c); + n = c; + } + None => break, + } + } + let mut in_new = vec![false; a.state.len()]; + for &i in &new_line { + in_new[i] = true; + } + // Every state off the new line goes into branches: a chain per first child, the other + // children starting branches of their own. + fn chain(a: &Arena, start: usize, parent: usize, in_new: &[bool], out: &mut Vec) { + let mut steps = vec![]; + let mut n = start; + let mut extra: Vec<(usize, usize)> = vec![]; + loop { + steps.push((a.label[n].clone(), a.state[n].clone())); + let kids: Vec = a.children[n].iter().copied().filter(|&c| !in_new[c]).collect(); + let Some((&first, rest)) = kids.split_first() else { break }; + extra.extend(rest.iter().map(|&c| (c, n))); + n = first; + } + out.push(Branch { parent: a.state[parent].clone(), steps }); + for (c, p) in extra { + chain(a, c, p, in_new, out); + } + } + let mut branches = vec![]; + for &n in &new_line { + for &c in &a.children[n] { + if !in_new[c] { + chain(&a, c, n, &in_new, &mut branches); + } + } + } + let k = new_line.iter().position(|&i| i == target).unwrap_or(0); + let undo: Vec<(String, Arc)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect(); + let redo: Vec<(String, Arc)> = new_line[k + 1..].iter().rev().map(|&i| (a.label[i].clone(), a.state[i].clone())).collect(); + self.project = a.state[target].clone(); + self.history.undo = undo; + self.history.redo = redo; + self.history.branches = branches; + self.history.merge_key = None; + self.sanitize_state(); + self.bump(); + true + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9333329a1..ad6ae4fba 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod camera_track; pub mod commands; pub mod config; pub mod demo; +pub mod history; pub mod links; pub mod mask_track; pub mod menus; @@ -21,6 +22,7 @@ pub mod prefs; pub mod psd_import; pub mod render_queue; pub mod roto; +pub mod scriptui; mod session_settings; pub mod shortcuts; pub mod tracking; @@ -47,6 +49,7 @@ pub use effectcraft_render as render; pub use effectcraft_text as text; pub use effectcraft_time as time; pub use effectcraft_track as track; +pub use history::{Branch, History, HistoryNode}; pub use render_queue::{ExportJob, ExportResult, Exporter, JobState}; #[derive(Debug, thiserror::Error)] @@ -90,15 +93,6 @@ pub trait Importer: Send + Sync { fn probe(&self, path: &str) -> std::result::Result; } -/// Undo history of whole-project snapshots. -#[derive(Clone, Default)] -pub struct History { - pub undo: Vec<(String, Arc)>, - pub redo: Vec<(String, Arc)>, - /// Key of the last merged step: a continuous gesture with the same key folds into one step. - pub merge_key: Option, -} - /// A keyframe reference: layer, property uid, key time (layer time). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct KeyRef { @@ -320,6 +314,11 @@ pub struct Session { /// The JavaScript scripting engine (set by the host that links `effectcraft-script`): /// `script.run`, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`) and the Script Console. pub script: Option, + /// Loads WebAssembly effect plug-ins (set by the host that links `effectcraft-plugin` with + /// its runtime): `effect.plugins.load`. + pub plugin_loader: Option, + /// ScriptUI windows and panels opened by scripts (see [`scriptui`]). + pub script_ui: scriptui::ScriptUi, /// Runs renders and analyses off the UI thread where there are no threads (the web app's /// Web Workers, see [`offload`]); `None` = threads (desktop) or inline (wasm32). pub offload: Option>, @@ -341,6 +340,10 @@ pub struct ScriptRequest<'a> { /// column, file} | null}`. pub type ScriptRunner = fn(&mut Session, &ScriptRequest) -> Value; +/// Loads one WebAssembly effect plug-in (`bytes`, `source` path) into the effect registry and +/// describes it (`{id, name, category, version, api, params}`). +pub type PluginLoader = fn(&[u8], &str) -> std::result::Result; + impl Default for Session { fn default() -> Self { Session { @@ -379,6 +382,8 @@ impl Default for Session { autosave: autosave::AutoSaveState::default(), snapshot: None, script: None, + plugin_loader: None, + script_ui: scriptui::ScriptUi::default(), offload: None, offloaded: vec![], } @@ -453,15 +458,13 @@ impl Session { } let same = merge.is_some() && merge.map(str::to_string) == self.history.merge_key; if !same { - self.history.undo.push((label.to_string(), before)); + // Undone steps aren't lost: they stay in the History panel as a branch. let levels = self.prefs.general.undo_levels.max(1) as usize; - if self.history.undo.len() > levels { - let extra = self.history.undo.len() - levels; - self.history.undo.drain(..extra); - } + self.history.record(label, before, levels); + } else { + self.history.abandon_redo(&before); } self.history.merge_key = merge.map(str::to_string); - self.history.redo.clear(); self.project = Arc::new(p); self.state = st; self.bump(); @@ -748,6 +751,8 @@ pub fn text_families() -> Vec { effectcraft_text::families().into_iter().map(|(f, _)| f).collect() } #[cfg(test)] +mod tests_history; +#[cfg(test)] mod tests_paint; #[cfg(test)] mod tests_roto; diff --git a/crates/engine/src/mask_track.rs b/crates/engine/src/mask_track.rs index 003151980..7d41fa713 100644 --- a/crates/engine/src/mask_track.rs +++ b/crates/engine/src/mask_track.rs @@ -247,9 +247,8 @@ impl Session { return self.offload_analysis(crate::offload::WorkerJob::MaskTrack { comp, layer, mask, direction, path, method, times }); } let wait = wait || cfg!(target_arch = "wasm32"); - self.history.undo.push(("Track Mask".into(), self.project.clone())); - self.history.redo.clear(); - self.history.merge_key = None; + let levels = self.prefs.general.undo_levels as usize; + self.history.record("Track Mask", self.project.clone(), levels); let shared = Arc::new(MaskTrackShared::default()); let (comp, layer) = (work.comp, work.layer); let thread = if wait { diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 51fa4e021..7f14a9883 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -280,7 +280,7 @@ pub fn parse(text: &str, mac: bool) -> Result, String> { /// Effect ▸ category submenus from the effect registry. fn effect_categories() -> Vec { let mut cats: Vec<(&str, Vec)> = vec![]; - for e in crate::effects::registry() { + for e in crate::effects::all() { let entry = MenuNode::Item(MenuEntry { label: e.name.into(), command: "effect.apply".into(), params: serde_json::json!({"effect": e.id}), shortcut: None }); match cats.iter_mut().find(|(c, _)| *c == e.category) { @@ -371,6 +371,8 @@ File Find Missing Footage | file.findMissing {"what":"footage"} --- Scripts + Install Script File... | file.installScript + Install ScriptUI Panel... | file.installScriptUIPanel Run Script File... | file.runScript --- Create Proxy diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs new file mode 100644 index 000000000..2a76ba04c --- /dev/null +++ b/crates/engine/src/scriptui.rs @@ -0,0 +1,572 @@ +//! ScriptUI: the windows, dialogs and dockable panels scripts build with After Effects' +//! ScriptUI object model (`new Window("dialog", "Title")`, `win.add("button", undefined, "OK")`, +//! `orientation`, `alignChildren`, `onClick`/`onChange`, `show()`, `close()`, +//! `layout.layout()`…). +//! +//! The scripting engine (`effectcraft-script`) owns the live JavaScript objects; after every +//! script step it publishes a serde description of each open window here ([`ScriptWindow`], +//! a [`Widget`] tree with computed [`Widget::bounds`]). Frontends draw that description (the egui +//! UI renders dialogs, palettes and dockable panels from it) and report what the user does as +//! [`ScriptUiEvent`]s through the `scriptui.*` commands, which hand them to the script's +//! handlers ([`ScriptUi::dispatch`]). Agents use the same commands: list the open script +//! windows, read their widget trees, click buttons and set values. +//! +//! [`layout`] is ScriptUI's automatic layout (orientation, alignChildren / alignment, spacing, +//! margins, preferredSize), so every frontend places widgets the same way. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::{EngineError, Result, Session}; + +/// What kind of window a script made. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WindowKind { + /// Modal: the script waits in `show()` until it closes. + #[default] + Dialog, + /// Floating, non-modal. + Palette, + /// A non-modal document window. + Window, + /// A dockable panel (a script from the ScriptUI Panels folder, opened from the Window menu). + Panel, +} + +/// ScriptUI control types (the `type` passed to `add()`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WidgetKind { + /// The window itself (the tree's root). + #[default] + Window, + Panel, + Group, + Button, + IconButton, + StaticText, + EditText, + Checkbox, + RadioButton, + Slider, + Scrollbar, + Progressbar, + DropDownList, + ListBox, + TabbedPanel, + Tab, + Image, + /// A type we don't draw (it still lays out as an empty box). + #[serde(other)] + Unknown, +} + +impl WidgetKind { + pub fn is_container(self) -> bool { + matches!(self, WidgetKind::Window | WidgetKind::Panel | WidgetKind::Group | WidgetKind::TabbedPanel | WidgetKind::Tab) + } +} + +/// One control (or container) of a script window. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Widget { + /// Unique within its window (the window itself is 0). + pub id: u32, + #[serde(rename = "type")] + pub kind: WidgetKind, + /// `properties.name` (agents can address controls by it). + pub name: String, + pub text: String, + /// Slider / scrollbar / progressbar value. + pub value: f64, + pub min: f64, + pub max: f64, + /// Checkbox / radio button state. + pub checked: bool, + /// Drop-down list / list box items. + pub items: Vec, + /// Selected item indices. + pub selection: Vec, + /// `column` (default for windows and panels), `row` (groups) or `stack`. + pub orientation: String, + /// `[horizontal, vertical]`: left / center / right / fill, top / center / bottom / fill. + pub align_children: Vec, + /// This control's own alignment in its parent (overrides the parent's alignChildren). + pub alignment: Vec, + pub spacing: Option, + /// `[left, top, right, bottom]`. + pub margins: Option<[f64; 4]>, + pub preferred_size: Option<[f64; 2]>, + /// Bounds set by the script (`[left, top, right, bottom]` relative to the parent). + pub fixed_bounds: Option<[f64; 4]>, + /// Laid-out bounds `[x, y, width, height]` relative to the window's content. + pub bounds: [f64; 4], + pub enabled: bool, + pub visible: bool, + pub help_tip: String, + pub multiline: bool, + pub read_only: bool, + /// Event handlers the script attached (`onClick`, `onChange`, `onChanging`…). + pub handlers: Vec, + /// The active tab of a tabbed panel (child index). + pub active_tab: usize, + pub children: Vec, +} + +impl Widget { + pub fn find(&self, id: u32) -> Option<&Widget> { + if self.id == id { + return Some(self); + } + self.children.iter().find_map(|c| c.find(id)) + } + pub fn find_mut(&mut self, id: u32) -> Option<&mut Widget> { + if self.id == id { + return Some(self); + } + self.children.iter_mut().find_map(|c| c.find_mut(id)) + } + /// Every control, depth first. + pub fn walk<'a>(&'a self, out: &mut Vec<&'a Widget>) { + out.push(self); + for c in &self.children { + c.walk(out); + } + } + /// A control by id (number or `"#12"`), `properties.name` or text. + pub fn lookup(&self, key: &Value) -> Option<&Widget> { + let mut all = vec![]; + self.walk(&mut all); + match key { + Value::Number(n) => n.as_u64().and_then(|n| self.find(n as u32)), + Value::String(s) => { + if let Some(n) = s.strip_prefix('#').and_then(|n| n.parse::().ok()) { + return self.find(n); + } + all.iter().find(|w| !w.name.is_empty() && w.name == *s).or_else(|| all.iter().find(|w| w.id != 0 && w.text == *s)).copied() + } + _ => None, + } + } +} + +/// An open script window. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ScriptWindow { + /// Unique in the process. + pub id: u32, + /// The script context that owns it (its handlers run there). + pub host: u32, + pub kind: WindowKind, + pub title: String, + /// The script that made it. + pub script: String, + /// Shown and not closed. + pub visible: bool, + /// The script is waiting in this dialog's `show()`. + pub modal: bool, + /// The window and its controls (`root.bounds` is the content size). + pub root: Widget, +} + +/// A user (or agent) action on a script window control. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ScriptUiEvent { + pub window: u32, + pub widget: u32, + /// `click`, `change`, `changing` or `close`. + pub kind: String, + /// The new value: text, number, bool, or selected index(es); for `close`, the result. + pub value: Value, +} + +/// Hands an event to the script that owns the window; returns what the handler reported +/// (`{ok, output, error}`) and updates [`Session::script_ui`]. +pub type ScriptUiDispatch = fn(&mut Session, &ScriptUiEvent) -> std::result::Result; + +/// The open script windows. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct ScriptUi { + pub windows: Vec, + /// Bumped whenever a window changes. + pub revision: u64, + /// The scripting engine's event hand-off (set with [`Session::script`]). + #[serde(skip)] + pub dispatch: Option, +} + +impl ScriptUi { + pub fn window(&self, id: u32) -> Option<&ScriptWindow> { + self.windows.iter().find(|w| w.id == id) + } + + /// Replace the windows a script host published (laying them out). + pub fn publish(&mut self, host: u32, mut windows: Vec) { + for w in &mut windows { + w.host = host; + layout(w); + } + windows.retain(|w| w.visible); + let mut out: Vec = vec![]; + // Keep the order windows first appeared in. + for old in self.windows.drain(..) { + if old.host != host { + out.push(old); + } else if let Some(i) = windows.iter().position(|w| w.id == old.id) { + out.push(windows.remove(i)); + } + } + out.extend(windows); + self.windows = out; + self.revision += 1; + } +} + +// ---------------------------------------------------------------- layout + +/// Average character width and line height of the default dialog font (points). +const CHAR_W: f64 = 7.0; +const LINE_H: f64 = 16.0; + +fn text_w(s: &str) -> f64 { + s.lines().map(|l| l.chars().count()).max().unwrap_or(0) as f64 * CHAR_W +} + +fn lines(s: &str) -> f64 { + s.lines().count().max(1) as f64 +} + +fn orientation(w: &Widget) -> &str { + match w.orientation.as_str() { + "" => match w.kind { + WidgetKind::Group => "row", + WidgetKind::TabbedPanel => "stack", + _ => "column", + }, + o => o, + } +} + +fn margins(w: &Widget) -> [f64; 4] { + w.margins.unwrap_or(match w.kind { + WidgetKind::Window => [15.0; 4], + WidgetKind::Panel => [10.0, 15.0, 10.0, 10.0], + WidgetKind::TabbedPanel => [0.0, 24.0, 0.0, 0.0], + WidgetKind::Tab => [10.0; 4], + _ => [0.0; 4], + }) +} + +fn spacing(w: &Widget) -> f64 { + w.spacing.unwrap_or(10.0) +} + +fn align_children(w: &Widget) -> (String, String) { + let d = match w.kind { + WidgetKind::Group => ("center", "center"), + _ => ("center", "top"), + }; + let a = |i: usize, d: &str| w.align_children.get(i).filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| d.to_string()); + // A single value applies to the axis it names. + if w.align_children.len() == 1 { + let v = w.align_children[0].clone(); + return match v.as_str() { + "top" | "bottom" => (d.0.into(), v), + "fill" if orientation(w) == "row" => (d.0.into(), v), + _ => (v, d.1.into()), + }; + } + (a(0, d.0), a(1, d.1)) +} + +/// Preferred size of a control (its own content; containers include their children). +pub fn preferred(w: &Widget) -> [f64; 2] { + let ps = w.preferred_size.unwrap_or([0.0; 2]); + let content = if w.kind.is_container() { + let kids: Vec<[f64; 2]> = w.children.iter().filter(|c| c.visible).map(preferred).collect(); + let m = margins(w); + let sp = spacing(w); + let n = kids.len() as f64; + let (cw, ch) = match orientation(w) { + "row" => (kids.iter().map(|k| k[0]).sum::() + sp * (n - 1.0).max(0.0), kids.iter().map(|k| k[1]).fold(0.0, f64::max)), + "stack" => (kids.iter().map(|k| k[0]).fold(0.0, f64::max), kids.iter().map(|k| k[1]).fold(0.0, f64::max)), + _ => (kids.iter().map(|k| k[0]).fold(0.0, f64::max), kids.iter().map(|k| k[1]).sum::() + sp * (n - 1.0).max(0.0)), + }; + let title_w = if matches!(w.kind, WidgetKind::Panel) { text_w(&w.text) + 20.0 } else { 0.0 }; + [(cw + m[0] + m[2]).max(title_w), ch + m[1] + m[3]] + } else { + match w.kind { + WidgetKind::Button | WidgetKind::IconButton => [(text_w(&w.text) + 24.0).max(80.0), 25.0], + WidgetKind::StaticText => [text_w(&w.text) + 4.0, LINE_H * lines(&w.text)], + WidgetKind::EditText => { + if w.multiline { + [(text_w(&w.text) + 12.0).max(160.0), (LINE_H * lines(&w.text) + 8.0).max(60.0)] + } else { + [(text_w(&w.text) + 12.0).max(40.0), 22.0] + } + } + WidgetKind::Checkbox | WidgetKind::RadioButton => [text_w(&w.text) + 24.0, 18.0], + WidgetKind::Slider | WidgetKind::Scrollbar => [100.0, 22.0], + WidgetKind::Progressbar => [100.0, 10.0], + WidgetKind::DropDownList => [w.items.iter().map(|i| text_w(i)).fold(0.0, f64::max) + 36.0, 22.0], + WidgetKind::ListBox => [(w.items.iter().map(|i| text_w(i)).fold(0.0, f64::max) + 24.0).max(80.0), (w.items.len().clamp(3, 10) as f64 * 18.0 + 4.0)], + _ => [20.0, 20.0], + } + }; + [if ps[0] > 0.0 { ps[0] } else { content[0] }, if ps[1] > 0.0 { ps[1] } else { content[1] }] +} + +fn place(w: &mut Widget, x: f64, y: f64, width: f64, height: f64) { + w.bounds = [x, y, width, height]; + if !w.kind.is_container() { + return; + } + let m = margins(w); + let sp = spacing(w); + let (ah, av) = align_children(w); + let inner = [m[0], m[1], (width - m[0] - m[2]).max(0.0), (height - m[1] - m[3]).max(0.0)]; + let orient = orientation(w).to_string(); + let mut cursor = 0.0; + for c in w.children.iter_mut() { + if !c.visible { + c.bounds = [0.0; 4]; + continue; + } + if let Some(b) = c.fixed_bounds { + let (cx, cy, cw, ch) = (x + b[0], y + b[1], b[2] - b[0], b[3] - b[1]); + place(c, cx, cy, cw, ch); + continue; + } + let pref = preferred(c); + let h_align = c.alignment.first().filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| ah.clone()); + let v_align = c.alignment.get(1).filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| av.clone()); + let along = |a: &str, free: f64| match a { + "center" => free / 2.0, + "right" | "bottom" => free, + _ => 0.0, + }; + let (cx, cy, cw, ch) = match orient.as_str() { + "row" => { + let ch = if v_align == "fill" { inner[3] } else { pref[1] }; + let cy = inner[1] + along(&v_align, inner[3] - ch); + let r = (inner[0] + cursor, cy, pref[0], ch); + cursor += pref[0] + sp; + r + } + "stack" => { + let cw = if h_align == "fill" { inner[2] } else { pref[0] }; + let ch = if v_align == "fill" { inner[3] } else { pref[1] }; + (inner[0] + along(&h_align, inner[2] - cw), inner[1] + along(&v_align, inner[3] - ch), cw, ch) + } + _ => { + let cw = if h_align == "fill" { inner[2] } else { pref[0] }; + let cx = inner[0] + along(&h_align, inner[2] - cw); + let r = (cx, inner[1] + cursor, cw, pref[1]); + cursor += pref[1] + sp; + r + } + }; + place(c, x + cx, y + cy, cw, ch); + } + // Rows centre their content run as a whole when it is narrower than the row. + if orient == "row" && (ah == "center" || ah == "right") { + let used = (cursor - sp).max(0.0); + let shift = along_shift(&ah, inner[2] - used); + if shift > 0.0 { + for c in w.children.iter_mut().filter(|c| c.visible && c.fixed_bounds.is_none()) { + shift_all(c, shift, 0.0); + } + } + } + // Columns aligned to the bottom / centre move the whole run. + if orient == "column" && (av == "center" || av == "bottom") { + let used = (cursor - sp).max(0.0); + let shift = along_shift(&av, inner[3] - used); + if shift > 0.0 { + for c in w.children.iter_mut().filter(|c| c.visible && c.fixed_bounds.is_none()) { + shift_all(c, 0.0, shift); + } + } + } +} + +fn along_shift(a: &str, free: f64) -> f64 { + match a { + "center" => (free / 2.0).max(0.0), + "right" | "bottom" => free.max(0.0), + _ => 0.0, + } +} + +fn shift_all(w: &mut Widget, dx: f64, dy: f64) { + w.bounds[0] += dx; + w.bounds[1] += dy; + for c in &mut w.children { + shift_all(c, dx, dy); + } +} + +/// ScriptUI automatic layout: size the window to its preferred size (or the size the script +/// gave it) and place every control (`bounds` relative to the window's top-left). +pub fn layout(w: &mut ScriptWindow) { + let pref = preferred(&w.root); + let (width, height) = match w.root.fixed_bounds { + Some(b) if b[2] > b[0] && b[3] > b[1] => (b[2] - b[0], b[3] - b[1]), + _ => (pref[0], pref[1]), + }; + place(&mut w.root, 0.0, 0.0, width, height); +} + +// ---------------------------------------------------------------- commands + +fn window_p(s: &Session, p: &Value, cmd: &str) -> Result { + let key = p.get("window"); + let w = match key { + Some(Value::Number(n)) => n.as_u64().map(|n| n as u32).filter(|n| s.script_ui.window(*n).is_some()), + Some(Value::String(t)) => s.script_ui.windows.iter().find(|w| w.title == *t).map(|w| w.id), + None if s.script_ui.windows.len() == 1 => s.script_ui.windows.first().map(|w| w.id), + _ => None, + }; + w.ok_or_else(|| crate::commands::bad(cmd, "no such script window (give `window`: id or title from scriptui.list)")) +} + +fn widget_p(s: &Session, win: u32, p: &Value, cmd: &str) -> Result { + let w = s.script_ui.window(win).ok_or_else(|| crate::commands::bad(cmd, "window closed"))?; + let key = p.get("widget").ok_or_else(|| crate::commands::bad(cmd, "missing `widget` (id, `#id`, properties.name or text)"))?; + w.root.lookup(key).map(|w| w.id).ok_or_else(|| crate::commands::bad(cmd, format!("no control {key} in window {win}"))) +} + +fn dispatch(s: &mut Session, ev: ScriptUiEvent) -> Result { + let f = s.script_ui.dispatch.ok_or_else(|| EngineError::Other("scripting is not available in this build".into()))?; + f(s, &ev).map_err(EngineError::Other) +} + +/// `scriptui.list`: the open script windows. +pub(crate) fn list(s: &mut Session, _: &Value) -> Result { + Ok(json!( + s.script_ui + .windows + .iter() + .map(|w| json!({"window": w.id, "title": w.title, "kind": w.kind, "script": w.script, "modal": w.modal, "size": [w.root.bounds[2], w.root.bounds[3]]})) + .collect::>() + )) +} + +/// `scriptui.get`: one window's control tree. +pub(crate) fn get(s: &mut Session, p: &Value) -> Result { + let id = window_p(s, p, "scriptui.get")?; + Ok(serde_json::to_value(s.script_ui.window(id)).unwrap_or(Value::Null)) +} + +/// `scriptui.click`: press a button / toggle a checkbox / pick a radio button. +pub(crate) fn click(s: &mut Session, p: &Value) -> Result { + let c = "scriptui.click"; + let window = window_p(s, p, c)?; + let widget = widget_p(s, window, p, c)?; + let w = s.script_ui.window(window).and_then(|w| w.root.find(widget)).cloned().unwrap_or_default(); + if !w.enabled { + return Err(crate::commands::bad(c, format!("control {widget} is disabled"))); + } + dispatch(s, ScriptUiEvent { window, widget, kind: "click".into(), value: Value::Null }) +} + +/// `scriptui.set`: set a control's value (edit text, slider, checkbox, list selection) as the +/// user would, firing onChanging / onChange. +pub(crate) fn set(s: &mut Session, p: &Value) -> Result { + let c = "scriptui.set"; + let window = window_p(s, p, c)?; + let widget = widget_p(s, window, p, c)?; + let w = s.script_ui.window(window).and_then(|w| w.root.find(widget)).cloned().unwrap_or_default(); + if !w.enabled { + return Err(crate::commands::bad(c, format!("control {widget} is disabled"))); + } + let mut value = p.get("value").cloned().ok_or_else(|| crate::commands::bad(c, "missing `value`"))?; + // List selections by item text. + if matches!(w.kind, WidgetKind::DropDownList | WidgetKind::ListBox) + && let Value::String(t) = &value + { + let i = w.items.iter().position(|it| it == t).ok_or_else(|| crate::commands::bad(c, format!("no item `{t}` (items: {:?})", w.items)))?; + value = json!(i); + } + dispatch(s, ScriptUiEvent { window, widget, kind: "change".into(), value }) +} + +/// `scriptui.close`: close a window (a dialog's `show()` returns `result`, default 2 = Cancel). +pub(crate) fn close(s: &mut Session, p: &Value) -> Result { + let window = window_p(s, p, "scriptui.close")?; + let result = p.get("result").cloned().unwrap_or(json!(2)); + dispatch(s, ScriptUiEvent { window, widget: 0, kind: "close".into(), value: result }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn w(kind: WidgetKind, text: &str) -> Widget { + Widget { kind, text: text.into(), enabled: true, visible: true, ..Default::default() } + } + + #[test] + fn column_and_row_layout() { + let mut row = w(WidgetKind::Group, ""); + row.id = 1; + let mut ok = w(WidgetKind::Button, "OK"); + ok.id = 2; + let mut cancel = w(WidgetKind::Button, "Cancel"); + cancel.id = 3; + row.children = vec![ok, cancel]; + let mut label = w(WidgetKind::StaticText, "Name:"); + label.id = 4; + let mut root = w(WidgetKind::Window, "Dialog"); + root.children = vec![label, row]; + let mut win = ScriptWindow { id: 1, root, visible: true, ..Default::default() }; + layout(&mut win); + let r = &win.root; + // Window: 15 px margins, children stacked with 10 px spacing. + let row = r.find(1).unwrap(); + assert_eq!(row.bounds[2], 170.0, "two 80 px buttons + 10 spacing"); + assert_eq!(r.bounds[2], 200.0); + assert_eq!(r.find(4).unwrap().bounds[1], 15.0); + assert_eq!(row.bounds[1], 15.0 + 16.0 + 10.0); + // Centred (the default alignChildren of windows). + assert_eq!(row.bounds[0], 15.0); + let label = r.find(4).unwrap(); + assert!((label.bounds[0] + label.bounds[2] / 2.0 - 100.0).abs() < 1e-9); + assert_eq!(r.find(3).unwrap().bounds[0], 15.0 + 80.0 + 10.0); + // alignChildren left / fill. + win.root.align_children = vec!["fill".into(), "top".into()]; + layout(&mut win); + assert_eq!(win.root.find(4).unwrap().bounds[2], 170.0); + win.root.align_children = vec!["left".into()]; + layout(&mut win); + assert_eq!(win.root.find(4).unwrap().bounds[0], 15.0); + // preferredSize wins. + win.root.find_mut(2).unwrap().preferred_size = Some([120.0, 30.0]); + layout(&mut win); + assert_eq!(win.root.find(2).unwrap().bounds[2..], [120.0, 30.0]); + // Lookup by name / text / id. + win.root.find_mut(2).unwrap().name = "ok".into(); + assert_eq!(win.root.lookup(&json!("ok")).unwrap().id, 2); + assert_eq!(win.root.lookup(&json!("Cancel")).unwrap().id, 3); + assert_eq!(win.root.lookup(&json!("#4")).unwrap().id, 4); + assert_eq!(win.root.lookup(&json!(1)).unwrap().id, 1); + } + + #[test] + fn publish_replaces_a_hosts_windows() { + let mut ui = ScriptUi::default(); + let win = |id: u32, visible: bool| ScriptWindow { id, visible, root: w(WidgetKind::Window, ""), ..Default::default() }; + ui.publish(7, vec![win(1, true), win(2, true)]); + ui.publish(8, vec![win(3, true)]); + assert_eq!(ui.windows.iter().map(|w| (w.id, w.host)).collect::>(), [(1, 7), (2, 7), (3, 8)]); + ui.publish(7, vec![win(2, true), win(1, false)]); + assert_eq!(ui.windows.iter().map(|w| w.id).collect::>(), [2, 3]); + let j = serde_json::to_value(&ui).unwrap(); + assert_eq!(j["windows"][0]["root"]["type"], "window"); + } +} diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 1a3370e47..155204d4f 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -30,10 +30,8 @@ impl Session { self.configure_disk_cache(); // Fewer undo levels apply right away. let levels = self.prefs.general.undo_levels.max(1) as usize; - if self.history.undo.len() > levels { - let extra = self.history.undo.len() - levels; - self.history.undo.drain(..extra); - } + let current = self.project.clone(); + self.history.trim(levels, ¤t); self.prefs_revision += 1; } diff --git a/crates/engine/src/tests_history.rs b/crates/engine/src/tests_history.rs new file mode 100644 index 000000000..326c37c04 --- /dev/null +++ b/crates/engine/src/tests_history.rs @@ -0,0 +1,135 @@ +//! Branching history: undo then a new step keeps the undone line as a branch; the tree lists +//! every state; jumping to any state (on any branch) restores it and keeps everything else. + +use serde_json::{Value, json}; + +use crate::Session; + +fn names(s: &Session) -> Vec { + s.project.comps().next().map(|(_, c)| c.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default() +} + +fn solid(s: &mut Session, name: &str) { + s.execute("layer.newSolid", json!({"name": name, "color": "#ff0000", "width": 10, "height": 10})).unwrap(); +} + +fn list(s: &mut Session) -> Vec { + s.execute("edit.history.list", json!({})).unwrap()["states"].as_array().cloned().unwrap() +} + +fn index_of(states: &[Value], label: &str, nth: usize) -> u64 { + states.iter().filter(|n| n["label"] == label).nth(nth).unwrap_or_else(|| panic!("no state {label} in {states:?}"))["index"].as_u64().unwrap() +} + +#[test] +fn undo_then_new_step_keeps_a_branch() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap(); + solid(&mut s, "A"); + solid(&mut s, "B"); + solid(&mut s, "C"); + // Linear so far: Original, New Composition, 3 solids. + let st = list(&mut s); + assert_eq!(st.len(), 5, "{st:?}"); + assert!(st.iter().all(|n| n["depth"] == 0)); + assert_eq!(st.last().unwrap()["current"], true); + // Undo twice (B and C undone), then a new step: AE would forget B and C. + assert!(s.undo() && s.undo()); + let st = list(&mut s); + assert_eq!(st.iter().filter(|n| n["future"] == true).count(), 2); + solid(&mut s, "D"); + assert!(s.history.redo.is_empty(), "the new step clears Redo, as in After Effects"); + assert_eq!(s.history.branches.len(), 1); + assert_eq!(names(&s), ["D", "A"]); + let st = list(&mut s); + assert_eq!(st.len(), 6, "the undone states are still listed: {st:?}"); + let branch: Vec<&Value> = st.iter().filter(|n| n["depth"] == 1).collect(); + assert_eq!(branch.len(), 2, "B and C sit on a branch: {st:?}"); + // Jump to C on the old branch. + let c = st.iter().filter(|n| n["depth"] == 1).last().unwrap()["index"].as_u64().unwrap(); + let r = s.execute("edit.history.goto", json!({"index": c})).unwrap(); + assert_eq!(r["label"], "New Solid", "{r}"); + assert_eq!(names(&s), ["C", "B", "A"]); + // Now D is the branch, and Undo walks the B/C line. + assert_eq!(s.history.branches.len(), 1); + assert_eq!(s.history.branches[0].steps.len(), 1); + assert!(s.undo()); + assert_eq!(names(&s), ["B", "A"]); + assert!(s.redo()); + // ...and back to D by id. + let st = list(&mut s); + let d = st.iter().find(|n| n["depth"] == 1).unwrap()["id"].as_str().unwrap().to_string(); + s.execute("edit.history.goto", json!({"id": d})).unwrap(); + assert_eq!(names(&s), ["D", "A"]); + let st = list(&mut s); + assert_eq!(st.len(), 6); + assert_eq!(st.iter().filter(|n| n["current"] == true).count(), 1); + // The original state. + let root = index_of(&st, "Original", 0); + s.execute("edit.history.goto", json!({"index": root})).unwrap(); + assert!(s.project.comps().next().is_none()); + assert!(s.history.undo.is_empty()); + assert_eq!(list(&mut s).len(), 6, "nothing is lost by jumping"); + // Relative steps along the line. + s.execute("edit.history.goto", json!({"steps": 2})).unwrap(); + assert_eq!(names(&s), ["A"]); + assert!(s.execute("edit.history.goto", json!({"id": "nope"})).is_err()); + assert!(s.execute("edit.history.goto", json!({})).is_err()); +} + +#[test] +fn branches_of_branches_and_purge() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap(); + solid(&mut s, "A"); + solid(&mut s, "B"); + s.undo(); + solid(&mut s, "C"); // branch 1: B + s.undo(); + solid(&mut s, "D"); // branch 2: C + let st = list(&mut s); + assert_eq!(st.len(), 6, "{st:?}"); + // Go to B, undo to A, make E: D's line becomes a branch, and a branch of the new line. + let b = st.iter().find(|n| n["depth"] == 1 && n["label"] == "New Solid").unwrap()["index"].as_u64().unwrap(); + s.execute("edit.history.goto", json!({"index": b})).unwrap(); + let which = names(&s); + assert!(which == ["B", "A"] || which == ["C", "A"], "{which:?}"); + s.undo(); + solid(&mut s, "E"); + let st = list(&mut s); + assert_eq!(st.len(), 7, "{st:?}"); + // Every state is reachable and restores its own layers. + let mut seen = std::collections::BTreeSet::new(); + for i in 0..st.len() { + s.execute("edit.history.goto", json!({"index": i})).unwrap(); + seen.insert(names(&s).join(",")); + assert_eq!(list(&mut s).len(), 7); + } + for want in ["", "A", "B,A", "C,A", "D,A", "E,A"] { + assert!(seen.contains(want), "{want:?} not reachable: {seen:?}"); + } + s.execute("edit.purgeUndo", json!({})).unwrap(); + assert_eq!(list(&mut s).len(), 1); +} + +#[test] +fn undo_levels_trim_branches() { + let mut s = Session::default(); + s.prefs.general.undo_levels = 3; + s.execute("comp.new", json!({"name": "H", "width": 64, "height": 64, "duration": 2})).unwrap(); + for n in ["A", "B", "C", "D", "E"] { + solid(&mut s, n); + } + assert_eq!(s.history.undo.len(), 3); + s.undo(); + s.undo(); + solid(&mut s, "F"); + // The branch (D, E) grew from a state still kept. + assert_eq!(s.history.branch_states(), 2); + for n in ["G", "H", "I"] { + solid(&mut s, n); + } + // Its parent fell off the end of the undo levels: the branch goes too. + assert_eq!(s.history.branch_states(), 0); + assert_eq!(list(&mut s).len(), 4); +} diff --git a/crates/engine/src/tests_paint.rs b/crates/engine/src/tests_paint.rs index 9926e04c5..9407ddd1d 100644 --- a/crates/engine/src/tests_paint.rs +++ b/crates/engine/src/tests_paint.rs @@ -250,3 +250,56 @@ fn liquify_stroke_adds_the_effect_and_warps() { assert!(layer(&s, id).effects().unwrap().groups().next().is_none()); assert!(px(&s, 0.0, 55, 50)[3] > 0.99); } + +fn pin_keys(s: &Session, id: u64, pin: u64) -> Vec<(f64, [f64; 2])> { + let l = layer(s, id); + let pos = l.props.find_group(pin).unwrap().get("position").unwrap().clone(); + pos.keys.iter().map(|k| (k.time.seconds(), k.value.as_vec2())).collect() +} + +#[test] +fn puppet_pin_recording_makes_keys_at_the_frame_rate() { + let (mut s, id) = setup(); + let a = s.execute("puppet.addPin", json!({"layer": id, "position": [10, 30]})).unwrap(); + s.execute("puppet.addPin", json!({"layer": id, "position": [90, 30]})).unwrap(); + let pin = a["pin"].as_u64().unwrap(); + s.set_time(Tick::from_seconds_f64(0.5)); + // A 0.5 s drag sampled at irregular wall-clock times: right 30 px, linear in time. + let samples: Vec = [0.0, 0.07, 0.2, 0.33, 0.41, 0.5].iter().map(|t| json!([t, 10.0 + 60.0 * t, 30.0])).collect(); + let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": samples, "smoothing": 0})).unwrap(); + // 0.5 s at 30 fps from 0.5 s: frames 15..=30, one key per frame. + assert_eq!(r["frames"], 16, "{r}"); + let keys = pin_keys(&s, id, pin); + let rec: Vec<&(f64, [f64; 2])> = keys.iter().filter(|k| k.0 >= 0.5 - 1e-9).collect(); + assert_eq!(rec.len(), 16, "{keys:?}"); + for (i, (t, v)) in rec.iter().enumerate() { + assert!((t - (0.5 + i as f64 / 30.0)).abs() < 1e-6, "{t}"); + assert!((v[0] - (10.0 + 60.0 * i as f64 / 30.0)).abs() < 1e-6, "{v:?}"); + } + assert_eq!(keys.len(), 17, "the pin's first key (0 s) is kept"); + assert_eq!(s.time().seconds(), 0.5, "the current time returns to where recording began"); + // The recording deforms the layer: by 1 s the left edge has followed the pin 30 px right. + assert!(px(&s, 0.0, 55, 50)[3] > 0.99); + assert!(px(&s, 1.0, 55, 50)[3] < 0.5); + // Speed 200 %: the same drag plays back over twice the frames; recording again replaces + // the keys in the span. + let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": samples, "speed": 200, "smoothing": 0})).unwrap(); + assert_eq!(r["frames"], 31); + let keys = pin_keys(&s, id, pin); + assert_eq!(keys.len(), 32); + let k = keys.iter().find(|k| (k.0 - (0.5 + 10.0 / 30.0)).abs() < 1e-6).unwrap(); + assert!((k.1[0] - (10.0 + 60.0 * 5.0 / 30.0)).abs() < 1e-6, "{k:?}"); + // Smoothing removes the extraneous keys of a straight-line drag. + let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": samples})).unwrap(); + assert!(r["keys"].as_u64().unwrap() < 6, "{r}"); + // One undo step per recording. + s.undo(); + assert_eq!(pin_keys(&s, id, pin).len(), 32); + // Record Options are tool state (serde for agents). + let o = s.execute("puppet.recordOptions", json!({"speed": 50, "smoothing": 0, "useDraftDeformation": true, "showMesh": true})).unwrap(); + assert_eq!(o["speed"], 50.0); + assert!(s.state.puppet.record_draft && s.state.puppet.record_show_mesh); + let r = s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": samples})).unwrap(); + assert_eq!(r["frames"], 9, "50 %: half the frames"); + assert!(s.execute("puppet.recordPin", json!({"layer": id, "pin": pin, "samples": []})).is_err()); +} diff --git a/crates/engine/src/tracking.rs b/crates/engine/src/tracking.rs index b4863d54d..a4386199b 100644 --- a/crates/engine/src/tracking.rs +++ b/crates/engine/src/tracking.rs @@ -358,9 +358,8 @@ impl Session { return self.offload_analysis(crate::offload::WorkerJob::Track { comp, layer, tracker, direction, settings, points, times }); } let wait = wait || cfg!(target_arch = "wasm32"); - self.history.undo.push(("Analyze Track".into(), self.project.clone())); - self.history.redo.clear(); - self.history.merge_key = None; + let levels = self.prefs.general.undo_levels as usize; + self.history.record("Analyze Track", self.project.clone(), levels); let shared = Arc::new(TrackShared::default()); let (comp, layer) = (work.comp, work.layer); let thread = if wait { diff --git a/crates/host/Cargo.toml b/crates/host/Cargo.toml index 635e32605..b6e9ff553 100644 --- a/crates/host/Cargo.toml +++ b/crates/host/Cargo.toml @@ -16,6 +16,11 @@ effectcraft-media = { workspace = true } effectcraft-expr = { workspace = true } effectcraft-script = { workspace = true } effectcraft-export = { workspace = true } +effectcraft-plugin = { workspace = true } + +# WebAssembly effect plug-ins run in wasmi on native builds. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +effectcraft-plugin = { workspace = true, features = ["wasm"] } [dev-dependencies] serde_json = { workspace = true } @@ -23,3 +28,4 @@ effectcraft-psd = { workspace = true } effectcraft-svg = { workspace = true } effectcraft-raster = { workspace = true } effectcraft-time = { workspace = true } +rayon = { workspace = true } diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index b5de77141..c379d0916 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -58,6 +58,8 @@ pub fn session() -> Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), script: Some(effectcraft_script::runner), + script_ui: effectcraft_engine::scriptui::ScriptUi { dispatch: Some(effectcraft_script::dispatch_ui), ..Default::default() }, + plugin_loader: effectcraft_plugin::wasm_available().then_some(effectcraft_plugin::loader as effectcraft_engine::PluginLoader), ..Default::default() } } diff --git a/crates/host/tests/determinism.rs b/crates/host/tests/determinism.rs new file mode 100644 index 000000000..58a1b9335 --- /dev/null +++ b/crates/host/tests/determinism.rs @@ -0,0 +1,74 @@ +//! Deterministic rendering (better than After Effects): the same project renders bit-identical +//! frames every time and whatever the number of render threads. Renders the demo comps (and a +//! comp of seeded-random effects: particles, grain, noise, wiggle expressions, Shatter) twice +//! on one thread and on several, and compares every float of every pixel. + +use effectcraft_engine::Session; +use effectcraft_engine::project::{ItemId, Project}; +use effectcraft_engine::render::{RenderOpts, Renderer}; +use effectcraft_engine::time::Tick; +use serde_json::json; + +fn render(s: &Session, p: &Project, comp: ItemId, t: f64) -> Vec { + let mut r = Renderer::new(p, s.footage.as_ref(), RenderOpts { scale: 0.25, ..Default::default() }); + r.expr = s.expr.as_deref(); + // No layer cache: every render computes everything. + let img = r.comp_frame(comp, Tick::from_seconds_f64(t)); + img.data.iter().flat_map(|px| px.iter().map(|c| c.to_bits())).collect() +} + +fn pool(n: usize) -> rayon::ThreadPool { + rayon::ThreadPoolBuilder::new().num_threads(n).build().expect("thread pool") +} + +/// A comp of effects that draw random numbers (all seeded per instance). +fn random_comp(s: &mut Session) -> ItemId { + s.execute("comp.new", json!({"name": "Random", "width": 480, "height": 270, "frameRate": 24, "duration": 4})).unwrap(); + let cid = s.active_comp_id().unwrap(); + let solid = |s: &mut Session, name: &str, color: &str| { + s.execute("layer.newSolid", json!({"name": name, "color": color, "width": 480, "height": 270})).unwrap()["layer"].as_u64().unwrap() + }; + let a = solid(s, "Particles", "#000000"); + for fx in ["ec.sim.ccparticleworld", "ec.noise.addgrain"] { + s.execute("effect.apply", json!({"layer": a, "effect": fx})).unwrap(); + } + let b = solid(s, "Noise", "#336699"); + for fx in ["ec.noise.fractal", "ec.sim.ccsnowfall", "ec.noise.noisehls"] { + s.execute("effect.apply", json!({"layer": b, "effect": fx})).unwrap(); + } + s.execute("prop.setExpression", json!({"layer": b, "path": "transform/position", "expression": "wiggle(3, 40)"})).unwrap(); + s.execute("prop.setExpression", json!({"layer": b, "path": "transform/opacity", "expression": "random(40, 90)"})).unwrap(); + let c = solid(s, "Shatter", "#cc8844"); + s.execute("layer.setTransform", json!({"layer": c, "prop": "scale", "value": [50, 50]})).unwrap(); + s.execute("effect.apply", json!({"layer": c, "effect": "ec.sim.shatter"})).unwrap(); + cid +} + +#[test] +fn renders_are_bit_identical_across_runs_and_thread_counts() { + let mut s = effectcraft_host::session(); + s.replace_project(effectcraft_engine::demo::demo_project(), None); + let random = random_comp(&mut s); + let p = (*s.project).clone(); + let mut comps: Vec<(ItemId, f64)> = p.comps().map(|(id, c)| (*id, c.duration.seconds())).collect(); + comps.sort_by_key(|c| c.0); + assert!(comps.len() >= 3, "demo comps + the random comp"); + assert!(comps.iter().any(|c| c.0 == random)); + let (one, many) = (pool(1), pool(6)); + let mut frames = 0; + for (comp, dur) in comps { + for t in [0.0, dur * 0.37, dur * 0.81] { + let a = one.install(|| render(&s, &p, comp, t)); + let b = many.install(|| render(&s, &p, comp, t)); + let c = many.install(|| render(&s, &p, comp, t)); + let d = one.install(|| render(&s, &p, comp, t)); + let name = &p.item(comp).unwrap().name; + assert!(!a.is_empty()); + assert!(a == b, "{name} @ {t:.2}s: 1 thread vs 6 threads differ"); + assert!(b == c, "{name} @ {t:.2}s: two 6-thread renders differ"); + assert!(a == d, "{name} @ {t:.2}s: two 1-thread renders differ"); + frames += 1; + } + } + assert!(frames >= 9); +} diff --git a/crates/plugin/Cargo.toml b/crates/plugin/Cargo.toml new file mode 100644 index 000000000..4081b61b6 --- /dev/null +++ b/crates/plugin/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "effectcraft-plugin" +description = "EffectCraft effect plug-ins: loads sandboxed WebAssembly effect modules (plug-in API v1) into the effect registry" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[features] +# The WebAssembly runtime (wasmi, a pure-Rust interpreter). Hosts enable it on native; without +# it `load_wasm` reports that WebAssembly plug-ins are unavailable. +wasm = ["dep:wasmi"] + +[dependencies] +effectcraft-effects = { workspace = true } +serde_json = { workspace = true } +wasmi = { workspace = true, optional = true } + +[dev-dependencies] +effectcraft-keyframe = { workspace = true } +effectcraft-project = { workspace = true } +effectcraft-raster = { workspace = true } diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs new file mode 100644 index 000000000..1231d171b --- /dev/null +++ b/crates/plugin/src/lib.rs @@ -0,0 +1,66 @@ +//! # effectcraft-plugin (L3) +//! +//! Loads **WebAssembly effect plug-ins** (plug-in API v1, see `docs/plugins.md`) into the effect +//! registry ([`effectcraft_effects::plugin`]). A plug-in is a `.wasm` (or `.wat`) 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` | a UTF-8 JSON [`PluginManifest`] 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; 0 = ok | +//! +//! For each render the host asks `ec_alloc` for one buffer holding the parameters (`nparams` +//! little-endian `f64`s) followed by the pixels (`width × height × 4` little-endian `f32`s, +//! premultiplied RGBA, row-major), calls `ec_render`, and reads the pixels back. +//! +//! Modules run in the [wasmi](https://github.com/wasmi-labs/wasmi) interpreter (pure Rust, +//! MIT/Apache-2.0) with deterministic floats, no imports (no files, network or clock) and a +//! fuel limit per frame, so a plug-in can't escape its sandbox, hang the app, or render +//! differently on another machine. The runtime is the `wasm` feature (on for the desktop app and +//! the CLI); without it [`load_wasm`] says plug-ins are unavailable. + +use effectcraft_effects::EffectSpec; +pub use effectcraft_effects::plugin::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, PluginParams, register_plugin}; + +#[cfg(feature = "wasm")] +mod wasm; + +/// Whether this build can load WebAssembly plug-ins. +pub fn wasm_available() -> bool { + cfg!(feature = "wasm") +} + +/// Load and register a WebAssembly plug-in (`bytes` = a `.wasm` binary or `.wat` text; +/// `source` = where it came from, for listings). Returns the registered effect. +pub fn load_wasm(bytes: &[u8], source: &str) -> Result<&'static EffectSpec, String> { + #[cfg(feature = "wasm")] + { + let p = wasm::WasmPlugin::new(bytes, source)?; + register_plugin(std::sync::Arc::new(p)) + } + #[cfg(not(feature = "wasm"))] + { + let _ = (bytes, source); + Err("WebAssembly plug-ins are not available in this build".into()) + } +} + +/// [`load_wasm`] for the engine's plug-in loader hook: `{id, name, category, version, api}`. +pub fn loader(bytes: &[u8], source: &str) -> Result { + let spec = load_wasm(bytes, source)?; + let m = effectcraft_effects::plugin::plugin(spec.id).map(|p| p.manifest().clone()); + Ok(serde_json::json!({ + "id": spec.id, + "name": spec.name, + "category": spec.category, + "version": m.as_ref().map(|m| m.version.clone()).unwrap_or_default(), + "api": PLUGIN_API_VERSION, + "params": spec.params.len(), + })) +} + +#[cfg(all(test, feature = "wasm"))] +mod tests; diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs new file mode 100644 index 000000000..9363ab3e0 --- /dev/null +++ b/crates/plugin/src/tests.rs @@ -0,0 +1,164 @@ +//! WebAssembly plug-ins: load, register, render through the effect registry, sandbox limits. + +use effectcraft_effects::{Buf, EffectCtx, EffectEnv, Image}; +use effectcraft_project::build::Ids; + +use crate::load_wasm; + +fn load_err(r: Result<&'static effectcraft_effects::EffectSpec, String>) -> String { + match r { + Err(e) => e, + Ok(s) => panic!("unexpectedly loaded {}", s.id), + } +} + +/// A plug-in in WebAssembly text: multiplies RGB by `gain`, adds `lift` × `tint` when `on`. +fn gain_module(id: &str, render_body: &str) -> String { + let manifest = format!( + r#"{{"api":1,"id":"{id}","name":"Gain {id}","category":"Test Plug-ins","version":"1.0","params":[{{"id":"gain","name":"Gain","type":"slider","default":0.5,"min":0,"max":4}},{{"id":"on","name":"Lift","type":"checkbox","default":false}},{{"id":"tint","name":"Tint","type":"color","default":[0,0,1,1]}}]}}"# + ); + let len = manifest.len(); + let escaped = manifest.replace('\\', "\\\\").replace('"', "\\\""); + format!( + r#"(module + (memory (export "memory") 2) + (data (i32.const 16) "{escaped}") + (func (export "ec_api_version") (result i32) i32.const 1) + (func (export "ec_manifest_ptr") (result i32) i32.const 16) + (func (export "ec_manifest_len") (result i32) i32.const {len}) + (func (export "ec_alloc") (param $n i32) (result i32) + (local $need i32) + local.get $n + i32.const 131071 + i32.add + i32.const 16 + i32.shr_u + memory.size + i32.sub + local.tee $need + i32.const 0 + i32.gt_s + if + local.get $need + memory.grow + drop + end + i32.const 65536) + (func (export "ec_render") (param $px i32) (param $w i32) (param $h i32) (param $params i32) (param $np i32) (param $t f64) (param $scale f64) (result i32) + (local $i i32) (local $end i32) (local $g f32) (local $on f64) + {render_body} + local.get $params + f64.load + f32.demote_f64 + local.set $g + local.get $params + f64.load offset=8 + local.set $on + local.get $px + local.get $w + local.get $h + i32.mul + i32.const 16 + i32.mul + i32.add + local.set $end + local.get $px + local.set $i + block $done + loop $l + local.get $i + local.get $end + i32.ge_u + br_if $done + local.get $i + local.get $i + f32.load + local.get $g + f32.mul + f32.store + local.get $i + local.get $i + f32.load offset=4 + local.get $g + f32.mul + f32.store offset=4 + local.get $i + local.get $i + f32.load offset=8 + local.get $g + f32.mul + local.get $on + local.get $params + f64.load offset=32 + f64.mul + f32.demote_f64 + f32.add + f32.store offset=8 + local.get $i + i32.const 16 + i32.add + local.set $i + br $l + end + end + i32.const 0))"# + ) +} + +fn render(spec: &effectcraft_effects::EffectSpec, gain: f64, on: bool) -> Image { + let mut next = 1; + let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [4.0, 2.0]); + let mut params = effectcraft_effects::flatten_params(&g, &mut |p| p.value.clone()); + params.values.insert("gain".into(), effectcraft_keyframe::Value::Scalar(gain)); + params.values.insert("on".into(), effectcraft_keyframe::Value::Bool(on)); + let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [4.0, 2.0], seed: 1, adjustment: false, env: EffectEnv::default() }; + let buf = Buf { img: Image::filled(4, 2, [0.8, 0.6, 0.2, 1.0]), offset: [0.0; 2], scale: 1.0 }; + (spec.render)(&ctx, buf).img +} + +#[test] +fn wasm_plugin_registers_and_renders() { + let spec = load_wasm(gain_module("org.test.gain", "").as_bytes(), "gain.wat").unwrap(); + assert_eq!(spec.id, "org.test.gain"); + assert_eq!(spec.category, "Test Plug-ins"); + assert_eq!(spec.params.len(), 3); + // Found like a built-in, listed under its own category. + assert!(effectcraft_effects::find("org.test.gain").is_some()); + assert!(effectcraft_effects::lookup("Gain org.test.gain").is_some()); + assert!(effectcraft_effects::all().iter().any(|e| e.id == "org.test.gain")); + assert!(effectcraft_effects::categories().contains(&"Test Plug-ins")); + let img = render(spec, 0.5, false); + assert_eq!(img.get(1, 1), [0.4, 0.3, 0.1, 1.0]); + // The checkbox and colour parameters arrive flattened (tint blue = 1 → +1 on blue). + let img = render(spec, 1.0, true); + assert_eq!(img.get(3, 0), [0.8, 0.6, 1.2, 1.0]); + // Deterministic, and safe to run on many threads at once (each takes an instance). + let a: Vec = (0..8).map(|_| render(spec, 0.25, false)).collect(); + std::thread::scope(|sc| { + let hs: Vec<_> = (0..4).map(|_| sc.spawn(|| render(spec, 0.25, false))).collect(); + for h in hs { + assert_eq!(h.join().unwrap(), a[0]); + } + }); + assert!(a.iter().all(|i| *i == a[0])); + // The same id can't be registered twice. + assert!(load_err(load_wasm(gain_module("org.test.gain", "").as_bytes(), "again.wat")).contains("already registered")); +} + +#[test] +fn wasm_plugins_are_sandboxed() { + // A runaway loop runs out of fuel: the frame comes back unchanged instead of hanging. + let spec = load_wasm(gain_module("org.test.spin", "(loop $spin br $spin)").as_bytes(), "spin.wat").unwrap(); + let img = render(spec, 0.5, false); + assert_eq!(img.get(0, 0), [0.8, 0.6, 0.2, 1.0]); + // No imports (files, clock, network…). + let imports = r#"(module (import "env" "now" (func $now (result f64))) (memory (export "memory") 1))"#; + assert!(load_err(load_wasm(imports.as_bytes(), "imports.wat")).contains("import")); + // Garbage, wrong API version, bad manifests. + assert!(load_wasm(b"\0asm nope", "bad.wasm").is_err()); + let v2 = gain_module("org.test.v2", "") + .replace("(func (export \"ec_api_version\") (result i32) i32.const 1)", "(func (export \"ec_api_version\") (result i32) i32.const 2)"); + assert!(load_err(load_wasm(v2.as_bytes(), "v2.wat")).contains("API 2")); + let reserved = gain_module("ec.blur.mine", ""); + assert!(load_err(load_wasm(reserved.as_bytes(), "reserved.wat")).contains("reserved")); +} diff --git a/crates/plugin/src/wasm.rs b/crates/plugin/src/wasm.rs new file mode 100644 index 000000000..8015f7eec --- /dev/null +++ b/crates/plugin/src/wasm.rs @@ -0,0 +1,151 @@ +//! The wasmi-backed [`EffectPlugin`] (plug-in API v1 ABI, see the crate docs). + +use std::sync::Mutex; + +use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, TypedFunc, WasmParams, WasmResults}; + +use crate::{EffectPlugin, PLUGIN_API_VERSION, PluginFrame, PluginManifest, PluginParams}; + +/// Instruction budget per frame: a fixed allowance plus some per pixel. Generous for per-pixel +/// work, but a runaway loop stops instead of hanging the render. +const FUEL_BASE: u64 = 50_000_000; +const FUEL_PER_PIXEL: u64 = 20_000; + +type RenderFn = TypedFunc<(i32, i32, i32, i32, i32, f64, f64), i32>; + +/// One instantiated module (renders running on several threads each take their own). +struct Inst { + store: Store<()>, + instance: Instance, + memory: Memory, + alloc: TypedFunc, + render: RenderFn, +} + +impl Inst { + fn func(&self, name: &str) -> Result, String> { + self.instance.get_typed_func::(&self.store, name).map_err(|_| missing(name)) + } +} + +fn missing(name: &str) -> String { + format!("the module exports no `{name}` (or with the wrong signature)") +} + +fn err(e: impl std::fmt::Display) -> String { + e.to_string() +} + +fn instantiate(engine: &Engine, module: &Module) -> Result { + let mut store = Store::new(engine, ()); + store.set_fuel(FUEL_BASE).map_err(err)?; + let linker = Linker::<()>::new(engine); + let instance = linker.instantiate_and_start(&mut store, module).map_err(|e| format!("instantiation failed: {e}"))?; + let memory = instance.get_memory(&store, "memory").ok_or("the module exports no `memory`")?; + let alloc = instance.get_typed_func::(&store, "ec_alloc").map_err(|_| missing("ec_alloc"))?; + let render = instance.get_typed_func::<(i32, i32, i32, i32, i32, f64, f64), i32>(&store, "ec_render").map_err(|_| missing("ec_render"))?; + Ok(Inst { store, instance, memory, alloc, render }) +} + +/// A loaded WebAssembly effect plug-in. +pub struct WasmPlugin { + manifest: PluginManifest, + source: String, + engine: Engine, + module: Module, + /// Idle instances. + pool: Mutex>, +} + +impl WasmPlugin { + pub fn new(bytes: &[u8], source: &str) -> Result { + let mut config = Config::default(); + config.consume_fuel(true); + let engine = Engine::new(&config); + let module = Module::new(&engine, bytes).map_err(|e| format!("not a valid WebAssembly module: {e}"))?; + if let Some(i) = module.imports().next() { + return Err(format!("plug-ins can't import anything (the module imports `{}.{}`)", i.module(), i.name())); + } + let mut inst = instantiate(&engine, &module)?; + let version = inst.func::<(), i32>("ec_api_version")?; + let v = version.call(&mut inst.store, ()).map_err(err)?; + if v != PLUGIN_API_VERSION as i32 { + return Err(format!("the module implements plug-in API {v}, this build implements API {PLUGIN_API_VERSION}")); + } + let ptr = inst.func::<(), i32>("ec_manifest_ptr")?.call(&mut inst.store, ()).map_err(err)?; + let len = inst.func::<(), i32>("ec_manifest_len")?.call(&mut inst.store, ()).map_err(err)?; + let mut text = vec![0u8; len.max(0) as usize]; + inst.memory.read(&inst.store, ptr as u32 as usize, &mut text).map_err(|e| format!("manifest out of bounds: {e}"))?; + let manifest: PluginManifest = serde_json::from_slice(&text).map_err(|e| format!("bad manifest JSON: {e}"))?; + manifest.validate()?; + Ok(WasmPlugin { manifest, source: source.to_string(), engine, module, pool: Mutex::new(vec![inst]) }) + } + + fn take(&self) -> Result { + if let Some(i) = self.pool.lock().ok().and_then(|mut p| p.pop()) { + return Ok(i); + } + instantiate(&self.engine, &self.module) + } + + fn give_back(&self, inst: Inst) { + if let Ok(mut p) = self.pool.lock() { + p.push(inst); + } + } +} + +impl EffectPlugin for WasmPlugin { + fn manifest(&self) -> &PluginManifest { + &self.manifest + } + + fn source(&self) -> String { + self.source.clone() + } + + fn render(&self, frame: &mut PluginFrame, params: &PluginParams, time: f64) -> Result<(), String> { + let n = frame.pixels.len(); + let params_bytes = params.flat.len() * 8; + let total = params_bytes + n * 16; + if total > i32::MAX as usize { + return Err("frame too large for a 32-bit plug-in".into()); + } + let mut inst = self.take()?; + inst.store.set_fuel(FUEL_BASE + FUEL_PER_PIXEL * n as u64).map_err(err)?; + let base = inst.alloc.call(&mut inst.store, total as i32).map_err(err)? as u32 as usize; + // Parameters, then pixels (little-endian, as WebAssembly memory is). + let mut buf: Vec = Vec::with_capacity(total); + for v in params.flat { + buf.extend_from_slice(&v.to_le_bytes()); + } + for px in frame.pixels.iter() { + for c in px { + buf.extend_from_slice(&c.to_le_bytes()); + } + } + inst.memory.write(&mut inst.store, base, &buf).map_err(|e| format!("ec_alloc returned a buffer out of bounds: {e}"))?; + let px_ptr = (base + params_bytes) as i32; + let r = inst + .render + .call(&mut inst.store, (px_ptr, frame.width as i32, frame.height as i32, base as i32, params.flat.len() as i32, time, frame.scale)) + .map_err(|e| format!("ec_render trapped: {e}")); + let r = match r { + Ok(0) => { + inst.memory.read(&inst.store, base + params_bytes, &mut buf[params_bytes..]).map_err(err)?; + for (px, b) in frame.pixels.iter_mut().zip(buf[params_bytes..].chunks_exact(16)) { + for (c, v) in px.iter_mut().zip(b.chunks_exact(4)) { + *c = f32::from_le_bytes([v[0], v[1], v[2], v[3]]); + } + } + Ok(()) + } + Ok(code) => Err(format!("ec_render returned error {code}")), + Err(e) => Err(e), + }; + if r.is_ok() { + self.give_back(inst); + } + r + } +} diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index a1e9e5d44..a7a85213f 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -30,6 +30,14 @@ //! * **Security**: scripts can import/open/save projects and render, but `File` reads are limited //! to the project's folder and writes (and the network) are off unless Preferences ▸ Scripting //! & Expressions ▸ Allow Scripts to Write Files and Access Network is on. +//! * **ScriptUI** (`scriptui.js`, `ui.rs`): `Window` (dialog / palette / window), `Panel`, +//! `Group`, `Button`, `StaticText`, `EditText`, `Checkbox`, `RadioButton`, `Slider`, +//! `Progressbar`, `DropDownList`, `ListBox`, `TabbedPanel`/`Tab`, with `add()`, `orientation`, +//! `alignChildren`, `alignment`, `margins`, `spacing`, `preferredSize`, `onClick` / +//! `onChange` / `onChanging` / `onClose`, `show()` / `close()` and `layout.layout()`. Windows +//! are published to [`Session::script_ui`](effectcraft_engine::Session::script_ui) for +//! frontends and agents (`scriptui.*` commands); modal dialogs block `show()` until they close. +//! Scripts in the ScriptUI Panels folder run with `this` = a dockable `Panel`. //! //! Entry points: [`install`] sets [`Session::script`](effectcraft_engine::Session::script), which //! the `script.run` command, File ▸ Scripts ▸ Run Script File… (`.jsx`/`.js`), the Script Console @@ -40,19 +48,22 @@ pub mod matchnames; mod model; mod runtime; +mod ui; use effectcraft_engine::{ScriptRequest, Session}; pub use runtime::{Outcome, ScriptError, run}; +pub use ui::dispatch_ui; /// The [`effectcraft_engine::ScriptRunner`] this crate provides. pub fn runner(s: &mut Session, req: &ScriptRequest) -> serde_json::Value { run(s, req).to_json() } -/// Enable scripting on a session. +/// Enable scripting (and ScriptUI) on a session. pub fn install(s: &mut Session) { s.script = Some(runner); + s.script_ui.dispatch = Some(dispatch_ui); } /// Run `code` as a one-off script named `name`. @@ -62,3 +73,5 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_ui; diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 03e80bb5b..182705901 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -59,8 +59,8 @@ pub fn query(s: &mut Session, kind: &str, a: &J) -> R { } "labels" => Ok(json!(Label::ALL.iter().map(|l| l.name()).collect::>())), "effects" => Ok(json!( - effectcraft_engine::effects::registry() - .iter() + effectcraft_engine::effects::all() + .into_iter() .map(|e| json!({"id": e.id, "name": e.name, "matchName": matchnames::effect(e.id), "category": e.category})) .collect::>() )), diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 61b495950..750918f4f 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -26,6 +26,9 @@ pub struct Outcome { /// `writeLn` / `$.writeln` / `alert` output, one entry per line. pub output: Vec, pub error: Option, + /// The script is waiting in a modal ScriptUI dialog's `show()` (it goes on when the dialog + /// closes; see [`crate::dispatch_ui`]). + pub waiting: bool, } /// An uncaught script error. @@ -42,36 +45,49 @@ impl Outcome { self.error.is_none() } pub fn to_json(&self) -> J { - json!({ + let mut j = json!({ "ok": self.ok(), "result": self.result, "output": self.output.join("\n"), "error": self.error.as_ref().map(|e| json!({"message": e.message, "line": e.line, "column": e.column, "file": e.file})), - }) + }); + if self.waiting { + j["waiting"] = json!(true); + } + j } } /// The session and per-run state while a script runs (the natives reach it here; nothing /// borrowed ever enters the JS heap). -struct Active { - session: Session, - output: Vec, +pub(crate) struct Active { + pub(crate) session: Session, + pub(crate) output: Vec, /// Open undo groups: (name, project snapshot when the outermost group began). - groups: Vec<(String, Arc)>, + pub(crate) groups: Vec<(String, Arc)>, /// `app.scheduleTask` queue: (id, code, repeat). - tasks: Vec<(u32, String, bool)>, - next_task: u32, - name: String, + pub(crate) tasks: Vec<(u32, String, bool)>, + pub(crate) next_task: u32, + pub(crate) name: String, + /// Running on a script-host thread: modal dialogs hand the session back to the caller + /// through this link while they wait. + pub(crate) link: Option, +} + +impl Active { + pub(crate) fn new(session: Session, name: &str) -> Active { + Active { session, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: name.to_string(), link: None } + } } thread_local! { - static ACTIVE: RefCell> = const { RefCell::new(None) }; + pub(crate) static ACTIVE: RefCell> = const { RefCell::new(None) }; /// The Script Console's persistent context (never dropped, like the expression runtime: boa's /// own thread-local heap may be torn down first at thread exit). - static CONSOLE: RefCell>> = const { RefCell::new(None) }; + pub(crate) static CONSOLE: RefCell>> = const { RefCell::new(None) }; } -fn with_active(f: impl FnOnce(&mut Active) -> Result) -> Result { +pub(crate) fn with_active(f: impl FnOnce(&mut Active) -> Result) -> Result { ACTIVE.with(|a| match a.try_borrow_mut() { Ok(mut g) => match g.as_mut() { Some(act) => f(act), @@ -81,11 +97,11 @@ fn with_active(f: impl FnOnce(&mut Active) -> Result) -> Result) -> boa_engine::JsError { +pub(crate) fn throw(msg: impl Into) -> boa_engine::JsError { JsNativeError::error().with_message(msg.into()).into() } -fn arg_string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { +pub(crate) fn arg_string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { Ok(args.get(i).cloned().unwrap_or_default().to_string(ctx)?.to_std_string_escaped()) } @@ -97,7 +113,7 @@ fn arg_json(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult { serde_json::from_str(&s).map_err(|e| throw(format!("bad parameters: {e}"))) } -fn js_str(s: &str) -> JsValue { +pub(crate) fn js_str(s: &str) -> JsValue { JsValue::from(JsString::from(s)) } @@ -205,7 +221,7 @@ fn native_undo(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult) { +pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc) { let h = &mut s.history; let Some(start) = h.undo.iter().rposition(|(_, p)| Arc::ptr_eq(p, snap)) else { // Nothing was recorded (or history was reset by New / Open Project). @@ -349,28 +365,32 @@ fn native_file(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult Result { +pub(crate) fn new_context() -> Result { let mut ctx = Context::default(); let limits = ctx.runtime_limits_mut(); limits.set_loop_iteration_limit(200_000_000); limits.set_recursion_limit(2000); - let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 6] = [ + let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult); 9] = [ (js_string!("__exec"), 2, native_exec), (js_string!("__query"), 2, native_query), (js_string!("__print"), 1, native_print), (js_string!("__undo"), 2, native_undo), (js_string!("__task"), 3, native_task), (js_string!("__file"), 3, native_file), + (js_string!("__uiNewId"), 0, crate::ui::native_new_id), + (js_string!("__uiModal"), 1, crate::ui::native_modal), + (js_string!("__uiLayoutNative"), 1, crate::ui::native_layout), ]; for (name, len, f) in natives { ctx.register_global_callable(name, len, NativeFunction::from_fn_ptr(f)).map_err(|e| e.to_string())?; } ctx.eval(Source::from_bytes(PRELUDE)).map_err(|e| format!("script prelude: {e}"))?; + ctx.eval(Source::from_bytes(crate::ui::SCRIPTUI)).map_err(|e| format!("ScriptUI prelude: {e}"))?; Ok(ctx) } /// Turn a boa error into a message with line and column (`code` is the user's script). -fn script_error(e: &boa_engine::JsError, ctx: &mut Context, file: &str, code: &str) -> ScriptError { +pub(crate) fn script_error(e: &boa_engine::JsError, ctx: &mut Context, file: &str, code: &str) -> ScriptError { // Thrown values: `throw new Error("x")`, `throw "x"`, native errors. let display = e.to_string(); let message = match e.as_opaque() { @@ -442,7 +462,7 @@ fn position(msg: &str, file: &str, code: &str) -> (Option, Option) { } /// Evaluate `code` in `ctx` and convert the result. -fn eval_in(ctx: &mut Context, code: &str, file: &str) -> Result { +pub(crate) fn eval_in(ctx: &mut Context, code: &str, file: &str) -> Result { let src = Source::from_bytes(code.as_bytes()).with_path(std::path::Path::new(file)); let script = boa_engine::Script::parse(src, None, ctx).map_err(|e| script_error(&e, ctx, file, code))?; let v = script.evaluate(ctx).map_err(|e| script_error(&e, ctx, file, code))?; @@ -466,8 +486,8 @@ fn result_json(v: &JsValue, ctx: &mut Context) -> J { } /// Restores the caller's session when a run ends (on every exit path). -struct Restore<'a> { - target: &'a mut Session, +pub(crate) struct Restore<'a> { + pub(crate) target: &'a mut Session, } impl Drop for Restore<'_> { @@ -478,8 +498,54 @@ impl Drop for Restore<'_> { } } +/// End of a script step: close undo groups left open, drop selections that no longer exist, +/// and take the output so far. +pub(crate) fn settle() -> Vec { + with_active(|a| { + if let Some((name, snap)) = a.groups.first().cloned() { + collapse(&mut a.session, &name, &snap); + a.groups.clear(); + } + a.session.sanitize_state(); + Ok(std::mem::take(&mut a.output)) + }) + .unwrap_or_default() +} + +/// Evaluate a script file's code, then the one-shot tasks it scheduled (`app.scheduleTask`). +pub(crate) fn eval_with_tasks(ctx: &mut Context, code: &str, file: &str) -> Result { + let mut r = eval_in(ctx, code, file); + let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default(); + for (_, code, repeat) in tasks { + if repeat { + continue; + } + if let Err(e) = eval_in(ctx, &code, "scheduled task") + && r.is_ok() + { + r = Err(e); + } + } + r +} + +pub(crate) fn outcome(output: Vec, result: Result) -> Outcome { + let mut out = Outcome { output, ..Default::default() }; + match result { + Ok(v) => out.result = v, + Err(e) => out.error = Some(e), + } + out +} + /// Run a script against `session`: the object model sees and edits that session through engine /// commands. Script errors come back in [`Outcome::error`]. +/// +/// On native builds a script file runs on its own script-host thread, so a ScriptUI dialog's +/// `show()` can wait for the user (the session comes back here meanwhile, and the dialog's +/// events reach the host through [`crate::dispatch_ui`]); palettes and panels keep their host +/// alive for their handlers. The Script Console (and the web build) run inline, where dialogs +/// don't block. pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome { let running = ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true)); if running { @@ -488,59 +554,49 @@ pub fn run(session: &mut Session, req: &ScriptRequest) -> Outcome { ..Default::default() }; } + #[cfg(not(target_arch = "wasm32"))] + if !req.console { + return crate::ui::run_threaded(session, req); + } + run_inline(session, req) +} + +/// Run on this thread (the Script Console's persistent context, or a fresh one). +pub(crate) fn run_inline(session: &mut Session, req: &ScriptRequest) -> Outcome { + let host = if req.console { crate::ui::CONSOLE_HOST } else { crate::ui::new_host_id() }; let s = std::mem::take(session); - ACTIVE.with(|a| *a.borrow_mut() = Some(Active { session: s, output: vec![], groups: vec![], tasks: vec![], next_task: 0, name: req.name.to_string() })); + ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, req.name))); let guard = Restore { target: session }; let file = if req.name.is_empty() { "script" } else { req.name }; - let mut result = if req.console { + let (result, windows) = if req.console { CONSOLE.with(|c| { let mut c = c.borrow_mut(); if c.is_none() { match new_context() { Ok(ctx) => *c = Some(ManuallyDrop::new(ctx)), - Err(e) => return Err(ScriptError { message: e, file: file.into(), ..Default::default() }), + Err(e) => return (Err(ScriptError { message: e, file: file.into(), ..Default::default() }), vec![]), } } let ctx = c.as_mut().map(|m| &mut **m).expect("console context"); - eval_in(ctx, req.code, file) + let r = eval_in(ctx, req.code, file); + (r, crate::ui::snapshot(ctx)) }) } else { match new_context() { Ok(mut ctx) => { - let r = eval_in(&mut ctx, req.code, file); - // app.scheduleTask: run the queued one-shot tasks after the script, in order. - let tasks = with_active(|a| Ok(std::mem::take(&mut a.tasks))).unwrap_or_default(); - let mut r = r; - for (_, code, repeat) in tasks { - if repeat { - continue; - } - if let Err(e) = eval_in(&mut ctx, &code, "scheduled task") - && r.is_ok() - { - r = Err(e); - } + let r = eval_with_tasks(&mut ctx, req.code, file); + let windows = crate::ui::snapshot(&mut ctx); + if windows.iter().any(|w| w.visible) { + // Palettes and panels outlive the run: keep their context for the handlers. + crate::ui::keep_inline(host, ctx); } - r + (r, windows) } - Err(e) => Err(ScriptError { message: e, file: file.into(), ..Default::default() }), + Err(e) => (Err(ScriptError { message: e, file: file.into(), ..Default::default() }), vec![]), } }; - // Close undo groups left open; drop selections that no longer exist. - let output = with_active(|a| { - if let Some((name, snap)) = a.groups.first().cloned() { - collapse(&mut a.session, &name, &snap); - a.groups.clear(); - } - a.session.sanitize_state(); - Ok(std::mem::take(&mut a.output)) - }) - .unwrap_or_default(); + let output = settle(); drop(guard); - let mut out = Outcome { output, ..Default::default() }; - match result.as_mut() { - Ok(v) => out.result = std::mem::take(v), - Err(e) => out.error = Some(e.clone()), - } - out + crate::ui::publish(session, host, req.name, windows); + outcome(output, result) } diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js new file mode 100644 index 000000000..e214be305 --- /dev/null +++ b/crates/script/src/scriptui.js @@ -0,0 +1,544 @@ +// EffectCraft scripting: ScriptUI, the dialog / panel object model After Effects scripts use +// (`new Window("dialog", "Title")`, `win.add("button", undefined, "OK")`, `onClick`, `show()`…). +// +// Written from the behaviour Adobe's public JavaScript Tools Guide documents. Controls are plain +// JavaScript objects; after each script step the host reads `__uiSnapshot()` (a JSON +// description of the open windows) and lays them out, frontends draw them, and user actions come +// back through `__uiDispatch(window, control, kind, valueJson)`. Modal dialogs block in +// `show()` (`__uiModal`) until they close, as in After Effects. + +var __uiWins = []; +// show() results of closed dialogs, by window id. +var __uiResults = {}; + +function __uiModalResult(id) { + var r = __uiResults[id]; + if (r !== undefined) { + delete __uiResults[id]; + return r; + } + return __uiWin(id) ? null : 2; +} + +function __dim(w, h) { + var d = [w, h]; + Object.defineProperty(d, "width", { get: function () { return this[0]; }, set: function (v) { this[0] = v; } }); + Object.defineProperty(d, "height", { get: function () { return this[1]; }, set: function (v) { this[1] = v; } }); + return d; +} +function __bounds4(l, t, r, b) { + var a = [l, t, r, b]; + Object.defineProperty(a, "left", { get: function () { return this[0]; } }); + Object.defineProperty(a, "top", { get: function () { return this[1]; } }); + Object.defineProperty(a, "right", { get: function () { return this[2]; } }); + Object.defineProperty(a, "bottom", { get: function () { return this[3]; } }); + Object.defineProperty(a, "x", { get: function () { return this[0]; } }); + Object.defineProperty(a, "y", { get: function () { return this[1]; } }); + Object.defineProperty(a, "width", { get: function () { return this[2] - this[0]; } }); + Object.defineProperty(a, "height", { get: function () { return this[3] - this[1]; } }); + return a; +} +function __toBounds(b) { + if (b === undefined || b === null) return null; + if (b.length === 4) return [__num(b[0]), __num(b[1]), __num(b[2]), __num(b[3])]; + if (b.width !== undefined) { + var x = b.x || b.left || 0, y = b.y || b.top || 0; + return [x, y, x + __num(b.width), y + __num(b.height)]; + } + return null; +} + +var __uiTypes = { + window: "Window", panel: "Panel", group: "Group", button: "Button", iconbutton: "IconButton", + statictext: "StaticText", edittext: "EditText", checkbox: "Checkbox", radiobutton: "RadioButton", + slider: "Slider", scrollbar: "Scrollbar", progressbar: "Progressbar", dropdownlist: "DropDownList", + listbox: "ListBox", tabbedpanel: "TabbedPanel", tab: "Tab", image: "ScriptUIImage" +}; +var __uiContainers = { window: 1, panel: 1, group: 1, tabbedpanel: 1, tab: 1 }; + +function Window(type, title, bounds, props) { + if (!(this instanceof Window)) return new Window(type, title, bounds, props); + type = String(type === undefined ? "window" : type).toLowerCase(); + if (type.indexOf("{") >= 0) throw __err("ScriptUI resource strings are not supported: build the window with add()"); + if (type !== "dialog" && type !== "palette" && type !== "window") throw __err("Bad window type " + type); + __uiInit(this, "window", null, null, bounds, title, props); + this.__kind = type; + this.__root = true; + __uiWins.push(this); +} +Window.alert = function (s) { alert(s); }; +Window.confirm = function (s) { return confirm(s); }; +Window.prompt = function (m, d) { return prompt(m, d); }; +Window.find = function (type, title) { + for (var i = 0; i < __uiWins.length; i++) if (__uiWins[i].text === title) return __uiWins[i]; + return null; +}; + +function Panel() {} +function Group() {} +function Button() {} +function IconButton() {} +function StaticText() {} +function EditText() {} +function Checkbox() {} +function RadioButton() {} +function Slider() {} +function Scrollbar() {} +function Progressbar() {} +function DropDownList() {} +function ListBox() {} +function TabbedPanel() {} +function Tab() {} +function ScriptUIImage() {} +function ListItem() {} + +var ScriptUI = { + version: "6.2.2", + frameworkName: "EffectCraft", + environment: { keyboardState: { shiftKey: false, ctrlKey: false, altKey: false, metaKey: false, keyName: "" } }, + FontStyle: { REGULAR: 0, BOLD: 1, ITALIC: 2, BOLDITALIC: 3 }, + BrushType: { SOLID_COLOR: 0, THEME_COLOR: 1 }, + PenType: { SOLID_COLOR: 0, THEME_COLOR: 1 }, + Alignment: { LEFT: "left", RIGHT: "right", TOP: "top", BOTTOM: "bottom", CENTER: "center", FILL: "fill" }, + newFont: function (name, style, size) { return { name: name, style: style, size: size }; }, + newImage: function () { return {}; }, + getResourceText: function (t) { return t; }, + events: { createEvent: function (t) { return { type: t }; } } +}; + +function __uiGraphics() { + return { + newBrush: function (t, c) { return { type: t, color: c }; }, + newPen: function (t, c, w) { return { type: t, color: c, lineWidth: w }; }, + newPath: function () {}, moveTo: function () {}, lineTo: function () {}, rectPath: function () {}, + ellipsePath: function () {}, fillPath: function () {}, strokePath: function () {}, closePath: function () {}, + drawString: function () {}, drawOSControl: function () {}, drawImage: function () {}, drawFocusRing: function () {}, + measureString: function (s) { return __dim(String(s).length * 7, 16); }, + font: null, foregroundColor: null, backgroundColor: null, disabledForegroundColor: null, disabledBackgroundColor: null + }; +} + +function __uiInit(o, type, win, parent, bounds, text, props) { + o.type = type; + o.window = win || o; + o.parent = parent || null; + o.__id = win ? win.__next++ : 0; + if (!win) { + o.__next = 1; + o.__wid = __uiNewId(); + } + o.properties = props || {}; + o.text = text === undefined || text === null ? "" : String(text); + o.enabled = true; + o.visible = true; + o.helpTip = ""; + o.children = []; + o.__ub = __toBounds(bounds); + o.__lb = [0, 0, 0, 0]; + o.preferredSize = __dim(-1, -1); + o.minimumSize = __dim(0, 0); + o.maximumSize = __dim(10000, 10000); + o.alignment = null; + o.graphics = __uiGraphics(); + o.__listeners = {}; + if (o.properties.name) o.name = o.properties.name; + if (__uiContainers[type]) { + o.orientation = type === "group" ? "row" : type === "tabbedpanel" ? "stack" : "column"; + o.alignChildren = type === "group" ? ["center", "center"] : ["center", "top"]; + o.spacing = 10; + o.margins = type === "window" ? [15, 15, 15, 15] : type === "panel" ? [10, 15, 10, 10] : type === "tab" ? [10, 10, 10, 10] : [0, 0, 0, 0]; + var self = o; + o.layout = { + layout: function () { __uiLayout(self.window); }, + resize: function () { __uiLayout(self.window); }, + margins: o.margins, + spacing: o.spacing + }; + } + if (type === "checkbox" || type === "radiobutton") o.value = !!o.properties.value; + if (type === "slider" || type === "scrollbar" || type === "progressbar") { + o.value = 0; + o.minvalue = 0; + o.maxvalue = 100; + } + if (type === "edittext") { + o.characters = 0; + o.textselection = ""; + o.active = false; + } + if (type === "dropdownlist" || type === "listbox") { + o.items = []; + o.__sel = []; + var it = o.properties.items; + if (it) for (var i = 0; i < it.length; i++) o.add("item", it[i]); + } + if (type === "tabbedpanel") o.__sel = []; + return o; +} + +function __uiProto() { + return { + add: function (type, bounds, text, props) { + var t = String(type).toLowerCase(); + if (t === "item" || t === "separator") return this.__addItem(t === "separator" ? "-" : bounds); + if (!__uiContainers[this.type]) throw __err("add(): a " + this.type + " can't contain controls"); + var ctor = __uiTypes[t]; + if (!ctor || t === "window") throw __err("add(): unknown control type " + type); + var c = Object.create(__ecGlobal[ctor].prototype); + // Value arguments after `text`: slider (value, min, max), progressbar (value, max), lists (items). + __uiInit(c, t, this.window, this, bounds, typeof text === "object" && text !== null && !(text instanceof Array) ? "" : text, props); + if ((t === "dropdownlist" || t === "listbox") && text instanceof Array) { + for (var i = 0; i < text.length; i++) c.add("item", text[i]); + c.text = ""; + } + if (t === "slider" || t === "scrollbar") { + c.text = ""; + c.value = text === undefined ? 0 : __num(text); + if (arguments.length > 3 && typeof props === "number") { + c.minvalue = props; + c.maxvalue = arguments[4] === undefined ? 100 : __num(arguments[4]); + c.properties = arguments[5] || {}; + } + } + if (t === "progressbar") { + c.text = ""; + c.value = text === undefined ? 0 : __num(text); + if (typeof props === "number") { + c.maxvalue = props; + c.properties = arguments[4] || {}; + } + } + if (t === "checkbox" || t === "radiobutton") c.value = false; + if (t === "edittext" && c.properties.multiline) c.__multiline = true; + if (t === "edittext" && c.properties.readonly) c.__readonly = true; + if (c.properties && c.properties.name) c.name = c.properties.name; + this.children.push(c); + if (t === "tab" && this.type === "tabbedpanel" && this.__sel.length === 0) this.__sel = [this.children.length - 1]; + return c; + }, + remove: function (what) { + var list = this.items && this.type !== "tabbedpanel" ? this.items : this.children; + var i = typeof what === "number" ? what : list.indexOf(what); + if (i < 0 || i >= list.length) return; + list.splice(i, 1); + if (this.items) { + for (var k = 0; k < this.items.length; k++) this.items[k].index = k; + this.__sel = this.__sel.filter(function (s) { return s !== i; }).map(function (s) { return s > i ? s - 1 : s; }); + } + }, + removeAll: function () { + if (this.items) { + this.items = []; + this.__sel = []; + } else this.children = []; + }, + find: function (text) { + var list = this.items || this.children; + for (var i = 0; i < list.length; i++) if (list[i].text === text) return list[i]; + return null; + }, + __addItem: function (text) { + if (!this.items) throw __err("add(\"item\"): only lists have items"); + var it = Object.create(ListItem.prototype); + it.text = String(text === undefined ? "" : text); + it.type = text === "-" ? "separator" : "item"; + it.index = this.items.length; + it.parent = this; + it.image = null; + it.checked = false; + this.items.push(it); + return it; + }, + show: function () { + if (!this.__root) { + this.visible = true; + return; + } + this.__shown = true; + this.__closed = false; + this.__fire("onShow"); + if (this.__kind === "dialog") { + this.__modal = true; + var r = __uiModal(this.__wid); + this.__modal = false; + return r === null ? undefined : r; + } + return undefined; + }, + hide: function () { + if (this.__root) { + this.close(2); + } else this.visible = false; + }, + close: function (result) { + if (!this.__root) return; + if (this.__fire("onClose") === false) return; + this.__closed = true; + this.__shown = false; + this.__result = result === undefined ? 0 : result; + if (this.__modal) __uiResults[this.__wid] = this.__result; + }, + center: function () {}, + notify: function (ev) { + var e = ev || (this.type === "edittext" || this.type === "slider" || this.type === "dropdownlist" || this.type === "listbox" ? "onChange" : "onClick"); + if (e.indexOf("on") !== 0) e = "on" + e.charAt(0).toUpperCase() + e.slice(1); + this.__fire(e); + }, + addEventListener: function (name, fn) { + var k = String(name).toLowerCase(); + (this.__listeners[k] = this.__listeners[k] || []).push(fn); + }, + removeEventListener: function (name, fn) { + var k = String(name).toLowerCase(); + if (this.__listeners[k]) this.__listeners[k] = this.__listeners[k].filter(function (f) { return f !== fn; }); + }, + dispatchEvent: function (e) { this.__fire("on" + String(e.type || e).charAt(0).toUpperCase() + String(e.type || e).slice(1)); }, + __fire: function (name) { + var r; + if (typeof this[name] === "function") r = this[name].call(this); + var l = this.__listeners[name.slice(2).toLowerCase()]; + if (l) for (var i = 0; i < l.length; i++) l[i].call(this, { type: name.slice(2).toLowerCase(), target: this }); + return r; + }, + __handlers: function () { + var out = []; + var names = ["onClick", "onChange", "onChanging", "onDoubleClick", "onClose", "onShow", "onActivate", "onDeactivate", "onResize"]; + for (var i = 0; i < names.length; i++) { + var n = names[i]; + if (typeof this[n] === "function" || (this.__listeners[n.slice(2).toLowerCase()] || []).length) out.push(n); + } + return out; + }, + toString: function () { return "[object " + (__uiTypes[this.type] || "Window") + "]"; } + }; +} + +(function () { + var all = ["Window", "Panel", "Group", "Button", "IconButton", "StaticText", "EditText", "Checkbox", "RadioButton", "Slider", + "Scrollbar", "Progressbar", "DropDownList", "ListBox", "TabbedPanel", "Tab", "ScriptUIImage"]; + for (var i = 0; i < all.length; i++) { + var C = __ecGlobal[all[i]]; + var p = __uiProto(); + for (var k in p) C.prototype[k] = p[k]; + Object.defineProperty(C.prototype, "bounds", { + get: function () { var b = this.__ub || this.__lb; return __bounds4(b[0], b[1], b[2], b[3]); }, + set: function (v) { this.__ub = __toBounds(v); }, + configurable: true + }); + Object.defineProperty(C.prototype, "size", { + get: function () { var b = this.bounds; return __dim(b[2] - b[0], b[3] - b[1]); }, + set: function (v) { + var b = this.bounds; + var w = v.width !== undefined ? v.width : v[0], h = v.height !== undefined ? v.height : v[1]; + this.__ub = [b[0], b[1], b[0] + __num(w), b[1] + __num(h)]; + }, + configurable: true + }); + Object.defineProperty(C.prototype, "location", { + get: function () { var b = this.bounds; return __dim(b[0], b[1]); }, + set: function (v) { + var b = this.bounds; + var x = v.x !== undefined ? v.x : v[0], y = v.y !== undefined ? v.y : v[1]; + this.__ub = [__num(x), __num(y), __num(x) + b[2] - b[0], __num(y) + b[3] - b[1]]; + }, + configurable: true + }); + } + var sel = { + get: function () { + var self = this; + if (this.type === "tabbedpanel") return this.__sel.length ? this.children[this.__sel[0]] : null; + var items = this.__sel.map(function (i) { return self.items[i]; }); + if (this.properties.multiselect) return items.length ? items : null; + return items.length ? items[0] : null; + }, + set: function (v) { + var self = this; + var list = this.type === "tabbedpanel" ? this.children : this.items; + var idx = function (x) { return typeof x === "number" ? x : list.indexOf(x); }; + if (v === null || v === undefined) this.__sel = []; + else if (v instanceof Array) this.__sel = v.map(idx).filter(function (i) { return i >= 0 && i < list.length; }); + else { + var i = idx(v); + this.__sel = i >= 0 && i < list.length ? [i] : []; + } + if (this.items) for (var k = 0; k < this.items.length; k++) this.items[k].selected = this.__sel.indexOf(k) >= 0; + }, + configurable: true + }; + Object.defineProperty(DropDownList.prototype, "selection", sel); + Object.defineProperty(ListBox.prototype, "selection", sel); + Object.defineProperty(TabbedPanel.prototype, "selection", sel); + Object.defineProperty(ListItem.prototype, "toString", { value: function () { return this.text; } }); +})(); + +// A dockable ScriptUI panel (scripts in the ScriptUI Panels folder run with `this` = it). +function __uiDockPanel(title) { + var p = Object.create(Panel.prototype); + __uiInit(p, "panel", null, null, undefined, title, {}); + p.type = "panel"; + p.__kind = "panel"; + p.__root = true; + p.__shown = true; + p.margins = [10, 10, 10, 10]; + __uiWins.push(p); + return p; +} + +function __uiAlign(v) { + if (v === null || v === undefined) return []; + if (v instanceof Array) return v.map(function (x) { return String(x).toLowerCase(); }); + return [String(v).toLowerCase()]; +} +function __uiMargins(m) { + if (m === null || m === undefined) return null; + if (typeof m === "number") return [m, m, m, m]; + if (m.length === 4) return [m[0], m[1], m[2], m[3]]; + return null; +} +function __uiSize(d) { + if (!d) return null; + var w = d.width !== undefined ? d.width : d[0], h = d.height !== undefined ? d.height : d[1]; + w = Number(w); + h = Number(h); + if (!(w > 0) && !(h > 0)) return null; + return [w > 0 ? w : 0, h > 0 ? h : 0]; +} + +function __uiJson(c) { + var ps = __uiSize(c.preferredSize); + if (c.type === "edittext" && c.characters > 0) ps = [c.characters * 7 + 12, ps ? ps[1] : 0]; + var o = { + id: c.__id, + type: c.type, + name: String(c.name || c.properties.name || ""), + text: String(c.text === undefined || c.text === null ? "" : c.text), + enabled: c.enabled !== false, + visible: c.visible !== false, + helpTip: String(c.helpTip || ""), + handlers: c.__handlers(), + children: [] + }; + if (ps) o.preferredSize = ps; + if (c.__ub && !c.__root) o.fixedBounds = c.__ub; + if (c.__root && c.__ub) o.fixedBounds = [0, 0, c.__ub[2] - c.__ub[0], c.__ub[3] - c.__ub[1]]; + if (__uiContainers[c.type]) { + o.orientation = String(c.orientation || "").toLowerCase(); + o.alignChildren = __uiAlign(c.alignChildren); + var m = __uiMargins(c.margins); + if (m) o.margins = m; + if (typeof c.spacing === "number") o.spacing = c.spacing; + var kids = c.children; + for (var i = 0; i < kids.length; i++) o.children.push(__uiJson(kids[i])); + } + if (c.alignment !== null && c.alignment !== undefined) o.alignment = __uiAlign(c.alignment); + if (c.type === "checkbox" || c.type === "radiobutton") o.checked = !!c.value; + if (c.type === "slider" || c.type === "scrollbar" || c.type === "progressbar") { + o.value = Number(c.value) || 0; + o.min = Number(c.minvalue) || 0; + o.max = Number(c.maxvalue); + if (!(o.max === o.max)) o.max = 100; + } + if (c.items) { + o.items = c.items.map(function (it) { return String(it.text); }); + o.selection = c.__sel.slice(); + } + if (c.type === "tabbedpanel") o.activeTab = c.__sel.length ? c.__sel[0] : 0; + if (c.type === "edittext") { + o.multiline = !!c.__multiline; + o.readOnly = !!c.__readonly; + } + return o; +} + +// The open windows of this script, for the host. +function __uiSnapshot() { + var out = []; + for (var i = 0; i < __uiWins.length; i++) { + var w = __uiWins[i]; + out.push({ id: w.__wid, kind: w.__kind, title: String(w.text || ""), visible: !!w.__shown && !w.__closed, modal: !!w.__modal, root: __uiJson(w) }); + } + // Closed windows are forgotten. + __uiWins = __uiWins.filter(function (w) { return !w.__closed; }); + return JSON.stringify(out); +} + +function __uiOpenCount() { + var n = 0; + for (var i = 0; i < __uiWins.length; i++) if (__uiWins[i].__shown && !__uiWins[i].__closed) n++; + return n; +} + +function __uiWin(id) { + for (var i = 0; i < __uiWins.length; i++) if (__uiWins[i].__wid === id) return __uiWins[i]; + return null; +} +function __uiFind(c, id) { + if (c.__id === id) return c; + for (var i = 0; i < (c.children || []).length; i++) { + var r = __uiFind(c.children[i], id); + if (r) return r; + } + return null; +} + +// layout.layout(): lay the window out now (the host does it before drawing anyway) so scripts +// can read bounds and sizes. +function __uiLayout(win) { + var b = JSON.parse(__uiLayoutNative(JSON.stringify({ id: win.__wid, kind: win.__kind, title: String(win.text || ""), visible: true, root: __uiJson(win) }))); + // Bounds are relative to the parent container, as in ScriptUI. + (function apply(c, ox, oy) { + var r = b[c.__id]; + if (!r) return; + c.__lb = [r[0] - ox, r[1] - oy, r[0] - ox + r[2], r[1] - oy + r[3]]; + for (var i = 0; i < (c.children || []).length; i++) apply(c.children[i], r[0], r[1]); + })(win, 0, 0); +} + +// A user action from the frontend. +function __uiDispatch(winId, id, kind, valueJson) { + var w = __uiWin(winId); + if (!w) return; + var v = valueJson === undefined || valueJson === "" ? null : JSON.parse(valueJson); + if (kind === "close") { + w.close(v === null ? 2 : v); + return; + } + var c = __uiFind(w, id); + if (!c || c.enabled === false) return; + var radio = function () { + var sib = c.parent ? c.parent.children : []; + for (var i = 0; i < sib.length; i++) if (sib[i] !== c && sib[i].type === "radiobutton") sib[i].value = false; + c.value = true; + }; + if (kind === "click") { + if (c.type === "checkbox") c.value = !c.value; + else if (c.type === "radiobutton") radio(); + else if (c.type === "tab" && c.parent) { + c.parent.selection = c; + c.parent.__fire("onChange"); + return; + } + var handled = c.__handlers().indexOf("onClick") >= 0; + c.__fire("onClick"); + // Dialog buttons without handlers: OK closes with 1, Cancel with 2. + if (!handled && c.type === "button" && w.__kind === "dialog") { + var n = String(c.name || c.properties.name || c.text).toLowerCase(); + if (c === w.defaultElement || n === "ok") w.close(1); + else if (c === w.cancelElement || n === "cancel") w.close(2); + } + return; + } + // change / changing + if (c.type === "edittext") c.text = v === null ? "" : String(v); + else if (c.type === "statictext") c.text = v === null ? "" : String(v); + else if (c.type === "slider" || c.type === "scrollbar" || c.type === "progressbar") c.value = Number(v); + else if (c.type === "checkbox") c.value = !!v; + else if (c.type === "radiobutton") { if (v) radio(); else c.value = false; } + else if (c.type === "dropdownlist" || c.type === "listbox" || c.type === "tabbedpanel") c.selection = v; + if (c.type === "edittext" || c.type === "slider" || c.type === "scrollbar") c.__fire("onChanging"); + if (kind === "change") { + if (c.type === "checkbox" || c.type === "radiobutton") c.__fire("onClick"); + else c.__fire("onChange"); + } +} diff --git a/crates/script/src/tests_ui.rs b/crates/script/src/tests_ui.rs new file mode 100644 index 000000000..667a32423 --- /dev/null +++ b/crates/script/src/tests_ui.rs @@ -0,0 +1,299 @@ +//! ScriptUI: windows built by scripts are published to the session, drawn by frontends and +//! driven through the `scriptui.*` commands; dialogs block `show()` until they close; dockable +//! panels; File ▸ Scripts (installed and sample scripts). + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::scriptui::{WidgetKind, WindowKind}; +use serde_json::{Value, json}; + +use crate::run_code; + +fn session() -> Session { + let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() }; + crate::install(&mut s); + s.config = Some(Arc::new(effectcraft_engine::config::MemoryConfig::default())); + s +} + +fn comp_names(s: &Session) -> Vec { + s.project.items.values().filter(|i| i.as_comp().is_some()).map(|i| i.name.clone()).collect() +} + +fn layer_names(s: &Session) -> Vec { + s.active_comp().map(|c| c.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default() +} + +const PALETTE: &str = r#" +var made = 0; +var w = new Window("palette", "Maker"); +w.orientation = "column"; +w.alignChildren = ["fill", "top"]; +var name = w.add("edittext", undefined, "Box"); +name.characters = 12; +var big = w.add("checkbox", undefined, "Big"); +var row = w.add("group"); +var make = row.add("button", undefined, "Make", { name: "make" }); +var label = row.add("statictext", undefined, "none yet"); +var size = w.add("slider", undefined, 50, 10, 200); +var kind = w.add("dropdownlist", undefined, ["Solid", "Null"]); +kind.selection = 0; +var echo = ""; +name.onChange = function () { echo = name.text; }; +make.onClick = function () { + var c = app.project.activeItem; + var s = big.value ? 200 : Math.round(size.value); + if (kind.selection.index === 0) c.layers.addSolid([1, 0, 0], name.text, s, s, 1); + else c.layers.addNull().name = name.text; + made++; + label.text = "made " + made; + writeLn("made " + name.text + " " + s); +}; +w.show(); +"palette up" +"#; + +#[test] +fn palette_controls_run_their_handlers() { + let mut s = session(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 2})).unwrap(); + let o = run_code(&mut s, PALETTE, "maker.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!("palette up")); + assert_eq!(s.script_ui.windows.len(), 1); + let w = s.script_ui.windows[0].clone(); + assert_eq!((w.kind, w.title.as_str(), w.script.as_str()), (WindowKind::Palette, "Maker", "maker.jsx")); + let kinds: Vec = w.root.children.iter().map(|c| c.kind).collect(); + assert_eq!(kinds, [WidgetKind::EditText, WidgetKind::Checkbox, WidgetKind::Group, WidgetKind::Slider, WidgetKind::DropDownList]); + // Laid out: the window has a size, the fill-aligned controls span its width. + assert!(w.root.bounds[2] > 100.0 && w.root.bounds[3] > 100.0, "{:?}", w.root.bounds); + let edit = &w.root.children[0]; + assert_eq!(edit.bounds[2], w.root.bounds[2] - 30.0); + assert_eq!(w.root.children[3].value, 50.0); + assert_eq!((w.root.children[3].min, w.root.children[3].max), (10.0, 200.0)); + assert_eq!(w.root.children[4].items, ["Solid", "Null"]); + assert_eq!(w.root.children[4].selection, [0]); + assert!(w.root.children[2].children[0].handlers.contains(&"onClick".to_string())); + // Agents: list, read, click. + let list = s.execute("scriptui.list", json!({})).unwrap(); + assert_eq!(list[0]["title"], "Maker"); + let tree = s.execute("scriptui.get", json!({"window": w.id})).unwrap(); + assert_eq!(tree["root"]["children"][2]["children"][0]["name"], "make"); + let r = s.execute("scriptui.click", json!({"widget": "make"})).unwrap(); + assert_eq!(r["output"], "made Box 50", "{r}"); + assert_eq!(layer_names(&s), ["Box"]); + // Handlers' edits are undoable engine commands. + assert!(s.undo()); + assert!(layer_names(&s).is_empty()); + // Set values like a user: text (onChange), checkbox, slider, list by item text. + s.execute("scriptui.set", json!({"widget": "#1", "value": "Crate"})).unwrap(); + s.execute("scriptui.click", json!({"widget": "Big"})).unwrap(); + s.execute("scriptui.set", json!({"window": "Maker", "widget": 7, "value": "Null"})).unwrap(); + let r = s.execute("scriptui.click", json!({"widget": "make"})).unwrap(); + assert_eq!(r["output"], "made Crate 200"); + assert_eq!(layer_names(&s), ["Crate"]); + assert!(s.active_comp().unwrap().layers[0].source == effectcraft_engine::project::LayerSource::Null); + // The handler updated a label: the published tree follows. + let w = s.script_ui.windows[0].clone(); + assert_eq!(w.root.children[2].children[1].text, "made 2"); + assert!(w.root.children[1].checked); + assert_eq!(w.root.children[4].selection, [1]); + // Errors. + assert!(s.execute("scriptui.click", json!({"widget": "nope"})).is_err()); + assert!(s.execute("scriptui.get", json!({"window": 999_999})).is_err()); + // Closing the window ends the script host. + s.execute("scriptui.close", json!({})).unwrap(); + assert!(s.script_ui.windows.is_empty()); + assert!(s.execute("scriptui.list", json!({})).unwrap().as_array().unwrap().is_empty()); +} + +const DIALOG: &str = r#" +var d = new Window("dialog", "New Comp"); +d.alignChildren = "left"; +var e = d.add("edittext", undefined, "Untitled"); +var fps = d.add("radiobutton", undefined, "24 fps"); +var fps30 = d.add("radiobutton", undefined, "30 fps"); +fps.value = true; +var g = d.add("group"); +g.add("button", undefined, "Cancel", { name: "cancel" }); +var ok = g.add("button", undefined, "OK", { name: "ok" }); +writeLn("before"); +var r = d.show(); +writeLn("result " + r + " " + e.text + " " + (fps30.value ? 30 : 24)); +if (r === 1) app.project.items.addComp(e.text, 100, 100, 1, 1, fps30.value ? 30 : 24); +r +"#; + +#[test] +fn dialogs_block_show_until_they_close() { + let mut s = session(); + let o = run_code(&mut s, DIALOG, "dialog.jsx"); + // The script waits in show(); the session is back here meanwhile. + assert!(o.waiting, "{o:?}"); + assert_eq!(o.output, ["before"]); + assert_eq!(s.script_ui.windows.len(), 1); + let w = s.script_ui.windows[0].clone(); + assert!(w.modal && w.kind == WindowKind::Dialog); + // The app keeps working while the dialog is up. + s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64, "duration": 1})).unwrap(); + s.execute("scriptui.set", json!({"widget": "#1", "value": "Shot 010"})).unwrap(); + s.execute("scriptui.click", json!({"widget": "30 fps"})).unwrap(); + let w = s.script_ui.windows[0].clone(); + assert!(!w.root.children[1].checked && w.root.children[2].checked, "radio buttons are exclusive"); + // OK closes the dialog with 1: the script runs on. + let r = s.execute("scriptui.click", json!({"widget": "ok"})).unwrap(); + assert_eq!(r["output"], "result 1 Shot 010 30", "{r}"); + assert_eq!(r["result"], 1); + assert!(r.get("waiting").is_none()); + assert!(s.script_ui.windows.is_empty()); + let names = comp_names(&s); + assert!(names.contains(&"Shot 010".to_string()) && names.contains(&"Other".to_string()), "{names:?}"); + // Cancel (or closing the window) returns 2. + let o = run_code(&mut s, DIALOG, "dialog.jsx"); + assert!(o.waiting); + let r = s.execute("scriptui.close", json!({})).unwrap(); + assert_eq!(r["output"], "result 2 Untitled 24"); + assert_eq!(comp_names(&s).len(), 2); +} + +#[test] +fn layout_and_handler_errors() { + let mut s = session(); + let code = r#" + var w = new Window("palette", "L"); + var g = w.add("group"); + g.orientation = "row"; + var a = g.add("button", undefined, "A"); + var b = g.add("button", undefined, "B"); + b.preferredSize = [120, 30]; + var p = w.add("panel", undefined, "Options"); + p.add("checkbox", undefined, "One"); + var bad = w.add("button", undefined, "Bad"); + bad.onClick = function () { nope(); }; + w.layout.layout(true); + w.show(); + [a.bounds.width, b.size.width, b.size.height, b.bounds[0] - a.bounds[0], w.bounds.width > 200, p.bounds.top > a.bounds.bottom] + "#; + let o = run_code(&mut s, code, "layout.jsx"); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(o.result, json!([80, 120, 30, 90, true, true])); + // An error in a handler is reported, the window stays. + let r = s.execute("scriptui.click", json!({"widget": "Bad"})).unwrap(); + assert_eq!(r["ok"], false); + assert!(r["error"]["message"].as_str().unwrap().contains("nope"), "{r}"); + assert_eq!(s.script_ui.windows.len(), 1); + // Resource strings aren't supported (a clear error). + let o = run_code(&mut s, "new Window(\"dialog { text: 'x' }\")", "res.jsx"); + assert!(o.error.unwrap().message.contains("resource")); + s.execute("scriptui.close", json!({})).unwrap(); +} + +#[test] +fn console_windows_run_inline() { + let mut s = session(); + let req = effectcraft_engine::ScriptRequest { + code: "var cw = new Window('palette', 'Console UI'); var cb = cw.add('button', undefined, 'Hi'); var hits = 0; cb.onClick = function () { hits++; writeLn('hit ' + hits); }; cw.show(); 1", + name: "Script Console", + console: true, + }; + let o = crate::run(&mut s, &req); + assert!(o.error.is_none(), "{:?}", o.error); + assert_eq!(s.script_ui.windows.len(), 1); + let r = s.execute("scriptui.click", json!({"widget": "Hi"})).unwrap(); + assert_eq!(r["output"], "hit 1"); + // The console context sees the handler's state. + let o = crate::run(&mut s, &effectcraft_engine::ScriptRequest { code: "hits", name: "Script Console", console: true }); + assert_eq!(o.result, json!(1)); + // Dialogs don't block in the console. + let o = crate::run( + &mut s, + &effectcraft_engine::ScriptRequest { code: "var dd = new Window('dialog', 'D'); dd.show(); 'went on'", name: "Script Console", console: true }, + ); + assert_eq!(o.result, json!("went on")); + assert_eq!(s.script_ui.windows.len(), 2); + s.execute("scriptui.close", json!({"window": "D"})).unwrap(); + s.execute("scriptui.close", json!({"window": "Console UI"})).unwrap(); + assert!(s.script_ui.windows.is_empty()); +} + +fn solid(s: &mut Session, name: &str, at: f64) -> u64 { + let id = s.execute("layer.newSolid", json!({"name": name, "color": "#808080", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layers": [id], "in": at})).unwrap(); + id +} + +#[test] +fn sample_scripts_and_installing() { + let mut s = session(); + s.execute("comp.new", json!({"name": "Main", "width": 400, "height": 200, "duration": 10})).unwrap(); + for (n, at) in [("C", 2.0), ("A", 0.0), ("B", 1.0)] { + solid(&mut s, n, at); + } + // File ▸ Scripts lists the samples. + let list = s.execute("file.scripts.list", json!({})).unwrap(); + let names: Vec<&str> = list.as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap()).collect(); + for want in ["Rename Layers.jsx", "Sort Layers by In Point.jsx", "Create Null at Selected Layers.jsx", "Render Queue Batch.jsx", "Layer Tools.jsx"] { + assert!(names.contains(&want), "{names:?}"); + } + // Sort Layers by In Point (all layers when none are selected). + s.execute("edit.deselectAll", json!({})).unwrap(); + s.execute("file.runScript", json!({"name": "Sort Layers by In Point.jsx"})).unwrap(); + assert_eq!(layer_names(&s), ["A", "B", "C"]); + assert!(s.undo()); + assert_eq!(layer_names(&s), ["B", "A", "C"]); + // Create Null at Selected Layers. + let ids: Vec = s.active_comp().unwrap().layers.iter().take(2).map(|l| l.id.0).collect(); + s.execute("layer.select", json!({"layers": ids})).unwrap(); + s.execute("file.runScript", json!({"name": "Create Null at Selected Layers.jsx"})).unwrap(); + let c = s.active_comp().unwrap(); + let null = c.layers.iter().find(|l| l.name == "Centroid").unwrap(); + assert!(c.layers.iter().filter(|l| ids.contains(&l.id.0)).all(|l| l.parent == Some(null.id))); + // Rename Layers: a dialog; fill it in and press OK. + s.execute("layer.select", json!({"layers": ids})).unwrap(); + let r = s.execute("file.runScript", json!({"name": "Rename Layers.jsx"})).unwrap(); + assert_eq!(r["waiting"], true); + s.execute("scriptui.set", json!({"widget": "#11", "value": "Shot_"})).unwrap(); + s.execute("scriptui.click", json!({"widget": "Number the layers"})).unwrap(); + s.execute("scriptui.click", json!({"widget": "ok"})).unwrap(); + let names = layer_names(&s); + assert!(names.contains(&"Shot_B 1".to_string()) && names.contains(&"Shot_A 2".to_string()), "{names:?}"); + // Install a script and a ScriptUI panel (they live in the settings store). + let dir = std::env::temp_dir().join(format!("ec-scripts-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Hello.jsx"); + std::fs::write(&file, "writeLn('hello from ' + $.fileName); 42").unwrap(); + s.execute("file.installScript", json!({"path": file.to_string_lossy()})).unwrap(); + let panel = dir.join("Mini Panel.jsx"); + std::fs::write(&panel, "var p = this; p.add('button', undefined, 'Go').onClick = function () { writeLn('go'); }; p.layout.layout(true);").unwrap(); + s.execute("file.installScriptUIPanel", json!({"path": panel.to_string_lossy()})).unwrap(); + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Hello.jsx" && e["source"] == "installed" && e["panel"] == false)); + assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Mini Panel.jsx" && e["panel"] == true)); + let r = s.execute("file.runScript", json!({"name": "Hello.jsx"})).unwrap(); + assert_eq!(r["result"], 42); + // Window ▸ Mini Panel.jsx: a dockable panel; `this` is the panel. + let r = s.execute("window.scriptPanel", json!({"name": "Mini Panel.jsx"})).unwrap(); + let wid = r["window"].as_u64().unwrap(); + let w = s.script_ui.window(wid as u32).unwrap().clone(); + assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Mini Panel")); + let r = s.execute("scriptui.click", json!({"window": wid, "widget": "Go"})).unwrap(); + assert_eq!(r["output"], "go"); + // Opening it again brings the same panel forward. + let r = s.execute("window.scriptPanel", json!({"name": "Mini Panel.jsx"})).unwrap(); + assert_eq!(r["window"], wid); + // The sample panel docks too, and works. + let r = s.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap(); + let tools = r["window"].as_u64().unwrap(); + let before = s.active_comp().unwrap().layers.len(); + s.execute("layer.select", json!({"layers": ids})).unwrap(); + s.execute("scriptui.click", json!({"window": tools, "widget": "Null at Centroid"})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers.len(), before + 1); + s.execute("scriptui.close", json!({"window": tools})).unwrap(); + s.execute("scriptui.close", json!({"window": wid})).unwrap(); + assert!(s.execute("file.installScript", json!({"path": "/nope/x.txt"})).is_err()); + s.execute("file.uninstallScript", json!({"name": "Hello.jsx"})).unwrap(); + assert!(s.execute("file.runScript", json!({"name": "Hello.jsx"})).is_err()); + let _ = std::fs::remove_dir_all(&dir); + let _: Value = json!(null); +} diff --git a/crates/script/src/ui.rs b/crates/script/src/ui.rs new file mode 100644 index 000000000..66574ce06 --- /dev/null +++ b/crates/script/src/ui.rs @@ -0,0 +1,339 @@ +//! ScriptUI hosts: who owns a script window's JavaScript objects, and how user actions reach +//! its handlers. +//! +//! * **Script files** (native builds) run on a *script-host thread*. The caller's session moves +//! to that thread while JavaScript runs and comes back whenever the script stops: at the end, +//! or while a modal dialog waits in `show()`. Each [`ScriptUiEvent`] moves the session there +//! again, runs the handler, and brings it back with the windows' new state. The host ends +//! when its last window closes. Only one side holds the session at a time, so nothing runs +//! concurrently and every edit is still an ordinary undoable engine command. +//! * **The Script Console** (and every run on the web) runs inline: its windows' handlers run in +//! the console's (or a kept) context on the caller's thread, and dialogs don't block. + +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::mem::ManuallyDrop; +use std::sync::atomic::{AtomicU32, Ordering}; + +use boa_engine::{Context, JsResult, JsValue, js_string}; +use effectcraft_engine::Session; +use effectcraft_engine::scriptui::{ScriptUiEvent, ScriptWindow, layout}; +use serde_json::{Value as J, json}; + +use crate::runtime::{ACTIVE, Active, CONSOLE, Restore, ScriptError, arg_string, js_str, outcome, script_error, settle, throw, with_active}; + +pub(crate) const SCRIPTUI: &str = include_str!("scriptui.js"); + +/// The Script Console's host id. +pub(crate) const CONSOLE_HOST: u32 = u32::MAX; + +static NEXT_WINDOW: AtomicU32 = AtomicU32::new(1); +static NEXT_HOST: AtomicU32 = AtomicU32::new(1); + +pub(crate) fn new_host_id() -> u32 { + NEXT_HOST.fetch_add(1, Ordering::Relaxed) +} + +thread_local! { + /// Contexts of inline runs whose palettes / panels are still open (dropped when they close). + static INLINE: RefCell>> = const { RefCell::new(BTreeMap::new()) }; +} + +pub(crate) fn keep_inline(host: u32, ctx: Context) { + INLINE.with(|m| m.borrow_mut().insert(host, ManuallyDrop::new(ctx))); +} + +fn drop_inline(host: u32) { + if let Some(ctx) = INLINE.with(|m| m.borrow_mut().remove(&host)) { + drop(ManuallyDrop::into_inner(ctx)); + } +} + +// ---------------------------------------------------------------- JavaScript side + +fn call_global(ctx: &mut Context, name: &str, args: &[JsValue]) -> JsResult { + let f = ctx.global_object().get(js_string!(name), ctx)?; + let f = f.as_object().ok_or_else(|| throw(format!("{name} is not defined")))?; + f.call(&JsValue::undefined(), args, ctx) +} + +/// The open windows of a context (closed ones are reported once, invisible, then forgotten). +pub(crate) fn snapshot(ctx: &mut Context) -> Vec { + let Ok(v) = call_global(ctx, "__uiSnapshot", &[]) else { return vec![] }; + let s = v.to_string(ctx).map(|s| s.to_std_string_escaped()).unwrap_or_default(); + serde_json::from_str(&s).unwrap_or_default() +} + +/// Run a window event's handlers in `ctx`. +fn dispatch_js(ctx: &mut Context, ev: &ScriptUiEvent) -> Result { + let args = [JsValue::from(ev.window), JsValue::from(ev.widget), js_str(&ev.kind), js_str(&ev.value.to_string())]; + match call_global(ctx, "__uiDispatch", &args) { + Ok(_) => Ok(J::Null), + Err(e) => { + let mut err = script_error(&e, ctx, "ScriptUI handler", ""); + err.message = format!("{} handler: {}", ev.kind, err.message); + Err(err) + } + } +} + +/// `__uiNewId()`: a window id unique in the process. +pub(crate) fn native_new_id(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult { + Ok(JsValue::from(NEXT_WINDOW.fetch_add(1, Ordering::Relaxed))) +} + +/// `__uiLayoutNative(windowJson)` → `{controlId: [x, y, width, height]}`: ScriptUI's automatic +/// layout ([`effectcraft_engine::scriptui::layout`]) for `layout.layout()`. +pub(crate) fn native_layout(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let s = arg_string(args, 0, ctx)?; + let mut w: ScriptWindow = serde_json::from_str(&s).map_err(|e| throw(format!("layout: {e}")))?; + layout(&mut w); + let mut all = vec![]; + w.root.walk(&mut all); + let map: serde_json::Map = all.iter().map(|c| (c.id.to_string(), json!(c.bounds))).collect(); + Ok(js_str(&J::Object(map).to_string())) +} + +/// `__uiModal(windowId)`: a dialog's `show()`. On a script-host thread it hands the session back +/// to the caller and waits for the dialog's events until it closes, returning its result. Inline +/// (Script Console, web) it returns `null` at once: the dialog stays open like a palette. +pub(crate) fn native_modal(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { + let id = args.first().cloned().unwrap_or_default().to_number(ctx)? as u32; + let linked = with_active(|a| Ok(a.link.is_some())).unwrap_or(false); + if !linked { + return Ok(JsValue::null()); + } + loop { + let r = call_global(ctx, "__uiModalResult", &[JsValue::from(id)])?; + if !r.is_null() && !r.is_undefined() { + return Ok(r); + } + let windows = snapshot(ctx); + let out = crate::Outcome { output: settle(), waiting: true, ..Default::default() }; + let Some(ev) = yield_wait(out, windows, false) else { + // The caller went away: cancel the dialog. + return Ok(JsValue::from(2)); + }; + if let Err(e) = dispatch_js(ctx, &ev) { + let line = e.line.map(|l| format!(" (line {l})")).unwrap_or_default(); + let _ = with_active(|a| { + a.output.push(format!("Error{line}: {}", e.message)); + Ok(()) + }); + } + } +} + +// ---------------------------------------------------------------- script-host threads + +pub(crate) enum ToHost { + Start(Session), + Event(Session, ScriptUiEvent), +} + +pub(crate) struct FromHost { + session: Session, + outcome: crate::Outcome, + windows: Vec, + name: String, + /// The host thread has ended (no windows left). + done: bool, +} + +/// The host thread's end of the hand-off. +pub(crate) struct Link { + rx: std::sync::mpsc::Receiver, + tx: std::sync::mpsc::Sender, +} + +/// The caller's end. +struct HostEnd { + tx: std::sync::mpsc::Sender, + rx: std::sync::mpsc::Receiver, +} + +fn hosts() -> &'static std::sync::Mutex> { + static H: std::sync::OnceLock>> = std::sync::OnceLock::new(); + H.get_or_init(Default::default) +} + +// The session crosses to script-host threads and back. +const _: fn() = || { + fn send() {} + send::(); +}; + +/// On the host thread: hand the session (with the windows' state and what the script printed) +/// back to the caller, and wait for the next event. `None` when the caller has gone. +fn yield_wait(outcome: crate::Outcome, windows: Vec, done: bool) -> Option { + let (session, link, name) = ACTIVE.with(|a| { + let mut a = a.borrow_mut(); + let a = a.as_mut()?; + Some((std::mem::take(&mut a.session), a.link.take()?, a.name.clone())) + })?; + if link.tx.send(FromHost { session, outcome, windows, name, done }).is_err() { + return None; + } + if done { + return None; + } + match link.rx.recv() { + Ok(ToHost::Event(s, ev)) => { + ACTIVE.with(|a| { + if let Some(a) = a.borrow_mut().as_mut() { + a.session = s; + a.link = Some(link); + } + }); + Some(ev) + } + _ => None, + } +} + +fn host_main(name: String, code: String, link: Link) { + let Ok(ToHost::Start(session)) = link.rx.recv() else { return }; + let mut a = Active::new(session, &name); + a.link = Some(link); + ACTIVE.with(|c| *c.borrow_mut() = Some(a)); + let file = if name.is_empty() { "script".to_string() } else { name.clone() }; + let body = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut ctx = match crate::runtime::new_context() { + Ok(c) => c, + Err(e) => { + yield_wait(outcome(settle(), Err(ScriptError { message: e, file: file.clone(), ..Default::default() })), vec![], true); + return; + } + }; + let r = crate::runtime::eval_with_tasks(&mut ctx, &code, &file); + let mut out = outcome(settle(), r); + loop { + let windows = snapshot(&mut ctx); + let open = windows.iter().any(|w| w.visible); + match yield_wait(out, windows, !open) { + Some(ev) => { + let r = dispatch_js(&mut ctx, &ev); + out = outcome(settle(), r); + } + None => return, + } + } + })); + // A panic: give the session back all the same. + let left = ACTIVE.with(|c| c.borrow_mut().take()); + if let Some(mut a) = left + && let Some(link) = a.link.take() + { + let message = if body.is_err() { "the script engine stopped unexpectedly" } else { "the script ended" }; + let out = crate::Outcome { error: Some(ScriptError { message: message.into(), file, ..Default::default() }), ..Default::default() }; + let _ = link.tx.send(FromHost { session: std::mem::take(&mut a.session), outcome: out, windows: vec![], name, done: true }); + } +} + +/// Wait for the host to hand the session back; keep its link while it has windows open. +fn receive(session: &mut Session, host: u32, end: HostEnd) -> crate::Outcome { + match end.rx.recv() { + Ok(FromHost { session: s, outcome, windows, name, done }) => { + *session = s; + publish(session, host, &name, windows); + if done { + session.script_ui.windows.retain(|w| w.host != host); + session.script_ui.revision += 1; + } else if let Ok(mut h) = hosts().lock() { + h.insert(host, end); + } + outcome + } + Err(_) => crate::Outcome { error: Some(ScriptError { message: "the script host stopped".into(), ..Default::default() }), ..Default::default() }, + } +} + +/// Run a script file on a new script-host thread (see the module docs). +pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::ScriptRequest) -> crate::Outcome { + let host = new_host_id(); + let (to_tx, to_rx) = std::sync::mpsc::channel(); + let (from_tx, from_rx) = std::sync::mpsc::channel(); + let (name, code) = (req.name.to_string(), req.code.to_string()); + let spawned = + std::thread::Builder::new().name(format!("script-{host}")).stack_size(64 << 20).spawn(move || host_main(name, code, Link { rx: to_rx, tx: from_tx })); + if spawned.is_err() { + return crate::runtime::run_inline(session, req); + } + if let Err(std::sync::mpsc::SendError(ToHost::Start(s) | ToHost::Event(s, _))) = to_tx.send(ToHost::Start(std::mem::take(session))) { + *session = s; + return crate::Outcome { error: Some(ScriptError { message: "the script host did not start".into(), ..Default::default() }), ..Default::default() }; + } + receive(session, host, HostEnd { tx: to_tx, rx: from_rx }) +} + +/// Record the windows a host published (named after the script that made them). +pub(crate) fn publish(session: &mut Session, host: u32, name: &str, mut windows: Vec) { + for w in &mut windows { + if w.script.is_empty() { + w.script = name.to_string(); + } + } + let had = session.script_ui.windows.iter().any(|w| w.host == host); + if windows.is_empty() && !had { + return; + } + session.script_ui.publish(host, windows); + if host != CONSOLE_HOST && !session.script_ui.windows.iter().any(|w| w.host == host) { + drop_inline(host); + } +} + +/// The [`effectcraft_engine::scriptui::ScriptUiDispatch`] this crate provides: run a window +/// event's handlers in the script that made the window, then publish its windows' new state. +/// Returns the handler run's `{ok, output, error, waiting?}`. +pub fn dispatch_ui(session: &mut Session, ev: &ScriptUiEvent) -> Result { + if ACTIVE.with(|a| a.try_borrow().map(|g| g.is_some()).unwrap_or(true)) { + return Err("scripts can't drive script windows".into()); + } + let w = session.script_ui.window(ev.window).ok_or_else(|| format!("no script window {}", ev.window))?; + let (host, name) = (w.host, w.script.clone()); + let inline = host == CONSOLE_HOST || INLINE.with(|m| m.borrow().contains_key(&host)); + if inline { + return Ok(dispatch_inline(session, host, &name, ev).to_json()); + } + let end = hosts().lock().ok().and_then(|mut h| h.remove(&host)); + let Some(end) = end else { + session.script_ui.windows.retain(|w| w.host != host); + session.script_ui.revision += 1; + return Err("the script that made this window has ended".into()); + }; + if let Err(std::sync::mpsc::SendError(ToHost::Event(s, _) | ToHost::Start(s))) = end.tx.send(ToHost::Event(std::mem::take(session), ev.clone())) { + *session = s; + session.script_ui.windows.retain(|w| w.host != host); + session.script_ui.revision += 1; + return Err("the script that made this window has ended".into()); + } + Ok(receive(session, host, end).to_json()) +} + +fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEvent) -> crate::Outcome { + let s = std::mem::take(session); + ACTIVE.with(|a| *a.borrow_mut() = Some(Active::new(s, name))); + let guard = Restore { target: session }; + let (r, windows) = if host == CONSOLE_HOST { + CONSOLE.with(|c| match c.borrow_mut().as_mut() { + Some(ctx) => { + let r = dispatch_js(ctx, ev); + (r, snapshot(ctx)) + } + None => (Ok(J::Null), vec![]), + }) + } else { + INLINE.with(|m| match m.borrow_mut().get_mut(&host) { + Some(ctx) => { + let r = dispatch_js(ctx, ev); + (r, snapshot(ctx)) + } + None => (Ok(J::Null), vec![]), + }) + }; + let output = settle(); + drop(guard); + publish(session, host, name, windows); + outcome(output, r) +} diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index b2fa142e7..b3f5908fe 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -221,7 +221,21 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter } x = r.max.x + 12.0; } - x + 4.0 + // Record Options… (⌘/Ctrl-drag a pin records its motion in real time). + let label = "Record Options..."; + let w = 104.0; + let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(w, 20.0)); + let resp = ui.interact(r, egui::Id::new("puppet-record-options"), egui::Sense::click()).on_hover_text("⌘/Ctrl-drag a pin to record its motion"); + p.rect_stroke(r, 3.0, egui::Stroke::new(1.0, if resp.hovered() { t.accent } else { t.field_border }), egui::StrokeKind::Inside); + p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.5), t.text); + app.auto.add("header.puppet.recordOptions", r, label); + if resp.clicked() { + let ctx = ui.ctx().clone(); + if let Err(e) = crate::menus::invoke(app, &ctx, "puppet.recordOptions", json!({})) { + app.ui.status = e; + } + } + r.max.x + 16.0 } fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index 888f9d86d..21d63dec3 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -322,7 +322,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let searching = !query.is_empty(); let mut y = list.min.y - scroll; let mut apply: Option = None; - let reg = effectcraft_engine::effects::registry(); + let reg = effectcraft_engine::effects::all(); let keep = |e: &effectcraft_engine::effects::EffectSpec| depth_keeps(&view.depth, e) && (query.is_empty() || e.name.to_lowercase().contains(&query)); let lookup = |id: &str| reg.iter().find(|e| e.id == id); @@ -381,7 +381,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { effect_row(app, ui, &lp, list, &mut y, e, -16.0, &query, "effects.item", &mut apply); } } else { - let mut cats: Vec<&str> = effectcraft_engine::effects::CATEGORIES.to_vec(); + let mut cats: Vec<&str> = effectcraft_engine::effects::categories(); cats.retain(|c| reg.iter().any(|e| e.category == *c)); for cat in cats { let items: Vec<_> = reg.iter().filter(|e| e.category == cat && keep(e)).collect(); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 39640ef69..50f43f733 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -136,6 +136,19 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { 1, )], ), + // Puppet tool ▸ Record Options. + "puppet.recordOptions" if !has(p, &["speed", "smoothing", "useDraftDeformation", "showMesh"]) => { + let o = &s.state.puppet; + ( + "Puppet Record Options".into(), + vec![ + Field::num("speed", "Speed (%)", o.record_speed), + Field::num("smoothing", "Smoothing", o.record_smoothing), + Field::bool("useDraftDeformation", "Use Draft Deformation", o.record_draft), + Field::bool("showMesh", "Show Mesh", o.record_show_mesh), + ], + ) + } "layer.setTransform" if !has(p, &["value"]) => { let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position"); let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt)); diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index 69d95f664..ea12591c0 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -172,34 +172,43 @@ pub fn audio(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { pub fn history(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let t = app.tokens; let p = ui.painter().with_clip_rect(rect); + // The branching history (EffectCraft's History panel): every state, branches indented under + // the state they grew from; click any state to go there (`edit.history.goto`). + let nodes = app.session.history_tree(); + let row_h = 22.0; + let cur = nodes.iter().position(|n| n.current).unwrap_or(0); + // Keep the current state in view. + let visible = ((rect.height() - 12.0) / row_h).max(1.0) as usize; + let first = (cur + 2).saturating_sub(visible).min(nodes.len().saturating_sub(visible)); let mut y = rect.min.y + 6.0; - let undo: Vec = app.session.history.undo.iter().map(|u| u.0.clone()).collect(); - let redo: Vec = app.session.history.redo.iter().rev().map(|u| u.0.clone()).collect(); - let n = undo.len(); - let mut jump: Option = None; - for (i, label) in undo.iter().chain(redo.iter()).enumerate() { - let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), 22.0)); - y += 22.0; - let current = i + 1 == n; - let future = i >= n; - let resp = ui.interact(r, egui::Id::new(("hist", i)), Sense::click()); - if current { + let mut jump: Option = None; + for n in nodes.iter().skip(first).take(visible + 1) { + let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), row_h)); + y += row_h; + let resp = ui.interact(r, egui::Id::new(("hist", n.id.as_str())), Sense::click()); + if n.current { p.rect_filled(r, 0.0, t.row_selected); } else if resp.hovered() { p.rect_filled(r, 0.0, t.hover); } - p.text(pos2(r.min.x + 12.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.0), if future { t.text_faint } else { t.text }); + let x = r.min.x + 12.0 + n.depth as f32 * 14.0; + if n.depth > 0 { + // Branch marker. + let c = pos2(x - 7.0, r.center().y); + p.line_segment([pos2(c.x, r.min.y), c], egui::Stroke::new(1.0, t.text_faint)); + p.line_segment([c, pos2(c.x + 5.0, c.y)], egui::Stroke::new(1.0, t.text_faint)); + } + let col = if !n.current && (n.future || !n.line) { t.text_faint } else { t.text }; + p.text(pos2(x, r.center().y), Align2::LEFT_CENTER, &n.label, Tokens::ui(12.0), col); + app.auto.add(&format!("history.state.{}", n.index), r, &n.label); if resp.clicked() { - jump = Some(i as i64 + 1 - n as i64); + jump = Some(n.index); } } - if let Some(d) = jump { - for _ in 0..d.unsigned_abs() { - if d < 0 { - app.session.undo(); - } else { - app.session.redo(); - } + if let Some(i) = jump { + let ctx = ui.ctx().clone(); + if let Err(e) = crate::menus::invoke(app, &ctx, "edit.history.goto", serde_json::json!({"index": i})) { + app.ui.status = e; } } } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 04999a90b..54643bd45 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -121,6 +121,17 @@ enum Gesture { pin: u64, inv: Mat3, }, + /// Puppet tool Record mode (⌘/Ctrl-drag a pin): the drag is sampled in real time (seconds + /// since it began, layer space) and becomes keyframes on release (`puppet.recordPin`); the + /// current time runs along while recording. + PuppetRecord { + layer: LayerId, + pin: u64, + inv: Mat3, + began: f64, + cti: Tick, + samples: Vec<(f64, [f64; 2])>, + }, /// Pen tool: pulling the tangents of the vertex just placed. Pen { layer: LayerId, @@ -861,7 +872,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }), t if t.puppet_kind().is_some() => pin_hits.iter().rev().find(|h| h.pos.distance(press) < 8.0).and_then(|h| { let l = comp.layer(h.layer)?; - Some(Gesture::PuppetPin { layer: h.layer, pin: h.pin, inv: l2c(&ectx, l).0.inverse()? }) + let inv = l2c(&ectx, l).0.inverse()?; + if mods.command { + let lp = inv.apply(gv2(cpt[0], cpt[1])); + let began = ui.input(|i| i.time); + return Some(Gesture::PuppetRecord { layer: h.layer, pin: h.pin, inv, began, cti: time, samples: vec![(0.0, [lp.x, lp.y])] }); + } + Some(Gesture::PuppetPin { layer: h.layer, pin: h.pin, inv }) }), Tool::Orbit | Tool::PanCamera | Tool::Dolly => Some(Gesture::Camera { tool }), Tool::Selection if super::tracker::hit_at(&track_hits, press).is_some() => { @@ -1097,6 +1114,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let _ = app.session.execute("mask.setVertex", params); } + Gesture::PuppetRecord { layer, pin, inv, began, cti, mut samples } => { + let lp = inv.apply(gv2(cpt[0], cpt[1])); + let now = ui.input(|i| i.time) - began; + if samples.last().is_none_or(|s| now > s.0) { + samples.push((now, [lp.x, lp.y])); + } + // The current time runs with the recording (Record Options ▸ Speed). + let speed = app.session.state.puppet.record_speed.max(1.0) / 100.0; + app.session.set_time(cti + Tick::from_seconds_f64(now / speed)); + painter.circle_filled(pos, 6.0, Color32::from_rgb(0xe0, 0x30, 0x30)); + painter.text(pos + vec2(10.0, -10.0), Align2::LEFT_BOTTOM, "Recording", Tokens::ui(11.0), Color32::from_rgb(0xff, 0x60, 0x60)); + ui.data_mut(|dd| dd.insert_temp(gid, Gesture::PuppetRecord { layer, pin, inv, began, cti, samples })); + ui.ctx().request_repaint(); + } Gesture::PuppetPin { layer, pin, inv } => { let lp = inv.apply(gv2(cpt[0], cpt[1])); if let Err(e) = app.session.execute("puppet.movePin", json!({"layer": layer.0, "pin": pin, "position": [lp.x, lp.y], "merge": merge})) { @@ -1129,6 +1160,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.drag_stopped() { if let (Some(g), Some(pos)) = (gesture, resp.interact_pointer_pos()) { match g { + Gesture::PuppetRecord { layer, pin, cti, samples, .. } => { + let pts: Vec = samples.iter().map(|(t, p)| json!([t, p[0], p[1]])).collect(); + if let Err(e) = app.session.execute("puppet.recordPin", json!({"layer": layer.0, "pin": pin, "samples": pts, "start": cti.seconds()})) { + app.ui.status = e.to_string(); + } + } Gesture::Create { tool, start } => { let end = map.to_comp(pos); create_shape(app, tool, start, end, mods.shift); diff --git a/docs/architecture.md b/docs/architecture.md index a54703b71..6ff400dcb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,9 +34,10 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for | L3 | `export` | Render queue encoding: H.264, ProRes, WebM (VP9 + alpha, Opus), PNG/JPEG/TIFF/EXR sequences, GIF, WAV/AIFF | | L3 | `gpu` | The GPU compositor and GPU effects on wgpu compute shaders (Metal, Vulkan, Direct3D 12, WebGPU), checked against the CPU renderer | | L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express | -| L4 | `engine` | `Session`: project, undo history, editor state, the command registry and menus | -| L4 | `script` | Scripting: JavaScript (boa) with an After Effects-style object model (`app.project`, comps, layers, properties, render queue) whose edits run engine commands; AE match names | -| L4 | `host` | A fully wired `Session` (media, expressions, scripting, exporter) for the frontends | +| L3 | `plugin` | Effect plug-ins: loads sandboxed WebAssembly effect modules (plug-in API v1; the wasmi interpreter behind the `wasm` feature, on for native hosts) into the effect registry ([plugins.md](plugins.md)) | +| L4 | `engine` | `Session`: project, branching undo history, editor state, the command registry and menus, the ScriptUI window model (`scriptui`) | +| L4 | `script` | Scripting: JavaScript (boa) with an After Effects-style object model (`app.project`, comps, layers, properties, render queue) and ScriptUI (dialogs, palettes, dockable panels; script-host threads so dialogs block `show()`), whose edits run engine commands; AE match names | +| L4 | `host` | A fully wired `Session` (media, expressions, scripting, WebAssembly plug-ins, exporter) for the frontends | | L5 | `ui-egui` | The desktop interface: docking, panels, viewer, timeline, graph editor, dialogs, control channel | | L5 | `automation` | The MCP server, headless or bridged to the running app | | L6 | apps `effectcraft`, `effectcraft-cli`, `effectcraft-web` | Desktop app; command-line tool (render, exec, get/set, MCP); the browser app (wasm32, [web.md](web.md)) | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 21928b90d..dfedbea32 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -43,6 +43,7 @@ const LAYERS: &[(&str, u8)] = &[ ("gpu", 3), ("lottie", 3), ("format", 3), + ("plugin", 3), ("engine", 4), ("script", 4), ("host", 4),