AEP import: Lumetri Color's master RGB curve, Hue vs Sat and colour wheels

After Effects 2026 presets made by hand (1200 lumetri-58 ... -62) decode
the data the scripted samples could not set.

- RGB curve (-0039): the first of the four curves is the master curve
  and arrives as curves/rgbCurves/master. A moved red, green or blue
  curve is reported: no sample tells which slot is which channel.
- Hue vs Sat (-0042): y is the offset from neutral, 1 doubling the
  saturation (in linear light), so EffectCraft's y is 0.5 + y / 2. The
  other four hue / saturation curves stay reported when moved.
- Color Wheels (-0047): four triples of hue (degrees), brightness slider
  (0.5 neutral) and distance. Shadows, Midtones and Highlights become
  EffectCraft's wheel colours (HSL of that hue, saturation = distance,
  lightness 0.5); the brightness sliders and the fourth wheel are
  reported.
- Damaged curve or wheel data is reported as damaged.
- Legacy Tone And Saturation is reported when a record says on: its pard
  default turns from off to on when the project is saved again (the
  25.x / 24.x / 26 copies of 1200), so comparing with it missed it.
- The Input LUT and Look presets confirm -0095 / -0096 hold the names;
  the LUT files are After Effects' own and are not embedded.

Evidence: the 62 read-backs still equal the imported values in the new
63-composition 1200 file. The version copies store the same Lumetri
definitions, ranges and values and give the same report. Renders: master
curve within 0.024 (After Effects uses a natural spline, EffectCraft's
Lumetri a monotone one), Hue vs Sat mean 0.008, wheels mean 0.042.

