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:
Elias authored and Claude committed 2026-10-11 12:29:57 +00:00
1 parent a5b7e18cfd
commit 4e751ad241
11 files changed
+378 -30

No files matched your search

+18 -4
View File
@@ -236,6 +236,8 @@ struct PendingImport {
comps: Vec<u64>,
items: Vec<u64>,
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).
@@ -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")),
},
};
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<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 {
let mut rest = vec![];
for path in paths {
@@ -415,13 +425,13 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
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<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 warnings = vec![];
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() {
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
@@ -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
)]
}
+2
View File
@@ -25,6 +25,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;
@@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(relink::specs());
v.extend(relink_moved::specs());
v.extend(lottie::specs());
v.extend(import_project::specs());
v.extend(timeline::specs());
v.extend(comp_more::specs());
v.extend(frame_export::specs());
+2
View File
@@ -1068,6 +1068,8 @@ mod tests_fidelity;
#[cfg(test)]
mod tests_frame_export;
#[cfg(test)]
mod tests_import_project;
#[cfg(test)]
mod tests_key_metadata_order;
#[cfg(test)]
mod tests_keylight;
+1
View File
@@ -525,6 +525,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
+158
View File
@@ -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);
}
+33 -24
View File
@@ -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);
}
+3 -2
View File
@@ -1282,10 +1282,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));
}
}
}
+4
View File
@@ -274,6 +274,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..."),
@@ -900,6 +901,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..."),
@@ -1526,6 +1528,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", "最近使用したフッテージを読み込み"),
@@ -2149,6 +2152,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..."),
+1
View File
@@ -1205,6 +1205,7 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
"view.importGuides" => ("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"])),
+13
View File
@@ -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.