mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
* Add craft-aep: After Effects project container and typed view from EffectCraft The .aep (RIFX 'Egg!') and .aepx container as a lossless chunk tree that writes back byte for byte, and the typed, read-only view over it: compositions, layers, properties, keyframes, effects and their settings data, masks, shapes, text documents, markers, cameras, track mattes, footage, project settings and the saving version. Moved from effectcraft crates/aep/src/container at f6b4da0a with its unit tests, the container integration tests, the aepdump and stubicc tools and docs/aep-format.md. The match-name tables (effect_map, style_map), the importer and the exporter are written against EffectCraft's model and stay there. ExtractorChannels::sources takes the caller's name for a copied channel instead of an EffectCraft constant. * craft-aep: read the Photoshop layer a footage item shows A footage item made from one layer of a .psd stores the layer in its `opti` (tag `8BPS`): the layer record's index (big-endian i32 at 14, -1 for the merged image), the document's two sides (little-endian u32 at 32 and 36), the layer's bounds (little-endian i32 at 78, 82, 86, 90) and its name (UTF-8 from 344). `Footage::psd_layer` reads them as `PsdLayer`. The format part of storytold/effectcraft#3449; the importer's use of it stays in EffectCraft. --------- Co-authored-by: arnav-hiwarkar <asharnav2008@gmail.com>
175 lines
8.8 KiB
Rust
175 lines
8.8 KiB
Rust
//! `.aepx` (the XML form) read into the chunk tree, compared with the `.aep` of the same project.
|
|
//!
|
|
//! The pair `aepx-pair` (cases 000, 058, 060, 110 and 124, one composition each) was saved by
|
|
//! After Effects from the same state, the `.aepx` right after the `.aep`.
|
|
//! The trees are identical except for two documented differences: the save counter (`head`
|
|
//! `u32` at byte 16) is one higher in the `.aepx`, and pad bytes are not written in XML (an odd
|
|
//! body gets a zero pad byte). The XMP packet of the fixtures is the base packet in both forms.
|
|
|
|
use craft_aep::{AepError, AepFile, Body, Chunk, Limits, Project, is_aepx, parse, parse_any, read_aepx, read_aepx_with_limits, write};
|
|
|
|
/// The `.aepx` fixtures; each has the `.aep` of the same name.
|
|
const PAIRS: &[&str] = &["aepx-pair"];
|
|
|
|
/// The `.aep` tree with the differences an `.aepx` of the next save has removed: the `head` save
|
|
/// counter is taken from `other`.
|
|
fn with_head_of(mut file: AepFile<'static>, other: &AepFile<'_>) -> AepFile<'static> {
|
|
if let (Some(h), Some(src)) = (file.chunks.iter_mut().find(|c| c.is(b"head")), other.chunk(b"head")) {
|
|
*h = src.clone().into_owned();
|
|
}
|
|
file
|
|
}
|
|
|
|
/// The first difference of two chunk sequences, as a path, ignoring pad bytes.
|
|
fn first_difference(a: &[Chunk<'_>], b: &[Chunk<'_>], path: &str) -> Option<String> {
|
|
if a.len() != b.len() {
|
|
return Some(format!("{path}: {} chunks against {}", a.len(), b.len()));
|
|
}
|
|
for (i, (x, y)) in a.iter().zip(b).enumerate() {
|
|
let name = String::from_utf8_lossy(&x.list_type().unwrap_or(x.id)).into_owned();
|
|
let here = format!("{path}/{i}:{name}");
|
|
if x.id != y.id || x.list_type() != y.list_type() {
|
|
return Some(format!("{here}: id differs"));
|
|
}
|
|
match (&x.body, &y.body) {
|
|
(Body::Data(p), Body::Data(q)) | (Body::OpaqueList { data: p, .. }, Body::OpaqueList { data: q, .. }) => {
|
|
if p != q {
|
|
return Some(format!("{here}: body differs"));
|
|
}
|
|
}
|
|
(Body::Chunks(p), Body::Chunks(q)) | (Body::List { children: p, .. }, Body::List { children: q, .. }) => {
|
|
if let Some(d) = first_difference(p, q, &here) {
|
|
return Some(d);
|
|
}
|
|
}
|
|
_ => return Some(format!("{here}: body kind differs")),
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn head_counter(file: &AepFile<'_>) -> u32 {
|
|
let h = file.chunk(b"head").and_then(Chunk::bytes).expect("head");
|
|
u32::from_be_bytes([h[16], h[17], h[18], h[19]])
|
|
}
|
|
|
|
#[test]
|
|
fn every_pair_gives_the_same_chunk_tree() {
|
|
for name in PAIRS {
|
|
let aep = skip!(crate::common::try_file(&format!("{name}.aep")));
|
|
let aepx = skip!(crate::common::try_file(&format!("{name}.aepx")));
|
|
assert!(is_aepx(&aepx) && !is_aepx(&aep), "{name}");
|
|
let bin = parse(&aep).unwrap();
|
|
let xml = read_aepx(&aepx).unwrap_or_else(|e| panic!("{name}: {e}"));
|
|
// The save counter is the only difference in the container.
|
|
assert_eq!(head_counter(&xml), head_counter(&bin) + 1, "{name}");
|
|
let same = with_head_of(bin.clone().into_owned(), &xml);
|
|
assert_eq!(first_difference(&same.chunks, &xml.chunks, ""), None, "{name}");
|
|
assert_eq!(xml.trailer, bin.trailer, "{name}: XMP packet");
|
|
// Even the bytes agree: written as an `.aep`, the tree is the `.aep` with the next save counter.
|
|
assert!(write(&xml).unwrap() == write(&same).unwrap(), "{name}: written bytes differ");
|
|
// And the content sniffer picks the right reader for both.
|
|
assert_eq!(parse_any(&aepx).unwrap(), xml, "{name}");
|
|
assert_eq!(parse_any(&aep).unwrap(), bin, "{name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_typed_view_reads_the_same_values() {
|
|
for name in PAIRS {
|
|
let aep = skip!(crate::common::try_file(&format!("{name}.aep")));
|
|
let aepx = skip!(crate::common::try_file(&format!("{name}.aepx")));
|
|
let bin = parse(&aep).unwrap();
|
|
let xml = read_aepx(&aepx).unwrap();
|
|
let (pb, px) = (Project::new(&bin).unwrap(), Project::new(&xml).unwrap());
|
|
let items = |p: &Project<'_, '_>| p.all_items().iter().map(|i| (i.kind(), i.display_name(), i.id())).collect::<Vec<_>>();
|
|
assert_eq!(items(&pb), items(&px), "{name}");
|
|
for (cb, cx) in pb.all_items().iter().filter_map(|i| i.comp()).zip(px.all_items().iter().filter_map(|i| i.comp())) {
|
|
assert_eq!(cb.header(), cx.header(), "{name}");
|
|
let layers = |c: &craft_aep::Comp<'_, '_>| c.layers().map(|l| (l.name(), l.header(), l.transform(1920, 1080))).collect::<Vec<_>>();
|
|
assert_eq!(layers(&cb), layers(&cx), "{name}");
|
|
}
|
|
}
|
|
// Spot checks against the values the cases were made with (060's expression, 110's text).
|
|
let aepx = skip!(crate::common::try_file("aepx-pair.aepx"));
|
|
let xml = read_aepx(&aepx).unwrap();
|
|
assert!(xml.walk().any(|(_, c)| c.is(b"Utf8") && c.bytes() == Some(&b"wiggle(1,10)"[..])));
|
|
assert!(xml.walk().any(|(_, c)| c.is_list(b"btdk") && matches!(c.body, Body::OpaqueList { .. })));
|
|
let names: Vec<_> = Project::new(&xml).unwrap().all_items().iter().filter(|i| i.comp().is_some()).filter_map(|i| i.name()).collect();
|
|
assert_eq!(names, ["000-base", "058-key-opacity-2key", "060-expr-opacity-wiggle", "110-text-A", "124-precomp-Pre", "Pre"]);
|
|
}
|
|
|
|
#[test]
|
|
fn damaged_aepx_is_an_error_or_a_tree_never_a_panic() {
|
|
let aepx = skip!(crate::common::try_file("aepx-pair.aepx"));
|
|
// Truncation at about 160 points.
|
|
for cut in (0..aepx.len()).step_by(aepx.len() / 160) {
|
|
let _ = read_aepx(&aepx[..cut]);
|
|
}
|
|
assert!(read_aepx(&aepx[..aepx.len() / 2]).is_err());
|
|
// Byte flips: each result is an error or a tree that writes and reads back.
|
|
let mut state = 0x2545_f491_u32;
|
|
for _ in 0..400 {
|
|
let mut damaged = aepx.clone();
|
|
for _ in 0..4 {
|
|
state ^= state << 13;
|
|
state ^= state >> 17;
|
|
state ^= state << 5;
|
|
let at = state as usize % damaged.len();
|
|
damaged[at] ^= 1 << (state % 8);
|
|
}
|
|
if let Ok(file) = read_aepx(&damaged) {
|
|
let bytes = write(&file).unwrap();
|
|
let _ = Project::new(&parse(&bytes).unwrap()).map(|p| p.all_items().len());
|
|
}
|
|
}
|
|
// Invalid hex and a damaged entity are errors.
|
|
let text = String::from_utf8(aepx.clone()).unwrap();
|
|
let odd = text.replacen("bdata=\"00", "bdata=\"0", 1);
|
|
assert!(matches!(read_aepx(odd.as_bytes()), Err(AepError::Xml { .. })));
|
|
let nonhex = text.replacen("bdata=\"00", "bdata=\"0g", 1);
|
|
assert!(matches!(read_aepx(nonhex.as_bytes()), Err(AepError::Xml { .. })));
|
|
// Deep nesting is refused at the limit; many elements are bounded.
|
|
let deep = format!("<AfterEffectsProject>{}</AfterEffectsProject>", "<Fold>".repeat(1_000_000));
|
|
assert_eq!(read_aepx(deep.as_bytes()), Err(AepError::TooDeep));
|
|
assert_eq!(read_aepx_with_limits(&aepx, Limits { max_depth: 64, max_chunks: 100 }), Err(AepError::TooManyChunks));
|
|
assert_eq!(read_aepx_with_limits(&aepx, Limits { max_depth: 3, max_chunks: 10_000_000 }), Err(AepError::TooDeep));
|
|
// A huge hex body is bounded by the input: nothing is allocated from a declared size.
|
|
let huge = format!("<AfterEffectsProject><cdat bdata=\"{}\"/></AfterEffectsProject>", "ab".repeat(1 << 20));
|
|
assert_eq!(read_aepx(huge.as_bytes()).unwrap().chunks[0].bytes().map(<[u8]>::len), Some(1 << 20));
|
|
}
|
|
|
|
/// Every pair of the local corpus (not in the repository): `AEP_CORPUS=<folder> cargo test
|
|
/// --release -p craft-aep --test it container::aepx:: -- --ignored --nocapture`.
|
|
#[test]
|
|
#[ignore = "needs the local corpus (AEP_CORPUS)"]
|
|
fn every_corpus_pair_gives_the_same_chunk_tree() {
|
|
let Ok(dir) = std::env::var("AEP_CORPUS") else { return };
|
|
let mut names: Vec<_> =
|
|
std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "aepx")).collect();
|
|
names.sort();
|
|
let (mut same, mut differ) = (0, Vec::new());
|
|
for x in &names {
|
|
let aepx = std::fs::read(x).unwrap();
|
|
let aep = std::fs::read(x.with_extension("aep")).unwrap();
|
|
let bin = parse(&aep).unwrap();
|
|
let xml = match read_aepx(&aepx) {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
differ.push(format!("{}: {e}", x.display()));
|
|
continue;
|
|
}
|
|
};
|
|
let fixed = with_head_of(bin.clone().into_owned(), &xml);
|
|
match first_difference(&fixed.chunks, &xml.chunks, "") {
|
|
None if write(&xml).unwrap() == write(&AepFile { trailer: xml.trailer.clone(), ..fixed }).unwrap() => same += 1,
|
|
None => differ.push(format!("{}: bytes differ", x.display())),
|
|
Some(d) => differ.push(format!("{}: {d}", x.display())),
|
|
}
|
|
}
|
|
println!("{same} of {} pairs give the same chunk tree", names.len());
|
|
for d in &differ {
|
|
println!("{d}");
|
|
}
|
|
}
|