mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 20:16:52 -06:00
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:
co-authored by
Claude Opus 5.5
parent
ebcb8c95ce
commit
89c9b1b6ee
Generated
+148
-3
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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("/"))
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -19,4 +19,5 @@ effectcraft-track = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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, ¶ms, 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) + "]");
|
||||
})();
|
||||
@@ -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.");
|
||||
})();
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,8 @@ impl Session {
|
||||
self.configure_disk_cache();
|
||||
// Fewer undo levels apply right away.
|
||||
let levels = self.prefs.general.undo_levels.max(1) as usize;
|
||||
if self.history.undo.len() > levels {
|
||||
let extra = self.history.undo.len() - levels;
|
||||
self.history.undo.drain(..extra);
|
||||
}
|
||||
let current = self.project.clone();
|
||||
self.history.trim(levels, ¤t);
|
||||
self.prefs_revision += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
@@ -0,0 +1,164 @@
|
||||
//! WebAssembly plug-ins: load, register, render through the effect registry, sandbox limits.
|
||||
|
||||
use effectcraft_effects::{Buf, EffectCtx, EffectEnv, Image};
|
||||
use effectcraft_project::build::Ids;
|
||||
|
||||
use crate::load_wasm;
|
||||
|
||||
fn load_err(r: Result<&'static effectcraft_effects::EffectSpec, String>) -> String {
|
||||
match r {
|
||||
Err(e) => e,
|
||||
Ok(s) => panic!("unexpectedly loaded {}", s.id),
|
||||
}
|
||||
}
|
||||
|
||||
/// A plug-in in WebAssembly text: multiplies RGB by `gain`, adds `lift` × `tint` when `on`.
|
||||
fn gain_module(id: &str, render_body: &str) -> String {
|
||||
let manifest = format!(
|
||||
r#"{{"api":1,"id":"{id}","name":"Gain {id}","category":"Test Plug-ins","version":"1.0","params":[{{"id":"gain","name":"Gain","type":"slider","default":0.5,"min":0,"max":4}},{{"id":"on","name":"Lift","type":"checkbox","default":false}},{{"id":"tint","name":"Tint","type":"color","default":[0,0,1,1]}}]}}"#
|
||||
);
|
||||
let len = manifest.len();
|
||||
let escaped = manifest.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
format!(
|
||||
r#"(module
|
||||
(memory (export "memory") 2)
|
||||
(data (i32.const 16) "{escaped}")
|
||||
(func (export "ec_api_version") (result i32) i32.const 1)
|
||||
(func (export "ec_manifest_ptr") (result i32) i32.const 16)
|
||||
(func (export "ec_manifest_len") (result i32) i32.const {len})
|
||||
(func (export "ec_alloc") (param $n i32) (result i32)
|
||||
(local $need i32)
|
||||
local.get $n
|
||||
i32.const 131071
|
||||
i32.add
|
||||
i32.const 16
|
||||
i32.shr_u
|
||||
memory.size
|
||||
i32.sub
|
||||
local.tee $need
|
||||
i32.const 0
|
||||
i32.gt_s
|
||||
if
|
||||
local.get $need
|
||||
memory.grow
|
||||
drop
|
||||
end
|
||||
i32.const 65536)
|
||||
(func (export "ec_render") (param $px i32) (param $w i32) (param $h i32) (param $params i32) (param $np i32) (param $t f64) (param $scale f64) (result i32)
|
||||
(local $i i32) (local $end i32) (local $g f32) (local $on f64)
|
||||
{render_body}
|
||||
local.get $params
|
||||
f64.load
|
||||
f32.demote_f64
|
||||
local.set $g
|
||||
local.get $params
|
||||
f64.load offset=8
|
||||
local.set $on
|
||||
local.get $px
|
||||
local.get $w
|
||||
local.get $h
|
||||
i32.mul
|
||||
i32.const 16
|
||||
i32.mul
|
||||
i32.add
|
||||
local.set $end
|
||||
local.get $px
|
||||
local.set $i
|
||||
block $done
|
||||
loop $l
|
||||
local.get $i
|
||||
local.get $end
|
||||
i32.ge_u
|
||||
br_if $done
|
||||
local.get $i
|
||||
local.get $i
|
||||
f32.load
|
||||
local.get $g
|
||||
f32.mul
|
||||
f32.store
|
||||
local.get $i
|
||||
local.get $i
|
||||
f32.load offset=4
|
||||
local.get $g
|
||||
f32.mul
|
||||
f32.store offset=4
|
||||
local.get $i
|
||||
local.get $i
|
||||
f32.load offset=8
|
||||
local.get $g
|
||||
f32.mul
|
||||
local.get $on
|
||||
local.get $params
|
||||
f64.load offset=32
|
||||
f64.mul
|
||||
f32.demote_f64
|
||||
f32.add
|
||||
f32.store offset=8
|
||||
local.get $i
|
||||
i32.const 16
|
||||
i32.add
|
||||
local.set $i
|
||||
br $l
|
||||
end
|
||||
end
|
||||
i32.const 0))"#
|
||||
)
|
||||
}
|
||||
|
||||
fn render(spec: &effectcraft_effects::EffectSpec, gain: f64, on: bool) -> Image {
|
||||
let mut next = 1;
|
||||
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [4.0, 2.0]);
|
||||
let mut params = effectcraft_effects::flatten_params(&g, &mut |p| p.value.clone());
|
||||
params.values.insert("gain".into(), effectcraft_keyframe::Value::Scalar(gain));
|
||||
params.values.insert("on".into(), effectcraft_keyframe::Value::Bool(on));
|
||||
let ctx = EffectCtx { params: ¶ms, time: 0.0, layer_size: [4.0, 2.0], seed: 1, adjustment: false, env: EffectEnv::default() };
|
||||
let buf = Buf { img: Image::filled(4, 2, [0.8, 0.6, 0.2, 1.0]), offset: [0.0; 2], scale: 1.0 };
|
||||
(spec.render)(&ctx, buf).img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasm_plugin_registers_and_renders() {
|
||||
let spec = load_wasm(gain_module("org.test.gain", "").as_bytes(), "gain.wat").unwrap();
|
||||
assert_eq!(spec.id, "org.test.gain");
|
||||
assert_eq!(spec.category, "Test Plug-ins");
|
||||
assert_eq!(spec.params.len(), 3);
|
||||
// Found like a built-in, listed under its own category.
|
||||
assert!(effectcraft_effects::find("org.test.gain").is_some());
|
||||
assert!(effectcraft_effects::lookup("Gain org.test.gain").is_some());
|
||||
assert!(effectcraft_effects::all().iter().any(|e| e.id == "org.test.gain"));
|
||||
assert!(effectcraft_effects::categories().contains(&"Test Plug-ins"));
|
||||
let img = render(spec, 0.5, false);
|
||||
assert_eq!(img.get(1, 1), [0.4, 0.3, 0.1, 1.0]);
|
||||
// The checkbox and colour parameters arrive flattened (tint blue = 1 → +1 on blue).
|
||||
let img = render(spec, 1.0, true);
|
||||
assert_eq!(img.get(3, 0), [0.8, 0.6, 1.2, 1.0]);
|
||||
// Deterministic, and safe to run on many threads at once (each takes an instance).
|
||||
let a: Vec<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"));
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) |
|
||||
|
||||
@@ -43,6 +43,7 @@ const LAYERS: &[(&str, u8)] = &[
|
||||
("gpu", 3),
|
||||
("lottie", 3),
|
||||
("format", 3),
|
||||
("plugin", 3),
|
||||
("engine", 4),
|
||||
("script", 4),
|
||||
("host", 4),
|
||||
|
||||
Reference in New Issue
Block a user