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