mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
addProperty("ADBE Text Tracking Amount") returned the first property added to the animator, which is
the Tracking Type companion, so the script changed the wrong value. The engine now reports the
requested property's position in the Properties group, and the script returns that property. An
animator's Properties group also finds its properties by their After Effects names, such as
ADBE Text Tracking Amount, which the name table already had in the other direction. A regression
test checks the property returned, its value, and the lookup by name.
Not covered: the Advanced and Wiggly selector children and the Expression amount, which still keep
their own names.
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
1051 lines
45 KiB
Rust
1051 lines
45 KiB
Rust
//! End-to-end scripts against a headless session: build with the object model, read back through
|
|
//! the engine.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use effectcraft_engine::Session;
|
|
use effectcraft_engine::keyframe::Value as KV;
|
|
use effectcraft_engine::project::{Comp, ItemId, Layer};
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::{Outcome, run_code};
|
|
|
|
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);
|
|
s
|
|
}
|
|
|
|
fn ok(s: &mut Session, code: &str) -> Outcome {
|
|
let o = run_code(s, code, "test.jsx");
|
|
assert!(o.error.is_none(), "script failed: {:?}\noutput: {:?}", o.error, o.output);
|
|
o
|
|
}
|
|
|
|
fn comp_named<'a>(s: &'a Session, name: &str) -> (ItemId, &'a Comp) {
|
|
let it = s.project.items.values().find(|i| i.name == name && i.as_comp().is_some()).unwrap_or_else(|| panic!("no comp {name}"));
|
|
(it.id, it.as_comp().unwrap())
|
|
}
|
|
|
|
fn layer<'a>(c: &'a Comp, name: &str) -> &'a Layer {
|
|
c.layers.iter().find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}"))
|
|
}
|
|
|
|
#[test]
|
|
fn builds_a_comp_end_to_end() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
app.beginUndoGroup("Build");
|
|
var comp = app.project.items.addComp("Main", 640, 360, 1, 4, 25);
|
|
var bg = comp.layers.addSolid([0.2, 0.4, 0.6], "BG", 640, 360, 1);
|
|
var txt = comp.layers.addText("Hello");
|
|
txt.name = "Title";
|
|
var pos = txt.property("ADBE Transform Group").property("ADBE Position");
|
|
pos.setValueAtTime(0, [100, 180]);
|
|
pos.setValueAtTime(2, [540, 180]);
|
|
txt.transform.opacity.expression = "50 + 25";
|
|
var blur = bg.Effects.addProperty("ADBE Gaussian Blur 2");
|
|
blur.property("Blurriness").setValue(12);
|
|
var nul = comp.layers.addNull();
|
|
txt.setParentWithJump(nul);
|
|
bg.blendingMode = BlendingMode.MULTIPLY;
|
|
var rq = app.project.renderQueue.items.add(comp);
|
|
rq.outputModule(1).file = new File("/tmp/effectcraft-script-test/out.mp4");
|
|
app.endUndoGroup();
|
|
writeLn("layers: " + comp.numLayers);
|
|
[comp.numLayers, pos.numKeys, pos.keyValue(2)[0], txt.transform.opacity.value, comp.layer(1).name]
|
|
"#,
|
|
);
|
|
assert_eq!(o.output, vec!["layers: 3"]);
|
|
assert_eq!(o.result, json!([3, 2, 540, 75, "Null 1"]));
|
|
let (cid, c) = comp_named(&s, "Main");
|
|
assert_eq!((c.width, c.height), (640, 360));
|
|
assert_eq!(c.duration.seconds(), 4.0);
|
|
// AE adds new layers at the top.
|
|
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
|
|
assert_eq!(names, ["Null 1", "Title", "BG"]);
|
|
let title = layer(c, "Title");
|
|
let p = title.props.prop("transform/position").unwrap();
|
|
assert_eq!(p.keys.len(), 2);
|
|
assert_eq!(p.keys[1].value, KV::Vec3([540.0, 180.0, 0.0]));
|
|
assert_eq!(title.props.prop("transform/opacity").unwrap().expr.as_ref().unwrap().text, "50 + 25");
|
|
assert_eq!(title.parent, Some(layer(c, "Null 1").id));
|
|
let bg = layer(c, "BG");
|
|
assert_eq!(bg.blend_mode, effectcraft_engine::color::BlendMode::Multiply);
|
|
let fx = bg.props.group("effects/#1").unwrap();
|
|
assert_eq!(fx.match_id, "ec.blur.gaussian");
|
|
assert_eq!(bg.props.prop("effects/#1/blurriness").unwrap().value, KV::Scalar(12.0));
|
|
assert_eq!(s.project.render_queue.len(), 1);
|
|
assert_eq!(s.project.render_queue[0].comp, cid);
|
|
// One undo step for the whole group.
|
|
assert_eq!(s.history.undo.len(), 1);
|
|
assert_eq!(s.history.undo[0].0, "Build");
|
|
assert!(s.undo());
|
|
assert!(s.project.items.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn fill_parameters_use_documented_match_names() {
|
|
// M13.15: `ADBE Fill-0002` is Fill's Color (All Masks is -0007), not the second row.
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"var c = app.project.items.addComp("F", 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 Fill");
|
|
[fx.property("ADBE Fill-0002").name, fx.property("ADBE Fill-0007").name, fx.property(2).matchName].join("|")"#,
|
|
);
|
|
assert_eq!(o.result, json!("Color|All Masks|ADBE Fill-0007"));
|
|
}
|
|
|
|
#[test]
|
|
fn undo_steps_without_a_group() {
|
|
let mut s = session();
|
|
ok(&mut s, r#"var c = app.project.items.addComp("A", 100, 100, 1, 1, 24); c.layers.addNull(); c.layers.addNull();"#);
|
|
assert_eq!(s.history.undo.len(), 3);
|
|
// An unbalanced group is closed when the script ends.
|
|
ok(&mut s, r#"app.beginUndoGroup("Two"); var c = app.project.item(1); c.layers.addNull(); c.layers.addNull();"#);
|
|
assert_eq!(s.history.undo.len(), 4);
|
|
assert_eq!(s.history.undo[3].0, "Two");
|
|
// Nested groups fold into the outermost one.
|
|
ok(
|
|
&mut s,
|
|
r#"app.beginUndoGroup("Outer"); app.beginUndoGroup("Inner"); app.project.item(1).layers.addNull(); app.endUndoGroup(); app.project.item(1).layers.addNull(); app.endUndoGroup();"#,
|
|
);
|
|
assert_eq!(s.history.undo.len(), 5);
|
|
assert_eq!(s.history.undo[4].0, "Outer");
|
|
s.undo();
|
|
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn errors_report_line_and_column() {
|
|
let mut s = session();
|
|
let o = run_code(&mut s, "var a = 1;\nvar b = 2;\nfoo.bar();\n", "broken.jsx");
|
|
let e = o.error.expect("error");
|
|
assert_eq!(e.line, Some(3), "{e:?}");
|
|
assert!(e.message.contains("foo"), "{e:?}");
|
|
assert_eq!(e.file, "broken.jsx");
|
|
let o = run_code(&mut s, "var a = 1;\n\nvar = ;\n", "syntax.jsx");
|
|
let e = o.error.expect("syntax error");
|
|
assert_eq!(e.line, Some(3), "{e:?}");
|
|
let o = run_code(&mut s, "writeLn('before');\nthrow new Error('custom failure');\n", "throw.jsx");
|
|
let e = o.error.expect("thrown");
|
|
assert_eq!(e.message, "custom failure");
|
|
assert_eq!(e.line, Some(2), "{e:?}");
|
|
assert_eq!(o.output, vec!["before"]);
|
|
// Errors from the object model point at the user's line.
|
|
let o = run_code(&mut s, "var c = app.project.items.addComp('C', 10, 10, 1, 1, 24);\n\nc.layer(5);\n", "range.jsx");
|
|
let e = o.error.expect("range error");
|
|
assert!(e.message.contains("out of range"), "{e:?}");
|
|
assert_eq!(e.line, Some(3), "{e:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn match_names_and_lookups() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("M", 200, 200, 1, 2, 30);
|
|
var l = c.layers.addSolid([1, 1, 1], "S", 200, 200, 1);
|
|
var tr = l.property("ADBE Transform Group");
|
|
var fx = l.property("ADBE Effect Parade").addProperty("Gaussian Blur");
|
|
var m = l.property("ADBE Mask Parade").addProperty("ADBE Mask Atom");
|
|
var sh = c.layers.addShape();
|
|
var grp = sh.property("ADBE Root Vectors Group").addProperty("ADBE Vector Group");
|
|
var rect = grp.property("ADBE Vectors Group").addProperty("ADBE Vector Shape - Rect");
|
|
var fill = grp.property("Contents").addProperty("ADBE Vector Graphic - Fill");
|
|
fill.property("ADBE Vector Fill Color").setValue([1, 0, 0]);
|
|
rect.property("ADBE Vector Rect Size").setValue([50, 60]);
|
|
[
|
|
l.matchName, tr.matchName, tr.property("ADBE Position").matchName, tr.property("Anchor Point").matchName,
|
|
tr.property(1).name, tr.anchorPoint.matchName, l.transform.position.propertyValueType == PropertyValueType.ThreeD_SPATIAL,
|
|
fx.matchName, fx.property(1).matchName, fx.property("ADBE Gaussian Blur 2-0001").name, m.matchName,
|
|
m.property("ADBE Mask Shape").name, sh.matchName, grp.matchName, rect.matchName, fill.matchName,
|
|
tr.propertyType == PropertyType.NAMED_GROUP, l.Effects.propertyType == PropertyType.INDEXED_GROUP,
|
|
tr.position.parentProperty.matchName, tr.position.propertyDepth, l.property("Nope") === null,
|
|
fx.isEffect, m.isMask, l.Effects.numProperties
|
|
]
|
|
"#,
|
|
);
|
|
assert_eq!(
|
|
o.result,
|
|
json!([
|
|
"ADBE AV Layer",
|
|
"ADBE Transform Group",
|
|
"ADBE Position",
|
|
"ADBE Anchor Point",
|
|
"Anchor Point",
|
|
"ADBE Anchor Point",
|
|
true,
|
|
"ADBE Gaussian Blur 2",
|
|
"ADBE Gaussian Blur 2-0001",
|
|
"Blurriness",
|
|
"ADBE Mask Atom",
|
|
"Mask Path",
|
|
"ADBE Vector Layer",
|
|
"ADBE Vector Group",
|
|
"ADBE Vector Shape - Rect",
|
|
"ADBE Vector Graphic - Fill",
|
|
true,
|
|
true,
|
|
"ADBE Transform Group",
|
|
2,
|
|
true,
|
|
true,
|
|
true,
|
|
1
|
|
])
|
|
);
|
|
let (_, c) = comp_named(&s, "M");
|
|
let sh = c.layers.iter().find(|l| l.props.sub("contents").is_some()).unwrap();
|
|
assert_eq!(sh.props.prop("contents/#1/contents/rect/size").unwrap().value, KV::Vec2([50.0, 60.0]));
|
|
assert_eq!(sh.props.prop("contents/#1/contents/fill/color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 1.0]));
|
|
}
|
|
|
|
#[test]
|
|
fn keyframes_eases_and_interpolation() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("K", 100, 100, 1, 5, 25);
|
|
var l = c.layers.addNull();
|
|
var r = l.transform.rotation;
|
|
r.setValuesAtTimes([0, 1, 2], [0, 90, 180]);
|
|
r.setInterpolationTypeAtKey(2, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.LINEAR);
|
|
r.setTemporalEaseAtKey(3, [new KeyframeEase(0, 75)]);
|
|
var idx = r.addKey(1.5);
|
|
r.removeKey(1);
|
|
var out = [r.numKeys, idx, r.keyTime(1), r.keyInInterpolationType(1) == KeyframeInterpolationType.HOLD,
|
|
r.keyOutInterpolationType(1) == KeyframeInterpolationType.LINEAR, r.keyInTemporalEase(3)[0].influence,
|
|
r.valueAtTime(1, false), r.isTimeVarying, r.nearestKeyIndex(1.9)];
|
|
l.startTime = 1;
|
|
out.push(r.keyTime(1));
|
|
out
|
|
"#,
|
|
);
|
|
let v = o.result.as_array().unwrap();
|
|
assert_eq!(v[0], json!(3));
|
|
assert_eq!(v[1], json!(3));
|
|
assert_eq!(v[2], json!(1));
|
|
assert_eq!(v[3], json!(true));
|
|
assert_eq!(v[4], json!(true));
|
|
assert!((v[5].as_f64().unwrap() - 75.0).abs() < 1e-6, "{v:?}");
|
|
assert_eq!(v[6], json!(90));
|
|
assert_eq!(v[7], json!(true));
|
|
assert_eq!(v[8], json!(3));
|
|
// Moving the layer moves its keys (layer time): key 1 now sits at 2 s comp time.
|
|
assert_eq!(v[9], json!(2));
|
|
}
|
|
|
|
#[test]
|
|
fn spatial_tangents_round_trip() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("T", 100, 100, 1, 2, 25);
|
|
var p = c.layers.addNull().transform.position;
|
|
p.setValuesAtTimes([0, 1], [[0, 0], [100, 0]]);
|
|
p.setSpatialTangentsAtKey(1, [0, 0], [30, 40]);
|
|
[p.keyOutSpatialTangent(1)[0], p.keyOutSpatialTangent(1)[1], p.keyInSpatialTangent(1)[0]]
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!([30, 40, 0]));
|
|
}
|
|
|
|
#[test]
|
|
fn text_documents() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("T", 400, 200, 1, 2, 30);
|
|
var t = c.layers.addText("abc");
|
|
var st = t.property("ADBE Text Properties").property("ADBE Text Document");
|
|
var doc = st.value;
|
|
doc.text = "Changed";
|
|
doc.fontSize = 40;
|
|
doc.fillColor = [1, 0, 0];
|
|
doc.justification = ParagraphJustification.CENTER_JUSTIFY;
|
|
doc.applyStroke = true;
|
|
doc.strokeWidth = 3;
|
|
st.setValue(doc);
|
|
var d2 = t.text.sourceText.value;
|
|
[d2.text, d2.fontSize, d2.fillColor, d2.justification == ParagraphJustification.CENTER_JUSTIFY, d2.strokeWidth, t instanceof TextLayer, t.name]
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!(["Changed", 40, [1, 0, 0], true, 3, true, "abc"]));
|
|
let (_, c) = comp_named(&s, "T");
|
|
let KV::Text(d) = &c.layers[0].props.prop("text/sourceText").unwrap().value else { panic!() };
|
|
assert_eq!(d.text, "Changed");
|
|
assert_eq!(d.style_at(0).size, 40.0);
|
|
}
|
|
|
|
#[test]
|
|
fn layers_items_and_markers() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var f = app.project.items.addFolder("Comps");
|
|
var c = f.items.addComp("Inner", 100, 100, 1, 3, 30);
|
|
var main = app.project.items.addComp("Outer", 200, 200, 1, 6, 30);
|
|
var nested = main.layers.add(c);
|
|
var a = main.layers.addSolid([1, 0, 0], "A", 50, 50, 1, 2);
|
|
var b = main.layers.addSolid([0, 1, 0], "B", 50, 50, 1);
|
|
b.moveAfter(nested);
|
|
a.enabled = false; a.solo = true; a.locked = true; a.shy = true;
|
|
a.threeDLayer = true;
|
|
var cam = main.layers.addCamera("Cam", [100, 100]);
|
|
var lt = main.layers.addLight("Key", [100, 100]);
|
|
lt.lightType = LightType.SPOT;
|
|
var d = a.duplicate();
|
|
d.name = "A copy";
|
|
var mv = new MarkerValue("hello");
|
|
mv.duration = 0.5;
|
|
main.markerProperty.setValueAtTime(1, mv);
|
|
b.marker.setValueAtTime(0.5, new MarkerValue("layer marker"));
|
|
b.inPoint = 1; b.outPoint = 3;
|
|
var pc = main.layers.precompose([b.index], "Pre", true);
|
|
[c.parentFolder.name, nested.source.name, a.outPoint, a.enabled, a.solo, a.locked, a.shy, a.threeDLayer,
|
|
cam instanceof CameraLayer, lt.lightType == LightType.SPOT, main.markerProperty.numKeys, main.markerProperty.keyValue(1).comment,
|
|
main.markerProperty.keyValue(1).duration, pc.name, pc instanceof CompItem, main.layer("A copy").index, app.project.numItems,
|
|
main.layers.byName("Pre") !== null, f.numItems]
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!(["Comps", "Inner", 2, false, true, true, true, true, true, true, 1, "hello", 0.5, "Pre", true, 3, 7, true, 1]));
|
|
let (_, c) = comp_named(&s, "Outer");
|
|
assert_eq!(c.markers.len(), 1);
|
|
assert!(
|
|
c.layers
|
|
.iter()
|
|
.any(|l| l.name == "Key"
|
|
&& matches!(l.source, effectcraft_engine::project::LayerSource::Light { kind: effectcraft_engine::project::LightKind::Spot }))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn security_gate_for_files() {
|
|
let dir = std::env::temp_dir().join(format!("ec-script-gate-{}", std::process::id()));
|
|
let proj = dir.join("proj");
|
|
std::fs::create_dir_all(&proj).unwrap();
|
|
std::fs::write(proj.join("data.txt"), "inside").unwrap();
|
|
std::fs::write(dir.join("secret.txt"), "outside").unwrap();
|
|
let mut s = session();
|
|
s.path = Some(proj.join("p.ecproj").to_string_lossy().to_string());
|
|
let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap();
|
|
// Reading inside the project folder works.
|
|
let o = ok(&mut s, &format!("var f = new File({}); f.open('r'); var t = f.read(); f.close(); t", q(&proj.join("data.txt"))));
|
|
assert_eq!(o.result, json!("inside"));
|
|
// Outside it (also via `..`) is refused.
|
|
for path in [dir.join("secret.txt"), proj.join("../secret.txt")] {
|
|
let o = run_code(&mut s, &format!("var f = new File({}); f.open('r');", q(&path)), "gate.jsx");
|
|
let e = o.error.expect("blocked");
|
|
assert!(e.message.contains("Allow Scripts to Write Files"), "{e:?}");
|
|
}
|
|
// Writing is refused anywhere, the network too.
|
|
let o = run_code(&mut s, &format!("var f = new File({}); f.open('w'); f.write('x'); f.close();", q(&proj.join("out.txt"))), "gate.jsx");
|
|
assert!(o.error.is_some());
|
|
assert!(!proj.join("out.txt").exists());
|
|
assert!(run_code(&mut s, "new Socket()", "net.jsx").error.is_some());
|
|
assert!(run_code(&mut s, "system.callSystem('ls')", "sys.jsx").error.is_some());
|
|
// With the preference on, scripts can write and read anywhere.
|
|
s.prefs.scripting.allow_scripts_write_files = true;
|
|
ok(&mut s, &format!("var f = new File({}); f.open('w'); f.writeln('written'); f.close();", q(&dir.join("w.txt"))));
|
|
assert_eq!(std::fs::read_to_string(dir.join("w.txt")).unwrap(), "written\n");
|
|
let o = ok(&mut s, &format!("var f = new File({}); f.open('r'); f.readln()", q(&dir.join("secret.txt"))));
|
|
assert_eq!(o.result, json!("outside"));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// #590: a script can't give itself file and network access (or turn off the warning before
|
|
/// executing files) by changing Settings ▸ Scripting & Expressions; the user still can.
|
|
#[test]
|
|
fn scripts_cant_change_what_scripts_may_do() {
|
|
let mut s = session();
|
|
let attempts = [
|
|
"app.run('prefs.set', {key: 'scripting.allowScriptsWriteFiles', value: true})",
|
|
"app.run('prefs.set', {values: {'scripting.warnExecutingFiles': false}})",
|
|
"app.run('prefs.set', {key: 'scripting', value: {allowScriptsWriteFiles: true}})",
|
|
"app.run('engine.batch', {steps: [{command: 'prefs.set', params: {key: 'scripting.enableJsDebugger', value: true}}]})",
|
|
];
|
|
for code in attempts {
|
|
let o = run_code(&mut s, code, "grant.jsx");
|
|
let e = o.error.unwrap_or_else(|| panic!("`{code}` was allowed"));
|
|
assert!(e.message.contains("Scripts can't change what scripts are allowed to do"), "{code}: {e:?}");
|
|
let sc = &s.prefs.scripting;
|
|
assert!(!sc.allow_scripts_write_files && sc.warn_executing_files && !sc.enable_js_debugger, "{code}");
|
|
}
|
|
assert!(run_code(&mut s, "new Socket()", "net.jsx").error.is_some());
|
|
// Other settings, and the expression editor's, are still the script's to change.
|
|
ok(&mut s, "app.run('prefs.set', {values: {'general.undoLevels': 40, 'scripting.editorFontSize': 16}})");
|
|
assert_eq!((s.prefs.general.undo_levels, s.prefs.scripting.editor_font_size), (40, 16));
|
|
// The user grants access; a script's Reset Settings doesn't take it back or change it.
|
|
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
|
|
ok(&mut s, "app.run('prefs.reset', {})");
|
|
assert!(s.prefs.scripting.allow_scripts_write_files);
|
|
assert_eq!(s.prefs.general.undo_levels, effectcraft_engine::prefs::Prefs::default().general.undo_levels);
|
|
// A command script file is a script too.
|
|
let dir = std::env::temp_dir().join(format!("ec-script-grant-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let f = dir.join("grant.json");
|
|
std::fs::write(&f, r#"[{"command": "prefs.set", "params": {"key": "scripting.allowScriptsWriteFiles", "value": false}}]"#).unwrap();
|
|
let e = s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap_err().to_string();
|
|
assert!(e.contains("Scripts can't change"), "{e}");
|
|
assert!(s.prefs.scripting.allow_scripts_write_files);
|
|
// The user (UI, CLI, control channel) can take it back.
|
|
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": false})).unwrap();
|
|
assert!(!s.prefs.scripting.allow_scripts_write_files);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn engine_entry_points() {
|
|
let mut s = session();
|
|
// script.run reports errors in the result, not as a command failure.
|
|
let r = s.execute("script.run", json!({"code": "writeLn('hi'); 1 + 2"})).unwrap();
|
|
assert_eq!(r["ok"], json!(true));
|
|
assert_eq!(r["result"], json!(3));
|
|
assert_eq!(r["output"], json!("hi"));
|
|
let r = s.execute("script.run", json!({"code": "\nnope()"})).unwrap();
|
|
assert_eq!(r["ok"], json!(false));
|
|
assert_eq!(r["error"]["line"], json!(2));
|
|
// Scripts can't run scripts.
|
|
let r = s.execute("script.run", json!({"code": "app.run('script.run', {code: '1'})"})).unwrap();
|
|
assert!(r["error"]["message"].as_str().unwrap().contains("scripts can't run scripts"), "{r}");
|
|
// Compiled .jsxbin scripts (by name or by their header) are unsupported, not a SyntaxError
|
|
// at 1:1 (#176).
|
|
for p in [json!({"code": "@JSXBIN@ES@2.0@MyBbyBn0AB", "name": "compiled.bin"}), json!({"code": "1", "name": "/x/compiled.JSXBIN"})] {
|
|
let e = s.execute("script.run", p.clone()).unwrap_err().to_string();
|
|
assert!(e.contains(".jsxbin scripts are not supported: run the .jsx source"), "{p}: {e}");
|
|
}
|
|
// File ▸ Scripts ▸ Run Script File… runs .jsx as JavaScript.
|
|
let dir = std::env::temp_dir().join(format!("ec-script-file-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let f = dir.join("make.jsx");
|
|
std::fs::write(&f, "app.project.items.addComp('From File', 320, 240, 1, 2, 24);").unwrap();
|
|
s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap();
|
|
assert!(s.project.items.values().any(|i| i.name == "From File"));
|
|
std::fs::write(&f, "\n\nbroken(;").unwrap();
|
|
let e = s.execute("file.runScript", json!({"path": f.to_string_lossy()})).unwrap_err().to_string();
|
|
assert!(e.contains("make.jsx (line 3)"), "{e}");
|
|
// JSON command scripts still work.
|
|
let j = dir.join("steps.json");
|
|
std::fs::write(&j, r#"[{"command": "comp.new", "params": {"name": "From Steps"}}]"#).unwrap();
|
|
s.execute("file.runScript", json!({"path": j.to_string_lossy()})).unwrap();
|
|
assert!(s.project.items.values().any(|i| i.name == "From Steps"));
|
|
// Menu commands by name.
|
|
let o = ok(&mut s, "app.findMenuCommandId('New Project') > 0 && app.findMenuCommandId('No Such Thing') === 0");
|
|
assert_eq!(o.result, json!(true));
|
|
let o = ok(&mut s, "app.executeCommand(app.findMenuCommandId('New Project')); app.project.numItems");
|
|
assert_eq!(o.result, json!(0));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn console_keeps_variables() {
|
|
let mut s = session();
|
|
let req = |code: &'static str| effectcraft_engine::ScriptRequest { code, name: "console", console: true };
|
|
let r = crate::run(&mut s, &req("var counter = 41;"));
|
|
assert!(r.error.is_none(), "{r:?}");
|
|
let r = crate::run(&mut s, &req("counter + 1"));
|
|
assert_eq!(r.result, json!(42));
|
|
let _ = Value::Null;
|
|
}
|
|
|
|
#[test]
|
|
fn schedule_task_runs_after_the_script() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
"app.scheduleTask('writeLn(\"later\")', 100, false); var id = app.scheduleTask('writeLn(\"never\")', 10, false); app.cancelTask(id); writeLn('now');",
|
|
);
|
|
assert_eq!(o.output, vec!["now", "later"]);
|
|
}
|
|
|
|
#[test]
|
|
fn layer_switches_timing_and_comp_settings() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("S", 320, 240, 1, 5, 30);
|
|
var matte = c.layers.addText("M");
|
|
var fill = c.layers.addSolid([1, 1, 1], "Fill", 320, 240, 1);
|
|
fill.moveToEnd();
|
|
fill.setTrackMatte(matte, TrackMatteType.LUMA);
|
|
var t1 = fill.trackMatteType == TrackMatteType.LUMA && fill.hasTrackMatte && fill.trackMatteLayer.name == "M";
|
|
fill.stretch = 200;
|
|
fill.label = 3;
|
|
c.width = 400;
|
|
c.duration = 8;
|
|
c.bgColor = [0.1, 0.2, 0.3];
|
|
c.time = 2;
|
|
c.workAreaStart = 1;
|
|
c.workAreaDuration = 3;
|
|
var extra = c.layers.addNull();
|
|
extra.remove();
|
|
fill.selected = true;
|
|
var sel = c.selectedLayers.length;
|
|
fill.transform.opacity.expression = "thisLayer.nope(";
|
|
var err = fill.transform.opacity.expressionError;
|
|
fill.transform.opacity.expressionEnabled = false;
|
|
var copy = c.duplicate();
|
|
var f = app.project.items.addFolder("Bin");
|
|
copy.parentFolder = f;
|
|
[t1, fill.stretch, fill.label, c.width, c.duration, c.bgColor[2], c.time, c.workAreaStart, c.workAreaDuration,
|
|
c.numLayers, sel, err.length > 0, fill.transform.opacity.expressionEnabled, copy.name, f.numItems, copy.parentFolder.name]
|
|
"#,
|
|
);
|
|
let v = o.result;
|
|
assert_eq!(v[0], json!(true), "{v}");
|
|
assert_eq!(v[1], json!(200));
|
|
assert_eq!(v[2], json!(3));
|
|
assert_eq!(v[3], json!(400));
|
|
assert_eq!(v[4], json!(8));
|
|
assert!((v[5].as_f64().unwrap() - 0.3).abs() < 1e-6);
|
|
assert_eq!(v[6], json!(2));
|
|
assert_eq!(v[7], json!(1));
|
|
assert_eq!(v[8], json!(3));
|
|
assert_eq!(v[9], json!(2));
|
|
assert_eq!(v[10], json!(1));
|
|
assert_eq!(v[11], json!(true));
|
|
assert_eq!(v[12], json!(false));
|
|
assert_eq!(v[13], json!("S 2"));
|
|
assert_eq!(v[14], json!(1));
|
|
assert_eq!(v[15], json!("Bin"));
|
|
// Removing the copy through the object model.
|
|
let o = ok(&mut s, "var n = app.project.numItems; app.project.item(1).remove(); n - app.project.numItems");
|
|
assert_eq!(o.result, json!(1));
|
|
}
|
|
|
|
#[test]
|
|
fn motion_graphics_template_hooks_add_mirrors_and_controllers() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var comp = app.project.items.addComp("Card", 64, 64, 1, 2, 30);
|
|
var bg = comp.layers.addSolid([1, 1, 1], "BG", 64, 64, 1);
|
|
var op = bg.transform.opacity;
|
|
var r = [op.canAddToMotionGraphicsTemplate(comp), op.addToMotionGraphicsTemplateAs(comp, "Fade")];
|
|
// Again: a mirror of the same property (a controller of its own).
|
|
r.push(op.addToMotionGraphicsTemplate(comp));
|
|
r.push(comp.motionGraphicsTemplateControllerCount);
|
|
r.push(comp.getMotionGraphicsTemplateControllerName(1), comp.getMotionGraphicsTemplateControllerName(2));
|
|
comp.setMotionGraphicsControllerName(2, "Fade Again");
|
|
r.push(comp.getMotionGraphicsTemplateControllerName(2));
|
|
r
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!([true, true, true, 2, "Fade", "Fade", "Fade Again"]));
|
|
let (_, c) = comp_named(&s, "Card");
|
|
let eg = c.essential.as_ref().unwrap();
|
|
assert!(matches!(eg.controls[1].kind, effectcraft_engine::project::essential::EgKind::Mirror { of } if of == eg.controls[0].id));
|
|
}
|
|
|
|
#[test]
|
|
fn comp_preserves_nested_frame_rate_and_resolution() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var comp = app.project.items.addComp("Nested", 320, 180, 1, 2, 12);
|
|
var before = [comp.preserveNestedFrameRate, comp.preserveNestedResolution];
|
|
comp.preserveNestedFrameRate = true;
|
|
comp.preserveNestedResolution = true;
|
|
before.concat([comp.preserveNestedFrameRate, comp.preserveNestedResolution]).join(",");
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!("false,false,true,true"));
|
|
let (_, c) = comp_named(&s, "Nested");
|
|
assert!(c.preserve_frame_rate && c.preserve_resolution);
|
|
}
|
|
|
|
#[test]
|
|
fn text_document_baseline_flags_reflect_pending_edits_and_applied_value() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var comp = app.project.items.addComp("Baseline", 64, 64, 1, 1, 24);
|
|
var src = comp.layers.addText("AB").text.sourceText;
|
|
var doc = src.value;
|
|
doc.superscript = true;
|
|
var pendingSuper = [doc.superscript, doc.subscript];
|
|
doc.text = doc.superscript ? "super" : "normal";
|
|
src.setValue(doc);
|
|
var appliedSuper = [src.value.text, src.value.superscript, src.value.subscript];
|
|
doc = src.value;
|
|
doc.subscript = true;
|
|
var pendingSub = [doc.superscript, doc.subscript];
|
|
src.setValue(doc);
|
|
var appliedSub = [src.value.superscript, src.value.subscript];
|
|
doc = src.value;
|
|
doc.subscript = false;
|
|
var cleared = [doc.superscript, doc.subscript];
|
|
src.setValue(doc);
|
|
var appliedClear = [src.value.superscript, src.value.subscript];
|
|
doc = src.value;
|
|
doc.superscript = true;
|
|
doc.superscript = false;
|
|
var repeatClear = [doc.superscript, doc.subscript];
|
|
[pendingSuper, appliedSuper, pendingSub, appliedSub, cleared, appliedClear, repeatClear];
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!([[true, false], ["super", true, false], [false, true], [false, true], [false, false], [false, false], [false, false]]));
|
|
}
|
|
|
|
#[test]
|
|
fn setting_item_selected_false_preserves_other_project_items() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var target = app.project.items.addFolder("Target");
|
|
var keep = app.project.items.addFolder("Keep");
|
|
app.run("project.select", {items: []});
|
|
keep.selected = true;
|
|
target.selected = true;
|
|
var states = [[target.selected, keep.selected]];
|
|
target.selected = false;
|
|
states.push([target.selected, keep.selected]);
|
|
target.selected = false; // idempotent: never remove Keep
|
|
states.push([target.selected, keep.selected]);
|
|
target.selected = true;
|
|
states.push([target.selected, keep.selected]);
|
|
app.project.selection.map(function (item) { return item.name; }).join(",")
|
|
+ "|" + JSON.stringify(states);
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!("Keep,Target|[[true,true],[false,true],[false,true],[true,true]]"));
|
|
}
|
|
|
|
#[test]
|
|
fn add_solid_keeps_the_requested_pixel_aspect() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("PA", 64, 32, 1, 1, 10);
|
|
var square = c.layers.addSolid([1, 0, 0], "Square", 16, 16, 1);
|
|
var wide = c.layers.addSolid([0, 1, 0], "Wide", 16, 16, 2);
|
|
var inherited = c.layers.addSolid([0, 0, 1], "Inherited", 16, 16);
|
|
[c.pixelAspect, square.source.pixelAspect, wide.source.pixelAspect, inherited.source.pixelAspect,
|
|
wide.source.width, wide.source.height]
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!([1, 1, 2, 1, 16, 16]));
|
|
let bad = run_code(&mut s, r#"app.project.items.addComp("Q", 8, 8, 1, 1, 10).layers.addSolid([1, 1, 1], "Bad", 8, 8, 0)"#, "bad.jsx");
|
|
assert!(bad.error.is_some_and(|e| e.message.contains("pixelAspect")));
|
|
}
|
|
|
|
#[test]
|
|
fn property_selected_false_preserves_other_property_and_key_selections() {
|
|
let mut s = session();
|
|
let out = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("Selection", 64, 64, 1, 1, 10);
|
|
c.openInViewer();
|
|
var layer = c.layers.addSolid([1, 0, 0], "Layer", 8, 8, 1);
|
|
var opacity = layer.transform.opacity;
|
|
var rotation = layer.transform.rotation;
|
|
rotation.setValueAtTime(0, 0);
|
|
rotation.setValueAtTime(0.5, 45);
|
|
app.run("edit.deselectAll", {});
|
|
rotation.selected = true;
|
|
opacity.selected = true;
|
|
var states = [[opacity.selected, rotation.selected]];
|
|
opacity.selected = false;
|
|
states.push([opacity.selected, rotation.selected]);
|
|
opacity.selected = false;
|
|
states.push([opacity.selected, rotation.selected]);
|
|
opacity.selected = true;
|
|
states.push([opacity.selected, rotation.selected]);
|
|
[states, rotation.selectedKeys.length, c.selectedProperties.length];
|
|
"#,
|
|
);
|
|
assert_eq!(out.result, json!([[[true, true], [false, true], [false, true], [true, true]], 2, 2]));
|
|
}
|
|
|
|
#[test]
|
|
fn group_selected_false_deselects_masks_and_effects() {
|
|
let mut s = session();
|
|
let out = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("Groups", 64, 64, 1, 1, 10);
|
|
c.openInViewer();
|
|
var l = c.layers.addSolid([1, 0, 0], "Layer", 64, 64, 1);
|
|
var m = l.property("ADBE Mask Parade").addProperty("ADBE Mask Atom");
|
|
var fx = l.property("ADBE Effect Parade").addProperty("Gaussian Blur");
|
|
app.run("edit.deselectAll", {});
|
|
m.selected = true;
|
|
fx.selected = true;
|
|
var states = [[m.selected, fx.selected]];
|
|
m.selected = false;
|
|
states.push([m.selected, fx.selected]);
|
|
fx.selected = false;
|
|
states.push([m.selected, fx.selected]);
|
|
states;
|
|
"#,
|
|
);
|
|
assert_eq!(out.result, json!([[true, true], [false, true], [false, false]]));
|
|
}
|
|
|
|
/// Probes every file as a 64x32 still (the media layer's single-file probe).
|
|
struct Stills;
|
|
impl effectcraft_engine::Importer for Stills {
|
|
fn probe(&self, path: &str) -> Result<effectcraft_engine::project::Footage, String> {
|
|
use effectcraft_engine::project::{Footage, FootageKind};
|
|
Ok(Footage { path: path.into(), kind: FootageKind::Still, width: 64, height: 32, has_video: true, codec: "PNG".into(), ..Default::default() })
|
|
}
|
|
}
|
|
|
|
/// `importFile(new ImportOptions(file))` imports just the picked file unless `sequence` is set (#475).
|
|
#[test]
|
|
fn import_options_sequence_picks_one_still_or_the_run() {
|
|
use effectcraft_engine::project::{FootageKind, ItemKind};
|
|
let dir = std::env::temp_dir().join(format!("ec-script-seq-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
for n in ["shot_0001.png", "shot_0002.png", "shot_0003.png"] {
|
|
std::fs::write(dir.join(n), b"").unwrap();
|
|
}
|
|
let pick = dir.join("shot_0002.png").to_string_lossy().replace('\\', "/");
|
|
let mut s = session();
|
|
s.importer = Some(Arc::new(Stills));
|
|
let kind_of = |s: &Session, name: &str| {
|
|
let it = s.project.items.values().find(|i| i.name == name).unwrap_or_else(|| panic!("no item {name}"));
|
|
let ItemKind::Footage(f) = &it.kind else { panic!("footage") };
|
|
f.kind
|
|
};
|
|
ok(&mut s, &format!("var io = new ImportOptions(new File('{pick}')); app.project.importFile(io);"));
|
|
assert_eq!(kind_of(&s, "shot_0002.png"), FootageKind::Still);
|
|
ok(&mut s, &format!("var io = new ImportOptions(new File('{pick}')); io.sequence = true; app.project.importFile(io);"));
|
|
assert_eq!(kind_of(&s, "shot_[0001-0003].png"), FootageKind::Sequence);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn binary_file_io_preserves_all_byte_values() {
|
|
let dir = std::env::temp_dir().join(format!("ec-script-binary-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let src = dir.join("sig.bin");
|
|
let dst = dir.join("written.bin");
|
|
let copied = dir.join("copied.bin");
|
|
let original = [0x89u8, 0x50, 0x4e, 0x47, 0x00, 0x7f, 0x80, 0xff, 0x0a];
|
|
std::fs::write(&src, original).unwrap();
|
|
let mut s = session();
|
|
s.path = Some(dir.join("project.ecproj").to_string_lossy().to_string());
|
|
s.prefs.scripting.allow_scripts_write_files = true;
|
|
let q = |p: &std::path::Path| serde_json::to_string(&p.to_string_lossy()).unwrap();
|
|
|
|
let read = ok(
|
|
&mut s,
|
|
&format!(
|
|
"var f = new File({}); f.encoding = 'BINARY'; f.open('r'); var data = f.read(); f.close(); [data.length, data.charCodeAt(0), data.charCodeAt(6), data.charCodeAt(7)]",
|
|
q(&src)
|
|
),
|
|
);
|
|
assert_eq!(read.result, json!([9, 0x89, 0x80, 0xff]));
|
|
|
|
ok(
|
|
&mut s,
|
|
&format!(
|
|
"var f = new File({}); f.encoding = 'BINARY'; f.open('w'); f.write(String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 0, 127, 128, 255, 10)); f.close();",
|
|
q(&dst)
|
|
),
|
|
);
|
|
assert_eq!(std::fs::read(&dst).unwrap(), original);
|
|
|
|
let copy = ok(&mut s, &format!("new File({}).copy({})", q(&src), q(&copied)));
|
|
assert_eq!(copy.result, json!(true));
|
|
assert_eq!(std::fs::read(&copied).unwrap(), original);
|
|
|
|
ok(&mut s, &format!("var f = new File({}); f.encoding = 'BINARY'; f.open('a'); f.write(String.fromCharCode(0xfe)); f.close();", q(&dst)));
|
|
assert_eq!(std::fs::read(&dst).unwrap(), [original.as_slice(), &[0xfe]].concat());
|
|
|
|
let invalid = run_code(
|
|
&mut s,
|
|
&format!("var f = new File({}); f.encoding = 'BINARY'; f.open('w'); f.write(String.fromCharCode(0x100)); f.close();", q(&dst)),
|
|
"invalid-binary.jsx",
|
|
);
|
|
assert!(invalid.error.is_some(), "code points above 255 must not be truncated");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn script_undo_uses_stable_after_effects_command_id() {
|
|
let mut s = session();
|
|
let lookup = ok(&mut s, "app.findMenuCommandId('Undo')");
|
|
assert_eq!(lookup.result, json!(16));
|
|
// ID 16 used to index an unrelated command as the registry grew. It must
|
|
// undo the preceding layer creation rather than dispatching by position.
|
|
let r = ok(&mut s, "var c = app.project.items.addComp('Undo ID', 100, 100, 1, 1, 24); c.layers.addNull(); app.executeCommand(16); c.numLayers");
|
|
assert_eq!(r.result, json!(0));
|
|
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 0);
|
|
// Lookup of other command names still uses the existing registry.
|
|
assert!(ok(&mut s, "app.findMenuCommandId('New Project')").result.as_u64().unwrap_or(0) > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn long_script_undo_group_counts_as_one_level() {
|
|
let mut s = session();
|
|
s.prefs.general.undo_levels = 3;
|
|
ok(&mut s, "var c = app.project.items.addComp('Long Undo', 100, 100, 1, 1, 24); c.layers.addNull();");
|
|
assert_eq!(s.history.undo.len(), 2);
|
|
// More edits than the history cap must still be one undoable group.
|
|
ok(&mut s, "var c = app.project.item(1); app.beginUndoGroup('Forty layers'); for (var i = 0; i < 40; i++) c.layers.addNull(); app.endUndoGroup();");
|
|
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 41);
|
|
assert_eq!(s.history.undo.len(), 3);
|
|
assert_eq!(s.history.undo[2].0, "Forty layers");
|
|
s.undo();
|
|
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 1);
|
|
// Entries preceding the group remain available instead of being trimmed by
|
|
// the forty intermediate operations.
|
|
s.undo();
|
|
assert_eq!(s.project.comp(ItemId(1)).unwrap().layers.len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn script_remove_keys_batches_a_range_as_one_edit() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
"var c = app.project.items.addComp('Batch Keys', 100, 100, 1, 10, 25); var p = c.layers.addNull().transform.rotation; for (var i = 0; i < 5; i++) p.setValueAtTime(i, i * 10); p.removeKeys(2, 4); [p.numKeys, p.keyTime(1), p.keyTime(2)]",
|
|
);
|
|
assert_eq!(o.result, json!([2, 0, 4]));
|
|
// The entire deletion is one undo step; keys outside the range survive.
|
|
s.undo();
|
|
let restored = ok(&mut s, "app.project.item(1).layer(1).transform.rotation.numKeys");
|
|
assert_eq!(restored.result, json!(5));
|
|
let invalid = run_code(&mut s, "app.project.item(1).layer(1).transform.rotation.removeKeys(0, 2)", "remove-keys-range.jsx");
|
|
assert!(invalid.error.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn script_layer_comment_and_unnamed_marker_params_round_trip() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("Notes", 100, 100, 1, 5, 25);
|
|
var l = c.layers.addSolid([1, 0, 0], "Solid", 10, 10, 1);
|
|
l.comment = "Saved layer note";
|
|
var markers = l.property("ADBE Marker");
|
|
var plain = new MarkerValue("Plain");
|
|
plain.setParameters({ lml: "shot", index: "1" });
|
|
markers.setValueAtTime(1, plain);
|
|
var named = new MarkerValue("Named");
|
|
named.cuePointName = "cue";
|
|
named.setParameters({ lml: "shot", index: "2" });
|
|
markers.setValueAtTime(2, named);
|
|
[l.comment, markers.keyValue(1).getParameters().index,
|
|
markers.keyValue(1).cuePointName, markers.keyValue(2).getParameters().index]
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!(["Saved layer note", "1", "", "2"]));
|
|
}
|
|
|
|
#[test]
|
|
fn script_av_layer_transform_shortcuts_return_live_properties() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("Shortcuts", 100, 100, 1, 5, 25);
|
|
var l = c.layers.addSolid([1, 0, 0], "S", 10, 10, 1);
|
|
l.position.setValue([30, 40]);
|
|
l.rotation.setValueAtTime(1, 45);
|
|
[l.position.value[0], l.transform.position.value[1], l.rotation.numKeys,
|
|
l.opacity === undefined, l.scale === undefined, l.anchorPoint === undefined]
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!([30, 40, 1, false, false, false]));
|
|
s.undo();
|
|
let restored = ok(&mut s, "app.project.item(1).layer(1).rotation.numKeys");
|
|
assert_eq!(restored.result, json!(0));
|
|
}
|
|
|
|
#[test]
|
|
fn extendscript_substr_keeps_start_and_length_semantics() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"[
|
|
"abcdef".substr(0, 2),
|
|
"abcdef".substr(2, 3),
|
|
"abcdef".substr(-2),
|
|
"abcdef".substr(-20, 2),
|
|
"abcdef".substr(2, 0),
|
|
"abcdef".substr(2, -3),
|
|
"abcdef".substr(2.9, 2.9),
|
|
"abcdef".substr(3),
|
|
"abcdef".substr(30, 2),
|
|
"abcdef".substr(NaN, 2),
|
|
"abcdef".substr(2, NaN)
|
|
]"#,
|
|
);
|
|
assert_eq!(o.result, json!(["ab", "cde", "ef", "ab", "", "", "cd", "def", "", "ab", ""]));
|
|
}
|
|
|
|
#[test]
|
|
fn import_options_preserve_layered_psd_compositions() {
|
|
use effectcraft_psd::Rect;
|
|
use effectcraft_psd::write::{WDoc, WLayer, write};
|
|
|
|
let dir = std::env::temp_dir().join(format!("ec-script-import-as-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let source = dir.join("layers.psd");
|
|
let mut doc = WDoc::new(12, 12);
|
|
doc.layers.push(WLayer::solid("Background", Rect::new(0, 0, 12, 12), [1.0, 1.0, 1.0, 1.0]));
|
|
doc.layers.push(WLayer::solid("Accent", Rect::new(2, 2, 4, 4), [0.0, 0.4, 1.0, 1.0]));
|
|
std::fs::write(&source, write(&doc)).unwrap();
|
|
let file = serde_json::to_string(&source.to_string_lossy()).unwrap();
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
&format!(
|
|
"var io = new ImportOptions(new File({file})); io.importAs = ImportAsType.COMPOSITION_CROPPED_LAYERS; \
|
|
[io.canImportAs(io.importAs), app.project.importFile(io).typeName]"
|
|
),
|
|
);
|
|
assert_eq!(o.result, json!([true, "Composition"]));
|
|
let imported = s.project.comps().next().unwrap().1;
|
|
assert_eq!(imported.layers.len(), 2);
|
|
let bad = ok(&mut s, &format!("new ImportOptions(new File({file})).canImportAs(ImportAsType.PROJECT)"));
|
|
assert_eq!(bad.result, json!(false));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn script_item_and_layer_identity_survives_repeated_access() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("Identity", 120, 80, 1, 3, 25);
|
|
var parent = c.layers.addNull(), child = c.layers.addNull();
|
|
child.parent = parent;
|
|
[
|
|
parent.containingComp === c,
|
|
c.layer(1) === c.layer(1),
|
|
child.parent === parent,
|
|
app.project.item(1) === app.project.item(1),
|
|
app.project.item(1) === c,
|
|
app.project.itemByID(c.id) === c
|
|
]
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!([true, true, true, true, true, true]));
|
|
// Reusing item and layer IDs in a new project must not alias old wrappers.
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var old = app.project.item(1);
|
|
app.newProject();
|
|
var next = app.project.items.addComp("New", 120, 80, 1, 3, 25);
|
|
[old !== next, app.project.item(1) === next]
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!([true, true]));
|
|
}
|
|
|
|
#[test]
|
|
fn script_text_path_options_expose_after_effects_match_names() {
|
|
let mut s = session();
|
|
let r = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("Text Path", 160, 90, 1, 2, 24);
|
|
var t = c.layers.addText("On a path");
|
|
var options = t.property("ADBE Text Properties").property("ADBE Text Path Options");
|
|
var names = [];
|
|
for (var i = 1; i <= options.numProperties; i++)
|
|
names.push(options.property(i).matchName);
|
|
[names, options.property("ADBE Text Path").matchName,
|
|
options.property("ADBE Text Perpendicular To Path").matchName]
|
|
"#,
|
|
);
|
|
assert_eq!(
|
|
r.result,
|
|
json!([
|
|
[
|
|
"ADBE Text Path",
|
|
"ADBE Text Reverse Path",
|
|
"ADBE Text Perpendicular To Path",
|
|
"ADBE Text Force Align Path",
|
|
"ADBE Text First Margin",
|
|
"ADBE Text Last Margin"
|
|
],
|
|
"ADBE Text Path",
|
|
"ADBE Text Perpendicular To Path"
|
|
])
|
|
);
|
|
}
|
|
|
|
/// A scripted text animator has only the selectors the script adds, and the Range Selector and
|
|
/// its Start/End/Offset resolve by After Effects match name (#837).
|
|
#[test]
|
|
fn scripted_text_range_selector_resolves_and_is_the_only_selector() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("Sel", 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");
|
|
a.property("ADBE Text Animator Properties").addProperty("ADBE Text Opacity").setValue(0);
|
|
var sels = a.property("ADBE Text Selectors");
|
|
var sel = sels.addProperty("ADBE Text Selector");
|
|
sel.property("ADBE Text Percent Start").setValue(50);
|
|
var end = sel.property("ADBE Text Percent End").value;
|
|
[sels.numProperties, sel.matchName, sel.property("start").value, end, sel.property("ADBE Text Percent Offset").matchName]
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!([1, "ADBE Text Selector", 50, 100, "ADBE Text Percent Offset"]));
|
|
}
|
|
|
|
/// Pro Levels2's Alpha controls sit one group down (under Alpha), so a script finds them by their
|
|
/// name on the effect as well as through the group (#1063).
|
|
#[test]
|
|
fn scripted_levels_alpha_controls_resolve_from_the_effect() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("Levels", 320, 180, 1, 3, 30);
|
|
var l = c.layers.addSolid([1, 0, 0], "S", 320, 180, 1);
|
|
var fx = l.property("ADBE Effect Parade").addProperty("ADBE Pro Levels2");
|
|
fx.property("Alpha Input White").setValue(0.5);
|
|
[fx.property("Alpha").property("Alpha Input White").value, fx.property("Alpha Input White").value]
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!([0.5, 0.5]));
|
|
}
|
|
|
|
/// 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]
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!(["Tracking Amount", 20, "Tracking Amount"]));
|
|
}
|