Files
craft-libs/crates/aep/tests/it/container/effects.rs
Dylan M. Taylorandarnav-hiwarkar 119ac0771f Add craft-aep: After Effects project container and typed view from EffectCraft (#888)
* Add craft-aep: After Effects project container and typed view from EffectCraft

The .aep (RIFX 'Egg!') and .aepx container as a lossless chunk tree that
writes back byte for byte, and the typed, read-only view over it:
compositions, layers, properties, keyframes, effects and their settings
data, masks, shapes, text documents, markers, cameras, track mattes,
footage, project settings and the saving version.

Moved from effectcraft crates/aep/src/container at f6b4da0a with its unit
tests, the container integration tests, the aepdump and stubicc tools and
docs/aep-format.md. The match-name tables (effect_map, style_map), the
importer and the exporter are written against EffectCraft's model and
stay there. ExtractorChannels::sources takes the caller's name for a
copied channel instead of an EffectCraft constant.

* craft-aep: read the Photoshop layer a footage item shows

A footage item made from one layer of a .psd stores the layer in its
`opti` (tag `8BPS`): the layer record's index (big-endian i32 at 14, -1
for the merged image), the document's two sides (little-endian u32 at 32
and 36), the layer's bounds (little-endian i32 at 78, 82, 86, 90) and its
name (UTF-8 from 344). `Footage::psd_layer` reads them as `PsdLayer`.

The format part of storytold/effectcraft#3449; the importer's use of it
stays in EffectCraft.

---------

Co-authored-by: arnav-hiwarkar <asharnav2008@gmail.com>
2026-11-11 16:42:18 -05:00

293 lines
15 KiB
Rust

