M13.1: branching history, puppet pin recording, WebAssembly plug-in API, ScriptUI model and hosts, File ▸ Scripts, deterministic render test

- 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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 04:07:20 -04:00
co-authored by Claude Opus 5.5
parent ebcb8c95ce
commit 89c9b1b6ee
48 changed files with 4440 additions and 134 deletions
Generated
+148 -3
View File
@@ -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"
+3
View File
@@ -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"] }
+12
View File
@@ -216,6 +216,18 @@ impl ConfigStore for WebConfig {
self.store.changed();
Ok(())
}
fn list(&self, dir: &str) -> Vec<String> {
let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/'));
let mut v: Vec<String> = 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<PathBuf> {
Some(PathBuf::from("/"))
}
+19
View File
@@ -315,6 +315,18 @@ fn redo(b: &mut Backend, a: &Value) -> Result<Reply> {
history(b, a, "edit.redo")
}
fn history_tool(b: &mut Backend, a: &Value) -> Result<Reply> {
if let Some(g) = get(a, "goto") {
let p = match g {
Value::String(id) if id.parse::<u64>().is_err() => json!({"id": id}),
Value::String(n) => json!({"index": n.parse::<u64>().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<Reply> {
@@ -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.",
+1
View File
@@ -19,4 +19,5 @@ effectcraft-track = { workspace = true }
rayon = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+26 -2
View File
@@ -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()
+338
View File
@@ -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<f64>,
#[serde(default, rename = "sliderMax")]
slider_max: Option<f64>,
#[serde(default = "two")]
decimals: u8,
},
Angle {
#[serde(default)]
default: f64,
},
Checkbox {
#[serde(default)]
default: bool,
},
Popup {
options: Vec<String>,
/// 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<PluginParam>,
}
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<Arc<dyn EffectPlugin>>; MAX_PLUGINS] = [const { OnceLock::new() }; MAX_PLUGINS];
/// Registered plug-in effects, in registration order.
fn plugin_specs() -> &'static RwLock<Vec<&'static EffectSpec>> {
static P: OnceLock<RwLock<Vec<&'static EffectSpec>>> = 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<Arc<dyn EffectPlugin>> {
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<dyn EffectPlugin>) -> 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<const I: usize>(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<f64> {
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, &params, ctx.time) {
log::warn!("plug-in `{}`: {e}", m.id);
buf.img.data = before;
}
buf
}
@@ -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) + "]");
})();
+71
View File
@@ -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).");
})();
@@ -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();
})();
@@ -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);
@@ -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.");
})();
+52 -2
View File
@@ -584,8 +584,7 @@ fn edit_original(s: &mut Session, _: &Value) -> Result<Value> {
}
fn purge(s: &mut Session, _: &Value) -> Result<Value> {
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<CommandSpec> {
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<Value> {
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<Value> {
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) }
}
+79 -2
View File
@@ -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<Value> {
use effectcraft_effects::plugin;
let list: Vec<Value> = 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::<Vec<_>>(),
})
})
.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<Value> {
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<String> = match (str_p(p, "path"), str_p(p, "folder")) {
(Some(path), _) => vec![path.to_string()],
(None, Some(dir)) => {
let mut v: Vec<String> = 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<Value> {
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<Value> {
fn list(_: &mut Session, p: &Value) -> Result<Value> {
let filter = str_p(p, "filter").map(str::to_ascii_lowercase);
let v: Vec<Value> = effectcraft_effects::registry()
.iter()
let v: Vec<Value> = 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::<Vec<_>>()}))
.collect();
@@ -305,5 +367,20 @@ pub fn specs() -> Vec<CommandSpec> {
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
),
]
}
+27 -8
View File
@@ -364,6 +364,14 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
/// 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<Value> {
// File ▸ Scripts ▸ <installed or sample script>.
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<Value> {
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<Value> = 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<Value> {
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<Value> {
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<Value> {
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<CommandSpec> {
"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
),
+2
View File
@@ -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
})
+155 -2
View File
@@ -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<Value> {
Ok(Value::Null)
}
/// Parse recorded samples `[[t, x, y]…]` (t = seconds since the drag began, layer space).
fn samples(p: &Value, cmd: &str) -> Result<Vec<(f64, [f64; 2])>> {
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<f64> = 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<Value> {
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<Keyframe> = 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<Keyframe> = 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<f64>)> = 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<Value> {
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<Value> {
let (cid, lid) = layer_p(s, p, "puppet.info")?;
@@ -364,6 +480,24 @@ pub fn specs() -> Vec<CommandSpec> {
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,
}
}
}
+222
View File
@@ -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<ScriptEntry> {
let mut v: Vec<ScriptEntry> = 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<bool>) -> 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<Value> {
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<Value> {
Ok(json!(scripts(s)))
}
fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
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 ▸ <name>). 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<Value> {
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 ▸ <ScriptUI panel>: run the panel script (or bring its panel forward).
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
let c = "window.scriptPanel";
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<CommandSpec> {
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
),
]
}
+20 -2
View File
@@ -16,6 +16,12 @@ pub trait ConfigStore: Send + Sync {
fn read(&self, name: &str) -> Option<String>;
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<String> {
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<PathBuf> {
@@ -86,6 +92,11 @@ impl ConfigStore for MemoryConfig {
}
Ok(())
}
fn list(&self, dir: &str) -> Vec<String> {
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<String> {
let Ok(rd) = std::fs::read_dir(self.dir.join(dir)) else { return vec![] };
let mut v: Vec<String> = 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)) {
+346
View File
@@ -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<Project>`), 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<Project>)>,
/// Undone steps, the next one last: (step label, the state it leads to).
pub redo: Vec<(String, Arc<Project>)>,
/// Key of the last merged step: a continuous gesture with the same key folds into one step.
pub merge_key: Option<String>,
/// Alternative futures kept when a new step was made after undoing.
pub branches: Vec<Branch>,
}
/// 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<Project>,
/// The branch's states in order: (label of the step that made it, state).
pub steps: Vec<(String, Arc<Project>)>,
}
/// 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<usize>,
/// 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<Project>) -> usize {
Arc::as_ptr(p) as usize
}
/// Id of a state (its address while the history holds it).
pub fn state_id(p: &Arc<Project>) -> String {
format!("s{:x}", key(p))
}
/// The whole tree as an arena (internal).
struct Arena {
label: Vec<String>,
state: Vec<Arc<Project>>,
parent: Vec<Option<usize>>,
children: Vec<Vec<usize>>,
/// Index of each working-line state, oldest first, and the current state's position in it.
line: Vec<usize>,
current: usize,
}
impl Arena {
fn build(h: &History, current: &Arc<Project>) -> 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<Project>, parent: Option<usize>| {
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<usize> = 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<usize, usize> = 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<bool> {
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<HistoryNode> {
let on_line = self.on_line();
let pos_in_line: HashMap<usize, usize> = self.line.iter().enumerate().map(|(k, &i)| (i, k)).collect();
let cur_pos = pos_in_line[&self.current];
let mut out: Vec<HistoryNode> = vec![];
let mut order: HashMap<usize, usize> = 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<Project>, 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<Project>) {
if self.redo.is_empty() {
return;
}
let steps: Vec<(String, Arc<Project>)> = 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<Project>) {
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<Project>) {
if self.branches.is_empty() {
return;
}
let mut live: std::collections::HashSet<usize> = 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<Project>) -> Vec<HistoryNode> {
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<HistoryNode> {
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<Branch>) {
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<usize> = 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<Project>)> = (0..k).map(|i| (a.label[new_line[i + 1]].clone(), a.state[new_line[i]].clone())).collect();
let redo: Vec<(String, Arc<Project>)> = 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
}
}
+20 -15
View File
@@ -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<effectcraft_project::Footage, String>;
}
/// Undo history of whole-project snapshots.
#[derive(Clone, Default)]
pub struct History {
pub undo: Vec<(String, Arc<Project>)>,
pub redo: Vec<(String, Arc<Project>)>,
/// Key of the last merged step: a continuous gesture with the same key folds into one step.
pub merge_key: Option<String>,
}
/// 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<ScriptRunner>,
/// Loads WebAssembly effect plug-ins (set by the host that links `effectcraft-plugin` with
/// its runtime): `effect.plugins.load`.
pub plugin_loader: Option<PluginLoader>,
/// 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<Arc<dyn offload::Offload>>,
@@ -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<Value, String>;
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<String> {
effectcraft_text::families().into_iter().map(|(f, _)| f).collect()
}
#[cfg(test)]
mod tests_history;
#[cfg(test)]
mod tests_paint;
#[cfg(test)]
mod tests_roto;
+2 -3
View File
@@ -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 {
+3 -1
View File
@@ -280,7 +280,7 @@ pub fn parse(text: &str, mac: bool) -> Result<Vec<MenuNode>, String> {
/// Effect ▸ category submenus from the effect registry.
fn effect_categories() -> Vec<MenuNode> {
let mut cats: Vec<(&str, Vec<MenuNode>)> = 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
+572
View File
@@ -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<String>,
/// Selected item indices.
pub selection: Vec<usize>,
/// `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<String>,
/// This control's own alignment in its parent (overrides the parent's alignChildren).
pub alignment: Vec<String>,
pub spacing: Option<f64>,
/// `[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<String>,
/// The active tab of a tabbed panel (child index).
pub active_tab: usize,
pub children: Vec<Widget>,
}
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::<u32>().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<Value, String>;
/// The open script windows.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ScriptUi {
pub windows: Vec<ScriptWindow>,
/// Bumped whenever a window changes.
pub revision: u64,
/// The scripting engine's event hand-off (set with [`Session::script`]).
#[serde(skip)]
pub dispatch: Option<ScriptUiDispatch>,
}
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<ScriptWindow>) {
for w in &mut windows {
w.host = host;
layout(w);
}
windows.retain(|w| w.visible);
let mut out: Vec<ScriptWindow> = 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::<f64>() + 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::<f64>() + 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<u32> {
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<u32> {
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<Value> {
let f = s.script_ui.dispatch.ok_or_else(|| EngineError::Other("scripting is not available in this build".into()))?;
f(s, &ev).map_err(EngineError::Other)
}
/// `scriptui.list`: the open script windows.
pub(crate) fn list(s: &mut Session, _: &Value) -> Result<Value> {
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::<Vec<_>>()
))
}
/// `scriptui.get`: one window's control tree.
pub(crate) fn get(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
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<Value> {
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::<Vec<_>>(), [(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::<Vec<_>>(), [2, 3]);
let j = serde_json::to_value(&ui).unwrap();
assert_eq!(j["windows"][0]["root"]["type"], "window");
}
}
+2 -4
View File
@@ -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, &current);
self.prefs_revision += 1;
}
+135
View File
@@ -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<String> {
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<Value> {
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);
}
+53
View File
@@ -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<Value> = [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());
}
+2 -3
View File
@@ -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 {
+6
View File
@@ -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 }
+2
View File
@@ -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()
}
}
+74
View File
@@ -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<u32> {
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);
}
+25
View File
@@ -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 }
+66
View File
@@ -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<serde_json::Value, String> {
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;
+164
View File
@@ -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: &params, 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<Image> = (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"));
}
+151
View File
@@ -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<i32, i32>,
render: RenderFn,
}
impl Inst {
fn func<P: WasmParams, R: WasmResults>(&self, name: &str) -> Result<TypedFunc<P, R>, String> {
self.instance.get_typed_func::<P, R>(&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<Inst, String> {
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::<i32, i32>(&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<Vec<Inst>>,
}
impl WasmPlugin {
pub fn new(bytes: &[u8], source: &str) -> Result<WasmPlugin, String> {
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<Inst, String> {
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<u8> = 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
}
}
+14 -1
View File
@@ -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;
+2 -2
View File
@@ -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::<Vec<_>>())),
"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::<Vec<_>>()
)),
+113 -57
View File
@@ -26,6 +26,9 @@ pub struct Outcome {
/// `writeLn` / `$.writeln` / `alert` output, one entry per line.
pub output: Vec<String>,
pub error: Option<ScriptError>,
/// 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<String>,
pub(crate) struct Active {
pub(crate) session: Session,
pub(crate) output: Vec<String>,
/// Open undo groups: (name, project snapshot when the outermost group began).
groups: Vec<(String, Arc<Project>)>,
pub(crate) groups: Vec<(String, Arc<Project>)>,
/// `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<crate::ui::Link>,
}
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<Option<Active>> = const { RefCell::new(None) };
pub(crate) static ACTIVE: RefCell<Option<Active>> = 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<Option<ManuallyDrop<Context>>> = const { RefCell::new(None) };
pub(crate) static CONSOLE: RefCell<Option<ManuallyDrop<Context>>> = const { RefCell::new(None) };
}
fn with_active<T>(f: impl FnOnce(&mut Active) -> Result<T, String>) -> Result<T, String> {
pub(crate) fn with_active<T>(f: impl FnOnce(&mut Active) -> Result<T, String>) -> Result<T, String> {
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<T>(f: impl FnOnce(&mut Active) -> Result<T, String>) -> Result<T,
})
}
fn throw(msg: impl Into<String>) -> boa_engine::JsError {
pub(crate) fn throw(msg: impl Into<String>) -> boa_engine::JsError {
JsNativeError::error().with_message(msg.into()).into()
}
fn arg_string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<String> {
pub(crate) fn arg_string(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<String> {
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<J> {
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<JsV
}
/// Fold the undo steps recorded since the project was `snap` into one step called `name`.
fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>) {
pub(crate) fn collapse(s: &mut Session, name: &str, snap: &Arc<Project>) {
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<JsV
}
}
fn new_context() -> Result<Context, String> {
pub(crate) fn new_context() -> Result<Context, String> {
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<JsValue>); 6] = [
let natives: [(JsString, usize, fn(&JsValue, &[JsValue], &mut Context) -> JsResult<JsValue>); 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<u32>, Option<u32>) {
}
/// Evaluate `code` in `ctx` and convert the result.
fn eval_in(ctx: &mut Context, code: &str, file: &str) -> Result<J, ScriptError> {
pub(crate) fn eval_in(ctx: &mut Context, code: &str, file: &str) -> Result<J, ScriptError> {
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<String> {
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<J, ScriptError> {
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<String>, result: Result<J, ScriptError>) -> 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)
}
+544
View File
@@ -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");
}
}
+299
View File
@@ -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<String> {
s.project.items.values().filter(|i| i.as_comp().is_some()).map(|i| i.name.clone()).collect()
}
fn layer_names(s: &Session) -> Vec<String> {
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<WidgetKind> = 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<u64> = 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);
}
+339
View File
@@ -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<BTreeMap<u32, ManuallyDrop<Context>>> = 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<JsValue> {
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<ScriptWindow> {
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<J, ScriptError> {
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<JsValue> {
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<JsValue> {
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<String, J> = 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<JsValue> {
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<ScriptWindow>,
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<ToHost>,
tx: std::sync::mpsc::Sender<FromHost>,
}
/// The caller's end.
struct HostEnd {
tx: std::sync::mpsc::Sender<ToHost>,
rx: std::sync::mpsc::Receiver<FromHost>,
}
fn hosts() -> &'static std::sync::Mutex<BTreeMap<u32, HostEnd>> {
static H: std::sync::OnceLock<std::sync::Mutex<BTreeMap<u32, HostEnd>>> = std::sync::OnceLock::new();
H.get_or_init(Default::default)
}
// The session crosses to script-host threads and back.
const _: fn() = || {
fn send<T: Send>() {}
send::<Session>();
};
/// 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<ScriptWindow>, done: bool) -> Option<ScriptUiEvent> {
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<ScriptWindow>) {
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<J, String> {
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)
}
+15 -1
View File
@@ -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) {
+2 -2
View File
@@ -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<Apply> = 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();
+13
View File
@@ -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));
+29 -20
View File
@@ -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<String> = app.session.history.undo.iter().map(|u| u.0.clone()).collect();
let redo: Vec<String> = app.session.history.redo.iter().rev().map(|u| u.0.clone()).collect();
let n = undo.len();
let mut jump: Option<i64> = 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<usize> = 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;
}
}
}
+38 -1
View File
@@ -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<serde_json::Value> = 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);
+4 -3
View File
@@ -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)) |
+1
View File
@@ -43,6 +43,7 @@ const LAYERS: &[(&str, u8)] = &[
("gpu", 3),
("lottie", 3),
("format", 3),
("plugin", 3),
("engine", 4),
("script", 4),
("host", 4),