mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
1245 lines
55 KiB
Rust
1245 lines
55 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}"))
|
|
}
|
|
|
|
/// #1063 (item 8): Source Text retains CR while sourceRectAtTime sees both lines.
|
|
#[test]
|
|
fn carriage_return_source_text_has_multiline_script_bounds() {
|
|
let mut s = session();
|
|
let result = ok(
|
|
&mut s,
|
|
r#"
|
|
var comp = app.project.items.addComp("Line breaks", 640, 360, 1, 1, 30);
|
|
var crDoc = new TextDocument("Line 1\rLine 2");
|
|
crDoc.font = "Inter";
|
|
crDoc.fontSize = 40;
|
|
var lfDoc = new TextDocument("Line 1\nLine 2");
|
|
lfDoc.font = "Inter";
|
|
lfDoc.fontSize = 40;
|
|
var cr = comp.layers.addText(crDoc);
|
|
var lf = comp.layers.addText(lfDoc);
|
|
var crRect = cr.sourceRectAtTime(0, false);
|
|
var lfRect = lf.sourceRectAtTime(0, false);
|
|
[cr.text.sourceText.value.text, crRect.height, lfRect.height, crRect.width, lfRect.width]
|
|
"#,
|
|
);
|
|
assert_eq!(result.result[0], "Line 1\rLine 2");
|
|
assert!(result.result[1].as_f64().unwrap() > 40.0);
|
|
assert_eq!(result.result[1], result.result[2]);
|
|
assert_eq!(result.result[3], result.result[4]);
|
|
}
|
|
|
|
#[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:?}");
|
|
}
|
|
|
|
/// A script wrapped in a function (the usual `(function () { ... })();` shape) reports the line
|
|
/// that failed, not the wrapper's line (#1063).
|
|
#[test]
|
|
fn errors_inside_a_function_wrapper_report_the_failing_line() {
|
|
let mut s = session();
|
|
let o = run_code(&mut s, "(function () {\n var a = 1;\n throw new Error('inside');\n})();\n", "thrown.jsx");
|
|
let e = o.error.expect("thrown");
|
|
assert_eq!(e.message, "inside");
|
|
assert_eq!(e.line, Some(3), "{e:?}");
|
|
let o = run_code(&mut s, "(function () {\n var a = 1;\n nope.call();\n})();\n", "reference.jsx");
|
|
let e = o.error.expect("reference error");
|
|
assert_eq!(e.line, Some(3), "{e:?}");
|
|
let o = run_code(&mut s, "(function () {\n var c = app.project.items.addComp('C', 10, 10, 1, 1, 24);\n c.layer(5);\n})();\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@[email protected]@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);
|
|
}
|
|
|
|
/// #813: FootageItem proxies, replaceWithSequence and mainSource.conformFrameRate.
|
|
#[test]
|
|
fn footage_proxies_sequences_and_conform_rate() {
|
|
use effectcraft_engine::project::ItemKind;
|
|
let dir = std::env::temp_dir().join(format!("ec-script-proxy-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
for n in ["shot_0001.png", "shot_0002.png", "take_0001.png", "take_0002.png", "proxy.png"] {
|
|
std::fs::write(dir.join(n), b"").unwrap();
|
|
}
|
|
let p = |n: &str| dir.join(n).to_string_lossy().replace('\\', "/");
|
|
let mut s = session();
|
|
s.importer = Some(Arc::new(Stills));
|
|
let o = ok(
|
|
&mut s,
|
|
&format!(
|
|
"var io = new ImportOptions(new File('{}')); io.sequence = true; var item = app.project.importFile(io);\
|
|
var same = item.mainSource === item.mainSource;\
|
|
item.mainSource.conformFrameRate = 12; var c12 = item.mainSource.conformFrameRate, fr = item.frameRate;\
|
|
item.mainSource.conformFrameRate = 0; var c0 = item.mainSource.conformFrameRate;\
|
|
var before = [item.useProxy, item.proxySource];\
|
|
item.setProxy(new File('{}')); var set = [item.useProxy, item.proxySource.file.name];\
|
|
item.useProxy = false; var off = item.useProxy;\
|
|
item.setProxyToNone(); var none = item.proxySource;\
|
|
item.replaceWithSequence(new File('{}'), false);\
|
|
[same, c12, fr, c0, before[0], before[1], set[0], set[1], off, none]",
|
|
p("shot_0001.png"),
|
|
p("proxy.png"),
|
|
p("take_0001.png")
|
|
),
|
|
);
|
|
assert_eq!(o.result, json!([true, 12, 12, 0, false, null, true, "proxy.png", false, null]));
|
|
let paths: Vec<String> =
|
|
s.project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.path.replace('\\', "/")) } else { None }).collect();
|
|
assert!(paths.iter().any(|f| f.contains("take_")), "replaced with the take sequence: {paths:?}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// A flat green footage source, so `sampleImage` on a footage layer has known pixels.
|
|
struct Flat;
|
|
impl effectcraft_engine::render::FootageSource for Flat {
|
|
fn frame(
|
|
&self,
|
|
_item: effectcraft_engine::project::ItemId,
|
|
f: &effectcraft_engine::project::Footage,
|
|
_t: effectcraft_engine::time::Tick,
|
|
) -> Option<Arc<effectcraft_engine::render::Image>> {
|
|
Some(Arc::new(effectcraft_engine::render::Image::filled(f.width.max(1), f.height.max(1), [0.0, 1.0, 0.0, 1.0])))
|
|
}
|
|
}
|
|
|
|
/// `sampleImage` on a footage layer reads the footage's pixels from a script, not transparent
|
|
/// black: the expression evaluator needs the session's footage source (#1000).
|
|
#[test]
|
|
fn sample_image_reads_footage_pixels_in_scripts() {
|
|
let mut s = session();
|
|
s.footage = Arc::new(Flat);
|
|
s.importer = Some(Arc::new(Stills));
|
|
let out = ok(
|
|
&mut s,
|
|
r#"
|
|
var comp = app.project.items.addComp("Scene", 640, 360, 1, 1, 25);
|
|
comp.openInViewer();
|
|
var img = comp.layers.add(app.project.importFile(new ImportOptions(new File("/tmp/ec-flat.png"))));
|
|
img.name = "Img";
|
|
var cc = comp.layers.addNull().property("ADBE Effect Parade").addProperty("ADBE Color Control").property(1);
|
|
cc.expression = 'thisComp.layer("Img").sampleImage([32, 16], [0.5, 0.5], true, time);';
|
|
var v = cc.valueAtTime(0, false);
|
|
[v[0], v[1], v[2], v[3], cc.expressionError];
|
|
"#,
|
|
);
|
|
assert_eq!(out.result, json!([0, 1, 0, 1, ""]), "green footage sampled, not transparent black");
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
|
|
/// #814: one key is looked up without serialising the whole list (removeKey on a property with
|
|
/// thousands of keys was linear in the key count), and the key calls behave as before.
|
|
#[test]
|
|
fn script_key_lookup_fetches_only_that_key() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
"var c = app.project.items.addComp('Keys', 100, 100, 1, 100, 25); var p = c.layers.addNull().transform.rotation;\
|
|
var ts = [], vs = []; for (var i = 0; i < 2000; i++) { ts.push(i / 25); vs.push(i); } p.setValuesAtTimes(ts, vs);\
|
|
var q = { comp: p.__layer.__comp, layer: p.__layer.__id, uid: p.__uid };\
|
|
q.index = 2; var one = __get('keys', q); q.index = 2000; var none = __get('keys', q);\
|
|
p.removeKey(p.numKeys); p.removeKey(1);\
|
|
var err = ''; try { p.keyTime(1999); } catch (e) { err = e.message; }\
|
|
[one.count, one.key.time, none.key, p.numKeys, p.keyTime(1), p.keyValue(1998), err]",
|
|
);
|
|
assert_eq!(o.result, json!([2000, 0.08, null, 1998, 0.04, 1998, "keyIndex 1999 is out of range 1..1998"]));
|
|
}
|
|
|
|
/// #815: a stroke's Dashes use After Effects' match names, and addProperty with them returns
|
|
/// Dash 1 / Gap 1 / Offset or adds the Dash 2 / Gap 2 pair.
|
|
#[test]
|
|
fn script_stroke_dashes_use_after_effects_match_names() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
"function names(g) { var a = []; for (var i = 1; i <= g.numProperties; i++) a.push(g.property(i).matchName); return a.join(','); }\
|
|
var comp = app.project.items.addComp('Names', 100, 100, 1, 10, 25);\
|
|
var contents = comp.layers.addShape().property('ADBE Root Vectors Group'); contents.addProperty('ADBE Vector Shape - Rect');\
|
|
var d = contents.addProperty('ADBE Vector Graphic - Stroke').property('ADBE Vector Stroke Dashes');\
|
|
var before = names(d);\
|
|
var dash1 = d.addProperty('ADBE Vector Stroke Dash 1'); dash1.setValue(12);\
|
|
var gap2 = d.addProperty('ADBE Vector Stroke Gap 2');\
|
|
var err = ''; try { d.addProperty('ADBE Vector Stroke Dash 9'); } catch (e) { err = e.message; }\
|
|
[before, dash1.matchName, d.property('ADBE Vector Stroke Dash 1').value, d.property('ADBE Vector Stroke Offset').matchName, gap2.matchName, names(d), err]",
|
|
);
|
|
assert_eq!(
|
|
o.result,
|
|
json!([
|
|
"ADBE Vector Stroke Dash 1,ADBE Vector Stroke Gap 1,ADBE Vector Stroke Offset",
|
|
"ADBE Vector Stroke Dash 1",
|
|
12,
|
|
"ADBE Vector Stroke Offset",
|
|
"ADBE Vector Stroke Gap 2",
|
|
"ADBE Vector Stroke Dash 1,ADBE Vector Stroke Gap 1,ADBE Vector Stroke Dash 2,ADBE Vector Stroke Gap 2,ADBE Vector Stroke Offset",
|
|
"Can not add \"ADBE Vector Stroke Dash 9\" to Dashes"
|
|
])
|
|
);
|
|
}
|
|
|
|
#[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"]));
|
|
}
|
|
|
|
/// `setInterpolationTypeAtKey` and `setTemporalEaseAtKey` (`keys.interpolation` / `keys.velocity`)
|
|
/// act on effect parameters too, not just transform properties (#994).
|
|
#[test]
|
|
fn effect_parameter_interpolation_and_ease() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var c = app.project.items.addComp("E", 100, 100, 1, 5, 25);
|
|
var l = c.layers.addNull();
|
|
var slider = l.property("ADBE Effect Parade").addProperty("ADBE Slider Control").property(1);
|
|
slider.setValuesAtTimes([0, 1, 2], [0, 50, 100]);
|
|
slider.setInterpolationTypeAtKey(1, KeyframeInterpolationType.BEZIER, KeyframeInterpolationType.BEZIER);
|
|
slider.setTemporalEaseAtKey(2, [new KeyframeEase(0, 16.67)], [new KeyframeEase(0, 33.33)]);
|
|
var point = l.property("ADBE Effect Parade").addProperty("ADBE Point3D Control").property(1);
|
|
point.setValuesAtTimes([0, 1], [[0, 0, 0], [50, 50, 50]]);
|
|
point.setInterpolationTypeAtKey(1, KeyframeInterpolationType.BEZIER, KeyframeInterpolationType.BEZIER);
|
|
[slider.keyInInterpolationType(1) == KeyframeInterpolationType.BEZIER,
|
|
slider.keyOutInterpolationType(1) == KeyframeInterpolationType.BEZIER,
|
|
slider.keyInTemporalEase(2)[0].influence,
|
|
slider.keyOutTemporalEase(2)[0].influence,
|
|
point.keyInInterpolationType(1) == KeyframeInterpolationType.BEZIER];
|
|
"#,
|
|
);
|
|
assert_eq!(o.result, json!([true, true, 16.67, 33.33, true]));
|
|
}
|
|
|
|
/// #808: sourcePointToComp and compPointToSource follow the layer's transform.
|
|
#[test]
|
|
fn source_point_conversions_follow_the_layer_transform() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var comp = app.project.items.addComp("Repro", 1920, 1080, 1, 10, 25);
|
|
var layer = comp.layers.addSolid([1, 0, 0], "S", 200, 100, 1);
|
|
layer.transform.position.setValue([400, 300]);
|
|
layer.transform.scale.setValue([50, 50]);
|
|
var a = layer.sourcePointToComp([0, 0]);
|
|
var b = layer.compPointToSource([400, 300]);
|
|
[a[0], a[1], b[0], b[1]]
|
|
"#,
|
|
);
|
|
let v = o.result;
|
|
let near = |x: &Value, want: f64| x.as_f64().is_some_and(|g| (g - want).abs() < 1e-6);
|
|
assert!(near(&v[0], 350.0) && near(&v[1], 275.0), "{v}");
|
|
assert!(near(&v[2], 100.0) && near(&v[3], 50.0), "{v}");
|
|
}
|
|
|
|
/// #1063: a script asks a light's options for After Effects' "ADBE Casts Shadows".
|
|
#[test]
|
|
fn light_options_accept_the_after_effects_casts_shadows_name() {
|
|
let mut s = session();
|
|
let o = ok(
|
|
&mut s,
|
|
r#"
|
|
var comp = app.project.items.addComp("Repro", 1920, 1080, 1, 10, 25);
|
|
var light = comp.layers.addLight("Key", [100, 100]);
|
|
var c = light.property("ADBE Light Options Group").property("ADBE Casts Shadows");
|
|
c == null ? "null" : c.name
|
|
"#,
|
|
);
|
|
assert_ne!(o.result, json!("null"), "{:?}", o.result);
|
|
}
|