Merge remote-tracking branch 'upstream/main' into aep-import-lumetri

# Conflicts:
#	crates/aep/src/import/effects.rs
This commit is contained in:
vangel76
2026-11-08 02:32:59 +01:00
8 changed files with 596 additions and 33 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(),
}
+83 -6
View File
@@ -2,6 +2,7 @@
//! of a layer's source.
use crate::container::bytes::{be_f32, be_f64, be_u32, cstr, le_u32, text};
use crate::container::chunk::Chunk;
use crate::container::header::Rational;
use crate::container::typed::{Item, ItemKind, Layer, Project};
@@ -159,19 +160,25 @@ fn json_string_at(json: &[u8], mut i: usize) -> Option<String> {
}
}
/// The numbering of an image sequence item (353, 603).
/// The numbering of an image sequence item (353, 603), or its file list when it was imported with
/// Force alphabetical order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SequenceInfo {
/// The file name before the frame number (`seq_`, `shot_`): the first `Utf8` after the alias.
/// An empty prefix is not stored (`0001.png`): the one `Utf8` is then the suffix.
pub prefix: String,
/// The file name after the frame number (`.png`, `.exr`): the second `Utf8` after the alias.
pub suffix: String,
/// The first and last frame number (`sspc` `u32` at 172 and 176: 1 and 3 in 353, 101 and 103
/// in 603).
/// in 603; both `0xFFFFFFFF` for a sequence with a file list).
pub start: u32,
pub end: u32,
/// The digits of the frame number (`sspc` `u32` at 180: 3 for `seq_001`, 4 for `shot_0101`).
pub digits: u32,
/// The file names in order for a sequence imported with Force alphabetical order (`LIST StVc`
/// after the alias: `StVS` with the count as a little-endian `u32`, then one `Utf8` per file);
/// empty for a numbered sequence. When set, it replaces the numbering.
pub files: Vec<String>,
}
impl SequenceInfo {
@@ -180,6 +187,62 @@ impl SequenceInfo {
let width = usize::try_from(self.digits.min(16)).unwrap_or(0);
format!("{}{n:0width$}{}", self.prefix, self.suffix)
}
/// The number of files: the listed files, or the frame numbers from start to end (0 when the
/// end lies before the start).
pub fn frame_count(&self) -> u32 {
if self.files.is_empty() {
self.end.checked_sub(self.start).map_or(0, |d| d.saturating_add(1))
} else {
u32::try_from(self.files.len()).unwrap_or(u32::MAX)
}
}
/// The name of the `i`-th file (from 0): from the file list, or numbered from `start`; `None`
/// past the last file.
pub fn nth_file_name(&self, i: u32) -> Option<String> {
if self.files.is_empty() {
let n = self.start.checked_add(i).filter(|n| *n <= self.end)?;
Some(self.file_name(n))
} else {
self.files.get(usize::try_from(i).ok()?).cloned()
}
}
}
/// The file names of a `LIST StVc` (Force alphabetical order): the `Utf8` children in order, at most
/// as many as the count in `StVS` (little-endian `u32`) says. Empty names count toward that number
/// and are then skipped. Without a readable count (no `StVS`, or one shorter than 4 bytes) the list
/// is unreadable (empty). A name that is not a plain file name (holding `/`, `\`, `:` or a control
/// character such as NUL, or being `.` or `..`) makes the whole list unreadable (empty): the names
/// are joined to the folder.
fn sequence_file_list(list: &Chunk<'_>) -> Vec<String> {
// After Effects writes the count first (the one sample); a list without a readable count is
// not read rather than guessed.
let Some(count) = list.child(b"StVS").and_then(|c| c.bytes()).and_then(|b| le_u32(b, 0)).and_then(|n| usize::try_from(n).ok()) else {
return Vec::new();
};
let names: Vec<String> =
list.children().iter().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).take(count).map(text).filter(|n| !n.is_empty()).collect();
if names.iter().any(|n| n.contains(|c: char| matches!(c, '/' | '\\' | ':') || c.is_control()) || n == "." || n == "..") {
return Vec::new();
}
names
}
/// The prefix and suffix of a sequence whose naming holds one `Utf8` (`part`): After Effects leaves
/// out an empty prefix (`0001.png` stores only `.png`). The stored first file name (`opti`) decides:
/// a name starting with `part` has it as the prefix, one ending with it as the suffix. Without that
/// evidence, a `part` starting with `.` is the suffix (an extension); anything else is not read.
fn single_name_part(part: &str, first_file: Option<&str>) -> Option<(String, String)> {
if let Some(first) = first_file.filter(|_| !part.is_empty()) {
match (first.starts_with(part), first.ends_with(part)) {
(true, false) => return Some((part.to_string(), String::new())),
(false, true) => return Some((String::new(), part.to_string())),
_ => {}
}
}
part.starts_with('.').then(|| (String::new(), part.to_string()))
}
impl<'f, 'a> Item<'f, 'a> {
@@ -194,10 +257,24 @@ impl<'f, 'a> Item<'f, 'a> {
let sspc = pin.child(b"sspc")?.bytes()?;
let kids = pin.children();
let after = kids.iter().position(|c| c.is_list(b"Als2"))?;
let mut names = kids.iter().skip(after + 1).take_while(|c| !c.is(b"opti")).filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text);
let prefix = names.next()?;
let suffix = names.next()?;
Some(SequenceInfo { prefix, suffix, start: be_u32(sspc, 172)?, end: be_u32(sspc, 176)?, digits: be_u32(sspc, 180)? })
let naming = || kids.iter().skip(after.saturating_add(1)).take_while(|c| !c.is(b"opti"));
let (start, end, digits) = (be_u32(sspc, 172)?, be_u32(sspc, 176)?, be_u32(sspc, 180)?);
// Force alphabetical order: the file list replaces the numbering.
if let Some(list) = naming().find(|c| c.is_list(b"StVc")) {
let files = sequence_file_list(list);
if files.is_empty() {
return None;
}
return Some(SequenceInfo { prefix: String::new(), suffix: String::new(), start, end, digits, files });
}
let names: Vec<String> = naming().filter(|c| c.is(b"Utf8")).filter_map(|c| c.bytes()).map(text).take(2).collect();
let (prefix, suffix) = match names.as_slice() {
[prefix, suffix] => (prefix.clone(), suffix.clone()),
// An empty prefix is not stored (`0001.png` has only `.png`).
[one] => single_name_part(one, f.name.as_deref())?,
_ => return None,
};
Some(SequenceInfo { prefix, suffix, start, end, digits, files: Vec::new() })
}
/// The rate a clip is conformed to (Interpret Footage ▸ Conform to frame rate; `sspc` `u32` at
+27 -9
View File
@@ -206,6 +206,8 @@ fn new_comp(h: &CompHeader, renderer: Option<AepRenderer>, map: &TimeMap, name:
comp
}
/// Adds one footage item to the project under the given parent.
/// Unreadable footage is recorded as a loss, solids and null placeholders are handled separately, and file and sequence footage is linked to its files.
fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_>, name: String, label: Label, parent: ItemId, null_only: &HashSet<u32>) {
let Some(f) = item.footage() else {
cx.col.lose(LossCategory::DamagedData, format!("footage '{name}': data unreadable, not imported"));
@@ -240,31 +242,42 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
}
let mut path = f.path.clone().unwrap_or_default();
// An image sequence's alias is its folder; its files are named by the stored numbering
// (353, 603). The first file stands for the sequence, as in EffectCraft.
// (353, 603), or listed one by one when imported with Force alphabetical order. The first
// file stands for the sequence, as in EffectCraft.
let mut sequence = Vec::new();
if let Some(seq) = item.sequence_info().filter(|s| !path.is_empty() && s.end >= s.start) {
let frames = (seq.end - seq.start).saturating_add(1);
if let Some(seq) = item.sequence_info().filter(|s| !path.is_empty() && s.frame_count() > 0) {
let frames = seq.frame_count();
let sep = if path.contains('\\') { '\\' } else { '/' };
let folder = path.trim_end_matches(['\\', '/']).to_string();
// One name is the folder, a separator and the file name; the number is as wide as the stored
// digits or as the largest number, whichever is longer (an upper bound for every frame).
let name_bytes = (folder.len() + 1 + seq.prefix.len() + seq.suffix.len() + usize::try_from(seq.digits.min(16)).unwrap_or(16).max(10)) as u64;
let bytes = name_bytes.saturating_mul(u64::from(frames));
// One name is the folder, a separator and the file name. A numbered name is as wide as the
// stored digits or as the largest number, whichever is longer (an upper bound for every
// frame); listed names are counted as they are.
let len = |n: usize| u64::try_from(n).unwrap_or(u64::MAX);
let file_bytes = if seq.files.is_empty() {
len(seq.prefix.len().saturating_add(seq.suffix.len()).saturating_add(usize::try_from(seq.digits.min(16)).unwrap_or(16).max(10)))
.saturating_mul(u64::from(frames))
} else {
seq.files.iter().fold(0u64, |sum, n| sum.saturating_add(len(n.len())))
};
let bytes = len(folder.len().saturating_add(1)).saturating_mul(u64::from(frames)).saturating_add(file_bytes);
if frames > MAX_SEQUENCE_FRAMES || frames > cx.sequence_budget || bytes > cx.sequence_bytes {
// Not expanded: the item keeps its first file (a still standing for the sequence).
cx.col.lose(
LossCategory::DamagedData,
format!("footage '{name}': a sequence of {frames} frames is longer than the import expands; only its first file is linked"),
);
path = format!("{folder}{sep}{}", seq.file_name(seq.start));
path = format!("{folder}{sep}{}", seq.nth_file_name(0).unwrap_or_default());
} else {
cx.sequence_budget -= frames;
cx.sequence_bytes -= bytes;
sequence = (seq.start..=seq.end).map(|n| format!("{folder}{sep}{}", seq.file_name(n))).collect();
sequence = (0..frames).filter_map(|i| seq.nth_file_name(i)).map(|n| format!("{folder}{sep}{n}")).collect();
}
if let Some(first) = sequence.first() {
path = first.clone();
}
} else if f.is_sequence && !path.is_empty() {
// The alias is a folder, but the file names could not be read: the item links the folder.
cx.col.lose(LossCategory::DamagedData, format!("footage '{name}': image sequence naming not read, linked to its folder"));
}
let present = !path.is_empty() && (cx.options.exists)(&path);
if !present {
@@ -318,6 +331,11 @@ fn import_footage(project: &mut Project, cx: &mut Ctx<'_>, item: &AepItem<'_, '_
/// sequence the name with the frame range (`shot_[0101-0103].exr`).
fn default_footage_name(item: &AepItem<'_, '_>, f: &crate::Footage) -> Option<String> {
if let Some(s) = item.sequence_info() {
// A listed sequence (Force alphabetical order) has no numbering: its first file names it.
// Assumption: what After Effects shows for such an item without a stored name was not sampled.
if let Some(first) = s.files.first() {
return Some(first.clone());
}
let w = usize::try_from(s.digits.min(16)).unwrap_or(0);
return Some(format!("{}[{:0w$}-{:0w$}]{}", s.prefix, s.start, s.end, s.suffix));
}
+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::Lumetri {
crate::import::lumetri::import_lumetri(&fx, &params, cx, &shown, ParamOut { group: &mut group, refs: &mut *refs, col: &mut *col });
} else if map.extra == Extra::Cryptomatte {
+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
+398
View File
@@ -77,6 +77,167 @@ fn a_sequence_with_long_names_is_not_expanded_past_the_byte_budget() {
assert_eq!(longest, 0, "the sequence was not expanded");
}
/// The project with the sequence's naming (the chunks between `Als2` and `opti` in its `Pin `)
/// replaced by `naming`, and the `sspc` numbering (start, end, digits at 172, 176, 180) set to
/// `numbering` when given.
fn with_sequence_naming(naming: &[Chunk<'static>], numbering: Option<[u32; 3]>) -> Vec<u8> {
let mut file: AepFile<'_> = parse(SYNTHETIC).unwrap();
let mut patched = 0;
visit(&mut file.chunks, &mut |c| {
if !c.is_list(b"Pin ") {
return;
}
let Body::List { children, .. } = &mut c.body else { return };
let Some(als) = children.iter().position(|k| k.is_list(b"Als2")) else { return };
let Some(opti) = children.iter().position(|k| k.is(b"opti")) else { return };
assert!(als < opti);
let tail = children.split_off(opti);
children.truncate(als + 1);
children.extend(naming.iter().map(shorten));
children.extend(tail);
if let Some(numbering) = numbering {
let sspc = children.iter_mut().find(|k| k.is(b"sspc")).unwrap();
for (at, v) in [172, 176, 180].into_iter().zip(numbering) {
set_data(sspc, at, &v.to_be_bytes());
}
}
patched += 1;
});
assert_eq!(patched, 1, "the synthetic project has one sequence");
write(&file).unwrap()
}
/// `bytes` with the first file name the sequence stores in its png `opti` (byte 58, NUL-terminated)
/// set to `name`.
fn with_first_file(bytes: &[u8], name: &str) -> Vec<u8> {
let mut file: AepFile<'_> = parse(bytes).unwrap();
let mut patched = 0;
visit(&mut file.chunks, &mut |c| {
if c.is(b"opti") && c.bytes().is_some_and(|d| d.get(..4) == Some(&b"png!"[..])) {
let mut z = name.as_bytes().to_vec();
z.push(0);
set_data(c, 58, &z);
patched += 1;
}
});
assert_eq!(patched, 1, "the synthetic project has one png item");
write(&file).unwrap()
}
/// The imported sequence item's footage.
fn the_footage(p: &Project) -> effectcraft_project::Footage {
p.items.values().find_map(|i| if let ItemKind::Footage(f) = &i.kind { Some(f.clone()) } else { None }).expect("the sequence")
}
/// The file names (without the folder) of a footage's sequence.
fn file_names(f: &effectcraft_project::Footage) -> Vec<String> {
f.sequence.iter().map(|s| s.rsplit(['\\', '/']).next().unwrap_or_default().to_string()).collect()
}
/// A copy of `c` that borrows for any lifetime (a chunk is covariant in its lifetime).
fn shorten<'a>(c: &Chunk<'static>) -> Chunk<'a> {
c.clone()
}
/// A `Utf8` string chunk holding `s`.
fn utf8(s: &str) -> Chunk<'static> {
Chunk::data(*b"Utf8", s.as_bytes().to_vec())
}
/// A sequence whose naming has only a suffix (no filename prefix) imports as a sequence of its files.
#[test]
fn a_sequence_without_a_filename_prefix_imports_its_files() {
// `0001.png` .. `0003.png`: After Effects omits the empty prefix, so only `.png` follows the
// alias. Before, `.png` was read as the prefix, the suffix was missing and the item became a
// video linked to its folder.
let (p, r) = run(&with_sequence_naming(&[utf8(".png")], None), true);
r.unwrap();
let f = the_footage(&p);
assert_eq!(f.kind, FootageKind::Sequence);
assert_eq!(file_names(&f), ["0001.png", "0002.png", "0003.png"]);
assert!(f.path.ends_with("0001.png"), "{}", f.path);
}
/// A single `Utf8` naming part is read as the suffix or the prefix depending on how the stored first file name matches it.
#[test]
fn a_single_naming_part_is_placed_by_the_stored_first_file_name() {
// `0001_v2.png` ..: the one `Utf8` is the suffix, as the first file name (`opti`) ends with it.
let bytes = with_first_file(&with_sequence_naming(&[utf8("_v2.png")], None), "0001_v2.png");
let (p, r) = run(&bytes, true);
r.unwrap();
let f = the_footage(&p);
assert_eq!(f.kind, FootageKind::Sequence);
assert_eq!(file_names(&f), ["0001_v2.png", "0002_v2.png", "0003_v2.png"]);
// `shot_0001` ..: a name without an extension; the one `Utf8` is the prefix.
let bytes = with_first_file(&with_sequence_naming(&[utf8("shot_")], None), "shot_0001");
let (p, r) = run(&bytes, true);
r.unwrap();
assert_eq!(file_names(&the_footage(&p)), ["shot_0001", "shot_0002", "shot_0003"]);
// Without that evidence (the stored first file is `shot_0001.png`), a part that is no extension
// is not guessed: the item is reported.
let bytes = with_sequence_naming(&[utf8("_v2.png")], None);
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
}
/// A file list containing a name with a separator, drive colon, dot directory or control character is refused and reported.
#[test]
fn a_file_list_with_a_name_that_is_not_a_plain_file_name_is_reported() {
// The listed names are joined to the folder: a separator, a drive colon or a dot directory
// would point outside it, and a NUL or another control character is no file name, so such a
// list is not read.
for hostile in ["..\\..\\x.png", "../x.png", "C:x.png", "..", ".", "x\0.png", "x\n.png"] {
let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 2u32.to_le_bytes().to_vec()), utf8("a.png"), utf8(hostile)]);
let bytes = with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0]));
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
}
}
/// A file list with a missing or too short count chunk is refused and reported.
#[test]
fn a_file_list_without_a_readable_count_is_reported() {
// No `StVS`, or one too short for its `u32`: the list is not read rather than taken whole.
for count in [None, Some(vec![2u8, 0])] {
let mut kids: Vec<Chunk<'static>> = count.map(|c| Chunk::data(*b"StVS", c)).into_iter().collect();
kids.extend([utf8("a.png"), utf8("b.png")]);
let bytes = with_sequence_naming(&[Chunk::list(*b"StVc", kids)], Some([u32::MAX, u32::MAX, 0]));
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
}
}
/// A force-alphabetical sequence imports its stored file list in the stored order.
#[test]
fn a_sequence_imported_with_force_alphabetical_order_imports_its_file_list() {
// Force alphabetical order: `LIST StVc` with `StVS` (the count, little-endian) and one `Utf8`
// per file instead of the prefix and suffix; the numbering in `sspc` is not set. Before, the
// item became a video linked to its folder.
let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 3u32.to_le_bytes().to_vec()), utf8("b_take.png"), utf8("a_take.png"), utf8("c_take.png")]);
let (p, r) = run(&with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])), true);
let r = r.unwrap();
let f = the_footage(&p);
assert_eq!(f.kind, FootageKind::Sequence);
// The stored order is kept.
assert_eq!(file_names(&f), ["b_take.png", "a_take.png", "c_take.png"]);
assert!(f.path.ends_with("b_take.png"), "{}", f.path);
assert!(losses(&r, LossCategory::DamagedData).is_empty(), "{:?}", r.report.losses);
// The count bounds the list.
let list = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 2u32.to_le_bytes().to_vec()), utf8("x.png"), utf8("y.png"), utf8("z.png")]);
let (p, r) = run(&with_sequence_naming(&[list], Some([u32::MAX, u32::MAX, 0])), true);
r.unwrap();
assert_eq!(file_names(&the_footage(&p)), ["x.png", "y.png"]);
}
/// A sequence with no naming or an empty file list is reported and linked to its folder.
#[test]
fn a_sequence_whose_naming_cannot_be_read_is_reported() {
// No naming at all after the alias. Before, the item silently linked its folder.
let bytes = with_sequence_naming(&[], None);
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
// An empty file list is no naming either.
let empty = Chunk::list(*b"StVc", vec![Chunk::data(*b"StVS", 0u32.to_le_bytes().to_vec())]);
let bytes = with_sequence_naming(&[empty], Some([u32::MAX, u32::MAX, 0]));
reported_and_refused(&bytes, LossCategory::DamagedData, "image sequence naming not read, linked to its folder");
}
#[test]
fn a_solid_with_a_nan_channel_is_repaired_and_reported() {
let nan = f32::NAN.to_be_bytes();
@@ -392,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() {
@@ -403,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");
}
+4 -3
View File
@@ -1,6 +1,6 @@
# After Effects project format (`.aep`)
> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-05 · **Change:** versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026
> **Last reviewed:** 2026-11-05 · **Last updated:** 2026-11-07 · **Change:** footage: an image sequence without a filename prefix stores only the suffix `Utf8`, and one imported with Force alphabetical order stores its file list in `LIST StVc` / `StVS` (section 3, footage); before that: versions: `svap` is the version that wrote the file, `head` bytes 4 to 7 the version it is written for, and what a Save a Copy As 25.x / 24.x of After Effects 2026 changes (section 1, "Versions"); before that: the stretch numerator is signed (a negative one reverses the layer, 981-983); layer styles (3.12b, 950-980); before that: footage: `sspc@118` of an image sequence is its folder's modification time (860, read-back x1-x3); before that: text: paragraph `/24`, `/35` and style `/68` are neutral for 0 and 1 (732-734, edited in place); before that: keyframes: the ease of mask path keys (599, 700), the spatial speed unit of fractional values (597, 598, 730); before that: text: character and paragraph settings, manual kerning, vertical text, `/nil` is neutral (S17: 538-578, 595, 596); before that: cameras, lights and 3D rotations (S16: the auto Position of a two-node camera is where After Effects renders it, 537; camera and light defaults read back 255, 256; Orientation `otst/otda` 488), Timewarp (529-533), an expression on an effect parameter (534); before that: text: style slot /13 is superscript / subscript (526-528; every scripted text of the corpus is superscript), wrapping box text 517; before that: effects of batch 5 (409-450, 511-516: integer sliders, checkbox defaults in `pard`, arbitrary data of Hue/Saturation and Levels, the parameters of Invert, Fast Box Blur, Gaussian Blur, Drop Shadow, Glow; section 3.7); before that: shape items of batch 5 (451-484, 519: ellipse, fill and stroke opacity, fill rule, group transform, shape layer anchor, trim paths, rectangle roundness, polystar fields, gradient default, nested groups, the defaults of every item; section 3.9); before that: track mattes: mode at `ldta` byte 107 and the matte layer id at byte 160 (cases 402-408, section 3.1); before that: text: automatic leading is verified (400); Exposure (`ADBE Exposure2`, cases 389-397): group start / end parameter types 13 / 14, float slider valid minimum and slider range (section 3.7); before that: text: U+0003 is a paragraph break (386, 387); before that: a stored text Anchor Point is pixels (385, now V); before that: the XML form `.aepx` is read into the same chunk tree (section 1, "The XML form"); the `head` save counter is a `u32` at byte 16; text documents for the importer (stroke colour, automatic leading, the font of each run, the undecoded-slot check, the text-layer Anchor Point, the Source Text record); before that: third round of verified field layouts (explicit-default semantics, type-major bezier ease, exact time stretch and in / out points, Time Remapping state, comp pixel aspect / shutter phase / display start, Point default, Layer Control None, dropdown selection, one `mkif` per mask, polystar and radial gradient records, text styles, camera and light options, marker cue data, audio and image-sequence footage) added to the typed view of `effectcraft-aep` · **Target:** Adobe After Effects 2026
What `crates/aep` ([`effectcraft-aep`](../crates/aep/README.md)) reads and writes, and the evidence
for each statement. Everything here was observed on project files we generated ourselves; no
@@ -279,7 +279,8 @@ Footage items beyond solids and stills (`Pin /sspc`, 222 bytes; audio and image
| bits per pixel | 60 | u32 | RGBA still 32, RGB sequence 24, 0 for solids and audio | 126, 352 | V |
| **sample rate** | 160 | f64 | 48 000.0 for the WAV; the same for an mp4 with sound (48 000.0 and 44 100.0 read back from AAC tracks of those rates), absent for an mp4 without (the importer takes a clip's audio from it) | 350, read-back vid-audio | V |
| **file size** | 208 | u32 | bytes of the source file(s): png 149, WAV 96 044 (44 + 48 000 x 2), three png of 168 bytes 504 | 126, 350, 352 | V |
| **sequence numbering** | 172, 176, 180 | u32 x 3 | first and last frame number and the digits of a sequence: 1, 3, 3 for `seq_001.png`..`seq_003.png` (353), 101, 103, 4 for `shot_0101.exr`..`shot_0103.exr` (603); 0 for single files (604, 605, 607). The name around the number is the pair of `Utf8` chunks between `Als2` and `opti` in `Pin ` (`seq_` / `.png`, `shot_` / `.exr`), absent on single files. `Item::sequence_info`, `SequenceInfo::file_name` | 353, 603, 604, 605, 607 | V |
| **sequence numbering** | 172, 176, 180 | u32 x 3 | first and last frame number and the digits of a sequence: 1, 3, 3 for `seq_001.png`..`seq_003.png` (353), 101, 103, 4 for `shot_0101.exr`..`shot_0103.exr` (603); 0 for single files (604, 605, 607). The name around the number is the pair of `Utf8` chunks between `Als2` and `opti` in `Pin ` (`seq_` / `.png`, `shot_` / `.exr`), absent on single files. **An empty prefix is not written**: `0001.png`..`0005.png` leave one `Utf8` (`.png`) there, so a single `Utf8` is the prefix when the first file name in `opti` (byte 58: `0001.png`) starts with it and the suffix when it ends with it; without that evidence the reader takes one starting with `.` as the suffix and reads nothing else. `Item::sequence_info`, `SequenceInfo::file_name` | 353, 603, 604, 605, 607; the prefix-less sequence of issue 3174 (After Effects 25.2) | V |
| **sequence file list** (Force alphabetical order) | `Pin /LIST StVc` | `StVS` (4 bytes, `u32` LE), then `Utf8` x count | a sequence imported with Force alphabetical order has, between `Als2` and `opti`, a `LIST StVc` instead of the prefix and suffix `Utf8`s: `StVS` holds the file count as a **little-endian** `u32` (`05 00 00 00`), followed by one `Utf8` per file name in order (`shot_0001.png`..`shot_0005.png`). The numbering at @172 / @176 is then `0xFFFFFFFF` and the digits @180 0; duration and rate are stored as for a numbered sequence (5/30, 30). The list replaces the numbering; a list without a readable `StVS` count, or with a name that is not a plain file name (`/`, `\`, `:`, a control character such as NUL, `.`, `..`), is not read. Without a stored item name the importer names the item after its first listed file (an assumption: what After Effects shows was not sampled). `SequenceInfo::files`, `SequenceInfo::nth_file_name` | the alphabetical sequence of issue 3174 (After Effects 25.2) | V |
| **sequence folder time** | 118 | u32 (unaligned) | seconds since 1904-01-01 UTC: the modification time of a sequence's folder when After Effects scanned it (860: 0xe6fb443a = 2026-10-19 03:51:22, the folder's and its files' time). With the folder's time After Effects keeps the stored numbering on open; with another (an older time, the first or the last file's time while the folder's differs: x1, x2 against x3) it scans the folder again and warns about a gap in a modal dialog | 860, `export-tests\sparse860\exp` x1-x3 | V |
| still duration denominator | 42 | u32 | 1 for png (126, 607), 600 for jpeg (605), 100 for OpenEXR (604): a still's duration is 0 over a per-format denominator | 126, 604, 605, 607 | V |
@@ -460,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) |