mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #404 from storytold/feat/plugins-ae-parity
Plug-ins and extensions as in After Effects: ScriptUI panels reopen, Ease Presets becomes a bundled panel, AE's Snapping options
This commit is contained in:
40 files changed
+1573
-1173
No files matched your search
@@ -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)
|
||||
|
||||
@@ -241,6 +241,8 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
api::set_info("restored", json!(restored));
|
||||
// The ScriptUI panels open on the last visit open again.
|
||||
let _ = session.execute("window.restoreScriptPanels", json!({}));
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.set_gpu_failure_bridge(gpu_failures);
|
||||
app.ui.start_screen = home;
|
||||
|
||||
@@ -158,6 +158,9 @@ fn main() -> eframe::Result {
|
||||
eprintln!("effectcraft: {e}");
|
||||
}
|
||||
let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
|
||||
// The ScriptUI panels open when the app last quit open again (a failing one is
|
||||
// reported in a toast and forgotten).
|
||||
let _ = session.execute("window.restoreScriptPanels", json!({}));
|
||||
let mut app = EffectcraftApp::new(session);
|
||||
app.set_gpu_failure_bridge(gpu_failures);
|
||||
app.ui.start_screen = show_home;
|
||||
|
||||
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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!(
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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};
|
||||
@@ -18,14 +22,90 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui};
|
||||
/// The settings-store folders of installed scripts.
|
||||
pub const SCRIPTS_DIR: &str = "Scripts";
|
||||
pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
|
||||
/// The 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<String, BTreeMap<String, String>>;
|
||||
|
||||
/// 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<Value> {
|
||||
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<Value> {
|
||||
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.
|
||||
@@ -35,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,
|
||||
}
|
||||
|
||||
@@ -44,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<ScriptEntry> {
|
||||
let mut v: Vec<ScriptEntry> = vec![];
|
||||
if let Some(cfg) = &s.config {
|
||||
@@ -54,16 +134,16 @@ pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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<bool>) -> Option<(bool, String)> {
|
||||
if let Some(cfg) = &s.config {
|
||||
for (dir, is_panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
|
||||
@@ -75,7 +155,7 @@ pub fn script_code(s: &Session, name: &str, panel: Option<bool>) -> 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<Value> {
|
||||
@@ -118,10 +198,10 @@ fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if removed { Ok(json!({"removed": name})) } else { Err(bad(c, format!("`{name}` is not installed"))) }
|
||||
}
|
||||
|
||||
/// Run an installed or sample script by name (File ▸ Scripts ▸ <name>). A ScriptUI panel run
|
||||
/// Run an installed or bundled script by name (File ▸ Scripts ▸ <name>). A ScriptUI panel run
|
||||
/// this way gets `this` = a floating palette-like panel window.
|
||||
pub(crate) fn run_named(s: &mut Session, name: &str) -> Result<Value> {
|
||||
let (panel, code) = script_code(s, name, None).ok_or_else(|| bad("file.runScript", format!("no installed or sample script `{name}`")))?;
|
||||
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}));
|
||||
}
|
||||
@@ -134,6 +214,64 @@ pub fn panel_wrapper(title: &str, code: &str) -> String {
|
||||
format!("(function () {{ {code}\n}}).call(__uiDockPanel({}));", Value::String(title.to_string()))
|
||||
}
|
||||
|
||||
/// The ScriptUI panels open now (script names).
|
||||
pub(crate) fn open_panel_names(s: &Session) -> Vec<String> {
|
||||
s.script_ui.windows.iter().filter(|w| w.kind == scriptui::WindowKind::Panel).map(|w| w.script.clone()).collect()
|
||||
}
|
||||
|
||||
/// The panels open when EffectCraft last quit (`{"open": [names]}`); a missing or unreadable
|
||||
/// file is none.
|
||||
fn remembered_panels(s: &Session) -> Vec<String> {
|
||||
let text = s.config.as_ref().and_then(|c| c.read(OPEN_PANELS_FILE)).unwrap_or_default();
|
||||
let doc: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
|
||||
doc.get("open").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).filter(|n| is_script(n)).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// Change the remembered panels with `f` (written only when it changed). The list is kept so
|
||||
/// the next launch reopens them, as After Effects reopens the panels of its workspace; quitting
|
||||
/// leaves it as it is.
|
||||
fn edit_remembered_panels(s: &Session, f: impl FnOnce(&mut Vec<String>)) {
|
||||
let Some(cfg) = &s.config else { return };
|
||||
let before = remembered_panels(s);
|
||||
let mut list = before.clone();
|
||||
f(&mut list);
|
||||
if list != before
|
||||
&& let Err(e) = cfg.write(OPEN_PANELS_FILE, &json!({"open": list}).to_string())
|
||||
{
|
||||
log::warn!("cannot remember the open ScriptUI panels: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget the panels of `before` (open before a script window event) that are closed now.
|
||||
pub(crate) fn forget_closed_panels(s: &Session, before: &[String]) {
|
||||
let now = open_panel_names(s);
|
||||
let closed: Vec<&String> = before.iter().filter(|n| !now.contains(n)).collect();
|
||||
if !closed.is_empty() {
|
||||
edit_remembered_panels(s, |l| l.retain(|n| !closed.contains(&n)));
|
||||
}
|
||||
}
|
||||
|
||||
/// `window.restoreScriptPanels`: open the ScriptUI panels that were open when EffectCraft last
|
||||
/// quit (frontends call it once at launch). Panels whose script is gone or fails are reported
|
||||
/// and forgotten; the others still open.
|
||||
fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let (mut opened, mut failed) = (vec![], vec![]);
|
||||
for name in remembered_panels(s) {
|
||||
let r = match script_code(s, &name, Some(true)) {
|
||||
Some(_) => open_panel(s, &json!({"name": name})).map_err(|e| e.to_string()),
|
||||
None => Err("no such ScriptUI panel".to_string()),
|
||||
};
|
||||
match r {
|
||||
Ok(_) => opened.push(name),
|
||||
Err(error) => {
|
||||
edit_remembered_panels(s, |l| l.retain(|n| *n != name));
|
||||
failed.push(json!({"name": name, "error": error}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"opened": opened, "failed": failed}))
|
||||
}
|
||||
|
||||
/// Window ▸ <ScriptUI panel>: run the panel script (or bring its panel forward).
|
||||
fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "window.scriptPanel";
|
||||
@@ -150,6 +288,13 @@ fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(id) = id {
|
||||
s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) });
|
||||
}
|
||||
if id.is_some() {
|
||||
edit_remembered_panels(s, |l| {
|
||||
if !l.contains(&name) {
|
||||
l.push(name.clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(json!({"window": id, "result": out}))
|
||||
}
|
||||
|
||||
@@ -174,7 +319,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|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",
|
||||
@@ -184,6 +329,25 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
open_panel
|
||||
),
|
||||
cmd!(
|
||||
"window.restoreScriptPanels",
|
||||
"Restore ScriptUI Panels",
|
||||
[],
|
||||
None,
|
||||
"{} → reopens the ScriptUI panels open when EffectCraft last quit (frontends call it at launch) → {opened: [name], failed: [{name, error}]}",
|
||||
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",
|
||||
|
||||
@@ -467,7 +467,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Snapping Options",
|
||||
[],
|
||||
None,
|
||||
"{edgesExtended?, edges?, corners?, centers?, anchorPoints?, paths?: bool, toggle?: one of those keys} → the options",
|
||||
"{edgesExtended? (Snap Edges Extended), collapsedFeatures? (Snap to Features in Collapsed Compositions and Text Layers): bool, toggle?: one of those keys} → the options",
|
||||
always,
|
||||
snapping_options
|
||||
),
|
||||
|
||||
@@ -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<Item = (&str, EaseCurve, bool)> {
|
||||
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<EaseCurve> {
|
||||
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<usize>> = 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<EasePreset> {
|
||||
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<EasePreset> = vec![];
|
||||
for entry in doc.get("presets").and_then(Value::as_array).into_iter().flatten() {
|
||||
let preset = serde_json::from_value::<EasePreset>(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<EasePreset>) -> 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<String> {
|
||||
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<String> {
|
||||
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<EaseCurve> {
|
||||
let parsed = match v.as_array() {
|
||||
Some(a) => {
|
||||
let h: Option<Vec<f64>> = 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::<EaseCurve>(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<EaseCurve> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
Ok(json!(captured(s, "keys.easePreset.capture")?))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<usize> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
Ok(presets(s).map(|(name, curve, built_in)| json!({"name": name, "builtIn": built_in, "curve": curve})).collect())
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
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),
|
||||
]
|
||||
}
|
||||
@@ -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<Arc<dyn config::ConfigStore>>,
|
||||
/// 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<ease_presets::EasePreset>,
|
||||
/// 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<shortcuts::UiCommand>,
|
||||
/// 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -629,7 +629,11 @@ fn widget_p(s: &Session, win: u32, p: &Value, cmd: &str) -> Result<u32> {
|
||||
|
||||
fn dispatch(s: &mut Session, ev: ScriptUiEvent) -> Result<Value> {
|
||||
let f = s.script_ui.dispatch.ok_or_else(|| EngineError::Other("scripting is not available in this build".into()))?;
|
||||
f(s, &ev).map_err(EngineError::Other)
|
||||
// The event may close a ScriptUI panel (its tab, or the script itself): forget it then.
|
||||
let open = crate::commands::scripts::open_panel_names(s);
|
||||
let r = f(s, &ev).map_err(EngineError::Other);
|
||||
crate::commands::scripts::forget_closed_panels(s, &open);
|
||||
r
|
||||
}
|
||||
|
||||
/// `scriptui.list`: the open script windows.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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::<Vec<_>>(), ["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);
|
||||
}
|
||||
@@ -84,8 +84,8 @@ fn snap_targets_cover_layers_comp_guides_and_grid() {
|
||||
}
|
||||
|
||||
/// Tools bar ▸ Snapping options (#253): Snap Edges Extended off keeps a layer's edges to its
|
||||
/// extent, each feature family can be turned off, hidden layers are no targets, and with
|
||||
/// Snapping off only guides and the grid are left.
|
||||
/// extent, every feature of a layer snaps, hidden layers are no targets, and with Snapping off
|
||||
/// only guides and the grid are left.
|
||||
#[test]
|
||||
fn snapping_options_pick_the_targets() {
|
||||
use crate::viewer::{SnapFeatures, SnapOptions, layer_features, targets};
|
||||
@@ -108,35 +108,63 @@ fn snapping_options_pick_the_targets() {
|
||||
let own = targets(&ctx, &[], opts(SnapFeatures { edges_extended: false, ..Default::default() }));
|
||||
assert!(snap(&[[268.0, 340.0]], &own, 4.0).is_none_or(|h| h.hits.iter().all(|t| t.source != SnapSource::Layer(LayerId(a)))));
|
||||
assert_eq!(snap(&[[268.0, 190.0]], &own, 4.0).unwrap().at, [270.0, 190.0]);
|
||||
// Centres (and anchor points, a's sits in its centre) off: nothing at the comp centre, where
|
||||
// a's centre is too; edges off: no edge lines or midpoints, only the comp's centre lines.
|
||||
let no_centres = targets(&ctx, &[], opts(SnapFeatures { centers: false, anchor_points: false, ..Default::default() }));
|
||||
assert!(!no_centres.iter().any(|t| t.pos == [320.0, 180.0]));
|
||||
assert!(no_centres.iter().any(|t| t.pos == [270.0, 155.0]), "corners stay");
|
||||
let no_edges = targets(&ctx, &[], opts(SnapFeatures { edges: false, ..Default::default() }));
|
||||
assert!(!no_edges.iter().any(|t| t.pos == [320.0, 155.0]));
|
||||
assert!(no_edges.iter().filter(|t| t.kind != SnapKind::Point).all(|t| t.source == SnapSource::Comp && (t.pos[0] == 320.0 || t.pos[1] == 180.0)));
|
||||
// The dragged layer's features follow the options too: corners only.
|
||||
// Corners, edge midpoints and the centre of a are targets.
|
||||
for p in [[270.0, 155.0], [320.0, 155.0], [320.0, 180.0]] {
|
||||
assert!(all.iter().any(|t| t.source == SnapSource::Layer(LayerId(a)) && t.pos == p), "{p:?}");
|
||||
}
|
||||
// The dragged layer's features: corners, edge midpoints, centre and anchor point.
|
||||
let l = comp.layer(LayerId(a)).unwrap();
|
||||
let corners = SnapFeatures { edges: false, centers: false, anchor_points: false, paths: false, ..Default::default() };
|
||||
assert_eq!(layer_features(&ctx, l, corners), vec![[270.0, 155.0], [370.0, 155.0], [370.0, 205.0], [270.0, 205.0]]);
|
||||
let f = layer_features(&ctx, l);
|
||||
assert_eq!(f[..4], [[270.0, 155.0], [370.0, 155.0], [370.0, 205.0], [270.0, 205.0]]);
|
||||
assert_eq!(f.len(), 10, "{f:?}");
|
||||
// Snapping off: guides only.
|
||||
let guides = targets(&ctx, &[], SnapOptions { layers: false, guides: true, ..opts(SnapFeatures::default()) });
|
||||
assert!(!guides.is_empty() && guides.iter().all(|t| t.source == SnapSource::Guide));
|
||||
}
|
||||
|
||||
/// Snap to Features in Collapsed Compositions and Text Layers: the layers inside a collapsed
|
||||
/// precomp layer are targets (highlighting the precomp layer), only while the option is on and
|
||||
/// the precomp layer collapses transformations.
|
||||
#[test]
|
||||
fn collapsed_precomp_features_snap() {
|
||||
use crate::viewer::{SnapFeatures, SnapOptions, targets};
|
||||
let mut s = comp();
|
||||
// A 40×20 solid at (100, 50) in a 640×360 precomp.
|
||||
let inner = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 20})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": inner, "path": "transform/position", "value": [100, 50, 0]})).unwrap();
|
||||
s.execute("layer.precompose", json!({"layers": [inner], "name": "Inner"})).unwrap();
|
||||
let pre = s.active_comp().unwrap().layers[0].id;
|
||||
// Moved by (10, 20) in the outer comp: the inner solid's corner (80, 40) lands at (90, 60).
|
||||
s.execute("prop.set", json!({"layer": pre.0, "path": "transform/position", "value": [330, 200, 0]})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let at = |s: &Session, f: SnapFeatures| {
|
||||
let comp = s.project.comp(cid).unwrap().clone();
|
||||
let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, Tick::ZERO);
|
||||
targets(&ctx, &[], SnapOptions { layers: true, features: f, guides: false, grid: false, grid_spacing: 0.0 })
|
||||
};
|
||||
let corner = |t: &[crate::viewer::SnapTarget]| t.iter().any(|t| t.kind == SnapKind::Point && t.pos == [90.0, 60.0] && t.source == SnapSource::Layer(pre));
|
||||
assert!(!corner(&at(&s, SnapFeatures::default())), "not collapsed");
|
||||
s.execute("layer.setSwitch", json!({"layers": [pre.0], "switch": "collapse", "value": true})).unwrap();
|
||||
let on = at(&s, SnapFeatures::default());
|
||||
assert!(corner(&on), "collapsed");
|
||||
assert_eq!(snap(&[[93.0, 63.0]], &on, 5.0).unwrap().at, [90.0, 60.0]);
|
||||
assert!(!corner(&at(&s, SnapFeatures { collapsed_features: false, ..Default::default() })), "option off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapping_options_command_sets_and_toggles() {
|
||||
let mut s = comp();
|
||||
assert!(s.state.snap_features.edges_extended && s.state.snap_features.anchor_points);
|
||||
let r = s.execute("view.snappingOptions", json!({"edgesExtended": false, "anchorPoints": false})).unwrap();
|
||||
assert_eq!(r["edgesExtended"], false);
|
||||
assert!(!s.state.snap_features.edges_extended && !s.state.snap_features.anchor_points && s.state.snap_features.corners);
|
||||
s.execute("view.snappingOptions", json!({"toggle": "paths"})).unwrap();
|
||||
assert!(!s.state.snap_features.paths);
|
||||
s.execute("view.snappingOptions", json!({"toggle": "paths"})).unwrap();
|
||||
assert!(s.state.snap_features.paths);
|
||||
assert!(s.execute("view.snappingOptions", json!({"toggle": "nope"})).is_err());
|
||||
assert_eq!(s.state.snap_features, crate::viewer::SnapFeatures { edges_extended: true, collapsed_features: true });
|
||||
let r = s.execute("view.snappingOptions", json!({"edgesExtended": false})).unwrap();
|
||||
assert_eq!(r, json!({"edgesExtended": false, "collapsedFeatures": true}));
|
||||
s.execute("view.snappingOptions", json!({"toggle": "collapsedFeatures"})).unwrap();
|
||||
assert!(!s.state.snap_features.collapsed_features);
|
||||
s.execute("view.snappingOptions", json!({"toggle": "collapsedFeatures"})).unwrap();
|
||||
assert!(s.state.snap_features.collapsed_features);
|
||||
// The per-feature toggles are gone, as in After Effects' menu.
|
||||
for k in ["nope", "corners", "anchorPoints", "paths"] {
|
||||
assert!(s.execute("view.snappingOptions", json!({"toggle": k})).is_err(), "{k}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- channels, exposure, snapshot
|
||||
|
||||
+98
-68
@@ -67,48 +67,34 @@ impl SnapTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools bar ▸ Snapping options: which layer features snap (the dragged layer's and the
|
||||
/// targets), and Snap Edges Extended (layer edges snap along their whole line, beyond the
|
||||
/// layer's bounds). Everything is on by default.
|
||||
/// Tools bar ▸ Snapping options, the two After Effects' Snapping menu has: Snap Edges Extended
|
||||
/// (layer edges snap along their whole line, beyond the layer's bounds) and Snap to Features in
|
||||
/// Collapsed Compositions and Text Layers (the layers inside a precomp layer with Collapse
|
||||
/// Transformations on are targets too). Both are on by default. Every layer feature snaps
|
||||
/// (corners, edge midpoints, centre, anchor point, mask and shape path points), as in After
|
||||
/// Effects.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SnapFeatures {
|
||||
pub edges_extended: bool,
|
||||
/// Edges and edge midpoints of layers, and the comp's edges.
|
||||
pub edges: bool,
|
||||
pub corners: bool,
|
||||
/// Layer centres and the comp's centre.
|
||||
pub centers: bool,
|
||||
pub anchor_points: bool,
|
||||
/// Mask and shape path points.
|
||||
pub paths: bool,
|
||||
pub collapsed_features: bool,
|
||||
}
|
||||
|
||||
impl Default for SnapFeatures {
|
||||
fn default() -> Self {
|
||||
SnapFeatures { edges_extended: true, edges: true, corners: true, centers: true, anchor_points: true, paths: true }
|
||||
SnapFeatures { edges_extended: true, collapsed_features: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl SnapFeatures {
|
||||
/// The options in the Tools bar's Snapping menu order: (parameter key, label).
|
||||
pub const OPTIONS: [(&'static str, &'static str); 6] = [
|
||||
("edgesExtended", "Snap Edges Extended"),
|
||||
("edges", "Edges"),
|
||||
("corners", "Corners"),
|
||||
("centers", "Centers"),
|
||||
("anchorPoints", "Anchor Points"),
|
||||
("paths", "Mask and Shape Path Points"),
|
||||
];
|
||||
pub const OPTIONS: [(&'static str, &'static str); 2] =
|
||||
[("edgesExtended", "Snap Edges Extended"), ("collapsedFeatures", "Snap to Features in Collapsed Compositions and Text Layers")];
|
||||
/// The option called `key` (an [`OPTIONS`](Self::OPTIONS) key).
|
||||
pub fn option_mut(&mut self, key: &str) -> Option<&mut bool> {
|
||||
Some(match key {
|
||||
"edgesExtended" => &mut self.edges_extended,
|
||||
"edges" => &mut self.edges,
|
||||
"corners" => &mut self.corners,
|
||||
"centers" => &mut self.centers,
|
||||
"anchorPoints" => &mut self.anchor_points,
|
||||
"paths" => &mut self.paths,
|
||||
"collapsedFeatures" => &mut self.collapsed_features,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -187,15 +173,13 @@ pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Sn
|
||||
Some(Snap { delta, hits, at })
|
||||
}
|
||||
|
||||
/// Snap features of a layer in comp space that `f` turns on: corners, edge midpoints, centre,
|
||||
/// anchor point and mask / shape path vertices (no box for layers without bounds).
|
||||
pub fn layer_features(ctx: &EvalCtx, layer: &Layer, f: SnapFeatures) -> Vec<[f64; 2]> {
|
||||
/// Snap features of a layer in comp space: corners, edge midpoints, centre, anchor point and
|
||||
/// mask / shape path vertices (no box for layers without bounds).
|
||||
pub fn layer_features(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
|
||||
let (m, _) = ctx.layer_to_comp(layer);
|
||||
let mut out = layer_box(ctx, layer, &m).map(|b| b.features(f)).unwrap_or_default();
|
||||
out.extend(anchor_feature(ctx, layer, &m).filter(|_| f.anchor_points));
|
||||
if f.paths {
|
||||
out.extend(layer_vertices(ctx, layer));
|
||||
}
|
||||
let mut out = layer_box(ctx, layer, &m).map(|b| b.points()).unwrap_or_default();
|
||||
out.extend(anchor_feature(ctx, layer, &m));
|
||||
out.extend(layer_vertices(ctx, layer));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -208,19 +192,9 @@ struct BoxPoints {
|
||||
}
|
||||
|
||||
impl BoxPoints {
|
||||
/// The points `f` turns on.
|
||||
fn features(&self, f: SnapFeatures) -> Vec<[f64; 2]> {
|
||||
let mut out = vec![];
|
||||
if f.corners {
|
||||
out.extend(self.corners);
|
||||
}
|
||||
if f.edges {
|
||||
out.extend(self.mids);
|
||||
}
|
||||
if f.centers {
|
||||
out.push(self.center);
|
||||
}
|
||||
out
|
||||
/// Corners, edge midpoints and centre.
|
||||
fn points(&self) -> Vec<[f64; 2]> {
|
||||
self.corners.iter().chain(&self.mids).chain([&self.center]).copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,8 +275,8 @@ pub struct SnapOptions {
|
||||
}
|
||||
|
||||
/// Snap targets of a comp at the context time: other visible layers' features and vertices and
|
||||
/// the comp's edges and centre (with `layers`, as `features` allows), and guides and grid lines
|
||||
/// (when on). Layers in `exclude` (the dragged ones) are skipped.
|
||||
/// the comp's edges and centre (with `layers`, as the Snapping options allow), and guides and
|
||||
/// grid lines (when on). Layers in `exclude` (the dragged ones) are skipped.
|
||||
pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
|
||||
let comp = ctx.comp;
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
@@ -315,17 +289,10 @@ pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<Sna
|
||||
out.extend(x.iter().map(|x| SnapTarget::vline(*x, PRI_COMP, SnapSource::Comp).within(ys)));
|
||||
out.extend(y.iter().map(|y| SnapTarget::hline(*y, PRI_COMP, SnapSource::Comp).within(xs)));
|
||||
};
|
||||
if f.edges {
|
||||
lines(&[0.0, w], &[0.0, h]);
|
||||
}
|
||||
if f.centers {
|
||||
lines(&[w / 2.0], &[h / 2.0]);
|
||||
out.push(SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp));
|
||||
}
|
||||
// Visible layers: eye on, and soloed while any layer is.
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(ctx.time));
|
||||
let visible = |l: &&Layer| l.is_active_at(ctx.time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id);
|
||||
for l in comp.layers.iter().filter(visible) {
|
||||
lines(&[0.0, w], &[0.0, h]);
|
||||
lines(&[w / 2.0], &[h / 2.0]);
|
||||
out.push(SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp));
|
||||
for l in visible_layers(ctx, exclude) {
|
||||
out.extend(layer_targets(ctx, l, true, f));
|
||||
}
|
||||
}
|
||||
@@ -354,11 +321,27 @@ pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<Sna
|
||||
out
|
||||
}
|
||||
|
||||
/// A 2D layer's snap targets as `f` allows: its box points, its edges as lines while unrotated
|
||||
/// (across the comp with Snap Edges Extended, along the layer otherwise), its anchor point
|
||||
/// unless `anchor` is false (Pan Behind drags it, the box stays put) and its mask and shape path
|
||||
/// vertices. None for cameras, lights and 3D layers.
|
||||
/// How deep Snap to Features in Collapsed Compositions looks into nested collapsed precomps.
|
||||
const MAX_COLLAPSED_DEPTH: usize = 8;
|
||||
|
||||
/// Visible layers of a comp at the context time: active, eye on, and soloed while any layer is.
|
||||
fn visible_layers<'a>(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterator<Item = &'a Layer> {
|
||||
let comp = ctx.comp;
|
||||
let time = ctx.time;
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time));
|
||||
comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id))
|
||||
}
|
||||
|
||||
/// A 2D layer's snap targets: its box points, its edges as lines while unrotated (across the
|
||||
/// comp with Snap Edges Extended, along the layer otherwise), its anchor point unless `anchor` is
|
||||
/// false (Pan Behind drags it, the box stays put), its mask and shape path vertices and, with
|
||||
/// Snap to Features in Collapsed Compositions, the targets of the visible layers inside a
|
||||
/// collapsed precomp layer. None for cameras, lights and 3D layers.
|
||||
pub fn layer_targets(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures) -> Vec<SnapTarget> {
|
||||
layer_targets_at(ctx, l, anchor, f, 0)
|
||||
}
|
||||
|
||||
fn layer_targets_at(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures, depth: usize) -> Vec<SnapTarget> {
|
||||
if l.is_camera() || l.is_light() || l.is_3d() {
|
||||
return vec![];
|
||||
}
|
||||
@@ -367,7 +350,7 @@ pub fn layer_targets(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures) ->
|
||||
let mut out = vec![];
|
||||
if let Some(b) = layer_box(ctx, l, &m) {
|
||||
let [nw, ne, _, sw] = b.corners;
|
||||
if f.edges && (nw[1] - ne[1]).abs() < 1e-6 && (nw[0] - sw[0]).abs() < 1e-6 {
|
||||
if (nw[1] - ne[1]).abs() < 1e-6 && (nw[0] - sw[0]).abs() < 1e-6 {
|
||||
let (xs, ys) = if f.edges_extended { (None, None) } else { (Some([nw[0], ne[0]]), Some([nw[1], sw[1]])) };
|
||||
for x in [nw[0], ne[0]] {
|
||||
out.push(SnapTarget::vline(x, PRI_LAYER, src).within(ys));
|
||||
@@ -376,11 +359,58 @@ pub fn layer_targets(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures) ->
|
||||
out.push(SnapTarget::hline(y, PRI_LAYER, src).within(xs));
|
||||
}
|
||||
}
|
||||
out.extend(b.features(f).into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, src)));
|
||||
out.extend(b.points().into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, src)));
|
||||
}
|
||||
out.extend(anchor_feature(ctx, l, &m).filter(|_| anchor && f.anchor_points).map(|p| SnapTarget::point(p, PRI_LAYER, src)));
|
||||
if f.paths {
|
||||
out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id))));
|
||||
out.extend(anchor_feature(ctx, l, &m).filter(|_| anchor).map(|p| SnapTarget::point(p, PRI_LAYER, src)));
|
||||
out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id))));
|
||||
if f.collapsed_features
|
||||
&& l.switches.collapse
|
||||
&& depth < MAX_COLLAPSED_DEPTH
|
||||
&& let effectcraft_project::LayerSource::Comp { item } = l.source
|
||||
&& let Some(nc) = ctx.project.comp(item)
|
||||
{
|
||||
out.extend(collapsed_targets(ctx, l, item, nc, &m, f, depth));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The targets of the layers inside collapsed precomp layer `l` (showing comp `nc`), in the
|
||||
/// outer comp (`m`: `l`'s layer → comp). They highlight `l` when they snap. Lines stay lines
|
||||
/// only while `l` is unrotated; otherwise only the points snap.
|
||||
fn collapsed_targets(
|
||||
ctx: &EvalCtx,
|
||||
l: &Layer,
|
||||
item: effectcraft_project::ItemId,
|
||||
nc: &effectcraft_project::Comp,
|
||||
m: &Mat3,
|
||||
f: SnapFeatures,
|
||||
depth: usize,
|
||||
) -> Vec<SnapTarget> {
|
||||
let nctx = EvalCtx { comp_id: item, comp: nc, time: ctx.nested_time(l), ..*ctx };
|
||||
let axis_aligned = m.0[0][1].abs() < 1e-9 && m.0[1][0].abs() < 1e-9;
|
||||
let (sx, ox, sy, oy) = (m.0[0][0], m.0[0][2], m.0[1][1], m.0[1][2]);
|
||||
let mut out = vec![];
|
||||
for nl in visible_layers(&nctx, &[]) {
|
||||
for t in layer_targets_at(&nctx, nl, true, f, depth + 1) {
|
||||
let source = match t.source {
|
||||
SnapSource::Vertex(_) => SnapSource::Vertex(l.id),
|
||||
_ => SnapSource::Layer(l.id),
|
||||
};
|
||||
let mapped = match t.kind {
|
||||
SnapKind::Point => {
|
||||
let q = m.apply(vec2(t.pos[0], t.pos[1]));
|
||||
Some(SnapTarget::point([q.x, q.y], t.priority, source))
|
||||
}
|
||||
SnapKind::VLine if axis_aligned => {
|
||||
Some(SnapTarget::vline(sx * t.pos[0] + ox, t.priority, source).within(t.span.map(|[a, b]| [sy * a + oy, sy * b + oy])))
|
||||
}
|
||||
SnapKind::HLine if axis_aligned => {
|
||||
Some(SnapTarget::hline(sy * t.pos[1] + oy, t.priority, source).within(t.span.map(|[a, b]| [sx * a + ox, sx * b + ox])))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
out.extend(mapped);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -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<EaseCurve> {
|
||||
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<EaseCurve> {
|
||||
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<Ease> { 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<EaseCurve> {
|
||||
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<Vec<f64>> {
|
||||
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<Ease> { (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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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,10 @@ 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;
|
||||
#[cfg(test)]
|
||||
mod tests_ui_more;
|
||||
+35
-13
@@ -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<J> {
|
||||
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::<Vec<_>>(),
|
||||
"selectedLayers": sel,
|
||||
"selectedProperties": s.state.selected_props.iter().filter(|(l, _)| c.layer(*l).is_some()).map(|(l, p)| json!([l.0, p])).collect::<Vec<_>>(),
|
||||
"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<Tick> = 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<J> = 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<J> = (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<J> = 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),
|
||||
})
|
||||
|
||||
@@ -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; },
|
||||
|
||||
@@ -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<JsValue> {
|
||||
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<J, String> {
|
||||
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<J, String> {
|
||||
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<JsValue> {
|
||||
let kind = arg_string(args, 0, ctx)?;
|
||||
|
||||
@@ -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<MemoryConfig>) -> 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<Value> {
|
||||
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<String> =
|
||||
["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);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! ScriptUI Panels, as in After Effects: scripts in the user's `Scripts/ScriptUI Panels` folder
|
||||
//! (in the settings folder) are listed at the bottom of the Window menu, open as dockable panels
|
||||
//! with `this` = the panel, and the panels open when the app quits open again at the next launch.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::commands::scripts::{OPEN_PANELS_FILE, PANELS_DIR};
|
||||
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
|
||||
use effectcraft_engine::scriptui::WindowKind;
|
||||
use serde_json::json;
|
||||
|
||||
const PANEL: &str = "var p = this;\np.add('statictext', undefined, 'Hello from a panel');\np.layout.layout(true);\n";
|
||||
|
||||
fn session(store: &Arc<MemoryConfig>) -> Session {
|
||||
let mut s = Session::default();
|
||||
crate::install(&mut s);
|
||||
s.config = Some(store.clone());
|
||||
s
|
||||
}
|
||||
|
||||
fn panel_window(s: &Session, name: &str) -> Option<u32> {
|
||||
s.script_ui.windows.iter().find(|w| w.kind == WindowKind::Panel && w.script == name).map(|w| w.id)
|
||||
}
|
||||
|
||||
fn remembered(store: &MemoryConfig) -> serde_json::Value {
|
||||
serde_json::from_str(&store.read(OPEN_PANELS_FILE).unwrap_or_default()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panel_dropped_in_the_folder_is_listed_and_opens_as_a_panel() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
store.write(&format!("{PANELS_DIR}/Hello Panel.jsx"), PANEL).unwrap();
|
||||
// Not a script: not listed.
|
||||
store.write(&format!("{PANELS_DIR}/notes.txt"), "x").unwrap();
|
||||
let mut s = session(&store);
|
||||
let cx = effectcraft_engine::menus::DynCtx::default();
|
||||
let (entries, _) = effectcraft_engine::menus::dynamic(&s, "scriptPanels", &cx);
|
||||
let names: Vec<&str> = entries.iter().map(|e| e.label.as_str()).collect();
|
||||
assert!(names.contains(&"Hello Panel.jsx") && !names.contains(&"notes.txt"), "{names:?}");
|
||||
let e = entries.iter().find(|e| e.label == "Hello Panel.jsx").unwrap();
|
||||
assert_eq!((e.command.as_str(), &e.params), ("window.scriptPanel", &json!({"name": "Hello Panel.jsx"})));
|
||||
let r = s.execute("window.scriptPanel", json!({"name": "Hello Panel.jsx"})).unwrap();
|
||||
let id = r["window"].as_u64().unwrap() as u32;
|
||||
let w = s.script_ui.window(id).unwrap();
|
||||
assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Hello Panel"));
|
||||
assert_eq!(w.root.children[0].text, "Hello from a panel");
|
||||
// Frontends dock it (`window.scriptPanel {window}` for the UI).
|
||||
let events = s.drain_events();
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|e| matches!(e, effectcraft_engine::Event::Frontend { command, params } if command == "window.scriptPanel" && params["window"] == id))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_panels_open_again_at_the_next_launch() {
|
||||
let store = Arc::new(MemoryConfig::default());
|
||||
store.write(&format!("{PANELS_DIR}/Hello Panel.jsx"), PANEL).unwrap();
|
||||
let mut s = session(&store);
|
||||
s.execute("window.scriptPanel", json!({"name": "Hello Panel.jsx"})).unwrap();
|
||||
s.execute("window.scriptPanel", json!({"name": "Layer Tools.jsx"})).unwrap();
|
||||
assert_eq!(remembered(&store), json!({"open": ["Hello Panel.jsx", "Layer Tools.jsx"]}));
|
||||
// Quit with both open; the next launch opens them again.
|
||||
drop(s);
|
||||
let mut s = session(&store);
|
||||
let r = s.execute("window.restoreScriptPanels", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"opened": ["Hello Panel.jsx", "Layer Tools.jsx"], "failed": []}));
|
||||
assert!(panel_window(&s, "Hello Panel.jsx").is_some() && panel_window(&s, "Layer Tools.jsx").is_some());
|
||||
// Closing a panel (its tab) forgets it.
|
||||
let id = panel_window(&s, "Layer Tools.jsx").unwrap();
|
||||
s.execute("scriptui.close", json!({"window": id})).unwrap();
|
||||
assert_eq!(remembered(&store), json!({"open": ["Hello Panel.jsx"]}));
|
||||
// A panel whose script was removed, or that fails, is reported and forgotten; the others open.
|
||||
store.write(OPEN_PANELS_FILE, &json!({"open": ["Gone.jsx", "Broken.jsx", "Hello Panel.jsx"]}).to_string()).unwrap();
|
||||
store.write(&format!("{PANELS_DIR}/Broken.jsx"), "throw new Error('nope');").unwrap();
|
||||
let mut s = session(&store);
|
||||
let r = s.execute("window.restoreScriptPanels", json!({})).unwrap();
|
||||
assert_eq!(r["opened"], json!(["Hello Panel.jsx"]));
|
||||
let failed: Vec<&str> = r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(failed, ["Gone.jsx", "Broken.jsx"]);
|
||||
assert_eq!(remembered(&store), json!({"open": ["Hello Panel.jsx"]}));
|
||||
// A corrupt list opens nothing (and is no error).
|
||||
store.write(OPEN_PANELS_FILE, "{not json").unwrap();
|
||||
let mut s = session(&store);
|
||||
assert_eq!(s.execute("window.restoreScriptPanels", json!({})).unwrap(), json!({"opened": [], "failed": []}));
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -251,8 +251,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Snapping options menu beside the checkbox: Snap Edges Extended and which layer features
|
||||
/// snap (`view.snappingOptions`).
|
||||
/// The Snapping options menu beside the checkbox: After Effects' Snap Edges Extended and Snap to
|
||||
/// Features in Collapsed Compositions and Text Layers (`view.snappingOptions`).
|
||||
fn snapping_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
let t = app.tokens;
|
||||
let pop = egui::Id::new("snap-options");
|
||||
@@ -263,19 +263,9 @@ fn snapping_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
if !widgets::popup_is_open(ui, pop) {
|
||||
return;
|
||||
}
|
||||
// Snap Edges Extended, then the features ("" keys the separator).
|
||||
let f = app.session.state.snap_features;
|
||||
let mut keys = vec![];
|
||||
let mut items = vec![];
|
||||
for (i, (k, label)) in vw::SnapFeatures::OPTIONS.into_iter().enumerate() {
|
||||
if i == 1 {
|
||||
keys.push("");
|
||||
items.push("-".to_string());
|
||||
}
|
||||
keys.push(k);
|
||||
items.push(widgets::check_label(f.option(k), label));
|
||||
}
|
||||
if let Some(k) = widgets::popup_menu(ui, pop, r.left_bottom(), &items, None).and_then(|i| keys.get(i))
|
||||
let items: Vec<String> = vw::SnapFeatures::OPTIONS.iter().map(|(k, label)| widgets::check_label(f.option(k), label)).collect();
|
||||
if let Some((k, _)) = widgets::popup_menu(ui, pop, r.left_bottom(), &items, None).and_then(|i| vw::SnapFeatures::OPTIONS.get(i))
|
||||
&& let Err(e) = app.session.execute("view.snappingOptions", json!({"toggle": k}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
|
||||
@@ -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ヘルプ..."),
|
||||
|
||||
@@ -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.<n>` (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<String>,
|
||||
/// 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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1248,8 +1248,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
})
|
||||
.collect();
|
||||
// Snap the grabbed layer's feature nearest the pointer (AE's snap handle).
|
||||
let features = app.session.state.snap_features;
|
||||
let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer, features)).and_then(|f| {
|
||||
let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer)).and_then(|f| {
|
||||
let c = map.to_comp(press);
|
||||
f.into_iter().min_by(|a, b| {
|
||||
let da = (a[0] - c[0]).powi(2) + (a[1] - c[1]).powi(2);
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -90,6 +90,64 @@ fn script_windows_are_drawn_and_clickable() {
|
||||
h.state_mut().session.execute("scriptui.close", json!({})).unwrap();
|
||||
}
|
||||
|
||||
/// A ScriptUI panel open when the app quit opens again, docked, at the next launch (the
|
||||
/// frontends call `window.restoreScriptPanels` before building the app).
|
||||
#[test]
|
||||
fn panels_open_at_quit_dock_again_at_launch() {
|
||||
use effectcraft_engine::config::{ConfigStore, MemoryConfig};
|
||||
let store = std::sync::Arc::new(MemoryConfig::default());
|
||||
store.write("Scripts/ScriptUI Panels/Hello Panel.jsx", "this.add('button', undefined, 'Hi', { name: 'hi' });").unwrap();
|
||||
store.write(effectcraft_engine::commands::scripts::OPEN_PANELS_FILE, r#"{"open": ["Hello Panel.jsx"]}"#).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
s.config = Some(store.clone());
|
||||
s.execute("window.restoreScriptPanels", json!({})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
let panel = h.state().session.script_ui.windows.iter().find(|w| w.script == "Hello Panel.jsx").expect("the panel's window").id;
|
||||
assert!(h.state().ui.dock.contains(PanelKind::ScriptPanel(panel)), "the panel docks");
|
||||
assert!(h.state().auto.find(&format!("scriptui.{panel}.hi")).is_some(), "its controls are drawn");
|
||||
// Closing its tab forgets it for the next launch.
|
||||
h.state_mut().close_panel(PanelKind::ScriptPanel(panel));
|
||||
h.run_steps(2);
|
||||
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();
|
||||
|
||||
@@ -295,6 +295,11 @@ fn layer_drag_snaps_to_other_layers_edges_ctrl_toggles_and_options_apply() {
|
||||
// Snapping options ▸ Snap Edges Extended off: below the target its edge no longer snaps.
|
||||
let menu = rect(&h, "header.snappingOptions").center();
|
||||
click(&mut h, menu);
|
||||
// After Effects' two options, without per-feature toggles.
|
||||
assert!(h.query_by_label("✓ Snap to Features in Collapsed Compositions and Text Layers").is_some());
|
||||
for gone in ["✓ Corners", "✓ Anchor Points", "✓ Mask and Shape Path Points"] {
|
||||
assert!(h.query_by_label(gone).is_none(), "{gone}");
|
||||
}
|
||||
let item = h.query_by_label("✓ Snap Edges Extended").expect("the Snapping options menu").rect().center();
|
||||
click(&mut h, item);
|
||||
assert!(!h.state().session.state.snap_features.edges_extended);
|
||||
|
||||
+4
-1
@@ -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
|
||||
|
||||
|
||||
@@ -110,8 +110,9 @@ revolutions are `timeline.prop.<uid>.revolutions`, `effectControls.prop.<uid>.re
|
||||
`timeline.graph.snap` and `timeline.graph.reference`. Use `ui.elements` to see what is on screen.
|
||||
|
||||
Viewer state that agents drive headless too: `view.snapping`, `view.snappingOptions
|
||||
{edgesExtended?, edges?, corners?, centers?, anchorPoints?, paths?, toggle?}` (the Tools bar's
|
||||
Snapping options, `header.snappingOptions`), `view.channel {channel, colorized?}`,
|
||||
{edgesExtended?, collapsedFeatures?, toggle?}` (the Tools bar's Snapping options,
|
||||
`header.snappingOptions`: After Effects' Snap Edges Extended and Snap to Features in Collapsed
|
||||
Compositions and Text Layers), `view.channel {channel, colorized?}`,
|
||||
`view.exposure {stops | delta}`, `view.resetExposure`, `view.takeSnapshot`, `view.showSnapshot`,
|
||||
`view.fastPreviewMode {mode}`, `view.setRegionOfInterest {rect}`, `view.addGuide`,
|
||||
`view.moveGuide`, `view.removeGuide`; editing: `shape.newPath` (Pen on shape layers),
|
||||
|
||||
+12
-3
@@ -194,9 +194,18 @@ The most important missing piece: it turns every other estimate here into a meas
|
||||
- After Effects SDK plug-ins cannot run in EffectCraft. Our own WebAssembly plug-in API exists
|
||||
([plugins.md](plugins.md)). Grow it: documentation, examples, and original effects that cover
|
||||
what the most common third-party plug-ins are used for (particles, glows, 3D objects, sabers).
|
||||
- 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)).
|
||||
- Features that come from third parties in After Effects are extensions in EffectCraft too, built
|
||||
on the same extension points as After Effects: scripts, ScriptUI panels and effect plug-ins
|
||||
([plugins.md](plugins.md#extension-points-compared-with-after-effects) compares them). ScriptUI
|
||||
panels in the user's ScriptUI Panels folder are listed in the Window menu, dock like any panel
|
||||
and open again at the next launch. Missing: the Startup and Shutdown script folders, keyboard
|
||||
shortcuts for scripts, ExtendScript's `#include` / `#target` directives and `.jsxbin`.
|
||||
- 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
|
||||
|
||||
|
||||
+32
-19
@@ -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
|
||||
|
||||
|
||||
+132
-7
@@ -1,4 +1,129 @@
|
||||
# Effect plug-ins
|
||||
# Plug-ins and extensions
|
||||
|
||||
EffectCraft's core aims at After Effects parity. What After Effects users get from third parties
|
||||
(effect plug-ins, scripts, ScriptUI panels) is an extension in EffectCraft too, and EffectCraft
|
||||
offers the same kinds of extension points as After Effects so that anyone can write their own.
|
||||
|
||||
## Extension points compared with After Effects
|
||||
|
||||
From After Effects' public documentation (the user guide's scripting pages and the Scripting
|
||||
Guide):
|
||||
|
||||
| After Effects | EffectCraft | |
|
||||
|---|---|---|
|
||||
| Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (plug-in API v1) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) |
|
||||
| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same |
|
||||
| File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) |
|
||||
| ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | Not understood: a script that starts with one doesn't parse | Gap |
|
||||
| `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap |
|
||||
| `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 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`
|
||||
on Windows and `$XDG_CONFIG_HOME/effectcraft` (or `~/.config/effectcraft`) on Linux
|
||||
([preferences.md](preferences.md)); the web app keeps it in browser storage.
|
||||
|
||||
## Scripts
|
||||
|
||||
Scripts use After Effects' scripting object model (`app`, `app.project`, `CompItem`, layers,
|
||||
`Property` with keyframes and eases, `app.beginUndoGroup`…), so most scripts written for After
|
||||
Effects' documented API run unchanged. Every edit a script makes is an engine command: undoable,
|
||||
journaled and the same as the UI's action. The `effectcraft-script` crate's docs
|
||||
(`crates/script/src/lib.rs`) list the object model.
|
||||
|
||||
* Run one: File ▸ Scripts ▸ Run Script File…, the Script Console panel, `effectcraft-cli script
|
||||
file.jsx`, or `script.run {code, name}` over the control channel and MCP.
|
||||
* Install one: File ▸ Scripts ▸ Install Script File… (or copy it into `Scripts/` in the settings
|
||||
folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart).
|
||||
* `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs
|
||||
one; `file.uninstallScript {name}` removes an installed one.
|
||||
|
||||
## ScriptUI panels
|
||||
|
||||
A ScriptUI panel is a script that builds its user interface into the panel it is given as `this`.
|
||||
Put it in `Scripts/ScriptUI Panels/` in the settings folder (or use File ▸ Scripts ▸ Install
|
||||
ScriptUI Panel…) and it is listed at the bottom of the Window menu under its file name; choosing it
|
||||
runs the script and docks its panel as a tab, which you can move, float, maximize and close like any
|
||||
other panel. Closing the tab closes the panel (its `onClose` runs). Panels open when EffectCraft
|
||||
quits open again at the next launch (`window.restoreScriptPanels`, which the desktop and web apps
|
||||
run at start-up; the list is `scriptui_panels.json` in the settings folder).
|
||||
|
||||
Run from File ▸ Scripts instead, the same script gets no panel, so it should make a floating
|
||||
palette itself. A minimal panel:
|
||||
|
||||
```js
|
||||
// Nudge.jsx: moves the selected layers 10 px right, as a dockable panel.
|
||||
(function (thisObj) {
|
||||
var ui = thisObj instanceof Panel ? thisObj : new Window("palette", "Nudge", undefined, { resizeable: true });
|
||||
ui.orientation = "column";
|
||||
ui.alignChildren = ["fill", "top"];
|
||||
var go = ui.add("button", undefined, "Nudge Right", { name: "nudge" });
|
||||
var status = ui.add("statictext", undefined, "Select layers, then click.");
|
||||
go.onClick = function () {
|
||||
var comp = app.project.activeItem;
|
||||
if (!(comp instanceof CompItem)) return;
|
||||
var layers = comp.selectedLayers;
|
||||
app.beginUndoGroup("Nudge Right");
|
||||
for (var i = 0; i < layers.length; i++) {
|
||||
var p = layers[i].transform.position;
|
||||
var v = p.value;
|
||||
if (p.numKeys === 0) p.setValue([v[0] + 10, v[1]]);
|
||||
}
|
||||
app.endUndoGroup();
|
||||
status.text = "Moved " + layers.length + " layer(s).";
|
||||
};
|
||||
if (ui instanceof Window) ui.show(); else ui.layout.layout(true);
|
||||
})(this);
|
||||
```
|
||||
|
||||
Supported controls: `panel`, `group`, `button`, `iconbutton`, `statictext`, `edittext`,
|
||||
`checkbox`, `radiobutton`, `slider`, `scrollbar`, `progressbar`, `dropdownlist`, `listbox`,
|
||||
`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. 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.<window>.<control id or name>`. The bundled sample `Layer Tools.jsx`
|
||||
(`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
|
||||
|
||||
EffectCraft loads third-party effects through a small, versioned plug-in API (**API version 1**).
|
||||
A plug-in effect behaves exactly like a built-in one: it is listed in Effects & Presets (and the
|
||||
@@ -18,7 +143,7 @@ There are two ways to write one:
|
||||
`effect.plugins.list` lists what is loaded: `{api, wasm, plugins: [{id, name, category, version,
|
||||
author, source, params}]}`.
|
||||
|
||||
## The contract
|
||||
### The contract
|
||||
|
||||
* **Pixels** are premultiplied RGBA `f32` (0–1; values may exceed 1 in 32 bpc projects), row
|
||||
major, processed **in place**. The buffer is the layer's (after masks and the effects above),
|
||||
@@ -31,7 +156,7 @@ author, source, params}]}`.
|
||||
plug-in gets one instance per thread).
|
||||
* A failed render (an error code, a trap, running out of fuel) leaves the frame unchanged.
|
||||
|
||||
## The manifest
|
||||
### The manifest
|
||||
|
||||
Every plug-in describes itself with a JSON manifest:
|
||||
|
||||
@@ -64,7 +189,7 @@ Every plug-in describes itself with a JSON manifest:
|
||||
parameters; a slider needs `min ≤ default ≤ max` (and `sliderMin ≤ sliderMax`); the manifest
|
||||
is at most 1 MiB.
|
||||
|
||||
## WebAssembly ABI (v1)
|
||||
### WebAssembly ABI (v1)
|
||||
|
||||
A module with **no imports** that exports:
|
||||
|
||||
@@ -81,7 +206,7 @@ little-endian `f64`s) followed by the pixels (`width × height × 4` little-endi
|
||||
calls `ec_render`, and reads the pixels back. Each call gets a fuel budget proportional to the
|
||||
frame size; a module that loops forever stops with an error instead of hanging the app.
|
||||
|
||||
### Example
|
||||
#### Example
|
||||
|
||||
[`examples/plugins/posterize-bands`](../examples/plugins/posterize-bands) is a complete plug-in in
|
||||
plain Rust (no dependencies):
|
||||
@@ -97,7 +222,7 @@ effectcraft-cli exec effect.plugins.load '{"path": "target/wasm32-unknown-unknow
|
||||
Any language that compiles to WebAssembly works (C, Zig, AssemblyScript…) as long as the module
|
||||
has no imports. `crates/plugin/src/tests.rs` has a plug-in written directly in WebAssembly text.
|
||||
|
||||
## Rust plug-ins
|
||||
### Rust plug-ins
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
@@ -121,7 +246,7 @@ register_plugin(Arc::new(Swap(manifest)))?; // before the UI builds its menus
|
||||
`PluginParams` reads values by parameter id (`f`, `b`, `point`, `color`). At most 64 plug-in
|
||||
effects can be registered per process.
|
||||
|
||||
## Compatibility
|
||||
### Compatibility
|
||||
|
||||
The API version is checked when a plug-in loads: a manifest or module for another version is
|
||||
refused with a message. Within a version the contract above does not change; additions (new
|
||||
|
||||
@@ -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);
|
||||
@@ -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).
|
||||
Reference in new issue
Block a user