mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-21 19:22:52 -05:00
file.importProject: import an EffectCraft project into the open project
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 four languages. (cherry picked from commit 19c653fccc95133fb35e978a7f7f20a18c0e8c89) (cherry picked from commit f4d36d11b0ccdfc56371a3bbd8d43d3b8281ee1b) (cherry picked from commit c08135b2de8429693e723d065584465c459a61f9)
This commit is contained in:
1 parent
a5b7e18cfd
commit
4e751ad241
11 files changed
+378
-30
No files matched your search
@@ -236,6 +236,8 @@ struct PendingImport {
|
|||||||
comps: Vec<u64>,
|
comps: Vec<u64>,
|
||||||
items: Vec<u64>,
|
items: Vec<u64>,
|
||||||
errors: Vec<String>,
|
errors: Vec<String>,
|
||||||
|
/// EffectCraft projects merged in (`file.importProject` results); never added to a comp.
|
||||||
|
projects: Vec<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything probing a file needs, apart from the session (it can run on a background thread).
|
/// Everything probing a file needs, apart from the session (it can run on a background thread).
|
||||||
@@ -361,10 +363,18 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
|||||||
_ => return Err(bad("file.import", "page: a page number from 1")),
|
_ => return Err(bad("file.import", "page: a page number from 1")),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let mut paths = paths;
|
|
||||||
let mut comps = vec![];
|
let mut comps = vec![];
|
||||||
let mut items = vec![];
|
let mut items = vec![];
|
||||||
let mut errors = vec![];
|
let mut errors = vec![];
|
||||||
|
// EffectCraft projects import as projects, as After Effects imports an .aep.
|
||||||
|
let (project_paths, mut paths): (Vec<String>, Vec<String>) = paths.into_iter().partition(|x| super::import_project::is_project_path(x));
|
||||||
|
let mut projects = vec![];
|
||||||
|
for path in project_paths {
|
||||||
|
match s.execute("file.importProject", json!({"path": path})) {
|
||||||
|
Ok(r) => projects.push(r),
|
||||||
|
Err(e) => errors.push(format!("{path}: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(retain) = retain {
|
if let Some(retain) = retain {
|
||||||
let mut rest = vec![];
|
let mut rest = vec![];
|
||||||
for path in paths {
|
for path in paths {
|
||||||
@@ -415,13 +425,13 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
|||||||
page,
|
page,
|
||||||
psd_layer: p.get("layer").cloned(),
|
psd_layer: p.get("layer").cloned(),
|
||||||
};
|
};
|
||||||
Ok(PendingImport { sources, prober, comps, items, errors })
|
Ok(PendingImport { sources, prober, comps, items, errors, projects })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The last part of an import: add the probed files to the project (one undo step) and select
|
/// The last part of an import: add the probed files to the project (one undo step) and select
|
||||||
/// everything the import made, so the Project panel shows it.
|
/// everything the import made, so the Project panel shows it.
|
||||||
fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec<std::result::Result<Probed, String>>) -> Result<Value> {
|
fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec<std::result::Result<Probed, String>>) -> Result<Value> {
|
||||||
let PendingImport { comps, mut items, mut errors, .. } = pending;
|
let PendingImport { comps, mut items, mut errors, projects, .. } = pending;
|
||||||
let mut found = vec![];
|
let mut found = vec![];
|
||||||
let mut warnings = vec![];
|
let mut warnings = vec![];
|
||||||
for r in probed {
|
for r in probed {
|
||||||
@@ -473,7 +483,11 @@ fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec<std::result:
|
|||||||
if let Some(first) = asked.first() {
|
if let Some(first) = asked.first() {
|
||||||
s.events.push(crate::Event::Frontend { command: "file.interpretFootage".into(), params: json!({"item": first, "reason": "unlabeledAlpha"}) });
|
s.events.push(crate::Event::Frontend { command: "file.interpretFootage".into(), params: json!({"item": first, "reason": "unlabeledAlpha"}) });
|
||||||
}
|
}
|
||||||
Ok(json!({"items": items, "comps": comps, "errors": errors, "warnings": warnings, "unlabeledAlpha": asked}))
|
let mut out = json!({"items": items, "comps": comps, "errors": errors, "warnings": warnings, "unlabeledAlpha": asked});
|
||||||
|
if !projects.is_empty() {
|
||||||
|
out["projects"] = Value::Array(projects);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a file format states how its alpha is stored (PNG, GIF, WebP and PSD are straight by
|
/// Whether a file format states how its alpha is stored (PNG, GIF, WebP and PSD are straight by
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//! File ▸ Import ▸ EffectCraft Project: merge another `.ecproj` (or its XML copy) into the
|
||||||
|
//! current project, as After Effects imports an `.aep` into the open project. The imported
|
||||||
|
//! items land in a folder named after the file; every id is shifted past the current project's
|
||||||
|
//! so nothing collides, and the whole import is one undo step.
|
||||||
|
|
||||||
|
use effectcraft_color::Label;
|
||||||
|
use effectcraft_project::{ItemId, ItemKind, LayerSource, Project, essential};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use super::{CommandSpec, always, bad, str_p};
|
||||||
|
use crate::{EngineError, Result, Session, cmd};
|
||||||
|
|
||||||
|
const C: &str = "file.importProject";
|
||||||
|
|
||||||
|
/// Whether `path` names a project EffectCraft imports as a project (not as footage).
|
||||||
|
pub(crate) fn is_project_path(path: &str) -> bool {
|
||||||
|
let lower = path.to_ascii_lowercase();
|
||||||
|
lower.ends_with(".ecproj") || lower.ends_with(".ecprojx")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a project file (JSON or its XML copy), as File ▸ Open does.
|
||||||
|
fn read_project(s: &Session, path: &str) -> Result<(Project, Option<String>)> {
|
||||||
|
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
|
||||||
|
let text = String::from_utf8(bytes).map_err(|_| bad(C, format!("{path} is not a text project file")))?;
|
||||||
|
let text = if crate::xml_project::is_xml(&text) { crate::xml_project::from_xml(&text).map_err(EngineError::Other)? } else { text };
|
||||||
|
let project = Project::from_json(&text).map_err(|e| bad(C, format!("{path} is not a project EffectCraft can open: {e}")))?;
|
||||||
|
Ok((project, effectcraft_project::saved_by(&text)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Footage paths relative to the imported project's folder become absolute when the file is
|
||||||
|
/// there; returns the footage paths that are missing.
|
||||||
|
fn resolve_footage(s: &Session, project: &mut Project, dir: &std::path::Path) -> Vec<String> {
|
||||||
|
let services = s.services.clone();
|
||||||
|
let resolve = |raw: &mut String| -> bool {
|
||||||
|
if raw.is_empty() || services.exists(raw) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if std::path::Path::new(raw.as_str()).is_absolute() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let joined = dir.join(raw.as_str()).to_string_lossy().to_string();
|
||||||
|
if services.exists(&joined) {
|
||||||
|
*raw = joined;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut missing = vec![];
|
||||||
|
for it in project.items.values_mut() {
|
||||||
|
if let ItemKind::Footage(f) = &mut it.kind {
|
||||||
|
if f.sequence.is_empty() {
|
||||||
|
if !resolve(&mut f.path) {
|
||||||
|
missing.push(f.path.clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut all = true;
|
||||||
|
for frame in &mut f.sequence {
|
||||||
|
all &= resolve(frame);
|
||||||
|
}
|
||||||
|
if !all {
|
||||||
|
missing.push(f.path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
missing
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The comps no other comp of `project` nests: the ones to open or place.
|
||||||
|
fn root_comps(project: &Project) -> Vec<ItemId> {
|
||||||
|
let nested: std::collections::HashSet<ItemId> =
|
||||||
|
project.comps().flat_map(|(_, c)| c.layers.iter().filter_map(|l| if let LayerSource::Comp { item } = l.source { Some(item) } else { None })).collect();
|
||||||
|
project.comps().map(|(id, _)| *id).filter(|id| !nested.contains(id)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn import_project(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let path = str_p(p, "path").ok_or_else(|| bad(C, "missing `path` (.ecproj)"))?.to_string();
|
||||||
|
let (mut sub, saved_by) = read_project(s, &path)?;
|
||||||
|
crate::upgrade_effects(&mut sub);
|
||||||
|
let file = std::path::Path::new(&path);
|
||||||
|
let dir = file.parent().map(|d| d.to_path_buf()).unwrap_or_default();
|
||||||
|
let missing = resolve_footage(s, &mut sub, &dir);
|
||||||
|
// The imported project's own render queue stays behind (as for templates).
|
||||||
|
let base = s.project.next_id;
|
||||||
|
if sub.next_id.checked_add(base).is_none() {
|
||||||
|
return Err(bad(C, format!("{path} has ids out of range")));
|
||||||
|
}
|
||||||
|
essential::offset_ids(&mut sub, base);
|
||||||
|
let roots: Vec<ItemId> = root_comps(&sub);
|
||||||
|
let folder_name = file.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Imported Project".into());
|
||||||
|
let (folder, items) = s.edit("Import Project", None, |proj, st| {
|
||||||
|
let folder = proj.add_item(&folder_name, Label::Yellow, None, ItemKind::Folder);
|
||||||
|
let mut items = vec![];
|
||||||
|
for (id, mut it) in std::mem::take(&mut sub.items) {
|
||||||
|
if it.parent.is_none() {
|
||||||
|
it.parent = Some(folder);
|
||||||
|
}
|
||||||
|
items.push(id.0);
|
||||||
|
proj.items.insert(id, it);
|
||||||
|
}
|
||||||
|
proj.next_id = proj.next_id.max(sub.next_id);
|
||||||
|
proj.fix_next_id();
|
||||||
|
st.project_selection = vec![folder];
|
||||||
|
Ok((folder, items))
|
||||||
|
})?;
|
||||||
|
let comps: Vec<Value> = s
|
||||||
|
.project
|
||||||
|
.comps()
|
||||||
|
.filter(|(id, _)| items.contains(&id.0))
|
||||||
|
.map(|(id, _)| json!({"id": id.0, "name": s.project.item(*id).map(|i| i.name.clone()).unwrap_or_default()}))
|
||||||
|
.collect();
|
||||||
|
let primary = if let [one] = roots.as_slice() { Some(one.0) } else { None };
|
||||||
|
let mut warnings = vec![];
|
||||||
|
if let Some(v) = saved_by.as_deref().filter(|v| effectcraft_project::is_newer_version(v, effectcraft_project::APP_VERSION)) {
|
||||||
|
warnings.push(format!("saved by EffectCraft {v}, newer than this version ({})", effectcraft_project::APP_VERSION));
|
||||||
|
}
|
||||||
|
if !missing.is_empty() {
|
||||||
|
warnings.push(format!("{} footage file{} missing", missing.len(), if missing.len() == 1 { " is" } else { "s are" }));
|
||||||
|
}
|
||||||
|
s.toast(if warnings.is_empty() { format!("Imported {folder_name}") } else { format!("Imported {folder_name}: {}", warnings.join("; ")) });
|
||||||
|
Ok(json!({
|
||||||
|
"folder": folder.0,
|
||||||
|
"items": items,
|
||||||
|
"comps": comps,
|
||||||
|
"rootComps": roots.iter().map(|id| id.0).collect::<Vec<_>>(),
|
||||||
|
"primaryComp": primary,
|
||||||
|
"missingMedia": missing,
|
||||||
|
"warnings": warnings,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn specs() -> Vec<CommandSpec> {
|
||||||
|
vec![cmd!(
|
||||||
|
"file.importProject",
|
||||||
|
"EffectCraft Project...",
|
||||||
|
["File", "Import"],
|
||||||
|
None,
|
||||||
|
"{path (.ecproj or .ecprojx)} — merges the project into a folder named after the file; returns {folder, items, comps, rootComps, primaryComp (the only root comp, else null), missingMedia, warnings}",
|
||||||
|
always,
|
||||||
|
import_project
|
||||||
|
)]
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ pub mod footage_panel;
|
|||||||
pub(crate) mod frame_export;
|
pub(crate) mod frame_export;
|
||||||
mod frontend;
|
mod frontend;
|
||||||
mod help;
|
mod help;
|
||||||
|
mod import_project;
|
||||||
mod key_labels;
|
mod key_labels;
|
||||||
mod key_transform;
|
mod key_transform;
|
||||||
mod keys_more;
|
mod keys_more;
|
||||||
@@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||||||
v.extend(relink::specs());
|
v.extend(relink::specs());
|
||||||
v.extend(relink_moved::specs());
|
v.extend(relink_moved::specs());
|
||||||
v.extend(lottie::specs());
|
v.extend(lottie::specs());
|
||||||
|
v.extend(import_project::specs());
|
||||||
v.extend(timeline::specs());
|
v.extend(timeline::specs());
|
||||||
v.extend(comp_more::specs());
|
v.extend(comp_more::specs());
|
||||||
v.extend(frame_export::specs());
|
v.extend(frame_export::specs());
|
||||||
|
|||||||
@@ -1068,6 +1068,8 @@ mod tests_fidelity;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_frame_export;
|
mod tests_frame_export;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_import_project;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_key_metadata_order;
|
mod tests_key_metadata_order;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_keylight;
|
mod tests_keylight;
|
||||||
|
|||||||
@@ -525,6 +525,7 @@ File
|
|||||||
File... | file.import
|
File... | file.import
|
||||||
Multiple Files... | file.importMultiple
|
Multiple Files... | file.importMultiple
|
||||||
Adobe Premiere Pro Project... | file.importTimeline
|
Adobe Premiere Pro Project... | file.importTimeline
|
||||||
|
EffectCraft Project... | file.importProject
|
||||||
Placeholder... | file.importPlaceholder
|
Placeholder... | file.importPlaceholder
|
||||||
Solid... | file.importSolid
|
Solid... | file.importSolid
|
||||||
Lottie... | file.importLottie
|
Lottie... | file.importLottie
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
@@ -701,13 +701,13 @@ pub fn with_overrides(project: &Project, comp: ItemId, overrides: &[Override]) -
|
|||||||
// ------------------------------------------------------------------------------ id remapping
|
// ------------------------------------------------------------------------------ id remapping
|
||||||
|
|
||||||
/// Shift every id (items, layers, property uids, Essential Graphics controls) of `p` by
|
/// Shift every id (items, layers, property uids, Essential Graphics controls) of `p` by
|
||||||
/// `offset`, keeping all references consistent: used when merging a template project into
|
/// `offset`, keeping all references consistent: used when merging a template or another project
|
||||||
/// another project.
|
/// into a project. Ids from a damaged file saturate instead of overflowing.
|
||||||
pub fn offset_ids(p: &mut Project, offset: u64) {
|
pub fn offset_ids(p: &mut Project, offset: u64) {
|
||||||
let items = std::mem::take(&mut p.items);
|
let items = std::mem::take(&mut p.items);
|
||||||
for (_, mut it) in items {
|
for (_, mut it) in items {
|
||||||
it.id = ItemId(it.id.0 + offset);
|
it.id = ItemId(it.id.0.saturating_add(offset));
|
||||||
it.parent = it.parent.map(|f| ItemId(f.0 + offset));
|
it.parent = it.parent.map(|f| ItemId(f.0.saturating_add(offset)));
|
||||||
if let ItemKind::Comp(c) = &mut it.kind {
|
if let ItemKind::Comp(c) = &mut it.kind {
|
||||||
let c = std::sync::Arc::make_mut(c);
|
let c = std::sync::Arc::make_mut(c);
|
||||||
for l in &mut c.layers {
|
for l in &mut c.layers {
|
||||||
@@ -716,18 +716,18 @@ pub fn offset_ids(p: &mut Project, offset: u64) {
|
|||||||
if let Some(eg) = &mut c.essential {
|
if let Some(eg) = &mut c.essential {
|
||||||
fn go(v: &mut [EgControl], o: u64) {
|
fn go(v: &mut [EgControl], o: u64) {
|
||||||
for c in v {
|
for c in v {
|
||||||
c.id += o;
|
c.id = c.id.saturating_add(o);
|
||||||
match &mut c.kind {
|
match &mut c.kind {
|
||||||
EgKind::Property { layer, prop, links } => {
|
EgKind::Property { layer, prop, links } => {
|
||||||
*layer = LayerId(layer.0 + o);
|
*layer = LayerId(layer.0.saturating_add(o));
|
||||||
*prop += o;
|
*prop = prop.saturating_add(o);
|
||||||
for k in links {
|
for k in links {
|
||||||
k.layer = LayerId(k.layer.0 + o);
|
k.layer = LayerId(k.layer.0.saturating_add(o));
|
||||||
k.prop += o;
|
k.prop = k.prop.saturating_add(o);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EgKind::Mirror { of } => *of += o,
|
EgKind::Mirror { of } => *of = of.saturating_add(o),
|
||||||
EgKind::Media { layer } => *layer = LayerId(layer.0 + o),
|
EgKind::Media { layer } => *layer = LayerId(layer.0.saturating_add(o)),
|
||||||
EgKind::Group { children } => go(children, o),
|
EgKind::Group { children } => go(children, o),
|
||||||
EgKind::Comment { .. } => {}
|
EgKind::Comment { .. } => {}
|
||||||
}
|
}
|
||||||
@@ -739,34 +739,43 @@ pub fn offset_ids(p: &mut Project, offset: u64) {
|
|||||||
p.items.insert(it.id, it);
|
p.items.insert(it.id, it);
|
||||||
}
|
}
|
||||||
p.render_queue.clear();
|
p.render_queue.clear();
|
||||||
p.next_id += offset;
|
p.next_id = p.next_id.saturating_add(offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn offset_layer(l: &mut Layer, o: u64) {
|
fn offset_layer(l: &mut Layer, o: u64) {
|
||||||
l.id = LayerId(l.id.0 + o);
|
l.id = LayerId(l.id.0.saturating_add(o));
|
||||||
l.parent = l.parent.map(|x| LayerId(x.0 + o));
|
l.parent = l.parent.map(|x| LayerId(x.0.saturating_add(o)));
|
||||||
if let Some(tm) = &mut l.track_matte {
|
if let Some(tm) = &mut l.track_matte {
|
||||||
tm.layer = LayerId(tm.layer.0 + o);
|
tm.layer = LayerId(tm.layer.0.saturating_add(o));
|
||||||
}
|
}
|
||||||
match &mut l.source {
|
match &mut l.source {
|
||||||
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } | LayerSource::Model { item } => *item = ItemId(item.0 + o),
|
LayerSource::Footage { item } | LayerSource::Comp { item } | LayerSource::Solid { item } | LayerSource::Model { item } => {
|
||||||
|
*item = ItemId(item.0.saturating_add(o))
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
fn group(g: &mut PropGroup, o: u64, essential_parent: bool) {
|
fn group(g: &mut PropGroup, o: u64, essential_parent: bool) {
|
||||||
g.uid += o;
|
g.uid = g.uid.saturating_add(o);
|
||||||
if let GroupKind::Essential { overridden } = &mut g.kind {
|
match &mut g.kind {
|
||||||
for u in overridden.iter_mut() {
|
GroupKind::Essential { overridden } => {
|
||||||
*u += o;
|
for u in overridden.iter_mut() {
|
||||||
|
*u = u.saturating_add(o);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// A tracker's Motion Target is a layer of the same comp.
|
||||||
|
GroupKind::Tracker { settings } => {
|
||||||
|
settings.target = settings.target.map(|t| LayerId(t.0.saturating_add(o)));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
let is_essential = matches!(g.kind, GroupKind::Essential { .. }) || essential_parent;
|
let is_essential = matches!(g.kind, GroupKind::Essential { .. }) || essential_parent;
|
||||||
for c in &mut g.children {
|
for c in &mut g.children {
|
||||||
match c {
|
match c {
|
||||||
Node::Prop(p) => {
|
Node::Prop(p) => {
|
||||||
p.uid += o;
|
p.uid = p.uid.saturating_add(o);
|
||||||
let fix = |v: &mut Value| {
|
let fix = |v: &mut Value| {
|
||||||
if let Value::Layer(Some(id)) = v {
|
if let Value::Layer(Some(id)) = v {
|
||||||
*id += o;
|
*id = id.saturating_add(o);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fix(&mut p.value);
|
fix(&mut p.value);
|
||||||
@@ -774,7 +783,7 @@ fn offset_layer(l: &mut Layer, o: u64) {
|
|||||||
fix(&mut k.value);
|
fix(&mut k.value);
|
||||||
}
|
}
|
||||||
if is_essential && control_of(&p.match_id).is_some() {
|
if is_essential && control_of(&p.match_id).is_some() {
|
||||||
p.match_id = match_id(control_of(&p.match_id).unwrap_or(0) + o);
|
p.match_id = match_id(control_of(&p.match_id).unwrap_or(0).saturating_add(o));
|
||||||
// Media replacement values are item ids.
|
// Media replacement values are item ids.
|
||||||
if matches!(p.ui, ParamUi::Hidden)
|
if matches!(p.ui, ParamUi::Hidden)
|
||||||
&& let Value::Scalar(x) = &mut p.value
|
&& let Value::Scalar(x) = &mut p.value
|
||||||
@@ -785,7 +794,7 @@ fn offset_layer(l: &mut Layer, o: u64) {
|
|||||||
}
|
}
|
||||||
Node::Group(sub) => {
|
Node::Group(sub) => {
|
||||||
if is_essential && let Some(id) = control_of(&sub.match_id) {
|
if is_essential && let Some(id) = control_of(&sub.match_id) {
|
||||||
sub.match_id = match_id(id + o);
|
sub.match_id = match_id(id.saturating_add(o));
|
||||||
}
|
}
|
||||||
group(sub, o, is_essential);
|
group(sub, o, is_essential);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1282,10 +1282,11 @@ impl Project {
|
|||||||
pub fn fix_next_id(&mut self) {
|
pub fn fix_next_id(&mut self) {
|
||||||
let mut m = self.next_id;
|
let mut m = self.next_id;
|
||||||
for (id, it) in &self.items {
|
for (id, it) in &self.items {
|
||||||
m = m.max(id.0 + 1);
|
// Saturating: a damaged file's ids must not overflow (the import then refuses it).
|
||||||
|
m = m.max(id.0.saturating_add(1));
|
||||||
if let ItemKind::Comp(c) = &it.kind {
|
if let ItemKind::Comp(c) = &it.kind {
|
||||||
for l in &c.layers {
|
for l in &c.layers {
|
||||||
m = m.max(l.id.0 + 1).max(l.props.max_uid() + 1);
|
m = m.max(l.id.0.saturating_add(1)).max(l.props.max_uid().saturating_add(1));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
|
|||||||
("file.import", "File...", "文件..."),
|
("file.import", "File...", "文件..."),
|
||||||
("file.importMultiple", "Multiple Files...", "多个文件..."),
|
("file.importMultiple", "Multiple Files...", "多个文件..."),
|
||||||
("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro 项目..."),
|
("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro 项目..."),
|
||||||
|
("file.importProject", "EffectCraft Project...", "EffectCraft 项目..."),
|
||||||
("file.importPlaceholder", "Placeholder...", "占位符..."),
|
("file.importPlaceholder", "Placeholder...", "占位符..."),
|
||||||
("file.importSolid", "Solid...", "纯色..."),
|
("file.importSolid", "Solid...", "纯色..."),
|
||||||
("file.importLottie", "Lottie...", "Lottie..."),
|
("file.importLottie", "Lottie...", "Lottie..."),
|
||||||
@@ -900,6 +901,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
|
|||||||
("file.import", "File...", "檔案..."),
|
("file.import", "File...", "檔案..."),
|
||||||
("file.importMultiple", "Multiple Files...", "多個檔案..."),
|
("file.importMultiple", "Multiple Files...", "多個檔案..."),
|
||||||
("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro 專案..."),
|
("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro 專案..."),
|
||||||
|
("file.importProject", "EffectCraft Project...", "EffectCraft 專案..."),
|
||||||
("file.importPlaceholder", "Placeholder...", "預留位置..."),
|
("file.importPlaceholder", "Placeholder...", "預留位置..."),
|
||||||
("file.importSolid", "Solid...", "純色..."),
|
("file.importSolid", "Solid...", "純色..."),
|
||||||
("file.importLottie", "Lottie...", "Lottie..."),
|
("file.importLottie", "Lottie...", "Lottie..."),
|
||||||
@@ -1526,6 +1528,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
|
|||||||
("file.importPlaceholder", "Placeholder...", "プレースホルダー..."),
|
("file.importPlaceholder", "Placeholder...", "プレースホルダー..."),
|
||||||
("file.importSolid", "Solid...", "平面..."),
|
("file.importSolid", "Solid...", "平面..."),
|
||||||
("file.importLottie", "Lottie...", "Lottie..."),
|
("file.importLottie", "Lottie...", "Lottie..."),
|
||||||
|
("file.importProject", "EffectCraft Project...", "EffectCraftプロジェクト..."),
|
||||||
("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Vanishing Point (.vpe)..."),
|
("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Vanishing Point (.vpe)..."),
|
||||||
("essential.importTemplate", "Essential Graphics Template...", "エッセンシャルグラフィックステンプレート..."),
|
("essential.importTemplate", "Essential Graphics Template...", "エッセンシャルグラフィックステンプレート..."),
|
||||||
("", "Import Recent Footage", "最近使用したフッテージを読み込み"),
|
("", "Import Recent Footage", "最近使用したフッテージを読み込み"),
|
||||||
@@ -2149,6 +2152,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
|
|||||||
("file.import", "File...", "Файл..."),
|
("file.import", "File...", "Файл..."),
|
||||||
("file.importMultiple", "Multiple Files...", "Кілька файлів..."),
|
("file.importMultiple", "Multiple Files...", "Кілька файлів..."),
|
||||||
("file.importTimeline", "Adobe Premiere Pro Project...", "Проєкт Adobe Premiere Pro..."),
|
("file.importTimeline", "Adobe Premiere Pro Project...", "Проєкт Adobe Premiere Pro..."),
|
||||||
|
("file.importProject", "EffectCraft Project...", "Проєкт EffectCraft..."),
|
||||||
("file.importPlaceholder", "Placeholder...", "Заповнювач..."),
|
("file.importPlaceholder", "Placeholder...", "Заповнювач..."),
|
||||||
("file.importSolid", "Solid...", "Суцільний колір..."),
|
("file.importSolid", "Solid...", "Суцільний колір..."),
|
||||||
("file.importLottie", "Lottie...", "Lottie..."),
|
("file.importLottie", "Lottie...", "Lottie..."),
|
||||||
|
|||||||
@@ -1205,6 +1205,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
|
|||||||
"view.importGuides" => ("path", Ask::Open(&["json"])),
|
"view.importGuides" => ("path", Ask::Open(&["json"])),
|
||||||
"file.exportLottie" => ("path", Ask::Save("Animation.json")),
|
"file.exportLottie" => ("path", Ask::Save("Animation.json")),
|
||||||
"file.importLottie" => ("path", Ask::Open(&["json", "lottie"])),
|
"file.importLottie" => ("path", Ask::Open(&["json", "lottie"])),
|
||||||
|
"file.importProject" => ("path", Ask::Open(&["ecproj", "ecprojx"])),
|
||||||
"file.importTimeline" => ("path", Ask::Open(&["xml", "fcpxml", "otio", "edl", "aaf", "omf"])),
|
"file.importTimeline" => ("path", Ask::Open(&["xml", "fcpxml", "otio", "edl", "aaf", "omf"])),
|
||||||
"render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")),
|
"render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")),
|
||||||
"file.runScript" if params.get("name").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
|
"file.runScript" if params.get("name").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
## Update: File ▸ Import ▸ EffectCraft Project
|
||||||
|
|
||||||
|
- **Import a project into a project**, as After Effects imports an `.aep`:
|
||||||
|
`file.importProject {path}` (File ▸ Import ▸ EffectCraft Project..., and `.ecproj` /
|
||||||
|
`.ecprojx` files given to File ▸ Import) merges the project into a folder named after the
|
||||||
|
file. Every item, layer and property id moves past the open project's, so references
|
||||||
|
(precomps, parents, track mattes, Layer Control values, Essential Graphics, tracker
|
||||||
|
targets) stay intact. It's one undo step. Footage paths relative to the imported project
|
||||||
|
resolve next to it. The result lists the comps, the root comps, `primaryComp` (the only
|
||||||
|
root comp) and missing media.
|
||||||
|
- Id shifting saturates instead of overflowing on damaged files, for both templates and
|
||||||
|
projects.
|
||||||
Reference in new issue
Block a user