//! Tests for the menu-parity command families (Layer / Edit / Animation / File / Composition / //! View menus), including undo. use effectcraft_keyframe::Value as KV; use effectcraft_project::{FrameBlend, ItemKind, MatteKind, Quality, Sampling}; use serde_json::{Value, json}; use crate::{EngineError, Event, Session}; fn comp() -> Session { let mut s = Session::default(); s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap(); s } fn solid(s: &mut Session, color: &str) -> u64 { s.execute("layer.newSolid", json!({"color": color, "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap() } fn layer(s: &Session, id: u64) -> effectcraft_project::Layer { s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone() } fn tmp(name: &str) -> String { let d = std::env::temp_dir().join(format!("effectcraft-menu-tests-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); d.join(name).to_string_lossy().to_string() } fn frontend_events(s: &mut Session) -> Vec<(String, Value)> { s.drain_events() .into_iter() .filter_map(|e| match e { Event::Frontend { command, params } => Some((command, params)), _ => None, }) .collect() } #[test] fn layer_quality_sampling_frame_blending_and_undo() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); s.execute("layer.quality", json!({"quality": "wireframe"})).unwrap(); s.execute("layer.sampling", json!({"sampling": "bicubic"})).unwrap(); s.execute("layer.frameBlending", json!({"mode": "pixelMotion"})).unwrap(); let l = layer(&s, a); assert_eq!((l.switches.quality, l.switches.sampling, l.switches.frame_blend), (Quality::Wireframe, Sampling::Bicubic, FrameBlend::PixelMotion)); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(layer(&s, a).switches.frame_blend, FrameBlend::Off); assert!(s.execute("layer.quality", json!({"quality": "fancy"})).is_err()); } #[test] fn layer_switches_menu() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); let b = solid(&mut s, "#00ff00"); s.execute("layer.select", json!({"layers": [b]})).unwrap(); s.execute("layer.hideOtherVideo", json!({})).unwrap(); assert!(!layer(&s, a).switches.video && layer(&s, b).switches.video); s.execute("layer.showAllVideo", json!({})).unwrap(); assert!(layer(&s, a).switches.video); s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "lock"})).unwrap(); assert!(layer(&s, a).switches.locked); s.execute("layer.unlockAll", json!({})).unwrap(); assert!(!layer(&s, a).switches.locked && !layer(&s, b).switches.locked); s.execute("edit.undo", json!({})).unwrap(); assert!(layer(&s, a).switches.locked); // Expressions on / off. s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap(); s.execute("layer.expressions", json!({"layers": [a], "enabled": false})).unwrap(); assert!(!layer(&s, a).props.prop("transform/opacity").unwrap().expr.as_ref().unwrap().enabled); s.execute("layer.expressions", json!({"layers": [a], "enabled": true})).unwrap(); assert!(layer(&s, a).props.prop("transform/opacity").unwrap().has_expression()); } #[test] fn transform_dialog_values_and_center_anchor() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); s.execute("layer.setTransform", json!({"prop": "position", "value": [100, 50]})).unwrap(); s.execute("layer.setTransform", json!({"prop": "rotation", "value": 90})).unwrap(); s.execute("layer.setTransform", json!({"prop": "opacity", "value": 25})).unwrap(); let l = layer(&s, a); assert_eq!(l.props.prop("transform/position").unwrap().value, KV::Vec3([100.0, 50.0, 0.0])); assert_eq!(l.props.prop("transform/opacity").unwrap().value.as_f64(), 25.0); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(layer(&s, a).props.prop("transform/opacity").unwrap().value.as_f64(), 100.0); assert!(s.execute("layer.setTransform", json!({"prop": "skew", "value": 1})).is_err()); // Move the anchor off-centre, then Center Anchor Point keeps the layer in place. s.execute("layer.setTransform", json!({"prop": "anchor", "value": [0, 0]})).unwrap(); s.execute("layer.setTransform", json!({"prop": "rotation", "value": 0})).unwrap(); let before = layer(&s, a).props.prop("transform/position").unwrap().value.as_vec3(); s.execute("layer.centerAnchor", json!({})).unwrap(); let l = layer(&s, a); assert_eq!(l.props.prop("transform/anchor").unwrap().value.as_vec2(), [100.0, 50.0]); let after = l.props.prop("transform/position").unwrap().value.as_vec3(); assert!((after[0] - before[0] - 100.0).abs() < 1e-6 && (after[1] - before[1] - 50.0).abs() < 1e-6, "{before:?} → {after:?}"); s.execute("layer.autoOrient", json!({"mode": "alongPath"})).unwrap(); assert_eq!(layer(&s, a).auto_orient, effectcraft_project::AutoOrient::AlongPath); } #[test] fn mask_menu_on_existing_masks() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); assert!(!s.is_enabled("layer.mask.reset")); s.execute("layer.addMask", json!({})).unwrap(); s.execute("layer.addMask", json!({"shape": "ellipse"})).unwrap(); assert!(s.is_enabled("layer.mask.reset")); let masks = |s: &Session| layer(s, a).masks().unwrap().groups().cloned().collect::>(); s.execute("layer.mask.set", json!({"field": "feather", "value": 12, "mask": 1})).unwrap(); assert_eq!(masks(&s)[0].get("feather").unwrap().value, KV::Vec2([12.0, 12.0])); s.execute("layer.mask.set", json!({"field": "opacity", "value": 40})).unwrap(); assert!(masks(&s).iter().all(|m| m.get("opacity").unwrap().value.as_f64() == 40.0)); s.execute("layer.mask.invert", json!({"mask": 2})).unwrap(); s.execute("layer.mask.mode", json!({"mode": "Subtract", "mask": 2})).unwrap(); s.execute("layer.mask.lock", json!({"mask": 1})).unwrap(); let kinds: Vec<_> = masks(&s).iter().map(|m| m.kind.clone()).collect(); assert!(matches!(kinds[1], effectcraft_project::GroupKind::Mask { inverted: true, mode: effectcraft_project::MaskMode::Subtract, .. })); assert!(matches!(kinds[0], effectcraft_project::GroupKind::Mask { locked: true, .. })); s.execute("layer.mask.unlockAll", json!({})).unwrap(); assert!(matches!(masks(&s)[0].kind, effectcraft_project::GroupKind::Mask { locked: false, .. })); s.execute("layer.mask.lockOthers", json!({"mask": 1})).unwrap(); assert!(matches!(masks(&s)[1].kind, effectcraft_project::GroupKind::Mask { locked: true, .. })); s.execute("layer.mask.reset", json!({"mask": 1})).unwrap(); assert_eq!(masks(&s)[0].get("opacity").unwrap().value.as_f64(), 100.0); s.execute("layer.mask.shape", json!({"mask": 1, "rect": [10, 10, 50, 40], "shape": "ellipse"})).unwrap(); s.execute("layer.mask.remove", json!({"mask": 2})).unwrap(); assert_eq!(masks(&s).len(), 1); s.execute("layer.mask.removeAll", json!({})).unwrap(); assert!(masks(&s).is_empty()); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(masks(&s).len(), 1); } #[test] fn layer_markers_lock_and_delete() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); s.execute("time.set", json!({"time": 1.0})).unwrap(); s.execute("layer.addMarker", json!({"comment": "hit"})).unwrap(); s.execute("layer.addMarker", json!({"time": 2.0})).unwrap(); assert_eq!(layer(&s, a).markers.len(), 2); s.execute("layer.markersLock", json!({})).unwrap(); assert!(layer(&s, a).markers_locked); s.execute("layer.deleteAllMarkers", json!({})).unwrap(); assert_eq!(layer(&s, a).markers.len(), 2, "locked markers survive"); s.execute("layer.markersLock", json!({"value": false})).unwrap(); s.execute("layer.deleteAllMarkers", json!({})).unwrap(); assert!(layer(&s, a).markers.is_empty()); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(layer(&s, a).markers.len(), 2); } #[test] fn track_matte_menu() { let mut s = comp(); let below = solid(&mut s, "#ff0000"); let top = solid(&mut s, "#00ff00"); s.execute("layer.select", json!({"layers": [below]})).unwrap(); s.execute("layer.trackMatte", json!({"op": "luma"})).unwrap(); let m = layer(&s, below).track_matte.unwrap(); assert_eq!((m.layer.0, m.kind), (top, MatteKind::Luma)); assert!(!layer(&s, top).switches.video); s.execute("layer.trackMatte", json!({"op": "alphaInverted"})).unwrap(); assert_eq!(layer(&s, below).track_matte.unwrap().kind, MatteKind::AlphaInverted); s.execute("layer.trackMatte", json!({"op": "none"})).unwrap(); assert!(layer(&s, below).track_matte.is_none()); s.execute("layer.select", json!({"layers": [top]})).unwrap(); assert!(s.execute("layer.trackMatte", json!({"op": "above"})).is_err()); s.execute("layer.trackMatte", json!({"op": "below"})).unwrap(); assert_eq!(layer(&s, top).track_matte.unwrap().layer.0, below); } #[test] fn keyframe_clipboard_paste_and_paste_reversed() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); let b = solid(&mut s, "#00ff00"); s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/opacity"})).unwrap(); s.execute("time.set", json!({"time": 1.0})).unwrap(); s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 0})).unwrap(); s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap(); s.execute("edit.copy", json!({})).unwrap(); assert_eq!(s.state.key_clipboard.iter().map(|c| c.keys.len()).sum::(), 2); s.execute("layer.select", json!({"layers": [b]})).unwrap(); s.execute("time.set", json!({"time": 2.0})).unwrap(); s.execute("edit.paste", json!({})).unwrap(); let keys = layer(&s, b).props.prop("transform/opacity").unwrap().keys.clone(); assert_eq!(keys.len(), 2); assert!((keys[0].time.seconds() - 2.0).abs() < 1e-6 && keys[0].value.as_f64() == 100.0); s.execute("edit.undo", json!({})).unwrap(); s.execute("edit.pasteReversedKeyframes", json!({})).unwrap(); let keys = layer(&s, b).props.prop("transform/opacity").unwrap().keys.clone(); assert_eq!((keys[0].value.as_f64(), keys[1].value.as_f64()), (0.0, 100.0)); } #[test] fn property_links_and_expression_only() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); let b = solid(&mut s, "#00ff00"); s.execute("prop.select", json!({"layer": a, "path": "transform/position"})).unwrap(); s.execute("edit.copyWithPropertyLinks", json!({})).unwrap(); s.execute("layer.select", json!({"layers": [b]})).unwrap(); s.execute("edit.paste", json!({})).unwrap(); let e = layer(&s, b).props.prop("transform/position").unwrap().expr.clone().unwrap(); let name = layer(&s, a).name; assert_eq!(e.text, format!("comp(\"Main\").layer({}).transform(\"Position\")", serde_json::to_string(&name).unwrap())); // Relative links use thisComp. s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap(); s.execute("edit.copyWithRelativePropertyLinks", json!({})).unwrap(); s.execute("layer.select", json!({"layers": [b]})).unwrap(); s.execute("edit.paste", json!({})).unwrap(); assert!(layer(&s, b).props.prop("transform/opacity").unwrap().expr.as_ref().unwrap().text.starts_with("thisComp.layer(")); // Copy Expression Only. s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "time * 90"})).unwrap(); s.execute("prop.select", json!({"layer": a, "path": "transform/rotation"})).unwrap(); s.execute("edit.copyExpressionOnly", json!({})).unwrap(); s.execute("layer.select", json!({"layers": [b]})).unwrap(); s.execute("edit.paste", json!({})).unwrap(); assert_eq!(layer(&s, b).props.prop("transform/rotation").unwrap().expr.as_ref().unwrap().text, "time * 90"); // Layers with property links: the pasted copy follows the original. s.state.selected_props.clear(); s.execute("layer.select", json!({"layers": [a]})).unwrap(); s.execute("edit.copyWithPropertyLinks", json!({})).unwrap(); let new = s.execute("edit.paste", json!({})).unwrap(); let nid = new[0].as_u64().unwrap(); assert!(layer(&s, nid).props.prop("transform/scale").unwrap().has_expression()); } #[test] fn lift_and_extract_work_area() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); s.execute("comp.workArea", json!({"start": 2.0, "end": 4.0})).unwrap(); s.execute("edit.liftWorkArea", json!({})).unwrap(); let c = s.active_comp().unwrap(); assert_eq!(c.layers.len(), 2, "split around the gap"); let mut spans: Vec<(f64, f64)> = c.layers.iter().map(|l| (l.in_point.seconds(), l.out_point.seconds())).collect(); spans.sort_by(|x, y| x.0.total_cmp(&y.0)); assert!((spans[0].1 - 2.0).abs() < 1e-6 && (spans[1].0 - 4.0).abs() < 1e-6); s.execute("edit.undo", json!({})).unwrap(); s.execute("layer.select", json!({"layers": [a]})).unwrap(); s.execute("edit.extractWorkArea", json!({})).unwrap(); let c = s.active_comp().unwrap(); let mut spans: Vec<(f64, f64)> = c.layers.iter().map(|l| (l.in_point.seconds(), l.out_point.seconds())).collect(); spans.sort_by(|x, y| x.0.total_cmp(&y.0)); assert!((spans[1].0 - 2.0).abs() < 1e-6 && (spans[1].1 - 8.0).abs() < 1e-6, "{spans:?}"); } #[test] fn label_group_purge_and_edit_original() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); let b = solid(&mut s, "#00ff00"); let _c = solid(&mut s, "#0000ff"); s.execute("edit.label", json!({"layers": [a, b], "label": "Cyan"})).unwrap(); s.execute("layer.select", json!({"layers": [a]})).unwrap(); s.execute("edit.selectLabelGroup", json!({})).unwrap(); assert_eq!(s.state.selected_layers.len(), 2); // A footage source that counts purges (the media pool drops its decoded frames). struct Counting(std::sync::atomic::AtomicUsize); impl crate::render::FootageSource for Counting { fn frame( &self, _: effectcraft_project::ItemId, _: &effectcraft_project::Footage, _: effectcraft_time::Tick, ) -> Option> { None } fn purge(&self) { self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); } } let src = std::sync::Arc::new(Counting(Default::default())); s.footage = src.clone(); s.drain_events(); s.execute("edit.purge", json!({"what": "memory"})).unwrap(); assert!(s.drain_events().contains(&Event::PurgeCaches)); assert_eq!(src.0.load(std::sync::atomic::Ordering::SeqCst), 1, "decoded footage frames are dropped"); // The disk cache and the snapshot purge alone: the RAM preview stays. for what in ["disk", "snapshot"] { s.execute("edit.purge", json!({"what": what})).unwrap(); assert!(!s.drain_events().contains(&Event::PurgeCaches), "{what}"); } assert_eq!(src.0.load(std::sync::atomic::Ordering::SeqCst), 1); s.execute("edit.purge", json!({"what": "3d"})).unwrap(); assert!(s.drain_events().contains(&Event::PurgeCaches)); assert!(!s.is_enabled("edit.editOriginal")); } #[test] fn animation_menu_keys_presets_text() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); // Add Keyframe on selected properties. s.execute("prop.select", json!({"layer": a, "path": "transform/scale"})).unwrap(); s.execute("anim.addKeyframe", json!({})).unwrap(); s.execute("time.set", json!({"time": 1.0})).unwrap(); s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [400, 400]})).unwrap(); assert_eq!(layer(&s, a).props.prop("transform/scale").unwrap().keys.len(), 2); // Exponential Scale: a key per frame, geometric in between. s.execute("prop.select", json!({"layer": a, "path": "transform/scale"})).unwrap(); s.execute("keys.exponentialScale", json!({})).unwrap(); let keys = layer(&s, a).props.prop("transform/scale").unwrap().keys.clone(); assert_eq!(keys.len(), 31); assert!((keys[15].value.as_vec3()[0] - 200.0).abs() < 1e-6, "{:?}", keys[15].value); s.execute("edit.undo", json!({})).unwrap(); // Time-Reverse Keyframes. s.execute("prop.select", json!({"layer": a, "path": "transform/scale"})).unwrap(); s.execute("keys.timeReverse", json!({})).unwrap(); let keys = layer(&s, a).props.prop("transform/scale").unwrap().keys.clone(); assert_eq!((keys[0].value.as_vec3()[0], keys[1].value.as_vec3()[0]), (400.0, 100.0)); // Presets round-trip through a file onto another layer at the CTI. let path = tmp("scale.ecpreset"); s.execute("prop.select", json!({"layer": a, "path": "transform/scale"})).unwrap(); s.execute("anim.savePreset", json!({"path": path})).unwrap(); let b = solid(&mut s, "#00ff00"); s.execute("time.set", json!({"time": 3.0})).unwrap(); let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [b]})).unwrap(); assert_eq!(r["applied"], 1); let keys = layer(&s, b).props.prop("transform/scale").unwrap().keys.clone(); assert_eq!(keys.len(), 2); assert!((keys[0].time.seconds() - 3.0).abs() < 1e-6); // Text: animate, add selector, reveal, remove all. let t = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.addTextAnimator", json!({"properties": ["anchor", "position", "scale", "skew", "rotation", "opacity"]})).unwrap(); s.execute("text.addSelector", json!({"kind": "range"})).unwrap(); let anim = layer(&s, t).props.group("text/animators").unwrap().groups().next().unwrap().clone(); assert_eq!(anim.sub("selectors").unwrap().children.len(), 2); // Skew brings Skew Axis along. assert_eq!(anim.sub("properties").unwrap().children.len(), 7); s.execute("text.addSelector", json!({"kind": "wiggly"})).unwrap(); assert!(s.execute("text.addSelector", json!({"kind": "other"})).is_err()); s.execute("text.removeAllAnimators", json!({})).unwrap(); assert!(layer(&s, t).props.group("text/animators").unwrap().children.is_empty()); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(layer(&s, t).props.group("text/animators").unwrap().children.len(), 1); } #[test] fn reveal_properties() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); s.execute("prop.toggleAnimation", json!({"layer": a, "path": "transform/rotation"})).unwrap(); s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap(); s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [50, 50]})).unwrap(); s.drain_events(); let n = |v: &Value| v["props"].as_array().unwrap().len(); assert_eq!(n(&s.execute("anim.reveal", json!({"kind": "keyframes"})).unwrap()), 1); assert_eq!(n(&s.execute("anim.reveal", json!({"kind": "animation"})).unwrap()), 2); assert_eq!(n(&s.execute("anim.reveal", json!({"kind": "modified"})).unwrap()), 3); assert!(frontend_events(&mut s).iter().all(|(c, _)| c == "timeline.revealProps")); } #[test] fn sequence_layers() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); let b = solid(&mut s, "#00ff00"); let c = solid(&mut s, "#0000ff"); for (id, out) in [(a, 2.0), (b, 3.0), (c, 1.0)] { s.execute("layer.timing", json!({"layers": [id], "out": out})).unwrap(); } s.execute("layer.sequence", json!({"layers": [a, b, c], "overlap": true, "duration": 0.5, "transition": "dissolveFront"})).unwrap(); assert!((layer(&s, b).in_point.seconds() - 1.5).abs() < 1e-6); assert!((layer(&s, c).in_point.seconds() - 4.0).abs() < 1e-6); assert_eq!(layer(&s, c).props.prop("transform/opacity").unwrap().keys.len(), 2); } #[test] fn file_menu_project_ops() { let mut s = comp(); let f = s.execute("project.newFolder", json!({"name": "Stuff"})).unwrap()["item"].as_u64().unwrap(); assert!(s.project.item(effectcraft_project::ItemId(f)).unwrap().is_folder()); let p1 = s.execute("file.importPlaceholder", json!({"name": "Shot 1", "width": 320, "height": 240})).unwrap()["item"].as_u64().unwrap(); let p2 = s.execute("file.importPlaceholder", json!({"name": "Unused"})).unwrap()["item"].as_u64().unwrap(); s.execute("file.importSolid", json!({"name": "Grey"})).unwrap(); s.state.project_selection = vec![effectcraft_project::ItemId(p1)]; let r = s.execute("file.newCompFromSelection", json!({})).unwrap(); let nc = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); assert_eq!((s.project.comp(nc).unwrap().width, s.project.comp(nc).unwrap().layers.len()), (320, 1)); // Missing footage = the placeholders. let miss = s.execute("file.findMissing", json!({"what": "footage"})).unwrap(); assert_eq!(miss["items"].as_array().unwrap().len(), 2); assert!(s.execute("file.findMissing", json!({"what": "fonts"})).is_ok()); // Interpretation. s.state.project_selection = vec![effectcraft_project::ItemId(p1)]; s.execute("file.interpretFootage", json!({"frameRate": 24, "alpha": "premultiplied", "loop": 3})).unwrap(); s.execute("file.rememberInterpretation", json!({})).unwrap(); s.state.project_selection = vec![effectcraft_project::ItemId(p2)]; s.execute("file.applyInterpretation", json!({})).unwrap(); match &s.project.item(effectcraft_project::ItemId(p2)).unwrap().kind { ItemKind::Footage(f) => assert_eq!((f.alpha, f.loop_count, f.frame_rate.as_f64().round()), (effectcraft_project::AlphaMode::Premultiplied, 3, 24.0)), _ => panic!(), } // Remove Unused Footage drops the unused placeholder and the unused solid. let before = s.project.items.len(); s.execute("file.removeUnusedFootage", json!({})).unwrap(); assert_eq!(s.project.items.len(), before - 2); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.items.len(), before); // Replace with a solid retargets the layer. s.state.project_selection = vec![effectcraft_project::ItemId(p1)]; s.execute("file.replaceWithSolid", json!({"color": "#336699"})).unwrap(); assert!(matches!(s.project.comp(nc).unwrap().layers[0].source, effectcraft_project::LayerSource::Solid { .. })); // Reduce Project keeps only the selected comp and what it uses (+ folders of kept items). s.state.project_selection = vec![nc]; s.execute("file.reduceProject", json!({})).unwrap(); assert!(s.project.items.len() <= 2, "{:?}", s.project.items.values().map(|i| &i.name).collect::>()); // Save a copy leaves the session path alone. let path = tmp("copy.ecproj"); s.execute("file.saveCopy", json!({"path": path})).unwrap(); assert!(s.path.is_none() && std::fs::metadata(&path).is_ok()); s.execute("file.closeProject", json!({})).unwrap(); assert!(s.project.items.is_empty()); } #[test] fn consolidate_duplicate_footage() { let mut s = comp(); let f = |s: &mut Session| s.execute("file.importPlaceholder", json!({})).unwrap()["item"].as_u64().unwrap(); let a = f(&mut s); let b = f(&mut s); // Give both the same file path. let mut p = (*s.project).clone(); for id in [a, b] { if let Some(ItemKind::Footage(ft)) = p.item_mut(effectcraft_project::ItemId(id)).map(|i| &mut i.kind) { ft.path = "/media/shot.mov".into(); } } s.project = std::sync::Arc::new(p); s.execute("layer.addItem", json!({"item": b})).unwrap(); assert_eq!(s.execute("file.consolidateFootage", json!({})).unwrap()["removed"], 1); let c = s.active_comp().unwrap(); assert_eq!(c.layers[0].source.item().unwrap().0, a); } #[test] fn run_script_steps() { let mut s = comp(); let path = tmp("script.jsonl"); std::fs::write(&path, "// make two solids\n{\"command\":\"layer.newSolid\",\"params\":{\"color\":\"#ff0000\"}}\n{\"method\":\"engine.execute\",\"params\":{\"id\":\"layer.newNull\",\"params\":{}}}\n").unwrap(); let r = s.execute("file.runScript", json!({"path": path})).unwrap(); assert_eq!(r["steps"], 2); assert_eq!(s.active_comp().unwrap().layers.len(), 2); assert!(s.execute("file.runScript", json!({"steps": [{"command": "nope.nope"}]})).is_err()); } #[test] fn crop_comp_and_save_frame() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); // ROI crop moves layers so nothing shifts on screen. assert!(!s.is_enabled("comp.cropToRegionOfInterest")); s.execute("view.setRegionOfInterest", json!({"rect": [100, 50, 200, 100]})).unwrap(); s.execute("comp.cropToRegionOfInterest", json!({})).unwrap(); let c = s.active_comp().unwrap(); assert_eq!((c.width, c.height), (200, 100)); assert_eq!(layer(&s, a).props.prop("transform/position").unwrap().value.as_vec2(), [220.0, 130.0]); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.active_comp().unwrap().width, 640); // Crop to the selected layer's bounds (a 200×100 solid centred in 640×360). s.execute("layer.select", json!({"layers": [a]})).unwrap(); let r = s.execute("comp.cropToLayerBounds", json!({})).unwrap(); assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(200), Some(100))); assert_eq!(layer(&s, a).props.prop("transform/position").unwrap().value.as_vec2(), [100.0, 50.0]); // Save Frame As ▸ File… writes a PNG. let path = tmp("frame.png"); let r = s.execute("comp.saveFrameAs", json!({"path": path, "scale": 0.5})).unwrap(); assert_eq!(r["width"], 100); let bytes = std::fs::read(&path).unwrap(); assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n"); // Responsive Design — Time. s.execute("time.set", json!({"time": 2.0})).unwrap(); s.execute("comp.responsiveTime", json!({"op": "intro"})).unwrap(); let m = &s.active_comp().unwrap().markers[0]; assert!(m.protected && (m.duration.seconds() - 2.0).abs() < 1e-6); } #[test] fn guides_add_clear_import_export() { let mut s = comp(); s.execute("view.addGuide", json!({"orientation": "horizontal", "position": 40})).unwrap(); s.execute("view.addGuide", json!({})).unwrap(); assert_eq!(s.active_comp().unwrap().guides.len(), 2); assert_eq!(s.active_comp().unwrap().guides[1].position, 320.0); let path = tmp("guides.json"); s.execute("view.exportGuides", json!({"path": path})).unwrap(); s.execute("view.clearGuides", json!({})).unwrap(); assert!(s.active_comp().unwrap().guides.is_empty()); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.active_comp().unwrap().guides.len(), 2); s.execute("view.clearGuides", json!({})).unwrap(); s.execute("view.importGuides", json!({"path": path})).unwrap(); assert_eq!(s.active_comp().unwrap().guides.len(), 2); } #[test] fn frontend_commands_emit_events_and_stubs_are_disabled() { let mut s = comp(); s.drain_events(); s.execute("view.zoomIn", json!({})).unwrap(); s.execute("window.panel", json!({"panel": "align"})).unwrap(); let ev = frontend_events(&mut s); assert_eq!(ev[0].0, "view.zoomIn"); assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"}))); for id in ["layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] { assert!(!s.is_enabled(id), "{id}"); assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}"); } } fn solid_of(s: &Session, l: u64) -> (u64, effectcraft_project::Solid, String) { let effectcraft_project::LayerSource::Solid { item } = layer(s, l).source else { panic!("not a solid") }; let it = s.project.item(item).unwrap(); let ItemKind::Solid(so) = &it.kind else { panic!("not a solid item") }; (item.0, so.clone(), it.name.clone()) } #[test] fn layer_settings_edit_the_solid_or_give_the_layer_its_own() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); s.execute("layer.select", json!({"layers": [a]})).unwrap(); let b = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); assert_eq!(solid_of(&s, a).0, solid_of(&s, b).0, "the duplicate shares the solid"); // Nothing given: nothing recorded. let steps = s.history.undo.len(); s.execute("layer.settings", json!({"layer": b})).unwrap(); assert_eq!(s.history.undo.len(), steps); // Affect all off: the duplicate gets its own solid; the original keeps its colour. let r = s.execute("layer.settings", json!({"layer": b, "color": "#0000ff", "name": "Blue", "affectAll": false})).unwrap(); let (bi, bs, bn) = solid_of(&s, b); assert_eq!(r, json!({"layer": b, "solid": bi})); assert_ne!(bi, solid_of(&s, a).0); assert_eq!((bs.color, bn.as_str(), layer(&s, b).name.as_str()), ([0.0, 0.0, 1.0], "Blue", "Blue")); assert_eq!(solid_of(&s, a).1.color, [1.0, 0.0, 0.0]); assert_eq!( s.project.item(effectcraft_project::ItemId(bi)).unwrap().parent, s.project.item(effectcraft_project::ItemId(solid_of(&s, a).0)).unwrap().parent, "same folder" ); // Affect all (the default) changes the shared solid in place; size and pixel aspect too. s.execute("layer.select", json!({"layers": [a]})).unwrap(); let c = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); s.execute("layer.settings", json!({"layer": a, "width": 320, "height": 90, "pixelAspect": 2.0})).unwrap(); assert_eq!(solid_of(&s, a).0, solid_of(&s, c).0); let so = solid_of(&s, c).1; assert_eq!((so.width, so.height, so.pixel_aspect), (320, 90, 2.0)); assert!(s.execute("layer.settings", json!({"layer": a, "pixelAspect": 0})).is_err()); // One undo step each. s.execute("edit.undo", json!({})).unwrap(); assert_eq!(solid_of(&s, a).1.width, 200); // A new solid takes a pixel aspect too. let d = s.execute("layer.newSolid", json!({"color": "#00ff00", "pixelAspect": 0.9091})).unwrap()["layer"].as_u64().unwrap(); assert_eq!(solid_of(&s, d).1.pixel_aspect, 0.9091); // Text and shape layers have no settings. assert!(s.is_enabled("layer.settings")); s.execute("layer.newText", json!({"text": "T"})).unwrap(); assert!(!s.is_enabled("layer.settings")); } fn order(s: &Session) -> Vec { s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect() } fn pos(s: &Session, l: u64) -> [f64; 3] { let ly = layer(s, l); let tr = ly.transform().unwrap(); match tr.get("positionX") { Some(x) => [x.value.as_f64(), tr.get("positionY").unwrap().value.as_f64(), tr.get("positionZ").map(|z| z.value.as_f64()).unwrap_or(0.0)], None => tr.get("position").unwrap().value.as_vec3(), } } #[test] fn locked_layers_are_not_selected_deleted_or_moved() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); let b = solid(&mut s, "#00ff00"); s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap(); assert!(layer(&s, b).switches.locked); s.execute("edit.selectAll", json!({})).unwrap(); assert_eq!(s.state.selected_layers.iter().map(|l| l.0).collect::>(), vec![a]); // Ctrl+Up / Down step over it. s.execute("layer.select", json!({"layers": [a]})).unwrap(); s.execute("layer.selectPrevious", json!({})).unwrap(); assert_eq!(s.state.selected_layers[0].0, a, "nothing above the locked layer to step to"); // Deleting, arranging or transforming only the locked layer is refused; with others, it stays. let e = s.execute("edit.clear", json!({"layers": [b]})).unwrap_err().to_string(); assert!(e.contains("locked"), "{e}"); assert!(s.execute("layer.arrange", json!({"layers": [b], "to": "back"})).is_err()); assert!(s.execute("layer.transform", json!({"layers": [b], "op": "center"})).is_err()); s.execute("edit.clear", json!({"layers": [a, b]})).unwrap(); assert_eq!(order(&s), vec![b]); } #[test] fn deleting_a_parent_keeps_its_children_in_place() { let mut s = comp(); let parent = solid(&mut s, "#ff0000"); s.execute("prop.set", json!({"layer": parent, "path": "transform/position", "value": [300, 200]})).unwrap(); s.execute("prop.set", json!({"layer": parent, "path": "transform/rotation", "value": 90})).unwrap(); let child = solid(&mut s, "#00ff00"); s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [210, 160]})).unwrap(); s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap(); assert_ne!(pos(&s, child)[..2], [210.0, 160.0], "expressed in the parent's space"); s.execute("edit.clear", json!({"layers": [parent]})).unwrap(); let p = pos(&s, child); assert!((p[0] - 210.0).abs() < 1e-6 && (p[1] - 160.0).abs() < 1e-6, "{p:?}"); assert_eq!(layer(&s, child).parent, None); let r = layer(&s, child).transform().unwrap().get("rotation").unwrap().value.as_f64(); assert!(r.abs() < 1e-6, "{r}"); // One undo step restores both. s.execute("edit.undo", json!({})).unwrap(); assert_eq!(layer(&s, child).parent.map(|l| l.0), Some(parent)); } #[test] fn bring_forward_and_send_backward_move_each_layer_one_step() { let mut s = comp(); let ids: Vec = (0..4).map(|_| solid(&mut s, "#ffffff")).collect(); let top = order(&s); assert_eq!(top, ids.iter().rev().copied().collect::>()); let (l0, l1, l2, l3) = (top[0], top[1], top[2], top[3]); // A non-contiguous selection: each moves down one step. s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "backward"})).unwrap(); assert_eq!(order(&s), vec![l1, l0, l3, l2]); // Forward again; a layer already at the top stays there. s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "forward"})).unwrap(); assert_eq!(order(&s), vec![l0, l1, l2, l3]); s.execute("layer.arrange", json!({"layers": [l0, l2], "to": "forward"})).unwrap(); assert_eq!(order(&s), vec![l0, l2, l1, l3]); // Front / Back keep the layers' order. s.execute("layer.arrange", json!({"layers": [l0, l1], "to": "back"})).unwrap(); assert_eq!(order(&s), vec![l2, l3, l0, l1]); } #[test] fn transform_commands_keep_depth_and_follow_separated_dimensions() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); s.execute("layer.setSwitch", json!({"layers": [a], "switch": "threeD", "value": true})).unwrap(); s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [10, 20, -300]})).unwrap(); s.execute("layer.transform", json!({"layers": [a], "op": "center"})).unwrap(); assert_eq!(pos(&s, a), [320.0, 180.0, -300.0]); s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap(); assert_eq!(pos(&s, a), [320.0, 180.0, -300.0]); let b = solid(&mut s, "#00ff00"); s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [10, 20]})).unwrap(); s.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); s.execute("layer.transform", json!({"layers": [b], "op": "center"})).unwrap(); assert_eq!(pos(&s, b)[..2], [320.0, 180.0]); s.execute("layer.transform", json!({"layers": [b], "op": "reset"})).unwrap(); assert_eq!(pos(&s, b)[..2], [320.0, 180.0]); // Center Anchor Point moves separated X / Y Position too, so the layer stays put. s.execute("prop.set", json!({"layer": b, "path": "transform/anchor", "value": [0, 0]})).unwrap(); s.execute("prop.set", json!({"layer": b, "path": "transform/positionX", "value": 100})).unwrap(); s.execute("prop.set", json!({"layer": b, "path": "transform/positionY", "value": 50})).unwrap(); s.execute("layer.centerAnchor", json!({"layers": [b]})).unwrap(); assert_eq!(pos(&s, b)[..2], [200.0, 100.0], "the 200×100 solid's centre"); } #[test] fn duplicated_and_pasted_layers_keep_their_links() { let mut s = comp(); let parent = solid(&mut s, "#ff0000"); let matte = solid(&mut s, "#ffffff"); let child = solid(&mut s, "#00ff00"); s.execute("layer.setParent", json!({"layers": [child], "parent": parent})).unwrap(); s.execute("layer.setTrackMatte", json!({"layer": child, "matte": matte, "kind": "alpha"})).unwrap(); let m = layer(&s, child).track_matte.unwrap().layer; assert_eq!(m.0, matte); // Duplicating the child alone keeps the original parent and matte. let d = s.execute("edit.duplicate", json!({"layers": [child]})).unwrap()[0].as_u64().unwrap(); assert_eq!((layer(&s, d).parent.map(|l| l.0), layer(&s, d).track_matte.map(|t| t.layer)), (Some(parent), Some(m))); // Duplicating parent and child together: the copy follows the copied parent. let r = s.execute("edit.duplicate", json!({"layers": [parent, child]})).unwrap(); let (p2, c2) = (r[0].as_u64().unwrap(), r[1].as_u64().unwrap()); assert_eq!(layer(&s, c2).parent.map(|l| l.0), Some(p2)); // Copy / paste in the same composition keeps the links; in another one they are dropped. s.execute("layer.select", json!({"layers": [child]})).unwrap(); s.execute("edit.copy", json!({})).unwrap(); let pasted = s.execute("edit.paste", json!({})).unwrap()[0].as_u64().unwrap(); assert_eq!(layer(&s, pasted).parent.map(|l| l.0), Some(parent)); assert_eq!(layer(&s, pasted).track_matte.map(|t| t.layer), Some(m)); s.execute("comp.new", json!({"name": "Other", "width": 64, "height": 64})).unwrap(); let other = s.execute("edit.paste", json!({})).unwrap()[0].as_u64().unwrap(); assert_eq!((layer(&s, other).parent, layer(&s, other).track_matte), (None, None)); } #[test] fn precompose_takes_the_compositions_settings() { let mut s = Session::default(); s.execute( "comp.new", json!({"name": "Main", "width": 640, "height": 360, "pixelAspect": 2.0, "shutterAngle": 90, "motionBlurSamples": 8, "renderer": "advanced3D", "background": [0.2, 0.3, 0.4]}), ) .unwrap(); let a = solid(&mut s, "#ff0000"); let r = s.execute("layer.precompose", json!({"layers": [a], "name": "Inner"})).unwrap(); let inner = s.project.comp(effectcraft_project::ItemId(r["comp"].as_u64().unwrap())).unwrap(); assert_eq!((inner.pixel_aspect, inner.shutter_angle, inner.motion_blur_samples), (2.0, 90.0, 8)); assert_eq!((inner.renderer, inner.background), (effectcraft_project::Renderer::Advanced3D, [0.2, 0.3, 0.4])); } #[test] fn new_comp_from_selection_is_one_undo_step_with_the_dialog_options() { let mut s = Session::default(); let still = |s: &mut Session, name: &str, w: u32, par: f64| { let f = effectcraft_project::Footage { kind: effectcraft_project::FootageKind::Still, width: w, height: 100, pixel_aspect: par, has_video: true, ..Default::default() }; std::sync::Arc::make_mut(&mut s.project).add_item(name, effectcraft_color::Label::None, None, ItemKind::Footage(f)) }; let a = still(&mut s, "a.png", 200, 1.0); let b = still(&mut s, "b.png", 300, 2.0); s.state.project_selection = vec![a, b]; // Multiple compositions (the default): one per item, its size and pixel aspect; one undo step. let steps = s.history.undo.len(); let r = s.execute("file.newCompFromSelection", json!({"duration": 4})).unwrap(); let comps: Vec = r["comps"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); assert_eq!(comps.len(), 2); let cb = s.project.comp(effectcraft_project::ItemId(comps[1])).unwrap(); assert_eq!((cb.width, cb.pixel_aspect, cb.duration.seconds()), (300, 2.0, 4.0)); assert_eq!(s.history.undo.len(), steps + 1); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(s.project.comps().count(), 0); // One composition holding both, sized like the second, sequenced with a 1 s overlap, queued. s.state.project_selection = vec![a, b]; let r = s .execute( "file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 1, "duration": 3, "sequence": true, "overlap": true, "overlapDuration": 1, "addToRenderQueue": true}), ) .unwrap(); let c = s.project.comp(effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap())).unwrap(); assert_eq!((c.width, c.pixel_aspect, c.duration.seconds()), (300, 2.0, 5.0)); let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect(); assert_eq!(names, ["a.png", "b.png"], "first selected on top"); // Stills have no rate: the comp is 29.97 fps and the second layer starts on the frame nearest 2 s. assert_eq!(c.layers[0].in_point.seconds(), 0.0); assert!((c.layers[1].in_point.seconds() - 2.0).abs() < 1.0 / 29.97, "{}", c.layers[1].in_point.seconds()); assert_eq!(s.project.render_queue.len(), 1); assert_eq!(s.history.undo.len(), steps + 1); s.execute("edit.undo", json!({})).unwrap(); assert!(s.project.render_queue.is_empty() && s.project.comps().count() == 0); s.state.project_selection = vec![a, b]; assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 2})).is_err()); } #[test] fn project_files_keep_their_format_warn_about_newer_versions_and_never_save_unreadable() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); let path = tmp("Safe.ecproj"); s.execute("file.saveAs", json!({"path": path})).unwrap(); let saved = std::fs::read_to_string(&path).unwrap(); assert_eq!(effectcraft_project::saved_by(&saved).as_deref(), Some(effectcraft_project::APP_VERSION)); // Save a Copy and Increment and Save keep an XML project XML. let copy = tmp("Copy.ecprojx"); s.execute("file.saveCopy", json!({"path": copy})).unwrap(); assert!(std::fs::read_to_string(©).unwrap().starts_with(" effectcraft_time::Tick::ZERO, "{overlap}: {}", c.duration.seconds()); } s.state.project_selection = vec![a, b]; assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 99})).is_err()); } fn key_times(s: &Session, l: u64) -> Vec { layer(s, l).props.prop("transform/opacity").unwrap().keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect() } fn opacity_keys(s: &mut Session, l: u64, times: &[f64]) { for (i, t) in times.iter().enumerate() { s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t, "value": i as f64 * 10.0})).unwrap(); } } #[test] fn dragging_a_key_past_another_keeps_it() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); opacity_keys(&mut s, a, &[1.0, 2.0]); s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); // One drag, frame by frame, from 1 s over the 2 s key to 3 s. for _ in 0..60 { s.execute("keys.move", json!({"delta": 1.0 / 30.0, "merge": "key-drag"})).unwrap(); } assert_eq!(key_times(&s, a), vec![2.0, 3.0], "the 2 s key passed over is still there"); // Dropped exactly on a key: it replaces it, as in After Effects. s.history.merge_key = None; s.execute("keys.move", json!({"delta": -1.0, "merge": "key-drag-2"})).unwrap(); assert_eq!(key_times(&s, a), vec![2.0]); // The whole drag was one undo step. s.execute("edit.undo", json!({})).unwrap(); s.execute("edit.undo", json!({})).unwrap(); assert_eq!(key_times(&s, a), vec![1.0, 2.0]); } #[test] fn keys_of_a_stretched_layer_move_with_the_pointer() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); opacity_keys(&mut s, a, &[0.0, 1.0]); s.execute_checked("layer.timeStretch", json!({"layers": [a], "percent": 200})).unwrap(); assert_eq!(layer(&s, a).stretch, 200.0); let comp_time = |s: &Session, i: usize| { let l = layer(s, a); l.comp_time(l.props.prop("transform/opacity").unwrap().keys[i].time).seconds() }; let before = comp_time(&s, 1); s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap(); s.execute("keys.move", json!({"delta": 0.5})).unwrap(); assert!((comp_time(&s, 1) - (before + 0.5)).abs() < 1.0 / 30.0, "{} → {}", before, comp_time(&s, 1)); s.execute("keys.nudge", json!({"frames": 3})).unwrap(); assert!((comp_time(&s, 1) - (before + 0.6)).abs() < 1.0 / 60.0, "three comp frames"); } #[test] fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); opacity_keys(&mut s, a, &[0.0, 1.0]); let k = |t: f64| json!({"layer": a, "path": "transform/opacity", "time": t}); s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap(); s.execute("keys.select", json!({"keys": [k(1.0)], "toggle": true})).unwrap(); assert_eq!(s.state.selected_keys.len(), 2); s.execute("keys.select", json!({"keys": [k(0.0)], "toggle": true})).unwrap(); assert_eq!(s.state.selected_keys.len(), 1, "toggled out"); // Hold off returns an eased key to Bezier. s.execute("keys.easyEase", json!({})).unwrap(); s.execute("keys.toggleHold", json!({})).unwrap(); s.execute("keys.toggleHold", json!({})).unwrap(); let kf = layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].clone(); assert_eq!(kf.out_interp, effectcraft_keyframe::Interp::Bezier); // Locked: its keys can't be selected. s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "value": true})).unwrap(); s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap(); assert!(s.state.selected_keys.is_empty()); s.execute("keys.selectAll", json!({"layers": [a]})).unwrap(); assert!(s.state.selected_keys.is_empty()); } #[test] fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() { let mut s = comp(); let a = solid(&mut s, "#ff0000"); opacity_keys(&mut s, a, &[0.0, 1.0, 2.0]); s.execute("keys.selectAll", json!({"layers": [a]})).unwrap(); // Squeezed onto one frame on the way, then back: the three keys survive. for k in [0.5, 0.01, 0.0001, 1.0] { s.execute("keys.transform", json!({"timeScale": k, "timeAnchor": 0.0, "merge": "box", "fromStart": true})).unwrap(); } assert_eq!(key_times(&s, a), vec![0.0, 1.0, 2.0]); // A slow move in tiny steps still arrives (each step is the whole offset). s.history.merge_key = None; for i in 1..=30 { s.execute("keys.transform", json!({"timeOffset": i as f64 * 0.1 / 30.0, "merge": "move", "fromStart": true})).unwrap(); } assert_eq!(key_times(&s, a), vec![0.1, 1.1, 2.1]); }