Files
craft-libs/crates/aep/tests/it/container/effects_batch5.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

176 lines
8.5 KiB
Rust

//! Effects of batch 5 (cases 409-450, 511-516): integer sliders, checkbox defaults, the stored
//! scale of each slider (its valid maximum), popup items, and the arbitrary data of Hue/Saturation
//! and Levels.
use crate::container::helpers::{first_comp, open};
use craft_aep::{EffectParam, HueSaturationData, LevelsData, ParamKind, ParamValue, Project};
/// Runs `f` on the parameters of the first effect of layer L (the only layer).
fn with_params<R>(name: &str, f: impl FnOnce(&[EffectParam<'_, '_>], &craft_aep::Effect<'_, '_>) -> R) -> Option<R> {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
let project = Project::new(&file).expect("project");
let comp = first_comp(&project);
let layer = comp.layers().next().expect("L");
let fx = layer.effects().into_iter().next().expect("an effect");
let params = project.effect_params(&fx);
f(&params, &fx)
})
}
fn by_name<'p, 'f, 'a>(params: &'p [EffectParam<'f, 'a>], name: &str) -> &'p EffectParam<'f, 'a> {
params
.iter()
.find(|p| p.name == name || p.items.as_ref().is_some_and(|i| i.first().is_some_and(|f| f == name)))
.unwrap_or_else(|| panic!("no {name}"))
}
#[test]
fn invert_channel_items_and_blend() {
skip!(with_params("410-fx-invert-channel-2.aep", |p, fx| {
assert_eq!(fx.match_name(), "ADBE Invert");
let ch = by_name(p, "Channel");
assert_eq!(ch.kind, ParamKind::Popup);
// The items include separators "(-" (one index each).
assert_eq!(ch.items.as_ref().map(|i| (i.len(), i[1].as_str(), i[4].as_str(), i[15].as_str())), Some((16, "Red", "(-", "Alpha")));
assert_eq!(ch.stored_value(), Some(ParamValue::Index(2)));
assert_eq!(by_name(p, "Blend With Original").valid_max(), Some(100.0));
}));
skip!(with_params("411-fx-invert-blend-50.aep", |p, _| assert_eq!(
by_name(p, "Blend With Original").stored_value(),
Some(ParamValue::Number(50.0))
)));
}
#[test]
fn fast_box_blur_integer_slider_and_checkbox_default_on() {
skip!(with_params("413-fx-fastbox-radius-20.aep", |p, _| {
let r = by_name(p, "Blur Radius");
assert_eq!((r.kind, r.valid_max(), r.stored_value()), (ParamKind::FixedSlider, Some(30000.0), Some(ParamValue::Number(20.0))));
let it = by_name(p, "Iterations");
assert_eq!(
(it.kind, it.pard_default(), it.valid_max(), it.stored_value()),
(ParamKind::IntSlider, Some(ParamValue::Number(3.0)), Some(50.0), None)
);
// Repeat Edge Pixels is on by default (AE read-back 1): the pard default, no record.
let rep = by_name(p, "Repeat Edge Pixels");
assert_eq!((rep.kind, rep.pard_default(), rep.value()), (ParamKind::Checkbox, Some(ParamValue::Bool(true)), Some(ParamValue::Bool(true))));
}));
skip!(with_params("414-fx-fastbox-iterations-1.aep", |p, _| assert_eq!(by_name(p, "Iterations").stored_value(), Some(ParamValue::Number(1.0)))));
skip!(with_params("416-fx-fastbox-repeatedge-off.aep", |p, _| {
let rep = by_name(p, "Repeat Edge Pixels");
assert_eq!((rep.stored_value(), rep.value(), rep.is_default()), (Some(ParamValue::Bool(false)), Some(ParamValue::Bool(false)), Some(false)));
}));
}
#[test]
fn gaussian_blur_dimensions_and_repeat_edge() {
skip!(with_params("418-fx-gaussian-dimensions-3.aep", |p, _| {
assert_eq!(by_name(p, "Blur Dimensions").stored_value(), Some(ParamValue::Index(3)));
assert_eq!(by_name(p, "Blurriness").pard_default(), Some(ParamValue::Number(25.0)));
assert_eq!(by_name(p, "Repeat Edge Pixels").value(), Some(ParamValue::Bool(true)));
}));
skip!(with_params("419-fx-gaussian-repeatedge-off.aep", |p, _| assert_eq!(
by_name(p, "Repeat Edge Pixels").value(),
Some(ParamValue::Bool(false))
)));
}
#[test]
fn drop_shadow_opacity_is_stored_on_0_to_255() {
skip!(with_params("422-fx-dropshadow-opacity-204.aep", |p, _| {
let o = by_name(p, "Opacity");
assert_eq!(
(o.valid_max(), o.pard_default(), o.stored_value()),
(Some(255.0), Some(ParamValue::Number(127.5)), Some(ParamValue::Number(204.0)))
);
assert_eq!(by_name(p, "Distance").valid_max(), Some(4000.0));
assert_eq!(by_name(p, "Shadow Only").pard_default(), Some(ParamValue::Bool(false)));
}));
skip!(with_params("426-fx-dropshadow-shadowonly-on.aep", |p, _| assert_eq!(
by_name(p, "Shadow Only").stored_value(),
Some(ParamValue::Bool(true))
)));
}
#[test]
fn glow_defaults_items_and_scales() {
skip!(with_params("427-fx-glow-default.aep", |p, _| {
let popup = |n: &str| (by_name(p, n).pard_default(), by_name(p, n).items.as_ref().map(|i| i.len()));
assert_eq!(popup("Glow Based On"), (Some(ParamValue::Index(2)), Some(2)));
assert_eq!(popup("Composite Original"), (Some(ParamValue::Index(2)), Some(3)));
assert_eq!(popup("Glow Operation"), (Some(ParamValue::Index(3)), Some(25)));
assert_eq!(popup("Color Looping"), (Some(ParamValue::Index(3)), Some(4)));
let t = by_name(p, "Glow Threshold");
assert_eq!((t.valid_max(), t.pard_default()), (Some(255.0), Some(ParamValue::Number(153.0))));
let m = by_name(p, "A & B Midpoint");
assert_eq!(m.pard_default(), Some(ParamValue::Number(0.5)));
assert!(m.valid_max().is_some_and(|v| (v - 0.99).abs() < 0.001));
}));
skip!(with_params("429-fx-glow-threshold-76.5.aep", |p, _| assert_eq!(
by_name(p, "Glow Threshold").stored_value(),
Some(ParamValue::Number(76.5))
)));
skip!(with_params("438-fx-glow-midpoint-0.25.aep", |p, _| assert_eq!(
by_name(p, "A & B Midpoint").stored_value(),
Some(ParamValue::Number(0.25))
)));
}
#[test]
fn hue_saturation_master_values_are_arbitrary_data() {
for (name, master) in [("444-fx-huesat-hue-90.aep", [90, 0, 0]), ("446-fx-huesat-light-m30.aep", [0, 0, -30])] {
skip!(with_params(name, |p, fx| {
// The visible Master parameters keep no record.
assert!(by_name(p, "Master Hue").stored().is_none(), "{name}");
let range = by_name(p, "Channel Range");
assert_eq!(range.kind, ParamKind::ArbitraryData);
let data = fx.arbitrary_data(&range.match_name).expect("data");
let d = HueSaturationData::parse(data).expect("180 bytes");
assert_eq!(d.master, master, "{name}");
assert!(d.ranges_are_default(), "{name}");
}));
}
skip!(with_params("448-fx-huesat-colorizehue-120.aep", |p, fx| {
assert_eq!(by_name(p, "Colorize Hue").stored_value(), Some(ParamValue::Number(120.0)));
assert_eq!(by_name(p, "Colorize Saturation").pard_default(), Some(ParamValue::Number(25.0)));
let d = HueSaturationData::parse(fx.arbitrary_data(&by_name(p, "Channel Range").match_name).expect("data")).expect("parses");
assert_eq!(d.master, [0, 0, 0]);
}));
}
#[test]
fn levels_values_are_arbitrary_data() {
let f32r = |x: f64| f32::from_bits((x as f32).to_bits());
for (name, rgb) in [
("512-fx-levels-inblack-0.1.aep", [f32r(0.1), 1.0, 1.0, 0.0, 1.0]),
("514-fx-levels-gamma-2.aep", [0.0, 1.0, 2.0, 0.0, 1.0]),
("516-fx-levels-outwhite-0.9.aep", [0.0, 1.0, 1.0, 0.0, f32r(0.9)]),
] {
skip!(with_params(name, |p, fx| {
assert!(by_name(p, "Input Black").stored().is_none(), "{name}: the visible parameter keeps no record");
let h = by_name(p, "Histogram");
assert_eq!(h.kind, ParamKind::ArbitraryData);
let d = LevelsData::parse(fx.arbitrary_data(&h.match_name).expect("data")).expect("108 bytes");
assert_eq!(d.header, [0x000f_10a7, 1], "{name}");
assert_eq!(d.channels[0], rgb, "{name}");
for c in &d.channels[1..] {
assert_eq!(*c, LevelsData::DEFAULT_CHANNEL, "{name}");
}
let clip = by_name(p, "Clip To Output Black");
assert_eq!(clip.items.as_ref().map(|i| i.join("|")), Some("On|Off|Off for 32 bpc Color".to_string()));
// A borrowed popup (a later Levels of the combined file) has no reliable pard default.
let want = if clip.borrowed_definition { None } else { Some(ParamValue::Index(3)) };
assert_eq!(clip.pard_default(), want, "{name}");
}));
}
}
#[test]
fn hostile_arbitrary_data_is_none() {
assert_eq!(HueSaturationData::parse(&[0; 179]), None);
assert_eq!(LevelsData::parse(&[0; 200]), None);
assert!(HueSaturationData::parse(&[0xff; 180]).is_some());
}