aep: review fixes for #3281: NTSC table beside TimeBase, real-path NTSC round trip, light and camera defaults on import, doc wording

This commit is contained in:
vangel76 committed 2026-11-08 04:34:39 +01:00
1 parent 4c1a75aa9e
commit 66cbb9d89e
8 files changed
+144 -14

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
//! Local check tool for #3281 (never committed).
//! aepcheck3281 dump <dir> -> per fixture: hashes of the imported project, import report, export bytes, export report
//! aepcheck3281 bench <file> <reps> -> median import / export time in microseconds
//! aepcheck3281 scale <in.aep> <n> <out.aep>
#![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<T>(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<Chunk<'_>> = 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<String> = 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"),
}
}
+9
View File
@@ -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<f64> {
(self.frame_duration != 0).then(|| f64::from(self.timescale) / f64::from(self.frame_duration))
-5
View File
@@ -60,11 +60,6 @@ pub(crate) fn time_base(rate: FrameRate) -> Option<TimeBase> {
}
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) {
-5
View File
@@ -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]
@@ -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();
+17
View File
@@ -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();
+10 -2
View File
@@ -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);
}
+1 -1
View File
@@ -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. |