From 2abd727338dea0a7b48131794689253ba01a8173 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 16:41:56 +0100 Subject: [PATCH] AEP import: Puppet (ADBE FreePin3) arrives as EffectCraft's Puppet, its meshes and pins are reported After Effects' Puppet was dropped with a one-line "has the effect" loss. It now arrives as EffectCraft's Puppet (ec.distort.puppet) in its place in the stack, with its name and fx switch, and the report lists per mesh Triangles, Expansion and per pin group (Deform, Overlap, Stiffness) the pins, keyed pins and atom match names; entries outside the known tree are named once, and long lists stop at ten with "and N more". No pins are created: a pin's rest place is a vertex of the hidden mesh data and the unit of its Position is unknown, and no corpus file holds a Puppet. The tree (docs/aep-format.md 3.7) comes from an external scripting listing, not the corpus; the sample that settles it is described there. The fx_inject test helpers can now inject any effect instance, which the new tests use. Refs #3093 --- crates/aep/src/import.rs | 1 + crates/aep/src/import/effects.rs | 7 +- crates/aep/src/import/puppet.rs | 234 ++++++++++++++++++++++++ crates/aep/tests/it/import.rs | 1 + crates/aep/tests/it/import/fx_inject.rs | 40 ++-- crates/aep/tests/it/import/puppet.rs | 171 +++++++++++++++++ docs/aep-format.md | 49 ++++- docs/aep-import.md | 4 +- 8 files changed, 489 insertions(+), 18 deletions(-) create mode 100644 crates/aep/src/import/puppet.rs create mode 100644 crates/aep/tests/it/import/puppet.rs diff --git a/crates/aep/src/import.rs b/crates/aep/src/import.rs index 956e3b1c5..938d0c46e 100644 --- a/crates/aep/src/import.rs +++ b/crates/aep/src/import.rs @@ -30,6 +30,7 @@ mod effects; mod markers; mod masks; mod props; +mod puppet; mod report; mod settings; mod shapes; diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 8409a6f7e..f205245eb 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -76,6 +76,11 @@ pub(crate) fn import_effects<'f, 'a>( col: &mut Collector, ) { for fx in layer.effects() { + // Puppet keeps its meshes and pins in nested groups, not in `parT` (its own step). + if fx.match_name() == crate::import::puppet::MATCH_NAME { + crate::import::puppet::import_puppet(defs, &fx, cx, ids, target, col); + continue; + } let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string()); let Some(map) = EFFECTS.iter().find(|m| m.match_name == fx.match_name()) else { col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); @@ -112,7 +117,7 @@ pub(crate) fn import_effects<'f, 'a>( } /// Parameters that never carry a value to import. -fn carries_nothing(p: &EffectParam<'_, '_>) -> bool { +pub(crate) fn carries_nothing(p: &EffectParam<'_, '_>) -> bool { matches!(p.kind, ParamKind::Button | ParamKind::NoData | ParamKind::GroupStart | ParamKind::GroupEnd) || (p.index == 0 && p.kind == ParamKind::Layer) } diff --git a/crates/aep/src/import/puppet.rs b/crates/aep/src/import/puppet.rs new file mode 100644 index 000000000..ce10ae7f5 --- /dev/null +++ b/crates/aep/src/import/puppet.rs @@ -0,0 +1,234 @@ +//! After Effects' Puppet effect (`ADBE FreePin3`) arrives as EffectCraft's Puppet +//! (`ec.distort.puppet`) in its place in the stack, with its name and fx switch, but **without +//! its meshes and pins**: how After Effects stores a pin's rest place (a vertex index and offset +//! into the hidden mesh data) and its Position (pixels or a fraction of the layer) is not decoded +//! yet, and a guessed rest place would deform the layer wrongly without a word. What the instance +//! holds is reported instead, mesh by mesh: Triangles, Expansion, and per pin container (Deform, +//! Stiffness, Overlap) the number of pins, how many are keyed or carry an expression, and the +//! match names of their atoms (Advanced and Bend pins show up there). Any entry this reader does +//! not know is named verbatim. +//! +//! The tree read here (docs/aep-format.md 3.7, Puppet) comes from an external scripting listing +//! of the effect's property tree, not from the corpus (no corpus file has a Puppet): **?** until a +//! byte sample exists. It is read with the same verified group reader as masks, shapes and layer +//! styles; a file whose tree differs is reported, never guessed at. + +use effectcraft_project::build::Ids; +use effectcraft_project::{Node, PropGroup}; + +use crate::import::effects::{LayerCtx, carries_nothing}; +use crate::import::report::{Collector, LossCategory}; +use crate::{Effect as AepEffect, EffectDefinitions, ParamValue, PropGroup as AepGroup, PropNode, Property as AepProperty}; + +/// After Effects' Puppet effect. +pub(crate) const MATCH_NAME: &str = "ADBE FreePin3"; +/// The hidden group that holds the meshes (`arap`). +const ARAP: &str = "ADBE FreePin3 ARAP Group"; +/// Auto-traced Shapes, beside the meshes. +const OUTLINES: &str = "ADBE FreePin3 Outlines"; +/// The hidden group of the meshes. +const MESH_GROUP: &str = "ADBE FreePin3 Mesh Group"; +/// One mesh (`Mesh 1`). +const MESH_ATOM: &str = "ADBE FreePin3 Mesh Atom"; +/// A mesh's hidden triangulation. +const MESH_DATA: &str = "ADBE FreePin3 Mesh"; +const TRIANGLES: &str = "ADBE FreePin3 Mesh Tri Count"; +const EXPANSION: &str = "ADBE FreePin3 Mesh Expansion"; +/// At most this many meshes, pin atom names or unrecognised names are listed in a report line; +/// the rest are counted (`and 3 more`). +const LIST_CAP: usize = 10; +/// The pin containers of a mesh: match name and the name After Effects shows. +const PIN_GROUPS: [(&str, &str); 3] = [("ADBE FreePin3 PosPins", "Deform"), ("ADBE FreePin3 HghtPins", "Overlap"), ("ADBE FreePin3 StarchPins", "Stiffness")]; + +/// Imports a Puppet instance (the caller checked the match name) into `target`. +pub(crate) fn import_puppet<'f, 'a>( + defs: &EffectDefinitions<'f, 'a>, + fx: &AepEffect<'f, 'a>, + cx: &LayerCtx<'_>, + ids: &mut Ids<'_>, + target: &mut PropGroup, + col: &mut Collector, +) { + let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string()); + let here = format!("{}: '{}' effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer); + let Some(spec) = effectcraft_effects::find(effectcraft_effects::puppet::ID) else { + col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer)); + return; + }; + let size = [f64::from(cx.size.0), f64::from(cx.size.1)]; + let mut group = effectcraft_effects::instantiate(spec, ids, &shown, size); + match fx.enabled() { + Some(on) => group.enabled = on, + None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)), + } + // The ordinary parameters (`parT`): none is mapped yet, so a stored one is reported. + let params = defs.params(fx); + for p in ¶ms { + if carries_nothing(p) { + continue; + } + if p.stored().is_some() && !matches!(p.stored_value(), Some(ParamValue::Layer(0))) { + let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) }; + col.lose(LossCategory::Effects, format!("{here}: parameter {label} not imported")); + } + } + report_meshes(fx, ¶ms.iter().map(|p| p.match_name.as_str()).collect::>(), &here, col); + // The effect itself arrives (an EffectCraft Puppet the user can pin again), so it counts as + // imported; what it held is in the losses above. + col.imported.effects = col.imported.effects.saturating_add(1); + target.children.push(Node::Group(group)); +} + +/// Reports the meshes and pins of `fx` and every entry of its value group that is neither a +/// `parT` parameter (`param_names`) nor the mesh tree. +fn report_meshes(fx: &AepEffect<'_, '_>, param_names: &[&str], here: &str, col: &mut Collector) { + let mut unknown: Vec = Vec::new(); + let values = fx.chunk().child_list(b"tdgp").and_then(AepGroup::from_chunk); + let mut arap = None; + for e in values.map(|g| g.entries()).unwrap_or_default() { + if e.match_name == ARAP { + if let PropNode::Group(g) = e.node { + if arap.is_none() { + arap = Some(g); + } + } else { + push_unique(&mut unknown, e.match_name); + } + } else if !param_names.contains(&e.match_name.as_str()) { + match e.node { + // A value outside `parT` (On Transparent, ...): reported with what it holds. + PropNode::Property(p) => col.lose(LossCategory::Effects, format!("{here}: property {} ({}) not imported", e.match_name, describe(&p))), + PropNode::Group(_) | PropNode::Other(_) => push_unique(&mut unknown, e.match_name), + } + } + } + let mut meshes: Vec = Vec::new(); + let mut found = false; + if let Some(arap) = arap { + for e in arap.entries() { + match (e.match_name.as_str(), e.node) { + (MESH_GROUP, PropNode::Group(g)) if !found => { + found = true; + for m in g.entries() { + match (m.match_name.as_str(), m.node) { + (MESH_ATOM, PropNode::Group(mesh)) => { + let n = meshes.len().saturating_add(1); + meshes.push(mesh_summary(&mesh, n, &mut unknown)); + } + _ => push_unique(&mut unknown, m.match_name.clone()), + } + } + } + // Auto-traced Shapes are made from the layer again (EffectCraft traces the alpha). + (OUTLINES, _) => {} + _ => push_unique(&mut unknown, e.match_name.clone()), + } + } + } + let extra = if unknown.is_empty() { String::new() } else { format!("; not recognised: {}", capped(&unknown, ", ")) }; + if !found { + col.lose( + LossCategory::Effects, + format!("{here}: imported without meshes or pins: its mesh group ({ARAP} / {MESH_GROUP}) was not found or is not a group{extra}"), + ); + } else if !meshes.is_empty() { + col.lose( + LossCategory::Effects, + format!( + "{here}: imported without its meshes and pins (After Effects' pin rest places and positions are not decoded yet): {}{extra}", + capped(&meshes, "; ") + ), + ); + } else if !unknown.is_empty() { + col.lose(LossCategory::Effects, format!("{here}: no mesh{extra}")); + } +} + +/// `Mesh n (Triangles t, Expansion e): Deform 3 pins (1 keyed) [atom names], Overlap no pins, ...` +fn mesh_summary(mesh: &AepGroup<'_, '_>, n: usize, unknown: &mut Vec) -> String { + let name = mesh.user_name().unwrap_or_else(|| format!("Mesh {n}")); + let mut settings: Vec = Vec::new(); + let mut containers: Vec = Vec::new(); + for e in mesh.entries() { + let label = match e.match_name.as_str() { + TRIANGLES => Some("Triangles"), + EXPANSION => Some("Expansion"), + _ => None, + }; + match (label, e.node) { + (Some(label), PropNode::Property(p)) => settings.push(format!("{label} {}", describe(&p))), + // The triangulation After Effects keeps; EffectCraft builds its own. + _ if e.match_name == MESH_DATA => {} + _ => match (PIN_GROUPS.iter().find(|(m, _)| *m == e.match_name), e.node) { + (Some((_, shown)), PropNode::Group(g)) => containers.push(format!("{shown} {}", pins_summary(&g, unknown))), + _ => push_unique(unknown, e.match_name), + }, + } + } + let settings = if settings.is_empty() { String::new() } else { format!(" ({})", settings.join(", ")) }; + let containers = if containers.is_empty() { "no pin groups".to_string() } else { containers.join(", ") }; + format!("{name}{settings}: {containers}") +} + +/// `3 pins (1 keyed, 1 with an expression) [ADBE FreePin3 PosPin Atom]`, or `no pins`. An entry +/// of the container that is not a group (a pin is one) goes to `unknown`. +fn pins_summary(container: &AepGroup<'_, '_>, unknown: &mut Vec) -> String { + let mut count = 0usize; + let mut keyed = 0usize; + let mut expressions = 0usize; + let mut atoms: Vec = Vec::new(); + for e in container.entries() { + let PropNode::Group(pin) = e.node else { + push_unique(unknown, e.match_name); + continue; + }; + count = count.saturating_add(1); + push_unique(&mut atoms, e.match_name); + let props: Vec> = + pin.entries().into_iter().filter_map(|p| if let PropNode::Property(p) = p.node { Some(p) } else { None }).collect(); + if props.iter().any(AepProperty::is_animated) { + keyed = keyed.saturating_add(1); + } + if props.iter().any(|p| p.expression().is_some()) { + expressions = expressions.saturating_add(1); + } + } + if count == 0 { + return "no pins".to_string(); + } + let mut what = vec![format!("{keyed} keyed")]; + if expressions > 0 { + what.push(format!("{expressions} with an expression")); + } + format!("{count} pin{} ({}) [{}]", if count == 1 { "" } else { "s" }, what.join(", "), capped(&atoms, ", ")) +} + +/// A stored property's value for the report: `keyed`, the static value, or `unreadable`. +fn describe(p: &AepProperty<'_, '_>) -> String { + if p.is_animated() { + return "keyed".to_string(); + } + match p.static_value() { + Some(v) if v.len() == 1 => v.first().map(|x| format!("{x}")).unwrap_or_default(), + Some(v) => format!("[{}]", v.iter().map(|x| format!("{x}")).collect::>().join(", ")), + None => "unreadable".to_string(), + } +} + +/// Adds `name` to `names` unless it is there already. +fn push_unique(names: &mut Vec, name: String) { + if !names.contains(&name) { + names.push(name); + } +} + +/// The first [`LIST_CAP`] of `items` joined by `sep`, and how many more there are. +fn capped(items: &[String], sep: &str) -> String { + let shown: Vec<&str> = items.iter().take(LIST_CAP).map(String::as_str).collect(); + let mut out = shown.join(sep); + let more = items.len().saturating_sub(LIST_CAP); + if more > 0 { + out.push_str(&format!("{sep}and {more} more")); + } + out +} diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 102a215c8..61a04c401 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -19,6 +19,7 @@ mod fx_inject; mod markers; mod masks; mod mattes; +mod puppet; mod resaved_defaults; mod robustness; mod shapes; diff --git a/crates/aep/tests/it/import/fx_inject.rs b/crates/aep/tests/it/import/fx_inject.rs index 81e6a821a..ae61794d3 100644 --- a/crates/aep/tests/it/import/fx_inject.rs +++ b/crates/aep/tests/it/import/fx_inject.rs @@ -103,18 +103,18 @@ pub fn tdmn(name: &str) -> Chunk<'static> { } /// A UTF-8 string chunk (`Utf8`). -fn utf8(s: &str) -> Chunk<'static> { +pub fn utf8(s: &str) -> Chunk<'static> { Chunk::data(*b"Utf8", s.as_bytes().to_vec()) } /// A chunk whose body is chunks without being a `LIST` (`pdnm`, `fnam`: `NESTED_IDS`). -fn nested(id: [u8; 4], children: Vec>) -> Chunk<'static> { +pub fn nested(id: [u8; 4], children: Vec>) -> Chunk<'static> { Chunk { id, body: Body::Chunks(children), pad: None } } /// A static value record holding `v`, after the one After Effects wrote for Glow's A & B Midpoint /// (its header; without the expression). -fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> { +pub fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> { let mut kids = Vec::new(); for c in template.children() { match &c.body { @@ -131,7 +131,7 @@ fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> { } /// A keyframed value record: `record`'s header with a key list of linear one-component keys. -fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> { +pub fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> { let Body::List { children, .. } = record(template, keys.first().map_or(0.0, |k| k.1)).body else { unreachable!("record builds a LIST") }; let mut children = children; let mut lhd3 = vec![0u8; 52]; @@ -229,39 +229,51 @@ fn find_glow_template(chunks: &[Chunk<'_>]) -> Option> { None } -/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with `fx`; the number replaced. -fn replace_glow(chunks: &mut [Chunk<'_>], fx: &Fx, template: &Chunk<'_>) -> usize { +/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with the effect `match_name` +/// and its `sspc`; the number replaced. +fn replace_glow(chunks: &mut [Chunk<'_>], match_name: &str, sspc: &Chunk<'static>) -> usize { let mut n = 0; for ch in chunks { let kids = ch.children_mut(); if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2\0"))) && kids.get(at + 1).is_some_and(|k| k.is_list(b"sspc")) { - kids[at] = tdmn(fx.match_name); - kids[at + 1] = sspc(fx, template); + kids[at] = tdmn(match_name); + kids[at + 1] = sspc.clone(); n += 1; } - n += replace_glow(kids, fx, template); + n += replace_glow(kids, match_name, sspc); } n } -/// `synthetic.aep` with `fx` in place of the Glow effect. -pub fn bytes_with(fx: &Fx) -> Vec { +/// `synthetic.aep` with the effect `match_name` in place of the Glow effect; `make` builds its +/// `sspc` from the Glow's value record (the template of every record). +pub fn bytes_with_effect(match_name: &str, make: impl Fn(&Chunk<'_>) -> Chunk<'static>) -> Vec { let file = parse(SYNTHETIC).unwrap(); let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record"); + let sspc = make(&template); let mut file = parse(SYNTHETIC).unwrap(); - assert_eq!(replace_glow(&mut file.chunks, fx, &template), 2, "the Glow effect and its shared definitions"); + assert_eq!(replace_glow(&mut file.chunks, match_name, &sspc), 2, "the Glow effect and its shared definitions"); write(&file).unwrap() } +/// `synthetic.aep` with `fx` in place of the Glow effect. +pub fn bytes_with(fx: &Fx) -> Vec { + bytes_with_effect(fx.match_name, |t| sspc(fx, t)) +} + /// Imports `synthetic.aep` with `fx` in place of its Glow: the EffectCraft effect `effect` (if /// imported) and the result. pub fn run(fx: &Fx, effect: &str) -> (Option, ImportResult) { - let bytes = bytes_with(fx); + import_bytes(&bytes_with(fx), effect) +} + +/// Imports `bytes`: the first EffectCraft effect `effect` (if imported) and the result. +pub fn import_bytes(bytes: &[u8], effect: &str) -> (Option, ImportResult) { let mut project = Project::default(); let none = |_: &str| false; - let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); + let r = import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); let g = project .comps() .flat_map(|(_, c)| c.layers.iter()) diff --git a/crates/aep/tests/it/import/puppet.rs b/crates/aep/tests/it/import/puppet.rs new file mode 100644 index 000000000..a3054286e --- /dev/null +++ b/crates/aep/tests/it/import/puppet.rs @@ -0,0 +1,171 @@ +//! After Effects' Puppet (`ADBE FreePin3`, #3093) arrives as EffectCraft's Puppet +//! (`ec.distort.puppet`) in its place in the stack, and what its meshes and pins hold is reported +//! mesh by mesh instead of the one line "has the effect 'Puppet'". The effect is built into the +//! committed `synthetic.aep` in place of its Glow (`fx_inject`, in memory), in the tree +//! docs/aep-format.md 3.7 gives for Puppet. That tree comes from an external scripting listing, +//! not a byte sample: these tests prove the reader's walk and its report, not the format. + +use effectcraft_aep::Chunk; +use effectcraft_aep::import::{ImportOptions, LossCategory, import}; +use effectcraft_project::Project; + +use crate::import::fx_inject::{bytes_with_effect, import_bytes, keyed_record, losses, nested, record, tdmn, utf8}; + +const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep"); +const PUPPET: &str = "ADBE FreePin3"; +const POS_ATOM: &str = "ADBE FreePin3 PosPin Atom"; + +fn tdsb(v: u32) -> Chunk<'static> { + Chunk::data(*b"tdsb", v.to_be_bytes().to_vec()) +} + +/// A property group of `entries` (match name, node). +fn group(entries: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> { + let mut kids = vec![tdsb(1)]; + for (name, node) in entries { + kids.push(tdmn(name)); + kids.push(node); + } + Chunk::list(*b"tdgp", kids) +} + +/// A pin atom with its hidden vertex records and Position (keyed when `keys`). The values are +/// one-component records: the reader only asks whether a pin is keyed. +fn pin(t: &Chunk<'_>, atom: &'static str, keys: bool) -> (&'static str, Chunk<'static>) { + let position = if keys { keyed_record(t, &[(0, 1.0), (25_600, 2.0)]) } else { record(t, 1.0) }; + ( + atom, + group(vec![ + ("ADBE FreePin3 PosPin Vtx Offset", record(t, 0.0)), + ("ADBE FreePin3 PosPin Vtx Index", record(t, 7.0)), + ("ADBE FreePin3 PosPin Position", position), + ]), + ) +} + +/// `Mesh 1`: Triangles 500, Expansion 5, four Deform pins (one keyed, one with an atom name +/// nobody knows) and a property among them, no Overlap pin, one Stiffness pin, and a group +/// nobody knows. +fn full_mesh(t: &Chunk<'_>) -> Chunk<'static> { + let mesh_data = + Chunk::list(*b"tdbs", vec![tdsb(1), Chunk::data(*b"cdat", vec![0u8; 4]), Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", vec![1, 2, 3, 4])])]); + let deform = group(vec![ + pin(t, POS_ATOM, false), + pin(t, POS_ATOM, true), + pin(t, POS_ATOM, false), + pin(t, "ADBE FreePin3 Test Atom", false), + ("ADBE FreePin3 Test Prop", record(t, 3.0)), + ]); + let starch = group(vec![("ADBE FreePin3 StarchPin Atom", group(vec![("ADBE FreePin3 StarchPin Amount", record(t, 0.5))]))]); + group(vec![ + ("ADBE FreePin3 Mesh", mesh_data), + ("ADBE FreePin3 Mesh Tri Count", record(t, 500.0)), + ("ADBE FreePin3 Mesh Expansion", record(t, 5.0)), + ("ADBE FreePin3 PosPins", deform), + ("ADBE FreePin3 HghtPins", group(vec![])), + ("ADBE FreePin3 StarchPins", starch), + ("ADBE FreePin3 Test Group", group(vec![])), + ]) +} + +/// The Puppet instance: `fx_switch` is the value group's `tdsb`, `arap` its mesh tree (`None`: +/// none), `extra` more entries of its value group. +fn sspc(fx_switch: u32, arap: Option>, extra: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> { + let pard = |kind: u8| { + let mut b = vec![0u8; 148]; + b[15] = kind; + Chunk::data(*b"pard", b) + }; + let part = vec![Chunk::data(*b"parn", 2u32.to_be_bytes().to_vec()), tdmn("ADBE FreePin3-0000"), pard(0), tdmn("ADBE Effect Built In Params"), pard(9)]; + let mut values = vec![tdsb(fx_switch)]; + if let Some(arap) = arap { + values.push(tdmn("ADBE FreePin3 ARAP Group")); + values.push(arap); + } + for (name, node) in extra { + values.push(tdmn(name)); + values.push(node); + } + Chunk::list(*b"sspc", vec![nested(*b"fnam", vec![utf8("Puppet")]), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)]) +} + +/// The layer that holds `effect`, the effect's place in that layer's stack and the stack's size. +fn stack_place(bytes: &[u8], effect: &str) -> Option<(String, usize, usize)> { + let mut project = Project::default(); + let none = |_: &str| false; + import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); + project.comps().flat_map(|(_, c)| c.layers.iter()).find_map(|l| { + let fx: Vec<_> = l.props.sub("effects")?.children.iter().filter_map(|n| n.as_group()).collect(); + let at = fx.iter().position(|g| g.match_id == effect)?; + Some((l.name.clone(), at, fx.len())) + }) +} + +#[test] +fn puppet_arrives_and_its_pins_are_reported_mesh_by_mesh() { + let bytes = bytes_with_effect(PUPPET, |t| { + let arap = group(vec![("ADBE FreePin3 Outlines", group(vec![])), ("ADBE FreePin3 Mesh Group", group(vec![("ADBE FreePin3 Mesh Atom", full_mesh(t))]))]); + sspc(1, Some(arap), vec![("ADBE FreePin3 On Transparent", record(t, 1.0))]) + }); + let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID); + let g = g.expect("an EffectCraft Puppet in the Glow's place"); + assert!(g.enabled); + assert_eq!(g.name, "Puppet"); + // No pin is made up: the rest places are not decoded. + assert_eq!(effectcraft_effects::puppet::pin_count(&g), 0); + // In the Glow's place in its layer's stack (the second of two effects there). + assert!(stack_place(SYNTHETIC, "ec.stylize.glow").is_some_and(|(_, at, n)| at > 0 && n > 1)); + assert_eq!(stack_place(&bytes, effectcraft_effects::puppet::ID), stack_place(SYNTHETIC, "ec.stylize.glow")); + let l = losses(&r, LossCategory::Effects, PUPPET); + assert_eq!(l.len(), 2, "{l:#?}"); + assert!(!l.iter().any(|d| d.contains("has the effect")), "not the old one-line report: {l:#?}"); + assert!(l.iter().any(|d| d.ends_with("property ADBE FreePin3 On Transparent (1) not imported")), "{l:#?}"); + let mesh = l.iter().find(|d| d.contains("imported without its meshes and pins")).expect("the mesh report"); + assert!( + mesh.ends_with( + ": Mesh 1 (Triangles 500, Expansion 5): Deform 4 pins (1 keyed) [ADBE FreePin3 PosPin Atom, ADBE FreePin3 Test Atom], \ + Overlap no pins, Stiffness 1 pin (0 keyed) [ADBE FreePin3 StarchPin Atom]; not recognised: ADBE FreePin3 Test Prop, ADBE FreePin3 Test Group" + ), + "{mesh}" + ); + assert_eq!(losses(&r, LossCategory::DamagedData, PUPPET), Vec::::new()); +} + +#[test] +fn many_meshes_are_listed_up_to_ten_and_names_once() { + let bytes = bytes_with_effect(PUPPET, |_| { + let mesh = || group(vec![("ADBE FreePin3 Test Group", group(vec![]))]); + let meshes = (0..12).map(|_| ("ADBE FreePin3 Mesh Atom", mesh())).collect(); + sspc(1, Some(group(vec![("ADBE FreePin3 Mesh Group", group(meshes))])), vec![]) + }); + let (_, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID); + let l = losses(&r, LossCategory::Effects, PUPPET); + assert_eq!(l.len(), 1, "{l:#?}"); + assert!(l[0].ends_with("Mesh 9: no pin groups; Mesh 10: no pin groups; and 2 more; not recognised: ADBE FreePin3 Test Group"), "{}", l[0]); +} + +#[test] +fn a_puppet_without_a_mesh_loses_nothing_and_keeps_its_fx_switch() { + let bytes = bytes_with_effect(PUPPET, |_| sspc(0, Some(group(vec![("ADBE FreePin3 Mesh Group", group(vec![]))])), vec![])); + let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID); + let g = g.expect("an EffectCraft Puppet"); + assert!(!g.enabled, "switched off in the Effect Controls"); + assert_eq!(losses(&r, LossCategory::Effects, PUPPET), Vec::::new()); +} + +#[test] +fn a_puppet_whose_mesh_tree_is_not_found_is_reported() { + let bytes = bytes_with_effect(PUPPET, |t| sspc(1, None, vec![("ADBE FreePin3 Other Group", group(vec![("x", record(t, 1.0))]))])); + let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID); + assert!(g.is_some(), "the effect still arrives"); + let l = losses(&r, LossCategory::Effects, PUPPET); + assert_eq!(l.len(), 1, "{l:#?}"); + assert!( + l[0].ends_with( + "imported without meshes or pins: its mesh group (ADBE FreePin3 ARAP Group / ADBE FreePin3 Mesh Group) was not found or is not a group; \ + not recognised: ADBE FreePin3 Other Group" + ), + "{}", + l[0] + ); +} diff --git a/docs/aep-format.md b/docs/aep-format.md index d6ec7fb14..db0d79d1c 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -1,6 +1,6 @@ # After Effects project format (`.aep`) -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-05 · **Change:** versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); 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: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-07 · **Change:** Puppet (`ADBE FreePin3`, 3.7): the property tree from an external scripting listing, **?** until a byte sample exists (#3093); before that: versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); 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: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026 What `crates/aep` ([`effectcraft-aep`](../crates/aep/README.md)) reads and writes, and the evidence for each statement. Everything here was observed on project files we generated ourselves; no @@ -610,6 +610,52 @@ presets, and After Effects draws exactly the picked objects as selected (`crypto `crypto-07`: box and disc, the two objects of `mat_paint`). API: `CryptomatteData` (refuses data without `cry1`, a length past the end or a non-zero byte at 112). +**Puppet (`ADBE FreePin3`; ?: external listing, not the corpus, #3093).** No file of the +corpus, the committed fixtures or the samples holds a Puppet. The tree below comes from outside +the corpus: a public scripting listing of the effect's properties (a forum post, an After Effects +release from before the Advanced and Bend pins and Density). It is not bytes we have seen; that +After Effects writes it by the general rule (a `tdmn` and the `LIST tdgp` or `tdbs` it names, +nested in the instance's value group `sspc/tdgp`, as masks, shapes and layer styles are) is +**H**: + +```text +Puppet ADBE FreePin3 + On Transparent ADBE FreePin3 On Transparent + arap ADBE FreePin3 ARAP Group (hidden) + Auto-traced Shapes ADBE FreePin3 Outlines + Mesh ADBE FreePin3 Mesh Group (hidden) + Mesh 1 ADBE FreePin3 Mesh Atom + Mesh ADBE FreePin3 Mesh (hidden: the triangulation) + Triangles ADBE FreePin3 Mesh Tri Count + Expansion ADBE FreePin3 Mesh Expansion + Deform ADBE FreePin3 PosPins + Puppet Pin 1 ADBE FreePin3 PosPin Atom + Vertex Offset ADBE FreePin3 PosPin Vtx Offset (hidden, 2 values) + Vertex Index ADBE FreePin3 PosPin Vtx Index (hidden; 4294967295 = none) + Position ADBE FreePin3 PosPin Position + Overlap ADBE FreePin3 HghtPins (hidden while empty) + Stiffness ADBE FreePin3 StarchPins (hidden while empty) +``` + +A pin's rest place is not a property: it is a vertex of the hidden mesh (Vertex Index) plus an +offset, so it cannot be read without decoding the mesh data. The unit of Position (layer pixels, +or a fraction of the layer as effect points are) is **?**. The match names of the Overlap and +Starch pin atoms and their properties, of the Advanced and Bend pins (Scale, Rotation) and of +Density, Mesh Rotation Refinement and the Puppet Engine are **?**, and so is which of them are +`parT` parameters; whether After Effects stores some `parT` parameters of a fresh Puppet even at +their defaults (the importer would then report them every time) is **?**; whether a Puppet +without pins has an (empty) Mesh Group, or no ARAP group at all, is **?**. The importer +(`import/puppet.rs`) therefore brings the effect in as EffectCraft's Puppet without meshes or +pins and reports, per mesh, Triangles, Expansion and per pin group the pins, how many are keyed +or carry an expression, and their atom match names; every entry outside this tree is named in the +report. The sample that settles it: a fresh Puppet with no pins in one composition and, in +another, a 400 x 800 solid in a larger composition (pixels against fractions) with a Puppet of +three Position pins (one keyed, one moved off its rest place without +keys), one Advanced, one Bend, one Starch and one Overlap pin, Triangles, Expansion and Density +off their defaults, a second mesh, and a scripting read-back of every property's match name and +value, the hidden ones included. Pins added by script are reported to come out disabled, so the +pins are placed by hand or by a preset. + ### 3.8 Masks and bezier paths (`tdgp[ADBE Mask Parade]`) | Field | Chunk path | Offset | Type | Meaning | Evidence | Conf. | @@ -894,6 +940,7 @@ another value). **Effects** - `pard` byte 48 (flags: **?**), 68 / 72 (the point default as a percent pair: **V** in the corpus for 074 and 368, not exposed), 120 (the float slider default again as f32: **V**, not exposed), 124 (display decimals: **H**), 132..147 (fixed slider slider-max, default copy and flags; the low bit of @144 is set on the two percent-displayed sliders and clear on Scatter: **H** "display as percent"), the colour default copy at @60, and the stale memory after the parameter name. The fixed slider valid minimum is not decoded (the float slider one is, 389). +- Puppet (`ADBE FreePin3`, 3.7): the hidden mesh data, a pin's Vertex Index and Vertex Offset (its rest place), the unit of a pin's Position, the Overlap, Starch, Advanced and Bend pins, Density, Mesh Rotation Refinement and the Puppet Engine: no sample (**?**). - Effects renamed, or stacked twice on ONE layer (`Gradient Ramp 2`: does the second keep an empty `parT`?); which instance keeps the full `parT` when a second instance is created on a layer below the first; expressions on effect parameters; keyed per-channel Exposure values and a keyed checkbox; Curves / Levels (arrays), Fractal Noise, and whether the `tdpi` of a Layer Control holds an id or an index after the referenced layer is reordered or deleted. **Masks, shapes** diff --git a/docs/aep-import.md b/docs/aep-import.md index ed3ab57f5..5d268e503 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-05 · **Change:** 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-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: 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` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -58,7 +58,7 @@ outside the sampled ones keeps the current value and is reported as `project set | Text | the Source Text of a text layer: the text (paragraphs separated by CR; a hard line break is a CR too; U+0003 is laid out by After Effects exactly like a CR (386 against 387) and becomes a CR), per style run the font (PostScript name), size, fill and stroke colour, whether fill and stroke are drawn, stroke width, stroke over fill (one switch for the text), tracking, leading (automatic or explicit), faux bold, faux italic, all caps, small caps, superscript and subscript (526-528), horizontal and vertical scale, baseline shift, automatic kerning (metrics, optical, none), ligatures, tsume (538-578); per paragraph the justification (left, centre, right and the four justified variants), first-line / start / end indent, space before / after, direction, composer, hanging punctuation (in a right-to-left paragraph the stored justification is relative to the direction: After Effects lays 0 and 3 out flush right and 1 and 4 flush left, read-back test t7, so they arrive as right / left); point text or box text (box size and top-left in layer pixels); vertical right-to-left text (575) | | Text animators | every animator with its name and its range selectors (Start, End, Offset, or the Index range when Units is Index; Units, Based On, Mode, Amount, Shape, Smoothness, Ease High / Low, Randomize Order, Random Seed) and the properties Anchor Point, Position, Scale, Skew, Skew Axis, Rotation, Opacity, Fill Color, Stroke Color, Stroke Width, Tracking Amount, Line Spacing, Blur, Character Offset and Character Range, with keys and expressions (579-594, 114, 728). Values are stored as After Effects shows them; popups are 1-based in the file | | Masks | every mask of an imported layer, in stack order (on text and shape layers, which have no source, the stored mask box is in layer pixels: 705, 706): name, path (vertices and tangents in layer pixels, open or closed), mode (None, Add, Subtract, Intersect, Lighten, Darken, Difference), inverted, feather (x, y in pixels), opacity (percent), expansion (pixels); path keyframes (time, linear or hold sides, roving, label), feather / opacity / expansion keyframes with ease | -| Effects | **Curves** (961-964: the five curves' points from the arbitrary data as EffectCraft's `x,y` points in 0..1; render within 6 levels: EffectCraft interpolates monotone, After Effects stores a natural spline) and **Mesh Warp** (965, 985: Rows, Columns, Quality and the Distortion Mesh as per-vertex offsets in layer pixels; Bezier handles moved on their own are kept as eight extra numbers of the vertex for the export and reported, EffectCraft draws the mesh bilinear; the header is rows + 1, columns + 1; render 985: 1.4 % of pixels > 2 levels around the moved vertex), **Tint**, **Gradient Ramp**, **Exposure**, **Invert**, **Fast Box Blur**, **Gaussian Blur**, **Drop Shadow**, **Glow**, **Fill**, **Hue/Saturation**, **Levels**, **Timewarp** (Method, Adjust Time By, Speed with keys, Source Frame; 529-533), **Directional Blur**, **Radial Blur**, **CC Radial Blur**, **Venetian Blinds**, **Channel Blur**, **Black & White**, **Tritone**, **Luma Key**, **Simple Choker**, **Matte Choker**, **Transform** (all parameters; 711-723) and the expression controls **Slider**, **Angle**, **Checkbox**, **Color** and **Point Control** (table below) on any imported layer (also text and shape layers and layers with masks), in stack order, with the instance name, every parameter (static value or default) and its keyframes; also the second and later instances of a type, whose parameter definitions are only in the project's shared definition list. Other effects are reported (below) | +| Effects | **Curves** (961-964: the five curves' points from the arbitrary data as EffectCraft's `x,y` points in 0..1; render within 6 levels: EffectCraft interpolates monotone, After Effects stores a natural spline) and **Mesh Warp** (965, 985: Rows, Columns, Quality and the Distortion Mesh as per-vertex offsets in layer pixels; Bezier handles moved on their own are kept as eight extra numbers of the vertex for the export and reported, EffectCraft draws the mesh bilinear; the header is rows + 1, columns + 1; render 985: 1.4 % of pixels > 2 levels around the moved vertex), **Tint**, **Gradient Ramp**, **Exposure**, **Invert**, **Fast Box Blur**, **Gaussian Blur**, **Drop Shadow**, **Glow**, **Fill**, **Hue/Saturation**, **Levels**, **Timewarp** (Method, Adjust Time By, Speed with keys, Source Frame; 529-533), **Directional Blur**, **Radial Blur**, **CC Radial Blur**, **Venetian Blinds**, **Channel Blur**, **Black & White**, **Tritone**, **Luma Key**, **Simple Choker**, **Matte Choker**, **Transform** (all parameters; 711-723) and the expression controls **Slider**, **Angle**, **Checkbox**, **Color** and **Point Control** (table below) on any imported layer (also text and shape layers and layers with masks), in stack order, with the instance name, every parameter (static value or default) and its keyframes; also the second and later instances of a type, whose parameter definitions are only in the project's shared definition list. **Puppet** arrives in its place with its name and fx switch but without meshes and pins (a pin's rest place lies in the mesh data, not decoded yet); per mesh the report gives Triangles, Expansion and each pin group's pins, keyed pins and atom match names. Other effects are reported (below) | | Layer styles | Drop Shadow, Inner Shadow, Outer Glow, Inner Glow, Bevel and Emboss, Satin, Color Overlay, Gradient Overlay and Stroke when applied (switch group `tdsb` 1): every parameter, starting from After Effects' defaults (Use Global Light is off there), with keys on scalars and colours and expressions; Blending Options: Fill Opacity, the channel switches, Blend Interior Styles as Group, Use Blend Ranges, and Global Light Angle / Altitude, which the composition adopts (one setting per composition here). Units and popups through `effectcraft_aep::style_map` (aep-format 3.12b). Render check against After Effects (`proof\styles`): drop shadow, inner shadow, satin, colour overlay, stroke within a few levels; glow and bevel intensities and the Gradient Overlay direction differ in EffectCraft's renderer | | Shape layers | groups (nested, with anchor, position, scale, skew, skew axis, rotation, opacity), rectangle (size, position, roundness), ellipse (size, position), polystar (star or polygon, points, position, rotation, radii, roundness), bezier path (vertices and tangents in layer pixels, path keys), fill (colour, opacity, fill rule), stroke (colour, opacity, width, cap, join, miter limit), gradient fill and stroke (linear or radial, start and end point; the default white-to-black colours), trim paths (start, end, offset, trim multiple shapes), round corners (radius), merge paths (mode; 707, 708), offset paths (amount, line join, miter limit, copies, copy offset; 710; After Effects offsets copy *i* by Amount x (*i* + Copy Offset), default 1, EffectCraft by Amount x (*i* + 1 + copyOffset), so the copy offset arrives 1 less, also its keys; an expression on it is kept and reported; 826-828, 828 renders equal to After Effects on every grid point), repeater (copies, offset, composite, transform; 709), in file order, with keyframes and expressions, the operators' defaults as After Effects reads them back (731); the layer anchor in pixels; 238-242, 330-333, 451-484, 519, 705-710, 731 | | Markers | layer and composition markers: time (layer time for layer markers, exact frames), duration (1/600 s units, 509, 510), comment, chapter, URL, frame target, label, cue point (name, event or navigation, parameters); 122, 123, 249, 347-349, 509, 510 |