From d10991eff3b405aa8f1e3171ece47cde0bd764e2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Thu, 5 Nov 2026 14:45:39 +0100 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 61af9e5b725658ec1d8a394cfb6d2b14391ba0f7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 15:41:14 +0100 Subject: [PATCH 07/13] #3233: a text layer's name follows its text until the user renames it A text layer's name followed its Source Text whenever the name started with "Text", so a user's "Text Title" was replaced by the next text edit. The layer now records whether its name follows the text (`name_follows_text`, optional in the project file). A new text layer named after its text follows it; a name given at creation or by Rename (Enter, layer.rename, a script's layer.name, Layer Settings' name) is the user's and stays; renaming to an empty name shows the text again and follows it; committing the unchanged name changes nothing; copies keep the state. On-canvas typing and layer.setText share the rule. The name is the text's first paragraph, which ends at a line feed or a bare carriage return (After Effects' paragraph break). Projects saved without the state keep the old rule for the names that followed: a name that is empty, the text's own, or "Text"/"Text 2"... follows; any other name stays. AEP import records a stored layer name as the user's and no name as following, and names an empty text layer without a name "Text" instead of "Layer"; export writes no name for a following layer and keeps the name the user gave, even when it equals the text. Fixes #3233 --- crates/aep/src/export/plan.rs | 5 +- crates/aep/src/import/build.rs | 11 ++- crates/aep/tests/it/export/text.rs | 42 ++++++++++ crates/engine/src/cmd/layer.rs | 10 ++- crates/engine/src/cmd/layer/contents.rs | 12 +-- crates/engine/src/cmd/layer/create.rs | 4 +- crates/engine/src/cmd/layer/switches.rs | 26 ++++++- crates/engine/src/cmd/layer/transform.rs | 4 +- crates/engine/src/cmd/text_edit.rs | 15 ++-- crates/engine/src/tests/text_edit.rs | 99 ++++++++++++++++++++++++ crates/project/src/build.rs | 1 + crates/project/src/layer.rs | 26 +++++++ crates/project/src/lib.rs | 2 +- docs/agents.md | 2 + 14 files changed, 236 insertions(+), 23 deletions(-) diff --git a/crates/aep/src/export/plan.rs b/crates/aep/src/export/plan.rs index 9f45c50cd..f8aa2b3b8 100644 --- a/crates/aep/src/export/plan.rs +++ b/crates/aep/src/export/plan.rs @@ -1047,9 +1047,10 @@ fn plan_layer(cx: &PlanCx<'_>, at: CompAt<'_>, cp: &CompPlan, layer: &Layer, col let props = plan_transform(layer, &src, comp, source_size, cp, comp_name, col); let remap = remap_prop.filter(|_| remapped).map(|p| plan_remap(p, comp, cp, &layer.name, comp_name, col)); let mut name = cut_name(&layer.name, 255, &what("layer"), col); - // A text layer named after its text stores no name (110, 113: After Effects shows the text). + // A text layer named after its text stores no name (110, 113: After Effects shows the text): + // the layer's own rule decides (#3233). if let LayerSrc::Text(tp) = &src - && tp.text.split('\r').next().is_some_and(|first| first.chars().take(64).collect::() == name) + && layer.name_follows_text(&effectcraft_project::text_layer_name(&tp.text)) { name.clear(); } diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 4ec346328..fa66ee411 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -479,7 +479,12 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition .map(|d| crate::import::text::first_paragraph(&d.raw_text)) .filter(|t| !t.trim().is_empty()) }; - let name = aep_name.clone().or_else(|| src.as_ref().map(|s| s.name.clone())).or_else(text_name).unwrap_or_else(|| "Layer".to_string()); + let name = aep_name + .clone() + .or_else(|| src.as_ref().map(|s| s.name.clone())) + .or_else(text_name) + // An empty text layer without a name is named as a new one is (#3233). + .unwrap_or_else(|| if h.layer_type == LayerType::Text { "Text" } else { "Layer" }.to_string()); let skip = |cx: &mut Ctx<'_>, skipped: &mut HashMap, category: LossCategory, what: &str| { cx.col.lose(category, format!("{comp_name}: {what} '{name}'")); skipped.insert(h.id, what.to_string()); @@ -537,6 +542,10 @@ fn build_layers(project: &mut Project, cx: &mut Ctx<'_>, defs: &EffectDefinition }; let mut l: Layer = build::layer(project, &comp, &name, source.clone(), size, None); l.name = name.clone(); + // A text layer without a stored name follows its text; a stored name is the user's (#3233). + if is_text { + l.name_follows_text = Some(aep_name.is_none()); + } l.label = Label::ALL.get(usize::from(h.label)).copied().unwrap_or(Label::None); apply_timing(&mut l, &h, &tm, &name, &mut cx.col); apply_switches(&mut l, &h); diff --git a/crates/aep/tests/it/export/text.rs b/crates/aep/tests/it/export/text.rs index 32c7572c5..32e55a269 100644 --- a/crates/aep/tests/it/export/text.rs +++ b/crates/aep/tests/it/export/text.rs @@ -196,6 +196,48 @@ fn documents_built_in_code_are_written_as_their_fields() { assert_eq!(layer.header().map(|h| h.layer_type), Some(effectcraft_aep::LayerType::Text)); } +/// #3233: whether a text layer's name follows its text survives export and import. A followed +/// name is written as no name (After Effects shows the text) and comes back following; a name the +/// user gave is written, even when it equals the text, and comes back as the user's. Without a +/// recorded state (older projects) a name equal to the text follows. +#[test] +fn a_text_layer_name_follows_its_text_through_export_and_import() { + for (name, follows, stored) in [ + ("Hello", Some(false), Some("Hello")), + ("Hello 2", Some(true), None), + ("Hello", None, None), + ("Text 2", None, None), + ("Text Title", None, Some("Text Title")), + ("Text Title", Some(false), Some("Text Title")), + ] { + let case = format!("{name:?} {follows:?}"); + let (mut p, c) = text_project(vec![TextDoc::plain("Hello")]); + with_layer(&mut p, c, 0, |l| { + l.name = name.into(); + l.name_follows_text = follows; + }); + let r = export_all(&p); + let file = parse(&r.bytes).expect("parse"); + let view = effectcraft_aep::Project::new(&file).expect("project"); + let layer = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).expect("layer"); + assert_eq!(layer.name().as_deref(), stored, "stored name ({case})"); + let back = reimport(&r.bytes); + let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer"); + assert_eq!(l.name, stored.unwrap_or("Hello"), "imported name ({case})"); + assert_eq!(l.name_follows_text, Some(stored.is_none()), "imported state ({case})"); + } + // An empty text layer that follows its text is written without a name, as After Effects + // writes it, and comes back named "Text", still following. + let (mut p, c) = text_project(vec![TextDoc::plain("")]); + with_layer(&mut p, c, 0, |l| { + l.name = "Text".into(); + l.name_follows_text = Some(true); + }); + let back = reimport(&export_all(&p).bytes); + let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer"); + assert_eq!((l.name.as_str(), l.name_follows_text), ("Text", Some(true)), "an empty text layer"); +} + #[test] fn what_a_text_document_cannot_hold_is_reported() { let mut d = TextDoc::plain("x"); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index f88ab32c8..b015b72b4 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -114,7 +114,15 @@ pub fn specs() -> Vec { has_layers, set_switch ), - cmd!("layer.rename", "Rename", [], Some("Enter"), "{layer?, name}", has_layers, rename), + cmd!( + "layer.rename", + "Rename", + [], + Some("Enter"), + "{layer?, name} — a text layer's name follows its text until renamed; an empty name follows it again", + has_layers, + rename + ), cmd!("layer.setComment", "Layer Comment", [], None, "{layers?, comment}", has_layers, set_comment), cmd!("layer.setBlendMode", "Blending Mode", [], None, "{layers?, mode?: Normal|Multiply|Screen|…, step?: ±1}", has_layers, blend), cmd!( diff --git a/crates/engine/src/cmd/layer/contents.rs b/crates/engine/src/cmd/layer/contents.rs index 5eb253beb..3015f4ecf 100644 --- a/crates/engine/src/cmd/layer/contents.rs +++ b/crates/engine/src/cmd/layer/contents.rs @@ -207,14 +207,14 @@ pub(super) fn set_text(s: &mut Session, p: &Value) -> Result { KV::Str(t) => TextDoc::plain(&t), _ => TextDoc::default(), }; + let before = effectcraft_project::text_layer_name(&doc.text); apply_text_params("layer.setText", p, &mut doc, range.clone())?; pr.set_value_at(lt, KV::Text(Box::new(doc.clone()))); - if let Some(first) = doc.text.lines().next() - && str_p(p, "text").is_some() - && !first.is_empty() - && (l.name.starts_with("Text") || l.name.is_empty()) - { - l.name = first.chars().take(40).collect(); + // Text layers are named after their text until renamed (#3233). + let after = effectcraft_project::text_layer_name(&doc.text); + if str_p(p, "text").is_some() && !after.is_empty() && l.name_follows_text(&before) { + l.name = after; + l.name_follows_text = Some(true); } Ok(()) })?; diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 621ae866b..0196b5aee 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -187,8 +187,10 @@ pub(super) fn new_text(s: &mut Session, p: &Value) -> Result { } let edit = b_p(p, "edit").unwrap_or(false); let id = s.edit("New Text Layer", None, |proj, st| { - let name: String = layer_name.clone().unwrap_or_else(|| doc.text.lines().next().unwrap_or("").chars().take(40).collect()); + let name: String = layer_name.clone().unwrap_or_else(|| effectcraft_project::text_layer_name(&doc.text)); let mut l = build::layer(proj, &comp, if name.is_empty() { "Text" } else { &name }, LayerSource::Text, (comp.width, comp.height), None); + // Named after its text, the name follows the text until renamed; a given name does not. + l.name_follows_text = Some(layer_name.as_deref().is_none_or(str::is_empty)); if let Some(pr) = l.props.prop_mut("text/sourceText") { pr.value = KV::Text(Box::new(doc.clone())); } diff --git a/crates/engine/src/cmd/layer/switches.rs b/crates/engine/src/cmd/layer/switches.rs index 366c1eeb1..afee522c6 100644 --- a/crates/engine/src/cmd/layer/switches.rs +++ b/crates/engine/src/cmd/layer/switches.rs @@ -139,13 +139,37 @@ fn drop_3d_values(l: &mut Layer) { pub(super) fn rename(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.rename")?; let name = str_p(p, "name").ok_or_else(|| bad("layer.rename", "missing `name`"))?.to_string(); + let t = s.time(); s.edit("Rename Layer", None, |proj, _| { - layer_mut(proj, cid, lid)?.name = name.clone(); + set_layer_name(layer_mut(proj, cid, lid)?, &name, t); Ok(()) })?; Ok(Value::Null) } +/// Give layer `l` the name `name` the user typed (comp time `t` picks the text). A text layer's +/// name follows its text until the user renames it; an empty name makes it follow the text again +/// (After Effects, #3233). Committing the unchanged name changes nothing. +pub(super) fn set_layer_name(l: &mut Layer, name: &str, t: effectcraft_time::Tick) { + let text_name = + l.props.prop("text/sourceText").filter(|_| matches!(l.source, effectcraft_project::LayerSource::Text)).map(|pr| match pr.value_at(l.layer_time(t)) { + KV::Text(d) => effectcraft_project::text_layer_name(&d.text), + KV::Str(txt) => effectcraft_project::text_layer_name(&txt), + _ => String::new(), + }); + match text_name { + Some(first) if name.trim().is_empty() => { + l.name = if first.is_empty() { "Text".to_string() } else { first }; + l.name_follows_text = Some(true); + } + Some(_) if name != l.name => { + l.name = name.to_string(); + l.name_follows_text = Some(false); + } + _ => l.name = name.to_string(), + } +} + /// The Timeline's Comment column. pub(super) fn set_comment(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index eb0f610f9..87403fb26 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -172,10 +172,12 @@ pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result { let pixel_aspect = pixel_aspect_p(p)?; let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true); + let t = s.time(); let solid = s.edit("Layer Settings", None, |proj, _| { let src = layer_mut(proj, cid, lid)?.source.clone(); if let Some(n) = &name { - layer_mut(proj, cid, lid)?.name = n.clone(); + // A user name: a text layer's name stops following its text (#3233). + super::switches::set_layer_name(layer_mut(proj, cid, lid)?, n, t); } let LayerSource::Solid { item } = src else { return Ok(None) }; let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1; diff --git a/crates/engine/src/cmd/text_edit.rs b/crates/engine/src/cmd/text_edit.rs index cdb650486..9240f0c1a 100644 --- a/crates/engine/src/cmd/text_edit.rs +++ b/crates/engine/src/cmd/text_edit.rs @@ -6,7 +6,7 @@ //! Positions are character indices. The selection is `anchor..caret` (either order). use effectcraft_keyframe::{CharStyle, TextDoc, Value as KV}; -use effectcraft_project::{ItemId, LayerId, LayerSource}; +use effectcraft_project::{ItemId, LayerId, LayerSource, text_layer_name}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -288,10 +288,6 @@ fn session_on(s: &mut Session, lid: LayerId) -> TextEdit { } } -fn first_line_name(text: &str) -> String { - text.lines().next().unwrap_or("").chars().take(40).collect() -} - /// Edit a layer's Source Text at the CTI (one undo step, or merged by `merge`), then set the /// session's selection. pub(crate) fn edit_doc( @@ -319,12 +315,13 @@ pub(crate) fn edit_doc( // already has) must not turn a static Str into Text or add a keyframe at the CTI: that // would change the project and record an empty undo step. if doc != original { - let before = first_line_name(&original.text); - let after = first_line_name(&doc.text); + let before = text_layer_name(&original.text); + let after = text_layer_name(&doc.text); pr.set_value_at(lt, KV::Text(Box::new(doc))); - // Text layers are named after their text until renamed. - if before != after && !after.is_empty() && (l.name == before || l.name.starts_with("Text") || l.name.is_empty()) { + // Text layers are named after their text until renamed (#3233). + if before != after && !after.is_empty() && l.name_follows_text(&before) { l.name = after; + l.name_follows_text = Some(true); } } Ok(()) diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index eb1de6f89..bcb4f0559 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -1417,3 +1417,102 @@ fn duplicate_of_an_empty_type_tool_layer_ends_the_edit_and_copies_it() { assert_eq!(layer_count(&s), before + 1, "the empty layer is kept, as text.endEdit keeps it, and copied"); assert_eq!(s.state.selected_layers, vec![LayerId(copy)], "and the copy is selected"); } + +fn layer_name(s: &Session, t: u64) -> String { + s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap().name.clone() +} + +/// Replace the text with `text` in the on-canvas editor (everything selected, typed, committed). +fn retype(s: &mut Session, t: u64, text: &str) { + begin_edit(s, t); + s.execute("text.insert", json!({"text": text})).unwrap(); + s.execute("text.endEdit", json!({})).unwrap(); +} + +/// #3233: a text layer's name follows its Source Text, edited on canvas or by `layer.setText`, +/// until the user renames it (After Effects). A user name stays, even one that starts with +/// "Text", and so does a copy's; renaming to an empty name follows the text again. +#[test] +fn a_text_layer_name_follows_its_text_until_renamed() { + let (mut s, t) = session("Hello"); + assert_eq!(layer_name(&s, t), "Hello"); + retype(&mut s, t, "World"); + assert_eq!(layer_name(&s, t), "World", "a new text layer follows its text"); + s.execute("layer.setText", json!({"layer": t, "text": "Again"})).unwrap(); + assert_eq!(layer_name(&s, t), "Again"); + s.execute("layer.rename", json!({"layer": t, "name": "Text Title"})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "text": "Changed"})).unwrap(); + assert_eq!(layer_name(&s, t), "Text Title", "layer.setText keeps the user's name"); + retype(&mut s, t, "Typed"); + assert_eq!(layer_name(&s, t), "Text Title", "typing keeps the user's name"); + s.execute("layer.select", json!({"layers": [t]})).unwrap(); + let copy = s.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap(); + let copy_name = layer_name(&s, copy); + s.execute("layer.setText", json!({"layer": copy, "text": "Copied"})).unwrap(); + assert_eq!(layer_name(&s, copy), copy_name, "a copy keeps the user's name too"); + // Renaming to an empty name: the name is the text's again, and follows it. + s.execute("layer.rename", json!({"layer": t, "name": ""})).unwrap(); + assert_eq!(layer_name(&s, t), "Typed", "an empty name shows the text"); + retype(&mut s, t, "Followed"); + assert_eq!(layer_name(&s, t), "Followed", "and follows it again"); + // Committing the unchanged name (Enter, Enter) does not stop the following. + s.execute("layer.rename", json!({"layer": t, "name": "Followed"})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "text": "Still"})).unwrap(); + assert_eq!(layer_name(&s, t), "Still"); + // A name given in Layer Settings is the user's too. The UI offers Layer Settings only for + // solids, nulls, cameras and lights (as After Effects does); this guards the command, which + // agents can call on any layer. + s.execute("layer.settings", json!({"layer": t, "name": "Settings Name"})).unwrap(); + s.execute("layer.setText", json!({"layer": t, "text": "Later"})).unwrap(); + assert_eq!(layer_name(&s, t), "Settings Name", "Layer Settings names it for good"); + // A layer given a name when it is made does not follow. + let named = s.execute("layer.newText", json!({"text": "Body", "name": "Caption"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setText", json!({"layer": named, "text": "Other"})).unwrap(); + assert_eq!(layer_name(&s, named), "Caption"); +} + +/// #3233: After Effects breaks paragraphs with a bare carriage return; the name is the first +/// paragraph, without the break, and an older layer named after it counts as following. +#[test] +fn a_text_layer_name_ends_at_a_bare_carriage_return() { + let (mut s, t) = session("A\rB"); + assert_eq!(layer_name(&s, t), "A"); + s.execute("layer.setText", json!({"layer": t, "text": "C\rD"})).unwrap(); + assert_eq!(layer_name(&s, t), "C"); + retype(&mut s, t, "E\rF"); + assert_eq!(layer_name(&s, t), "E"); + let cid = s.active_comp_id().unwrap(); + let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(t)).unwrap(); + l.name = "Para1".into(); + l.name_follows_text = None; + l.props.prop_mut("text/sourceText").unwrap().value = effectcraft_keyframe::Value::Text(Box::new(TextDoc::plain("Para1\rPara2"))); + s.execute("layer.setText", json!({"layer": t, "text": "Next\rLine"})).unwrap(); + assert_eq!(layer_name(&s, t), "Next", "an older layer named after its first paragraph follows"); +} + +/// #3233: projects saved before the state was recorded load without it; there a name that is +/// the text's, empty, or the default "Text"/"Text 2"… follows the text, any other name stays. +#[test] +fn older_text_layers_without_the_name_state_follow_as_before() { + let (mut s, t) = session("Hello"); + let mut v = serde_json::to_value(s.active_comp().unwrap().layer(effectcraft_project::LayerId(t)).unwrap()).unwrap(); + v.as_object_mut().unwrap().remove("name_follows_text"); + let old: effectcraft_project::Layer = serde_json::from_value(v).unwrap(); + assert_eq!(old.name_follows_text, None, "an older project has no state"); + for (name, follows) in [("Hello", true), ("", true), ("Text", true), ("Text 3", true), ("Text Title", false), ("Text 3b", false), ("Title", false)] { + let l = effectcraft_project::Layer { name: name.into(), ..old.clone() }; + assert_eq!(l.name_follows_text("Hello"), follows, "{name:?}"); + } + let legacy = |s: &mut Session, name: &str| { + let cid = s.active_comp_id().unwrap(); + let l = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(t)).unwrap(); + l.name = name.into(); + l.name_follows_text = None; + }; + legacy(&mut s, "Hello"); + s.execute("layer.setText", json!({"layer": t, "text": "World"})).unwrap(); + assert_eq!(layer_name(&s, t), "World", "a name equal to the text follows it"); + legacy(&mut s, "Title"); + retype(&mut s, t, "Typed"); + assert_eq!(layer_name(&s, t), "Title", "another name stays"); +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 1aa8d7c90..7a960dbfb 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -935,6 +935,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource Layer { id, name: comp.unique_layer_name(name), + name_follows_text: None, source, label, comment: String::new(), diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index 34eeb50c0..f7b3b5e51 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -299,6 +299,8 @@ impl Layer { let Layer { id, name, + // Whether the name follows the text changes no pixels until the name changes. + name_follows_text: _, source, label, comment, @@ -474,6 +476,11 @@ pub enum AutoOrient { pub struct Layer { pub id: LayerId, pub name: String, + /// A text layer's name follows the first line of its Source Text until the user renames it + /// (After Effects). `Some(true)`: it follows, `Some(false)`: the user named it. `None` (older + /// projects, layers made before this was recorded): see [`Layer::name_follows_text()`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name_follows_text: Option, pub source: LayerSource, pub label: Label, #[serde(default)] @@ -512,7 +519,26 @@ pub struct Layer { pub props: PropGroup, } +/// The name a text layer takes from its text: the first 40 characters of the first line. A line +/// ends at a line feed or at a bare carriage return (After Effects' paragraph break). +pub fn text_layer_name(text: &str) -> String { + text.split(['\r', '\n']).next().unwrap_or("").chars().take(40).collect() +} + +/// A text layer's default name when its text gives none: "Text", numbered "Text 2", "Text 3"… +fn is_default_text_name(name: &str) -> bool { + name == "Text" || name.strip_prefix("Text ").is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) +} + impl Layer { + /// Whether this text layer's name follows its Source Text, whose first line gives the name + /// `current` (see [`text_layer_name`]). Without a recorded state (an older project), a name + /// that is empty, the text's own, or the default "Text"/"Text 2"… follows; any other name was + /// given by the user. + pub fn name_follows_text(&self, current: &str) -> bool { + self.name_follows_text.unwrap_or_else(|| self.name.is_empty() || self.name == current || is_default_text_name(&self.name)) + } + /// The layers this layer reads: its parent, track matte, layer parameters (Displacement Map, /// Audio Spectrum's Audio Layer…) and tracker targets. pub fn referenced_layers(&self) -> Vec { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 079b81765..fabcd607f 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -43,7 +43,7 @@ pub use item::NO_LAYER; pub use item::{Heard, Item, ItemId, ItemKind, LayerId}; pub use layer::{ AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte, is_audio_effect, - reads_other_frames, + reads_other_frames, text_layer_name, }; pub use layers::Layers; pub use settings::{ diff --git a/docs/agents.md b/docs/agents.md index ac77439b7..27664cb73 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -713,6 +713,8 @@ in the Plug-in Library: [publishing-plugins.md](publishing-plugins.md). step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers). * Rename a layer with Enter (or double-click a name): the whole name is selected, Enter or a click elsewhere commits, Escape cancels; `layer.rename {layer, name}` does it directly. + A text layer's name follows its text until it is renamed; renaming it to an empty name makes + it follow the text again (After Effects). * Ctrl/Cmd-click on the current-time display (Timeline or Composition panel) toggles the Time Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`. * `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its From 2abd727338dea0a7b48131794689253ba01a8173 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 16:41:56 +0100 Subject: [PATCH 08/13] AEP import: Puppet (ADBE FreePin3) arrives as EffectCraft's Puppet, its meshes and pins are reported After Effects' Puppet was dropped with a one-line "has the effect" loss. It now arrives as EffectCraft's Puppet (ec.distort.puppet) in its place in the stack, with its name and fx switch, and the report lists per mesh Triangles, Expansion and per pin group (Deform, Overlap, Stiffness) the pins, keyed pins and atom match names; entries outside the known tree are named once, and long lists stop at ten with "and N more". No pins are created: a pin's rest place is a vertex of the hidden mesh data and the unit of its Position is unknown, and no corpus file holds a Puppet. The tree (docs/aep-format.md 3.7) comes from an external scripting listing, not the corpus; the sample that settles it is described there. The fx_inject test helpers can now inject any effect instance, which the new tests use. Refs #3093 --- crates/aep/src/import.rs | 1 + crates/aep/src/import/effects.rs | 7 +- crates/aep/src/import/puppet.rs | 234 ++++++++++++++++++++++++ crates/aep/tests/it/import.rs | 1 + crates/aep/tests/it/import/fx_inject.rs | 40 ++-- crates/aep/tests/it/import/puppet.rs | 171 +++++++++++++++++ docs/aep-format.md | 49 ++++- docs/aep-import.md | 4 +- 8 files changed, 489 insertions(+), 18 deletions(-) create mode 100644 crates/aep/src/import/puppet.rs create mode 100644 crates/aep/tests/it/import/puppet.rs diff --git a/crates/aep/src/import.rs b/crates/aep/src/import.rs index 956e3b1c5..938d0c46e 100644 --- a/crates/aep/src/import.rs +++ b/crates/aep/src/import.rs @@ -30,6 +30,7 @@ mod effects; mod markers; mod masks; mod props; +mod puppet; mod report; mod settings; mod shapes; diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 8409a6f7e..f205245eb 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -76,6 +76,11 @@ pub(crate) fn import_effects<'f, 'a>( col: &mut Collector, ) { for fx in layer.effects() { + // Puppet keeps its meshes and pins in nested groups, not in `parT` (its own step). + if fx.match_name() == crate::import::puppet::MATCH_NAME { + crate::import::puppet::import_puppet(defs, &fx, cx, ids, target, col); + continue; + } let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string()); let Some(map) = EFFECTS.iter().find(|m| m.match_name == fx.match_name()) else { col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name())); @@ -112,7 +117,7 @@ pub(crate) fn import_effects<'f, 'a>( } /// Parameters that never carry a value to import. -fn carries_nothing(p: &EffectParam<'_, '_>) -> bool { +pub(crate) fn carries_nothing(p: &EffectParam<'_, '_>) -> bool { matches!(p.kind, ParamKind::Button | ParamKind::NoData | ParamKind::GroupStart | ParamKind::GroupEnd) || (p.index == 0 && p.kind == ParamKind::Layer) } diff --git a/crates/aep/src/import/puppet.rs b/crates/aep/src/import/puppet.rs new file mode 100644 index 000000000..ce10ae7f5 --- /dev/null +++ b/crates/aep/src/import/puppet.rs @@ -0,0 +1,234 @@ +//! After Effects' Puppet effect (`ADBE FreePin3`) arrives as EffectCraft's Puppet +//! (`ec.distort.puppet`) in its place in the stack, with its name and fx switch, but **without +//! its meshes and pins**: how After Effects stores a pin's rest place (a vertex index and offset +//! into the hidden mesh data) and its Position (pixels or a fraction of the layer) is not decoded +//! yet, and a guessed rest place would deform the layer wrongly without a word. What the instance +//! holds is reported instead, mesh by mesh: Triangles, Expansion, and per pin container (Deform, +//! Stiffness, Overlap) the number of pins, how many are keyed or carry an expression, and the +//! match names of their atoms (Advanced and Bend pins show up there). Any entry this reader does +//! not know is named verbatim. +//! +//! The tree read here (docs/aep-format.md 3.7, Puppet) comes from an external scripting listing +//! of the effect's property tree, not from the corpus (no corpus file has a Puppet): **?** until a +//! byte sample exists. It is read with the same verified group reader as masks, shapes and layer +//! styles; a file whose tree differs is reported, never guessed at. + +use effectcraft_project::build::Ids; +use effectcraft_project::{Node, PropGroup}; + +use crate::import::effects::{LayerCtx, carries_nothing}; +use crate::import::report::{Collector, LossCategory}; +use crate::{Effect as AepEffect, EffectDefinitions, ParamValue, PropGroup as AepGroup, PropNode, Property as AepProperty}; + +/// After Effects' Puppet effect. +pub(crate) const MATCH_NAME: &str = "ADBE FreePin3"; +/// The hidden group that holds the meshes (`arap`). +const ARAP: &str = "ADBE FreePin3 ARAP Group"; +/// Auto-traced Shapes, beside the meshes. +const OUTLINES: &str = "ADBE FreePin3 Outlines"; +/// The hidden group of the meshes. +const MESH_GROUP: &str = "ADBE FreePin3 Mesh Group"; +/// One mesh (`Mesh 1`). +const MESH_ATOM: &str = "ADBE FreePin3 Mesh Atom"; +/// A mesh's hidden triangulation. +const MESH_DATA: &str = "ADBE FreePin3 Mesh"; +const TRIANGLES: &str = "ADBE FreePin3 Mesh Tri Count"; +const EXPANSION: &str = "ADBE FreePin3 Mesh Expansion"; +/// At most this many meshes, pin atom names or unrecognised names are listed in a report line; +/// the rest are counted (`and 3 more`). +const LIST_CAP: usize = 10; +/// The pin containers of a mesh: match name and the name After Effects shows. +const PIN_GROUPS: [(&str, &str); 3] = [("ADBE FreePin3 PosPins", "Deform"), ("ADBE FreePin3 HghtPins", "Overlap"), ("ADBE FreePin3 StarchPins", "Stiffness")]; + +/// Imports a Puppet instance (the caller checked the match name) into `target`. +pub(crate) fn import_puppet<'f, 'a>( + defs: &EffectDefinitions<'f, 'a>, + fx: &AepEffect<'f, 'a>, + cx: &LayerCtx<'_>, + ids: &mut Ids<'_>, + target: &mut PropGroup, + col: &mut Collector, +) { + let shown = fx.display_name().unwrap_or_else(|| fx.match_name().to_string()); + let here = format!("{}: '{}' effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer); + let Some(spec) = effectcraft_effects::find(effectcraft_effects::puppet::ID) else { + col.lose(LossCategory::Effects, format!("{}: '{}' has the effect '{shown}' ({MATCH_NAME})", cx.comp, cx.layer)); + return; + }; + let size = [f64::from(cx.size.0), f64::from(cx.size.1)]; + let mut group = effectcraft_effects::instantiate(spec, ids, &shown, size); + match fx.enabled() { + Some(on) => group.enabled = on, + None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)), + } + // The ordinary parameters (`parT`): none is mapped yet, so a stored one is reported. + let params = defs.params(fx); + for p in ¶ms { + if carries_nothing(p) { + continue; + } + if p.stored().is_some() && !matches!(p.stored_value(), Some(ParamValue::Layer(0))) { + let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) }; + col.lose(LossCategory::Effects, format!("{here}: parameter {label} not imported")); + } + } + report_meshes(fx, ¶ms.iter().map(|p| p.match_name.as_str()).collect::>(), &here, col); + // The effect itself arrives (an EffectCraft Puppet the user can pin again), so it counts as + // imported; what it held is in the losses above. + col.imported.effects = col.imported.effects.saturating_add(1); + target.children.push(Node::Group(group)); +} + +/// Reports the meshes and pins of `fx` and every entry of its value group that is neither a +/// `parT` parameter (`param_names`) nor the mesh tree. +fn report_meshes(fx: &AepEffect<'_, '_>, param_names: &[&str], here: &str, col: &mut Collector) { + let mut unknown: Vec = Vec::new(); + let values = fx.chunk().child_list(b"tdgp").and_then(AepGroup::from_chunk); + let mut arap = None; + for e in values.map(|g| g.entries()).unwrap_or_default() { + if e.match_name == ARAP { + if let PropNode::Group(g) = e.node { + if arap.is_none() { + arap = Some(g); + } + } else { + push_unique(&mut unknown, e.match_name); + } + } else if !param_names.contains(&e.match_name.as_str()) { + match e.node { + // A value outside `parT` (On Transparent, ...): reported with what it holds. + PropNode::Property(p) => col.lose(LossCategory::Effects, format!("{here}: property {} ({}) not imported", e.match_name, describe(&p))), + PropNode::Group(_) | PropNode::Other(_) => push_unique(&mut unknown, e.match_name), + } + } + } + let mut meshes: Vec = Vec::new(); + let mut found = false; + if let Some(arap) = arap { + for e in arap.entries() { + match (e.match_name.as_str(), e.node) { + (MESH_GROUP, PropNode::Group(g)) if !found => { + found = true; + for m in g.entries() { + match (m.match_name.as_str(), m.node) { + (MESH_ATOM, PropNode::Group(mesh)) => { + let n = meshes.len().saturating_add(1); + meshes.push(mesh_summary(&mesh, n, &mut unknown)); + } + _ => push_unique(&mut unknown, m.match_name.clone()), + } + } + } + // Auto-traced Shapes are made from the layer again (EffectCraft traces the alpha). + (OUTLINES, _) => {} + _ => push_unique(&mut unknown, e.match_name.clone()), + } + } + } + let extra = if unknown.is_empty() { String::new() } else { format!("; not recognised: {}", capped(&unknown, ", ")) }; + if !found { + col.lose( + LossCategory::Effects, + format!("{here}: imported without meshes or pins: its mesh group ({ARAP} / {MESH_GROUP}) was not found or is not a group{extra}"), + ); + } else if !meshes.is_empty() { + col.lose( + LossCategory::Effects, + format!( + "{here}: imported without its meshes and pins (After Effects' pin rest places and positions are not decoded yet): {}{extra}", + capped(&meshes, "; ") + ), + ); + } else if !unknown.is_empty() { + col.lose(LossCategory::Effects, format!("{here}: no mesh{extra}")); + } +} + +/// `Mesh n (Triangles t, Expansion e): Deform 3 pins (1 keyed) [atom names], Overlap no pins, ...` +fn mesh_summary(mesh: &AepGroup<'_, '_>, n: usize, unknown: &mut Vec) -> String { + let name = mesh.user_name().unwrap_or_else(|| format!("Mesh {n}")); + let mut settings: Vec = Vec::new(); + let mut containers: Vec = Vec::new(); + for e in mesh.entries() { + let label = match e.match_name.as_str() { + TRIANGLES => Some("Triangles"), + EXPANSION => Some("Expansion"), + _ => None, + }; + match (label, e.node) { + (Some(label), PropNode::Property(p)) => settings.push(format!("{label} {}", describe(&p))), + // The triangulation After Effects keeps; EffectCraft builds its own. + _ if e.match_name == MESH_DATA => {} + _ => match (PIN_GROUPS.iter().find(|(m, _)| *m == e.match_name), e.node) { + (Some((_, shown)), PropNode::Group(g)) => containers.push(format!("{shown} {}", pins_summary(&g, unknown))), + _ => push_unique(unknown, e.match_name), + }, + } + } + let settings = if settings.is_empty() { String::new() } else { format!(" ({})", settings.join(", ")) }; + let containers = if containers.is_empty() { "no pin groups".to_string() } else { containers.join(", ") }; + format!("{name}{settings}: {containers}") +} + +/// `3 pins (1 keyed, 1 with an expression) [ADBE FreePin3 PosPin Atom]`, or `no pins`. An entry +/// of the container that is not a group (a pin is one) goes to `unknown`. +fn pins_summary(container: &AepGroup<'_, '_>, unknown: &mut Vec) -> String { + let mut count = 0usize; + let mut keyed = 0usize; + let mut expressions = 0usize; + let mut atoms: Vec = Vec::new(); + for e in container.entries() { + let PropNode::Group(pin) = e.node else { + push_unique(unknown, e.match_name); + continue; + }; + count = count.saturating_add(1); + push_unique(&mut atoms, e.match_name); + let props: Vec> = + pin.entries().into_iter().filter_map(|p| if let PropNode::Property(p) = p.node { Some(p) } else { None }).collect(); + if props.iter().any(AepProperty::is_animated) { + keyed = keyed.saturating_add(1); + } + if props.iter().any(|p| p.expression().is_some()) { + expressions = expressions.saturating_add(1); + } + } + if count == 0 { + return "no pins".to_string(); + } + let mut what = vec![format!("{keyed} keyed")]; + if expressions > 0 { + what.push(format!("{expressions} with an expression")); + } + format!("{count} pin{} ({}) [{}]", if count == 1 { "" } else { "s" }, what.join(", "), capped(&atoms, ", ")) +} + +/// A stored property's value for the report: `keyed`, the static value, or `unreadable`. +fn describe(p: &AepProperty<'_, '_>) -> String { + if p.is_animated() { + return "keyed".to_string(); + } + match p.static_value() { + Some(v) if v.len() == 1 => v.first().map(|x| format!("{x}")).unwrap_or_default(), + Some(v) => format!("[{}]", v.iter().map(|x| format!("{x}")).collect::>().join(", ")), + None => "unreadable".to_string(), + } +} + +/// Adds `name` to `names` unless it is there already. +fn push_unique(names: &mut Vec, name: String) { + if !names.contains(&name) { + names.push(name); + } +} + +/// The first [`LIST_CAP`] of `items` joined by `sep`, and how many more there are. +fn capped(items: &[String], sep: &str) -> String { + let shown: Vec<&str> = items.iter().take(LIST_CAP).map(String::as_str).collect(); + let mut out = shown.join(sep); + let more = items.len().saturating_sub(LIST_CAP); + if more > 0 { + out.push_str(&format!("{sep}and {more} more")); + } + out +} diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 102a215c8..61a04c401 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -19,6 +19,7 @@ mod fx_inject; mod markers; mod masks; mod mattes; +mod puppet; mod resaved_defaults; mod robustness; mod shapes; diff --git a/crates/aep/tests/it/import/fx_inject.rs b/crates/aep/tests/it/import/fx_inject.rs index 81e6a821a..ae61794d3 100644 --- a/crates/aep/tests/it/import/fx_inject.rs +++ b/crates/aep/tests/it/import/fx_inject.rs @@ -103,18 +103,18 @@ pub fn tdmn(name: &str) -> Chunk<'static> { } /// A UTF-8 string chunk (`Utf8`). -fn utf8(s: &str) -> Chunk<'static> { +pub 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> { +pub 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> { +pub fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> { let mut kids = Vec::new(); for c in template.children() { match &c.body { @@ -131,7 +131,7 @@ fn record(template: &Chunk<'_>, v: f64) -> Chunk<'static> { } /// 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> { +pub 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]; @@ -229,39 +229,51 @@ 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 { +/// Replaces every Glow (`ADBE Glo2` with its `sspc`) below `chunks` with the effect `match_name` +/// and its `sspc`; the number replaced. +fn replace_glow(chunks: &mut [Chunk<'_>], match_name: &str, sspc: &Chunk<'static>) -> 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); + kids[at] = tdmn(match_name); + kids[at + 1] = sspc.clone(); n += 1; } - n += replace_glow(kids, fx, template); + n += replace_glow(kids, match_name, sspc); } n } -/// `synthetic.aep` with `fx` in place of the Glow effect. -pub fn bytes_with(fx: &Fx) -> Vec { +/// `synthetic.aep` with the effect `match_name` in place of the Glow effect; `make` builds its +/// `sspc` from the Glow's value record (the template of every record). +pub fn bytes_with_effect(match_name: &str, make: impl Fn(&Chunk<'_>) -> Chunk<'static>) -> Vec { let file = parse(SYNTHETIC).unwrap(); let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record"); + let sspc = make(&template); let mut file = parse(SYNTHETIC).unwrap(); - assert_eq!(replace_glow(&mut file.chunks, fx, &template), 2, "the Glow effect and its shared definitions"); + assert_eq!(replace_glow(&mut file.chunks, match_name, &sspc), 2, "the Glow effect and its shared definitions"); write(&file).unwrap() } +/// `synthetic.aep` with `fx` in place of the Glow effect. +pub fn bytes_with(fx: &Fx) -> Vec { + bytes_with_effect(fx.match_name, |t| sspc(fx, t)) +} + /// 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); + import_bytes(&bytes_with(fx), effect) +} + +/// Imports `bytes`: the first EffectCraft effect `effect` (if imported) and the result. +pub fn import_bytes(bytes: &[u8], effect: &str) -> (Option, ImportResult) { let mut project = Project::default(); let none = |_: &str| false; - let r = import(&mut project, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); + let r = import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); let g = project .comps() .flat_map(|(_, c)| c.layers.iter()) diff --git a/crates/aep/tests/it/import/puppet.rs b/crates/aep/tests/it/import/puppet.rs new file mode 100644 index 000000000..a3054286e --- /dev/null +++ b/crates/aep/tests/it/import/puppet.rs @@ -0,0 +1,171 @@ +//! After Effects' Puppet (`ADBE FreePin3`, #3093) arrives as EffectCraft's Puppet +//! (`ec.distort.puppet`) in its place in the stack, and what its meshes and pins hold is reported +//! mesh by mesh instead of the one line "has the effect 'Puppet'". The effect is built into the +//! committed `synthetic.aep` in place of its Glow (`fx_inject`, in memory), in the tree +//! docs/aep-format.md 3.7 gives for Puppet. That tree comes from an external scripting listing, +//! not a byte sample: these tests prove the reader's walk and its report, not the format. + +use effectcraft_aep::Chunk; +use effectcraft_aep::import::{ImportOptions, LossCategory, import}; +use effectcraft_project::Project; + +use crate::import::fx_inject::{bytes_with_effect, import_bytes, keyed_record, losses, nested, record, tdmn, utf8}; + +const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep"); +const PUPPET: &str = "ADBE FreePin3"; +const POS_ATOM: &str = "ADBE FreePin3 PosPin Atom"; + +fn tdsb(v: u32) -> Chunk<'static> { + Chunk::data(*b"tdsb", v.to_be_bytes().to_vec()) +} + +/// A property group of `entries` (match name, node). +fn group(entries: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> { + let mut kids = vec![tdsb(1)]; + for (name, node) in entries { + kids.push(tdmn(name)); + kids.push(node); + } + Chunk::list(*b"tdgp", kids) +} + +/// A pin atom with its hidden vertex records and Position (keyed when `keys`). The values are +/// one-component records: the reader only asks whether a pin is keyed. +fn pin(t: &Chunk<'_>, atom: &'static str, keys: bool) -> (&'static str, Chunk<'static>) { + let position = if keys { keyed_record(t, &[(0, 1.0), (25_600, 2.0)]) } else { record(t, 1.0) }; + ( + atom, + group(vec![ + ("ADBE FreePin3 PosPin Vtx Offset", record(t, 0.0)), + ("ADBE FreePin3 PosPin Vtx Index", record(t, 7.0)), + ("ADBE FreePin3 PosPin Position", position), + ]), + ) +} + +/// `Mesh 1`: Triangles 500, Expansion 5, four Deform pins (one keyed, one with an atom name +/// nobody knows) and a property among them, no Overlap pin, one Stiffness pin, and a group +/// nobody knows. +fn full_mesh(t: &Chunk<'_>) -> Chunk<'static> { + let mesh_data = + Chunk::list(*b"tdbs", vec![tdsb(1), Chunk::data(*b"cdat", vec![0u8; 4]), Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", vec![1, 2, 3, 4])])]); + let deform = group(vec![ + pin(t, POS_ATOM, false), + pin(t, POS_ATOM, true), + pin(t, POS_ATOM, false), + pin(t, "ADBE FreePin3 Test Atom", false), + ("ADBE FreePin3 Test Prop", record(t, 3.0)), + ]); + let starch = group(vec![("ADBE FreePin3 StarchPin Atom", group(vec![("ADBE FreePin3 StarchPin Amount", record(t, 0.5))]))]); + group(vec![ + ("ADBE FreePin3 Mesh", mesh_data), + ("ADBE FreePin3 Mesh Tri Count", record(t, 500.0)), + ("ADBE FreePin3 Mesh Expansion", record(t, 5.0)), + ("ADBE FreePin3 PosPins", deform), + ("ADBE FreePin3 HghtPins", group(vec![])), + ("ADBE FreePin3 StarchPins", starch), + ("ADBE FreePin3 Test Group", group(vec![])), + ]) +} + +/// The Puppet instance: `fx_switch` is the value group's `tdsb`, `arap` its mesh tree (`None`: +/// none), `extra` more entries of its value group. +fn sspc(fx_switch: u32, arap: Option>, extra: Vec<(&str, Chunk<'static>)>) -> Chunk<'static> { + let pard = |kind: u8| { + let mut b = vec![0u8; 148]; + b[15] = kind; + Chunk::data(*b"pard", b) + }; + let part = vec![Chunk::data(*b"parn", 2u32.to_be_bytes().to_vec()), tdmn("ADBE FreePin3-0000"), pard(0), tdmn("ADBE Effect Built In Params"), pard(9)]; + let mut values = vec![tdsb(fx_switch)]; + if let Some(arap) = arap { + values.push(tdmn("ADBE FreePin3 ARAP Group")); + values.push(arap); + } + for (name, node) in extra { + values.push(tdmn(name)); + values.push(node); + } + Chunk::list(*b"sspc", vec![nested(*b"fnam", vec![utf8("Puppet")]), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)]) +} + +/// The layer that holds `effect`, the effect's place in that layer's stack and the stack's size. +fn stack_place(bytes: &[u8], effect: &str) -> Option<(String, usize, usize)> { + let mut project = Project::default(); + let none = |_: &str| false; + import(&mut project, bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).unwrap(); + project.comps().flat_map(|(_, c)| c.layers.iter()).find_map(|l| { + let fx: Vec<_> = l.props.sub("effects")?.children.iter().filter_map(|n| n.as_group()).collect(); + let at = fx.iter().position(|g| g.match_id == effect)?; + Some((l.name.clone(), at, fx.len())) + }) +} + +#[test] +fn puppet_arrives_and_its_pins_are_reported_mesh_by_mesh() { + let bytes = bytes_with_effect(PUPPET, |t| { + let arap = group(vec![("ADBE FreePin3 Outlines", group(vec![])), ("ADBE FreePin3 Mesh Group", group(vec![("ADBE FreePin3 Mesh Atom", full_mesh(t))]))]); + sspc(1, Some(arap), vec![("ADBE FreePin3 On Transparent", record(t, 1.0))]) + }); + let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID); + let g = g.expect("an EffectCraft Puppet in the Glow's place"); + assert!(g.enabled); + assert_eq!(g.name, "Puppet"); + // No pin is made up: the rest places are not decoded. + assert_eq!(effectcraft_effects::puppet::pin_count(&g), 0); + // In the Glow's place in its layer's stack (the second of two effects there). + assert!(stack_place(SYNTHETIC, "ec.stylize.glow").is_some_and(|(_, at, n)| at > 0 && n > 1)); + assert_eq!(stack_place(&bytes, effectcraft_effects::puppet::ID), stack_place(SYNTHETIC, "ec.stylize.glow")); + let l = losses(&r, LossCategory::Effects, PUPPET); + assert_eq!(l.len(), 2, "{l:#?}"); + assert!(!l.iter().any(|d| d.contains("has the effect")), "not the old one-line report: {l:#?}"); + assert!(l.iter().any(|d| d.ends_with("property ADBE FreePin3 On Transparent (1) not imported")), "{l:#?}"); + let mesh = l.iter().find(|d| d.contains("imported without its meshes and pins")).expect("the mesh report"); + assert!( + mesh.ends_with( + ": Mesh 1 (Triangles 500, Expansion 5): Deform 4 pins (1 keyed) [ADBE FreePin3 PosPin Atom, ADBE FreePin3 Test Atom], \ + Overlap no pins, Stiffness 1 pin (0 keyed) [ADBE FreePin3 StarchPin Atom]; not recognised: ADBE FreePin3 Test Prop, ADBE FreePin3 Test Group" + ), + "{mesh}" + ); + assert_eq!(losses(&r, LossCategory::DamagedData, PUPPET), Vec::::new()); +} + +#[test] +fn many_meshes_are_listed_up_to_ten_and_names_once() { + let bytes = bytes_with_effect(PUPPET, |_| { + let mesh = || group(vec![("ADBE FreePin3 Test Group", group(vec![]))]); + let meshes = (0..12).map(|_| ("ADBE FreePin3 Mesh Atom", mesh())).collect(); + sspc(1, Some(group(vec![("ADBE FreePin3 Mesh Group", group(meshes))])), vec![]) + }); + let (_, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID); + let l = losses(&r, LossCategory::Effects, PUPPET); + assert_eq!(l.len(), 1, "{l:#?}"); + assert!(l[0].ends_with("Mesh 9: no pin groups; Mesh 10: no pin groups; and 2 more; not recognised: ADBE FreePin3 Test Group"), "{}", l[0]); +} + +#[test] +fn a_puppet_without_a_mesh_loses_nothing_and_keeps_its_fx_switch() { + let bytes = bytes_with_effect(PUPPET, |_| sspc(0, Some(group(vec![("ADBE FreePin3 Mesh Group", group(vec![]))])), vec![])); + let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID); + let g = g.expect("an EffectCraft Puppet"); + assert!(!g.enabled, "switched off in the Effect Controls"); + assert_eq!(losses(&r, LossCategory::Effects, PUPPET), Vec::::new()); +} + +#[test] +fn a_puppet_whose_mesh_tree_is_not_found_is_reported() { + let bytes = bytes_with_effect(PUPPET, |t| sspc(1, None, vec![("ADBE FreePin3 Other Group", group(vec![("x", record(t, 1.0))]))])); + let (g, r) = import_bytes(&bytes, effectcraft_effects::puppet::ID); + assert!(g.is_some(), "the effect still arrives"); + let l = losses(&r, LossCategory::Effects, PUPPET); + assert_eq!(l.len(), 1, "{l:#?}"); + assert!( + l[0].ends_with( + "imported without meshes or pins: its mesh group (ADBE FreePin3 ARAP Group / ADBE FreePin3 Mesh Group) was not found or is not a group; \ + not recognised: ADBE FreePin3 Other Group" + ), + "{}", + l[0] + ); +} diff --git a/docs/aep-format.md b/docs/aep-format.md index d6ec7fb14..db0d79d1c 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -1,6 +1,6 @@ # After Effects project format (`.aep`) -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-05 · **Change:** versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-07 · **Change:** Puppet (`ADBE FreePin3`, 3.7): the property tree from an external scripting listing, **?** until a byte sample exists (#3093); before that: versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026 What `crates/aep` ([`effectcraft-aep`](../crates/aep/README.md)) reads and writes, and the evidence for each statement. Everything here was observed on project files we generated ourselves; no @@ -610,6 +610,52 @@ presets, and After Effects draws exactly the picked objects as selected (`crypto `crypto-07`: box and disc, the two objects of `mat_paint`). API: `CryptomatteData` (refuses data without `cry1`, a length past the end or a non-zero byte at 112). +**Puppet (`ADBE FreePin3`; ?: external listing, not the corpus, #3093).** No file of the +corpus, the committed fixtures or the samples holds a Puppet. The tree below comes from outside +the corpus: a public scripting listing of the effect's properties (a forum post, an After Effects +release from before the Advanced and Bend pins and Density). It is not bytes we have seen; that +After Effects writes it by the general rule (a `tdmn` and the `LIST tdgp` or `tdbs` it names, +nested in the instance's value group `sspc/tdgp`, as masks, shapes and layer styles are) is +**H**: + +```text +Puppet ADBE FreePin3 + On Transparent ADBE FreePin3 On Transparent + arap ADBE FreePin3 ARAP Group (hidden) + Auto-traced Shapes ADBE FreePin3 Outlines + Mesh ADBE FreePin3 Mesh Group (hidden) + Mesh 1 ADBE FreePin3 Mesh Atom + Mesh ADBE FreePin3 Mesh (hidden: the triangulation) + Triangles ADBE FreePin3 Mesh Tri Count + Expansion ADBE FreePin3 Mesh Expansion + Deform ADBE FreePin3 PosPins + Puppet Pin 1 ADBE FreePin3 PosPin Atom + Vertex Offset ADBE FreePin3 PosPin Vtx Offset (hidden, 2 values) + Vertex Index ADBE FreePin3 PosPin Vtx Index (hidden; 4294967295 = none) + Position ADBE FreePin3 PosPin Position + Overlap ADBE FreePin3 HghtPins (hidden while empty) + Stiffness ADBE FreePin3 StarchPins (hidden while empty) +``` + +A pin's rest place is not a property: it is a vertex of the hidden mesh (Vertex Index) plus an +offset, so it cannot be read without decoding the mesh data. The unit of Position (layer pixels, +or a fraction of the layer as effect points are) is **?**. The match names of the Overlap and +Starch pin atoms and their properties, of the Advanced and Bend pins (Scale, Rotation) and of +Density, Mesh Rotation Refinement and the Puppet Engine are **?**, and so is which of them are +`parT` parameters; whether After Effects stores some `parT` parameters of a fresh Puppet even at +their defaults (the importer would then report them every time) is **?**; whether a Puppet +without pins has an (empty) Mesh Group, or no ARAP group at all, is **?**. The importer +(`import/puppet.rs`) therefore brings the effect in as EffectCraft's Puppet without meshes or +pins and reports, per mesh, Triangles, Expansion and per pin group the pins, how many are keyed +or carry an expression, and their atom match names; every entry outside this tree is named in the +report. The sample that settles it: a fresh Puppet with no pins in one composition and, in +another, a 400 x 800 solid in a larger composition (pixels against fractions) with a Puppet of +three Position pins (one keyed, one moved off its rest place without +keys), one Advanced, one Bend, one Starch and one Overlap pin, Triangles, Expansion and Density +off their defaults, a second mesh, and a scripting read-back of every property's match name and +value, the hidden ones included. Pins added by script are reported to come out disabled, so the +pins are placed by hand or by a preset. + ### 3.8 Masks and bezier paths (`tdgp[ADBE Mask Parade]`) | Field | Chunk path | Offset | Type | Meaning | Evidence | Conf. | @@ -894,6 +940,7 @@ another value). **Effects** - `pard` byte 48 (flags: **?**), 68 / 72 (the point default as a percent pair: **V** in the corpus for 074 and 368, not exposed), 120 (the float slider default again as f32: **V**, not exposed), 124 (display decimals: **H**), 132..147 (fixed slider slider-max, default copy and flags; the low bit of @144 is set on the two percent-displayed sliders and clear on Scatter: **H** "display as percent"), the colour default copy at @60, and the stale memory after the parameter name. The fixed slider valid minimum is not decoded (the float slider one is, 389). +- Puppet (`ADBE FreePin3`, 3.7): the hidden mesh data, a pin's Vertex Index and Vertex Offset (its rest place), the unit of a pin's Position, the Overlap, Starch, Advanced and Bend pins, Density, Mesh Rotation Refinement and the Puppet Engine: no sample (**?**). - Effects renamed, or stacked twice on ONE layer (`Gradient Ramp 2`: does the second keep an empty `parT`?); which instance keeps the full `parT` when a second instance is created on a layer below the first; expressions on effect parameters; keyed per-channel Exposure values and a keyed checkbox; Curves / Levels (arrays), Fractal Noise, and whether the `tdpi` of a Layer Control holds an id or an index after the referenced layer is reordered or deleted. **Masks, shapes** diff --git a/docs/aep-import.md b/docs/aep-import.md index ed3ab57f5..5d268e503 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-05 · **Change:** the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 +> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-07 · **Change:** After Effects' Puppet arrives as EffectCraft's Puppet without its meshes and pins, which are reported mesh by mesh (#3093; the storage is not sampled yet, aep-format 3.7); before that: the report names the After Effects version a file is written for and the one that wrote it (`aeVersion`); copies saved as 25.x and 24.x import like the 2026 save; before that: Curves and Mesh Warp are imported (961-965, 985; FORMAT S of the corpus); before that: a shape item's eye switch is imported (bit 0 of its group flags, 986 / 987: a switched-off Fill under Trim Paths is no longer drawn, render equal to After Effects), and a layer style's (switch group `tdsb` 0, 988); before that: a negative time stretch (Time Reverse Layer) is imported: in and out point in order, the layer plays backwards (981-983: render equal to After Effects at 7 points); before that: After Effects' None on Compound Blur, Displacement Map and Set Matte arrives as an explicit None (`NO_LAYER`), not as the owning layer; before that: layer styles are imported (the nine styles and Blending Options, 951-979; Global Light becomes the composition's); before that: the 27 effects of round 3 are in the table (`effectcraft_aep::effect_map`); before that: a layer of a clip, sequence or sound ends at its source's end (After Effects ends it there when it opens a project, read-back test f1), unless it is remapped or looped or runs less than a frame past; before that: keys of shape colours are read (the reader took their 152-byte spatial records for plain ones: `tdb4` byte 7 is 0 there, 854); before that: the effect table moved to `effectcraft_aep::effect_map`; point defaults in percent and a popup default slot of 0 (After Effects' save of a project it opened, e1 / e2 re-saves) are read; before that: the justification of right-to-left paragraphs is read relative to the direction (t7); before that: 24 more layer blend modes (906, 907); before that: 27 more effects are imported (batch 6: 903-905), with layer parameters; before that: a Rec. 709 working space also sets the output space (After Effects renders in the working space, 902); before that: a mask whose path was never set arrives as the source rectangle (900, 901); before that: Offset Paths' Copy Offset arrives as EffectCraft's (After Effects' value minus 1, 828); before that: project settings, image sequences, conformed clips and effect instance names are imported, next to the mattes, shapes, text settings and animators, effects, markers, time remapping, cameras and lights); before that: parallel and ambient lights (701, 702) and keyed Orientation (703) are imported; before that: masks on text and shape layers (705, 706: AE vs EffectCraft render 12 and 0 of 5184 points, glyph edges) and Merge Paths, Offset Paths, Repeater (707-710, 731; merge renders equal but for edges; Repeater copy opacity and Offset Paths' outline differ in EffectCraft's renderer) are imported; before that: keyframes: the ease of Anchor Point, effect point, effect colour and mask path keys is imported (597-599, 700, 730: within 0.5 px of After Effects' read-backs); before that: effects: Directional Blur, Radial Blur, CC Radial Blur, Venetian Blinds, Channel Blur, Black & White, Tritone, Luma Key, Simple Choker, Matte Choker and Transform are imported (711-723; render checks: CC Radial Blur and Channel Blur equal, Directional Blur within 5/255; Venetian Blinds, Radial Blur, Black & White (8/255), Tritone (19/255), Luma Key, Matte Choker and Transform differ in EffectCraft's renderer, see STATUS); before that: text: character and paragraph settings (stroke, scales, baseline shift, kerning, ligatures, tsume, indents, spacing, justified text, composer, direction, vertical text; 538-578) and text animators with range selectors (579-594, 114) are imported; render checks 594 (keyed range selector at 0 / 0.5 / 1 / 1.5 / 2.5 s), 595, 596 equal but for glyph edges; before that: cameras, spot and point lights, X / Y Rotation and Orientation, Timewarp and expressions on effect parameters are imported (render checks 486, 535-537 equal but for edges; 488 differs by 5.25 %, cause in EffectCraft's renderer not isolated); before that: text: superscript and subscript are imported (526-528); before that: effects: Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow, Fill, Hue/Saturation, Levels and five expression controls are imported, popups are mapped by item name, checkbox defaults come from the file; before that: shape layers are imported (groups, paths, paints, trim paths, round corners; 451-484, 519 added); before that: markers and Time Remapping are imported (509, 510 added); before that: track mattes are imported (402-408); before that: text: automatic leading is verified (400); effects: Exposure is imported (Channels, all twelve sliders, Bypass, keys), next to Tint and Gradient Ramp; before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: effects: Tint and Gradient Ramp are imported (all parameters and keys), other effects stay reported; `.aepx` is accepted; text layers are imported (Source Text with style runs, paragraphs and box text; fonts by PostScript name, missing fonts listed); layer styles are reported; before that: layer masks · **Target:** Adobe After Effects 2026 File ▸ Import ▸ After Effects Project… (`file.importAep`) reads an `.aep` or its XML form `.aepx` and adds its items to the open project, in a folder named after the file (as After Effects does when it imports a project). @@ -58,7 +58,7 @@ outside the sampled ones keeps the current value and is reported as `project set | Text | the Source Text of a text layer: the text (paragraphs separated by CR; a hard line break is a CR too; U+0003 is laid out by After Effects exactly like a CR (386 against 387) and becomes a CR), per style run the font (PostScript name), size, fill and stroke colour, whether fill and stroke are drawn, stroke width, stroke over fill (one switch for the text), tracking, leading (automatic or explicit), faux bold, faux italic, all caps, small caps, superscript and subscript (526-528), horizontal and vertical scale, baseline shift, automatic kerning (metrics, optical, none), ligatures, tsume (538-578); per paragraph the justification (left, centre, right and the four justified variants), first-line / start / end indent, space before / after, direction, composer, hanging punctuation (in a right-to-left paragraph the stored justification is relative to the direction: After Effects lays 0 and 3 out flush right and 1 and 4 flush left, read-back test t7, so they arrive as right / left); point text or box text (box size and top-left in layer pixels); vertical right-to-left text (575) | | Text animators | every animator with its name and its range selectors (Start, End, Offset, or the Index range when Units is Index; Units, Based On, Mode, Amount, Shape, Smoothness, Ease High / Low, Randomize Order, Random Seed) and the properties Anchor Point, Position, Scale, Skew, Skew Axis, Rotation, Opacity, Fill Color, Stroke Color, Stroke Width, Tracking Amount, Line Spacing, Blur, Character Offset and Character Range, with keys and expressions (579-594, 114, 728). Values are stored as After Effects shows them; popups are 1-based in the file | | Masks | every mask of an imported layer, in stack order (on text and shape layers, which have no source, the stored mask box is in layer pixels: 705, 706): name, path (vertices and tangents in layer pixels, open or closed), mode (None, Add, Subtract, Intersect, Lighten, Darken, Difference), inverted, feather (x, y in pixels), opacity (percent), expansion (pixels); path keyframes (time, linear or hold sides, roving, label), feather / opacity / expansion keyframes with ease | -| Effects | **Curves** (961-964: the five curves' points from the arbitrary data as EffectCraft's `x,y` points in 0..1; render within 6 levels: EffectCraft interpolates monotone, After Effects stores a natural spline) and **Mesh Warp** (965, 985: Rows, Columns, Quality and the Distortion Mesh as per-vertex offsets in layer pixels; Bezier handles moved on their own are kept as eight extra numbers of the vertex for the export and reported, EffectCraft draws the mesh bilinear; the header is rows + 1, columns + 1; render 985: 1.4 % of pixels > 2 levels around the moved vertex), **Tint**, **Gradient Ramp**, **Exposure**, **Invert**, **Fast Box Blur**, **Gaussian Blur**, **Drop Shadow**, **Glow**, **Fill**, **Hue/Saturation**, **Levels**, **Timewarp** (Method, Adjust Time By, Speed with keys, Source Frame; 529-533), **Directional Blur**, **Radial Blur**, **CC Radial Blur**, **Venetian Blinds**, **Channel Blur**, **Black & White**, **Tritone**, **Luma Key**, **Simple Choker**, **Matte Choker**, **Transform** (all parameters; 711-723) and the expression controls **Slider**, **Angle**, **Checkbox**, **Color** and **Point Control** (table below) on any imported layer (also text and shape layers and layers with masks), in stack order, with the instance name, every parameter (static value or default) and its keyframes; also the second and later instances of a type, whose parameter definitions are only in the project's shared definition list. Other effects are reported (below) | +| Effects | **Curves** (961-964: the five curves' points from the arbitrary data as EffectCraft's `x,y` points in 0..1; render within 6 levels: EffectCraft interpolates monotone, After Effects stores a natural spline) and **Mesh Warp** (965, 985: Rows, Columns, Quality and the Distortion Mesh as per-vertex offsets in layer pixels; Bezier handles moved on their own are kept as eight extra numbers of the vertex for the export and reported, EffectCraft draws the mesh bilinear; the header is rows + 1, columns + 1; render 985: 1.4 % of pixels > 2 levels around the moved vertex), **Tint**, **Gradient Ramp**, **Exposure**, **Invert**, **Fast Box Blur**, **Gaussian Blur**, **Drop Shadow**, **Glow**, **Fill**, **Hue/Saturation**, **Levels**, **Timewarp** (Method, Adjust Time By, Speed with keys, Source Frame; 529-533), **Directional Blur**, **Radial Blur**, **CC Radial Blur**, **Venetian Blinds**, **Channel Blur**, **Black & White**, **Tritone**, **Luma Key**, **Simple Choker**, **Matte Choker**, **Transform** (all parameters; 711-723) and the expression controls **Slider**, **Angle**, **Checkbox**, **Color** and **Point Control** (table below) on any imported layer (also text and shape layers and layers with masks), in stack order, with the instance name, every parameter (static value or default) and its keyframes; also the second and later instances of a type, whose parameter definitions are only in the project's shared definition list. **Puppet** arrives in its place with its name and fx switch but without meshes and pins (a pin's rest place lies in the mesh data, not decoded yet); per mesh the report gives Triangles, Expansion and each pin group's pins, keyed pins and atom match names. Other effects are reported (below) | | Layer styles | Drop Shadow, Inner Shadow, Outer Glow, Inner Glow, Bevel and Emboss, Satin, Color Overlay, Gradient Overlay and Stroke when applied (switch group `tdsb` 1): every parameter, starting from After Effects' defaults (Use Global Light is off there), with keys on scalars and colours and expressions; Blending Options: Fill Opacity, the channel switches, Blend Interior Styles as Group, Use Blend Ranges, and Global Light Angle / Altitude, which the composition adopts (one setting per composition here). Units and popups through `effectcraft_aep::style_map` (aep-format 3.12b). Render check against After Effects (`proof\styles`): drop shadow, inner shadow, satin, colour overlay, stroke within a few levels; glow and bevel intensities and the Gradient Overlay direction differ in EffectCraft's renderer | | Shape layers | groups (nested, with anchor, position, scale, skew, skew axis, rotation, opacity), rectangle (size, position, roundness), ellipse (size, position), polystar (star or polygon, points, position, rotation, radii, roundness), bezier path (vertices and tangents in layer pixels, path keys), fill (colour, opacity, fill rule), stroke (colour, opacity, width, cap, join, miter limit), gradient fill and stroke (linear or radial, start and end point; the default white-to-black colours), trim paths (start, end, offset, trim multiple shapes), round corners (radius), merge paths (mode; 707, 708), offset paths (amount, line join, miter limit, copies, copy offset; 710; After Effects offsets copy *i* by Amount x (*i* + Copy Offset), default 1, EffectCraft by Amount x (*i* + 1 + copyOffset), so the copy offset arrives 1 less, also its keys; an expression on it is kept and reported; 826-828, 828 renders equal to After Effects on every grid point), repeater (copies, offset, composite, transform; 709), in file order, with keyframes and expressions, the operators' defaults as After Effects reads them back (731); the layer anchor in pixels; 238-242, 330-333, 451-484, 519, 705-710, 731 | | Markers | layer and composition markers: time (layer time for layer markers, exact frames), duration (1/600 s units, 509, 510), comment, chapter, URL, frame target, label, cue point (name, event or navigation, parameters); 122, 123, 249, 347-349, 509, 510 | From 5f73e5b5e935aae74587d18f20034f93a85e811e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 17:03:59 +0100 Subject: [PATCH 09/13] =?UTF-8?q?Time=20stretch:=20hold=20a=20loaded=20or?= =?UTF-8?q?=20imported=20stretch=20to=20=C2=B11=20%=20to=20=C2=B19900=20%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project file, an .aep, a timeline document or a Lottie file could give a layer a time stretch of 1e-300 %, 0 or 1e300 %. Layer and comp time divide by it and the audio mix read about usize::MAX source samples for one block, which overflowed or tried a huge allocation and aborted the app. - limits::stretch brings a stretch within After Effects' range (±1 % to ±9900 %, Layer.stretch in the scripting guide), sign kept; 0 and NaN are 100 %. Opening a project file corrects each layer and says so; a stretch written as null (NaN in an auto-save) now opens instead of failing. - .aep import and Lottie import clamp and report the stretch; timeline import clamps the clip speed and warns. - layer.timeStretch refuses a non-number and more than 9900 %; the Time Stretch dialog's ranges match. - Layer::layer_time / comp_time and the audio mix use the clamped stretch, so a value set in memory can't divide by zero or read without bound. Fixes #3247 --- crates/aep/src/import/build.rs | 9 +- crates/aep/tests/it/import/synthetic.rs | 41 ++++++++ crates/engine/src/cmd/layer_time.rs | 7 ++ crates/engine/src/tests/limits.rs | 18 ++++ crates/interchange/src/import.rs | 24 ++++- crates/interchange/src/tests.rs | 18 ++++ crates/lottie/src/import.rs | 7 +- crates/lottie/src/tests.rs | 15 +++ crates/project/src/layer.rs | 28 +++++- crates/project/src/limits.rs | 113 +++++++++++++++++++++- crates/render/src/audio.rs | 7 +- crates/render/src/tests.rs | 1 + crates/render/src/tests/stretch_limits.rs | 96 ++++++++++++++++++ crates/ui-egui/src/panels/key_dialogs.rs | 16 ++- 14 files changed, 389 insertions(+), 11 deletions(-) create mode 100644 crates/render/src/tests/stretch_limits.rs diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 4ec346328..ded1e64ad 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -681,7 +681,14 @@ fn apply_timing(l: &mut Layer, h: &LayerHeader, tm: &TimeMap, name: &str, col: & col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch is zero or unreadable, 100 % is used")); Rational { num: 1, den: 1 } }); - l.stretch = stretch.to_f64().map_or(100.0, |r| r * 100.0); + let stored = stretch.to_f64().map_or(100.0, |r| r * 100.0); + // A ratio such as 1/2^31 (5e-8 %) or 2^31/1 is outside what After Effects allows (±1 % to + // ±9900 %); taken as stored, the layer and audio time would divide by ~0 (#3247). The in and + // out points keep the comp times the file stores. + l.stretch = effectcraft_project::limits::stretch(stored); + if l.stretch.to_bits() != stored.to_bits() { + col.lose(LossCategory::DamagedData, format!("layer '{name}': the time stretch of {stored:e} % is outside ±1 % to ±9900 %, {} % is used", l.stretch)); + } // A stored time with a zero denominator (or a sum that overflows) has no value; it is read as 0 // and reported, so the layer's timing never changes silently. let unreadable: Vec<&str> = [("start", h.start), ("in point", h.in_relative), ("out point", h.out_relative)] diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 18709f9e5..e2fb20da4 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -273,6 +273,47 @@ fn layer_times_with_a_zero_denominator_are_reported() { assert!(losses(&r.unwrap(), LossCategory::DamagedData).is_empty()); } +/// The time stretch of every imported layer. +fn stretches(p: &Project) -> Vec { + p.items.values().filter_map(|i| if let ItemKind::Comp(c) = &i.kind { Some(c) } else { None }).flat_map(|c| c.layers.iter().map(|l| l.stretch)).collect() +} + +#[test] +fn a_time_stretch_outside_one_to_9900_percent_is_clamped_and_reported() { + // ldta byte 8 is the stretch numerator (i32), byte 108 the denominator (u32). 1/(2^31 - 1) is + // about 5e-8 %: imported as stored, the layer and audio time divided by ~0 (#3247). + for (num, den, want) in [(1i32, i32::MAX as u32, 1.0), (-1, i32::MAX as u32, -1.0), (i32::MAX, 1, 9900.0), (i32::MIN, 1, -9900.0)] { + let bytes = mutate(&mut |c| { + if c.id == *b"ldta" { + set_data(c, 8, &num.to_be_bytes()); + set_data(c, 108, &den.to_be_bytes()); + } + }); + let (p, r) = run(&bytes, true); + let r = r.unwrap(); + let found = losses(&r, LossCategory::DamagedData); + assert!(found.iter().any(|d| d.contains("time stretch") && d.contains("outside")), "{num}/{den}: {found:?}"); + let s = stretches(&p); + assert!(!s.is_empty() && s.iter().all(|v| *v == want), "{num}/{den}: {s:?}"); + let (p, r) = run(&bytes, false); + assert!(r.is_err(), "{num}/{den}: strict import must refuse"); + assert!(p.items.is_empty()); + } + // A small stretch within the range (1/64: 1.5625 %) and -9900 % itself are no damage. + for (num, den, want) in [(1i32, 64u32, 1.5625), (-99, 1, -9900.0)] { + let bytes = mutate(&mut |c| { + if c.id == *b"ldta" { + set_data(c, 8, &num.to_be_bytes()); + set_data(c, 108, &den.to_be_bytes()); + } + }); + let (p, r) = run(&bytes, true); + let r = r.unwrap(); + assert!(!losses(&r, LossCategory::DamagedData).iter().any(|d| d.contains("time stretch")), "{num}/{den}: {:?}", r.report.losses); + assert!(stretches(&p).iter().all(|v| *v == want), "{num}/{den}: {:?}", stretches(&p)); + } +} + /// The project with `nhed` bytes from `at` replaced. fn header(at: usize, bytes: &[u8]) -> Vec { mutate(&mut |c| { diff --git a/crates/engine/src/cmd/layer_time.rs b/crates/engine/src/cmd/layer_time.rs index 4ecf725ec..a6fe3d990 100644 --- a/crates/engine/src/cmd/layer_time.rs +++ b/crates/engine/src/cmd/layer_time.rs @@ -59,9 +59,16 @@ fn time_stretch(s: &mut Session, p: &Value) -> Result { (None, Some(d)) if span > 0.0 => old * d / span, _ => old, }; + if !new.is_finite() { + return Err(bad("layer.timeStretch", "the stretch factor must be a number")); + } if new.abs() < 1.0 { return Err(bad("layer.timeStretch", "the stretch factor must be at least 1 %")); } + // After Effects' largest stretch; a project file holds no more either (#3247). + if new.abs() > effectcraft_project::limits::MAX_STRETCH { + return Err(bad("layer.timeStretch", "the stretch factor must be at most 9900 %")); + } // The hold point keeps showing the same layer time. let h = match hold { "current" => cti, diff --git a/crates/engine/src/tests/limits.rs b/crates/engine/src/tests/limits.rs index 524719bbf..9c156c4f7 100644 --- a/crates/engine/src/tests/limits.rs +++ b/crates/engine/src/tests/limits.rs @@ -103,6 +103,24 @@ fn commands_cap_sizes_at_30000_px() { assert_eq!((f.width, f.height, f.frame_rate), (limits::MAX_SIZE, 8, FrameRate::FPS_29_97)); } +/// Time Stretch takes After Effects' range, ±1 % to ±9900 %: before, 1e300 % was set, and a +/// project file holding it opened at 9900 % (#3247). +#[test] +fn time_stretch_refuses_more_than_9900_percent() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 32, "frameRate": 30, "duration": 2})).unwrap(); + let layer = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + let stretch = |s: &Session| s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == layer).unwrap().stretch; + for pct in [1e300, -1e300, 9900.5] { + assert!(s.execute("layer.timeStretch", json!({"layers": [layer], "percent": pct})).is_err(), "{pct}"); + assert_eq!(stretch(&s), 100.0, "{pct}"); + } + for pct in [limits::MAX_STRETCH, -limits::MAX_STRETCH] { + s.execute("layer.timeStretch", json!({"layers": [layer], "percent": pct})).unwrap(); + assert_eq!(stretch(&s), pct); + } +} + #[test] fn a_project_with_a_precomp_cycle_opens_adds_and_renders_without_running_out_of_stack() { // Comps A and B nest each other (a damaged file); C is unrelated. Checking whether A diff --git a/crates/interchange/src/import.rs b/crates/interchange/src/import.rs index 9789ceb89..22e22b4f2 100644 --- a/crates/interchange/src/import.rs +++ b/crates/interchange/src/import.rs @@ -522,11 +522,31 @@ impl Importer<'_> { // ---- timing let start = tick_in(ti.start); let dur = tick_in(ti.duration); - let speed = if ti.speed.abs() < 1e-9 { 1.0 } else { ti.speed.abs() }; + // A zero, tiny (below 1e-9) or non-finite speed plays at 100 %. Otherwise the layer's + // stretch (100 / speed) is held to the range the app supports (±1 % to ±9900 %): a speed + // of 1e300 or 1e-300 from a damaged file made the layer and audio time divide by ~0 + // (#3247). The speed follows the stretch so the start time below matches it. + let raw = ti.speed.abs(); + let (speed, stretch) = if !raw.is_finite() || raw < 1e-9 { + // A speed of exactly 0 keeps its old silent 100 %; a tiny or non-finite one is named. + if !raw.is_finite() || raw > 0.0 { + self.warn(format!("clip \"{}\": a speed of {} is not supported (100 % used)", ti.name, ti.speed)); + } + (1.0, 100.0) + } else { + let stretch = effectcraft_project::limits::stretch(100.0 / raw); + if stretch.to_bits() == (100.0 / raw).to_bits() { + (raw, stretch) + } else { + let speed = 100.0 / stretch; + self.warn(format!("clip \"{}\": a speed of {:e} % is outside what a layer supports ({} % used)", ti.name, raw * 100.0, speed * 100.0)); + (speed, stretch) + } + }; let reverse = ti.reverse || ti.speed < 0.0; let src_in = tick_in(ti.source_in); let span = Tick((dur.0 as f64 * speed).round() as i64); - l.stretch = if reverse { -100.0 / speed } else { 100.0 / speed }; + l.stretch = if reverse { -stretch } else { stretch }; l.start_time = if reverse { start + Tick(((src_in + span).0 as f64 / speed).round() as i64) } else { start - Tick((src_in.0 as f64 / speed).round() as i64) }; l.in_point = start; diff --git a/crates/interchange/src/tests.rs b/crates/interchange/src/tests.rs index f4c9158f8..b451ecf81 100644 --- a/crates/interchange/src/tests.rs +++ b/crates/interchange/src/tests.rs @@ -314,6 +314,24 @@ fn imports_fcp7_xml_fcpxml_and_otio() { } } +/// A clip speed past what a layer's stretch supports (±1 % to ±9900 %) is held to it and named. +/// Before, a speed of 1e6 gave a stretch of 1e-4 % and the layer and audio time divided by ~0 +/// (#3247). +#[test] +fn an_extreme_clip_speed_is_held_to_the_stretch_range() { + for (speed, stretch) in [(1e6, 1.0), (1e-6, 9900.0)] { + let (mut doc, main) = edit(); + doc.sequence_mut(main).unwrap().video_tracks[1].items[0].speed = speed; + let (bytes, _) = fc::export(&doc, main, fc::Format::Otio, &Default::default()).unwrap(); + let (p, r) = import_doc(&bytes, "Main.otio", &mut probe); + let main_comp = r.comps.iter().copied().find(|c| p.item(*c).unwrap().name == "Main").expect("Main comp"); + let c = p.comp(main_comp).unwrap(); + let stretched: Vec = c.layers.iter().map(|l| l.stretch).filter(|s| (s - 100.0).abs() > 1e-9).collect(); + assert_eq!(stretched, [stretch], "speed {speed}"); + assert!(r.warnings.iter().any(|w| w.contains("speed") && w.contains("outside")), "speed {speed}: {:?}", r.warnings); + } +} + #[test] fn imports_aaf_and_omf() { let (doc, main) = edit(); diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 53b1ba71c..57ef6427a 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -431,7 +431,12 @@ fn build_layers(cx: &mut Ctx, project: &mut Project, comp: &mut Comp, layers: &[ // Below 1 % (or 0) the layer time divides by ~0, and layer.timeStretch refuses it too. let sr = f(j, "sr").unwrap_or(1.0); l.stretch = if sr.is_finite() && sr.abs() >= 0.01 { - sr * 100.0 + // Past ±9900 % too (#3247): the largest stretch a layer supports, sign kept. + let pct = effectcraft_project::limits::stretch(sr * 100.0); + if pct.to_bits() != (sr * 100.0).to_bits() { + cx.im.warn(format!("{name}: a time stretch of {} % is not supported ({pct} % used)", sr * 100.0)); + } + pct } else { cx.im.warn(format!("{name}: a time stretch of {} % is not supported (100 % used)", sr * 100.0)); 100.0 diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index d26b3642d..414c875f0 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -520,6 +520,21 @@ fn zero_time_stretch_is_not_imported() { assert!(imp.warnings.iter().any(|w| w.contains("time stretch")), "{:?}", imp.warnings); } +#[test] +fn a_time_stretch_past_9900_percent_is_held_to_it() { + // `sr` 1e300 was imported as 1e302 % (#3247); the sign of a reversed layer is kept. + for (sr, want) in [(1e300, 9900.0), (-1e300, -9900.0), (99.0, 9900.0)] { + let doc = json!({"w": 20, "h": 20, "fr": 30, "ip": 0, "op": 30, "assets": [], + "layers": [{"ty": 1, "ind": 1, "sc": "#ff0000", "sw": 20, "sh": 20, "sr": sr, "ip": 0, "op": 30, "st": 0, "ks": {}}]}); + let mut q = Project::default(); + let imp = import(&mut q, doc.to_string().as_bytes(), "Imported", "", &mut |_| None).unwrap(); + let l = &q.comp(imp.comp).unwrap().layers[0]; + assert_eq!(l.stretch, want, "sr {sr}"); + let warned = imp.warnings.iter().any(|w| w.contains("time stretch")); + assert_eq!(warned, sr != 99.0, "sr {sr}: {:?}", imp.warnings); + } +} + #[test] fn embedded_glyphs_are_reported_when_dropped() { let glyph = |ch: &str, fam: &str| json!({"ch": ch, "size": 100, "style": "Regular", "w": 60, "fFamily": fam, "data": {"shapes": []}}); diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index 34eeb50c0..bc747bd88 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -470,6 +470,20 @@ pub enum AutoOrient { TowardsPointOfInterest, } +/// A stretch written as `null` (a NaN or infinite stretch: JSON holds neither, and auto-saves +/// write it so) reads as NaN, so the project still opens and loading brings it to 100 % with a +/// note ([`crate::limits::stretch`], #3247) instead of the whole file being refused. An infinite +/// stretch therefore also reopens at 100 %, not at ±9900 %. +fn stretch_or_nan<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + Ok(Option::::deserialize(d)?.unwrap_or(f64::NAN)) +} + +/// `t × mul / div` in ticks, within [`Tick::MIN`]..=[`Tick::MAX`] (the cast saturates, NaN is 0), +/// so a layer time of an extreme stretch can't overflow later tick arithmetic. +fn scaled(t: Tick, mul: f64, div: f64) -> Tick { + Tick(((t.0 as f64 * mul / div) as i64).clamp(Tick::MIN.0, Tick::MAX.0)) +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Layer { pub id: LayerId, @@ -484,6 +498,7 @@ pub struct Layer { pub in_point: Tick, pub out_point: Tick, /// Time stretch in percent (100 = normal, negative = reversed). + #[serde(deserialize_with = "stretch_or_nan")] pub stretch: f64, pub switches: Switches, pub blend_mode: BlendMode, @@ -555,15 +570,24 @@ impl Layer { t >= self.in_point && t < self.out_point } + /// The time stretch the layer plays at: [`Layer::stretch`] within + /// [`limits::stretch`](crate::limits::stretch) (a damaged value that got past loading, 0 or + /// NaN, never divides the layer's time by zero). + pub fn effective_stretch(&self) -> f64 { + crate::limits::stretch(self.stretch) + } + /// Layer time for a comp time (stretch-aware). pub fn layer_time(&self, comp_t: Tick) -> Tick { let d = comp_t - self.start_time; - if (self.stretch - 100.0).abs() < 1e-9 { d } else { Tick((d.0 as f64 * 100.0 / self.stretch) as i64) } + let s = self.effective_stretch(); + if (s - 100.0).abs() < 1e-9 { d } else { scaled(d, 100.0, s) } } /// Comp time for a layer time. pub fn comp_time(&self, layer_t: Tick) -> Tick { - if (self.stretch - 100.0).abs() < 1e-9 { self.start_time + layer_t } else { self.start_time + Tick((layer_t.0 as f64 * self.stretch / 100.0) as i64) } + let s = self.effective_stretch(); + if (s - 100.0).abs() < 1e-9 { self.start_time + layer_t } else { self.start_time + scaled(layer_t, s, 100.0) } } pub fn transform(&self) -> Option<&PropGroup> { diff --git a/crates/project/src/limits.rs b/crates/project/src/limits.rs index 998f40225..7a74c992b 100644 --- a/crates/project/src/limits.rs +++ b/crates/project/src/limits.rs @@ -27,6 +27,30 @@ pub const MAX_PIXEL_ASPECT: f64 = 10.0; pub const MOTION_BLUR_SAMPLES: (u32, u32) = (2, 64); /// Composition Settings ▸ Advanced ▸ Adaptive Sample Limit. pub const ADAPTIVE_SAMPLE_LIMIT: (u32, u32) = (16, 256); +/// The smallest time stretch, in percent, either way (After Effects sets a stretch between 0 and +/// 1 % to 1 %, between -1 % and 0 to -1 %; `layer.timeStretch` refuses less). +pub const MIN_STRETCH: f64 = 1.0; +/// The largest time stretch, in percent, either way (After Effects' `Layer.stretch` range is +/// -9900 to 9900). +pub const MAX_STRETCH: f64 = 9900.0; + +/// A layer's time stretch from [`MIN_STRETCH`] to [`MAX_STRETCH`] % either way, its sign (a +/// reversed layer) kept; 100 % for 0 or NaN. Layer and comp time divide by the stretch, and the +/// audio mix reads `100 / stretch` source samples per sample, so a stretch of 1e-300 % or NaN +/// from a damaged file must not reach them (#3247). +pub fn stretch(v: f64) -> f64 { + if v.is_nan() || v == 0.0 { + return 100.0; + } + // Infinity clamps to the largest stretch with its sign. + v.signum() * v.abs().clamp(MIN_STRETCH, MAX_STRETCH) +} + +/// A stretch for a note: a plain number in a readable range, scientific notation beyond it (a +/// plain 1e-300 would print 300 digits). +fn percent(v: f64) -> String { + if v == 0.0 || (1e-3..1e7).contains(&v.abs()) { format!("{v} %") } else { format!("{v:e} %") } +} /// A width or height from 1 to [`MAX_SIZE`]. pub fn size(v: u64) -> u32 { @@ -97,9 +121,11 @@ fn repair_comp(name: &str, c: &Comp, notes: &mut Vec) -> Option { let duration = c.duration.max(rate.frame_duration()); let samples = c.motion_blur_samples.clamp(MOTION_BLUR_SAMPLES.0, MOTION_BLUR_SAMPLES.1); let adaptive = c.motion_blur_adaptive_limit.clamp(ADAPTIVE_SAMPLE_LIMIT.0, ADAPTIVE_SAMPLE_LIMIT.1); + let stretch_ok = |l: &crate::Layer| stretch(l.stretch).to_bits() == l.stretch.to_bits(); let unchanged = (width, height, rate, duration, samples, adaptive) == (c.width, c.height, c.frame_rate, c.duration, c.motion_blur_samples, c.motion_blur_adaptive_limit) - && pa.to_bits() == c.pixel_aspect.to_bits(); + && pa.to_bits() == c.pixel_aspect.to_bits() + && c.layers.iter().all(stretch_ok); if unchanged { return None; } @@ -125,6 +151,12 @@ fn repair_comp(name: &str, c: &Comp, notes: &mut Vec) -> Option { } (n.width, n.height, n.pixel_aspect, n.frame_rate, n.duration) = (width, height, pa, rate, duration); (n.motion_blur_samples, n.motion_blur_adaptive_limit) = (samples, adaptive); + // Only the layers out of range are copied (the others stay shared). + for l in n.layers.iter_mut_where(|l| !stretch_ok(l)) { + let fixed = stretch(l.stretch); + note(notes, name, &format!("layer '{}' time stretch", l.name), percent(l.stretch), percent(fixed)); + l.stretch = fixed; + } Some(n) } @@ -217,6 +249,85 @@ mod tests { ); } + #[test] + fn a_stretch_is_held_to_one_to_9900_percent_with_its_sign() { + assert_eq!(stretch(f64::NAN), 100.0); + assert_eq!(stretch(0.0), 100.0); + assert_eq!(stretch(-0.0), 100.0); + assert_eq!(stretch(1e-300), MIN_STRETCH); + assert_eq!(stretch(-1e-300), -MIN_STRETCH); + assert_eq!(stretch(0.5), MIN_STRETCH); + assert_eq!(stretch(1e300), MAX_STRETCH); + assert_eq!(stretch(f64::INFINITY), MAX_STRETCH); + assert_eq!(stretch(f64::NEG_INFINITY), -MAX_STRETCH); + for v in [1.0, -1.0, 33.3, 100.0, -100.0, 200.0, 9900.0, -9900.0] { + assert_eq!(stretch(v).to_bits(), v.to_bits(), "{v} is in range"); + } + } + + /// A layer whose stretch got past loading (0, NaN, 1e-300 %, set in memory) still maps time + /// through a stretch in range: before, 0 and 1e-300 % saturated every layer time to the end of + /// time and NaN collapsed it to 0 (#3247). + #[test] + fn layer_and_comp_time_use_a_stretch_in_range() { + let mut p = crate::Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(10.0)); + let mut l = crate::build::layer(&mut p, &comp, "L", crate::LayerSource::Camera, (64, 64), None); + l.start_time = Tick::from_seconds_f64(1.0); + let at = |s: f64| Tick::from_seconds_f64(s); + for (bad, effective) in [(0.0, 100.0), (f64::NAN, 100.0), (1e-300, 1.0), (-1e-300, -1.0), (1e300, 9900.0), (f64::NEG_INFINITY, -9900.0)] { + l.stretch = bad; + assert_eq!(l.effective_stretch(), effective, "{bad}"); + // One comp second after the start is 100 / stretch layer seconds. + let want = Tick((at(1.0).0 as f64 * 100.0 / effective) as i64); + assert_eq!(l.layer_time(at(2.0)), want, "{bad}"); + // And back, within the rounding of the two conversions. + assert!((l.comp_time(l.layer_time(at(2.0))) - at(2.0)).abs() <= Tick(100), "{bad}"); + } + } + + #[test] + fn a_stretch_out_of_range_in_a_file_is_corrected_and_reported() { + let mut p = crate::Project::default(); + let comp = Comp::new(64, 64, FrameRate::FPS_25, Tick::from_seconds_f64(2.0)); + let mut c = comp.clone(); + for (name, s) in [("tiny", 1e-300), ("reversed tiny", -1e-300), ("zero", 0.0), ("huge", 1e300), ("reversed huge", -5e4), ("fine", -50.0)] { + let mut l = crate::build::layer(&mut p, &comp, name, crate::LayerSource::Camera, (64, 64), None); + l.stretch = s; + c.layers.push(l); + } + // Added through `add_item`, the comp is held to the limits too: write the values back in. + let id = p.add_item("Comp", effectcraft_color::Label::None, None, ItemKind::Comp(Arc::new(c.clone()))); + assert!(p.comp(id).unwrap().layers.iter().all(|l| l.stretch.abs() >= MIN_STRETCH && l.stretch.abs() <= MAX_STRETCH)); + let mut v: serde_json::Value = serde_json::from_str(&p.to_json()).unwrap(); + for (k, l) in c.layers.iter().enumerate() { + v["items"][id.0.to_string()]["kind"]["layers"][k]["stretch"] = serde_json::json!(l.stretch); + } + let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap(); + let got: Vec = back.comp(id).unwrap().layers.iter().map(|l| l.stretch).collect(); + assert_eq!(got, [1.0, -1.0, 100.0, 9900.0, -9900.0, -50.0]); + assert_eq!( + notes, + [ + "Comp: layer 'tiny' time stretch 1e-300 % → 1 %", + "Comp: layer 'reversed tiny' time stretch -1e-300 % → -1 %", + "Comp: layer 'zero' time stretch 0 % → 100 %", + "Comp: layer 'huge' time stretch 1e300 % → 9900 %", + "Comp: layer 'reversed huge' time stretch -50000 % → -9900 %", + ] + ); + // A NaN stretch is written as null (an auto-save): the file opens, at 100 %, and says so. + // Before, the whole project was refused. + v["items"][id.0.to_string()]["kind"]["layers"][0]["stretch"] = serde_json::Value::Null; + let (back, notes) = crate::Project::from_json_repaired(&v.to_string()).unwrap(); + assert_eq!(back.comp(id).unwrap().layers.iter().next().unwrap().stretch, 100.0); + assert_eq!(notes.first().map(String::as_str), Some("Comp: layer 'tiny' time stretch NaN % → 100 %")); + let mut nan = p.clone(); + nan.comp_mut(id).unwrap().layers.iter_mut().for_each(|l| l.stretch = f64::NAN); + let (back, _) = crate::Project::from_json_repaired(&nan.to_json_compact()).unwrap(); + assert!(back.comp(id).unwrap().layers.iter().all(|l| l.stretch == 100.0)); + } + #[test] fn a_comp_in_range_is_not_copied() { let c = Comp::new(1920, 1080, FrameRate::FPS_24, Tick::from_seconds_f64(2.0)); diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index e2861340c..00342847d 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -155,7 +155,9 @@ impl MixEnv<'_> { return out; } let m = b - a; - let speed = 100.0 / l.stretch; + // Within ±1 % to ±9900 % (#3247): a stretch of 1e-300 % set in memory read about + // usize::MAX source samples for one block. + let speed = 100.0 / l.effective_stretch(); let src_n = ((m as f64 * speed).ceil() as usize).max(1); let lt = l.layer_time(sample_tick(i0 + a as i64, rate)); let buf = match &l.source { @@ -291,7 +293,8 @@ fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut let any_solo = comp.layers.iter().any(|l| l.switches.solo && audible(project, l)); let s0 = start.to_units_floor(rate as i64); for l in &comp.layers { - if !audible(project, l) || (any_solo && !l.switches.solo) || l.stretch <= 0.0 { + // The stretch the layer's time uses (#3247): 0 and NaN play at 100 %, as `layer_time`. + if !audible(project, l) || (any_solo && !l.switches.solo) || l.effective_stretch() <= 0.0 { continue; } // Sample range of the block that falls inside the layer's [in, out). diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index c77fc2d94..930bd810b 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -22,6 +22,7 @@ mod nested; mod paint; mod remap; mod roi; +mod stretch_limits; mod styles; mod time; diff --git a/crates/render/src/tests/stretch_limits.rs b/crates/render/src/tests/stretch_limits.rs new file mode 100644 index 000000000..17bdff317 --- /dev/null +++ b/crates/render/src/tests/stretch_limits.rs @@ -0,0 +1,96 @@ +//! A project file whose layers carry a time stretch outside ±1 % to ±9900 % (1e-300 %, 0, +//! 1e300 %…) opens with the stretch brought into that range, and a frame of it draws and its audio +//! mixes with bounded source reads. Before, a stretch of 1e-300 % asked the footage for about +//! `usize::MAX` samples and the mix overflowed or tried a huge allocation, aborting the app (#3247). + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use effectcraft_color::Label; +use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, build, limits}; +use effectcraft_time::Tick; + +use super::{setup, solid}; +use crate::{FootageSource, Image, audio::mix_comp, render_frame}; + +/// Silent audio footage that records the longest read asked of it and refuses a read past a +/// thousand seconds (so a runaway read can't take the machine's memory: the mix then fills the +/// read with silence itself). +#[derive(Default)] +struct Probe { + longest: AtomicUsize, +} + +impl FootageSource for Probe { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn audio(&self, _: ItemId, _: &Footage, _: Tick, frames: usize, rate: u32) -> Option> { + self.longest.fetch_max(frames, Ordering::Relaxed); + (frames <= rate as usize * 1000).then(|| vec![0.0; frames * 2]) + } +} + +/// The project file of a comp holding a red solid and a sound, both with their stretch set to +/// `stretch` in the file text. +fn file_with_stretch(stretch: f64) -> (String, ItemId) { + let (mut p, cid, comp) = setup(); + let red = solid(&mut p, &comp, [1.0, 0.0, 0.0], 200, 100); + let f = Footage { + path: "tone.wav".into(), + kind: FootageKind::Audio, + has_audio: true, + frame_rate: comp.frame_rate, + duration: Tick::from_seconds_f64(10.0), + ..Default::default() + }; + let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f)); + let sound = build::layer(&mut p, &comp, "sound", LayerSource::Footage { item: fid }, (0, 0), None); + let c = p.comp_mut(cid).unwrap(); + c.layers.push(red); + c.layers.push(sound); + // Both layers are at 100 %; `{:e}` writes a JSON number for any finite value, and NaN is + // written as null, as serializing the project writes it. + let text = p.to_json(); + assert_eq!(text.matches("\"stretch\": 100.0").count(), 2); + let value = if stretch.is_nan() { "null".to_string() } else { format!("{stretch:e}") }; + (text.replace("\"stretch\": 100.0", &format!("\"stretch\": {value}")), cid) +} + +#[test] +fn a_file_with_an_extreme_stretch_opens_in_range_draws_and_mixes_with_bounded_reads() { + for (stored, opened) in + [(1e-300, 1.0), (-1e-300, -1.0), (0.0, 100.0), (1e300, limits::MAX_STRETCH), (-1e300, -limits::MAX_STRETCH), (0.5, 1.0), (f64::NAN, 100.0)] + { + let (text, cid) = file_with_stretch(stored); + let (p, notes) = Project::from_json_repaired(&text).unwrap(); + assert!(notes.iter().filter(|n| n.contains("time stretch")).count() == 2, "{stored}: {notes:?}"); + let c = p.comp(cid).unwrap(); + assert!(c.layers.iter().all(|l| l.stretch == opened), "{stored}: {:?}", c.layers.iter().map(|l| l.stretch).collect::>()); + // A frame draws: the solid has no keys and keeps its in and out points, so it is red. + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + assert_eq!(img.get(10, 10), [1.0, 0.0, 0.0, 1.0], "{stored}"); + // The audio mixes, reading at most 100 source samples per comp sample (a stretch of 1 %). + let (rate, n) = (8000, 4000); + let probe = Probe::default(); + let mixed = mix_comp(&p, &probe, None, cid, Tick::from_seconds_f64(0.5), n, rate); + assert_eq!(mixed.len(), n * 2, "{stored}"); + assert!(probe.longest.load(Ordering::Relaxed) <= 100 * n + 1, "{stored}: a read of {} samples", probe.longest.load(Ordering::Relaxed)); + } +} + +/// A stretch set in memory (not through a file, so not repaired) mixes as the clamped stretch the +/// layer's time uses: before, 1e-300 % read about `usize::MAX` source samples and overflowed. +#[test] +fn an_in_memory_extreme_stretch_mixes_with_bounded_reads() { + for stretch in [1e-300, 0.0, f64::NAN, 1e300] { + let (text, cid) = file_with_stretch(100.0); + let (mut p, _) = Project::from_json_repaired(&text).unwrap(); + p.comp_mut(cid).unwrap().layers.iter_mut().for_each(|l| l.stretch = stretch); + let (rate, n) = (8000, 4000); + let probe = Probe::default(); + let mixed = mix_comp(&p, &probe, None, cid, Tick::from_seconds_f64(0.5), n, rate); + assert_eq!(mixed.len(), n * 2, "{stretch}"); + assert!(probe.longest.load(Ordering::Relaxed) <= 100 * n + 1, "{stretch}: a read of {} samples", probe.longest.load(Ordering::Relaxed)); + } +} diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs index 2a5579837..db3144ffd 100644 --- a/crates/ui-egui/src/panels/key_dialogs.rs +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -262,7 +262,14 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Stretch Factor:")); let before = d.percent; - drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, DragSpec { speed: 0.5, range: -10000.0..=10000.0, suffix: " %" }, true); + drag( + ui, + app, + "dialog.timeStretch.percent", + &mut d.percent, + DragSpec { speed: 0.5, range: -effectcraft_project::limits::MAX_STRETCH..=effectcraft_project::limits::MAX_STRETCH, suffix: " %" }, + true, + ); if (before - d.percent).abs() > 1e-9 && d.orig_percent.abs() > 1e-9 { d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs(); } @@ -271,7 +278,12 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let before = d.duration; drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, DragSpec { speed: 0.05, range: 0.01..=100000.0, suffix: " s" }, true); if (before - d.duration).abs() > 1e-9 && d.orig_duration > 1e-9 { - d.percent = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration; + let wanted = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration; + // Within what Time Stretch accepts (±1 % to ±9900 %, #3247); the duration follows. + d.percent = effectcraft_project::limits::stretch(wanted); + if d.percent.to_bits() != wanted.to_bits() && d.orig_percent.abs() > 1e-9 { + d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs(); + } } ui.end_row(); }); From 67a39d9a2b0e1915fbd3d8623745df0b50b47c3c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 02:32:11 +0100 Subject: [PATCH 10/13] .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 77c3fad6c5800ec951359cd031c4ec215f8b15e8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 02:32:11 +0100 Subject: [PATCH 11/13] #3233: an empty Layer Settings name follows the text again; document the name rule on layer.newText, layer.setText and layer.settings --- crates/engine/src/cmd/layer.rs | 6 +++--- crates/engine/src/cmd/layer/transform.rs | 7 ++++++- crates/engine/src/tests/text_edit.rs | 5 +++++ docs/agents.md | 5 ++++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index b015b72b4..f48fa26f8 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -49,7 +49,7 @@ pub fn specs() -> Vec { "Text", ["Layer", "New"], Some("Cmd+Alt+Shift+T"), - "{text?, name? (layer name; default the text), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)", + "{text?, name? (layer name; default the text, which the name then follows; a given name stays), position? [x,y], box? [x,y,w,h] (paragraph text, comp space), vertical?, edit? (start editing), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?, baselineShift?, hScale?, vScale?, tsume?, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?, superscript?, subscript?, kerning?, ligatures?, justify?, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?, composer?, hangingPunctuation?, strokeOverFill?, lineJoin?} (attributes as layer.setText)", has_comp, new_text ), @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Layer Settings...", ["Layer"], Some("Cmd+Shift+Y"), - "{layer?, name?, color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}", + "{layer?, name? (an empty name makes a text layer's name follow its text again), color?, width?, height?, pixelAspect?, affectAll?: bool (default true; false gives this layer its own copy of a solid other layers use)} → {layer, solid?}", has_layer_settings, layer_settings ), @@ -218,7 +218,7 @@ pub fn specs() -> Vec { "Edit Text", [], None, - "{layer?, range?: [start, end] (characters; default all), text?, font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}", + "{layer?, range?: [start, end] (characters; default all), text? (also renames a text layer whose name still follows its text), font?, style?, size?, fill?, stroke?, applyFill?, applyStroke?, strokeWidth?, tracking?, leading?: px|\"auto\", baselineShift? px, hScale? %, vScale? %, tsume? %, fauxBold?, fauxItalic?, allCaps?, smallCaps?, baseline?: normal|superscript|subscript, superscript?, subscript?, kerning?: metrics|optical|number, ligatures?, discretionaryLigatures? (OpenType), contextualAlternates?, stylisticAlternates?, stylisticSets?: [1–20], ss01?, ss02?, ss03?, ss04?, ss05?, ss06?, ss07?, ss08?, ss09?, ss10?, ss11?, ss12?, ss13?, ss14?, ss15?, ss16?, ss17?, ss18?, ss19?, ss20?, swash?, titling?, ordinals?, fractions?, allSmallCaps?, figureStyle?: default|lining|oldStyle, figureWidth?: default|proportional|tabular, figures?, variations?: {tag: value} (variable font axes; null resets), justify?: left|center|right|justifyLeft|justifyCenter|justifyRight|justifyAll, indentLeft?, indentRight?, indentFirst?, spaceBefore?, spaceAfter?, direction?: ltr|rtl, composer?: everyLine|singleLine, hangingPunctuation?, strokeOverFill?, lineJoin?: miter|round|bevel, box?: [x,y,w,h]|null (layer space), vertical?}", has_layers, set_text ), diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index 87403fb26..acccfc7df 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -157,7 +157,7 @@ pub(super) fn transform_op(s: &mut Session, p: &Value) -> Result { /// layers, which are solids) edit their Solid Settings. With `affectAll: false`, a solid other /// layers also use is copied into a new solid for this layer instead of being changed for all of /// them (After Effects' "Affect all layers that use this solid"). `name` renames the layer and -/// its solid. +/// its solid; an empty one makes a text layer's name follow its text again. pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.settings")?; // Cameras and lights open their own settings (Camera Settings / Light Settings). @@ -171,6 +171,9 @@ pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result { let h = p.get("height").and_then(Value::as_u64).map(limits::size); let pixel_aspect = pixel_aspect_p(p)?; let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + // An empty name gives a text layer its text's name again, as `layer.rename` does (#3233); + // other layers keep theirs. + let follow_text = str_p(p, "name").is_some_and(|n| n.trim().is_empty()); let affect_all = p.get("affectAll").and_then(Value::as_bool).unwrap_or(true); let t = s.time(); let solid = s.edit("Layer Settings", None, |proj, _| { @@ -178,6 +181,8 @@ pub(super) fn layer_settings(s: &mut Session, p: &Value) -> Result { if let Some(n) = &name { // A user name: a text layer's name stops following its text (#3233). super::switches::set_layer_name(layer_mut(proj, cid, lid)?, n, t); + } else if follow_text && matches!(src, LayerSource::Text) { + super::switches::set_layer_name(layer_mut(proj, cid, lid)?, "", t); } let LayerSource::Solid { item } = src else { return Ok(None) }; let shared = proj.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == src).count() > 1; diff --git a/crates/engine/src/tests/text_edit.rs b/crates/engine/src/tests/text_edit.rs index bcb4f0559..db8d4679a 100644 --- a/crates/engine/src/tests/text_edit.rs +++ b/crates/engine/src/tests/text_edit.rs @@ -1465,6 +1465,11 @@ fn a_text_layer_name_follows_its_text_until_renamed() { s.execute("layer.settings", json!({"layer": t, "name": "Settings Name"})).unwrap(); s.execute("layer.setText", json!({"layer": t, "text": "Later"})).unwrap(); assert_eq!(layer_name(&s, t), "Settings Name", "Layer Settings names it for good"); + // An empty Layer Settings name follows the text again, as `layer.rename` does. + s.execute("layer.settings", json!({"layer": t, "name": ""})).unwrap(); + assert_eq!(layer_name(&s, t), "Later", "an empty Layer Settings name shows the text"); + s.execute("layer.setText", json!({"layer": t, "text": "Follows"})).unwrap(); + assert_eq!(layer_name(&s, t), "Follows", "and follows it again"); // A layer given a name when it is made does not follow. let named = s.execute("layer.newText", json!({"text": "Body", "name": "Caption"})).unwrap()["layer"].as_u64().unwrap(); s.execute("layer.setText", json!({"layer": named, "text": "Other"})).unwrap(); diff --git a/docs/agents.md b/docs/agents.md index 27664cb73..ebf158029 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -714,7 +714,10 @@ in the Plug-in Library: [publishing-plugins.md](publishing-plugins.md). * Rename a layer with Enter (or double-click a name): the whole name is selected, Enter or a click elsewhere commits, Escape cancels; `layer.rename {layer, name}` does it directly. A text layer's name follows its text until it is renamed; renaming it to an empty name makes - it follow the text again (After Effects). + it follow the text again (After Effects). `layer.setText` and typing on canvas rename a text + layer whose name still follows its text; `layer.newText {name}` and a name set by + `layer.settings` or a script's `layer.name = …` (which runs `layer.rename`) stay, and an empty + `layer.settings` name follows the text again too. * Ctrl/Cmd-click on the current-time display (Timeline or Composition panel) toggles the Time Display Style between Timecode and Frames: `file.projectSettings {"timeDisplay": "frames"}`. * `prop.pickWhip {layer, prop, target: {layer, prop, dimension?}}` links a property as its From b90950c6a22b49e2c90b1e674c355121c8017679 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 02:32:56 +0100 Subject: [PATCH 12/13] .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); From c8ccf807e1996728bb14d4c3b412a673e1c9673a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 04:53:01 +0100 Subject: [PATCH 13/13] #3233: AEP import names an unnamed text layer as a following name is cut, so export and import agree --- crates/aep/src/import/text.rs | 7 ++++--- crates/aep/tests/it/export/text.rs | 21 +++++++++++++++++++++ docs/aep-import.md | 2 +- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/aep/src/import/text.rs b/crates/aep/src/import/text.rs index d9f75d5db..5333efcb0 100644 --- a/crates/aep/src/import/text.rs +++ b/crates/aep/src/import/text.rs @@ -26,10 +26,11 @@ pub(crate) const MAX_TEXT_CHARS: usize = 1 << 20; /// U+0003 (END OF TEXT): After Effects' other paragraph break besides `\r`. const ETX: char = '\u{3}'; -/// The first paragraph of a stored text, at most 64 characters: the name After Effects shows for -/// a text layer without a name of its own (U+0003 ends a paragraph like `\r`). +/// The name of a text layer without a name of its own: its first paragraph, cut as EffectCraft +/// cuts a following name ([`effectcraft_project::text_layer_name`]), so a following name that +/// export writes as no name comes back unchanged (#3233). U+0003 ends a paragraph like `\r`. pub(crate) fn first_paragraph(raw: &str) -> String { - raw.split(['\r', ETX]).next().unwrap_or_default().chars().take(64).collect() + effectcraft_project::text_layer_name(raw.split(ETX).next().unwrap_or_default()) } /// The Source Text value of `doc`, with what cannot be mapped reported for the layer `what`. diff --git a/crates/aep/tests/it/export/text.rs b/crates/aep/tests/it/export/text.rs index 32e55a269..befc2f47e 100644 --- a/crates/aep/tests/it/export/text.rs +++ b/crates/aep/tests/it/export/text.rs @@ -238,6 +238,27 @@ fn a_text_layer_name_follows_its_text_through_export_and_import() { assert_eq!((l.name.as_str(), l.name_follows_text), ("Text", Some(true)), "an empty text layer"); } +/// #3233: a following name cut from a first line longer than 40 characters is written as no name +/// and comes back as the same cut name, still following; a long name the user gave stays whole. +#[test] +fn a_long_first_line_names_a_text_layer_the_same_after_export_and_import() { + let line = "A headline that runs past forty characters, to 64"; + assert!((41..=64).contains(&line.chars().count())); + let text = format!("{line}\rSecond"); + let cut = effectcraft_project::text_layer_name(&text); + assert_eq!(cut.chars().count(), 40); + for (name, follows, back_name) in [(cut.as_str(), Some(true), cut.as_str()), (line, Some(false), line)] { + let (mut p, c) = text_project(vec![TextDoc::plain(&text)]); + with_layer(&mut p, c, 0, |l| { + l.name = name.into(); + l.name_follows_text = follows; + }); + let back = reimport(&export_all(&p).bytes); + let l = back.comps().flat_map(|(_, c)| c.layers.iter()).find(|l| matches!(l.source, LayerSource::Text)).expect("text layer"); + assert_eq!((l.name.as_str(), l.name_follows_text), (back_name, follows), "{name:?}"); + } +} + #[test] fn what_a_text_document_cannot_hold_is_reported() { let mut d = TextDoc::plain("x"); diff --git a/docs/aep-import.md b/docs/aep-import.md index ed3ab57f5..9320d408c 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -51,7 +51,7 @@ refused losses) leaves the project unchanged. | Project settings | colour depth, working space (None, sRGB, Rec. 709 Gamma 2.4; another one is reported as `project settings`), working gamma, Linearize Working Space, Blend Colors Using 1.0 Gamma, time display (timecode, frames, feet + frames 35 / 16 mm), frame count start (620-634). A colour depth, time display, frames-per-foot or frame count start code outside the sampled ones keeps the current value and is reported as `project settings`. After Effects renders in the working space (902: in a Rec. 709 Gamma 2.4 project an sRGB PNG step of 128 comes out as 135 and a grey 0.5 solid as 128, in the Render Queue output and in the captures), while EffectCraft converts its output to `output_space` (sRGB when unset): a Rec. 709 working space therefore also sets the output space to Rec. 709 (902 within 1 level of After Effects, was up to 13). They come in `ImportResult::settings`; the library never changes the project's own settings, `file.importAep` applies them when the project has no compositions yet (or with `applySettings: true`) | | Composition | name, size, exact frame rate, duration, work area, background colour (8 bit), pixel aspect, composition motion blur switch, shutter angle and phase (as stored), display start time, Classic/Advanced 3D renderer | -| Layers | solid, null, adjustment, footage, precomposition and **text** layers: name (the source's name when the layer has none; for a text layer its first line), in/out/start and time stretch (exact ratios, e.g. 50 % and 150 %); a layer of a clip, sequence or sound ends at its source's end as After Effects ends it (unless remapped, looped or less than a frame past; f1), enabled/audio/solo/shy/locked/effects/motion blur/collapse/3D/guide switches, draft quality, label, blend mode (Normal, Multiply, Add, Screen, Overlay, Difference; Soft Light, Hard Light, Darken, Lighten, Exclusion, Color Dodge, Color Burn by name, 906; Classic Difference, Hue, Saturation, Color, Luminosity, the Stencil and Silhouette modes, Classic Color Dodge / Burn, Linear Dodge / Burn, Linear, Vivid and Pin Light, Hard Mix by render, 907: each code rendered by After Effects matched exactly one EffectCraft mode; 906 renders within 1/255 of After Effects), parent, **track matte** (alpha, alpha inverted, luma, luma inverted; the matte layer anywhere in the composition, also below its target; 402-408) | +| Layers | solid, null, adjustment, footage, precomposition and **text** layers: name (the source's name when the layer has none; for a text layer the first 40 characters of its first line, as EffectCraft names a text layer after its text), in/out/start and time stretch (exact ratios, e.g. 50 % and 150 %); a layer of a clip, sequence or sound ends at its source's end as After Effects ends it (unless remapped, looped or less than a frame past; f1), enabled/audio/solo/shy/locked/effects/motion blur/collapse/3D/guide switches, draft quality, label, blend mode (Normal, Multiply, Add, Screen, Overlay, Difference; Soft Light, Hard Light, Darken, Lighten, Exclusion, Color Dodge, Color Burn by name, 906; Classic Difference, Hue, Saturation, Color, Luminosity, the Stencil and Silhouette modes, Classic Color Dodge / Burn, Linear Dodge / Burn, Linear, Vivid and Pin Light, Hard Mix by render, 907: each code rendered by After Effects matched exactly one EffectCraft mode; 906 renders within 1/255 of After Effects), parent, **track matte** (alpha, alpha inverted, luma, luma inverted; the matte layer anywhere in the composition, also below its target; 402-408) | | Transform | Anchor Point, Position, Scale, Rotation, Opacity, **X and Y Rotation** (486, 487) and **Orientation**, static (488) or keyed (703: key values, times, linear and hold sides; a bezier side becomes linear and is reported; After Effects slerps between the keys, EffectCraft interpolates each angle, so in-between values differ, e.g. 703 at 1 s `[6.26, 9.35, 15.22]` against `[5, 10, 15]`; an expression-driven orientation is reported): the stored value, or the default when the file stores none; keyframes with time, linear/bezier/hold on each side, temporal ease (speed and influence, per dimension for Scale), automatic or manual spatial tangents, roving and key labels | | Expressions | on the transform and mask properties and on effect, camera and light parameters (534): source text and enable switch | | Cameras and lights | **cameras** (one-node and two-node, from `ldta` byte 38 bit `0x40`): point of interest, Position as stored (also the auto Position `[0, 0, -zoom]`, where After Effects renders it, 537), orientation and rotations, Zoom (absent: `-z` of the auto Position, else the 50 mm default), Depth of Field, Focus Distance (absent: the zoom), Aperture (absent: 25.309 at 1920 px, read back from 256; scaled with the width, which is not sampled, E); **spot, point, parallel and ambient lights** (light type 1, 2, 0, 3: 701, 702): point of interest (not for point and ambient lights), Position, Intensity, Color, Cone Angle, Cone Feather, Casts Shadows; keys and expressions as for the transform; 251-256, 341-345, 535-537, 701, 702. Environment lights are reported |