mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 10:20:47 -06:00
Merge pull request #3129 from vangel76/aep-import-lumetri
.aep import: map Lumetri Color
This commit is contained in:
@@ -141,6 +141,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,
|
||||
/// EXtractoR's Channel Info (`ExtractorChannels`): the channels by name.
|
||||
Extractor,
|
||||
/// IDentifier's Channel Info (`identifier_channel_type`): the chosen channel by name.
|
||||
@@ -538,6 +541,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 },
|
||||
// The Cryptomatte plug-in (docs/aep-format.md 3.7): 1 Settings data, 2 Output, 3 Selection
|
||||
// Mode, imported by the Cryptomatte step (`Extra::Cryptomatte`).
|
||||
EffectMap { match_name: "Cryptomatte", effect: "ec.3d.cryptomatte", params: &[], extra: Extra::Cryptomatte },
|
||||
|
||||
@@ -461,7 +461,7 @@ fn plan_layer_param(layer: &Layer, g: &PropGroup, target: &str, own: bool, prop:
|
||||
fn plan_extra(spec: &EffectMap, g: &PropGroup, pdefs: &HashMap<u16, ParDef>, here: &str, col: &mut Collector) -> Option<(u16, Vec<u8>)> {
|
||||
let ids: &[&str] = match spec.extra {
|
||||
// (the Cryptomatte plug-in's settings are its own binary layout: not written, reported below)
|
||||
Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => return None,
|
||||
Extra::None | Extra::Curves | Extra::MeshWarp | Extra::Lumetri | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => return None,
|
||||
Extra::HueSaturation => &HUE_SATURATION_MASTER,
|
||||
Extra::Levels => &LEVELS_RGB,
|
||||
};
|
||||
@@ -643,9 +643,9 @@ fn report_unmapped(g: &PropGroup, (effect, spec): (&str, &EffectMap), size: (f64
|
||||
// effect was added at is not known here.
|
||||
let (fresh, other) = (fresh([size.0, size.1], &mut next), fresh([size.0 * 2.0 + 7.0, size.1 * 3.0 + 5.0], &mut next));
|
||||
let extra: &[&str] = match spec.extra {
|
||||
// EXtractoR and IDentifier have no prototype to write from, so they never get this far
|
||||
// (they are reported).
|
||||
Extra::None | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => &[],
|
||||
// Lumetri Color, EXtractoR and IDentifier have no prototype to write from, so they never
|
||||
// get this far (they are reported).
|
||||
Extra::None | Extra::Lumetri | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => &[],
|
||||
Extra::HueSaturation => &HUE_SATURATION_MASTER,
|
||||
Extra::Levels => &LEVELS_RGB,
|
||||
Extra::Curves => &["channel", "rgb", "red", "green", "blue", "alpha"],
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
mod build;
|
||||
mod camera;
|
||||
mod effects;
|
||||
mod lumetri;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod props;
|
||||
|
||||
@@ -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::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, popup_key};
|
||||
use crate::effect_map::{CRYPTOMATTE_OUTPUT, Conv, EFFECTS, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, ParamMap, popup_key};
|
||||
use crate::{
|
||||
CryptomatteData, CurvesData, DefinitionSource, Effect as AepEffect, EffectDefinitions, EffectParam, ExtractorChannels, HueSaturationData,
|
||||
Layer as AepLayer, LayerRef, LevelsData, MeshData, ParamKind, ParamValue,
|
||||
@@ -120,7 +120,9 @@ pub(crate) fn import_effects<'f, 'a>(
|
||||
);
|
||||
}
|
||||
}
|
||||
if map.extra == Extra::Cryptomatte {
|
||||
if map.extra == Extra::Lumetri {
|
||||
crate::import::lumetri::import_lumetri(&fx, ¶ms, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col });
|
||||
} else if map.extra == Extra::Cryptomatte {
|
||||
import_cryptomatte(&fx, ¶ms, cx, &shown, &mut group, col);
|
||||
} else {
|
||||
for p in ¶ms {
|
||||
@@ -152,10 +154,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) }
|
||||
}
|
||||
|
||||
/// Imports one parameter of `fx` into its EffectCraft property: its keyframes, the value in its
|
||||
@@ -165,7 +172,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);
|
||||
@@ -179,6 +186,14 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
}
|
||||
return;
|
||||
};
|
||||
import_mapped(p, m, cx, (&here, &label), ParamOut { group, refs, col });
|
||||
}
|
||||
|
||||
/// 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<'_>) {
|
||||
let (here, label) = names;
|
||||
let ParamOut { group, refs, col } = out;
|
||||
if p.kind != m.kind {
|
||||
col.lose(LossCategory::Effects, format!("{here}: parameter {label} has type {:?}, expected {:?}; not imported", p.kind, m.kind));
|
||||
return;
|
||||
@@ -186,7 +201,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;
|
||||
}
|
||||
_ => {}
|
||||
@@ -291,9 +306,9 @@ 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);
|
||||
}
|
||||
report_beyond_limit(m.limit, prop.keys.iter().map(|k| &k.value), &here, &label, col);
|
||||
report_beyond_limit(m.limit, prop.keys.iter().map(|k| &k.value), here, label, col);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -329,7 +344,7 @@ fn import_param(fx: &AepEffect<'_, '_>, map: &EffectMap, p: &EffectParam<'_, '_>
|
||||
};
|
||||
match converted {
|
||||
Some(v) => {
|
||||
report_beyond_limit(m.limit, std::iter::once(&v), &here, &label, col);
|
||||
report_beyond_limit(m.limit, std::iter::once(&v), here, label, col);
|
||||
prop.value = v;
|
||||
}
|
||||
None => col.lose(LossCategory::DamagedData, format!("{here}: parameter {label} holds a value that is not valid ({value:?}), default used")),
|
||||
@@ -556,7 +571,7 @@ fn import_extra(fx: &AepEffect<'_, '_>, extra: Extra, p: &EffectParam<'_, '_>, h
|
||||
},
|
||||
None => col.lose(LossCategory::Effects, format!("{here}: the Channel Info data is not in the sampled layout, the object ID channel used")),
|
||||
},
|
||||
Extra::None | Extra::Cryptomatte => {}
|
||||
Extra::None | Extra::Lumetri | Extra::Cryptomatte => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
//! 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). 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.
|
||||
//! - **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, 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 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).
|
||||
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,
|
||||
/// 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.
|
||||
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(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(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(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(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(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),
|
||||
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::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)),
|
||||
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), 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 {
|
||||
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, limit: None };
|
||||
import_mapped(p, &m, cx, (&here, &label), ParamOut { group: &mut *group, refs: &mut *refs, col: &mut *col });
|
||||
}
|
||||
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::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(
|
||||
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(_) if p.stored().is_some() && data().is_none() => {
|
||||
// The value record is there but its aRbs/aRbp payload is not: damaged, not the
|
||||
// default (a parameter without a record is the default).
|
||||
col.lose(LossCategory::DamagedData, format!("{here}: the data of {label} is missing, not imported (the default used)"));
|
||||
}
|
||||
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::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})"));
|
||||
}
|
||||
}
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(", ")));
|
||||
}
|
||||
}
|
||||
@@ -364,6 +364,23 @@ fn what_cannot_be_written_is_reported() {
|
||||
parse(&r.bytes).expect("still a valid file");
|
||||
}
|
||||
|
||||
/// Lumetri Color is imported but not written (no prototype to write from): the export reports it
|
||||
/// as a loss and still produces a valid file.
|
||||
#[test]
|
||||
fn lumetri_color_is_reported_not_written() {
|
||||
let mut p = skip!(tint(|_| {}));
|
||||
let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C");
|
||||
with_layer(&mut p, c, 0, |l| {
|
||||
if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) {
|
||||
g.kind = effectcraft_project::GroupKind::Effect { effect: "ec.color.lumetri".into() };
|
||||
}
|
||||
});
|
||||
let r = export_all(&p);
|
||||
assert_eq!(r.report.count(LossCategory::Effects), 1, "{:?}", r.report.losses);
|
||||
assert_eq!(r.report.exported.effects, 0);
|
||||
parse(&r.bytes).expect("still a valid file");
|
||||
}
|
||||
|
||||
/// CC Color Neutralizer, EXtractoR and IDentifier are in the import table but have no prototype
|
||||
/// to write from: each is reported, not written.
|
||||
#[test]
|
||||
|
||||
@@ -17,6 +17,7 @@ mod effects_round2;
|
||||
mod effects_round3;
|
||||
mod effects_thirdparty;
|
||||
mod fx_inject;
|
||||
mod lumetri;
|
||||
mod markers;
|
||||
mod masks;
|
||||
mod mattes;
|
||||
|
||||
@@ -72,7 +72,7 @@ fn the_table_names_tint_gradient_ramp_and_exposure() {
|
||||
] {
|
||||
assert!(t.contains(&want), "{want:?}");
|
||||
}
|
||||
assert_eq!(t.len(), 66);
|
||||
assert_eq!(t.len(), 67);
|
||||
for (_, id) in t {
|
||||
assert!(effectcraft_effects::find(id).is_some(), "{id}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
//! 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 value record whose arbitrary data (`aRbs`) is missing.
|
||||
NoData,
|
||||
}
|
||||
|
||||
/// 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" }),
|
||||
(106, Def::Data { name: "Hue vs Hue" }),
|
||||
(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, &[]))),
|
||||
(106, 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)]));
|
||||
}
|
||||
Rec::NoData => values.push(record(template, 1, 0.0)),
|
||||
}
|
||||
}
|
||||
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_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()));
|
||||
// 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 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 (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));
|
||||
}
|
||||
|
||||
/// A value record without its arbitrary data is damaged, not the default: reported for the RGB
|
||||
/// curve and the HSL key, the neighbours stay as they were.
|
||||
#[test]
|
||||
fn a_value_record_without_its_data_is_reported_as_damaged() {
|
||||
for (suffix, name) in [(39, "RGB Curve (ADBE Lumetri-0039)"), (73, "HSL Sliders (ADBE Lumetri-0073)")] {
|
||||
let (g, r) = run(&[(suffix, Rec::NoData)]);
|
||||
let l = losses(&r);
|
||||
assert!(l.len() == 1 && l[0].contains(name) && l[0].contains("is missing"), "{suffix}: {l:?}");
|
||||
assert!(r.report.losses.iter().any(|x| x.category == LossCategory::DamagedData), "{suffix}");
|
||||
// The grade stays at the default.
|
||||
assert_eq!(val(&g, "curves/rgbCurves/master"), Value::Str("0,0 1,1".into()));
|
||||
assert_eq!(num(&g, "hslSecondary/key/hueRange"), 180.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[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]
|
||||
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 (_, 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(), 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("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();
|
||||
// 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:?}");
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -604,6 +604,64 @@ 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: 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),
|
||||
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` (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; 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
|
||||
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). 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]
|
||||
|
||||
**Cryptomatte plug-in** (match name `Cryptomatte`, the free Cryptomatte plug-in, 2.6.6 in our
|
||||
sample; read from 88 instances in one production project, not made by us, and from our own After
|
||||
Effects 2026 sample 1400 (corpus `aep/cryptomatte.aep`: a fresh instance, Selection Mode on, each
|
||||
|
||||
+42
-3
File diff suppressed because one or more lines are too long
@@ -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 | partial | 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 shared with the exporter (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)). File ▸ Export ▸ After Effects Project… (`file.exportAep`, `effectcraft_aep::export`) writes the same scope with every chunk built in code; exports were opened and read back in After Effects 2026 ([aep-export.md](aep-export.md)); everything else is reported. Effects outside the table (plug-ins, Lumetri, …) are not converted | `crates/aep` tests (`tests/container`, `tests/import`, `tests/export`) |
|
||||
| Project `.aep` | R/W (native) | partial | partial | 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 shared with the exporter (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)). File ▸ Export ▸ After Effects Project… (`file.exportAep`, `effectcraft_aep::export`) writes the same scope with every chunk built in code; exports were opened and read back in After Effects 2026 ([aep-export.md](aep-export.md)); everything else is reported. Effects outside the table (plug-ins, …) are not converted | `crates/aep` tests (`tests/container`, `tests/import`, `tests/export`) |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user