Files
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

308 lines
15 KiB
Rust

//! Batch 4 (cases 356-383): Tint and Gradient Ramp parameters, their defaults, values, keys and
//! scales, the effect stack order, and the second instance of an effect type whose `parT` is
//! empty. Every expected value is the one the case was made with (its MANIFEST row).
use crate::container::helpers::{assert_near, base_layer, first_comp, open};
use craft_aep::{DefinitionSource, EffectParam, Interpolation, KeyframeKind, ParamKind, ParamValue, Project};
/// Key time 2 s in the property timescale 25600.
const T2: i32 = 51200;
/// Runs `f` on the project view and the parameters of the single effect of `L`.
fn with_params<R>(name: &str, f: impl FnOnce(&Project<'_, '_>, &[EffectParam<'_, '_>]) -> 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}");
// The first instance of a type in the combined file (the case that adds it at its defaults)
// keeps its own definitions; the later cases read the project copy.
let first = ["356-fx-tint-default.aep", "368-fx-ramp-default.aep", "389-fx-exposure-default.aep"].contains(&name);
let want = if first { DefinitionSource::Instance } else { DefinitionSource::Project };
assert_eq!(project.definition_source(&effects[0]), want, "{name}");
let params = project.effect_params(&effects[0]);
f(&project, &params)
})
}
fn argb(v: Option<ParamValue>) -> [f64; 4] {
match v {
Some(ParamValue::Argb(c)) => c,
other => panic!("{other:?} is not a colour"),
}
}
fn point(v: Option<ParamValue>) -> [f64; 2] {
match v {
Some(ParamValue::Point(p)) => p,
other => panic!("{other:?} is not a point"),
}
}
fn number(v: Option<ParamValue>) -> f64 {
match v {
Some(ParamValue::Number(n)) => n,
other => panic!("{other:?} is not a number"),
}
}
/// The colour as the case set it: float32 precision (0.2 becomes 0.20000000298).
const F32: f64 = 1e-6;
// ---------------------------------------------------------------------------- Tint
#[test]
fn tint_parameters_and_defaults() {
// 356: Tint with every parameter at its default.
let bytes = fixture!("356-fx-tint-default.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let fx = base_layer(&first_comp(&project)).effects();
let e = &fx[0];
assert_eq!((e.match_name(), e.name().as_deref()), ("ADBE Tint", Some("Tint")));
assert_eq!(e.declared_param_count(), Some(6));
let p = project.effect_params(e);
let kinds: Vec<_> = p.iter().map(|p| (p.kind, p.name.as_str())).collect();
assert_eq!(
kinds,
[
(ParamKind::Layer, ""),
(ParamKind::Color, "Map Black To"),
(ParamKind::Color, "Map White To"),
(ParamKind::FixedSlider, "Amount to Tint"),
(ParamKind::Button, ""),
(ParamKind::NoData, ""),
]
);
assert_eq!(p[4].match_name, "ADBE Tint-0004");
// The defaults have alpha 0 (what After Effects reads back: [0,0,0,0] / [1,1,1,0]).
assert_eq!(p[1].value(), Some(ParamValue::Argb([0.0, 0.0, 0.0, 0.0])));
assert_eq!(p[2].value(), Some(ParamValue::Argb([0.0, 1.0, 1.0, 1.0])));
assert!(p[1].stored().is_none() && p[2].stored().is_none() && p[3].stored().is_none());
// Amount to Tint: 100 percent, stored as a plain percent (valid max 100).
assert_eq!(p[3].value(), Some(ParamValue::Number(100.0)));
assert_eq!(p[3].valid_max(), Some(100.0));
// Swap Colors: a button whose record always exists and holds no value; its label is in pdnm.
assert!(p[4].stored().is_some());
assert_eq!(p[4].value(), None);
assert_eq!(p[4].is_default(), None);
assert_eq!(p[4].items.as_deref(), Some(&["Swap Colors".to_string()][..]));
assert!(p.iter().all(|p| !p.borrowed_definition && !p.is_animated()));
}
#[test]
fn tint_colour_and_amount_values() {
// 357: Map Black To [0.2, 0.4, 0.6], stored with alpha 255.
skip!(with_params("357-fx-tint-black-0.2-0.4-0.6.aep", |_, p| {
assert_near(&argb(p[1].stored_value()), &[1.0, 0.2, 0.4, 0.6], F32);
assert_eq!(p[1].is_default(), Some(false));
assert_eq!(p[2].value(), Some(ParamValue::Argb([0.0, 1.0, 1.0, 1.0])));
}));
// 361: Amount to Tint 0 writes a record with 0.
skip!(with_params("361-fx-tint-amount-0.aep", |_, p| {
assert_eq!(p[3].stored_value(), Some(ParamValue::Number(0.0)));
assert_eq!(p[3].is_default(), Some(false));
}));
}
#[test]
fn tint_keyframes() {
// 363: Amount to Tint keyed 20 at 0 s, 80 at 2 s, linear: plain 48-byte records.
skip!(with_params("363-fx-tint-amount-2key.aep", |_, p| {
let amount = &p[3];
assert!(amount.is_animated());
assert_eq!(amount.stored_value(), None);
let keys: Vec<_> = amount.keys().unwrap().keyframes().collect();
assert_eq!(keys.len(), 2);
assert_eq!(keys.iter().map(|k| k.time_units().unwrap()).collect::<Vec<_>>(), [0, T2]);
assert_eq!(keys.iter().map(|k| k.scalar_value().unwrap()).collect::<Vec<_>>(), [20.0, 80.0]);
assert!(keys.iter().all(|k| k.kind() == KeyframeKind::Plain { components: 1 } && k.interpolation_out() == Some(Interpolation::Linear)));
}));
// 364: Map White To keyed [1,1,0] -> [0,0,1]: 152-byte colour records, ARGB 0..255.
skip!(with_params("364-fx-tint-white-2key.aep", |_, p| {
let white = &p[2];
assert!(white.is_animated());
let keys: Vec<_> = white.keys().unwrap().keyframes().collect();
assert_eq!(keys.len(), 2);
assert!(keys.iter().all(|k| k.kind() == KeyframeKind::Spatial { components: 4 } && k.raw().len() == 152));
assert_eq!(keys.iter().map(|k| k.time_units().unwrap()).collect::<Vec<_>>(), [0, T2]);
assert_near(&keys[0].value().unwrap(), &[255.0, 255.0, 255.0, 0.0], 1e-9);
assert_near(&keys[1].value().unwrap(), &[255.0, 0.0, 0.0, 255.0], 1e-9);
}));
}
#[test]
fn effect_stack_order_is_the_parade_order() {
// 366: Gaussian Blur moved above the Tint.
let bytes = fixture!("366-fx-blur-then-tint.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let fx = base_layer(&first_comp(&project)).effects();
assert_eq!(fx.iter().map(|e| e.match_name()).collect::<Vec<_>>(), ["ADBE Gaussian Blur 2", "ADBE Tint"]);
// Both types first appear in earlier cases of the combined file (214, 356), so both read the
// project copy, which holds one copy per type.
assert!(fx.iter().all(|e| project.definition_source(e) == DefinitionSource::Project));
let defs: Vec<String> = project.effect_definitions().into_iter().map(|(m, _)| m).collect();
for m in ["ADBE Gaussian Blur 2", "ADBE Tint"] {
assert_eq!(defs.iter().filter(|d| *d == m).count(), 1, "{m}");
}
}
// ---------------------------------------------------------------------------- Gradient Ramp
#[test]
fn ramp_parameters_and_defaults() {
// 368: Gradient Ramp with every parameter at its default.
skip!(with_params("368-fx-ramp-default.aep", |_, p| {
let kinds: Vec<_> = p.iter().map(|p| (p.kind, p.name.as_str())).collect();
assert_eq!(
kinds,
[
(ParamKind::Layer, ""),
(ParamKind::Point, "Start of Ramp"),
(ParamKind::Color, "Start Color"),
(ParamKind::Point, "End of Ramp"),
(ParamKind::Color, "End Color"),
(ParamKind::Popup, "Ramp Shape"),
(ParamKind::FixedSlider, "Ramp Scatter"),
(ParamKind::FixedSlider, "Blend With Original"),
(ParamKind::Button, ""),
(ParamKind::NoData, ""),
]
);
// Start [960,0] and End [960,1080] on the 1920x1080 layer: fractions of the layer size.
assert_eq!(p[1].value(), Some(ParamValue::Point([0.5, 0.0])));
assert_eq!(p[3].value(), Some(ParamValue::Point([0.5, 1.0])));
assert_eq!(p[1].point_px(1920.0, 1080.0), Some([960.0, 0.0]));
assert_eq!(p[3].point_px(1920.0, 1080.0), Some([960.0, 1080.0]));
// Black to white with alpha 255.
assert_eq!(p[2].value(), Some(ParamValue::Argb([1.0, 0.0, 0.0, 0.0])));
assert_eq!(p[4].value(), Some(ParamValue::Argb([1.0, 1.0, 1.0, 1.0])));
// Linear Ramp is item 1 of 2.
assert_eq!(p[5].value(), Some(ParamValue::Index(1)));
assert_eq!(p[5].popup_item_count(), Some(2));
assert_eq!(p[5].popup_selected_item(), Some("Linear Ramp"));
// Scatter 0 (pixels, valid max 512); Blend 0 (a 0..1 fraction, valid max 1).
assert_eq!((p[6].value(), p[6].valid_max()), (Some(ParamValue::Number(0.0)), Some(512.0)));
assert_eq!((p[7].value(), p[7].valid_max()), (Some(ParamValue::Number(0.0)), Some(1.0)));
assert!(p[8].stored().is_some() && p[8].value().is_none());
assert!((1..=7).all(|i| p[i].stored().is_none()));
}));
}
#[test]
fn ramp_values() {
// 369: Start of Ramp [100, 200] -> [100/1920, 200/1080].
skip!(with_params("369-fx-ramp-start-100-200.aep", |_, p| {
assert_near(&point(p[1].stored_value()), &[100.0 / 1920.0, 200.0 / 1080.0], 1e-12);
assert_near(&p[1].point_px(1920.0, 1080.0).unwrap(), &[100.0, 200.0], 1e-9);
}));
// 371: Start Color red.
skip!(with_params("371-fx-ramp-startcolor-red.aep", |_, p| {
assert_eq!(p[2].stored_value(), Some(ParamValue::Argb([1.0, 1.0, 0.0, 0.0])));
}));
// 374: End of Ramp [2000, -100], outside the layer.
skip!(with_params("374-fx-ramp-end-2000-m100.aep", |_, p| {
assert_near(&p[3].point_px(1920.0, 1080.0).unwrap(), &[2000.0, -100.0], 1e-9);
}));
// 377: Ramp Shape 2 = Radial Ramp.
skip!(with_params("377-fx-ramp-shape-radial.aep", |_, p| {
assert_eq!(p[5].stored_value(), Some(ParamValue::Index(2)));
assert_eq!(p[5].popup_selected_item(), Some("Radial Ramp"));
}));
// 378: Ramp Scatter 12 (plain pixels).
skip!(with_params("378-fx-ramp-scatter-12.aep", |_, p| assert_eq!(number(p[6].stored_value()), 12.0)));
// 380: Blend With Original 25 % is stored as the fraction 0.25.
skip!(with_params("380-fx-ramp-blend-25pct.aep", |_, p| {
assert_eq!(number(p[7].stored_value()), 0.25);
assert_eq!(p[7].valid_max(), Some(1.0));
}));
}
#[test]
fn ramp_point_keyframes() {
// 382: Start of Ramp keyed [100,100] at 0 s, [1800,900] at 2 s, linear: 104-byte spatial records.
skip!(with_params("382-fx-ramp-start-2key.aep", |_, p| {
let start = &p[1];
assert!(start.is_animated());
let keys: Vec<_> = start.keys().unwrap().keyframes().collect();
assert_eq!(keys.len(), 2);
assert!(keys.iter().all(|k| k.kind() == KeyframeKind::Spatial { components: 2 } && k.raw().len() == 104));
assert_eq!(keys.iter().map(|k| k.time_units().unwrap()).collect::<Vec<_>>(), [0, T2]);
assert_near(&keys[0].value().unwrap(), &[100.0 / 1920.0, 100.0 / 1080.0], 1e-12);
assert_near(&keys[1].value().unwrap(), &[1800.0 / 1920.0, 900.0 / 1080.0], 1e-12);
assert!(keys.iter().all(|k| k.interpolation_in() == Some(Interpolation::Linear)));
let s = keys[0].spatial().unwrap();
assert_eq!((s.auto_bezier_flag, s.in_tangent.clone(), s.out_tangent.clone()), (1, vec![0.0; 2], vec![0.0; 2]));
}));
}
#[test]
fn second_instance_takes_its_definitions_from_the_project_and_its_values_from_itself() {
// 383: L keeps its default Gradient Ramp; the new 100x100 solid S100 above it has a Ramp with
// Start of Ramp [10, 20]. One of the two instances has an empty parT.
let bytes = fixture!("383-fx-ramp-on-100x100-start-10-20.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 l = base_layer(&comp);
let small = layers.iter().find(|x| x.id() != l.id()).unwrap();
let small_fx = small.effects();
let l_fx = l.effects();
assert_eq!((small_fx.len(), l_fx.len()), (1, 1));
// In the combined file an earlier case holds the type's full definitions, so here both
// instances take them from the project copy.
assert_eq!(project.definition_source(&small_fx[0]), DefinitionSource::Project);
assert_eq!(project.definition_source(&l_fx[0]), DefinitionSource::Project);
assert!(l_fx[0].params().is_empty());
assert_eq!(l_fx[0].declared_param_count(), None);
// S100: [10,20] px on its own 100x100 size; End of Ramp default [50,100].
let sp = project.effect_params(&small_fx[0]);
assert_near(&point(sp[1].stored_value()), &[0.1, 0.2], 1e-12);
assert_near(&sp[1].point_px(100.0, 100.0).unwrap(), &[10.0, 20.0], 1e-9);
assert_eq!(sp[3].point_px(100.0, 100.0), Some([50.0, 100.0]));
// L: the 368 defaults, not the values of the project copy (which is S100's instance).
let lp = project.effect_params(&l_fx[0]);
assert_eq!(lp.len(), 10);
assert!(lp.iter().all(|p| p.borrowed_definition));
assert_eq!(lp[1].name, "Start of Ramp");
assert!(lp[1].stored().is_none());
assert_eq!(lp[1].value(), Some(ParamValue::Point([0.5, 0.0])));
assert_eq!(lp[3].value(), Some(ParamValue::Point([0.5, 1.0])));
assert_eq!(lp[2].value(), Some(ParamValue::Argb([1.0, 0.0, 0.0, 0.0])));
assert_eq!(lp[4].value(), Some(ParamValue::Argb([1.0, 1.0, 1.0, 1.0])));
assert_eq!((number(lp[6].value()), number(lp[7].value())), (0.0, 0.0));
// A borrowed popup has no reliable pard default (a selection writes into that slot).
assert_eq!(lp[5].pard_default(), None);
assert_eq!(lp[5].items.as_deref().map(<[String]>::len), Some(2));
// L's own records: the input layer (its own id) and the button.
assert_eq!(lp[0].stored_value(), Some(ParamValue::Layer(l.id().unwrap())));
assert!(lp[8].stored().is_some());
// One definition for the type, although there are two instances.
let defs = project.effect_definitions();
assert_eq!(defs.iter().filter(|(m, _)| m == "ADBE Ramp").count(), 1);
}
#[test]
fn files_without_effects_have_no_definitions() {
// The definitions are per file: a file has some exactly when one of its layers has an effect.
let mut without = 0;
for name in crate::common::FILES.iter().filter(|n| n.ends_with(".aep")) {
let bytes = skip!(crate::common::try_file(name));
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let has_effects = project.all_items().iter().filter_map(|i| i.comp()).any(|c| c.layers().any(|l| !l.effects().is_empty()));
assert_eq!(project.effect_definitions().is_empty(), !has_effects, "{name}");
without += usize::from(!has_effects);
}
assert!(without > 0);
}