Files
Valentin Klein bb78d55825 After Effects project import (.aep / .aepx); export follows (#2558)
Add After Effects project import with bounded parsing, explicit loss reports, and regression coverage.

Fetch the cleaned external test corpus at commit 97262276f625afdeeb61787e4cbb74b3f17af510 and verify each fixture against the pinned SHA-256 manifest.
2026-11-04 12:06:08 +01:00

365 lines
15 KiB
Rust

//! The layer header (`ldta`) and the composition header (`cdta`, `PRin`), checked against the values
//! the synthetic corpus was made with (its MANIFEST): comp `C` 1920x1080, 25 fps, 5 s, background
//! black, shutter angle 180, one solid layer `L` (red, 1920x1080, label 1, Normal, Best, start 0,
//! in 0, out 5, stretch 100), then one change per file.
use crate::container::helpers::{base_layer, first_comp, open};
use effectcraft_aep::{BlendMode, LayerHeader, LayerType, Project, Quality, Rational};
const L: u32 = 0x1b;
/// The header of the solid layer `L` in a fixture.
fn l_header(name: &str) -> Option<LayerHeader> {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let comp = first_comp(&project);
base_layer(&comp).header().unwrap()
})
}
/// The switches that the MANIFEST lists, so that a case can say which one differs from the base.
#[derive(Debug, Clone, PartialEq)]
struct Switches {
shy: bool,
locked: bool,
solo: bool,
three_d: bool,
motion_blur: bool,
collapse: bool,
adjustment: bool,
guide: bool,
}
fn switches(h: &LayerHeader) -> Switches {
Switches {
shy: h.is_shy(),
locked: h.is_locked(),
solo: h.is_solo(),
three_d: h.is_three_d(),
motion_blur: h.is_motion_blur(),
collapse: h.collapses_transformations(),
adjustment: h.is_adjustment(),
guide: h.is_guide(),
}
}
const NONE: Switches =
Switches { shy: false, locked: false, solo: false, three_d: false, motion_blur: false, collapse: false, adjustment: false, guide: false };
/// Everything the base layer and every one-switch case share.
fn assert_plain(h: &LayerHeader) {
// The id is the case's own (0x1b in the single-case files); the source is the solid `L`.
assert_eq!(h.layer_type, LayerType::Av);
assert_eq!(h.label, 1);
// No name was set, so the copy of the name at byte 64 is empty (the layer shows its source name).
assert_eq!(h.name_copy, "");
assert!(!h.name_is_user_set());
assert!(!h.is_null_object());
assert_eq!(h.parent_id, None);
assert_eq!(h.source_id, Some(0x1a));
}
#[test]
fn base_layer_header() {
let h = skip!(l_header("000-base.aep"));
assert_plain(&h);
assert_eq!(h.quality, Quality::Best);
assert_eq!(h.blend_mode, BlendMode::Normal);
assert_eq!(h.stretch, 1);
assert_eq!(h.stretch_percent(), 100.0);
assert_eq!(switches(&h), NONE);
// start 0 s, in 0 s, out 5 s; denominator = the composition timescale.
assert_eq!(h.start, Rational { num: 0, den: 25600 });
assert_eq!(h.in_relative, Rational { num: 0, den: 25600 });
assert_eq!(h.out_relative, Rational { num: 128_000, den: 25600 });
assert_eq!(h.start_seconds(), Some(0.0));
assert_eq!(h.in_point_seconds(), Some(0.0));
assert_eq!(h.out_point_seconds(), Some(5.0));
assert_eq!(h.out_point(), Rational::new(5, 1));
}
#[test]
fn one_switch_per_case() {
// (file, the switches that case turns on)
let cases = [
("012-layer-shy.aep", Switches { shy: true, ..NONE }),
("013-layer-locked.aep", Switches { locked: true, ..NONE }),
("014-layer-solo.aep", Switches { solo: true, ..NONE }),
("015-layer-3d.aep", Switches { three_d: true, ..NONE }),
("017-layer-motionblur.aep", Switches { motion_blur: true, ..NONE }),
("024-layer-collapse.aep", Switches { collapse: true, ..NONE }),
("025-layer-adjustment.aep", Switches { adjustment: true, ..NONE }),
("026-layer-guide.aep", Switches { guide: true, ..NONE }),
];
for (name, expected) in cases {
let h = skip!(l_header(name));
assert_eq!(switches(&h), expected, "{name}");
assert_plain(&h);
assert_eq!((h.quality, h.blend_mode, h.stretch), (Quality::Best, BlendMode::Normal, 1), "{name}");
assert_eq!(h.out_point_seconds(), Some(5.0), "{name}");
}
}
#[test]
fn name_blend_mode_and_quality() {
let h = skip!(l_header("010-layer-name.aep"));
assert_eq!(h.name_copy, "Renamed");
assert!(h.name_is_user_set());
let bytes = fixture!("010-layer-name.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let layer = base_layer(&first_comp(&project));
// The layer name is the user's; the source footage keeps its own name.
assert_eq!(layer.name().as_deref(), Some("Renamed"));
assert_eq!(project.layer_source(&layer).and_then(|i| i.display_name()).as_deref(), Some("L"));
assert_eq!(project.layer_display_name(&layer).as_deref(), Some("Renamed"));
assert_eq!(skip!(l_header("016-layer-blend-multiply.aep")).blend_mode, BlendMode::Multiply);
assert_eq!(skip!(l_header("023-layer-quality-draft.aep")).quality, Quality::Draft);
}
#[test]
fn display_name_falls_back_to_the_source_name() {
let bytes = fixture!("000-base.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let layer = base_layer(&first_comp(&project));
assert_eq!(layer.name(), None);
assert_eq!(project.layer_display_name(&layer).as_deref(), Some("L"));
assert_eq!(project.layer_source_size(&layer), Some((1920, 1080)));
let source = project.layer_source(&layer).unwrap();
assert_eq!(source.id(), Some(0x1a));
assert!(source.footage().unwrap().is_solid);
}
#[test]
fn time_stretch_and_the_in_out_start_convention() {
// 018: stretch 100 -> 200: the stored out point stays 5 s, the layer ends at 10 s.
let h = skip!(l_header("018-layer-stretch200.aep"));
assert_eq!(h.stretch, 2);
assert_eq!(h.stretch_percent(), 200.0);
assert_eq!(h.out_relative, Rational { num: 128_000, den: 25600 });
assert_eq!(h.out_point_seconds(), Some(10.0));
assert_eq!(h.in_point_seconds(), Some(0.0));
// 019: inPoint 0 -> 1; AE moved the out point to 6 s and left the start at 0.
let h = skip!(l_header("019-layer-inpoint1.aep"));
assert_eq!(h.start_seconds(), Some(0.0));
assert_eq!(h.in_relative, Rational { num: 25600, den: 25600 });
assert_eq!(h.in_point(), Rational::new(1, 1));
assert_eq!(h.out_point_seconds(), Some(6.0));
// 021: startTime 0 -> 0.5: in 0.5 s and out 5.5 s although the stored in point stays 0.
let h = skip!(l_header("021-layer-starttime0.5.aep"));
assert_eq!(h.start, Rational { num: 12800, den: 25600 });
assert_eq!(h.start_seconds(), Some(0.5));
assert_eq!(h.in_relative.num, 0);
assert_eq!(h.in_point(), Rational::new(1, 2));
assert_eq!(h.in_point_seconds(), Some(0.5));
assert_eq!(h.out_point_seconds(), Some(5.5));
assert_eq!(h.out_point(), Rational::new(11, 2));
}
#[test]
fn parent_link_and_null_object() {
// 022: a new null layer "Null" (source "Null 1", 100x100) on top; L is its child.
let bytes = fixture!("022-layer-parent-null.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let comp = first_comp(&project);
let layers: Vec<_> = comp.layers().collect();
assert_eq!(layers.len(), 2);
let null = &layers[0];
let nh = null.header().unwrap();
assert_eq!(null.name().as_deref(), Some("Null"));
assert!(nh.is_null_object());
assert!(nh.name_is_user_set());
assert_eq!(nh.layer_type, LayerType::Av);
assert_eq!(nh.parent_id, None);
assert_eq!(project.layer_source(null).and_then(|i| i.display_name()).as_deref(), Some("Null 1"));
assert_eq!(project.layer_source_size(null), Some((100, 100)));
let lh = layers[1].header().unwrap();
assert_ne!(lh.id, nh.id);
assert_eq!(lh.parent_id, Some(nh.id));
assert!(!lh.is_null_object());
}
#[test]
fn layer_header_is_none_when_ldta_is_too_short() {
assert!(LayerHeader::parse(&[0; 139]).is_none());
assert!(LayerHeader::parse(&[]).is_none());
assert!(LayerHeader::parse(&[0; 140]).is_some());
}
#[test]
fn rational_is_exact() {
assert_eq!(Rational::new(12800, 25600), Some(Rational { num: 1, den: 2 }));
assert_eq!(Rational::new(-3, -6), Some(Rational { num: 1, den: 2 }));
assert_eq!(Rational::new(3, -6), Some(Rational { num: -1, den: 2 }));
assert_eq!(Rational::new(0, 7), Some(Rational { num: 0, den: 1 }));
assert_eq!(Rational::new(1, 0), None);
assert_eq!(Rational { num: 1, den: 0 }.to_f64(), None);
assert_eq!(Rational::new(i64::MIN, 1), None);
}
// ----------------------------------------------------------------------------------- composition
use effectcraft_aep::CompHeader;
fn comp_header(name: &str) -> Option<(CompHeader, Option<String>, Option<(String, String)>)> {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let comp = first_comp(&project);
(comp.header().unwrap(), comp.name(), comp.renderer().map(|r| (r.match_name, r.display_name)))
})
}
#[test]
fn base_comp_header() {
let (h, name, renderer) = skip!(comp_header("000-base.aep"));
assert_eq!(name.as_deref(), Some("C"));
assert_eq!((h.width, h.height), (1920, 1080));
assert_eq!((h.time_base.frame_duration, h.time_base.timescale), (1024, 25600));
assert_eq!(h.frame_rate(), Some(25.0));
assert_eq!(h.frame_rate_from_fixed(), 25.0);
assert_eq!(h.duration_units, 128_000);
assert_eq!(h.duration_seconds(), Some(5.0));
assert_eq!(h.background, [0, 0, 0]);
assert_eq!(h.background_rgb(), [0.0, 0.0, 0.0]);
assert_eq!(h.shutter_angle, 180);
assert_eq!(h.work_area_start_seconds(), Some(0.0));
assert_eq!(h.work_area_end_units, None);
assert_eq!(h.work_area_end_seconds(), Some(5.0));
assert_eq!(h.pixel_aspect, Rational { num: 1, den: 1 });
assert_eq!(h.pixel_aspect_ratio(), Some(1.0));
assert!(!h.motion_blur);
// The manifest calls the classic renderer `ADBE Advanced 3d`; the file stores `ADBE Escher`
// and the display name `Classic 3D`.
assert_eq!(renderer, Some(("ADBE Escher".to_string(), "Classic 3D".to_string())));
}
#[test]
fn one_comp_change_per_case() {
let (base, _, _) = skip!(comp_header("000-base.aep"));
let (h, _, _) = skip!(comp_header("030-comp-size-1280x720.aep"));
assert_eq!((h.width, h.height), (1280, 720));
assert_eq!(CompHeader { width: 1920, height: 1080, ..h }, base);
// 031: frameRate 25 -> 30: the frame duration stays 1024, the timescale becomes 30720; the
// duration stays 5 s (153 600 units).
let (h, _, _) = skip!(comp_header("031-comp-fps30.aep"));
assert_eq!((h.time_base.frame_duration, h.time_base.timescale), (1024, 30720));
assert_eq!(h.frame_rate(), Some(30.0));
assert_eq!(h.frame_rate_from_fixed(), 30.0);
assert_eq!(h.duration_units, 153_600);
assert_eq!(h.duration_seconds(), Some(5.0));
// 032: duration 5 -> 10 s; the work area stays the whole composition.
let (h, _, _) = skip!(comp_header("032-comp-duration10.aep"));
assert_eq!(h.duration_units, 256_000);
assert_eq!(h.duration_seconds(), Some(10.0));
assert_eq!(h.work_area_end_seconds(), Some(10.0));
// 033: bgColor [0,0,0] -> [0,1,0].
let (h, _, _) = skip!(comp_header("033-comp-bg-green.aep"));
assert_eq!(h.background, [0, 255, 0]);
assert_eq!(h.background_rgb(), [0.0, 1.0, 0.0]);
// 034: shutterAngle 180 -> 90.
let (h, _, _) = skip!(comp_header("034-comp-shutter90.aep"));
assert_eq!(h.shutter_angle, 90);
assert_eq!(CompHeader { shutter_angle: 180, ..h }, base);
// 035: workAreaStart 0 -> 1, workAreaDuration 5 -> 2: the end is start + duration = 3 s.
let (h, _, _) = skip!(comp_header("035-comp-workarea-1-2.aep"));
assert_eq!(h.work_area_start_units, 25600);
assert_eq!(h.work_area_end_units, Some(76_800));
assert_eq!(h.work_area_start_seconds(), Some(1.0));
assert_eq!(h.work_area_end_seconds(), Some(3.0));
}
#[test]
fn one_comp_change_per_case_aspect_blur_renderer() {
let (base, _, base_renderer) = skip!(comp_header("000-base.aep"));
// 037: pixelAspect 1 -> 2.
let (h, _, _) = skip!(comp_header("037-comp-pixelaspect2.aep"));
assert_eq!(h.pixel_aspect, Rational { num: 2, den: 1 });
assert_eq!(h.pixel_aspect_ratio(), Some(2.0));
// 038: comp motion blur on.
let (h, _, _) = skip!(comp_header("038-comp-motionblur.aep"));
assert!(h.motion_blur);
assert_eq!(CompHeader { motion_blur: false, ..h }, base);
// 039: renderer -> Advanced 3D.
let (h, _, renderer) = skip!(comp_header("039-comp-renderer-advanced3d.aep"));
assert_eq!(renderer, Some(("ADBE Calder".to_string(), "Advanced 3D".to_string())));
assert_ne!(renderer, base_renderer);
assert_eq!(h, base);
}
#[test]
fn comp_header_is_none_when_cdta_is_too_short() {
assert!(CompHeader::parse(&[0; 175]).is_none());
assert!(CompHeader::parse(&[0; 176]).is_some());
}
#[test]
fn precomp_item_ids_and_labels() {
// 124: L precomposed into "Pre"; the layer in C is a comp-sourced layer labelled 15.
let bytes = fixture!("124-precomp-Pre.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let comps: Vec<_> = project.all_items().into_iter().filter_map(|i| i.comp().map(|c| (i, c))).collect();
let names: Vec<_> = comps.iter().map(|(_, c)| c.name().unwrap()).collect();
assert_eq!(names, ["C", "Pre"]);
let (c_item, c) = &comps[0];
let (pre_item, pre) = &comps[1];
let layers: Vec<_> = c.layers().collect();
assert_eq!(layers.len(), 1);
let layer = &layers[0];
let h = layer.header().unwrap();
assert_eq!(h.source_id, pre_item.id());
assert_eq!(project.layer_source(layer).unwrap().id(), pre_item.id());
assert_eq!(h.label, 15);
assert!(!h.name_is_user_set());
assert_eq!(layer.name(), None);
assert_eq!(project.layer_display_name(layer).as_deref(), Some("Pre"));
assert_eq!(project.layer_source_size(layer), Some((1920, 1080)));
// The precomp layer's start time has the denominator 1; the Pre comp is 1920x1080, 25 fps, 5 s.
assert_eq!(h.start.den, 1);
let ph = pre.header().unwrap();
assert_eq!((ph.width, ph.height, ph.frame_rate(), ph.duration_seconds()), (1920, 1080, Some(25.0), Some(5.0)));
// C records that it uses the comp Pre (chunk idpi, a little-endian u32).
assert_eq!(c_item.used_item_ids(), [pre_item.id().unwrap()]);
assert!(pre_item.used_item_ids().is_empty());
assert_eq!(pre_item.label(), Some(15));
// The layer inside Pre is a new layer made from the solid L (moving the layers into a precomp
// creates new layers).
let inner: Vec<_> = pre.layers().collect();
assert_eq!(inner.len(), 1);
let ih = inner[0].header().unwrap();
assert_ne!(ih.id, L);
assert_eq!(ih.source_id, Some(0x1a));
assert_eq!(project.layer_display_name(&inner[0]).as_deref(), Some("L"));
}
#[test]
fn a_negative_stretch_is_a_reversed_layer() {
// 981: Time Reverse (stretch -100 %, start 5 s): numerator -1 (`0xffffffff`), the stored in
// point 0 and out point 5 s relative, so the layer runs from 5 s back to 0 s; 983: -50 % = -1/2.
let h = skip!(l_header("981-layer-stretch-minus100-start5.aep"));
assert_eq!((h.stretch, h.stretch_den), (-1, 1));
assert_eq!(h.stretch_percent(), -100.0);
assert_eq!((h.in_point_seconds(), h.out_point_seconds()), (Some(5.0), Some(0.0)));
let h = skip!(l_header("983-layer-stretch-minus50-opacity-keys.aep"));
assert_eq!((h.stretch, h.stretch_den), (-1, 2));
assert_eq!((h.in_point_seconds(), h.out_point_seconds()), (Some(5.0), Some(2.5)));
}