mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Merge pull request #2279 from dotCooCoo/feat/startup-shutdown-scripts
Scripts: the Startup and Shutdown folders run at launch and quit
This commit is contained in:
@@ -272,6 +272,8 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
}
|
||||
let mut session = session();
|
||||
session.load_settings();
|
||||
// Scripts in Scripts/Startup run before a project opens.
|
||||
let _ = session.execute("file.runStartupScripts", json!({}));
|
||||
let recovery = session.begin_recovery();
|
||||
// `?empty`: a blank project; `?demo`: the demo project; otherwise what the config's
|
||||
// `startup` says (default: the last visit's project, the demo on a first visit).
|
||||
|
||||
@@ -263,6 +263,9 @@ fn main() -> eframe::Result {
|
||||
// Fit the window to its monitor, or put it back where it was (still hidden).
|
||||
let saved_window = session.config.as_deref().and_then(window::load);
|
||||
let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
|
||||
// Scripts in Scripts/Startup run before a project opens (a failing one is reported in
|
||||
// a toast).
|
||||
let _ = session.execute("file.runStartupScripts", json!({}));
|
||||
let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
|
||||
let project = files.iter().find(|f| is_project_file(f)).cloned();
|
||||
if let Some(p) = project {
|
||||
@@ -495,6 +498,8 @@ impl eframe::App for Desktop {
|
||||
self.launch.exited();
|
||||
self.window.save(self.app.session.config.as_deref());
|
||||
self.app.on_exit();
|
||||
// Scripts in Scripts/Shutdown run with the project closed.
|
||||
let _ = self.app.session.execute("file.runShutdownScripts", json!({}));
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
|
||||
@@ -30,10 +30,9 @@ pub struct Confinement {
|
||||
write: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// Commands that run code, act outside the project or open other applications.
|
||||
/// Commands that run code, act outside the project or open other applications, besides the
|
||||
/// commands that run scripts ([`effectcraft_engine::commands::scripts::RUNS_SCRIPTS`]).
|
||||
const REFUSED: &[&str] = &[
|
||||
"script.run",
|
||||
"file.runScript",
|
||||
"file.installScript",
|
||||
"file.installScriptUIPanel",
|
||||
"file.uninstallScript",
|
||||
@@ -167,7 +166,7 @@ impl Confinement {
|
||||
/// Check engine command `id` with `params` before it runs. Returns what must still be looked
|
||||
/// up and passed to [`Confinement::check_lookup`] for commands that write implicitly.
|
||||
pub fn check_command(&self, id: &str, params: &Value) -> Result<Option<Lookup>> {
|
||||
if REFUSED.contains(&id) {
|
||||
if REFUSED.contains(&id) || effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id) {
|
||||
return Err(Error::BadArgs(format!("`{id}` is not available while file access is confined (--automation-read-root/--automation-write-root)")));
|
||||
}
|
||||
// Scripts can't grant themselves the access the user withheld
|
||||
@@ -397,6 +396,7 @@ mod tests {
|
||||
let classified = keys_of(READS, id).is_some()
|
||||
|| keys_of(WRITES, id).is_some()
|
||||
|| REFUSED.contains(&id)
|
||||
|| effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id)
|
||||
|| IMPLICIT_WRITES.contains(&id)
|
||||
|| id.starts_with("renderQueue.")
|
||||
|| id == "render.addOutputModule"
|
||||
|
||||
@@ -1412,6 +1412,18 @@ fn automation_roots_confine_file_access() {
|
||||
// Scripts can read and write anything: refused.
|
||||
denied(&mut s, "run_script", json!({"code": "1 + 1"}));
|
||||
denied(&mut s, "execute_command", json!({"command": "file.runScript", "params": {"path": p(&read, "x.jsx")}}));
|
||||
for (id, params) in [
|
||||
("file.runStartupScripts", json!({})),
|
||||
("file.runShutdownScripts", json!({})),
|
||||
// ScriptUI panels run their script when they open.
|
||||
("window.scriptPanel", json!({"name": "Layer Tools.jsx"})),
|
||||
("window.restoreScriptPanels", json!({})),
|
||||
] {
|
||||
let msg = denied(&mut s, "execute_command", json!({"command": id, "params": params}));
|
||||
assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
|
||||
let msg = denied(&mut s, "batch", json!({"steps": [{"command": id, "params": params}]}));
|
||||
assert!(msg.contains("not available while file access is confined"), "{id}: {msg}");
|
||||
}
|
||||
// Writes to the files the agent named: none outside.
|
||||
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "only the unconfined save");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
|
||||
@@ -9,8 +9,9 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, always, bad, str_p};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Commands that can't run inside a batch (they replace the project or nest batches).
|
||||
const NOT_IN_BATCH: &[&str] = &["engine.batch", "file.runScript", "script.run"];
|
||||
/// Commands that can't run inside a batch (they nest batches), besides the commands that run
|
||||
/// scripts ([`super::scripts::RUNS_SCRIPTS`]).
|
||||
const NOT_IN_BATCH: &[&str] = &["engine.batch"];
|
||||
|
||||
/// Resolve a `$N` / `$N.key.0.key` reference against the results so far.
|
||||
fn reference(s: &str, results: &[Value]) -> Option<Result<Value>> {
|
||||
@@ -78,7 +79,7 @@ fn batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.or_else(|| step.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| bad("engine.batch", "each step needs `command` (a command id)"))?;
|
||||
if NOT_IN_BATCH.contains(&id) {
|
||||
if NOT_IN_BATCH.contains(&id) || super::scripts::RUNS_SCRIPTS.contains(&id) {
|
||||
return Err(bad("engine.batch", format!("`{id}` can't run inside a batch")));
|
||||
}
|
||||
let params = substitute(step.get("params").unwrap_or(&json!({})), results)?;
|
||||
|
||||
@@ -501,7 +501,7 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad("file.runScript", format!("step {}: needs `command`", i + 1))),
|
||||
};
|
||||
if id == "file.runScript" || id == "script.run" {
|
||||
if super::scripts::RUNS_SCRIPTS.contains(&id.as_str()) {
|
||||
return Err(bad("file.runScript", "scripts can't run scripts"));
|
||||
}
|
||||
let r = if from_file { s.execute_for_script(&id, params) } else { s.execute(&id, params) };
|
||||
@@ -536,7 +536,7 @@ fn is_jsxbin(name: &str) -> bool {
|
||||
}
|
||||
|
||||
/// A script error becomes a toast and a command error.
|
||||
fn report_js(s: &mut Session, name: &str, out: Value) -> Result<Value> {
|
||||
pub(crate) 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");
|
||||
@@ -552,6 +552,13 @@ pub(crate) fn run_js_named(s: &mut Session, code: &str, name: &str) -> Result<Va
|
||||
report_js(s, name, out)
|
||||
}
|
||||
|
||||
/// Run the code of the script file at `path` (its `#include`s and `$.fileName` use that path),
|
||||
/// reported under its file name `name` (Startup and Shutdown scripts).
|
||||
pub(crate) fn run_js_file(s: &mut Session, code: &str, path: &str, name: &str) -> Result<Value> {
|
||||
let out = run_js(s, code, path, 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()))?;
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
//! 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. A subfolder of
|
||||
//! either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`.
|
||||
//! * **Startup and Shutdown scripts** in `Scripts/Startup/` and `Scripts/Shutdown/` run by
|
||||
//! themselves, in alphabetical order: at launch (`file.runStartupScripts`) and as EffectCraft
|
||||
//! quits, after the project is closed (`file.runShutdownScripts`).
|
||||
//! * **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`]).
|
||||
//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
|
||||
@@ -23,6 +26,18 @@ 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";
|
||||
/// The settings-store folders whose scripts run at launch and as EffectCraft quits.
|
||||
pub const STARTUP_DIR: &str = "Scripts/Startup";
|
||||
pub const SHUTDOWN_DIR: &str = "Scripts/Shutdown";
|
||||
/// Commands that run script files or script code (a ScriptUI panel runs its script when it
|
||||
/// opens). Scripts and batches can't run them, and confined automation sessions refuse them.
|
||||
pub const RUNS_SCRIPTS: &[&str] =
|
||||
&["script.run", "file.runScript", "file.runStartupScripts", "file.runShutdownScripts", "window.scriptPanel", "window.restoreScriptPanels"];
|
||||
/// Largest script file the Startup and Shutdown folders run (bytes), as `#include` and
|
||||
/// `$.evalFile` allow.
|
||||
const MAX_SCRIPT_BYTES: usize = 16 << 20;
|
||||
/// Modal dialogs a Shutdown script may open one after another; each closes as if canceled.
|
||||
const MAX_SHUTDOWN_DIALOGS: usize = 64;
|
||||
/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts.
|
||||
const PANELS_FOLDER: &str = "ScriptUI Panels";
|
||||
/// Subfolders of Scripts that hold scripts the app runs itself (panels; launch and quit, as in
|
||||
@@ -337,6 +352,85 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({"opened": opened, "failed": failed}))
|
||||
}
|
||||
|
||||
/// Run the scripts in `dir` of the settings store one after another, in alphabetical order (case
|
||||
/// ignored), as After Effects runs its Startup and Shutdown folders. A script that fails is
|
||||
/// reported in a toast and the log and listed in `failed`; the next one still runs. A script waiting in a
|
||||
/// modal dialog is listed in `waiting` and goes on when the dialog closes; the next one runs
|
||||
/// meanwhile. With `quitting`, no window is left to show a dialog in, so each dialog closes as
|
||||
/// if canceled (`show()` returns 2) and the script runs on.
|
||||
fn run_folder(s: &mut Session, dir: &str, quitting: bool) -> Value {
|
||||
let mut names: Vec<String> = s.config.as_ref().map(|c| c.list(dir)).unwrap_or_default().into_iter().filter(|n| is_script(n) && is_script_name(n)).collect();
|
||||
names.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
|
||||
let (mut ran, mut waiting, mut failed) = (vec![], vec![], vec![]);
|
||||
for name in names {
|
||||
let code = match s.config.as_ref().map(|c| c.read_bounded(&format!("{dir}/{name}"), MAX_SCRIPT_BYTES)) {
|
||||
Some(Ok(Some(code))) => code,
|
||||
read => {
|
||||
let why = match read {
|
||||
Some(Err(e)) => format!(": {e}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
let error = format!("{name}: cannot read the script{why}");
|
||||
log::warn!("{dir}: {error}");
|
||||
s.events.push(Event::Toast { message: error.clone(), error: true });
|
||||
failed.push(json!({"name": name, "error": error}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let is_waiting = |r: &Result<Value>| r.as_ref().is_ok_and(|v| v.get("waiting").and_then(Value::as_bool) == Some(true));
|
||||
// In a settings folder on disk, the script runs as its file, so `#include`, `$.fileName`
|
||||
// and `$.evalFile` find the files beside it.
|
||||
let path = s.config.as_ref().and_then(|c| c.dir()).map(|d| d.join(dir).join(&name).to_string_lossy().into_owned()).unwrap_or_else(|| name.clone());
|
||||
// Windows open before this script runs belong to other scripts, perhaps of the same name.
|
||||
let before: Vec<u32> = s.script_ui.windows.iter().map(|w| w.id).collect();
|
||||
let hosts_before = s.script_ui.task_hosts.clone();
|
||||
let mut r = super::file_more::run_js_file(s, &code, &path, &name);
|
||||
if !quitting {
|
||||
// The Startup script's context: the one that scheduled tasks or holds the windows it
|
||||
// opened (a task scheduled after its dialog closes runs there too).
|
||||
let tasks = s.script_ui.task_hosts.iter().filter(|h| !hosts_before.contains(h)).copied();
|
||||
let windows = s.script_ui.windows.iter().filter(|w| !before.contains(&w.id)).map(|w| w.host);
|
||||
let hosts: Vec<u32> = tasks.chain(windows).collect();
|
||||
for h in hosts {
|
||||
if !s.script_ui.startup_hosts.contains(&h) {
|
||||
s.script_ui.startup_hosts.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut closed = 0;
|
||||
while quitting && closed < MAX_SHUTDOWN_DIALOGS && is_waiting(&r) {
|
||||
let Some(id) = s.script_ui.windows.iter().rev().find(|w| w.modal && w.script == path && !before.contains(&w.id)).map(|w| w.id) else { break };
|
||||
// report_js reports an error the script throws after its dialog closes, as it does for an
|
||||
// error in the script's first run.
|
||||
r = scriptui::close(s, &json!({"window": id})).and_then(|out| super::file_more::report_js(s, &name, out));
|
||||
closed += 1;
|
||||
}
|
||||
match r {
|
||||
Ok(_) if is_waiting(&r) => waiting.push(name),
|
||||
Ok(_) => ran.push(name),
|
||||
Err(e) => {
|
||||
// Also in the log: no frame draws a Shutdown script's toast.
|
||||
log::warn!("{dir}: {e}");
|
||||
failed.push(json!({"name": name, "error": e.to_string()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({"ran": ran, "waiting": waiting, "failed": failed})
|
||||
}
|
||||
|
||||
/// `file.runStartupScripts`: run the scripts in `Scripts/Startup` (frontends call it once at
|
||||
/// launch, before a project opens).
|
||||
fn run_startup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(run_folder(s, STARTUP_DIR, false))
|
||||
}
|
||||
|
||||
/// `file.runShutdownScripts`: close the project, then run the scripts in `Scripts/Shutdown`
|
||||
/// (frontends call it as the app quits, after the unsaved-changes prompt).
|
||||
fn run_shutdown(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.replace_project(effectcraft_project::Project::default(), None);
|
||||
Ok(run_folder(s, SHUTDOWN_DIR, true))
|
||||
}
|
||||
|
||||
/// 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";
|
||||
@@ -414,6 +508,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
restore_panels
|
||||
),
|
||||
cmd!(
|
||||
"file.runStartupScripts",
|
||||
"Run Startup Scripts",
|
||||
[],
|
||||
None,
|
||||
"{} → runs the scripts in Scripts/Startup in alphabetical order (frontends call it at launch) → {ran: [name], waiting: [name] (in a modal dialog), failed: [{name, error}]}",
|
||||
always,
|
||||
run_startup
|
||||
),
|
||||
cmd!(
|
||||
"file.runShutdownScripts",
|
||||
"Run Shutdown Scripts",
|
||||
[],
|
||||
None,
|
||||
"{} → closes the project, then runs the scripts in Scripts/Shutdown in alphabetical order (frontends call it as the app quits; their modal dialogs close as if canceled) → {ran: [name], waiting: [name], failed: [{name, error}]}",
|
||||
always,
|
||||
run_shutdown
|
||||
),
|
||||
crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get),
|
||||
cmd!(
|
||||
"script.settings.save",
|
||||
|
||||
@@ -402,12 +402,16 @@ pub struct ScriptUi {
|
||||
#[serde(skip)]
|
||||
pub tasks: Option<ScriptTaskRunner>,
|
||||
/// Bumped whenever the project is closed or replaced (New / Open / Close Project): repeating
|
||||
/// tasks of scripts without an open window stop then.
|
||||
/// tasks of scripts without an open window stop then, except those of Startup scripts.
|
||||
#[serde(skip)]
|
||||
pub project_epoch: u64,
|
||||
/// The script contexts of this session with scheduled tasks (kept by the scripting engine).
|
||||
#[serde(skip)]
|
||||
pub task_hosts: Vec<u32>,
|
||||
/// The script contexts of Startup scripts: their tasks go on when the project is closed or
|
||||
/// replaced, until EffectCraft quits.
|
||||
#[serde(skip)]
|
||||
pub startup_hosts: Vec<u32>,
|
||||
}
|
||||
|
||||
impl ScriptUi {
|
||||
|
||||
@@ -126,7 +126,7 @@ pub(crate) fn js_str(s: &str) -> JsValue {
|
||||
fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let id = arg_string(args, 0, ctx)?;
|
||||
let mut params = arg_json(args, 1, ctx)?;
|
||||
if id == "script.run" || id == "file.runScript" {
|
||||
if effectcraft_engine::commands::scripts::RUNS_SCRIPTS.contains(&id.as_str()) {
|
||||
return Err(throw("scripts can't run scripts"));
|
||||
}
|
||||
let keys = params.as_object_mut().and_then(|o| o.remove("__keys"));
|
||||
|
||||
@@ -17,6 +17,7 @@ mod include;
|
||||
mod panels;
|
||||
#[cfg(not(windows))]
|
||||
mod services;
|
||||
mod startup;
|
||||
mod tasks;
|
||||
mod ui;
|
||||
mod ui_more;
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Startup and Shutdown scripts, as in After Effects: the scripts in the user's `Scripts/Startup`
|
||||
//! and `Scripts/Shutdown` folders (in the settings folder) run in alphabetical order at launch and
|
||||
//! as the app quits, after the project is closed.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::commands::scripts::{OPEN_PANELS_FILE, PANELS_DIR, SCRIPTS_DIR, SHUTDOWN_DIR, STARTUP_DIR};
|
||||
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
|
||||
use effectcraft_engine::scriptui::WindowKind;
|
||||
use serde_json::json;
|
||||
|
||||
fn session(store: &Arc<MemoryConfig>) -> Session {
|
||||
let mut s = Session::default();
|
||||
crate::install(&mut s);
|
||||
s.config = Some(store.clone());
|
||||
s
|
||||
}
|
||||
|
||||
/// A script that appends `mark` to the `Startup Test` / `order` script setting.
|
||||
fn mark(mark: &str) -> String {
|
||||
format!(
|
||||
r#"var k = app.settings.haveSetting("Startup Test", "order") ? app.settings.getSetting("Startup Test", "order") : "";
|
||||
app.settings.saveSetting("Startup Test", "order", k + "{mark}");"#
|
||||
)
|
||||
}
|
||||
|
||||
fn setting(s: &mut Session, key: &str) -> serde_json::Value {
|
||||
s.execute("script.settings.get", json!({"section": "Startup Test", "key": key})).unwrap()["value"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_scripts_run_in_alphabetical_order_and_a_failing_one_does_not_stop_the_rest() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
store.write(&format!("{STARTUP_DIR}/b.jsx"), &mark("b")).unwrap();
|
||||
store.write(&format!("{STARTUP_DIR}/A.jsx"), &mark("A")).unwrap();
|
||||
store.write(&format!("{STARTUP_DIR}/B2.jsx"), &mark("B")).unwrap();
|
||||
store.write(&format!("{STARTUP_DIR}/C.js"), "throw new Error('no');").unwrap();
|
||||
store.write(&format!("{STARTUP_DIR}/d.jsx"), &mark("d")).unwrap();
|
||||
// Not scripts, or not in the Startup folder: not run.
|
||||
store.write(&format!("{STARTUP_DIR}/notes.txt"), &mark("x")).unwrap();
|
||||
store.write(&format!("{SCRIPTS_DIR}/Menu Script.jsx"), &mark("m")).unwrap();
|
||||
store.write(&format!("{SHUTDOWN_DIR}/Quit.jsx"), &mark("q")).unwrap();
|
||||
let mut s = session(&store);
|
||||
let r = s.execute("file.runStartupScripts", json!({})).unwrap();
|
||||
assert_eq!(r["ran"], json!(["A.jsx", "b.jsx", "B2.jsx", "d.jsx"]), "{r}");
|
||||
let failed: Vec<&str> = r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(failed, ["C.js"]);
|
||||
assert_eq!(setting(&mut s, "order"), "AbBd");
|
||||
// The failing script is reported in a toast.
|
||||
let events = s.drain_events();
|
||||
assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("C.js"))), "{events:?}");
|
||||
// The Startup folder's scripts are not offered in File ▸ Scripts.
|
||||
let list = s.execute("file.scripts.list", json!({})).unwrap();
|
||||
assert!(list.as_array().unwrap().iter().all(|e| e["name"] != "b.jsx"), "{list}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_scripts_run_after_the_project_is_closed() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let probe = r#"app.settings.saveSetting("Startup Test", "items", String(app.project.numItems));"#;
|
||||
store.write(&format!("{SHUTDOWN_DIR}/Save State.jsx"), probe).unwrap();
|
||||
let mut s = session(&store);
|
||||
s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap();
|
||||
assert!(!s.project.items.is_empty());
|
||||
let r = s.execute("file.runShutdownScripts", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"ran": ["Save State.jsx"], "waiting": [], "failed": []}));
|
||||
assert_eq!(setting(&mut s, "items"), "0");
|
||||
assert!(s.project.items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_startup_dialog_stays_open_and_its_script_goes_on_when_it_closes() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
store.write(&format!("{STARTUP_DIR}/A Dialog.jsx"), &format!("new Window('dialog', 'Welcome').show();\n{}", mark("A"))).unwrap();
|
||||
store.write(&format!("{STARTUP_DIR}/B.jsx"), &mark("B")).unwrap();
|
||||
let mut s = session(&store);
|
||||
let r = s.execute("file.runStartupScripts", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"ran": ["B.jsx"], "waiting": ["A Dialog.jsx"], "failed": []}));
|
||||
assert_eq!(setting(&mut s, "order"), "B");
|
||||
assert!(s.script_ui.windows.iter().any(|w| w.modal && w.title == "Welcome"));
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
assert_eq!(setting(&mut s, "order"), "BA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_scripts_tasks_go_on_after_a_project_opens() {
|
||||
use web_time::{Duration, Instant};
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
store.write(&format!("{STARTUP_DIR}/A Poll.jsx"), "app.scheduleTask('writeLn(\"poll\")', 100, true);").unwrap();
|
||||
// A task scheduled after the script's dialog closes.
|
||||
store.write(&format!("{STARTUP_DIR}/B Ask.jsx"), "new Window('dialog', 'Ask').show();\napp.scheduleTask('writeLn(\"ask\")', 100, true);").unwrap();
|
||||
let mut s = session(&store);
|
||||
s.execute("file.runStartupScripts", json!({})).unwrap();
|
||||
s.execute("scriptui.close", json!({})).unwrap();
|
||||
// The launch opens a project after the Startup scripts ran.
|
||||
s.execute("file.closeProject", json!({})).unwrap();
|
||||
let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(500));
|
||||
assert_eq!(r["ran"], json!(2), "{r}");
|
||||
// A script run from File ▸ Scripts still stops when the project closes.
|
||||
s.execute("script.run", json!({"code": "app.scheduleTask('writeLn(\"menu\")', 100, true);", "name": "Menu.jsx"})).unwrap();
|
||||
s.execute("file.closeProject", json!({})).unwrap();
|
||||
let r = crate::tasks::run_tasks_at(&mut s, Instant::now() + Duration::from_millis(5000));
|
||||
assert_eq!(r["ran"], json!(2), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_dialogs_close_as_if_canceled() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let code = r#"var a = new Window("dialog", "One").show();
|
||||
var b = new Window("dialog", "Two").show();
|
||||
app.settings.saveSetting("Startup Test", "shown", a + "," + b);"#;
|
||||
store.write(&format!("{SHUTDOWN_DIR}/Ask.jsx"), code).unwrap();
|
||||
// An error after the dialog closes is a failure.
|
||||
store.write(&format!("{SHUTDOWN_DIR}/Broken.jsx"), "new Window('dialog', 'Three').show();\nthrow new Error('late');").unwrap();
|
||||
let mut s = session(&store);
|
||||
let r = s.execute("file.runShutdownScripts", json!({})).unwrap();
|
||||
assert_eq!((&r["ran"], &r["waiting"]), (&json!(["Ask.jsx"]), &json!([])), "{r}");
|
||||
assert_eq!(r["failed"][0]["name"], "Broken.jsx", "{r}");
|
||||
assert!(r["failed"][0]["error"].as_str().is_some_and(|e| e.contains("late")), "{r}");
|
||||
assert_eq!(setting(&mut s, "shown"), "2,2");
|
||||
assert!(s.script_ui.windows.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_closes_only_its_own_scripts_dialogs() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
store.write(&format!("{STARTUP_DIR}/Same.jsx"), &format!("new Window('dialog', 'Startup').show();\n{}", mark("S"))).unwrap();
|
||||
store.write(&format!("{SHUTDOWN_DIR}/Same.jsx"), &format!("new Window('dialog', 'Shutdown').show();\n{}", mark("Q"))).unwrap();
|
||||
let mut s = session(&store);
|
||||
assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap()["waiting"], json!(["Same.jsx"]));
|
||||
let r = s.execute("file.runShutdownScripts", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"ran": ["Same.jsx"], "waiting": [], "failed": []}));
|
||||
// The Startup script's dialog is still open, and its script has not gone on.
|
||||
assert_eq!(setting(&mut s, "order"), "Q");
|
||||
let titles: Vec<&str> = s.script_ui.windows.iter().map(|w| w.title.as_str()).collect();
|
||||
assert_eq!(titles, ["Startup"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_scripts_are_not_run() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let big = format!("{}\n{}", "//".repeat(8 << 20), mark("x"));
|
||||
store.write(&format!("{STARTUP_DIR}/Big.jsx"), &big).unwrap();
|
||||
let mut s = session(&store);
|
||||
let r = s.execute("file.runStartupScripts", json!({})).unwrap();
|
||||
assert_eq!(r["ran"], json!([]));
|
||||
assert_eq!(r["failed"][0]["name"], "Big.jsx");
|
||||
let events = s.drain_events();
|
||||
assert!(events.iter().any(|e| matches!(e, effectcraft_engine::Event::Toast { message, error: true } if message.starts_with("Big.jsx"))), "{events:?}");
|
||||
assert_eq!(setting(&mut s, "order"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_in_a_settings_folder_on_disk_include_files_beside_them() {
|
||||
use effectcraft_engine::config::DirConfig;
|
||||
let root = std::env::temp_dir().join(format!("ec-startup-include-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
for (file, code) in [
|
||||
(format!("{STARTUP_DIR}/lib/helper.jsx"), mark("i")),
|
||||
(format!("{STARTUP_DIR}/Main.jsx"), format!("#include \"lib/helper.jsx\"\n{}", mark("m"))),
|
||||
(format!("{SHUTDOWN_DIR}/Ask.jsx"), format!("new Window('dialog', 'Bye').show();\n{}", mark("q"))),
|
||||
] {
|
||||
let path = root.join(file);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, code).unwrap();
|
||||
}
|
||||
let mut s = Session::default();
|
||||
crate::install(&mut s);
|
||||
s.config = Some(Arc::new(DirConfig::new(&root)));
|
||||
let r = s.execute("file.runStartupScripts", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"ran": ["Main.jsx"], "waiting": [], "failed": []}));
|
||||
assert_eq!(setting(&mut s, "order"), "im");
|
||||
// A Shutdown script run as its file still has its dialog closed.
|
||||
let r = s.execute("file.runShutdownScripts", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"ran": ["Ask.jsx"], "waiting": [], "failed": []}));
|
||||
assert_eq!(setting(&mut s, "order"), "imq");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// A session with one comp, for checking that a refused command leaves the project open.
|
||||
fn session_with_comp(store: &Arc<MemoryConfig>) -> Session {
|
||||
let mut s = session(store);
|
||||
s.execute("script.run", json!({"code": r#"app.project.items.addComp("Main", 64, 64, 1, 1, 24);"#})).unwrap();
|
||||
assert!(!s.project.items.is_empty());
|
||||
s
|
||||
}
|
||||
|
||||
const FOLDER_COMMANDS: [&str; 2] = ["file.runStartupScripts", "file.runShutdownScripts"];
|
||||
|
||||
#[test]
|
||||
fn scripts_cannot_run_the_startup_or_shutdown_folders() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
for id in FOLDER_COMMANDS {
|
||||
let mut s = session_with_comp(&store);
|
||||
let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {{}})")})).unwrap();
|
||||
assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}");
|
||||
assert!(!s.project.items.is_empty(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_scripts_cannot_run_the_startup_or_shutdown_folders() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
for id in FOLDER_COMMANDS {
|
||||
let mut s = session_with_comp(&store);
|
||||
let e = s.execute("file.runScript", json!({"steps": [{"command": id}]})).unwrap_err().to_string();
|
||||
assert!(e.contains("scripts can't run scripts"), "{id}: {e}");
|
||||
assert!(!s.project.items.is_empty(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batches_cannot_run_the_startup_or_shutdown_folders() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
for id in FOLDER_COMMANDS {
|
||||
let mut s = session_with_comp(&store);
|
||||
let e = s.execute("engine.batch", json!({"steps": [{"command": id}]})).unwrap_err().to_string();
|
||||
assert!(e.contains("can't run inside a batch"), "{id}: {e}");
|
||||
assert!(!s.project.items.is_empty(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A ScriptUI panel whose script appends `p` to the `Startup Test` / `order` setting each time it
|
||||
/// runs, remembered as open at the last quit.
|
||||
fn session_with_panel(store: &Arc<MemoryConfig>) -> Session {
|
||||
store.write(&format!("{PANELS_DIR}/Mark Panel.jsx"), &format!("this.add('statictext', undefined, 'Mark');\n{}", mark("p"))).unwrap();
|
||||
store.write(OPEN_PANELS_FILE, &json!({"open": ["Mark Panel.jsx"]}).to_string()).unwrap();
|
||||
session(store)
|
||||
}
|
||||
|
||||
fn panel_open(s: &Session) -> bool {
|
||||
s.script_ui.windows.iter().any(|w| w.kind == WindowKind::Panel && w.script == "Mark Panel.jsx")
|
||||
}
|
||||
|
||||
/// The commands that open ScriptUI panels, which run the panel's script.
|
||||
fn panel_commands() -> [(&'static str, serde_json::Value); 2] {
|
||||
[("window.scriptPanel", json!({"name": "Mark Panel.jsx"})), ("window.restoreScriptPanels", json!({}))]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_cannot_open_script_panels() {
|
||||
for (id, params) in panel_commands() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session_with_panel(&store);
|
||||
// Opened by the user: a script's call would only bring it forward.
|
||||
s.execute("window.scriptPanel", json!({"name": "Mark Panel.jsx"})).unwrap();
|
||||
let r = s.execute("script.run", json!({"code": format!("app.run('{id}', {params})")})).unwrap();
|
||||
assert!(r["error"]["message"].as_str().is_some_and(|m| m.contains("scripts can't run scripts")), "{id}: {r}");
|
||||
assert_eq!(setting(&mut s, "order"), "p", "{id}");
|
||||
assert_eq!(store.read(OPEN_PANELS_FILE).unwrap_or_default(), json!({"open": ["Mark Panel.jsx"]}).to_string(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_scripts_and_batches_cannot_open_script_panels() {
|
||||
for (id, params) in panel_commands() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session_with_panel(&store);
|
||||
let e = s.execute("file.runScript", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string();
|
||||
assert!(e.contains("scripts can't run scripts"), "{id}: {e}");
|
||||
let e = s.execute("engine.batch", json!({"steps": [{"command": id, "params": params}]})).unwrap_err().to_string();
|
||||
assert!(e.contains("can't run inside a batch"), "{id}: {e}");
|
||||
assert!(!panel_open(&s), "{id}");
|
||||
assert_eq!(setting(&mut s, "order"), "", "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_app_still_reopens_script_panels_at_launch() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session_with_panel(&store);
|
||||
// As the desktop and web apps call it at launch.
|
||||
let r = s.execute("window.restoreScriptPanels", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"opened": ["Mark Panel.jsx"], "failed": []}));
|
||||
assert!(panel_open(&s));
|
||||
assert_eq!(setting(&mut s, "order"), "p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_missing_folders_run_nothing() {
|
||||
let none = json!({"ran": [], "waiting": [], "failed": []});
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
let mut s = session(&store);
|
||||
assert_eq!(s.execute("file.runStartupScripts", json!({})).unwrap(), none);
|
||||
// A session without a settings folder.
|
||||
let mut s = Session::default();
|
||||
crate::install(&mut s);
|
||||
assert_eq!(s.execute("file.runShutdownScripts", json!({})).unwrap(), none);
|
||||
}
|
||||
+10
-5
@@ -404,8 +404,14 @@ fn dispatch_inline(session: &mut Session, host: u32, name: &str, ev: &ScriptUiEv
|
||||
|
||||
// ---------------------------------------------------------------- scheduled tasks
|
||||
|
||||
/// Whether `host`'s tasks stop now that the project it ran with was closed: it has no window
|
||||
/// open and is not a Startup script's.
|
||||
fn ends_with_project(session: &Session, host: u32) -> bool {
|
||||
!session.script_ui.windows.iter().any(|w| w.host == host) && !session.script_ui.startup_hosts.contains(&host)
|
||||
}
|
||||
|
||||
/// Run the due tasks of the script-host threads; hosts without windows whose project was closed
|
||||
/// end.
|
||||
/// end, except Startup scripts' hosts.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) {
|
||||
let epoch = session.script_ui.project_epoch;
|
||||
@@ -413,8 +419,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr
|
||||
if let Ok(mut h) = hosts().lock() {
|
||||
for host in session.script_ui.task_hosts.clone() {
|
||||
let Some(end) = h.get(&host) else { continue };
|
||||
let windowless = !session.script_ui.windows.iter().any(|w| w.host == host);
|
||||
if windowless && end.epoch != epoch {
|
||||
if end.epoch != epoch && ends_with_project(session, host) {
|
||||
// Dropping its end lets the host thread finish.
|
||||
stale.extend(h.remove(&host));
|
||||
} else if end.next_due.is_some_and(|d| d <= now) {
|
||||
@@ -441,7 +446,7 @@ pub(crate) fn tick_threaded(session: &mut Session, now: Instant, report: &mut cr
|
||||
}
|
||||
|
||||
/// Run the due tasks of the inline hosts on this thread (the Script Console, web runs); those
|
||||
/// without windows whose project was closed stop.
|
||||
/// without windows whose project was closed stop, except Startup scripts' hosts.
|
||||
pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crate::tasks::Report) {
|
||||
for (host, name, epoch) in crate::tasks::hosts() {
|
||||
if !session.script_ui.task_hosts.contains(&host) {
|
||||
@@ -449,7 +454,7 @@ pub(crate) fn tick_inline(session: &mut Session, now: Instant, report: &mut crat
|
||||
}
|
||||
let windowless = !session.script_ui.windows.iter().any(|w| w.host == host);
|
||||
let alive = host == CONSOLE_HOST || INLINE.with(|m| m.borrow().contains_key(&host));
|
||||
if !alive || (windowless && epoch != session.script_ui.project_epoch) {
|
||||
if !alive || (epoch != session.script_ui.project_epoch && ends_with_project(session, host)) {
|
||||
crate::tasks::drop_host(host);
|
||||
if host != CONSOLE_HOST && windowless {
|
||||
drop_inline(host);
|
||||
|
||||
@@ -16,7 +16,16 @@ use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
/// Commands that replace or close the open project (Quit is asked when the window closes).
|
||||
const CLOSING: &[&str] = &["file.newProject", "file.open", "file.openRecent", "file.closeProject", "file.revert", "file.openDemoProject", "templates.create"];
|
||||
const CLOSING: &[&str] = &[
|
||||
"file.newProject",
|
||||
"file.open",
|
||||
"file.openRecent",
|
||||
"file.closeProject",
|
||||
"file.revert",
|
||||
"file.openDemoProject",
|
||||
"templates.create",
|
||||
"file.runShutdownScripts",
|
||||
];
|
||||
|
||||
/// The command waiting on the prompt, and whether the user already answered it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -239,6 +248,23 @@ mod tests {
|
||||
assert!(app.session.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_scripts_prompt_before_discarding_unsaved_work() {
|
||||
let (mut app, ctx) = app();
|
||||
modified(&mut app);
|
||||
let result = crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap();
|
||||
assert_eq!(result, json!({"dialog": "unsavedChanges"}));
|
||||
assert_eq!(app.session.project.comps().count(), 1);
|
||||
assert!(app.session.is_dirty());
|
||||
answer_with(&mut app, &ctx, Answer::Cancel);
|
||||
assert_eq!(app.session.project.comps().count(), 1);
|
||||
assert!(app.session.is_dirty());
|
||||
crate::menus::invoke(&mut app, &ctx, "file.runShutdownScripts", json!({})).unwrap();
|
||||
answer_with(&mut app, &ctx, Answer::Discard);
|
||||
assert_eq!(app.session.project.comps().count(), 0);
|
||||
assert!(!app.session.is_dirty() && app.dialog.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_modified_project_asks_and_cancel_keeps_it() {
|
||||
let (mut app, ctx) = app();
|
||||
|
||||
+3
-1
@@ -583,7 +583,9 @@ A dialog's `show()` waits for the user: the `script.run` / `file.runScript` repl
|
||||
`"waiting": true`, and the script continues (its final output arrives in the reply of the click
|
||||
that closes the dialog). File ▸ Scripts: `file.scripts.list`, `file.runScript {"name": …}`,
|
||||
`file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`,
|
||||
`window.restoreScriptPanels` (the panels open when the app last quit). Scripts' `app.settings`:
|
||||
`window.restoreScriptPanels` (the panels open when the app last quit), `file.runStartupScripts` and
|
||||
`file.runShutdownScripts` (the scripts in `Scripts/Startup` and `Scripts/Shutdown`; the second closes
|
||||
the project first). Scripts' `app.settings`:
|
||||
`script.settings.get {"section": …, "key": …}` and `script.settings.save {…, "value": …}`.
|
||||
Extension points and the bundled extensions: [plugins.md](plugins.md).
|
||||
|
||||
|
||||
+4
-1
@@ -310,7 +310,10 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie
|
||||
on the same extension points as After Effects: scripts, ScriptUI panels and effect plug-ins
|
||||
([plugins.md](plugins.md#extension-points-compared-with-after-effects) compares them). ScriptUI
|
||||
panels in the user's ScriptUI Panels folder are listed in the Window menu, dock like any panel
|
||||
and open again at the next launch. Missing: the Startup and Shutdown script folders, keyboard
|
||||
and open again at the next launch. The scripts in the Startup folder run at launch, and the desktop
|
||||
app runs the Shutdown folder's scripts as it quits. Missing: a global scope that scripts share
|
||||
for the session (a Startup script's variables and functions are not available to later
|
||||
scripts), keyboard
|
||||
shortcuts for scripts and `.jsxbin`; `#include` / `#target` directives landed (#759,
|
||||
[scripting-parity.md](scripting-parity.md)).
|
||||
- Done so far: ease presets (#254) cover what ease-curve plug-ins are used for: easing curves kept
|
||||
|
||||
+5
-1
@@ -16,7 +16,7 @@ Guide):
|
||||
| File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) |
|
||||
| File ▸ Scripts ▸ Recent Script Files: the 10 most recently run scripts; Cmd+Option+Shift+D (Ctrl+Alt+Shift+D on Windows) reruns one | File ▸ Scripts ▸ Recent Script Files lists the 10 scripts run last (script files and File ▸ Scripts entries, newest first, kept in the settings); Cmd+Alt+Shift+D runs the most recent one again (`file.runScript {"recent": 0}`, a key that can be changed in Edit ▸ Keyboard Shortcuts) | Same |
|
||||
| ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same |
|
||||
| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap |
|
||||
| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | `Scripts/Startup/` and `Scripts/Shutdown/` in the settings folder, in alphabetical order (case ignored): the Startup scripts run at launch before a project opens, and the Shutdown scripts run as the desktop app quits, after the project is closed. A Startup script that fails is reported in a toast and in the log, a Shutdown script that fails is reported in the log, and the next script still runs | Partial: each script runs in its own global scope, so variables and functions a Startup script defines are not available to later scripts; a Startup script's modal dialog stays open while the next scripts run and the app starts; a Shutdown script's modal dialogs close as if canceled; the web app runs no Shutdown scripts |
|
||||
| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu (a subfolder, one level, is a submenu named after it), docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches |
|
||||
| Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap |
|
||||
| Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points |
|
||||
@@ -39,6 +39,10 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs
|
||||
file.jsx`, or `script.run {code, name}` over the control channel and MCP.
|
||||
* Install one: File ▸ Scripts ▸ Install Script File… (or copy it into `Scripts/` in the settings
|
||||
folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart).
|
||||
* Run one at every launch or quit: copy it into `Scripts/Startup/` or `Scripts/Shutdown/` in the
|
||||
settings folder. These scripts are not listed in File ▸ Scripts. `file.runStartupScripts` and
|
||||
`file.runShutdownScripts` run them and return `{ran, waiting, failed}`; the desktop app calls them at
|
||||
launch and quit, and the web app calls `file.runStartupScripts` at launch.
|
||||
* `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs
|
||||
one; `file.uninstallScript {name}` removes an installed one.
|
||||
* A subfolder of `Scripts/` is a submenu of File ▸ Scripts named after the folder, and its scripts
|
||||
|
||||
@@ -21,9 +21,9 @@ our own scripts; no real-world script library has been run against both apps.
|
||||
| Scripting object model | `app`, `Project`, items, layers, properties, keys, eases, render queue, `TextDocument`, `Shape`, markers, `File`/`Folder`, `Socket`, `app.settings` | Same classes, edits run through undoable engine commands; match names table; layers, groups and properties are callable (`layer("Effects")(1)` is `.property(...)`) | good | `crates/script` tests (≈ 10 test files) |
|
||||
| `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging |
|
||||
| ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` |
|
||||
| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay |
|
||||
| `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows (a Startup script's tasks go on until EffectCraft quits); frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay |
|
||||
| Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) |
|
||||
| Startup / Shutdown folders | yes | not run | missing | plugins.md |
|
||||
| Startup / Shutdown folders | yes | `Scripts/Startup` runs at launch and `Scripts/Shutdown` runs as the desktop app quits, after the project is closed, each in alphabetical order | partial | `crates/script/src/tests/startup.rs`; each script has its own global scope, so a Startup script's variables and functions are not available to later scripts ([plugins.md](plugins.md)) |
|
||||
| `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths |
|
||||
| `.jsxbin` | yes | no | out of scope | No public specification |
|
||||
| Keyboard shortcuts for scripts | yes | no | missing | plugins.md |
|
||||
@@ -35,7 +35,7 @@ our own scripts; no real-world script library has been run against both apps.
|
||||
|
||||
## Estimate
|
||||
|
||||
**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown remain), script shortcuts 2–4 h,
|
||||
**≈ 20–40 h**: `#include` / `#target` (landed, #759) and Startup / Shutdown folders 4–8 h as first estimated (Startup / Shutdown landed; a global scope shared between scripts remains), script shortcuts 2–4 h,
|
||||
a corpus of public scripts and expressions with the fixes it finds 10–20 h, an expression
|
||||
performance benchmark 4–8 h.
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ compositing and effects dominate; audio is light). Hours are to full parity for
|
||||
| Project, footage and import | 8% | 92% | 55% | 25–45 | No relative paths or relinking when a project moves; Media Browser lists AVI, which it can't import (TGA/HDR/DPX/MXF import since #745, #756, #767); #431, #324, #552 and #351 are closed |
|
||||
| Compositions, layers and compositing | 13% | 98% | 60% | 30–55 | 32-bpc blend modes differed from After Effects (#494, closed); expand precomps in place (#601); #527, #510, #513, #539 and #540 are closed |
|
||||
| Animation: keyframes, graph editor, motion paths, puppet | 15% | 97% | 55% | 35–60 | Only scalar ease is measured against After Effects (168 samples); puppet lag (#586); #456, #457, #355, #553 and #318 are closed; Proportional Scrubbing (26.2) |
|
||||
| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, Startup/Shutdown folders, script shortcuts (`#include` landed, #759); external editor (#364); see [scripting-parity.md](scripting-parity.md) |
|
||||
| Expressions and scripting | 7% | 93% | 65% | 20–40 | `.jsxbin`, a global scope shared between scripts, script shortcuts (`#include` landed, #759; Startup/Shutdown folders run); external editor (#364); see [scripting-parity.md](scripting-parity.md) |
|
||||
| Shapes, masks and roto | 9% | 93% | 45% | 30–50 | Open mask and shape issues (#1004, #999, #1042, #932); #510, #513, #527, #509 and #303 are closed; paste SVG/AI as shape layers (26.3; SVG markup pastes since #738) |
|
||||
| Text and typography | 7% | 94% | 50% | 20–35 | CJK text in comps renders wrongly (#453); paragraph alignment inverted (#316); soft text at Fit (#417, mostly fixed by #444); toolbar font options (#550); #483 and #583 are closed; variable-font filter landed (#741) |
|
||||
| Effects and animation presets | 14% | 85% | 40% | 70–130 | All 306 effects exist, but rendered output is unmeasured against After Effects; 76 built-in presets (text, Transitions, Backgrounds, Behaviors) against After Effects' 621 ([effects-parity.md](effects-parity.md)); open effect bugs (#580 crash, #463, #369, #473, #474; #514 and #526 are closed); effect colour labels (26.5); see [effects-parity.md](effects-parity.md) |
|
||||
|
||||
Reference in New Issue
Block a user