mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-09 02:34:14 +00:00
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:
co-authored by
Claude Opus 5.5
parent
ce3a5b3673
commit
6a67b4dac1
@@ -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(", ")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user