//! 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)> { 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 stored in the imported project are resolved against its file /// ([`crate::doc_path::resolve_footage`]): relative ones become paths beside it, and items whose /// path is not followed are marked missing. Returns the footage paths that are missing, as stored. fn resolve_footage(s: &Session, project: &mut Project, path: &str) -> Vec { let stored: std::collections::HashMap = project.items.values().filter_map(|i| if let ItemKind::Footage(f) = &i.kind { Some((i.id, f.path.clone())) } else { None }).collect(); let refused = crate::doc_path::resolve_footage(project, Some(path)); let services = s.services.clone(); let exists = |p: &String| p.is_empty() || services.exists(p); let mut missing: Vec = refused.iter().map(|(_, p, _)| p.clone()).collect(); for it in project.items.values() { if let ItemKind::Footage(f) = &it.kind { if refused.iter().any(|(id, _, _)| *id == it.id) { continue; } let present = if f.sequence.is_empty() { exists(&f.path) } else { f.sequence.iter().all(exists) }; if !present { missing.push(stored.get(&it.id).cloned().unwrap_or_else(|| 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 missing = resolve_footage(s, &mut sub, &path); s.stored_paths.add(&sub, Some(&path)); // 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 )] }