diff --git a/crates/aep/src/container/footage.rs b/crates/aep/src/container/footage.rs index 3c9148155..da0369d64 100644 --- a/crates/aep/src/container/footage.rs +++ b/crates/aep/src/container/footage.rs @@ -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 { } } -/// 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, } 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 { + 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 { + // 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 = + 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 = 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 diff --git a/crates/aep/src/import/build.rs b/crates/aep/src/import/build.rs index 4ec346328..48ae1c1a5 100644 --- a/crates/aep/src/import/build.rs +++ b/crates/aep/src/import/build.rs @@ -206,6 +206,8 @@ fn new_comp(h: &CompHeader, renderer: Option, 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) { 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 { 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)); } diff --git a/crates/aep/tests/it/import/synthetic.rs b/crates/aep/tests/it/import/synthetic.rs index 18709f9e5..5d4d89c56 100644 --- a/crates/aep/tests/it/import/synthetic.rs +++ b/crates/aep/tests/it/import/synthetic.rs @@ -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 { + 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 { + 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 { + 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> = 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(); diff --git a/docs/aep-format.md b/docs/aep-format.md index d6ec7fb14..99a1199f0 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -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 |