//! 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); } #[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); }