diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6e23b06e..ec7442d1 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -69,6 +69,8 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall `crates/engine/scripts/Sort Layers by In Point.jsx`, `crates/engine/scripts/Create Null at Selected Layers.jsx`, `crates/engine/scripts/Render Queue Batch.jsx`, `crates/engine/scripts/ScriptUI Panels/Layer Tools.jsx` (sidecars `.attribution`). +- Bundled extension (ScriptUI panel) with its twelve built-in ease presets: + `extensions/scriptui-panels/Ease Presets.jsx` (sidecar `.attribution`). - Example effect plug-in: `examples/plugins/posterize-bands` (plug-in API v1, WebAssembly). ## Trained models (optional downloads, not bundled) diff --git a/crates/engine/src/commands/frontend.rs b/crates/engine/src/commands/frontend.rs index b65d7666..296160d1 100644 --- a/crates/engine/src/commands/frontend.rs +++ b/crates/engine/src/commands/frontend.rs @@ -86,7 +86,7 @@ pub fn specs() -> Vec { "Show Panel", [], None, - "{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill|createNullsFromPaths|vrCompEditor|easePresets}", + "{panel: project|effectControls|composition|layer|timeline|info|audio|preview|effectsPresets|properties|character|paragraph|align|tracker|wiggler|smoother|motionSketch|paint|brushes|renderQueue|flowchart|history|markers|tools|lumetriScopes|footage|mediaBrowser|metadata|progress|contentAwareFill|createNullsFromPaths|vrCompEditor}", always ), fe!( diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index b8d626ba..423aaea2 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -204,7 +204,6 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(crate::storage::specs()); v.extend(crate::learn::specs()); v.extend(crate::templates::specs()); - v.extend(crate::ease_presets::specs()); v.extend(crate::preview::specs()); v }) diff --git a/crates/engine/src/commands/scripts.rs b/crates/engine/src/commands/scripts.rs index b3847b6b..e6951c37 100644 --- a/crates/engine/src/commands/scripts.rs +++ b/crates/engine/src/commands/scripts.rs @@ -3,11 +3,15 @@ //! * **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 +//! lists them with the bundled sample scripts and extensions ([`BUNDLED`]) 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`]). +//! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by +//! section and key that last between runs ([`SCRIPT_SETTINGS_FILE`]). + +use std::collections::BTreeMap; use serde::Serialize; use serde_json::{Value, json}; @@ -20,14 +24,88 @@ pub const SCRIPTS_DIR: &str = "Scripts"; pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels"; /// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch. pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json"; +/// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects +/// keeps them in its preferences. +pub const SCRIPT_SETTINGS_FILE: &str = "script_settings.json"; +/// User presets of the core Ease Presets panel of v0.6.0 (#254) and where the Ease Presets +/// ScriptUI panel that replaced it keeps them: (file, section, key). The file's text moves there +/// unchanged (the panel reads the same JSON) the first time settings load. +const LEGACY_EASE_PRESETS: (&str, &str, &str) = ("ease_presets.json", "Ease Presets", "userPresets"); +/// Longest section, key and value a script may store (characters / bytes). +const MAX_SETTING_NAME: usize = 256; +const MAX_SETTING_VALUE: usize = 1 << 20; -/// 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")), +/// Scripts' `app.settings`: values by section and key. +pub type ScriptSettings = BTreeMap>; + +/// Load scripts' settings from the settings store (a corrupt file gives none), moving user ease +/// presets saved before Ease Presets became a ScriptUI panel into its section. +pub(crate) fn load_script_settings(s: &mut Session) { + let Some(cfg) = s.config.clone() else { return }; + s.script_settings = cfg + .read(SCRIPT_SETTINGS_FILE) + .and_then(|t| serde_json::from_str(&t).map_err(|e| log::warn!("{SCRIPT_SETTINGS_FILE} is not valid ({e}); scripts start without their settings")).ok()) + .unwrap_or_default(); + let (file, section, key) = LEGACY_EASE_PRESETS; + if let Some(text) = cfg.read(file) + && !s.script_settings.get(section).is_some_and(|m| m.contains_key(key)) + { + s.script_settings.entry(section.into()).or_default().insert(key.into(), text); + if let Err(e) = store_script_settings(s) { + log::warn!("cannot move {file} into the Ease Presets panel's settings: {e}"); + } + } +} + +fn store_script_settings(s: &Session) -> Result<()> { + let Some(cfg) = &s.config else { return Ok(()) }; + let text = serde_json::to_string_pretty(&s.script_settings).map_err(|e| EngineError::Other(format!("script settings: {e}")))?; + cfg.write(SCRIPT_SETTINGS_FILE, &text).map_err(|e| EngineError::Other(format!("cannot save script settings: {e}"))) +} + +fn setting_name<'a>(p: &'a Value, k: &str, c: &str) -> Result<&'a str> { + let v = str_p(p, k).ok_or_else(|| bad(c, format!("missing `{k}`")))?; + if v.chars().count() > MAX_SETTING_NAME { + return Err(bad(c, format!("`{k}` is longer than {MAX_SETTING_NAME} characters"))); + } + Ok(v) +} + +/// `script.settings.get {section, key}` → `{have, value}` (`value` "" when there is none). +fn settings_get(s: &mut Session, p: &Value) -> Result { + const C: &str = "script.settings.get"; + let (section, key) = (setting_name(p, "section", C)?, setting_name(p, "key", C)?); + let v = s.script_settings.get(section).and_then(|m| m.get(key)); + Ok(json!({"have": v.is_some(), "value": v.cloned().unwrap_or_default()})) +} + +/// `script.settings.save {section, key, value}`: kept in the settings store. +fn settings_save(s: &mut Session, p: &Value) -> Result { + const C: &str = "script.settings.save"; + let (section, key) = (setting_name(p, "section", C)?, setting_name(p, "key", C)?); + let value = match p.get("value") { + Some(Value::String(v)) => v.clone(), + Some(v) if !v.is_null() => v.to_string(), + _ => return Err(bad(C, "missing `value`")), + }; + if value.len() > MAX_SETTING_VALUE { + return Err(bad(C, format!("`value` is larger than {MAX_SETTING_VALUE} bytes"))); + } + s.script_settings.entry(section.into()).or_default().insert(key.into(), value); + store_script_settings(s)?; + Ok(Value::Null) +} + +/// Scripts that ship with EffectCraft (original work): (file name, ScriptUI panel, source, code). +/// `sample`s show how to script EffectCraft; `extension`s (in `extensions/`) are optional tools +/// built on the public scripting API, outside the core, like third-party scripts in After Effects. +pub const BUNDLED: &[(&str, bool, &str, &str)] = &[ + ("Create Null at Selected Layers.jsx", false, "sample", include_str!("../../scripts/Create Null at Selected Layers.jsx")), + ("Rename Layers.jsx", false, "sample", include_str!("../../scripts/Rename Layers.jsx")), + ("Render Queue Batch.jsx", false, "sample", include_str!("../../scripts/Render Queue Batch.jsx")), + ("Sort Layers by In Point.jsx", false, "sample", include_str!("../../scripts/Sort Layers by In Point.jsx")), + ("Layer Tools.jsx", true, "sample", include_str!("../../scripts/ScriptUI Panels/Layer Tools.jsx")), + ("Ease Presets.jsx", true, "extension", include_str!("../../../../extensions/scriptui-panels/Ease Presets.jsx")), ]; /// A script File ▸ Scripts (or the Window menu, for panels) offers. @@ -37,7 +115,7 @@ pub struct ScriptEntry { pub name: String, /// In the ScriptUI Panels folder: opens as a dockable panel. pub panel: bool, - /// `installed` or `sample`. + /// `installed`, `sample` or `extension` (bundled, see [`BUNDLED`]). pub source: &'static str, } @@ -46,7 +124,7 @@ fn is_script(name: &str) -> bool { l.ends_with(".jsx") || l.ends_with(".js") } -/// Installed scripts and panels, then the samples not shadowed by an installed one, by name. +/// Installed scripts and panels, then the bundled ones not shadowed by an installed one, by name. pub fn scripts(s: &Session) -> Vec { let mut v: Vec = vec![]; if let Some(cfg) = &s.config { @@ -56,16 +134,16 @@ pub fn scripts(s: &Session) -> Vec { } } } - for (name, panel, _) in SAMPLES { + for (name, panel, source, _) in BUNDLED { if !v.iter().any(|e| e.name == *name && e.panel == *panel) { - v.push(ScriptEntry { name: name.to_string(), panel: *panel, source: "sample" }); + v.push(ScriptEntry { name: name.to_string(), panel: *panel, source }); } } 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. +/// The code of an installed or bundled script / panel. pub fn script_code(s: &Session, name: &str, panel: Option) -> Option<(bool, String)> { if let Some(cfg) = &s.config { for (dir, is_panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] { @@ -77,7 +155,7 @@ pub fn script_code(s: &Session, name: &str, panel: Option) -> Option<(bool } } } - SAMPLES.iter().find(|(n, p, _)| *n == name && panel.is_none_or(|w| w == *p)).map(|(_, p, c)| (*p, c.to_string())) + BUNDLED.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 { @@ -120,10 +198,10 @@ fn uninstall(s: &mut Session, p: &Value) -> Result { if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) } } -/// Run an installed or sample script by name (File ▸ Scripts ▸ ). A ScriptUI panel run +/// Run an installed or bundled script by name (File ▸ Scripts ▸ ). A ScriptUI panel run /// this way gets `this` = a floating palette-like panel window. pub(crate) fn run_named(s: &mut Session, name: &str) -> Result { - let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or sample script `{name}`")))?; + let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or bundled script `{name}`")))?; if panel { return open_panel(s, &json!({"name": name})); } @@ -241,7 +319,7 @@ pub fn specs() -> Vec { |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), + crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample|extension}]", list), cmd!( "window.scriptPanel", "ScriptUI Panel", @@ -260,6 +338,16 @@ pub fn specs() -> Vec { always, restore_panels ), + crate::query!("script.settings.get", "Get Script Setting", "{section, key} → {have, value} (app.settings.haveSetting / getSetting)", settings_get), + cmd!( + "script.settings.save", + "Save Script Setting", + [], + None, + "{section, key, value (text)} → kept in the settings store (app.settings.saveSetting)", + always, + settings_save + ), crate::query!("scriptui.list", "List Script Windows", "{} → [{window, title, kind: dialog|palette|window|panel, script, modal, size}]", scriptui::list), crate::query!( "scriptui.get", diff --git a/crates/engine/src/ease_presets.rs b/crates/engine/src/ease_presets.rs deleted file mode 100644 index f7be97d2..00000000 --- a/crates/engine/src/ease_presets.rs +++ /dev/null @@ -1,272 +0,0 @@ -//! Ease presets (Window ▸ Ease Presets): easing curves kept by name and applied to pairs of -//! keyframes. A few original built-in curves ship with EffectCraft; user presets live in the config -//! store (`ease_presets.json`), read leniently: a corrupt file or entry is skipped, never fatal. -//! -//! Commands: `keys.easePreset.apply {preset | curve}` eases every pair of neighbouring selected -//! keyframes of each property (one undo step), `keys.easePreset.capture` reads the curve between -//! the first selected pair, `keys.easePreset.save {name, curve?}`, `keys.easePreset.list`, -//! `keys.easePreset.rename {name, newName}` and `keys.easePreset.delete {name}`. - -use std::collections::BTreeMap; - -use effectcraft_keyframe::{EaseCurve, key_at}; -use effectcraft_project::{LayerId, Uid}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -use crate::commands::{CommandSpec, always, bad, has_keys, str_p}; -use crate::{EngineError, Result, Session, cmd, query}; - -/// User presets in the config store. -pub const EASE_PRESETS_FILE: &str = "ease_presets.json"; -/// Longest preset name, in characters. -const MAX_NAME: usize = 64; -const NEED_PAIR: &str = "select two or more neighbouring keyframes of a property"; - -/// A user preset. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct EasePreset { - pub name: String, - pub curve: EaseCurve, -} - -const fn curve(out_influence: f64, out_speed: f64, in_influence: f64, in_speed: f64) -> EaseCurve { - EaseCurve { out_influence, out_speed, in_influence, in_speed } -} - -/// Built-in presets (our own values). "Accelerate" starts slowly and "Decelerate" settles slowly. -const BUILT_IN: [(&str, EaseCurve); 12] = [ - ("Linear", EaseCurve::LINEAR), - ("Smooth", curve(40.0, 0.25, 40.0, 0.25)), - ("Ease In-Out Soft", curve(25.0, 0.0, 25.0, 0.0)), - ("Ease In-Out", curve(50.0, 0.0, 50.0, 0.0)), - ("Ease In-Out Strong", curve(75.0, 0.0, 75.0, 0.0)), - ("Accelerate", curve(50.0, 0.0, 20.0, 1.5)), - ("Accelerate Strong", curve(80.0, 0.0, 15.0, 2.5)), - ("Decelerate", curve(20.0, 1.5, 50.0, 0.0)), - ("Decelerate Strong", curve(15.0, 2.5, 80.0, 0.0)), - ("Expo In-Out", curve(88.0, 0.0, 88.0, 0.0)), - ("Expo Accelerate", curve(75.0, 0.0, 12.0, 7.0)), - ("Expo Decelerate", curve(12.0, 7.0, 75.0, 0.0)), -]; - -/// Every preset: (name, curve, built in), the built-in ones first. -pub fn presets(s: &Session) -> impl Iterator { - BUILT_IN.iter().map(|(n, c)| (*n, *c, true)).chain(s.ease_presets.iter().map(|p| (p.name.as_str(), p.curve, false))) -} - -/// A preset's curve by name (any case). -pub fn find(s: &Session, name: &str) -> Option { - presets(s).find(|(n, ..)| n.eq_ignore_ascii_case(name)).map(|(_, c, _)| c) -} - -fn is_built_in(name: &str) -> bool { - BUILT_IN.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)) -} - -/// Pairs of neighbouring selected keyframes of the active comp: (layer, property, index of the -/// earlier key), by property and time. -pub fn selected_pairs(s: &Session) -> Vec<(LayerId, Uid, usize)> { - let Some(comp) = s.active_comp() else { return vec![] }; - let mut groups: BTreeMap<(LayerId, Uid), Vec> = BTreeMap::new(); - for k in &s.state.selected_keys { - let Some(pr) = comp.layer(k.layer).and_then(|l| l.props.find(k.prop)).filter(|pr| pr.value.interpolates()) else { continue }; - if let Some(i) = key_at(&pr.keys, k.time) { - groups.entry((k.layer, k.prop)).or_default().push(i); - } - } - let mut out = vec![]; - for ((lid, uid), mut idx) in groups { - idx.sort_unstable(); - idx.dedup(); - out.extend(idx.windows(2).filter_map(|w| match *w { - [a, b] if b == a + 1 => Some((lid, uid, a)), - _ => None, - })); - } - out -} - -/// Read user presets: entries that don't parse, have no usable name or repeat a name are -/// skipped (and logged); a file that isn't JSON gives none. -pub fn parse(text: &str) -> Vec { - let doc: Value = serde_json::from_str(text).unwrap_or_else(|e| { - log::warn!("{EASE_PRESETS_FILE} is not valid JSON ({e}); no user ease presets loaded"); - Value::Null - }); - let mut out: Vec = vec![]; - for entry in doc.get("presets").and_then(Value::as_array).into_iter().flatten() { - let preset = serde_json::from_value::(entry.clone()) - .ok() - .and_then(|p| Some(EasePreset { name: clean_name(&p.name)?, curve: p.curve.sanitized()? })) - .filter(|p| !is_built_in(&p.name) && !out.iter().any(|q| q.name.eq_ignore_ascii_case(&p.name))); - match preset { - Some(p) => out.push(p), - None => log::warn!("{EASE_PRESETS_FILE}: skipped the unusable entry {entry}"), - } - } - out -} - -/// The file as written. -#[derive(Serialize)] -struct PresetFile<'a> { - version: u32, - presets: &'a [EasePreset], -} - -/// Write `list` to the config store, then make it the session's user presets. -fn store(s: &mut Session, list: Vec) -> Result<()> { - if let Some(cfg) = &s.config { - let text = serde_json::to_string_pretty(&PresetFile { version: 1, presets: &list }).map_err(|e| EngineError::Other(format!("ease presets: {e}")))?; - cfg.write(EASE_PRESETS_FILE, &text).map_err(|e| EngineError::Other(format!("cannot save ease presets: {e}")))?; - } - s.ease_presets = list; - Ok(()) -} - -fn clean_name(name: &str) -> Option { - let name = name.trim(); - (!name.is_empty() && name.chars().count() <= MAX_NAME).then(|| name.to_string()) -} - -fn name_p(p: &Value, key: &str, c: &str) -> Result { - let raw = str_p(p, key).ok_or_else(|| bad(c, format!("missing `{key}`")))?; - clean_name(raw).ok_or_else(|| bad(c, format!("`{key}` must be 1–{MAX_NAME} characters"))) -} - -/// `curve`: `{outInfluence, outSpeed, inInfluence, inSpeed}` or handles `[x1, y1, x2, y2]`. -fn curve_v(v: &Value, c: &str) -> Result { - let parsed = match v.as_array() { - Some(a) => { - let h: Option> = a.iter().map(Value::as_f64).collect(); - let h: [f64; 4] = h.and_then(|h| h.try_into().ok()).ok_or_else(|| bad(c, "`curve` handles are four numbers [x1, y1, x2, y2]"))?; - EaseCurve::from_handles(h) - } - None => serde_json::from_value::(v.clone()).map_err(|e| bad(c, format!("`curve`: {e}")))?.sanitized(), - }; - parsed.ok_or_else(|| bad(c, "`curve` numbers must be finite")) -} - -/// The curve between the first selected pair of keyframes. -fn captured(s: &Session, c: &str) -> Result { - let comp = s.active_comp().ok_or(EngineError::NoComp)?; - let (lid, uid, i) = selected_pairs(s).into_iter().next().ok_or_else(|| bad(c, NEED_PAIR))?; - let pr = comp.layer(lid).and_then(|l| l.props.find(uid)).ok_or_else(|| bad(c, "no property"))?; - EaseCurve::measure(&pr.keys, i, pr.spatial).ok_or_else(|| bad(c, "the selected keyframes have no curve to keep (a hold, or no change in value)")) -} - -fn apply(s: &mut Session, p: &Value) -> Result { - const C: &str = "keys.easePreset.apply"; - let curve = match (str_p(p, "preset"), p.get("curve")) { - (Some(name), _) => find(s, name).ok_or_else(|| bad(C, format!("no ease preset `{name}` (see keys.easePreset.list)")))?, - (None, Some(v)) => curve_v(v, C)?, - (None, None) => return Err(bad(C, "pass `preset` (a name) or `curve`")), - }; - let pairs = selected_pairs(s); - if pairs.is_empty() { - return Err(bad(C, NEED_PAIR)); - } - let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; - let n = s.edit("Apply Ease Preset", None, |proj, _| { - let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - let mut n = 0; - for (lid, uid, i) in &pairs { - if let Some(pr) = comp.layer_mut(*lid).and_then(|l| l.props.find_mut(*uid)) - && curve.apply(&mut pr.keys, *i, pr.spatial) - { - n += 1; - } - } - Ok(n) - })?; - Ok(json!({"pairs": n, "curve": curve})) -} - -fn capture(s: &mut Session, _: &Value) -> Result { - Ok(json!(captured(s, "keys.easePreset.capture")?)) -} - -fn save(s: &mut Session, p: &Value) -> Result { - const C: &str = "keys.easePreset.save"; - let name = name_p(p, "name", C)?; - if is_built_in(&name) { - return Err(bad(C, format!("`{name}` is a built-in preset; choose another name"))); - } - let curve = match p.get("curve") { - Some(v) => curve_v(v, C)?, - None => captured(s, C)?, - }; - let preset = EasePreset { name, curve }; - let mut list = s.ease_presets.clone(); - // Saving under an existing name replaces that preset. - match list.iter_mut().find(|q| q.name.eq_ignore_ascii_case(&preset.name)) { - Some(q) => *q = preset.clone(), - None => list.push(preset.clone()), - } - store(s, list)?; - Ok(json!(preset)) -} - -/// Index of the user preset `name`; built-in presets can't be changed. -fn user_preset(s: &Session, name: &str, c: &str) -> Result { - s.ease_presets.iter().position(|q| q.name.eq_ignore_ascii_case(name)).ok_or_else(|| { - if is_built_in(name) { bad(c, format!("`{name}` is a built-in preset and can't be changed")) } else { bad(c, format!("no user ease preset `{name}`")) } - }) -} - -fn rename(s: &mut Session, p: &Value) -> Result { - const C: &str = "keys.easePreset.rename"; - let (name, new_name) = (name_p(p, "name", C)?, name_p(p, "newName", C)?); - let i = user_preset(s, &name, C)?; - if is_built_in(&new_name) || s.ease_presets.iter().enumerate().any(|(j, q)| j != i && q.name.eq_ignore_ascii_case(&new_name)) { - return Err(bad(C, format!("an ease preset named `{new_name}` exists"))); - } - let mut list = s.ease_presets.clone(); - if let Some(q) = list.get_mut(i) { - q.name = new_name.clone(); - } - store(s, list)?; - Ok(json!({"name": new_name})) -} - -fn delete(s: &mut Session, p: &Value) -> Result { - const C: &str = "keys.easePreset.delete"; - let name = name_p(p, "name", C)?; - user_preset(s, &name, C)?; - let mut list = s.ease_presets.clone(); - list.retain(|q| !q.name.eq_ignore_ascii_case(&name)); - store(s, list)?; - Ok(json!({"deleted": name})) -} - -fn list(s: &mut Session, _: &Value) -> Result { - Ok(presets(s).map(|(name, curve, built_in)| json!({"name": name, "builtIn": built_in, "curve": curve})).collect()) -} - -pub fn specs() -> Vec { - vec![ - cmd!( - "keys.easePreset.apply", - "Apply Ease Preset", - [], - None, - "{preset (name) | curve: {outInfluence (%), outSpeed, inInfluence (%), inSpeed} | [x1, y1, x2, y2]} — eases every pair of neighbouring selected keyframes; speeds are relative to the segment's average speed", - has_keys, - apply - ), - cmd!( - "keys.easePreset.save", - "Save Ease Preset", - [], - None, - "{name, curve? (as for apply; default: the curve between the first selected pair of keyframes)} — replaces a user preset of that name", - always, - save - ), - cmd!("keys.easePreset.rename", "Rename Ease Preset", [], None, "{name, newName}", always, rename), - cmd!("keys.easePreset.delete", "Delete Ease Preset", [], None, "{name}", always, delete), - query!("keys.easePreset.list", "Ease Presets", "{} — [{name, builtIn, curve}]", list), - query!("keys.easePreset.capture", "Ease Curve of Selected Keyframes", "{} — the curve between the first selected pair of keyframes", capture), - ] -} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b38a84ee..24ccf1af 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,7 +15,6 @@ pub mod camera_track; pub mod commands; pub mod config; pub mod demo; -pub mod ease_presets; pub mod footage_check; pub mod guard; pub mod history; @@ -406,8 +405,9 @@ pub struct Session { pub config: Option>, /// Keyboard shortcut presets. pub keymaps: shortcuts::Keymaps, - /// User ease presets (Window ▸ Ease Presets; the built-in ones are in [`ease_presets`]). - pub ease_presets: Vec, + /// Scripts' `app.settings`, kept in the settings store (see + /// [`commands::scripts::SCRIPT_SETTINGS_FILE`]). + pub script_settings: commands::scripts::ScriptSettings, /// Frontend-only commands offered for binding. pub ui_commands: Vec, /// Cache of the resolved active preset (read it with [`Session::shortcuts`]). @@ -521,7 +521,7 @@ impl Default for Session { prefs_revision: 0, config: None, keymaps: shortcuts::Keymaps::default(), - ease_presets: vec![], + script_settings: Default::default(), ui_commands: vec![], shortcut_table: std::sync::OnceLock::new(), autosave: autosave::AutoSaveState::default(), @@ -1008,8 +1008,6 @@ mod tests_color_view; #[cfg(test)] mod tests_disk_cache; #[cfg(test)] -mod tests_ease_presets; -#[cfg(test)] mod tests_effects; #[cfg(test)] mod tests_essential; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 38636936..c5830828 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -1200,7 +1200,6 @@ Window @dynamic:openViewers --- Create Nulls From Paths | window.panel {"panel":"createNullsFromPaths"} - Ease Presets | window.panel {"panel":"easePresets"} VR Comp Editor | window.panel {"panel":"vrCompEditor"} @dynamic:scriptPanels Help diff --git a/crates/engine/src/session_settings.rs b/crates/engine/src/session_settings.rs index 88e1ce18..34f24786 100644 --- a/crates/engine/src/session_settings.rs +++ b/crates/engine/src/session_settings.rs @@ -3,13 +3,13 @@ use std::path::Path; use crate::autosave::{self, Sentinel}; -use crate::ease_presets::EASE_PRESETS_FILE; use crate::prefs::{PREFS_FILE, Prefs}; use crate::shortcuts::{Keymaps, SHORTCUTS_FILE, ShortcutTable, UiCommand}; use crate::{EngineError, Result, Session}; impl Session { - /// Load settings, shortcut and ease presets from the config store and apply them. + /// Load settings, shortcut presets and scripts' settings from the config store and apply + /// them. pub fn load_settings(&mut self) { if let Some(c) = &self.config { if let Some(t) = c.read(PREFS_FILE) { @@ -18,10 +18,8 @@ impl Session { if let Some(t) = c.read(SHORTCUTS_FILE) { self.keymaps = Keymaps::from_json(&t); } - if let Some(t) = c.read(EASE_PRESETS_FILE) { - self.ease_presets = crate::ease_presets::parse(&t); - } } + crate::commands::scripts::load_script_settings(self); self.prefs_changed(); self.shortcuts_changed(); } diff --git a/crates/engine/src/tests_ease_presets.rs b/crates/engine/src/tests_ease_presets.rs deleted file mode 100644 index 5f064181..00000000 --- a/crates/engine/src/tests_ease_presets.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! Ease presets (#254): a saved curve applied to every pair of neighbouring selected keyframes -//! (one undo step), save / rename / delete persisted through the config store, and lenient loading -//! of a corrupt presets file. - -use std::sync::Arc; - -use effectcraft_keyframe::{Ease, Interp, ease_progress}; -use effectcraft_time::Tick; -use serde_json::{Value, json}; - -use crate::Session; -use crate::config::{ConfigStore, MemoryConfig}; -use crate::ease_presets::EASE_PRESETS_FILE; -use crate::tests_timeline::{animate, prop, setup}; - -fn close(a: f64, b: f64, tol: f64) -> bool { - (a - b).abs() < tol -} - -fn user_names(s: &mut Session) -> Vec { - let list = s.execute("keys.easePreset.list", json!({})).unwrap(); - list.as_array().unwrap().iter().filter(|p| p["builtIn"] == false).map(|p| p["name"].as_str().unwrap().to_string()).collect() -} - -#[test] -fn a_preset_eases_every_selected_pair_in_one_undo_step() { - let (mut s, l) = setup(); - animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); - animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]); - s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); - s.execute("prop.select", json!({"layer": l, "path": "transform/rotation", "add": true})).unwrap(); - let steps = s.history.undo.len(); - let r = s.execute("keys.easePreset.apply", json!({"preset": "Ease In-Out"})).unwrap(); - assert_eq!(r["pairs"], 3, "two opacity segments and one rotation segment"); - assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); - let eased = Ease { speed: 0.0, influence: 0.5 }; - let op = prop(&s, l, "transform/opacity"); - assert_eq!((op.keys[0].out_interp, op.keys[0].out_ease.clone()), (Interp::Bezier, vec![eased])); - assert_eq!((op.keys[1].in_ease.clone(), op.keys[1].out_ease.clone()), (vec![eased], vec![eased])); - assert_eq!((op.keys[2].in_interp, op.keys[2].in_ease.clone()), (Interp::Bezier, vec![eased])); - // The first key's in side and the last key's out side are not part of a pair. - assert_eq!((op.keys[0].in_interp, op.keys[2].out_interp), (Interp::Linear, Interp::Linear)); - // A symmetric curve passes the middle value at mid-time, slow near the keys. - assert!(close(op.value_at(Tick::from_seconds_f64(2.0)).as_f64(), 60.0, 1e-6)); - let quarter = op.value_at(Tick::from_seconds_f64(1.5)).as_f64(); - assert!(close(quarter, 100.0 - 80.0 * ease_progress(0.0, 0.5, 0.0, 0.5, 0.25), 1e-6), "{quarter}"); - assert!(prop(&s, l, "transform/rotation").keys[1].in_interp == Interp::Bezier); - s.execute("edit.undo", json!({})).unwrap(); - assert_eq!(s.history.redo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset")); - let op = prop(&s, l, "transform/opacity"); - assert!(op.keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear)); - assert!(close(op.value_at(Tick::from_seconds_f64(1.5)).as_f64(), 80.0, 1e-6)); -} - -#[test] -fn a_curve_scales_to_each_dimension_and_follows_motion_paths() { - let (mut s, l) = setup(); - animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50]))]); - animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (2.0, json!([300, 400]))]); - s.execute("prop.select", json!({"layer": l, "path": "transform/scale"})).unwrap(); - let curve = json!({"outInfluence": 60, "outSpeed": 0, "inInfluence": 20, "inSpeed": 2}); - s.execute("keys.easePreset.apply", json!({"curve": curve})).unwrap(); - let sc = prop(&s, l, "transform/scale"); - // X rises 100/s and Y falls 50/s: the in speed (2× average) is per dimension. - assert_eq!(sc.keys[1].in_ease[..2], [Ease { speed: 200.0, influence: 0.2 }, Ease { speed: -100.0, influence: 0.2 }]); - let f = ease_progress(0.0, 0.6, 2.0, 0.2, 0.5); - let v = sc.value_at(Tick::from_seconds_f64(0.5)).as_vec2(); - assert!(close(v[0], 100.0 + 100.0 * f, 1e-6) && close(v[1], 100.0 - 50.0 * f, 1e-6), "{v:?}"); - // Handles [x1, y1, x2, y2] on Position: the speed is along the 500 px path. - s.execute("prop.select", json!({"layer": l, "path": "transform/position"})).unwrap(); - s.execute("keys.easePreset.apply", json!({"curve": [0.25, 0.1, 0.25, 1.0]})).unwrap(); - let pos = prop(&s, l, "transform/position"); - assert!(pos.keys[0].out_ease.iter().all(|e| close(e.speed, 0.4 * 250.0, 1e-6) && close(e.influence, 0.25, 1e-9)), "{:?}", pos.keys[0].out_ease); - let f = ease_progress(0.4, 0.25, 0.0, 0.75, 0.5); - let p = pos.value_at(Tick::from_seconds_f64(1.0)).as_vec2(); - assert!(close(p[0], 300.0 * f, 0.05) && close(p[1], 400.0 * f, 0.05), "{p:?} {f}"); - // The captured curve is the applied one. - let c = s.execute("keys.easePreset.capture", json!({})).unwrap(); - assert!(close(c["inInfluence"].as_f64().unwrap(), 75.0, 1e-6) && close(c["outSpeed"].as_f64().unwrap(), 0.4, 1e-6), "{c}"); -} - -#[test] -fn apply_needs_neighbouring_keys_and_a_known_curve() { - let (mut s, l) = setup(); - animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]); - let key = |t: f64| json!({"layer": l, "path": "transform/opacity", "time": t}); - // Keys 1 and 3 aren't neighbours. - s.execute("keys.select", json!({"keys": [key(0.0), key(2.0)]})).unwrap(); - let steps = s.history.undo.len(); - let e = s.execute("keys.easePreset.apply", json!({"preset": "Linear"})).unwrap_err().to_string(); - assert!(e.contains("neighbouring"), "{e}"); - assert_eq!(s.history.undo.len(), steps, "nothing changed"); - s.execute("keys.select", json!({"keys": [key(0.0), key(1.0)]})).unwrap(); - for bad in - [json!({"preset": "Nope"}), json!({"curve": [0.1, 0.2]}), json!({"curve": [0.1, "a", 0.3, 0.4]}), json!({"curve": {"outInfluence": 50}}), json!({})] - { - assert!(s.execute("keys.easePreset.apply", bad.clone()).is_err(), "{bad}"); - } - // Names are matched in any case. - assert_eq!(s.execute("keys.easePreset.apply", json!({"preset": "expo in-out"})).unwrap()["pairs"], 1); -} - -#[test] -fn user_presets_save_rename_delete_and_persist() { - let store = Arc::new(MemoryConfig::default()); - let (mut s, l) = setup(); - s.config = Some(store.clone()); - animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100))]); - s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); - s.execute("keys.easePreset.apply", json!({"preset": "Decelerate"})).unwrap(); - // Without a curve, save keeps the one between the selected keys. - let saved = s.execute("keys.easePreset.save", json!({"name": " Settle "})).unwrap(); - assert_eq!(saved["name"], "Settle"); - assert!(close(saved["curve"]["outSpeed"].as_f64().unwrap(), 1.5, 1e-9) && close(saved["curve"]["inInfluence"].as_f64().unwrap(), 50.0, 1e-9), "{saved}"); - s.execute("keys.easePreset.save", json!({"name": "Snap", "curve": [0.1, 0.5, 0.2, 1.0]})).unwrap(); - assert_eq!(user_names(&mut s), ["Settle", "Snap"]); - // Built-in names and existing names are refused; built-in presets can't change. - assert!(s.execute("keys.easePreset.save", json!({"name": "linear", "curve": [0.3, 0.3, 0.7, 0.7]})).is_err()); - assert!(s.execute("keys.easePreset.save", json!({"name": " "})).is_err()); - assert!(s.execute("keys.easePreset.rename", json!({"name": "Settle", "newName": "snap"})).is_err()); - assert!(s.execute("keys.easePreset.rename", json!({"name": "Linear", "newName": "Straight"})).is_err()); - assert!(s.execute("keys.easePreset.delete", json!({"name": "Ease In-Out"})).is_err()); - s.execute("keys.easePreset.rename", json!({"name": "settle", "newName": "Soft Landing"})).unwrap(); - // Saving under an existing name replaces that preset. - s.execute("keys.easePreset.save", json!({"name": "SNAP", "curve": [0.2, 0.5, 0.2, 1.0]})).unwrap(); - assert_eq!(user_names(&mut s), ["Soft Landing", "SNAP"]); - s.execute("keys.easePreset.delete", json!({"name": "snap"})).unwrap(); - assert!(s.execute("keys.easePreset.delete", json!({"name": "snap"})).is_err()); - // A new session reads them back from the store. - let mut s2 = Session { config: Some(store.clone()), ..Default::default() }; - s2.load_settings(); - assert_eq!(user_names(&mut s2), ["Soft Landing"]); - let c = crate::ease_presets::find(&s2, "soft landing").unwrap(); - assert!(close(c.out_influence, 20.0, 1e-9) && close(c.out_speed, 1.5, 1e-9) && close(c.in_speed, 0.0, 1e-9), "{c:?}"); -} - -#[test] -fn a_corrupt_presets_file_loads_what_it_can() { - let store = Arc::new(MemoryConfig::default()); - store.write(EASE_PRESETS_FILE, "{\"presets\": [ not json").unwrap(); - let mut s = Session { config: Some(store.clone()), ..Default::default() }; - s.load_settings(); - assert!(s.ease_presets.is_empty()); - assert_eq!(s.execute("keys.easePreset.list", json!({})).unwrap().as_array().unwrap().len(), 12, "the built-in presets"); - let c = json!({"outInfluence": 30, "outSpeed": 0, "inInfluence": 30, "inSpeed": 0}); - let doc = json!({"version": 1, "presets": [ - {"name": "Good", "curve": c}, - {"name": "Linear", "curve": c}, - {"name": "", "curve": c}, - {"name": "No curve"}, - {"name": "good", "curve": c}, - 42, - {"name": "Wild", "curve": {"outInfluence": 1e9, "outSpeed": -1e9, "inInfluence": -5, "inSpeed": 0}}, - ]}); - store.write(EASE_PRESETS_FILE, &doc.to_string()).unwrap(); - s.load_settings(); - assert_eq!(s.ease_presets.iter().map(|p| p.name.as_str()).collect::>(), ["Good", "Wild"]); - let wild = s.ease_presets[1].curve; - assert_eq!((wild.out_influence, wild.out_speed, wild.in_influence), (100.0, -100.0, 0.1)); - // Saving rewrites a clean file. - s.execute("keys.easePreset.delete", json!({"name": "Wild"})).unwrap(); - let text: Value = serde_json::from_str(&store.read(EASE_PRESETS_FILE).unwrap()).unwrap(); - assert_eq!(text["presets"].as_array().unwrap().len(), 1); -} diff --git a/crates/keyframe/src/ease_curve.rs b/crates/keyframe/src/ease_curve.rs deleted file mode 100644 index 25b5cdee..00000000 --- a/crates/keyframe/src/ease_curve.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Ease curves: the temporal ease of one keyframe segment, independent of its duration and values, -//! so it can be kept as a preset and applied to any pair of keyframes. -//! -//! A curve is the out side of the first key and the in side of the second, in the Keyframe Velocity -//! dialog's terms: influence in percent of the segment, and speed relative to the segment's average -//! speed (1 = linear, 0 = at rest). In the normalised value graph (time and value 0 → 1) its handles -//! are the points (x1, y1) = (out influence, out speed × out influence) and -//! (x2, y2) = (1 − in influence, 1 − in speed × in influence). - -use serde::{Deserialize, Serialize}; - -use crate::{Ease, Interp, Keyframe, Value, effective_ease, secs, spatial_segment_length}; - -/// Smallest influence (percent), as in the Keyframe Velocity dialog. -const MIN_INFLUENCE: f64 = 0.1; -/// Relative speeds are kept within ± this (a handle 100 times steeper than linear). -const MAX_SPEED: f64 = 100.0; - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct EaseCurve { - pub out_influence: f64, - pub out_speed: f64, - pub in_influence: f64, - pub in_speed: f64, -} - -impl EaseCurve { - /// Constant speed: both handles a third of the way along the straight line. - pub const LINEAR: EaseCurve = EaseCurve { out_influence: 100.0 / 3.0, out_speed: 1.0, in_influence: 100.0 / 3.0, in_speed: 1.0 }; - - /// The curve with influences in 0.1–100 % and speeds within ±100; `None` when a number isn't - /// finite. - pub fn sanitized(self) -> Option { - if ![self.out_influence, self.out_speed, self.in_influence, self.in_speed].iter().all(|v| v.is_finite()) { - return None; - } - let inf = |v: f64| v.clamp(MIN_INFLUENCE, 100.0); - let speed = |v: f64| v.clamp(-MAX_SPEED, MAX_SPEED); - Some(EaseCurve { - out_influence: inf(self.out_influence), - out_speed: speed(self.out_speed), - in_influence: inf(self.in_influence), - in_speed: speed(self.in_speed), - }) - } - - /// Handles in the normalised value graph: `[x1, y1, x2, y2]`. - pub fn handles(&self) -> [f64; 4] { - let (o, i) = (self.out_influence / 100.0, self.in_influence / 100.0); - [o, self.out_speed * o, 1.0 - i, 1.0 - self.in_speed * i] - } - - /// The curve with handles `[x1, y1, x2, y2]` (each influence at least 0.1 %); `None` when a - /// number isn't finite. - pub fn from_handles([x1, y1, x2, y2]: [f64; 4]) -> Option { - let o = x1.clamp(MIN_INFLUENCE / 100.0, 1.0); - let i = (1.0 - x2).clamp(MIN_INFLUENCE / 100.0, 1.0); - EaseCurve { out_influence: o * 100.0, out_speed: y1 / o, in_influence: i * 100.0, in_speed: (1.0 - y2) / i }.sanitized() - } - - /// Ease segment `i` → `i + 1` with this curve: the out side of key `i` and the in side of key - /// `i + 1` become Bezier, scaled to the segment per dimension (along the motion path for - /// spatial values, as overall progress for paths and gradients), as Easy Ease shapes them. - /// Auto-Bezier keys keep their other side as it plays. False when there is no such segment or - /// its values don't interpolate. - pub fn apply(&self, keys: &mut [Keyframe], i: usize, spatial: bool) -> bool { - let Some(slopes) = segment_slopes(keys, i, spatial) else { return false }; - let side = |influence: f64, speed: f64| -> Vec { slopes.iter().map(|s| Ease { speed: speed * s, influence: influence / 100.0 }).collect() }; - let (out, inn) = (side(self.out_influence, self.out_speed), side(self.in_influence, self.in_speed)); - freeze_auto(keys, i, spatial); - freeze_auto(keys, i + 1, spatial); - if let Some(a) = keys.get_mut(i) { - a.out_interp = Interp::Bezier; - a.out_ease = out; - } - if let Some(b) = keys.get_mut(i + 1) { - b.in_interp = Interp::Bezier; - b.in_ease = inn; - } - true - } - - /// The curve segment `i` → `i + 1` plays (a linear side counts as speed 1 at 33.3 %), measured - /// on the dimension that changes most. `None` for hold segments, values that don't - /// interpolate and segments whose value doesn't change. - pub fn measure(keys: &[Keyframe], i: usize, spatial: bool) -> Option { - let slopes = segment_slopes(keys, i, spatial)?; - let (a, b) = (keys.get(i)?, keys.get(i + 1)?); - if a.out_interp == Interp::Hold { - return None; - } - let (d, slope) = slopes.iter().copied().enumerate().max_by(|x, y| x.1.abs().total_cmp(&y.1.abs()))?; - if slope.abs() < 1e-12 { - return None; - } - let is_spatial = is_spatial(&a.value, spatial); - let d = if is_spatial { 0 } else { d }; - let side = - |k: usize, bezier: bool, out: bool| if bezier { effective_ease(keys, k, d, is_spatial, out) } else { Ease { speed: slope, influence: 1.0 / 3.0 } }; - let out = side(i, a.out_interp == Interp::Bezier, true); - let inn = side(i + 1, b.in_interp == Interp::Bezier, false); - EaseCurve { out_influence: out.influence * 100.0, out_speed: out.speed / slope, in_influence: inn.influence * 100.0, in_speed: inn.speed / slope } - .sanitized() - } -} - -fn is_spatial(v: &Value, spatial: bool) -> bool { - spatial && matches!(v, Value::Vec2(_) | Value::Vec3(_)) -} - -/// Average speed of segment `i` → `i + 1` for each ease entry of its keys: per dimension, along -/// the motion path (spatial), or 1 / duration (paths and gradients ease their overall progress). -fn segment_slopes(keys: &[Keyframe], i: usize, spatial: bool) -> Option> { - let (a, b) = (keys.get(i)?, keys.get(i.checked_add(1)?)?); - let dur = secs(b.time) - secs(a.time); - if !a.value.interpolates() || !dur.is_finite() || dur <= 0.0 { - return None; - } - let n = a.value.dims().max(1); - let (ca, cb) = (a.value.components(), b.value.components()); - Some(if is_spatial(&a.value, spatial) { - vec![spatial_segment_length(keys, i) / dur; n] - } else if ca.is_empty() || ca.len() != cb.len() { - vec![1.0 / dur; n] - } else { - ca.iter().zip(&cb).map(|(x, y)| (y - x) / dur).collect() - }) -} - -/// Store the eases an Auto-Bezier key `k` plays and turn Auto-Bezier off, so editing one side -/// leaves the other as it was. -fn freeze_auto(keys: &mut [Keyframe], k: usize, spatial: bool) { - let Some(key) = keys.get(k).filter(|key| key.auto_bezier) else { return }; - let is_spatial = is_spatial(&key.value, spatial); - let n = key.value.dims().max(1); - let side = |out: bool| -> Vec { (0..n).map(|d| effective_ease(keys, k, if is_spatial { 0 } else { d }, is_spatial, out)).collect() }; - let (inn, out) = (side(false), side(true)); - if let Some(key) = keys.get_mut(k) { - key.in_ease = inn; - key.out_ease = out; - key.auto_bezier = false; - } -} - -#[cfg(test)] -mod tests { - use effectcraft_time::Tick; - - use super::*; - use crate::{ease_progress, evaluate}; - - fn s(x: f64) -> Tick { - Tick::from_seconds_f64(x) - } - - const CURVE: EaseCurve = EaseCurve { out_influence: 60.0, out_speed: 0.0, in_influence: 20.0, in_speed: 2.0 }; - - #[test] - fn handles_round_trip_and_reject_non_finite_numbers() { - let h = CURVE.handles(); - assert!(h.iter().zip([0.6, 0.0, 0.8, 0.6]).all(|(a, b)| (a - b).abs() < 1e-12), "{h:?}"); - let back = EaseCurve::from_handles(h).unwrap(); - for (a, b) in [(back.out_influence, 60.0), (back.out_speed, 0.0), (back.in_influence, 20.0), (back.in_speed, 2.0)] { - assert!((a - b).abs() < 1e-9, "{back:?}"); - } - // Influences stay ≥ 0.1 % even with a handle on the key. - assert!((EaseCurve::from_handles([0.0, 0.0, 1.0, 1.0]).unwrap().out_influence - MIN_INFLUENCE).abs() < 1e-12); - assert!(EaseCurve::from_handles([f64::NAN, 0.0, 0.5, 1.0]).is_none()); - assert!(EaseCurve { out_speed: f64::INFINITY, ..CURVE }.sanitized().is_none()); - assert_eq!(EaseCurve { in_speed: 1e9, ..CURVE }.sanitized().unwrap().in_speed, MAX_SPEED); - } - - #[test] - fn applied_curve_scales_to_each_dimension_and_measures_back() { - let mut keys = vec![Keyframe::new(s(1.0), Value::Vec2([0.0, 100.0])), Keyframe::new(s(3.0), Value::Vec2([200.0, 50.0]))]; - assert!(CURVE.apply(&mut keys, 0, false)); - assert_eq!((keys[0].out_interp, keys[1].in_interp), (Interp::Bezier, Interp::Bezier)); - // X goes 200 in 2 s (100/s), Y −50 (−25/s): speeds are relative to those. - assert_eq!(keys[1].in_ease, vec![Ease { speed: 200.0, influence: 0.2 }, Ease { speed: -50.0, influence: 0.2 }]); - // Every dimension follows the curve's progress. - let p = evaluate(&keys, s(2.0), false).unwrap().as_vec2(); - let f = ease_progress(0.0, 0.6, 2.0, 0.2, 0.5); - assert!((p[0] - 200.0 * f).abs() < 1e-6 && (p[1] - (100.0 - 50.0 * f)).abs() < 1e-6, "{p:?}"); - let m = EaseCurve::measure(&keys, 0, false).unwrap(); - assert!((m.out_influence - 60.0).abs() < 1e-9 && (m.in_speed - 2.0).abs() < 1e-9, "{m:?}"); - // No such segment / nothing to ease. - assert!(!CURVE.apply(&mut keys, 1, false)); - let mut held = vec![Keyframe::new(s(0.0), Value::Bool(false)), Keyframe::new(s(1.0), Value::Bool(true))]; - assert!(!CURVE.apply(&mut held, 0, false)); - } - - #[test] - fn spatial_curve_follows_the_motion_path() { - let mut keys = vec![Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), Keyframe::new(s(1.0), Value::Vec2([300.0, 400.0]))]; - assert!(CURVE.apply(&mut keys, 0, true)); - // 500 px along the path in 1 s. - assert!(keys[1].in_ease.iter().all(|e| (e.speed - 1000.0).abs() < 1e-6), "{:?}", keys[1].in_ease); - let p = evaluate(&keys, s(0.5), true).unwrap().as_vec2(); - let f = ease_progress(0.0, 0.6, 2.0, 0.2, 0.5); - // (within the arc-length table's precision) - assert!((p[0] - 300.0 * f).abs() < 0.05 && (p[1] - 400.0 * f).abs() < 0.05, "{p:?} {f}"); - } - - #[test] - fn linear_and_auto_bezier_sides_measure_as_they_play() { - let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(1.0), Value::Scalar(10.0))]; - let m = EaseCurve::measure(&keys, 0, false).unwrap(); - assert!((m.out_speed - 1.0).abs() < 1e-9 && (m.in_influence - 100.0 / 3.0).abs() < 1e-9, "{m:?}"); - // Easing the second segment keeps the auto-Bezier middle key's incoming side. - let mut keys = vec![ - Keyframe::new(s(0.0), Value::Scalar(0.0)), - Keyframe::new(s(1.0), Value::Scalar(10.0)).eased(), - Keyframe::new(s(2.0), Value::Scalar(30.0)).eased(), - ]; - keys[1].auto_bezier = true; - let before = evaluate(&keys, s(0.5), false); - assert!(CURVE.apply(&mut keys, 1, false)); - assert!(!keys[1].auto_bezier); - assert_eq!(evaluate(&keys, s(0.5), false), before); - let held = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)).hold(), Keyframe::new(s(1.0), Value::Scalar(10.0))]; - assert!(EaseCurve::measure(&held, 0, false).is_none()); - let flat = vec![Keyframe::new(s(0.0), Value::Scalar(5.0)), Keyframe::new(s(1.0), Value::Scalar(5.0))]; - assert!(EaseCurve::measure(&flat, 0, false).is_none()); - } -} diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index 584b4070..13814c53 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -10,11 +10,9 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -pub mod ease_curve; pub mod text_doc; pub mod value; -pub use ease_curve::EaseCurve; use effectcraft_time::{TICKS_PER_SECOND, Tick}; use serde::{Deserialize, Serialize}; pub use text_doc::{BaselineOption, CharStyle, Composer, Direction, FigureStyle, FigureWidth, Kerning, OpenType, ParaStyle, StyleRun}; diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index df2d2dcd..d1c9568c 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -14,7 +14,8 @@ //! ``` //! //! * **Object model** (`prelude.js`): `app` (project, open/newProject, undo groups, -//! `executeCommand`/`findMenuCommandId`, `scheduleTask`), `Project`, `ItemCollection`, +//! `executeCommand`/`findMenuCommandId`, `scheduleTask`, `settings` kept in the settings +//! folder), `Project`, `ItemCollection`, //! `CompItem`/`FootageItem`/`FolderItem`, `LayerCollection`, `AVLayer`/`TextLayer`/ //! `ShapeLayer`/`CameraLayer`/`LightLayer`, `PropertyGroup`/`Property` (values, keyframes, //! eases, expressions), `MaskPropertyGroup`, `TextDocument`, `Shape`, `KeyframeEase`, @@ -82,6 +83,8 @@ pub fn run_code(s: &mut Session, code: &str, name: &str) -> Outcome { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_ease_presets; +#[cfg(test)] mod tests_panels; #[cfg(test)] mod tests_ui; diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index 4a92f396..e0f17fef 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -92,6 +92,20 @@ fn label_index(l: Label) -> usize { Label::ALL.iter().position(|x| *x == l).unwrap_or(0) } +/// A comp's selected properties as `[layer, uid]`: the selected ones, then those with selected +/// keyframes (selecting a keyframe selects its property in After Effects, so scripts find +/// selected keys through `selectedProperties`). +fn selected_props(s: &Session, c: &effectcraft_project::Comp) -> Vec { + let mut v: Vec<(u64, u64)> = vec![]; + let keyed = s.state.selected_keys.iter().map(|k| (k.layer, k.prop)); + for (l, p) in s.state.selected_props.iter().copied().chain(keyed) { + if c.layer(l).is_some() && !v.contains(&(l.0, p)) { + v.push((l.0, p)); + } + } + v.into_iter().map(|(l, p)| json!([l, p])).collect() +} + fn item(s: &Session, a: &J) -> R { let id = ItemId(u(a, "id").ok_or("missing id")?); let it = s.project.item(id).ok_or("the item no longer exists (it was deleted)")?; @@ -123,7 +137,7 @@ fn item(s: &Session, a: &J) -> R { "duration": c.duration.seconds(), "frameRate": fps, "frameDuration": c.frame_duration().seconds(), "layers": c.layers.iter().map(|l| l.id.0).collect::>(), "selectedLayers": sel, - "selectedProperties": s.state.selected_props.iter().filter(|(l, _)| c.layer(*l).is_some()).map(|(l, p)| json!([l.0, p])).collect::>(), + "selectedProperties": selected_props(s, c), "time": s.time_of(cid).seconds(), "workAreaStart": c.work_area.0.seconds(), "workAreaDuration": (c.work_area.1 - c.work_area.0).seconds(), "bgColor": c.background, "displayStartTime": c.display_start.seconds(), @@ -321,6 +335,7 @@ fn node_json(c: &Comp, l: &Layer, uid: u64) -> R { o["type"] = json!(p.value.kind_name()); o["dims"] = json!(p.value.dims()); o["spatial"] = json!(p.spatial); + o["interpolates"] = json!(p.value.interpolates()); o["numKeys"] = json!(p.keys.len()); o["expression"] = json!(p.expr.as_ref().map(|e| e.text.clone()).unwrap_or_default()); o["expressionEnabled"] = json!(p.has_expression()); @@ -471,29 +486,36 @@ fn keys(s: &Session, a: &J) -> R { let p = l.props.find(uid).ok_or("the property no longer exists (it was deleted)")?; let sel: Vec = s.state.selected_keys.iter().filter(|k| k.layer == l.id && k.prop == uid).map(|k| k.time).collect(); let _ = cid; - let ease = |e: &[effectcraft_keyframe::Ease], n: usize| -> J { - let n = n.max(1); - // Influence is stored as a fraction; scripts see percent. - let mut v: Vec = e.iter().map(|x| json!({"speed": x.speed, "influence": x.influence * 100.0})).collect(); - let d = e.first().cloned().unwrap_or_default(); - while v.len() < n { - v.push(json!({"speed": d.speed, "influence": d.influence * 100.0})); - } + let dims = if p.spatial { 1 } else { p.value.dims().max(1) }; + // Each side's ease as it plays (After Effects reports linear and auto-Bezier sides this way + // too), else as stored; influence is stored as a fraction, scripts see percent. + let ease = |i: usize, out: bool| -> J { + let Some(k) = p.keys.get(i) else { return json!([]) }; + let stored = if out { &k.out_ease } else { &k.in_ease }; + let v: Vec = (0..dims) + .map(|d| { + let e = + effectcraft_keyframe::side_ease(&p.keys, i, d, p.spatial, out).or_else(|| stored.get(d).or(stored.first()).copied()).unwrap_or_default(); + json!({"speed": e.speed, "influence": e.influence * 100.0}) + }) + .collect(); J::Array(v) }; - let dims = if p.spatial { 1 } else { p.value.dims().max(1) }; let out: Vec = p .keys .iter() - .map(|k| { + .enumerate() + .map(|(i, k)| { + // Spatial tangents as the motion path plays (auto-Bezier and continuous keys too). + let (spatial_in, spatial_out) = if p.spatial { effectcraft_keyframe::spatial_tangents(&p.keys, i) } else { (k.spatial_in, k.spatial_out) }; json!({ "time": l.comp_time(k.time).seconds(), "layerTime": k.time.seconds(), "value": js_value(c, &k.value), "inInterp": interp(k.in_interp), "outInterp": interp(k.out_interp), - "inEase": ease(&k.in_ease, dims), "outEase": ease(&k.out_ease, dims), + "inEase": ease(i, false), "outEase": ease(i, true), "continuous": k.continuous, "autoBezier": k.auto_bezier, "roving": k.roving, - "spatialIn": k.spatial_in, "spatialOut": k.spatial_out, + "spatialIn": spatial_in, "spatialOut": spatial_out, "spatialContinuous": k.spatial_continuous, "spatialAutoBezier": k.spatial_auto, "selected": sel.contains(&k.time), }) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 358017e7..f52765e7 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -824,31 +824,30 @@ Property.prototype.property = function () { return null; }; }; P.keyTime = function (i) { return this.__key(i).time; }; P.keyValue = function (i) { return this.__fromEngine(this.__key(i).value, this.__info()); }; - // Select key `i` (only) so the selection-based keyframe commands act on it. - P.__select = function (i) { - var k = this.__key(i); - __call("keys.select", { comp: this.__layer.__comp, keys: [{ layer: this.__layer.__id, prop: this.__uid, time: k.layerTime }] }); - return k; - }; - P.removeKey = function (i) { - this.__select(i); - __call("keys.delete", { comp: this.__layer.__comp }); + // Run a keyframe command that acts on the selected keys on key `i` only; the user's key + // selection stays as it was, as with After Effects' per-key methods. + P.__keyCmd = function (i, id, params) { + this.__key(i); + params = params || {}; + params.comp = this.__layer.__comp; + params.__keys = [{ layer: this.__layer.__id, prop: this.__uid, index: __num(i) - 1 }]; + return __call(id, params); }; + P.removeKey = function (i) { this.__keyCmd(i, "keys.delete"); }; P.keyInInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).inInterp]; }; P.keyOutInterpolationType = function (i) { return KeyframeInterpolationType[this.__key(i).outInterp]; }; - P.isInterpolationTypeValid = function (t) { return t === 6612 || t === 6613 || t === 6614; }; + // Values that don't interpolate (checkboxes, popups, text) only hold. + P.isInterpolationTypeValid = function (t) { return t === 6614 || ((t === 6612 || t === 6613) && this.__info().interpolates !== false); }; P.setInterpolationTypeAtKey = function (i, inType, outType) { if (outType === undefined) outType = inType; if (!__interpKey[inType] || !__interpKey[outType]) throw __err("Bad KeyframeInterpolationType"); - this.__select(i); - __call("keys.interpolation", { comp: this.__layer.__comp, "in": __interpKey[inType], out: __interpKey[outType] }); + this.__keyCmd(i, "keys.interpolation", { "in": __interpKey[inType], out: __interpKey[outType] }); }; P.keyInTemporalEase = function (i) { return this.__key(i).inEase.map(function (e) { return new KeyframeEase(e.speed, e.influence); }); }; P.keyOutTemporalEase = function (i) { return this.__key(i).outEase.map(function (e) { return new KeyframeEase(e.speed, e.influence); }); }; P.setTemporalEaseAtKey = function (i, inEase, outEase) { if (outEase === undefined) outEase = inEase; if (!(inEase instanceof Array) || !(outEase instanceof Array)) throw __err("setTemporalEaseAtKey: eases must be arrays of KeyframeEase"); - this.__select(i); function sp(a) { return a.map(function (e) { return __num(e.speed, "speed"); }); } function inf(a) { return a.map(function (e) { @@ -857,14 +856,14 @@ Property.prototype.property = function () { return null; }; return x; }); } - __call("keys.velocity", { comp: this.__layer.__comp, inSpeed: sp(inEase), inInfluence: inf(inEase), outSpeed: sp(outEase), outInfluence: inf(outEase) }); + this.__keyCmd(i, "keys.velocity", { inSpeed: sp(inEase), inInfluence: inf(inEase), outSpeed: sp(outEase), outInfluence: inf(outEase) }); }; P.keyTemporalContinuous = function (i) { return !!this.__key(i).continuous; }; - P.setTemporalContinuousAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, continuous: !!b }); }; + P.setTemporalContinuousAtKey = function (i, b) { this.__keyCmd(i, "keys.interpolation", { continuous: !!b }); }; P.keyTemporalAutoBezier = function (i) { return !!this.__key(i).autoBezier; }; - P.setTemporalAutoBezierAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, autoBezier: !!b }); }; + P.setTemporalAutoBezierAtKey = function (i, b) { this.__keyCmd(i, "keys.interpolation", { autoBezier: !!b }); }; P.keyRoving = function (i) { return !!this.__key(i).roving; }; - P.setRovingAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, roving: !!b }); }; + P.setRovingAtKey = function (i, b) { this.__keyCmd(i, "keys.interpolation", { roving: !!b }); }; P.keyInSpatialTangent = function (i) { return this.__key(i).spatialIn.slice(0, this.__info().dims); }; P.keyOutSpatialTangent = function (i) { return this.__key(i).spatialOut.slice(0, this.__info().dims); }; P.keySpatialContinuous = function (i) { return !!this.__key(i).spatialContinuous; }; @@ -873,8 +872,8 @@ Property.prototype.property = function () { return null; }; var k = this.__key(i); __call("keys.setSpatialTangents", this.__ref({ time: k.layerTime, "in": inT, out: outT === undefined ? inT : outT })); }; - P.setSpatialContinuousAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, spatial: b ? "continuousBezier" : "bezier" }); }; - P.setSpatialAutoBezierAtKey = function (i, b) { this.__select(i); __call("keys.interpolation", { comp: this.__layer.__comp, spatial: b ? "autoBezier" : "bezier" }); }; + P.setSpatialContinuousAtKey = function (i, b) { this.__keyCmd(i, "keys.interpolation", { spatial: b ? "continuousBezier" : "bezier" }); }; + P.setSpatialAutoBezierAtKey = function (i, b) { this.__keyCmd(i, "keys.interpolation", { spatial: b ? "autoBezier" : "bezier" }); }; P.keySelected = function (i) { return !!this.__key(i).selected; }; P.setSelectedAtKey = function (i, b) { var k = this.__key(i); @@ -1743,11 +1742,11 @@ var app = { exitCode: 0, saveProjectOnCrash: true, onError: null, + // Kept in the settings folder, so they last between runs (`script.settings.*`). settings: { - __s: {}, - haveSetting: function (sec, key) { return (sec + "/" + key) in this.__s; }, - getSetting: function (sec, key) { return this.__s[sec + "/" + key] || ""; }, - saveSetting: function (sec, key, v) { this.__s[sec + "/" + key] = String(v); }, + haveSetting: function (sec, key) { return __call("script.settings.get", { section: String(sec), key: String(key) }).have; }, + getSetting: function (sec, key) { return __call("script.settings.get", { section: String(sec), key: String(key) }).value; }, + saveSetting: function (sec, key, v) { __call("script.settings.save", { section: String(sec), key: String(key), value: String(v) }); }, }, preferences: { havePref: function () { return false; }, diff --git a/crates/script/src/runtime.rs b/crates/script/src/runtime.rs index 9b48e770..62479c14 100644 --- a/crates/script/src/runtime.rs +++ b/crates/script/src/runtime.rs @@ -121,11 +121,17 @@ pub(crate) fn js_str(s: &str) -> JsValue { /// target comp made active for its duration. fn native_exec(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let id = arg_string(args, 0, ctx)?; - let params = arg_json(args, 1, ctx)?; + let mut params = arg_json(args, 1, ctx)?; if id == "script.run" || id == "file.runScript" { return Err(throw("scripts can't run scripts")); } - let r = with_active(|a| Ok(exec(&mut a.session, &id, params))); + let keys = params.as_object_mut().and_then(|o| o.remove("__keys")); + let r = with_active(|a| { + Ok(match &keys { + Some(k) => exec_on_keys(&mut a.session, &id, params, k), + None => exec(&mut a.session, &id, params), + }) + }); match r { Ok(Ok(v)) => Ok(js_str(&v.to_string())), Ok(Err(e)) | Err(e) => Err(throw(e)), @@ -164,6 +170,35 @@ pub(crate) fn exec(s: &mut Session, id: &str, params: J) -> Result { r.map_err(|e| e.to_string()) } +/// Run a keyframe command that acts on the selected keys (`keys.velocity`, `keys.interpolation`, +/// `keys.delete`) on the keys `targets` names instead (`[{layer, prop, index}]`, 0-based, in the +/// command's comp): After Effects' per-key methods (`setTemporalEaseAtKey`, `removeKey`…) leave +/// the user's key selection as it was. Selected keys that no longer exist drop out of it. +fn exec_on_keys(s: &mut Session, id: &str, params: J, targets: &J) -> Result { + use effectcraft_engine::KeyRef; + use effectcraft_engine::project::{ItemId, LayerId}; + let cid = params.get("comp").and_then(J::as_u64).map(ItemId).or(s.state.active_comp).ok_or("no composition is open")?; + let comp = s.project.comp(cid).ok_or("the composition no longer exists")?; + let mut keys = vec![]; + for t in targets.as_array().into_iter().flatten() { + let (Some(layer), Some(prop), Some(index)) = (t["layer"].as_u64(), t["prop"].as_u64(), t["index"].as_u64()) else { + return Err(format!("bad keyframe reference {t}")); + }; + let l = comp.layer(LayerId(layer)).ok_or("the layer no longer exists")?; + if l.switches.locked { + return Err(format!("Can not change keyframes of \"{}\": the layer is locked", l.name)); + } + let key = l.props.find(prop).and_then(|pr| pr.keys.get(usize::try_from(index).ok()?)).ok_or("the keyframe no longer exists")?; + keys.push(KeyRef { layer: LayerId(layer), prop, time: key.time }); + } + let saved = std::mem::replace(&mut s.state.selected_keys, keys); + let r = exec(s, id, params); + let comp = s.active_comp(); + let exists = |k: &KeyRef| comp.and_then(|c| c.layer(k.layer)).and_then(|l| l.props.find(k.prop)).is_some_and(|pr| pr.keys.iter().any(|x| x.time == k.time)); + s.state.selected_keys = saved.into_iter().filter(exists).collect(); + r +} + /// `__query(kind, json)` → JSON. fn native_query(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult { let kind = arg_string(args, 0, ctx)?; diff --git a/crates/script/src/tests_ease_presets.rs b/crates/script/src/tests_ease_presets.rs new file mode 100644 index 00000000..c803c27a --- /dev/null +++ b/crates/script/src/tests_ease_presets.rs @@ -0,0 +1,321 @@ +//! The scripting API the bundled Ease Presets ScriptUI panel uses (After Effects parity: +//! `app.settings` that last between runs, per-key methods that keep the key selection, eases and +//! spatial tangents reported as they play, `selectedProperties` with keyed properties, +//! `isInterpolationTypeValid`), and the panel itself (`extensions/scriptui-panels/Ease +//! Presets.jsx`) easing selected keyframes, keeping user presets and taking over the presets the +//! core panel of v0.6.0 saved. + +use std::sync::Arc; + +use effectcraft_engine::Session; +use effectcraft_engine::commands::scripts::SCRIPT_SETTINGS_FILE; +use effectcraft_engine::config::{ConfigStore, MemoryConfig}; +use effectcraft_engine::project::{LayerId, Property}; +use effectcraft_keyframe::{Ease, Interp, ease_progress}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use crate::run_code; + +const PANEL: &str = "Ease Presets.jsx"; + +fn session(store: &Arc) -> Session { + let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), ..Default::default() }; + crate::install(&mut s); + s.config = Some(store.clone()); + s.load_settings(); + s +} + +/// A comp with one solid; returns its id. +fn setup(s: &mut Session) -> u64 { + s.execute("comp.new", json!({"name": "Ease", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap() +} + +fn animate(s: &mut Session, l: u64, path: &str, keys: &[(f64, Value)]) { + for (t, v) in keys { + s.execute("prop.addKey", json!({"layer": l, "path": path, "time": t, "value": v})).unwrap(); + } +} + +fn prop(s: &Session, l: u64, path: &str) -> Property { + s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop(path).unwrap().clone() +} + +fn close(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() < tol +} + +fn ok(s: &mut Session, code: &str) -> Value { + let out = run_code(s, code, "test.jsx"); + assert!(out.error.is_none(), "{:?}", out.error); + out.result +} + +fn at(s: f64) -> Tick { + Tick::from_seconds_f64(s) +} + +#[test] +fn app_settings_last_between_runs() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + ok(&mut s, r#"app.settings.saveSetting("My Tool", "size", 12); app.settings.saveSetting("My Tool", "name", "Box");"#); + // A new run (the next launch) reads them back. + let mut s = session(&store); + let r = ok( + &mut s, + r#"[app.settings.haveSetting("My Tool", "size"), app.settings.getSetting("My Tool", "size"), app.settings.getSetting("My Tool", "name"), app.settings.haveSetting("My Tool", "nope")]"#, + ); + assert_eq!(r, json!([true, "12", "Box", false])); + assert_eq!(s.execute("script.settings.get", json!({"section": "My Tool", "key": "name"})).unwrap(), json!({"have": true, "value": "Box"})); + // A corrupt settings file loads as none, never an error. + store.write(SCRIPT_SETTINGS_FILE, "{oops").unwrap(); + let mut s = session(&store); + assert_eq!(ok(&mut s, r#"app.settings.haveSetting("My Tool", "size")"#), json!(false)); + assert!(s.execute("script.settings.save", json!({"section": "x"})).is_err()); +} + +#[test] +fn per_key_methods_keep_the_key_selection_and_report_eases_as_they_play() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]); + let key = |t: f64| json!({"layer": l, "path": "transform/opacity", "time": t}); + s.execute("edit.deselectAll", json!({})).unwrap(); + s.execute("keys.select", json!({"keys": [key(0.0), key(2.0)]})).unwrap(); + let before = s.state.selected_keys.clone(); + let r = ok( + &mut s, + r#"var comp = app.project.activeItem, p = comp.layer(1).transform.opacity; + // A linear side reports the speed it plays at (33.3 % influence). + var lin = p.keyInTemporalEase(3)[0]; + p.setTemporalEaseAtKey(2, [new KeyframeEase(0, 50)], [new KeyframeEase(0, 75)]); + p.setInterpolationTypeAtKey(3, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.HOLD); + // Keyed properties are selected properties, as in After Effects. + var sel = comp.selectedProperties; + [lin.speed, lin.influence, p.selectedKeys, sel.length, sel.length ? sel[0].name : ""]"#, + ); + assert!(close(r[0].as_f64().unwrap(), -100.0, 1e-9) && close(r[1].as_f64().unwrap(), 100.0 / 3.0, 1e-9), "{r}"); + assert_eq!(r[2], json!([1, 3]), "the selection is the user's"); + assert_eq!((r[3].clone(), r[4].clone()), (json!(1), json!("Opacity"))); + assert_eq!(s.state.selected_keys, before); + let op = prop(&s, l, "transform/opacity"); + assert_eq!((op.keys[1].in_ease[0], op.keys[1].out_ease[0]), (Ease { speed: 0.0, influence: 0.5 }, Ease { speed: 0.0, influence: 0.75 })); + assert_eq!(op.keys[2].in_interp, Interp::Hold); + // removeKey drops the removed key from the selection, keeps the rest. + ok(&mut s, "app.project.activeItem.layer(1).transform.opacity.removeKey(3)"); + assert_eq!(s.state.selected_keys, before[..1]); + // Values that don't interpolate only hold. + let r = ok( + &mut s, + r#"var comp = app.project.activeItem, o = comp.layer(1).transform.opacity, t = comp.layers.addText("Hi").text.sourceText; + [t.isInterpolationTypeValid(KeyframeInterpolationType.BEZIER), t.isInterpolationTypeValid(KeyframeInterpolationType.HOLD), o.isInterpolationTypeValid(KeyframeInterpolationType.BEZIER)]"#, + ); + assert_eq!(r, json!([false, true, true])); +} + +#[test] +fn spatial_tangents_are_reported_as_the_path_plays() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (1.0, json!([100, 0, 0])), (2.0, json!([100, 100, 0]))]); + let pos = prop(&s, l, "transform/position"); + let (inn, out) = effectcraft_keyframe::spatial_tangents(&pos.keys, 1); + let r = ok(&mut s, "var p = app.project.activeItem.layer(1).transform.position; [p.keyInSpatialTangent(2), p.keyOutSpatialTangent(2)]"); + for (got, want) in [(&r[0], inn), (&r[1], out)] { + for d in 0..2 { + assert!(close(got[d].as_f64().unwrap(), want[d], 1e-9), "{r} vs {inn:?} {out:?}"); + } + } + assert!(out[0].abs() + out[1].abs() > 1.0, "the middle key's path curves: {out:?}"); + assert_ne!(pos.keys[1].spatial_out, out, "auto-Bezier: the stored tangent isn't the one that plays"); +} + +/// The panel's window and a control's description by name. +fn open_panel(s: &mut Session) -> u64 { + let r = s.execute("window.scriptPanel", json!({"name": PANEL})).unwrap(); + r["window"].as_u64().unwrap() +} + +fn control(s: &mut Session, win: u64, name: &str) -> Value { + fn find(w: &Value, name: &str) -> Option { + if w["name"] == name { + return Some(w.clone()); + } + w["children"].as_array()?.iter().find_map(|c| find(c, name)) + } + let w = s.execute("scriptui.get", json!({"window": win})).unwrap(); + find(&w["root"], name).unwrap_or_else(|| panic!("no control {name}")) +} + +fn set(s: &mut Session, win: u64, widget: &str, value: Value) { + s.execute("scriptui.set", json!({"window": win, "widget": widget, "value": value})).unwrap(); +} + +fn click(s: &mut Session, win: u64, widget: &str) { + s.execute("scriptui.click", json!({"window": win, "widget": widget})).unwrap(); +} + +fn status(s: &mut Session, win: u64) -> String { + control(s, win, "status")["text"].as_str().unwrap().to_string() +} + +#[test] +fn the_ease_presets_panel_eases_every_selected_pair_in_one_undo_step() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (3.0, json!(20))]); + animate(&mut s, l, "transform/rotation", &[(0.0, json!(0)), (2.0, json!(90))]); + s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + s.execute("prop.select", json!({"layer": l, "path": "transform/rotation", "add": true})).unwrap(); + let selected = s.state.selected_keys.clone(); + let win = open_panel(&mut s); + // It is a bundled extension listed with the ScriptUI panels. + let list = s.execute("file.scripts.list", json!({})).unwrap(); + assert!(list.as_array().unwrap().iter().any(|e| e["name"] == PANEL && e["panel"] == true && e["source"] == "extension")); + let steps = s.history.undo.len(); + set(&mut s, win, "presets", json!("Ease In-Out")); + assert_eq!(status(&mut s, win), "Eased 3 keyframe pair(s) with \u{201c}Ease In-Out\u{201d}"); + assert_eq!(s.history.undo.len(), steps + 1, "one undo step"); + assert_eq!(s.history.undo.last().map(|(label, _)| label.as_str()), Some("Apply Ease Preset")); + assert_eq!(s.state.selected_keys, selected, "the keys stay selected for the next preset"); + let eased = Ease { speed: 0.0, influence: 0.5 }; + let op = prop(&s, l, "transform/opacity"); + assert_eq!((op.keys[0].out_interp, op.keys[0].out_ease[0]), (Interp::Bezier, eased)); + assert_eq!((op.keys[1].in_ease[0], op.keys[1].out_ease[0]), (eased, eased)); + assert_eq!((op.keys[2].in_interp, op.keys[2].in_ease[0]), (Interp::Bezier, eased)); + // The first key's in side and the last key's out side are not part of a pair. + assert_eq!((op.keys[0].in_interp, op.keys[2].out_interp), (Interp::Linear, Interp::Linear)); + // A symmetric curve passes the middle value at mid-time, slow near the keys. + assert!(close(op.value_at(at(2.0)).as_f64(), 60.0, 1e-6)); + let quarter = op.value_at(at(1.5)).as_f64(); + assert!(close(quarter, 100.0 - 80.0 * ease_progress(0.0, 0.5, 0.0, 0.5, 0.25), 1e-6), "{quarter}"); + assert_eq!(prop(&s, l, "transform/rotation").keys[1].in_interp, Interp::Bezier); + s.execute("edit.undo", json!({})).unwrap(); + let op = prop(&s, l, "transform/opacity"); + assert!(op.keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Linear)); + // Nothing selected: nothing changes, and the panel says why. + s.execute("edit.deselectAll", json!({})).unwrap(); + let steps = s.history.undo.len(); + set(&mut s, win, "presets", json!("Linear")); + assert_eq!(status(&mut s, win), "Select two or more neighbouring keyframes of a property"); + assert_eq!(s.history.undo.len(), steps); +} + +#[test] +fn a_typed_curve_scales_to_each_dimension_and_follows_motion_paths() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50]))]); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0, 0])), (2.0, json!([300, 400, 0]))]); + s.execute("prop.select", json!({"layer": l, "path": "transform/scale"})).unwrap(); + let win = open_panel(&mut s); + let typed = |s: &mut Session, c: [f64; 4]| { + for (name, v) in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().zip(c) { + set(s, win, name, json!(v.to_string())); + } + click(s, win, "apply"); + }; + typed(&mut s, [60.0, 0.0, 20.0, 2.0]); + let sc = prop(&s, l, "transform/scale"); + // X rises 100/s and Y falls 50/s: the in speed (2× average) is per dimension. + assert_eq!(sc.keys[1].in_ease[..2], [Ease { speed: 200.0, influence: 0.2 }, Ease { speed: -100.0, influence: 0.2 }]); + let f = ease_progress(0.0, 0.6, 2.0, 0.2, 0.5); + let v = sc.value_at(at(0.5)).as_vec2(); + assert!(close(v[0], 100.0 + 100.0 * f, 1e-6) && close(v[1], 100.0 - 50.0 * f, 1e-6), "{v:?}"); + // On Position the speed is along the 500 px path. + s.execute("prop.select", json!({"layer": l, "path": "transform/position"})).unwrap(); + typed(&mut s, [25.0, 0.4, 75.0, 0.0]); + let pos = prop(&s, l, "transform/position"); + assert!(pos.keys[0].out_ease.iter().all(|e| close(e.speed, 0.4 * 250.0, 1e-6) && close(e.influence, 0.25, 1e-9)), "{:?}", pos.keys[0].out_ease); + let f = ease_progress(0.4, 0.25, 0.0, 0.75, 0.5); + let p = pos.value_at(at(1.0)).as_vec2(); + assert!(close(p[0], 300.0 * f, 0.05) && close(p[1], 400.0 * f, 0.05), "{p:?} {f}"); + // From Keys reads the applied curve back into the fields. + for name in ["outInfluence", "outSpeed", "inInfluence", "inSpeed"] { + set(&mut s, win, name, json!("1")); + } + click(&mut s, win, "fromKeys"); + let fields: Vec = + ["outInfluence", "outSpeed", "inInfluence", "inSpeed"].iter().map(|n| control(&mut s, win, n)["text"].as_str().unwrap().to_string()).collect(); + assert_eq!(fields, ["25", "0.4", "75", "0"]); +} + +#[test] +fn user_presets_save_rename_delete_and_persist() { + let store = Arc::new(MemoryConfig::default()); + let mut s = session(&store); + let l = setup(&mut s); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100))]); + s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + let win = open_panel(&mut s); + set(&mut s, win, "presets", json!("Decelerate")); + // Save keeps the curve in the fields under the typed name. + set(&mut s, win, "name", json!(" Settle ")); + click(&mut s, win, "save"); + assert_eq!(status(&mut s, win), "Saved the ease preset \u{201c}Settle\u{201d}"); + // Built-in names are refused. + set(&mut s, win, "name", json!("linear")); + click(&mut s, win, "save"); + assert!(status(&mut s, win).contains("is a built-in preset")); + let items = |s: &mut Session| control(s, win, "presets")["items"].clone(); + assert_eq!(items(&mut s).as_array().unwrap().last().unwrap(), "Settle"); + // Rename and Delete act on the selected user preset; built-in presets can't change. + set(&mut s, win, "presets", json!("Linear")); + set(&mut s, win, "name", json!("Straight")); + click(&mut s, win, "rename"); + assert_eq!(status(&mut s, win), "Select one of your own presets first"); + set(&mut s, win, "presets", json!("Settle")); + set(&mut s, win, "name", json!("Soft Landing")); + click(&mut s, win, "rename"); + let names = items(&mut s); + assert!(names.as_array().unwrap().contains(&json!("Soft Landing")) && !names.as_array().unwrap().contains(&json!("Settle")), "{names}"); + // Kept with app.settings: a new run of the panel (the next launch) lists them. + let stored: Value = serde_json::from_str(&store.read(SCRIPT_SETTINGS_FILE).unwrap()).unwrap(); + let presets: Value = serde_json::from_str(stored["Ease Presets"]["userPresets"].as_str().unwrap()).unwrap(); + assert_eq!(presets["presets"][0]["name"], "Soft Landing"); + assert!(close(presets["presets"][0]["curve"]["outSpeed"].as_f64().unwrap(), 1.5, 1e-9), "{presets}"); + let mut s2 = session(&store); + setup(&mut s2); + let win2 = open_panel(&mut s2); + assert_eq!(control(&mut s2, win2, "presets")["items"].as_array().unwrap().len(), 13); + set(&mut s2, win2, "presets", json!("Soft Landing")); + click(&mut s2, win2, "delete"); + assert_eq!(control(&mut s2, win2, "presets")["items"].as_array().unwrap().len(), 12, "the built-in presets"); +} + +/// User presets the core Ease Presets panel of v0.6.0 saved (`ease_presets.json`) move to the +/// panel's settings when settings load, once; unusable entries are skipped by the panel. +#[test] +fn presets_saved_by_the_old_core_panel_carry_over() { + let store = Arc::new(MemoryConfig::default()); + let c = json!({"outInfluence": 30, "outSpeed": 0, "inInfluence": 30, "inSpeed": 0}); + let old = json!({"version": 1, "presets": [ + {"name": "Snap", "curve": c}, + {"name": "Linear", "curve": c}, + {"name": "", "curve": c}, + {"name": "No curve"}, + {"name": "snap", "curve": c}, + {"name": "Bad", "curve": {"outInfluence": "x", "outSpeed": 0, "inInfluence": 30, "inSpeed": 0}} + ]}); + store.write("ease_presets.json", &old.to_string()).unwrap(); + let mut s = session(&store); + setup(&mut s); + let win = open_panel(&mut s); + let items = control(&mut s, win, "presets")["items"].clone(); + assert_eq!(items.as_array().unwrap().len(), 13, "{items}"); + assert_eq!(items[12], "Snap"); + // Deleting it there is final: the old file doesn't come back. + set(&mut s, win, "presets", json!("Snap")); + click(&mut s, win, "delete"); + let mut s = session(&store); + setup(&mut s); + let win = open_panel(&mut s); + assert_eq!(control(&mut s, win, "presets")["items"].as_array().unwrap().len(), 12); +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index e62b6d46..058f601b 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -50,12 +50,10 @@ pub enum PanelKind { CreateNullsFromPaths, /// Window ▸ VR Comp Editor (our own panel). VrCompEditor, - /// Window ▸ Ease Presets (our own panel). - EasePresets, } impl PanelKind { - pub const ALL: [PanelKind; 35] = [ + pub const ALL: [PanelKind; 34] = [ PanelKind::Project, PanelKind::EffectControls, PanelKind::Composition, @@ -90,7 +88,6 @@ impl PanelKind { PanelKind::ContentAwareFill, PanelKind::CreateNullsFromPaths, PanelKind::VrCompEditor, - PanelKind::EasePresets, ]; pub fn title(self) -> &'static str { match self { @@ -130,7 +127,6 @@ impl PanelKind { PanelKind::ContentAwareFill => "Content-Aware Fill", PanelKind::CreateNullsFromPaths => "Create Nulls From Paths", PanelKind::VrCompEditor => "VR Comp Editor", - PanelKind::EasePresets => "Ease Presets", } } pub fn id(self) -> String { @@ -371,7 +367,6 @@ pub fn workspace(name: &str) -> DockNode { ContentAwareFill, CreateNullsFromPaths, VrCompEditor, - EasePresets, ], ), ), diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 7c86e572..ee08566f 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -754,7 +754,6 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("window.panel", "Render Queue", "渲染队列"), ("window.panel", "Timeline", "时间轴"), ("window.panel", "Create Nulls From Paths", "从路径创建空对象"), - ("window.panel", "Ease Presets", "缓动预设"), ("window.panel", "VR Comp Editor", "VR 合成编辑器"), ("", "Help", "帮助"), ("help.docs", "EffectCraft Help...", "EffectCraft 帮助..."), @@ -1378,7 +1377,6 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("window.panel", "Render Queue", "渲染佇列"), ("window.panel", "Timeline", "時間軸"), ("window.panel", "Create Nulls From Paths", "從路徑建立空物件"), - ("window.panel", "Ease Presets", "緩動預設集"), ("window.panel", "VR Comp Editor", "VR 合成編輯器"), ("", "Help", "說明"), ("help.docs", "EffectCraft Help...", "EffectCraft 說明..."), @@ -1999,7 +1997,6 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("window.panel", "Render Queue", "レンダーキュー"), ("window.panel", "Timeline", "タイムライン"), ("window.panel", "Create Nulls From Paths", "パスからヌルを作成"), - ("window.panel", "Ease Presets", "イーズプリセット"), ("window.panel", "VR Comp Editor", "VRコンポジションエディター"), ("", "Help", "ヘルプ"), ("help.docs", "EffectCraft Help...", "EffectCraftヘルプ..."), diff --git a/crates/ui-egui/src/panels/ease_presets.rs b/crates/ui-egui/src/panels/ease_presets.rs deleted file mode 100644 index cb069238..00000000 --- a/crates/ui-egui/src/panels/ease_presets.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! Ease Presets panel (Window ▸ Ease Presets): easing curves kept by name and applied to pairs of -//! keyframes (our own design and presets). The value graph at the top edits a working curve by -//! dragging its two handles, and Apply eases the selected keyframes with it; From Keys reads the -//! curve between the first selected pair. Clicking a preset's thumbnail loads it and applies it. -//! Save Current keeps the working curve under the name in the field; Rename and Delete act on the -//! selected user preset. Every action is a `keys.easePreset.*` command. -//! -//! Automation ids: `easePresets.graph`, `easePresets.handle.out` / `.in`, `easePresets.apply`, -//! `easePresets.fromKeys`, `easePresets.name`, `easePresets.save`, `easePresets.rename`, -//! `easePresets.delete`, `easePresets.preset.` (1-based, labelled with the preset's name). - -use effectcraft_engine::ease_presets; -use effectcraft_engine::keyframe::EaseCurve; -use egui::epaint::CubicBezierShape; -use egui::{Align2, Color32, CursorIcon, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -use super::panel_kit as kit; -use crate::theme::Tokens; -use crate::{EffectcraftApp, widgets}; - -/// Panel state (serde in the UI state). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct EasePanelState { - /// The curve the graph shows and Apply uses. - pub curve: EaseCurve, - /// The preset clicked last (Rename and Delete act on it). - pub selected: Option, - /// Name for Save Current and Rename. - pub name: String, -} - -impl Default for EasePanelState { - fn default() -> Self { - EasePanelState { curve: EaseCurve::LINEAR, selected: None, name: String::new() } - } -} - -/// Value range of the graph: room for handles above and below the keys. -const V_MIN: f64 = -0.35; -const V_MAX: f64 = 1.35; -const TILE: egui::Vec2 = vec2(84.0, 64.0); -const GAP: f32 = 6.0; -const ROW: f32 = 30.0; -/// Height of the controls under the graph (numbers, three button rows, status, "Presets"). -const CONTROLS: f32 = 154.0; -/// Panels at least this wide put the presets beside the graph and controls. -const SIDE_BY_SIDE: f32 = 560.0; - -/// Screen point of normalised time `x` and value `v` in `r`, values `lo..hi` bottom to top. -fn to_screen(r: Rect, lo: f64, hi: f64, x: f64, v: f64) -> Pos2 { - pos2(r.min.x + r.width() * x as f32, r.max.y - r.height() * ((v - lo) / (hi - lo)) as f32) -} - -/// The curve from (0, 0) to (1, 1) through its handles. -fn curve_shape(c: &EaseCurve, at: impl Fn(f64, f64) -> Pos2, stroke: Stroke) -> CubicBezierShape { - let [x1, y1, x2, y2] = c.handles(); - CubicBezierShape::from_points_stroke([at(0.0, 0.0), at(x1, y1), at(x2, y2), at(1.0, 1.0)], false, Color32::TRANSPARENT, stroke) -} - -pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { - let t = app.tokens; - let p = ui.painter().with_clip_rect(rect); - let mut st = app.ui.ease_presets.clone(); - let pairs = ease_presets::selected_pairs(&app.session).len(); - let x0 = rect.min.x + 10.0; - // Wide panels: graph and controls on the left, presets on the right; else presets below, - // with room for at least one row of them. - let side = rect.width() >= SIDE_BY_SIDE; - let w = if side { 300.0 } else { (rect.width() - 20.0).max(210.0) }; - let room = rect.height() - 16.0 - CONTROLS - if side { 0.0 } else { TILE.y + GAP }; - let g = Rect::from_min_size(pos2(x0, rect.min.y + 10.0), vec2(w, (w * 0.6).min(room).clamp(90.0, 220.0))); - graph(app, ui, g, &mut st.curve); - let c = st.curve; - let mut y = g.max.y + 4.0; - let numbers = format!("Out {:.1} % · {:.2} In {:.1} % · {:.2}", c.out_influence, c.out_speed, c.in_influence, c.in_speed); - p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, numbers, Tokens::mono(11.0), t.text_faint); - y += 22.0; - let at = |x: f32, y: f32, width: f32| Rect::from_min_size(pos2(x, y), vec2(width, 24.0)); - if kit::button(app, ui, at(x0, y, 90.0), "easePresets.apply", "Apply", pairs > 0) { - apply(app, json!({"curve": c}), pairs, "the curve"); - } - if kit::button(app, ui, at(x0 + 96.0, y, 100.0), "easePresets.fromKeys", "From Keys", false) - && let Some(v) = kit::exec(app, "keys.easePreset.capture", json!({})) - && let Ok(curve) = serde_json::from_value(v) - { - st.curve = curve; - st.selected = None; - } - y += ROW; - let field = at(x0, y, (w - 116.0).max(80.0)); - widgets::text_field(ui, field, &mut st.name, "Preset name", &t); - app.auto.add("easePresets.name", field, "Preset name"); - let name = st.name.trim().to_string(); - if kit::button(app, ui, at(field.max.x + 6.0, y, 110.0), "easePresets.save", "Save Current", false) { - if name.is_empty() { - app.ui.status = "Type a name for the preset first".into(); - } else if let Some(v) = kit::exec(app, "keys.easePreset.save", json!({"name": name, "curve": c})) { - st.selected = v["name"].as_str().map(str::to_string); - app.ui.status = format!("Saved the ease preset “{}”", st.selected.as_deref().unwrap_or(&name)); - } - } - y += ROW; - // Rename and Delete act on the selected user preset. - let user = st.selected.clone().filter(|n| app.session.ease_presets.iter().any(|q| &q.name == n)); - let rename = kit::button(app, ui, at(x0, y, 90.0), "easePresets.rename", "Rename", false); - let delete = kit::button(app, ui, at(x0 + 96.0, y, 90.0), "easePresets.delete", "Delete", false); - match (&user, rename, delete) { - (None, true, _) | (None, _, true) => app.ui.status = "Select one of your own presets first".into(), - (Some(old), true, _) if name.is_empty() => app.ui.status = format!("Type a new name for “{old}” first"), - (Some(old), true, _) => { - if let Some(v) = kit::exec(app, "keys.easePreset.rename", json!({"name": old, "newName": name})) { - st.selected = v["name"].as_str().map(str::to_string); - } - } - (Some(old), _, true) => { - let deleted = kit::exec(app, "keys.easePreset.delete", json!({"name": old})).is_some(); - if deleted { - st.selected = None; - } - } - _ => {} - } - y += ROW; - let msg = if pairs > 0 { - format!("{pairs} keyframe pair{} selected", if pairs == 1 { "" } else { "s" }) - } else { - "Select two or more neighbouring keyframes".into() - }; - p.text(pos2(x0, y + 4.0), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_faint); - y += 16.0; - let head = if side { pos2(x0 + w + 16.0, rect.min.y + 6.0) } else { pos2(x0, y) }; - kit::label(ui, head, "Presets", &t); - let list = Rect::from_min_max(head + vec2(0.0, 22.0), pos2(rect.max.x - 10.0, rect.max.y - 6.0)); - if let Some((name, curve)) = grid(app, ui, list, st.selected.as_deref()) { - st.curve = curve; - if pairs > 0 { - apply(app, json!({"preset": name}), pairs, &format!("“{name}”")); - } - st.selected = Some(name); - } - if st != app.ui.ease_presets { - app.ui.ease_presets = st; - } -} - -fn apply(app: &mut EffectcraftApp, params: serde_json::Value, pairs: usize, what: &str) { - if pairs == 0 { - app.ui.status = "Select two or more neighbouring keyframes of a property".into(); - } else if let Some(r) = kit::exec(app, "keys.easePreset.apply", params) { - app.ui.status = format!("Eased {} keyframe pair(s) with {what}", r["pairs"]); - } -} - -/// The working curve's value graph: keys at (0, 0) and (1, 1), the linear ease for reference, and -/// the two handles, dragged to edit the curve. -fn graph(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Rect, curve: &mut EaseCurve) { - let t = app.tokens; - let p = ui.painter().with_clip_rect(g); - p.rect_filled(g, 3.0, t.field_bg); - p.rect_stroke(g, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); - app.auto.add("easePresets.graph", g, "Ease curve"); - let plot = g.shrink2(vec2(16.0, 6.0)); - let at = |x: f64, v: f64| to_screen(plot, V_MIN, V_MAX, x, v); - for v in [0.0, 1.0] { - p.line_segment([at(0.0, v), at(1.0, v)], Stroke::new(1.0, t.separator)); - } - p.add(egui::Shape::dashed_line(&[at(0.0, 0.0), at(1.0, 1.0)], Stroke::new(1.0, t.separator), 4.0, 4.0)); - p.add(curve_shape(curve, at, Stroke::new(2.0, t.accent))); - let [x1, y1, x2, y2] = curve.handles(); - let mut next = None; - for (out, key, h) in [(true, at(0.0, 0.0), at(x1, y1)), (false, at(1.0, 1.0), at(x2, y2))] { - p.line_segment([key, h], Stroke::new(1.0, t.text_dim)); - p.rect_filled(Rect::from_center_size(key, vec2(7.0, 7.0)), 1.0, t.keyframe); - let r = Rect::from_center_size(h, vec2(14.0, 14.0)); - let resp = ui.interact(r, egui::Id::new(("easePresets.handle", out)), Sense::drag()); - app.auto.add(if out { "easePresets.handle.out" } else { "easePresets.handle.in" }, r, if out { "Out handle" } else { "In handle" }); - let hot = resp.hovered() || resp.dragged(); - if hot { - ui.ctx().set_cursor_icon(if resp.dragged() { CursorIcon::Grabbing } else { CursorIcon::Grab }); - } - p.circle_filled(h, if hot { 5.5 } else { 4.5 }, if hot { t.keyframe_selected } else { t.text }); - if resp.dragged() { - let to = h + resp.drag_delta(); - let x = f64::from((to.x - plot.min.x) / plot.width()).clamp(0.0, 1.0); - let v = (V_MIN + f64::from((plot.max.y - to.y) / plot.height()) * (V_MAX - V_MIN)).clamp(V_MIN, V_MAX); - next = EaseCurve::from_handles(if out { [x, v, x2, y2] } else { [x1, y1, x, v] }); - } - } - if let Some(c) = next { - *curve = c; - } -} - -/// Preset thumbnails in a wrapping grid that scrolls with the wheel; returns the clicked one. -fn grid(app: &mut EffectcraftApp, ui: &mut egui::Ui, list: Rect, selected: Option<&str>) -> Option<(String, EaseCurve)> { - let t = app.tokens; - let p = ui.painter().with_clip_rect(list); - let scroll = widgets::PanelScroll::begin(ui, egui::Id::new("ease-presets-scroll"), list); - let cols = (((list.width() + GAP) / (TILE.x + GAP)).floor() as usize).max(1); - let (mut clicked, mut count) = (None, 0); - for (n, (name, curve, built_in)) in ease_presets::presets(&app.session).enumerate() { - count = n + 1; - let r = Rect::from_min_size(list.min + vec2((n % cols) as f32 * (TILE.x + GAP), (n / cols) as f32 * (TILE.y + GAP) - scroll.offset), TILE); - if !r.intersects(list) { - continue; - } - let resp = ui.interact(r.intersect(list), egui::Id::new(("easePresets.preset", n)), Sense::click()); - app.auto.add(&format!("easePresets.preset.{count}"), r, name); - let on = selected == Some(name); - p.rect_filled( - r, - 4.0, - if on { - t.row_selected - } else if resp.hovered() { - t.hover - } else { - t.field_bg - }, - ); - let thumb = Rect::from_min_size(r.min, vec2(TILE.x, TILE.y - 16.0)).shrink2(vec2(12.0, 7.0)); - let [_, y1, _, y2] = curve.handles(); - let (lo, hi) = (y1.min(y2).min(0.0), y1.max(y2).max(1.0)); - p.add(curve_shape(&curve, |x, v| to_screen(thumb, lo, hi, x, v), Stroke::new(1.5, if on { t.text } else { t.text_dim }))); - widgets::text_fit( - &p, - pos2(r.center().x, r.max.y - 9.0), - Align2::CENTER_CENTER, - name, - Tokens::ui(11.0), - TILE.x - 6.0, - if built_in { t.text_dim } else { t.text }, - ); - if resp.on_hover_text(format!("{name}: click to apply it to the selected keyframes")).clicked() { - clicked = Some((name.to_string(), curve)); - } - } - scroll.end(ui, &mut app.auto, "easePresets.scroll", count.div_ceil(cols) as f32 * (TILE.y + GAP), &t); - clicked -} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index f95bd176..361a123a 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -7,7 +7,6 @@ pub mod content_fill_panel; pub mod delete_items; pub mod dialogs; pub mod dialogs_3d; -pub mod ease_presets; pub mod effect_controls; pub mod effects_presets; pub mod essential; @@ -128,7 +127,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rec PanelKind::ContentAwareFill => content_fill_panel::show(app, ui, rect), PanelKind::CreateNullsFromPaths => path_vr_panels::create_nulls(app, ui, rect), PanelKind::VrCompEditor => path_vr_panels::vr_editor(app, ui, rect), - PanelKind::EasePresets => ease_presets::show(app, ui, rect), } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 3b9901f3..d7a7edcd 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -667,9 +667,6 @@ pub struct UiState { /// Lumetri Scopes panel options. #[serde(default)] pub scopes: crate::panels::scopes_panel::ScopesState, - /// Ease Presets panel: working curve, selected preset, name field. - #[serde(default)] - pub ease_presets: crate::panels::ease_presets::EasePanelState, } impl Default for UiState { @@ -724,7 +721,6 @@ impl Default for UiState { mini_flowchart: None, flowchart: Default::default(), scopes: Default::default(), - ease_presets: Default::default(), } } } diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 82805f60..50ccf1ef 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -1,7 +1,6 @@ //! Headless checks for Timeline keyframes as a person uses them: dragging keys over many frames //! in one gesture (the drag used to end after the first frame), Shift-snapping, Ctrl+C / -//! Ctrl+V, which the windowing layer delivers as clipboard events rather than key presses, and -//! easing selected keys from the Ease Presets panel. +//! Ctrl+V, which the windowing layer delivers as clipboard events rather than key presses. use effectcraft_engine::Session; use effectcraft_engine::project::LayerId; @@ -255,45 +254,6 @@ fn k_follows_the_revealed_properties_and_auto_select_picks_the_speed_graph_for_p assert!(h.state().auto.find(&format!("timeline.graph.key.{pos}.1.0")).is_some(), "value graph: X and Y"); } -fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { - let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); - pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) -} - -#[test] -fn clicking_an_ease_preset_eases_the_selected_keys_and_handles_edit_the_curve() { - use effectcraft_engine::keyframe::{Ease, Interp}; - use effectcraft_ui_egui::dock::PanelKind; - let (mut h, id, _) = harness(); - h.state_mut().session.execute("prop.select", json!({"layer": id.0, "path": "transform/opacity"})).unwrap(); - h.state_mut().show_panel(PanelKind::EasePresets); - h.state_mut().ui.maximized = Some(PanelKind::EasePresets); - h.run_steps(4); - let opacity = - |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().clone(); - let tile = h.state().auto.query("easePresets.preset.").into_iter().find(|e| e.label == "Ease In-Out").expect("the Ease In-Out thumbnail").id.clone(); - let steps = h.state().session.history.undo.len(); - let at = center(&h, &tile); - click_with(&mut h, at, Default::default()); - let op = opacity(&h); - let eased = Ease { speed: 0.0, influence: 0.5 }; - assert_eq!((op.keys[0].out_interp, op.keys[0].out_ease.clone()), (Interp::Bezier, vec![eased])); - assert_eq!((op.keys[1].in_interp, op.keys[1].in_ease.clone()), (Interp::Bezier, vec![eased])); - assert_eq!(h.state().session.history.undo.len(), steps + 1); - assert_eq!(h.state().ui.ease_presets.selected.as_deref(), Some("Ease In-Out")); - // Dragging the out handle to the right lengthens its influence; Apply uses the edited curve. - let graph = h.state().auto.find("easePresets.graph").unwrap().rect; - let from = center(&h, "easePresets.handle.out"); - drag(&mut h, from, from + egui::vec2(graph[2] * 0.2, 0.0), Default::default()); - let out = h.state().ui.ease_presets.curve.out_influence; - assert!(out > 60.0 && out < 75.0, "{out}"); - let apply = center(&h, "easePresets.apply"); - click_with(&mut h, apply, Default::default()); - let applied = opacity(&h).keys[0].out_ease[0].influence; - assert!((applied * 100.0 - out).abs() < 1e-9, "{applied} vs {out}"); - assert_eq!(h.state().session.history.undo.len(), steps + 2); -} - /// #284: in the Graph Editor, dragging the handle of one selected key moves the same handle of /// every selected key with it (After Effects), in one undo step. #[test] diff --git a/crates/ui-egui/tests/ui_scriptui.rs b/crates/ui-egui/tests/ui_scriptui.rs index c48ed36c..5a01d18f 100644 --- a/crates/ui-egui/tests/ui_scriptui.rs +++ b/crates/ui-egui/tests/ui_scriptui.rs @@ -112,6 +112,42 @@ fn panels_open_at_quit_dock_again_at_launch() { assert_eq!(store.read(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE).as_deref(), Some(r#"{"open":[]}"#)); } +/// The bundled Ease Presets ScriptUI panel (Window ▸ Ease Presets.jsx) docks, and clicking a +/// preset in its list eases the selected keyframes in one undo step. +#[test] +fn ease_presets_panel_eases_the_selected_keys_from_its_list() { + use effectcraft_engine::keyframe::{Ease, Interp}; + use egui_kittest::kittest::Queryable; + let mut h = harness(); + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap()["layer"].as_u64().unwrap(); + for (t, v) in [(0.0, 0.0), (1.0, 100.0)] { + s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t, "value": v})).unwrap(); + } + s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + let cx = effectcraft_engine::menus::DynCtx::default(); + let (panels, _) = effectcraft_engine::menus::dynamic(s, "scriptPanels", &cx); + assert!(panels.iter().any(|e| e.label == "Ease Presets.jsx"), "listed in the Window menu"); + s.execute("window.scriptPanel", json!({"name": "Ease Presets.jsx"})).unwrap(); + h.run_steps(3); + let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Ease Presets.jsx").unwrap().id; + assert!(h.state().ui.dock.contains(PanelKind::ScriptPanel(panel)), "the panel docks"); + h.state_mut().ui.maximized = Some(PanelKind::ScriptPanel(panel)); + h.run_steps(3); + let steps = h.state().session.history.undo.len(); + let item = h.query_by_label("Ease In-Out").expect("the Ease In-Out preset").rect().center(); + h.input_mut().events.push(Event::PointerMoved(item)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: item, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + h.run_steps(3); + let op = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().clone(); + let eased = Ease { speed: 0.0, influence: 0.5 }; + assert_eq!((op.keys[0].out_interp, op.keys[0].out_ease[0]), (Interp::Bezier, eased)); + assert_eq!((op.keys[1].in_interp, op.keys[1].in_ease[0]), (Interp::Bezier, eased)); + assert_eq!(h.state().session.history.undo.len(), steps + 1); +} + #[test] fn history_panel_jumps_between_branches() { let mut h = harness(); diff --git a/docs/agents.md b/docs/agents.md index fd31d50a..207406f5 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -460,7 +460,10 @@ Scripts that build ScriptUI windows publish them to the session; agents drive th A dialog's `show()` waits for the user: the `script.run` / `file.runScript` reply carries `"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": …}`. +`file.installScript` / `file.installScriptUIPanel {"path": …}`, `window.scriptPanel {"name": …}`, +`window.restoreScriptPanels` (the panels open when the app last quit). Scripts' `app.settings`: +`script.settings.get {"section": …, "key": …}` and `script.settings.save {…, "value": …}`. +Extension points and the bundled extensions: [plugins.md](plugins.md). ### Timeline: layer order, properties and shortcuts diff --git a/docs/gaps.md b/docs/gaps.md index 4673245d..51928fc0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -200,9 +200,12 @@ The most important missing piece: it turns every other estimate here into a meas 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 shortcuts for scripts, ExtendScript's `#include` / `#target` directives and `.jsxbin`. -- Done so far: ease presets (Window ▸ Ease Presets, `keys.easePreset.*`, #254) cover what - ease-curve plug-ins are used for: easing curves kept by name, drawn and edited in a value graph, - and applied to pairs of keyframes ([parity.md](parity.md#update-ease-presets-254)). +- Done so far: ease presets (#254) cover what ease-curve plug-ins are used for: easing curves kept + by name and applied to pairs of keyframes. Modelled on a third-party panel, they are not core: a + ScriptUI panel bundled as an optional extension (`extensions/scriptui-panels/Ease Presets.jsx`, + Window ▸ Ease Presets.jsx) on the public scripting API, which needed `app.settings` that last, + per-key methods that keep the key selection, and eases reported as they play + ([plugins.md](plugins.md#bundled-extensions), [parity.md](parity.md)). ### G9. Reach diff --git a/docs/parity.md b/docs/parity.md index 0c47dc35..af8eaca4 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -400,27 +400,40 @@ Browser in the web app (landed in M13.10: browser storage and File System Access wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script `Socket` validates ports, and ScriptUI resource strings accept trailing array commas. -## Update: ease presets (#254) +## Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel -**Window ▸ Ease Presets** is our own panel for what ease-curve plug-ins are used for in After -Effects (G8): keep easing curves by name and apply them to pairs of keyframes. A curve is the out -handle of the first key and the in handle of the second, in the Keyframe Velocity dialog's terms: -influence in percent, and speed relative to the segment's average speed, so one curve fits any -duration and change of value. Applying it eases every pair of neighbouring selected keyframes of -each property in one undo step, per dimension, and along the motion path for spatial properties -(as Easy Ease does); an Auto Bezier key keeps its other side. Twelve built-in curves (Linear, -Smooth, Ease In-Out Soft / plain / Strong, Accelerate and Decelerate with Strong variants, Expo -In-Out / Accelerate / Decelerate) are our own numbers; user presets are kept in the settings store -(`ease_presets.json`; a corrupt file or entry is skipped, never fatal). The panel draws each preset -as a thumbnail (a click applies it) and the working curve in a value graph whose two handles drag; -Apply, From Keys (the curve between the first selected pair), Save Current, Rename and Delete. -Commands: `keys.easePreset.apply {preset | curve}` (`curve` as `{outInfluence, outSpeed, -inInfluence, inSpeed}` or cubic-bezier handles `[x1, y1, x2, y2]`), `keys.easePreset.capture`, -`keys.easePreset.save {name, curve?}`, `keys.easePreset.list`, `keys.easePreset.rename {name, -newName}`, `keys.easePreset.delete {name}`; automation ids `easePresets.*`. +EffectCraft's core keeps After Effects parity; what After Effects users get from third parties is +an extension, on the same extension points After Effects has ([plugins.md](plugins.md) compares +them: scripts, ScriptUI panels, effect plug-ins, with a guide to writing a panel). -Not yet: curves with more than one bend (bounce and elastic shapes, which need keyframes in -between), preset import / export files, and a live preview on the keys while dragging the handles. +- **ScriptUI panels** in `Scripts/ScriptUI Panels/` of the settings folder are listed at the + bottom of the Window menu and dock like any panel (as before); the panels open when the app quits + now open again at the next launch (`window.restoreScriptPanels`), as After Effects reopens its + workspace's panels. +- **Scripting API** (parity fixes the Ease Presets panel needed): `app.settings` lasts between + runs (`script_settings.json`, `script.settings.get` / `save`); per-key methods + (`setTemporalEaseAtKey`, `setInterpolationTypeAtKey`, `removeKey`, the continuous / auto-Bezier / + roving setters) no longer change the user's key selection; `keyInTemporalEase` / + `keyOutTemporalEase` report linear and auto-Bezier sides as they play, and + `keyIn/OutSpatialTangent` the tangents the motion path plays; `selectedProperties` includes the + properties of selected keyframes; `isInterpolationTypeValid` is false for Linear and Bezier on + values that only hold. +- **Ease presets** (#254), modelled on a third-party panel, moved out of the core: they are the + bundled ScriptUI panel `extensions/scriptui-panels/Ease Presets.jsx` (Window ▸ Ease + Presets.jsx), written only against the public scripting API. A curve is the out side of the + first key and the in side of the second, in the Keyframe Velocity dialog's terms (influence in + percent, speed relative to the segment's average speed); clicking a preset eases every pair of + neighbouring selected keyframes in one undo step, per dimension and along the motion path for + spatial properties, leaving the keys' other sides as they play. The twelve built-in curves are + unchanged; user presets (Save, Rename, Delete) are kept with `app.settings`, and those saved by + the core panel of v0.6.0 (`ease_presets.json`) move there the first time settings load. The + panel shows the working curve as a drawing and its four numbers as fields (the value graph's + handles don't drag: ScriptUI panels get no mouse-drag events here). The `keys.easePreset.*` + commands and `EaseCurve` are gone. Not yet: curves with more than one bend (bounce and elastic + shapes), preset import / export files. +- **Snapping options** (#265): the Tools bar's menu has After Effects' two options, Snap Edges + Extended and Snap to Features in Collapsed Compositions and Text Layers (the layers inside + collapsed precomp layers snap); the per-feature toggles are gone and every feature snaps. ## Update: M5.9–M5.14 keyframes, M3.15 clipboard, M12.8 responsiveness diff --git a/docs/plugins.md b/docs/plugins.md index 2e542302..d24c078b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -19,7 +19,7 @@ Guide): | `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, 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 | -| `app.settings` (kept in the preferences between sessions) | Kept for the run only | Gap | +| `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same | | Allow Scripts to Write Files and Access Network | Settings ▸ Scripting & Expressions | Same | The settings folder is `~/Library/Application Support/EffectCraft` on macOS, `%APPDATA%\EffectCraft` @@ -84,10 +84,44 @@ Supported controls: `panel`, `group`, `button`, `iconbutton`, `statictext`, `edi `tabbedpanel` / `tab` and `image`, with ScriptUI's automatic layout (`orientation`, `alignChildren`, `alignment`, `margins`, `spacing`, `preferredSize`), resource strings, event handlers (`onClick`, `onChange`, `onChanging`, `onClose`, `addEventListener`) and `onDraw` with -ScriptUIGraphics. Agents use a panel like a user: `scriptui.list`, `scriptui.get` (its controls), -`scriptui.click`, `scriptui.set` and `scriptui.close`; every control has the automation id +ScriptUIGraphics. Mouse events (`mousedown`, `mousemove`…) aren't sent to scripts yet. Agents use +a panel like a user: `scriptui.list`, `scriptui.get` (its controls), `scriptui.click`, +`scriptui.set` and `scriptui.close`; every control has the automation id `scriptui..`. The bundled sample `Layer Tools.jsx` -(`crates/engine/scripts/ScriptUI Panels/`) is a longer example. +(`crates/engine/scripts/ScriptUI Panels/`) and the Ease Presets extension below are longer +examples. + +## Bundled extensions + +Optional tools that ship with EffectCraft but are not part of its core: they are scripts on the +public scripting API, like the third-party scripts After Effects users install, and are listed +with the bundled samples (`file.scripts.list` reports them as `extension`). A script of the same +name in your Scripts folders takes their place. + +### Ease Presets (`extensions/scriptui-panels/Ease Presets.jsx`) + +Window ▸ Ease Presets.jsx: easing curves kept by name and applied to pairs of neighbouring +selected keyframes (#254; it was a core panel in v0.6.0). A curve is the out side of a segment's +first key and the in side of its second, in the Keyframe Velocity dialog's terms: influence in +percent, and speed relative to the segment's average speed (1 = as fast as a straight line, 0 = at +rest), so one curve fits any duration and change of value. + +* Click a preset to apply it to every pair of neighbouring selected keyframes of each selected + property, in one undo step (per dimension, and along the motion path for spatial properties; + the keys' other sides keep playing as they did). The keys stay selected, so you can try another. +* The drawing shows the working curve; type its numbers (Out % and Speed, In % and Speed) and + click Apply, or click From Keys to read the curve between the first selected pair. +* Save keeps the working curve under the typed name (replacing a preset of that name); Rename + and Delete act on the selected preset of your own. The twelve built-in presets can't change. +* User presets are kept with `app.settings` (section `Ease Presets`, key `userPresets`, in + `script_settings.json` in the settings folder). Presets saved by the v0.6.0 core panel + (`ease_presets.json`) move there the first time a later version loads its settings; the old + file is left as it was. + +It uses only After Effects' documented scripting API (`comp.selectedProperties`, +`Property.selectedKeys`, `keyInTemporalEase` / `keyOutTemporalEase`, `setTemporalEaseAtKey` with +`KeyframeEase`, `setInterpolationTypeAtKey`, `app.settings`, ScriptUI), so it is also a worked +example of a keyframe tool. ## Effect plug-ins diff --git a/extensions/scriptui-panels/Ease Presets.jsx b/extensions/scriptui-panels/Ease Presets.jsx new file mode 100644 index 00000000..a08fdbcc --- /dev/null +++ b/extensions/scriptui-panels/Ease Presets.jsx @@ -0,0 +1,454 @@ +// Ease Presets: easing curves kept by name and applied to pairs of neighbouring selected keyframes. +// +// A ScriptUI panel that ships with EffectCraft as an optional extension (Window ▸ Ease +// Presets.jsx). It uses only After Effects' documented scripting API: comp.selectedProperties, +// Property.selectedKeys, keyInTemporalEase / keyOutTemporalEase, setTemporalEaseAtKey with +// KeyframeEase, setInterpolationTypeAtKey, app.settings and ScriptUI. Original work by the +// EffectCraft contributors, MIT OR Apache-2.0. +// +// A curve is the out side of a segment's first key and the in side of its second, in the +// Keyframe Velocity dialog's terms: influence in percent of the segment, and speed relative to the +// segment's average speed (1 = as fast as a straight line, 0 = at rest). Applied to a segment, the +// speeds are scaled to it: per dimension, along the motion path for spatial properties, or as +// overall progress for shapes. +// +// User presets are kept with app.settings (section "Ease Presets", key "userPresets") as JSON: +// {"version": 1, "presets": [{"name": …, "curve": {"outInfluence", "outSpeed", "inInfluence", +// "inSpeed"}}]}. + +(function easePresets(thisObj) { + var SECTION = "Ease Presets"; + var KEY = "userPresets"; + var MAX_NAME = 64; + var MIN_INFLUENCE = 0.1; + var MAX_SPEED = 100; + var BEZIER = KeyframeInterpolationType.BEZIER; + var HOLD = KeyframeInterpolationType.HOLD; + + function curve(outInfluence, outSpeed, inInfluence, inSpeed) { + return { outInfluence: outInfluence, outSpeed: outSpeed, inInfluence: inInfluence, inSpeed: inSpeed }; + } + + // Our own presets. "Accelerate" starts slowly, "Decelerate" settles slowly. + var BUILT_IN = [ + { name: "Linear", curve: curve(100 / 3, 1, 100 / 3, 1) }, + { name: "Smooth", curve: curve(40, 0.25, 40, 0.25) }, + { name: "Ease In-Out Soft", curve: curve(25, 0, 25, 0) }, + { name: "Ease In-Out", curve: curve(50, 0, 50, 0) }, + { name: "Ease In-Out Strong", curve: curve(75, 0, 75, 0) }, + { name: "Accelerate", curve: curve(50, 0, 20, 1.5) }, + { name: "Accelerate Strong", curve: curve(80, 0, 15, 2.5) }, + { name: "Decelerate", curve: curve(20, 1.5, 50, 0) }, + { name: "Decelerate Strong", curve: curve(15, 2.5, 80, 0) }, + { name: "Expo In-Out", curve: curve(88, 0, 88, 0) }, + { name: "Expo Accelerate", curve: curve(75, 0, 12, 7) }, + { name: "Expo Decelerate", curve: curve(12, 7, 75, 0) } + ]; + + // ------------------------------------------------------------------ presets + + function finite(v) { + return typeof v === "number" && isFinite(v); + } + + // The curve with influences in 0.1–100 % and speeds within ±100; null when a number is missing. + function sanitized(c) { + if (!c || !finite(c.outInfluence) || !finite(c.outSpeed) || !finite(c.inInfluence) || !finite(c.inSpeed)) return null; + var inf = function (v) { return Math.min(100, Math.max(MIN_INFLUENCE, v)); }; + var speed = function (v) { return Math.min(MAX_SPEED, Math.max(-MAX_SPEED, v)); }; + return curve(inf(c.outInfluence), speed(c.outSpeed), inf(c.inInfluence), speed(c.inSpeed)); + } + + function cleanName(name) { + var n = String(name === undefined || name === null ? "" : name).replace(/^\s+|\s+$/g, ""); + return n.length > 0 && n.length <= MAX_NAME ? n : null; + } + + function same(a, b) { + return a.toLowerCase() === b.toLowerCase(); + } + + function find(list, name) { + for (var i = 0; i < list.length; i++) if (same(list[i].name, name)) return i; + return -1; + } + + // User presets; entries that don't parse, have no usable name or repeat a name are skipped. + function loadUser() { + var out = []; + if (!app.settings.haveSetting(SECTION, KEY)) return out; + var doc; + try { + doc = JSON.parse(app.settings.getSetting(SECTION, KEY)); + } catch (e) { + return out; + } + var list = doc && doc.presets instanceof Array ? doc.presets : []; + for (var i = 0; i < list.length; i++) { + var name = cleanName(list[i] && list[i].name); + var c = sanitized(list[i] && list[i].curve); + if (name && c && find(BUILT_IN, name) < 0 && find(out, name) < 0) out.push({ name: name, curve: c }); + } + return out; + } + + function saveUser(list) { + app.settings.saveSetting(SECTION, KEY, JSON.stringify({ version: 1, presets: list })); + } + + // ------------------------------------------------------------------ keyframes + + // Pairs of neighbouring selected keyframes of the active comp: {prop, key} (the earlier key's + // index), by property. + function selectedPairs() { + var comp = app.project.activeItem; + var out = []; + if (!(comp instanceof CompItem)) return out; + var props = comp.selectedProperties; + for (var i = 0; i < props.length; i++) { + var p = props[i]; + if (p.propertyType !== PropertyType.PROPERTY || p.numKeys < 2 || !p.isInterpolationTypeValid(BEZIER)) continue; + var keys = p.selectedKeys; + for (var k = 0; k + 1 < keys.length; k++) if (keys[k + 1] === keys[k] + 1) out.push({ prop: p, key: keys[k] }); + } + return out; + } + + // Length of the motion path from `a` to `b` with the tangents of their keys. + function pathLength(a, outTangent, inTangent, b) { + var p = [], n = Math.max(a.length, b.length), len = 0, prev = null; + for (var d = 0; d < n; d++) p.push([a[d] || 0, (a[d] || 0) + (outTangent[d] || 0), (b[d] || 0) + (inTangent[d] || 0), b[d] || 0]); + for (var s = 0; s <= 128; s++) { + var t = s / 128, u = 1 - t, pt = []; + for (var e = 0; e < n; e++) pt.push(u * u * u * p[e][0] + 3 * u * u * t * p[e][1] + 3 * u * t * t * p[e][2] + t * t * t * p[e][3]); + if (prev) { + var sq = 0; + for (var f = 0; f < n; f++) sq += (pt[f] - prev[f]) * (pt[f] - prev[f]); + len += Math.sqrt(sq); + } + prev = pt; + } + return len; + } + + // The layer a property belongs to. + function layerOf(p) { + return p.propertyGroup(p.propertyDepth); + } + + // Average speed of segment `a` → `a + 1` for each entry of its eases, in the units the eases use + // (keyframe times move with the layer, so the duration is in the layer's time). Null when the + // segment has no duration. + function slopes(p, a) { + var stretch = layerOf(p).stretch || 100; + var dur = (p.keyTime(a + 1) - p.keyTime(a)) * 100 / stretch; + if (!(dur > 0) || !isFinite(dur)) return null; + var n = Math.max(1, p.keyOutTemporalEase(a).length); + var v0 = p.keyValue(a), v1 = p.keyValue(a + 1), out = []; + var vt = p.propertyValueType; + if (vt === PropertyValueType.TwoD_SPATIAL || vt === PropertyValueType.ThreeD_SPATIAL) { + var speed = pathLength(v0, p.keyOutSpatialTangent(a), p.keyInSpatialTangent(a + 1), v1) / dur; + for (var i = 0; i < n; i++) out.push(speed); + } else if (typeof v0 === "number" && typeof v1 === "number") { + out.push((v1 - v0) / dur); + } else if (v0 instanceof Array && v1 instanceof Array && v0.length === v1.length) { + for (var d = 0; d < n; d++) out.push(((v1[d] || 0) - (v0[d] || 0)) / dur); + } else { + // Shapes and other values ease their overall progress. + for (var j = 0; j < n; j++) out.push(1 / dur); + } + return out; + } + + // Ease one side of key `k` with `eases`, leaving its other side as it plays (auto-Bezier and + // linear sides keep their shape; a continuous key stops being continuous, its sides now differ). + function easeSide(p, k, out, eases) { + var inType = p.keyInInterpolationType(k), outType = p.keyOutInterpolationType(k); + var inEase = p.keyInTemporalEase(k), outEase = p.keyOutTemporalEase(k); + if (p.keyTemporalContinuous(k)) p.setTemporalContinuousAtKey(k, false); + if (out) { + p.setTemporalEaseAtKey(k, inEase, eases); + p.setInterpolationTypeAtKey(k, inType, BEZIER); + } else { + p.setTemporalEaseAtKey(k, eases, outEase); + p.setInterpolationTypeAtKey(k, BEZIER, outType); + } + } + + // Ease every pair of neighbouring selected keyframes with `c` (one undo step). Returns the + // number of pairs eased. + function applyCurve(c) { + var pairs = selectedPairs(), n = 0; + if (!pairs.length) return 0; + app.beginUndoGroup("Apply Ease Preset"); + try { + for (var i = 0; i < pairs.length; i++) { + var p = pairs[i].prop, a = pairs[i].key, s = slopes(p, a); + if (!s) continue; + var outE = [], inE = []; + for (var d = 0; d < s.length; d++) { + outE.push(new KeyframeEase(c.outSpeed * s[d], c.outInfluence)); + inE.push(new KeyframeEase(c.inSpeed * s[d], c.inInfluence)); + } + easeSide(p, a, true, outE); + easeSide(p, a + 1, false, inE); + n++; + } + } finally { + app.endUndoGroup(); + } + return n; + } + + // The curve between the first selected pair of keyframes (measured on the dimension that + // changes most); null for holds and segments whose value doesn't change. + function captureCurve() { + var pairs = selectedPairs(); + if (!pairs.length) return null; + var p = pairs[0].prop, a = pairs[0].key; + if (p.keyOutInterpolationType(a) === HOLD) return null; + var s = slopes(p, a); + if (!s) return null; + var d = 0; + for (var i = 1; i < s.length; i++) if (Math.abs(s[i]) > Math.abs(s[d])) d = i; + if (Math.abs(s[d]) < 1e-12) return null; + var o = p.keyOutTemporalEase(a), n = p.keyInTemporalEase(a + 1); + var oe = o[d] || o[0], ne = n[d] || n[0]; + if (!oe || !ne) return null; + return sanitized(curve(oe.influence, oe.speed / s[d], ne.influence, ne.speed / s[d])); + } + + // ------------------------------------------------------------------ panel + + var ui = thisObj instanceof Panel ? thisObj : new Window("palette", "Ease Presets", undefined, { resizeable: true }); + ui.orientation = "column"; + ui.alignChildren = ["fill", "top"]; + ui.spacing = 6; + + var user = loadUser(); + var working = BUILT_IN[0].curve; + var refreshing = false; + + var list = ui.add("listbox", undefined, [], { name: "presets" }); + list.preferredSize = [220, 150]; + list.helpTip = "Click a preset to apply it to the selected keyframes"; + + // The working curve in the normalised value graph: keys at (0, 0) and (1, 1). + var graph = ui.add("group"); + graph.preferredSize = [220, 110]; + graph.onDraw = function () { + var g = this.graphics, w = this.size.width, h = this.size.height; + var lo = -0.35, hi = 1.35, m = 10; + var at = function (x, v) { return [m + (w - 2 * m) * x, h - m - (h - 2 * m) * (v - lo) / (hi - lo)]; }; + var frame = g.newPen(g.PenType.SOLID_COLOR, [0.5, 0.5, 0.5, 0.6], 1); + g.newPath(); + g.rectPath(0, 0, w, h); + g.strokePath(frame); + g.newPath(); + var a = at(0, 0), b = at(1, 0), c0 = at(0, 1), d = at(1, 1); + g.moveTo(a[0], a[1]); + g.lineTo(b[0], b[1]); + g.moveTo(c0[0], c0[1]); + g.lineTo(d[0], d[1]); + g.strokePath(frame); + // Handles: (out influence, out speed × out influence) and (1 − in influence, 1 − in speed × in influence). + var oi = working.outInfluence / 100, ii = working.inInfluence / 100; + var h1 = [oi, working.outSpeed * oi], h2 = [1 - ii, 1 - working.inSpeed * ii]; + var handles = g.newPen(g.PenType.SOLID_COLOR, [0.8, 0.8, 0.8, 1], 1); + var p1 = at(h1[0], h1[1]), p2 = at(h2[0], h2[1]), k0 = at(0, 0), k1 = at(1, 1); + g.newPath(); + g.moveTo(k0[0], k0[1]); + g.lineTo(p1[0], p1[1]); + g.moveTo(k1[0], k1[1]); + g.lineTo(p2[0], p2[1]); + g.strokePath(handles); + var accent = g.newPen(g.PenType.SOLID_COLOR, [0.29, 0.56, 1, 1], 2); + g.newPath(); + for (var s = 0; s <= 48; s++) { + var t = s / 48, u = 1 - t; + var x = 3 * u * u * t * h1[0] + 3 * u * t * t * h2[0] + t * t * t; + var v = 3 * u * u * t * h1[1] + 3 * u * t * t * h2[1] + t * t * t; + var q = at(x, v); + if (s === 0) g.moveTo(q[0], q[1]); + else g.lineTo(q[0], q[1]); + } + g.strokePath(accent); + }; + + function field(row, label, name) { + row.add("statictext", undefined, label); + var f = row.add("edittext", undefined, "", { name: name }); + f.characters = 5; + return f; + } + var outRow = ui.add("group"); + var outInfluence = field(outRow, "Out %", "outInfluence"); + var outSpeed = field(outRow, "Speed", "outSpeed"); + var inRow = ui.add("group"); + var inInfluence = field(inRow, "In %", "inInfluence"); + var inSpeed = field(inRow, "Speed", "inSpeed"); + + var actions = ui.add("group"); + var applyButton = actions.add("button", undefined, "Apply", { name: "apply" }); + var fromKeys = actions.add("button", undefined, "From Keys", { name: "fromKeys" }); + var nameRow = ui.add("group"); + nameRow.alignChildren = ["fill", "center"]; + var nameField = nameRow.add("edittext", undefined, "", { name: "name" }); + nameField.characters = 14; + nameField.helpTip = "Preset name"; + var saveButton = nameRow.add("button", undefined, "Save", { name: "save" }); + var edits = ui.add("group"); + var renameButton = edits.add("button", undefined, "Rename", { name: "rename" }); + var deleteButton = edits.add("button", undefined, "Delete", { name: "delete" }); + var status = ui.add("statictext", undefined, "", { name: "status" }); + status.alignment = ["fill", "top"]; + + function round(v, places) { + var f = Math.pow(10, places); + return String(Math.round(v * f) / f); + } + + function showCurve(c) { + working = c; + outInfluence.text = round(c.outInfluence, 1); + outSpeed.text = round(c.outSpeed, 2); + inInfluence.text = round(c.inInfluence, 1); + inSpeed.text = round(c.inSpeed, 2); + } + + // The curve typed into the fields (null when one isn't a number). + function typedCurve() { + return sanitized(curve(parseFloat(outInfluence.text), parseFloat(outSpeed.text), parseFloat(inInfluence.text), parseFloat(inSpeed.text))); + } + + function presets() { + return BUILT_IN.concat(user); + } + + // The user preset selected in the list (null for built-in ones and no selection). + function selectedUser() { + var item = list.selection; + if (!item) return null; + var i = find(user, item.text); + return i < 0 ? null : user[i]; + } + + function refresh(select) { + refreshing = true; + list.removeAll(); + var all = presets(); + for (var i = 0; i < all.length; i++) list.add("item", all[i].name); + if (select) { + var j = find(all, select); + if (j >= 0) list.selection = j; + } + refreshing = false; + } + + function apply(c, what) { + var n = applyCurve(c); + status.text = n > 0 ? "Eased " + n + " keyframe pair(s) with " + what : "Select two or more neighbouring keyframes of a property"; + return n; + } + + list.onChange = function () { + if (refreshing || !list.selection) return; + var all = presets(), i = find(all, list.selection.text); + if (i < 0) return; + showCurve(all[i].curve); + nameField.text = find(user, all[i].name) >= 0 ? all[i].name : ""; + apply(all[i].curve, "“" + all[i].name + "”"); + }; + + var onTyped = function () { + var c = typedCurve(); + if (c) working = c; + }; + outInfluence.onChange = outSpeed.onChange = inInfluence.onChange = inSpeed.onChange = onTyped; + + applyButton.onClick = function () { + var c = typedCurve(); + if (!c) { + status.text = "Type numbers for the influences and speeds"; + return; + } + showCurve(c); + apply(c, "the curve"); + }; + + fromKeys.onClick = function () { + var c = captureCurve(); + if (!c) { + status.text = "Select two neighbouring keyframes whose value changes"; + return; + } + showCurve(c); + status.text = "Read the curve of the selected keyframes"; + }; + + saveButton.onClick = function () { + var name = cleanName(nameField.text), c = typedCurve(); + if (!name) { + status.text = "Type a name for the preset first"; + return; + } + if (find(BUILT_IN, name) >= 0) { + status.text = "“" + name + "” is a built-in preset; choose another name"; + return; + } + if (!c) { + status.text = "Type numbers for the influences and speeds"; + return; + } + // Saving under an existing name replaces that preset. + var i = find(user, name); + if (i >= 0) user[i] = { name: name, curve: c }; + else user.push({ name: name, curve: c }); + saveUser(user); + refresh(name); + status.text = "Saved the ease preset “" + name + "”"; + }; + + renameButton.onClick = function () { + var p = selectedUser(), name = cleanName(nameField.text); + if (!p) { + status.text = "Select one of your own presets first"; + return; + } + if (!name) { + status.text = "Type a new name for “" + p.name + "” first"; + return; + } + var other = find(user, name); + if (find(BUILT_IN, name) >= 0 || (other >= 0 && user[other] !== p)) { + status.text = "An ease preset named “" + name + "” exists"; + return; + } + p.name = name; + saveUser(user); + refresh(name); + status.text = "Renamed to “" + name + "”"; + }; + + deleteButton.onClick = function () { + var p = selectedUser(); + if (!p) { + status.text = "Select one of your own presets first"; + return; + } + user.splice(find(user, p.name), 1); + saveUser(user); + refresh(null); + nameField.text = ""; + status.text = "Deleted “" + p.name + "”"; + }; + + refresh(null); + showCurve(working); + status.text = "Select keyframes, then click a preset"; + if (ui instanceof Window) { + ui.show(); + } else { + ui.layout.layout(true); + } +})(this); diff --git a/extensions/scriptui-panels/Ease Presets.jsx.attribution b/extensions/scriptui-panels/Ease Presets.jsx.attribution new file mode 100644 index 00000000..18a95b7a --- /dev/null +++ b/extensions/scriptui-panels/Ease Presets.jsx.attribution @@ -0,0 +1,7 @@ +asset: Ease Presets.jsx +title: EffectCraft bundled ScriptUI panel (Ease Presets) and its built-in ease presets +author: EffectCraft contributors +source: original work +license: MIT OR Apache-2.0 +added: 2026-10-09 by EffectCraft contributors +notes: Written from scratch for EffectCraft against After Effects' documented scripting behaviour (Scripting Guide); not derived from any Adobe sample or third-party panel. The twelve built-in curves are our own values, moved unchanged from the core Ease Presets panel (#254).