Part of #3111

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-11-05 17:48:29 +01:00
co-authored by Claude Opus 5.5
parent ce3a5b3673
commit 6a67b4dac1
4 changed files with 348 additions and 49 deletions
+173 -17
View File
@@ -16,11 +16,18 @@
//! 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).
//! - **Arbitrary data** (`ParamKind::ArbitraryData`, every instance stores it). Curves are a
//! curve type, a point count and points as `f64` x, y ([`curve_points`]).
//! - The RGB curve (`-0039`) holds four curves; the first is the master (white) curve (1200
//! `lumetri-60-preset-curves-rgb`), imported. The other three are reported when moved: no
//! sample tells which is which channel.
//! - Hue vs Sat (`-0042`): y is an offset from neutral, 1 doubling the saturation (`-61`), so
//! EffectCraft's y is `0.5 + y / 2`. The other four hue / saturation curves have no sample
//! of their scale and are reported when moved.
//! - Color Wheels (`-0047`): Shadows, Midtones, Highlights and a fourth wheel, each hue,
//! brightness slider, distance ([`import_wheels`], `-62`).
//! - The HSL Secondary key ([`HslKey`]) is imported; 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.
@@ -82,8 +89,9 @@ impl Section {
enum Data {
/// The four RGB curves (4 x 520 bytes).
RgbCurves,
/// One hue / saturation curve (520 bytes); the EffectCraft parameter it belongs to.
HueSatCurve,
/// One hue / saturation curve (520 bytes); the EffectCraft parameter when the scale of its y
/// is known (Hue vs Sat).
HueSatCurve(Option<&'static str>),
/// 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).
@@ -105,6 +113,11 @@ enum Role {
Switch,
/// No counterpart: reported when not at the default.
Missing,
/// A checkbox without counterpart whose `pard` default is not reliable: reported when a
/// record says on. Legacy Tone And Saturation (`-0097`) is off in a fresh instance, but its
/// `pard` default reads off in 1200 as saved by the script and on after the project was
/// opened and saved again (`versions/1200-lumetri-26`, `-25x`, `-24x`).
MissingWhenOn,
/// An eyedropper colour: reported when set.
Eyedropper,
/// The Input LUT (`lut` true) or the Look: a popup, a name or an asset.
@@ -187,19 +200,19 @@ const ROWS: &[Row] = &[
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(42, S::HueSat(0), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(Some("curves/hueSaturationCurves/hueVsSat")))),
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(106, S::HueSat(1), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
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(108, S::HueSat(2), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
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(110, S::HueSat(3), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
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)),
row(112, S::HueSat(4), Some(K::ArbitraryData), Role::Data(Data::HueSatCurve(None))),
// 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),
@@ -235,7 +248,7 @@ const ROWS: &[Row] = &[
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(97, S::Always, Some(K::Checkbox), Role::MissingWhenOn),
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)),
@@ -355,7 +368,11 @@ fn utf16_text(d: &[u8]) -> Option<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.
/// as little-endian `f64` pairs x, y from byte 8 (at most 32), sorted by x. Bytes after the
/// counted points are stale (a fresh hue curve has count 0 and old points behind it). An RGB
/// curve's x and y are 0..1 (2 points 0,0 and 1,1 by default); a hue / saturation curve's x is
/// the hue (0 red, 1/3 green) or the luma / saturation, 0..1, and y its offset from neutral.
/// `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 {
@@ -418,7 +435,7 @@ fn data_default(data: Data, d: Option<&[u8]>) -> bool {
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::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)),
@@ -552,6 +569,11 @@ pub(crate) fn import_lumetri(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, '
col.lose(LossCategory::Effects, format!("{here}: parameter {label} is off and has no counterpart"));
}
}
Role::MissingWhenOn => {
if p.is_animated() || matches!(p.recorded_value(), Some(ParamValue::Bool(true))) {
col.lose(LossCategory::Effects, format!("{here}: parameter {label} is on and has no counterpart, not imported"));
}
}
Role::Missing => {
if is_set(p) {
col.lose(
@@ -575,8 +597,20 @@ pub(crate) fn import_lumetri(fx: &AepEffect<'_, '_>, params: &[EffectParam<'_, '
}
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::RgbCurves => {
import_rgb_curves(data(), (&here, &label), &mut *group, &mut *col);
continue;
}
Data::HueSatCurve(Some(target)) => {
import_hue_sat_curve(data(), target, (&here, &label), &mut *group, &mut *col);
continue;
}
Data::Wheels => {
import_wheels(data(), (&here, &label), &mut *group, &mut *col);
continue;
}
Data::HueSatCurve(None) => "the curve's points are moved; no sample tells their scale",
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})"));
@@ -640,3 +674,125 @@ fn import_hsl_key(d: Option<&[u8]>, here: &str, group: &mut effectcraft_project:
set(&format!("{id}Softness"), Value::Scalar((t * 100.0).clamp(0.0, 100.0)));
}
}
/// A parameter of `group` set to `v` (ids the spec does not have are skipped).
fn set_prop(group: &mut effectcraft_project::PropGroup, id: &str, v: Value) {
if let Some(prop) = group.prop_mut(id) {
prop.value = v;
}
}
/// Curve points as EffectCraft's `x,y x,y ...` text, x and y clamped to 0..1; `None` when a value
/// is not finite.
fn points_text(pts: impl IntoIterator<Item = (f64, f64)>) -> Option<String> {
let mut out: Vec<String> = Vec::new();
for (x, y) in pts {
if !x.is_finite() || !y.is_finite() {
return None;
}
out.push(format!("{},{}", x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
}
Some(out.join(" "))
}
/// The RGB curve (`-0039`, four curves of 520 bytes). The first is the master curve (1200
/// `lumetri-60-preset-curves-rgb`: its midpoint raised to 0.787 lifts R, G and B alike) and
/// becomes `curves/rgbCurves/master`. The other three are the channel curves, but no sample moves
/// one, so which is red, green or blue is not known: a moved one is reported.
fn import_rgb_curves(d: Option<&[u8]>, (here, label): (&str, &str), group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
let Some(d) = d else { return };
let curves: Option<Vec<Vec<(f64, f64)>>> = if d.len() == 4 * 520 { d.as_chunks::<520>().0.iter().map(|c| curve_points(c)).collect() } else { None };
let Some(curves) = curves else {
col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the curves are not imported"));
return;
};
let identity = |pts: &[(f64, f64)]| pts.iter().all(|(x, y)| (x - y).abs() < 1e-9);
if let Some(master) = curves.first().filter(|c| !identity(c)) {
match points_text(master.iter().copied()) {
Some(text) => set_prop(group, "curves/rgbCurves/master", Value::Str(text)),
None => col.lose(LossCategory::DamagedData, format!("{here}: the master curve of {label} has unreadable points, not imported")),
}
}
let moved = curves.iter().skip(1).filter(|c| !identity(c)).count();
if moved > 0 {
col.lose(
LossCategory::Effects,
format!("{here}: {moved} channel curve(s) of {label} are not imported (no sample tells which curve is red, green or blue)"),
);
}
}
/// Hue vs Sat (`-0042`): After Effects' y is an offset from neutral where 1 doubles the
/// saturation (1200 `lumetri-61-preset-curves-huesat`: a point at hue 0.0155, y 0.97 scales pure
/// red's saturation by about 1.96 in linear light; pins at -0.03 and -0.02 scale green, blue and
/// cyan by 0.97). EffectCraft's y = 1 doubles it and 0.5 is neutral, so y becomes `0.5 + y / 2`.
/// x is the hue in both (0 red, 1/3 green).
fn import_hue_sat_curve(
d: Option<&[u8]>,
target: &str,
(here, label): (&str, &str),
group: &mut effectcraft_project::PropGroup,
col: &mut crate::import::report::Collector,
) {
let Some(d) = d else { return };
match curve_points(d).and_then(|pts| points_text(pts.into_iter().map(|(x, y)| (x, 0.5 + y / 2.0)))) {
Some(text) => set_prop(group, target, Value::Str(text)),
None => col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the curve is not imported")),
}
}
/// The colour EffectCraft's wheel takes for After Effects' hue (degrees, 0 red, 120 green) and
/// distance from the centre (0..1): the HSL colour of that hue with saturation = distance and
/// lightness 0.5 (grey 0.5 at distance 0). EffectCraft's wheel adds that colour's offset from its
/// grey, so the direction is After Effects' and the strength grows with the distance.
fn wheel_colour(hue: f64, distance: f64) -> [f64; 4] {
let c = distance.clamp(0.0, 1.0);
let h = hue.rem_euclid(360.0) / 60.0;
let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs());
let (r, g, b) = match h {
h if h < 1.0 => (c, x, 0.0),
h if h < 2.0 => (x, c, 0.0),
h if h < 3.0 => (0.0, c, x),
h if h < 4.0 => (0.0, x, c),
h if h < 5.0 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = 0.5 - c / 2.0;
[r + m, g + m, b + m, 1.0]
}
/// Color Wheels (`-0047`, 96 bytes): four wheels of three little-endian `f64`, the hue in degrees,
/// the brightness slider (0..1, 0.5 neutral) and the distance from the centre (0..1); `0, 0.5, 0`
/// each by default. The first three are Shadows, Midtones and Highlights (1200
/// `lumetri-62-preset-wheels`: Shadows 229 degrees (blue), slider 0.37, distance 0.55; Midtones
/// 0.1 degrees (red), 0.5, 0.39; Highlights 93 degrees (yellow-green), 0.73, 0.32); the fourth is
/// not known. Hue and distance become EffectCraft's wheel colour ([`wheel_colour`]); the brightness
/// sliders and a set fourth wheel have no counterpart and are reported.
fn import_wheels(d: Option<&[u8]>, (here, label): (&str, &str), group: &mut effectcraft_project::PropGroup, col: &mut crate::import::report::Collector) {
let Some(d) = d else { return };
let wheels: Option<Vec<[f64; 3]>> = if d.len() == 4 * 24 {
(0..4).map(|w| Some([le_f64(d, w * 24)?, le_f64(d, w * 24 + 8)?, le_f64(d, w * 24 + 16)?]).filter(|v| v.iter().all(|x| x.is_finite()))).collect()
} else {
None
};
let Some(wheels) = wheels else {
col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is unreadable, the colour wheels are not imported"));
return;
};
let mut lost: Vec<String> = Vec::new();
for (w, (id, name)) in wheels.iter().zip([("shadowsWheel", "Shadows"), ("midtonesWheel", "Midtones"), ("highlightsWheel", "Highlights")]) {
let [hue, brightness, distance] = *w;
if distance != 0.0 {
set_prop(group, &format!("colorWheels/{id}"), Value::Color(wheel_colour(hue, distance)));
}
if (brightness - 0.5).abs() > 1e-9 {
lost.push(format!("{name} brightness {brightness:.3} (0.5 is neutral)"));
}
}
if wheels.get(3).is_some_and(|w| *w != [0.0, 0.5, 0.0]) {
lost.push("the fourth wheel (its role is not known)".to_string());
}
if !lost.is_empty() {
col.lose(LossCategory::Effects, format!("{here}: {label}: {} not imported (no counterpart)", lost.join(", ")));
}
}
+118 -11
View File
@@ -172,6 +172,7 @@ fn defs() -> Vec<(u16, Def)> {
(39, Def::Data { name: "RGB Curve" }),
(104, Def::Check { default: true }),
(42, Def::Data { name: "Hue vs Sat" }),
(106, Def::Data { name: "Hue vs Hue" }),
(47, Def::Data { name: "Wheels" }),
(68, Def::Check { default: true }),
(73, Def::Data { name: "HSL Sliders" }),
@@ -195,6 +196,7 @@ fn default_data() -> Vec<(u16, Rec)> {
(32, Rec::Data(vec![0; 32])),
(39, Rec::Data(rgb_curves())),
(42, Rec::Data(curve(3, &[]))),
(106, Rec::Data(curve(3, &[]))),
(47, Rec::Data(wheels(4))),
(73, Rec::Data(hex(HSL_FRESH))),
(85, Rec::Data(wheels(8))),
@@ -372,20 +374,112 @@ fn a_look_is_reported_by_name() {
}
#[test]
fn hue_saturation_curves_with_neutral_points_arrive_moved_ones_are_reported() {
fn hue_vs_sat_arrives_with_its_y_scaled_other_moved_curves_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)])))]);
// 1200 `-61-preset-curves-huesat`: red raised to 0.97 (After Effects nearly doubles its
// saturation), the neighbours pinned near 0. EffectCraft's y: 0.5 + y / 2.
let preset = curve(3, &[(0.015_463_917_525_773_196, 0.97), (0.262_886_597_938_144_34, -0.03), (0.764_604_810_996_563_5, -0.02)]);
let (g, r) = run(&[(42, Rec::Data(preset))]);
assert_eq!(losses(&r), Vec::<String>::new());
let Value::Str(text) = val(&g, "curves/hueSaturationCurves/hueVsSat") else { panic!("a point list") };
let pts: Vec<(f64, f64)> = text
.split(' ')
.map(|p| {
let (x, y) = p.split_once(',').unwrap();
(x.parse().unwrap(), y.parse().unwrap())
})
.collect();
let want = [(0.015_464, 0.985), (0.262_887, 0.485), (0.764_605, 0.49)];
assert_eq!(pts.len(), 3, "{text}");
for ((x, y), (wx, wy)) in pts.iter().zip(want) {
assert!((x - wx).abs() < 1e-5 && (y - wy).abs() < 1e-9, "{text}");
}
// Hue vs Hue moved: no sample tells its scale, reported and left neutral.
let (g, r) = run(&[(106, 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.
assert!(l.len() == 1 && l[0].contains("Hue vs Hue (ADBE Lumetri-0106) is not imported"), "{l:?}");
assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsHue"), Value::Str(String::new()));
// A point count past the data: not a default, reported as damaged, 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);
let (g, r) = run(&[(42, Rec::Data(bad))]);
let l = losses(&r);
assert!(l.len() == 1 && l[0].contains("Hue vs Sat (ADBE Lumetri-0042) is unreadable"), "{l:?}");
assert_eq!(val(&g, "curves/hueSaturationCurves/hueVsSat"), Value::Str(String::new()));
}
#[test]
fn the_master_rgb_curve_arrives_moved_channel_curves_are_reported() {
// 1200 `-60-preset-curves-rgb`: the first of the four curves, its midpoint raised.
let master = curve(0, &[(0.0, 0.0), (0.491_408_934_707_903_76, 0.786_885_245_901_639_3), (1.0, 1.0)]);
let identity = curve(0, &[(0.0, 0.0), (1.0, 1.0)]);
let mut d = master.clone();
(0..3).for_each(|_| d.extend(&identity));
let (g, r) = run(&[(39, Rec::Data(d))]);
assert_eq!(losses(&r), Vec::<String>::new());
assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 0.49140893470790376,0.7868852459016393 1,1".into()));
assert_eq!(val(&g, "curves/rgbCurves/red"), Value::Str("0,0 1,1".into()));
// A moved channel curve: which channel it is is not known, reported.
let mut d = identity.clone();
d.extend(&master);
(0..2).for_each(|_| d.extend(&identity));
let (g, r) = run(&[(39, Rec::Data(d))]);
let l = losses(&r);
assert!(l.len() == 1 && l[0].contains("1 channel curve(s) of RGB Curve (ADBE Lumetri-0039) are not imported"), "{l:?}");
assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 1,1".into()));
// Damaged: the wrong length.
let (_, r) = run(&[(39, Rec::Data(master))]);
assert!(losses(&r).iter().any(|x| x.contains("RGB Curve (ADBE Lumetri-0039) is unreadable")), "{:?}", losses(&r));
}
#[test]
fn colour_wheels_arrive_as_colours_their_brightness_is_reported() {
// 1200 `-62-preset-wheels`: Shadows towards blue with the slider down, Midtones towards red,
// Highlights towards yellow-green with the slider up; the fourth wheel at its default.
let w: Vec<u8> = [
229.119_895_976_548_18,
0.369_892_467_494_294_2,
0.547_209_348_004_638,
0.104_745_366_878_020_72,
0.5,
0.394_961_840_132_529_44,
93.374_076_744_668_57,
0.726_881_718_523_399_9,
0.318_976_840_675_999_35,
0.0,
0.5,
0.0,
]
.into_iter()
.flat_map(f64::to_le_bytes)
.collect();
let (g, r) = run(&[(47, Rec::Data(w.clone()))]);
let colour = |id: &str| match val(&g, id) {
Value::Color(c) => c,
other => panic!("{id}: {other:?}"),
};
// The hue's direction: blue strongest in Shadows, red in Midtones, green in Highlights.
let s = colour("colorWheels/shadowsWheel");
assert!(s[2] > 0.77 && s[0] < 0.23 && (s[0] + s[2] - 1.0).abs() < 1e-9, "{s:?}");
let m = colour("colorWheels/midtonesWheel");
assert!((m[0] - 0.697_480_920_066_264_7).abs() < 1e-9 && m[1] > m[2], "{m:?}");
let h = colour("colorWheels/highlightsWheel");
assert!(h[1] > h[0] && h[0] > h[2], "{h:?}");
let l = losses(&r);
assert!(l.len() == 1 && l[0].contains("Shadows brightness 0.370") && l[0].contains("Highlights brightness 0.727"), "{l:?}");
assert!(!l[0].contains("Midtones") && !l[0].contains("fourth"), "{l:?}");
// The fourth wheel set: reported; damaged data: reported, the wheels stay grey.
let mut w4 = w;
w4[88..96].copy_from_slice(&0.3f64.to_le_bytes());
let (_, r) = run(&[(47, Rec::Data(w4))]);
assert!(losses(&r).iter().any(|x| x.contains("the fourth wheel")), "{:?}", losses(&r));
let (g, r) = run(&[(47, Rec::Data(vec![1; 95]))]);
assert!(losses(&r).iter().any(|x| x.contains("Wheels (ADBE Lumetri-0047) is unreadable")), "{:?}", losses(&r));
assert_eq!(val(&g, "colorWheels/shadowsWheel"), Value::Color([0.5, 0.5, 0.5, 1.0]));
}
#[test]
@@ -420,17 +514,30 @@ fn the_picked_hsl_key_becomes_hue_saturation_and_lightness_ranges() {
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 (_, r) = run(&[(32, Rec::Data(split)), (97, Rec::Num(1.0, 3)), (95, Rec::Data(utf16("my.cube")))]);
let l = losses(&r);
assert_eq!(l.len(), 4, "{l:?}");
assert_eq!(l.len(), 3, "{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 legacy_tone_on_is_reported_whatever_the_pard_default_says() {
// After the project is opened and saved again, the `pard` default of Legacy Tone And
// Saturation reads on (versions/1200-lumetri-26 / -25x / -24x; off in 1200 as the script
// saved it): a record that says on is still reported, a missing record is not.
let mut d = defs();
if let Some((_, Def::Check { default })) = d.iter_mut().find(|(s, _)| *s == 97) {
*default = true;
}
let (_, r) = run_with(&d, &[(97, Rec::Num(1.0, 3))]);
let l = losses(&r);
assert!(l.len() == 1 && l[0].contains("Legacy Tone And Saturation (ADBE Lumetri-0097) is on"), "{l:?}");
let (_, r) = run_with(&d, &[]);
assert_eq!(losses(&r), Vec::<String>::new());
}
#[test]
fn an_unknown_scale_or_parameter_is_reported_not_guessed() {
let mut d = defs();
+36 -16
View File
@@ -534,9 +534,10 @@ 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
**Lumetri Color** (`ADBE Lumetri`, 1200: 63 compositions in After Effects 2026, 32 bpc, working space
None; by script: defaults, all set, one per section, valid minima and maxima, every popup item the script
can set, checkboxes toggled, keys, an expression; from presets made by hand, `-58` ... `-62`: an Input
LUT, a Look, the RGB curve, Hue vs Sat, the colour wheels): 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),
@@ -546,31 +547,50 @@ HDR Range 100..10000, Refine Blur 0..1000 (0..30), Vignette Amount -5..5 (-3..3)
`-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`
Saturation `-0097` (its `pard` default reads off as the script saved the project and on once the
project was opened and saved again, `versions/1200-lumetri-26`, `-25x`, `-24x`; a fresh instance reads
back off), 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`);
holds defaults for controls set after the Look; it names the LUT file, `Fuji F125 Kodak 2393 (by
Adobe).cube`); `-0096` the Look's name, UTF-16LE (`SL BLUE ICE`, `Fuji F125 Kodak 2393 (by Adobe)` in
`-59-preset-look-lut`; 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
(`ALEXA_Default_LogC2Rec709`, `-58-preset-input-lut`; the popups `-0005` / `-0127` keep no record,
`-0004`, `-0024` and the assets `-0125` / `-0126` stay `fe fe`: the LUTs are After Effects' own files,
named, not embedded); `-0129` the Look colour space's 16-byte id (the GUID `LookColorSpace` of `-0001`
in Windows layout: the first 4, 2 and 2 bytes little-endian; 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,
sorted by x (bytes after the counted points are stale). RGB: x and y 0..1, (0, 0), (1, 1) by default;
the first of the four is the master curve (`-60-preset-curves-rgb`: (0.491, 0.787) lifts R, G and B
alike, and the render follows a natural cubic spline through the points within 0.003); the order of
the other three is H (the `-0001` XML names `ToneCurvesLuma`, `Red`, `Green`, `Blue`; no sample moves
one). Hue / saturation: x the hue (0 red, 1/3 green) or the luma / saturation 0..1, y the offset from
neutral 0; a fresh curve has no points, the selector colours `-0113` ... `-0117` add three at y = 0,
the hue picked +- 0.0556. Hue vs Sat's y scales the saturation by 1 + y in linear light
(`-61-preset-curves-huesat`: (0.0155, 0.97) takes pure red's linear saturation to 1.96 times, (0.263,
-0.03) and (0.765, -0.02) green, cyan and blue to 0.97); the other four curves' scale is H. `-0047`
Color Wheels, 4 `f64` triples: hue in degrees (0 red, 120 green), brightness slider 0..1 (0.5
neutral), distance from the centre 0..1; 0, 0.5, 0 each by default; Shadows, Midtones, Highlights, then
a fourth wheel of unknown role (`-62-preset-wheels`: Shadows 229.1, 0.370, 0.547 towards blue with the
slider down; Midtones 0.1, 0.5, 0.395 towards red; Highlights 93.4, 0.727, 0.319 towards yellow-green
with the slider up; the fourth at its default); `-0085` the HSL correction wheels (8 triples, the same
default; layout H); `-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]
or full width is H); `-0122` the Auto Tone analysis (UTF-16 JSON). The project saved as 25.x and 24.x
copies (`versions/1200-lumetri-25x`, `-24x`: the 58 scripted compositions, saved by After Effects
2026) stores every Lumetri definition, valid range and value as the 26 save does. [V for the sliders,
checkboxes, popups, the `tdsb` 3 values, the master RGB curve, Hue vs Sat, the colour wheels and the
LUT names; H where marked]
### 3.8 Masks and bezier paths (`tdgp[ADBE Mask Parade]`)
+21 -5
View File
File diff suppressed because one or more lines are too long