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); +}