diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 5a8a60285..38c796293 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -133,6 +133,8 @@ struct ParamOut<'a> { col: &'a mut Collector, } +/// Imports one parameter of `fx` into its EffectCraft property: its keyframes, the value in its +/// record or the `pard` default, with a loss for what cannot be read or mapped. fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>, cx: &LayerCtx<'_>, shown: &str, out: ParamOut<'_>) { let ParamOut { group, refs, col } = out; if carries_nothing(p) { diff --git a/crates/aep/tests/it/import/effects_quickwins.rs b/crates/aep/tests/it/import/effects_quickwins.rs index 792e08d74..f2b0594b6 100644 --- a/crates/aep/tests/it/import/effects_quickwins.rs +++ b/crates/aep/tests/it/import/effects_quickwins.rs @@ -10,6 +10,7 @@ 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", @@ -23,9 +24,11 @@ fn echo(values: Vec<(usize, f64)>) -> Fx { ], values, data: vec![], + keys: vec![], } } +/// HDR Highlight Compression's Amount, set and default. #[test] fn hdr_highlight_compression_amount() { let fx = |values| Fx { @@ -34,6 +37,7 @@ fn hdr_highlight_compression_amount() { 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)] { @@ -43,6 +47,7 @@ fn hdr_highlight_compression_amount() { } } +/// 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`. @@ -61,6 +66,7 @@ fn echo_values_and_operator() { 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. @@ -75,6 +81,25 @@ fn echo_values_beyond_effectcrafts_range_are_reported() { 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 { @@ -87,6 +112,7 @@ fn linear_wipe_values() { ], 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"); @@ -99,6 +125,7 @@ fn linear_wipe_values() { 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 { @@ -107,6 +134,7 @@ fn offset_blend_is_a_fraction_and_the_centre_a_point() { 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"); @@ -118,6 +146,7 @@ fn offset_blend_is_a_fraction_and_the_centre_a_point() { 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 { @@ -132,6 +161,7 @@ fn color_stabilizer_points_sample_size_and_stabilize() { ], 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"); diff --git a/crates/aep/tests/it/import/fx_inject.rs b/crates/aep/tests/it/import/fx_inject.rs index 9687e2d6c..81e6a821a 100644 --- a/crates/aep/tests/it/import/fx_inject.rs +++ b/crates/aep/tests/it/import/fx_inject.rs @@ -37,6 +37,7 @@ pub enum Default { Color([u8; 4]), } +/// The parameter type code `pard` byte 15 holds for `k`. fn kind_code(k: ParamKind) -> u8 { match k { ParamKind::Layer => 0, @@ -57,10 +58,12 @@ fn kind_code(k: ParamKind) -> u8 { } } +/// `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); @@ -99,6 +102,7 @@ pub fn tdmn(name: &str) -> Chunk<'static> { 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()) } @@ -126,6 +130,27 @@ fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> { 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). @@ -135,12 +160,18 @@ pub struct Fx { 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![ @@ -165,6 +196,10 @@ fn sspc(fx: &Fx, template: &Chunk<'_>) -> Chunk<'static> { 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)]) } @@ -194,6 +229,7 @@ fn find_glow_template(chunks: &[Chunk<'_>]) -> Option> { 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 { @@ -239,10 +275,12 @@ pub fn losses(r: &ImportResult, category: LossCategory, match_name: &str) -> Vec 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, @@ -250,11 +288,13 @@ pub fn num(g: &PropGroup, id: &str) -> f64 { } } +/// 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); @@ -265,14 +305,17 @@ pub fn slider(kind: ParamKind, name: &'static str, default: f64, valid: (f64, f6 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 @@ -291,6 +334,7 @@ fn pard_layout_decodes_as_written() { ], values: vec![(1, 20.0)], data: vec![], + keys: vec![], }; let bytes = bytes_with(&fx); let file = parse(&bytes).unwrap();