Files
effectcraft/crates/aep/src/container/effects.rs
T
vangel76andClaude Opus 5.5 e730da1435 AEP import: read effect values recorded in the default state (tdsb 3)
After Effects records some effect parameters as value records in the
default state (tdsb 3) that still hold the value it uses. A survey of 839
corpus and sample files finds 25 such effect records, all in Lumetri
Color (its hidden controls) and Cryptomatte (Selection Mode on); every one
differs from its pard default and reads back in After Effects as recorded.
No effect record in the default state holds a value that does not count.

The general reader now takes them: EffectParam::recorded_value (the same
name, signature and body as in the Lumetri import) reads a record whatever
its state, value() and is_default() use it, and the importer's mapped
parameters read it, so such a record imports like a written one instead of
being reported as an unreadable value with the default used.
stored_value keeps to written records.

Layer, camera, light, shape and text properties in the default state all
hold After Effects' defaults or values with no effect on that layer
(the point light cone, a polygon's inner radius, a camera's opacity), so
their readers are unchanged. docs/aep-format.md records both findings.

Closes #3126

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:51:53 +01:00

675 lines
31 KiB
Rust

//! Effect instances: match name, instance name, the parameter list from `parT`/`pard` and the
//! values stored with the instance.
//!
//! An effect on a layer is a `LIST sspc` under `ADBE Effect Parade`. It holds `fnam` (the
//! instance name), `LIST parT` (the parameter definitions: `parn`, then a `tdmn` and a 148-byte
//! `pard` per parameter, a `pdnm` after a popup), `LIST tdgp` (the values) and `pgui`. Only
//! parameters that differ from their `pard` default have a value record in the `tdgp` (the Layer
//! Control and button records are the exceptions: they always exist).
//!
//! When several layers carry an effect of the same type, only one instance has a full `parT`; the
//! others have an empty `LIST parT` (case 383). Their parameter definitions are the ones in the
//! project-level `LIST EfdG` (one `EfDf` per effect type: a `tdmn` with the match name, then a copy
//! of one instance's `sspc`), see [`Project::effect_params`]. That copy also carries the values of
//! the instance it was copied from, so values are only ever read from the instance's own `tdgp`.
use std::collections::{HashMap, HashSet};
use crate::container::Chunk;
use crate::container::bytes::{array, be_f32, be_f64, be_i32, be_u16, be_u32, cstr, text, until_nul};
use crate::container::typed::{KeyList, Layer, Project, PropGroup, PropNode, Property};
/// The parameter type, the `u32` at byte 12 of `pard`. The numbering matches the plug-in
/// parameter types of the After Effects effect interface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParamKind {
/// `0`: a layer reference (also the implicit first parameter, the input layer).
Layer,
/// `1`: an integer slider (Fast Box Blur's Iterations, 412-414): the default is an `i32` at
/// `pard` byte 56 (3), the valid maximum an `i32` at byte 128 (50), the value record a plain
/// `f64` (1.0).
IntSlider,
/// `2`: a fixed-point (16.16) slider.
FixedSlider,
/// `3`: an angle (16.16 degrees).
Angle,
/// `4`
Checkbox,
/// `5`
Color,
/// `6`
Point,
/// `7`: a popup menu (the items are in the `pdnm` chunk).
Popup,
/// `9`: no data (the group-end entry `ADBE Effect Built In Params`).
NoData,
/// `10`: a floating-point slider.
FloatSlider,
/// `11`: arbitrary data (Hue/Saturation's Channel Range, Levels' Histogram, 443, 511): the
/// value record is a 4-byte `cdat`, the data itself the `aRbp` of the `LIST aRbs` after it,
/// see [`Effect::arbitrary_data`].
ArbitraryData,
/// `12`: a path / mask reference.
Path,
/// `13`: the start of a twirl-down group (Exposure's Master, Red, Green and Blue, 389). It
/// holds no value; its value record always exists with `cdat` 0.
GroupStart,
/// `14`: the end of a twirl-down group (its name repeats the group's name, 389). It holds no
/// value; its value record always exists with `cdat` 0.
GroupEnd,
/// `15`: a button (Tint's and Gradient Ramp's Swap Colors). It holds no value; its value
/// record always exists with `cdat` 0 (356, 368).
Button,
/// Any other value.
Other(u32),
}
impl ParamKind {
fn from_u32(v: u32) -> Self {
match v {
0 => Self::Layer,
1 => Self::IntSlider,
2 => Self::FixedSlider,
3 => Self::Angle,
4 => Self::Checkbox,
5 => Self::Color,
6 => Self::Point,
7 => Self::Popup,
9 => Self::NoData,
10 => Self::FloatSlider,
11 => Self::ArbitraryData,
12 => Self::Path,
13 => Self::GroupStart,
14 => Self::GroupEnd,
15 => Self::Button,
other => Self::Other(other),
}
}
}
/// A decoded parameter value.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ParamValue {
/// A slider or angle value as stored: float sliders, fixed sliders and angles keep a plain
/// `f64` in their value record (an angle in degrees; a fixed slider in its own scale, see
/// [`EffectParam::valid_max`]); defaults read from `pard` are 16.16 decoded for fixed sliders
/// and angles.
Number(f64),
/// A checkbox: stored as 1.0 when on; an absent record means off.
Bool(bool),
/// A point control as stored: a fraction of the layer size (`[111, 222]` px on a 1920x1080
/// layer is `[111/1920, 222/1080]`).
Point([f64; 2]),
/// A layer control: the id of the chosen layer (the `tdpi` chunk next to the value record).
/// In the implicit first parameter it is the id of the layer that owns the effect.
Layer(u32),
/// A colour as ARGB fractions 0..1 (stored as doubles 0..255 in the order alpha, red, green,
/// blue: a colour `[0.2, 0.6, 1]` is `[255, 51, 153, 255]`, with float rounding).
Argb([f64; 4]),
/// A popup's item index, 1-based (Gradient Ramp's Radial Ramp is 2, 377): the default in
/// `pard`, or the stored selection.
Index(u32),
}
/// What a Layer Control points at, given the id of the layer that owns the effect.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LayerRef {
/// The layer itself (the default, "Layer 1" on the first layer): the stored `tdpi` equals the
/// owning layer's id.
This,
/// None: the stored `tdpi` is 0.
Nothing,
/// Another layer, by id.
Layer(u32),
}
/// An effect instance on a layer.
#[derive(Debug, Clone)]
pub struct Effect<'f, 'a> {
match_name: String,
chunk: &'f Chunk<'a>,
}
/// Where the parameter definitions of an effect instance come from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DefinitionSource {
/// The instance's own `parT`.
Instance,
/// The project-level `EfdG/EfDf` copy for the effect's match name (the instance's own `parT`
/// is empty, 383).
Project,
/// Neither has any: the effect has no readable parameter definitions.
Missing,
}
/// One parameter of an effect.
#[derive(Debug, Clone)]
pub struct EffectParam<'f, 'a> {
/// The position in `parT` (0 is the implicit input layer).
pub index: usize,
/// The `tdmn` match name (`<effect match name>-NNNN`, the last one is
/// `ADBE Effect Built In Params`).
pub match_name: String,
pub kind: ParamKind,
/// The parameter name (`Blurriness`, `Slider`, ...; empty for the implicit parameters).
pub name: String,
/// The popup items (`pdnm`, split at `|`; for a button its label, e.g. `Swap Colors`).
pub items: Option<Vec<String>>,
/// True when the definition is the project-level copy (see [`DefinitionSource::Project`]).
pub borrowed_definition: bool,
pard: &'f [u8],
stored: Option<Property<'f, 'a>>,
}
impl<'f, 'a> Effect<'f, 'a> {
/// The effect's match name (`ADBE Slider Control`, `ADBE Gaussian Blur 2`, ...).
pub fn match_name(&self) -> &str {
&self.match_name
}
/// The underlying `LIST sspc` chunk.
pub fn chunk(&self) -> &'f Chunk<'a> {
self.chunk
}
/// The instance name shown in the effect controls (`fnam`).
pub fn name(&self) -> Option<String> {
self.chunk.child(b"fnam")?.children().iter().find(|c| c.is(b"Utf8"))?.bytes().map(text)
}
/// The name the user gave the instance: the `tdsn` of the instance's value group when it is
/// not the placeholder `-_0_/-` (610: `My Tint`; `fnam` keeps the effect's own name `Tint`).
/// `None` while the instance shows the effect's name.
pub fn user_name(&self) -> Option<String> {
let values = self.chunk.child_list(b"tdgp")?;
let name = values.child(b"tdsn")?.children().iter().find(|c| c.is(b"Utf8"))?.bytes().map(text)?;
(name != "-_0_/-" && !name.is_empty()).then_some(name)
}
/// The instance's fx switch: the `tdsb` of its value group is 1 when on and 0 when switched
/// off (`fx-switch` cases: one instance on, one off, both in one save). `None` for another
/// value or a missing group.
pub fn enabled(&self) -> Option<bool> {
match be_u32(self.chunk.child_list(b"tdgp")?.child(b"tdsb")?.bytes()?, 0)? {
1 => Some(true),
0 => Some(false),
_ => None,
}
}
/// The name After Effects shows for the instance: [`Effect::user_name`], else [`Effect::name`].
pub fn display_name(&self) -> Option<String> {
self.user_name().or_else(|| self.name())
}
/// The number of parameters declared by `parn` (the implicit input layer and the built-in
/// entry included).
pub fn declared_param_count(&self) -> Option<u32> {
be_u32(self.chunk.child_list(b"parT")?.child(b"parn")?.bytes()?, 0)
}
/// True when the instance's own `parT` holds parameter definitions (a `pard`). A second
/// instance of an effect type has an empty `parT` (383).
pub fn has_own_definitions(&self) -> bool {
self.chunk.child_list(b"parT").is_some_and(has_pard)
}
/// The parameters in the order of the instance's own `parT`. Empty for an instance whose
/// `parT` is empty; [`Project::effect_params`] falls back to the project's definitions.
pub fn params(&self) -> Vec<EffectParam<'f, 'a>> {
match self.chunk.child_list(b"parT") {
Some(part) => self.params_from(part, false),
None => Vec::new(),
}
}
/// The parameters described by `part` (a `LIST parT`) with the values of this instance.
fn params_from(&self, part: &'f Chunk<'a>, borrowed: bool) -> Vec<EffectParam<'f, 'a>> {
let mut out: Vec<EffectParam<'f, 'a>> = Vec::new();
// The stored values by match name: the first entry of a name, as `PropGroup::get` finds it
// (a group of that name means no stored value). Indexed once: a lookup per parameter
// would scan the whole group for each of them.
let mut values: HashMap<String, Option<Property<'f, 'a>>> = HashMap::new();
for e in self.chunk.child_list(b"tdgp").and_then(PropGroup::from_chunk).map(|g| g.entries()).unwrap_or_default() {
let stored = if let PropNode::Property(p) = e.node { Some(p) } else { None };
values.entry(e.match_name).or_insert(stored);
}
let mut pending: Option<String> = None;
for c in part.children() {
if c.is(b"tdmn") {
pending = c.bytes().map(|b| text(until_nul(b)));
} else if c.is(b"pard") {
let Some(pard) = c.bytes() else { continue };
let match_name = pending.take().unwrap_or_default();
let stored = values.get(&match_name).copied().flatten();
out.push(EffectParam {
index: out.len(),
match_name,
kind: ParamKind::from_u32(be_u32(pard, 12).unwrap_or(u32::MAX)),
name: cstr(pard, 16, 32).unwrap_or_default(),
items: None,
borrowed_definition: borrowed,
pard,
stored,
});
} else if c.is(b"pdnm")
&& let Some(last) = out.last_mut()
&& let Some(u) = c.children().iter().find(|u| u.is(b"Utf8"))
&& let Some(b) = u.bytes()
{
last.items = Some(text(b).split('|').map(str::to_string).collect());
}
}
out
}
}
fn has_pard(part: &Chunk<'_>) -> bool {
part.children().iter().any(|c| c.is(b"pard"))
}
impl<'f, 'a> Project<'f, 'a> {
/// The project-level effect definitions (`LIST EfdG`): one entry per effect type, the match
/// name and the `LIST parT` of the copied instance, in file order (the stack order of the
/// types, 365/366). The `EfDC` count before them is little-endian and is not needed to read
/// them. A type whose copy has no definitions is left out.
pub fn effect_definitions(&self) -> Vec<(String, &'f Chunk<'a>)> {
let mut out: Vec<(String, &'f Chunk<'a>)> = Vec::new();
let Some(defs) = self.effect_defs else { return out };
let mut seen: HashSet<String> = HashSet::new();
for df in defs.children().iter().filter(|c| c.is_list(b"EfDf")) {
let mut name: Option<String> = None;
for c in df.children() {
if c.is(b"tdmn") {
name = c.bytes().map(|b| text(until_nul(b)));
} else if c.is_list(b"sspc")
&& let Some(n) = name.take()
&& let Some(part) = c.child_list(b"parT").filter(|p| has_pard(p))
&& seen.insert(n.clone())
{
out.push((n, part));
}
}
}
out
}
/// [`Self::effect_definitions`] indexed by match name, for looking up many effects: build it
/// once per file, not once per effect (a file can hold many definitions and many instances).
pub fn effect_definition_index(&self) -> EffectDefinitions<'f, 'a> {
EffectDefinitions { by_name: self.effect_definitions().into_iter().collect() }
}
/// Where the parameter definitions of `effect` come from. Builds the index each call: use
/// [`Self::effect_definition_index`] for more than one effect.
pub fn definition_source(&self, effect: &Effect<'f, 'a>) -> DefinitionSource {
self.effect_definition_index().source(effect)
}
/// The parameters of `effect` ([`EffectDefinitions::params`]). Builds the index each call:
/// use [`Self::effect_definition_index`] for more than one effect.
pub fn effect_params(&self, effect: &Effect<'f, 'a>) -> Vec<EffectParam<'f, 'a>> {
self.effect_definition_index().params(effect)
}
}
/// The project-level effect definitions by match name ([`Project::effect_definition_index`]).
#[derive(Debug, Clone, Default)]
pub struct EffectDefinitions<'f, 'a> {
by_name: HashMap<String, &'f Chunk<'a>>,
}
impl<'f, 'a> EffectDefinitions<'f, 'a> {
/// Where the parameter definitions of `effect` come from.
pub fn source(&self, effect: &Effect<'f, 'a>) -> DefinitionSource {
if effect.has_own_definitions() {
DefinitionSource::Instance
} else if self.by_name.contains_key(effect.match_name()) {
DefinitionSource::Project
} else {
DefinitionSource::Missing
}
}
/// The parameters of `effect`: from its own `parT`, or, when that is empty, from the
/// project-level definition of its match name ([`EffectParam::borrowed_definition`]). Values
/// always come from the instance's own records (absent = the `pard` default); the project
/// copy's values belong to another instance and are never read.
pub fn params(&self, effect: &Effect<'f, 'a>) -> Vec<EffectParam<'f, 'a>> {
if effect.has_own_definitions() {
return effect.params();
}
match self.by_name.get(effect.match_name()) {
Some(part) => effect.params_from(part, true),
None => Vec::new(),
}
}
}
impl<'f, 'a> EffectParam<'f, 'a> {
/// The raw 148-byte `pard` record (it contains stale memory after the name).
pub fn pard(&self) -> &'f [u8] {
self.pard
}
/// The value record stored with the instance (`<match>-NNNN` in the instance's `tdgp`), which
/// exists only for parameters that differ from the default.
pub fn stored(&self) -> Option<Property<'f, 'a>> {
self.stored
}
/// True when the parameter's value record has keyframes ([`EffectParam::keys`]); then
/// [`EffectParam::stored_value`] is `None` and [`EffectParam::value`] is not the value.
pub fn is_animated(&self) -> bool {
self.stored.is_some_and(|p| p.is_animated())
}
/// The keyframes of an animated parameter: plain 48-byte records for sliders (363), 152-byte
/// colour records (364) and 104-byte spatial records for points (382), with the values in the
/// stored units of [`EffectParam::stored_value`].
pub fn keys(&self) -> Option<KeyList<'f>> {
self.stored?.keys()
}
/// The value in the value record: float and fixed sliders, angles, checkboxes, points,
/// colours, popups and layer controls. A record counts when its `tdsb` is 1 and it has no
/// keyframes; a layer control reads the `tdpi` chunk; a button has no value.
pub fn stored_value(&self) -> Option<ParamValue> {
let p = self.stored?;
if self.kind != ParamKind::Layer && p.flags() != Some(1) {
return None;
}
self.recorded_value()
}
/// The value in the value record whatever the record's state (`tdsb`): what
/// [`EffectParam::stored_value`] reads, also from a record in the default state (`tdsb` 3).
/// Lumetri Color writes the values of its hidden parameters so (High Dynamic Range, HDR
/// Specular, Look: 1200 `lumetri-01-set-all`, `-02-sec-top`, `-06-sec-light`; After Effects
/// reads back the recorded values). `None` with keyframes or without a record.
pub fn recorded_value(&self) -> Option<ParamValue> {
let p = self.stored?;
if self.kind == ParamKind::Layer {
return be_u32(p.chunk().child(b"tdpi")?.bytes()?, 0).map(ParamValue::Layer);
}
if p.is_animated() {
return None;
}
let v = p.static_value()?;
match self.kind {
ParamKind::FloatSlider | ParamKind::FixedSlider | ParamKind::Angle | ParamKind::IntSlider => v.first().copied().map(ParamValue::Number),
ParamKind::Checkbox => v.first().map(|x| ParamValue::Bool(*x != 0.0)),
ParamKind::Point => {
let [x, y] = *v.as_slice().first_chunk::<2>()?;
Some(ParamValue::Point([x, y]))
}
ParamKind::Popup => {
let index = v.first().copied()?;
(0.0..=f64::from(u32::MAX)).contains(&index).then(|| ParamValue::Index(index.round() as u32))
}
ParamKind::Color => {
let [a, r, g, b] = *v.as_slice().first_chunk::<4>()?;
Some(ParamValue::Argb([a / 255.0, r / 255.0, g / 255.0, b / 255.0]))
}
_ => None,
}
}
/// The default stored in `pard` at byte 56: a float slider's `f64`, a fixed slider's or angle's
/// 16.16 value, a colour's ARGB bytes, a popup's item index, a point's two 16.16 fractions of
/// the layer size (Point Control 0.5, 0.5: the layer centre, also on a 100x100 layer;
/// Gradient Ramp Start 0.5, 0 and End 0.5, 1), an integer slider's `i32` (Iterations 3, 412),
/// a checkbox's `u32` (1 = on: Repeat Edge Pixels of Fast Box Blur and Gaussian Blur, read back
/// on, 412, 417; 0 = off: Shadow Only 420, Bypass Linear Light Conversion 389); other types:
/// `None`.
///
/// It is the default and not the current value: Gaussian Blur with Blurriness 40 still has 25
/// here and a value record with 40 (After Effects itself reads a fresh Gaussian Blur back as 25).
/// Exception: selecting a popup item also writes the selection to this slot. So a popup whose
/// definition was borrowed from the project copy ([`EffectParam::borrowed_definition`]) has no
/// reliable default here (the slot holds the copied instance's selection): `None`.
pub fn pard_default(&self) -> Option<ParamValue> {
match self.kind {
ParamKind::Popup if self.borrowed_definition => None,
ParamKind::FloatSlider => be_f64(self.pard, 56).map(ParamValue::Number),
ParamKind::FixedSlider | ParamKind::Angle => be_i32(self.pard, 56).map(|v| ParamValue::Number(f64::from(v) / 65536.0)),
ParamKind::IntSlider => be_i32(self.pard, 56).map(|v| ParamValue::Number(f64::from(v))),
ParamKind::Checkbox => be_u32(self.pard, 56).map(|v| ParamValue::Bool(v != 0)),
ParamKind::Popup => be_u32(self.pard, 56).map(ParamValue::Index),
ParamKind::Point => {
let p = [f64::from(be_i32(self.pard, 56)?) / 65536.0, f64::from(be_i32(self.pard, 60)?) / 65536.0];
// A fresh instance stores fractions of the layer (0.5, 0.5); After Effects' save of
// a project it opened stores percent (50, 50: the re-saves of read-back tests e1 and
// e2, where Gradient Ramp's Start and End and every other point default came back
// as 50 / 0 and 50 / 100). Point
// defaults lie inside the layer, so a component beyond 1 tells percent.
let percent = p.iter().any(|c| c.abs() > 1.0 + 1e-9);
Some(ParamValue::Point(if percent { p.map(|c| c / 100.0) } else { p }))
}
ParamKind::Color => {
let [a, r, g, b] = array::<4>(self.pard, 56)?.map(|c| f64::from(c) / 255.0);
Some(ParamValue::Argb([a, r, g, b]))
}
_ => None,
}
}
/// True when the parameter is at its default: it has no value record (a record exists exactly
/// when the value differs from the `pard` default; setting a value back to the default removes
/// the record again), or the record equals the default. A record in the default state (`tdsb`
/// 3) counts with its value ([`EffectParam::recorded_value`]). `None` when that cannot be
/// decided (unknown parameter type, keyframes).
pub fn is_default(&self) -> Option<bool> {
match (self.stored, self.kind) {
// A Layer Control always has its record, and a popup selection overwrites the default
// in `pard`. Buttons and group markers hold no value at all.
(_, ParamKind::Layer | ParamKind::Button | ParamKind::GroupStart | ParamKind::GroupEnd) => None,
(None, _) => Some(true),
(Some(_), ParamKind::Popup) => Some(false),
(Some(_), _) => {
let stored = self.recorded_value()?;
// A checkbox's default is in `pard` too (on for Repeat Edge Pixels, 412).
Some(self.pard_default().is_some_and(|d| d == stored))
}
}
}
/// A point control in pixels of a layer of this size (the stored point is a fraction of the
/// layer size; the default is the layer centre). `None` for other parameter types.
pub fn point_px(&self, layer_width: f64, layer_height: f64) -> Option<[f64; 2]> {
match self.value()? {
ParamValue::Point([x, y]) if self.kind == ParamKind::Point => Some([x * layer_width, y * layer_height]),
_ => None,
}
}
/// For a Layer Control: what the stored layer id means for the layer that owns the effect.
/// `None` for other types and for the implicit first parameter of an effect.
pub fn layer_ref(&self, owner_layer_id: u32) -> Option<LayerRef> {
if self.kind != ParamKind::Layer || self.index == 0 {
return None;
}
match self.stored_value()? {
ParamValue::Layer(0) => Some(LayerRef::Nothing),
ParamValue::Layer(id) if id == owner_layer_id => Some(LayerRef::This),
ParamValue::Layer(id) => Some(LayerRef::Layer(id)),
_ => None,
}
}
/// The largest valid value of a slider, from `pard`: a fixed slider's 16.16 at byte 128 (Tint
/// Amount to Tint 100, Gradient Ramp Ramp Scatter 512 and Blend With Original 1.0, Drop Shadow
/// Opacity 255), a float slider's `f32` at byte 108 (Blurriness 30000, Exposure 100, Exposure's
/// Gamma Correction 10). The stored scale of a
/// slider differs per parameter (Amount 37.5 % is stored 37.5, Blend 25 % is stored 0.25), so
/// this is what tells a reader the scale. `None` for other types.
pub fn valid_max(&self) -> Option<f64> {
match self.kind {
ParamKind::FixedSlider => be_i32(self.pard, 128).map(|v| f64::from(v) / 65536.0),
ParamKind::FloatSlider => be_f32(self.pard, 108).map(f64::from),
ParamKind::IntSlider => be_i32(self.pard, 128).map(f64::from),
_ => None,
}
}
/// The smallest valid value of a float slider, the `f32` at byte 104 of `pard` (Exposure
/// -100, Offset -2, Gamma Correction 0.1: 389). `None` for other types (a fixed slider's
/// minimum is not decoded).
pub fn valid_min(&self) -> Option<f64> {
match self.kind {
ParamKind::FloatSlider => be_f32(self.pard, 104).map(f64::from),
_ => None,
}
}
/// The range of a float slider's slider (what the control's drag covers, inside the valid
/// range): the `f32` pair at bytes 112 and 116 of `pard` (Exposure -4..4, Offset -0.5..0.5,
/// Gamma Correction 0.1..10: 389). Each value record repeats it as `tdum` / `tduM`. `None`
/// for other types.
pub fn slider_range(&self) -> Option<(f64, f64)> {
match self.kind {
ParamKind::FloatSlider => Some((f64::from(be_f32(self.pard, 112)?), f64::from(be_f32(self.pard, 116)?))),
_ => None,
}
}
/// For a popup: the number of items (`u16` at byte 60 of `pard`).
pub fn popup_item_count(&self) -> Option<u16> {
if self.kind != ParamKind::Popup {
return None;
}
be_u16(self.pard, 60)
}
/// For a popup: the selected item index (1-based): the stored selection, else the default.
pub fn popup_selected_index(&self) -> Option<u32> {
match self.value()? {
ParamValue::Index(i) if self.kind == ParamKind::Popup => Some(i),
_ => None,
}
}
/// For a popup: the text of the selected item.
pub fn popup_selected_item(&self) -> Option<&str> {
let index = usize::try_from(self.popup_selected_index()?).ok()?.checked_sub(1)?;
self.items.as_ref()?.get(index).map(String::as_str)
}
/// The effective value: the value in the record when there is one, also in a record in the
/// default state (`tdsb` 3, [`EffectParam::recorded_value`]), else the `pard` default (absent =
/// default); a checkbox without a record is off.
pub fn value(&self) -> Option<ParamValue> {
self.recorded_value().or_else(|| self.pard_default()).or(match self.kind {
ParamKind::Checkbox if self.stored.is_none() => Some(ParamValue::Bool(false)),
_ => None,
})
}
}
impl<'f, 'a> Layer<'f, 'a> {
/// The effects on the layer, in stack order (`ADBE Effect Parade`).
pub fn effects(&self) -> Vec<Effect<'f, 'a>> {
let Some(root) = self.properties() else { return Vec::new() };
let Some(PropNode::Group(parade)) = root.get("ADBE Effect Parade") else { return Vec::new() };
parade
.entries()
.into_iter()
.filter_map(|e| match e.node {
PropNode::Other(chunk) if chunk.is_list(b"sspc") => Some(Effect { match_name: e.match_name, chunk }),
_ => None,
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tdmn(name: &str) -> Chunk<'static> {
let mut b = name.as_bytes().to_vec();
b.resize(40, 0);
Chunk::data(*b"tdmn", b)
}
fn pard() -> Chunk<'static> {
Chunk::data(*b"pard", vec![0u8; 48])
}
fn tdbs() -> Chunk<'static> {
Chunk::list(*b"tdbs", Vec::new())
}
/// An effect instance whose `parT` names `defs` and whose `tdgp` stores `stored` (in order).
fn instance(defs: &[String], stored: &[(String, Chunk<'static>)]) -> Chunk<'static> {
let part = defs.iter().flat_map(|n| [tdmn(n), pard()]).collect();
let values = stored.iter().flat_map(|(n, c)| [tdmn(n), c.clone()]).collect();
Chunk::list(*b"sspc", vec![Chunk::list(*b"parT", part), Chunk::list(*b"tdgp", values)])
}
#[test]
fn a_parameter_takes_the_first_stored_entry_of_its_name() {
let names: Vec<String> = ["A", "B", "C"].map(String::from).to_vec();
// `A` is stored twice (the first wins), `B` names a group (no stored value), `C` is absent.
let stored = vec![
("A".to_string(), tdbs()),
("B".to_string(), Chunk::list(*b"tdgp", Vec::new())),
("A".to_string(), Chunk::list(*b"tdbs", vec![Chunk::data(*b"cdat", vec![0u8; 8])])),
];
let chunk = instance(&names, &stored);
let fx = Effect { match_name: "X".into(), chunk: &chunk };
let params = fx.params();
assert_eq!(params.iter().map(|p| p.match_name.as_str()).collect::<Vec<_>>(), ["A", "B", "C"]);
let first = params.first().and_then(|p| p.stored).expect("A stored");
assert!(first.chunk.children().is_empty(), "the first A (the second has a cdat)");
assert!(params.get(1).is_some_and(|p| p.stored.is_none()), "a group of that name is no stored value");
assert!(params.get(2).is_some_and(|p| p.stored.is_none()));
}
#[test]
fn many_parameters_are_read_in_linear_time() {
// 100 000 parameters with 100 000 stored values: a lookup per parameter that scans the
// group (as before) is 1e10 entry builds; one index is instant.
let n = 100_000;
let names: Vec<String> = (0..n).map(|i| format!("P{i}")).collect();
let stored: Vec<(String, Chunk<'static>)> = names.iter().map(|m| (m.clone(), tdbs())).collect();
let chunk = instance(&names, &stored);
let fx = Effect { match_name: "X".into(), chunk: &chunk };
let params = fx.params();
assert_eq!(params.len(), n);
assert!(params.iter().all(|p| p.stored.is_some()));
}
#[test]
fn many_project_definitions_are_indexed_once() {
// 50 000 definitions, each twice (the first copy wins), then 1000 instances without
// their own definitions looked up through one index.
let n = 50_000;
let mut defs = Vec::new();
for round in 0..2 {
for i in 0..n {
let part = Chunk::list(*b"parT", vec![tdmn(&format!("R{round}")), pard()]);
defs.push(Chunk::list(*b"EfDf", vec![tdmn(&format!("FX{i}")), Chunk::list(*b"sspc", vec![part])]));
}
}
let efdg = Chunk::list(*b"EfdG", defs);
let fold = Chunk::list(*b"Fold", Vec::new());
let project = Project { fold: &fold, effect_defs: Some(&efdg) };
let all = project.effect_definitions();
assert_eq!(all.len(), n);
let index = project.effect_definition_index();
let bare = Chunk::list(*b"sspc", vec![Chunk::list(*b"parT", Vec::new())]);
for i in (0..n).step_by(50) {
let fx = Effect { match_name: format!("FX{i}"), chunk: &bare };
assert_eq!(index.source(&fx), DefinitionSource::Project);
let params = index.params(&fx);
assert_eq!(params.iter().map(|p| p.match_name.as_str()).collect::<Vec<_>>(), ["R0"], "the first copy's definitions");
assert!(params.iter().all(|p| p.borrowed_definition));
}
let unknown = Effect { match_name: "nope".into(), chunk: &bare };
assert_eq!(index.source(&unknown), DefinitionSource::Missing);
assert!(index.params(&unknown).is_empty());
assert_eq!(project.definition_source(&unknown), DefinitionSource::Missing);
}
}