.aep import: effects without definitions borrow them from another instance (#3223)

Refs #3170 (the Color Control part).

Some files have no project-level effect definition list (EfdG); it was seen in
17 projects, all from inside After Effects-made Motion Graphics templates. In
those, one instance per effect type keeps the full parameter definitions
(parT) and every other instance of the type has an empty parT. The others
were reported "without readable parameter definitions" and dropped (129
Color Controls of one template). The definition index now falls back to the
parT of the first instance of the same match name that has its own
(DefinitionSource::OtherInstance). Values still come from each instance's own
records; a value record that borrowed definitions do not name is reported
instead of vanishing.

The "Color ... holds an unreadable value" report came from 0.21.1, which read
only written records (tdsb 1). Reading default-state records (tdsb 3, #3126)
probably fixes it, but that is not confirmed for Color Control; a synthetic
test now pins a default-state colour record.
This commit is contained in:
Valentin Klein
2026-11-08 01:57:47 +01:00
committed by GitHub
parent 99e24f5867
commit 514e16c027
6 changed files with 322 additions and 16 deletions
+57 -8
View File
@@ -12,6 +12,11 @@
//! 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`.
//!
//! Some files have no `EfdG` at all (seen in 17 projects, all from inside After Effects-made
//! Motion Graphics templates). In those, one instance per effect type kept its full `parT` (which
//! one is not known) and it is the only place the definitions are written, so the other instances
//! of that type borrow them from it (#3170).
use std::collections::{HashMap, HashSet};
@@ -138,7 +143,11 @@ pub enum DefinitionSource {
/// 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.
/// The own `parT` of another instance of the same match name in the file: the instance's
/// own `parT` is empty and the file has no project-level copy of the type (a project without
/// `EfdG`, #3170).
OtherInstance,
/// None of these has any: the effect has no readable parameter definitions.
Missing,
}
@@ -155,7 +164,8 @@ pub struct EffectParam<'f, 'a> {
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`]).
/// True when the definition is borrowed: the project-level copy ([`DefinitionSource::Project`])
/// or another instance's ([`DefinitionSource::OtherInstance`]).
pub borrowed_definition: bool,
pard: &'f [u8],
stored: Option<Property<'f, 'a>>,
@@ -214,6 +224,22 @@ impl<'f, 'a> Effect<'f, 'a> {
self.chunk.child_list(b"parT").is_some_and(has_pard)
}
/// The match names of the value records in the instance's own `tdgp` (properties only, not
/// groups), in file order, each once.
pub fn value_record_names(&self) -> Vec<String> {
let mut seen: HashSet<String> = HashSet::new();
self.chunk
.child_list(b"tdgp")
.and_then(PropGroup::from_chunk)
.map(|g| g.entries())
.unwrap_or_default()
.into_iter()
.filter(|e| matches!(e.node, PropNode::Property(_)))
.map(|e| e.match_name)
.filter(|n| seen.insert(n.clone()))
.collect()
}
/// 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>> {
@@ -299,8 +325,25 @@ impl<'f, 'a> Project<'f, 'a> {
/// [`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).
/// For a type without a project-level copy it also keeps the own `parT` of the first instance
/// in the file (items, layers and effects in file order) that has one.
pub fn effect_definition_index(&self) -> EffectDefinitions<'f, 'a> {
EffectDefinitions { by_name: self.effect_definitions().into_iter().collect() }
let by_name: HashMap<String, &'f Chunk<'a>> = self.effect_definitions().into_iter().collect();
let mut instances: HashMap<String, &'f Chunk<'a>> = HashMap::new();
for item in self.all_items() {
let Some(comp) = item.comp() else { continue };
for layer in comp.layers() {
for fx in layer.effects() {
if by_name.contains_key(&fx.match_name) || instances.contains_key(&fx.match_name) {
continue;
}
if let Some(part) = fx.chunk.child_list(b"parT").filter(|p| has_pard(p)) {
instances.insert(fx.match_name, part);
}
}
}
}
EffectDefinitions { by_name, instances }
}
/// Where the parameter definitions of `effect` come from. Builds the index each call: use
@@ -316,10 +359,13 @@ impl<'f, 'a> Project<'f, 'a> {
}
}
/// The project-level effect definitions by match name ([`Project::effect_definition_index`]).
/// The borrowable effect definitions by match name ([`Project::effect_definition_index`]).
#[derive(Debug, Clone, Default)]
pub struct EffectDefinitions<'f, 'a> {
/// The project-level copies (`EfdG`).
by_name: HashMap<String, &'f Chunk<'a>>,
/// For a type without a project-level copy: the own `parT` of the first instance that has one.
instances: HashMap<String, &'f Chunk<'a>>,
}
impl<'f, 'a> EffectDefinitions<'f, 'a> {
@@ -329,20 +375,23 @@ impl<'f, 'a> EffectDefinitions<'f, 'a> {
DefinitionSource::Instance
} else if self.by_name.contains_key(effect.match_name()) {
DefinitionSource::Project
} else if self.instances.contains_key(effect.match_name()) {
DefinitionSource::OtherInstance
} 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.
/// project-level definition of its match name, else from another instance of it that has
/// its own ([`EffectParam::borrowed_definition`]). Values always come from the instance's own
/// records (absent = the `pard` default); the values next to borrowed definitions 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()) {
match self.by_name.get(effect.match_name()).or_else(|| self.instances.get(effect.match_name())) {
Some(part) => effect.params_from(part, true),
None => Vec::new(),
}
+16
View File
@@ -99,6 +99,22 @@ pub(crate) fn import_effects<'f, 'a>(
None => col.lose(LossCategory::DamagedData, format!("{}: '{}': the fx switch of '{shown}' is unreadable, imported switched on", cx.comp, cx.layer)),
}
let params = defs.params(&fx);
// Borrowed definitions (another instance's or the project copy) may not name every value
// record this instance has: such a record has nothing to import into, so it is reported.
if params.first().is_some_and(|p| p.borrowed_definition) {
let defined: std::collections::HashSet<&str> = params.iter().map(|p| p.match_name.as_str()).collect();
for name in fx.value_record_names().iter().filter(|n| !defined.contains(n.as_str())) {
col.lose(
LossCategory::Effects,
format!(
"{}: '{}' effect '{shown}' ({}): the value record {name} has no parameter definition, not imported",
cx.comp,
cx.layer,
fx.match_name()
),
);
}
}
if map.extra == Extra::Cryptomatte {
import_cryptomatte(&fx, &params, cx, &shown, &mut group, col);
} else {
+4 -2
View File
@@ -237,9 +237,11 @@ fn exercise_layer(project: &Project<'_, '_>, comp_size: (u32, u32), layer: &Laye
if let Some(root) = layer.properties() {
exercise_group(&root, 0);
}
// One index for the layer's effects: building it walks every layer of the file.
let defs = project.effect_definition_index();
for e in layer.effects() {
let _ = (e.match_name().len(), e.name(), e.declared_param_count(), e.has_own_definitions(), project.definition_source(&e));
for p in e.params().into_iter().chain(project.effect_params(&e)) {
let _ = (e.match_name().len(), e.name(), e.declared_param_count(), e.has_own_definitions(), defs.source(&e));
for p in e.params().into_iter().chain(defs.params(&e)) {
let _ = (p.index, p.kind, p.name.len(), p.items.as_ref().map(Vec::len), p.pard().len(), p.value(), p.stored_value(), p.pard_default());
let _ = (p.is_default(), p.popup_item_count(), p.popup_selected_index(), p.popup_selected_item(), p.valid_max(), p.is_animated());
let _ = (p.valid_min(), p.slider_range());
+7 -5
View File
@@ -626,9 +626,10 @@ fn bogus_effect_structure_is_reported_not_imported_blindly() {
"{r:?}"
);
// No project-level definitions: a Ramp with an empty parT cannot be read and is reported; one
// with its own parT still arrives. (In the single-case file one of the two had its own; in
// the combined file an earlier case holds the definitions, so both take the project copy.)
// No project-level definitions: a Ramp with an empty parT borrows the definitions of a Ramp
// in the file that has its own parT (#3170). In the single-case file one of the two has its
// own, so both arrive; in the case cut from the combined file an earlier case (not kept)
// held them, so neither has readable definitions and both are reported.
let own = {
let bytes = skip!(crate::common::try_fixture("383-fx-ramp-on-100x100-start-10-20.aep"));
let file = parse(&bytes).unwrap();
@@ -646,8 +647,9 @@ fn bogus_effect_structure_is_reported_not_imported_blindly() {
}
}));
let r = report_of(&bytes);
assert_eq!(r.imported.effects, own, "{r:?}");
assert_eq!(r.count(effectcraft_aep::import::LossCategory::Effects), 2 - own, "{r:?}");
let arrive = if own > 0 { 2 } else { 0 };
assert_eq!(r.imported.effects, arrive, "{r:?}");
assert_eq!(r.count(effectcraft_aep::import::LossCategory::Effects), 2 - arrive, "{r:?}");
assert!(try_all(&bytes)); // 368, the first Gradient Ramp of its file, has its own parT: it still arrives.
let bytes = skip!(mutate("368-fx-ramp-default.aep", &mut |c| {
if let Body::List { list_type, .. } = &mut c.body
+237
View File
@@ -553,6 +553,186 @@ fn default_state_effect_records_import_like_written_ones() {
assert_eq!(clip, vec![("clipToOutputBlack".to_string(), effectcraft_keyframe::Value::Enum(1))]);
}
/// Sets the `cdat` of the value record `param` in the value group `tdgp` (the first `f64`), and
/// its state to `tdsb` when given. True when the group has that record.
fn patch_record(tdgp: &mut Chunk<'_>, param: &str, tdsb: Option<u32>, value: f64) -> bool {
let kids = tdgp.children_mut();
let Some(at) = kids.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(param.as_bytes()))) else {
return false;
};
let Some(record) = kids.get_mut(at + 1).filter(|k| k.is_list(b"tdbs")) else { return false };
for k in record.children_mut() {
if k.is(b"tdsb")
&& let Some(state) = tdsb
{
set_data(k, 0, &state.to_be_bytes());
} else if k.is(b"cdat") {
set_data(k, 0, &value.to_be_bytes());
}
}
true
}
/// The project without its project-level effect definitions (`EfdG`; some files have none, seen
/// in the projects inside After Effects-made Motion Graphics templates) and with a second Glow
/// just above the first on the layer: a copy of it whose `parT` is empty, as such files write
/// every instance of a type but one. `edit_copy` is applied to every chunk of the copy; with
/// `empty_original` the original's `parT` is emptied too.
fn second_glow_without_definitions(edit_copy: &mut dyn FnMut(&mut Chunk<'_>), empty_original: bool) -> Vec<u8> {
fn empty_part(fx: &mut Chunk<'_>) {
for k in fx.children_mut() {
if let Body::List { list_type, children, .. } = &mut k.body
&& list_type == b"parT"
{
children.clear();
}
}
}
let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
let mut added = 0;
visit(&mut file.chunks, &mut |c| {
if let Body::List { list_type, children, .. } = &mut c.body {
if list_type == b"EfdG" {
*list_type = *b"XfdG";
return;
}
if list_type != b"tdgp" {
return;
}
let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2"))) else {
return;
};
let (Some(name), Some(mut fx)) = (children.get(at).cloned(), children.get(at + 1).filter(|k| k.is_list(b"sspc")).cloned()) else { return };
empty_part(&mut fx);
edit_copy(&mut fx);
visit(fx.children_mut(), edit_copy);
if empty_original && let Some(original) = children.get_mut(at + 1) {
empty_part(original);
}
children.insert(at, fx);
children.insert(at, name);
added += 1;
}
});
assert_eq!(added, 1, "one Glow on the layer");
write(&file).unwrap()
}
/// The imported Glows in stack order, each as its property values by property id.
fn glow_values(p: &Project) -> Vec<Vec<(String, effectcraft_keyframe::Value)>> {
let glow = effectcraft_project::GroupKind::Effect { effect: "ec.stylize.glow".to_string() };
let mut glows = Vec::new();
for item in p.items.values() {
if let ItemKind::Comp(c) = &item.kind {
for layer in &c.layers {
for node in layer.effects().map(|fx| fx.children.as_slice()).unwrap_or_default() {
if let effectcraft_project::Node::Group(g) = node
&& g.kind == glow
{
let mut values = Vec::new();
g.walk("", &mut |_, prop| values.push((prop.match_id.clone(), prop.value.clone())));
glows.push(values);
}
}
}
}
}
glows
}
/// The value of property `id` in `values`.
fn value_of<'v>(values: Option<&'v Vec<(String, effectcraft_keyframe::Value)>>, id: &str) -> Option<&'v effectcraft_keyframe::Value> {
values?.iter().find(|(i, _)| i == id).map(|(_, v)| v)
}
/// Every instance of an effect type but one has an empty `parT`; without a project-level copy the
/// others borrow the definitions of that one (#3170: 129 Color Controls of a template were
/// reported "without readable parameter definitions" and dropped).
#[test]
fn an_instance_without_definitions_imports_with_those_of_another_instance() {
// Both Glows import; before, the copy was reported and not imported.
let (p, r) = run(&second_glow_without_definitions(&mut |_| {}, false), true);
let r = r.unwrap();
let found = losses(&r, LossCategory::Effects);
assert!(!found.iter().any(|d| d.contains("without readable parameter definitions") || d.contains("no parameter definition")), "{found:?}");
assert_eq!(r.report.imported.effects, 3, "Levels and both Glows: {:?}", r.report.losses);
let glows = glow_values(&p);
assert_eq!(glows.len(), 2);
assert_eq!(glows.first(), glows.get(1), "the copy has the same records, so the same values");
// The values come from each instance's own records: the copy's A & B Midpoint (`-0011`) set
// to 0.75 arrives as 75 % in the copy only; the Glow it borrows from keeps its own.
let mut patched = 0;
let bytes = second_glow_without_definitions(
&mut |c| {
if c.is_list(b"tdgp") && patch_record(c, "ADBE Glo2-0011", None, 0.75) {
patched += 1;
}
},
false,
);
assert_eq!(patched, 1, "the copy's record");
let (p, r) = run(&bytes, true);
r.unwrap();
let glows = glow_values(&p);
let seventy_five = effectcraft_keyframe::Value::Scalar(75.0);
assert_eq!(value_of(glows.first(), "abMidpoint"), Some(&seventy_five));
let own = value_of(glows.get(1), "abMidpoint");
assert!(own.is_some() && own != Some(&seventy_five), "{own:?}");
}
/// A value record that the borrowed definitions do not name is reported, not dropped silently.
#[test]
fn a_value_record_the_borrowed_definitions_do_not_name_is_reported() {
let bytes = second_glow_without_definitions(
&mut |c| {
if c.is(b"tdmn") && c.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2-0011")) {
set_data(c, 0, b"ADBE Glo2-0099\0");
}
},
false,
);
let (_, r) = run(&bytes, true);
let r = r.unwrap();
let found = losses(&r, LossCategory::Effects);
assert!(found.iter().any(|d| d.contains("ADBE Glo2-0099") && d.contains("no parameter definition")), "{found:?}");
assert_eq!(r.report.imported.effects, 3, "{:?}", r.report.losses);
}
/// When no instance of the type keeps its definitions, every instance is reported.
#[test]
fn instances_without_any_definitions_are_reported() {
let (p, r) = run(&second_glow_without_definitions(&mut |_| {}, true), true);
let r = r.unwrap();
let found = losses(&r, LossCategory::Effects);
assert_eq!(found.iter().filter(|d| d.contains("ADBE Glo2") && d.contains("without readable parameter definitions, not imported")).count(), 2, "{found:?}");
assert_eq!(r.report.imported.effects, 1, "Levels only: {:?}", r.report.losses);
assert!(glow_values(&p).is_empty());
}
/// The reader's side: the copy's definitions come from the other instance.
#[test]
fn an_instance_without_definitions_reads_those_of_another_instance() {
let bytes = second_glow_without_definitions(&mut |_| {}, false);
let file = parse(&bytes).unwrap();
let project = effectcraft_aep::Project::new(&file).unwrap();
let defs = project.effect_definition_index();
let mut sources = Vec::new();
for item in project.all_items() {
let Some(comp) = item.comp() else { continue };
for layer in comp.layers() {
for fx in layer.effects().iter().filter(|fx| fx.match_name() == "ADBE Glo2") {
sources.push(defs.source(fx));
let params = defs.params(fx);
assert_eq!(params.len(), 16, "Glow's parameters");
assert_eq!(params.iter().all(|p| p.borrowed_definition), defs.source(fx) == effectcraft_aep::DefinitionSource::OtherInstance);
}
}
}
// The copy is above the instance it borrows from (earlier in the stack).
assert_eq!(sources, [effectcraft_aep::DefinitionSource::OtherInstance, effectcraft_aep::DefinitionSource::Instance]);
}
/// A record in a state other than 1 or 3 is not read and is reported.
#[test]
fn an_effect_record_in_another_state_is_reported_as_damaged() {
@@ -564,3 +744,60 @@ fn an_effect_record_in_another_state_is_reported_as_damaged() {
assert!(damaged.iter().any(|l| l.contains("unreadable value")), "tdsb {tdsb}: {:?}", r.report.losses);
}
}
/// The project with a value record for Glow's Color A (`ADBE Glo2-0012`, which the project does
/// not store) in state `tdsb` holding the ARGB doubles `argb` (0..255): a copy of the A & B
/// Midpoint record (`-0011`) made a 4-component colour without its expression.
fn with_color_a(tdsb: u32, argb: [f64; 4]) -> Vec<u8> {
let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
let mut added = 0;
visit(&mut file.chunks, &mut |c| {
let Body::List { list_type, children, .. } = &mut c.body else { return };
if list_type != b"tdgp" {
return;
}
let Some(at) = children.iter().position(|k| k.is(b"tdmn") && k.bytes().is_some_and(|b| b.split(|x| *x == 0).next() == Some(b"ADBE Glo2-0011"))) else {
return;
};
let (Some(mut name), Some(mut record)) = (children.get(at).cloned(), children.get(at + 1).filter(|k| k.is_list(b"tdbs")).cloned()) else { return };
set_data(&mut name, 0, b"ADBE Glo2-0012\0");
if let Body::List { children: kids, .. } = &mut record.body {
kids.retain(|k| !k.is(b"Utf8"));
for k in kids.iter_mut() {
if k.is(b"tdsb") {
set_data(k, 0, &tdsb.to_be_bytes());
} else if k.is(b"tdb4") {
// 4 components; no expression (bytes 76, 77 and 120).
set_data(k, 3, &[4]);
for byte in [76, 77, 120] {
set_data(k, byte, &[0]);
}
} else if k.is(b"cdat") {
*k = Chunk::data(*b"cdat", argb.iter().flat_map(|v| v.to_be_bytes()).collect::<Vec<u8>>());
}
}
}
children.insert(at + 2, record);
children.insert(at + 2, name);
added += 1;
});
assert_eq!(added, 2, "the instance and the EfdG copy");
write(&file).unwrap()
}
/// A colour recorded in the default state (`tdsb` 3) reads like a written one (#3170's "parameter
/// Color ... holds an unreadable value" on 0.21.1, which read only written records).
#[test]
fn a_default_state_colour_record_holds_the_value() {
let argb = [255.0, 51.0, 153.0, 255.0];
let (written, r1) = run(&with_color_a(1, argb), true);
let (default_state, r3) = run(&with_color_a(3, argb), true);
let (r1, r3) = (r1.unwrap(), r3.unwrap());
assert!(losses(&r3, LossCategory::DamagedData).is_empty(), "{:?}", r3.report.losses);
assert_eq!(r3.report.losses, r1.report.losses);
let colour = |p: &Project| value_of(glow_values(p).first(), "colorA").cloned();
let (unchanged, _) = run(SYNTHETIC, true);
assert!(colour(&written).is_some());
assert_eq!(colour(&default_state), colour(&written));
assert_ne!(colour(&default_state), colour(&unchanged), "the recorded colour, not the default");
}
+1 -1
View File
@@ -461,7 +461,7 @@ The expression engine is project-wide (`LIST ExEn/Utf8`, `javascript-1.0`). API:
| **default** | `pard` | 56 | by type | float slider: f64 (Slider 0, **Blurriness 25**); fixed slider and angle: 16.16 (drop shadow opacity 127.5, direction 135, distance 5, softness 0; Fill opacity 1.0 = 100 %; **Tint Amount to Tint 100, Ramp Scatter 0, Blend With Original 0**); **colour: ARGB bytes (drop shadow `ff000000` black, Fill `ffff0000` red; Ramp Start `ff000000`, End `ffffffff`; Tint Map Black To `00000000` and Map White To `00ffffff`: alpha 0, which is what After Effects reads back)**; popup: u32 index (1); **point: two 16.16 fractions of the layer size at 56 and 60 (Point Control 0.5, 0.5; Ramp Start 0.5, 0 and End 0.5, 1)** | 071, 077, 080, 214, 215, 356, 368, 383 | V |
| **valid maximum** | `pard` | 128 / 108 | 16.16 / f32 | the largest valid value of a fixed slider (128: Tint Amount 100, Ramp Scatter 512, Blend With Original **1.0**) and of a float slider (108: Blurriness 30 000; **Exposure 100, Offset 2, Gamma Correction 10**). The stored scale differs per slider (below), so this is the reader's only hint at it | 356, 368, 078, 389 | V |
| **float slider valid minimum, slider range** | `pard` | 104; 112 / 116 | f32; f32 x 2 | the smallest valid value (Exposure -100, Offset -2, Gamma Correction 0.1 as f32) and the range the slider covers (Exposure -4..4, Offset -0.5..0.5, Gamma 0.1..10); the slider range is repeated as `tdum` / `tduM` (f64) in each value record (391-394). @120 holds the default again as f32 (Gamma 1.0, Blurriness 25), u16 @124 the display decimals (**H**: Exposure 2, Offset 4) | 389, 391-394 | V |
| **definitions of a second instance** | top-level `LIST EfdG` | | | one `EfDf` per effect TYPE (`tdmn` = match name, then a copy of ONE instance's `sspc`, including that instance's value records); the `EfDC` count before them is a little-endian u32. When two layers carry the same effect type, only one instance keeps a full `parT`; **the other has an empty `LIST parT` (4 bytes)** but its own `tdgp` (input layer `-0000` with its own `tdpi`, the button record, and its value records). Its definitions come from the `EfdG` copy by match name, its values from its own records, never from the copy (383: the copy holds S100's Start [0.1, 0.2]; L's Ramp reads the 368 defaults). Which instance keeps the full `parT` is **?** (in 383 S100 is both first in the file and newest). The `EfDf` order follows the stack order (365 / 366); `Pefl/pjef` does not | 365, 366, 383 | V |
| **definitions of a second instance** | top-level `LIST EfdG` | | | one `EfDf` per effect TYPE (`tdmn` = match name, then a copy of ONE instance's `sspc`, including that instance's value records); the `EfDC` count before them is a little-endian u32. When two layers carry the same effect type, only one instance keeps a full `parT`; **the other has an empty `LIST parT` (4 bytes)** but its own `tdgp` (input layer `-0000` with its own `tdpi`, the button record, and its value records). Its definitions come from the `EfdG` copy by match name, its values from its own records, never from the copy (383: the copy holds S100's Start [0.1, 0.2]; L's Ramp reads the 368 defaults). Which instance keeps the full `parT` is **?** (in 383 S100 is both first in the file and newest). The `EfDf` order follows the stack order (365 / 366); `Pefl/pjef` does not. **Some files have no `EfdG` at all** (seen in 17 projects, all from inside After Effects-made Motion Graphics templates). In each of them every effect type (196 in all; 2 to 14 Color Controls per file) has exactly one instance with a full `parT` and an empty one in every other instance, and the reader takes the others' definitions from that instance (`DefinitionSource::OtherInstance`, #3170). Which instance keeps it is **?** here too | 365, 366, 383; templates | V |
| popup items | `parT/pdnm/Utf8` | | text | items separated by `\|` | 077 (`Item 1\|Item 2\|Item 3`), 214 | V |
| stored value | `sspc/tdgp/<match>-NNNN` | | `tdbs` | present only for a parameter that differs from its default; `tdsb` 1, `cdat` as in 3.4. **Absent record = the `pard` default.** Some parameters are recorded in the default state (`tdsb` 3) and the record still holds the value After Effects uses: every one of the 25 such records in the corpus and the samples differs from its `pard` default and reads back as recorded (Lumetri Color's hidden controls High Dynamic Range, HDR Specular, the second Look, Legacy Tone And Saturation, Face Detection, Auto Color, Auto Intensity and SemanticAutoTone Progress, 1200; Cryptomatte's Selection Mode on, 1400). No effect record in the default state holds a value that does not count. The reader takes the value in either state (`EffectParam::recorded_value`, `EffectParam::value`); `EffectParam::stored_value` keeps to written records, and a record in any other state (no sample has one) is not read: the importer reports it as damaged data. That other effects record values so is H (no sample shows one) | see below; 1200, 1400 | V (1200, 1400), H (other effects) |