Merge pull request #3392 from arnav-hiwarkar/feat/aep-drag-drop

File ▸ Import: drop or pick After Effects projects to import them; name the project imports alike (#3103)
This commit is contained in:
ash
2026-11-10 10:16:48 +05:30
committed by GitHub
22 changed files with 744 additions and 90 deletions
+1
View File
@@ -161,6 +161,7 @@ mod params;
mod schema;
use super::commands;
pub use aep::is_aep_path;
pub use enabled::always;
pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run};
pub use params::match_path_of;
+21 -9
View File
@@ -1,5 +1,5 @@
//! File ▸ Import ▸ After Effects Project… (`effectcraft_aep::import`) and File ▸ Export ▸ After Effects
//! Project… (`effectcraft_aep::export`).
//! File ▸ Import ▸ Adobe After Effects Project… (`effectcraft_aep::import`) and File ▸ Export ▸
//! Adobe After Effects Project… (`effectcraft_aep::export`).
//!
//! Adds the items of an `.aep` (or its XML form, `.aepx`) to the project in a folder named after
//! the file (`docs/aep-import.md` lists what arrives). What the importer cannot bring over (effects
@@ -16,6 +16,14 @@ use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, str_p};
use crate::{EngineError, Result, Session, cmd};
/// Whether `path` is an After Effects project (`.aep`, or its XML form `.aepx`): imported as a
/// project (`file.importAep`), not as footage.
pub fn is_aep_path(path: &str) -> bool {
std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("aep") || e.eq_ignore_ascii_case("aepx"))
}
/// `file.importAep`: the `.aep` / `.aepx` at `path` into the open project, in a folder named after
/// the file; refused ([`EngineError::Refused`]) when parts would be lost and `allowLosses` is not set.
fn import_aep(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.importAep", "missing `path`"))?.to_string();
let allow_losses = b_p(p, "allowLosses").unwrap_or(false);
@@ -40,11 +48,12 @@ fn import_aep(s: &mut Session, p: &Value) -> Result<Value> {
return Ok(json!({"dryRun": true, "report": report}));
}
let importer = s.importer.clone();
let res = s.edit("Import After Effects Project", None, |proj, st| {
let res = s.edit("Import Adobe After Effects Project", None, |proj, st| {
let r = effectcraft_aep::import::import(proj, &bytes, &options).map_err(|e| match e {
AepImportError::Refused(_) => {
EngineError::Other(format!("{e} (nothing was imported: pass `allowLosses` to import anyway, or `dryRun` for the full report)"))
}
AepImportError::Refused(_) => EngineError::Refused {
losses: e.to_string(),
advice: "nothing was imported: pass `allowLosses` to import anyway, or `dryRun` for the full report",
},
other => EngineError::Other(format!("cannot import {path}: {other}")),
})?;
if apply {
@@ -90,6 +99,8 @@ fn import_aep(s: &mut Session, p: &Value) -> Result<Value> {
}))
}
/// `file.exportAep`: the open project as an `.aep` at `path`; refused ([`EngineError::Refused`])
/// when parts would be lost and `allowLosses` is not set.
fn export_aep(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_p(p, "path").ok_or_else(|| bad("file.exportAep", "missing `path`"))?.to_string();
let allow_losses = b_p(p, "allowLosses").unwrap_or(false);
@@ -112,7 +123,7 @@ fn export_aep(s: &mut Session, p: &Value) -> Result<Value> {
};
let res = effectcraft_aep::export::export_with(&s.project, &ExportOptions { allow_losses }, &folder_time).map_err(|e| match e {
AepExportError::Refused(_) => {
EngineError::Other(format!("{e} (nothing was written: pass `allowLosses` to export anyway, or `dryRun` for the full report)"))
EngineError::Refused { losses: e.to_string(), advice: "nothing was written: pass `allowLosses` to export anyway, or `dryRun` for the full report" }
}
other => EngineError::Other(format!("cannot export {path}: {other}")),
})?;
@@ -126,11 +137,12 @@ fn export_aep(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"path": path, "bytes": res.bytes.len(), "report": res.report}))
}
/// File ▸ Import / Export ▸ Adobe After Effects Project….
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"file.importAep",
"After Effects Project...",
"Adobe After Effects Project...",
["File", "Import"],
None,
"{path (.aep or .aepx), allowLosses?: bool (import although parts cannot be imported), dryRun?: bool (only return the report), applySettings?: bool (take over the file's project settings; default: when the project has no compositions)}",
@@ -139,7 +151,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(
"file.exportAep",
"After Effects Project...",
"Adobe After Effects Project...",
["File", "Export"],
None,
"{path (.aep), allowLosses?: bool (export although parts cannot be exported), dryRun?: bool (only return the report)}",
+16 -2
View File
@@ -404,8 +404,11 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
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));
// EffectCraft projects import as projects, as After Effects imports an .aep; so do After
// Effects projects, refused when parts of them cannot come over. The app asks about those
// before it gets here (`panels::aep_losses`); a script or agent gets the refusal.
let (project_paths, paths): (Vec<String>, Vec<String>) = paths.into_iter().partition(|x| super::import_project::is_project_path(x));
let (aep_paths, mut paths): (Vec<String>, Vec<String>) = paths.into_iter().partition(|x| super::is_aep_path(x));
let mut projects = vec![];
for path in project_paths {
match s.execute("file.importProject", json!({"path": path})) {
@@ -413,6 +416,17 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
Err(e) => errors.push(format!("{path}: {e}")),
}
}
for path in aep_paths {
match s.execute("file.importAep", json!({"path": path})) {
Ok(r) => projects.push(r),
// `file.import` has no `allowLosses`: point at the entry that lists the losses, and at
// the command that takes the consent.
Err(EngineError::Refused { losses, .. }) => errors.push(format!(
"{path}: {losses}; File ▸ Import ▸ Adobe After Effects Project… lists them and can import the rest (`file.importAep` with `allowLosses`)"
)),
Err(e) => errors.push(format!("{path}: {e}")),
}
}
if let Some(retain) = retain {
let mut rest = vec![];
for path in paths {
+4
View File
@@ -15,6 +15,10 @@ pub enum EngineError {
Project(#[from] effectcraft_project::ProjectError),
#[error("{0}")]
Other(String),
/// Refused because parts would be lost in a conversion; nothing changed. `losses` names them,
/// `advice` how to go on (`file.importAep` / `file.exportAep` without `allowLosses`).
#[error("{losses} ({advice})")]
Refused { losses: String, advice: &'static str },
}
pub type Result<T> = std::result::Result<T, EngineError>;
+11 -4
View File
@@ -23,6 +23,12 @@
//! Libraries, Bridge, Media Encoder, Behance, Creative Cloud…) are intentionally absent; the
//! Essential Graphics workspace uses the Properties panel and the Essential Graphics panel, whose
//! templates use EffectCraft's own open `.ectemplate` format.
//!
//! Where EffectCraft has entries After Effects lacks, After Effects' entries keep their order and
//! the others get a fixed place. File ▸ Import: the project imports sit together where After
//! Effects has Adobe Premiere Pro Project…, in alphabetical order and named in full (Adobe After
//! Effects Project…, Adobe Premiere Pro Project…, EffectCraft Project…); the formats only
//! EffectCraft imports follow a separator at the end, in alphabetical order (#3103).
use std::sync::OnceLock;
@@ -577,14 +583,15 @@ File
Import
File... | file.import
Multiple Files... | file.importMultiple
Adobe After Effects Project... | file.importAep
Adobe Premiere Pro Project... | file.importTimeline
EffectCraft Project... | file.importProject
Vanishing Point (.vpe)... | file.importVanishingPoint
Placeholder... | file.importPlaceholder
Solid... | file.importSolid
After Effects Project... | file.importAep
Lottie... | file.importLottie
Vanishing Point (.vpe)... | file.importVanishingPoint
---
Essential Graphics Template... | essential.importTemplate
Lottie... | file.importLottie
Import Recent Footage
@dynamic:recentFootage
---
@@ -592,7 +599,7 @@ File
Export
Add to Render Queue | renderQueue.add
Adobe Premiere Pro Project... | file.exportTimeline
After Effects Project... | file.exportAep
Adobe After Effects Project... | file.exportAep
Lottie JSON... | file.exportLottie
Essential Graphics Template... | essential.exportTemplate
---
+3 -2
View File
@@ -1,4 +1,4 @@
//! File ▸ Export ▸ After Effects Project… through the command registry.
//! File ▸ Export ▸ Adobe After Effects Project… through the command registry.
use serde_json::json;
@@ -73,11 +73,12 @@ fn bad_input_is_an_error_not_a_crash() {
assert!(empty.execute_checked("file.exportAep", json!({"path": path})).is_ok());
}
/// File ▸ Export ▸ Adobe After Effects Project… is in the menu under that label.
#[test]
fn aep_export_menu_entry_exists() {
let entries = crate::menus::entries();
let find = |cmd: &str| entries.iter().find(|(_, e)| e.command == cmd).map(|(p, e)| (p.join(" > "), e.label.clone()));
assert_eq!(find("file.exportAep"), Some(("File > Export".into(), "After Effects Project...".into())));
assert_eq!(find("file.exportAep"), Some(("File > Export".into(), "Adobe After Effects Project...".into())));
}
#[test]
+87 -2
View File
@@ -1,4 +1,4 @@
//! File ▸ Import ▸ After Effects Project… through the command registry, with the synthetic
//! File ▸ Import ▸ Adobe After Effects Project… through the command registry, with the synthetic
//! fixtures of `effectcraft-aep`.
use serde_json::json;
@@ -147,11 +147,96 @@ fn bad_input_is_an_error_not_a_crash() {
assert!(s.project.items.is_empty());
}
/// File ▸ Import: After Effects' own entries in its order, the project imports together where
/// After Effects has Adobe Premiere Pro Project… (alphabetical, named in full), then a separator
/// and the formats only EffectCraft imports, alphabetical (#3103).
#[test]
fn the_import_menu_groups_the_project_imports() {
use crate::menus::MenuNode;
let file = crate::menus::menu_bar().iter().find_map(|n| match n {
MenuNode::Submenu { label, children } if label == "File" => Some(children),
_ => None,
});
let import = file.into_iter().flatten().find_map(|n| match n {
MenuNode::Submenu { label, children } if label == "Import" => Some(children),
_ => None,
});
let labels: Vec<&str> = import
.into_iter()
.flatten()
.map(|n| match n {
MenuNode::Item(e) => e.label.as_str(),
MenuNode::Separator => "---",
MenuNode::Submenu { label, .. } => label.as_str(),
MenuNode::Dynamic { .. } => "@dynamic",
})
.collect();
assert_eq!(
labels,
[
"File...",
"Multiple Files...",
"Adobe After Effects Project...",
"Adobe Premiere Pro Project...",
"EffectCraft Project...",
"Vanishing Point (.vpe)...",
"Placeholder...",
"Solid...",
"---",
"Essential Graphics Template...",
"Lottie...",
]
);
}
/// `file.import` takes After Effects projects as After Effects' File… does, for scripts and
/// agents: one without losses imports as `file.importAep` does; one with losses is refused and
/// changes nothing, naming the menu entry that lists the losses; a file that is not one says why.
#[test]
fn file_import_takes_after_effects_projects() {
assert!(crate::cmd::is_aep_path("/a/Intro.aep") && crate::cmd::is_aep_path("/a/Intro.AEPX"));
assert!(!crate::cmd::is_aep_path("/a/Intro.ecproj") && !crate::cmd::is_aep_path("/a/aep") && !crate::cmd::is_aep_path("/a/clip.aep.mp4"));
let dir = std::env::temp_dir().join(format!("ec-file-import-aep-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let message = |r: crate::Result<serde_json::Value>| match r {
Ok(v) => v["errors"].to_string(),
Err(e) => e.to_string(),
};
let intro = dir.join("Intro.aep").to_string_lossy().to_string();
let mut source = Session::default();
source.execute("comp.new", json!({"name": "Intro", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap();
source.execute("file.exportAep", json!({"path": intro, "allowLosses": true})).unwrap();
let mut s = Session::default();
let r = s.execute("file.import", json!({"paths": [intro]})).unwrap();
assert_eq!(r["projects"].as_array().map(Vec::len), Some(1), "{r}");
let folder = s.project.items.values().find(|i| i.name == "Intro.aep").map(|i| i.id);
assert!(folder.is_some(), "the project's folder");
assert!(s.project.items.values().any(|i| i.name == "Intro" && i.parent == folder), "its composition in the folder");
let lossy = concat!(env!("CARGO_MANIFEST_DIR"), "/../aep/tests/fixtures/synthetic.aep");
let mut s = Session::default();
let refused = message(s.execute("file.import", json!({"paths": [lossy]})));
assert!(refused.contains("synthetic.aep") && refused.contains("File ▸ Import ▸ Adobe After Effects Project… lists them"), "{refused}");
assert!(refused.contains("`file.importAep` with `allowLosses`"), "and the command that takes the consent: {refused}");
assert!(s.project.items.is_empty(), "nothing imported");
let junk = dir.join("Junk.AEPX");
std::fs::write(&junk, b"not a project").unwrap();
let mut s = Session::default();
let error = message(s.execute("file.import", json!({"paths": [junk.to_string_lossy()]})));
assert!(error.contains("Junk.AEPX"), "{error}");
assert!(s.project.items.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
/// File ▸ Import ▸ Adobe After Effects Project… is in the menu under that label.
#[test]
fn aep_menu_entry_exists() {
let entries = crate::menus::entries();
let find = |cmd: &str| entries.iter().find(|(_, e)| e.command == cmd).map(|(p, e)| (p.join(" > "), e.label.clone()));
assert_eq!(find("file.importAep"), Some(("File > Import".into(), "After Effects Project...".into())));
assert_eq!(find("file.importAep"), Some(("File > Import".into(), "Adobe After Effects Project...".into())));
}
#[test]
+30 -28
View File
@@ -506,8 +506,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
("file.import", "File...", "文件..."),
("file.importMultiple", "Multiple Files...", "多个文件..."),
("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro 项目..."),
("file.importAep", "After Effects Project...", "After Effects 项目..."),
("file.exportAep", "After Effects Project...", "After Effects 项目..."),
("file.importAep", "Adobe After Effects Project...", "Adobe After Effects 项目..."),
("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects 项目..."),
("file.importProject", "EffectCraft Project...", "EffectCraft 项目..."),
("file.importPlaceholder", "Placeholder...", "占位符..."),
("file.importSolid", "Solid...", "纯色..."),
@@ -1146,8 +1146,8 @@ const SWEDISH: &[(&str, &str, &str)] = &[
("file.import", "File...", "Fil..."),
("file.importMultiple", "Multiple Files...", "Flera filer..."),
("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-projekt..."),
("file.importAep", "After Effects Project...", "After Effects-projekt..."),
("file.exportAep", "After Effects Project...", "After Effects-projekt..."),
("file.importAep", "Adobe After Effects Project...", "Adobe After Effects-projekt..."),
("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects-projekt..."),
("file.importProject", "EffectCraft Project...", "EffectCraft-projekt..."),
("file.importPlaceholder", "Placeholder...", "Platshållare..."),
("file.importSolid", "Solid...", "Enfärgad..."),
@@ -1788,8 +1788,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
("file.import", "File...", "檔案..."),
("file.importMultiple", "Multiple Files...", "多個檔案..."),
("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro 專案..."),
("file.importAep", "After Effects Project...", "After Effects 專案..."),
("file.exportAep", "After Effects Project...", "After Effects 專案..."),
("file.importAep", "Adobe After Effects Project...", "Adobe After Effects 專案..."),
("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects 專案..."),
("file.importProject", "EffectCraft Project...", "EffectCraft 專案..."),
("file.importPlaceholder", "Placeholder...", "預留位置..."),
("file.importSolid", "Solid...", "純色..."),
@@ -2427,8 +2427,8 @@ const JAPANESE: &[(&str, &str, &str)] = &[
("file.import", "File...", "ファイル..."),
("file.importMultiple", "Multiple Files...", "複数のファイル..."),
("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Proプロジェクト..."),
("file.importAep", "After Effects Project...", "After Effectsプロジェクト..."),
("file.exportAep", "After Effects Project...", "After Effectsプロジェクト..."),
("file.importAep", "Adobe After Effects Project...", "Adobe After Effectsプロジェクト..."),
("file.exportAep", "Adobe After Effects Project...", "Adobe After Effectsプロジェクト..."),
("file.importPlaceholder", "Placeholder...", "プレースホルダー..."),
("file.importSolid", "Solid...", "平面..."),
("file.importLottie", "Lottie...", "Lottie..."),
@@ -3069,8 +3069,8 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
("file.import", "File...", "Файл..."),
("file.importMultiple", "Multiple Files...", "Кілька файлів..."),
("file.importTimeline", "Adobe Premiere Pro Project...", "Проєкт Adobe Premiere Pro..."),
("file.importAep", "After Effects Project...", "Проєкт After Effects..."),
("file.exportAep", "After Effects Project...", "Проєкт After Effects..."),
("file.importAep", "Adobe After Effects Project...", "Проєкт Adobe After Effects..."),
("file.exportAep", "Adobe After Effects Project...", "Проєкт Adobe After Effects..."),
("file.importProject", "EffectCraft Project...", "Проєкт EffectCraft..."),
("file.importPlaceholder", "Placeholder...", "Заповнювач..."),
("file.importSolid", "Solid...", "Суцільний колір..."),
@@ -3712,8 +3712,8 @@ 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.importAep", "After Effects Project...", "Proyecto de After Effects..."),
("file.exportAep", "After Effects Project...", "Proyecto de After Effects..."),
("file.importAep", "Adobe After Effects Project...", "Proyecto de Adobe After Effects..."),
("file.exportAep", "Adobe After Effects Project...", "Proyecto de Adobe After Effects..."),
("file.importProject", "EffectCraft Project...", "Proyecto de EffectCraft..."),
("file.importPlaceholder", "Placeholder...", "Marcador de posición..."),
("file.importSolid", "Solid...", "Sólido..."),
@@ -4353,8 +4353,8 @@ 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.importAep", "After Effects Project...", "Projeto do After Effects..."),
("file.exportAep", "After Effects Project...", "Projeto do After Effects..."),
("file.importAep", "Adobe After Effects Project...", "Projeto do Adobe After Effects..."),
("file.exportAep", "Adobe After Effects Project...", "Projeto do Adobe After Effects..."),
("file.importProject", "EffectCraft Project...", "Projeto do EffectCraft..."),
("file.importPlaceholder", "Placeholder...", "Espaço reservado..."),
("file.importSolid", "Solid...", "Sólido..."),
@@ -5002,8 +5002,8 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
("file.import", "File...", "Файл..."),
("file.importMultiple", "Multiple Files...", "Несколько файлов..."),
("file.importTimeline", "Adobe Premiere Pro Project...", "Проект Adobe Premiere Pro..."),
("file.importAep", "After Effects Project...", "Проект After Effects..."),
("file.exportAep", "After Effects Project...", "Проект After Effects..."),
("file.importAep", "Adobe After Effects Project...", "Проект Adobe After Effects..."),
("file.exportAep", "Adobe After Effects Project...", "Проект Adobe After Effects..."),
("file.importProject", "EffectCraft Project...", "Проект EffectCraft..."),
("file.importPlaceholder", "Placeholder...", "Заполнитель..."),
("file.importSolid", "Solid...", "Сплошной цвет..."),
@@ -5643,8 +5643,8 @@ 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.importAep", "After Effects Project...", "Projet After Effects..."),
("file.exportAep", "After Effects Project...", "Projet After Effects..."),
("file.importAep", "Adobe After Effects Project...", "Projet Adobe After Effects..."),
("file.exportAep", "Adobe After Effects Project...", "Projet Adobe After Effects..."),
("file.importProject", "EffectCraft Project...", "Projet EffectCraft..."),
("file.importPlaceholder", "Placeholder...", "Espace réservé..."),
("file.importSolid", "Solid...", "Solide..."),
@@ -6284,8 +6284,8 @@ 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.importAep", "After Effects Project...", "After Effects-Projekt..."),
("file.exportAep", "After Effects Project...", "After Effects-Projekt..."),
("file.importAep", "Adobe After Effects Project...", "Adobe After Effects-Projekt..."),
("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects-Projekt..."),
("file.importProject", "EffectCraft Project...", "EffectCraft-Projekt..."),
("file.importPlaceholder", "Placeholder...", "Platzhalter..."),
("file.importSolid", "Solid...", "Farbfläche..."),
@@ -6925,8 +6925,8 @@ const POLISH: &[(&str, &str, &str)] = &[
("file.import", "File...", "Plik..."),
("file.importMultiple", "Multiple Files...", "Wiele plików..."),
("file.importTimeline", "Adobe Premiere Pro Project...", "Projekt Adobe Premiere Pro..."),
("file.importAep", "After Effects Project...", "Projekt After Effects..."),
("file.exportAep", "After Effects Project...", "Projekt After Effects..."),
("file.importAep", "Adobe After Effects Project...", "Projekt Adobe After Effects..."),
("file.exportAep", "Adobe After Effects Project...", "Projekt Adobe After Effects..."),
("file.importProject", "EffectCraft Project...", "Projekt EffectCraft..."),
("file.importPlaceholder", "Placeholder...", "Element zastępczy..."),
("file.importSolid", "Solid...", "Plansza..."),
@@ -7563,8 +7563,8 @@ 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.importAep", "After Effects Project...", "Projekt After Effects..."),
("file.exportAep", "After Effects Project...", "Projekt After Effects..."),
("file.importAep", "Adobe After Effects Project...", "Projekt Adobe After Effects..."),
("file.exportAep", "Adobe After Effects Project...", "Projekt Adobe After Effects..."),
("file.importProject", "EffectCraft Project...", "Projekt EffectCraft..."),
("file.importPlaceholder", "Placeholder...", "Zástupný záznam..."),
("file.importSolid", "Solid...", "Plná vrstva..."),
@@ -8201,8 +8201,8 @@ const GEORGIAN: &[(&str, &str, &str)] = &[
("file.import", "File...", "ფაილი..."),
("file.importMultiple", "Multiple Files...", "რამდენიმე ფაილი..."),
("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-ს პროექტი..."),
("file.importAep", "After Effects Project...", "After Effects-ის პროექტი..."),
("file.exportAep", "After Effects Project...", "After Effects-ის პროექტი..."),
("file.importAep", "Adobe After Effects Project...", "Adobe After Effects-ის პროექტი..."),
("file.exportAep", "Adobe After Effects Project...", "Adobe After Effects-ის პროექტი..."),
("file.importProject", "EffectCraft Project...", "EffectCraft-ის პროექტი..."),
("file.importPlaceholder", "Placeholder...", "ჩანაცვლების ობიექტი..."),
("file.importSolid", "Solid...", "სოლიდი..."),
@@ -9208,7 +9208,8 @@ mod tests {
assert!(!japanese(&app));
}
/// File ▸ Import ▸ After Effects Project… has its row in every menu catalog.
/// File ▸ Import / Export ▸ Adobe After Effects Project… has its row in every menu catalog,
/// under the label the menu draws (a row under an older label would fall back to English).
#[test]
fn the_aep_import_and_export_entries_are_in_every_menu_catalog() {
for (code, catalog) in [
@@ -9227,7 +9228,8 @@ mod tests {
("sv", SWEDISH),
] {
for id in ["file.importAep", "file.exportAep"] {
assert!(catalog.iter().any(|(command, _, _)| *command == id), "{code} has no {id} row");
let drawn = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == id).map(|(_, e)| e.label.clone()).unwrap_or_default();
assert!(catalog.iter().any(|(command, english, _)| *command == id && *english == drawn), "{code} has no {id} row for {drawn:?}");
}
}
}
+27 -11
View File
@@ -242,7 +242,7 @@ const JAPANESE: &[(&str, &str)] = &[
("Brush Dynamics", "ブラシのダイナミクス"),
("Caching frames", "フレームをキャッシュ中"),
("Cancel", "キャンセル"),
("Import After Effects Project", "After Effectsプロジェクトを読み込み"),
("Import Adobe After Effects Project", "Adobe After Effectsプロジェクトを読み込み"),
("Some parts of this project cannot be imported exactly (left out or changed):", "このプロジェクトの一部は正確に読み込めません(省略または変更されます):"),
("Import Anyway", "このまま読み込み"),
("Casts Shadows", "シャドウを落とす"),
@@ -1383,7 +1383,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Brush Dynamics", "画笔动态"),
("Caching frames", "正在缓存帧"),
("Cancel", "取消"),
("Import After Effects Project", "导入 After Effects 项目"),
("Import Adobe After Effects Project", "导入 Adobe After Effects 项目"),
("Some parts of this project cannot be imported exactly (left out or changed):", "此项目的某些部分无法准确导入(将被省略或更改):"),
("Import Anyway", "仍然导入"),
("Casts Shadows", "投射阴影"),
@@ -2499,7 +2499,7 @@ const SWEDISH: &[(&str, &str)] = &[
("Brush Dynamics", "Penseldynamik"),
("Caching frames", "Cachelagrar bildrutor"),
("Cancel", "Avbryt"),
("Import After Effects Project", "Importera After Effects-projekt"),
("Import Adobe After Effects Project", "Importera Adobe After Effects-projekt"),
(
"Some parts of this project cannot be imported exactly (left out or changed):",
"Vissa delar av projektet kan inte importeras exakt (utelämnas eller ändras):",
@@ -3188,6 +3188,14 @@ const SWEDISH: &[(&str, &str)] = &[
("A built-in preset has this name", "En inbyggd förinställning har det här namnet"),
("Cannot import dropped files: {}", "Kan inte importera släppta filer: {}"),
("Cannot open dropped project {}: {}", "Kan inte öppna det släppta projektet {}: {}"),
(
"Imported footage has an unlabeled alpha channel: choose how to interpret it in File ▸ Interpret Footage ▸ Main…",
"Importerat material har en omärkt alfakanal: välj hur den ska tolkas i Arkiv ▸ Tolka material ▸ Huvud…",
),
(
"Skipped {} more file(s) while a dialog is open; import them again after it",
"Hoppade över {} fil(er) till medan en dialogruta är öppen; importera dem igen efteråt",
),
("Click in the Composition panel to sample text {}", "Klicka i panelen Komposition för att hämta text {}"),
("Click to sample text {}", "Klicka för att hämta text {}"),
("Comment of {}: {}", "Kommentar till {}: {}"),
@@ -3768,7 +3776,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Brush Dynamics", "筆刷動態"),
("Caching frames", "正在快取幀"),
("Cancel", "取消"),
("Import After Effects Project", "匯入 After Effects 專案"),
("Import Adobe After Effects Project", "匯入 Adobe After Effects 專案"),
("Some parts of this project cannot be imported exactly (left out or changed):", "此專案的部分內容無法準確匯入(將被省略或變更):"),
("Import Anyway", "仍要匯入"),
("Casts Shadows", "投射陰影"),
@@ -4834,7 +4842,7 @@ const UKRAINIAN: &[(&str, &str)] = &[
("Brush Dynamics", "Динаміка пензля"),
("Caching frames", "Кешування кадрів"),
("Cancel", "Скасувати"),
("Import After Effects Project", "Імпортувати проєкт After Effects"),
("Import Adobe After Effects Project", "Імпортувати проєкт Adobe After Effects"),
(
"Some parts of this project cannot be imported exactly (left out or changed):",
"Деякі частини цього проєкту неможливо імпортувати точно (їх пропущено або змінено):",
@@ -5415,7 +5423,7 @@ const PORTUGUESE: &[(&str, &str)] = &[
("Brush Dynamics", "Dinâmica do pincel"),
("Caching frames", "Armazenando quadros em cache"),
("Cancel", "Cancelar"),
("Import After Effects Project", "Importar projeto do After Effects"),
("Import Adobe After Effects Project", "Importar projeto do Adobe After Effects"),
(
"Some parts of this project cannot be imported exactly (left out or changed):",
"Algumas partes deste projeto não podem ser importadas exatamente (omitidas ou alteradas):",
@@ -6188,7 +6196,7 @@ const POLISH: &[(&str, &str)] = &[
("Brush Dynamics", "Dynamika pędzla"),
("Caching frames", "Buforowanie klatek"),
("Cancel", "Anuluj"),
("Import After Effects Project", "Importuj projekt After Effects"),
("Import Adobe After Effects Project", "Importuj projekt Adobe After Effects"),
(
"Some parts of this project cannot be imported exactly (left out or changed):",
"Niektórych części tego projektu nie można zaimportować dokładnie (zostaną pominięte lub zmienione):",
@@ -6944,7 +6952,7 @@ const CZECH: &[(&str, &str)] = &[
("Brush Dynamics", "Dynamika štětce"),
("Caching frames", "Ukládání snímků do mezipaměti"),
("Cancel", "Zrušit"),
("Import After Effects Project", "Importovat projekt After Effects"),
("Import Adobe After Effects Project", "Importovat projekt Adobe After Effects"),
(
"Some parts of this project cannot be imported exactly (left out or changed):",
"Některé části tohoto projektu nelze importovat přesně (budou vynechány nebo změněny):",
@@ -7519,7 +7527,7 @@ const SPANISH: &[(&str, &str)] = &[
("Brush Dynamics", "Dinámica del pincel"),
("Caching frames", "Almacenando fotogramas en caché"),
("Cancel", "Cancelar"),
("Import After Effects Project", "Importar proyecto de After Effects"),
("Import Adobe After Effects Project", "Importar proyecto de Adobe After Effects"),
(
"Some parts of this project cannot be imported exactly (left out or changed):",
"Algunas partes de este proyecto no se pueden importar con exactitud (se omiten o cambian):",
@@ -8085,7 +8093,7 @@ const GEORGIAN: &[(&str, &str)] = &[
("Brush Dynamics", "ფუნჯის დინამიკა"),
("Caching frames", "კადრების ქეშირება"),
("Cancel", "გაუქმება"),
("Import After Effects Project", "After Effects-ის პროექტის იმპორტი"),
("Import Adobe After Effects Project", "Adobe After Effects-ის პროექტის იმპორტი"),
(
"Some parts of this project cannot be imported exactly (left out or changed):",
"ამ პროექტის ზოგიერთი ნაწილის ზუსტად იმპორტი შეუძლებელია (გამოტოვებული ან შეცვლილი):",
@@ -9058,7 +9066,7 @@ const FRENCH: &[(&str, &str)] = &[
("Brush Dynamics", "Dynamique du pinceau"),
("Caching frames", "Mise en cache des images"),
("Cancel", "Annuler"),
("Import After Effects Project", "Importer un projet After Effects"),
("Import Adobe After Effects Project", "Importer un projet Adobe After Effects"),
(
"Some parts of this project cannot be imported exactly (left out or changed):",
"Certaines parties de ce projet ne peuvent pas être importées à l’identique (omises ou modifiées) :",
@@ -9801,6 +9809,14 @@ const FRENCH: &[(&str, &str)] = &[
),
("Only one project can be opened per drop; skipped {} more", "Un seul projet peut être ouvert par dépôt ; {} de plus ignoré(s)"),
("Cannot open dropped project {}: {}", "Impossible d’ouvrir le projet déposé {} : {}"),
(
"Imported footage has an unlabeled alpha channel: choose how to interpret it in File ▸ Interpret Footage ▸ Main…",
"Le métrage importé a une couche alpha non étiquetée : choisissez son interprétation dans Fichier ▸ Interpréter le métrage ▸ Principal…",
),
(
"Skipped {} more file(s) while a dialog is open; import them again after it",
"{} fichier(s) de plus ignoré(s) pendant qu’une boîte de dialogue est ouverte ; importez-les ensuite",
),
("Cannot import dropped files: {}", "Impossible d’importer les fichiers déposés : {}"),
("A built-in preset has this name", "Un préréglage intégré porte ce nom"),
("Replaces your preset with this name", "Remplace votre préréglage portant ce nom"),
+2 -2
View File
@@ -27,8 +27,8 @@ pub(crate) use entries::{entry_checked, entry_enabled, entry_label, entry_shortc
pub use frontend::frontend;
#[cfg(test)]
use frontend::zoom_step;
pub(crate) use frontend::{dialog_answered, run_engine};
pub use invoke::{invoke, panel_command_id, reveal};
pub(crate) use frontend::{dialog_answered, import_aep_projects, run_engine};
pub use invoke::{invoke, panel_command_id, reveal, waits_on_dialog};
#[cfg(test)]
use shortcuts::modifiers_at_start;
pub use shortcuts::{Binding, bindings, chord_matches, forwardable_during_text_edit, handle_shortcuts, handle_tab_shortcut, parse_shortcut, shortcut_text};
+91 -10
View File
@@ -502,6 +502,15 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
return run_engine(app, ctx, "window.assignWorkspaceShortcut", q);
}
"file.interpretFootage" => {
// A background import finishing while another dialog waits (the After Effects losses
// prompt of the same drop) does not replace it: that dialog's work would be lost. The
// footage keeps the default interpretation; the note says where to change it.
if app.dialog.is_some() {
app.ui.status =
crate::i18n::tr("Imported footage has an unlabeled alpha channel: choose how to interpret it in File ▸ Interpret Footage ▸ Main…")
.to_string();
return Ok(Value::Null);
}
// Import ▸ Interpret Unlabeled Alpha As: Ask User.
if let Some(i) = p.get("item").and_then(Value::as_u64) {
app.session.state.project_selection = vec![effectcraft_engine::project::ItemId(i)];
@@ -601,7 +610,8 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
enum Ask {
Folder,
Import,
/// Footage, or a file with one of these extensions.
Import(&'static [&'static str]),
OpenProject,
Save(std::borrow::Cow<'static, str>),
Open(&'static [&'static str]),
@@ -617,7 +627,9 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
path.with_file_name(format!("{stem}{suffix}.{ext}")).to_string_lossy().into_owned().into()
};
let (key, ask) = match id {
"file.import" | "file.importMultiple" => ("paths", Ask::Import),
// Projects too, as After Effects' File… takes an .aep: they import as projects
// (`run_with_picked`, and `file.import` for EffectCraft projects).
"file.import" | "file.importMultiple" => ("paths", Ask::Import(&["aep", "aepx", "ecproj", "ecprojx"])),
"file.open" => ("path", Ask::OpenProject),
"file.save" if app.session.path.is_none() => ("path", Ask::Save(project_as("", "ecproj"))),
"file.saveAs" => ("path", Ask::Save(project_as("", "ecproj"))),
@@ -644,7 +656,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
"effect.plugins.install" if params.get("bundle").is_some() => ("folder", Ask::Folder),
"effect.plugins.install" if params.get("folder").is_none() && params.get("path").is_none() => ("path", Ask::Open(&["wasm"])),
"effect.helpers.load" => ("path", Ask::Folder),
"file.replaceFootage" => ("path", Ask::Import),
"file.replaceFootage" => ("path", Ask::Import(&[])),
"file.relinkFootage" => ("folder", Ask::Folder),
"file.collectFiles" => ("folder", Ask::Save("Collected Files".into())),
// An open XML project gets ` copy` too: the copy never writes over the open file.
@@ -672,6 +684,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
"eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv",
];
let footage = |more: &[&'static str]| effectcraft_project::VIDEO_EXTENSIONS.iter().chain(more).copied().collect::<Vec<_>>();
let importable = |more: &[&'static str]| footage(MEDIA).into_iter().chain(more.iter().copied()).collect::<Vec<_>>();
// A host whose blocking dialogs are unsafe inside this frame shows the dialog without
// blocking — macOS: the nested event loop re-entered winit's handler on a drag over the
// window and aborted the app (#511); Linux: the desktop portal's answer never gets pumped,
@@ -680,7 +693,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
if let Some(spawn) = app.hooks.file_dialog_async.as_ref() {
let request = match ask {
Ask::Folder => crate::FileDialog::Folder,
Ask::Import => crate::FileDialog::Open { exts: footage(MEDIA).iter().map(|e| e.to_string()).collect(), many: key == "paths" },
Ask::Import(more) => crate::FileDialog::Open { exts: importable(more).iter().map(|e| e.to_string()).collect(), many: key == "paths" },
Ask::OpenProject => crate::FileDialog::OpenProject,
Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false },
Ask::OpenFootage(more) => crate::FileDialog::Open { exts: footage(more).iter().map(|e| e.to_string()).collect(), many: false },
@@ -702,9 +715,9 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
let Some(f) = app.hooks.pick_folder.as_ref() else { return Some(Err("no folder dialog available (pass `folder`)".into())) };
f().map(Value::from)
}
Ask::Import => {
Ask::Import(more) => {
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) };
picked_value(key, f(&footage(MEDIA)))
picked_value(key, f(&importable(more)))
}
Ask::OpenProject => {
let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) };
@@ -758,7 +771,8 @@ pub(crate) fn show_result_errors(app: &mut EffectcraftApp, result: &Value) {
.map(|e| match (e.get("file").and_then(Value::as_str), e.get("error").and_then(Value::as_str)) {
(Some(file), Some(error)) => format!("{file}: {error}"),
(_, Some(error)) => error.to_string(),
_ => e.to_string(),
// A plain message (`file.import`'s errors) reads as itself, not as quoted JSON.
_ => e.as_str().map_or_else(|| e.to_string(), str::to_string),
})
.collect();
let mut msg = parts.iter().take(3).cloned().collect::<Vec<_>>().join("; ");
@@ -770,6 +784,14 @@ pub(crate) fn show_result_errors(app: &mut EffectcraftApp, result: &Value) {
/// Run command `id` with the path picked for it in parameter `key`.
fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value, v: Value) -> Result<Value, String> {
if matches!(id, "file.import" | "file.importMultiple")
&& let Some(picked) = v.as_array()
&& picked.iter().filter_map(Value::as_str).any(effectcraft_engine::commands::is_aep_path)
{
let (aep, rest): (Vec<String>, Vec<String>) =
picked.iter().filter_map(Value::as_str).map(str::to_string).partition(|p| effectcraft_engine::commands::is_aep_path(p));
return import_picked_with_aep(app, id, key, params, aep, rest);
}
let mut p = params.as_object().cloned().unwrap_or_default();
p.insert(key.to_string(), v);
// Picked files import in the background, with the Importing card showing progress (#270).
@@ -777,10 +799,13 @@ fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value
p.insert("background".into(), Value::Bool(true));
}
// Photoshop files ask how to import them first; numbered stills whether to import them as an
// image sequence.
// image sequence. Not while the losses prompt of an After Effects project picked with them
// shows: one dialog at a time, so they import with the defaults.
let picked = Value::Object(p.clone());
if id == "file.import" && crate::panels::dialogs::open_form(app, id, &picked)
|| matches!(id, "file.import" | "file.importMultiple") && crate::panels::forms::open_import_sequence(app, &picked)
let asking = crate::panels::aep_losses::asking(app);
if !asking
&& (id == "file.import" && crate::panels::dialogs::open_form(app, id, &picked)
|| matches!(id, "file.import" | "file.importMultiple") && crate::panels::forms::open_import_sequence(app, &picked))
{
return Ok(json!({"dialog": id}));
}
@@ -804,6 +829,62 @@ fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value
r
}
/// Import After Effects projects dropped or picked together, as File ▸ Import ▸ Adobe After
/// Effects Project… does each: one that would lose parts asks first (the losses prompt), and the
/// ones after it wait in its queue until it is answered (`panels::aep_losses`). Returns the errors
/// of those that could not be imported; each names its file.
pub(crate) fn import_aep_projects(app: &mut EffectcraftApp, paths: Vec<String>) -> Vec<String> {
let mut errors = Vec::new();
// An unanswered prompt's projects go first.
let mut paths = crate::panels::aep_losses::take_unanswered(app).into_iter().chain(paths);
while let Some(path) = paths.next() {
if crate::panels::aep_losses::asking(app) {
let queue = &mut app.dialog_state.aep_losses.queue;
queue.push(path);
queue.extend(paths);
break;
}
if let Err(e) = run_with_picked(app, "file.importAep", "path", &json!({}), json!(path)) {
errors.push(e);
}
}
errors
}
/// File ▸ Import ▸ File… with After Effects projects picked: they import first, as projects
/// ([`import_aep_projects`]), then the other files as footage. Their errors lead the result's
/// `errors` and the status line (callers of the blocking dialog drop the result), or the message
/// when the footage import failed.
fn import_picked_with_aep(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value, aep: Vec<String>, rest: Vec<String>) -> Result<Value, String> {
let errors = import_aep_projects(app, aep);
let r = if !rest.is_empty() {
run_with_picked(app, id, key, params, json!(rest))
} else if crate::panels::aep_losses::asking(app) {
Ok(json!({"dialog": "aepLosses"}))
} else {
Ok(json!({}))
};
if errors.is_empty() {
return r;
}
match r {
Ok(mut result) => {
if !result.is_object() {
result = json!({"result": result});
}
let own = result.get("errors").and_then(Value::as_array).cloned().unwrap_or_default();
result["errors"] = Value::Array(errors.into_iter().map(Value::from).chain(own).collect());
show_result_errors(app, &result);
Ok(result)
}
Err(e) => {
let message = format!("{}; {e}", errors.join("; "));
app.ui.status = message.clone();
Err(message)
}
}
}
/// View ▸ Zoom In / Zoom Out: the next standard magnification (as a share of screen pixels, so
/// 100 % is one comp pixel per screen pixel at any display scaling) above or below `cur`, which is
/// in points per comp pixel like the result (#882).
+6
View File
@@ -183,6 +183,12 @@ fn only_comp(p: &Value) -> Option<effectcraft_engine::project::ItemId> {
m.get("comp")?.as_u64().map(effectcraft_engine::project::ItemId)
}
/// Whether [`invoke`]'s result says the command waits on a dialog (a guard or a form deferred it
/// until the user answers, `{"dialog": …}`).
pub fn waits_on_dialog(result: &Value) -> bool {
result.get("dialog").is_some()
}
/// Execute a UI or engine command by id.
pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
crate::control::check_script_render(app, id)?;
+123
View File
@@ -730,3 +730,126 @@ fn effect_plugin_install_dialog_router() {
assert!(r.is_err(), "{r:?}");
assert!(pending.lock().unwrap().is_empty(), "no dialog for an inline folder");
}
/// File ▸ Import ▸ File… in the non-blocking dialog (macOS, Linux) offers After Effects projects
/// next to footage, and a picked one imports as File ▸ Import ▸ Adobe After Effects Project… does: its
/// folder, its composition open.
#[test]
fn import_file_dialog_offers_and_imports_after_effects_projects() {
use std::sync::{Arc, Mutex};
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let ctx = egui::Context::default();
let dir = std::env::temp_dir().join(format!("ec-import-aep-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let aep = dir.join("Intro.aep").to_string_lossy().to_string();
let mut source = effectcraft_engine::Session::default();
source.execute("comp.new", json!({"name": "Intro", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap();
source.execute("file.exportAep", json!({"path": aep, "allowLosses": true})).unwrap();
let pending: Arc<Mutex<Vec<crate::FileDialog>>> = Arc::default();
let queue = pending.clone();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog)));
invoke(&mut app, &ctx, "file.import", json!({})).unwrap();
let Some(crate::FileDialog::Open { exts, many }) = pending.lock().unwrap().pop() else { panic!("an Open dialog was shown") };
assert!(many && exts.iter().any(|e| e == "aep") && exts.iter().any(|e| e == "aepx"), "{exts:?}");
assert!(exts.iter().any(|e| e == "png"), "footage too");
super::frontend::dialog_answered(&mut app, crate::DialogAnswer { command: "file.import".into(), key: "paths", params: json!({}), paths: vec![aep] });
let comp = app.session.project.item(app.session.active_comp_id().expect("a composition is open")).unwrap();
assert_eq!(comp.name, "Intro");
assert_eq!(app.session.project.item(comp.parent.expect("in a folder")).unwrap().name, "Intro.aep");
let _ = std::fs::remove_dir_all(&dir);
}
/// Footage with unlabeled alpha asks how to interpret it (Interpret Unlabeled Alpha As: Ask
/// User), but not over a dialog that already waits (the After Effects losses prompt of the same
/// drop, which a background import can finish behind): that dialog stays, and a note says where
/// to choose the interpretation.
#[test]
fn the_unlabeled_alpha_question_does_not_replace_an_open_dialog() {
use effectcraft_engine::project::{Footage, FootageKind, ItemKind};
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let ctx = egui::Context::default();
let footage = Footage { path: "/m/matte.tga".into(), kind: FootageKind::Still, width: 4, height: 4, has_video: true, ..Default::default() };
let item = std::sync::Arc::make_mut(&mut app.session.project).add_item("matte.tga", Default::default(), None, ItemKind::Footage(footage)).0;
app.dialog = Some(crate::Dialog::AepLosses);
super::frontend(&mut app, &ctx, "file.interpretFootage", json!({"item": item, "reason": "unlabeledAlpha"})).unwrap();
assert_eq!(app.dialog, Some(crate::Dialog::AepLosses), "the waiting dialog stays");
assert!(app.ui.status.contains("Interpret Footage"), "{}", app.ui.status);
assert!(app.session.state.project_selection.is_empty(), "the selection is left alone");
app.dialog = None;
super::frontend(&mut app, &ctx, "file.interpretFootage", json!({"item": item, "reason": "unlabeledAlpha"})).unwrap();
assert_eq!(app.dialog, Some(crate::Dialog::Form), "with no dialog open it asks");
}
/// File ▸ Import ▸ File… and Multiple Files… offer projects next to footage; File ▸ Replace
/// Footage offers footage only.
#[test]
fn only_the_import_dialogs_offer_projects() {
use std::sync::{Arc, Mutex};
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let pending: Arc<Mutex<Vec<crate::FileDialog>>> = Arc::default();
let queue = pending.clone();
app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog)));
let projects = ["aep", "aepx", "ecproj", "ecprojx"];
for (id, offered) in [("file.import", true), ("file.importMultiple", true), ("file.replaceFootage", false)] {
assert!(super::frontend::file_dialog(&mut app, id, &json!({})).is_some(), "{id} asks for a file");
let Some(crate::FileDialog::Open { exts, .. }) = pending.lock().unwrap().pop() else { panic!("{id}: an Open dialog") };
assert!(exts.iter().any(|e| e == "png"), "{id} offers footage");
assert_eq!(projects.iter().all(|p| exts.iter().any(|e| e == p)), offered, "{id}: {exts:?}");
assert!(offered || !projects.iter().any(|p| exts.iter().any(|e| e == p)), "{id} offers no project: {exts:?}");
}
}
/// Picked After Effects projects that cannot be imported keep their errors next to the other
/// files': a broken `.aep` and a broken `.ecproj` are both named in the status line.
#[test]
fn picked_projects_report_every_error() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let dir = std::env::temp_dir().join(format!("ec-picked-errors-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let aep = dir.join("broken.aep");
let ecproj = dir.join("broken.ecproj");
std::fs::write(&aep, b"not an aep").unwrap();
std::fs::write(&ecproj, b"not a project").unwrap();
let paths = vec![aep.to_string_lossy().to_string(), ecproj.to_string_lossy().to_string()];
super::frontend::dialog_answered(&mut app, crate::DialogAnswer { command: "file.import".into(), key: "paths", params: json!({}), paths: paths.clone() });
let status = &app.ui.status;
assert!(status.contains("broken.aep") && status.contains("broken.ecproj"), "{status}");
assert!(!status.contains('"'), "messages, not quoted JSON: {status}");
assert!(app.dialog.is_none() && app.session.project.items.is_empty());
// The blocking dialog (Windows), whose callers drop the result: the status line still has both.
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let ctx = egui::Context::default();
app.hooks.pick_files = Some(Box::new(move |_: &[&str]| paths.clone()));
let _ = invoke(&mut app, &ctx, "file.import", json!({}));
let status = &app.ui.status;
assert!(status.contains("broken.aep") && status.contains("broken.ecproj"), "{status}");
let _ = std::fs::remove_dir_all(&dir);
}
/// A picked After Effects project with parts that cannot come over asks first; one picked with
/// it waits for the answer.
#[test]
fn a_picked_project_with_losses_asks_and_the_next_waits() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
let dir = std::env::temp_dir().join(format!("ec-picked-losses-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let lossy = dir.join("Synthetic.aep");
std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../aep/tests/fixtures/synthetic.aep"), &lossy).unwrap();
let intro = dir.join("Intro.aep").to_string_lossy().to_string();
let mut source = effectcraft_engine::Session::default();
source.execute("comp.new", json!({"name": "Intro", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap();
source.execute("file.exportAep", json!({"path": intro, "allowLosses": true})).unwrap();
let paths = vec![lossy.to_string_lossy().to_string(), intro.clone()];
super::frontend::dialog_answered(&mut app, crate::DialogAnswer { command: "file.import".into(), key: "paths", params: json!({}), paths });
assert_eq!(app.dialog, Some(crate::Dialog::AepLosses));
assert_eq!(app.dialog_state.aep_losses.queue, [intro]);
assert!(app.session.project.items.is_empty(), "nothing imported before the answer");
let _ = std::fs::remove_dir_all(&dir);
}
+96 -7
View File
@@ -1,8 +1,10 @@
//! File ▸ Import / Export ▸ After Effects Project… from the menu: what the conversion cannot
//! File ▸ Import / Export ▸ Adobe After Effects Project… from the menu: what the conversion cannot
//! carry over is shown first (the command's dry run), and the user chooses to cancel or to go on
//! with those losses (`allowLosses`). Cancel changes nothing; a conversion without losses runs at
//! once. Agents running `file.importAep` / `file.exportAep` with `engine.execute` are never asked:
//! they pass `allowLosses` or get the refusal.
//! they pass `allowLosses` or get the refusal. After Effects projects dropped or picked together
//! (File ▸ Import ▸ File…) are asked about one at a time: the others wait in the queue until the
//! prompt is answered, and Cancel skips only the one it asks about.
//!
//! Automation ids: `dialog.aepLosses.continue`, `dialog.aepLosses.cancel`.
@@ -17,11 +19,32 @@ use crate::{Dialog, EffectcraftApp};
#[derive(Clone, Debug, Default)]
pub struct Pending {
pub command: Option<(String, Value, Value)>,
/// After Effects projects dropped or picked with it, imported once it is answered.
pub queue: Vec<String>,
}
/// How many detail lines of a loss category the prompt shows.
const DETAILS: usize = 3;
/// Whether the prompt is showing with a conversion waiting on it.
pub fn asking(app: &EffectcraftApp) -> bool {
app.dialog == Some(Dialog::AepLosses) && app.dialog_state.aep_losses.command.is_some()
}
/// The projects of a prompt another dialog replaced (Save changes? on quitting) before it was
/// answered: its import's file, then its queue. Taken, so they are tried again instead of waiting
/// for an answer that never comes (a replaced export is dropped: it wrote nothing).
pub fn take_unanswered(app: &mut EffectcraftApp) -> Vec<String> {
if app.dialog == Some(Dialog::AepLosses) {
return Vec::new();
}
let losses = &mut app.dialog_state.aep_losses;
let Some((id, params, _)) = losses.command.take() else { return std::mem::take(&mut losses.queue) };
let mut paths: Vec<String> = params.get("path").and_then(Value::as_str).filter(|_| id == "file.importAep").map(str::to_string).into_iter().collect();
paths.append(&mut losses.queue);
paths
}
/// Whether command `id` with `params` needs the prompt first. When it does, the prompt opens
/// holding the command and the caller must not run it. `Err` when the dry run fails (the file
/// cannot be read or is not a project), with the message.
@@ -39,6 +62,9 @@ pub fn guard(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool,
if report["losses"].as_array().is_none_or(|l| l.is_empty()) {
return Ok(false);
}
// An unanswered prompt's projects wait behind this one.
let unanswered = take_unanswered(app);
app.dialog_state.aep_losses.queue.extend(unanswered);
app.dialog_state.aep_losses.command = Some((id.to_string(), params.clone(), report));
app.dialog = Some(Dialog::AepLosses);
Ok(true)
@@ -64,13 +90,15 @@ pub fn lines(report: &Value) -> Vec<(String, Vec<String>)> {
.collect()
}
/// Draw the prompt for the conversion waiting on it: its losses by category, Cancel and Import /
/// Export Anyway (Escape cancels).
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let Some((id, _, report)) = app.dialog_state.aep_losses.command.clone() else {
app.dialog = None;
return;
};
let import = id == "file.importAep";
let title = if import { "Import After Effects Project" } else { "Export After Effects Project" };
let title = if import { "Import Adobe After Effects Project" } else { "Export Adobe After Effects Project" };
let mut answer: Option<bool> = None;
super::dialogs::modal(ctx, title, vec2(520.0, 340.0), t, |ui| {
let text = if import {
@@ -114,11 +142,25 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
/// The prompt's answer: Cancel (`false`) drops the conversion, Import / Export Anyway runs it
/// with `allowLosses`.
fn finish(app: &mut EffectcraftApp, ctx: &egui::Context, go: bool) {
app.dialog = None;
let Some((id, params, _)) = app.dialog_state.aep_losses.command.take() else { return };
let Some((id, params, _)) = app.dialog_state.aep_losses.command.take() else {
app.dialog = None;
return;
};
let mut errors = Vec::new();
if go {
// An error is already in the status bar.
let _ = crate::menus::run_engine(app, ctx, &id, with_losses(&params));
// The prompt stays up while the conversion runs: a question queued by a footage import
// that finished meanwhile (Interpret Footage for unlabeled alpha, drained by `run_engine`)
// leaves a note instead of opening a dialog the next queued prompt would replace.
if let Err(e) = crate::menus::run_engine(app, ctx, &id, with_losses(&params)) {
errors.push(e);
}
}
app.dialog = None;
// The projects dropped or picked with this one: Cancel skips only this one.
let queue = std::mem::take(&mut app.dialog_state.aep_losses.queue);
errors.extend(crate::menus::import_aep_projects(app, queue));
if !errors.is_empty() {
app.ui.status = errors.join("; ");
}
}
@@ -142,6 +184,53 @@ mod tests {
assert!(lines(&json!({})).is_empty());
}
/// Projects dropped or picked together wait only behind a prompt that shows; with none
/// showing they are tried at once (a missing file is an error naming it).
#[test]
fn projects_wait_only_behind_a_showing_prompt() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
app.dialog_state.aep_losses.command = Some(("file.importAep".into(), json!({"path": "/a/One.aep"}), json!({})));
app.dialog = Some(Dialog::AepLosses);
assert!(crate::menus::import_aep_projects(&mut app, vec!["/no/such/Two.aep".into(), "/no/such/Three.aep".into()]).is_empty());
assert_eq!(app.dialog_state.aep_losses.queue, ["/no/such/Two.aep", "/no/such/Three.aep"]);
// Cancel: the queued ones are tried next (both missing here, so both are reported).
let ctx = egui::Context::default();
finish(&mut app, &ctx, false);
assert!(app.dialog_state.aep_losses.queue.is_empty());
assert!(app.ui.status.contains("Two.aep") && app.ui.status.contains("Three.aep"), "{}", app.ui.status);
}
/// A prompt another dialog replaced (Save changes? on quitting) holds nothing back: the next
/// import tries its project and queue first, then the new one.
#[test]
fn a_replaced_prompt_holds_nothing_back() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
app.dialog_state.aep_losses.command = Some(("file.importAep".into(), json!({"path": "/no/such/One.aep"}), json!({})));
app.dialog_state.aep_losses.queue = vec!["/no/such/Two.aep".into()];
app.dialog = Some(Dialog::UnsavedChanges);
let errors = crate::menus::import_aep_projects(&mut app, vec!["/no/such/Three.aep".into()]);
let named = |n: &str| errors.iter().position(|e| e.contains(n));
assert_eq!((named("One.aep"), named("Two.aep"), named("Three.aep")), (Some(0), Some(1), Some(2)), "{errors:?}");
assert!(app.dialog_state.aep_losses.command.is_none() && app.dialog_state.aep_losses.queue.is_empty());
assert_eq!(app.dialog, Some(Dialog::UnsavedChanges), "the other dialog stays");
}
/// A new prompt keeps an unanswered one's projects: they wait behind it.
#[test]
fn a_new_prompt_keeps_an_unanswered_ones_projects() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
app.dialog_state.aep_losses.command = Some(("file.importAep".into(), json!({"path": "/a/One.aep"}), json!({})));
app.dialog_state.aep_losses.queue = vec!["/a/Two.aep".into()];
app.dialog = Some(Dialog::UnsavedChanges);
// Rec. 2020 has no After Effects counterpart: exporting asks.
std::sync::Arc::make_mut(&mut app.session.project).settings.working_space = Some(effectcraft_engine::color::ColorSpace::Rec2020);
let dir = TempDir::new();
assert_eq!(guard(&mut app, "file.exportAep", &json!({"path": dir.0.join("out.aep").to_string_lossy()})), Ok(true));
assert_eq!(app.dialog, Some(Dialog::AepLosses));
assert_eq!(app.dialog_state.aep_losses.queue, ["/a/One.aep", "/a/Two.aep"]);
}
#[test]
fn agents_and_consented_calls_are_not_asked() {
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
+3 -1
View File
@@ -475,13 +475,15 @@ fn home_page(
}
}
/// Run the commands the Home screen's buttons and recent files asked for, and leave Home once one
/// opened something (not while a dialog it opened waits, nor when only the list changed).
fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, serde_json::Value)>) {
for (id, params) in actions {
let before = (app.session.path.clone(), app.session.revision);
match crate::menus::invoke(app, ctx, id, params) {
Err(e) => app.ui.status = e,
// Asking to save the open project first: Home stays until it is answered.
Ok(r) if r.get("dialog").is_some() => {}
Ok(r) if crate::menus::waits_on_dialog(&r) => {}
// Leave Home unless only the list changed or a file dialog was cancelled.
Ok(_) => {
let cancelled = matches!(id, "file.open" | "file.import") && (app.session.path.clone(), app.session.revision) == before;
+42 -7
View File
@@ -75,12 +75,45 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
if proj.len() > 1 {
errors.push(crate::i18n::tr_args("Only one project can be opened per drop; skipped {} more", &[&proj.len().saturating_sub(1)]));
}
// While the After Effects losses prompt waits, opening a project would replace it (and the
// project the answer imports into): the project waits for the answer, dropped again after it.
let proj = if crate::panels::aep_losses::asking(app) && !proj.is_empty() {
errors.push(crate::i18n::tr_args("Skipped {} more file(s) while a dialog is open; import them again after it", &[&1]));
Vec::new()
} else {
proj
};
if let Some(p) = proj.first()
&& let Err(e) = crate::menus::invoke(app, &ctx, "file.open", json!({"path": p}))
{
errors.push(crate::i18n::tr_args("Cannot open dropped project {}: {}", &[&p, &e]));
// After Effects projects import as projects (File ▸ Import ▸ Adobe After Effects Project…),
// each into a folder named after its file, opening its first composition.
let (aep, mut rest): (Vec<String>, Vec<String>) = rest.into_iter().partition(|p| effectcraft_engine::commands::is_aep_path(p));
// A project in the drop changes the active composition: the other files go to the Project
// panel only, not into a composition.
let has_project = !proj.is_empty() || !aep.is_empty();
// Opened first, so the other files land in the project it opens. While it waits on Save
// changes? the rest of the drop is skipped with a note: it would land in the project about
// to be replaced.
let mut waiting = false;
if let Some(p) = proj.first() {
match crate::menus::invoke(app, &ctx, "file.open", json!({"path": p})) {
Ok(r) => waiting = crate::menus::waits_on_dialog(&r),
Err(e) => errors.push(crate::i18n::tr_args("Cannot open dropped project {}: {}", &[&p, &e])),
}
}
let mut aep = aep;
if waiting {
let skipped = rest.len() + aep.len();
if skipped > 0 {
errors.push(crate::i18n::tr_args("Skipped {} more file(s) while a dialog is open; import them again after it", &[&skipped]));
}
rest.clear();
aep.clear();
}
// One with losses asks first; the projects after it wait for the answer, the other files
// import meanwhile. The errors name their files.
errors.extend(crate::menus::import_aep_projects(app, aep));
if !rest.is_empty() {
let viewer = app
@@ -101,7 +134,7 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
.is_some_and(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])).contains(p))
});
let at = if proj.is_empty() && !over_timeline {
let at = if !has_project && !over_timeline {
ctx.input(|i| i.pointer.hover_pos()).filter(|p| viewer.is_some_and(|v| v.contains(*p))).and_then(|p| crate::panels::viewer::screen_to_comp(&ctx, p))
} else {
None
@@ -116,11 +149,13 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
let mut params = json!({
"paths": rest,
"drag": true,
"addToComp": at.is_some() || over_timeline,
"addToComp": !has_project && (at.is_some() || over_timeline),
"position": at,
"background": true
});
if !layered {
// While the losses prompt of a project dropped with it shows, a Photoshop file imports
// with Default Drag Import As: one dialog at a time.
if !layered || crate::panels::aep_losses::asking(app) {
params["importAs"] = json!(app.session.prefs.drag_import_as());
}
if alt {
+162
View File
@@ -722,3 +722,165 @@ fn illustrator_import_offers_and_applies_retain_layer_sizes() {
assert_eq!((f.width, f.height), (30, 20));
assert!(f.layer.as_ref().unwrap().layer_size);
}
/// An After Effects project, written by File ▸ Export ▸ Adobe After Effects Project… to a folder of its
/// own: composition "Intro" with a solid.
fn aep_file(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ec-ui-aep-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Intro.aep");
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Intro", "width": 320, "height": 180, "frameRate": 30, "duration": 2})).unwrap();
s.execute("layer.newSolid", json!({"name": "Backdrop", "color": "#204060"})).unwrap();
s.execute("file.exportAep", json!({"path": path.to_string_lossy(), "allowLosses": true})).unwrap();
path
}
/// The open composition is "Intro", in the folder named after the imported file.
fn intro_is_open_in_its_folder(h: &Harness<'_, EffectcraftApp>) {
let s = &h.state().session;
let comp = s.project.item(s.active_comp_id().expect("a composition is open")).unwrap();
assert_eq!(comp.name, "Intro");
let folder = s.project.item(comp.parent.expect("in a folder")).unwrap();
assert_eq!(folder.name, "Intro.aep");
assert!(matches!(folder.kind, effectcraft_engine::project::ItemKind::Folder));
}
/// Dropped After Effects projects import as File ▸ Import ▸ Adobe After Effects Project… does
/// (they were refused as unsupported footage): each in a folder named after its file, the last
/// one's composition open. With nothing lost, nothing is asked.
#[test]
fn dropped_after_effects_projects_import_and_open_their_composition() {
let (mut h, _) = harness();
let aep = aep_file("drop");
h.event(Event::PointerMoved(rect(&h, "viewer.area").center()));
h.step();
let outro = aep.with_file_name("Outro.aep");
std::fs::copy(&aep, &outro).unwrap();
h.input_mut().dropped_files.push(Arc::new(Dropped(outro)));
h.input_mut().dropped_files.push(Arc::new(Dropped(aep)));
h.run_steps(3);
assert_eq!(h.state().dialog, None, "nothing is lost, so nothing to ask");
assert!(h.state().session.project.items.values().any(|i| i.name == "Outro.aep"), "the first project too");
intro_is_open_in_its_folder(&h);
assert_eq!(h.state().ui.status, "", "no errors");
}
/// File ▸ Import ▸ File… offers After Effects projects; a picked one imports as a project, and
/// the other files picked with it as footage.
#[test]
fn import_file_dialog_imports_a_picked_after_effects_project_with_the_other_files() {
let (mut h, _) = harness();
let aep = aep_file("pick");
let still = aep.with_file_name("still.png");
std::fs::write(&still, b"").unwrap();
let offered = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let seen = offered.clone();
let picked = vec![aep.to_string_lossy().to_string(), still.to_string_lossy().to_string()];
h.state_mut().session.importer = Some(Arc::new(Stills));
h.state_mut().hooks.pick_files = Some(Box::new(move |exts: &[&str]| {
*seen.lock().unwrap() = exts.iter().map(|e| e.to_string()).collect();
picked.clone()
}));
let ctx = h.ctx.clone();
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({})).unwrap();
assert!(["aep", "aepx"].iter().all(|e| offered.lock().unwrap().iter().any(|o| o == e)), "{:?}", offered.lock().unwrap());
step_until(&mut h, |h| footage_items(h).iter().any(|(n, _)| n == "still.png"));
intro_is_open_in_its_folder(&h);
}
/// Footage dropped with an After Effects project goes to the Project panel only, also over the
/// Timeline: the project opens its own composition, which the footage does not join.
#[test]
fn footage_dropped_with_an_after_effects_project_stays_in_the_project_panel() {
let (mut h, _) = harness();
let aep = aep_file("with-footage");
let still = aep.with_file_name("still.png");
std::fs::write(&still, b"").unwrap();
h.state_mut().session.importer = Some(Arc::new(Stills));
h.event(Event::PointerMoved(rect(&h, "panel.Timeline").center()));
h.step();
h.input_mut().dropped_files.push(Arc::new(Dropped(still)));
h.input_mut().dropped_files.push(Arc::new(Dropped(aep)));
h.run_steps(3);
step_until(&mut h, |h| footage_items(h).iter().any(|(n, _)| n == "still.png"));
h.run_steps(2);
intro_is_open_in_its_folder(&h);
let s = &h.state().session;
assert!(s.project.comps().all(|(_, c)| c.layers.iter().all(|l| l.name != "still.png")), "no composition got a still.png layer");
}
/// A dropped After Effects project with parts that cannot come over asks first (the losses
/// prompt). The projects dropped with it wait for the answer; the other files import meanwhile,
/// a Photoshop file with Default Drag Import As (one dialog at a time). Cancel skips only the
/// project it asks about; Import Anyway imports it.
#[test]
fn a_dropped_after_effects_project_with_losses_asks_first() {
let (mut h, _) = harness();
let intro = aep_file("losses");
let lossy = intro.with_file_name("Synthetic.aep");
std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../aep/tests/fixtures/synthetic.aep"), &lossy).unwrap();
h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) }));
h.state_mut().session.prefs.import.drag_import_as = "footage".into();
for path in [lossy.clone(), intro.clone(), intro.with_file_name("Layers.PSD"), intro.with_file_name("still.png")] {
h.input_mut().dropped_files.push(Arc::new(Dropped(path)));
}
h.run_steps(3);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::AepLosses), "the drop asks first");
assert_eq!(h.state_mut().dialog_state_mut().aep_losses.queue, [intro.to_string_lossy().to_string()], "the other project waits");
let named = |h: &Harness<'_, EffectcraftApp>, name: &str| h.state().session.project.items.values().any(|i| i.name == name);
assert!(!named(&h, "Synthetic.aep") && !named(&h, "Intro.aep"), "no project imported before the answer");
step_until(&mut h, |h| ["Layers.PSD", "still.png"].iter().all(|n| footage_items(h).iter().any(|(f, _)| f == n)));
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::AepLosses), "the footage did not replace the prompt");
click_id(&mut h, "dialog.aepLosses.cancel");
h.run_steps(2);
assert_eq!(h.state().dialog, None);
assert!(!named(&h, "Synthetic.aep"), "Cancel skips the project it asked about");
intro_is_open_in_its_folder(&h);
h.input_mut().dropped_files.push(Arc::new(Dropped(lossy)));
h.run_steps(3);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::AepLosses));
click_id(&mut h, "dialog.aepLosses.continue");
h.run_steps(2);
assert_eq!(h.state().dialog, None);
assert!(named(&h, "Synthetic.aep"), "Import Anyway imports it");
}
/// An After Effects project with nothing lost imports at once, so a Photoshop file dropped with
/// it still asks how to import it (Import Photoshop File).
#[test]
fn a_photoshop_file_dropped_with_an_after_effects_project_still_asks() {
let (mut h, _) = harness();
h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) }));
let aep = aep_file("with-psd");
h.input_mut().dropped_files.push(Arc::new(Dropped(aep.with_file_name("Layers.PSD"))));
h.input_mut().dropped_files.push(Arc::new(Dropped(aep)));
h.run_steps(2);
intro_is_open_in_its_folder(&h);
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form), "Import Photoshop File asks");
}
/// A dropped file that is not an After Effects project reports why, changes nothing, and the
/// other files of the drop still import.
#[test]
fn a_corrupt_dropped_after_effects_project_reports_and_keeps_the_other_files() {
let (mut h, _) = harness();
let dir = aep_file("corrupt").parent().unwrap().to_path_buf();
let broken = dir.join("Broken.aep");
std::fs::write(&broken, b"not an After Effects project").unwrap();
let still = dir.join("still.png");
std::fs::write(&still, b"").unwrap();
h.state_mut().session.importer = Some(Arc::new(Stills));
let comp = h.state().session.active_comp_id();
h.input_mut().dropped_files.push(Arc::new(Dropped(broken)));
h.input_mut().dropped_files.push(Arc::new(Dropped(still)));
h.run_steps(3);
step_until(&mut h, |h| footage_items(h).iter().any(|(n, _)| n == "still.png"));
let status = h.state().ui.status.clone();
assert!(status.contains("Broken.aep"), "the error names the file: {status}");
assert!(!h.state().session.project.items.values().any(|i| i.name == "Broken.aep"));
assert_eq!(h.state().session.active_comp_id(), comp, "the open composition stays");
}
+1 -1
View File
@@ -2,7 +2,7 @@
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-11-05 · **Change:** a 3D layer's changed Material Options are reported, shape and text animator colours outside 0 to 1 are clamped and reported, and a popup expression is no longer reported where the option order is the same (2026-11-05); before that: composition settings the base writes at their defaults (Preserve Frame Rate / Resolution, motion blur samples, adaptive sample limit) and render queue items are reported, and a sequence's folder time is read only when its file passes the document-path policy (2026-11-04); before that: every chunk is built in code, no template is read (2026-10-29); before that: Curves and Mesh Warp are written (R53); before that: a switched-off shape item or layer style is written switched off (R52, 986-988); before that: a reversed layer (negative stretch) is written as After Effects stores it (R36, 981-983); before that: an explicit None (`NO_LAYER`) on Compound Blur, Displacement Map or Set Matte is written as None, and Layer Control on a named layer no longer reports a source choice; before that: layer styles are written (R51, 950-980; read back in After Effects: `export-tests\styles`); before that: an image sequence's folder modification time is written when the caller knows it (`sspc@118`, R50: without it After Effects rescans the folder on open and warns about every gap of a sparse sequence in a modal dialog); before that: an output space equal to the working space is not reported (After Effects renders in the working space, 902; the importer sets it so for Rec.709 Gamma 2.4); before that: one effect table for both directions again: the 27 effects of the importer round 3 and the Layer Control are written too (prototypes from 903 and 076, layer parameters by layer id; not read back in After Effects yet); before that: the importer round 3 is merged: Offset Paths' Copy Offset is written as EffectCraft's value + 1 (826-828, the importer's inverse); before that: sparse sequences checked (860, written as After Effects stores them); before that: the Rec.709 Gamma 2.4 working space is written with a profile authored here (R49, g5); before that: text animators with range selectors are written (R48, t8); before that: a footage layer running past its source is ended there on import, as After Effects does, and less than a frame past is not a loss (LayerTiming); before that: Anchor Point and mask path ease are written (R47); before that: shape operators (polystar, gradients, trim paths, round corners, merge / offset paths, repeater) and keys and expressions on shape properties are written (R46, o2); before that: every effect of the importer's table is written, through one shared table and records built per parameter type (R45, e2); before that: time remapping is written (319, 354, r1, R44); before that: the character and paragraph settings are read back in After Effects (t6) and right-to-left justification is written relative to the direction (t7, R43); before that: the character and paragraph settings the importer decodes are written (scales, baseline shift, kerning, ligatures, tsume, fill / stroke switches and width, justified variants, indents, spacing, composer, direction); before that: track mattes, markers, cameras, lights and shape layers (R39-R42), keys on stretched layers, conformed clips, tga / psd / ai / eps, project settings (620-634, R37), superscript / subscript (526-528); before: precomposition layers (124, 257, 258, 600-602), the effects Tint, Gradient Ramp and Exposure (356-397, 610), footage (351, 353, 603-607, 611-613), text layers (110, 245, t1-t4), the 3D switch (015) and time stretch (018, 316, 317) are exported · **Target:** Adobe After Effects 2026
File ▸ Export ▸ After Effects Project… (`file.exportAep`) writes the open project as an `.aep` that
File ▸ Export ▸ Adobe After Effects Project… (`file.exportAep`) writes the open project as an `.aep` that
After Effects 2026 opens. It is deliberately narrow: what it writes is exact, tested and checked
against After Effects itself; everything else is **reported** and never dropped silently. The
importer ([aep-import.md](aep-import.md)) is the mirror image; both rest on the clean-room format
+10 -3
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -322,6 +322,10 @@ build real projects through these interfaces; they are worked examples of everyt
then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`.
* **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or
`{"colors":[[0,"#0080ff"],[1,[1,1,0]]],"opacities":[[0,1],[1,0.5]]}`.
* **Projects**: `file.import` takes `.ecproj` / `.ecprojx` and After Effects projects (`.aep`, `.aepx`)
too, each into a folder named after it (`projects` in the result). An After Effects project that
would lose parts is refused (an `errors` entry, nothing imported): `file.importAep {"path": …,
"dryRun": true}` lists the losses, `"allowLosses": true` imports the rest.
* **Image sequences**: `file.import {"paths":["/r/shot_0001.png"], "sequence": true}` imports the
file's whole numbered run as one item (several picked frames: that range; a folder: each run in
it, also without `sequence`). Without `"sequence": true` a picked or dropped numbered file imports
+1 -1
View File
@@ -22,7 +22,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
| Format | After Effects | EffectCraft read | Write | Fidelity / notes | Tested by |
|---|---|---|---|---|---|
| Project `.aep` | R/W (native) | partial | partial | Main file format; the clean-room method is in [aep-format.md](aep-format.md), the maintainers' decision on it is open (#190). `crates/aep` reads and writes the RIFX container losslessly (byte-identical round trip) with a typed view of the decoded fields, from our own generated files only ([aep-format.md](aep-format.md)). File ▸ Import ▸ After Effects Project… (`file.importAep`, `effectcraft_aep::import`): folders, project settings, compositions, every layer kind, Transform, keyframes, expressions, masks, text with animators, shapes, cameras, lights, track mattes, markers, time remapping, layer styles, the built-in effects of a table shared with the exporter (Lumetri Color, Curves, Levels, Glow, Fractal Noise, Gradient Ramp, … and the expression controls), footage and image sequences; everything else is reported ([aep-import.md](aep-import.md)). File ▸ Export ▸ After Effects Project… (`file.exportAep`, `effectcraft_aep::export`) writes the same scope with every chunk built in code; exports were opened and read back in After Effects 2026 ([aep-export.md](aep-export.md)); everything else is reported. Effects outside the table (plug-ins, …) are not converted | `crates/aep` tests (`tests/container`, `tests/import`, `tests/export`) |
| Project `.aep` | R/W (native) | partial | partial | Main file format; the clean-room method is in [aep-format.md](aep-format.md), the maintainers' decision on it is open (#190). `crates/aep` reads and writes the RIFX container losslessly (byte-identical round trip) with a typed view of the decoded fields, from our own generated files only ([aep-format.md](aep-format.md)). File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`, `effectcraft_aep::import`): folders, project settings, compositions, every layer kind, Transform, keyframes, expressions, masks, text with animators, shapes, cameras, lights, track mattes, markers, time remapping, layer styles, the built-in effects of a table shared with the exporter (Lumetri Color, Curves, Levels, Glow, Fractal Noise, Gradient Ramp, … and the expression controls), footage and image sequences; everything else is reported ([aep-import.md](aep-import.md)). File ▸ Export ▸ Adobe After Effects Project… (`file.exportAep`, `effectcraft_aep::export`) writes the same scope with every chunk built in code; exports were opened and read back in After Effects 2026 ([aep-export.md](aep-export.md)); everything else is reported. Effects outside the table (plug-ins, …) are not converted | `crates/aep` tests (`tests/container`, `tests/import`, `tests/export`) |
| XML project `.aepx` | R/W | partial | none | Read into the same chunk tree as `.aep` by `crates/aep` (`read_aepx`; mapping in [aep-format.md](aep-format.md) section 1), so `file.importAep` imports it with the same coverage as `.aep`; not written | `crates/aep` `aepx` tests (container and import) |
| Templates `.aet` / `.aetx` | R/W | none | none | Own `.ectemplate` (Home ▸ Templates, Save as Template) | `templates.rs` tests |
| Motion Graphics template `.mogrt` | W (and R in Premiere) | none | none | Own `.ectemplate` Essential Graphics templates; FilmCraft rejects our `.fcgt` export (#611) | `essential.rs` tests |
+3
View File
@@ -70,6 +70,9 @@ next to it.
Agents: `file.importProject {"path": "/projects/titles.ecproj"}` returns the comps, the root comps,
`primaryComp` (the only root comp, if there is one) and any missing media.
After Effects projects (`.aep`, `.aepx`) given to File ▸ Import ▸ File… or dropped on the window
import as File ▸ Import ▸ Adobe After Effects Project… does ([aep-import.md](aep-import.md)).
## Finding a layer's source in the Project panel
Right-click a layer in the Timeline or Composition panel and choose **Reveal Layer Source in