From 0a6d264554a7f5ce7fc767b5ae90fb112e6441f9 Mon Sep 17 00:00:00 2001 From: Sean Hawkey <62113798+GenuineMoron@users.noreply.github.com> Date: Fri, 13 Nov 2026 14:41:03 +0800 Subject: [PATCH] Import: a static shape path an expression draws is kept, empty, with its expression (#3606) After Effects stores a static shape path that an expression draws (a text-box rig writes its outline from the text's size) with no vertices. The importer dropped the whole path ('its path has no vertices'), expression and all, so such shapes drew nothing. A path without vertices that has an expression and no keys now arrives as an empty path with its expression; one without an expression is still reported. --- crates/aep/src/import/masks.rs | 5 +- crates/aep/src/import/shapes.rs | 11 +- crates/aep/tests/it/import.rs | 1 + .../tests/it/import/shape_path_expression.rs | 106 ++++++++++++++++++ 4 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 crates/aep/tests/it/import/shape_path_expression.rs diff --git a/crates/aep/src/import/masks.rs b/crates/aep/src/import/masks.rs index 184250c4c..d6870e6c1 100644 --- a/crates/aep/src/import/masks.rs +++ b/crates/aep/src/import/masks.rs @@ -50,13 +50,16 @@ fn source_rectangle(size: (u32, u32)) -> ShapePath { } } +/// [`to_path`]'s error for a stored path without vertices. +pub(crate) const NO_VERTICES: &str = "its path has no vertices"; + pub(crate) fn to_path(s: &AepPath, size: (u32, u32)) -> Result { if !s.points.len().is_multiple_of(3) { return Err("its point count is not a multiple of three"); } let n = s.vertex_count(); if n == 0 { - return Err("its path has no vertices"); + return Err(NO_VERTICES); } if n > MAX_VERTICES { return Err("its path has too many vertices"); diff --git a/crates/aep/src/import/shapes.rs b/crates/aep/src/import/shapes.rs index 9d0a59120..83e2fdc9d 100644 --- a/crates/aep/src/import/shapes.rs +++ b/crates/aep/src/import/shapes.rs @@ -18,11 +18,11 @@ //! options of extruded shapes. use crate::{Layer as AepLayer, PropGroup as AepGroup, PropNode, Property as AepProp, ShapePath as AepPath}; -use effectcraft_keyframe::Gradient; +use effectcraft_keyframe::{Gradient, ShapePath}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{Node, PropGroup}; -use crate::import::masks::{import_path_keys, to_path}; +use crate::import::masks::{NO_VERTICES, import_path_keys, to_path}; use crate::import::props::{Kind, import_expression, import_keys}; use crate::import::report::{Collector, LossCategory}; use crate::import::time::TimeMap; @@ -400,15 +400,20 @@ fn path(g: &AepGroup<'_, '_>, cx: &mut Cx<'_, '_>) -> Option { Some(None) => Err("its path is unreadable"), None => Err("it has no path"), }; + let node = oms.child_list(b"tdbs").and_then(AepProp::from_chunk); + // A static path an expression draws (a text-box rig writes its outline from the text's size): + // After Effects stores it empty. The expression is the path, so it is kept, on an empty value. + let drawn_by_expression = node.as_ref().is_some_and(|n| n.expression().is_some() && !n.is_animated()); let first = match first { Ok(p) => p, + Err(why) if why == NO_VERTICES && drawn_by_expression => ShapePath::default(), Err(why) => { cx.col.lose(LossCategory::ShapeLayers, format!("{}: a shape path not imported, {why}", cx.what)); return None; } }; let mut p = build::shape_path(cx.ids, first); - if let (Some(node), Some(prop)) = (oms.child_list(b"tdbs").and_then(AepProp::from_chunk), p.get_mut("path")) { + if let (Some(node), Some(prop)) = (node, p.get_mut("path")) { if node.is_animated() { import_path_keys(&shapes, &node, cx.what, (1, 1), cx.tm, prop, cx.col); } diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 3c1e71bca..9f4df3e14 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -27,6 +27,7 @@ mod placeholders; mod puppet; mod resaved_defaults; mod robustness; +mod shape_path_expression; mod shapes; mod shapes_round2; mod styles; diff --git a/crates/aep/tests/it/import/shape_path_expression.rs b/crates/aep/tests/it/import/shape_path_expression.rs new file mode 100644 index 000000000..0684d8a0e --- /dev/null +++ b/crates/aep/tests/it/import/shape_path_expression.rs @@ -0,0 +1,106 @@ +//! A static shape path that an expression draws: After Effects stores the path empty and the +//! expression builds it (text-box rigs write their outline from the text's size). The importer +//! dropped the whole path ("its path has no vertices"), expression and all, so such shapes drew +//! nothing. It keeps the path, empty, with its expression; an empty path without one is still +//! reported. + +use effectcraft_aep::import::{ImportOptions, LossCategory, import}; +use effectcraft_aep::{AepFile, Body, Chunk, parse, write}; +use effectcraft_keyframe::Value; +use effectcraft_project::Project; + +const CASE: &str = "240-shape-custom-path-tangents.aep"; +const EXPRESSION: &str = "createPath([[0,0],[100,0],[100,50],[0,50]], [], [], true)"; + +fn visit(chunks: &mut [Chunk<'_>], f: &mut dyn FnMut(&mut Chunk<'_>)) { + for c in chunks { + f(c); + visit(c.children_mut(), f); + } +} + +/// The case with its shape path emptied, and with `expression` on it when given (as After Effects +/// writes one: `tdsb` 1, the expression switches at `tdb4` 119 / 120 and a `Utf8` in `tdbs`). +fn empty_path(expression: Option<&str>) -> Option> { + let bytes = crate::common::try_fixture(CASE)?; + let mut file: AepFile<'_> = parse(&bytes).unwrap(); + let mut emptied = 0; + visit(&mut file.chunks, &mut |c| { + if c.is_list(b"shap") { + for k in c.children_mut() { + if k.is_list(b"list") { + for d in k.children_mut() { + if let Body::Data(data) = &mut d.body { + if d.id == *b"lhd3" { + data.to_mut()[8..12].copy_from_slice(&0u32.to_be_bytes()); + } else if d.id == *b"ldat" { + *data = std::borrow::Cow::Owned(Vec::new()); + } + } + } + } + } + emptied += 1; + } + let Some(text) = expression else { return }; + if !c.is_list(b"om-s") { + return; + } + for k in c.children_mut() { + if !k.is_list(b"tdbs") { + continue; + } + for r in k.children_mut() { + if let Body::Data(d) = &mut r.body { + let d = d.to_mut(); + match &r.id { + b"tdsb" => d.copy_from_slice(&1u32.to_be_bytes()), + b"tdb4" => { + d[119] = 0; + d[120] = 1; + } + _ => {} + } + } + } + if let Body::List { children, .. } = &mut k.body { + children.push(Chunk::data(*b"Utf8", text.as_bytes().to_vec())); + } + } + }); + assert!(emptied > 0, "{CASE} has a shape path"); + Some(write(&file).unwrap()) +} + +fn import_allowing(bytes: &[u8]) -> (Project, effectcraft_aep::import::ImportResult) { + let mut project = Project::default(); + let none = |_: &str| false; + let r = import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); + (project, r) +} + +#[test] +fn an_empty_path_with_an_expression_is_kept_with_it() { + let Some(bytes) = empty_path(Some(EXPRESSION)) else { return }; + let (p, r) = import_allowing(&bytes); + let mut paths = Vec::new(); + for l in p.comp(r.comps[0]).unwrap().layers.iter() { + l.props.walk("", &mut |_, pr| { + if pr.match_id == "path" { + paths.push((pr.value.clone(), pr.expr.as_ref().map(|e| e.text.clone()))); + } + }); + } + assert_eq!(paths.len(), 1, "{:?}", r.report.losses); + let (value, expr) = &paths[0]; + assert!(matches!(value, Value::Path(sp) if sp.vertices.is_empty()), "{value:?}"); + assert_eq!(expr.as_deref(), Some(EXPRESSION)); + assert_eq!(r.report.count(LossCategory::ShapeLayers), 0, "{:?}", r.report.losses); +} + +#[test] +fn an_empty_path_without_an_expression_is_still_reported() { + let Some(bytes) = empty_path(None) else { return }; + let (_, r) = import_allowing(&bytes); + assert!(r.report.losses.iter().flat_map(|l| &l.details).any(|d| d.contains("its path has no vertices")), "{:?}", r.report.losses); +}