AEP import: map Lumetri Color (ADBE Lumetri) to EffectCraft's Lumetri Color

The importer now converts After Effects' Lumetri Color. Its parameters are
found by match-name suffix, not by parT index: After Effects inserted
-0127 ... -0130 mid-list and five tone sliders share one valid range.

- Values are read 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 (EffectParam::recorded_value; stored_value is
  unchanged).
- A switched-off section, and HSL Secondary without a picked key colour,
  draws nothing in After Effects: its set values are reported in one line
  and not imported.
- The HSL key data is decoded into hue / saturation / lightness ranges.
- LUTs and Looks (by name), controls without a counterpart, eyedropper
  colours and undecoded curves, wheels and Split Toning are reported.
- EffectCraft's Lumetri sliders take After Effects' valid ranges.
- import_param is split into lookup plus import_mapped for reuse.

Evidence: 1200 samples (After Effects 2026). The 62 read-backs of mapped
controls in sections that act equal the imported values. The other 61
lie in sections that do nothing and are reported. Renders of identity
grades are within 1/255.

Part of #3111

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-11-05 15:25:42 +01:00
co-authored by Claude Opus 5.5
parent a439b1bb19
commit ce3a5b3673
12 changed files with 1244 additions and 37 deletions
+6
View File
@@ -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(
+14 -1
View File
@@ -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<ParamValue> {
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<ParamValue> {
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()?;
+1
View File
@@ -27,6 +27,7 @@
mod build;
mod camera;
mod effects;
mod lumetri;
mod markers;
mod masks;
mod props;
+43 -13
View File
@@ -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 &params {
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, &params, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col });
} else {
for p in &params {
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<PendingLayerRef>,
col: &'a mut Collector,
pub(crate) struct ParamOut<'a> {
pub group: &'a mut PropGroup,
pub refs: &'a mut Vec<PendingLayerRef>,
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 => {}
}
}
+642
View File
@@ -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<ParamKind>,
role: Role,
}
const fn row(suffix: u16, section: Section, kind: Option<ParamKind>, 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<u16> {
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<ParamValue> {
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<ParamValue>) -> 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<u32> {
Some(u32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?))
}
fn le_f32(d: &[u8], at: usize) -> Option<f32> {
Some(f32::from_le_bytes(*d.get(at..at.checked_add(4)?)?.first_chunk::<4>()?))
}
fn le_f64(d: &[u8], at: usize) -> Option<f64> {
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<String> {
if is_empty_data(Some(d)) {
return None;
}
let units: Vec<u16> = 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<Vec<(f64, f64)>> {
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<HslKey> {
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<String>,
item: Option<String>,
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<String> {
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<String>)> = Vec::new();
let mut eyedroppers: Vec<String> = 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)));
}
}
+1
View File
@@ -13,6 +13,7 @@ mod effects;
mod effects_batch5;
mod effects_round2;
mod effects_round3;
mod lumetri;
mod markers;
mod masks;
mod mattes;
+1 -1
View File
@@ -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}");
}
+448
View File
@@ -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<Chunk<'static>> {
let mut b = vec![0u8; 148];
let name = |b: &mut Vec<u8>, 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<u8>),
}
/// 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<u8> {
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<u8> {
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<u8> {
(0..4).flat_map(|_| curve(0, &[(0.0, 0.0), (1.0, 1.0)])).collect()
}
fn wheels(n: usize) -> Vec<u8> {
(0..n).flat_map(|_| [0.0f64, 0.5, 0.0].into_iter().flat_map(f64::to_le_bytes)).collect()
}
fn utf16(s: &str) -> Vec<u8> {
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<u8> {
(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<Chunk<'a>> {
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<String> {
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::<String>::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::<String>::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::<String>::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::<String>::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:?}");
}
+23 -18
View File
@@ -705,32 +705,37 @@ fn lumetri(ctx: &EffectCtx, mut b: Buf) -> Buf {
pub fn specs() -> Vec<EffectSpec> {
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<EffectSpec> {
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()),
+38
View File
@@ -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. |
+26 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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 |