//! Effect instances: match names, parameter lists from `parT`/`pard` and stored values, checked
//! against the values the corpus was made with (its MANIFEST).
use crate::container::helpers::{base_layer, first_comp, open};
use craft_aep::{Effect, ParamKind, ParamValue, Project};
/// Runs `f` on the single effect of the solid `L` in a fixture.
fn with_effect<R>(name: &str, f: impl FnOnce(&Effect<'_, '_>) -> R) -> Option<R> {
with_effect_in(name, |_, _, e| f(e))
}
/// Runs `f` on the project, the solid `L` and its single effect in a fixture.
fn with_effect_in<R>(name: &str, f: impl FnOnce(&Project<'_, '_>, &craft_aep::Layer<'_, '_>, &Effect<'_, '_>) -> R) -> Option<R> {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let layer = base_layer(&first_comp(&project));
let effects = layer.effects();
assert_eq!(effects.len(), 1, "{name}");
f(&project, &layer, &effects[0])
})
}
fn kinds(e: &Effect<'_, '_>) -> Vec<(ParamKind, String)> {
e.params().into_iter().map(|p| (p.kind, p.name)).collect()
}
fn approx(v: Option<ParamValue>, expected: f64) {
match v {
Some(ParamValue::Number(n)) => assert!((n - expected).abs() < 1e-9, "{n} != {expected}"),
other => panic!("{other:?} is not the number {expected}"),
}
}
#[test]
fn no_effects_without_a_parade_entry() {
let bytes = fixture!("000-base.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
assert!(base_layer(&first_comp(&project)).effects().is_empty());
}
#[test]
fn slider_control_with_a_stored_value() {
// 071: Slider Control, Slider = 77.
skip!(with_effect_in("071-fx-slider-value77.aep", |_, layer, e| {
assert_eq!((e.match_name(), e.name().as_deref()), ("ADBE Slider Control", Some("Slider Control")));
assert_eq!(e.declared_param_count(), Some(3));
assert_eq!(kinds(e), [(ParamKind::Layer, String::new()), (ParamKind::FloatSlider, "Slider".to_string()), (ParamKind::NoData, String::new())]);
let params = e.params();
assert_eq!(params[1].match_name, "ADBE Slider Control-0001");
assert_eq!(params[2].match_name, "ADBE Effect Built In Params");
assert_eq!(params[1].stored_value(), Some(ParamValue::Number(77.0)));
assert_eq!(params[1].value(), Some(ParamValue::Number(77.0)));
// The pard default of the slider is 0 (070 has the untouched control).
assert_eq!(params[1].pard_default(), Some(ParamValue::Number(0.0)));
// The input layer parameter has a value record of its own: its `tdpi` holds the id of the layer that owns the effect.
assert_eq!(params[0].stored_value(), Some(ParamValue::Layer(layer.id().unwrap())));
assert_eq!(params[0].pard().len(), 148);
}));
}
#[test]
fn color_control_stores_argb_doubles() {
// 073: Color -> [0.2, 0.6, 1], stored as [255, 51, 153, 255] (alpha first).
skip!(with_effect("073-fx-color-0.2-0.6-1.aep", |e| {
assert_eq!((e.match_name(), e.name().as_deref()), ("ADBE Color Control", Some("Color Control")));
assert_eq!(kinds(e), [(ParamKind::Layer, String::new()), (ParamKind::Color, "Color".to_string()), (ParamKind::NoData, String::new())]);
let color = e.params()[1].value();
let Some(ParamValue::Argb([a, r, g, b])) = color else { panic!("{color:?}") };
for (got, want) in [(a, 1.0), (r, 0.2), (g, 0.6), (b, 1.0)] {
assert!((got - want).abs() < 1e-6, "{color:?}");
}
}));
}
#[test]
fn dropdown_menu_items_and_default() {
// 077: Dropdown Menu Control with the default items.
skip!(with_effect("077-fx-dropdown-default.aep", |e| {
// A dropdown's match name is a fresh `Pseudo/@@` + 22-character id for every instance.
assert!(e.match_name().starts_with("Pseudo/@@") && e.match_name().len() == 31, "{}", e.match_name());
assert_eq!(e.name().as_deref(), Some("Dropdown Menu Control"));
assert_eq!(kinds(e), [(ParamKind::Layer, String::new()), (ParamKind::Popup, "Menu".to_string()), (ParamKind::NoData, String::new())]);
let menu = &e.params()[1];
assert_eq!(menu.items.as_deref(), Some(&["Item 1".to_string(), "Item 2".to_string(), "Item 3".to_string()][..]));
assert_eq!(menu.pard_default(), Some(ParamValue::Index(1)));
// The choice count is the u16 at byte 60 of pard.
assert_eq!(u16::from_be_bytes([menu.pard()[60], menu.pard()[61]]), 3);
// No other parameter has items.
assert!(e.params().iter().filter(|p| p.index != 1).all(|p| p.items.is_none()));
}));
}
#[test]
fn drop_shadow_defaults_come_from_pard() {
// 080: Drop Shadow at its defaults (opacity 50 % = 127.5 of 255, direction 135, distance 5, softness 0).
skip!(with_effect("080-fx-dropshadow-default.aep", |e| {
assert_eq!((e.match_name(), e.name().as_deref()), ("ADBE Drop Shadow", Some("Drop Shadow")));
let k: Vec<_> = e.params().into_iter().map(|p| (p.kind, p.name)).collect();
let expected = [
(ParamKind::Layer, ""),
(ParamKind::Color, "Shadow Color"),
(ParamKind::FixedSlider, "Opacity"),
(ParamKind::Angle, "Direction"),
(ParamKind::FixedSlider, "Distance"),
(ParamKind::FixedSlider, "Softness"),
(ParamKind::Checkbox, "Shadow Only"),
(ParamKind::NoData, ""),
];
assert_eq!(k, expected.map(|(kind, n)| (kind, n.to_string())));
let p = e.params();
approx(p[2].value(), 127.5);
approx(p[3].value(), 135.0);
approx(p[4].value(), 5.0);
approx(p[5].value(), 0.0);
// A checkbox without a record is off; the shadow colour default (ARGB bytes in pard) is black.
assert_eq!(p[6].value(), Some(ParamValue::Bool(false)));
assert_eq!(p[1].value(), Some(ParamValue::Argb([1.0, 0.0, 0.0, 0.0])));
}));
}
// ---------------------------------------------------------------------------- batch 2 (defaults and values)
fn param<'f, 'a>(e: &Effect<'f, 'a>, name: &str) -> craft_aep::EffectParam<'f, 'a> {
e.params().into_iter().find(|p| p.name == name).unwrap_or_else(|| panic!("no parameter {name}"))
}
#[test]
fn gaussian_blur_default_is_the_pard_default_25() {
// 214: Gaussian Blur, all parameters default. The file has no value record and a pard default of 25
// (the manifest says 0; the files only ever contain 25, see docs/aep-format.md).
skip!(with_effect("214-fx-gaussianblur-default.aep", |e| {
assert_eq!(e.match_name(), "ADBE Gaussian Blur 2");
let blur = param(e, "Blurriness");
assert_eq!(blur.kind, ParamKind::FloatSlider);
assert!(blur.stored().is_none());
assert_eq!(blur.stored_value(), None);
assert_eq!(blur.value(), Some(ParamValue::Number(25.0)));
assert_eq!(f64::from_be_bytes(blur.pard()[56..64].try_into().unwrap()), 25.0);
assert_eq!(f32::from_be_bytes(blur.pard()[120..124].try_into().unwrap()), 25.0);
// Dimensions: Horizontal and Vertical (the first item of the popup); Repeat Edge Pixels: on
// (its pard default is 1; After Effects reads a fresh Gaussian Blur back with Repeat Edge
// Pixels 1, 417; the corpus manifest's "off" for 214 was wrong).
let dims = param(e, "Blur Dimensions");
assert_eq!(dims.kind, ParamKind::Popup);
assert_eq!(dims.value(), Some(ParamValue::Index(1)));
assert_eq!(dims.items.as_ref().and_then(|i| i.first()).map(String::as_str), Some("Horizontal and Vertical"));
// The last parameter is the Repeat Edge Pixels checkbox (its label is not in pard).
let edge = &e.params()[3];
assert_eq!(edge.kind, ParamKind::Checkbox);
assert!(edge.stored().is_none());
assert_eq!(edge.value(), Some(ParamValue::Bool(true)));
}));
}
#[test]
fn gaussian_blur_40_adds_a_value_record_and_leaves_pard_at_the_default() {
// 216: Blurriness 40.
// The case takes its definitions from the project copy (an earlier Gaussian Blur of its file).
skip!(with_effect_in("216-fx-gaussianblur-40.aep", |project, _, e| {
let blur = project.effect_params(e).into_iter().find(|p| p.name == "Blurriness").unwrap();
assert_eq!(blur.stored().unwrap().flags(), Some(1));
assert_eq!(blur.stored_value(), Some(ParamValue::Number(40.0)));
assert_eq!(blur.value(), Some(ParamValue::Number(40.0)));
// pard holds the default, not the current value.
assert_eq!(blur.pard_default(), Some(ParamValue::Number(25.0)));
}));
}
#[test]
fn fill_defaults_come_from_pard() {
// 215: Fill with the default colour (red) and opacity (100 %); the file has no value records for them.
skip!(with_effect("215-fx-fill-default.aep", |e| {
assert_eq!(e.match_name(), "ADBE Fill");
let color = param(e, "Color");
assert_eq!(color.kind, ParamKind::Color);
assert!(color.stored().is_none());
assert_eq!(&color.pard()[56..60], [0xff, 0xff, 0x00, 0x00]);
assert_eq!(color.value(), Some(ParamValue::Argb([1.0, 1.0, 0.0, 0.0])));
let opacity = param(e, "Opacity");
assert_eq!(opacity.kind, ParamKind::FixedSlider);
assert!(opacity.stored().is_none());
assert_eq!(opacity.value(), Some(ParamValue::Number(1.0)));
}));
}
#[test]
fn checkbox_point_and_angle_values() {
// 217: Checkbox 0 -> 1 (stored 1.0).
skip!(with_effect("217-fx-checkbox-on.aep", |e| {
let p = param(e, "Checkbox");
assert_eq!(p.kind, ParamKind::Checkbox);
assert_eq!(p.stored_value(), Some(ParamValue::Bool(true)));
assert_eq!(p.value(), Some(ParamValue::Bool(true)));
assert_eq!(p.stored().unwrap().static_value(), Some(vec![1.0]));
}));
// 218: Point -> [111, 222], stored as a fraction of the layer size (the layer is 1920x1080).
skip!(with_effect("218-fx-point-111-222.aep", |e| {
let p = param(e, "Point");
assert_eq!(p.kind, ParamKind::Point);
let Some(ParamValue::Point([x, y])) = p.value() else { panic!("{:?}", p.value()) };
assert!((x - 111.0 / 1920.0).abs() < 1e-12 && (y - 222.0 / 1080.0).abs() < 1e-12, "{x} {y}");
assert!((x * 1920.0 - 111.0).abs() < 1e-9 && (y * 1080.0 - 222.0).abs() < 1e-9);
}));
// 219: Angle -> 90, stored as plain degrees.
skip!(with_effect("219-fx-angle-90.aep", |e| {
let p = param(e, "Angle");
assert_eq!(p.kind, ParamKind::Angle);
assert_eq!(p.stored_value(), Some(ParamValue::Number(90.0)));
assert_eq!(p.value(), Some(ParamValue::Number(90.0)));
}));
}
#[test]
fn layer_control_refers_to_a_layer_by_id() {
// 220: a new Null on top (layer index 1); L (now index 2) gets a Layer Control with Layer = 1: the tdpi
// next to the value record holds the id of the Null.
let bytes = fixture!("220-fx-layercontrol-to-layer1.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let comp = first_comp(&project);
let null = comp.layers().next().unwrap();
let null_id = null.id().unwrap();
assert_ne!(Some(null_id), base_layer(&comp).id());
assert!(null.header().unwrap().is_null_object());
let layer = base_layer(&comp);
let e = &layer.effects()[0];
assert_eq!(e.match_name(), "ADBE Layer Control");
let p = param(e, "Layer");
assert_eq!(p.kind, ParamKind::Layer);
assert_eq!(p.stored_value(), Some(ParamValue::Layer(null_id)));
// The implicit input layer parameter refers to the layer that owns the effect.
assert_eq!(e.params()[0].stored_value(), Some(ParamValue::Layer(layer.id().unwrap())));
}
#[test]
fn curves_and_mesh_warp_data() {
// 963: Red (0,0) (128,44) (255,255); 964: RGB (0,0) (62,24) (194,230) (255,255). Every stored
// table is the natural spline through its points.
for (name, curve, points) in [
("963-curves-red-mid-down-on-S.aep", 1usize, vec![(0u8, 0u8), (128, 44), (255, 255)]),
("964-curves-rgb-s-curve-on-S.aep", 0, vec![(0, 0), (62, 24), (194, 230), (255, 255)]),
] {
let bytes = fixture!(name);
let file = open(&bytes);
let project = craft_aep::Project::new(&file).expect("project");
let comp = first_comp(&project);
let fx = comp.layers().flat_map(|l| l.effects()).find(|e| e.match_name() == "ADBE CurvesCustom").expect("curves");
let data = fx.arbitrary_data("ADBE CurvesCustom-0001").expect("data");
let d = craft_aep::CurvesData::parse(data).expect("parse");
assert_eq!(d.curves[curve], points, "{name}");
assert!(d.curves.iter().enumerate().all(|(i, c)| i == curve || c.as_slice() == craft_aep::CURVE_IDENTITY), "{name}");
// Rebuilt from the points, the data is the stored data byte for byte.
assert_eq!(d.to_bytes().as_deref(), Some(data), "{name}");
}
// 985: a 3 x 3 mesh (4 x 4 vertices), vertex (2, 2) moved by (+132, -2) px on 1920 x 1080 with
// its handles; the others at their default.
let bytes = fixture!("985-meshwarp-3x3-vertex-moved-on-S.aep");
let file = open(&bytes);
let project = craft_aep::Project::new(&file).expect("project");
let comp = first_comp(&project);
let fx = comp.layers().flat_map(|l| l.effects()).find(|e| e.match_name() == "ADBE MESH WARP").expect("mesh");
let data = fx.arbitrary_data("ADBE MESH WARP-0004").expect("data");
let m = craft_aep::MeshData::parse(data).expect("parse");
assert_eq!((m.columns, m.rows, m.vertices.len()), (3, 3, 16));
for (k, v) in m.vertices.iter().enumerate() {
let (i, j) = (k as u32 % 4, k as u32 / 4);
let d = craft_aep::MeshData::default_vertex(3, 3, i, j);
let (dx, dy) = if (i, j) == (2, 2) { (132.0 / 1920.0, -2.0 / 1080.0) } else { (0.0, 0.0) };
for (a, b) in v.iter().zip(&d) {
let moved = if *b == [0.0, 0.0] { [0.0, 0.0] } else { [b[0] + dx, b[1] + dy] };
assert!((a[0] - moved[0]).abs() < 2e-6 && (a[1] - moved[1]).abs() < 2e-6, "vertex {i},{j}: {a:?} vs {moved:?}");
}
}
assert_eq!(m.to_bytes().as_deref(), Some(data));
}
#[test]
fn the_fx_switch_is_the_value_group_flag() {
// fx-switch.aep: two Gaussian Blurs on L, the first switched off in After Effects, the second
// added after it and left on (one save).
let bytes = skip!(crate::common::try_file("fx-switch.aep"));
let file = open(&bytes);
let p = Project::new(&file).expect("project");
let comp = first_comp(&p);
let layer = base_layer(&comp);
let fx = layer.effects();
assert_eq!(fx.iter().map(|e| e.match_name().to_string()).collect::<Vec<_>>(), ["ADBE Gaussian Blur 2", "ADBE Gaussian Blur 2"]);
assert_eq!(fx.iter().map(Effect::enabled).collect::<Vec<_>>(), [Some(false), Some(true)]);
}