diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index 5a71a86db..f6a05f83b 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -121,6 +121,10 @@ pub struct ParamMap { /// The EffectCraft parameter id. pub target: &'static str, pub conv: Conv, + /// The range EffectCraft's effect works in when it is narrower than the range After Effects + /// accepts (the effect limits the value there): a stored value or key outside it is imported + /// as it is and reported. `None`: no narrower range. + pub limit: Option<(f64, f64)>, } /// Values of an effect that are not ordinary parameters. @@ -148,7 +152,12 @@ pub struct EffectMap { } const fn pm(index: usize, kind: ParamKind, target: &'static str, conv: Conv) -> ParamMap { - ParamMap { index, kind, target, conv } + ParamMap { index, kind, target, conv, limit: None } +} + +/// [`pm`] for a parameter EffectCraft's effect limits to `lo..=hi` (see [`ParamMap::limit`]). +const fn pm_limited(index: usize, kind: ParamKind, target: &'static str, conv: Conv, lo: f64, hi: f64) -> ParamMap { + ParamMap { index, kind, target, conv, limit: Some((lo, hi)) } } const fn fx(match_name: &'static str, effect: &'static str, params: &'static [ParamMap]) -> EffectMap { @@ -340,6 +349,13 @@ pub const MATTE_USE: &[&str] = &["Red Channel", "Green Channel", "Blue Channel", /// 903. pub const SHUTTER_CONTROL: &[&str] = &["Manual", "Automatic"]; +// After Effects' popup items of the #3110 effects as the 2026-11-05 importer block stores them +// (`pdnm`; the `echo-*` and `colstab-*` compositions). +/// Echo's Echo Operator (`echo-05` ... `echo-10`). +pub const ECHO_OPERATOR: &[&str] = &["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"]; +/// Color Stabilizer's Stabilize (`colstab-02`, `colstab-03`; the fresh default is Levels). +pub const STABILIZE: &[&str] = &["Brightness", "Levels", "Curves"]; + /// The effects the importer converts. pub const EFFECTS: &[EffectMap] = &[ // Tint (356-364): Map Black To, Map White To, Amount to Tint; 4 = Swap Colors (a button). @@ -1007,6 +1023,50 @@ pub const EFFECTS: &[EffectMap] = &[ pm(4, K::FloatSlider, "vectorDetail", Conv::Plain { valid_max: 100.0 }), ], ), + // HDR Highlight Compression (importer block 2026-11-05, `hdr-*`): Amount a float slider in + // percent (valid 0..100, default 100). + fx("ADBE HDR ToneMap", "ec.utility.hdrcompression", &[pm(1, K::FloatSlider, "amount", Conv::Plain { valid_max: 100.0 })]), + // Echo (`echo-*`): Echo Time in seconds (valid -30 000..30 000), Number Of Echoes an integer + // (valid 0..30 000), Starting Intensity 0..1, Decay (valid 0..100, slider 0..1), Echo + // Operator. EffectCraft's Echo uses at most 255 echoes and a Decay up to 1. + fx( + "ADBE Echo", + "ec.time.echo", + &[ + pm(1, K::FloatSlider, "echoTime", Conv::Plain { valid_max: 30000.0 }), + pm_limited(2, K::IntSlider, "numberOfEchoes", Conv::Plain { valid_max: 30000.0 }, 0.0, 255.0), + pm(3, K::FloatSlider, "startingIntensity", Conv::Plain { valid_max: 1.0 }), + pm_limited(4, K::FloatSlider, "decay", Conv::Plain { valid_max: 100.0 }, 0.0, 1.0), + pm(5, K::Popup, "echoOperator", Conv::Popup { default: 1, items: ECHO_OPERATOR }), + ], + ), + // Linear Wipe (`linwipe-*`): Transition Completion and Feather fixed sliders (valid 100 and + // 32 000), Wipe Angle in degrees. + fx( + "ADBE Linear Wipe", + "ec.transition.linearwipe", + &[ + pm(1, K::FixedSlider, "completion", Conv::Plain { valid_max: 100.0 }), + pm(2, K::Angle, "angle", Conv::Angle), + pm(3, K::FixedSlider, "feather", Conv::Plain { valid_max: 32000.0 }), + ], + ), + // Offset (`offset-*`): Blend With Original is a 0..1 fraction, a percentage here. + fx("ADBE Offset", "ec.distort.offset", &[pm(1, K::Point, "shift", Conv::Point), pm(2, K::FixedSlider, "blend", Conv::Percent { valid_max: 1.0 })]), + // Color Stabilizer (`colstab-*`): Stabilize (fresh default Levels), the three sample points, + // Sample Size a fixed slider (valid 50, default 3). The reference frame (Set Frame) is not a + // parameter in the file and is not imported. + fx( + "ADBE Deflicker", + "ec.color.colorstabilizer", + &[ + pm(1, K::Popup, "stabilize", Conv::Popup { default: 2, items: STABILIZE }), + pm(2, K::Point, "blackPoint", Conv::Point), + pm(3, K::Point, "midPoint", Conv::Point), + pm(4, K::Point, "whitePoint", Conv::Point), + pm(5, K::FixedSlider, "sampleSize", Conv::Plain { valid_max: 50.0 }), + ], + ), // Layer Control (076, 220, 320, 321): the default is the owning layer, None is unset. fx("ADBE Layer Control", "ec.control.layer", &[pm(1, K::Layer, "layer", Conv::LayerRef { own: true })]), fx("ADBE Slider Control", "ec.control.slider", &[pm(1, K::FloatSlider, "slider", Conv::Plain { valid_max: 1_000_000.0 })]), diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 7e128376f..094d4eef3 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -268,6 +268,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_> if let Some(map) = &popup_map { remap_popup_keys(prop, map, &here, &label, col); } + report_beyond_limit(m.limit, prop.keys.iter().map(|k| &k.value), &here, &label, col); } return; } @@ -302,11 +303,24 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_> _ => convert(value, kind), }; match converted { - Some(v) => prop.value = v, + Some(v) => { + report_beyond_limit(m.limit, std::iter::once(&v), &here, &label, col); + prop.value = v; + } None => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?}), default used")), } } +/// Reports a value After Effects accepts but EffectCraft's effect limits (`ParamMap::limit`): it is +/// imported as it is, and the effect renders it as the nearest end of its range. +fn report_beyond_limit<'v>(limit: Option<(f64, f64)>, values: impl Iterator, here: &str, label: &str, col: &mut Collector) { + let Some((lo, hi)) = limit else { return }; + let beyond: Vec = values.filter_map(|v| if let Value::Scalar(x) = v { Some(*x) } else { None }).filter(|x| *x < lo || *x > hi).collect(); + if let Some(x) = beyond.first() { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} is {x}, EffectCraft's effect uses {lo} to {hi} (rendered as the nearest of them)")); + } +} + /// A layer parameter: None stays unset, the owning layer is itself (`own`) or unset, another layer /// is resolved by [`resolve_layer_refs`] once the composition's layers exist. /// `names` is `(here, label)`, the effect and the parameter for the report. diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 95dced570..178dd2bb3 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -11,8 +11,10 @@ mod combined; mod ease_round2; mod effects; mod effects_batch5; +mod effects_quickwins; mod effects_round2; mod effects_round3; +mod fx_inject; mod markers; mod masks; mod mattes; diff --git a/crates/aep/tests/it/import/effects.rs b/crates/aep/tests/it/import/effects.rs index be1293b9a..aa4180567 100644 --- a/crates/aep/tests/it/import/effects.rs +++ b/crates/aep/tests/it/import/effects.rs @@ -72,7 +72,7 @@ fn the_table_names_tint_gradient_ramp_and_exposure() { ] { assert!(t.contains(&want), "{want:?}"); } - assert_eq!(t.len(), 57); + assert_eq!(t.len(), 62); for (_, id) in t { assert!(effectcraft_effects::find(id).is_some(), "{id}"); } diff --git a/crates/aep/tests/it/import/effects_quickwins.rs b/crates/aep/tests/it/import/effects_quickwins.rs new file mode 100644 index 000000000..f2b0594b6 --- /dev/null +++ b/crates/aep/tests/it/import/effects_quickwins.rs @@ -0,0 +1,179 @@ +//! HDR Highlight Compression, Echo, Linear Wipe, Offset and Color Stabilizer (#3110) arrive as +//! EffectCraft's effects of the same name. Each is built into `synthetic.aep` (`fx_inject`) with +//! the definitions After Effects 2026 wrote for it in the importer block of 2026-11-05 (valid +//! ranges, defaults, popup items) and the values of that block's compositions. + +use crate::import::fx_inject::{Default, Fx, losses, near, num, other, popup, pt, run, slider, v}; +use effectcraft_aep::ParamKind as K; +use effectcraft_aep::import::LossCategory; +use effectcraft_keyframe::Value; + +const ECHO_OPS: &[&str] = &["Add", "Maximum", "Minimum", "Screen", "Composite In Back", "Composite In Front", "Blend"]; + +/// Echo with the definitions After Effects 2026 wrote and the stored `values`. +fn echo(values: Vec<(usize, f64)>) -> Fx { + Fx { + match_name: "ADBE Echo", + name: "Echo", + defs: vec![ + slider(K::FloatSlider, "Echo Time (seconds)", -1.0 / 30.0, (-30000.0, 30000.0)), + slider(K::IntSlider, "Number Of Echoes", 1.0, (0.0, 30000.0)), + slider(K::FloatSlider, "Starting Intensity", 1.0, (0.0, 1.0)), + slider(K::FloatSlider, "Decay", 1.0, (0.0, 100.0)), + popup("Echo Operator", 1, ECHO_OPS), + ], + values, + data: vec![], + keys: vec![], + } +} + +/// HDR Highlight Compression's Amount, set and default. +#[test] +fn hdr_highlight_compression_amount() { + let fx = |values| Fx { + match_name: "ADBE HDR ToneMap", + name: "HDR Highlight Compression", + defs: vec![slider(K::FloatSlider, "Amount", 100.0, (0.0, 100.0))], + values, + data: vec![], + keys: vec![], + }; + // `hdr-01-set-all`: 90; `hdr-00-defaults`: no record, 100. + for (values, want) in [(vec![(1, 90.0)], 90.0), (vec![], 100.0)] { + let (g, r) = run(&fx(values), "ec.utility.hdrcompression"); + assert_eq!(losses(&r, LossCategory::Effects, "ADBE HDR ToneMap"), Vec::::new()); + near(&g.expect("imported"), "amount", want); + } +} + +/// Echo's values and operator, set and default. +#[test] +fn echo_values_and_operator() { + // `echo-01-set-all` (without its Decay 20, below) and `echo-09-pop5-6`. + let (g, r) = run(&echo(vec![(1, 9.967), (2, 14.0), (3, 0.0), (5, 6.0)]), "ec.time.echo"); + assert_eq!(losses(&r, LossCategory::Effects, "ADBE Echo"), Vec::::new()); + let g = g.expect("imported"); + near(&g, "echoTime", 9.967); + near(&g, "numberOfEchoes", 14.0); + near(&g, "startingIntensity", 0.0); + near(&g, "decay", 1.0); + assert_eq!(v(&g, "echoOperator"), Value::Enum(5), "Composite In Front"); + // `echo-00-defaults`: one echo a frame back (1/30 s), Add. + let (g, _) = run(&echo(vec![]), "ec.time.echo"); + let g = g.expect("imported"); + assert!((num(&g, "echoTime") + 1.0 / 30.0).abs() < 1e-6); + assert_eq!((num(&g, "numberOfEchoes"), v(&g, "echoOperator")), (1.0, Value::Enum(0))); +} + +/// Echo values beyond EffectCraft's ranges are imported as stored and reported. +#[test] +fn echo_values_beyond_effectcrafts_range_are_reported() { + // `echo-03-max`: 30 000 echoes and Decay 100; EffectCraft's Echo uses at most 255 and 1. + let (g, r) = run(&echo(vec![(2, 30000.0), (4, 100.0)]), "ec.time.echo"); + let found = losses(&r, LossCategory::Effects, "ADBE Echo"); + assert_eq!(found.len(), 2, "{found:?}"); + assert!(found.iter().any(|d| d.contains("Number Of Echoes") && d.contains("0 to 255")), "{found:?}"); + assert!(found.iter().any(|d| d.contains("Decay") && d.contains("0 to 1")), "{found:?}"); + // Kept as stored. + let g = g.expect("still imported"); + near(&g, "numberOfEchoes", 30000.0); + near(&g, "decay", 100.0); +} + +/// A keyframed Echo parameter keeps its keys as stored and reports the range loss. +#[test] +fn echo_keys_beyond_effectcrafts_range_are_kept_and_reported() { + // Number Of Echoes keyed 10 -> 30 000: the second key is beyond EffectCraft's 255. + let mut fx = echo(vec![]); + fx.keys = vec![(2, vec![(0, 10.0), (1000, 30000.0)])]; + let (g, r) = run(&fx, "ec.time.echo"); + let found = losses(&r, LossCategory::Effects, "ADBE Echo"); + assert_eq!(found.len(), 1, "{found:?}"); + assert!(found[0].contains("Number Of Echoes") && found[0].contains("30000") && found[0].contains("0 to 255"), "{found:?}"); + assert!(losses(&r, LossCategory::DamagedData, "ADBE Echo").is_empty()); + // Both keys arrive with their values. + let g = g.expect("still imported"); + let keys = &g.prop("numberOfEchoes").expect("numberOfEchoes").keys; + let values: Vec = keys.iter().map(|k| k.value.clone()).collect(); + assert_eq!(values, [Value::Scalar(10.0), Value::Scalar(30000.0)]); +} + +/// Linear Wipe's values, set and default. +#[test] +fn linear_wipe_values() { + let fx = |values| Fx { + match_name: "ADBE Linear Wipe", + name: "Linear Wipe", + defs: vec![ + slider(K::FixedSlider, "Transition Completion", 0.0, (0.0, 100.0)), + other(K::Angle, "Wipe Angle", Default::Number(90.0)), + slider(K::FixedSlider, "Feather", 0.0, (0.0, 32000.0)), + ], + values, + data: vec![], + keys: vec![], + }; + // `linwipe-01-set-all`. + let (g, r) = run(&fx(vec![(1, 10.0), (2, 77.0), (3, 16.0)]), "ec.transition.linearwipe"); + assert_eq!(losses(&r, LossCategory::Effects, "ADBE Linear Wipe"), Vec::::new()); + let g = g.expect("imported"); + near(&g, "completion", 10.0); + near(&g, "angle", 77.0); + near(&g, "feather", 16.0); + // `linwipe-00-defaults`: the Wipe Angle default 90. + near(&run(&fx(vec![]), "ec.transition.linearwipe").0.expect("imported"), "angle", 90.0); +} + +/// Offset's Blend With Original as a percentage and its centre as a point. +#[test] +fn offset_blend_is_a_fraction_and_the_centre_a_point() { + let fx = |values| Fx { + match_name: "ADBE Offset", + name: "Offset", + defs: vec![other(K::Point, "Shift Center To", Default::Point(0.5, 0.5)), slider(K::FixedSlider, "Blend With Original", 0.0, (0.0, 1.0))], + values, + data: vec![], + keys: vec![], + }; + // `offset-02-chk-toggled`: Blend With Original stored 1.0 is 100 %. + let (g, r) = run(&fx(vec![(2, 1.0)]), "ec.distort.offset"); + assert_eq!(losses(&r, LossCategory::Effects, "ADBE Offset"), Vec::::new()); + let g = g.expect("imported"); + near(&g, "blend", 100.0); + // The default centre is the layer's centre (synthetic.aep's layer is 8x8). + pt(&g, "shift", [4.0, 4.0]); + near(&run(&fx(vec![(2, 0.25)]), "ec.distort.offset").0.expect("imported"), "blend", 25.0); +} + +/// Color Stabilizer's points, Sample Size and Stabilize, default and set. +#[test] +fn color_stabilizer_points_sample_size_and_stabilize() { + let fx = |values| Fx { + match_name: "ADBE Deflicker", + name: "Color Stabilizer", + defs: vec![ + popup("Stabilize", 2, &["Brightness", "Levels", "Curves"]), + other(K::Point, "Black Point", Default::Point(0.2, 0.5)), + other(K::Point, "Mid Point", Default::Point(0.5, 0.5)), + other(K::Point, "White Point", Default::Point(0.8, 0.5)), + slider(K::FixedSlider, "Sample Size", 3.0, (0.0, 50.0)), + ], + values, + data: vec![], + keys: vec![], + }; + // `colstab-00-defaults`: Levels, the points at 20 %, 50 % and 80 % of the width. + let (g, r) = run(&fx(vec![]), "ec.color.colorstabilizer"); + assert_eq!(losses(&r, LossCategory::Effects, "ADBE Deflicker"), Vec::::new()); + let g = g.expect("imported"); + assert_eq!(v(&g, "stabilize"), Value::Enum(1), "Levels"); + pt(&g, "blackPoint", [1.6, 4.0]); + pt(&g, "whitePoint", [6.4, 4.0]); + near(&g, "sampleSize", 3.0); + // `colstab-03-pop1-3` and `colstab-27-pop5-25`. + let (g, _) = run(&fx(vec![(1, 3.0), (5, 25.0)]), "ec.color.colorstabilizer"); + let g = g.expect("imported"); + assert_eq!(v(&g, "stabilize"), Value::Enum(2), "Curves"); + near(&g, "sampleSize", 25.0); +} diff --git a/crates/aep/tests/it/import/fx_inject.rs b/crates/aep/tests/it/import/fx_inject.rs new file mode 100644 index 000000000..81e6a821a --- /dev/null +++ b/crates/aep/tests/it/import/fx_inject.rs @@ -0,0 +1,366 @@ +//! Builds an effect instance into the committed `synthetic.aep` in place of its Glow (the instance +//! on the layer and the project's shared definitions), so an effect mapping can be tested without +//! the external corpus. The parameter definitions are written in the `pard` layout of +//! docs/aep-format.md 3.7 (type at byte 15, name at 16, default at 56, a float slider's valid +//! range as `f32` at 104 / 108 and its slider range at 112 / 116, a fixed or integer slider's valid +//! maximum at 128, a popup's item count at 60); `pard_layout_decodes_as_written` reads them back. +//! The ranges and defaults the tests use are the ones After Effects 2026 wrote in the importer +//! block of 2026-11-05 (`1100-quickwins.aep`, `1300-thirdparty.aep`, own projects). + +use effectcraft_aep::import::{ImportOptions, ImportResult, LossCategory, import}; +use effectcraft_aep::{Body, Chunk, ParamKind, ParamValue, Project as AepProject, parse, write}; +use effectcraft_keyframe::Value; +use effectcraft_project::{Project, PropGroup}; + +const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep"); + +/// A parameter definition. +#[derive(Clone)] +pub struct Def { + pub kind: ParamKind, + pub name: &'static str, + pub default: Default, + /// A float slider's valid range, a fixed or integer slider's valid maximum (`.1`). + pub valid: (f64, f64), + /// A popup's items. + pub items: &'static [&'static str], +} + +/// The default a definition holds at byte 56. +#[derive(Clone, Copy)] +pub enum Default { + None, + Number(f64), + Index(u32), + Point(f64, f64), + /// ARGB 0..255. + Color([u8; 4]), +} + +/// The parameter type code `pard` byte 15 holds for `k`. +fn kind_code(k: ParamKind) -> u8 { + match k { + ParamKind::Layer => 0, + ParamKind::IntSlider => 1, + ParamKind::FixedSlider => 2, + ParamKind::Angle => 3, + ParamKind::Checkbox => 4, + ParamKind::Color => 5, + ParamKind::Point => 6, + ParamKind::Popup => 7, + ParamKind::NoData => 9, + ParamKind::FloatSlider => 10, + ParamKind::ArbitraryData => 11, + ParamKind::GroupStart => 13, + ParamKind::GroupEnd => 14, + ParamKind::Button => 15, + _ => 9, + } +} + +/// `v` as a big-endian 16.16 fixed-point value. +fn fixed(v: f64) -> [u8; 4] { + ((v * 65536.0).round() as i32).to_be_bytes() +} + +/// The 148-byte parameter definition (`pard`) of `d`: type, name, default and range. +pub fn pard(d: &Def) -> Chunk<'static> { + let mut b = vec![0u8; 148]; + b[15] = kind_code(d.kind); + b[16..16 + d.name.len()].copy_from_slice(d.name.as_bytes()); + match (d.kind, d.default) { + (ParamKind::FloatSlider, Default::Number(v)) => b[56..64].copy_from_slice(&v.to_be_bytes()), + (ParamKind::FixedSlider | ParamKind::Angle, Default::Number(v)) => b[56..60].copy_from_slice(&fixed(v)), + (ParamKind::IntSlider | ParamKind::Checkbox, Default::Number(v)) => b[56..60].copy_from_slice(&(v as i32).to_be_bytes()), + (ParamKind::Popup, Default::Index(i)) => b[56..60].copy_from_slice(&i.to_be_bytes()), + (ParamKind::Point, Default::Point(x, y)) => { + b[56..60].copy_from_slice(&fixed(x)); + b[60..64].copy_from_slice(&fixed(y)); + } + (ParamKind::Color, Default::Color(c)) => b[56..60].copy_from_slice(&c), + _ => {} + } + match d.kind { + ParamKind::FloatSlider => { + b[104..108].copy_from_slice(&(d.valid.0 as f32).to_be_bytes()); + b[108..112].copy_from_slice(&(d.valid.1 as f32).to_be_bytes()); + b[112..116].copy_from_slice(&(d.valid.0 as f32).to_be_bytes()); + b[116..120].copy_from_slice(&(d.valid.1 as f32).to_be_bytes()); + } + ParamKind::FixedSlider => b[128..132].copy_from_slice(&fixed(d.valid.1)), + ParamKind::IntSlider => b[128..132].copy_from_slice(&(d.valid.1 as i32).to_be_bytes()), + ParamKind::Popup => b[60..62].copy_from_slice(&(d.items.len() as u16).to_be_bytes()), + _ => {} + } + Chunk::data(*b"pard", b) +} + +/// A match name chunk (`tdmn`, 40 bytes). +pub fn tdmn(name: &str) -> Chunk<'static> { + let mut b = name.as_bytes().to_vec(); + b.resize(40, 0); + Chunk::data(*b"tdmn", b) +} + +/// A UTF-8 string chunk (`Utf8`). +fn utf8(s: &str) -> Chunk<'static> { + Chunk::data(*b"Utf8", s.as_bytes().to_vec()) +} + +/// A chunk whose body is chunks without being a `LIST` (`pdnm`, `fnam`: `NESTED_IDS`). +fn nested(id: [u8; 4], children: Vec>) -> Chunk<'static> { + Chunk { id, body: Body::Chunks(children), pad: None } +} + +/// A static value record holding `v`, after the one After Effects wrote for Glow's A & B Midpoint +/// (its header; without the expression). +fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> { + let mut kids = Vec::new(); + for c in template.children() { + match &c.body { + Body::Data(d) if c.id == *b"tdsb" || c.id == *b"tdb4" => kids.push(Chunk::data(c.id, d.to_vec())), + Body::Data(_) if c.id == *b"cdat" => { + let mut d = vec![0u8; 40]; + d[..8].copy_from_slice(&v.to_be_bytes()); + kids.push(Chunk::data(*b"cdat", d)); + } + _ => {} + } + } + Chunk::list(*b"tdbs", kids) +} + +/// A keyframed value record: `record`'s header with a key list of linear one-component keys. +fn keyed_record(template: &Chunk<'_>, keys: &[(i32, f64)]) -> Chunk<'static> { + let Body::List { children, .. } = record(template, keys.first().map_or(0.0, |k| k.1)).body else { unreachable!("record builds a LIST") }; + let mut children = children; + let mut lhd3 = vec![0u8; 52]; + lhd3[0..4].copy_from_slice(&0x00d0_0beeu32.to_be_bytes()); + lhd3[8..12].copy_from_slice(&(keys.len() as u32).to_be_bytes()); + lhd3[16..20].copy_from_slice(&48u32.to_be_bytes()); + let mut ldat = Vec::new(); + for (time, value) in keys { + let mut rec = vec![0u8; 48]; + rec[0..4].copy_from_slice(&time.to_be_bytes()); + rec[4] = 1; + rec[5] = 1; + rec[8..16].copy_from_slice(&value.to_be_bytes()); + ldat.extend(rec); + } + children.push(Chunk::list(*b"list", vec![Chunk::data(*b"lhd3", lhd3), Chunk::data(*b"ldat", ldat)])); + Chunk::list(*b"tdbs", children) +} + +/// An effect instance: its match name, display name, parameter definitions (after the input +/// layer, before the built-in entry), the 1-D values stored (`parT` index, value) and the +/// arbitrary data stored (`parT` index, bytes). +pub struct Fx { + pub match_name: &'static str, + pub name: &'static str, + pub defs: Vec, + pub values: Vec<(usize, f64)>, + pub data: Vec<(usize, Vec)>, + /// Keyframed 1-D parameters (`parT` index, (time units, value) per key), linear keys; the + /// parameter then has no entry in `values`. + pub keys: Vec<(usize, Vec<(i32, f64)>)>, +} + +/// The match name of parameter `i` of `fx` (`-NNNN`). +fn param_name(fx: &Fx, i: usize) -> String { + format!("{}-{i:04}", fx.match_name) +} + +/// The effect's `sspc`: its name, the parameter definitions (`parT`) and the stored values +/// (`tdgp`), each value record copied from `template`. +fn sspc(fx: &Fx, template: &Chunk<'_>) -> Chunk<'static> { + let n = fx.defs.len() + 2; + let mut part = vec![ + Chunk::data(*b"parn", (n as u32).to_be_bytes().to_vec()), + tdmn(¶m_name(fx, 0)), + pard(&Def { kind: ParamKind::Layer, name: "", default: Default::None, valid: (0.0, 0.0), items: &[] }), + ]; + for (i, d) in fx.defs.iter().enumerate() { + part.push(tdmn(¶m_name(fx, i + 1))); + part.push(pard(d)); + if d.kind == ParamKind::Popup { + part.push(nested(*b"pdnm", vec![utf8(&d.items.join("|"))])); + } + } + part.push(tdmn("ADBE Effect Built In Params")); + part.push(pard(&Def { kind: ParamKind::NoData, name: "", default: Default::None, valid: (0.0, 0.0), items: &[] })); + let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec())]; + for (i, v) in &fx.values { + values.push(tdmn(¶m_name(fx, *i))); + values.push(record(template, *v)); + if let Some((_, d)) = fx.data.iter().find(|(j, _)| j == i) { + values.push(Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", d.clone())])); + } + } + for (i, keys) in &fx.keys { + values.push(tdmn(¶m_name(fx, *i))); + values.push(keyed_record(template, keys)); + } + Chunk::list(*b"sspc", vec![nested(*b"fnam", vec![utf8(fx.name)]), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)]) +} + +/// The value record of Glow's A & B Midpoint (`ADBE Glo2-0011`): a 1-D static record. +fn find_glow_template(chunks: &[Chunk<'_>]) -> Option> { + for c in chunks { + let kids = c.children(); + if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2-0011\0"))) + && let Some(record) = kids.get(at + 1).filter(|k| k.is_list(b"tdbs")) + { + return Some(Chunk::list( + *b"tdbs", + record + .children() + .iter() + .map(|k| match &k.body { + Body::Data(d) => Chunk::data(k.id, d.to_vec()), + _ => Chunk::data(k.id, Vec::new()), + }) + .collect(), + )); + } + if let Some(t) = find_glow_template(kids) { + return Some(t); + } + } + None +} + +/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with `fx`; the number replaced. +fn replace_glow(chunks: &mut [Chunk<'_>], fx: &Fx, template: &Chunk<'_>) -> usize { + let mut n = 0; + for ch in chunks { + let kids = ch.children_mut(); + if let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.starts_with(b"ADBE Glo2\0"))) + && kids.get(at + 1).is_some_and(|k| k.is_list(b"sspc")) + { + kids[at] = tdmn(fx.match_name); + kids[at + 1] = sspc(fx, template); + n += 1; + } + n += replace_glow(kids, fx, template); + } + n +} + +/// `synthetic.aep` with `fx` in place of the Glow effect. +pub fn bytes_with(fx: &Fx) -> Vec { + let file = parse(SYNTHETIC).unwrap(); + let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record"); + let mut file = parse(SYNTHETIC).unwrap(); + assert_eq!(replace_glow(&mut file.chunks, fx, &template), 2, "the Glow effect and its shared definitions"); + write(&file).unwrap() +} + +/// Imports `synthetic.aep` with `fx` in place of its Glow: the EffectCraft effect `effect` (if +/// imported) and the result. +pub fn run(fx: &Fx, effect: &str) -> (Option, ImportResult) { + let bytes = bytes_with(fx); + let mut project = Project::default(); + let none = |_: &str| false; + let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); + let g = project + .comps() + .flat_map(|(_, c)| c.layers.iter()) + .find_map(|l| l.props.sub("effects").and_then(|fx| fx.children.iter().filter_map(|n| n.as_group()).find(|g| g.match_id == effect).cloned())); + (g, r) +} + +/// The losses of `category` that name the effect `match_name`. +pub fn losses(r: &ImportResult, category: LossCategory, match_name: &str) -> Vec { + let tag = format!("({match_name})"); + r.report.losses.iter().filter(|l| l.category == category).flat_map(|l| l.details.clone()).filter(|d| d.contains(&tag)).collect() +} + +/// The value of property `id` of `g` (panics when it is missing). +pub fn v(g: &PropGroup, id: &str) -> Value { + g.prop(id).unwrap_or_else(|| panic!("no {id} in {}", g.match_id)).value.clone() +} + +/// The scalar value of property `id` of `g`. +pub fn num(g: &PropGroup, id: &str) -> f64 { + match v(g, id) { + Value::Scalar(x) => x, + other => panic!("{id}: {other:?}"), + } +} + +/// Asserts that property `id` of `g` is `want` (relative 1e-6). +pub fn near(g: &PropGroup, id: &str, want: f64) { + let got = num(g, id); + assert!((got - want).abs() <= want.abs().max(1.0) * 1e-6, "{} {id}: {got} != {want}", g.match_id); +} + +/// Asserts that the point property `id` of `g` is `want` (within 0.01). +pub fn pt(g: &PropGroup, id: &str, want: [f64; 2]) { + let Value::Vec2(a) = v(g, id) else { panic!("{id}") }; + assert!((a[0] - want[0]).abs() < 1e-2 && (a[1] - want[1]).abs() < 1e-2, "{} {id}: {a:?} != {want:?}", g.match_id); +} + +/// A slider definition. +pub fn slider(kind: ParamKind, name: &'static str, default: f64, valid: (f64, f64)) -> Def { + Def { kind, name, default: Default::Number(default), valid, items: &[] } +} + +/// A popup definition: default item (1-based) and items. +pub fn popup(name: &'static str, default: u32, items: &'static [&'static str]) -> Def { + Def { kind: ParamKind::Popup, name, default: Default::Index(default), valid: (0.0, 0.0), items } +} + +/// A definition of another kind (angle, point, colour, ...) with `default`. +pub fn other(kind: ParamKind, name: &'static str, default: Default) -> Def { + Def { kind, name, default, valid: (0.0, 0.0), items: &[] } +} + +/// The definitions written here read back as the reader decodes After Effects' `pard`. +#[test] +fn pard_layout_decodes_as_written() { + // Echo's Decay (float, valid 0..100, default 1), Linear Wipe's Feather (fixed, valid max + // 32 000), Echo's Number Of Echoes (integer, max 30 000, default 1), Offset's centre point, + // Color Stabilizer's Stabilize (default 2 of 3): what the sample dump of 2026-11-05 read. + let fx = Fx { + match_name: "Test FX", + name: "Test FX", + defs: vec![ + slider(ParamKind::FloatSlider, "Decay", 1.0, (0.0, 100.0)), + slider(ParamKind::FixedSlider, "Feather", 0.0, (0.0, 32000.0)), + slider(ParamKind::IntSlider, "Number Of Echoes", 1.0, (0.0, 30000.0)), + other(ParamKind::Point, "Shift Center To", Default::Point(0.5, 0.5)), + popup("Stabilize", 2, &["Brightness", "Levels", "Curves"]), + other(ParamKind::Color, "Shadows Unbalance", Default::Color([0, 0, 0, 0])), + ], + values: vec![(1, 20.0)], + data: vec![], + keys: vec![], + }; + let bytes = bytes_with(&fx); + let file = parse(&bytes).unwrap(); + let proj = AepProject::new(&file).unwrap(); + let defs = proj.effect_definition_index(); + let found = proj + .all_items() + .into_iter() + .filter_map(|i| i.comp()) + .flat_map(|c| c.layers().collect::>()) + .flat_map(|l| l.effects()) + .find(|e| e.match_name() == "Test FX") + .expect("the effect"); + let p = defs.params(&found); + let by = |i: usize| p.iter().find(|q| q.index == i).expect("parameter").clone(); + let decay = by(1); + assert_eq!( + (decay.kind, decay.name.as_str(), decay.valid_min(), decay.valid_max(), decay.pard_default()), + (ParamKind::FloatSlider, "Decay", Some(0.0), Some(100.0), Some(ParamValue::Number(1.0))) + ); + assert_eq!(decay.stored_value(), Some(ParamValue::Number(20.0))); + assert_eq!((by(2).kind, by(2).valid_max(), by(2).pard_default()), (ParamKind::FixedSlider, Some(32000.0), Some(ParamValue::Number(0.0)))); + assert_eq!((by(3).kind, by(3).valid_max(), by(3).pard_default()), (ParamKind::IntSlider, Some(30000.0), Some(ParamValue::Number(1.0)))); + assert_eq!(by(4).pard_default(), Some(ParamValue::Point([0.5, 0.5]))); + let stab = by(5); + assert_eq!((stab.popup_item_count(), stab.items.clone()), (Some(3), Some(vec!["Brightness".to_string(), "Levels".into(), "Curves".into()]))); + assert_eq!(found.display_name().as_deref(), Some("Test FX")); + assert_eq!(by(6).pard_default(), Some(ParamValue::Argb([0.0, 0.0, 0.0, 0.0]))); +} diff --git a/docs/aep-format.md b/docs/aep-format.md index effe31a61..3097a98dc 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -519,6 +519,20 @@ popup_selected_item, borrowed_definition}`, `Effect::has_own_definitions`, (356, 357, 361, 363, 364, 366, 368, 369, 371, 374, 377, 378, 380, 382, 383), `tests/effects_exposure.rs` (389-391, 393-397). +**Importer block 2026-11-05, #3110 (`1100-quickwins.aep`, After Effects 26.2.1; V for every +row: defaults from the defaults composition, the valid ranges from the minimum and maximum +compositions, the items from one composition per popup item).** Each set value is stored as set +(Decay 20 is `20.0`, Blend With Original 100 % is `1.0`); a value set to the default leaves no record +(HDR Amount 100): + +| Effect (match name) | Parameters (`parT` index: type, valid range, default) | +|---|---| +| `ADBE HDR ToneMap` (HDR Highlight Compression) | 1 Amount: float, 0..100 (slider 0..100), default 100 | +| `ADBE Echo` | 1 Echo Time (seconds): float, -30 000..30 000 (slider -5..5), default -1/30 (float32); 2 Number Of Echoes: integer, max 30 000, default 1; 3 Starting Intensity: float, 0..1, default 1; 4 Decay: float, **valid 0..100, slider 0..1**, default 1; 5 Echo Operator: popup (Add, Maximum, Minimum, Screen, Composite In Back, Composite In Front, Blend), default 1 | +| `ADBE Linear Wipe` | 1 Transition Completion: fixed, max 100, default 0; 2 Wipe Angle: angle, default 90; 3 Feather: fixed, max 32 000, default 0 | +| `ADBE Offset` | 1 Shift Center To: point, default the layer centre; 2 Blend With Original: fixed, **max 1 (a 0..1 fraction)**, default 0 | +| `ADBE Deflicker` (Color Stabilizer) | 1 Stabilize: popup (Brightness, Levels, Curves), **default 2 (Levels)**; 2 Black Point, 3 Mid Point, 4 White Point: points, defaults 0.2 / 0.5 / 0.8 of the width at half the height; 5 Sample Size: fixed, max 50, default 3. The reference frame the Set Frame button picks is not among the parameters | + **Curves and Mesh Warp** (961-965, 985; applied from presets, the data is not scriptable): their arbitrary-data parameter (type 11) always has a record (`tdbs` with a 4-byte `cdat`) and a `LIST aRbs` / `aRbp`, also at the default. Curves (`ADBE CurvesCustom-0001`, 1644 bytes): `u16` 1, 1; diff --git a/docs/aep-import.md b/docs/aep-import.md index 54df8d627..c05bd6d14 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -178,6 +178,7 @@ parameter gets the file's value: | `ADBE HUE SATURATION` 1 Channel Control, 2 Channel Range data, 6 Colorize, 7 Colorize Hue, 8 Colorize Saturation, 9 Colorize Lightness | `channelControl`; `hue`, `saturation`, `lightness` from the data; `colorize`, `colorizeHue`, `colorizeSaturation`, `colorizeLightness` | popup by name; the master hue (degrees), saturation and lightness are the `i32` of the arbitrary data (the visible Master parameters keep no record); the colour ranges are reported when they are not at their defaults (not sampled) | 443-450 | | `ADBE Easy Levels2` 1 Channel, 2 Histogram data, 8 / 9 Clip To Output Black / White | `channel`; `inBlack`, `inWhite`, `gamma`, `outBlack`, `outWhite` from the data; `clipToOutputBlack`, `clipToOutputWhite` | popups by name (After Effects' items On, Off, Off for 32 bpc Color are in another order here); the RGB values are the `f32` of the arbitrary data; the per-channel values are reported when they are not at their defaults (not sampled) | 511-516 | | Batch 6 (903 defaults, 904 / 905 set values): `ADBE Brightness & Contrast 2`, `ADBE Turbulent Displace`, `ADBE Fractal Noise`, `ADBE Laser` (Beam), `CC Radial Fast Blur`, `ADBE Noise2`, `ADBE Pro Levels2` (Levels (Individual Controls)), `ADBE Corner Pin`, `ADBE Grid`, `ADBE Sharpen`, `ADBE Linear Color Key2`, `ADBE Radial Wipe`, `ADBE Remove Color Matting`, `CC Bend It`, `CC Power Pin`, `ADBE 4ColorGradient`, `ADBE Change Color`, `ADBE Extract`, `ADBE Noise HLS2`, `ADBE Scatter`, `CC Light Sweep`, `ADBE Compound Blur`, `ADBE Displacement Map`, `ADBE Set Matte3`, `ADBE Gaussian Blur` (legacy), `ADBE OFMotionBlur` (Pixel Motion Blur), `ADBE Layer Control` | the EffectCraft effect of the same name, every parameter EffectCraft has | sliders as stored (each checked against its valid maximum); Beam Length, Time and Softness and CC Power Pin Perspective are 0..1 fractions, percentages here; points as layer pixels; popups by item name (Set Matte's `Red Channel` ... is `Red` ...); twirl-down group markers and the Levels histogram carry nothing; **layer parameters** (below) | 903, 904, 905; 220, 320, 321 | +| Importer block 2026-11-05 (`1100-quickwins.aep`: per effect defaults, set values, minimum and maximum, every popup item, a keyed and an expression parameter): `ADBE HDR ToneMap` (HDR Highlight Compression) 1 Amount; `ADBE Echo` 1 Echo Time, 2 Number Of Echoes, 3 Starting Intensity, 4 Decay, 5 Echo Operator; `ADBE Linear Wipe` 1 Transition Completion, 2 Wipe Angle, 3 Feather; `ADBE Offset` 1 Shift Center To, 2 Blend With Original; `ADBE Deflicker` (Color Stabilizer) 1 Stabilize, 2-4 Black / Mid / White Point, 5 Sample Size | `ec.utility.hdrcompression` `amount`; `ec.time.echo` `echoTime`, `numberOfEchoes`, `startingIntensity`, `decay`, `echoOperator`; `ec.transition.linearwipe` `completion`, `angle`, `feather`; `ec.distort.offset` `shift`, `blend`; `ec.color.colorstabilizer` `stabilize`, `blackPoint`, `midPoint`, `whitePoint`, `sampleSize` | percent (float, valid 0..100, default 100); seconds (float, valid ±30 000), integer (valid 30 000), 0..1 (float), Decay as stored (float, valid 0..100), popup by name: EffectCraft's Echo uses at most 255 echoes and a Decay up to 1, a value or key beyond that is imported as stored and **reported**; percent (fixed, 100), degrees, pixels (fixed, 32 000); point in layer pixels, Blend 0..1 x 100 = percent; popup by name (**fresh default Levels**), points in layer pixels, Sample Size as stored (fixed, valid 50, default 3; its unit, pixels here, is not verified). Color Stabilizer's reference frame (Set Frame) is not a parameter in the file and is not imported (EffectCraft's Reference Frame stays 0 s) | `tests/it/import/effects_quickwins.rs`; render comparison below | | `ADBE Slider Control`, `ADBE Angle Control`, `ADBE Checkbox Control`, `ADBE Color Control`, `ADBE Point Control` (parameter 1) | `ec.control.slider` `slider`, `ec.control.angle` `angle`, `ec.control.checkbox` `checkbox`, `ec.control.color` `color`, `ec.control.point` `point` | float (valid max 1 000 000) as stored; degrees; checkbox; colour; point in layer pixels | 071, 073, 217-219, 221, 304, 310 | - **Nothing to import:** the implicit input-layer parameter (index 0), the Swap Colors buttons @@ -308,6 +309,22 @@ frame blending, 3D material options, audio levels, guides, the mask colour, lock between them (363: 50 at 1 s; 364: grey at 1 s; 382: [950, 500] at 1 s), the defaults equal to EffectCraft's, stack order with a reported Gaussian Blur (366), the second instance (383), and lossless imports without `allowLosses`. +- `crates/aep/tests/it/import/effects_quickwins.rs` (#3110): HDR Highlight Compression, Echo, + Linear Wipe, Offset and Color Stabilizer, each built into `synthetic.aep` (`fx_inject.rs`, which + also checks that its `pard` layout reads back) with the definitions and values of the importer + block of 2026-11-05. Render check of that block's 75 compositions (320x180, 32 bpc, working space + None, 0.5 s) against After Effects' frames (its `saveFrameToPng` of a 32 bpc project writes + `floor(value x 25)` per channel, so values are compared at that quantisation): Offset (5), Linear + Wipe defaults / min / max, Echo with the Add, Maximum, Minimum, Screen operators and the keyed and + expression variants, Color Stabilizer with every Stabilize item and Sample Size up to 25 match + within one level. Differences, all in EffectCraft's effects and not in the mapping: HDR + Highlight Compression's curve (up to 6 of 25 levels on 19 % of the pixels at Amount 100, equal + at Amount 0); Echo's Blend operator (After Effects' frame is as bright as Add's, EffectCraft + averages) and the edge of the moving square with Composite In Back / Front (0.5 % of the + pixels); Linear Wipe's feathered edge (0.6 % of the pixels); Color Stabilizer with a Sample Size + over 25 (EffectCraft's samples drift, After Effects' frame stays unchanged) and with keyed + sample points. The renders do not exercise a Decay above 1 (After Effects' set value has + Starting Intensity 0) or what Color Stabilizer corrects (the test card does not flicker). - `crates/aep/tests/it/import/combined.rs`: masks and effects on the same layer, an effect on a text layer, and the same through `.aepx` (built in-test from the fixtures: the effect parade of one fixture grafted onto the layer of another).