effectcraft/crates/engine/src/tests_m145.rs

710 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! M14.5: settings that change behaviour, the keyframe assistants and labels, and the menu
//! entries added for After Effects parity (History, recent footage / presets, XML copies, VR,
//! shortcut slots, logging and the compatibility report).
use std::path::PathBuf;
use serde_json::{Value, json};
use crate::render::RenderOpts;
use crate::{KeyRef, Session};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{ItemId, LayerId};
use effectcraft_time::Tick;
fn tmp(name: &str) -> PathBuf {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let d = std::env::temp_dir().join(format!("ec-m145-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d.join(name)
}
fn session() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 48, "duration": 2, "frameRate": 10})).unwrap();
s
}
fn solid(s: &mut Session, color: &str) -> u64 {
s.execute("layer.newSolid", json!({"color": color, "width": 32, "height": 24})).unwrap()["layer"].as_u64().unwrap()
}
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
s.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
}
// ---------------------------------------------------------------- General
#[test]
fn split_layers_go_above_or_below_the_original() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
s.execute("time.set", json!({"time": 1.0})).unwrap();
let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
assert_eq!(order, vec![a, below], "off (default): the new part goes below");
s.execute("edit.undo", json!({})).unwrap();
s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": true})).unwrap();
let above = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
assert_eq!(order, vec![above, a]);
}
#[test]
fn new_masks_cycle_colours_only_when_asked() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
let colors = |s: &Session| -> Vec<[u8; 3]> {
layer(s, a)
.props
.sub("masks")
.unwrap()
.groups()
.map(|g| match g.kind {
effectcraft_project::GroupKind::Mask { color, .. } => color,
_ => [0; 3],
})
.collect()
};
s.execute("layer.addMask", json!({"layer": a})).unwrap();
s.execute("layer.addMask", json!({"layer": a})).unwrap();
let c = colors(&s);
assert_ne!(c[0], c[1], "cycling by default");
s.execute("prefs.set", json!({"key": "appearance.cycleMaskColors", "value": false})).unwrap();
s.execute("layer.addMask", json!({"layer": a})).unwrap();
s.execute("mask.new", json!({"layer": a, "vertices": [[0, 0], [5, 5]]})).unwrap();
let c = colors(&s);
assert_eq!(c[2], c[0]);
assert_eq!(c[3], c[0]);
}
#[test]
fn preserve_constant_vertex_count_edits_every_mask_key() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
let m = s.execute("layer.addMask", json!({"layer": a})).unwrap()["mask"].as_u64().unwrap();
s.execute("prop.select", json!({"layer": a, "prop": layer(&s, a).props.sub("masks").unwrap().groups().next().unwrap().get("path").unwrap().uid})).unwrap();
let path_uid = layer(&s, a).props.sub("masks").unwrap().groups().next().unwrap().get("path").unwrap().uid;
s.execute("prop.toggleAnimation", json!({"layer": a, "prop": path_uid})).unwrap();
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("mask.setVertex", json!({"layer": a, "mask": m, "index": 0, "point": [1, 1]})).unwrap();
let counts = |s: &Session| -> Vec<usize> {
layer(s, a)
.props
.find(path_uid)
.unwrap()
.keys
.iter()
.map(|k| match &k.value {
KV::Path(p) => p.vertices.len(),
_ => 0,
})
.collect()
};
assert_eq!(counts(&s), vec![4, 4]);
// On (the default): a vertex added at one key is added to the other.
s.execute("mask.insertVertex", json!({"layer": a, "mask": m, "segment": 0})).unwrap();
assert_eq!(counts(&s), vec![5, 5]);
s.state.selected_vertices = vec![crate::VertexRef { layer: LayerId(a), mask: m, index: 1 }];
s.execute("mask.deleteVertices", json!({})).unwrap();
assert_eq!(counts(&s), vec![4, 4]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(counts(&s), vec![5, 5], "one undo step");
// Off: only the current key changes.
s.execute("prefs.set", json!({"key": "general.preserveConstantVertexCount", "value": false})).unwrap();
s.execute("mask.addVertex", json!({"layer": a, "mask": m, "point": [3, 3]})).unwrap();
assert_eq!(counts(&s), vec![5, 6]);
}
#[test]
fn switches_affect_nested_comps_passes_quality_down() {
let mut s = Session::default();
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 40, "height": 40, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let a = solid(&mut s, "#ff8040");
// A rotated, scaled layer: bilinear (Best) and nearest (Draft) sampling differ.
s.execute("prop.set", json!({"layer": a, "path": "transform/rotation", "value": 17})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/scale", "value": [131, 131]})).unwrap();
let outer = s.execute("comp.new", json!({"name": "Outer", "width": 40, "height": 40, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let pre = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.quality", json!({"layers": [pre], "quality": "draft"})).unwrap();
let draw = |s: &Session| s.render(ItemId(outer), Tick::ZERO, RenderOpts::default());
let on = draw(&s);
s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": false})).unwrap();
let off = draw(&s);
assert_ne!(on.data, off.data, "the precomp's Draft switch reaches the nested layer only when on");
// On = the nested layer itself set to Draft.
s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": true})).unwrap();
s.open_comp(ItemId(inner));
s.execute("layer.quality", json!({"layers": [a], "quality": "draft"})).unwrap();
s.execute("prefs.set", json!({"key": "general.switchesAffectNestedComps", "value": false})).unwrap();
assert_eq!(draw(&s).data, on.data);
}
#[test]
fn related_comps_share_the_current_time() {
let mut s = Session::default();
let inner = s.execute("comp.new", json!({"name": "Inner", "duration": 10, "frameRate": 10})).unwrap()["comp"].as_u64().unwrap();
let outer = s.execute("comp.new", json!({"name": "Outer", "duration": 10, "frameRate": 10})).unwrap()["comp"].as_u64().unwrap();
let l = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setTiming", json!({"layer": l, "startTime": 2.0})).ok();
s.edit("t", None, |p, _| {
p.comp_mut(ItemId(outer)).unwrap().layer_mut(LayerId(l)).unwrap().start_time = Tick::from_seconds_f64(2.0);
Ok(())
})
.unwrap();
s.set_time(Tick::from_seconds_f64(5.0));
assert!((s.time_of(ItemId(inner)).seconds() - 3.0).abs() < 1e-9, "nested comp follows through the layer's start");
s.open_comp(ItemId(inner));
s.set_time(Tick::from_seconds_f64(1.0));
assert!((s.time_of(ItemId(outer)).seconds() - 3.0).abs() < 1e-9, "and the other way");
s.execute("prefs.set", json!({"key": "general.syncTimeRelatedItems", "value": false})).unwrap();
s.set_time(Tick::from_seconds_f64(7.0));
assert!((s.time_of(ItemId(outer)).seconds() - 3.0).abs() < 1e-9);
}
#[test]
fn pick_whip_writes_match_names_without_compact_english() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
let r = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": r, "effect": "Gaussian Blur"})).unwrap();
s.execute("prefs.set", json!({"key": "general.expressionPickWhipCompact", "value": false})).unwrap();
let e = s.execute("prop.pickWhip", json!({"layer": a, "path": "transform/opacity", "target": {"layer": r, "path": "transform/opacity"}})).unwrap();
assert_eq!(e, json!("thisComp.layer(\"Other\")(\"transform\")(\"opacity\")"));
let e = s.execute("prop.pickWhip", json!({"layer": a, "path": "transform/rotation", "target": {"layer": a, "path": "transform/opacity"}})).unwrap();
assert_eq!(e, json!("thisLayer(\"transform\")(\"opacity\")"));
}
// ---------------------------------------------------------------- pure setting helpers
#[test]
fn cache_budgets_leave_reserved_ram_and_shrink_when_low() {
use crate::sysinfo::SysMemory;
let mut p = crate::prefs::Prefs::default();
p.memory.layer_cache_mb = 4096;
p.memory.media_cache_mb = 4096;
p.memory.preview_cache_mb = 8192;
p.memory.ram_reserved_gb = 6;
let b = p.cache_budgets(None);
assert_eq!((b.layer >> 20, b.preview >> 20, b.capped), (4096, 8192, false));
// 16 GB machine, 6 GB reserved: 16 GB of caches scaled into 10 GB.
let m = SysMemory { total: 16 << 30, available: 12 << 30 };
let b = p.cache_budgets(Some(m));
assert!(b.capped && !b.reduced);
assert!(((b.layer + b.media + b.preview) as i64 - (10i64 << 30)).abs() < 4, "{b:?}");
// Low on memory: halved; off: not.
let low = SysMemory { total: 16 << 30, available: 512 << 20 };
let r = p.cache_budgets(Some(low));
assert!(r.reduced && r.preview < b.preview / 2 + 2);
p.memory.reduce_cache_when_low = false;
assert!(!p.cache_budgets(Some(low)).reduced);
}
/// Eight stacked 1080p clips ran the browser build out of its 4 GiB of memory: the footage and
/// layer caches kept their desktop sizes there, beside the decoders' own frames.
#[test]
fn web_cache_budgets_leave_room_for_the_decoders() {
use crate::prefs::{WEB_LAYER_CACHE, WEB_MEDIA_CACHE};
let mut p = crate::prefs::Prefs::default();
p.memory.layer_cache_mb = 4096;
p.memory.media_cache_mb = 4096;
let web = p.cache_budgets_in(None, true);
assert_eq!((web.layer as u64, web.media as u64), (WEB_LAYER_CACHE, WEB_MEDIA_CACHE));
assert_eq!(web.preview, p.cache_budgets_in(None, false).preview, "the RAM preview keeps its budget");
// Smaller settings stay as they are; the desktop is unchanged.
p.memory.media_cache_mb = 128;
assert_eq!(p.cache_budgets_in(None, true).media, 128 << 20);
assert_eq!(p.cache_budgets_in(None, false).layer, 4096 << 20);
}
#[test]
fn motion_path_span_follows_the_composition_setting() {
let mut p = crate::prefs::Prefs::default();
let s = |x: f64| Tick::from_seconds_f64(x);
let keys: Vec<Tick> = (0..40).map(|i| s(i as f64)).collect();
// Default: no more than 15 keyframes, centred on the current time.
assert_eq!(p.motion_path_span(&keys, s(20.0)), Some((s(13.0), s(27.0))));
assert_eq!(p.motion_path_span(&keys, s(0.0)), Some((s(0.0), s(14.0))));
p.composition.motion_path = "all".into();
assert_eq!(p.motion_path_span(&keys, s(5.0)), Some((s(0.0), s(39.0))));
p.composition.motion_path = "none".into();
assert_eq!(p.motion_path_span(&keys, s(5.0)), None);
p.composition.motion_path = "seconds".into();
p.composition.motion_path_seconds = 4.0;
assert_eq!(p.motion_path_span(&keys, s(10.0)), Some((s(8.0), s(12.0))));
}
#[test]
fn import_export_helpers() {
use effectcraft_project::AlphaMode;
let mut p = crate::prefs::Prefs::default();
assert_eq!(p.unlabeled_alpha(false), None, "Ask User");
for (v, movie, want) in [
("ignore", false, AlphaMode::Ignore),
("premultiplied", false, AlphaMode::Premultiplied),
("guess", true, AlphaMode::Premultiplied),
("guess", false, AlphaMode::Straight),
] {
p.import.unlabeled_alpha = v.into();
assert_eq!(p.unlabeled_alpha(movie), Some(want));
}
assert_eq!(p.drag_import_as(), "footage");
p.import.drag_import_as = "compLayerSizes".into();
assert_eq!(p.drag_import_as(), "compositionLayerSizes");
assert_eq!(
crate::sequence::missing_report(&["a_001.png".into(), "a_002.png".into(), "a_005.png".into(), "a_007.png".into()]).unwrap(),
"3 missing frames (3–4, 6)"
);
assert!(crate::sequence::missing_report(&["a1.png".into(), "a2.png".into()]).is_none());
// Append Bit Depth to File Name.
assert_eq!(p.output_name("/out/Comp 1.png", 16), "/out/Comp 1.png");
p.export.append_bits_to_name = true;
assert_eq!(PathBuf::from(p.output_name("/out/Comp 1.png", 16)), PathBuf::from("/out/Comp 1_16bpc.png"));
assert_eq!(p.output_name("Comp_[#####].exr", 32), "Comp_[#####]_32bpc.exr");
// Segments: sequences by file count, movies by size at the data rate.
assert_eq!(p.segment_frames(100, true, 0.0), None);
p.export.segment_sequences = true;
p.export.segment_sequence_files = 30;
assert_eq!(p.segment_frames(100, true, 0.0), Some(30));
p.export.segment_movies = true;
p.export.segment_movie_mb = 1;
assert_eq!(p.segment_frames(100, false, 1_048_576.0 / 10.0), Some(10));
assert_eq!(p.segment_frames(5, false, 1_048_576.0 / 10.0), None);
// Recent fonts.
for f in ["A", "B", "C", "A"] {
p.push_recent_font(f);
}
p.type_.recent_fonts = 2;
assert_eq!(p.recent_fonts_shown(), ["A".to_string(), "C".to_string()]);
}
#[test]
fn render_queue_uses_default_folder_bit_depth_names_and_segments() {
use effectcraft_project::render_queue::{OutputFormat, RenderQueueItem};
let mut s = session();
solid(&mut s, "#ff0000");
let dir = tmp("out");
s.execute("prefs.set", json!({"values": {"export.defaultOutputFolder": dir.to_string_lossy(), "export.appendBitsToName": true}})).unwrap();
let cid = s.active_comp_id().unwrap();
let mut item = RenderQueueItem::new(1, cid);
item.output.output = "Main.png".into();
item.output.format = OutputFormat::PngSequence;
let out = s.resolve_output(&item).unwrap();
assert_eq!(PathBuf::from(&out), dir.join("Main.png"));
assert_eq!(
s.prefs.output_name(&out, crate::render_queue::output_bits(OutputFormat::PngSequence, s.project.settings.bit_depth)),
dir.join("Main_8bpc.png").to_string_lossy()
);
// 20 frames in segments of 8 files: three folders, spans tiling the work area.
s.prefs.export.segment_sequences = true;
s.prefs.export.segment_sequence_files = 8;
let segs = s.segments(&item, &out);
assert_eq!(segs.len(), 3);
let comp = s.active_comp().unwrap().clone();
let n: Vec<u64> = segs.iter().map(|(i, _)| i.settings.frame_count(&comp)).collect();
assert_eq!(n, vec![8, 8, 4]);
assert!(segs[1].1.contains("Main_002"), "{}", segs[1].1);
assert_eq!(segs[2].0.settings.first_frame(&comp), 16);
// Movies: a ~1 MB file per segment at the H.264 bitrate.
s.prefs.export.segment_movies = true;
s.prefs.export.segment_movie_mb = 1;
item.output.format = OutputFormat::H264;
item.output.bitrate_kbps = 4000; // 50 kB per frame at 10 fps
let segs = s.segments(&item, "/o/Main.mp4");
assert_eq!(segs.len(), 1, "20 frames × 50 kB fit in 1 MB");
item.output.bitrate_kbps = 8000; // 100 kB per frame: 10 frames per MB
let segs = s.segments(&item, "/o/Main.mp4");
assert_eq!(segs.iter().map(|(_, p)| PathBuf::from(p)).collect::<Vec<_>>(), [PathBuf::from("/o/Main_001.mp4"), PathBuf::from("/o/Main_002.mp4")]);
}
#[test]
fn every_wired_setting_is_live_and_display_only_ones_are_documented() {
let todo = crate::prefs::todo_keys();
assert_eq!(todo, ["general.useSystemColorPicker", "composition.hardwareAcceleratePanels", "scripting.enableJsDebugger"]);
let docs = include_str!("../../../docs/preferences.md");
assert!(docs.contains("### Display-only settings"));
assert!(!docs.contains("Not wired yet"));
}
// ---------------------------------------------------------------- scripting gate
#[test]
fn execute_file_needs_the_gate_and_asks_first() {
let mut s = session();
let f = tmp("run.txt");
std::fs::write(&f, "x").unwrap();
let p = json!({"path": f.to_string_lossy()});
assert!(s.execute("file.executeFile", p.clone()).is_err(), "off by default");
s.execute("prefs.set", json!({"key": "scripting.allowScriptsWriteFiles", "value": true})).unwrap();
s.drain_events();
let r = s.execute("file.executeFile", p.clone()).unwrap();
assert!(r.get("needsConfirmation").is_some());
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "app.confirmExecute")));
let r = s.execute("file.executeFile", json!({"path": f.to_string_lossy(), "confirmed": true})).unwrap();
assert!(r.get("opened").is_some());
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::OpenUrl(u) if u.starts_with("file://"))));
s.execute("prefs.set", json!({"key": "scripting.warnExecutingFiles", "value": false})).unwrap();
assert!(s.execute("file.executeFile", p).unwrap().get("opened").is_some());
}
// ---------------------------------------------------------------- Edit ▸ History, recent lists
#[test]
fn history_submenu_jumps_back_several_steps() {
let mut s = session();
solid(&mut s, "#ff0000");
solid(&mut s, "#00ff00");
solid(&mut s, "#0000ff");
let list = s.execute("edit.history", json!({})).unwrap();
assert_eq!(list["undo"][0], json!("New Solid"));
let n = s.history.undo.len();
let (entries, _) = crate::menus::dynamic(&s, "history", &Default::default());
assert_eq!(entries.len(), n);
assert_eq!(entries[1].params, json!({"steps": 2}));
s.execute("edit.history", json!({"steps": 2})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
assert_eq!(s.history.redo.len(), 2);
s.execute("edit.history", json!({"redo": 2})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 3);
assert!(s.execute("edit.history", json!({"steps": 999})).is_err());
}
#[test]
fn recent_footage_and_presets_are_remembered() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
// Imports are remembered even when the build has no decoder (the error comes after).
let _ = s.execute("file.import", json!({"paths": ["/x/clip.mov", "/x/still.png"]}));
assert_eq!(s.prefs.recent_footage, ["/x/still.png", "/x/clip.mov"]);
let (e, _) = crate::menus::dynamic(&s, "recentFootage", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["still.png", "clip.mov"]);
s.execute("file.clearRecentFootage", json!({})).unwrap();
assert_eq!(crate::menus::dynamic(&s, "recentFootage", &Default::default()), (vec![], Some("No Recent Footage")));
// Presets.
let path = tmp("fade.ecpreset");
s.execute("prop.select", json!({"layer": a, "path": "transform/opacity"})).unwrap();
s.execute("anim.savePreset", json!({"path": path})).unwrap();
s.execute("anim.applyPreset", json!({"path": path, "layers": [a]})).unwrap();
assert_eq!(s.prefs.recent_presets, [path.to_string_lossy().to_string()]);
let (e, _) = crate::menus::dynamic(&s, "recentPresets", &Default::default());
assert_eq!(e[0].label, "fade");
s.state.selected_layers = vec![LayerId(a)];
s.execute("anim.applyRecentPreset", json!({"index": 0})).unwrap();
s.execute("anim.clearRecentPresets", json!({})).unwrap();
assert!(s.prefs.recent_presets.is_empty());
// Recent lists survive a settings round trip and a reset.
s.prefs.push_recent_footage("/y/a.wav");
let back = crate::prefs::Prefs::from_json(&s.prefs.to_json());
assert_eq!(back.recent_footage, ["/y/a.wav"]);
s.execute("prefs.reset", json!({})).unwrap();
assert_eq!(s.prefs.recent_footage, ["/y/a.wav"]);
}
#[test]
fn save_a_copy_as_xml_opens_again() {
let mut s = session();
solid(&mut s, "#ff0000");
let path = tmp("copy.ecprojx");
s.execute("file.saveCopyAsXml", json!({"path": path.to_string_lossy()})).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.starts_with("<?xml") && text.contains("<EffectCraftProject"));
let before = s.project.to_json();
let mut t = Session::default();
t.execute("file.open", json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(t.project.to_json(), before);
// Saving a project opened from XML writes XML.
t.execute("file.save", json!({})).unwrap();
assert!(std::fs::read_to_string(&path).unwrap().starts_with("<?xml"));
}
// ---------------------------------------------------------------- shortcut slots
#[test]
fn assign_shortcut_slots_move_keys_between_views_and_workspaces() {
let mut s = session();
s.execute("view.3d.top", json!({})).unwrap();
let label = crate::menus::submenu_label(&s, "Assign Shortcut to 3D View", &Default::default());
assert_eq!(label, "Assign Shortcut to “Top”");
let (e, _) = crate::menus::dynamic(&s, "view3dShortcuts", &Default::default());
assert_eq!(e[0].label, "F10 (Replace “Front”)");
let r = s.execute("view.assign3dShortcut", json!({"slot": "F10"})).unwrap();
assert_eq!(r["replaced"], json!(["Front"]));
assert_eq!(s.shortcuts().shortcut_of("view.3d.top", &Value::Null), Some("F10"));
assert_eq!(s.shortcuts().shortcut_of("view.3d.front", &Value::Null), None);
// Workspaces: Shift+F11 now opens Animation instead of Standard.
s.execute("window.assignWorkspaceShortcut", json!({"slot": "Shift+F11", "workspace": "Animation"})).unwrap();
assert_eq!(s.shortcuts().shortcut_of("window.workspace", &json!({"name": "Animation"})), Some("Shift+F11"));
assert_eq!(s.shortcuts().shortcut_of("window.workspace", &json!({"name": "Standard"})), None);
assert!(s.execute("view.assign3dShortcut", json!({"slot": "F9"})).is_err());
// Without a workspace the frontend fills it in.
s.drain_events();
s.execute("window.assignWorkspaceShortcut", json!({"slot": "F10"})).unwrap();
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "window.assignWorkspaceShortcut")));
}
// ---------------------------------------------------------------- Help
#[test]
fn compatibility_report_and_logging() {
let mut s = session();
let r = s.execute("help.systemReport", json!({"quiet": true})).unwrap();
for k in ["os", "cpu", "cores", "memory", "gpu", "import", "export", "issues"] {
assert!(r.get(k).is_some(), "{k} in {r}");
}
let text = crate::commands::report_text_for_tests(&r);
assert!(text.contains("Operating system") && text.contains("Import:"));
// #596: without a GPU compositor the report says why, and names the window's adapter.
s.accel_note = Some("GPU compositor unavailable: no storage textures".into());
s.window_adapter = Some("NVIDIA GeForce RTX 4070 (Gl, DiscreteGpu)".into());
let r = s.execute("help.systemReport", json!({"quiet": true})).unwrap();
assert_eq!(r["gpuNote"], "GPU compositor unavailable: no storage textures");
let text = crate::commands::report_text_for_tests(&r);
assert!(text.contains("GPU: none (CPU compositing): GPU compositor unavailable: no storage textures"), "{text}");
assert!(text.contains("Window graphics: NVIDIA GeForce RTX 4070 (Gl, DiscreteGpu)"), "{text}");
assert!(r["issues"][0].as_str().is_some_and(|i| i.contains("no storage textures")), "{r}");
s.drain_events();
s.execute("help.systemReport", json!({})).unwrap();
assert!(s.drain_events().iter().any(|e| matches!(e, crate::Event::Frontend { command, .. } if command == "app.showReport")));
// Logging into a file in the settings folder.
let dir = tmp("cfg");
std::fs::create_dir_all(&dir).unwrap();
s.config = Some(std::sync::Arc::new(crate::config::DirConfig::new(dir.clone())));
let r = s.execute("help.enableLogging", json!({"on": true})).unwrap();
let file = PathBuf::from(r["path"].as_str().unwrap());
assert!(file.starts_with(&dir));
log::warn!(target: "effectcraft::tests", "hello log");
assert!(std::fs::read_to_string(&file).unwrap().contains("logging enabled"));
assert_eq!(crate::menus::checked(&s, "help.enableLogging", &Value::Null), Some(true));
let r = s.execute("help.revealLogFile", json!({})).unwrap();
assert!(r["url"].as_str().unwrap().starts_with("file://"));
s.execute("help.enableLogging", json!({"on": false})).unwrap();
assert!(!crate::logging::is_enabled());
}
// ---------------------------------------------------------------- VR
#[test]
fn create_vr_environment_and_extract_cubemap() {
let mut s = session();
let main = s.active_comp_id().unwrap();
let a = solid(&mut s, "#ff0000");
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "threeD", "value": true})).unwrap();
let undo = s.history.undo.len();
let aname = layer(&s, a).name.clone();
let r = s.execute("comp.vr.createEnvironment", json!({"size": 64})).unwrap();
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
assert_eq!(s.history.undo.last().unwrap().0, "Create VR Environment");
let faces: Vec<u64> = r["faces"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect();
assert_eq!(faces.len(), 6);
for f in &faces {
let c = s.project.comp(ItemId(*f)).unwrap();
assert_eq!((c.width, c.height), (64, 64));
assert!(c.layers.iter().any(|l| l.is_camera()));
let nested = c.layers.iter().find(|l| matches!(l.source, effectcraft_project::LayerSource::Comp { item } if item == main)).expect("the scene, nested");
assert!(nested.switches.collapse && nested.is_3d(), "collapsed 3D precomp");
let _ = &aname;
}
let cube = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap();
assert_eq!((cube.width, cube.height, cube.layers.len()), (192, 128, 6));
let out = s.project.comp(ItemId(r["output"].as_u64().unwrap())).unwrap();
assert_eq!((out.width, out.height), (128, 64));
let fx = out.layers[0].props.sub("effects").unwrap().groups().next().unwrap().clone();
assert_eq!(fx.match_id, "ec.vr.converter");
// Every face camera looks a different way.
let mut dirs = vec![];
for f in &faces {
let c = s.project.comp(ItemId(*f)).unwrap();
let cam = c.layers.iter().find(|l| l.is_camera()).unwrap();
dirs.push(format!("{:?}", cam.props.prop("transform/orientation").unwrap().value));
}
dirs.sort();
dirs.dedup();
assert_eq!(dirs.len(), 6, "{dirs:?}");
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.comp(ItemId(faces[0])).is_none());
// Extract Cubemap of a 2:1 comp.
let eq = s.execute("comp.new", json!({"name": "Equi", "width": 128, "height": 64, "duration": 1})).unwrap()["comp"].as_u64().unwrap();
let r = s.execute("comp.vr.extractCubemap", json!({"comp": eq})).unwrap();
let c = s.project.comp(ItemId(r["cubeMap"].as_u64().unwrap())).unwrap();
assert_eq!((c.width, c.height), (96, 64));
}
/// The equirectangular output sees each wall of a box around the rig in the right direction.
#[test]
fn vr_environment_faces_look_the_right_way() {
// One wall at a time (centre 100,100,0; AE's −Y is up), and where the equirectangular output
// must show it: front +Z, back −Z, right +X, left −X, up, down.
let walls = [
("front", [100.0, 100.0, 150.0], [0.0, 0.0], (0.5, 0.5)),
("back", [100.0, 100.0, -150.0], [0.0, 180.0], (0.01, 0.5)),
("right", [250.0, 100.0, 0.0], [0.0, 90.0], (0.75, 0.5)),
("left", [-50.0, 100.0, 0.0], [0.0, 90.0], (0.25, 0.5)),
("up", [100.0, -50.0, 0.0], [90.0, 0.0], (0.5, 0.03)),
("down", [100.0, 250.0, 0.0], [90.0, 0.0], (0.5, 0.97)),
];
for (name, pos, rot, (fx, fy)) in walls {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Room", "width": 200, "height": 200, "duration": 1, "frameRate": 10})).unwrap();
let l = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 200})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": pos})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/rotationX", "value": rot[0]})).unwrap();
s.execute("prop.set", json!({"layer": l, "path": "transform/rotationY", "value": rot[1]})).unwrap();
let r = s.execute("comp.vr.createEnvironment", json!({"size": 64})).unwrap();
let img = s.render(ItemId(r["output"].as_u64().unwrap()), Tick::ZERO, RenderOpts::default());
let (w, h) = (img.width as f64, img.height as f64);
let alpha =
|x: f64, y: f64| img.data[((y * h) as usize).min(img.height as usize - 1) * img.width as usize + ((x * w) as usize).min(img.width as usize - 1)][3];
assert!(alpha(fx, fy) > 0.5, "{name}: the wall shows in its direction");
// The opposite direction is empty.
let (ox, oy) = if fy != 0.5 { (fx, 1.0 - fy) } else { ((fx + 0.5) % 1.0, fy) };
assert!(alpha(ox, oy) < 0.5, "{name}: nothing in the opposite direction");
}
}
// ---------------------------------------------------------------- keyframes
#[test]
fn convert_audio_to_keyframes_makes_the_amplitude_null() {
assert_eq!(crate::commands::amplitudes_for_tests(&[0.5, -0.25, -0.5, 0.25]), (50.0, 25.0, 37.5));
let mut s = session();
let undo = s.history.undo.len();
let r = s.execute("keys.audioToKeyframes", json!({})).unwrap();
assert_eq!(s.history.undo.len(), undo + 1);
let l = layer(&s, r["layer"].as_u64().unwrap());
assert_eq!(l.name, "Audio Amplitude");
let fx: Vec<_> = l.props.sub("effects").unwrap().groups().map(|g| (g.name.clone(), g.get("slider").unwrap().keys.len())).collect();
assert_eq!(fx, [("Left Channel".to_string(), 20), ("Right Channel".to_string(), 20), ("Both Channels".to_string(), 20)]);
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layers.is_empty());
}
#[test]
fn keyframe_labels_and_label_groups() {
let mut s = session();
let a = solid(&mut s, "#ff0000");
for t in [0.0, 0.5, 1.0] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": 50})).unwrap();
}
let uid = layer(&s, a).props.prop("transform/opacity").unwrap().uid;
let k = |t: f64| KeyRef { layer: LayerId(a), prop: uid, time: Tick::from_seconds_f64(t) };
s.state.selected_keys = vec![k(0.0), k(1.0)];
// Edit ▸ Label colours the selected keyframes.
s.execute("edit.label", json!({"label": "Blue"})).unwrap();
let labels: Vec<u8> = layer(&s, a).props.prop("transform/opacity").unwrap().keys.iter().map(|k| k.label).collect();
let blue = effectcraft_color::Label::ALL.iter().position(|l| *l == effectcraft_color::Label::Blue).unwrap() as u8;
assert_eq!(labels, [blue, 0, blue]);
assert_eq!(layer(&s, a).label, effectcraft_color::Label::Red, "the layer keeps its label");
// Serde: labels round-trip; unlabeled keys don't write the field.
let json = s.project.to_json();
assert!(json.contains(&format!("\"label\": {blue}")) || json.contains(&format!("\"label\":{blue}")));
let back = effectcraft_project::Project::from_json(&json).unwrap();
assert_eq!(back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[2].label, blue);
// Moving a labelled key's value keeps the label.
s.execute("time.set", json!({"time": 1.0})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/opacity", "value": 80})).unwrap();
assert_eq!(layer(&s, a).props.prop("transform/opacity").unwrap().keys[2].label, blue);
// Select Keyframe Label Group.
s.state.selected_keys = vec![k(0.0)];
let r = s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap();
assert_eq!(r["keys"], 2);
s.state.selected_keys = vec![k(0.5)];
s.execute("keys.selectLabelGroup", json!({"scope": "selected"})).unwrap();
assert_eq!(s.state.selected_keys, vec![k(0.5)]);
s.state.selected_keys = vec![k(0.0)];
let r = s.execute("keys.selectLabelGroup", json!({"scope": "visibleAll", "visible": [12345]})).unwrap();
assert_eq!(r["keys"], 0, "only the given (visible) properties");
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert!(layer(&s, a).props.prop("transform/opacity").unwrap().keys.iter().all(|k| k.label == 0));
}
#[test]
fn rpf_camera_import_reads_json_and_csv_camera_data() {
let mut s = session();
let json_path = tmp("cam.json");
std::fs::write(
&json_path,
r#"{"frameRate": 10, "frames": [{"frame": 0, "position": [0, 0, -500], "rotation": [0, 0, 0], "fov": 90}, {"frame": 10, "position": [100, 0, -500], "orientation": [0, 30, 0], "zoom": 400}]}"#,
)
.unwrap();
let undo = s.history.undo.len();
let r = s.execute("keys.rpfCameraImport", json!({"path": json_path.to_string_lossy()})).unwrap();
assert_eq!(s.history.undo.len(), undo + 1);
let cam = layer(&s, r["layer"].as_u64().unwrap());
assert!(cam.is_camera());
let pos = &cam.props.prop("transform/position").unwrap().keys;
assert_eq!(pos.len(), 2);
assert_eq!(pos[1].value, KV::Vec3([100.0, 0.0, -500.0]));
assert!((pos[1].time.seconds() - 1.0).abs() < 1e-9);
let zoom = &cam.props.prop("cameraOptions/zoom").unwrap().keys;
assert!((zoom[0].value.as_f64() - 32.0).abs() < 1e-6, "90° across 64 px = zoom 32: {:?}", zoom[0].value);
let csv = tmp("cam.csv");
std::fs::write(&csv, "frame,px,py,pz,rx,ry,rz,zoom\n0,1,2,3,0,0,0,100\n5,4,5,6,10,20,30,200\n").unwrap();
let r = s.execute("keys.rpfCameraImport", json!({"path": csv.to_string_lossy()})).unwrap();
let cam = layer(&s, r["layer"].as_u64().unwrap());
let o = &cam.props.prop("transform/orientation").unwrap().keys;
assert_eq!(o[1].value, KV::Vec3([10.0, 20.0, 30.0]));
assert!(s.execute("keys.rpfCameraImport", json!({"path": "/x/scene.rpf"})).unwrap_err().to_string().contains("JSON or CSV"));
}
// ---------------------------------------------------------------- menus
#[test]
fn dynamic_menus_and_window_viewer_labels() {
let mut s = session();
s.prefs.push_recent("/p/One.ecproj");
let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
assert_eq!((e[0].label.as_str(), e[0].command.as_str()), ("One.ecproj", "file.openRecent"));
assert_eq!(crate::menus::dynamic(&s, "history", &Default::default()).0.len(), s.history.undo.len());
let a = solid(&mut s, "#ff0000");
s.state.selected_layers = vec![LayerId(a)];
let entry = |cmd: &str, panel: &str| crate::menus::MenuEntry {
label: "x".into(),
command: cmd.into(),
params: json!({"panel": panel}),
shortcut: None,
alt_shortcuts: vec![],
};
assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "composition")), "Composition: Main");
assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "timeline")), "Timeline: Main");
assert!(crate::menus::entry_label(&s, &entry("window.panel", "layer")).starts_with("Layer: "));
assert_eq!(crate::menus::entry_label(&s, &entry("window.panel", "footage")), "Footage: (none)");
// Other open comps are listed as viewers.
let other = s.execute("comp.new", json!({"name": "Second"})).unwrap()["comp"].as_u64().unwrap();
let (e, _) = crate::menus::dynamic(&s, "openViewers", &Default::default());
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Composition: Main"]);
assert_eq!(e[0].command, "comp.open");
let _ = other;
// Every tree entry of the new menus resolves.
for (path, e) in crate::menus::entries() {
if ["History", "VR", "Recent Animation Presets", "Import Recent Footage"].iter().any(|p| path.contains(&p.to_string())) {
assert!(crate::commands::find(&e.command).is_some());
}
}
let labels: Vec<String> = crate::menus::entries().iter().map(|(_, e)| e.label.clone()).collect();
for l in [
"Save a Copy As XML...",
"With Layered Comp",
"Create VR Environment...",
"Extract Cubemap...",
"System Compatibility Report...",
"Enable Logging",
"Reveal Logging File",
] {
assert!(labels.iter().any(|x| x == l), "{l}");
}
}
#[test]
fn unsaved_project_renders_to_movies_or_home_not_the_working_directory() {
// A Finder-launched app has `/` as its working directory, where renders fail (#446).
let home = tmp("home446");
assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.clone()));
std::fs::create_dir_all(home.join("Movies")).unwrap();
assert_eq!(crate::render_queue::default_output_base(Some(home.clone())), Some(home.join("Movies")));
assert_eq!(crate::render_queue::default_output_base(None), None);
}