mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-08 03:22:22 +00:00
Image sequences were found only by the desktop media prober: one picked numbered still brought its whole run, with no way to import just that frame, picking every frame made one sequence item per frame, the browser couldn't make sequences at all, and a gap in the numbering closed up silently. The engine now groups numbered stills itself (engine/src/sequence.rs): one picked file brings its run from its folder, several picked files of one run import as that range, a picked or dropped folder imports its files with each run as a sequence, and Force Alphabetical Order takes every image of that type in the folder by name. `file.import` takes `sequence` (default on), `alphabetical` and `frameRate`; Replace Footage, Reload, proxies and render outputs probe the same way. The media layer probes one file; the browser lists its file table for the run and registers every frame with its media pool. File > Import > File... asks, like After Effects' "PNG Sequence" checkbox, whether picked numbered stills import as a sequence, with Force Alphabetical Order and the frame rate. Sequences are named `shot_[0001-0250].png`. Interpret Footage gains Missing Frames (Show Placeholder, colour bars as in After Effects, the new default; Hold Previous Frame; Skip) and Start Frame (the frame number at the footage's first frame: trims or pads the head). Saved projects keep Skip, so their sequences play as before. Form notes keep a readable width and the form fits its rows, so OK stays in view. docs/footage.md explains importing footage and sequences. Test: tests_sequence (engine: run, picked range, every frame, Sequence off, folder, alphabetical, Interpret Footage Missing Frames / Start Frame, Replace Footage and Reload), project sequence unit tests, sequence_gaps_show_placeholders_hold_or_close_up (media), import_dialog_offers_the_image_sequence_option and dropped_frames_import_as_one_sequence_with_sequence_interpretation (UI). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
668 lines
34 KiB
Rust
668 lines
34 KiB
Rust
//! 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);
|
||
}
|
||
|
||
#[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:"));
|
||
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 };
|
||
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}");
|
||
}
|
||
}
|