diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 74ec861e..5825eac2 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -245,6 +245,8 @@ struct PendingImport { comps: Vec, items: Vec, errors: Vec, + /// EffectCraft projects merged in (`file.importProject` results); never added to a comp. + projects: Vec, } /// Everything probing a file needs, apart from the session (it can run on a background thread). @@ -370,10 +372,18 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { _ => return Err(bad("file.import", "page: a page number from 1")), }, }; - let mut paths = paths; let mut comps = vec![]; let mut items = vec![]; let mut errors = vec![]; + // EffectCraft projects import as projects, as After Effects imports an .aep. + let (project_paths, mut paths): (Vec, Vec) = 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 { let mut rest = vec![]; for path in paths { @@ -424,13 +434,13 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { page, 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 /// everything the import made, so the Project panel shows it. fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec>) -> Result { - let PendingImport { comps, mut items, mut errors, .. } = pending; + let PendingImport { comps, mut items, mut errors, projects, .. } = pending; let mut found = vec![]; let mut warnings = vec![]; for r in probed { @@ -482,7 +492,11 @@ fn add_footage(s: &mut Session, pending: PendingImport, probed: Vec 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)> { + 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 { + 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 { + let nested: std::collections::HashSet = + 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 { + 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 = 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 = 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::>(), + "primaryComp": primary, + "missingMedia": missing, + "warnings": warnings, + })) +} + +pub fn specs() -> Vec { + 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 + )] +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 99b2d642..d3873e47 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -26,6 +26,7 @@ pub mod footage_panel; pub(crate) mod frame_export; mod frontend; mod help; +mod import_project; mod key_labels; mod key_transform; mod keys_more; @@ -193,6 +194,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(file_more::specs()); v.extend(relink::specs()); v.extend(lottie::specs()); + v.extend(import_project::specs()); v.extend(timeline::specs()); v.extend(comp_more::specs()); v.extend(frame_export::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6243e44..c59bad27 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1132,6 +1132,8 @@ mod tests_frame_export; #[cfg(test)] mod tests_fuzz_open; #[cfg(test)] +mod tests_import_project; +#[cfg(test)] mod tests_key_metadata_order; #[cfg(test)] mod tests_keylight; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index bd1cffb0..e28b92e5 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -528,6 +528,7 @@ File File... | file.import Multiple Files... | file.importMultiple Adobe Premiere Pro Project... | file.importTimeline + EffectCraft Project... | file.importProject Placeholder... | file.importPlaceholder Solid... | file.importSolid Lottie... | file.importLottie diff --git a/crates/engine/src/tests_import_project.rs b/crates/engine/src/tests_import_project.rs new file mode 100644 index 00000000..6aa52b41 --- /dev/null +++ b/crates/engine/src/tests_import_project.rs @@ -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 { + 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) { + 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 `/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 = 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 = 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 = 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::>().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); +} diff --git a/crates/project/src/essential.rs b/crates/project/src/essential.rs index fcf2cfb3..77859c63 100644 --- a/crates/project/src/essential.rs +++ b/crates/project/src/essential.rs @@ -701,13 +701,13 @@ pub fn with_overrides(project: &Project, comp: ItemId, overrides: &[Override]) - // ------------------------------------------------------------------------------ id remapping /// 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 -/// another project. +/// `offset`, keeping all references consistent: used when merging a template or another project +/// into a project. Ids from a damaged file saturate instead of overflowing. pub fn offset_ids(p: &mut Project, offset: u64) { let items = std::mem::take(&mut p.items); for (_, mut it) in items { - it.id = ItemId(it.id.0 + offset); - it.parent = it.parent.map(|f| ItemId(f.0 + offset)); + it.id = ItemId(it.id.0.saturating_add(offset)); + it.parent = it.parent.map(|f| ItemId(f.0.saturating_add(offset))); if let ItemKind::Comp(c) = &mut it.kind { let c = std::sync::Arc::make_mut(c); 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 { fn go(v: &mut [EgControl], o: u64) { for c in v { - c.id += o; + c.id = c.id.saturating_add(o); match &mut c.kind { EgKind::Property { layer, prop, links } => { - *layer = LayerId(layer.0 + o); - *prop += o; + *layer = LayerId(layer.0.saturating_add(o)); + *prop = prop.saturating_add(o); for k in links { - k.layer = LayerId(k.layer.0 + o); - k.prop += o; + k.layer = LayerId(k.layer.0.saturating_add(o)); + k.prop = k.prop.saturating_add(o); } } - EgKind::Mirror { of } => *of += o, - EgKind::Media { layer } => *layer = LayerId(layer.0 + o), + EgKind::Mirror { of } => *of = of.saturating_add(o), + EgKind::Media { layer } => *layer = LayerId(layer.0.saturating_add(o)), EgKind::Group { children } => go(children, o), EgKind::Comment { .. } => {} } @@ -739,34 +739,43 @@ pub fn offset_ids(p: &mut Project, offset: u64) { p.items.insert(it.id, it); } 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) { - l.id = LayerId(l.id.0 + o); - l.parent = l.parent.map(|x| LayerId(x.0 + o)); + l.id = LayerId(l.id.0.saturating_add(o)); + l.parent = l.parent.map(|x| LayerId(x.0.saturating_add(o))); 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 { - 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) { - g.uid += o; - if let GroupKind::Essential { overridden } = &mut g.kind { - for u in overridden.iter_mut() { - *u += o; + g.uid = g.uid.saturating_add(o); + match &mut g.kind { + GroupKind::Essential { overridden } => { + 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; for c in &mut g.children { match c { Node::Prop(p) => { - p.uid += o; + p.uid = p.uid.saturating_add(o); let fix = |v: &mut Value| { if let Value::Layer(Some(id)) = v { - *id += o; + *id = id.saturating_add(o); } }; fix(&mut p.value); @@ -774,7 +783,7 @@ fn offset_layer(l: &mut Layer, o: u64) { fix(&mut k.value); } 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. if matches!(p.ui, ParamUi::Hidden) && let Value::Scalar(x) = &mut p.value @@ -785,7 +794,7 @@ fn offset_layer(l: &mut Layer, o: u64) { } Node::Group(sub) => { 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); } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index fee3b8ce..aaf155f3 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1394,10 +1394,11 @@ impl Project { pub fn fix_next_id(&mut self) { let mut m = self.next_id; 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 { 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)); } } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4e814ea2..b07ac4d7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -303,6 +303,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[ ("file.import", "File...", "文件..."), ("file.importMultiple", "Multiple Files...", "多个文件..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro 项目..."), + ("file.importProject", "EffectCraft Project...", "EffectCraft 项目..."), ("file.importPlaceholder", "Placeholder...", "占位符..."), ("file.importSolid", "Solid...", "纯色..."), ("file.importLottie", "Lottie...", "Lottie..."), @@ -933,6 +934,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[ ("file.import", "File...", "檔案..."), ("file.importMultiple", "Multiple Files...", "多個檔案..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro 專案..."), + ("file.importProject", "EffectCraft Project...", "EffectCraft 專案..."), ("file.importPlaceholder", "Placeholder...", "預留位置..."), ("file.importSolid", "Solid...", "純色..."), ("file.importLottie", "Lottie...", "Lottie..."), @@ -1563,6 +1565,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[ ("file.importPlaceholder", "Placeholder...", "プレースホルダー..."), ("file.importSolid", "Solid...", "平面..."), ("file.importLottie", "Lottie...", "Lottie..."), + ("file.importProject", "EffectCraft Project...", "EffectCraftプロジェクト..."), ("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Vanishing Point (.vpe)..."), ("essential.importTemplate", "Essential Graphics Template...", "エッセンシャルグラフィックステンプレート..."), ("", "Import Recent Footage", "最近使用したフッテージを読み込み"), @@ -2190,6 +2193,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Файл..."), ("file.importMultiple", "Multiple Files...", "Кілька файлів..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Проєкт Adobe Premiere Pro..."), + ("file.importProject", "EffectCraft Project...", "Проєкт EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Заповнювач..."), ("file.importSolid", "Solid...", "Суцільний колір..."), ("file.importLottie", "Lottie...", "Lottie..."), @@ -2821,6 +2825,7 @@ const SPANISH: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Archivo..."), ("file.importMultiple", "Multiple Files...", "Varios archivos..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Proyecto de Adobe Premiere Pro..."), + ("file.importProject", "EffectCraft Project...", "Proyecto de EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Marcador de posición..."), ("file.importSolid", "Solid...", "Sólido..."), ("file.importLottie", "Lottie...", "Lottie..."), @@ -3450,6 +3455,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Arquivo..."), ("file.importMultiple", "Multiple Files...", "Vários arquivos..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Projeto do Adobe Premiere Pro..."), + ("file.importProject", "EffectCraft Project...", "Projeto do EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Espaço reservado..."), ("file.importSolid", "Solid...", "Sólido..."), ("file.importLottie", "Lottie...", "Lottie..."), @@ -4087,6 +4093,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Файл..."), ("file.importMultiple", "Multiple Files...", "Несколько файлов..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Проект Adobe Premiere Pro..."), + ("file.importProject", "EffectCraft Project...", "Проект EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Заполнитель..."), ("file.importSolid", "Solid...", "Сплошной цвет..."), ("file.importLottie", "Lottie...", "Lottie..."), @@ -4716,6 +4723,7 @@ const FRENCH: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Fichier..."), ("file.importMultiple", "Multiple Files...", "Plusieurs fichiers..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Projet Adobe Premiere Pro..."), + ("file.importProject", "EffectCraft Project...", "Projet EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Espace réservé..."), ("file.importSolid", "Solid...", "Solide..."), ("file.importLottie", "Lottie...", "Lottie..."), @@ -5345,6 +5353,7 @@ const GERMAN: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Datei..."), ("file.importMultiple", "Multiple Files...", "Mehrere Dateien..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-Projekt..."), + ("file.importProject", "EffectCraft Project...", "EffectCraft-Projekt..."), ("file.importPlaceholder", "Placeholder...", "Platzhalter..."), ("file.importSolid", "Solid...", "Farbfläche..."), ("file.importLottie", "Lottie...", "Lottie..."), @@ -5974,6 +5983,7 @@ const CZECH: &[(&str, &str, &str)] = &[ ("file.import", "File...", "Soubor..."), ("file.importMultiple", "Multiple Files...", "Více souborů..."), ("file.importTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."), + ("file.importProject", "EffectCraft Project...", "Projekt EffectCraft..."), ("file.importPlaceholder", "Placeholder...", "Zástupný záznam..."), ("file.importSolid", "Solid...", "Plná vrstva..."), ("file.importLottie", "Lottie...", "Lottie..."), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b10b0e31..c21f5595 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1239,6 +1239,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option ("path", Ask::Open(&["json"])), "file.exportLottie" => ("path", Ask::Save("Animation.json")), "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"])), "render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")), "file.runScript" if params.get("name").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])), diff --git a/docs/parity-log.md b/docs/parity-log.md new file mode 100644 index 00000000..22319e6e --- /dev/null +++ b/docs/parity-log.md @@ -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.