mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 02:11:41 -06:00
Merge pull request #3392 from arnav-hiwarkar/feat/aep-drag-drop
File ▸ Import: drop or pick After Effects projects to import them; name the project imports alike (#3103)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! File ▸ Import ▸ After Effects Project… (`effectcraft_aep::import`) and File ▸ Export ▸ After Effects
|
||||
//! Project… (`effectcraft_aep::export`).
|
||||
//! File ▸ Import ▸ Adobe After Effects Project… (`effectcraft_aep::import`) and File ▸ Export ▸
|
||||
//! Adobe After Effects Project… (`effectcraft_aep::export`).
|
||||
//!
|
||||
//! Adds the items of an `.aep` (or its XML form, `.aepx`) to the project in a folder named after
|
||||
//! the file (`docs/aep-import.md` lists what arrives). What the importer cannot bring over (effects
|
||||
@@ -16,6 +16,14 @@ use serde_json::{Value, json};
|
||||
use super::{CommandSpec, always, b_p, bad, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
/// Whether `path` is an After Effects project (`.aep`, or its XML form `.aepx`): imported as a
|
||||
/// project (`file.importAep`), not as footage.
|
||||
pub fn is_aep_path(path: &str) -> bool {
|
||||
std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("aep") || e.eq_ignore_ascii_case("aepx"))
|
||||
}
|
||||
|
||||
/// `file.importAep`: the `.aep` / `.aepx` at `path` into the open project, in a folder named after
|
||||
/// the file; refused ([`EngineError::Refused`]) when parts would be lost and `allowLosses` is not set.
|
||||
fn import_aep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.importAep", "missing `path`"))?.to_string();
|
||||
let allow_losses = b_p(p, "allowLosses").unwrap_or(false);
|
||||
@@ -40,11 +48,12 @@ fn import_aep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({"dryRun": true, "report": report}));
|
||||
}
|
||||
let importer = s.importer.clone();
|
||||
let res = s.edit("Import After Effects Project", None, |proj, st| {
|
||||
let res = s.edit("Import Adobe After Effects Project", None, |proj, st| {
|
||||
let r = effectcraft_aep::import::import(proj, &bytes, &options).map_err(|e| match e {
|
||||
AepImportError::Refused(_) => {
|
||||
EngineError::Other(format!("{e} (nothing was imported: pass `allowLosses` to import anyway, or `dryRun` for the full report)"))
|
||||
}
|
||||
AepImportError::Refused(_) => EngineError::Refused {
|
||||
losses: e.to_string(),
|
||||
advice: "nothing was imported: pass `allowLosses` to import anyway, or `dryRun` for the full report",
|
||||
},
|
||||
other => EngineError::Other(format!("cannot import {path}: {other}")),
|
||||
})?;
|
||||
if apply {
|
||||
@@ -90,6 +99,8 @@ fn import_aep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
/// `file.exportAep`: the open project as an `.aep` at `path`; refused ([`EngineError::Refused`])
|
||||
/// when parts would be lost and `allowLosses` is not set.
|
||||
fn export_aep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = str_p(p, "path").ok_or_else(|| bad("file.exportAep", "missing `path`"))?.to_string();
|
||||
let allow_losses = b_p(p, "allowLosses").unwrap_or(false);
|
||||
@@ -112,7 +123,7 @@ fn export_aep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let res = effectcraft_aep::export::export_with(&s.project, &ExportOptions { allow_losses }, &folder_time).map_err(|e| match e {
|
||||
AepExportError::Refused(_) => {
|
||||
EngineError::Other(format!("{e} (nothing was written: pass `allowLosses` to export anyway, or `dryRun` for the full report)"))
|
||||
EngineError::Refused { losses: e.to_string(), advice: "nothing was written: pass `allowLosses` to export anyway, or `dryRun` for the full report" }
|
||||
}
|
||||
other => EngineError::Other(format!("cannot export {path}: {other}")),
|
||||
})?;
|
||||
@@ -126,11 +137,12 @@ fn export_aep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"path": path, "bytes": res.bytes.len(), "report": res.report}))
|
||||
}
|
||||
|
||||
/// File ▸ Import / Export ▸ Adobe After Effects Project….
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"file.importAep",
|
||||
"After Effects Project...",
|
||||
"Adobe After Effects Project...",
|
||||
["File", "Import"],
|
||||
None,
|
||||
"{path (.aep or .aepx), allowLosses?: bool (import although parts cannot be imported), dryRun?: bool (only return the report), applySettings?: bool (take over the file's project settings; default: when the project has no compositions)}",
|
||||
@@ -139,7 +151,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(
|
||||
"file.exportAep",
|
||||
"After Effects Project...",
|
||||
"Adobe After Effects Project...",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{path (.aep), allowLosses?: bool (export although parts cannot be exported), dryRun?: bool (only return the report)}",
|
||||
|
||||
@@ -404,8 +404,11 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
||||
let mut comps = vec![];
|
||||
let mut items = vec![];
|
||||
let mut errors = vec![];
|
||||
// EffectCraft projects import as projects, as After Effects imports an .aep.
|
||||
let (project_paths, mut paths): (Vec<String>, Vec<String>) = paths.into_iter().partition(|x| super::import_project::is_project_path(x));
|
||||
// EffectCraft projects import as projects, as After Effects imports an .aep; so do After
|
||||
// Effects projects, refused when parts of them cannot come over. The app asks about those
|
||||
// before it gets here (`panels::aep_losses`); a script or agent gets the refusal.
|
||||
let (project_paths, paths): (Vec<String>, Vec<String>) = paths.into_iter().partition(|x| super::import_project::is_project_path(x));
|
||||
let (aep_paths, mut paths): (Vec<String>, Vec<String>) = paths.into_iter().partition(|x| super::is_aep_path(x));
|
||||
let mut projects = vec![];
|
||||
for path in project_paths {
|
||||
match s.execute("file.importProject", json!({"path": path})) {
|
||||
@@ -413,6 +416,17 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result<PendingImport> {
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
}
|
||||
for path in aep_paths {
|
||||
match s.execute("file.importAep", json!({"path": path})) {
|
||||
Ok(r) => projects.push(r),
|
||||
// `file.import` has no `allowLosses`: point at the entry that lists the losses, and at
|
||||
// the command that takes the consent.
|
||||
Err(EngineError::Refused { losses, .. }) => errors.push(format!(
|
||||
"{path}: {losses}; File ▸ Import ▸ Adobe After Effects Project… lists them and can import the rest (`file.importAep` with `allowLosses`)"
|
||||
)),
|
||||
Err(e) => errors.push(format!("{path}: {e}")),
|
||||
}
|
||||
}
|
||||
if let Some(retain) = retain {
|
||||
let mut rest = vec![];
|
||||
for path in paths {
|
||||
|
||||
@@ -15,6 +15,10 @@ pub enum EngineError {
|
||||
Project(#[from] effectcraft_project::ProjectError),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
/// Refused because parts would be lost in a conversion; nothing changed. `losses` names them,
|
||||
/// `advice` how to go on (`file.importAep` / `file.exportAep` without `allowLosses`).
|
||||
#[error("{losses} ({advice})")]
|
||||
Refused { losses: String, advice: &'static str },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! File ▸ Export ▸ After Effects Project… through the command registry.
|
||||
//! File ▸ Export ▸ Adobe After Effects Project… through the command registry.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
@@ -73,11 +73,12 @@ fn bad_input_is_an_error_not_a_crash() {
|
||||
assert!(empty.execute_checked("file.exportAep", json!({"path": path})).is_ok());
|
||||
}
|
||||
|
||||
/// File ▸ Export ▸ Adobe After Effects Project… is in the menu under that label.
|
||||
#[test]
|
||||
fn aep_export_menu_entry_exists() {
|
||||
let entries = crate::menus::entries();
|
||||
let find = |cmd: &str| entries.iter().find(|(_, e)| e.command == cmd).map(|(p, e)| (p.join(" > "), e.label.clone()));
|
||||
assert_eq!(find("file.exportAep"), Some(("File > Export".into(), "After Effects Project...".into())));
|
||||
assert_eq!(find("file.exportAep"), Some(("File > Export".into(), "Adobe After Effects Project...".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! File ▸ Import ▸ After Effects Project… through the command registry, with the synthetic
|
||||
//! File ▸ Import ▸ Adobe After Effects Project… through the command registry, with the synthetic
|
||||
//! fixtures of `effectcraft-aep`.
|
||||
|
||||
use serde_json::json;
|
||||
@@ -147,11 +147,96 @@ fn bad_input_is_an_error_not_a_crash() {
|
||||
assert!(s.project.items.is_empty());
|
||||
}
|
||||
|
||||
/// File ▸ Import: After Effects' own entries in its order, the project imports together where
|
||||
/// After Effects has Adobe Premiere Pro Project… (alphabetical, named in full), then a separator
|
||||
/// and the formats only EffectCraft imports, alphabetical (#3103).
|
||||
#[test]
|
||||
fn the_import_menu_groups_the_project_imports() {
|
||||
use crate::menus::MenuNode;
|
||||
let file = crate::menus::menu_bar().iter().find_map(|n| match n {
|
||||
MenuNode::Submenu { label, children } if label == "File" => Some(children),
|
||||
_ => None,
|
||||
});
|
||||
let import = file.into_iter().flatten().find_map(|n| match n {
|
||||
MenuNode::Submenu { label, children } if label == "Import" => Some(children),
|
||||
_ => None,
|
||||
});
|
||||
let labels: Vec<&str> = import
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|n| match n {
|
||||
MenuNode::Item(e) => e.label.as_str(),
|
||||
MenuNode::Separator => "---",
|
||||
MenuNode::Submenu { label, .. } => label.as_str(),
|
||||
MenuNode::Dynamic { .. } => "@dynamic",
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
[
|
||||
"File...",
|
||||
"Multiple Files...",
|
||||
"Adobe After Effects Project...",
|
||||
"Adobe Premiere Pro Project...",
|
||||
"EffectCraft Project...",
|
||||
"Vanishing Point (.vpe)...",
|
||||
"Placeholder...",
|
||||
"Solid...",
|
||||
"---",
|
||||
"Essential Graphics Template...",
|
||||
"Lottie...",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `file.import` takes After Effects projects as After Effects' File… does, for scripts and
|
||||
/// agents: one without losses imports as `file.importAep` does; one with losses is refused and
|
||||
/// changes nothing, naming the menu entry that lists the losses; a file that is not one says why.
|
||||
#[test]
|
||||
fn file_import_takes_after_effects_projects() {
|
||||
assert!(crate::cmd::is_aep_path("/a/Intro.aep") && crate::cmd::is_aep_path("/a/Intro.AEPX"));
|
||||
assert!(!crate::cmd::is_aep_path("/a/Intro.ecproj") && !crate::cmd::is_aep_path("/a/aep") && !crate::cmd::is_aep_path("/a/clip.aep.mp4"));
|
||||
let dir = std::env::temp_dir().join(format!("ec-file-import-aep-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let message = |r: crate::Result<serde_json::Value>| match r {
|
||||
Ok(v) => v["errors"].to_string(),
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
|
||||
let intro = dir.join("Intro.aep").to_string_lossy().to_string();
|
||||
let mut source = Session::default();
|
||||
source.execute("comp.new", json!({"name": "Intro", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap();
|
||||
source.execute("file.exportAep", json!({"path": intro, "allowLosses": true})).unwrap();
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("file.import", json!({"paths": [intro]})).unwrap();
|
||||
assert_eq!(r["projects"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let folder = s.project.items.values().find(|i| i.name == "Intro.aep").map(|i| i.id);
|
||||
assert!(folder.is_some(), "the project's folder");
|
||||
assert!(s.project.items.values().any(|i| i.name == "Intro" && i.parent == folder), "its composition in the folder");
|
||||
|
||||
let lossy = concat!(env!("CARGO_MANIFEST_DIR"), "/../aep/tests/fixtures/synthetic.aep");
|
||||
let mut s = Session::default();
|
||||
let refused = message(s.execute("file.import", json!({"paths": [lossy]})));
|
||||
assert!(refused.contains("synthetic.aep") && refused.contains("File ▸ Import ▸ Adobe After Effects Project… lists them"), "{refused}");
|
||||
assert!(refused.contains("`file.importAep` with `allowLosses`"), "and the command that takes the consent: {refused}");
|
||||
assert!(s.project.items.is_empty(), "nothing imported");
|
||||
|
||||
let junk = dir.join("Junk.AEPX");
|
||||
std::fs::write(&junk, b"not a project").unwrap();
|
||||
let mut s = Session::default();
|
||||
let error = message(s.execute("file.import", json!({"paths": [junk.to_string_lossy()]})));
|
||||
assert!(error.contains("Junk.AEPX"), "{error}");
|
||||
assert!(s.project.items.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// File ▸ Import ▸ Adobe After Effects Project… is in the menu under that label.
|
||||
#[test]
|
||||
fn aep_menu_entry_exists() {
|
||||
let entries = crate::menus::entries();
|
||||
let find = |cmd: &str| entries.iter().find(|(_, e)| e.command == cmd).map(|(p, e)| (p.join(" > "), e.label.clone()));
|
||||
assert_eq!(find("file.importAep"), Some(("File > Import".into(), "After Effects Project...".into())));
|
||||
assert_eq!(find("file.importAep"), Some(("File > Import".into(), "Adobe After Effects Project...".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+30
-28
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -502,6 +502,15 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
||||
return run_engine(app, ctx, "window.assignWorkspaceShortcut", q);
|
||||
}
|
||||
"file.interpretFootage" => {
|
||||
// A background import finishing while another dialog waits (the After Effects losses
|
||||
// prompt of the same drop) does not replace it: that dialog's work would be lost. The
|
||||
// footage keeps the default interpretation; the note says where to change it.
|
||||
if app.dialog.is_some() {
|
||||
app.ui.status =
|
||||
crate::i18n::tr("Imported footage has an unlabeled alpha channel: choose how to interpret it in File ▸ Interpret Footage ▸ Main…")
|
||||
.to_string();
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
// Import ▸ Interpret Unlabeled Alpha As: Ask User.
|
||||
if let Some(i) = p.get("item").and_then(Value::as_u64) {
|
||||
app.session.state.project_selection = vec![effectcraft_engine::project::ItemId(i)];
|
||||
@@ -601,7 +610,8 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
||||
pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
enum Ask {
|
||||
Folder,
|
||||
Import,
|
||||
/// Footage, or a file with one of these extensions.
|
||||
Import(&'static [&'static str]),
|
||||
OpenProject,
|
||||
Save(std::borrow::Cow<'static, str>),
|
||||
Open(&'static [&'static str]),
|
||||
@@ -617,7 +627,9 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
|
||||
path.with_file_name(format!("{stem}{suffix}.{ext}")).to_string_lossy().into_owned().into()
|
||||
};
|
||||
let (key, ask) = match id {
|
||||
"file.import" | "file.importMultiple" => ("paths", Ask::Import),
|
||||
// Projects too, as After Effects' File… takes an .aep: they import as projects
|
||||
// (`run_with_picked`, and `file.import` for EffectCraft projects).
|
||||
"file.import" | "file.importMultiple" => ("paths", Ask::Import(&["aep", "aepx", "ecproj", "ecprojx"])),
|
||||
"file.open" => ("path", Ask::OpenProject),
|
||||
"file.save" if app.session.path.is_none() => ("path", Ask::Save(project_as("", "ecproj"))),
|
||||
"file.saveAs" => ("path", Ask::Save(project_as("", "ecproj"))),
|
||||
@@ -644,7 +656,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
|
||||
"effect.plugins.install" if params.get("bundle").is_some() => ("folder", Ask::Folder),
|
||||
"effect.plugins.install" if params.get("folder").is_none() && params.get("path").is_none() => ("path", Ask::Open(&["wasm"])),
|
||||
"effect.helpers.load" => ("path", Ask::Folder),
|
||||
"file.replaceFootage" => ("path", Ask::Import),
|
||||
"file.replaceFootage" => ("path", Ask::Import(&[])),
|
||||
"file.relinkFootage" => ("folder", Ask::Folder),
|
||||
"file.collectFiles" => ("folder", Ask::Save("Collected Files".into())),
|
||||
// An open XML project gets ` copy` too: the copy never writes over the open file.
|
||||
@@ -672,6 +684,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
|
||||
"eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv",
|
||||
];
|
||||
let footage = |more: &[&'static str]| effectcraft_project::VIDEO_EXTENSIONS.iter().chain(more).copied().collect::<Vec<_>>();
|
||||
let importable = |more: &[&'static str]| footage(MEDIA).into_iter().chain(more.iter().copied()).collect::<Vec<_>>();
|
||||
// A host whose blocking dialogs are unsafe inside this frame shows the dialog without
|
||||
// blocking — macOS: the nested event loop re-entered winit's handler on a drag over the
|
||||
// window and aborted the app (#511); Linux: the desktop portal's answer never gets pumped,
|
||||
@@ -680,7 +693,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
|
||||
if let Some(spawn) = app.hooks.file_dialog_async.as_ref() {
|
||||
let request = match ask {
|
||||
Ask::Folder => crate::FileDialog::Folder,
|
||||
Ask::Import => crate::FileDialog::Open { exts: footage(MEDIA).iter().map(|e| e.to_string()).collect(), many: key == "paths" },
|
||||
Ask::Import(more) => crate::FileDialog::Open { exts: importable(more).iter().map(|e| e.to_string()).collect(), many: key == "paths" },
|
||||
Ask::OpenProject => crate::FileDialog::OpenProject,
|
||||
Ask::Open(exts) => crate::FileDialog::Open { exts: exts.iter().map(|e| e.to_string()).collect(), many: false },
|
||||
Ask::OpenFootage(more) => crate::FileDialog::Open { exts: footage(more).iter().map(|e| e.to_string()).collect(), many: false },
|
||||
@@ -702,9 +715,9 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
|
||||
let Some(f) = app.hooks.pick_folder.as_ref() else { return Some(Err("no folder dialog available (pass `folder`)".into())) };
|
||||
f().map(Value::from)
|
||||
}
|
||||
Ask::Import => {
|
||||
Ask::Import(more) => {
|
||||
let Some(f) = app.hooks.pick_files.as_ref() else { return Some(Err("no file dialog available (pass `paths`)".into())) };
|
||||
picked_value(key, f(&footage(MEDIA)))
|
||||
picked_value(key, f(&importable(more)))
|
||||
}
|
||||
Ask::OpenProject => {
|
||||
let Some(f) = app.hooks.pick_open_project.as_ref() else { return Some(Err("no file dialog available (pass `path`)".into())) };
|
||||
@@ -758,7 +771,8 @@ pub(crate) fn show_result_errors(app: &mut EffectcraftApp, result: &Value) {
|
||||
.map(|e| match (e.get("file").and_then(Value::as_str), e.get("error").and_then(Value::as_str)) {
|
||||
(Some(file), Some(error)) => format!("{file}: {error}"),
|
||||
(_, Some(error)) => error.to_string(),
|
||||
_ => e.to_string(),
|
||||
// A plain message (`file.import`'s errors) reads as itself, not as quoted JSON.
|
||||
_ => e.as_str().map_or_else(|| e.to_string(), str::to_string),
|
||||
})
|
||||
.collect();
|
||||
let mut msg = parts.iter().take(3).cloned().collect::<Vec<_>>().join("; ");
|
||||
@@ -770,6 +784,14 @@ pub(crate) fn show_result_errors(app: &mut EffectcraftApp, result: &Value) {
|
||||
|
||||
/// Run command `id` with the path picked for it in parameter `key`.
|
||||
fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value, v: Value) -> Result<Value, String> {
|
||||
if matches!(id, "file.import" | "file.importMultiple")
|
||||
&& let Some(picked) = v.as_array()
|
||||
&& picked.iter().filter_map(Value::as_str).any(effectcraft_engine::commands::is_aep_path)
|
||||
{
|
||||
let (aep, rest): (Vec<String>, Vec<String>) =
|
||||
picked.iter().filter_map(Value::as_str).map(str::to_string).partition(|p| effectcraft_engine::commands::is_aep_path(p));
|
||||
return import_picked_with_aep(app, id, key, params, aep, rest);
|
||||
}
|
||||
let mut p = params.as_object().cloned().unwrap_or_default();
|
||||
p.insert(key.to_string(), v);
|
||||
// Picked files import in the background, with the Importing card showing progress (#270).
|
||||
@@ -777,10 +799,13 @@ fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value
|
||||
p.insert("background".into(), Value::Bool(true));
|
||||
}
|
||||
// Photoshop files ask how to import them first; numbered stills whether to import them as an
|
||||
// image sequence.
|
||||
// image sequence. Not while the losses prompt of an After Effects project picked with them
|
||||
// shows: one dialog at a time, so they import with the defaults.
|
||||
let picked = Value::Object(p.clone());
|
||||
if id == "file.import" && crate::panels::dialogs::open_form(app, id, &picked)
|
||||
|| matches!(id, "file.import" | "file.importMultiple") && crate::panels::forms::open_import_sequence(app, &picked)
|
||||
let asking = crate::panels::aep_losses::asking(app);
|
||||
if !asking
|
||||
&& (id == "file.import" && crate::panels::dialogs::open_form(app, id, &picked)
|
||||
|| matches!(id, "file.import" | "file.importMultiple") && crate::panels::forms::open_import_sequence(app, &picked))
|
||||
{
|
||||
return Ok(json!({"dialog": id}));
|
||||
}
|
||||
@@ -804,6 +829,62 @@ fn run_with_picked(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value
|
||||
r
|
||||
}
|
||||
|
||||
/// Import After Effects projects dropped or picked together, as File ▸ Import ▸ Adobe After
|
||||
/// Effects Project… does each: one that would lose parts asks first (the losses prompt), and the
|
||||
/// ones after it wait in its queue until it is answered (`panels::aep_losses`). Returns the errors
|
||||
/// of those that could not be imported; each names its file.
|
||||
pub(crate) fn import_aep_projects(app: &mut EffectcraftApp, paths: Vec<String>) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
// An unanswered prompt's projects go first.
|
||||
let mut paths = crate::panels::aep_losses::take_unanswered(app).into_iter().chain(paths);
|
||||
while let Some(path) = paths.next() {
|
||||
if crate::panels::aep_losses::asking(app) {
|
||||
let queue = &mut app.dialog_state.aep_losses.queue;
|
||||
queue.push(path);
|
||||
queue.extend(paths);
|
||||
break;
|
||||
}
|
||||
if let Err(e) = run_with_picked(app, "file.importAep", "path", &json!({}), json!(path)) {
|
||||
errors.push(e);
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
/// File ▸ Import ▸ File… with After Effects projects picked: they import first, as projects
|
||||
/// ([`import_aep_projects`]), then the other files as footage. Their errors lead the result's
|
||||
/// `errors` and the status line (callers of the blocking dialog drop the result), or the message
|
||||
/// when the footage import failed.
|
||||
fn import_picked_with_aep(app: &mut EffectcraftApp, id: &str, key: &str, params: &Value, aep: Vec<String>, rest: Vec<String>) -> Result<Value, String> {
|
||||
let errors = import_aep_projects(app, aep);
|
||||
let r = if !rest.is_empty() {
|
||||
run_with_picked(app, id, key, params, json!(rest))
|
||||
} else if crate::panels::aep_losses::asking(app) {
|
||||
Ok(json!({"dialog": "aepLosses"}))
|
||||
} else {
|
||||
Ok(json!({}))
|
||||
};
|
||||
if errors.is_empty() {
|
||||
return r;
|
||||
}
|
||||
match r {
|
||||
Ok(mut result) => {
|
||||
if !result.is_object() {
|
||||
result = json!({"result": result});
|
||||
}
|
||||
let own = result.get("errors").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
result["errors"] = Value::Array(errors.into_iter().map(Value::from).chain(own).collect());
|
||||
show_result_errors(app, &result);
|
||||
Ok(result)
|
||||
}
|
||||
Err(e) => {
|
||||
let message = format!("{}; {e}", errors.join("; "));
|
||||
app.ui.status = message.clone();
|
||||
Err(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// View ▸ Zoom In / Zoom Out: the next standard magnification (as a share of screen pixels, so
|
||||
/// 100 % is one comp pixel per screen pixel at any display scaling) above or below `cur`, which is
|
||||
/// in points per comp pixel like the result (#882).
|
||||
|
||||
@@ -183,6 +183,12 @@ fn only_comp(p: &Value) -> Option<effectcraft_engine::project::ItemId> {
|
||||
m.get("comp")?.as_u64().map(effectcraft_engine::project::ItemId)
|
||||
}
|
||||
|
||||
/// Whether [`invoke`]'s result says the command waits on a dialog (a guard or a form deferred it
|
||||
/// until the user answers, `{"dialog": …}`).
|
||||
pub fn waits_on_dialog(result: &Value) -> bool {
|
||||
result.get("dialog").is_some()
|
||||
}
|
||||
|
||||
/// Execute a UI or engine command by id.
|
||||
pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
crate::control::check_script_render(app, id)?;
|
||||
|
||||
@@ -730,3 +730,126 @@ fn effect_plugin_install_dialog_router() {
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
assert!(pending.lock().unwrap().is_empty(), "no dialog for an inline folder");
|
||||
}
|
||||
|
||||
/// File ▸ Import ▸ File… in the non-blocking dialog (macOS, Linux) offers After Effects projects
|
||||
/// next to footage, and a picked one imports as File ▸ Import ▸ Adobe After Effects Project… does: its
|
||||
/// folder, its composition open.
|
||||
#[test]
|
||||
fn import_file_dialog_offers_and_imports_after_effects_projects() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let ctx = egui::Context::default();
|
||||
let dir = std::env::temp_dir().join(format!("ec-import-aep-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let aep = dir.join("Intro.aep").to_string_lossy().to_string();
|
||||
let mut source = effectcraft_engine::Session::default();
|
||||
source.execute("comp.new", json!({"name": "Intro", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap();
|
||||
source.execute("file.exportAep", json!({"path": aep, "allowLosses": true})).unwrap();
|
||||
|
||||
let pending: Arc<Mutex<Vec<crate::FileDialog>>> = Arc::default();
|
||||
let queue = pending.clone();
|
||||
app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog)));
|
||||
invoke(&mut app, &ctx, "file.import", json!({})).unwrap();
|
||||
let Some(crate::FileDialog::Open { exts, many }) = pending.lock().unwrap().pop() else { panic!("an Open dialog was shown") };
|
||||
assert!(many && exts.iter().any(|e| e == "aep") && exts.iter().any(|e| e == "aepx"), "{exts:?}");
|
||||
assert!(exts.iter().any(|e| e == "png"), "footage too");
|
||||
|
||||
super::frontend::dialog_answered(&mut app, crate::DialogAnswer { command: "file.import".into(), key: "paths", params: json!({}), paths: vec![aep] });
|
||||
let comp = app.session.project.item(app.session.active_comp_id().expect("a composition is open")).unwrap();
|
||||
assert_eq!(comp.name, "Intro");
|
||||
assert_eq!(app.session.project.item(comp.parent.expect("in a folder")).unwrap().name, "Intro.aep");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Footage with unlabeled alpha asks how to interpret it (Interpret Unlabeled Alpha As: Ask
|
||||
/// User), but not over a dialog that already waits (the After Effects losses prompt of the same
|
||||
/// drop, which a background import can finish behind): that dialog stays, and a note says where
|
||||
/// to choose the interpretation.
|
||||
#[test]
|
||||
fn the_unlabeled_alpha_question_does_not_replace_an_open_dialog() {
|
||||
use effectcraft_engine::project::{Footage, FootageKind, ItemKind};
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let ctx = egui::Context::default();
|
||||
let footage = Footage { path: "/m/matte.tga".into(), kind: FootageKind::Still, width: 4, height: 4, has_video: true, ..Default::default() };
|
||||
let item = std::sync::Arc::make_mut(&mut app.session.project).add_item("matte.tga", Default::default(), None, ItemKind::Footage(footage)).0;
|
||||
app.dialog = Some(crate::Dialog::AepLosses);
|
||||
super::frontend(&mut app, &ctx, "file.interpretFootage", json!({"item": item, "reason": "unlabeledAlpha"})).unwrap();
|
||||
assert_eq!(app.dialog, Some(crate::Dialog::AepLosses), "the waiting dialog stays");
|
||||
assert!(app.ui.status.contains("Interpret Footage"), "{}", app.ui.status);
|
||||
assert!(app.session.state.project_selection.is_empty(), "the selection is left alone");
|
||||
|
||||
app.dialog = None;
|
||||
super::frontend(&mut app, &ctx, "file.interpretFootage", json!({"item": item, "reason": "unlabeledAlpha"})).unwrap();
|
||||
assert_eq!(app.dialog, Some(crate::Dialog::Form), "with no dialog open it asks");
|
||||
}
|
||||
|
||||
/// File ▸ Import ▸ File… and Multiple Files… offer projects next to footage; File ▸ Replace
|
||||
/// Footage offers footage only.
|
||||
#[test]
|
||||
fn only_the_import_dialogs_offer_projects() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let pending: Arc<Mutex<Vec<crate::FileDialog>>> = Arc::default();
|
||||
let queue = pending.clone();
|
||||
app.hooks.file_dialog_async = Some(Box::new(move |dialog, _done| queue.lock().unwrap().push(dialog)));
|
||||
let projects = ["aep", "aepx", "ecproj", "ecprojx"];
|
||||
for (id, offered) in [("file.import", true), ("file.importMultiple", true), ("file.replaceFootage", false)] {
|
||||
assert!(super::frontend::file_dialog(&mut app, id, &json!({})).is_some(), "{id} asks for a file");
|
||||
let Some(crate::FileDialog::Open { exts, .. }) = pending.lock().unwrap().pop() else { panic!("{id}: an Open dialog") };
|
||||
assert!(exts.iter().any(|e| e == "png"), "{id} offers footage");
|
||||
assert_eq!(projects.iter().all(|p| exts.iter().any(|e| e == p)), offered, "{id}: {exts:?}");
|
||||
assert!(offered || !projects.iter().any(|p| exts.iter().any(|e| e == p)), "{id} offers no project: {exts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Picked After Effects projects that cannot be imported keep their errors next to the other
|
||||
/// files': a broken `.aep` and a broken `.ecproj` are both named in the status line.
|
||||
#[test]
|
||||
fn picked_projects_report_every_error() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let dir = std::env::temp_dir().join(format!("ec-picked-errors-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let aep = dir.join("broken.aep");
|
||||
let ecproj = dir.join("broken.ecproj");
|
||||
std::fs::write(&aep, b"not an aep").unwrap();
|
||||
std::fs::write(&ecproj, b"not a project").unwrap();
|
||||
let paths = vec![aep.to_string_lossy().to_string(), ecproj.to_string_lossy().to_string()];
|
||||
super::frontend::dialog_answered(&mut app, crate::DialogAnswer { command: "file.import".into(), key: "paths", params: json!({}), paths: paths.clone() });
|
||||
let status = &app.ui.status;
|
||||
assert!(status.contains("broken.aep") && status.contains("broken.ecproj"), "{status}");
|
||||
assert!(!status.contains('"'), "messages, not quoted JSON: {status}");
|
||||
assert!(app.dialog.is_none() && app.session.project.items.is_empty());
|
||||
|
||||
// The blocking dialog (Windows), whose callers drop the result: the status line still has both.
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let ctx = egui::Context::default();
|
||||
app.hooks.pick_files = Some(Box::new(move |_: &[&str]| paths.clone()));
|
||||
let _ = invoke(&mut app, &ctx, "file.import", json!({}));
|
||||
let status = &app.ui.status;
|
||||
assert!(status.contains("broken.aep") && status.contains("broken.ecproj"), "{status}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A picked After Effects project with parts that cannot come over asks first; one picked with
|
||||
/// it waits for the answer.
|
||||
#[test]
|
||||
fn a_picked_project_with_losses_asks_and_the_next_waits() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let dir = std::env::temp_dir().join(format!("ec-picked-losses-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let lossy = dir.join("Synthetic.aep");
|
||||
std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../aep/tests/fixtures/synthetic.aep"), &lossy).unwrap();
|
||||
let intro = dir.join("Intro.aep").to_string_lossy().to_string();
|
||||
let mut source = effectcraft_engine::Session::default();
|
||||
source.execute("comp.new", json!({"name": "Intro", "width": 64, "height": 36, "frameRate": 30, "duration": 1})).unwrap();
|
||||
source.execute("file.exportAep", json!({"path": intro, "allowLosses": true})).unwrap();
|
||||
let paths = vec![lossy.to_string_lossy().to_string(), intro.clone()];
|
||||
super::frontend::dialog_answered(&mut app, crate::DialogAnswer { command: "file.import".into(), key: "paths", params: json!({}), paths });
|
||||
assert_eq!(app.dialog, Some(crate::Dialog::AepLosses));
|
||||
assert_eq!(app.dialog_state.aep_losses.queue, [intro]);
|
||||
assert!(app.session.project.items.is_empty(), "nothing imported before the answer");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! File ▸ Import / Export ▸ After Effects Project… from the menu: what the conversion cannot
|
||||
//! File ▸ Import / Export ▸ Adobe After Effects Project… from the menu: what the conversion cannot
|
||||
//! carry over is shown first (the command's dry run), and the user chooses to cancel or to go on
|
||||
//! with those losses (`allowLosses`). Cancel changes nothing; a conversion without losses runs at
|
||||
//! once. Agents running `file.importAep` / `file.exportAep` with `engine.execute` are never asked:
|
||||
//! they pass `allowLosses` or get the refusal.
|
||||
//! they pass `allowLosses` or get the refusal. After Effects projects dropped or picked together
|
||||
//! (File ▸ Import ▸ File…) are asked about one at a time: the others wait in the queue until the
|
||||
//! prompt is answered, and Cancel skips only the one it asks about.
|
||||
//!
|
||||
//! Automation ids: `dialog.aepLosses.continue`, `dialog.aepLosses.cancel`.
|
||||
|
||||
@@ -17,11 +19,32 @@ use crate::{Dialog, EffectcraftApp};
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Pending {
|
||||
pub command: Option<(String, Value, Value)>,
|
||||
/// After Effects projects dropped or picked with it, imported once it is answered.
|
||||
pub queue: Vec<String>,
|
||||
}
|
||||
|
||||
/// How many detail lines of a loss category the prompt shows.
|
||||
const DETAILS: usize = 3;
|
||||
|
||||
/// Whether the prompt is showing with a conversion waiting on it.
|
||||
pub fn asking(app: &EffectcraftApp) -> bool {
|
||||
app.dialog == Some(Dialog::AepLosses) && app.dialog_state.aep_losses.command.is_some()
|
||||
}
|
||||
|
||||
/// The projects of a prompt another dialog replaced (Save changes? on quitting) before it was
|
||||
/// answered: its import's file, then its queue. Taken, so they are tried again instead of waiting
|
||||
/// for an answer that never comes (a replaced export is dropped: it wrote nothing).
|
||||
pub fn take_unanswered(app: &mut EffectcraftApp) -> Vec<String> {
|
||||
if app.dialog == Some(Dialog::AepLosses) {
|
||||
return Vec::new();
|
||||
}
|
||||
let losses = &mut app.dialog_state.aep_losses;
|
||||
let Some((id, params, _)) = losses.command.take() else { return std::mem::take(&mut losses.queue) };
|
||||
let mut paths: Vec<String> = params.get("path").and_then(Value::as_str).filter(|_| id == "file.importAep").map(str::to_string).into_iter().collect();
|
||||
paths.append(&mut losses.queue);
|
||||
paths
|
||||
}
|
||||
|
||||
/// Whether command `id` with `params` needs the prompt first. When it does, the prompt opens
|
||||
/// holding the command and the caller must not run it. `Err` when the dry run fails (the file
|
||||
/// cannot be read or is not a project), with the message.
|
||||
@@ -39,6 +62,9 @@ pub fn guard(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool,
|
||||
if report["losses"].as_array().is_none_or(|l| l.is_empty()) {
|
||||
return Ok(false);
|
||||
}
|
||||
// An unanswered prompt's projects wait behind this one.
|
||||
let unanswered = take_unanswered(app);
|
||||
app.dialog_state.aep_losses.queue.extend(unanswered);
|
||||
app.dialog_state.aep_losses.command = Some((id.to_string(), params.clone(), report));
|
||||
app.dialog = Some(Dialog::AepLosses);
|
||||
Ok(true)
|
||||
@@ -64,13 +90,15 @@ pub fn lines(report: &Value) -> Vec<(String, Vec<String>)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Draw the prompt for the conversion waiting on it: its losses by category, Cancel and Import /
|
||||
/// Export Anyway (Escape cancels).
|
||||
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let Some((id, _, report)) = app.dialog_state.aep_losses.command.clone() else {
|
||||
app.dialog = None;
|
||||
return;
|
||||
};
|
||||
let import = id == "file.importAep";
|
||||
let title = if import { "Import After Effects Project" } else { "Export After Effects Project" };
|
||||
let title = if import { "Import Adobe After Effects Project" } else { "Export Adobe After Effects Project" };
|
||||
let mut answer: Option<bool> = None;
|
||||
super::dialogs::modal(ctx, title, vec2(520.0, 340.0), t, |ui| {
|
||||
let text = if import {
|
||||
@@ -114,11 +142,25 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
/// The prompt's answer: Cancel (`false`) drops the conversion, Import / Export Anyway runs it
|
||||
/// with `allowLosses`.
|
||||
fn finish(app: &mut EffectcraftApp, ctx: &egui::Context, go: bool) {
|
||||
app.dialog = None;
|
||||
let Some((id, params, _)) = app.dialog_state.aep_losses.command.take() else { return };
|
||||
let Some((id, params, _)) = app.dialog_state.aep_losses.command.take() else {
|
||||
app.dialog = None;
|
||||
return;
|
||||
};
|
||||
let mut errors = Vec::new();
|
||||
if go {
|
||||
// An error is already in the status bar.
|
||||
let _ = crate::menus::run_engine(app, ctx, &id, with_losses(¶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 +184,53 @@ mod tests {
|
||||
assert!(lines(&json!({})).is_empty());
|
||||
}
|
||||
|
||||
/// Projects dropped or picked together wait only behind a prompt that shows; with none
|
||||
/// showing they are tried at once (a missing file is an error naming it).
|
||||
#[test]
|
||||
fn projects_wait_only_behind_a_showing_prompt() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.dialog_state.aep_losses.command = Some(("file.importAep".into(), json!({"path": "/a/One.aep"}), json!({})));
|
||||
app.dialog = Some(Dialog::AepLosses);
|
||||
assert!(crate::menus::import_aep_projects(&mut app, vec!["/no/such/Two.aep".into(), "/no/such/Three.aep".into()]).is_empty());
|
||||
assert_eq!(app.dialog_state.aep_losses.queue, ["/no/such/Two.aep", "/no/such/Three.aep"]);
|
||||
|
||||
// Cancel: the queued ones are tried next (both missing here, so both are reported).
|
||||
let ctx = egui::Context::default();
|
||||
finish(&mut app, &ctx, false);
|
||||
assert!(app.dialog_state.aep_losses.queue.is_empty());
|
||||
assert!(app.ui.status.contains("Two.aep") && app.ui.status.contains("Three.aep"), "{}", app.ui.status);
|
||||
}
|
||||
|
||||
/// A prompt another dialog replaced (Save changes? on quitting) holds nothing back: the next
|
||||
/// import tries its project and queue first, then the new one.
|
||||
#[test]
|
||||
fn a_replaced_prompt_holds_nothing_back() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.dialog_state.aep_losses.command = Some(("file.importAep".into(), json!({"path": "/no/such/One.aep"}), json!({})));
|
||||
app.dialog_state.aep_losses.queue = vec!["/no/such/Two.aep".into()];
|
||||
app.dialog = Some(Dialog::UnsavedChanges);
|
||||
let errors = crate::menus::import_aep_projects(&mut app, vec!["/no/such/Three.aep".into()]);
|
||||
let named = |n: &str| errors.iter().position(|e| e.contains(n));
|
||||
assert_eq!((named("One.aep"), named("Two.aep"), named("Three.aep")), (Some(0), Some(1), Some(2)), "{errors:?}");
|
||||
assert!(app.dialog_state.aep_losses.command.is_none() && app.dialog_state.aep_losses.queue.is_empty());
|
||||
assert_eq!(app.dialog, Some(Dialog::UnsavedChanges), "the other dialog stays");
|
||||
}
|
||||
|
||||
/// A new prompt keeps an unanswered one's projects: they wait behind it.
|
||||
#[test]
|
||||
fn a_new_prompt_keeps_an_unanswered_ones_projects() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.dialog_state.aep_losses.command = Some(("file.importAep".into(), json!({"path": "/a/One.aep"}), json!({})));
|
||||
app.dialog_state.aep_losses.queue = vec!["/a/Two.aep".into()];
|
||||
app.dialog = Some(Dialog::UnsavedChanges);
|
||||
// Rec. 2020 has no After Effects counterpart: exporting asks.
|
||||
std::sync::Arc::make_mut(&mut app.session.project).settings.working_space = Some(effectcraft_engine::color::ColorSpace::Rec2020);
|
||||
let dir = TempDir::new();
|
||||
assert_eq!(guard(&mut app, "file.exportAep", &json!({"path": dir.0.join("out.aep").to_string_lossy()})), Ok(true));
|
||||
assert_eq!(app.dialog, Some(Dialog::AepLosses));
|
||||
assert_eq!(app.dialog_state.aep_losses.queue, ["/a/One.aep", "/a/Two.aep"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agents_and_consented_calls_are_not_asked() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
|
||||
@@ -475,13 +475,15 @@ fn home_page(
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the commands the Home screen's buttons and recent files asked for, and leave Home once one
|
||||
/// opened something (not while a dialog it opened waits, nor when only the list changed).
|
||||
fn run_actions(app: &mut EffectcraftApp, ctx: &egui::Context, actions: Vec<(&str, serde_json::Value)>) {
|
||||
for (id, params) in actions {
|
||||
let before = (app.session.path.clone(), app.session.revision);
|
||||
match crate::menus::invoke(app, ctx, id, params) {
|
||||
Err(e) => app.ui.status = e,
|
||||
// Asking to save the open project first: Home stays until it is answered.
|
||||
Ok(r) if r.get("dialog").is_some() => {}
|
||||
Ok(r) if crate::menus::waits_on_dialog(&r) => {}
|
||||
// Leave Home unless only the list changed or a file dialog was cancelled.
|
||||
Ok(_) => {
|
||||
let cancelled = matches!(id, "file.open" | "file.import") && (app.session.path.clone(), app.session.revision) == before;
|
||||
|
||||
@@ -75,12 +75,45 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
|
||||
if proj.len() > 1 {
|
||||
errors.push(crate::i18n::tr_args("Only one project can be opened per drop; skipped {} more", &[&proj.len().saturating_sub(1)]));
|
||||
}
|
||||
// While the After Effects losses prompt waits, opening a project would replace it (and the
|
||||
// project the answer imports into): the project waits for the answer, dropped again after it.
|
||||
let proj = if crate::panels::aep_losses::asking(app) && !proj.is_empty() {
|
||||
errors.push(crate::i18n::tr_args("Skipped {} more file(s) while a dialog is open; import them again after it", &[&1]));
|
||||
Vec::new()
|
||||
} else {
|
||||
proj
|
||||
};
|
||||
|
||||
if let Some(p) = proj.first()
|
||||
&& let Err(e) = crate::menus::invoke(app, &ctx, "file.open", json!({"path": p}))
|
||||
{
|
||||
errors.push(crate::i18n::tr_args("Cannot open dropped project {}: {}", &[&p, &e]));
|
||||
// After Effects projects import as projects (File ▸ Import ▸ Adobe After Effects Project…),
|
||||
// each into a folder named after its file, opening its first composition.
|
||||
let (aep, mut rest): (Vec<String>, Vec<String>) = rest.into_iter().partition(|p| effectcraft_engine::commands::is_aep_path(p));
|
||||
// A project in the drop changes the active composition: the other files go to the Project
|
||||
// panel only, not into a composition.
|
||||
let has_project = !proj.is_empty() || !aep.is_empty();
|
||||
|
||||
// Opened first, so the other files land in the project it opens. While it waits on Save
|
||||
// changes? the rest of the drop is skipped with a note: it would land in the project about
|
||||
// to be replaced.
|
||||
let mut waiting = false;
|
||||
if let Some(p) = proj.first() {
|
||||
match crate::menus::invoke(app, &ctx, "file.open", json!({"path": p})) {
|
||||
Ok(r) => waiting = crate::menus::waits_on_dialog(&r),
|
||||
Err(e) => errors.push(crate::i18n::tr_args("Cannot open dropped project {}: {}", &[&p, &e])),
|
||||
}
|
||||
}
|
||||
let mut aep = aep;
|
||||
if waiting {
|
||||
let skipped = rest.len() + aep.len();
|
||||
if skipped > 0 {
|
||||
errors.push(crate::i18n::tr_args("Skipped {} more file(s) while a dialog is open; import them again after it", &[&skipped]));
|
||||
}
|
||||
rest.clear();
|
||||
aep.clear();
|
||||
}
|
||||
|
||||
// One with losses asks first; the projects after it wait for the answer, the other files
|
||||
// import meanwhile. The errors name their files.
|
||||
errors.extend(crate::menus::import_aep_projects(app, aep));
|
||||
|
||||
if !rest.is_empty() {
|
||||
let viewer = app
|
||||
@@ -101,7 +134,7 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
|
||||
.is_some_and(|e| Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])).contains(p))
|
||||
});
|
||||
|
||||
let at = if proj.is_empty() && !over_timeline {
|
||||
let at = if !has_project && !over_timeline {
|
||||
ctx.input(|i| i.pointer.hover_pos()).filter(|p| viewer.is_some_and(|v| v.contains(*p))).and_then(|p| crate::panels::viewer::screen_to_comp(&ctx, p))
|
||||
} else {
|
||||
None
|
||||
@@ -116,11 +149,13 @@ pub fn dropped_files(app: &mut EffectcraftApp, ui: &egui::Ui, bounds: Rect) {
|
||||
let mut params = json!({
|
||||
"paths": rest,
|
||||
"drag": true,
|
||||
"addToComp": at.is_some() || over_timeline,
|
||||
"addToComp": !has_project && (at.is_some() || over_timeline),
|
||||
"position": at,
|
||||
"background": true
|
||||
});
|
||||
if !layered {
|
||||
// While the losses prompt of a project dropped with it shows, a Photoshop file imports
|
||||
// with Default Drag Import As: one dialog at a time.
|
||||
if !layered || crate::panels::aep_losses::asking(app) {
|
||||
params["importAs"] = json!(app.session.prefs.drag_import_as());
|
||||
}
|
||||
if alt {
|
||||
|
||||
@@ -722,3 +722,165 @@ fn illustrator_import_offers_and_applies_retain_layer_sizes() {
|
||||
assert_eq!((f.width, f.height), (30, 20));
|
||||
assert!(f.layer.as_ref().unwrap().layer_size);
|
||||
}
|
||||
|
||||
/// An After Effects project, written by File ▸ Export ▸ Adobe After Effects Project… to a folder of its
|
||||
/// own: composition "Intro" with a solid.
|
||||
fn aep_file(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ec-ui-aep-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Intro.aep");
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Intro", "width": 320, "height": 180, "frameRate": 30, "duration": 2})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Backdrop", "color": "#204060"})).unwrap();
|
||||
s.execute("file.exportAep", json!({"path": path.to_string_lossy(), "allowLosses": true})).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// The open composition is "Intro", in the folder named after the imported file.
|
||||
fn intro_is_open_in_its_folder(h: &Harness<'_, EffectcraftApp>) {
|
||||
let s = &h.state().session;
|
||||
let comp = s.project.item(s.active_comp_id().expect("a composition is open")).unwrap();
|
||||
assert_eq!(comp.name, "Intro");
|
||||
let folder = s.project.item(comp.parent.expect("in a folder")).unwrap();
|
||||
assert_eq!(folder.name, "Intro.aep");
|
||||
assert!(matches!(folder.kind, effectcraft_engine::project::ItemKind::Folder));
|
||||
}
|
||||
|
||||
/// Dropped After Effects projects import as File ▸ Import ▸ Adobe After Effects Project… does
|
||||
/// (they were refused as unsupported footage): each in a folder named after its file, the last
|
||||
/// one's composition open. With nothing lost, nothing is asked.
|
||||
#[test]
|
||||
fn dropped_after_effects_projects_import_and_open_their_composition() {
|
||||
let (mut h, _) = harness();
|
||||
let aep = aep_file("drop");
|
||||
h.event(Event::PointerMoved(rect(&h, "viewer.area").center()));
|
||||
h.step();
|
||||
let outro = aep.with_file_name("Outro.aep");
|
||||
std::fs::copy(&aep, &outro).unwrap();
|
||||
h.input_mut().dropped_files.push(Arc::new(Dropped(outro)));
|
||||
h.input_mut().dropped_files.push(Arc::new(Dropped(aep)));
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, None, "nothing is lost, so nothing to ask");
|
||||
assert!(h.state().session.project.items.values().any(|i| i.name == "Outro.aep"), "the first project too");
|
||||
intro_is_open_in_its_folder(&h);
|
||||
assert_eq!(h.state().ui.status, "", "no errors");
|
||||
}
|
||||
|
||||
/// File ▸ Import ▸ File… offers After Effects projects; a picked one imports as a project, and
|
||||
/// the other files picked with it as footage.
|
||||
#[test]
|
||||
fn import_file_dialog_imports_a_picked_after_effects_project_with_the_other_files() {
|
||||
let (mut h, _) = harness();
|
||||
let aep = aep_file("pick");
|
||||
let still = aep.with_file_name("still.png");
|
||||
std::fs::write(&still, b"").unwrap();
|
||||
let offered = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
|
||||
let seen = offered.clone();
|
||||
let picked = vec![aep.to_string_lossy().to_string(), still.to_string_lossy().to_string()];
|
||||
h.state_mut().session.importer = Some(Arc::new(Stills));
|
||||
h.state_mut().hooks.pick_files = Some(Box::new(move |exts: &[&str]| {
|
||||
*seen.lock().unwrap() = exts.iter().map(|e| e.to_string()).collect();
|
||||
picked.clone()
|
||||
}));
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({})).unwrap();
|
||||
assert!(["aep", "aepx"].iter().all(|e| offered.lock().unwrap().iter().any(|o| o == e)), "{:?}", offered.lock().unwrap());
|
||||
step_until(&mut h, |h| footage_items(h).iter().any(|(n, _)| n == "still.png"));
|
||||
intro_is_open_in_its_folder(&h);
|
||||
}
|
||||
|
||||
/// Footage dropped with an After Effects project goes to the Project panel only, also over the
|
||||
/// Timeline: the project opens its own composition, which the footage does not join.
|
||||
#[test]
|
||||
fn footage_dropped_with_an_after_effects_project_stays_in_the_project_panel() {
|
||||
let (mut h, _) = harness();
|
||||
let aep = aep_file("with-footage");
|
||||
let still = aep.with_file_name("still.png");
|
||||
std::fs::write(&still, b"").unwrap();
|
||||
h.state_mut().session.importer = Some(Arc::new(Stills));
|
||||
h.event(Event::PointerMoved(rect(&h, "panel.Timeline").center()));
|
||||
h.step();
|
||||
h.input_mut().dropped_files.push(Arc::new(Dropped(still)));
|
||||
h.input_mut().dropped_files.push(Arc::new(Dropped(aep)));
|
||||
h.run_steps(3);
|
||||
step_until(&mut h, |h| footage_items(h).iter().any(|(n, _)| n == "still.png"));
|
||||
h.run_steps(2);
|
||||
intro_is_open_in_its_folder(&h);
|
||||
let s = &h.state().session;
|
||||
assert!(s.project.comps().all(|(_, c)| c.layers.iter().all(|l| l.name != "still.png")), "no composition got a still.png layer");
|
||||
}
|
||||
|
||||
/// A dropped After Effects project with parts that cannot come over asks first (the losses
|
||||
/// prompt). The projects dropped with it wait for the answer; the other files import meanwhile,
|
||||
/// a Photoshop file with Default Drag Import As (one dialog at a time). Cancel skips only the
|
||||
/// project it asks about; Import Anyway imports it.
|
||||
#[test]
|
||||
fn a_dropped_after_effects_project_with_losses_asks_first() {
|
||||
let (mut h, _) = harness();
|
||||
let intro = aep_file("losses");
|
||||
let lossy = intro.with_file_name("Synthetic.aep");
|
||||
std::fs::copy(concat!(env!("CARGO_MANIFEST_DIR"), "/../aep/tests/fixtures/synthetic.aep"), &lossy).unwrap();
|
||||
h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) }));
|
||||
h.state_mut().session.prefs.import.drag_import_as = "footage".into();
|
||||
for path in [lossy.clone(), intro.clone(), intro.with_file_name("Layers.PSD"), intro.with_file_name("still.png")] {
|
||||
h.input_mut().dropped_files.push(Arc::new(Dropped(path)));
|
||||
}
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::AepLosses), "the drop asks first");
|
||||
assert_eq!(h.state_mut().dialog_state_mut().aep_losses.queue, [intro.to_string_lossy().to_string()], "the other project waits");
|
||||
let named = |h: &Harness<'_, EffectcraftApp>, name: &str| h.state().session.project.items.values().any(|i| i.name == name);
|
||||
assert!(!named(&h, "Synthetic.aep") && !named(&h, "Intro.aep"), "no project imported before the answer");
|
||||
step_until(&mut h, |h| ["Layers.PSD", "still.png"].iter().all(|n| footage_items(h).iter().any(|(f, _)| f == n)));
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::AepLosses), "the footage did not replace the prompt");
|
||||
|
||||
click_id(&mut h, "dialog.aepLosses.cancel");
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert!(!named(&h, "Synthetic.aep"), "Cancel skips the project it asked about");
|
||||
intro_is_open_in_its_folder(&h);
|
||||
|
||||
h.input_mut().dropped_files.push(Arc::new(Dropped(lossy)));
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::AepLosses));
|
||||
click_id(&mut h, "dialog.aepLosses.continue");
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert!(named(&h, "Synthetic.aep"), "Import Anyway imports it");
|
||||
}
|
||||
|
||||
/// An After Effects project with nothing lost imports at once, so a Photoshop file dropped with
|
||||
/// it still asks how to import it (Import Photoshop File).
|
||||
#[test]
|
||||
fn a_photoshop_file_dropped_with_an_after_effects_project_still_asks() {
|
||||
let (mut h, _) = harness();
|
||||
h.state_mut().session.importer = Some(Arc::new(Gated { started: AtomicUsize::new(0), allow: Arc::new(AtomicUsize::new(usize::MAX)) }));
|
||||
let aep = aep_file("with-psd");
|
||||
h.input_mut().dropped_files.push(Arc::new(Dropped(aep.with_file_name("Layers.PSD"))));
|
||||
h.input_mut().dropped_files.push(Arc::new(Dropped(aep)));
|
||||
h.run_steps(2);
|
||||
intro_is_open_in_its_folder(&h);
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form), "Import Photoshop File asks");
|
||||
}
|
||||
|
||||
/// A dropped file that is not an After Effects project reports why, changes nothing, and the
|
||||
/// other files of the drop still import.
|
||||
#[test]
|
||||
fn a_corrupt_dropped_after_effects_project_reports_and_keeps_the_other_files() {
|
||||
let (mut h, _) = harness();
|
||||
let dir = aep_file("corrupt").parent().unwrap().to_path_buf();
|
||||
let broken = dir.join("Broken.aep");
|
||||
std::fs::write(&broken, b"not an After Effects project").unwrap();
|
||||
let still = dir.join("still.png");
|
||||
std::fs::write(&still, b"").unwrap();
|
||||
h.state_mut().session.importer = Some(Arc::new(Stills));
|
||||
let comp = h.state().session.active_comp_id();
|
||||
h.input_mut().dropped_files.push(Arc::new(Dropped(broken)));
|
||||
h.input_mut().dropped_files.push(Arc::new(Dropped(still)));
|
||||
h.run_steps(3);
|
||||
step_until(&mut h, |h| footage_items(h).iter().any(|(n, _)| n == "still.png"));
|
||||
let status = h.state().ui.status.clone();
|
||||
assert!(status.contains("Broken.aep"), "the error names the file: {status}");
|
||||
assert!(!h.state().session.project.items.values().any(|i| i.name == "Broken.aep"));
|
||||
assert_eq!(h.state().session.active_comp_id(), comp, "the open composition stays");
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-11-05 · **Change:** a 3D layer's changed Material Options are reported, shape and text animator colours outside 0 to 1 are clamped and reported, and a popup expression is no longer reported where the option order is the same (2026-11-05); before that: composition settings the base writes at their defaults (Preserve Frame Rate / Resolution, motion blur samples, adaptive sample limit) and render queue items are reported, and a sequence's folder time is read only when its file passes the document-path policy (2026-11-04); before that: every chunk is built in code, no template is read (2026-10-29); before that: Curves and Mesh Warp are written (R53); before that: a switched-off shape item or layer style is written switched off (R52, 986-988); before that: a reversed layer (negative stretch) is written as After Effects stores it (R36, 981-983); before that: an explicit None (`NO_LAYER`) on Compound Blur, Displacement Map or Set Matte is written as None, and Layer Control on a named layer no longer reports a source choice; before that: layer styles are written (R51, 950-980; read back in After Effects: `export-tests\styles`); before that: an image sequence's folder modification time is written when the caller knows it (`sspc@118`, R50: without it After Effects rescans the folder on open and warns about every gap of a sparse sequence in a modal dialog); before that: an output space equal to the working space is not reported (After Effects renders in the working space, 902; the importer sets it so for Rec.709 Gamma 2.4); before that: one effect table for both directions again: the 27 effects of the importer round 3 and the Layer Control are written too (prototypes from 903 and 076, layer parameters by layer id; not read back in After Effects yet); before that: the importer round 3 is merged: Offset Paths' Copy Offset is written as EffectCraft's value + 1 (826-828, the importer's inverse); before that: sparse sequences checked (860, written as After Effects stores them); before that: the Rec.709 Gamma 2.4 working space is written with a profile authored here (R49, g5); before that: text animators with range selectors are written (R48, t8); before that: a footage layer running past its source is ended there on import, as After Effects does, and less than a frame past is not a loss (LayerTiming); before that: Anchor Point and mask path ease are written (R47); before that: shape operators (polystar, gradients, trim paths, round corners, merge / offset paths, repeater) and keys and expressions on shape properties are written (R46, o2); before that: every effect of the importer's table is written, through one shared table and records built per parameter type (R45, e2); before that: time remapping is written (319, 354, r1, R44); before that: the character and paragraph settings are read back in After Effects (t6) and right-to-left justification is written relative to the direction (t7, R43); before that: the character and paragraph settings the importer decodes are written (scales, baseline shift, kerning, ligatures, tsume, fill / stroke switches and width, justified variants, indents, spacing, composer, direction); before that: track mattes, markers, cameras, lights and shape layers (R39-R42), keys on stretched layers, conformed clips, tga / psd / ai / eps, project settings (620-634, R37), superscript / subscript (526-528); before: precomposition layers (124, 257, 258, 600-602), the effects Tint, Gradient Ramp and Exposure (356-397, 610), footage (351, 353, 603-607, 611-613), text layers (110, 245, t1-t4), the 3D switch (015) and time stretch (018, 316, 317) are exported · **Target:** Adobe After Effects 2026
|
||||
|
||||
File ▸ Export ▸ After Effects Project… (`file.exportAep`) writes the open project as an `.aep` that
|
||||
File ▸ Export ▸ Adobe After Effects Project… (`file.exportAep`) writes the open project as an `.aep` that
|
||||
After Effects 2026 opens. It is deliberately narrow: what it writes is exact, tested and checked
|
||||
against After Effects itself; everything else is **reported** and never dropped silently. The
|
||||
importer ([aep-import.md](aep-import.md)) is the mirror image; both rest on the clean-room format
|
||||
|
||||
+10
-3
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user