//! 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 { 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), Keys(Vec), } #[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, /// New arbitrary data for the parameter with this index (Hue/Saturation, Levels). pub data: Option<(u16, Vec)>, /// 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 { (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 (`-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, /// The default in stored units (`pard@56`). pub default: Vec, /// 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, /// The default arbitrary data (the `aRbp` after the `pard`). pub data: Option>, } fn be_i32(d: &[u8], at: usize) -> Option { Some(i32::from_be_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?)) } fn be_u32(d: &[u8], at: usize) -> Option { Some(u32::from_be_bytes(d.get(at..at.checked_add(4)?)?.try_into().ok()?)) } fn be_f32(d: &[u8], at: usize) -> Option { 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 { 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 = 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 = [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 { 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> { static DEFS: OnceLock>> = 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> { let c = v.components(); let g = |i: usize| c.get(i).copied().and_then(sane); let one = |x: f64| -> Vec { 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> { 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 { 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 { 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, here: &str, col: &mut Collector) -> Option<(u16, Vec)> { 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::().ok()?, y.trim().split(',').next()?.trim().parse::().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) -> Option<(u16, Vec)> { 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, here: &str, col: &mut Collector) -> Option<(u16, Vec)> { 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, size: (f64, f64), here: &str, col: &mut Collector) -> Option<(u16, Vec)> { 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> = 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 = t.split(',').map(|x| x.trim().parse::().ok().filter(|x| x.is_finite())).collect::>()?; (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 { 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 { 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 { 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, Chunk<'static>, Chunk<'static>)>, /// `pard` type code -> the static value record of that type. kinds: HashMap>, /// 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 { 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 { 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> { 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 { 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, source_size: (u32, u32), uses: &mut FxUse, ) -> R>> { 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>, record: &str| -> Option { 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 = 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 = 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, source_size: (u32, u32), uses: &mut FxUse, ) -> R>> { 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(()) }