From 9e5810828810ade19000630328a40a453b9ebcd7 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 08:49:26 +0530 Subject: [PATCH 1/2] #3103: import dropped and picked After Effects projects; name the project imports alike Dropping an .aep or .aepx on the window, or picking one in File > Import > File... or Multiple Files... (also from the Home screen and the Project panel), imports it as File > Import > Adobe After Effects Project... does, instead of failing as unsupported footage: the losses prompt first when parts cannot come over, its items in a folder named after the file, its first composition open. - Several projects are asked about one at a time: the others wait in the prompt's queue, and Cancel skips only the one it asks about. A prompt another dialog replaced (Save changes?) strands nothing: its projects are tried with the next import or queued behind the next prompt. - The other files of a drop or pick import as footage, into the Project panel only when a project is in the drop. While the prompt shows, a Photoshop file or image sequence imports with its defaults instead of replacing the prompt, an Interpret Footage question leaves a note instead, and a dropped .ecproj is skipped with a note. While Save changes? waits for a dropped .ecproj, the rest of the drop is skipped with a note instead of landing in the replaced project. - Errors of every picked project reach the status line on both the blocking and the non-blocking file dialog. - The Import File dialogs list .aep, .aepx, .ecproj and .ecprojx; Replace Footage offers footage only. - For scripts and agents, file.import hands After Effects projects to file.importAep; a refusal (new EngineError::Refused) names the menu entry and `file.importAep` with `allowLosses`. - File > Import / Export name the entry Adobe After Effects Project..., as Adobe Premiere Pro Project...; File > Import keeps After Effects' order with the project imports together and the formats only EffectCraft imports after a separator. All 13 menu catalogs and the losses dialog title are updated. - Docs: aep-import, aep-export, footage, file-format-parity, agents. Supersedes #3312. --- crates/engine/src/cmd.rs | 1 + crates/engine/src/cmd/aep.rs | 25 ++-- crates/engine/src/cmd/file.rs | 18 ++- crates/engine/src/error.rs | 4 + crates/engine/src/menus.rs | 15 ++- crates/engine/src/tests/aep_export.rs | 4 +- crates/engine/src/tests/aep_import.rs | 88 ++++++++++++- crates/ui-egui/src/i18n.rs | 58 +++++---- crates/ui-egui/src/i18n/ui.rs | 38 ++++-- crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/menus/frontend.rs | 101 +++++++++++++-- crates/ui-egui/src/menus/invoke.rs | 6 + crates/ui-egui/src/menus/tests.rs | 123 ++++++++++++++++++ crates/ui-egui/src/panels/aep_losses.rs | 101 ++++++++++++++- crates/ui-egui/src/panels/home.rs | 2 +- crates/ui-egui/src/prefs_live.rs | 49 ++++++- crates/ui-egui/tests/it/ui_drag_drop.rs | 162 ++++++++++++++++++++++++ docs/aep-export.md | 2 +- docs/aep-import.md | 13 +- docs/agents.md | 4 + docs/file-format-parity.md | 2 +- docs/footage.md | 3 + 22 files changed, 733 insertions(+), 90 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 076be70ec..40603e8cd 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -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; diff --git a/crates/engine/src/cmd/aep.rs b/crates/engine/src/cmd/aep.rs index 575171f89..3445a478b 100644 --- a/crates/engine/src/cmd/aep.rs +++ b/crates/engine/src/cmd/aep.rs @@ -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,12 @@ 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")) +} + fn import_aep(s: &mut Session, p: &Value) -> Result { 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 +46,12 @@ fn import_aep(s: &mut Session, p: &Value) -> Result { 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 { @@ -112,7 +119,7 @@ fn export_aep(s: &mut Session, p: &Value) -> Result { }; 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}")), })?; @@ -130,7 +137,7 @@ pub fn specs() -> Vec { 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 +146,7 @@ pub fn specs() -> Vec { ), 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)}", diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8129759c5..890a9def1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -404,8 +404,11 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { 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, Vec) = 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, Vec) = paths.into_iter().partition(|x| super::import_project::is_project_path(x)); + let (aep_paths, mut paths): (Vec, Vec) = 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 { 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 { diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs index 75e9e4467..e1f5bfa3c 100644 --- a/crates/engine/src/error.rs +++ b/crates/engine/src/error.rs @@ -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 = std::result::Result; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 70acbd1fb..f0ef8bd57 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -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 --- diff --git a/crates/engine/src/tests/aep_export.rs b/crates/engine/src/tests/aep_export.rs index 41c2ebfee..a716ef7bc 100644 --- a/crates/engine/src/tests/aep_export.rs +++ b/crates/engine/src/tests/aep_export.rs @@ -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; @@ -77,7 +77,7 @@ fn bad_input_is_an_error_not_a_crash() { 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] diff --git a/crates/engine/src/tests/aep_import.rs b/crates/engine/src/tests/aep_import.rs index 8f09f1c0b..7aba82983 100644 --- a/crates/engine/src/tests/aep_import.rs +++ b/crates/engine/src/tests/aep_import.rs @@ -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,95 @@ 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| 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); +} + #[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] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index c998538d4..a549edd5c 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -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:?}"); } } } diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index 259fe2d60..a16b879d7 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -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"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb28b0bcd..9020b8fc0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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}; diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index bd1294330..bf981fc67 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -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> { 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::>(); + let importable = |more: &[&'static str]| footage(MEDIA).into_iter().chain(more.iter().copied()).collect::>(); // 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::>().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 { + 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, Vec) = + 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) -> Vec { + 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, rest: Vec) -> Result { + 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). diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index bebb7f767..4349e6b33 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -183,6 +183,12 @@ fn only_comp(p: &Value) -> Option { 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 { crate::control::check_script_render(app, id)?; diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index b12483b0a..12aac3cd9 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -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>> = 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>> = 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); +} diff --git a/crates/ui-egui/src/panels/aep_losses.rs b/crates/ui-egui/src/panels/aep_losses.rs index 746e83b86..14010a5df 100644 --- a/crates/ui-egui/src/panels/aep_losses.rs +++ b/crates/ui-egui/src/panels/aep_losses.rs @@ -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, } /// 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 { + 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 = 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 = None; super::dialogs::modal(ctx, title, vec2(520.0, 340.0), t, |ui| { let text = if import { @@ -114,11 +140,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(¶ms)); + // 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(¶ms)) { + 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 +182,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()); diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index 3b735e47e..e61d523b1 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -481,7 +481,7 @@ fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str 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; diff --git a/crates/ui-egui/src/prefs_live.rs b/crates/ui-egui/src/prefs_live.rs index 63beb3c39..af8e27856 100644 --- a/crates/ui-egui/src/prefs_live.rs +++ b/crates/ui-egui/src/prefs_live.rs @@ -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, Vec) = 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 { diff --git a/crates/ui-egui/tests/it/ui_drag_drop.rs b/crates/ui-egui/tests/it/ui_drag_drop.rs index 5a0d94ddf..d7e99046b 100644 --- a/crates/ui-egui/tests/it/ui_drag_drop.rs +++ b/crates/ui-egui/tests/it/ui_drag_drop.rs @@ -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::::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"); +} diff --git a/docs/aep-export.md b/docs/aep-export.md index e8b89b424..3d30441a1 100644 --- a/docs/aep-export.md +++ b/docs/aep-export.md @@ -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 diff --git a/docs/aep-import.md b/docs/aep-import.md index f41ba3081..c704e0a89 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,9 +1,16 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-07 · **Change:** After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-10 · **Change:** dropped projects and projects picked in File ▸ Import ▸ File… import as projects, several asked about one at a time; `file.import` hands them to `file.importAep`; the menu entries are named Adobe After Effects Project… (#3103); before that: After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: Lumetri Color's master RGB curve, Hue vs Sat curve and colour wheels are imported (1200 presets made by hand); before that: Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 -File ▸ Import ▸ After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` +File ▸ Import ▸ Adobe After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). +A project dropped on the window, or picked in an Import File dialog (File ▸ Import ▸ File… or +Multiple Files…, the Home screen, the Project panel), imports the same way. Several are asked +about one at a time: while the losses prompt shows, the others wait, and Cancel skips only the +one it asks about. The other files of the drop or pick import as footage, into the Project panel +only (not into a composition, as the project opens its own); while the prompt shows, a Photoshop file or image sequence imports with its defaults instead of +opening its own dialog. Agents giving an `.aep` to `file.import` get the refusal when parts would +be lost (`file.importAep` takes `allowLosses` and `dryRun`). It is deliberately narrow: what it brings over is exact and tested, everything else is **reported** and never dropped silently. Wider coverage grows with the reader's verified field layouts ([aep-format.md](aep-format.md)); this page lists what exists today. @@ -496,7 +503,7 @@ generated corpus and its case numbers, not a claim about what was or was not see ## Export -The other direction, File ▸ Export ▸ After Effects Project… (`file.exportAep`), is described in +The other direction, File ▸ Export ▸ Adobe After Effects Project… (`file.exportAep`), is described in [aep-export.md](aep-export.md). ## Not done yet diff --git a/docs/agents.md b/docs/agents.md index ebf158029..ae9b1a6f6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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 diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0e98bda58..fd908a50f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 | diff --git a/docs/footage.md b/docs/footage.md index 7841dc84c..7a0639c02 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -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 From eec3c51296abec2c035c52979b52d4acc82e988a Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Tue, 10 Nov 2026 09:35:56 +0530 Subject: [PATCH 2/2] #3103: doc comments for the touched import and prompt functions --- crates/engine/src/cmd/aep.rs | 5 +++++ crates/engine/src/tests/aep_export.rs | 1 + crates/engine/src/tests/aep_import.rs | 1 + crates/ui-egui/src/panels/aep_losses.rs | 2 ++ crates/ui-egui/src/panels/home.rs | 2 ++ 5 files changed, 11 insertions(+) diff --git a/crates/engine/src/cmd/aep.rs b/crates/engine/src/cmd/aep.rs index 3445a478b..eab218100 100644 --- a/crates/engine/src/cmd/aep.rs +++ b/crates/engine/src/cmd/aep.rs @@ -22,6 +22,8 @@ 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 { 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); @@ -97,6 +99,8 @@ fn import_aep(s: &mut Session, p: &Value) -> Result { })) } +/// `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 { 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); @@ -133,6 +137,7 @@ fn export_aep(s: &mut Session, p: &Value) -> Result { Ok(json!({"path": path, "bytes": res.bytes.len(), "report": res.report})) } +/// File ▸ Import / Export ▸ Adobe After Effects Project…. pub fn specs() -> Vec { vec![ cmd!( diff --git a/crates/engine/src/tests/aep_export.rs b/crates/engine/src/tests/aep_export.rs index a716ef7bc..fc5ea29bc 100644 --- a/crates/engine/src/tests/aep_export.rs +++ b/crates/engine/src/tests/aep_export.rs @@ -73,6 +73,7 @@ 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(); diff --git a/crates/engine/src/tests/aep_import.rs b/crates/engine/src/tests/aep_import.rs index 7aba82983..c62987ddf 100644 --- a/crates/engine/src/tests/aep_import.rs +++ b/crates/engine/src/tests/aep_import.rs @@ -231,6 +231,7 @@ fn file_import_takes_after_effects_projects() { 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(); diff --git a/crates/ui-egui/src/panels/aep_losses.rs b/crates/ui-egui/src/panels/aep_losses.rs index 14010a5df..4eb71104d 100644 --- a/crates/ui-egui/src/panels/aep_losses.rs +++ b/crates/ui-egui/src/panels/aep_losses.rs @@ -90,6 +90,8 @@ pub fn lines(report: &Value) -> Vec<(String, Vec)> { .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; diff --git a/crates/ui-egui/src/panels/home.rs b/crates/ui-egui/src/panels/home.rs index e61d523b1..601f4f1ef 100644 --- a/crates/ui-egui/src/panels/home.rs +++ b/crates/ui-egui/src/panels/home.rs @@ -475,6 +475,8 @@ 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);