Files
effectcraft/crates/aep/src/export/effects.rs
T
vangel76 5f2d88ea5c Merge remote-tracking branch 'upstream/main' into aep-import-lumetri
# Conflicts:
#	crates/aep/src/container/effect_map.rs
#	crates/aep/src/export/effects.rs
#	crates/aep/src/import/effects.rs
#	crates/aep/tests/it/export/effects.rs
#	crates/aep/tests/it/import/effects.rs
#	docs/aep-import.md
2026-11-08 16:32:43 +01:00

1222 lines
56 KiB
Rust

//! Effects: the inverse of the importer, through the same table (`crate::effect_map`).
//! An EffectCraft effect the table maps is written as the After Effects effect it came from:
//!
//! * the instance is the effect's default instance, built in code from the effect table
//! (`fx_defs`, `fxsynth`: `tdmn` + `sspc` with its full `parT` and the records After Effects
//! always keeps: the input layer, buttons, group markers, hidden entries, arbitrary data), with
//! its instance name and the owning layer's id in the input-layer record;
//! * a parameter whose value differs from its `pard` default gets a value record (absent =
//! default, `docs/aep-format.md` 3.7). The record is built from the **record of its parameter
//! type**: over the whole corpus every static value record of a type has the same `tdsb` and
//! `tdb4` apart from the time scale (`@12`) and, for points, the layer aspect (`@24`), and a
//! slider's `tdum` / `tduM` repeat its `pard` slider range (float sliders `f32` @112 / @116,
//! fixed sliders 16.16 and integer sliders `i32` @132 / @136); its `tdsn` is the parameter's
//! name (a checkbox's `pdnm` label); `tests/effects.rs` rebuilds every value record of every
//! effect fixture and compares it with After Effects' bytes;
//! * a keyed parameter turns its record into the keyed form of its kind (fixed slider 363, colour
//! 364, point 382, float slider 397: `tdb4@5` bit 0 cleared, `cdat` replaced by the key list);
//! * an expression is stored as on any property (534, R15);
//! * Hue/Saturation's master values and Levels' RGB values are patched into the effect's
//! arbitrary data (444-446, 512-516);
//! * the project lists each effect type in `Pefl` and keeps one definition copy per type in
//! `EfdG`; the first instance of a type in the file holds the full parameter definitions
//! (`parT`), every later one an empty `parT` (383).
//!
//! Units are the importer's inverse (`Conv`): colours straight RGBA 0..1 to ARGB 0..255 (alpha
//! 255), points layer pixels to fractions of the layer size, percent sliders by their valid
//! maximum, popups by item name to the file's 1-based index, checkboxes 1.0 when on.
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::effect_map::{Conv, EffectMap, Extra, HUE_SATURATION_MASTER, LEVELS_RGB, by_effect, by_match_name, popup_key};
use crate::{AepFile, Body, CURVE_IDENTITY, Chunk, CurvesData, MeshData, ParamKind};
use effectcraft_keyframe::{Interp, Keyframe, Value};
use effectcraft_project::{GroupKind, Layer, LayerId, Node, ParamUi, PropGroup, Property};
use crate::export::plan::{CompPlan, EaseSlot, KeyPlan, LIMIT};
use crate::export::report::{Collector, LossCategory};
use crate::export::tree::{R, child_data, child_mut, data_mut, kids_mut, list_mut, put, put_f64, put_u8, put_u16, put_u32, tpl};
/// How a parameter is stored in its value record.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum PKind {
/// ARGB 0..255 (4 components).
Color,
/// Two fractions of the layer size.
Point,
/// A fixed slider (pard type 2): the number as it is.
Fixed,
/// An angle in degrees (pard type 3).
Angle,
/// An integer slider (pard type 1).
Int,
/// A float slider, stored as the float32 After Effects keeps (widened to f64).
Float,
/// A popup, stored 1-based.
Popup,
/// A checkbox, stored 1.0 when on.
Checkbox,
}
impl PKind {
fn of(k: ParamKind) -> Option<PKind> {
Some(match k {
ParamKind::Color => PKind::Color,
ParamKind::Point => PKind::Point,
ParamKind::FixedSlider => PKind::Fixed,
ParamKind::Angle => PKind::Angle,
ParamKind::IntSlider => PKind::Int,
ParamKind::FloatSlider => PKind::Float,
ParamKind::Popup => PKind::Popup,
ParamKind::Checkbox => PKind::Checkbox,
_ => return None,
})
}
/// The `pard` type code (`docs/aep-format.md` 3.7).
fn code(self) -> u32 {
match self {
PKind::Int => 1,
PKind::Fixed => 2,
PKind::Angle => 3,
PKind::Checkbox => 4,
PKind::Color => 5,
PKind::Point => 6,
PKind::Popup => 7,
PKind::Float => 10,
}
}
fn dims(self) -> usize {
match self {
PKind::Color => 4,
PKind::Point => 2,
_ => 1,
}
}
}
/// A parameter value in the file's units.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum ParamValue {
Static(Vec<f64>),
Keys(Vec<KeyPlan>),
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct ParamPlan {
pub index: u16,
pub kind: PKind,
pub value: ParamValue,
/// The expression text and its enable switch.
pub expr: Option<(String, bool)>,
}
/// An effect instance in the file's units.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct EffectPlan {
pub match_name: &'static str,
pub name: String,
pub params: Vec<ParamPlan>,
/// New arbitrary data for the parameter with this index (Hue/Saturation, Levels).
pub data: Option<(u16, Vec<u8>)>,
/// The layer each layer parameter names (by its index); absent = the owning layer.
pub layers: Vec<(u16, LayerPick)>,
/// The fx switch (the value group's `tdsb`: 1 on, 0 off).
pub enabled: bool,
}
/// What a layer parameter names (`docs/aep-format.md` 3.7: its record always exists, its `tdpi`
/// is the chosen layer's id, the owning layer's own id by default, 0 for None; 320, 321, 904).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum LayerPick {
/// The layer that owns the effect (After Effects' default).
Owner,
/// None.
Nothing,
/// Another layer of the composition.
Layer(LayerId),
}
fn sane(v: f64) -> Option<f64> {
(v.is_finite() && v.abs() <= LIMIT).then_some(v)
}
// ---------------------------------------------------------------------------------------------
// The parameter definitions of the prototypes (read once from the embedded effect template).
/// One parameter definition of a prototype instance (`pard`, `pdnm`).
#[derive(Clone, Debug, Default)]
pub(crate) struct ParDef {
/// The parameter's match name (`<effect>-NNNN`), the name of its value record. The number in
/// it is not the position in `parT` (Fill: position 2 is `ADBE Fill-0007`, 215).
pub record: String,
/// `pard@12`.
pub code: u32,
/// `pard@16`, or a checkbox's `pdnm` label.
pub name: String,
/// The popup items (`pdnm`, separated by `|`).
pub items: Vec<String>,
/// The default in stored units (`pard@56`).
pub default: Vec<f64>,
/// The slider range a value record repeats as `tdum` / `tduM`.
pub range: Option<(f64, f64)>,
/// A slider's valid maximum (the table's scale check).
pub valid_max: Option<f64>,
/// The default arbitrary data (the `aRbp` after the `pard`).
pub data: Option<Vec<u8>>,
}
fn be_i32(d: &[u8], at: usize) -> Option<i32> {
Some(i32::from_be_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?))
}
fn be_u32(d: &[u8], at: usize) -> Option<u32> {
Some(u32::from_be_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?))
}
fn be_f32(d: &[u8], at: usize) -> Option<f64> {
Some(f64::from(f32::from_be_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?)))
}
fn be_f64(d: &[u8], at: usize) -> Option<f64> {
Some(f64::from_be_bytes(d.get(at..at.checked_add(8)?)?.try_into().ok()?))
}
fn cname(d: &[u8]) -> String {
d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect()
}
impl ParDef {
fn from_pard(pard: &[u8], pdnm: Option<&str>, data: Option<&[u8]>) -> ParDef {
let code = be_u32(pard, 12).unwrap_or(u32::MAX);
let fx = |at: usize| be_i32(pard, at).map(|v| f64::from(v) / 65536.0);
let default: Vec<f64> = match code {
10 => be_f64(pard, 56).into_iter().collect(),
2 | 3 => fx(56).into_iter().collect(),
1 => be_i32(pard, 56).map(f64::from).into_iter().collect(),
4 | 7 => be_u32(pard, 56).map(f64::from).into_iter().collect(),
// Fractions of the layer, or percent after an After Effects re-save (see the reader).
6 => {
let p: Vec<f64> = [fx(56), fx(60)].into_iter().flatten().collect();
if p.iter().any(|c| c.abs() > 1.0 + 1e-9) { p.iter().map(|c| c / 100.0).collect() } else { p }
}
5 => pard.get(56..60).map(|c| c.iter().map(|b| f64::from(*b)).collect()).unwrap_or_default(),
_ => Vec::new(),
};
let range = match code {
10 => be_f32(pard, 112).zip(be_f32(pard, 116)),
2 => fx(132).zip(fx(136)),
1 => be_i32(pard, 132).map(f64::from).zip(be_i32(pard, 136).map(f64::from)),
_ => None,
};
let valid_max = match code {
10 => be_f32(pard, 108),
2 => fx(128),
1 => be_i32(pard, 128).map(f64::from),
_ => None,
};
let pname = pard.get(16..48).map(cname).unwrap_or_default();
let (name, items) = match (code, pdnm) {
(7, Some(p)) if !p.is_empty() => (pname, p.split('|').map(str::to_string).collect()),
(4, Some(p)) if pname.trim().is_empty() => (p.to_string(), Vec::new()),
_ => (pname, Vec::new()),
};
ParDef { record: String::new(), code, name, items, default, range, valid_max, data: data.map(<[u8]>::to_vec) }
}
}
/// The parameter definitions of an instance's `parT`, by position (the table's index).
fn par_defs(sspc: &Chunk<'_>) -> HashMap<u16, ParDef> {
let mut out = HashMap::new();
let Some(part) = sspc.child_list(b"parT") else { return out };
let k = part.children();
let mut i = 0usize;
let mut position = 0u16;
while i < k.len() {
let (Some(m), Some(p)) = (k.get(i), k.get(i + 1)) else { break };
if !(m.is(b"tdmn") && p.is(b"pard")) {
i += 1;
continue;
}
let name = tdmn_name(m).unwrap_or_default();
let mut j = i + 2;
let mut pdnm = None;
let mut data = None;
while let Some(c) = k.get(j) {
if c.is(b"tdmn") {
break;
}
if c.is(b"pdnm") {
pdnm = c.children().iter().find(|u| u.is(b"Utf8")).and_then(|u| u.bytes()).map(|b| String::from_utf8_lossy(b).to_string());
}
if c.is(b"aRbp") {
data = c.bytes();
}
j += 1;
}
if let Some(bytes) = p.bytes() {
out.insert(position, ParDef { record: name, ..ParDef::from_pard(bytes, pdnm.as_deref(), data) });
}
position = position.saturating_add(1);
i = j;
}
out
}
/// The parameter definitions of every effect type, by match name (read from the default
/// instances built from the effect table).
fn defs() -> &'static HashMap<String, HashMap<u16, ParDef>> {
static DEFS: OnceLock<HashMap<String, HashMap<u16, ParDef>>> = OnceLock::new();
DEFS.get_or_init(|| {
crate::export::fx_defs::FX_DEFS.iter().map(|fx| (fx.match_name.to_string(), par_defs(&crate::export::fxsynth::default_sspc(fx)))).collect()
})
}
// ---------------------------------------------------------------------------------------------
// Planning.
/// The stored components of an EffectCraft value, `None` when it does not fit.
fn stored(conv: Conv, kind: PKind, v: &Value, size: (f64, f64), ui: &ParamUi, def: &ParDef) -> Option<Vec<f64>> {
let c = v.components();
let g = |i: usize| c.get(i).copied().and_then(sane);
let one = |x: f64| -> Vec<f64> {
match kind {
// The float32 After Effects keeps (393, 394).
PKind::Float => vec![f64::from(x as f32)],
PKind::Int => vec![x.round()],
_ => vec![x],
}
};
Some(match conv {
Conv::Color => {
let ch = |i: usize| g(i).map(|x| x.clamp(0.0, 1.0) * 255.0);
vec![255.0, ch(0)?, ch(1)?, ch(2)?]
}
Conv::Point => vec![g(0)? / size.0.max(1.0), g(1)? / size.1.max(1.0)],
Conv::Percent { valid_max } => one(g(0)? * valid_max / 100.0),
Conv::Plain { .. } | Conv::Angle => one(g(0)?),
Conv::Factor { factor, .. } => one(g(0)? / factor),
Conv::Checkbox => match v {
Value::Bool(b) => vec![if *b { 1.0 } else { 0.0 }],
_ => vec![if g(0)? != 0.0 { 1.0 } else { 0.0 }],
},
Conv::Popup { items, .. } => {
let i = match v {
Value::Enum(i) => usize::try_from(*i).ok()?,
_ => usize::try_from(g(0)?.round() as i64).ok()?,
};
let ParamUi::Popup { options } = ui else { return None };
let name = options.get(i)?;
let file: Vec<&str> = if def.items.is_empty() { items.to_vec() } else { def.items.iter().map(String::as_str).collect() };
let at = file.iter().position(|f| f.trim() != "(-" && popup_key(f) == popup_key(name))?;
vec![f64::from(u32::try_from(at).ok()?.checked_add(1)?)]
}
// Not a value record (layer parameters, histograms).
Conv::LayerRef { .. } | Conv::Skip => return None,
})
}
/// Whether a popup's items are After Effects' items in the same order (separators included, as
/// After Effects counts them), so an expression's 1-based result picks the same item in both.
fn same_popup_order(items: &[&str], ui: &ParamUi, def: &ParDef) -> bool {
let ParamUi::Popup { options } = ui else { return false };
let file: Vec<&str> = if def.items.is_empty() { items.to_vec() } else { def.items.iter().map(String::as_str).collect() };
file.len() == options.len() && file.iter().zip(options).all(|(f, o)| f.trim() != "(-" && popup_key(f) == popup_key(o))
}
/// [`stored`], reporting what it changes in a value it can store: a colour channel outside 0 to 1
/// (written as the nearest limit) and a fraction on an integer parameter (rounded).
fn stored_reported(conv: Conv, kind: PKind, v: &Value, (size, ui, def): ((f64, f64), &ParamUi, &ParDef), what: &str, col: &mut Collector) -> Option<Vec<f64>> {
let s = stored(conv, kind, v, size, ui, def)?;
if matches!(conv, Conv::Color) {
let c = v.components();
if c.iter().take(3).any(|x| !(0.0..=1.0).contains(x)) {
col.lose(LossCategory::InvalidValues, format!("{what}: a colour channel outside 0 to 1 is written as the nearest limit"));
}
// After Effects' colour parameters have no opacity (written as 255); Drop Shadow, for
// one, uses EffectCraft's.
if c.get(3).is_some_and(|a| a.to_bits() != 1f64.to_bits()) {
col.lose(LossCategory::Effects, format!("{what}: the colour's opacity is not written"));
}
}
if kind == PKind::Int
&& let Some(raw) = stored(conv, PKind::Fixed, v, size, ui, def)
&& raw.iter().zip(&s).any(|(a, b)| a.to_bits() != b.to_bits())
{
col.lose(LossCategory::Effects, format!("{what}: {raw:?} is written as the whole number {s:?}"));
}
Some(s)
}
fn is_default(kind: PKind, s: &[f64], def: &ParDef) -> bool {
let tol = |a: f64, b: f64| {
let t = match kind {
// 16.16 defaults.
PKind::Fixed | PKind::Angle | PKind::Point => 1.0 / 65536.0,
_ => 1e-9 * a.abs().max(b.abs()).max(1.0),
};
(a - b).abs() <= t
};
match kind {
// Alpha is not editable in After Effects; the RGB channels decide.
PKind::Color => s.get(1..4).zip(def.default.get(1..4)).is_some_and(|(a, b)| a.iter().zip(b).all(|(x, y)| tol(*x, *y))),
_ => s.len() == def.default.len() && s.iter().zip(&def.default).all(|(a, b)| tol(*a, *b)),
}
}
/// Plans the effects of `layer` (its source is `size` pixels) and reports what cannot be written.
pub(crate) fn plan_effects(layer: &Layer, size: (u32, u32), cp: &CompPlan, comp_name: &str, col: &mut Collector) -> Vec<EffectPlan> {
let Some(fx) = layer.effects() else { return Vec::new() };
let size = (f64::from(size.0.max(1)), f64::from(size.1.max(1)));
let mut out = Vec::new();
for node in &fx.children {
let Node::Group(g) = node else { continue };
let here = format!("{comp_name}: '{}' effect '{}'", layer.name, g.name);
let GroupKind::Effect { effect } = &g.kind else {
col.lose(LossCategory::Effects, format!("{here}: not an effect group"));
continue;
};
let (Some(spec), Some(pdefs)) = (by_effect(effect), by_effect(effect).and_then(|s| defs().get(s.match_name))) else {
col.lose(LossCategory::Effects, format!("{comp_name}: '{}' has the effect '{}' ({effect})", layer.name, g.name));
continue;
};
if g.label.is_some() {
col.lose(LossCategory::Effects, format!("{here}: the effect's label colour is not written"));
}
let mut params = Vec::new();
let mut layers = Vec::new();
for pm in spec.params {
let Some(prop) = g.prop(pm.target) else { continue };
let Ok(index) = u16::try_from(pm.index) else { continue };
if let Conv::LayerRef { own } = pm.conv {
if let Some(pick) = plan_layer_param(layer, g, pm.target, own, prop, &here, col) {
layers.push((index, pick));
}
continue;
}
let (Some(kind), Some(def)) = (PKind::of(pm.kind), pdefs.get(&index)) else {
col.lose(LossCategory::Effects, format!("{here}: parameter '{}' has no prototype definition", prop.name));
continue;
};
if def.code != kind.code() {
col.lose(LossCategory::Effects, format!("{here}: parameter '{}': the prototype has another type", prop.name));
continue;
}
if let Some(pp) = plan_param(ParamIn { conv: pm.conv, kind, def, prop, size }, index, cp, &here, col) {
params.push(pp);
}
}
let data = match spec.extra {
Extra::Curves => plan_curves(g, pdefs, &here, col),
Extra::MeshWarp => plan_mesh(g, pdefs, size, &here, col),
_ => plan_extra(spec, g, pdefs, &here, col),
};
report_unmapped(g, (effect, spec), size, &here, col);
col.exported.effects = col.exported.effects.saturating_add(1);
out.push(EffectPlan {
match_name: spec.match_name,
name: crate::export::plan::cut_name(&g.name, 255, &here, col),
params,
data,
layers,
enabled: g.enabled,
});
}
out
}
/// A layer parameter (the importer's inverse, `Conv::LayerRef`): an unset layer is None where the
/// owning layer is EffectCraft's own default (`own`, Layer Control) and the owning layer
/// elsewhere (Compound Blur, Displacement Map, Set Matte: unset means "this layer" in both
/// programs); `None` = the default, the owning layer.
fn plan_layer_param(layer: &Layer, g: &PropGroup, target: &str, own: bool, prop: &Property, here: &str, col: &mut Collector) -> Option<LayerPick> {
let what = format!("{here}: parameter '{}'", prop.name);
if !prop.keys.is_empty() || prop.has_expression() {
col.lose(LossCategory::Effects, format!("{what}: keys or an expression on a layer parameter are not written, its value is"));
}
let pick = match prop.value {
Value::Layer(None) if own => LayerPick::Nothing,
Value::Layer(None) => LayerPick::Owner,
Value::Layer(Some(effectcraft_project::NO_LAYER)) => LayerPick::Nothing,
Value::Layer(Some(id)) if id == layer.id.0 => LayerPick::Owner,
Value::Layer(Some(id)) => LayerPick::Layer(LayerId(id)),
_ => {
col.lose(LossCategory::InvalidValues, format!("{what}: not a layer, the default was written"));
LayerPick::Owner
}
};
// After Effects hands the named layer's source to the effect (904 E24, the importer's
// companion `Source` = 0); another choice is not sampled. Layer Control (`own`) has no
// source choice in After Effects: it only names the layer.
if let LayerPick::Layer(_) = pick
&& !own
&& g.prop(&format!("{target}Source")).is_some_and(|s| !matches!(s.value, Value::Enum(0)))
{
col.lose(LossCategory::Effects, format!("{what}: the named layer's masks and effects are not applied in After Effects (it takes the layer's source)"));
}
(pick != LayerPick::Owner).then_some(pick)
}
/// Hue/Saturation's master values or Levels' RGB values, as new arbitrary data (the prototype's
/// default data with the values patched in); `None` when they are at the default.
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::Lumetri | Extra::Cryptomatte | Extra::Extractor | Extra::Identifier => return None,
Extra::HueSaturation => &HUE_SATURATION_MASTER,
Extra::Levels => &LEVELS_RGB,
};
let mut values = Vec::with_capacity(ids.len());
for id in ids {
let v = g.prop(id).and_then(|p| {
if !p.keys.is_empty() || p.has_expression() {
col.lose(
LossCategory::Effects,
format!("{here}: keys or an expression on '{}' are not written (arbitrary data, the static value is written)", p.name),
);
}
let v = p.value.components().first().copied();
if v.is_some_and(|v| sane(v).is_none()) {
col.lose(LossCategory::InvalidValues, format!("{here}: '{}' is beyond the format's range, the default is written", p.name));
}
v.and_then(sane)
});
values.push(v);
}
let (index, mut data) = pdefs.iter().find(|(_, d)| d.code == 11).and_then(|(i, d)| d.data.clone().map(|data| (*i, data)))?;
let before = data.clone();
for (k, (id, v)) in ids.iter().zip(values).enumerate() {
let Some(v) = v else { continue };
if spec.extra == Extra::HueSaturation {
// Three i32 at 0, 4, 8 (444-446): whole degrees and percents.
let r = v.round().clamp(-1e6, 1e6);
if (r - v).abs() > 1e-9 {
col.lose(LossCategory::Effects, format!("{here}: '{id}' {v} is written as {r} (After Effects keeps whole numbers)"));
}
put(&mut data, k * 4, &(r as i32).to_be_bytes()).ok()?;
} else {
// The RGB channel's five f32 at 8.. (512-516).
put(&mut data, 8 + k * 4, &(v as f32).to_be_bytes()).ok()?;
}
}
(data != before).then_some((index, data))
}
/// `x,y` pairs (EffectCraft's curve points and mesh offsets; whitespace or `;` between pairs).
fn pairs_of(s: &str) -> Vec<(f64, f64)> {
s.split(|c: char| c.is_whitespace() || c == ';')
.filter(|t| !t.is_empty())
.filter_map(|t| {
let (x, y) = t.split_once(',')?;
let (x, y) = (x.trim().parse::<f64>().ok()?, y.trim().split(',').next()?.trim().parse::<f64>().ok()?);
(x.is_finite() && y.is_finite()).then_some((x, y))
})
.collect()
}
/// The arbitrary-data parameter's index and default data.
fn data_param(pdefs: &HashMap<u16, ParDef>) -> Option<(u16, Vec<u8>)> {
pdefs.iter().find(|(_, d)| d.code == 11).and_then(|(i, d)| d.data.clone().map(|data| (*i, data)))
}
fn static_str<'p>(g: &'p PropGroup, id: &str, here: &str, col: &mut Collector) -> Option<&'p str> {
let p = g.prop(id)?;
if !p.keys.is_empty() || p.has_expression() {
col.lose(LossCategory::Effects, format!("{here}: keys or an expression on '{}' are not written (arbitrary data, the static value is written)", p.name));
}
match &p.value {
Value::Str(s) => Some(s.as_str()),
_ => {
col.lose(LossCategory::InvalidValues, format!("{here}: '{}' is not text, the default is written", p.name));
None
}
}
}
/// Curves (FORMAT S.1): EffectCraft's points (0..1) as After Effects' whole 0..255 points, with
/// the lookup tables After Effects stores for them (the natural spline); `None` at the default.
fn plan_curves(g: &PropGroup, pdefs: &HashMap<u16, ParDef>, here: &str, col: &mut Collector) -> Option<(u16, Vec<u8>)> {
let mut curves: [Vec<(u8, u8)>; 5] = Default::default();
for (slot, id) in curves.iter_mut().zip(["rgb", "red", "green", "blue", "alpha"]) {
let pts = static_str(g, id, here, col).map(pairs_of).unwrap_or_default();
let mut ints: Vec<(u8, u8)> = Vec::with_capacity(pts.len());
let (mut rounded, mut clipped) = (false, false);
for (x, y) in pts {
let (x, y) = (x * 255.0, y * 255.0);
let (xs, ys) = (x.clamp(0.0, 255.0), y.clamp(0.0, 255.0));
clipped |= xs.to_bits() != x.to_bits() || ys.to_bits() != y.to_bits();
rounded |= (xs - xs.round()).abs() > 1e-3 || (ys - ys.round()).abs() > 1e-3;
ints.push((xs.round() as u8, ys.round() as u8));
}
// Points with the same input are merged as EffectCraft's own curve merges them (the
// first is kept), and fewer than two points are the identity there too.
ints.sort_by_key(|p| p.0);
ints.dedup_by_key(|p| p.0);
if clipped {
col.lose(LossCategory::Effects, format!("{here}: curve '{id}' points outside 0 to 1 are written as the nearest limit"));
}
if rounded {
col.lose(LossCategory::Effects, format!("{here}: curve '{id}' points rounded to whole levels (After Effects keeps 0..255)"));
}
if ints.len() > 16 {
col.lose(LossCategory::Effects, format!("{here}: curve '{id}' has {} points, After Effects keeps 16; the first 16 are written", ints.len()));
ints.truncate(16);
}
*slot = if ints.len() < 2 { CURVE_IDENTITY.to_vec() } else { ints };
}
let d = CurvesData { curves };
if d.is_default() {
return None;
}
let (index, _) = data_param(pdefs)?;
match d.to_bytes() {
Some(bytes) => Some((index, bytes)),
None => {
col.lose(LossCategory::Effects, format!("{here}: the curves cannot be written, the default is"));
None
}
}
}
/// Mesh Warp (FORMAT S.2): EffectCraft's per-vertex pixel offsets as After Effects' vertices
/// (fractions of the layer) with their default handles moved along; `None` at the default.
fn plan_mesh(g: &PropGroup, pdefs: &HashMap<u16, ParDef>, size: (f64, f64), here: &str, col: &mut Collector) -> Option<(u16, Vec<u8>)> {
let int = |id: &str| g.prop(id).map(|p| p.value.as_f64().round().clamp(1.0, 31.0) as u32).unwrap_or(7);
let (rows, columns) = (int("rows"), int("columns"));
// Per vertex `dx,dy` (pixels), optionally followed by the eight pixel moves of its handles
// (top, right, bottom, left) that the importer keeps.
let offs: Vec<Vec<f64>> = static_str(g, "mesh", here, col)
.map(|s| {
s.split(|c: char| c.is_whitespace() || c == ';')
.filter(|t| !t.is_empty())
.filter_map(|t| {
let v: Vec<f64> = t.split(',').map(|x| x.trim().parse::<f64>().ok().filter(|x| x.is_finite())).collect::<Option<_>>()?;
(v.len() >= 2).then_some(v)
})
.collect()
})
.unwrap_or_default();
let count = usize::try_from((rows + 1) * (columns + 1)).ok()?;
let moved = offs.iter().any(|v| v.iter().any(|x| *x != 0.0));
if !moved && rows == 7 && columns == 7 {
return None;
}
if moved && offs.len() != count {
col.lose(
LossCategory::Effects,
format!("{here}: the Distortion Mesh has {} vertices for {columns} x {rows} cells, an undistorted mesh is written", offs.len()),
);
}
let (index, default) = data_param(pdefs)?;
let tail = MeshData::parse(&default).map(|m| m.tail).unwrap_or([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x0d, 0x0d]);
let mut vertices = Vec::with_capacity(count);
for k in 0..count {
let k32 = u32::try_from(k).ok()?;
let (i, j) = (k32 % (columns + 1), k32 / (columns + 1));
let mut v = MeshData::default_vertex(columns, rows, i, j);
if offs.len() == count
&& let Some(o) = offs.get(k)
{
// A moved vertex carries its handles along (985), then each handle its own move.
let at = |n: usize| o.get(n).copied().unwrap_or(0.0);
let (fx, fy) = ((at(0) / size.0) as f32, (at(1) / size.1) as f32);
for (n, p) in v.iter_mut().enumerate() {
if n == 0 || *p != [0.0, 0.0] {
let (hx, hy) = if n == 0 { (0.0, 0.0) } else { ((at(2 * n) / size.0) as f32, (at(2 * n + 1) / size.1) as f32) };
*p = [p[0] + fx + hx, p[1] + fy + hy];
}
}
}
vertices.push(v);
}
MeshData { columns, rows, tail, vertices }.to_bytes().map(|b| (index, b))
}
/// EffectCraft parameters the table does not map are lost when they carry anything.
/// Reports the parameters of `g` the table has no record for: with keys or an expression, or at
/// a value other than the one a new instance of the effect has.
fn report_unmapped(g: &PropGroup, (effect, spec): (&str, &EffectMap), size: (f64, f64), here: &str, col: &mut Collector) {
let mut next = 1;
let fresh = |size: [f64; 2], next: &mut u64| {
effectcraft_effects::find(effect).map(|s| effectcraft_effects::instantiate(s, &mut effectcraft_project::build::Ids(next), &g.name, size))
};
// A default that depends on the layer size (a centre point) is not compared: the size an
// 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 {
// 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"],
Extra::MeshWarp => &["mesh"],
};
let mut stack: Vec<(&PropGroup, String)> = vec![(g, String::new())];
let mut steps = 0usize;
while let Some((grp, prefix)) = stack.pop() {
steps += 1;
if steps > 10_000 {
break;
}
for n in &grp.children {
match n {
Node::Group(sub) => stack.push((sub, format!("{prefix}{}/", sub.match_id))),
Node::Prop(p) => {
let path = format!("{prefix}{}", p.match_id);
// A layer parameter's source companion is checked with the parameter (`plan_layer_param`).
let companion = spec.params.iter().any(|s| matches!(s.conv, Conv::LayerRef { .. }) && path == format!("{}Source", s.target));
if spec.params.iter().any(|s| s.target == path) || extra.contains(&path.as_str()) || companion {
continue;
}
if !p.keys.is_empty() || p.has_expression() {
col.lose(LossCategory::Effects, format!("{here}: parameter '{}' has no After Effects counterpart", p.name));
} else if let Some(d) = fresh.as_ref().and_then(|f| f.prop(&path))
&& other.as_ref().and_then(|f| f.prop(&path)).is_some_and(|o| o.value == d.value)
&& d.value != p.value
{
col.lose(LossCategory::Effects, format!("{here}: parameter '{}' has no After Effects counterpart, its value is not written", p.name));
}
}
}
}
}
}
/// One effect parameter to plan: its table conversion, stored kind, prototype definition and
/// property, and the layer's source size.
#[derive(Clone, Copy)]
struct ParamIn<'a> {
conv: Conv,
kind: PKind,
def: &'a ParDef,
prop: &'a Property,
size: (f64, f64),
}
fn plan_param(pin: ParamIn<'_>, index: u16, cp: &CompPlan, here: &str, col: &mut Collector) -> Option<ParamPlan> {
let ParamIn { conv, kind, def, prop, size } = pin;
let what = format!("{here}: parameter '{}'", prop.name);
// The table's scale was verified with this valid maximum (the importer checks the same).
let vm = match conv {
Conv::Percent { valid_max } | Conv::Plain { valid_max } | Conv::Factor { valid_max, .. } => Some(valid_max),
_ => None,
};
if let (Some(want), Some(found)) = (vm, def.valid_max)
&& (want - found).abs() > want.abs() * 1e-6
{
col.lose(LossCategory::Effects, format!("{what}: the prototype's scale differs from the table's, not written"));
return None;
}
let expr = prop.expr.as_ref().filter(|e| !e.text.is_empty()).map(|e| (e.text.clone(), e.enabled));
if expr.is_some() {
col.exported.expressions = col.exported.expressions.saturating_add(1);
// The expression is written as it is; where the stored value is scaled (a percent or
// factor parameter has other units) or a popup's items are in another order, its result
// means something else in After Effects. Popups count from 1 in both programs' expressions.
let other_units = match conv {
Conv::Popup { items, .. } => !same_popup_order(items, &prop.ui, def),
Conv::Percent { valid_max } => (valid_max - 100.0).abs() > 1e-9,
Conv::Factor { factor, .. } => (factor - 1.0).abs() > 1e-9,
_ => false,
};
if other_units {
col.lose(LossCategory::Expressions, format!("{what}: the expression is written, but After Effects reads its result in other units"));
}
}
if prop.keys.is_empty() {
let Some(s) = stored_reported(conv, kind, &prop.value, (size, &prop.ui, def), &what, col) else {
if matches!(conv, Conv::Popup { .. }) {
col.lose(LossCategory::Effects, format!("{what}: the popup item has no After Effects counterpart, the default was written"));
} else {
col.lose(LossCategory::InvalidValues, format!("{what}: a value that cannot be stored, the default was written"));
}
return None;
};
// A property with an expression keeps its record, also at the default (R15).
if is_default(kind, &s, def) && expr.is_none() {
return None;
}
return Some(ParamPlan { index, kind, value: ParamValue::Static(s), expr });
}
if !matches!(kind, PKind::Fixed | PKind::Float | PKind::Color | PKind::Point) {
col.lose(
LossCategory::Effects,
format!("{what}: keys on a popup, checkbox, angle or integer slider are not written (not sampled), the first key's value was written"),
);
let first = prop.keys.first().map(|k| &k.value).unwrap_or(&prop.value);
let s = stored_reported(conv, kind, first, (size, &prop.ui, def), &what, col)?;
return (!is_default(kind, &s, def) || expr.is_some()).then_some(ParamPlan { index, kind, value: ParamValue::Static(s), expr });
}
let keys = plan_keys(pin, cp, &what, col);
if keys.is_empty() {
return None;
}
col.exported.keyframes = col.exported.keyframes.saturating_add(keys.len());
Some(ParamPlan { index, kind, value: ParamValue::Keys(keys), expr })
}
fn plan_keys(pin: ParamIn<'_>, cp: &CompPlan, what: &str, col: &mut Collector) -> Vec<KeyPlan> {
let ParamIn { conv, kind, def, prop, size } = pin;
let keys: &[Keyframe] = &prop.keys;
let mut timed: Vec<(i64, &Keyframe)> = Vec::with_capacity(keys.len());
for k in keys {
let (u, inexact) = cp.tb.units(k.time);
if inexact {
col.lose(LossCategory::RoundedTimes, format!("{what}: key at {:.4} s moved to the time base", k.time.seconds()));
}
timed.push((u, k));
}
timed.sort_by_key(|(u, _)| *u);
let before = timed.len();
timed.dedup_by_key(|(u, _)| *u);
if timed.len() != before {
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: keys at the same time were merged"));
}
// The ease of colour keys is sampled only as speed 0 / influence 0, which After Effects
// interpolates linearly (598): a colour key is written linear.
let ease_ok = matches!(kind, PKind::Fixed | PKind::Float | PKind::Point);
// Ease speeds are in stored units per second; a point's in pixels per second over the layer
// height (597, 598, 730).
let factor = match conv {
Conv::Percent { valid_max } => 100.0 / valid_max,
Conv::Factor { factor, .. } => factor,
Conv::Point => size.1.max(1.0),
_ => 1.0,
};
let mut out = Vec::with_capacity(timed.len());
let beyond = std::cell::Cell::new(false);
for (u, k) in timed {
let Ok(time) = i32::try_from(u) else {
col.lose(LossCategory::InvalidValues, format!("{what}: key time out of range, key dropped"));
continue;
};
let Some(value) = stored_reported(conv, kind, &k.value, (size, &prop.ui, def), what, col) else {
col.lose(LossCategory::InvalidValues, format!("{what}: a key value that cannot be stored, key dropped"));
continue;
};
let mut side = |i: Interp| -> u8 {
match i {
Interp::Linear => 1,
Interp::Hold => 3,
Interp::Bezier if ease_ok => 2,
Interp::Bezier => {
col.lose(
LossCategory::ApproximatedKeyframes,
format!("{what}: bezier ease of a colour key is written as linear (only speed 0 / influence 0 is sampled)"),
);
1
}
}
};
let (in_i, out_i) = (side(k.in_interp), side(k.out_interp));
let ease = |eases: &[effectcraft_keyframe::Ease], on: bool| -> Vec<EaseSlot> {
if !on {
return vec![EaseSlot::default()];
}
let e = crate::export::plan::key_ease(eases, 0);
let speed = sane(e.speed / factor).unwrap_or_else(|| {
beyond.set(true);
0.0
});
let influence = if e.influence.is_finite() { e.influence.clamp(0.001, 1.0) } else { 1.0 / 3.0 };
vec![EaseSlot { speed, influence }]
};
let (mut tin, mut tout) = ([0.0; 3], [0.0; 3]);
if kind == PKind::Point {
for (t, s) in [(&mut tin, k.spatial_in), (&mut tout, k.spatial_out)] {
for (o, v) in t.iter_mut().zip([s[0] / size.0, s[1] / size.1]) {
*o = sane(v).unwrap_or_else(|| {
beyond.set(true);
0.0
});
}
}
}
out.push(KeyPlan {
time,
value,
in_interp: in_i,
out_interp: out_i,
in_ease: ease(&k.in_ease, in_i == 2),
out_ease: ease(&k.out_ease, out_i == 2),
spatial_in: tin,
spatial_out: tout,
spatial_auto: kind != PKind::Point || k.spatial_auto,
roving: false,
label: k.label.min(16),
});
if k.roving {
col.lose(LossCategory::ApproximatedKeyframes, format!("{what}: a roving key is written as a normal key"));
}
}
crate::export::plan::report_beyond(beyond.get(), what, col);
out
}
// ---------------------------------------------------------------------------------------------
// Emit.
/// The effect prototypes, built in code ([`FxProtos::build`]).
pub(crate) struct FxProtos {
/// match name -> (`tdmn`, default `sspc`, `EfDf` definition copy).
defaults: HashMap<String, (Chunk<'static>, Chunk<'static>, Chunk<'static>)>,
/// `pard` type code -> the static value record of that type.
kinds: HashMap<u32, Chunk<'static>>,
/// Keyed value records by kind.
keyed: Vec<(KeyedKind, Chunk<'static>)>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum KeyedKind {
Fixed,
Color,
Point,
Float,
}
fn keyed_kind(k: PKind) -> Option<KeyedKind> {
match k {
PKind::Fixed => Some(KeyedKind::Fixed),
PKind::Color => Some(KeyedKind::Color),
PKind::Point => Some(KeyedKind::Point),
PKind::Float => Some(KeyedKind::Float),
_ => None,
}
}
pub(crate) fn tdmn_name(c: &Chunk<'_>) -> Option<String> {
let d = c.bytes()?;
Some(d.iter().take_while(|b| **b != 0).map(|b| char::from(*b)).collect())
}
impl FxProtos {
/// The prototypes built in code from the effect table (`fx_defs`, `fxsynth`).
pub(crate) fn build() -> FxProtos {
use crate::export::fxsynth::value_spec;
let mut defaults = HashMap::new();
for fx in crate::export::fx_defs::FX_DEFS {
let sspc = crate::export::fxsynth::default_sspc(fx);
let m = crate::export::synth::tdmn(fx.match_name);
let efdf = Chunk::list(*b"EfDf", vec![m.clone(), sspc.clone()]);
defaults.insert(fx.match_name.to_string(), (m, sspc, efdf));
}
let kinds = [1u8, 2, 3, 4, 5, 6, 7, 10]
.iter()
.filter_map(|c| value_spec(*c, None).map(|s| (u32::from(*c), crate::export::synth::tdbs(&s, false, 25_600))))
.collect();
let keyed = [(KeyedKind::Fixed, 2u8), (KeyedKind::Color, 5), (KeyedKind::Point, 6), (KeyedKind::Float, 10)]
.iter()
.filter_map(|(k, c)| value_spec(*c, None).map(|s| (*k, crate::export::synth::tdbs(&s, true, 25_600))))
.collect();
FxProtos { defaults, kinds, keyed }
}
}
/// Which effect types already have an instance with full definitions in the file, in the order
/// they first appear.
#[derive(Default)]
pub(crate) struct FxUse {
pub types: Vec<&'static str>,
}
/// A `tdsn` chunk holding `text`.
fn tdsn(text: &str) -> Chunk<'static> {
nested(*b"tdsn", text)
}
/// A chunk that holds one `Utf8` chunk (`tdsn`, `fnam`).
fn nested(id: [u8; 4], text: &str) -> Chunk<'static> {
Chunk { id, body: Body::Chunks(vec![Chunk::data(*b"Utf8", text.as_bytes().to_vec())]), pad: None }
}
/// The value record of `p`, built from the record of its parameter type.
pub(crate) fn value_record(protos: &FxProtos, p: &ParamPlan, def: &ParDef, source_size: (u32, u32)) -> R<(Chunk<'static>, Chunk<'static>)> {
let mut rec = protos.kinds.get(&p.kind.code()).cloned().ok_or_else(|| tpl("effect value prototype missing"))?;
{
let kids = kids_mut(&mut rec)?;
if let Some(slot) = kids.iter_mut().find(|c| c.is(b"tdsn"))
&& !def.name.is_empty()
{
*slot = tdsn(&def.name);
}
}
if p.kind == PKind::Point {
// The layer's aspect, as on the Anchor Point (369: 16 / 9, 383: 1 on 100 x 100).
put_f64(child_data(&mut rec, b"tdb4")?, 24, f64::from(source_size.0) / f64::from(source_size.1.max(1)))?;
}
if let Some((lo, hi)) = def.range {
put_f64(child_data(&mut rec, b"tdum")?, 0, lo)?;
put_f64(child_data(&mut rec, b"tduM")?, 0, hi)?;
}
match &p.value {
ParamValue::Static(v) => {
let cdat = child_data(&mut rec, b"cdat")?;
cdat.iter_mut().for_each(|b| *b = 0);
for (i, x) in v.iter().enumerate() {
put_f64(cdat, i * 8, *x)?;
}
}
ParamValue::Keys(keys) => {
let kk = keyed_kind(p.kind).ok_or_else(|| tpl("keys on a parameter kind without a keyed prototype"))?;
let proto = protos.keyed.iter().find(|(k, _)| *k == kk).map(|(_, c)| c).ok_or_else(|| tpl("keyed effect prototype missing"))?;
let mut list = proto.child_list(b"list").cloned().ok_or_else(|| tpl("keyed effect prototype without keys"))?;
{
// Static and keyed records of the same parameter differ in `tdb4` bytes 5 (the
// static bit), 68 (has keys) and 79 (spatial kinds only): taken from the keyed
// record of the kind (362 / 363, 359 / 364, 369 / 382, 391 / 397).
let ktdb4 = proto.child(b"tdb4").and_then(|c| c.bytes()).ok_or_else(|| tpl("keyed effect prototype without tdb4"))?;
let tdb4 = child_data(&mut rec, b"tdb4")?;
for at in [5usize, 68, 79] {
put_u8(tdb4, at, *ktdb4.get(at).ok_or_else(|| tpl("short tdb4"))?)?;
}
}
let n = u32::try_from(keys.len()).map_err(|_| tpl("too many keys"))?;
let dims = p.kind.dims();
let size = match kk {
KeyedKind::Fixed | KeyedKind::Float => 8 + 40 * dims,
KeyedKind::Color | KeyedKind::Point => 56 + 24 * dims,
};
{
let lhd3 = child_data(&mut list, b"lhd3")?;
crate::export::synth::put_key_counts(lhd3, n, u32::try_from(size).map_err(|_| tpl("record size"))?)?;
}
let mut ldat = Vec::with_capacity(size * keys.len());
for k in keys {
ldat.extend(match kk {
KeyedKind::Fixed | KeyedKind::Float => crate::export::emit::plain_record(1, k, size)?,
KeyedKind::Color | KeyedKind::Point => spatial_record(kk, k, dims, size)?,
});
}
{
let lk = kids_mut(&mut list)?;
match lk.iter_mut().find(|c| c.is(b"ldat")) {
Some(c) => *c = Chunk::data(*b"ldat", ldat),
None => lk.push(Chunk::data(*b"ldat", ldat)),
}
}
let kids = kids_mut(&mut rec)?;
let at = kids.iter().position(|c| c.is(b"cdat")).ok_or_else(|| tpl("value record without cdat"))?;
if let Some(slot) = kids.get_mut(at) {
*slot = list;
}
}
}
if let Some((text, enabled)) = &p.expr {
// `tdb4@120` = 1 and the text before `tdum` (534; unlike the Transform properties,
// bytes 76 and 77 stay 0); `@119` the disabled switch as on the Transform properties (R15).
{
let tdb4 = child_data(&mut rec, b"tdb4")?;
put_u8(tdb4, 120, 1)?;
put_u8(tdb4, 119, u8::from(!*enabled))?;
}
let kids = kids_mut(&mut rec)?;
let at = kids.iter().position(|c| c.is(b"tdum")).unwrap_or(kids.len());
kids.insert(at, Chunk::data(*b"Utf8", text.as_bytes().to_vec()));
}
Ok((Chunk::data(*b"tdmn", tdmn_bytes(&def.record)), rec))
}
/// A key of a colour (flags `0x0001`, 210, 364) or point (flags `0x0007`, `u32@8` the automatic
/// tangent switch, 382) parameter: the spatial layout with the value at 56.
fn spatial_record(kk: KeyedKind, k: &KeyPlan, n: usize, size: usize) -> R<Vec<u8>> {
let mut r = vec![0u8; size];
put(&mut r, 0, &k.time.to_be_bytes())?;
put_u8(&mut r, 4, k.in_interp)?;
put_u8(&mut r, 5, k.out_interp)?;
let base: u16 = if kk == KeyedKind::Point { 0x0007 } else { 0x0001 };
put_u16(&mut r, 6, base | (u16::from(k.label) << 6))?;
if kk == KeyedKind::Point {
put_u32(&mut r, 8, u32::from(k.spatial_auto))?;
// The temporal ease (598): speed in layer heights per second, influence a fraction.
let (i, o) = (k.in_ease.first().copied().unwrap_or_default(), k.out_ease.first().copied().unwrap_or_default());
put_f64(&mut r, 24, i.speed)?;
put_f64(&mut r, 32, i.influence)?;
put_f64(&mut r, 40, o.speed)?;
put_f64(&mut r, 48, o.influence)?;
}
for j in 0..n {
put_f64(&mut r, 56 + 8 * j, k.value.get(j).copied().unwrap_or(0.0))?;
if kk == KeyedKind::Point {
put_f64(&mut r, 56 + 8 * n + 8 * j, k.spatial_in.get(j).copied().unwrap_or(0.0))?;
put_f64(&mut r, 56 + 16 * n + 8 * j, k.spatial_out.get(j).copied().unwrap_or(0.0))?;
}
}
Ok(r)
}
/// A match name padded to the 40 bytes of `tdmn`.
fn tdmn_bytes(name: &str) -> Vec<u8> {
let mut v = vec![0u8; 40];
for (i, b) in name.bytes().take(39).enumerate() {
if let Some(slot) = v.get_mut(i) {
*slot = b;
}
}
v
}
/// The `tdmn` + `sspc` of one instance on the layer with id `layer_id`.
fn instance(
protos: &FxProtos,
fx: &EffectPlan,
layer_id: u32,
layer_ids: &HashMap<LayerId, u32>,
source_size: (u32, u32),
uses: &mut FxUse,
) -> R<Vec<Chunk<'static>>> {
let (tdmn, sspc, _) = protos.defaults.get(fx.match_name).ok_or_else(|| tpl("effect prototype missing"))?;
let mut sspc = sspc.clone();
let pdefs = par_defs(&sspc);
// `fnam` keeps the effect's own name; a name of the user's goes into the value group's
// `tdsn`, which holds the placeholder `-_0_/-` otherwise (610 against 609).
let own = child_mut(&mut sspc, b"fnam")?.children().iter().find(|c| c.is(b"Utf8")).and_then(|c| c.bytes()).map(|b| String::from_utf8_lossy(b).to_string());
if own.as_deref() != Some(fx.name.as_str()) && !fx.name.is_empty() {
let values = list_mut(&mut sspc, b"tdgp")?;
let slot = child_mut(values, b"tdsn")?;
*slot = nested(*b"tdsn", &fx.name);
}
// The fx switch: the value group's `tdsb` (`fx-switch` cases).
put_u32(child_data(list_mut(&mut sspc, b"tdgp")?, b"tdsb")?, 0, u32::from(fx.enabled))?;
let mut records = Vec::with_capacity(fx.params.len());
for p in &fx.params {
let def = pdefs.get(&p.index).ok_or_else(|| tpl("parameter definition missing"))?;
records.push((p.index, value_record(protos, p, def, source_size)?));
}
{
let values = list_mut(&mut sspc, b"tdgp")?;
let kids = kids_mut(values)?;
// The input layer (`-0000`) names the layer that owns the effect (320).
let tdpi_of = |kids: &mut Vec<Chunk<'static>>, record: &str| -> Option<usize> {
kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(record)).map(|i| i + 1)
};
let input = pdefs.get(&0).map(|d| d.record.clone()).unwrap_or_default();
// The prototype's own layer id (its input layer), to tell "the owning layer" from None.
let mut proto_owner = None;
if let Some(at) = tdpi_of(kids, &input)
&& let Some(node) = kids.get_mut(at)
{
let tdpi = child_data(node, b"tdpi")?;
proto_owner = tdpi.get(0..4).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes);
put_u32(tdpi, 0, layer_id)?;
}
// Every layer parameter's record exists in the prototype (its `tdpi` the prototype's
// default): a mapped one names what the plan says (the owning layer by default; a layer
// that is not exported was reported and left out of the plan in
// `plan::resolve_effect_layers`); an unmapped one keeps its default, the prototype's own
// layer becoming the owning layer and None (0) staying None (Timewarp's Matte / Warp
// Layer, 529).
let mapped: Vec<u16> = by_match_name(fx.match_name)
.map(|s| s.params.iter().filter(|p| matches!(p.conv, Conv::LayerRef { .. })).filter_map(|p| u16::try_from(p.index).ok()).collect())
.unwrap_or_default();
for (index, def) in &pdefs {
if *index == 0 || def.code != 0 {
continue;
}
let Some(at) = tdpi_of(kids, &def.record) else { continue };
let Some(node) = kids.get_mut(at) else { continue };
let tdpi = child_data(node, b"tdpi")?;
let id = if mapped.contains(index) {
match fx.layers.iter().find(|(i, _)| i == index).map(|(_, p)| *p) {
None | Some(LayerPick::Owner) => layer_id,
Some(LayerPick::Nothing) => 0,
Some(LayerPick::Layer(l)) => layer_ids.get(&l).copied().unwrap_or(layer_id),
}
} else {
let stored = tdpi.get(0..4).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes);
if stored.is_some() && stored == proto_owner { layer_id } else { stored.unwrap_or(0) }
};
put_u32(tdpi, 0, id)?;
}
// Arbitrary data: the `aRbp` of the `LIST aRbs` after the parameter's record.
if let Some((index, data)) = &fx.data {
let want = pdefs.get(index).map(|d| d.record.clone()).unwrap_or_default();
let i = kids
.iter()
.position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(want.as_str()))
.ok_or_else(|| tpl("arbitrary data record missing"))?;
let list = kids.iter_mut().skip(i + 1).take(3).find(|c| c.is_list(b"aRbs")).ok_or_else(|| tpl("arbitrary data missing"))?;
let arbp = child_mut(list, b"aRbp")?;
*data_mut(arbp)? = data.clone();
}
let position: HashMap<String, u16> = pdefs.iter().map(|(i, d)| (d.record.clone(), *i)).collect();
for (index, (m, r)) in records {
// Before the first record of a later parameter (or the built-in group, or the end).
let at = kids
.iter()
.enumerate()
.filter(|(_, c)| c.is(b"tdmn"))
.find(|(_, c)| {
let n = tdmn_name(c).unwrap_or_default();
position.get(&n).is_some_and(|i| *i > index) || n == "ADBE Effect Built In Params" || n == "ADBE Group End"
})
.map(|(i, _)| i)
.unwrap_or(kids.len());
kids.splice(at..at, [m, r]);
}
}
// A popup selection is also written into its `pard` default slot (@56, u32; 377, 390, 324).
if let Some(part) = sspc.children_mut().iter_mut().find(|c| c.is_list(b"parT")) {
for p in &fx.params {
let (PKind::Popup, ParamValue::Static(v)) = (p.kind, &p.value) else { continue };
let Some(sel) = v.first().map(|x| x.round()).filter(|x| *x >= 1.0 && *x <= 1000.0) else { continue };
let want = pdefs.get(&p.index).map(|d| d.record.clone()).unwrap_or_default();
let kids = kids_mut(part)?;
if let Some(i) = kids.iter().position(|c| c.is(b"tdmn") && tdmn_name(c).as_deref() == Some(want.as_str()))
&& let Some(pard) = kids.get_mut(i + 1)
{
put_u32(data_mut(pard)?, 56, sel as u32)?;
}
}
}
if uses.types.contains(&fx.match_name) {
// A later instance of a type: its definitions come from the project's copy (383).
let part = list_mut(&mut sspc, b"parT")?;
*part = Chunk::list(*b"parT", Vec::new());
} else {
uses.types.push(fx.match_name);
}
Ok(vec![tdmn.clone(), sspc])
}
/// The layer's `ADBE Effect Parade` (`tdmn` + `tdgp`), the effects in stack order.
pub(crate) fn build_parade(
protos: &FxProtos,
fxs: &[EffectPlan],
layer_id: u32,
layer_ids: &HashMap<LayerId, u32>,
source_size: (u32, u32),
uses: &mut FxUse,
) -> R<Vec<Chunk<'static>>> {
let mut kids = vec![Chunk::data(*b"tdsb", vec![0, 0, 0, 1]), tdsn("-_0_/-")];
for fx in fxs {
kids.extend(instance(protos, fx, layer_id, layer_ids, source_size, uses)?);
}
kids.push(Chunk::data(*b"tdmn", tdmn_bytes("ADBE Group End")));
Ok(vec![Chunk::data(*b"tdmn", tdmn_bytes("ADBE Effect Parade")), Chunk::list(*b"tdgp", kids)])
}
/// Writes the project-level effect lists: `Pefl` (one `pjef` per type, sorted by match name, 365)
/// and `EfdG` (the `EfDC` count, little-endian, and one `EfDf` per type in order of first use)
/// right after `Pefl`.
pub(crate) fn put_project_lists(protos: &FxProtos, file: &mut AepFile<'static>, uses: &FxUse) -> R<()> {
if uses.types.is_empty() {
return Ok(());
}
let at = file.chunks.iter().position(|c| c.is_list(b"Pefl")).ok_or_else(|| tpl("template without Pefl"))?;
let mut sorted: Vec<&str> = uses.types.clone();
sorted.sort_unstable();
if let Some(pefl) = file.chunks.get_mut(at) {
let kids = kids_mut(pefl)?;
kids.clear();
kids.extend(sorted.iter().map(|n| Chunk::data(*b"pjef", n.as_bytes().to_vec())));
}
let n = u32::try_from(uses.types.len()).map_err(|_| tpl("too many effect types"))?;
let mut efdg = vec![Chunk::data(*b"EfDC", n.to_le_bytes().to_vec())];
for t in &uses.types {
let (_, _, def) = protos.defaults.get(*t).ok_or_else(|| tpl("effect prototype missing"))?;
efdg.push(def.clone());
}
file.chunks.insert(at + 1, Chunk::list(*b"EfdG", efdg));
Ok(())
}