diff --git a/crates/aep/src/container/effect_map.rs b/crates/aep/src/container/effect_map.rs index a7c9bbd48..312332e8f 100644 --- a/crates/aep/src/container/effect_map.rs +++ b/crates/aep/src/container/effect_map.rs @@ -134,6 +134,9 @@ pub enum Extra { Curves, /// Mesh Warp's Distortion Mesh (965, 985, `MeshData`). MeshWarp, + /// Lumetri Color: every parameter goes through its own table, keyed by match name + /// (`import/lumetri.rs`, 1200). + Lumetri, } /// One mapped effect. @@ -509,6 +512,9 @@ pub const EFFECTS: &[EffectMap] = &[ ], extra: Extra::MeshWarp, }, + // Lumetri Color (1200): the parameters are mapped by match name, not by `parT` index + // (`import/lumetri.rs`). + EffectMap { match_name: "ADBE Lumetri", effect: "ec.color.lumetri", params: &[], extra: Extra::Lumetri }, // Expression controls (070-077, 217-221, 310). // Directional Blur (711): Direction in degrees, Blur Length in pixels (valid max 1 000). fx( diff --git a/crates/aep/src/container/effects.rs b/crates/aep/src/container/effects.rs index df1c2b9c1..597710587 100644 --- a/crates/aep/src/container/effects.rs +++ b/crates/aep/src/container/effects.rs @@ -375,11 +375,24 @@ impl<'f, 'a> EffectParam<'f, 'a> { /// colours, popups and layer controls. A record counts when its `tdsb` is 1 and it has no /// keyframes; a layer control reads the `tdpi` chunk; a button has no value. pub fn stored_value(&self) -> Option { + let p = self.stored?; + if self.kind != ParamKind::Layer && p.flags() != Some(1) { + return None; + } + self.recorded_value() + } + + /// The value in the value record whatever the record's state (`tdsb`): what + /// [`EffectParam::stored_value`] reads, also from a record in the default state (`tdsb` 3). + /// Lumetri Color writes the values of its hidden parameters so (High Dynamic Range, HDR + /// Specular, Look: 1200 `lumetri-01-set-all`, `-02-sec-top`, `-06-sec-light`; After Effects + /// reads back the recorded values). `None` with keyframes or without a record. + pub fn recorded_value(&self) -> Option { let p = self.stored?; if self.kind == ParamKind::Layer { return be_u32(p.chunk().child(b"tdpi")?.bytes()?, 0).map(ParamValue::Layer); } - if p.flags() != Some(1) || p.is_animated() { + if p.is_animated() { return None; } let v = p.static_value()?; diff --git a/crates/aep/src/import.rs b/crates/aep/src/import.rs index 1ffe3ec48..eb4eee8c8 100644 --- a/crates/aep/src/import.rs +++ b/crates/aep/src/import.rs @@ -27,6 +27,7 @@ mod build; mod camera; mod effects; +mod lumetri; mod markers; mod masks; mod props; diff --git a/crates/aep/src/import/effects.rs b/crates/aep/src/import/effects.rs index 477a25df4..2ef912e00 100644 --- a/crates/aep/src/import/effects.rs +++ b/crates/aep/src/import/effects.rs @@ -34,7 +34,7 @@ //! import. Hue/Saturation's master values and Levels' values are arbitrary data, decoded by the //! reader (444-446, 512-516). -use crate::effect_map::{Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, popup_key}; +use crate::effect_map::{Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, ParamMap, popup_key}; use crate::{ CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, HueSaturationData, Layer as AepLayer, LayerRef, LevelsData, MeshData, ParamKind, ParamValue, @@ -99,8 +99,12 @@ pub(crate) fn import_effects<'f, 'a>( None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)), } let params = defs.params(&fx); - for p in ¶ms { - import_param(&fx, map, p, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col }); + if map.extra == Extra::Lumetri { + crate::import::lumetri::import_lumetri(&fx, ¶ms, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col }); + } else { + for p in ¶ms { + import_param(&fx, map, p, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col }); + } } col.imported.effects = col.imported.effects.saturating_add(1); target.children.push(Node::Group(group)); @@ -108,7 +112,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) } @@ -127,10 +131,15 @@ pub(crate) struct PendingLayerRef { /// Where a parameter is imported to: the effect's group, the layer parameters still to resolve /// and the report. -struct ParamOut<'a> { - group: &'a mut PropGroup, - refs: &'a mut Vec, - col: &'a mut Collector, +pub(crate) struct ParamOut<'a> { + pub group: &'a mut PropGroup, + pub refs: &'a mut Vec, + pub col: &'a mut Collector, +} + +/// How a parameter is named in the report: its name and match name. +pub(crate) fn param_label(p: &EffectParam<'_, '_>) -> String { + if p.name.trim().is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name.trim(), p.match_name) } } fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>, cx: &LayerCtx<'_>, shown: &str, out: ParamOut<'_>) { @@ -138,7 +147,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_> if carries_nothing(p) { return; } - let label = if p.name.is_empty() { p.match_name.clone() } else { format!("{} ({})", p.name, p.match_name) }; + let label = param_label(p); let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()); if p.kind == ParamKind::ArbitraryData && map.extra != Extra::None { import_extra(fx, map.extra, p, &here, cx.size, group, col); @@ -152,6 +161,27 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_> } return; }; + import_mapped(p, m, cx, (&here, &label), ParamOut { group, refs, col }, ValueRecord::Written); +} + +/// Which value records count. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum ValueRecord { + /// Only written records (`tdsb` 1, [`EffectParam::stored_value`]). + Written, + /// Every record, also one in the default state (Lumetri Color, [`EffectParam::recorded_value`]). + Any, +} + +/// Imports parameter `p` into the property `m` names, converted by `m.conv`. `names` is +/// `(here, label)`, the effect and the parameter for the report. +pub(crate) fn import_mapped(p: &EffectParam<'_, '_>, m: &ParamMap, cx: &LayerCtx<'_>, names: (&str, &str), out: ParamOut<'_>, records: ValueRecord) { + let (here, label) = names; + let ParamOut { group, refs, col } = out; + let read = |p: &EffectParam<'_, '_>| match records { + ValueRecord::Written => p.stored_value(), + ValueRecord::Any => p.recorded_value(), + }; if p.kind != m.kind { col.lose(LossCategory::Effects, format!("{here}: parameter {label} has type {:?}, expected {:?}; not imported", p.kind, m.kind)); return; @@ -159,7 +189,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_> match m.conv { Conv::Skip => return, Conv::LayerRef { own } => { - import_layer_ref(p, m.target, own, cx, (&here, &label), ParamOut { group, refs, col }); + import_layer_ref(p, m.target, own, cx, (here, label), ParamOut { group, refs, col }); return; } _ => {} @@ -264,12 +294,12 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_> col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} has no readable keyframe, default used")); } if let Some(map) = &popup_map { - remap_popup_keys(prop, map, &here, &label, col); + remap_popup_keys(prop, map, here, label, col); } } return; } - let value = match (p.stored(), p.stored_value()) { + let value = match (p.stored(), read(p)) { (Some(_), Some(v)) => Some(v), (Some(_), None) => { col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds an unreadable value, default used")); @@ -451,7 +481,7 @@ 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::None => {} + Extra::None | Extra::Lumetri => {} } } diff --git a/crates/aep/src/import/lumetri.rs b/crates/aep/src/import/lumetri.rs new file mode 100644 index 000000000..22f28b1aa --- /dev/null +++ b/crates/aep/src/import/lumetri.rs @@ -0,0 +1,642 @@ +//! Lumetri Color (`ADBE Lumetri`) to EffectCraft's Lumetri Color (`ec.color.lumetri`). +//! +//! The parameters are found by their match name (`ADBE Lumetri-0011` is Exposure), not by their +//! place in `parT`: After Effects has added parameters in the middle of the list over the +//! versions (`-0127` ... `-0130` sit between older ones), and several sliders share a valid range +//! (Contrast, Highlights, Shadows, Whites and Blacks are all -150..150), so a shifted index +//! would map silently to the wrong control. Evidence: the 1200 samples (After Effects 2026, +//! `docs/aep-format.md` 3.7). +//! +//! - **Values in any record state.** Lumetri writes the values of its hidden controls (High +//! Dynamic Range, HDR Specular, the second Look popup, ...) in records in the default state +//! (`tdsb` 3); After Effects reads them back as set, so every record counts here +//! ([`EffectParam::recorded_value`]). +//! - **Section switches.** Each section (Basic Correction, Creative, Curves, RGB Curves, the five +//! hue / saturation curves, Color Wheels, HSL Secondary, Vignette) has an on / off checkbox in +//! After Effects; EffectCraft's Lumetri has none. A section that is off does nothing in After +//! Effects (1200 `lumetri-03-sec-basic-correction`, `-17-sec-vignette` render as `-00-defaults`), +//! so its values are not imported (the effect looks the same) and are listed in the report. +//! - **Arbitrary data** (`ParamKind::ArbitraryData`, every instance stores it): the hue / +//! saturation curves (a curve type, a point count, points as `f64` x, y with y an offset from +//! neutral) are imported when neutral and reported otherwise (no sample moves a point); the +//! HSL Secondary key ([`HslKey`]) is imported; the RGB curves, the colour wheels, Split Toning +//! and the correction wheels are reported when they are not the default (not sampled). +//! - **LUTs and looks.** The Input LUT and the Look are files After Effects names (the Look's name +//! is UTF-16 text in `-0096`, the popups only index a list of the installed ones); they are not +//! in the project, so a set one is reported by name and EffectCraft's popup stays None. +//! - **Eyedropper colours** (White Balance, the hue curve selectors, the key's Set / Add / +//! Remove colours) only set other controls in After Effects; they are reported when set. +//! - The `Blob` (`-0001`, an XML copy of the grade After Effects writes when a Look changes and does +//! not keep current: 1200 `-01-set-all` holds the defaults of controls set after the Look) and +//! the Auto Tone analysis (`-0121`, `-0122`) are derived data and not read. + +use crate::effect_map::{Conv, ParamMap}; +use crate::{Effect as AepEffect, EffectParam, ParamKind, ParamValue}; +use effectcraft_keyframe::Value; + +use crate::import::effects::{LayerCtx, ParamOut, ValueRecord, carries_nothing, import_mapped, param_label}; +use crate::import::report::LossCategory; +use ParamKind as K; + +/// After Effects' Show Mask styles (`-0075`, `pdnm`), EffectCraft's in the same order. +pub const MASK_STYLE: &[&str] = &["Color/Gray", "Color/Black", "White/Black"]; + +/// A part of the effect with its own on / off switch. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Section { + /// No switch (High Dynamic Range and the controls outside the sections). + Always, + BasicCorrection, + Creative, + RgbCurves, + /// The five hue / saturation curves (0 Hue vs Sat ... 4 Sat vs Sat). + HueSat(u8), + ColorWheels, + HslSecondary, + Vignette, + SpeedGradeCustom, +} + +impl Section { + fn name(self) -> &'static str { + match self { + Section::Always => "", + Section::BasicCorrection => "Basic Correction", + Section::Creative => "Creative", + Section::RgbCurves => "RGB Curves", + Section::HueSat(0) => "Hue vs Sat", + Section::HueSat(1) => "Hue vs Hue", + Section::HueSat(2) => "Hue vs Luma", + Section::HueSat(3) => "Luma vs Sat", + Section::HueSat(_) => "Sat vs Sat", + Section::ColorWheels => "Color Wheels", + Section::HslSecondary => "HSL Secondary", + Section::Vignette => "Vignette", + Section::SpeedGradeCustom => "SpeedGrade Custom", + } + } +} + +/// Arbitrary data the importer reads. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Data { + /// The four RGB curves (4 x 520 bytes). + RgbCurves, + /// One hue / saturation curve (520 bytes); the EffectCraft parameter it belongs to. + HueSatCurve, + /// Color Wheels (4 x 3 `f64`, each wheel 0, 0.5, 0 by default). + Wheels, + /// HSL Secondary's correction wheels (8 x 3 `f64`, the same default). + CorrectionWheels, + /// Split Toning (32 zero bytes by default). + SplitToning, + /// HSL Secondary's key ([`HslKey`]). + HslKey, + /// Data without a counterpart, empty by default (`fe fe` or zero bytes). + Other, +} + +/// What a Lumetri parameter is. +#[derive(Clone, Copy, Debug)] +enum Role { + /// An EffectCraft parameter and the conversion. + To(&'static str, Conv), + /// The on / off switch of a section. + Switch, + /// No counterpart: reported when not at the default. + Missing, + /// An eyedropper colour: reported when set. + Eyedropper, + /// The Input LUT (`lut` true) or the Look: a popup, a name or an asset. + Lut { + lut: bool, + }, + /// The Look's colour space (its popup and the space's id): goes with the Look. + LookSpace, + Data(Data), + /// Derived data, not read. + Derived, +} + +struct Row { + suffix: u16, + section: Section, + kind: Option, + role: Role, +} + +const fn row(suffix: u16, section: Section, kind: Option, role: Role) -> Row { + Row { suffix, section, kind, role } +} + +const fn to(suffix: u16, section: Section, kind: ParamKind, target: &'static str, conv: Conv) -> Row { + row(suffix, section, Some(kind), Role::To(target, conv)) +} + +const fn plain(valid_max: f64) -> Conv { + Conv::Plain { valid_max } +} + +use Section as S; + +/// Every Lumetri parameter by match-name suffix (1200: `dump.props` and the `pard` of +/// `lumetri-00-defaults`). The valid maxima are After Effects 2026's; the stored numbers are the +/// values After Effects shows (read back in every variant). +const ROWS: &[Row] = &[ + to(62, S::Always, K::Checkbox, "highDynamicRange", Conv::Checkbox), + // Basic Correction: 3 the switch; 4 / 95 the Input LUT's name data, 5 / 127 its popups. + row(3, S::BasicCorrection, Some(K::Checkbox), Role::Switch), + row(4, S::BasicCorrection, Some(K::ArbitraryData), Role::Lut { lut: true }), + row(95, S::BasicCorrection, Some(K::ArbitraryData), Role::Lut { lut: true }), + row(5, S::BasicCorrection, Some(K::Popup), Role::Lut { lut: true }), + row(127, S::BasicCorrection, Some(K::Popup), Role::Lut { lut: true }), + to(63, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/hdrWhite", plain(1000.0)), + // The Auto tone's Intensity (next to the Auto and Reset buttons 120, 119). + row(123, S::BasicCorrection, Some(K::FloatSlider), Role::Missing), + row(94, S::BasicCorrection, Some(K::Color), Role::Eyedropper), + to(7, S::BasicCorrection, K::FloatSlider, "basicCorrection/whiteBalance/temperature", plain(300.0)), + to(8, S::BasicCorrection, K::FloatSlider, "basicCorrection/whiteBalance/tint", plain(300.0)), + to(20, S::BasicCorrection, K::FloatSlider, "basicCorrection/saturation", plain(300.0)), + to(11, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/exposure", plain(7.0)), + to(12, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/contrast", plain(150.0)), + to(13, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/highlights", plain(150.0)), + to(14, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/shadows", plain(150.0)), + to(15, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/whites", plain(150.0)), + to(16, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/blacks", plain(150.0)), + to(64, S::BasicCorrection, K::FloatSlider, "basicCorrection/tone/hdrSpecular", plain(150.0)), + // Creative: 23 the switch; 24 / 96 the Look's name, 25 / 128 its popups, 129 / 130 its colour + // space. + row(23, S::Creative, Some(K::Checkbox), Role::Switch), + row(24, S::Creative, Some(K::ArbitraryData), Role::Lut { lut: false }), + row(96, S::Creative, Some(K::ArbitraryData), Role::Lut { lut: false }), + row(25, S::Creative, Some(K::Popup), Role::Lut { lut: false }), + row(128, S::Creative, Some(K::Popup), Role::Lut { lut: false }), + row(129, S::Creative, Some(K::ArbitraryData), Role::LookSpace), + row(130, S::Creative, Some(K::Popup), Role::LookSpace), + to(26, S::Creative, K::FloatSlider, "creative/lookIntensity", plain(200.0)), + to(28, S::Creative, K::FloatSlider, "creative/adjustments/fadedFilm", plain(150.0)), + to(29, S::Creative, K::FloatSlider, "creative/adjustments/sharpen", plain(100.0)), + to(30, S::Creative, K::FloatSlider, "creative/adjustments/vibrance", plain(100.0)), + to(31, S::Creative, K::FloatSlider, "creative/adjustments/creativeSaturation", plain(300.0)), + row(32, S::Creative, Some(K::ArbitraryData), Role::Data(Data::SplitToning)), + to(33, S::Creative, K::FloatSlider, "creative/adjustments/tintBalance", plain(150.0)), + // Curves: 37 switches all curves, 118 the RGB curves, 104 ... 111 each hue / saturation curve. + row(37, S::RgbCurves, Some(K::Checkbox), Role::Switch), + row(118, S::RgbCurves, Some(K::Checkbox), Role::Switch), + row(65, S::RgbCurves, Some(K::FloatSlider), Role::Missing), + row(39, S::RgbCurves, Some(K::ArbitraryData), Role::Data(Data::RgbCurves)), + row(104, S::HueSat(0), Some(K::Checkbox), Role::Switch), + row(113, S::HueSat(0), Some(K::Color), Role::Eyedropper), + row(42, S::HueSat(0), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + row(105, S::HueSat(1), Some(K::Checkbox), Role::Switch), + row(114, S::HueSat(1), Some(K::Color), Role::Eyedropper), + row(106, S::HueSat(1), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + row(107, S::HueSat(2), Some(K::Checkbox), Role::Switch), + row(115, S::HueSat(2), Some(K::Color), Role::Eyedropper), + row(108, S::HueSat(2), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + row(109, S::HueSat(3), Some(K::Checkbox), Role::Switch), + row(116, S::HueSat(3), Some(K::Color), Role::Eyedropper), + row(110, S::HueSat(3), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + row(111, S::HueSat(4), Some(K::Checkbox), Role::Switch), + row(117, S::HueSat(4), Some(K::Color), Role::Eyedropper), + row(112, S::HueSat(4), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve)), + // Color Wheels: 46 the switch, 102 Face Detection (on by default), 66 HDR White, 47 the wheels. + row(46, S::ColorWheels, Some(K::Checkbox), Role::Switch), + row(102, S::ColorWheels, Some(K::Checkbox), Role::Missing), + row(66, S::ColorWheels, Some(K::FloatSlider), Role::Missing), + row(47, S::ColorWheels, Some(K::ArbitraryData), Role::Data(Data::Wheels)), + // HSL Secondary: 68 the switch; the key (70-76), Refine (81, 82), Correction (85-90). + row(68, S::HslSecondary, Some(K::Checkbox), Role::Switch), + row(70, S::HslSecondary, Some(K::Color), Role::Eyedropper), + row(71, S::HslSecondary, Some(K::Color), Role::Eyedropper), + row(72, S::HslSecondary, Some(K::Color), Role::Eyedropper), + row(73, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::HslKey)), + to(74, S::HslSecondary, K::Checkbox, "hslSecondary/key/showMask", Conv::Checkbox), + to(75, S::HslSecondary, K::Popup, "hslSecondary/key/maskStyle", Conv::Popup { default: 1, items: MASK_STYLE }), + to(76, S::HslSecondary, K::Checkbox, "hslSecondary/key/invertMask", Conv::Checkbox), + to(81, S::HslSecondary, K::FloatSlider, "hslSecondary/refine/denoise", plain(100.0)), + to(82, S::HslSecondary, K::FloatSlider, "hslSecondary/refine/blur", plain(1000.0)), + row(85, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::CorrectionWheels)), + // The correction wheel's mode (4 zero bytes by default). + row(93, S::HslSecondary, Some(K::ArbitraryData), Role::Data(Data::Other)), + to(86, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/temperature", plain(300.0)), + to(87, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/tint", plain(300.0)), + to(88, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/contrast", plain(150.0)), + to(89, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/sharpen", plain(100.0)), + to(90, S::HslSecondary, K::FloatSlider, "hslSecondary/correction/saturation", plain(300.0)), + // Vignette: 50 the switch. + row(50, S::Vignette, Some(K::Checkbox), Role::Switch), + to(51, S::Vignette, K::FloatSlider, "vignette/vignetteAmount", plain(5.0)), + to(52, S::Vignette, K::FloatSlider, "vignette/vignetteMidpoint", plain(100.0)), + to(53, S::Vignette, K::FloatSlider, "vignette/vignetteRoundness", plain(100.0)), + to(54, S::Vignette, K::FloatSlider, "vignette/vignetteFeather", plain(100.0)), + // SpeedGrade Custom: 57 Custom Layer (on by default), 60 its data. + row(57, S::SpeedGradeCustom, Some(K::Checkbox), Role::Switch), + row(60, S::SpeedGradeCustom, Some(K::ArbitraryData), Role::Data(Data::Other)), + // Outside the sections: Legacy Tone And Saturation, the embedded LUTs, Auto Tone, Auto Color, + // the LUT and Look assets. + row(97, S::Always, Some(K::Checkbox), Role::Missing), + row(98, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)), + row(99, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)), + row(100, S::Always, Some(K::ArbitraryData), Role::Data(Data::Other)), + row(124, S::Always, Some(K::Checkbox), Role::Missing), + row(125, S::Always, Some(K::ArbitraryData), Role::Lut { lut: true }), + row(126, S::Always, Some(K::ArbitraryData), Role::Lut { lut: false }), + row(1, S::Always, Some(K::ArbitraryData), Role::Derived), + row(121, S::Always, Some(K::FloatSlider), Role::Derived), + row(122, S::Always, Some(K::ArbitraryData), Role::Derived), +]; + +/// The names After Effects shows for the parameters whose `pard` name is blank (`dump.props`). +fn blank_name(suffix: u16) -> Option<&'static str> { + Some(match suffix { + 62 => "High Dynamic Range", + 3 | 23 | 37 | 118 | 104 | 105 | 107 | 109 | 111 | 46 | 68 | 50 => "Active", + 4 | 95 => "Input LUT data", + 24 | 96 => "Look name", + 102 => "Face Detection", + 74 => "Show Mask", + 75 => "Mask style", + 76 => "Invert Mask", + 97 => "Legacy Tone And Saturation", + 124 => "Auto Color Active", + _ => return None, + }) +} + +/// The parameter's name for the report: its own, else the one After Effects shows. +fn name_of(p: &EffectParam<'_, '_>) -> String { + match (p.name.trim(), suffix(&p.match_name).and_then(blank_name)) { + ("", Some(n)) => n.to_string(), + ("", None) => p.match_name.clone(), + (n, _) => n.to_string(), + } +} + +/// The match-name suffix of a Lumetri parameter (`ADBE Lumetri-0011` is 11). +fn suffix(match_name: &str) -> Option { + match_name.strip_prefix("ADBE Lumetri-")?.parse().ok() +} + +/// The parameter's value: the recorded one, else the `pard` default. +fn value_of(p: &EffectParam<'_, '_>) -> Option { + p.recorded_value().or_else(|| p.pard_default()) +} + +/// True when the parameter has keyframes, an expression, or a recorded value other than its +/// default (a record without a readable default counts as set). +fn is_set(p: &EffectParam<'_, '_>) -> bool { + if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) { + return true; + } + match (p.recorded_value(), p.pard_default()) { + (Some(v), Some(d)) => !same(v, d), + (Some(_), None) => true, + (None, _) => false, + } +} + +/// Equal within the float noise of a stored `f64` / `f32`. +fn same(a: ParamValue, b: ParamValue) -> bool { + let close = |x: f64, y: f64| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0); + match (a, b) { + (ParamValue::Number(x), ParamValue::Number(y)) => close(x, y), + (ParamValue::Argb(x), ParamValue::Argb(y)) => x.iter().zip(y).skip(1).all(|(x, y)| close(*x, y)), + _ => a == b, + } +} + +/// A value as the report shows it. +fn shown_value(v: Option) -> String { + match v { + Some(ParamValue::Number(n)) => { + let s = format!("{n:.3}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() + } + Some(ParamValue::Bool(b)) => if b { "on" } else { "off" }.to_string(), + Some(ParamValue::Index(i)) => format!("item {i}"), + Some(ParamValue::Argb([_, r, g, b])) => format!("RGB {:.3} {:.3} {:.3}", r, g, b), + Some(other) => format!("{other:?}"), + None => "set".to_string(), + } +} + +/// Empty data: absent, `fe fe` (no text, no asset) or only zero bytes. +fn is_empty_data(d: Option<&[u8]>) -> bool { + match d { + None => true, + Some(d) => d == [0xfe, 0xfe] || d.iter().all(|b| *b == 0), + } +} + +fn le_u32(d: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?)) +} + +fn le_f32(d: &[u8], at: usize) -> Option { + Some(f32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?)) +} + +fn le_f64(d: &[u8], at: usize) -> Option { + Some(f64::from_le_bytes(*d.get(at..at.checked_add(8)?)?.first_chunk::<8>()?)) +} + +/// A UTF-16LE text up to its NUL (the Look's name, `-0096`: `SL BLUE ICE` in 1200 `-01-set-all`); +/// `None` for empty data. +fn utf16_text(d: &[u8]) -> Option { + if is_empty_data(Some(d)) { + return None; + } + let units: Vec = d.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).take_while(|u| *u != 0).collect(); + let s = String::from_utf16_lossy(&units); + let s = s.trim(); + (!s.is_empty()).then(|| s.to_string()) +} + +/// A Lumetri curve (520 bytes): `i32` curve type at 0 (3 for the hue curves, which wrap; 2 for +/// Luma vs Sat and Sat vs Sat; 0 for an RGB curve), the `u32` point count at 4, then the points +/// as little-endian `f64` pairs x, y from byte 8 (at most 32). `None` when the count does not fit. +fn curve_points(d: &[u8]) -> Option> { + let n = usize::try_from(le_u32(d, 4)?).ok()?; + if n > d.len().saturating_sub(8) / 16 { + return None; + } + (0..n).map(|i| Some((le_f64(d, 8 + 16 * i)?, le_f64(d, 16 + 16 * i)?))).collect() +} + +/// HSL Secondary's key (`-0073`, 40 bytes): a flag byte (1 once a key colour was picked, 0 for a +/// fresh effect), then hue, saturation and lightness, each three little-endian `f32` (centre, +/// range, tolerance; fractions of the full hue circle / of 0..1) and an enabled byte. A fresh effect +/// stores flag 0 and centre 0.5, range 0, tolerance 0, enabled for each (1200 `-00-defaults`); +/// After Effects' own copy in `-0001` names the same fields (`middleHLS`, `rangeHLS`, +/// `toleranceHLS`, `enableHLS`, hue in degrees: centre 180). Picking Set color RGB 0.02 0.55 0.85 +/// gives hue centre 0.5605 (that colour's hue, 201.8 degrees) and lightness centre 0.435 (its +/// HSL lightness) with range 0.05 and tolerance 0.1 (`-01-set-all`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HslKey { + pub picked: bool, + /// Hue, saturation, lightness: centre, range, tolerance, enabled. + pub channels: [(f32, f32, f32, bool); 3], +} + +impl HslKey { + pub fn parse(d: &[u8]) -> Option { + if d.len() != 40 { + return None; + } + let picked = match d.first()? { + 0 => false, + 1 => true, + _ => return None, + }; + let ch = |at: usize| -> Option<(f32, f32, f32, bool)> { + let (c, r, t) = (le_f32(d, at)?, le_f32(d, at + 4)?, le_f32(d, at + 8)?); + let on = match d.get(at + 12)? { + 0 => false, + 1 => true, + _ => return None, + }; + (c.is_finite() && r.is_finite() && t.is_finite()).then_some((c, r, t, on)) + }; + Some(HslKey { picked, channels: [ch(1)?, ch(14)?, ch(27)?] }) + } + + fn is_default(&self) -> bool { + !self.picked && self.channels.iter().all(|c| *c == (0.5, 0.0, 0.0, true)) + } +} + +/// The colour wheel data: triples of `f64`, each wheel `0, 0.5, 0` by default. +fn wheels_default(d: &[u8], wheels: usize) -> bool { + d.len() == wheels * 24 && (0..wheels).all(|w| le_f64(d, w * 24) == Some(0.0) && le_f64(d, w * 24 + 8) == Some(0.5) && le_f64(d, w * 24 + 16) == Some(0.0)) +} + +/// Is the data of `data` at After Effects' default (as far as it can be told)? +fn data_default(data: Data, d: Option<&[u8]>) -> bool { + let Some(d) = d else { return true }; + match data { + Data::RgbCurves => { + d.len() == 4 * 520 && d.as_chunks::<520>().0.iter().all(|c| curve_points(c).is_some_and(|pts| pts.iter().all(|(x, y)| (x - y).abs() < 1e-9))) + } + Data::HueSatCurve => curve_points(d).is_some_and(|pts| pts.iter().all(|(_, y)| *y == 0.0)), + Data::Wheels => wheels_default(d, 4), + Data::CorrectionWheels => wheels_default(d, 8), + Data::SplitToning | Data::Other => is_empty_data(Some(d)), + Data::HslKey => HslKey::parse(d).is_some_and(|k| k.is_default()), + } +} + +/// What the Input LUT or the Look holds: names, a popup item, an asset. +#[derive(Default)] +struct LutState { + names: Vec, + item: Option, + asset: bool, +} + +/// How a set parameter of a section that does nothing is listed in the report; `None` when it is +/// at its default. +fn off_text(p: &EffectParam<'_, '_>, role: Role, data: Option<&[u8]>) -> Option { + match role { + Role::Data(d) => (!data_default(d, data)).then(|| name_of(p)), + // A LUT by its name (the popups index the same choice). + Role::Lut { lut } if p.kind == K::ArbitraryData => { + let what = if lut { "Input LUT" } else { "Look" }; + data.and_then(utf16_text).map(|n| format!("{what} '{n}'")).or_else(|| (!is_empty_data(data)).then(|| what.to_string())) + } + // The Look's colour space goes with the Look. + Role::Lut { .. } | Role::LookSpace | Role::Derived => None, + _ => is_set(p).then(|| format!("{} {}", name_of(p), shown_value(p.recorded_value()))), + } +} + +/// Notes what an Input LUT or Look parameter holds: a popup item, an asset or a name. +fn note_lut(p: &EffectParam<'_, '_>, suffix: u16, data: Option<&[u8]>, st: &mut LutState) { + if p.kind == K::Popup { + // The popup indexes the LUTs installed with After Effects; item 1 is None. + if let Some(ParamValue::Index(i)) = p.recorded_value() + && i > 1 + { + st.item = usize::try_from(i).ok().and_then(|i| p.items.as_ref()?.get(i - 1).cloned()).or(Some(format!("item {i}"))); + } + } else if suffix == 125 || suffix == 126 { + st.asset |= !is_empty_data(data); + } else if let Some(name) = data.and_then(utf16_text) + && !st.names.contains(&name) + { + st.names.push(name); + } +} + +/// Imports a Lumetri Color instance's parameters into `out.group` and reports what does not arrive. +pub(crate) fn import_lumetri(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, '_>], cx: &LayerCtx<'_>, shown: &str, out: ParamOut<'_>) { + let ParamOut { group, refs, col } = out; + let here = format!("{}: '{}' effect '{shown}' ({})", cx.comp, cx.layer, fx.match_name()); + let find = |s: u16| params.iter().find(|p| suffix(&p.match_name) == Some(s)); + // The switches: on unless a record says off. Keys or an expression on a switch are not + // imported (the static value counts). + let switch_on = |s: u16| -> bool { + match find(s) { + Some(p) => !matches!(value_of(p), Some(ParamValue::Bool(false))), + None => true, + } + }; + // HSL Secondary without a picked key colour selects nothing: After Effects applies none of + // its correction (1200 `-16-sec-correction`, `-15-sec-refine` render as `-00-defaults`); + // EffectCraft's default key is the whole image, so the values are left out too. + // Unreadable or missing key data does not count as unpicked (it is reported or older). + let key_picked = find(73).and_then(|p| fx.arbitrary_data(&p.match_name)).and_then(HslKey::parse).is_none_or(|k| k.picked); + let section_on = |sec: Section| -> bool { + match sec { + Section::Always => true, + Section::BasicCorrection => switch_on(3), + Section::Creative => switch_on(23), + Section::RgbCurves => switch_on(37) && switch_on(118), + Section::HueSat(i) => switch_on(37) && switch_on([104, 105, 107, 109, 111].get(usize::from(i)).copied().unwrap_or(104)), + Section::ColorWheels => switch_on(46), + Section::HslSecondary => switch_on(68) && key_picked, + Section::Vignette => switch_on(50), + // Custom Layer off is itself reported (a switch without counterpart). + Section::SpeedGradeCustom => true, + } + }; + let mut off: Vec<(Section, Vec)> = Vec::new(); + let mut eyedroppers: Vec = Vec::new(); + let mut luts = [LutState::default(), LutState::default()]; + for p in params { + if carries_nothing(p) || p.match_name == "ADBE Effect Built In Params" { + continue; + } + let label = if p.name.trim().is_empty() { format!("{} ({})", name_of(p), p.match_name) } else { param_label(p) }; + let Some(row) = suffix(&p.match_name).and_then(|s| ROWS.iter().find(|r| r.suffix == s)) else { + // A parameter this table does not know (a newer version): lost when set. + if p.stored().is_some() + && (p.kind != K::ArbitraryData && is_set(p) || p.kind == K::ArbitraryData && !is_empty_data(fx.arbitrary_data(&p.match_name))) + { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} not imported")); + } + continue; + }; + if row.kind.is_some_and(|k| k != p.kind) { + if p.stored().is_some() { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} has type {:?}, expected {:?}; not imported", p.kind, row.kind)); + } + continue; + } + let data = || fx.arbitrary_data(&p.match_name); + // A switched-off section: its set values are listed, not imported. + if !matches!(row.role, Role::Switch) && !section_on(row.section) { + let text = off_text(p, row.role, data()); + if let Some(text) = text { + match off.iter_mut().find(|(s, _)| *s == row.section) { + Some((_, list)) => { + if !list.contains(&text) { + list.push(text); + } + } + None => off.push((row.section, vec![text])), + } + } + continue; + } + match row.role { + Role::To(target, conv) => { + let m = ParamMap { index: p.index, kind: p.kind, target, conv }; + import_mapped(p, &m, cx, (&here, &label), ParamOut { group: &mut *group, refs: &mut *refs, col: &mut *col }, ValueRecord::Any); + } + Role::Switch => { + if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) { + col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on the switch {label} are not imported")); + } + if row.section == Section::SpeedGradeCustom && !switch_on(row.suffix) { + col.lose(LossCategory::Effects, format!("{here}: parameter {label} is off and has no counterpart")); + } + } + Role::Missing => { + if is_set(p) { + col.lose( + LossCategory::Effects, + format!("{here}: parameter {label} is {} and has no counterpart, not imported", shown_value(p.recorded_value())), + ); + } + } + Role::Eyedropper => { + if is_set(p) { + eyedroppers.push(name_of(p)); + } + } + Role::Lut { lut } => note_lut(p, row.suffix, data(), &mut luts[usize::from(!lut)]), + // The Look's colour space only applies to a Look, which is reported. + Role::LookSpace => {} + Role::Data(Data::HslKey) => import_hsl_key(data(), &here, &mut *group, &mut *col), + Role::Data(d) => { + if p.is_animated() || p.stored().is_some_and(|s| s.expression().is_some()) { + col.lose(LossCategory::Effects, format!("{here}: keyframes or an expression on {label} are not imported")); + } + if !data_default(d, data()) { + let why = match d { + Data::HueSatCurve => "the curve's points are moved; no sample tells their scale", + Data::RgbCurves | Data::Wheels | Data::CorrectionWheels | Data::SplitToning => "the data is not decoded (not sampled)", + Data::HslKey | Data::Other => "no counterpart", + }; + col.lose(LossCategory::Effects, format!("{here}: parameter {label} is not imported ({why})")); + } + } + Role::Derived => {} + } + } + for (sec, list) in off { + let why = if sec == Section::HslSecondary && switch_on(68) { + "has no key colour picked (After Effects applies it to nothing)" + } else { + "is switched off (EffectCraft's Lumetri has no section switch)" + }; + col.lose(LossCategory::Effects, format!("{here}: section {} {why}, its values are not imported: {}", sec.name(), list.join(", "))); + } + if !eyedroppers.is_empty() { + col.lose(LossCategory::Effects, format!("{here}: the eyedropper colours {} are not imported (they only set other controls)", eyedroppers.join(", "))); + } + for (st, what) in luts.iter().zip(["Input LUT", "Look"]) { + let name = st.names.first().cloned().or_else(|| st.item.clone()); + if name.is_some() || st.asset { + let name = name.map(|n| format!(" '{n}'")).unwrap_or_default(); + col.lose( + LossCategory::Effects, + format!("{here}: the {what}{name} is not imported (the LUT file is not in the project; EffectCraft's {what} stays None)"), + ); + } + } +} + +/// HSL Secondary's key as EffectCraft's hue centre / range / softness (degrees) and saturation and +/// lightness bands (percent): range is taken as the half width, tolerance as the softness. A key +/// that was never picked keeps EffectCraft's default (the whole image). +fn import_hsl_key(d: Option<&[u8]>, here: &str, group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) { + let Some(d) = d else { return }; + let Some(key) = HslKey::parse(d) else { + col.lose(LossCategory::DamagedData, format!("{here}: the HSL Secondary key data is unreadable, the default key used")); + return; + }; + if !key.picked { + if !key.is_default() { + col.lose(LossCategory::Effects, format!("{here}: the HSL Secondary key holds values without a picked colour, not imported")); + } + return; + } + let mut set = |id: &str, v: Value| { + if let Some(prop) = group.prop_mut(&format!("hslSecondary/key/{id}")) { + prop.value = v; + } + }; + let [h, s, l] = key.channels.map(|(c, r, t, on)| (f64::from(c), f64::from(r), f64::from(t), on)); + set("hueEnabled", Value::Bool(h.3)); + set("hueCenter", Value::Scalar((h.0 * 360.0).rem_euclid(360.0))); + set("hueRange", Value::Scalar((h.1 * 360.0).clamp(0.0, 180.0))); + set("hueSoftness", Value::Scalar((h.2 * 360.0).clamp(0.0, 180.0))); + for ((c, r, t, on), id) in [(s.0, s.1, s.2, s.3), (l.0, l.1, l.2, l.3)].into_iter().zip(["saturation", "lightness"]) { + set(&format!("{id}Enabled"), Value::Bool(on)); + set(&format!("{id}Low"), Value::Scalar(((c - r) * 100.0).clamp(0.0, 100.0))); + set(&format!("{id}High"), Value::Scalar(((c + r) * 100.0).clamp(0.0, 100.0))); + set(&format!("{id}Softness"), Value::Scalar((t * 100.0).clamp(0.0, 100.0))); + } +} diff --git a/crates/aep/tests/it/import.rs b/crates/aep/tests/it/import.rs index 95dced570..122d18c2b 100644 --- a/crates/aep/tests/it/import.rs +++ b/crates/aep/tests/it/import.rs @@ -13,6 +13,7 @@ mod effects; mod effects_batch5; mod effects_round2; mod effects_round3; +mod lumetri; 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..010df44d5 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(), 58); for (_, id) in t { assert!(effectcraft_effects::find(id).is_some(), "{id}"); } diff --git a/crates/aep/tests/it/import/lumetri.rs b/crates/aep/tests/it/import/lumetri.rs new file mode 100644 index 000000000..526f43648 --- /dev/null +++ b/crates/aep/tests/it/import/lumetri.rs @@ -0,0 +1,448 @@ +//! Lumetri Color (`ADBE Lumetri`) arrives as EffectCraft's Lumetri Color (`ec.color.lumetri`), +//! its parameters found by match name. The effect is built into the committed `synthetic.aep` in +//! place of its Glow, in the layout of the 1200 samples (After Effects 2026, docs/aep-format.md +//! 3.7): the definitions are a subset in an order of our own, so a mapping by `parT` index fails. + +use effectcraft_aep::import::{ImportOptions, ImportResult, LossCategory, import}; +use effectcraft_aep::{Body, Chunk, parse, write}; +use effectcraft_keyframe::Value; +use effectcraft_project::{Project, PropGroup}; + +const SYNTHETIC: &[u8] = include_bytes!("../../fixtures/synthetic.aep"); + +fn tdmn(name: &str) -> Chunk<'static> { + let mut b = name.as_bytes().to_vec(); + b.resize(40, 0); + Chunk::data(*b"tdmn", b) +} + +/// A chunk holding chunks (`pdnm`, `fnam`: `NESTED_IDS`) with one `Utf8`. +fn nested(id: [u8; 4], text: &[u8]) -> Chunk<'static> { + Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", text.to_vec())]), pad: None } +} + +fn mn(suffix: u16) -> String { + format!("ADBE Lumetri-{suffix:04}") +} + +/// A parameter definition (`pard`, 148 bytes): the type at byte 15, the name at 16, the default at +/// 56 (a float slider's `f64`, a checkbox's or popup's `u32`), a popup's item count at 60, a float +/// slider's valid range at 104 / 108 and slider range at 112 / 116 (`f32`). +#[derive(Clone)] +enum Def { + Float { name: &'static str, default: f64, min: f32, max: f32 }, + Check { default: bool }, + Popup { name: &'static str, items: &'static [&'static str] }, + Data { name: &'static str }, + Group { name: &'static str, end: bool }, +} + +fn pard(d: &Def) -> Vec> { + let mut b = vec![0u8; 148]; + let name = |b: &mut Vec, n: &str| b[16..16 + n.len()].copy_from_slice(n.as_bytes()); + let mut extra = Vec::new(); + match d { + Def::Float { name: n, default, min, max } => { + b[15] = 10; + name(&mut b, n); + b[56..64].copy_from_slice(&default.to_be_bytes()); + b[104..108].copy_from_slice(&min.to_be_bytes()); + b[108..112].copy_from_slice(&max.to_be_bytes()); + b[112..116].copy_from_slice(&min.to_be_bytes()); + b[116..120].copy_from_slice(&max.to_be_bytes()); + } + Def::Check { default } => { + b[15] = 4; + name(&mut b, " "); + b[56..60].copy_from_slice(&u32::from(*default).to_be_bytes()); + } + Def::Popup { name: n, items } => { + b[15] = 7; + name(&mut b, n); + b[56..60].copy_from_slice(&1u32.to_be_bytes()); + b[60..62].copy_from_slice(&u16::try_from(items.len()).unwrap().to_be_bytes()); + extra.push(nested(*b"pdnm", items.join("|").as_bytes())); + } + Def::Data { name: n } => { + b[15] = 11; + name(&mut b, n); + } + Def::Group { name: n, end } => { + b[15] = if *end { 14 } else { 13 }; + name(&mut b, n); + } + } + let mut v = vec![Chunk::data(*b"pard", b)]; + v.extend(extra); + v +} + +/// A stored value. +#[derive(Clone)] +enum Rec { + /// A number in a record of state `tdsb` (1 written, 3 the default state). + Num(f64, u32), + /// Arbitrary data. + Data(Vec), +} + +/// A static value record after the one After Effects wrote for Glow's A & B Midpoint (its header, +/// one component). +fn record(template: &Chunk<'_>, tdsb: u32, v: f64) -> Chunk<'static> { + let mut kids = Vec::new(); + for c in template.children() { + match &c.body { + Body::Data(_) if c.id == *b"tdsb" => kids.push(Chunk::data(*b"tdsb", tdsb.to_be_bytes().to_vec())), + Body::Data(d) if 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) +} + +/// The defaults of a fresh instance's data (1200 `lumetri-00-defaults`). +fn empty() -> Vec { + vec![0xfe, 0xfe] +} + +/// A Lumetri curve: type, point count, points (`f64` x, y), padded to 520 bytes. +fn curve(kind: i32, pts: &[(f64, f64)]) -> Vec { + let mut d = kind.to_le_bytes().to_vec(); + d.extend(u32::try_from(pts.len()).unwrap().to_le_bytes()); + for (x, y) in pts { + d.extend(x.to_le_bytes()); + d.extend(y.to_le_bytes()); + } + d.resize(520, 0); + d +} + +fn rgb_curves() -> Vec { + (0..4).flat_map(|_| curve(0, &[(0.0, 0.0), (1.0, 1.0)])).collect() +} + +fn wheels(n: usize) -> Vec { + (0..n).flat_map(|_| [0.0f64, 0.5, 0.0].into_iter().flat_map(f64::to_le_bytes)).collect() +} + +fn utf16(s: &str) -> Vec { + s.encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect() +} + +/// HSL Secondary's key data as 1200 `lumetri-01-set-all` stores it (Set color RGB 0.02 0.55 0.85). +const HSL_PICKED: &str = "01327f0f3fcdcc4c3dcdcccc3d01d519683f2e57c93dcade173e01dfdede3ecdcc4c3dcdcccc3d01"; +/// A fresh instance's key data (`-00-defaults`). +const HSL_FRESH: &str = "000000003f0000000000000000010000003f0000000000000000010000003f000000000000000001"; + +fn hex(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() +} + +const MASK_STYLE: &[&str] = &["Color/Gray", "Color/Black", "White/Black"]; +const LOOKS: &[&str] = &["None", "[Custom]", "Browse...", "(-", "SL BIG", "SL BLUE ICE"]; + +/// The definitions, an order of our own (not After Effects' `parT` order) and without the ones +/// After Effects 2026 added late (-0127 ... -0130). +fn defs() -> Vec<(u16, Def)> { + let f = |name, default, min, max| Def::Float { name, default, min, max }; + vec![ + (1, Def::Data { name: "Blob" }), + (62, Def::Check { default: false }), + (2, Def::Group { name: "Basic Correction", end: false }), + (3, Def::Check { default: true }), + (95, Def::Data { name: " " }), + (64, f("HDR Specular", 0.0, -150.0, 150.0)), + (16, f("Blacks", 0.0, -150.0, 150.0)), + (11, f("Exposure", 0.0, -7.0, 7.0)), + (12, f("Contrast", 0.0, -150.0, 150.0)), + (7, f("Temperature", 0.0, -300.0, 300.0)), + (21, Def::Group { name: "", end: true }), + (22, Def::Group { name: "Creative", end: false }), + (23, Def::Check { default: true }), + (96, Def::Data { name: " " }), + (25, Def::Popup { name: "Look", items: LOOKS }), + (26, f("Intensity", 100.0, 0.0, 200.0)), + (32, Def::Data { name: "Split Toning" }), + (35, Def::Group { name: "", end: true }), + (39, Def::Data { name: "RGB Curve" }), + (104, Def::Check { default: true }), + (42, Def::Data { name: "Hue vs Sat" }), + (47, Def::Data { name: "Wheels" }), + (68, Def::Check { default: true }), + (73, Def::Data { name: "HSL Sliders" }), + (74, Def::Check { default: false }), + (75, Def::Popup { name: " ", items: MASK_STYLE }), + (85, Def::Data { name: "Color Wheels" }), + (86, f("Temperature", 0.0, -300.0, 300.0)), + (50, Def::Check { default: true }), + (51, f("Amount", 0.0, -5.0, 5.0)), + (54, f("Feather", 50.0, 0.0, 100.0)), + (97, Def::Check { default: false }), + ] +} + +/// The data every instance stores, at the defaults. +fn default_data() -> Vec<(u16, Rec)> { + vec![ + (1, Rec::Data(empty())), + (95, Rec::Data(empty())), + (96, Rec::Data(empty())), + (32, Rec::Data(vec![0; 32])), + (39, Rec::Data(rgb_curves())), + (42, Rec::Data(curve(3, &[]))), + (47, Rec::Data(wheels(4))), + (73, Rec::Data(hex(HSL_FRESH))), + (85, Rec::Data(wheels(8))), + ] +} + +/// The Lumetri instance: the definitions, then the stored values `recs` (data replaces the +/// default data of the same parameter). +fn sspc(defs: &[(u16, Def)], recs: &[(u16, Rec)], template: &Chunk<'_>) -> Chunk<'static> { + let mut part = vec![Chunk::data(*b"parn", u32::try_from(defs.len() + 2).unwrap().to_be_bytes().to_vec()), tdmn(&mn(0))]; + let mut layer = vec![0u8; 148]; + layer[15] = 0; + part.push(Chunk::data(*b"pard", layer)); + for (s, d) in defs { + part.push(tdmn(&mn(*s))); + part.extend(pard(d)); + } + part.push(tdmn("ADBE Effect Built In Params")); + let mut end = vec![0u8; 148]; + end[15] = 9; + part.push(Chunk::data(*b"pard", end)); + let mut all: Vec<(u16, Rec)> = default_data().into_iter().filter(|(s, _)| !recs.iter().any(|(r, _)| r == s)).collect(); + all.extend(recs.iter().cloned()); + let mut values = vec![Chunk::data(*b"tdsb", 1u32.to_be_bytes().to_vec())]; + for (s, r) in all { + values.push(tdmn(&mn(s))); + match r { + Rec::Num(v, tdsb) => values.push(record(template, tdsb, v)), + Rec::Data(d) => { + values.push(record(template, 1, 0.0)); + values.push(Chunk::list(*b"aRbs", vec![Chunk::data(*b"aRbp", d)])); + } + } + } + Chunk::list(*b"sspc", vec![nested(*b"fnam", b"Lumetri Color"), Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)]) +} + +fn find_glow_template<'a>(chunks: &'a [Chunk<'a>]) -> 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(record.clone()); + } + if let Some(t) = find_glow_template(kids) { + return Some(t); + } + } + None +} + +/// Puts the instance in place of the Glow effect (in the effect parade and the project's shared +/// definitions); the number of places. +fn replace_glow(chunks: &mut [Chunk<'_>], instance: &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("ADBE Lumetri"); + kids[at + 1] = instance.clone(); + n += 1; + } + n += replace_glow(kids, instance); + } + n +} + +fn run_with(defs: &[(u16, Def)], recs: &[(u16, Rec)]) -> (PropGroup, ImportResult) { + let file = parse(SYNTHETIC).unwrap(); + let template = find_glow_template(&file.chunks).expect("Glow's A & B Midpoint record"); + let instance = sspc(defs, recs, &template); + let mut file = parse(SYNTHETIC).unwrap(); + assert_eq!(replace_glow(&mut file.chunks, &instance), 2, "the Glow effect and its shared definitions"); + let bytes = write(&file).unwrap(); + 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 == "ec.color.lumetri").cloned())) + .expect("an EffectCraft Lumetri Color"); + (g, r) +} + +fn run(recs: &[(u16, Rec)]) -> (PropGroup, ImportResult) { + run_with(&defs(), recs) +} + +fn losses(r: &ImportResult) -> Vec { + r.report + .losses + .iter() + .filter(|l| matches!(l.category, LossCategory::Effects | LossCategory::DamagedData)) + .flat_map(|l| l.details.clone()) + .filter(|d| d.contains("Lumetri")) + .collect() +} + +fn num(g: &PropGroup, id: &str) -> f64 { + g.prop(id).unwrap_or_else(|| panic!("no {id}")).value.as_f64() +} + +fn val(g: &PropGroup, id: &str) -> Value { + g.prop(id).unwrap_or_else(|| panic!("no {id}")).value.clone() +} + +#[test] +fn a_fresh_lumetri_arrives_without_losses() { + let (g, r) = run(&[]); + assert_eq!(losses(&r), Vec::::new()); + assert_eq!((num(&g, "basicCorrection/tone/exposure"), num(&g, "creative/lookIntensity"), num(&g, "vignette/vignetteFeather")), (0.0, 100.0, 50.0)); + assert_eq!(val(&g, "creative/look"), Value::Enum(0)); +} + +#[test] +fn lumetri_values_arrive_by_match_name_also_from_default_state_records() { + // 1200 `lumetri-01-set-all` / `-06-sec-light`: High Dynamic Range and HDR Specular are + // recorded in the default state (`tdsb` 3) and read back as set. + let (g, r) = run(&[ + (62, Rec::Num(1.0, 3)), + (64, Rec::Num(22.0, 3)), + (11, Rec::Num(2.589_999_914_169_311_5, 1)), + (12, Rec::Num(37.0, 1)), + (16, Rec::Num(-150.0, 1)), + (7, Rec::Num(300.0, 1)), + (26, Rec::Num(134.0, 1)), + (51, Rec::Num(1.85, 1)), + (74, Rec::Num(1.0, 1)), + (75, Rec::Num(3.0, 1)), + (73, Rec::Data(hex(HSL_PICKED))), + ]); + assert_eq!(losses(&r), Vec::::new()); + assert_eq!(val(&g, "highDynamicRange"), Value::Bool(true)); + for (id, want) in [ + ("basicCorrection/tone/hdrSpecular", 22.0), + ("basicCorrection/tone/exposure", 2.589_999_914_169_311_5), + ("basicCorrection/tone/contrast", 37.0), + ("basicCorrection/tone/blacks", -150.0), + // Past EffectCraft's old slider range: kept (the range is After Effects' now). + ("basicCorrection/whiteBalance/temperature", 300.0), + ("creative/lookIntensity", 134.0), + ("vignette/vignetteAmount", 1.85), + ] { + assert_eq!(num(&g, id), want, "{id}"); + } + assert_eq!((val(&g, "hslSecondary/key/showMask"), val(&g, "hslSecondary/key/maskStyle")), (Value::Bool(true), Value::Enum(2))); +} + +#[test] +fn a_switched_off_section_is_reported_and_not_applied() { + // 1200 `-03-sec-basic-correction`, `-17-sec-vignette`: a section that is off does nothing. + let (g, r) = run(&[(3, Rec::Num(0.0, 1)), (11, Rec::Num(2.0, 1)), (50, Rec::Num(0.0, 1)), (51, Rec::Num(-2.0, 1)), (12, Rec::Num(37.0, 1))]); + assert_eq!((num(&g, "basicCorrection/tone/exposure"), num(&g, "basicCorrection/tone/contrast"), num(&g, "vignette/vignetteAmount")), (0.0, 0.0, 0.0)); + let l = losses(&r); + assert_eq!(l.len(), 2, "{l:?}"); + assert!(l[0].contains("section Basic Correction is switched off") && l[0].contains("Exposure 2, Contrast 37"), "{l:?}"); + assert!(l[1].contains("section Vignette is switched off") && l[1].contains("Amount -2"), "{l:?}"); +} + +#[test] +fn a_look_is_reported_by_name() { + // 1200 `-01-set-all`: the Look's name in -0096, the popup's record in the default state. + let (g, r) = run(&[(96, Rec::Data(utf16("SL BLUE ICE"))), (25, Rec::Num(6.0, 3))]); + assert_eq!(val(&g, "creative/look"), Value::Enum(0)); + let l = losses(&r); + assert_eq!(l.len(), 1, "{l:?}"); + assert!(l[0].contains("the Look 'SL BLUE ICE' is not imported"), "{l:?}"); + // Only the popup: its item. + let (_, r) = run(&[(25, Rec::Num(5.0, 1))]); + assert!(losses(&r).iter().any(|d| d.contains("the Look 'SL BIG' is not imported")), "{:?}", losses(&r)); +} + +#[test] +fn hue_saturation_curves_with_neutral_points_arrive_moved_ones_are_reported() { + // 1200 `-11-sec-hue-saturation-curves`: the selector put three neutral points on the curve. + let neutral = curve(3, &[(0.742_483, 0.0), (0.798_038, 0.0), (0.853_594, 0.0)]); + let (g, r) = run(&[(42, Rec::Data(neutral))]); + assert_eq!(losses(&r), Vec::::new()); + assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsSat"), Value::Str(String::new())); + let (_, r) = run(&[(42, Rec::Data(curve(3, &[(0.5, 0.25)])))]); + let l = losses(&r); + assert!(l.len() == 1 && l[0].contains("Hue vs Sat (ADBE Lumetri-0042) is not imported"), "{l:?}"); + // A point count past the data: not a default, reported, no panic. + let mut bad = curve(3, &[]); + bad[4..8].copy_from_slice(&1000u32.to_le_bytes()); + let (_, r) = run(&[(42, Rec::Data(bad))]); + assert_eq!(losses(&r).len(), 1); +} + +#[test] +fn the_picked_hsl_key_becomes_hue_saturation_and_lightness_ranges() { + let (g, r) = run(&[(73, Rec::Data(hex(HSL_PICKED)))]); + assert_eq!(losses(&r), Vec::::new()); + let close = |id: &str, want: f64| assert!((num(&g, id) - want).abs() < 0.01, "{id}: {} != {want}", num(&g, id)); + // Hue centre 0.5605 of the circle, range 0.05, tolerance 0.1. + close("hslSecondary/key/hueCenter", 201.79); + close("hslSecondary/key/hueRange", 18.0); + close("hslSecondary/key/hueSoftness", 36.0); + // Lightness centre 0.435 +- 0.05, tolerance 0.1, as percent. + close("hslSecondary/key/lightnessLow", 38.53); + close("hslSecondary/key/lightnessHigh", 48.53); + close("hslSecondary/key/lightnessSoftness", 10.0); + assert_eq!(val(&g, "hslSecondary/key/hueEnabled"), Value::Bool(true)); + // With the key picked the correction applies; without, After Effects applies it to nothing + // (1200 `-16-sec-correction` renders as `-00-defaults`): left out and reported. + let (g, _) = run(&[(73, Rec::Data(hex(HSL_PICKED))), (86, Rec::Num(19.0, 1))]); + assert_eq!(num(&g, "hslSecondary/correction/temperature"), 19.0); + let (g, r) = run(&[(86, Rec::Num(19.0, 1))]); + assert_eq!(num(&g, "hslSecondary/correction/temperature"), 0.0); + let l = losses(&r); + assert!(l.len() == 1 && l[0].contains("HSL Secondary has no key colour picked") && l[0].contains("Temperature 19"), "{l:?}"); + // Damaged key data: reported, the default key kept. + let (g, r) = run(&[(73, Rec::Data(vec![7; 40]))]); + assert!(losses(&r).iter().any(|d| d.contains("HSL Secondary key data is unreadable")), "{:?}", losses(&r)); + assert_eq!(num(&g, "hslSecondary/key/hueRange"), 180.0); +} + +#[test] +fn undecoded_data_and_controls_without_counterpart_are_reported_when_set() { + let mut split = vec![0u8; 32]; + split[0] = 1; + let mut w = wheels(4); + w[8] = 1; + let (_, r) = run(&[(32, Rec::Data(split)), (47, Rec::Data(w)), (97, Rec::Num(1.0, 3)), (95, Rec::Data(utf16("my.cube")))]); + let l = losses(&r); + assert_eq!(l.len(), 4, "{l:?}"); + assert!(l.iter().any(|d| d.contains("Split Toning (ADBE Lumetri-0032) is not imported")), "{l:?}"); + assert!(l.iter().any(|d| d.contains("Wheels (ADBE Lumetri-0047) is not imported")), "{l:?}"); + assert!(l.iter().any(|d| d.contains("Legacy Tone And Saturation (ADBE Lumetri-0097) is on")), "{l:?}"); + assert!(l.iter().any(|d| d.contains("the Input LUT 'my.cube' is not imported")), "{l:?}"); +} + +#[test] +fn an_unknown_scale_or_parameter_is_reported_not_guessed() { + let mut d = defs(); + // Exposure with another valid range (an older or newer version): not guessed at. + if let Some((_, Def::Float { min, max, .. })) = d.iter_mut().find(|(s, _)| *s == 11) { + (*min, *max) = (-100.0, 100.0); + } + // A parameter this importer does not know, set. + d.push((200, Def::Float { name: "Future", default: 0.0, min: -1.0, max: 1.0 })); + let (g, r) = run_with(&d, &[(11, Rec::Num(3.0, 1)), (200, Rec::Num(0.5, 1))]); + assert_eq!(num(&g, "basicCorrection/tone/exposure"), 0.0); + let l = losses(&r); + assert!(l.iter().any(|x| x.contains("Exposure (ADBE Lumetri-0011) has the valid maximum Some(100.0), expected 7")), "{l:?}"); + assert!(l.iter().any(|x| x.contains("Future (ADBE Lumetri-0200) not imported")), "{l:?}"); +} diff --git a/crates/effects/src/color3.rs b/crates/effects/src/color3.rs index 4850710ab..d4cdedd98 100644 --- a/crates/effects/src/color3.rs +++ b/crates/effects/src/color3.rs @@ -705,32 +705,37 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf { pub fn specs() -> Vec { let grey = || col(0.5, 0.5, 0.5); + // After Effects' valid ranges (a value past the slider's drag range is still kept: .aep + // import, 1200), the slider over the range After Effects' slider covers. + let wb = || slider(-300.0, 300.0, -100.0, 100.0, 1); + let tone = || slider(-150.0, 150.0, -100.0, 100.0, 1); + let sat = || slider(0.0, 300.0, 0.0, 200.0, 1); let mut lum = vec![ p("highDynamicRange", "High Dynamic Range", Value::Bool(false), ParamUi::Checkbox), p("basicCorrection/inputLut", "Input LUT", Value::Enum(0), popup(&["None", "Custom"])), // A .cube file path (or the file's text) for Input LUT = Custom. p("basicCorrection/inputLutFile", "Input LUT File", Value::Str(String::new()), ParamUi::Text), - p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), bipolar()), - p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), bipolar()), - p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)), - p("basicCorrection/tone/contrast", "Contrast", num(0.0), bipolar()), - p("basicCorrection/tone/highlights", "Highlights", num(0.0), bipolar()), - p("basicCorrection/tone/shadows", "Shadows", num(0.0), bipolar()), - p("basicCorrection/tone/whites", "Whites", num(0.0), bipolar()), - p("basicCorrection/tone/blacks", "Blacks", num(0.0), bipolar()), + p("basicCorrection/whiteBalance/temperature", "Temperature", num(0.0), wb()), + p("basicCorrection/whiteBalance/tint", "Tint", num(0.0), wb()), + p("basicCorrection/tone/exposure", "Exposure", num(0.0), slider(-7.0, 7.0, -5.0, 5.0, 2)), + p("basicCorrection/tone/contrast", "Contrast", num(0.0), tone()), + p("basicCorrection/tone/highlights", "Highlights", num(0.0), tone()), + p("basicCorrection/tone/shadows", "Shadows", num(0.0), tone()), + p("basicCorrection/tone/whites", "Whites", num(0.0), tone()), + p("basicCorrection/tone/blacks", "Blacks", num(0.0), tone()), p("basicCorrection/tone/hdrWhite", "HDR White", num(100.0), slider(100.0, 10000.0, 100.0, 4000.0, 0)), - p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), bipolar()), - p("basicCorrection/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("basicCorrection/tone/hdrSpecular", "HDR Specular", num(0.0), tone()), + p("basicCorrection/saturation", "Saturation", num(100.0), sat()), p("creative/look", "Look", Value::Enum(0), popup(&LUMETRI_LOOKS)), p("creative/lookFile", "Look File", Value::Str(String::new()), ParamUi::Text), p("creative/lookIntensity", "Intensity", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), - p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), pct()), + p("creative/adjustments/fadedFilm", "Faded Film", num(0.0), slider(0.0, 150.0, 0.0, 100.0, 1)), p("creative/adjustments/sharpen", "Sharpen", num(0.0), bipolar()), p("creative/adjustments/vibrance", "Vibrance", num(0.0), bipolar()), - p("creative/adjustments/creativeSaturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("creative/adjustments/creativeSaturation", "Saturation", num(100.0), sat()), p("creative/adjustments/shadowTint", "Shadow Tint", grey(), ParamUi::Color), p("creative/adjustments/highlightTint", "Highlight Tint", grey(), ParamUi::Color), - p("creative/adjustments/tintBalance", "Tint Balance", num(0.0), bipolar()), + p("creative/adjustments/tintBalance", "Tint Balance", num(0.0), tone()), ]; const CURVES: [(&str, &str); 12] = [ ("curves/curveMasterShadows", "RGB Curve Shadows"), @@ -786,13 +791,13 @@ pub fn specs() -> Vec { p("hslSecondary/key/maskStyle", "Mask Style", Value::Enum(0), popup(&HSL_MASK_STYLES)), p("hslSecondary/key/invertMask", "Invert Mask", Value::Bool(false), ParamUi::Checkbox), p("hslSecondary/refine/denoise", "Denoise", num(0.0), pct()), - p("hslSecondary/refine/blur", "Blur", num(0.0), pct()), + p("hslSecondary/refine/blur", "Blur", num(0.0), slider(0.0, 1000.0, 0.0, 100.0, 1)), p("hslSecondary/correction/correctionWheel", "Correction", grey(), ParamUi::Color), - p("hslSecondary/correction/temperature", "Temperature", num(0.0), bipolar()), - p("hslSecondary/correction/tint", "Tint", num(0.0), bipolar()), - p("hslSecondary/correction/contrast", "Contrast", num(0.0), bipolar()), + p("hslSecondary/correction/temperature", "Temperature", num(0.0), wb()), + p("hslSecondary/correction/tint", "Tint", num(0.0), wb()), + p("hslSecondary/correction/contrast", "Contrast", num(0.0), tone()), p("hslSecondary/correction/sharpen", "Sharpen", num(0.0), bipolar()), - p("hslSecondary/correction/saturation", "Saturation", num(100.0), slider(0.0, 200.0, 0.0, 200.0, 1)), + p("hslSecondary/correction/saturation", "Saturation", num(100.0), sat()), p("vignette/vignetteAmount", "Amount", num(0.0), slider(-5.0, 5.0, -5.0, 5.0, 2)), p("vignette/vignetteMidpoint", "Midpoint", num(50.0), pct()), p("vignette/vignetteRoundness", "Roundness", num(0.0), bipolar()), diff --git a/docs/aep-format.md b/docs/aep-format.md index fcc8f53be..9a37b384f 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -534,6 +534,44 @@ suffix is a parameter id, not the `parT` position (Curves -0002 Channel before - -0005 Quality before -0004). API: `CurvesData`, `MeshData`, `curve_lut`. [V; details: corpus FORMAT S] +**Lumetri Color** (`ADBE Lumetri`, 1200: 58 compositions made by script in After Effects 2026, 32 bpc, +working space None; defaults, all set, one per section, valid minima and maxima, every popup item the +script can set, checkboxes toggled, keys, an expression): 131 `parT` entries. The `-NNNN` suffix is a +parameter id, not the position (`-0127` ... `-0130` sit between `-0005` and `-0026`), so the importer +keys by suffix. Float sliders (type 10) store the value After Effects shows; the valid ranges (`pard` +104 / 108) are wider than the slider's (112 / 116): Temperature / Tint -300..300 (slider -100..100), +Saturation 0..300 (0..200), Exposure -7..7 (-5..5), Contrast, Highlights, Shadows, Whites, Blacks, +HDR Specular, Tint Balance -150..150 (-100..100), Faded Film 0..150 (0..100), HDR White 100..1000, +HDR Range 100..10000, Refine Blur 0..1000 (0..30), Vignette Amount -5..5 (-3..3); the read-backs of +`-19-min` / `-20-max` equal the stored numbers. **Hidden controls are recorded in the default state** +(`tdsb` 3) and still hold the value After Effects reads back: High Dynamic Range `-0062` (1.0), +HDR Specular `-0064` (22), the second Look popup `-0128` (26.16 as set, read back 26), Legacy Tone And +Saturation `-0097`, Auto Color `-0124`, Face Detection `-0102`, the Auto Intensity `-0123` +(`EffectParam::recorded_value`). Each section has an Active checkbox (blank `pard` name; `-0003` Basic +Correction, `-0023` Creative, `-0037` Curves, `-0118` RGB Curves, `-0104` / `-0105` / `-0107` / `-0109` / +`-0111` the hue / saturation curves, `-0046` Color Wheels, `-0068` HSL Secondary, `-0050` Vignette, on +by default); off, the section does nothing (`-03-sec-basic-correction`, `-17-sec-vignette` render as +`-00-defaults`). Arbitrary data (type 11, a record and `aRbs` in every instance; empty is `fe fe`): +`-0001` an XML copy of the grade written only when the Look changes (not kept current: `-01-set-all` +holds defaults for controls set after the Look); `-0096` the Look's name, UTF-16LE (`SL BLUE ICE`; the +popup `-0025` itself has no record, `-0128` can disagree with it: `-07-sec-creative` names item 20 +while `-0128` holds 23), `-0095` likewise for the Input LUT (H, by analogy: no sample sets a LUT); +`-0129` the Look colour space's 16-byte id (the GUID `LookColorSpace` of `-0001`, byte-swapped; zero +for None; the Color Space popup `-0130` writes no record of its own, `-22-pop38-1` ... `-53-pop38-33`); +`-0032` Split Toning (32 zero bytes); `-0039` the four RGB curves and `-0042`, `-0106`, `-0108`, +`-0110`, `-0112` the hue / saturation curves, 520 bytes each: `i32` type (3 for the hue curves, 2 for +Luma vs Sat and Sat vs Sat, 0 for an RGB curve), `u32` point count, then little-endian `f64` pairs x, y +(RGB: (0, 0), (1, 1) by default; hue / saturation: no points, and the selector colours `-0113` ... +`-0117` add three points at y = 0, the hue picked +- 0.0556; no sample moves a point, so y's scale is +H); `-0047` Color Wheels (4 x `f64` triples, 0, 0.5, 0 each) and `-0085` the HSL correction wheels (8 +triples, the same); `-0073` the HSL key, 40 bytes: a flag byte (1 once a colour was picked), then hue, +saturation, lightness, each centre, range, tolerance (`f32` LE, fractions) and an enabled byte (fresh: +0.5, 0, 0, on; picking RGB 0.02 0.55 0.85 gives hue centre 0.5605 = that colour's hue and lightness +centre 0.435 = its HSL lightness, range 0.05, tolerance 0.1: `-01-set-all`; whether range is the half +or full width is H); `-0122` the Auto Tone analysis (UTF-16 JSON). Curves, LUTs and wheels are not +scriptable: the presets for them are still to be made. [V for the sliders, checkboxes, popups and the +`tdsb` 3 values; H where marked] + ### 3.8 Masks and bezier paths (`tdgp[ADBE Mask Parade]`) | Field | Chunk path | Offset | Type | Meaning | Evidence | Conf. | diff --git a/docs/aep-import.md b/docs/aep-import.md index baeb8d0e1..07c089782 100644 --- a/docs/aep-import.md +++ b/docs/aep-import.md @@ -1,6 +1,6 @@ # After Effects project import -> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** 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-05 · **Change:** Lumetri Color is imported (1200: every slider, checkbox and popup with a counterpart, by match name, also from records in the default state; switched-off sections, LUTs, looks, undecoded curves and wheels are reported); 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 | **Lumetri Color** (1200; see *Lumetri Color* below), **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) | | 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 | @@ -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 | | `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 | +| `ADBE Lumetri` by match-name suffix (1200), not by index: -0062 High Dynamic Range; -0063 HDR White; -0007 / -0008 Temperature / Tint; -0020 Saturation; -0011 Exposure; -0012 ... -0016 Contrast, Highlights, Shadows, Whites, Blacks; -0064 HDR Specular; -0026 Look Intensity; -0028 Faded Film; -0029 Sharpen; -0030 Vibrance; -0031 Saturation; -0033 Tint Balance; -0074 Show Mask; -0075 mask style; -0076 Invert Mask; -0081 Denoise; -0082 Blur; -0086 ... -0090 the HSL correction's Temperature, Tint, Contrast, Sharpen, Saturation; -0051 ... -0054 Vignette Amount, Midpoint, Roundness, Feather; -0073 the HSL key data | `highDynamicRange`, `basicCorrection/tone/hdrWhite`, `basicCorrection/whiteBalance/temperature` / `tint`, `basicCorrection/saturation`, `basicCorrection/tone/exposure`, `.../contrast`, `highlights`, `shadows`, `whites`, `blacks`, `hdrSpecular`, `creative/lookIntensity`, `creative/adjustments/fadedFilm`, `sharpen`, `vibrance`, `creativeSaturation`, `tintBalance`, `hslSecondary/key/showMask`, `maskStyle`, `invertMask`, `hslSecondary/refine/denoise`, `blur`, `hslSecondary/correction/*`, `vignette/*`; `hslSecondary/key/hue*`, `saturation*`, `lightness*` | sliders: the stored number x 1 (valid maxima 1000, 300, 300, 7, 150, 200, 150, 100, 100, 300, 150, 100, 1000, 5, 100 as in `pard`); checkboxes and the popup as elsewhere, **also from records in the default state** (`tdsb` 3); the key: hue centre x 360 degrees, range x 360 as the half width, tolerance x 360 as the softness, saturation and lightness centre +- range x 100 percent, tolerance x 100, once a colour was picked | 1200: all 62 read-backs of mapped controls in sections that act equal after import (min, max, sections, popups); the other 61 lie in sections that do nothing and are reported; keys and the expression on HDR White (`-56-keys`, `-57-expr`) | - **Nothing to import:** the implicit input-layer parameter (index 0), the Swap Colors buttons (Tint 4, Ramp 8: they always have a record and hold no value), Exposure's twirl-down group @@ -230,6 +231,28 @@ parameter gets the file's value: column just outside the layer: After Effects' Glow draws a dark halo about 10 px wide outside the layer (red 0.55, 0.65, 0.74, 0.92 at 0, 1, 2, 5 px), EffectCraft's Glow none (an effect difference, recorded for a separate fix). +- **Lumetri Color** (1200, `crates/aep/src/import/lumetri.rs`). The parameters are found by match + name (After Effects inserted `-0127` ... `-0130` mid-list; five tone sliders share one valid range, + so an index shift would not be caught). Values come from every value record, also those in the + default state (`tdsb` 3), where Lumetri keeps High Dynamic Range, HDR Specular and the second Look + popup. EffectCraft's Lumetri has no section switches: a section that is off in After Effects + (Basic Correction, Creative, Curves / RGB Curves, each hue / saturation curve, Color Wheels, HSL + Secondary, Vignette), and HSL Secondary without a picked key colour, does nothing there, so its set + values are listed in one report line and not imported (the frame stays as After Effects draws it). + The Input LUT and the Look are reported by name (LUT files are not in the project). Controls + without a counterpart (the Auto tone's Intensity, RGB Curves' HDR Range, Color Wheels' HDR White and + Face Detection, Legacy Tone And Saturation, Auto Color, SpeedGrade Custom Layer), the eyedropper + colours, and the RGB curves, colour wheels, Split Toning, HSL correction wheels and moved hue / + saturation curve points (not sampled) are reported when set. Render check at 0.5 s against After + Effects' 32 bpc PNGs (stored as value x 6553.5 in 16 bits, so read as raw / 6553.5): every + composition whose grade After Effects draws as identity (defaults, set-all with every section off, + the switched-off sections, the popups, keys, expression) is within 1/255 in EffectCraft; the + graded ones differ by EffectCraft's Lumetri formulas, not by the import: White Balance (sec-color: + After Effects warms and brightens, max 0.10 above the default; EffectCraft darkens, mean error + 0.015), Exposure / tone (sec-light: After Effects rolls highlights off and lifts the blacks to 0.18, + EffectCraft clips at 1 and keeps black at 0; mean 0.12), Creative adjustments (sec-adjustments: + Faded Film lifts black to 0.07 in After Effects, 0.035 here) and the extremes (min / max: mean 0.063 / + 0.454; max: After Effects' vignette at +5 and the tone sliders give mid grey, EffectCraft white). - **Colour.** After Effects stores the colour its picker shows as ARGB doubles 0..255 in the project's working-space encoding; EffectCraft's parameter colours are straight RGBA 0..1 that the render pipeline hands to the effect as stored (`crates/render/src/color.rs`: authored colours are @@ -389,5 +412,5 @@ crate yet. ## Not done yet -Effects outside the table (Lumetri; EXtractoR: its channel choice is arbitrary data; Stroke: its path is a mask reference), environment lights, the ease of bezier Orientation keys, wiggly and expression selectors and the other text animator properties (Character Value, Character Alignment other than its default, the Hue / Saturation / Brightness / Opacity variants, 3D rotations, variable font axes), manual kerning, No Break, the stroke line join, the Japanese leading model, Source Text keyframes, layer-style gradients, Pattern Overlay and the switch state 3, Mesh Warp handles moved on their own, +Lumetri Color's section switches (a switched-off section is reported and its values left out), its LUTs and looks (files After Effects names: reported by name), its RGB curves, colour wheels, Split Toning and HSL correction wheels (not sampled: reported when set) and moved hue / saturation curve points (the scale of y is not sampled); effects outside the table (EXtractoR: its channel choice is arbitrary data; Stroke: its path is a mask reference), environment lights, the ease of bezier Orientation keys, wiggly and expression selectors and the other text animator properties (Character Value, Character Alignment other than its default, the Hue / Saturation / Brightness / Opacity variants, 3D rotations, variable font axes), manual kerning, No Break, the stroke line join, the Japanese leading model, Source Text keyframes, layer-style gradients, Pattern Overlay and the switch state 3, Mesh Warp handles moved on their own, separated dimensions and footage interpretation need verified layouts in the reader first. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5ef21511d..f94fc3789 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -22,7 +22,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | Format | After Effects | EffectCraft read | Write | Fidelity / notes | Tested by | |---|---|---|---|---|---| -| Project `.aep` | R/W (native) | partial | none | Main file format; the clean-room method is in [aep-format.md](aep-format.md), the maintainers' decision on it is open (#190). `crates/aep` reads and writes the RIFX container losslessly (byte-identical round trip) with a typed view of the decoded fields, from our own generated files only ([aep-format.md](aep-format.md)). File ▸ Import ▸ After Effects Project… (`file.importAep`, `effectcraft_aep::import`): folders, project settings, compositions, every layer kind, Transform, keyframes, expressions, masks, text with animators, shapes, cameras, lights, track mattes, markers, time remapping, layer styles, the built-in effects of a table (Curves, Levels, Glow, Fractal Noise, Gradient Ramp, … and the expression controls), footage and image sequences; everything else is reported ([aep-import.md](aep-import.md)). Export to `.aep` is not built yet. Effects outside the table (plug-ins, Lumetri, …) are not converted | `crates/aep` tests (`tests/it`: `container`, `import`) | +| Project `.aep` | R/W (native) | partial | none | Main file format; the clean-room method is in [aep-format.md](aep-format.md), the maintainers' decision on it is open (#190). `crates/aep` reads and writes the RIFX container losslessly (byte-identical round trip) with a typed view of the decoded fields, from our own generated files only ([aep-format.md](aep-format.md)). File ▸ Import ▸ After Effects Project… (`file.importAep`, `effectcraft_aep::import`): folders, project settings, compositions, every layer kind, Transform, keyframes, expressions, masks, text with animators, shapes, cameras, lights, track mattes, markers, time remapping, layer styles, the built-in effects of a table (Lumetri Color, Curves, Levels, Glow, Fractal Noise, Gradient Ramp, … and the expression controls), footage and image sequences; everything else is reported ([aep-import.md](aep-import.md)). Export to `.aep` is not built yet. Effects outside the table (plug-ins, …) are not converted | `crates/aep` tests (`tests/it`: `container`, `import`) | | XML project `.aepx` | R/W | partial | none | Read into the same chunk tree as `.aep` by `crates/aep` (`read_aepx`; mapping in [aep-format.md](aep-format.md) section 1), so `file.importAep` imports it with the same coverage as `.aep`; not written | `crates/aep` `aepx` tests (container and import) | | Templates `.aet` / `.aetx` | R/W | none | none | Own `.ectemplate` (Home ▸ Templates, Save as Template) | `templates.rs` tests | | Motion Graphics template `.mogrt` | W (and R in Premiere) | none | none | Own `.ectemplate` Essential Graphics templates; FilmCraft rejects our `.fcgt` export (#611) | `essential.rs` tests |