diff --git a/crates/aep/examples/aepcheck3281.rs b/crates/aep/examples/aepcheck3281.rs new file mode 100644 index 000000000..0ed70c5eb --- /dev/null +++ b/crates/aep/examples/aepcheck3281.rs @@ -0,0 +1,105 @@ +//! Local check tool for #3281 (never committed). +//! aepcheck3281 dump -> per fixture: hashes of the imported project, import report, export bytes, export report +//! aepcheck3281 bench -> median import / export time in microseconds +//! aepcheck3281 scale +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +use std::hint::black_box; +use std::time::Instant; + +use effectcraft_aep::{Body, Chunk}; + +fn fnv(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in bytes { + h ^= u64::from(*b); + h = h.wrapping_mul(0x0100_0000_01b3); + } + h +} + +fn dump(dir: &str) { + let mut names: Vec<_> = std::fs::read_dir(dir).unwrap().filter_map(|e| e.ok()).map(|e| e.path()).collect(); + names.sort(); + let exists = |_: &str| true; + let opts = effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&exists) }; + for path in names { + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + if ext != "aep" && ext != "aepx" { + continue; + } + let bytes = std::fs::read(&path).unwrap(); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let mut p = effectcraft_project::Project::default(); + match effectcraft_aep::import::import(&mut p, &bytes, &opts) { + Ok(r) => { + let pj = serde_json::to_vec(&p).unwrap(); + let rj = serde_json::to_vec(&r.report).unwrap(); + let summary = (r.report.total_losses(), r.report.is_lossless(), r.report.summary()); + let eo = effectcraft_aep::export::ExportOptions { allow_losses: true }; + let (eb, er, es) = match effectcraft_aep::export::export(&p, &eo) { + Ok(x) => ( + fnv(&x.bytes), + fnv(&serde_json::to_vec(&x.report).unwrap()), + format!("{:?}", (x.report.total_losses(), x.report.is_lossless(), x.report.summary())), + ), + Err(e) => (0, 0, format!("ERR {e}")), + }; + println!("{name} project={:016x} report={:016x} export={eb:016x} export_report={er:016x} isum={summary:?} esum={es}", fnv(&pj), fnv(&rj)); + } + Err(e) => println!("{name} IMPORT ERR {e}"), + } + } +} + +fn time(reps: usize, mut f: impl FnMut() -> T) -> f64 { + let mut v = Vec::with_capacity(reps); + for _ in 0..reps { + let t = Instant::now(); + black_box(f()); + v.push(t.elapsed().as_secs_f64() * 1e6); + } + v.sort_by(f64::total_cmp); + v[v.len() / 2] +} + +fn bench(path: &str, reps: usize) { + let bytes = std::fs::read(path).unwrap(); + let exists = |_: &str| true; + let opts = effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&exists) }; + let m = time(reps, || { + let mut p = effectcraft_project::Project::default(); + effectcraft_aep::import::import(&mut p, &bytes, &opts).map(|r| r.items.len()).ok() + }); + println!("import {m:.1} us"); + let mut proj = effectcraft_project::Project::default(); + effectcraft_aep::import::import(&mut proj, &bytes, &opts).unwrap(); + let eo = effectcraft_aep::export::ExportOptions { allow_losses: true }; + let m = time(reps, || effectcraft_aep::export::export(&proj, &eo).map(|r| r.bytes.len()).ok()); + println!("export {m:.1} us"); +} + +fn scale(input: &str, n: usize, out: &str) { + let bytes = std::fs::read(input).unwrap(); + let mut file = effectcraft_aep::parse(&bytes).unwrap(); + for c in &mut file.chunks { + if c.is_list(b"Fold") + && let Body::List { children, .. } = &mut c.body + { + let items: Vec> = children.iter().filter(|c| c.is_list(b"Item")).cloned().collect(); + for _ in 1..n { + children.extend(items.iter().cloned()); + } + } + } + std::fs::write(out, effectcraft_aep::write(&file).unwrap()).unwrap(); +} + +fn main() { + let a: Vec = std::env::args().collect(); + match a.get(1).map(String::as_str) { + Some("dump") => dump(&a[2]), + Some("bench") => bench(&a[2], a.get(3).and_then(|s| s.parse().ok()).unwrap_or(20)), + Some("scale") => scale(&a[2], a[3].parse().unwrap(), &a[4]), + _ => eprintln!("usage: see the top of this file"), + } +} diff --git a/crates/aep/src/container/typed.rs b/crates/aep/src/container/typed.rs index a61c69090..09567fd84 100644 --- a/crates/aep/src/container/typed.rs +++ b/crates/aep/src/container/typed.rs @@ -135,6 +135,15 @@ pub struct TimeBase { } impl TimeBase { + /// The NTSC rates and the value After Effects stores for each (a float32 of exactly these + /// decimals): the one table the importer and the exporter use. + pub(crate) const NTSC: [(effectcraft_time::FrameRate, f32); 4] = [ + (effectcraft_time::FrameRate::FPS_23_976, 23.976), + (effectcraft_time::FrameRate::FPS_29_97, 29.97), + (effectcraft_time::FrameRate::FPS_59_94, 59.94), + (effectcraft_time::FrameRate::FPS_119_88, 119.88), + ]; + /// Frames per second (`timescale / frame_duration`), `None` for a zero duration. pub fn frame_rate(&self) -> Option { (self.frame_duration != 0).then(|| f64::from(self.timescale) / f64::from(self.frame_duration)) diff --git a/crates/aep/src/export/timebase.rs b/crates/aep/src/export/timebase.rs index 05c464e94..af9d22926 100644 --- a/crates/aep/src/export/timebase.rs +++ b/crates/aep/src/export/timebase.rs @@ -60,11 +60,6 @@ pub(crate) fn time_base(rate: FrameRate) -> Option { } impl TimeBase { - /// The NTSC rates and the typed value After Effects stores for each: the one table the - /// importer and the exporter use. - pub(crate) const NTSC: [(FrameRate, f32); 4] = - [(FrameRate::FPS_23_976, 23.976), (FrameRate::FPS_29_97, 29.97), (FrameRate::FPS_59_94, 59.94), (FrameRate::FPS_119_88, 119.88)]; - /// `tick` in time units, rounded to the nearest unit (saturated to `i64`); the second value /// tells whether it was not exact. pub(crate) fn units(&self, tick: Tick) -> (i64, bool) { diff --git a/crates/aep/src/import/time.rs b/crates/aep/src/import/time.rs index ed734fc57..0f4f67097 100644 --- a/crates/aep/src/import/time.rs +++ b/crates/aep/src/import/time.rs @@ -81,11 +81,6 @@ mod tests { assert_eq!(frame_rate(30720, 1024), Some(FrameRate::new(30, 1))); assert_eq!(frame_rate(0, 1), None); assert_eq!(frame_rate(1, 0), None); - // The time bases the exporter writes for the NTSC rates read back as those rates. - let written = [(23976, 1000), (23976, 800), (23976, 400), (23976, 200)]; - for ((rate, _), (ts, fd)) in TimeBase::NTSC.into_iter().zip(written) { - assert_eq!(frame_rate(ts, fd), Some(rate)); - } } #[test] diff --git a/crates/aep/tests/it/container/batch3_objects.rs b/crates/aep/tests/it/container/batch3_objects.rs index d4b4f83ba..ca24d385d 100644 --- a/crates/aep/tests/it/container/batch3_objects.rs +++ b/crates/aep/tests/it/container/batch3_objects.rs @@ -148,7 +148,8 @@ fn a_mask_box_is_normalised_to_the_layer_size() { for (got, want) in m.shapes[0].bbox.iter().zip(want_box) { assert!(near(f64::from(*got), want, 1e-6), "{:?}", m.shapes[0].bbox); } - // The importer scales the box by the layer's source size. + // The box scales by the size of the layer's source (the importer takes that size from the + // source item it matched, the same item `layer_source_size` reads). let (w, h) = project.layer_source_size(&layer).unwrap(); assert_eq!(m.shapes.len(), 1); let got: Vec<[f64; 2]> = m.shapes[0].vertices((f64::from(w), f64::from(h))).iter().map(|v| v.position).collect(); diff --git a/crates/aep/tests/it/export/built.rs b/crates/aep/tests/it/export/built.rs index a3d4b7ea6..dc4c8dc3c 100644 --- a/crates/aep/tests/it/export/built.rs +++ b/crates/aep/tests/it/export/built.rs @@ -83,6 +83,23 @@ fn ntsc_and_odd_frame_rates_use_the_time_bases_after_effects_writes() { } } +#[test] +fn every_ntsc_rate_reads_back_as_the_rate_it_was_written_for() { + // The export's time base for each NTSC rate and the import's rate for that time base use the + // one NTSC table; the real paths must agree both ways. + for rate in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94, FrameRate::FPS_119_88] { + let mut p = Project::default(); + add_comp(&mut p, "R", 640, 360, rate, 2.0, None); + let r = export_all(&p); + let exists = |_: &str| true; + let options = effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&exists) }; + let mut back = Project::default(); + let imported = effectcraft_aep::import::import(&mut back, &r.bytes, &options).expect("import"); + let comp = imported.comps.first().and_then(|c| back.comp(*c)).expect("comp"); + assert_eq!(comp.frame_rate, rate, "{}", rate.label()); + } +} + #[test] fn layer_header_fields() { let mut p = Project::default(); diff --git a/crates/aep/tests/it/import/cameras.rs b/crates/aep/tests/it/import/cameras.rs index 3b4f3f30c..cdc4326a4 100644 --- a/crates/aep/tests/it/import/cameras.rs +++ b/crates/aep/tests/it/import/cameras.rs @@ -46,7 +46,7 @@ fn cameras() { assert_eq!(c.auto_orient, AutoOrient::TowardsPointOfInterest); close(&v(&c, "transform/poi"), &[960.0, 540.0, 0.0]); close(&v(&c, "transform/position"), &[0.0, 0.0, -2666.6667]); - close(&v(&c, "cameraOptions/zoom"), &[2666.6667]); + close(&v(&c, "cameraOptions/zoom"), &[effectcraft_aep::DEFAULT_CAMERA_ZOOM]); close(&v(&c, "cameraOptions/focusDistance"), &[2666.6667]); close(&v(&c, "cameraOptions/aperture"), &[25.3093]); assert_eq!(v(&c, "cameraOptions/dof"), Value::Bool(false)); @@ -62,6 +62,8 @@ fn cameras() { close(&v(&skip!(layer("341-camera-zoom-2000.aep", "Camera 1")), "cameraOptions/zoom"), &[2000.0]); let c = skip!(layer("342-camera-dof-800-30.aep", "Camera 1")); assert_eq!(v(&c, "cameraOptions/dof"), Value::Bool(true)); + // The zoom it does not store stays the default. + close(&v(&c, "cameraOptions/zoom"), &[2666.6667]); close(&v(&c, "cameraOptions/focusDistance"), &[800.0]); close(&v(&c, "cameraOptions/aperture"), &[30.0]); // 535-537: a camera above the 3D solid of 486. @@ -83,10 +85,14 @@ fn spot_and_point_lights() { close(&v(&l, "lightOptions/color"), &[1.0, 1.0, 1.0, 1.0]); close(&v(&l, "lightOptions/coneAngle"), &[90.0]); close(&v(&l, "lightOptions/coneFeather"), &[50.0]); - close(&v(&skip!(layer("343-light-spot-color-red.aep", "Light 1")), "lightOptions/color"), &[1.0, 0.0, 0.0, 1.0]); + let l = skip!(layer("343-light-spot-color-red.aep", "Light 1")); + close(&v(&l, "lightOptions/color"), &[1.0, 0.0, 0.0, 1.0]); + close(&v(&l, "lightOptions/coneAngle"), &[90.0]); + close(&v(&l, "lightOptions/coneFeather"), &[50.0]); let l = skip!(layer("344-light-spot-coneangle-60.aep", "Light 1")); close(&v(&l, "lightOptions/coneAngle"), &[60.0]); close(&v(&l, "lightOptions/coneFeather"), &[50.0]); + close(&v(&l, "lightOptions/color"), &[1.0, 1.0, 1.0, 1.0]); let l = skip!(layer("345-light-spot-conefeather-30.aep", "Light 1")); close(&v(&l, "lightOptions/coneFeather"), &[30.0]); close(&v(&l, "lightOptions/coneAngle"), &[90.0]); @@ -94,6 +100,8 @@ fn spot_and_point_lights() { let l = skip!(layer("252-light-point.aep", "Light 1")); assert!(matches!(l.source, LayerSource::Light { kind: LightKind::Point })); close(&v(&l, "lightOptions/intensity"), &[100.0]); + // Its stored Cone Angle 90 (a default-state record) has no counterpart: a point light has no cone. + assert!(l.props.prop("lightOptions/coneAngle").is_none()); assert_eq!(l.auto_orient, AutoOrient::Off); } diff --git a/docs/aep-format.md b/docs/aep-format.md index d6eff25f8..a0eb9de6a 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -138,7 +138,7 @@ write on their own. | `svap` (4 bytes, the first top-level chunk) is the version of the After Effects that **wrote** the file: `0f 11 0e 02` in all 36 (a down-saved copy keeps the 2026 word) and in all 1 192 other `.aep` files of the local corpus folders. After Effects opens a file without `svap` (`t-nosvap`). | All 36; corpus scan. | | `head` bytes 4 to 7 are the version the file is **written for**: `0f 11 0e 02` (= `svap`) in the 2026 saves, `0f 0b 06 61` in the 25.x copies, `0f 03 06 41` in the 24.x copies, the same word in every family. After Effects refuses a file whose word is zero ("created with an older version [0.0x0]", `u-zhead`). | All 36; `u-zhead`. | | The word is packed (bit 31 first): bits 25-24 and 21-19 the major, 18-15 the minor, 14-11 the bug-fix, 10-8 a stage (6 in all), 7-0 the build; bits 31-26 (`000011`) and 23-22 (`00`) are the same in all samples and not decoded. `0f110e02` = **26.2.1x2**, which is what the saving After Effects reports for itself (`app.version` recorded by the corpus scripts) - this one sample fixes minor, bug-fix and build. The copies read as **25.6.0x97** and **24.6.0x65** (major **V** from three consecutive majors; minor, bug-fix and build of the copies **E**: one other word decides their positions). `effectcraft_aep::AeVersion`, `AepFile::file_version`, `AepFile::saved_by_version`. | Tests `decodes_the_sampled_words`, `the_container_of_a_copy_states_its_version_in_head_and_the_writer_in_svap`. | -| `head` bytes 0 to 1 (a `u16`) step with the major: 97 (`0x61`) in the 2026 saves, 96 in 25.x, 95 in 24.x; bytes 2 to 3 are 5, 9 and 6 (**?**). Bytes 8 to 15 differ between families, not between versions; bytes 16 to 19 are the save counter (one higher in each copy). Nothing is derived from bytes 0 to 3 (`AepFile::head_revision` exposes the `u16`). | All 36. | +| `head` bytes 0 to 1 (a `u16`) step with the major: 97 (`0x61`) in the 2026 saves, 96 in 25.x, 95 in 24.x; bytes 2 to 3 are 5, 9 and 6 (**?**). Bytes 8 to 15 differ between families, not between versions; bytes 16 to 19 are the save counter (one higher in each copy). Nothing is derived from bytes 0 to 3, and the reader does not expose them. | All 36. | | The XMP packet after the container is no guide: its `CreatorTool` is "Adobe After Effects 2026" in the copies too. | All 36. | | **26 against 25.x** (per family, chunk tree aligned by item and layer id): besides `head`, `nhed` / `nnhd` differ in 6 bytes (`nhed` 20-23 and 26-27: `827fa930` / `0015` in the 2026 save, `00000000` / `011b` in both copies; session state, see section 4). Every **solid's** `Pin /CLRS` changes: the 2026 save has `dcui` (1 byte, 1), `apid` all `0xff` and an empty `Utf8`; the copies drop `dcui`, set `apid` to a GUID (`601e9973...`, the same in all) and embed a 935-byte JSON with an ICC profile (`baseColorProfile/colorProfileData`). A **footage** item that the 2026 save stores with an assigned profile (`mcsp` = 1 and a profile JSON) loses `mcsp` and that JSON in the copies, and its `epid` becomes all `0xff`. The rest is save noise: the footage alias `ascendcount_base` (the copies were saved in another folder), `sspc` byte 112 of a footage item (a modification time), the `oEXR` `opti` of an EXR item (the copies hold the display device name `\.\DISPLAY1` as UTF-16 at byte 126 and, in the 24.x copy, a few non-zero bytes from byte 2 102 that look like uninitialised memory; the 2026 save has zeros), the plug-ins' sequence data `sdat` of Lumetri Color (16 instances and the project's `EfdG`), the mask counter `mkif` byte 8. No chunk is added or reordered, and no `pard`, `cdat`, `ldta`, `cdta`, `tdb4`, keyframe, text or shape chunk differs. | All 12 families. | | **25.x against 24.x**: only `head` (bytes 1, 3, 5 and 7), and the session noise in `opti` and `sdat` above. | All 12 families. |