mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 04:26:55 -06:00
223 lines
10 KiB
Rust
223 lines
10 KiB
Rust
//! 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, draw (onDraw paint list), 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, changing?: bool (a live update: each keystroke / slider step; fires onChanging only)} (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
|
|
),
|
|
]
|
|
}
|