Files
effectcraft/crates/aep/src/import/props.rs
T
Valentin Klein 7c62272305 .aep import: placed .ai/PDF footage framed by its ArtBox, Null anchors at the corner (#3099) (#3311)
* #3099: placed .ai pages fill the size After Effects stored; null anchors default to the corner

Footage of an .aep keeps the size After Effects stored for a placed PDF / Illustrator / EPS
file (the ArtBox, rounded up), and masks and anchors are measured against it. The page was
drawn as a PDF viewer frames it (CropBox, /Rotate) at its own size, scaled by the width ratio
at full resolution and stretched to the stored aspect below 3/4: masks too large, art off
centre, squeezed at half resolution.

- effectcraft-pdf: Framing::Placed / parse_page_framed (ArtBox clipped to the CropBox,
  /Rotate ignored); boxes with a non-finite corner or span are not used
- Footage::placed_page (set by the .aep importer, serialized only when true): the page is
  framed so and filled into the stored size (effectcraft_render::vector_fit), decoded,
  continuously rasterised and in Create Shapes from Vector Layer; Reload keeps the size;
  the frame cache keys it by size and framing
- .aep import/export: a null's absent Anchor Point is [0, 0], as After Effects reads it
- fixture ai-mask-3099.aep (own synthetic files, scrubbed, profiles stubbed) and tests
  against After Effects 2026's read-back

* #3099: placed-page framing only for .ai and .pdf, EPS as before; doc comments

After Effects' sizing was measured with PDFs named .ai only; an EPS from an .aep keeps its
bounding box at its own size (listed as not measured in docs/gaps.md and docs/aep-import.md).
Doc comments on the touched functions.

* #3099: shapes from a placed page keep its frame (track matte)

Create Shapes from Vector Layer converted every path of a placed page, so bleed, art past the
artboard or the page's background showed where the footage's raster hides them. The layers it
makes from a placed page now take a hidden "<layer> Frame" shape layer (the footage's
width x height) as their Alpha track matte: drawn at the comp's resolution, its edge is the
raster's on shape and image layers alike. The command returns the frame layer's id too.
2026-11-08 21:52:32 -06:00

453 lines
21 KiB
Rust

//! The Transform properties of a layer: static values and keyframes in EffectCraft's units, and
//! expressions as source text.
//!
//! Units: Anchor Point is stored as a fraction of the source size (pixels here; on text layers,
//! which have no source, it is stored in pixels and absent means the text origin), Scale and Opacity
//! as fractions (percent here, ease speeds scaled likewise), Position in pixels, Rotation in
//! degrees. Keyframe times are layer time and become ticks through [`TimeMap`].
use crate::{Interpolation, Keyframe as AepKey, KeyframeKind, Layer as AepLayer, PropGroup, PropNode, Property as AepProp, ValueSource};
use effectcraft_keyframe::{Ease, Interp, Keyframe, Value};
use effectcraft_project::{Expression, PropGroup as EcGroup, Property as EcProp};
use crate::import::report::{Collector, LossCategory};
use crate::import::time::TimeMap;
/// Which property is being converted: one of the five Transform properties, a mask property, or
/// an effect parameter (see `effects.rs`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum Kind {
Position,
/// Anchor Point of a source this big (pixels).
Anchor(f64, f64),
Scale,
Rotation,
Opacity,
/// Mask Feather: pixels `[x, y]`, never negative.
MaskFeather,
/// Mask Opacity: stored as a fraction 0..1, percent here.
MaskOpacity,
/// Mask Expansion: pixels.
MaskExpansion,
/// An effect slider or angle: the stored number times this factor.
EffectNumber(f64),
/// An effect point on a layer this big (pixels): stored as fractions of the layer size.
EffectPoint(f64, f64),
/// An effect colour: stored as ARGB 0..255, imported as straight RGBA 0..1 with alpha 1.
EffectColor,
/// An effect popup with this many items: stored 1-based, imported 0-based.
EffectPopup(u32),
/// An effect checkbox: stored 1.0 when on.
EffectBool,
/// Time Remapping: source seconds, as stored (319, 354).
TimeRemap,
/// A one-component value used as stored (shape sizes in pixels, percentages, degrees).
Number,
/// A two-component value used as stored (shape sizes and positions in pixels, group scale in
/// percent).
Vec2,
/// A three-component percentage used as stored (text animator Scale 3D, 592); a missing third
/// component is 100.
Vec3Percent,
}
impl Kind {
/// Stored value to the unit shown (percent for Scale and Opacity), also applied to ease
/// speeds.
fn factor(self) -> f64 {
match self {
Kind::Scale | Kind::Opacity | Kind::MaskOpacity => 100.0,
Kind::EffectNumber(f) => f,
// A fractional point's spatial speed is pixels per second divided by the layer height
// (597, 598, 730).
Kind::Anchor(_, h) | Kind::EffectPoint(_, h) => h,
_ => 1.0,
}
}
/// Whether a bezier ease of this kind can be imported. The ease of effect colours is only
/// sampled as speed 0 / influence 0 (598, handled in [`import_keys`]); popups and checkboxes
/// have no ease.
fn ease_decoded(self) -> bool {
!matches!(self, Kind::EffectColor | Kind::EffectPopup(_) | Kind::EffectBool)
}
/// The value as the project stores it; `None` when a needed component is missing or not a
/// finite number.
pub(crate) fn value(self, c: &[f64]) -> Option<Value> {
let g = |i: usize| c.get(i).copied().filter(|v| v.is_finite());
let value = match self {
Kind::Rotation | Kind::Opacity => Value::Scalar(g(0)? * self.factor()),
Kind::Position => Value::Vec3([g(0)?, g(1)?, g(2).unwrap_or(0.0)]),
Kind::Scale => Value::Vec3([g(0)? * 100.0, g(1)? * 100.0, g(2).map_or(100.0, |z| z * 100.0)]),
Kind::Anchor(w, h) => Value::Vec3([g(0)? * w, g(1)? * h, g(2).unwrap_or(0.0)]),
Kind::MaskFeather => Value::Vec2([g(0).filter(|v| *v >= 0.0)?, g(1).filter(|v| *v >= 0.0)?]),
Kind::MaskOpacity => Value::Scalar(g(0).filter(|v| (0.0..=1.0).contains(v))? * 100.0),
Kind::MaskExpansion => Value::Scalar(g(0)?),
Kind::EffectNumber(f) => Value::Scalar(g(0)? * f),
Kind::EffectPoint(w, h) => Value::Vec2([g(0)? * w, g(1)? * h]),
// ARGB: alpha first. After Effects colour parameters carry no editable alpha (Tint's
// defaults even store alpha 0); EffectCraft's colour parameters are opaque.
Kind::EffectColor => Value::Color([g(1)? / 255.0, g(2)? / 255.0, g(3)? / 255.0, 1.0]),
Kind::EffectPopup(items) => {
let i = g(0)?.round();
if i < 1.0 || i > f64::from(items) {
return None;
}
// In range 1..=items, so the conversion is exact.
Value::Enum(i as u32 - 1)
}
Kind::EffectBool => Value::Bool(g(0)? != 0.0),
Kind::TimeRemap | Kind::Number => Value::Scalar(g(0)?),
Kind::Vec2 => Value::Vec2([g(0)?, g(1)?]),
Kind::Vec3Percent => Value::Vec3([g(0)?, g(1)?, g(2).unwrap_or(100.0)]),
};
sane(&value).then_some(value)
}
/// A tangent offset, in the same units as the value.
fn tangent(self, c: &[f64]) -> [f64; 3] {
let g = |i: usize| c.get(i).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
let t = match self {
Kind::Anchor(w, h) => [g(0) * w, g(1) * h, g(2)],
Kind::EffectPoint(w, h) => [g(0) * w, g(1) * h, 0.0],
_ => [g(0), g(1), g(2)],
};
if t.iter().all(|v| v.abs() <= LIMIT) { t } else { [0.0; 3] }
}
}
/// No sensible coordinate, angle or percentage comes near this; larger numbers (and
/// NaN or infinity) mean damaged data.
pub(crate) const LIMIT: f64 = 1e9;
pub(crate) fn sane(v: &Value) -> bool {
v.components().iter().all(|c| c.is_finite() && c.abs() <= LIMIT)
}
/// The five properties: stored match name, project property id.
const SPECS: [(&str, &str); 5] =
[("ADBE Anchor Point", "anchor"), ("ADBE Position", "position"), ("ADBE Scale", "scale"), ("ADBE Rotate Z", "rotation"), ("ADBE Opacity", "opacity")];
/// Match names of the transform group that are always present and carry nothing in their default
/// state (the separated position dimensions and the reflection switch).
const IGNORED: [&str; 4] = ["ADBE Position_0", "ADBE Position_1", "ADBE Position_2", "ADBE Envir Appear in Reflect"];
/// How the stored Anchor Point becomes pixels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AnchorUnit {
/// A fraction of a source this big (pixels): AV layers; the source's centre when absent.
Source(u32, u32),
/// A fraction of a null object's source this big (pixels); its top-left corner when absent:
/// After Effects 2026 reads a null's absent Anchor Point as `[0, 0]` (#3099: two nulls made
/// with `addNull`, one left at its default, read back `[0, 0, 0]` and `[10, 20, 0]`).
Null(u32, u32),
/// Pixels as stored, the origin when absent: text (385) and shape (462) layers, which have no
/// source.
Pixels,
/// The point of interest of a camera or light: pixels as stored (251, 253, 256), imported into
/// `poi`; when absent the new layer's comp centre stays (255, 256 read back `[960, 540, 0]`).
PointOfInterest,
}
/// The 3D rotations: stored match name, project property id (486, 487: plain degrees).
const ROTATIONS: [(&str, &str); 2] = [("ADBE Rotate X", "rotationX"), ("ADBE Rotate Y", "rotationY")];
/// Imports the transform of `layer` into `target` (the layer's `transform` group).
///
/// `comp` is the composition size in pixels; `anchor` says how the stored Anchor Point becomes
/// pixels.
pub(crate) fn import_transform(
layer: &AepLayer<'_, '_>,
layer_name: &str,
comp: (u32, u32),
anchor: AnchorUnit,
tm: &TimeMap,
target: &mut EcGroup,
col: &mut Collector,
) {
let Some(tr) = layer.transform(comp.0, comp.1) else { return };
let Some(PropNode::Group(group)) = layer.properties().and_then(|p| p.get("ADBE Transform Group")) else { return };
let (anchor_kind, anchor_px) = match anchor {
AnchorUnit::Source(w, h) => {
let (sw, sh) = (f64::from(w), f64::from(h));
(Kind::Anchor(sw, sh), tr.anchor_px(sw, sh))
}
AnchorUnit::Null(w, h) => {
let (sw, sh) = (f64::from(w), f64::from(h));
let px = match tr.anchor_fraction.source {
ValueSource::Default | ValueSource::Unreadable => [0.0; 3],
ValueSource::Stored | ValueSource::Keyframed => tr.anchor_px(sw, sh),
};
(Kind::Anchor(sw, sh), px)
}
AnchorUnit::Pixels | AnchorUnit::PointOfInterest => (Kind::Anchor(1.0, 1.0), tr.text_anchor_px()),
};
let poi = anchor == AnchorUnit::PointOfInterest;
for (match_name, id) in SPECS {
let (kind, base, source_of) = match id {
"anchor" => (anchor_kind, Value::Vec3(anchor_px), tr.anchor_fraction.source),
"position" => (Kind::Position, Value::Vec3(tr.position.value), tr.position.source),
"scale" => (Kind::Scale, Value::Vec3(tr.scale_percent.value), tr.scale_percent.source),
"rotation" => (Kind::Rotation, Value::Scalar(tr.rotation_degrees.value), tr.rotation_degrees.source),
_ => (Kind::Opacity, Value::Scalar(tr.opacity_percent.value), tr.opacity_percent.source),
};
let target_id = if poi && id == "anchor" { "poi" } else { id };
let Some(prop) = target.get_mut(target_id) else { continue };
if poi && id == "anchor" && source_of == ValueSource::Default {
// No record: the default point of interest of the new layer stays.
continue;
}
if source_of == ValueSource::Unreadable {
// Present in the file but not decodable: the reader gave the default in its place.
col.lose(LossCategory::DamagedData, format!("{layer_name}: {match_name} unreadable, default used"));
}
if sane(&base) {
prop.value = base;
} else {
col.lose(LossCategory::DamagedData, format!("{layer_name}: {match_name} holds a number that is not valid, default used"));
}
let Some(PropNode::Property(node)) = group.get(match_name) else { continue };
if source_of == ValueSource::Keyframed {
import_keys(&node, kind, layer_name, match_name, tm, prop, col);
}
import_expression(&node, prop, col);
}
for (match_name, id) in ROTATIONS {
let Some(PropNode::Property(node)) = group.get(match_name) else { continue };
let Some(prop) = target.get_mut(id) else {
col.lose(LossCategory::TransformProperties, format!("{layer_name}: {match_name}"));
continue;
};
if node.is_animated() {
import_keys(&node, Kind::Rotation, layer_name, match_name, tm, prop, col);
} else if let Some(v) = node.static_value().and_then(|v| v.first().copied()) {
let value = Value::Scalar(v);
if sane(&value) {
prop.value = value;
} else {
col.lose(LossCategory::DamagedData, format!("{layer_name}: {match_name} holds a number that is not valid, default used"));
}
}
import_expression(&node, prop, col);
}
if let Some(PropNode::Other(_)) = group.get("ADBE Orientation") {
match (layer.orientation(), target.get_mut("orientation")) {
(Some(o), Some(prop)) if !o.animated && !o.has_expression && sane(&Value::Vec3(o.degrees)) => prop.value = Value::Vec3(o.degrees),
// A layer without an orientation (an ambient light) loses nothing with the default.
(Some(o), None) if !o.animated && !o.has_expression && o.degrees == [0.0; 3] => {}
(Some(o), Some(prop)) if o.animated && !o.has_expression => import_orientation_keys(layer, layer_name, tm, prop, col),
(Some(o), Some(_)) if o.has_expression => {
col.lose(LossCategory::TransformProperties, format!("{layer_name}: ADBE Orientation expression (not decoded)"));
}
_ => col.lose(LossCategory::TransformProperties, format!("{layer_name}: ADBE Orientation")),
}
}
report_other_properties(&group, layer_name, col);
}
/// The keys of a keyed Orientation (703). Linear and hold sides arrive as they are; the ease of a
/// bezier side is not sampled, so it becomes linear and is reported. EffectCraft interpolates the
/// three angles one by one, After Effects by a slerp of the rotations (a renderer difference).
fn import_orientation_keys(layer: &AepLayer<'_, '_>, layer_name: &str, tm: &TimeMap, prop: &mut EcProp, col: &mut Collector) {
let what = format!("{layer_name}: ADBE Orientation");
let stored = layer.orientation_keys();
if stored.is_empty() {
col.lose(LossCategory::TransformProperties, format!("{what} keys unreadable, the static value is used"));
return;
}
// Key times count in the property's time scale (a stretched layer's is scaled), as in
// `import_keys`.
let scale = layer.orientation_time_scale().filter(|s| *s != 0).unwrap_or(tm.timescale);
let mut keys: Vec<Keyframe> = Vec::with_capacity(stored.len());
let mut approximated = false;
for k in &stored {
let value = Value::Vec3(k.degrees);
if !sane(&value) {
col.lose(LossCategory::DamagedData, format!("{what}: keyframe value is not a number"));
continue;
}
let mut key = Keyframe::new(tm.units(i64::from(k.time_units), scale), value);
for (side, i) in [(&mut key.in_interp, k.interpolation_in), (&mut key.out_interp, k.interpolation_out)] {
*side = match interp(Some(i), col, &what) {
Interp::Bezier => {
approximated = true;
Interp::Linear
}
other => other,
};
}
keys.push(key);
}
if approximated {
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: bezier key read as linear (the Orientation ease is not sampled)"));
}
keys.sort_by_key(|k| k.time);
keys.dedup_by_key(|k| k.time);
col.imported.keyframes = col.imported.keyframes.saturating_add(keys.len());
if let Some(first) = keys.first() {
prop.value = first.value.clone();
}
prop.keys = keys;
}
/// Stored transform properties that are not imported are reported.
fn report_other_properties(group: &PropGroup<'_, '_>, layer_name: &str, col: &mut Collector) {
for entry in group.entries() {
let name = entry.match_name.as_str();
if SPECS.iter().chain(ROTATIONS.iter()).any(|(m, _)| *m == name) || name == "ADBE Group End" {
continue;
}
let PropNode::Property(p) = entry.node else { continue };
// A property in its default state (`tdsb` 3) holds nothing to lose.
let stored = p.is_animated() || p.flags() == Some(1) || p.expression().is_some();
if stored && !(IGNORED.contains(&name) && p.flags() != Some(1) && !p.is_animated()) {
col.lose(LossCategory::TransformProperties, format!("{layer_name}: {name}"));
}
}
}
/// Attaches the expression of `node` (source text and enable switch) to `prop`.
pub(crate) fn import_expression(node: &AepProp<'_, '_>, prop: &mut EcProp, col: &mut Collector) {
let Some(enabled) = node.expression_enabled() else { return };
let Some(text) = node.expression() else { return };
prop.expr = Some(Expression { text, enabled });
col.imported.expressions += 1;
}
pub(crate) fn interp(i: Option<Interpolation>, col: &mut Collector, what: &str) -> Interp {
match i {
Some(Interpolation::Linear) | None => Interp::Linear,
Some(Interpolation::Bezier) => Interp::Bezier,
Some(Interpolation::Hold) => Interp::Hold,
Some(Interpolation::Other(code)) => {
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: unknown interpolation {code} read as linear"));
Interp::Linear
}
}
}
/// An ease that can be evaluated: finite speed, influence within 0.1 % and 100 %.
fn ease(speed: f64, influence: f64, factor: f64) -> Option<Ease> {
let speed = speed * factor;
(speed.is_finite() && speed.abs() <= LIMIT * 100.0 && influence.is_finite()).then(|| Ease { speed, influence: influence.clamp(0.001, 1.0) })
}
/// The temporal ease of both sides of a key: `(in, out)` lists with one entry per dimension, or
/// `None` where the data cannot be used.
fn key_eases(key: &AepKey<'_>, kind: Kind) -> Option<(Vec<Ease>, Vec<Ease>)> {
let f = kind.factor();
match key.kind() {
KeyframeKind::Spatial { .. } | KeyframeKind::Plain { components: 1 } => {
let e = key.ease()?;
Some((vec![ease(e.in_speed, e.in_influence, f)?], vec![ease(e.out_speed, e.out_influence, f)?]))
}
KeyframeKind::Plain { components } => {
// One ease per dimension (the stored slots are type-major, see
// `Keyframe::ease_component`), so Scale keys with a different ease in each
// dimension stay bezier.
let all = key.ease_components()?;
if all.len() != components {
return None;
}
let mut in_e = Vec::with_capacity(components);
let mut out_e = Vec::with_capacity(components);
for e in &all {
in_e.push(ease(e.in_speed, e.in_influence, f)?);
out_e.push(ease(e.out_speed, e.out_influence, f)?);
}
Some((in_e, out_e))
}
_ => None,
}
}
/// Keyframes of `node` into `prop`. Leaves `prop` untouched when no key is readable.
pub(crate) fn import_keys(node: &AepProp<'_, '_>, kind: Kind, layer_name: &str, name: &str, tm: &TimeMap, prop: &mut EcProp, col: &mut Collector) {
let what = format!("{layer_name}: {name}");
let Some(list) = node.keys() else { return };
let scale = node.time_scale().filter(|s| *s != 0).unwrap_or(tm.timescale);
let mut keys: Vec<Keyframe> = Vec::new();
for rec in list.keyframes() {
let (Some(units), Some(raw)) = (rec.time_units(), rec.value()) else {
col.lose(LossCategory::DamagedData, format!("{what}: unreadable keyframe"));
continue;
};
let Some(value) = kind.value(&raw) else {
col.lose(LossCategory::DamagedData, format!("{what}: keyframe value is missing or not a number"));
continue;
};
let mut key = Keyframe::new(tm.units(i64::from(units), scale), value);
key.in_interp = interp(rec.interpolation_in(), col, &what);
key.out_interp = interp(rec.interpolation_out(), col, &what);
if let Some(spatial) = rec.spatial() {
// The switch is 1 for automatic spatial tangents (EffectCraft computes them) and 0
// once the tangents were set by hand.
key.spatial_auto = spatial.auto_bezier_flag != 0;
key.spatial_in = kind.tangent(&spatial.in_tangent);
key.spatial_out = kind.tangent(&spatial.out_tangent);
}
key.roving = rec.is_roving().unwrap_or(false);
key.label = rec.label().filter(|l| *l <= 16).unwrap_or(0);
// Colour keys switched to bezier store speed 0 and influence 0 on both sides, which After
// Effects interpolates linearly (598).
if kind == Kind::EffectColor
&& (key.in_interp == Interp::Bezier || key.out_interp == Interp::Bezier)
&& rec.ease().is_some_and(|e| [e.in_speed, e.in_influence, e.out_speed, e.out_influence] == [0.0; 4])
{
if key.in_interp == Interp::Bezier {
key.in_interp = Interp::Linear;
}
if key.out_interp == Interp::Bezier {
key.out_interp = Interp::Linear;
}
}
if key.in_interp == Interp::Bezier || key.out_interp == Interp::Bezier {
let undecoded = !kind.ease_decoded();
match key_eases(&rec, kind).filter(|_| !undecoded) {
Some((in_e, out_e)) => {
if key.in_interp == Interp::Bezier {
key.in_ease = in_e;
}
if key.out_interp == Interp::Bezier {
key.out_ease = out_e;
}
}
None => {
// The ease cannot be used, so the bezier sides become linear.
let why = if undecoded { "the ease of this effect parameter is not decoded" } else { "ease data not decodable" };
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: bezier key read as linear ({why})"));
if key.in_interp == Interp::Bezier {
key.in_interp = Interp::Linear;
}
if key.out_interp == Interp::Bezier {
key.out_interp = Interp::Linear;
}
}
}
}
keys.push(key);
}
if keys.is_empty() {
return;
}
keys.sort_by_key(|k| k.time);
let before = keys.len();
keys.dedup_by_key(|k| k.time);
if keys.len() != before {
col.lose(LossCategory::DamagedData, format!("{what}: keyframes at the same time"));
}
// The first key's incoming and the last key's outgoing ease mean nothing.
if let Some(first) = keys.first_mut() {
first.in_ease.clear();
}
if let Some(last) = keys.last_mut() {
last.out_ease.clear();
}
col.imported.keyframes += keys.len();
if let Some(first) = keys.first() {
prop.value = first.value.clone();
}
prop.keys = keys;
}