From d10991eff3b405aa8f1e3171ece47cde0bd764e2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 14:45:39 +0100 Subject: [PATCH 1/8] AEP import: map HDR Highlight Compression, Echo, Linear Wipe, Offset and Color Stabilizer The importer reported these five effects as lost. They now become EffectCraft's effects of the same name with every parameter After Effects stores (ranges, defaults and popup items from our own After Effects 2026 project, documented in docs/aep-format.md 3.7): - ADBE HDR ToneMap -> ec.utility.hdrcompression (Amount) - ADBE Echo -> ec.time.echo (Echo Time, Number Of Echoes, Starting Intensity, Decay, Echo Operator by item name) - ADBE Linear Wipe -> ec.transition.linearwipe - ADBE Offset -> ec.distort.offset (Blend With Original is a stored 0..1 fraction, a percentage here) - ADBE Deflicker -> ec.color.colorstabilizer (fresh Stabilize default Levels, points, Sample Size) A table row can name the range EffectCraft's effect works in (ParamMap::limit): Echo values or keys beyond 255 echoes or a Decay of 1 are imported as stored and reported. Tests build each effect into the committed synthetic.aep (fx_inject.rs, which also checks its pard layout reads back). On the sample project the effect losses go from 75 to 3 (the out-of-range Echo values). Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/container/effect_map.rs | 62 +++- crates/aep/src/import/effects.rs | 16 +- crates/aep/tests/it/import.rs | 2 + crates/aep/tests/it/import/effects.rs | 2 +- .../aep/tests/it/import/effects_quickwins.rs | 149 ++++++++ crates/aep/tests/it/import/fx_inject.rs | 322 ++++++++++++++++++ docs/aep-format.md | 14 + docs/aep-import.md | 17 + 8 files changed, 581 insertions(+), 3 deletions(-) create mode 100644 crates/aep/tests/it/import/effects_quickwins.rs create mode 100644 crates/aep/tests/it/import/fx_inject.rs diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index a7c9bbd48..5b872450f 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -120,6 +120,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. @@ -147,7 +151,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 { @@ -339,6 +348,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). @@ -1006,6 +1022,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 477a25df4..1d20a9dd1 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -266,6 +266,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; } @@ -298,11 +299,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..792e08d74 --- /dev/null +++ b/crates/aep/tests/it/import/effects_quickwins.rs @@ -0,0 +1,149 @@ +//! 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"]; + +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![], + } +} + +#[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![], + }; + // `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); + } +} + +#[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))); +} + +#[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); +} + +#[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![], + }; + // `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); +} + +#[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![], + }; + // `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); +} + +#[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![], + }; + // `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..9687e2d6c --- /dev/null +++ b/crates/aep/tests/it/import/fx_inject.rs @@ -0,0 +1,322 @@ +//! 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]), +} + +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, + } +} + +fn fixed(v: f64) -> [u8; 4] { + ((v * 65536.0).round() as i32).to_be_bytes() +} + +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) +} + +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) +} + +/// 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)>, +} + +fn param_name(fx: &Fx, i: usize) -> String { + format!("{}-{i:04}", fx.match_name) +} + +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())])); + } + } + 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 +} + +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() +} + +pub fn v(g: &PropGroup, id: &str) -> Value { + g.prop(id).unwrap_or_else(|| panic!("no {id} in {}", g.match_id)).value.clone() +} + +pub fn num(g: &PropGroup, id: &str) -> f64 { + match v(g, id) { + Value::Scalar(x) => x, + other => panic!("{id}: {other:?}"), + } +} + +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); +} + +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: &[] } +} + +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 } +} + +pub fn other(kind: ParamKind, name: &'static str, default: Default) -> Def { + Def { kind, name, default, valid: (0.0, 0.0), items: &[] } +} + +#[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![], + }; + 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 fcc8f53be..32ec74854 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 baeb8d0e1..1926274f0 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). From 77e91199e88ff4aa353990f08b8b725e601fca47 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 14:55:01 +0100 Subject: [PATCH 2/8] AEP import: map CC Color Neutralizer, EXtractoR and IDentifier The importer reported these three plug-ins as lost effects. They now become EffectCraft's effects of the same name (parameter lists and data layouts from our own After Effects 2026 project, docs/aep-format.md 3.7): - CS Color Neutralizer -> ec.color.cccolorneutralizer: Pinning and Blend w. Original. After Effects' correction is the per-range Red / Green / Blue sliders (the Unbalance colours are pickers that keep no record), plus View and Black / White Point; EffectCraft's effect has none of them, so each is reported when stored. - EXtractoR -> ec.3d.extractor: Black / White Point, UnMult, and the default Channel Info (R, G, B, (copy)) as EffectCraft's R, G, B, A (ExtractorChannels). Other channels are reported: only the default layout is sampled. - IDentifier -> ec.3d.identifier: Display by item name, ID. Its Channel Info names only the channel type (Object ID, identifier_channel_type), not the channel After Effects reads; After Effects left the sampled EXR unchanged where EffectCraft draws the footage's object ID channel, so every instance is reported. On the sample project no effect is lost any more (29 before); the parameters above are reported instead. Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/container.rs | 2 +- crates/aep/src/container/arbitrary.rs | 51 +++++ crates/aep/src/container/effect_map.rs | 42 ++++ crates/aep/src/import/effects.rs | 38 +++- crates/aep/src/lib.rs | 14 +- crates/aep/tests/it/import.rs | 1 + crates/aep/tests/it/import/effects.rs | 2 +- .../aep/tests/it/import/effects_thirdparty.rs | 179 ++++++++++++++++++ docs/aep-format.md | 16 ++ docs/aep-import.md | 13 ++ 10 files changed, 347 insertions(+), 11 deletions(-) create mode 100644 crates/aep/tests/it/import/effects_thirdparty.rs diff --git a/crates/aep/src/container.rs b/crates/aep/src/container.rs index 4aa0f393a..0a4746f68 100644 --- a/crates/aep/src/container.rs +++ b/crates/aep/src/container.rs @@ -36,7 +36,7 @@ mod values; mod write; pub use aepx::{is_aepx, parse_any, read_aepx, read_aepx_with_limits}; -pub use arbitrary::{CURVE_IDENTITY, CurvesData, HueSaturationData, LevelsData, MeshData, curve_lut}; +pub use arbitrary::{CURVE_IDENTITY, CurvesData, ExtractorChannels, HueSaturationData, LevelsData, MeshData, curve_lut, identifier_channel_type}; pub use camera::{CameraPosition, DEFAULT_CAMERA_ZOOM}; pub use chunk::{AepFile, Body, Chunk, LIST, NESTED_IDS, Walk}; pub use effects::{DefinitionSource, Effect, EffectDefinitions, EffectParam, LayerRef, ParamKind, ParamValue}; diff --git a/crates/aep/src/container/arbitrary.rs b/crates/aep/src/container/arbitrary.rs index dea70d22c..7b47e83c7 100644 --- a/crates/aep/src/container/arbitrary.rs +++ b/crates/aep/src/container/arbitrary.rs @@ -307,3 +307,54 @@ impl MeshData { Some(out) } } + +/// EXtractoR's Channel Info data (`EXtractoR-0013`, 688 bytes, the plug-in's own little-endian +/// layout; read from the default instances of the importer block of 2026-11-05, where no channel +/// was changed): a `u32` 1 and 28 bytes, then four records of 164 bytes (Red, Green, Blue, Alpha), +/// each a `u32` kind (1 a named channel, 2 the input's channel: `(copy)` on Alpha) and the channel +/// name from byte 35, NUL-terminated. Only the default (`R`, `G`, `B`, `(copy)`) is sampled. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExtractorChannels { + /// Per output channel (red, green, blue, alpha): the kind and the name. + pub channels: [(u32, String); 4], +} + +impl ExtractorChannels { + /// The data's length. + pub const LEN: usize = 688; + const RECORD: usize = 164; + + /// `None` when the data does not have this layout. + pub fn parse(data: &[u8]) -> Option { + if data.len() != Self::LEN || data.get(..4)? != 1u32.to_le_bytes() { + return None; + } + let mut channels: [(u32, String); 4] = Default::default(); + for (k, slot) in channels.iter_mut().enumerate() { + let at = 32usize.checked_add(k.checked_mul(Self::RECORD)?)?; + let rec = data.get(at..at.checked_add(Self::RECORD)?)?; + let kind = u32::from_le_bytes(*rec.first_chunk::<4>()?); + let name = rec.get(35..)?; + let end = name.iter().position(|b| *b == 0)?; + let name = std::str::from_utf8(name.get(..end)?).ok()?; + *slot = (kind, name.to_string()); + } + Some(Self { channels }) + } + + /// The channels of a fresh EXtractoR: Red, Green and Blue from the channels of those names, + /// Alpha copied from the input. + pub fn is_default(&self) -> bool { + let want = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")]; + self.channels.iter().zip(want).all(|((k, n), (wk, wn))| *k == wk && n == wn) + } +} + +/// IDentifier's Channel Info data (`IDentifier-0005`, 148 bytes): the channel type's name, +/// NUL-terminated from byte 19 (`Object ID` in every sample of the importer block of 2026-11-05; +/// the bytes around it differ between instances with the same settings and are not read). +pub fn identifier_channel_type(data: &[u8]) -> Option<&str> { + let s = data.get(19..)?; + let end = s.iter().position(|b| *b == 0)?; + std::str::from_utf8(s.get(..end)?).ok() +} diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index 5b872450f..46fcf39ea 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -138,6 +138,10 @@ pub enum Extra { Curves, /// Mesh Warp's Distortion Mesh (965, 985, `MeshData`). MeshWarp, + /// EXtractoR's Channel Info (`ExtractorChannels`): the default channels only. + Extractor, + /// IDentifier's Channel Info (`identifier_channel_type`): Object ID only. + Identifier, } /// One mapped effect. @@ -354,6 +358,8 @@ pub const SHUTTER_CONTROL: &[&str] = &["Manual", "Automatic"]; 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"]; +/// IDentifier's Display (`identifier-04` ... `identifier-06`, `1300-thirdparty.aep`). +pub const IDENTIFIER_DISPLAY: &[&str] = &["Colors", "Luma Matte", "Alpha Matte", "Raw"]; /// The effects the importer converts. pub const EFFECTS: &[EffectMap] = &[ @@ -1066,6 +1072,40 @@ pub const EFFECTS: &[EffectMap] = &[ pm(5, K::FixedSlider, "sampleSize", Conv::Plain { valid_max: 50.0 }), ], ), + // CC Color Neutralizer (importer block 2026-11-05, `ccneutral-*`): Pinning and Blend w. + // Original are float sliders in percent (valid 0..100). After Effects' correction is the Red / + // Green / Blue slider of each range (3-5, 9-11, 15-17; the Unbalance colours are pickers that + // set them and keep no record), then View (22) and Black / White Point (23, 24): + // EffectCraft's CC Color Neutralizer has none of them, so they are reported when stored. + fx( + "CS Color Neutralizer", + "ec.color.cccolorneutralizer", + &[pm(19, K::FloatSlider, "pinning", Conv::Plain { valid_max: 100.0 }), pm(20, K::FloatSlider, "blendWOriginal", Conv::Plain { valid_max: 100.0 })], + ), + // EXtractoR (`extractor-*`): 1 Channel Info (arbitrary data, `Extra::Extractor`), 2 / 6 the + // Process group, Black and White Point float sliders (valid ±3.4e38, the float32 range), + // UnMult. EffectCraft's Clip has no counterpart and stays off. + EffectMap { + match_name: "EXtractoR", + effect: "ec.3d.extractor", + params: &[ + pm(3, K::FloatSlider, "blackPoint", Conv::Plain { valid_max: F32_MAX }), + pm(4, K::FloatSlider, "whitePoint", Conv::Plain { valid_max: F32_MAX }), + pm(5, K::Checkbox, "unMult", Conv::Checkbox), + ], + extra: Extra::Extractor, + }, + // IDentifier (`identifier-*`): 1 Channel Info (arbitrary data, `Extra::Identifier`), 2 Display, + // 3 ID an integer slider (valid 0..2 147 483 647). + EffectMap { + match_name: "IDentifier", + effect: "ec.3d.identifier", + params: &[ + pm(2, K::Popup, "display", Conv::Popup { default: 1, items: IDENTIFIER_DISPLAY }), + pm(3, K::IntSlider, "id", Conv::Plain { valid_max: 2_147_483_647.0 }), + ], + extra: Extra::Identifier, + }, // 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 })]), @@ -1079,6 +1119,8 @@ pub const EFFECTS: &[EffectMap] = &[ pub const EXPOSURE: Conv = Conv::Plain { valid_max: 100.0 }; pub const OFFSET: Conv = Conv::Plain { valid_max: 2.0 }; pub const GAMMA: Conv = Conv::Plain { valid_max: 10.0 }; +/// The largest float32, the valid maximum of EXtractoR's Black and White Point. +pub const F32_MAX: f64 = f32::MAX as f64; /// The valid maximum of Glow's A & B Midpoint (16.16 of 0.99, 427). pub const GLOW_MIDPOINT_MAX: f64 = 0.990_005_493_164_062_5; diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 1d20a9dd1..a9fa3c2c7 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -36,8 +36,8 @@ use crate::effect_map::{Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, popup_key}; use crate::{ - CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, HueSaturationData, Layer as AepLayer, LayerRef, LevelsData, MeshData, - ParamKind, ParamValue, + CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, ExtractorChannels, HueSaturationData, Layer as AepLayer, LayerRef, + LevelsData, MeshData, ParamKind, ParamValue, }; use effectcraft_keyframe::Value; use effectcraft_project::build::Ids; @@ -465,6 +465,40 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h Some(m) => import_mesh(&m, here, size, group, col), None => col.lose(LossCategory::DamagedData, format!("{here}: the Distortion Mesh is unreadable, defaults used")), }, + Extra::Extractor => match ExtractorChannels::parse(data) { + // The fresh channels: EffectCraft's own R, G, B, A (its A is the input's alpha, After + // Effects' `(copy)`). + Some(c) if c.is_default() => { + for (id, name) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] { + if let Some(prop) = group.prop_mut(id) { + prop.value = Value::Str(name.into()); + } + } + } + Some(c) => { + let names: Vec<&str> = c.channels.iter().map(|(_, n)| n.as_str()).collect(); + col.lose( + LossCategory::Effects, + format!("{here}: the channels {} are not imported (only the default channels are sampled), R, G, B, A used", names.join(", ")), + ); + } + None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, R, G, B, A used")), + }, + Extra::Identifier => match crate::identifier_channel_type(data) { + Some("Object ID") => { + if let Some(prop) = group.prop_mut("channelType") { + prop.value = Value::Enum(0); + } + // The data names the type, not the channel After Effects reads: with this Channel + // Info After Effects left the sampled EXR unchanged, where EffectCraft draws the + // footage's object ID channel. + col.lose( + LossCategory::Effects, + format!("{here}: the channel IDentifier reads (Channel Info) is not decoded; EffectCraft uses the footage's object ID channel"), + ); + } + other => col.lose(LossCategory::Effects, format!("{here}: the channel type {other:?} is not imported (only Object ID is sampled), Object ID used")), + }, Extra::None => {} } } diff --git a/crates/aep/src/lib.rs b/crates/aep/src/lib.rs index c8b37d716..dba3136ee 100644 --- a/crates/aep/src/lib.rs +++ b/crates/aep/src/lib.rs @@ -14,11 +14,11 @@ pub mod import; pub use container::{ AepError, AepFile, AutoKern, BlendMode, Body, CURVE_IDENTITY, CameraPosition, Caps, Chunk, Comp, CompHeader, CueType, CurvesData, DEFAULT_CAMERA_ZOOM, - DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, FontBaseline, Footage, GradientType, HueSaturationData, Interpolation, Item, - ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer, LayerHeader, LayerRef, LayerType, LevelsData, - LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS, Orientation, OrientationKey, ParagraphRun, ParamKind, - ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode, Property, Quality, Rational, Renderer, SequenceInfo, - ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun, TextBox, TextDocument, TextValue, TimeBase, - TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace, curve_lut, effect_map, is_aepx, parse, parse_any, - parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map, write, write_to, + DefinitionSource, Effect, EffectDefinitions, EffectParam, ExpressionFlags, ExtractorChannels, FontBaseline, Footage, GradientType, HueSaturationData, + Interpolation, Item, ItemKind, Justification, KernRun, KeyEase, KeyLayout, KeyList, Keyframe, KeyframeKind, LIST, Layer, LayerHeader, LayerRef, LayerType, + LevelsData, LightType, Limits, ListHeader, Marker, Mask, MaskAtom, MaskMode, MatteMode, MeshData, NESTED_IDS, Orientation, OrientationKey, ParagraphRun, + ParamKind, ParamValue, PathVertex, PolystarType, Project, ProjectSettings, PropEntry, PropGroup, PropNode, Property, Quality, Rational, Renderer, + SequenceInfo, ShapeDetail, ShapeGroup, ShapeItem, ShapePath, SpatialKey, Stroke, StrokeCap, StrokeJoin, StyleRun, TextBox, TextDocument, TextValue, + TimeBase, TimeDisplay, TimeRemap, TimeRemapKey, TrackMatte, Tracked, Transform, ValueSource, Walk, WorkingSpace, curve_lut, effect_map, + identifier_channel_type, is_aepx, parse, parse_any, parse_text_dict, parse_with_limits, read_aepx, read_aepx_with_limits, style_map, write, write_to, }; diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 178dd2bb3..a24f9af26 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -14,6 +14,7 @@ mod effects_batch5; mod effects_quickwins; mod effects_round2; mod effects_round3; +mod effects_thirdparty; mod fx_inject; mod markers; mod masks; diff --git a/crates/aep/tests/it/import/effects.rs b/crates/aep/tests/it/import/effects.rs index aa4180567..fbb28b406 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(), 62); + assert_eq!(t.len(), 65); for (_, id) in t { assert!(effectcraft_effects::find(id).is_some(), "{id}"); } diff --git a/crates/aep/tests/it/import/effects_thirdparty.rs b/crates/aep/tests/it/import/effects_thirdparty.rs new file mode 100644 index 000000000..20f0b8230 --- /dev/null +++ b/crates/aep/tests/it/import/effects_thirdparty.rs @@ -0,0 +1,179 @@ +//! CC Color Neutralizer, EXtractoR and IDentifier (#3112) 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 (`1300-thirdparty.aep`) and the values +//! of that block's compositions; the Channel Info data is laid out as those instances store it. + +use crate::import::fx_inject::{Default, Fx, losses, near, other, popup, run, slider, v}; +use effectcraft_aep::import::LossCategory; +use effectcraft_aep::{ExtractorChannels, ParamKind as K, identifier_channel_type}; +use effectcraft_keyframe::Value; + +fn neutralizer(values: Vec<(usize, f64)>) -> Fx { + let range = |name| slider(K::FloatSlider, name, 0.0, (-100.0, 100.0)); + let group = |name| other(K::GroupStart, name, Default::None); + let end = || other(K::GroupEnd, "", Default::None); + Fx { + match_name: "CS Color Neutralizer", + name: "CC Color Neutralizer", + defs: vec![ + other(K::Color, "Shadows Unbalance", Default::Color([0, 0, 0, 0])), + group("Shadows"), + range("Red - Shadows"), + range("Green - Shadows"), + range("Blue - Shadows"), + end(), + other(K::Color, "Midtones Unbalance", Default::Color([255, 127, 127, 127])), + group("Midtones"), + range("Red - Midtones"), + range("Green - Midtones"), + range("Blue - Midtones"), + end(), + other(K::Color, "Highlights Unbalance", Default::Color([255, 255, 255, 255])), + group("Highlights"), + range("Red - Highlights"), + range("Green - Highlights"), + range("Blue - Highlights"), + end(), + slider(K::FloatSlider, "Pinning", 0.0, (0.0, 100.0)), + slider(K::FloatSlider, "Blend w. Original", 0.0, (0.0, 100.0)), + group("Special"), + popup("View", 1, &["Final Result", "Correction B to W", "Correction Stair"]), + slider(K::FloatSlider, "Black Point", 0.0, (0.0, 49.9)), + slider(K::FloatSlider, "White Point", 100.0, (50.1, 100.0)), + end(), + ], + values, + data: vec![], + } +} + +#[test] +fn cc_color_neutralizer_pinning_and_blend() { + // `ccneutral-00-defaults` and the Pinning / Blend of `ccneutral-01-set-all`. + let (g, r) = run(&neutralizer(vec![(19, 16.0), (20, 19.0)]), "ec.color.cccolorneutralizer"); + assert_eq!(losses(&r, LossCategory::Effects, "CS Color Neutralizer"), Vec::::new()); + let g = g.expect("imported"); + near(&g, "pinning", 16.0); + near(&g, "blendWOriginal", 19.0); + // EffectCraft's own (identity) balance stays. + assert_eq!(v(&g, "midtonesUnbalance"), v(&g, "midtonesBalance")); +} + +#[test] +fn cc_color_neutralizer_corrections_without_a_counterpart_are_reported() { + // `ccneutral-03-sec-shadows`, `ccneutral-09-pop22-2`, `ccneutral-06-sec-special`. + let (g, r) = run(&neutralizer(vec![(3, 13.0), (4, 16.0), (5, 19.0), (22, 2.0), (23, 10.0), (24, 87.0)]), "ec.color.cccolorneutralizer"); + let found = losses(&r, LossCategory::Effects, "CS Color Neutralizer"); + for name in ["Red - Shadows", "Green - Shadows", "Blue - Shadows", "View", "Black Point", "White Point"] { + assert!(found.iter().any(|d| d.contains(&format!("parameter {name} (")) && d.contains("not imported")), "{name}: {found:?}"); + } + assert_eq!(found.len(), 6, "{found:?}"); + assert!(g.is_some(), "the effect itself is imported"); +} + +/// EXtractoR's Channel Info data with these record kinds and names. +fn channels(recs: [(u32, &str); 4]) -> Vec { + let mut d = vec![0u8; ExtractorChannels::LEN]; + d[..4].copy_from_slice(&1u32.to_le_bytes()); + for (k, (kind, name)) in recs.iter().enumerate() { + let at = 32 + k * 164; + d[at..at + 4].copy_from_slice(&kind.to_le_bytes()); + d[at + 35..at + 35 + name.len()].copy_from_slice(name.as_bytes()); + } + d +} + +const DEFAULT_CHANNELS: [(u32, &str); 4] = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")]; + +fn extractor(values: Vec<(usize, f64)>, data: Vec) -> Fx { + let mut values = values; + values.insert(0, (1, 0.0)); + Fx { + match_name: "EXtractoR", + name: "EXtractoR", + defs: vec![ + other(K::ArbitraryData, "Channel Info", Default::None), + other(K::GroupStart, "Process", Default::None), + slider(K::FloatSlider, "Black Point", 0.0, (-f64::from(f32::MAX), f64::from(f32::MAX))), + slider(K::FloatSlider, "White Point", 1.0, (-f64::from(f32::MAX), f64::from(f32::MAX))), + other(K::Checkbox, "", Default::Number(0.0)), + other(K::GroupEnd, "", Default::None), + ], + values, + data: vec![(1, data)], + } +} + +#[test] +fn extractor_channel_info_layout() { + let c = ExtractorChannels::parse(&channels(DEFAULT_CHANNELS)).expect("the default layout"); + assert!(c.is_default()); + let c = ExtractorChannels::parse(&channels([(1, "Z"), (1, "G"), (1, "B"), (2, "(copy)")])).expect("parses"); + assert!(!c.is_default()); + assert_eq!(c.channels[0], (1, "Z".to_string())); + assert_eq!(ExtractorChannels::parse(&[0u8; 10]), None); +} + +#[test] +fn extractor_points_unmult_and_default_channels() { + // `extractor-01-set-all`: Black Point 10, White Point 14, UnMult on. + let (g, r) = run(&extractor(vec![(3, 10.0), (4, 14.0), (5, 1.0)], channels(DEFAULT_CHANNELS)), "ec.3d.extractor"); + assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::::new()); + let g = g.expect("imported"); + near(&g, "blackPoint", 10.0); + near(&g, "whitePoint", 14.0); + assert_eq!((v(&g, "unMult"), v(&g, "clip")), (Value::Bool(true), Value::Bool(false))); + // After Effects' `(copy)` Alpha is the input's alpha: EffectCraft's A. + for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] { + assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); + } +} + +#[test] +fn extractor_channels_other_than_the_default_are_reported() { + let (g, r) = run(&extractor(vec![], channels([(1, "Z"), (1, "Z"), (1, "Z"), (2, "(copy)")])), "ec.3d.extractor"); + let found = losses(&r, LossCategory::Effects, "EXtractoR"); + assert!(found.len() == 1 && found[0].contains("the channels Z, Z, Z, (copy) are not imported"), "{found:?}"); + assert_eq!(v(&g.expect("imported"), "red"), Value::Str("R".into())); +} + +/// IDentifier's Channel Info: the channel type's name at 19 among bytes that are not read. +fn id_info(name: &str) -> Vec { + let mut d: Vec = (0..148u32).map(|i| (i * 37 % 251) as u8 | 1).collect(); + d[19..19 + name.len()].copy_from_slice(name.as_bytes()); + d[19 + name.len()] = 0; + d +} + +fn identifier(values: Vec<(usize, f64)>, data: Vec) -> Fx { + let mut values = values; + values.insert(0, (1, 0.0)); + Fx { + match_name: "IDentifier", + name: "IDentifier", + defs: vec![ + other(K::ArbitraryData, "Channel Info", Default::None), + popup("Display", 1, &["Colors", "Luma Matte", "Alpha Matte", "Raw"]), + slider(K::IntSlider, "ID", 0.0, (0.0, 2_147_483_647.0)), + ], + values, + data: vec![(1, data)], + } +} + +#[test] +fn identifier_display_id_and_channel_type() { + assert_eq!(identifier_channel_type(&id_info("Object ID")), Some("Object ID")); + // `identifier-01-set-all` (ID 10) and `identifier-05-pop2-3` (Alpha Matte). + let (g, r) = run(&identifier(vec![(2, 3.0), (3, 10.0)], id_info("Object ID")), "ec.3d.identifier"); + // Which channel After Effects reads is not decoded (it left the sampled EXR unchanged): reported. + let found = losses(&r, LossCategory::Effects, "IDentifier"); + assert!(found.len() == 1 && found[0].contains("is not decoded"), "{found:?}"); + let g = g.expect("imported"); + assert_eq!((v(&g, "display"), v(&g, "channelType")), (Value::Enum(2), Value::Enum(0))); + near(&g, "id", 10.0); + // A channel type that is not sampled is reported. + let (_, r) = run(&identifier(vec![], id_info("Material ID")), "ec.3d.identifier"); + let found = losses(&r, LossCategory::Effects, "IDentifier"); + assert!(found.len() == 1 && found[0].contains("\"Material ID\""), "{found:?}"); +} diff --git a/docs/aep-format.md b/docs/aep-format.md index 32ec74854..29ee51963 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -533,6 +533,22 @@ compositions, the items from one composition per popup item).** Each set value i | `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 | +**Importer block 2026-11-05, #3112 (`1300-thirdparty.aep`; V for the parameter lists, H for the +data layouts, read from default instances only):** + +| Effect (match name, plug-in version) | Parameters (`parT` index: type, valid range, default) | +|---|---| +| `CS Color Neutralizer` (CC Color Neutralizer, 1.3) | 1 / 7 / 13 Shadows / Midtones / Highlights Unbalance: colours (black, 127 grey, white; pickers: setting one sets the range's sliders, e.g. [0.27, 0.55, 0.85] gives Red 42.94, Green 1.18, Blue -44.12 = (mean - channel) x 150 / 255 on 8-bit values, and leaves no colour record); 2-6, 8-12, 14-18 a group per range (start, Red, Green, Blue, end) with float sliders -100..100 (slider -50..50), default 0; 19 Pinning: float 0..100 (slider 0..50); 20 Blend w. Original: float 0..100; 21-25 the Special group: 22 View popup (Final Result, Correction B to W, Correction Stair), default 1, 23 Black Point float 0..49.9, default 0, 24 White Point float 50.1..100, default 100 | +| `EXtractoR` (2.6.6) | 1 Channel Info (`EXtractoR-0013`): arbitrary data, 688 bytes, below; 2-6 the Process group: 3 Black Point and 4 White Point float sliders, valid ±3.4028235e38 (the float32 range; slider ±1 000), default 0 / 1; 5 UnMult: checkbox (empty `pard` name, the label in `pdnm`), default off | +| `IDentifier` (2.6.6) | 1 Channel Info (`IDentifier-0005`): arbitrary data, 148 bytes, below; 2 Display: popup (Colors, Luma Matte, Alpha Matte, Raw), default 1; 3 ID: integer slider, max 2 147 483 647, default 0 | + +EXtractoR's Channel Info is the plug-in's own **little-endian** layout: `u32` 1 and 28 zero bytes, +then four 164-byte records (Red, Green, Blue, Alpha), each a `u32` kind (1 in R, G, B; 2 in Alpha, +whose name is `(copy)`) and the channel name NUL-terminated from record byte 35 (`R`, `G`, `B`). +The data is the same in every composition of the block. IDentifier's data differs between +instances with the same settings except for the channel type's name, NUL-terminated from byte 19 +(`Object ID`); the other bytes are not read. API: `ExtractorChannels`, `identifier_channel_type`. + **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 1926274f0..f14141156 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -179,6 +179,7 @@ parameter gets the file's value: | `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 | +| Importer block 2026-11-05 (`1300-thirdparty.aep`), plug-ins that ship with After Effects: `CS Color Neutralizer` (CC Color Neutralizer) 19 Pinning, 20 Blend w. Original; `EXtractoR` 1 Channel Info data, 3 Black Point, 4 White Point, 5 UnMult; `IDentifier` 1 Channel Info data, 2 Display, 3 ID | `ec.color.cccolorneutralizer` `pinning`, `blendWOriginal`; `ec.3d.extractor` `red`, `green`, `blue`, `alpha`, `blackPoint`, `whitePoint`, `unMult`; `ec.3d.identifier` `channelType`, `display`, `id` | CC Color Neutralizer: percent (float, valid 100); its correction is the Red / Green / Blue slider of each range (the Unbalance colours are pickers that set them and keep no record), with View and Black / White Point: **EffectCraft's effect has no counterpart, each is reported when stored** (EffectCraft's Unbalance / Balance stay at their identity defaults). EXtractoR: the default channels (R, G, B, `(copy)`) are EffectCraft's R, G, B, A, other channels are reported (`ExtractorChannels`; only the default is sampled), Black and White Point as stored (float, valid ±3.4e38; a value beyond ±1e9 is reported as damaged data, as for every number), UnMult a checkbox, Clip stays off. IDentifier: Display by name, ID as stored (integer, valid 2 147 483 647); the Channel Info names only the channel type (Object ID, `identifier_channel_type`; another is reported), not the channel After Effects reads, so **every instance is reported**: EffectCraft draws the footage's object ID channel where After Effects left the sampled EXR unchanged | `tests/it/import/effects_thirdparty.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 @@ -325,6 +326,18 @@ frame blending, 3D material options, audio levels, guides, the mask colour, lock 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/effects_thirdparty.rs` (#3112): CC Color Neutralizer, EXtractoR and + IDentifier built into `synthetic.aep` like the #3110 effects. Render check of the 29 compositions + of `1300-thirdparty.aep` (as above): CC Color Neutralizer at its defaults and the minimum and + maximum compositions match within one level (no composition isolates Pinning: the maximum has + Blend w. Original 100, which shows the original), the compositions with reported Red / + Green / Blue corrections or View differ as reported (up to 5 levels, View 25); EXtractoR at its + defaults and with UnMult within 3 levels on 6 % of the pixels, keyed and expression Black Point + within one level, the ±3.4e38 compositions differ (reported as damaged data). IDentifier: After + Effects' frame is the unchanged EXR in every variant (its default Object ID finds no channel in + that EXR), while EffectCraft's IDentifier finds the EXR's `ID` channel and draws it; the import + reports this for every IDentifier. No sample changes EXtractoR's channels or IDentifier's channel + type (the presets for that were missing from the block). - `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). From 9c25944c700ab7ac8463462b006f34425b8e6a39 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 17:45:19 +0100 Subject: [PATCH 3/8] AEP import: EXtractoR's and IDentifier's chosen channels by name Presets of our own (blocks 1300 and 1310) show what the Channel Info stores for a chosen channel. - EXtractoR: each record is a kind in byte 0 and the channel name. Named channels are imported by name (Z, Z, Z renders equal to After Effects) and `(copy)` as the input's own channel. The kind is byte 0 only: one definition default has `02 00 00 32`. - IDentifier: byte 19 holds the chosen channel's name, `Object ID` when none is chosen. After Effects lists only UINT channels and leaves the layer unchanged without one, which the loss now says. A chosen `ObjectID` / `MaterialID` channel (any case) is the one EffectCraft reads itself, so it imports without a loss; other names are reported. Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/container/arbitrary.rs | 39 +++++++++--- crates/aep/src/container/effect_map.rs | 4 +- crates/aep/src/import/effects.rs | 49 +++++++++++---- .../aep/tests/it/import/effects_thirdparty.rs | 62 +++++++++++++++---- docs/aep-format.md | 24 +++++-- docs/aep-import.md | 14 ++++- 6 files changed, 149 insertions(+), 43 deletions(-) diff --git a/crates/aep/src/container/arbitrary.rs b/crates/aep/src/container/arbitrary.rs index 7b47e83c7..cbcc56c53 100644 --- a/crates/aep/src/container/arbitrary.rs +++ b/crates/aep/src/container/arbitrary.rs @@ -309,10 +309,14 @@ impl MeshData { } /// EXtractoR's Channel Info data (`EXtractoR-0013`, 688 bytes, the plug-in's own little-endian -/// layout; read from the default instances of the importer block of 2026-11-05, where no channel -/// was changed): a `u32` 1 and 28 bytes, then four records of 164 bytes (Red, Green, Blue, Alpha), -/// each a `u32` kind (1 a named channel, 2 the input's channel: `(copy)` on Alpha) and the channel -/// name from byte 35, NUL-terminated. Only the default (`R`, `G`, `B`, `(copy)`) is sampled. +/// layout; read from the instances of the importer block of 2026-11-05: the defaults and a preset +/// of our own that reads `Z` into Red, Green and Blue): a `u32` 1 and 28 bytes, then four records +/// of 164 bytes (Red, Green, Blue, Alpha), each a kind in byte 0 (1 a channel of the footage by +/// name, 2 the input's own channel: `(copy)`; bytes 1..4 are not always zero, `0x32` at 3 in one +/// definition's default, so only byte 0 is read) and the channel name from byte 35, +/// NUL-terminated. The other bytes are not read (they differ between instances with the same +/// channels; the `u32` at 4 of a chosen channel looks like its position in the footage's sorted +/// channel list, 13 of 14 for `Z`, where the defaults hold 0). Only the name is read. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExtractorChannels { /// Per output channel (red, green, blue, alpha): the kind and the name. @@ -333,7 +337,7 @@ impl ExtractorChannels { for (k, slot) in channels.iter_mut().enumerate() { let at = 32usize.checked_add(k.checked_mul(Self::RECORD)?)?; let rec = data.get(at..at.checked_add(Self::RECORD)?)?; - let kind = u32::from_le_bytes(*rec.first_chunk::<4>()?); + let kind = u32::from(*rec.first()?); let name = rec.get(35..)?; let end = name.iter().position(|b| *b == 0)?; let name = std::str::from_utf8(name.get(..end)?).ok()?; @@ -348,11 +352,30 @@ impl ExtractorChannels { let want = [(1, "R"), (1, "G"), (1, "B"), (2, "(copy)")]; self.channels.iter().zip(want).all(|((k, n), (wk, wn))| *k == wk && n == wn) } + + /// Per output channel (red, green, blue, alpha) the channel it reads, `Err` with the + /// position of the first record of another kind or without a name: a named channel by its + /// name, `(copy)` as the input's own channel of the same position (`R`, `G`, `B`, `A`). + pub fn sources(&self) -> Result<[String; 4], usize> { + const OWN: [&str; 4] = ["R", "G", "B", "A"]; + let mut out: [String; 4] = Default::default(); + for (k, ((kind, name), slot)) in self.channels.iter().zip(out.iter_mut()).enumerate() { + *slot = match (kind, name.trim()) { + (1, n) if !n.is_empty() => n.to_string(), + (2, _) => OWN.get(k).ok_or(k)?.to_string(), + _ => return Err(k), + }; + } + Ok(out) + } } -/// IDentifier's Channel Info data (`IDentifier-0005`, 148 bytes): the channel type's name, -/// NUL-terminated from byte 19 (`Object ID` in every sample of the importer block of 2026-11-05; -/// the bytes around it differ between instances with the same settings and are not read). +/// IDentifier's Channel Info data (`IDentifier-0005`, 148 bytes): the chosen channel's name, +/// NUL-terminated from byte 19, or `Object ID` when none is chosen (importer blocks 1300 and 1310 +/// of 2026-11-05: `objectId` in a preset of our own that chose that UINT channel, `Object ID` in +/// every other instance). The bytes around it differ between instances with the same settings +/// and are not read (a chosen channel has 15 at byte 4, which looks like its position in the +/// EXR's sorted channel list, as for EXtractoR). pub fn identifier_channel_type(data: &[u8]) -> Option<&str> { let s = data.get(19..)?; let end = s.iter().position(|b| *b == 0)?; diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index 46fcf39ea..7f426bcd1 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -138,9 +138,9 @@ pub enum Extra { Curves, /// Mesh Warp's Distortion Mesh (965, 985, `MeshData`). MeshWarp, - /// EXtractoR's Channel Info (`ExtractorChannels`): the default channels only. + /// EXtractoR's Channel Info (`ExtractorChannels`): the channels by name. Extractor, - /// IDentifier's Channel Info (`identifier_channel_type`): Object ID only. + /// IDentifier's Channel Info (`identifier_channel_type`): the chosen channel by name. Identifier, } diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index a9fa3c2c7..5a8a60285 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -465,44 +465,67 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h Some(m) => import_mesh(&m, here, size, group, col), None => col.lose(LossCategory::DamagedData, format!("{here}: the Distortion Mesh is unreadable, defaults used")), }, - Extra::Extractor => match ExtractorChannels::parse(data) { - // The fresh channels: EffectCraft's own R, G, B, A (its A is the input's alpha, After - // Effects' `(copy)`). - Some(c) if c.is_default() => { - for (id, name) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] { + Extra::Extractor => match ExtractorChannels::parse(data).map(|c| (c.sources(), c)) { + // Each output channel reads the footage's channel of the stored name, which + // EffectCraft's EXtractoR finds by the same name (`Z`); After Effects' `(copy)` is the + // input's own channel, EffectCraft's R, G, B or A (the fresh channels are R, G, B, A). + Some((Ok(names), _)) => { + for (id, name) in ["red", "green", "blue", "alpha"].into_iter().zip(names) { if let Some(prop) = group.prop_mut(id) { - prop.value = Value::Str(name.into()); + prop.value = Value::Str(name); } } } - Some(c) => { + Some((Err(k), c)) => { let names: Vec<&str> = c.channels.iter().map(|(_, n)| n.as_str()).collect(); col.lose( LossCategory::Effects, - format!("{here}: the channels {} are not imported (only the default channels are sampled), R, G, B, A used", names.join(", ")), + format!( + "{here}: the channels {} are not imported (record {} is of an unknown kind or names no channel), R, G, B, A used", + names.join(", "), + k.saturating_add(1) + ), ); } None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, R, G, B, A used")), }, Extra::Identifier => match crate::identifier_channel_type(data) { + // No channel chosen (the type's label): After Effects lists only the footage's integer + // (UINT) channels and, with none chosen, leaves the layer unchanged, where EffectCraft + // draws the footage's object ID channel. Some("Object ID") => { if let Some(prop) = group.prop_mut("channelType") { prop.value = Value::Enum(0); } - // The data names the type, not the channel After Effects reads: with this Channel - // Info After Effects left the sampled EXR unchanged, where EffectCraft draws the - // footage's object ID channel. col.lose( LossCategory::Effects, - format!("{here}: the channel IDentifier reads (Channel Info) is not decoded; EffectCraft uses the footage's object ID channel"), + format!("{here}: no ID channel is chosen (Channel Info), so After Effects leaves the layer unchanged; EffectCraft draws the footage's object ID channel"), ); } - other => col.lose(LossCategory::Effects, format!("{here}: the channel type {other:?} is not imported (only Object ID is sampled), Object ID used")), + // A chosen channel, by name. EffectCraft's IDentifier has no channel of its own: it + // reads the footage's first `ObjectID` (`MaterialID`) channel, names compared without + // regard to case, so a channel of that name is the one After Effects reads. + Some(name) => match IDENTIFIER_OWN.iter().position(|own| own.eq_ignore_ascii_case(name.trim())) { + Some(kind) => { + if let Some(prop) = group.prop_mut("channelType") { + prop.value = Value::Enum(u32::try_from(kind).unwrap_or(0)); + } + } + None => col.lose( + LossCategory::Effects, + format!("{here}: the ID channel '{name}' (Channel Info) is not imported; EffectCraft reads the footage's object ID channel (ObjectID, object_id, objectId, id)"), + ), + }, + None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, the object ID channel used")), }, Extra::None => {} } } +/// The channels EffectCraft's IDentifier reads first for its Channel Type Object ID and Material +/// ID (`AuxChannels::object_id`, `material_id`). +const IDENTIFIER_OWN: [&str; 2] = ["ObjectID", "MaterialID"]; + /// Curves' EffectCraft parameters, in the stored order (RGB, Red, Green, Blue, Alpha). const CURVE_IDS: [&str; 5] = ["rgb", "red", "green", "blue", "alpha"]; diff --git a/crates/aep/tests/it/import/effects_thirdparty.rs b/crates/aep/tests/it/import/effects_thirdparty.rs index 20f0b8230..7bf1ad3c8 100644 --- a/crates/aep/tests/it/import/effects_thirdparty.rs +++ b/crates/aep/tests/it/import/effects_thirdparty.rs @@ -112,6 +112,11 @@ fn extractor_channel_info_layout() { assert!(!c.is_default()); assert_eq!(c.channels[0], (1, "Z".to_string())); assert_eq!(ExtractorChannels::parse(&[0u8; 10]), None); + // The kind is byte 0 of a record: the definition default of `extractor-00-defaults` has + // `02 00 00 32` on Alpha, still the input's own channel. + let c = ExtractorChannels::parse(&channels([(1, "R"), (1, "G"), (1, "B"), (0x3200_0002, "(copy)")])).expect("parses"); + assert!(c.is_default()); + assert_eq!(c.sources(), Ok(["R", "G", "B", "A"].map(String::from))); } #[test] @@ -129,12 +134,32 @@ fn extractor_points_unmult_and_default_channels() { } } +/// `extractor-07-preset-channel-z` (a preset of our own): `Z` into Red, Green and Blue, Alpha +/// `(copy)`. EffectCraft's EXtractoR reads the footage's channel by the same name. #[test] -fn extractor_channels_other_than_the_default_are_reported() { +fn extractor_channels_are_imported_by_name() { let (g, r) = run(&extractor(vec![], channels([(1, "Z"), (1, "Z"), (1, "Z"), (2, "(copy)")])), "ec.3d.extractor"); - let found = losses(&r, LossCategory::Effects, "EXtractoR"); - assert!(found.len() == 1 && found[0].contains("the channels Z, Z, Z, (copy) are not imported"), "{found:?}"); - assert_eq!(v(&g.expect("imported"), "red"), Value::Str("R".into())); + assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::::new()); + let g = g.expect("imported"); + for (id, want) in [("red", "Z"), ("green", "Z"), ("blue", "Z"), ("alpha", "A")] { + assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); + } + // `(copy)` on another channel is that channel of the input. + let (g, _) = run(&extractor(vec![], channels([(2, "(copy)"), (1, "CryptoObject00.R"), (2, "(copy)"), (1, "Z")])), "ec.3d.extractor"); + let g = g.expect("imported"); + for (id, want) in [("red", "R"), ("green", "CryptoObject00.R"), ("blue", "B"), ("alpha", "Z")] { + assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); + } +} + +#[test] +fn extractor_channels_of_an_unknown_kind_are_reported() { + for recs in [[(1, "Z"), (3, "Z"), (1, "Z"), (2, "(copy)")], [(1, "Z"), (1, ""), (1, "Z"), (2, "(copy)")]] { + let (g, r) = run(&extractor(vec![], channels(recs)), "ec.3d.extractor"); + let found = losses(&r, LossCategory::Effects, "EXtractoR"); + assert!(found.len() == 1 && found[0].contains("are not imported (record 2 is of an unknown kind or names no channel)"), "{recs:?}: {found:?}"); + assert_eq!(v(&g.expect("imported"), "red"), Value::Str("R".into()), "{recs:?}"); + } } /// IDentifier's Channel Info: the channel type's name at 19 among bytes that are not read. @@ -164,16 +189,31 @@ fn identifier(values: Vec<(usize, f64)>, data: Vec) -> Fx { #[test] fn identifier_display_id_and_channel_type() { assert_eq!(identifier_channel_type(&id_info("Object ID")), Some("Object ID")); - // `identifier-01-set-all` (ID 10) and `identifier-05-pop2-3` (Alpha Matte). + // `identifier-01-set-all` (ID 10) and `identifier-05-pop2-3` (Alpha Matte): no channel chosen + // (After Effects found none or none was picked), so After Effects leaves the layer unchanged. let (g, r) = run(&identifier(vec![(2, 3.0), (3, 10.0)], id_info("Object ID")), "ec.3d.identifier"); - // Which channel After Effects reads is not decoded (it left the sampled EXR unchanged): reported. let found = losses(&r, LossCategory::Effects, "IDentifier"); - assert!(found.len() == 1 && found[0].contains("is not decoded"), "{found:?}"); + assert!(found.len() == 1 && found[0].contains("no ID channel is chosen"), "{found:?}"); let g = g.expect("imported"); assert_eq!((v(&g, "display"), v(&g, "channelType")), (Value::Enum(2), Value::Enum(0))); near(&g, "id", 10.0); - // A channel type that is not sampled is reported. - let (_, r) = run(&identifier(vec![], id_info("Material ID")), "ec.3d.identifier"); - let found = losses(&r, LossCategory::Effects, "IDentifier"); - assert!(found.len() == 1 && found[0].contains("\"Material ID\""), "{found:?}"); +} + +/// `identifier-09-preset-channel-objectid-id2` (1310, a preset of our own): the UINT channel +/// `objectId` chosen and ID 2. EffectCraft's IDentifier reads that channel itself (`ObjectID` +/// without regard to case), so nothing is lost; another name is reported. +#[test] +fn identifier_chosen_channels_by_name() { + for (name, kind) in [("objectId", 0), ("ObjectID", 0), ("materialid", 1)] { + let (g, r) = run(&identifier(vec![(3, 2.0)], id_info(name)), "ec.3d.identifier"); + assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::::new(), "{name}"); + let g = g.expect("imported"); + assert_eq!(v(&g, "channelType"), Value::Enum(kind), "{name}"); + near(&g, "id", 2.0); + } + for name in ["IndexOB.X", "ID", "Material ID"] { + let (_, r) = run(&identifier(vec![], id_info(name)), "ec.3d.identifier"); + let found = losses(&r, LossCategory::Effects, "IDentifier"); + assert!(found.len() == 1 && found[0].contains(&format!("the ID channel '{name}' (Channel Info) is not imported")), "{name}: {found:?}"); + } } diff --git a/docs/aep-format.md b/docs/aep-format.md index 29ee51963..7b9e67981 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -542,12 +542,24 @@ data layouts, read from default instances only):** | `EXtractoR` (2.6.6) | 1 Channel Info (`EXtractoR-0013`): arbitrary data, 688 bytes, below; 2-6 the Process group: 3 Black Point and 4 White Point float sliders, valid ±3.4028235e38 (the float32 range; slider ±1 000), default 0 / 1; 5 UnMult: checkbox (empty `pard` name, the label in `pdnm`), default off | | `IDentifier` (2.6.6) | 1 Channel Info (`IDentifier-0005`): arbitrary data, 148 bytes, below; 2 Display: popup (Colors, Luma Matte, Alpha Matte, Raw), default 1; 3 ID: integer slider, max 2 147 483 647, default 0 | -EXtractoR's Channel Info is the plug-in's own **little-endian** layout: `u32` 1 and 28 zero bytes, -then four 164-byte records (Red, Green, Blue, Alpha), each a `u32` kind (1 in R, G, B; 2 in Alpha, -whose name is `(copy)`) and the channel name NUL-terminated from record byte 35 (`R`, `G`, `B`). -The data is the same in every composition of the block. IDentifier's data differs between -instances with the same settings except for the channel type's name, NUL-terminated from byte 19 -(`Object ID`); the other bytes are not read. API: `ExtractorChannels`, `identifier_channel_type`. +EXtractoR's Channel Info is the plug-in's own **little-endian** layout: `u32` 1 and 28 bytes, then +four 164-byte records (Red, Green, Blue, Alpha), each a kind in byte 0 (1 a channel of the footage by +name: `R`, `G`, `B` by default; 2 the input's own channel, named `(copy)`: Alpha by default) and the +channel name NUL-terminated from record byte 35. The rest is not read: it differs between instances +with the same channels (uninitialised-looking values, also in bytes 1..4 of a record: `02 00 00 32` +on Alpha in one definition's default, which is why only byte 0 is the kind). **A chosen channel** +(`extractor-07-preset-channel-z`, a preset of our own that reads `Z` into Red, Green and Blue) is kind +1 with its name (`Z`), and the `u32` at record byte 4 is then 13, which looks like the channel's +position in the EXR's sorted channel list of 14 (the defaults hold 0 there: H). IDentifier's data +differs between instances with the same settings except for **the chosen channel's name**, +NUL-terminated from byte 19, which is `Object ID` when no channel is chosen. After Effects lists +only integer (UINT) channels: in 1300's EXR (a FLOAT `ID` channel) it found none ("No ID channels +available"), so that block's preset `identifier-09-preset-channel-id` only sets ID 2; in 1310's +EXR (UINT `objectId` and `IndexOB.X` added) the preset `identifier-09-preset-channel-objectid-id2` +stores `objectId`, with 15 at byte 4 (the channel's position in the sorted list of 16, as for +EXtractoR: H). Without a chosen channel After Effects leaves the layer unchanged (1300 and 1310 +defaults). The other bytes are not read. API: `ExtractorChannels`, +`identifier_channel_type`. **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 diff --git a/docs/aep-import.md b/docs/aep-import.md index f14141156..cb61cd2ab 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -179,7 +179,7 @@ parameter gets the file's value: | `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 | -| Importer block 2026-11-05 (`1300-thirdparty.aep`), plug-ins that ship with After Effects: `CS Color Neutralizer` (CC Color Neutralizer) 19 Pinning, 20 Blend w. Original; `EXtractoR` 1 Channel Info data, 3 Black Point, 4 White Point, 5 UnMult; `IDentifier` 1 Channel Info data, 2 Display, 3 ID | `ec.color.cccolorneutralizer` `pinning`, `blendWOriginal`; `ec.3d.extractor` `red`, `green`, `blue`, `alpha`, `blackPoint`, `whitePoint`, `unMult`; `ec.3d.identifier` `channelType`, `display`, `id` | CC Color Neutralizer: percent (float, valid 100); its correction is the Red / Green / Blue slider of each range (the Unbalance colours are pickers that set them and keep no record), with View and Black / White Point: **EffectCraft's effect has no counterpart, each is reported when stored** (EffectCraft's Unbalance / Balance stay at their identity defaults). EXtractoR: the default channels (R, G, B, `(copy)`) are EffectCraft's R, G, B, A, other channels are reported (`ExtractorChannels`; only the default is sampled), Black and White Point as stored (float, valid ±3.4e38; a value beyond ±1e9 is reported as damaged data, as for every number), UnMult a checkbox, Clip stays off. IDentifier: Display by name, ID as stored (integer, valid 2 147 483 647); the Channel Info names only the channel type (Object ID, `identifier_channel_type`; another is reported), not the channel After Effects reads, so **every instance is reported**: EffectCraft draws the footage's object ID channel where After Effects left the sampled EXR unchanged | `tests/it/import/effects_thirdparty.rs`; render comparison below | +| Importer block 2026-11-05 (`1300-thirdparty.aep`), plug-ins that ship with After Effects: `CS Color Neutralizer` (CC Color Neutralizer) 19 Pinning, 20 Blend w. Original; `EXtractoR` 1 Channel Info data, 3 Black Point, 4 White Point, 5 UnMult; `IDentifier` 1 Channel Info data, 2 Display, 3 ID | `ec.color.cccolorneutralizer` `pinning`, `blendWOriginal`; `ec.3d.extractor` `red`, `green`, `blue`, `alpha`, `blackPoint`, `whitePoint`, `unMult`; `ec.3d.identifier` `channelType`, `display`, `id` | CC Color Neutralizer: percent (float, valid 100); its correction is the Red / Green / Blue slider of each range (the Unbalance colours are pickers that set them and keep no record), with View and Black / White Point: **EffectCraft's effect has no counterpart, each is reported when stored** (EffectCraft's Unbalance / Balance stay at their identity defaults). EXtractoR: each output channel by the footage channel's name, which EffectCraft's EXtractoR reads by the same name (the default R, G, B; `Z` from our preset), and `(copy)` as the input's own channel of that position (EffectCraft's R, G, B or A; the default Alpha is A); a record of another kind or without a name is reported and R, G, B, A used (`ExtractorChannels`), Black and White Point as stored (float, valid ±3.4e38; a value beyond ±1e9 is reported as damaged data, as for every number), UnMult a checkbox, Clip stays off. IDentifier: Display by name, ID as stored (integer, valid 2 147 483 647); the Channel Info names the chosen channel (`identifier_channel_type`): After Effects lists only the footage's integer (UINT) channels, and with none chosen (the data then says `Object ID`) it leaves the layer unchanged, where EffectCraft draws the footage's object ID channel: **reported**; a chosen channel named `ObjectID` or `MaterialID` (without regard to case, `objectId` in our preset) is the one EffectCraft's IDentifier reads itself (Channel Type Object ID or Material ID), any other name is reported (EffectCraft's IDentifier has no channel choice) | `tests/it/import/effects_thirdparty.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 @@ -336,8 +336,16 @@ frame blending, 3D material options, audio levels, guides, the mask colour, lock within one level, the ±3.4e38 compositions differ (reported as damaged data). IDentifier: After Effects' frame is the unchanged EXR in every variant (its default Object ID finds no channel in that EXR), while EffectCraft's IDentifier finds the EXR's `ID` channel and draws it; the import - reports this for every IDentifier. No sample changes EXtractoR's channels or IDentifier's channel - type (the presets for that were missing from the block). + reports this for every IDentifier. The follow-up presets (our own): `extractor-07-preset-channel-z` + (`Z` into Red, Green and Blue) imports without a loss and renders equal to After Effects (within + 3e-5 at After Effects' 1/10 PNG scale; the background's Z of 100 clips there in both). IDentifier + needs an integer channel: block 1310 (`1310-identifier-uint.aep`, the EXR with UINT channels + `objectId` and `IndexOB.X` added) with the preset `identifier-09-preset-channel-objectid-id2` + (`objectId`, ID 2) imports without a loss, and EffectCraft's IDentifier finds the same channel: + the ID regions are the same pixels (8 449, 2 400, 1 515, 1 256, 780), the colours differ (After + Effects draws flat colours per ID and does not highlight the chosen ID, EffectCraft draws its + own ID colours with the chosen ID white). At its defaults (no channel chosen) After Effects + leaves the EXR unchanged in 1310 too. - `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). From 495221aac67f1b8491455fe8bd1bd1fe3f40428f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 18:36:56 +0100 Subject: [PATCH 4/8] AEP import: doc comments on the quick-win test helpers and tests Co-Authored-By: Claude Opus 5.5 --- crates/aep/src/import/effects.rs | 2 ++ crates/aep/tests/it/import/effects_quickwins.rs | 7 +++++++ crates/aep/tests/it/import/fx_inject.rs | 15 +++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 1d20a9dd1..c078f7b76 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..0d4ed61ec 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", @@ -26,6 +27,7 @@ fn echo(values: Vec<(usize, f64)>) -> Fx { } } +/// HDR Highlight Compression's Amount, set and default. #[test] fn hdr_highlight_compression_amount() { let fx = |values| Fx { @@ -43,6 +45,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 +64,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 +79,7 @@ fn echo_values_beyond_effectcrafts_range_are_reported() { near(&g, "decay", 100.0); } +/// Linear Wipe's values, set and default. #[test] fn linear_wipe_values() { let fx = |values| Fx { @@ -99,6 +104,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 { @@ -118,6 +124,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 { diff --git a/crates/aep/tests/it/import/fx_inject.rs b/crates/aep/tests/it/import/fx_inject.rs index 9687e2d6c..bf9f08ea2 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()) } @@ -137,10 +141,13 @@ pub struct Fx { pub data: Vec<(usize, Vec)>, } +/// 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![ @@ -194,6 +201,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 +247,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 +260,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 +277,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 From c32aa7cfbc1c63d1f8b9cba1c5e34419fd498d7e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 23:58:35 +0100 Subject: [PATCH 5/8] AEP import: Echo keyframe regression for the range loss Co-Authored-By: Claude Sonnet 5.5 --- .../aep/tests/it/import/effects_quickwins.rs | 23 +++++++++++++++ crates/aep/tests/it/import/fx_inject.rs | 29 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/crates/aep/tests/it/import/effects_quickwins.rs b/crates/aep/tests/it/import/effects_quickwins.rs index 0d4ed61ec..f2b0594b6 100644 --- a/crates/aep/tests/it/import/effects_quickwins.rs +++ b/crates/aep/tests/it/import/effects_quickwins.rs @@ -24,6 +24,7 @@ fn echo(values: Vec<(usize, f64)>) -> Fx { ], values, data: vec![], + keys: vec![], } } @@ -36,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)] { @@ -79,6 +81,24 @@ 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() { @@ -92,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"); @@ -113,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"); @@ -139,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 bf9f08ea2..81e6a821a 100644 --- a/crates/aep/tests/it/import/fx_inject.rs +++ b/crates/aep/tests/it/import/fx_inject.rs @@ -130,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). @@ -139,6 +160,9 @@ 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`). @@ -172,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)]) } @@ -306,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(); From 0f882368846d75bd30212abfe9ae5b4e11bb3eb9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 6 Nov 2026 00:05:54 +0100 Subject: [PATCH 6/8] AEP import: negative cases for EXtractoR and IDentifier channel names and a missing Channel Info payload Co-Authored-By: Claude Sonnet 5.5 --- .../aep/tests/it/import/effects_thirdparty.rs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/crates/aep/tests/it/import/effects_thirdparty.rs b/crates/aep/tests/it/import/effects_thirdparty.rs index 7bf1ad3c8..7c7ec190c 100644 --- a/crates/aep/tests/it/import/effects_thirdparty.rs +++ b/crates/aep/tests/it/import/effects_thirdparty.rs @@ -44,6 +44,7 @@ fn neutralizer(values: Vec<(usize, f64)>) -> Fx { ], values, data: vec![], + keys: vec![], } } @@ -101,6 +102,7 @@ fn extractor(values: Vec<(usize, f64)>, data: Vec) -> Fx { ], values, data: vec![(1, data)], + keys: vec![], } } @@ -183,6 +185,7 @@ fn identifier(values: Vec<(usize, f64)>, data: Vec) -> Fx { ], values, data: vec![(1, data)], + keys: vec![], } } @@ -217,3 +220,71 @@ fn identifier_chosen_channels_by_name() { assert!(found.len() == 1 && found[0].contains(&format!("the ID channel '{name}' (Channel Info) is not imported")), "{name}: {found:?}"); } } + +/// EXtractoR's data with the name of record `k` from byte 35 replaced by `name` (no NUL added; the +/// rest of the record's name field is left as `fill`). +fn channels_with_raw_name(k: usize, name: &[u8], fill: u8) -> Vec { + let mut d = channels(DEFAULT_CHANNELS); + let at = 32 + k * 164 + 35; + d[at..at + 129].fill(fill); + d[at..at + name.len()].copy_from_slice(name); + d +} + +/// A channel name that never ends (no NUL up to the end of its record) or is not UTF-8 is +/// refused by the decoder and reported at the importer; the defaults R, G, B, A stay. +#[test] +fn extractor_names_without_a_terminator_or_in_invalid_utf8_are_refused() { + let unterminated = channels_with_raw_name(2, b"Z", b'Z'); + let invalid = channels_with_raw_name(1, &[0x5a, 0xff, 0xfe, 0x00], 0); + for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] { + assert_eq!(ExtractorChannels::parse(&data), None, "{what}"); + let (g, r) = run(&extractor(vec![], data), "ec.3d.extractor"); + let found = losses(&r, LossCategory::Effects, "EXtractoR"); + assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}"); + let g = g.expect("imported"); + for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] { + assert_eq!(v(&g, id), Value::Str(want.into()), "{what}: {id}"); + } + } +} + +/// The same for IDentifier: a channel name without NUL up to the end of the data, or in invalid +/// UTF-8, is refused and reported; the Object ID channel stays. +#[test] +fn identifier_names_without_a_terminator_or_in_invalid_utf8_are_refused() { + let mut unterminated = id_info("objectId"); + unterminated[19..].fill(b'x'); + let mut invalid = id_info("objectId"); + invalid[19..22].copy_from_slice(&[b'o', 0xff, 0]); + for (what, data) in [("unterminated", unterminated), ("invalid UTF-8", invalid)] { + assert_eq!(identifier_channel_type(&data), None, "{what}"); + let (g, r) = run(&identifier(vec![], data), "ec.3d.identifier"); + let found = losses(&r, LossCategory::Effects, "IDentifier"); + assert!(found.len() == 1 && found[0].contains("not in the sampled layout"), "{what}: {found:?}"); + assert_eq!(v(&g.expect("imported"), "channelType"), Value::Enum(0), "{what}"); + } + // Data too short to reach the name. + assert_eq!(identifier_channel_type(&[1; 10]), None); +} + +/// A Channel Info value record without its arbitrary data (no `aRbs`): the effect still imports, +/// with the defaults, and nothing panics. +#[test] +fn a_missing_channel_info_payload_leaves_the_defaults() { + let mut fx = extractor(vec![], vec![]); + fx.data.clear(); + let (g, r) = run(&fx, "ec.3d.extractor"); + assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::::new()); + let g = g.expect("imported"); + for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] { + assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); + } + let mut fx = identifier(vec![(3, 5.0)], vec![]); + fx.data.clear(); + let (g, r) = run(&fx, "ec.3d.identifier"); + assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::::new()); + let g = g.expect("imported"); + assert_eq!(v(&g, "channelType"), Value::Enum(0)); + near(&g, "id", 5.0); +} From 67a39d9a2b0e1915fbd3d8623745df0b50b47c3c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 02:32:11 +0100 Subject: [PATCH 7/8] .aep import: EXtractoR (copy) reads the effect input, not the EXR plane of that name After Effects' Channel Info kind 2 "(copy)" was imported as R, G, B or A. EffectCraft's EXtractoR resolves those names through the footage's aux planes first, and an OpenEXR keeps its own R/G/B/A planes, so "(copy)" read the original file plane and dropped what an earlier effect did to the channel. "(copy)" is now a reserved EXtractoR channel name (EXTRACTOR_COPY) that the CPU and GPU renderers never look up in the aux planes: it is the input's own channel of the same position. The importer writes it for kind 2. Render regression: a modified input buffer with same-named R/G/B/A aux planes of other values; GPU parity case with (copy) on an aux scene with R/G/B. --- crates/aep/src/container/arbitrary.rs | 8 +-- crates/aep/src/import/effects.rs | 3 +- .../aep/tests/it/import/effects_thirdparty.rs | 13 ++--- crates/effects/src/channel3d.rs | 51 ++++++++++++++----- crates/effects/src/channel3d/tests.rs | 32 ++++++++++++ crates/effects/src/lib.rs | 5 +- crates/gpu/src/fx_depth.rs | 15 ++---- crates/gpu/src/tests/fx_depth.rs | 3 ++ docs/aep-format.md | 4 +- 9 files changed, 99 insertions(+), 35 deletions(-) diff --git a/crates/aep/src/container/arbitrary.rs b/crates/aep/src/container/arbitrary.rs index 72e7db28a..e4916cfe8 100644 --- a/crates/aep/src/container/arbitrary.rs +++ b/crates/aep/src/container/arbitrary.rs @@ -355,14 +355,16 @@ impl ExtractorChannels { /// Per output channel (red, green, blue, alpha) the channel it reads, `Err` with the /// position of the first record of another kind or without a name: a named channel by its - /// name, `(copy)` as the input's own channel of the same position (`R`, `G`, `B`, `A`). + /// name, `(copy)` as EffectCraft's [`EXTRACTOR_COPY`](effectcraft_effects::EXTRACTOR_COPY), + /// the effect input's own channel of the same position. It is not `R`, `G`, `B` or `A`: + /// EffectCraft's EXtractoR reads those from an OpenEXR footage's channels of that name, the + /// file's own, which loses what an earlier effect did to the channel. pub fn sources(&self) -> Result<[String; 4], usize> { - const OWN: [&str; 4] = ["R", "G", "B", "A"]; let mut out: [String; 4] = Default::default(); for (k, ((kind, name), slot)) in self.channels.iter().zip(out.iter_mut()).enumerate() { *slot = match (kind, name.trim()) { (1, n) if !n.is_empty() => n.to_string(), - (2, _) => OWN.get(k).ok_or(k)?.to_string(), + (2, _) => effectcraft_effects::EXTRACTOR_COPY.to_string(), _ => return Err(k), }; } diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 8729571ec..a65a67d58 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -492,7 +492,8 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h Extra::Extractor => match ExtractorChannels::parse(data).map(|c| (c.sources(), c)) { // Each output channel reads the footage's channel of the stored name, which // EffectCraft's EXtractoR finds by the same name (`Z`); After Effects' `(copy)` is the - // input's own channel, EffectCraft's R, G, B or A (the fresh channels are R, G, B, A). + // input's own channel, EffectCraft's `(copy)` (not `A`, which EffectCraft reads from an + // OpenEXR footage's alpha plane, losing what earlier effects did to it). Some((Ok(names), _)) => { for (id, name) in ["red", "green", "blue", "alpha"].into_iter().zip(names) { if let Some(prop) = group.prop_mut(id) { diff --git a/crates/aep/tests/it/import/effects_thirdparty.rs b/crates/aep/tests/it/import/effects_thirdparty.rs index 7c7ec190c..e7e00dd77 100644 --- a/crates/aep/tests/it/import/effects_thirdparty.rs +++ b/crates/aep/tests/it/import/effects_thirdparty.rs @@ -118,7 +118,7 @@ fn extractor_channel_info_layout() { // `02 00 00 32` on Alpha, still the input's own channel. let c = ExtractorChannels::parse(&channels([(1, "R"), (1, "G"), (1, "B"), (0x3200_0002, "(copy)")])).expect("parses"); assert!(c.is_default()); - assert_eq!(c.sources(), Ok(["R", "G", "B", "A"].map(String::from))); + assert_eq!(c.sources(), Ok(["R", "G", "B", "(copy)"].map(String::from))); } #[test] @@ -130,8 +130,9 @@ fn extractor_points_unmult_and_default_channels() { near(&g, "blackPoint", 10.0); near(&g, "whitePoint", 14.0); assert_eq!((v(&g, "unMult"), v(&g, "clip")), (Value::Bool(true), Value::Bool(false))); - // After Effects' `(copy)` Alpha is the input's alpha: EffectCraft's A. - for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] { + // After Effects' `(copy)` Alpha is the input's alpha: EffectCraft's `(copy)`, not `A` (an + // OpenEXR's own alpha plane). + for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "(copy)")] { assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); } } @@ -143,13 +144,13 @@ fn extractor_channels_are_imported_by_name() { let (g, r) = run(&extractor(vec![], channels([(1, "Z"), (1, "Z"), (1, "Z"), (2, "(copy)")])), "ec.3d.extractor"); assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::::new()); let g = g.expect("imported"); - for (id, want) in [("red", "Z"), ("green", "Z"), ("blue", "Z"), ("alpha", "A")] { + for (id, want) in [("red", "Z"), ("green", "Z"), ("blue", "Z"), ("alpha", "(copy)")] { assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); } - // `(copy)` on another channel is that channel of the input. + // `(copy)` on another channel is that channel of the input (EffectCraft's `(copy)` there). let (g, _) = run(&extractor(vec![], channels([(2, "(copy)"), (1, "CryptoObject00.R"), (2, "(copy)"), (1, "Z")])), "ec.3d.extractor"); let g = g.expect("imported"); - for (id, want) in [("red", "R"), ("green", "CryptoObject00.R"), ("blue", "B"), ("alpha", "Z")] { + for (id, want) in [("red", "(copy)"), ("green", "CryptoObject00.R"), ("blue", "(copy)"), ("alpha", "Z")] { assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); } } diff --git a/crates/effects/src/channel3d.rs b/crates/effects/src/channel3d.rs index 9e9534ac1..b84b89ebb 100644 --- a/crates/effects/src/channel3d.rs +++ b/crates/effects/src/channel3d.rs @@ -619,6 +619,39 @@ fn cryptomatte(ctx: &EffectCtx, mut b: Buf) -> Buf { // ---------------------------------------------------------------- EXtractoR +/// EXtractoR's channel name for After Effects' `(copy)`: the effect input's own channel of the +/// same position (red's red ... alpha's alpha), as the earlier effects left it, never a footage +/// channel of some name (an OpenEXR's `A` is the file's alpha, not the input's). +pub const EXTRACTOR_COPY: &str = "(copy)"; + +/// Whether an EXtractoR channel name is [`EXTRACTOR_COPY`]. +pub fn is_extractor_copy(name: &str) -> bool { + name.trim().eq_ignore_ascii_case(EXTRACTOR_COPY) +} + +/// The layer's own channel an EXtractoR output channel at position `k` reads when no footage +/// channel of that name exists: `R`/`G`/`B`/`A` (or the long names) by name, [`EXTRACTOR_COPY`] +/// its own position. +pub fn extractor_own_channel(k: usize, name: &str) -> Option { + if is_extractor_copy(name) { + return (k < 4).then_some(k); + } + match name.trim().to_ascii_uppercase().as_str() { + "R" | "RED" => Some(0), + "G" | "GREEN" => Some(1), + "B" | "BLUE" => Some(2), + "A" | "ALPHA" => Some(3), + _ => None, + } +} + +/// The footage channel an EXtractoR output channel reads by name: none for an empty name or +/// [`EXTRACTOR_COPY`] (the input's own channel). +pub fn extractor_aux_channel<'a>(a: &'a AuxChannels, name: &str) -> Option<&'a [f32]> { + let n = name.trim(); + if n.is_empty() || is_extractor_copy(n) { None } else { a.get(n) } +} + fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf { let a = aux(ctx); let names = [ctx.params.s("red"), ctx.params.s("green"), ctx.params.s("blue"), ctx.params.s("alpha")]; @@ -626,17 +659,11 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf { let unmult = ctx.params.b("unMult"); let clip = ctx.params.b("clip"); let idx = a.as_ref().map(|a| index_map(&b, a)); - // Each output channel: an aux channel by name, or the layer's own R/G/B/A. - let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() { - "R" | "RED" => Some(0), - "G" | "GREEN" => Some(1), - "B" | "BLUE" => Some(2), - "A" | "ALPHA" => Some(3), - "" => None, - _ => None, - }; - let src_ch: Vec> = names.iter().map(|n| a.as_ref().and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect(); - if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k)) { + // Each output channel: an aux channel by name, or the layer's own R/G/B/A (`(copy)`: its + // own position, never an aux channel). + let own = |k: usize, n: &str| extractor_own_channel(k, n); + let src_ch: Vec> = names.iter().map(|n| a.as_ref().and_then(|a| extractor_aux_channel(a, n))).collect(); + if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(k, n) == Some(k)) { return b; } let src = b.img.clone(); @@ -652,7 +679,7 @@ fn extractor(ctx: &EffectCtx, mut b: Buf) -> Buf { o[k] = match (src_ch[k], idx.as_ref().and_then(|m| m[i])) { (Some(ch), Some(j)) => ch[j], (Some(_), None) => 0.0, - (None, _) => match own(names[k]) { + (None, _) => match own(k, names[k]) { Some(c) => straight[c], None => { if k == 3 { diff --git a/crates/effects/src/channel3d/tests.rs b/crates/effects/src/channel3d/tests.rs index ac9547444..c1fa21711 100644 --- a/crates/effects/src/channel3d/tests.rs +++ b/crates/effects/src/channel3d/tests.rs @@ -457,6 +457,38 @@ fn extractor_maps_named_channels() { assert_eq!(s.img.get(0, 0), [1.0, 0.3, 0.4, 1.0]); } +/// `(copy)` (After Effects' EXtractoR choice, imported from .aep) is the effect input's own +/// channel as earlier effects left it, never the OpenEXR's plane of the same name: here the input +/// buffer differs from the footage's R, G, B and A planes, as after an earlier effect. +#[test] +fn extractor_copy_reads_the_current_input_not_the_footage_plane() { + let (w, h) = (4usize, 3usize); + let mut a = AuxChannels::new(w as u32, h as u32, 1.0); + // The file's own planes (what the footage held before any effect) and a depth channel. + a.channels = ["R", "G", "B", "A"].iter().map(|n| (n.to_string(), vec![0.9; w * h])).collect(); + a.channels.push(("Z".into(), vec![0.7; w * h])); + let host = AuxHost(Arc::new(a)); + let env = || EffectEnv { host: Some(&host), ..Default::default() }; + // The input after an earlier effect: straight (0.2, 0.3, 0.4) at alpha 0.5, premultiplied. + let input = Image::filled(w as u32, h as u32, [0.1, 0.15, 0.2, 0.5]); + let copy = || Value::Str(EXTRACTOR_COPY.into()); + // All four `(copy)`: the input unchanged. + let all = [("red", copy()), ("green", copy()), ("blue", copy()), ("alpha", copy())]; + let o = run_fx("ec.3d.extractor", &all, input.clone(), 0.0, env()).img; + assert_eq!(o.data, input.data); + // Mixed with a named channel (no pass-through): red from Z, green and alpha copied, blue the + // footage's B plane. + let vals = [("red", Value::Str("Z".into())), ("green", copy()), ("blue", Value::Str("B".into())), ("alpha", copy())]; + let px = run_fx("ec.3d.extractor", &vals, input.clone(), 0.0, env()).img.get(1, 1); + let want = [0.7 * 0.5, 0.3 * 0.5, 0.9 * 0.5, 0.5]; + for (c, (got, want)) in px.iter().zip(want).enumerate() { + assert!((got - want).abs() < 1e-6, "channel {c}: {got} != {want} ({px:?})"); + } + // `A` by name is the footage's alpha plane (0.9), not the input's alpha (0.5). + let named = [("alpha", Value::Str("A".into())), ("red", Value::Str("Z".into()))]; + assert!((run_fx("ec.3d.extractor", &named, input, 0.0, env()).img.get(1, 1)[3] - 0.9).abs() < 1e-6); +} + #[test] fn channel_extract_antialias_averages_sub_pixels() { // Depth at twice the layer resolution, alternating 0 / 1000 per aux column. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 0cde17766..bd239aa6f 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -116,8 +116,9 @@ pub use time_fx::{PixelMotion, TimewarpPlan, TwStep, pmb_plan, posterized_time, pub use ocio::color_stabilizer_maps; // GPU effects, part B (3D Channel, Immersive Video, colour management, simulation render passes). pub use channel3d::{ - CRYPTO_LAYERS, CryptoAux, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu, - crypto_layer_name, crypto_layer_named, id_color, matte_only_px, reads_aux, selection_hashes, + CRYPTO_LAYERS, CryptoAux, EXTRACTOR_COPY, MATTE_ONLY_IN_ALPHA, aux_point, crypto_aux_image, crypto_aux_image_with, crypto_layer_choices, crypto_layer_menu, + crypto_layer_name, crypto_layer_named, extractor_aux_channel, extractor_own_channel, id_color, is_extractor_copy, matte_only_px, reads_aux, + selection_hashes, }; pub use ocio::{ColorOp, Straight, Tf, color_program}; pub use sim::{Acc, Blob, Bubble, Post, Shape, Sprite, SpritePlan, Tint, bubble_list, bubble_sample_bounds, drizzle_drops, mercury_blobs, sprite_plan}; diff --git a/crates/gpu/src/fx_depth.rs b/crates/gpu/src/fx_depth.rs index 5932a5aa1..3f348c47d 100644 --- a/crates/gpu/src/fx_depth.rs +++ b/crates/gpu/src/fx_depth.rs @@ -292,19 +292,14 @@ fn extractor(e: &mut Enc, ctx: &EffectCtx, b: GBuf, a: Option<&AuxChannels>) -> let names = [pr.s("red"), pr.s("green"), pr.s("blue"), pr.s("alpha")]; let (bp, wp) = (pr.f("blackPoint") as f32, pr.f("whitePoint") as f32); let unmult = pr.b("unMult"); - let own = |n: &str| match n.trim().to_ascii_uppercase().as_str() { - "R" | "RED" => Some(0u32), - "G" | "GREEN" => Some(1), - "B" | "BLUE" => Some(2), - "A" | "ALPHA" => Some(3), - _ => None, - }; - let src_ch: Vec> = names.iter().map(|n| a.and_then(|a| if n.trim().is_empty() { None } else { a.get(n.trim()) })).collect(); - if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(n) == Some(k as u32)) { + // `(copy)`: the input's own channel of the same position, never an aux channel. + let own = |k: usize, n: &str| effectcraft_effects::extractor_own_channel(k, n).and_then(|c| u32::try_from(c).ok()); + let src_ch: Vec> = names.iter().map(|n| a.and_then(|a| effectcraft_effects::extractor_aux_channel(a, n))).collect(); + if src_ch.iter().all(Option::is_none) && bp == 0.0 && wp == 1.0 && !unmult && names.iter().enumerate().all(|(k, n)| own(k, n) == Some(k as u32)) { return Some(b); } // Per output channel: 0..3 = the layer's own channel, 4 = aux, 5 = default (0, alpha 1). - let sources: [u32; 4] = std::array::from_fn(|k| if src_ch[k].is_some() { 4 } else { own(names[k]).unwrap_or(5) }); + let sources: [u32; 4] = std::array::from_fn(|k| if src_ch[k].is_some() { 4 } else { own(k, names[k]).unwrap_or(5) }); let plane = match a { Some(a) if src_ch.iter().any(Option::is_some) => { let tex = aux_texture(e, a, [src_ch[0], src_ch[1], src_ch[2], src_ch[3]])?; diff --git a/crates/gpu/src/tests/fx_depth.rs b/crates/gpu/src/tests/fx_depth.rs index 088b160ce..f60f31e3e 100644 --- a/crates/gpu/src/tests/fx_depth.rs +++ b/crates/gpu/src/tests/fx_depth.rs @@ -288,4 +288,7 @@ fn extractor() { ); case("ec.3d.extractor", &[("red", Value::Str("A".into())), ("blackPoint", n(0.2)), ("whitePoint", n(0.8)), ("clip", on())]); case("ec.3d.extractor", &[("green", Value::Str("".into())), ("alpha", Value::Str("nothing".into()))]); + // `(copy)` reads the input's own channel, not the footage's R plane of aux_scene. + let copy = || Value::Str(effectcraft_effects::EXTRACTOR_COPY.into()); + case("ec.3d.extractor", &[("red", copy()), ("green", Value::Str("Coverage".into())), ("alpha", copy())]); } diff --git a/docs/aep-format.md b/docs/aep-format.md index f4c0059e6..8e5230bed 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -571,7 +571,9 @@ data layouts, read from default instances only):** EXtractoR's Channel Info is the plug-in's own **little-endian** layout: `u32` 1 and 28 bytes, then four 164-byte records (Red, Green, Blue, Alpha), each a kind in byte 0 (1 a channel of the footage by name: `R`, `G`, `B` by default; 2 the input's own channel, named `(copy)`: Alpha by default) and the -channel name NUL-terminated from record byte 35. The rest is not read: it differs between instances +channel name NUL-terminated from record byte 35. EffectCraft imports kind 2 as its EXtractoR's `(copy)`, +the effect input's channel of the same position as earlier effects left it, not as `A` (which names +an OpenEXR footage's own alpha plane). The rest is not read: it differs between instances with the same channels (uninitialised-looking values, also in bytes 1..4 of a record: `02 00 00 32` on Alpha in one definition's default, which is why only byte 0 is the kind). **A chosen channel** (`extractor-07-preset-channel-z`, a preset of our own that reads `Z` into Red, Green and Blue) is kind From b90950c6a22b49e2c90b1e674c355121c8017679 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 02:32:56 +0100 Subject: [PATCH 8/8] .aep import: a Channel Info record without its data is reported as damaged import_extra returned as soon as the arbitrary data was missing, so an EXtractoR or IDentifier value record whose aRbs/aRbp payload is gone was silently imported with the default channels; the payload may have chosen other ones (Z). As for Lumetri's data parameters, a stored record without its payload now adds a DamagedData loss; a parameter with no record stays the silent default. The test now asserts the report. --- crates/aep/src/import/effects.rs | 10 +++++++++- crates/aep/tests/it/import/effects_thirdparty.rs | 9 +++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index a65a67d58..77d1db32e 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -438,7 +438,15 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {} are not imported", p.name)); } let Some(data) = fx.arbitrary_data(&p.match_name) else { - // No data: every value is at its default. + // No record: every value is at its default. A Channel Info value record without its + // aRbs/aRbp payload (every sampled record has it) is damaged: the payload may have chosen + // other channels (`Z`), so the defaults are not what After Effects renders. + if p.stored().is_some() && matches!(extra, Extra::Extractor | Extra::Identifier) { + col.lose( + LossCategory::DamagedData, + format!("{here}: the Channel Info data is missing (a value record without its data), the default channels used"), + ); + } return; }; let mut set = |id: &str, v: f64| { diff --git a/crates/aep/tests/it/import/effects_thirdparty.rs b/crates/aep/tests/it/import/effects_thirdparty.rs index e7e00dd77..567d77642 100644 --- a/crates/aep/tests/it/import/effects_thirdparty.rs +++ b/crates/aep/tests/it/import/effects_thirdparty.rs @@ -270,13 +270,16 @@ fn identifier_names_without_a_terminator_or_in_invalid_utf8_are_refused() { } /// A Channel Info value record without its arbitrary data (no `aRbs`): the effect still imports, -/// with the defaults, and nothing panics. +/// with the defaults, nothing panics, and the missing data is reported as damaged (it may have +/// chosen other channels, so the defaults are not what After Effects renders). #[test] -fn a_missing_channel_info_payload_leaves_the_defaults() { +fn a_missing_channel_info_payload_is_reported_and_leaves_the_defaults() { let mut fx = extractor(vec![], vec![]); fx.data.clear(); let (g, r) = run(&fx, "ec.3d.extractor"); assert_eq!(losses(&r, LossCategory::Effects, "EXtractoR"), Vec::::new()); + let found = losses(&r, LossCategory::DamagedData, "EXtractoR"); + assert!(found.len() == 1 && found[0].contains("Channel Info data is missing"), "{found:?}"); let g = g.expect("imported"); for (id, want) in [("red", "R"), ("green", "G"), ("blue", "B"), ("alpha", "A")] { assert_eq!(v(&g, id), Value::Str(want.into()), "{id}"); @@ -285,6 +288,8 @@ fn a_missing_channel_info_payload_leaves_the_defaults() { fx.data.clear(); let (g, r) = run(&fx, "ec.3d.identifier"); assert_eq!(losses(&r, LossCategory::Effects, "IDentifier"), Vec::::new()); + let found = losses(&r, LossCategory::DamagedData, "IDentifier"); + assert!(found.len() == 1 && found[0].contains("Channel Info data is missing"), "{found:?}"); let g = g.expect("imported"); assert_eq!(v(&g, "channelType"), Value::Enum(0)); near(&g, "id", 5.0);