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