Merge main into #2279; keep main's Scripts subfolders (RESERVED_FOLDERS) next to the Startup/Shutdown constants, merge docs rows

This commit is contained in:
bflatastic
2026-10-30 05:34:26 +07:00
564 changed files with 34296 additions and 7590 deletions
+73
View File
@@ -90,6 +90,44 @@ pub fn stroke_dash(name: &str) -> Option<&'static str> {
const TEXT_ANIMATOR: &[(&str, &str)] = &[("selectors", "ADBE Text Selectors"), ("properties", "ADBE Text Animator Properties")];
/// Animator property ids and their stable After Effects scripting names. Shared by lookup,
/// `.matchName`, and `addProperty` so the same property has one name in every direction.
pub const TEXT_ANIMATOR_PROPERTIES: &[(&str, &str)] = &[
("anchor", "ADBE Text Anchor Point 3D"),
("position", "ADBE Text Position 3D"),
("scale", "ADBE Text Scale 3D"),
("skew", "ADBE Text Skew"),
("rotation", "ADBE Text Rotation"),
("opacity", "ADBE Text Opacity"),
("fillColor", "ADBE Text Fill Color"),
("fillHue", "ADBE Text Fill Hue"),
("fillSaturation", "ADBE Text Fill Saturation"),
("fillBrightness", "ADBE Text Fill Brightness"),
("fillOpacity", "ADBE Text Fill Opacity"),
("strokeColor", "ADBE Text Stroke Color"),
("strokeHue", "ADBE Text Stroke Hue"),
("strokeSaturation", "ADBE Text Stroke Saturation"),
("strokeBrightness", "ADBE Text Stroke Brightness"),
("strokeOpacity", "ADBE Text Stroke Opacity"),
("strokeWidth", "ADBE Text Stroke Width"),
("tracking", "ADBE Text Tracking Amount"),
("lineAnchor", "ADBE Text Line Anchor"),
("lineSpacing", "ADBE Text Line Spacing"),
("characterOffset", "ADBE Text Character Offset"),
("characterValue", "ADBE Text Character Replace"),
("blur", "ADBE Text Blur"),
];
/// Companion properties that exist below an animator but are not kinds `addProperty` accepts:
/// they come with Tracking (Track Type), Skew (Skew Axis) and per-character 3D Rotation
/// (X and Y Rotation). Lookup and `.matchName` know them; `addProperty` does not.
pub const TEXT_ANIMATOR_COMPANIONS: &[(&str, &str)] = &[
("trackingType", "ADBE Text Track Type"),
("skewAxis", "ADBE Text Skew Axis"),
("rotationX", "ADBE Text Rotation X"),
("rotationY", "ADBE Text Rotation Y"),
];
const TEXT_SELECTORS: &[(&str, &str)] =
&[("rangeSelector", "ADBE Text Selector"), ("wigglySelector", "ADBE Text Wiggly Selector"), ("expressionSelector", "ADBE Text Expressible Selector")];
@@ -209,6 +247,14 @@ const SHAPE_PROPS: &[(&str, &str, &str)] = &[
("twist", "angle", "ADBE Vector Twist Angle"),
("twist", "center", "ADBE Vector Twist Center"),
("merge", "mode", "ADBE Vector Merge Type"),
("wiggle", "size", "ADBE Vector Roughen Size"),
("wiggle", "detail", "ADBE Vector Roughen Detail"),
("wiggle", "points", "ADBE Vector Roughen Points"),
("wiggle", "speed", "ADBE Vector Temporal Freq"),
("wiggle", "correlation", "ADBE Vector Correlation"),
("wiggle", "phase", "ADBE Vector Temporal Phase"),
("wiggle", "spatialPhase", "ADBE Vector Spatial Phase"),
("wiggle", "seed", "ADBE Vector Random Seed"),
];
/// Effects: our effect id → AE match name (the documented ones; the rest keep our id).
@@ -479,6 +525,9 @@ pub fn node(chain: &[&Node]) -> String {
"text" if pid == "animators" => "ADBE Text Animator".into(),
"text" if pid == "selectors" => find(TEXT_SELECTORS, id).map(str::to_string).unwrap_or_else(own),
"text" if pid == "rangeSelector" => find(TEXT_RANGE_SELECTOR, id).map(str::to_string).unwrap_or_else(own),
"text" if pid == "properties" && chain.len() == 5 && chain.get(1).is_some_and(|n| n.match_id() == "animators") => {
find(TEXT_ANIMATOR_PROPERTIES, id).or_else(|| find(TEXT_ANIMATOR_COMPANIONS, id)).map(str::to_string).unwrap_or_else(own)
}
"text" if grand.map(|g| g.match_id()) == Some("animators") => find(TEXT_ANIMATOR, id).map(str::to_string).unwrap_or_else(own),
"cameraOptions" => find(CAMERA, id).map(str::to_string).unwrap_or_else(own),
"lightOptions" => find(LIGHT, id).map(str::to_string).unwrap_or_else(own),
@@ -529,6 +578,30 @@ mod tests {
assert_eq!(shape_kind("nope"), None);
}
#[test]
fn text_animator_property_names_are_canonical_and_scoped() {
let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m));
let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties"));
for (id, name) in TEXT_ANIMATOR_PROPERTIES {
let prop = n(id);
assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name);
assert_eq!(node(&[&properties, &prop]), *id, "an unrelated Properties group keeps its own ids");
}
}
/// #1063: the companions an animator property brings (Track Type, Skew Axis, X / Y Rotation)
/// have their After Effects names too, though `addProperty` does not accept them.
#[test]
fn text_animator_companions_have_after_effects_names() {
let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m));
let (text, animators, animator, properties) = (n("text"), n("animators"), n("animator"), n("properties"));
for (id, name) in TEXT_ANIMATOR_COMPANIONS {
let prop = n(id);
assert_eq!(node(&[&text, &animators, &animator, &properties, &prop]), *name);
assert!(!TEXT_ANIMATOR_PROPERTIES.iter().any(|(k, m)| k == id || m == name), "{id} is not an addProperty kind");
}
}
#[test]
fn text_range_selector_names() {
let n = |m: &str| Node::from(effectcraft_project::PropGroup::new(1, m, m));
+3 -27
View File
@@ -743,36 +743,12 @@ fn resolve_add(s: &Session, a: &J) -> R {
/// A text animator property (`layer.addTextAnimatorProperty` key) from an After Effects match
/// name or a display name.
fn text_animator_property(name: &str) -> Option<&'static str> {
const AE: &[(&str, &str)] = &[
("ADBE Text Anchor Point 3D", "anchor"),
("ADBE Text Position 3D", "position"),
("ADBE Text Scale 3D", "scale"),
("ADBE Text Skew", "skew"),
("ADBE Text Rotation", "rotation"),
("ADBE Text Opacity", "opacity"),
("ADBE Text Fill Color", "fillColor"),
("ADBE Text Fill Hue", "fillHue"),
("ADBE Text Fill Saturation", "fillSaturation"),
("ADBE Text Fill Brightness", "fillBrightness"),
("ADBE Text Fill Opacity", "fillOpacity"),
("ADBE Text Stroke Color", "strokeColor"),
("ADBE Text Stroke Hue", "strokeHue"),
("ADBE Text Stroke Saturation", "strokeSaturation"),
("ADBE Text Stroke Brightness", "strokeBrightness"),
("ADBE Text Stroke Opacity", "strokeOpacity"),
("ADBE Text Stroke Width", "strokeWidth"),
("ADBE Text Tracking Amount", "tracking"),
("ADBE Text Line Anchor", "lineAnchor"),
("ADBE Text Line Spacing", "lineSpacing"),
("ADBE Text Character Offset", "characterOffset"),
("ADBE Text Character Replace", "characterValue"),
("ADBE Text Blur", "blur"),
];
if let Some((_, k)) = AE.iter().find(|(m, _)| m.eq_ignore_ascii_case(name)) {
let table = matchnames::TEXT_ANIMATOR_PROPERTIES;
if let Some((k, _)) = table.iter().find(|(_, m)| m.eq_ignore_ascii_case(name)) {
return Some(k);
}
let n = loose(name);
AE.iter().map(|(_, k)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking"))
table.iter().map(|(k, _)| *k).find(|k| loose(k) == n || (n == "anchorpoint" && *k == "anchor") || (n == "trackingamount" && *k == "tracking"))
}
/// `app.findMenuCommandId(name)`: 1-based index of the command whose label matches (menu items
+10 -6
View File
@@ -189,7 +189,12 @@ BridgeTalk.pump = function () { return false; };
// ------------------------------------------------------------------ File / Folder
function __join(a, b) { return a.replace(/[\/\\]+$/, "") + "/" + b; }
function __trimPathSeparators(path) {
var end = path.length;
while (end > 0 && (path.charAt(end - 1) === "/" || path.charAt(end - 1) === "\\")) end--;
return path.slice(0, end);
}
function __join(a, b) { return __trimPathSeparators(a) + "/" + b; }
function File(path) {
if (!(this instanceof File)) return new File(path);
@@ -292,9 +297,9 @@ Folder.prototype = {
get fsName() { return this.__path; },
get fullName() { return this.__path; },
get absoluteURI() { return this.__path; },
get name() { return this.__path.replace(/[\/\\]+$/, "").split(/[\/\\]/).pop(); },
get name() { return __trimPathSeparators(this.__path).split(/[\/\\]/).pop(); },
get displayName() { return this.name; },
get path() { var p = this.__path.replace(/[\/\\]+$/, "").split(/[\/\\]/); p.pop(); return p.join("/"); },
get path() { var p = __trimPathSeparators(this.__path).split(/[\/\\]/); p.pop(); return p.join("/"); },
get parent() { return new Folder(this.path); },
get exists() { return JSON.parse(__file("isDir", this.__path)) === true; },
toString: function () { return this.__path; },
@@ -729,9 +734,8 @@ PropertyGroup.prototype.addProperty = function (name) {
} else if (r.kind === "animator") {
uid = __call("layer.addTextAnimator", { comp: L.__comp, layer: L.__id, properties: [] }).animator;
} else if (r.kind === "animatorProperty") {
var before = this.numProperties;
__call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property });
return this.property(before + 1);
var added = __call("layer.addTextAnimatorProperty", { comp: L.__comp, layer: L.__id, animator: r.animator, property: r.property });
return this.property(added.index);
} else if (r.kind === "selector") {
var n0 = this.numProperties;
__call("layer.addTextSelector", { comp: L.__comp, layer: L.__id, animator: r.animator, kind: r.selector });
+17 -3
View File
@@ -55,7 +55,7 @@ mod imp {
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use super::*;
use super::{J, err, json};
enum Sock {
Stream { s: TcpStream, buf: Vec<u8>, eof: bool },
@@ -394,19 +394,33 @@ mod tests {
/// Fill bounded socket buffers nonblocking, rather than assuming a 64 MiB write stalls.
/// In particular, Windows may accept that entire write with its default send buffering.
/// Writes must stay blocked for [`SETTLED`] before the buffers count as full: on macOS the
/// accepted stream does not keep the listener's 4 KiB receive buffer (it gets about 114 KiB),
/// so the first `WouldBlock` only means the 4 KiB send buffer is full, and the send buffer then
/// drains into the peer's larger receive buffer.
fn backpressure(id: u32) {
const SETTLED: Duration = Duration::from_millis(200);
let mut stream = super::imp::test_stream(id);
socket2::SockRef::from(&stream).set_send_buffer_size(4096).unwrap();
stream.set_nonblocking(true).unwrap();
let start = Instant::now();
let chunk = [b'x'; 4096];
let mut sent = 0;
let mut blocked_since = None;
loop {
assert!(sent < 16 * 1024 * 1024 && start.elapsed() < Duration::from_secs(5), "could not establish backpressure");
match stream.write(&chunk) {
Ok(0) => panic!("peer closed while establishing backpressure"),
Ok(n) => sent += n,
Err(e) if e.kind() == ErrorKind::WouldBlock => break,
Ok(n) => {
sent += n;
blocked_since = None;
}
Err(e) if e.kind() == ErrorKind::WouldBlock => {
if blocked_since.get_or_insert_with(Instant::now).elapsed() >= SETTLED {
break;
}
std::thread::sleep(Duration::from_millis(5));
}
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
Err(e) => panic!("establishing backpressure: {e}"),
}
+114
View File
@@ -271,6 +271,44 @@ fn an_undefined_name_mentioned_earlier_reports_its_use_in_the_reading_function()
assert_eq!(e.line, Some(6), "{e:?}");
}
/// Scripts reach Wiggle Paths' properties, Spatial Phase included, by their match names.
#[test]
fn wiggle_paths_properties_by_match_name() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("W", 200, 200, 1, 2, 30);
var sh = c.layers.addShape();
var w = sh.property("ADBE Root Vectors Group").addProperty("ADBE Vector Filter - Roughen");
w.property("ADBE Vector Spatial Phase").setValue(45);
var names = [];
for (var i = 1; i <= w.numProperties; i++) names.push(w.property(i).matchName);
[w.matchName, w.property("ADBE Vector Spatial Phase").name, names]
"#,
);
assert_eq!(
o.result,
json!([
"ADBE Vector Filter - Roughen",
"Spatial Phase",
[
"ADBE Vector Roughen Size",
"ADBE Vector Roughen Detail",
"ADBE Vector Roughen Points",
"ADBE Vector Temporal Freq",
"ADBE Vector Correlation",
"ADBE Vector Temporal Phase",
"ADBE Vector Spatial Phase",
"ADBE Vector Random Seed"
]
])
);
let (_, c) = comp_named(&s, "W");
let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap();
assert_eq!(sh.props.prop("contents/wiggle/spatialPhase").unwrap().value, KV::Scalar(45.0));
}
#[test]
fn match_names_and_lookups() {
let mut s = session();
@@ -457,6 +495,38 @@ fn layers_items_and_markers() {
);
}
#[test]
fn folder_paths_trim_trailing_separators() {
let mut s = session();
let o = ok(
&mut s,
r#"
var paths = ['', '/', '///', 'a/b/', 'a\\b\\/', 'a/b', 'a/\n'];
var result = paths.map(function (path) {
var folder = new Folder(path);
return [folder.name, folder.path, __join(path, 'child')];
});
// A long run followed by a non-separator used to cause quadratic regex backtracking.
var longPath = Array(20001).join('/') + 'x';
result.push([new Folder(longPath).name, new Folder(longPath).path.length, __join(longPath, 'child').length]);
result
"#,
);
assert_eq!(
o.result,
json!([
["", "", "/child"],
["", "", "/child"],
["", "", "/child"],
["b", "a", "a/b/child"],
["b", "a", "a\\b/child"],
["b", "a", "a/b/child"],
["\n", "a", "a/\n/child"],
["x", 19999, 20007]
])
);
}
#[test]
fn security_gate_for_files() {
let dir = std::env::temp_dir().join(format!("ec-script-gate-{}", std::process::id()));
@@ -1650,6 +1720,50 @@ fn layer_property_resolves_transform_and_source_text_shortcuts() {
assert_eq!(layer(c, "s").props.prop("transform/opacity").unwrap().value, KV::Scalar(25.0));
}
/// A text animator property added by its After Effects name is the one asked for, not its Tracking
/// Type companion, and it can be found again by that name (#1063).
#[test]
fn scripted_text_animator_property_is_the_one_asked_for() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30);
var t = c.layers.addText("MOVARA");
var a = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator");
var props = a.property("ADBE Text Animator Properties");
var added = props.addProperty("ADBE Text Tracking Amount");
added.setValue(20);
[added.name, props.property("ADBE Text Tracking Amount").value, props.property("ADBE Text Tracking Amount").name,
added.matchName, props.property(added.matchName).propertyIndex]
"#,
);
assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount", "ADBE Text Tracking Amount", 2]));
}
/// The companion properties added with Tracking and Skew are found by their After Effects names,
/// but cannot be added on their own (#1063).
#[test]
fn text_animator_companion_properties_resolve_by_name() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("Track", 960, 300, 1, 3, 30);
var t = c.layers.addText("MOVARA");
var props = t.property("ADBE Text Properties").property("ADBE Text Animators").addProperty("ADBE Text Animator").property("ADBE Text Animator Properties");
props.addProperty("ADBE Text Tracking Amount");
props.addProperty("ADBE Text Skew");
var type = props.property("ADBE Text Track Type");
var axis = props.property("ADBE Text Skew Axis");
var added = "";
try { props.addProperty("ADBE Text Track Type"); added = "added"; } catch (e) { added = "refused"; }
[type ? type.matchName : null, axis ? axis.matchName : null, added]
"#,
);
assert_eq!(o.result, json!(["ADBE Text Track Type", "ADBE Text Skew Axis", "refused"]));
}
/// #1936: an array whose length doesn't match the property's dimension is an error in After
/// Effects, not a value to truncate or broadcast. Two values for a 3-D property stay legal.
#[test]
+34
View File
@@ -608,3 +608,37 @@ fn presets_saved_by_the_old_core_panel_carry_over() {
let win = open_panel(&mut s);
assert_eq!(control(&mut s, win, "presets")["items"].as_array().unwrap().len(), 12);
}
/// #2465: the Linear preset sets Linear interpolation on every side it applies to, including the
/// unused first-in and last-out sides; editing its speeds makes a Bezier curve.
#[test]
fn the_linear_preset_sets_linear_interpolation_and_edited_speeds_give_bezier() {
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))]);
s.execute("prop.select", json!({"layer": l, "path": "transform/opacity"})).unwrap();
let win = open_panel(&mut s);
// Start from Bezier keys so Linear has something to restore.
ok(
&mut s,
r#"var p = app.project.activeItem.layer(1).transform.opacity, K = KeyframeInterpolationType;
for (var i = 1; i <= p.numKeys; i++) p.setInterpolationTypeAtKey(i, K.BEZIER, K.BEZIER);"#,
);
let op = prop(&s, l, "transform/opacity");
for (i, k) in op.keys.iter().enumerate() {
assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier), "initial key {i}");
}
let steps = s.history.undo.len();
set(&mut s, win, "presets", json!("Linear"));
assert_eq!(s.history.undo.len(), steps + 1, "one undo step");
let op = prop(&s, l, "transform/opacity");
for (i, k) in op.keys.iter().enumerate() {
assert_eq!((k.in_interp, k.out_interp), (Interp::Linear, Interp::Linear), "key {i}");
}
// Edited speeds make a custom Bezier curve even though Linear is still the selected preset.
select_keys(&mut s, l, &[("transform/opacity", 1.0)]);
apply_typed(&mut s, win, "In-Out", [33.0, 2.0, 33.0, 2.0]);
let k = &prop(&s, l, "transform/opacity").keys[1];
assert_eq!((k.in_interp, k.out_interp), (Interp::Bezier, Interp::Bezier));
}
+24
View File
@@ -86,3 +86,27 @@ fn open_panels_open_again_at_the_next_launch() {
let mut s = session(&store);
assert_eq!(s.execute("window.restoreScriptPanels", json!({})).unwrap(), json!({"opened": [], "failed": []}));
}
/// A panel in a subfolder (Window ▸ Motion Kit ▸ Animate.jsx) is `Motion Kit/Animate.jsx`: it opens
/// titled after its file and opens again at the next launch.
#[test]
fn a_panel_in_a_subfolder_opens_and_opens_again_at_the_next_launch() {
let store = Arc::new(MemoryConfig::default());
store.write(&format!("{PANELS_DIR}/Motion Kit/Animate.jsx"), PANEL).unwrap();
let mut s = session(&store);
let list = s.execute("file.scripts.list", json!({})).unwrap();
assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}");
let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap();
let w = s.script_ui.window(r["window"].as_u64().unwrap() as u32).unwrap();
assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Animate"));
assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]}));
// A saved name that leaves the folder, or goes two folders deep, fails and is forgotten.
store.write(OPEN_PANELS_FILE, &json!({"open": ["../Top.jsx", "Motion Kit/Animate.jsx", "a/b/c.jsx"]}).to_string()).unwrap();
drop(s);
let mut s = session(&store);
let r = s.execute("window.restoreScriptPanels", json!({})).unwrap();
assert_eq!(r["opened"], json!(["Motion Kit/Animate.jsx"]));
assert_eq!(r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect::<Vec<_>>(), ["../Top.jsx", "a/b/c.jsx"]);
assert!(panel_window(&s, "Motion Kit/Animate.jsx").is_some());
assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]}));
}
+10 -1
View File
@@ -222,7 +222,16 @@ fn position_now(s: &Session, layer: u64) -> [f64; 2] {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap();
let l = comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap();
let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref(), footage: None };
let ctx = effectcraft_engine::render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: s.time(),
expr: s.expr.as_deref(),
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let v = ctx.value(l, l.props.prop("transform/position").unwrap()).components();
[v[0], v[1]]
}