mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-26 02:57:34 -06:00
Footage paths stored in a project are resolved through craft-docpath against the project file when it is opened or imported: relative paths are taken from the project's folder, and an item whose path is on another computer, names a device or is a non-file URL is marked missing without being looked at. The footage check, the changed-on-disk scan, Reload Footage, Relink, Edit Original and Reveal in Finder resolve the path again before touching the file, and the Project panel, Metadata panel and waveforms read no file details of missing footage. Lottie image assets are resolved against the Lottie file; one on another computer is skipped with a warning. Files a 3D model names are read only when local or on the model's own share. Reveal in Finder refuses a folder that would open as an app or other bundle. Files imported or relinked in this session are opened as given.
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
|
|
)]
|
|
}
|