mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-08 11:31:25 +00:00
.aep import: image sequences without a filename prefix or with Force alphabetical order (#3213)
* AEP import: read image sequences without a filename prefix and with Force alphabetical order (#3174) After Effects leaves out an empty filename prefix, so only one Utf8 (the suffix, such as `.png`) follows the alias. The single part is now placed by the first file name stored in `opti`: a name starting with it has it as the prefix, one ending with it as the suffix; without that evidence a part starting with `.` is the suffix and anything else is not read. A sequence imported with Force alphabetical order stores its files in `LIST StVc` (`StVS` with the count as a little-endian u32, then one Utf8 per file name) instead of prefix and suffix; that list now replaces the numbering. A list with a name that is not a plain file name (a separator, a colon, `.` or `..`) is not read, since the names are joined to the folder. A folder alias whose naming still cannot be read is reported as an import loss instead of silently linking the folder. docs/aep-format.md describes the empty prefix and StVc/StVS. * AEP import: a listed sequence file name with a control character such as NUL is not read (#3174) A NUL or another control character in a Force alphabetical order file name now makes the whole list unreadable, like a separator or a dot directory, so the import reports the naming instead of linking files that cannot exist. * AEP import: a sequence file list without a readable count is not read (#3174) After Effects writes the StVS count before the names. A StVc list without StVS, or with one shorter than its u32, is now reported as unreadable naming instead of taking every listed name. * Doc comments for the functions this PR touches
This commit is contained in:
1 parent
bb2d2005ce
commit
99e24f5867
4 files changed
+274
-17
No files matched your search
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+3
-2
@@ -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 |
|
||||
|
||||
|
||||
Reference in new issue
Block a user