mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-14 22:33:09 +00:00
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:
1 parent
4c1a75aa9e
commit
66cbb9d89e
8 files changed
+144
-14
No files matched your search
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user