mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 21:16:52 -06:00
File > Import > EffectCraft Project (and .ecproj/.ecprojx given to File > Import)
merges the project into a folder named after the file, with every id shifted past
the open project's, in one undo step. Relative footage resolves next to the file;
the result lists comps, root comps, primaryComp and missing media.
offset_ids now shifts tracker Motion Targets and saturates instead of overflowing;
fix_next_id saturates too, so a damaged file can't overflow on open or import.
Menu entry translated in all ten menu catalogues.
Rebased onto current upstream main for the split of #858 (test module list in
engine lib.rs; es/pt-br/ru/fr/de/cs menu translations).
(cherry picked from commit 19c653fccc95133fb35e978a7f7f20a18c0e8c89)
(cherry picked from commit 4e751ad2)
(cherry picked from commit ceed706cc3b5f3755769738a1ddb3d965a94ab6b)
159 lines
7.7 KiB
Rust
159 lines
7.7 KiB
Rust
//! File ▸ Import ▸ EffectCraft Project: another project merges into the open one with fresh ids,
|
|
//! its references intact, in one undo step.
|
|
|
|
use std::collections::HashSet;
|
|
|
|
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Project};
|
|
use serde_json::json;
|
|
|
|
use crate::Session;
|
|
|
|
fn out_dir(name: &str) -> std::path::PathBuf {
|
|
let d = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out").join(name);
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
/// Every item, layer and property uid of `p` (they share one counter).
|
|
fn all_ids(p: &Project) -> Vec<u64> {
|
|
let mut v = vec![];
|
|
for (id, it) in &p.items {
|
|
v.push(id.0);
|
|
if let ItemKind::Comp(c) = &it.kind {
|
|
for l in &c.layers {
|
|
v.push(l.id.0);
|
|
fn uids(g: &effectcraft_project::PropGroup, v: &mut Vec<u64>) {
|
|
v.push(g.uid);
|
|
for c in &g.children {
|
|
match c {
|
|
effectcraft_project::Node::Prop(p) => v.push(p.uid),
|
|
effectcraft_project::Node::Group(g) => uids(g, v),
|
|
}
|
|
}
|
|
}
|
|
uids(&l.props, &mut v);
|
|
}
|
|
}
|
|
}
|
|
v
|
|
}
|
|
|
|
/// A project with "Main" nesting "Inner" under a parented null, a data file next to it by a
|
|
/// relative path and a footage file that's gone. Saved as `<dir>/Library Card.ecproj`.
|
|
fn source_project(dir: &std::path::Path) -> String {
|
|
let mut s = Session::default();
|
|
s.execute("comp.new", json!({"name": "Inner", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
|
let inner = s.active_comp_id().unwrap();
|
|
s.execute("layer.newSolid", json!({"name": "Fill", "color": "#ff0000"})).unwrap();
|
|
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
|
let main = s.active_comp_id().unwrap();
|
|
let nest = s.execute("layer.addItem", json!({"comp": main.0, "item": inner.0, "time": 0.0})).unwrap()["layer"].as_u64().unwrap();
|
|
let rig = s.execute("layer.newNull", json!({"name": "Rig"})).unwrap()["layer"].as_u64().unwrap();
|
|
s.execute("layer.setParent", json!({"layers": [nest], "parent": rig})).unwrap();
|
|
std::fs::write(dir.join("data.json"), b"{\"v\": 1}").unwrap();
|
|
let p = std::sync::Arc::make_mut(&mut s.project);
|
|
p.add_item("data.json", Default::default(), None, ItemKind::Footage(Footage { path: "data.json".into(), kind: FootageKind::Data, ..Default::default() }));
|
|
p.add_item(
|
|
"gone.mov",
|
|
Default::default(),
|
|
None,
|
|
ItemKind::Footage(Footage { path: "/offline/gone.mov".into(), kind: FootageKind::Video, ..Default::default() }),
|
|
);
|
|
let path = dir.join("Library Card.ecproj").to_string_lossy().to_string();
|
|
s.execute("file.saveAs", json!({"path": path})).unwrap();
|
|
path
|
|
}
|
|
|
|
#[test]
|
|
fn import_project_merges_with_fresh_ids_and_one_undo() {
|
|
let dir = out_dir("import-project");
|
|
let path = source_project(&dir);
|
|
|
|
let mut t = Session::default();
|
|
t.execute("comp.new", json!({"name": "Show", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
|
t.execute("layer.newSolid", json!({"name": "Back", "color": "#00ff00"})).unwrap();
|
|
let before = t.project.items.len();
|
|
let before_ids: HashSet<u64> = all_ids(&t.project).into_iter().collect();
|
|
|
|
let r = t.execute("file.importProject", json!({"path": path})).unwrap();
|
|
let folder = ItemId(r["folder"].as_u64().unwrap());
|
|
assert_eq!(t.project.item(folder).unwrap().name, "Library Card.ecproj");
|
|
// Inner, Main, the Solids folder and its solid, two footage items, and the new folder.
|
|
assert_eq!(t.project.items.len(), before + 7);
|
|
let imported: Vec<ItemId> = r["items"].as_array().unwrap().iter().map(|v| ItemId(v.as_u64().unwrap())).collect();
|
|
// Everything lands in the folder (the Solids folder keeps its solid).
|
|
let top = |mut id: ItemId| {
|
|
while let Some(up) = t.project.item(id).and_then(|i| i.parent).filter(|p| *p != folder) {
|
|
id = up;
|
|
}
|
|
t.project.item(id).and_then(|i| i.parent)
|
|
};
|
|
assert!(imported.iter().all(|id| top(*id) == Some(folder)), "everything lands in the folder");
|
|
|
|
// No id is taken twice, and none of the open project's ids moved.
|
|
let ids = all_ids(&t.project);
|
|
let unique: HashSet<u64> = ids.iter().copied().collect();
|
|
assert_eq!(ids.len(), unique.len(), "ids collide");
|
|
assert!(before_ids.is_subset(&unique));
|
|
assert!(t.project.next_id > *ids.iter().max().unwrap());
|
|
|
|
// References follow the new ids: Main nests the imported Inner, under the imported null.
|
|
let main = ItemId(r["primaryComp"].as_u64().unwrap());
|
|
assert_eq!(t.project.item(main).unwrap().name, "Main");
|
|
assert_eq!(r["rootComps"], json!([main.0]));
|
|
let comp = t.project.comp(main).unwrap();
|
|
let nest = comp.layers.iter().find(|l| matches!(l.source, LayerSource::Comp { .. })).unwrap();
|
|
let LayerSource::Comp { item } = nest.source else { panic!("a precomp layer") };
|
|
assert_eq!(t.project.item(item).unwrap().name, "Inner");
|
|
assert!(imported.contains(&item));
|
|
let parent = nest.parent.unwrap();
|
|
assert_eq!(comp.layer(parent).unwrap().name, "Rig");
|
|
|
|
// A relative footage path resolves next to the imported project; a missing one is reported.
|
|
let data = t.project.items.values().find(|i| i.name == "data.json").unwrap();
|
|
let ItemKind::Footage(f) = &data.kind else { panic!("footage") };
|
|
assert_eq!(std::path::Path::new(&f.path), dir.join("data.json"));
|
|
assert_eq!(r["missingMedia"], json!(["/offline/gone.mov"]));
|
|
|
|
// One undo step removes all of it; redo brings it back.
|
|
t.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(t.project.items.len(), before);
|
|
t.execute("edit.redo", json!({})).unwrap();
|
|
assert_eq!(t.project.items.len(), before + 7);
|
|
|
|
// Importing again keeps the copies apart, and File ▸ Import routes .ecproj here.
|
|
let r2 = t.execute("file.import", json!({"paths": [path]})).unwrap();
|
|
let again = &r2["projects"][0];
|
|
assert_ne!(again["primaryComp"], r["primaryComp"]);
|
|
assert!(r2["items"].as_array().unwrap().is_empty(), "a project is not footage");
|
|
let ids = all_ids(&t.project);
|
|
assert_eq!(ids.len(), ids.iter().copied().collect::<HashSet<_>>().len());
|
|
}
|
|
|
|
#[test]
|
|
fn import_project_rejects_bad_files_and_leaves_the_project() {
|
|
let dir = out_dir("import-project-bad");
|
|
let mut t = Session::default();
|
|
t.execute("comp.new", json!({"name": "Show", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
|
|
let before = t.project.items.len();
|
|
|
|
std::fs::write(dir.join("junk.ecproj"), b"not a project").unwrap();
|
|
assert!(t.execute("file.importProject", json!({"path": dir.join("junk.ecproj").to_string_lossy()})).is_err());
|
|
assert!(t.execute("file.importProject", json!({"path": dir.join("absent.ecproj").to_string_lossy()})).is_err());
|
|
|
|
// Regression: an id at the top of the range saturated instead of overflowing (opening and
|
|
// importing must not panic), and the import refuses it.
|
|
let mut p = Project::default();
|
|
let id = p.add_item("Huge", Default::default(), None, ItemKind::Folder);
|
|
let mut it = p.items.remove(&id).unwrap();
|
|
it.id = ItemId(u64::MAX);
|
|
p.items.insert(it.id, it);
|
|
let huge = dir.join("huge.ecproj");
|
|
std::fs::write(&huge, p.to_json()).unwrap();
|
|
assert_eq!(Project::from_json(&p.to_json()).unwrap().next_id, u64::MAX);
|
|
let e = t.execute("file.importProject", json!({"path": huge.to_string_lossy()})).unwrap_err();
|
|
assert!(e.to_string().contains("out of range"), "{e}");
|
|
assert_eq!(t.project.items.len(), before);
|
|
}
|