Merge main into fix/1063-text-animator-stacked

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDvxCS8xcR28VArjzn1x7U
This commit is contained in:
Claude
2026-10-29 09:27:06 +00:00
909 changed files with 87377 additions and 50172 deletions
+301
View File
@@ -10,6 +10,18 @@ use serde_json::{Value, json};
use crate::{Outcome, run_code};
mod callable;
mod ease_presets;
mod evalfile;
mod include;
mod panels;
#[cfg(not(windows))]
mod services;
mod tasks;
mod ui;
mod ui_more;
mod ui_mouse;
fn session() -> Session {
let mut s = Session { expr: Some(Arc::new(effectcraft_expr::Expressions)), expr_check: Some(effectcraft_expr::check_syntax), ..Default::default() };
crate::install(&mut s);
@@ -126,6 +138,65 @@ fn fill_parameters_use_documented_match_names() {
assert_eq!(o.result, json!("Color|All Masks|ADBE Fill-0007"));
}
#[test]
fn stroke_parameters_use_after_effects_match_names() {
// #1405: `ADBE Stroke-0002` is Stroke's Color in After Effects, not All Masks.
let mut s = session();
let o = ok(
&mut s,
r#"var c = app.project.items.addComp("S", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1);
var fx = l.property("ADBE Effect Parade").addProperty("ADBE Stroke"), out = [];
for (var i = 1; i <= 11; i++) out.push(fx.property("ADBE Stroke-" + ("000" + i).slice(-4)).name);
out.push(fx.property("Color").matchName); out"#,
);
assert_eq!(
o.result,
json!([
"Path",
"Color",
"Brush Size",
"Brush Hardness",
"Opacity",
"Spacing",
"Paint Style",
"Start",
"End",
"All Masks",
"Stroke Sequentially",
"ADBE Stroke-0002"
])
);
}
#[test]
fn hue_saturation_parameters_use_after_effects_match_names() {
// #1405: `ADBE HUE SATURATION-0004` is Master Hue in After Effects. The per-channel controls,
// which After Effects doesn't expose, are numbered after its parameters so none takes an AE number.
let mut s = session();
let o = ok(
&mut s,
r#"var c = app.project.items.addComp("H", 100, 100, 1, 1, 12); var l = c.layers.addSolid([1,1,1], "S", 100, 100, 1);
var fx = l.property("ADBE Effect Parade").addProperty("ADBE HUE SATURATION"), out = [];
var n = [2, 4, 5, 6, 7, 8, 9, 10];
for (var i = 0; i < n.length; i++) out.push(fx.property("ADBE HUE SATURATION-" + ("000" + n[i]).slice(-4)).name);
out.push(fx.property("Red Hue").matchName); out"#,
);
assert_eq!(
o.result,
json!([
"Channel Control",
"Master Hue",
"Master Saturation",
"Master Lightness",
"Colorize",
"Colorize Hue",
"Colorize Saturation",
"Colorize Lightness",
"ADBE HUE SATURATION-0011"
])
);
}
#[test]
fn undo_steps_without_a_group() {
let mut s = session();
@@ -199,6 +270,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();
@@ -385,6 +494,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()));
@@ -552,6 +693,26 @@ fn engine_entry_points() {
let _ = std::fs::remove_dir_all(&dir);
}
/// `app.project.framesCountType` / `displayStartFrame`: Project Settings ▸ Frame Count (#1807).
#[test]
fn frames_count_type_sets_the_project_frame_count() {
let mut s = session();
let o = ok(
&mut s,
"var p = app.project; var a = [p.framesCountType === FramesCountType.FC_START_0, p.displayStartFrame]; p.framesCountType = FramesCountType.FC_START_1; a.concat([p.framesCountType === FramesCountType.FC_START_1, p.displayStartFrame]).join('|')",
);
assert_eq!(o.result, json!("true|0|true|1"));
// After Effects' enum values, so scripts that store the number keep working.
let o = ok(&mut s, "[FramesCountType.FC_START_0, FramesCountType.FC_START_1, FramesCountType.FC_TIMECODE_CONVERSION].join('|')");
assert_eq!(o.result, json!("2612|2613|2614"));
assert_eq!(s.project.settings.frame_start, 1);
ok(&mut s, "app.project.displayStartFrame = 0");
assert_eq!(s.project.settings.frame_start, 0);
let o = run_code(&mut s, "app.project.framesCountType = FramesCountType.FC_TIMECODE_CONVERSION", "test.jsx");
assert!(o.error.is_some_and(|e| e.message.contains("Timecode Conversion")));
assert_eq!(s.project.settings.frame_start, 0);
}
#[test]
fn console_keeps_variables() {
let mut s = session();
@@ -904,6 +1065,37 @@ fn footage_proxies_sequences_and_conform_rate() {
let _ = std::fs::remove_dir_all(&dir);
}
/// #813: `AVLayer.replaceSource` changes the source item and keeps the layer's name, timing and
/// properties; a folder is not a source.
#[test]
fn av_layer_replace_source_keeps_the_layer() {
let mut s = session();
let o = ok(
&mut s,
r#"
var main = app.project.items.addComp("Main", 100, 100, 1, 5, 25);
var layer = main.layers.addSolid([1, 0, 0], "Old", 100, 100, 1);
layer.transform.position.setValue([10, 20]);
layer.startTime = 1;
var nested = app.project.items.addComp("Nested", 200, 100, 1, 3, 25);
layer.replaceSource(nested, false);
[layer.source.name, layer.source.width, layer.source.height, layer.source instanceof CompItem,
layer.name, layer.transform.position.value[0], layer.startTime, main.numLayers]
"#,
);
assert_eq!(o.result, json!(["Nested", 200, 100, true, "Old", 10, 1, 1]));
let bad = run_code(
&mut s,
r#"
var main = app.project.items.addComp("Other", 100, 100, 1, 2, 25);
var layer = main.layers.addSolid([1, 0, 0], "Layer", 100, 100, 1);
layer.replaceSource(app.project.items.addFolder("F"), false);
"#,
"replace-source.jsx",
);
assert!(bad.error.as_ref().is_some_and(|e| e.message.contains("folder")), "{bad:?}");
}
/// A flat green footage source, so `sampleImage` on a footage layer has known pixels.
struct Flat;
impl effectcraft_engine::render::FootageSource for Flat {
@@ -1326,6 +1518,50 @@ fn key_interpolation_and_ease_apply_outside_the_active_comp() {
assert!(v[3].as_f64().is_some_and(|x| (x - -44.5).abs() > 1.0), "{v}");
}
/// #1531: a key the user selected in the property's comp stays selected, and only the named key
/// changes (on the old code the change went to the selected key instead).
/// The active comp's own key selection is untouched.
#[test]
fn a_per_key_method_in_another_comp_changes_only_its_key_and_keeps_both_selections() {
let mut s = session();
let o = ok(
&mut s,
r#"
var C = app.project.items.addComp('C', 100, 100, 1, 10, 20);
var p = C.layers.addNull().transform.rotation;
p.setValuesAtTimes([0, 1, 2], [0, 90, 180]);
var A = app.project.items.addComp('A', 100, 100, 1, 10, 20);
A.openInViewer();
var q = A.layers.addNull().transform.opacity;
q.setValuesAtTimes([0, 1], [0, 100]);
app.run('edit.deselectAll', {});
q.setSelectedAtKey(2, true);
// the user selected key 3 of C earlier: C remembers it while A is active
p.setSelectedAtKey(3, true);
p.setInterpolationTypeAtKey(1, KeyframeInterpolationType.HOLD);
p.setTemporalEaseAtKey(2, [new KeyframeEase(0, 80)]);
var hold = KeyframeInterpolationType.HOLD;
var out = [p.keyOutInterpolationType(1) == hold, p.keyOutInterpolationType(3) == hold, p.keyInInterpolationType(3) == hold,
p.keyInTemporalEase(2)[0].influence, p.keyInTemporalEase(3)[0].influence,
q.keySelected(1), q.keySelected(2)];
p.removeKey(1);
out.push(p.numKeys, p.keyTime(1), p.keyValue(1), q.keySelected(2), app.project.activeItem.name);
// C's remembered selection is live again once C is active: still the user's key (now 2)
C.openInViewer();
out.push(p.keySelected(1), p.keySelected(2));
out;
"#,
);
let v = o.result.as_array().unwrap();
let ease = |i: usize| v[i].as_f64().unwrap();
assert_eq!(&v[..3], &[json!(true), json!(false), json!(false)], "only key 1 holds: {v:?}");
assert!((ease(3) - 80.0).abs() < 1e-6, "key 2 eased: {v:?}");
assert!((ease(4) - 80.0).abs() > 1e-6, "key 3 untouched: {v:?}");
assert_eq!(&v[5..7], &[json!(false), json!(true)], "A's key selection kept: {v:?}");
assert_eq!(&v[7..12], &[json!(2), json!(1), json!(90), json!(true), json!("A")], "key 1 removed, A still active: {v:?}");
assert_eq!(&v[12..], &[json!(false), json!(true)], "C's key selection kept, the target key not added: {v:?}");
}
/// #808: sourcePointToComp and compPointToSource follow the layer's transform.
#[test]
fn source_point_conversions_follow_the_layer_transform() {
@@ -1503,3 +1739,68 @@ fn scripted_text_animator_property_is_the_one_asked_for() {
);
assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount", "ADBE Text Tracking Amount", 2]));
}
/// #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]
fn set_value_rejects_an_array_of_the_wrong_dimension() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("Dims", 640, 360, 1, 2, 30);
var l = c.layers.addSolid([1, 0, 0], "s", 640, 360, 1);
var ramp = l.property("ADBE Effect Parade").addProperty("ADBE Ramp").property("ADBE Ramp-0001");
function attempt(f) { try { f(); return "no error"; } catch (e) { return e.message; } }
var before = "" + ramp.value;
var tooMany = attempt(function () { ramp.setValue([1, 2, 3]); });
var tooFew = attempt(function () { ramp.setValue([1]); });
var untouched = "" + ramp.value;
ramp.setValue([7, 8]);
var keyed = attempt(function () { ramp.setValueAtTime(0, [1, 2, 3, 4]); });
// A 3-D property still takes two values, as it does in After Effects: z stays 0.
l.property("Position").setValue([120, 34]);
[tooMany, tooFew, untouched === before, "" + ramp.value, keyed, "" + l.property("Position").value]
"#,
);
assert_eq!(
o.result,
json!([
"Array is not of the correct dimension",
"Array is not of the correct dimension",
true,
"7,8",
"Array is not of the correct dimension",
"120,34,0"
])
);
let (_, c) = comp_named(&s, "Dims");
assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([120.0, 34.0, 0.0]));
}
/// #1942: the scripting width and height setters resize the frame only. Re-anchoring the old frame
/// in the new one, which moves every unparented layer, belongs to the Composition Settings dialog.
#[test]
fn setting_comp_width_and_height_leaves_the_layers_where_they_are() {
let mut s = session();
let o = ok(
&mut s,
r#"
var c = app.project.items.addComp("w", 400, 200, 1, 1, 25);
var l = c.layers.addSolid([1, 1, 1], "s", 50, 50, 1, 1);
var pos = l.property("ADBE Transform Group").property("ADBE Position");
pos.setValue([100, 100]);
var k = c.layers.addSolid([1, 1, 1], "k", 50, 50, 1, 1);
var kpos = k.property("ADBE Transform Group").property("ADBE Position");
kpos.setValueAtTime(0, [10, 20]);
kpos.setValueAtTime(1, [30, 40]);
var before = "" + pos.value;
c.width = 800;
c.height = 400;
[before, "" + pos.value, "" + kpos.keyValue(1), "" + kpos.keyValue(2), c.width, c.height]
"#,
);
assert_eq!(o.result, json!(["100,100,0", "100,100,0", "10,20,0", "30,40,0", 800, 400]));
let (_, c) = comp_named(&s, "w");
assert_eq!(layer(c, "s").props.prop("transform/position").unwrap().value, KV::Vec3([100.0, 100.0, 0.0]));
}