mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
128 lines
6.2 KiB
Rust
128 lines
6.2 KiB
Rust
//! 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 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<String> {
|
|
let stored: std::collections::HashMap<ItemId, String> =
|
|
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<String> = 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<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 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<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
|
|
)]
|
|
}
|