From 9e2461414af08ac181fef7a7664d2d1801b8d513 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 04:04:44 +0100 Subject: [PATCH 1/5] aep: remove test-only container accessors, share report and time-base code (#3281) - Remove 35 public container accessors that only tests called (header, camera/light, keys, footage, masks, shapes, version) and the dead StyleMap::switch_name. Tests now check the importer's own reading of the same bytes, or the decoder it uses; three import tests gain the values the removed checks covered. - Import and export reports share one loss entry (Loss) with record, total, count and summary; the JSON shape is pinned for both. - Collector::lose_with builds a detail only below the detail cap (callers switch later). - The export uses the container's TimeBase; one NTSC table serves import and export; TimeBase::frames saturates like units instead of returning 0. --- crates/aep/src/container/camera.rs | 89 -------------- crates/aep/src/container/footage.rs | 26 ----- crates/aep/src/container/header.rs | 81 ------------- crates/aep/src/container/keys.rs | 41 +------ crates/aep/src/container/masks.rs | 12 +- crates/aep/src/container/shapes.rs | 11 +- crates/aep/src/container/style_map.rs | 4 - crates/aep/src/container/version.rs | 9 +- crates/aep/src/export/report.rs | 79 ++++++++++--- crates/aep/src/export/timebase.rs | 70 ++++++----- crates/aep/src/import/report.rs | 110 ++++++++++++++++-- crates/aep/src/import/time.rs | 15 ++- crates/aep/tests/it/common/mod.rs | 7 ++ crates/aep/tests/it/container/batch3_core.rs | 30 ++--- .../aep/tests/it/container/batch3_objects.rs | 96 +++------------ crates/aep/tests/it/container/cases_600.rs | 4 +- crates/aep/tests/it/container/header.rs | 57 +++------ crates/aep/tests/it/container/helpers.rs | 3 +- crates/aep/tests/it/container/keyframes.rs | 11 +- .../aep/tests/it/container/layer_switches.rs | 46 +++----- crates/aep/tests/it/container/matte.rs | 2 +- crates/aep/tests/it/container/robustness.rs | 83 ++++--------- .../it/container/text_markers_footage.rs | 2 - crates/aep/tests/it/container/typed.rs | 2 +- crates/aep/tests/it/container/values.rs | 9 +- crates/aep/tests/it/export/built.rs | 3 +- crates/aep/tests/it/export/masks.rs | 5 +- crates/aep/tests/it/export/objects.rs | 51 +++++--- crates/aep/tests/it/export/precomp.rs | 6 +- crates/aep/tests/it/import/cameras.rs | 24 +++- crates/aep/tests/it/import/versions.rs | 3 +- 31 files changed, 392 insertions(+), 599 deletions(-) diff --git a/crates/aep/src/container/camera.rs b/crates/aep/src/container/camera.rs index 15623fd2c..0b72537ef 100644 --- a/crates/aep/src/container/camera.rs +++ b/crates/aep/src/container/camera.rs @@ -16,7 +16,6 @@ //! only when it is not the default 100. use crate::container::typed::{Layer, PropNode}; -use crate::container::values::{Tracked, ValueSource, read, read_opt}; /// The Position of a camera or light layer as stored. #[derive(Debug, Clone, Copy, PartialEq)] @@ -28,16 +27,6 @@ pub struct CameraPosition { pub auto_state: bool, } -impl CameraPosition { - /// The position as the batch-3 notes had After Effects report it: in the auto state of a - /// two-node camera (`two_node`) x and y are the composition centre; otherwise the stored - /// value. **Contradicted by 256 and 537 (session S16): After Effects reports and renders the - /// stored value**; use `stored` for what After Effects shows. - pub fn reported(&self, comp_width: u32, comp_height: u32, two_node: bool) -> [f64; 3] { - if self.auto_state && two_node { [f64::from(comp_width) / 2.0, f64::from(comp_height) / 2.0, self.stored[2]] } else { self.stored } - } -} - impl<'f, 'a> Layer<'f, 'a> { /// The Position record of a camera or light layer. pub fn camera_position(&self) -> Option { @@ -49,87 +38,9 @@ impl<'f, 'a> Layer<'f, 'a> { let derived = h.get(76) == Some(&1) && h.get(77) == Some(&1) && position.expression().is_none(); Some(CameraPosition { stored, auto_state: derived }) } - - /// The point of interest of a camera in pixels (its Anchor Point record); `None` when absent. - pub fn camera_point_of_interest(&self) -> Option<[f64; 3]> { - let Some(PropNode::Group(transform)) = self.properties()?.get("ADBE Transform Group") else { return None }; - let tracked = read(&transform, "ADBE Anchor Point", [0.0; 3], 1.0); - matches!(tracked.source, ValueSource::Stored | ValueSource::Keyframed).then_some(tracked.value) - } - - /// A light's intensity in percent; the default 100 when the property is absent. - pub fn light_intensity(&self) -> Option> { - let root = self.properties()?; - let Some(PropNode::Group(options)) = root.get("ADBE Light Options Group") else { return None }; - let t = read(&options, "ADBE Light Intensity", [100.0], 1.0); - Some(Tracked { value: t.value[0], source: t.source }) - } } /// The camera zoom After Effects uses when `ADBE Camera Zoom` is absent: 2666.67 px for the 50 mm /// default camera in the 1920 px wide corpus composition. Whether it follows other composition /// widths was not sampled. pub const DEFAULT_CAMERA_ZOOM: f64 = 2_666.666_666_666_666_5; - -impl<'f, 'a> Layer<'f, 'a> { - fn camera_options(&self) -> Option> { - match self.properties()?.get("ADBE Camera Options Group") { - Some(PropNode::Group(g)) => Some(g), - _ => None, - } - } - - fn light_options(&self) -> Option> { - match self.properties()?.get("ADBE Light Options Group") { - Some(PropNode::Group(g)) => Some(g), - _ => None, - } - } - - /// A camera's zoom in pixels (`ADBE Camera Options Group/ADBE Camera Zoom`); absent = the - /// default, [`DEFAULT_CAMERA_ZOOM`] (341: 2000 is stored). The auto Position keeps `-2666.67` and - /// is not recomputed from the zoom. - pub fn camera_zoom(&self) -> Option> { - let t = read(&self.camera_options()?, "ADBE Camera Zoom", [DEFAULT_CAMERA_ZOOM], 1.0); - Some(Tracked { value: t.value[0], source: t.source }) - } - - /// The depth of field switch (`ADBE Camera Depth of Field`, a checkbox; off by default, 342). - pub fn camera_depth_of_field(&self) -> Option> { - let t = read(&self.camera_options()?, "ADBE Camera Depth of Field", [0.0], 1.0); - Some(Tracked { value: t.value[0] != 0.0, source: t.source }) - } - - /// A camera's focus distance in pixels (`ADBE Camera Focus Distance`); `None` while absent (its - /// default is not known). - pub fn camera_focus_distance(&self) -> Option> { - let t = read_opt::<1>(&self.camera_options()?, "ADBE Camera Focus Distance", 1.0)?; - Some(Tracked { value: t.value[0], source: t.source }) - } - - /// A camera's aperture (`ADBE Camera Aperture`); `None` while absent (its default is not known). - pub fn camera_aperture(&self) -> Option> { - let t = read_opt::<1>(&self.camera_options()?, "ADBE Camera Aperture", 1.0)?; - Some(Tracked { value: t.value[0], source: t.source }) - } - - /// A light's colour as `[A, R, G, B]` fractions (`ADBE Light Options Group/ADBE Light Color`, - /// stored as doubles 0..255); white when absent (343 stores red). - pub fn light_color(&self) -> Option> { - Some(read(&self.light_options()?, "ADBE Light Color", [1.0; 4], 1.0 / 255.0)) - } - - /// A spot light's cone angle in degrees (`ADBE Light Cone Angle`); 90 when absent (344 stores 60). - /// A point light stores 90 with `tdsb` 3. - pub fn light_cone_angle(&self) -> Option> { - let t = read(&self.light_options()?, "ADBE Light Cone Angle", [90.0], 1.0); - Some(Tracked { value: t.value[0], source: t.source }) - } - - /// A spot light's cone feather in percent (`ADBE Light Cone Feather 2`); 50 when absent (345 - /// stores 30). - pub fn light_cone_feather(&self) -> Option> { - let t = read(&self.light_options()?, "ADBE Light Cone Feather 2", [50.0], 1.0); - Some(Tracked { value: t.value[0], source: t.source }) - } -} diff --git a/crates/aep/src/container/footage.rs b/crates/aep/src/container/footage.rs index da0369d64..c1007fce5 100644 --- a/crates/aep/src/container/footage.rs +++ b/crates/aep/src/container/footage.rs @@ -48,21 +48,6 @@ pub struct Footage { } impl Footage { - /// The number of frames of an image sequence (`duration * native_frame_rate`: 3/30 s at 30 fps is - /// 3); `None` for anything else. - pub fn sequence_frame_count(&self) -> Option { - if !self.is_sequence { - return None; - } - let d = self.duration?; - let frames = i128::from(d.num).checked_mul(i128::from(self.native_frame_rate))?; - let den = i128::from(d.den); - if den <= 0 || frames % den != 0 { - return None; - } - u32::try_from(frames / den).ok() - } - /// The solid's colour as fractions R, G, B. pub fn solid_rgb(&self) -> Option<[f32; 3]> { let [_, r, g, b] = self.solid_argb?; @@ -297,12 +282,6 @@ impl<'f, 'a> Item<'f, 'a> { self.chunk.child(b"idta")?.bytes()?.get(58).copied() } - /// The ids of the compositions this item uses as precomp layers: the little-endian `u32` of - /// each `idpi` chunk. - pub fn used_item_ids(&self) -> Vec { - self.chunk.children().iter().filter(|c| c.is(b"idpi")).filter_map(|c| le_u32(c.bytes()?, 0)).collect() - } - /// The footage data of a footage item. pub fn footage(&self) -> Option { if self.kind()? != ItemKind::Footage { @@ -351,11 +330,6 @@ impl<'f, 'a> Project<'f, 'a> { self.item_by_id(layer.header()?.source_id?) } - /// The name a layer shows: its own name, or its source's name when it has none. - pub fn layer_display_name(&self, layer: &Layer<'f, 'a>) -> Option { - layer.name().or_else(|| self.layer_source(layer)?.display_name()) - } - /// The pixel size of a layer's source: a footage item's width and height, or a precomp's /// composition size. pub fn layer_source_size(&self, layer: &Layer<'f, 'a>) -> Option<(u32, u32)> { diff --git a/crates/aep/src/container/header.rs b/crates/aep/src/container/header.rs index 508353eb2..060d37840 100644 --- a/crates/aep/src/container/header.rs +++ b/crates/aep/src/container/header.rs @@ -237,12 +237,6 @@ impl LayerHeader { self.flag_bytes.get(byte).is_some_and(|b| b & mask != 0) } - /// Byte 37 bit `0x01`: the layer has a name set by the user (clear on a default-named camera or - /// light and on a precomp layer, whose name is the source's). - pub fn name_is_user_set(&self) -> bool { - self.flag(0, 0x01) - } - /// Byte 37 bit `0x02`: guide layer. pub fn is_guide(&self) -> bool { self.flag(0, 0x02) @@ -333,17 +327,6 @@ impl LayerHeader { Rational::new(i64::from(self.stretch), i64::from(self.stretch_den)) } - /// The time stretch in percent (`stretch / stretch_den * 100`); a zero denominator reads as 1. - pub fn stretch_percent(&self) -> f64 { - let den = if self.stretch_den == 0 { 1.0 } else { f64::from(self.stretch_den) }; - f64::from(self.stretch) / den * 100.0 - } - - /// The start time in seconds, `None` for a zero denominator. - pub fn start_seconds(&self) -> Option { - self.start.to_f64() - } - /// The absolute in point, `start + in * stretch`, exact and reduced. pub fn in_point(&self) -> Option { self.start.plus_scaled(&self.in_relative, &self.stretch_ratio()?) @@ -355,16 +338,6 @@ impl LayerHeader { pub fn out_point(&self) -> Option { self.start.plus_scaled(&self.out_relative, &self.stretch_ratio()?) } - - /// The absolute in point in seconds. - pub fn in_point_seconds(&self) -> Option { - self.in_point()?.to_f64() - } - - /// The absolute out point in seconds. - pub fn out_point_seconds(&self) -> Option { - self.out_point()?.to_f64() - } } impl<'f, 'a> Layer<'f, 'a> { @@ -464,64 +437,10 @@ impl CompHeader { self.time_base.frame_rate() } - /// The frame rate from the 16.16 fixed-point field (25 is `0x0019_0000`). - pub fn frame_rate_from_fixed(&self) -> f64 { - f64::from(self.frame_rate_fixed) / 65536.0 - } - /// The duration in seconds. pub fn duration_seconds(&self) -> Option { self.time_base.seconds(i32::try_from(self.duration_units).ok()?) } - - /// The work area start in seconds. - pub fn work_area_start_seconds(&self) -> Option { - self.time_base.seconds(i32::try_from(self.work_area_start_units).ok()?) - } - - /// The work area end in seconds; the composition duration when the work area is the whole - /// composition. - pub fn work_area_end_seconds(&self) -> Option { - match self.work_area_end_units { - Some(end) => self.time_base.seconds(i32::try_from(end).ok()?), - None => self.duration_seconds(), - } - } - - /// The background colour as fractions 0..1 (`byte / 255`). - pub fn background_rgb(&self) -> [f64; 3] { - self.background.map(|c| f64::from(c) / 255.0) - } - - /// The pixel aspect ratio as a number. - pub fn pixel_aspect_ratio(&self) -> Option { - self.pixel_aspect.to_f64() - } - - /// The display start time in seconds (the start timecode), `None` when absent or the - /// denominator is zero. - pub fn display_start_seconds(&self) -> Option { - self.display_start?.to_f64() - } - - /// The display start as a frame number (`display start * frame rate`, rounded); 100 at 25 fps - /// for a display start of 4 s. - pub fn display_start_frame(&self) -> Option { - let rate = self.frame_rate()?; - Some((self.display_start_seconds()? * rate).round() as i64) - } - - /// The frame rate as an exact fraction `timescale / frame_duration`, as typed: 29.97 fps is - /// `23976 / 800` (not `30000 / 1001`), 23.976 fps is `23976 / 1000`. - pub fn frame_rate_exact(&self) -> Option { - Rational::new(i64::from(self.time_base.timescale), i64::from(self.time_base.frame_duration)) - } - - /// The duration in whole frames (`duration / frame_duration`). After Effects snaps a duration - /// to a whole number of frames: 5 s at 29.97 fps becomes 150 frames (5.005 s). - pub fn duration_frames(&self) -> Option { - self.duration_units.checked_div(self.time_base.frame_duration) - } } impl<'f, 'a> Comp<'f, 'a> { diff --git a/crates/aep/src/container/keys.rs b/crates/aep/src/container/keys.rs index d78bc6693..06fcaecc5 100644 --- a/crates/aep/src/container/keys.rs +++ b/crates/aep/src/container/keys.rs @@ -193,19 +193,10 @@ impl<'f> Keyframe<'f> { } } - /// The value of a 3-component spatial keyframe (Position), x, y and z in pixels. - pub fn spatial_value(&self) -> Option<[f64; 3]> { - let v = self.value()?; - if !matches!(self.kind(), KeyframeKind::Spatial { components: 3 }) { - return None; - } - Some([*v.first()?, *v.get(1)?, *v.get(2)?]) - } - /// The ease of the key: the four slots at byte 16 of a one-component plain record or at byte 24 /// of a spatial record or a mask path record. For linear and hold keys all four are 0 (a bezier key stores what /// `setTemporalEase` was given). Plain records with several components keep four slots per - /// component, see [`Keyframe::ease_component`] and [`Keyframe::plain_ease_slots`]. + /// component, see [`Keyframe::ease_component`]. pub fn ease(&self) -> Option { match self.kind() { KeyframeKind::Plain { components: 1 } => ease_at(self.record, 16), @@ -214,16 +205,6 @@ impl<'f> Keyframe<'f> { } } - /// The `4 n` ease `f64` of a plain record as stored (from byte `8 + 8 n`). With one component - /// they are [`Keyframe::ease`]; with several they are type-major, see - /// [`Keyframe::ease_component`]. - pub fn plain_ease_slots(&self) -> Option> { - match self.kind() { - KeyframeKind::Plain { components } => doubles(self.record, components.checked_mul(8)?.checked_add(8)?, components.checked_mul(4)?), - _ => None, - } - } - /// The ease of one value component. A plain record with `n` components stores the `4 n` ease /// doubles **type-major**: `[in speed x n][in influence x n][out speed x n][out influence x n]` /// starting at byte `8 + 8 n`, and component `i` uses slot `i` of each block (verified for the @@ -260,16 +241,6 @@ impl<'f> Keyframe<'f> { (0..n).map(|i| self.ease_component(i)).collect() } - /// The Euclidean ARGB distance to the next key that After Effects writes at byte 16 of a colour - /// (4-component spatial) record whose interpolation is not linear: 255 sqrt(2) = 360.6245 for - /// red to blue; 0 on the last key and on linear keys. `None` for other records. - pub fn color_distance(&self) -> Option { - match self.kind() { - KeyframeKind::Spatial { components: 4 } => be_f64(self.record, 16), - _ => None, - } - } - /// The decoded spatial record (Position: 128 bytes, colour: 152 bytes). pub fn spatial(&self) -> Option { let KeyframeKind::Spatial { components: n } = self.kind() else { return None }; @@ -300,11 +271,6 @@ pub struct ExpressionFlags { } impl ExpressionFlags { - /// True when bytes 76, 77 and 120 are all 1. - pub fn all_set(&self) -> bool { - self.byte_76 == 1 && self.byte_77 == 1 && self.byte_120 == 1 - } - /// True when byte 119 is 1. pub fn disabled(&self) -> bool { self.byte_119 == 1 @@ -324,11 +290,6 @@ impl<'f, 'a> Property<'f, 'a> { be_u32(self.chunk.child(b"tdsb")?.bytes()?, 0) } - /// True when `tdb4` byte 5 has bit 0 set: the property is static, it has no keyframes. - pub fn is_static_flag_set(&self) -> Option { - Some(self.header()?.get(5)? & 1 != 0) - } - /// The expression source: the raw UTF-8 text of the `Utf8` child that follows the value in the /// property (no length prefix, no engine tag; the engine is project-wide). pub fn expression(&self) -> Option { diff --git a/crates/aep/src/container/masks.rs b/crates/aep/src/container/masks.rs index 7e77c69e9..6246fc1f5 100644 --- a/crates/aep/src/container/masks.rs +++ b/crates/aep/src/container/masks.rs @@ -8,7 +8,7 @@ use crate::container::Chunk; use crate::container::bytes::{be_f32, be_u32, until_nul}; -use crate::container::typed::{KeyList, Layer, Project, PropGroup, PropNode, Property}; +use crate::container::typed::{KeyList, Layer, PropGroup, PropNode, Property}; use crate::container::values::{Tracked, read}; /// The mask mode (`mkif` `u32` at byte 4). @@ -193,16 +193,6 @@ fn mask_from_atom(atom: &PropGroup<'_, '_>, mkif: Option<&[u8]>) -> Mask { mask } -impl<'f, 'a> Project<'f, 'a> { - /// The vertices of every path shape of a mask in pixels of the layer: mask boxes are normalised - /// to the **layer** size (the size of its source), not the composition's. `None` when the layer - /// has no source with a size. - pub fn mask_vertices_px(&self, layer: &Layer<'f, 'a>, mask: &Mask) -> Option>> { - let (width, height) = self.layer_source_size(layer)?; - Some(mask.shapes.iter().map(|s| s.vertices((f64::from(width), f64::from(height)))).collect()) - } -} - /// One mask with the views of its records, for readers that need more than [`Mask`]: the /// keyframes and expressions of the mask properties (in [`MaskAtom::atom`]: `ADBE Mask Feather`, /// `ADBE Mask Opacity`, `ADBE Mask Offset`) and the path keyframes paired with their shapes. diff --git a/crates/aep/src/container/shapes.rs b/crates/aep/src/container/shapes.rs index ea89155a1..8aad83d3f 100644 --- a/crates/aep/src/container/shapes.rs +++ b/crates/aep/src/container/shapes.rs @@ -10,7 +10,7 @@ //! points are in pixels relative to the layer origin. use crate::container::masks::ShapePath; -use crate::container::typed::{Layer, PropGroup, PropNode, Property}; +use crate::container::typed::{Layer, PropGroup, PropNode}; use crate::container::values::{Tracked, read, read_opt}; /// The stroke line cap (`ADBE Vector Stroke Line Cap`, stored as a double; butt is the default and @@ -262,12 +262,3 @@ impl<'f, 'a> Layer<'f, 'a> { .collect() } } - -impl<'f, 'a> Property<'f, 'a> { - /// The ARGB fractions of a static colour property (4 components stored as doubles 0..255). - pub fn color_argb(&self) -> Option<[f64; 4]> { - let v = self.static_value()?; - let [a, r, g, b] = *v.as_slice().first_chunk::<4>()?; - Some([a / 255.0, r / 255.0, g / 255.0, b / 255.0]) - } -} diff --git a/crates/aep/src/container/style_map.rs b/crates/aep/src/container/style_map.rs index fdb28c875..bc3169c9e 100644 --- a/crates/aep/src/container/style_map.rs +++ b/crates/aep/src/container/style_map.rs @@ -73,10 +73,6 @@ pub struct StyleMap { } impl StyleMap { - /// The switch group's match name (`dropShadow/enabled`). - pub fn switch_name(&self) -> String { - format!("{}/enabled", self.ae) - } /// A parameter's full match name (`dropShadow/opacity`). pub fn param_name(&self, p: &StyleParam) -> String { format!("{}/{}", self.ae, p.ae) diff --git a/crates/aep/src/container/version.rs b/crates/aep/src/container/version.rs index bf3fbf4c4..1c859cdac 100644 --- a/crates/aep/src/container/version.rs +++ b/crates/aep/src/container/version.rs @@ -8,7 +8,7 @@ use std::fmt; -use crate::container::bytes::{be_u16, be_u32}; +use crate::container::bytes::be_u32; use crate::container::chunk::AepFile; /// An After Effects version as packed in `head` / `svap`: `26.2.1x2` is major 26, minor 2, bug-fix @@ -80,13 +80,6 @@ impl AepFile<'_> { pub fn saved_by_version(&self) -> Option { AeVersion::from_word(be_u32(self.chunk(b"svap")?.bytes()?, 0)?) } - - /// The `u16` at `head` byte 0: 97 (`0x61`) in a 2026 file, 96 in a copy saved as 25.x, 95 as - /// 24.x. It steps with the major version of [`AepFile::file_version`]; what it counts is not - /// known, so nothing is derived from it. - pub fn head_revision(&self) -> Option { - be_u16(self.chunk(b"head")?.bytes()?, 0) - } } #[cfg(test)] diff --git a/crates/aep/src/export/report.rs b/crates/aep/src/export/report.rs index 7caba0e7d..309f564e8 100644 --- a/crates/aep/src/export/report.rs +++ b/crates/aep/src/export/report.rs @@ -7,8 +7,9 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -/// Most example lines kept per loss category (the count is always exact). -pub const MAX_DETAILS: usize = 20; +/// Most example lines kept per loss category (the count is always exact); the same as the import +/// report's. +pub const MAX_DETAILS: usize = crate::import::MAX_DETAILS; /// Why a piece of the project did not arrive in the `.aep`, or arrived changed. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -130,15 +131,9 @@ impl LossCategory { } } -/// One category of things that were not exported. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Loss { - pub category: LossCategory, - /// How many occurrences (exact). - pub count: usize, - /// Up to [`MAX_DETAILS`] example lines. - pub details: Vec, -} +/// One category of things that were not exported: the import report's loss entry with the +/// export categories. +pub type Loss = crate::import::Loss; /// What was written to the file. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -222,7 +217,7 @@ pub struct ExportReport { impl ExportReport { /// The sum of all loss counts. pub fn total_losses(&self) -> usize { - self.losses.iter().map(|l| l.count).sum() + Loss::total(&self.losses) } /// True when nothing was lost. @@ -232,12 +227,12 @@ impl ExportReport { /// The loss count of a category. pub fn count(&self, category: LossCategory) -> usize { - self.losses.iter().find(|l| l.category == category).map_or(0, |l| l.count) + Loss::count_of(&self.losses, category) } /// `effects: 3, masks: 1` (empty when lossless). pub fn summary(&self) -> String { - self.losses.iter().map(|l| format!("{}: {}", l.category.label(), l.count)).collect::>().join(", ") + Loss::summary(&self.losses, LossCategory::label) } } @@ -251,11 +246,7 @@ pub(crate) struct Collector { impl Collector { /// Records one loss. pub fn lose(&mut self, category: LossCategory, detail: impl Into) { - let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new() }); - loss.count = loss.count.saturating_add(1); - if loss.details.len() < MAX_DETAILS { - loss.details.push(detail.into()); - } + Loss::record(&mut self.losses, category, move || detail.into()); } pub fn finish(self) -> ExportReport { @@ -266,3 +257,53 @@ impl Collector { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn collected() -> ExportReport { + let mut col = Collector::default(); + col.exported.layers = 2; + col.lose(LossCategory::RoundedTimes, "time 1"); + col.lose(LossCategory::Effects, String::from("fx 1")); + col.lose(LossCategory::Effects, "fx 2"); + let mut report = col.finish(); + report.limitations.truncate(1); + report.limitations[0] = "limit".into(); + report + } + + /// The JSON shape agents read: field names, category spelling, order and the loss entries. + #[test] + fn the_json_shape_is_pinned() { + let json = serde_json::to_string(&collected()).unwrap(); + assert_eq!( + json, + r#"{"exported":{"folders":0,"compositions":0,"solids":0,"layers":2,"solidLayers":0,"nullLayers":0,"adjustmentLayers":0,"properties":0,"keyframes":0,"expressions":0,"masks":0,"precompLayers":0,"effects":0,"footage":0,"footageLayers":0,"textLayers":0,"markers":0,"cameras":0,"lights":0,"shapeLayers":0,"timeRemaps":0,"textAnimators":0,"layerStyles":0},"losses":[{"category":"effects","count":2,"details":["fx 1","fx 2"]},{"category":"rounded_times","count":1,"details":["time 1"]}],"limitations":["limit"]}"# + ); + let back: ExportReport = serde_json::from_str(&json).unwrap(); + assert_eq!(back, collected()); + } + + #[test] + fn the_queries_read_the_losses() { + let report = collected(); + assert_eq!((report.total_losses(), report.is_lossless()), (3, false)); + assert_eq!((report.count(LossCategory::Effects), report.count(LossCategory::Lights)), (2, 0)); + assert_eq!(report.summary(), "effects: 2, rounded times: 1"); + let empty = Collector::default().finish(); + assert_eq!((empty.total_losses(), empty.is_lossless(), empty.summary()), (0, true, String::new())); + } + + #[test] + fn details_stop_at_the_cap() { + let mut col = Collector::default(); + for i in 0..MAX_DETAILS + 5 { + col.lose(LossCategory::Masks, format!("mask {i}")); + } + let report = col.finish(); + assert_eq!(report.count(LossCategory::Masks), MAX_DETAILS + 5); + assert_eq!(report.losses[0].details.len(), MAX_DETAILS); + } +} diff --git a/crates/aep/src/export/timebase.rs b/crates/aep/src/export/timebase.rs index 7414a1aa7..05c464e94 100644 --- a/crates/aep/src/export/timebase.rs +++ b/crates/aep/src/export/timebase.rs @@ -9,13 +9,9 @@ use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; -/// Frame duration and time scale in units; one frame lasts `frame_duration` units and a second -/// has `timescale` units. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct TimeBase { - pub frame_duration: u32, - pub timescale: u32, -} +/// The container's time base (frame duration and time scale in units; one frame lasts +/// `frame_duration` units and a second has `timescale` units), which the importer reads. +pub(crate) use crate::container::TimeBase; /// The 16.16 fixed-point rate After Effects stores next to the time base (a float32 rate). pub(crate) fn fixed_rate(rate: FrameRate) -> u32 { @@ -26,11 +22,8 @@ pub(crate) fn fixed_rate(rate: FrameRate) -> u32 { /// The typed value of an NTSC rate (23.976, 29.97, 59.94, 119.88), which After Effects stores as /// a float32 of exactly these decimals. -pub(crate) fn ntsc_typed(rate: FrameRate) -> Option { - [(FrameRate::FPS_23_976, 23.976f32), (FrameRate::FPS_29_97, 29.97), (FrameRate::FPS_59_94, 59.94), (FrameRate::FPS_119_88, 119.88)] - .into_iter() - .find(|(r, _)| *r == rate) - .map(|(_, v)| v) +fn ntsc_typed(rate: FrameRate) -> Option { + TimeBase::NTSC.into_iter().find(|(r, _)| *r == rate).map(|(_, v)| v) } /// The time base for `rate`, or `None` for a rate After Effects cannot hold (non-positive, or not @@ -67,28 +60,32 @@ pub(crate) fn time_base(rate: FrameRate) -> Option { } impl TimeBase { - /// `tick` in time units, rounded to the nearest unit; the second value tells whether it was - /// not exact. - pub fn units(&self, tick: Tick) -> (i64, bool) { - let n = i128::from(tick.0) * i128::from(self.timescale); - let d = i128::from(TICKS_PER_SECOND); - let q = n.div_euclid(d); - let r = n.rem_euclid(d); - let rounded = if r * 2 >= d { q + 1 } else { q }; - (i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX }), r != 0) + /// 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) { + nearest(i128::from(tick.0).saturating_mul(i128::from(self.timescale)), i128::from(TICKS_PER_SECOND)) } - /// Whole frames of `tick` (nearest). - pub fn frames(&self, tick: Tick, rate: FrameRate) -> i64 { - let n = i128::from(tick.0) * i128::from(rate.num); - let d = i128::from(TICKS_PER_SECOND) * i128::from(rate.den.max(1)); - let q = n.div_euclid(d); - let r = n.rem_euclid(d); - let f = if r * 2 >= d { q + 1 } else { q }; - i64::try_from(f).unwrap_or(0) + /// Whole frames of `tick` (nearest, saturated to `i64` like [`TimeBase::units`]). + pub(crate) fn frames(&self, tick: Tick, rate: FrameRate) -> i64 { + let d = i128::from(TICKS_PER_SECOND).saturating_mul(i128::from(rate.den.max(1))); + nearest(i128::from(tick.0).saturating_mul(i128::from(rate.num)), d).0 } } +/// `n / d` rounded to the nearest whole number (a half rounds up), saturated to `i64`, and whether +/// it was not exact. `d` is positive. +fn nearest(n: i128, d: i128) -> (i64, bool) { + let (q, r) = (n.div_euclid(d), n.rem_euclid(d)); + let rounded = if r.saturating_mul(2) >= d { q.saturating_add(1) } else { q }; + (i64::try_from(rounded).unwrap_or(if rounded < 0 { i64::MIN } else { i64::MAX }), r != 0) +} + #[cfg(test)] mod tests { use super::*; @@ -131,4 +128,19 @@ mod tests { assert!(tb.units(Tick(TICKS_PER_SECOND / 3)).1); assert_eq!(tb.frames(Tick(TICKS_PER_SECOND * 2), FrameRate::new(25, 1)), 50); } + + /// Frames saturate like units (they used to become 0 past `i64`). No storable rate gets + /// there: [`time_base`] only accepts rates far below one frame per tick. + #[test] + fn frames_and_units_saturate_alike() { + let tb = TimeBase { frame_duration: 1024, timescale: u32::MAX }; + let huge = FrameRate { num: i64::MAX, den: 1 }; + assert_eq!(tb.frames(Tick(i64::MAX), huge), i64::MAX); + assert_eq!(tb.frames(Tick(i64::MIN), huge), i64::MIN); + // A half rounds up on the signed value, in frames and in units. + assert_eq!(tb.frames(Tick(TICKS_PER_SECOND / 2), FrameRate::new(1, 1)), 1); + assert_eq!(tb.frames(Tick(-TICKS_PER_SECOND / 2), FrameRate::new(1, 1)), 0); + let tb1 = TimeBase { frame_duration: 1, timescale: 1 }; + assert_eq!((tb1.units(Tick(TICKS_PER_SECOND / 2)), tb1.units(Tick(-TICKS_PER_SECOND / 2))), ((1, true), (0, true))); + } } diff --git a/crates/aep/src/import/report.rs b/crates/aep/src/import/report.rs index 129fcf143..d7dbfe898 100644 --- a/crates/aep/src/import/report.rs +++ b/crates/aep/src/import/report.rs @@ -92,16 +92,45 @@ impl LossCategory { } } -/// One category of things that were not imported. +/// One category of things that were not imported. The export report uses the same type with its +/// own categories (`export::Loss`). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Loss { - pub category: LossCategory, +pub struct Loss { + pub category: C, /// How many occurrences (exact). pub count: usize, /// Up to [`MAX_DETAILS`] example lines. pub details: Vec, } +/// The loss bookkeeping the import and the export report share. +impl Loss { + /// Counts one loss of `category`; `detail` is only called while the category holds fewer than + /// [`MAX_DETAILS`] lines. + pub(crate) fn record(losses: &mut BTreeMap, category: C, detail: impl FnOnce() -> String) { + let loss = losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new() }); + loss.count = loss.count.saturating_add(1); + if loss.details.len() < MAX_DETAILS { + loss.details.push(detail()); + } + } + + /// The sum of all loss counts. + pub(crate) fn total(losses: &[Self]) -> usize { + losses.iter().map(|l| l.count).sum() + } + + /// The loss count of a category. + pub(crate) fn count_of(losses: &[Self], category: C) -> usize { + losses.iter().find(|l| l.category == category).map_or(0, |l| l.count) + } + + /// `effects: 3, masks: 1` (empty without losses). + pub(crate) fn summary(losses: &[Self], label: impl Fn(C) -> &'static str) -> String { + losses.iter().map(|l| format!("{}: {}", label(l.category), l.count)).collect::>().join(", ") + } +} + /// What arrived in the project. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -243,7 +272,7 @@ impl AeVersionInfo { impl ImportReport { /// The sum of all loss counts. pub fn total_losses(&self) -> usize { - self.losses.iter().map(|l| l.count).sum() + Loss::total(&self.losses) } /// True when nothing was lost. @@ -253,12 +282,12 @@ impl ImportReport { /// The loss count of a category. pub fn count(&self, category: LossCategory) -> usize { - self.losses.iter().find(|l| l.category == category).map_or(0, |l| l.count) + Loss::count_of(&self.losses, category) } /// `effects: 3, masks: 1` (empty when lossless). pub fn summary(&self) -> String { - self.losses.iter().map(|l| format!("{}: {}", l.category.label(), l.count)).collect::>().join(", ") + Loss::summary(&self.losses, LossCategory::label) } } @@ -274,11 +303,13 @@ pub(crate) struct Collector { impl Collector { /// Records one loss. pub fn lose(&mut self, category: LossCategory, detail: impl Into) { - let loss = self.losses.entry(category).or_insert_with(|| Loss { category, count: 0, details: Vec::new() }); - loss.count = loss.count.saturating_add(1); - if loss.details.len() < MAX_DETAILS { - loss.details.push(detail.into()); - } + self.lose_with(category, move || detail.into()); + } + + /// Records one loss whose detail line is built only while the category holds fewer than + /// [`MAX_DETAILS`] lines (for a detail that needs `format!`). + pub fn lose_with(&mut self, category: LossCategory, detail: impl FnOnce() -> String) { + Loss::record(&mut self.losses, category, detail); } pub fn finish(self) -> ImportReport { @@ -301,3 +332,60 @@ pub const LIMITATIONS: &[&str] = &[ "Text: per-character 3D, text on a path and several style slots of unknown meaning are not decoded (stored values that differ from the default are reported as text layer features).", "Footage that is not a solid is linked by its stored path with its stored frame rate and duration; the importer never opens the files (use Reload Footage after the files are found).", ]; + +#[cfg(test)] +mod tests { + use super::*; + + fn collected() -> ImportReport { + let mut col = Collector::default(); + col.imported.layers = 2; + col.missing_footage.push("a.png".into()); + col.lose(LossCategory::Masks, "mask 1"); + col.lose(LossCategory::Effects, String::from("fx 1")); + col.lose_with(LossCategory::Effects, || "fx 2".to_string()); + let mut report = col.finish(); + report.limitations.truncate(1); + report + } + + /// The JSON shape agents read: field names, category spelling, order and the loss entries. + #[test] + fn the_json_shape_is_pinned() { + let json = serde_json::to_string(&collected()).unwrap(); + assert_eq!( + json, + r#"{"imported":{"folders":0,"compositions":0,"solids":0,"footage":0,"layers":2,"solidLayers":0,"nullLayers":0,"adjustmentLayers":0,"footageLayers":0,"precompLayers":0,"textLayers":0,"keyframes":0,"expressions":0,"masks":0,"trackMattes":0,"markers":0,"timeRemaps":0,"shapeLayers":0,"shapeItems":0,"cameras":0,"lights":0,"textAnimators":0,"effects":0,"layerStyles":0},"losses":[{"category":"effects","count":2,"details":["fx 1","fx 2"]},{"category":"masks","count":1,"details":["mask 1"]}],"missingFootage":["a.png"],"missingFonts":[],"limitations":["Frame blending, 3D material options, audio levels, guides are not decoded and not imported."],"aeVersion":{"fileVersion":"unknown","savedBy":"unknown","tested":false}}"# + ); + let back: ImportReport = serde_json::from_str(&json).unwrap(); + assert_eq!(back, collected()); + } + + #[test] + fn the_queries_read_the_losses() { + let report = collected(); + assert_eq!((report.total_losses(), report.is_lossless()), (3, false)); + assert_eq!((report.count(LossCategory::Effects), report.count(LossCategory::Lights)), (2, 0)); + assert_eq!(report.summary(), "effects: 2, masks: 1"); + let empty = Collector::default().finish(); + assert_eq!((empty.total_losses(), empty.is_lossless(), empty.summary()), (0, true, String::new())); + } + + /// The count stays exact past the detail cap, and a detail past the cap is never built. + #[test] + fn details_stop_at_the_cap_and_later_ones_are_not_built() { + let mut col = Collector::default(); + let mut built = 0; + for i in 0..MAX_DETAILS + 5 { + col.lose_with(LossCategory::Masks, || { + built += 1; + format!("mask {i}") + }); + } + let report = col.finish(); + assert_eq!(report.count(LossCategory::Masks), MAX_DETAILS + 5); + assert_eq!(report.losses[0].details.len(), MAX_DETAILS); + assert_eq!(report.losses[0].details.last().map(String::as_str), Some("mask 19")); + assert_eq!(built, MAX_DETAILS); + } +} diff --git a/crates/aep/src/import/time.rs b/crates/aep/src/import/time.rs index 936d5efcb..ed734fc57 100644 --- a/crates/aep/src/import/time.rs +++ b/crates/aep/src/import/time.rs @@ -7,6 +7,8 @@ use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; +use crate::container::TimeBase; + /// The composition's frame structure: the stored time base and the rate the project gets. #[derive(Clone, Copy, Debug)] pub(crate) struct TimeMap { @@ -27,12 +29,8 @@ pub(crate) fn frame_rate(timescale: u32, frame_duration: u32) -> Option Option { locate(name).map(|s| s.path().clone()) } +/// The ids of the compositions an item uses as precomposition layers, as the file stores them: +/// the little-endian `u32` of each `idpi` chunk (the importer does not read them; the exporter +/// writes them). +pub fn used_item_ids(item: &effectcraft_aep::Item<'_, '_>) -> Vec { + item.chunk().children().iter().filter(|c| c.is(b"idpi")).filter_map(|c| Some(u32::from_le_bytes(c.bytes()?.get(..4)?.try_into().ok()?))).collect() +} + /// The bytes of a case (see the module comment), or `None` when it is missing. pub fn try_fixture(name: &str) -> Option> { match locate(name)? { diff --git a/crates/aep/tests/it/container/batch3_core.rs b/crates/aep/tests/it/container/batch3_core.rs index 925deaa28..1813e2df8 100644 --- a/crates/aep/tests/it/container/batch3_core.rs +++ b/crates/aep/tests/it/container/batch3_core.rs @@ -140,16 +140,13 @@ fn scale_keys_keep_their_ease_type_major() { } assert_near(&keys[0].value().unwrap(), &[1.0, 1.0, 1.0], 1e-9); assert_near(&keys[1].value().unwrap(), &[2.0, 1.5, 1.0], 1e-9); - // The raw block: [in speed x3][in influence x3][out speed x3][out influence x3]. - assert_near(&keys[0].plain_ease_slots().unwrap(), &[0.0, 0.0, 0.0, 0.33, 0.33, 0.33, 0.2, 0.2, 0.2, 0.66, 0.66, 0.66], 1e-9); - assert_near(&keys[1].plain_ease_slots().unwrap(), &[0.1, 0.1, 0.1, 0.66, 0.66, 0.66, 0.0, 0.0, 0.0, 0.33, 0.33, 0.33], 1e-9); + // The raw block is [in speed x3][in influence x3][out speed x3][out influence x3]. for i in 0..3 { assert!(same_ease(&keys[0].ease_component(i).unwrap(), &ease(0.0, 0.33, 0.2, 0.66)), "key 1 component {i}"); assert!(same_ease(&keys[1].ease_component(i).unwrap(), &ease(0.1, 0.66, 0.0, 0.33)), "key 2 component {i}"); } assert!(keys[0].ease_component(3).is_none()); assert_eq!(keys[0].ease_components().unwrap().len(), 3); - assert!(keys[0].color_distance().is_none()); })); } @@ -172,7 +169,6 @@ fn mask_feather_keys_keep_their_ease_type_major() { } assert_near(&keys[0].value().unwrap(), &[0.0, 0.0], 1e-9); assert_near(&keys[1].value().unwrap(), &[50.0, 30.0], 1e-9); - assert_near(&keys[0].plain_ease_slots().unwrap(), &[0.0, 0.0, 0.33, 0.33, 15.0, 15.0, 0.66, 0.66], 1e-9); for i in 0..2 { assert!(same_ease(&keys[0].ease_component(i).unwrap(), &ease(0.0, 0.33, 15.0, 0.66)), "key 1 component {i}"); assert!(same_ease(&keys[1].ease_component(i).unwrap(), &ease(8.0, 0.66, 0.0, 0.33)), "key 2 component {i}"); @@ -213,14 +209,15 @@ fn bezier_colour_keys_carry_the_distance_to_the_next_key() { assert_eq!((k.interpolation_in(), k.interpolation_out()), (Some(Interpolation::Bezier), Some(Interpolation::Bezier))); assert!(same_ease(&k.ease_component(0).unwrap(), &ease(0.0, 0.0, 0.0, 0.0))); } - assert!(near(keys[0].color_distance().unwrap(), 255.0 * 2f64.sqrt(), 1e-3), "{:?}", keys[0].color_distance()); - assert!(near(keys[0].color_distance().unwrap(), 360.6245, 1e-3)); - assert_eq!(keys[1].color_distance(), Some(0.0)); + let distance = |k: &effectcraft_aep::Keyframe<'_>| k.spatial().unwrap().segment_length; + assert!(near(distance(&keys[0]), 255.0 * 2f64.sqrt(), 1e-3), "{}", distance(&keys[0])); + assert!(near(distance(&keys[0]), 360.6245, 1e-3)); + assert_eq!(distance(&keys[1]), 0.0); })); skip!(with_layer("210-key-fill-color-2key-linear.aep", L, |_, layer| { let p = layer.effects()[0].params().into_iter().find(|p| p.name == "Color").unwrap(); let k = p.stored().unwrap().keys().unwrap().keyframes().next().unwrap(); - assert_eq!(k.color_distance(), Some(0.0)); + assert_eq!(k.spatial().unwrap().segment_length, 0.0); })); } @@ -243,21 +240,19 @@ fn time_stretch_is_an_exact_ratio() { let (h, in_p, out_p, scale) = skip!(stretch_facts("316-layer-stretch50.aep")); assert_eq!((h.stretch, h.stretch_den), (1, 2)); assert_eq!(h.stretch_ratio(), Some(rat(1, 2))); - assert_eq!(h.stretch_percent(), 50.0); assert_eq!(h.out_relative, rat(128000, 25600)); assert_eq!((in_p, out_p, scale), (Some(rat(0, 1)), Some(rat(5, 2)), 25600)); // 317: 150 %: 3/2, ends at 7.5 s, the property time scale is 25600 x 3/2. let (h, in_p, out_p, scale) = skip!(stretch_facts("317-layer-stretch150.aep")); assert_eq!((h.stretch, h.stretch_den), (3, 2)); assert_eq!(h.stretch_ratio(), Some(rat(3, 2))); - assert_eq!(h.stretch_percent(), 150.0); assert_eq!((in_p, out_p, scale), (Some(rat(0, 1)), Some(rat(15, 2)), 38400)); // 018: 200 %: 2/1, ends at 10 s, time scale 51200. The base is 1/1. let (h, _, out_p, scale) = skip!(stretch_facts("018-layer-stretch200.aep")); assert_eq!((h.stretch, h.stretch_den, h.stretch_ratio()), (2, 1, Some(rat(2, 1)))); assert_eq!((out_p, scale), (Some(rat(10, 1)), 51200)); let (h, _, out_p, scale) = skip!(stretch_facts("000-base.aep")); - assert_eq!((h.stretch, h.stretch_den, h.stretch_percent()), (1, 1, 100.0)); + assert_eq!((h.stretch, h.stretch_den, h.stretch_ratio()), (1, 1, Some(rat(1, 1)))); assert_eq!((out_p, scale), (Some(rat(5, 1)), 25600)); } @@ -269,9 +264,9 @@ fn fractional_times_are_exact_multiples_of_one_over_25600() { assert_eq!(h.in_relative, rat(8532, 25600)); assert_eq!(h.out_relative, rat(136532, 25600)); assert_eq!(h.start, rat(0, 25600)); + // 0.33328125 s and 5.33328125 s. assert_eq!(h.in_point(), Some(rat(2133, 6400))); - assert_eq!(h.in_point_seconds(), Some(0.33328125)); - assert_eq!(h.out_point_seconds(), Some(5.33328125)); + assert_eq!(h.out_point(), Some(rat(34133, 6400))); assert_eq!(h.stretch_ratio(), Some(rat(1, 1))); } @@ -364,18 +359,17 @@ fn pixel_aspect_shutter_phase_and_display_start() { // 325: pixelAspect 0.9091 reads back 0.90909090909091 = the exact ratio 10/11. let h = skip!(comp_header("325-comp-pixelaspect-0.9091.aep")); assert_eq!(h.pixel_aspect.reduced(), Some(rat(10, 11))); - assert!(near(h.pixel_aspect_ratio().unwrap(), 0.90909090909091, 1e-12)); // 326: shutterPhase -90, the shutter angle stays 180. let h = skip!(comp_header("326-comp-shutterphase-m90.aep")); assert_eq!((h.shutter_phase, h.shutter_angle), (Some(-90), 180)); // 327: displayStartFrame 100 at 25 fps = 4 s = 102400/25600; the shutter phase is back to 0. let h = skip!(comp_header("327-comp-displaystartframe-100.aep")); assert_eq!(h.display_start, Some(rat(102400, 25600))); - assert_eq!((h.display_start_seconds(), h.display_start_frame(), h.shutter_phase), (Some(4.0), Some(100), Some(0))); + assert_eq!(h.shutter_phase, Some(0)); // The base: no phase, start 0. let h = skip!(comp_header("000-base.aep")); - assert_eq!((h.shutter_phase, h.display_start_seconds(), h.display_start_frame()), (Some(0), Some(0.0), Some(0))); - assert_eq!(h.pixel_aspect_ratio(), Some(1.0)); + assert_eq!((h.shutter_phase, h.display_start.and_then(|r| r.reduced())), (Some(0), Some(rat(0, 1)))); + assert_eq!(h.pixel_aspect.reduced(), Some(rat(1, 1))); // A short chunk still decodes the old fields. let bytes = [0u8; 176]; let h = effectcraft_aep::CompHeader::parse(&bytes).unwrap(); diff --git a/crates/aep/tests/it/container/batch3_objects.rs b/crates/aep/tests/it/container/batch3_objects.rs index d1cfbac29..d4b4f83ba 100644 --- a/crates/aep/tests/it/container/batch3_objects.rs +++ b/crates/aep/tests/it/container/batch3_objects.rs @@ -148,19 +148,20 @@ 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); } - let shapes = project.mask_vertices_px(&layer, m).unwrap(); - assert_eq!(shapes.len(), 1); - let got: Vec<[f64; 2]> = shapes[0].iter().map(|v| v.position).collect(); + // The importer scales the box by the layer's source size. + 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(); for (g, w) in got.iter().zip([[20.0, 30.0], [80.0, 30.0], [80.0, 70.0], [20.0, 70.0]]) { assert_near(g, &w, 1e-3); } assert_eq!(got.len(), 4); - // A layer without a sized source has no pixel vertices. + // A layer without a sized source has no size to scale by. let bytes = fixture!("333-shape-group-two-rects.aep"); let file = open(&bytes); let project = Project::new(&file).unwrap(); let shape = layer_named(&first_comp(&project), "Shape"); - assert!(project.mask_vertices_px(&shape, m).is_none()); + assert!(project.layer_source_size(&shape).is_none()); } #[test] @@ -178,12 +179,14 @@ fn one_mkif_per_mask_and_the_mode_none() { assert_eq!((m.mode, m.inverted, m.counter), (Some(MaskMode::Add), Some(false), Some(counter))); assert_eq!(m.shapes.len(), 1); } - let v1 = project.mask_vertices_px(&layer, &masks[0]).unwrap(); + let (w, h) = project.layer_source_size(&layer).unwrap(); + let px = |m: &effectcraft_aep::Mask| m.shapes.iter().map(|s| s.vertices((f64::from(w), f64::from(h)))).collect::>(); + let v1 = px(&masks[0]); for (v, w) in v1[0].iter().zip(ellipse_vertices()) { assert_near(&v.position, &w, 1e-2); } // Mask 2 has its own box (500..900 x 350..650) normalised to the layer. - let v2 = project.mask_vertices_px(&layer, &masks[1]).unwrap(); + let v2 = px(&masks[1]); for (v, w) in v2[0].iter().zip([[500.0, 350.0], [900.0, 350.0], [900.0, 650.0], [500.0, 650.0]]) { assert_near(&v.position, &w, 1e-2); } @@ -383,77 +386,14 @@ fn the_last_style_run_absorbs_the_final_carriage_return() { // ------------------------------------------------------------------ camera and light (341-345) #[test] -fn camera_zoom_and_depth_of_field() { - let camera = |name: &str| { - Some({ - let bytes = crate::common::try_fixture(name)?; - let file = open(&bytes); - let project = Project::new(&file).unwrap(); - let layer = layer_of_type(&first_comp(&project), LayerType::Camera); - ( - layer.camera_zoom(), - layer.camera_depth_of_field(), - layer.camera_focus_distance(), - layer.camera_aperture(), - layer.camera_position().map(|p| (p.stored, p.auto_state)), - ) - }) - }; - // 341: Zoom 2000 is stored; the auto Position keeps [0, 0, -2666.67]. - let (zoom, dof, focus, aperture, pos) = skip!(camera("341-camera-zoom-2000.aep")); - assert_eq!(zoom.map(|z| (z.value, z.source)), Some((2000.0, ValueSource::Stored))); - assert_eq!((dof.map(|d| (d.value, d.source)), focus, aperture), (Some((false, ValueSource::Default)), None, None)); - let (stored, auto) = pos.unwrap(); - assert!(auto && near(stored[2], -2666.67, 0.01) && stored[0] == 0.0, "{stored:?}"); - // 342: DOF on, Focus Distance 800, Aperture 30; Zoom stays the default. - let (zoom, dof, focus, aperture, _) = skip!(camera("342-camera-dof-800-30.aep")); - assert_eq!(zoom.map(|z| (z.source, near(z.value, 2666.67, 0.01))), Some((ValueSource::Default, true))); - assert_eq!(dof.map(|d| (d.value, d.source)), Some((true, ValueSource::Stored))); - assert_eq!(focus.map(|f| (f.value, f.source)), Some((800.0, ValueSource::Stored))); - assert_eq!(aperture.map(|f| (f.value, f.source)), Some((30.0, ValueSource::Stored))); - // The default camera (256) has nothing but the default zoom. - let (zoom, dof, ..) = skip!(camera("256-camera-two-node-default.aep")); - assert_eq!((zoom.unwrap().source, dof.unwrap().value), (ValueSource::Default, false)); - assert_eq!(zoom.unwrap().value, effectcraft_aep::DEFAULT_CAMERA_ZOOM); -} - -#[test] -fn light_colour_cone_angle_and_feather() { - let light = |name: &str| { - Some({ - let bytes = crate::common::try_fixture(name)?; - let file = open(&bytes); - let project = Project::new(&file).unwrap(); - let layer = layer_of_type(&first_comp(&project), LayerType::Light); - assert_eq!(layer.header().unwrap().light_type(), Some(effectcraft_aep::LightType::Spot), "{name}"); - let t = |x: Option>| x.map(|x| (x.value, x.source)); - (layer.light_color().map(|c| (c.value, c.source)), t(layer.light_cone_angle()), t(layer.light_cone_feather())) - }) - }; - // The default spot light: white, 90, 50, all absent. - let (colour, angle, feather) = skip!(light("254-light-spot-intensity-50.aep")); - assert_eq!((colour, angle, feather), (Some(([1.0; 4], ValueSource::Default)), Some((90.0, ValueSource::Default)), Some((50.0, ValueSource::Default)))); - // 343: colour [1,0,0,1] = ARGB doubles [255,255,0,0]. - let (colour, angle, feather) = skip!(light("343-light-spot-color-red.aep")); - assert_eq!(colour, Some(([1.0, 1.0, 0.0, 0.0], ValueSource::Stored))); - assert_eq!((angle, feather), (Some((90.0, ValueSource::Default)), Some((50.0, ValueSource::Default)))); - // 344: Cone Angle 60. 345: Cone Feather 30. - let (colour, angle, feather) = skip!(light("344-light-spot-coneangle-60.aep")); - assert_eq!((colour.unwrap().1, angle, feather), (ValueSource::Default, Some((60.0, ValueSource::Stored)), Some((50.0, ValueSource::Default)))); - let (_, angle, feather) = skip!(light("345-light-spot-conefeather-30.aep")); - assert_eq!((angle, feather), (Some((90.0, ValueSource::Default)), Some((30.0, ValueSource::Stored)))); - // A point light (252) stores Cone Angle 90 / Feather 50 in their default state. - let bytes = fixture!("252-light-point.aep"); +fn camera_zoom_keeps_the_auto_position() { + // 341: Zoom 2000 is stored (checked on import); the auto Position keeps [0, 0, -2666.67]. + let bytes = fixture!("341-camera-zoom-2000.aep"); let file = open(&bytes); let project = Project::new(&file).unwrap(); - let layer = layer_of_type(&first_comp(&project), LayerType::Light); - assert_eq!(layer.light_cone_angle().map(|a| (a.value, a.source)), Some((90.0, ValueSource::Stored))); - // Layers without light options have no light accessors. - let bytes = fixture!("000-base.aep"); - let file = open(&bytes); - let project = Project::new(&file).unwrap(); - let solid = base_layer(&first_comp(&project)); - assert!(solid.light_color().is_none() && solid.camera_zoom().is_none() && solid.camera_depth_of_field().is_none()); + let layer = layer_of_type(&first_comp(&project), LayerType::Camera); + let pos = layer.camera_position().unwrap(); + assert!(pos.auto_state && near(pos.stored[2], -2666.67, 0.01) && pos.stored[0] == 0.0, "{:?}", pos.stored); } // ------------------------------------------------------------------ marker data (347-349, 249) @@ -503,7 +443,7 @@ fn audio_footage_item() { assert_eq!((f.width, f.height, f.is_solid), (0, 0, false)); assert_eq!(f.duration, Some(rat(48000, 48000))); assert_eq!((f.audio_sample_rate, f.file_size, f.bits_per_pixel, f.native_frame_rate), (Some(48000.0), 96044, 0, 0)); - assert!(!f.is_sequence && f.sequence_frame_count().is_none()); + assert!(!f.is_sequence && item.sequence_info().is_none()); // The combined file stores no display name for the item (the single-case file had // "test-tone.wav"); the path names the file. assert_eq!(item.label(), Some(7)); @@ -527,7 +467,7 @@ fn image_sequence_is_one_item() { assert_eq!(f.duration, Some(rat(3, 30)), "{name}"); assert_eq!((f.native_frame_rate, f.bits_per_pixel, f.file_size, f.audio_sample_rate), (30, 24, 504, None), "{name}"); assert!(f.is_sequence, "{name}"); - assert_eq!(f.sequence_frame_count(), Some(3), "{name}"); + assert_eq!(item.sequence_info().map(|s| s.frame_count()), Some(3), "{name}"); // The first file name sits in opti; the alias points at the sequence folder. assert_eq!(f.name.as_deref(), Some("seq_001.png"), "{name}"); assert!(f.path.as_deref().is_some_and(|p| p.ends_with("test-seq")), "{name}"); diff --git a/crates/aep/tests/it/container/cases_600.rs b/crates/aep/tests/it/container/cases_600.rs index 53651e6e3..ad6248ecc 100644 --- a/crates/aep/tests/it/container/cases_600.rs +++ b/crates/aep/tests/it/container/cases_600.rs @@ -38,7 +38,7 @@ fn a_composition_counts_the_compositions_it_shows() { let id = |n: &str| items.iter().find(|i| i.display_name().as_deref() == Some(n)).and_then(|i| i.id()).expect(n); let c = items.iter().find(|i| i.display_name().as_deref() == Some("C")).expect("C"); // The list holds each used composition once (in no particular order). - let mut used = c.used_item_ids(); + let mut used = crate::common::used_item_ids(c); used.sort(); let mut want = vec![id("P30"), id("Q")]; want.sort(); @@ -60,8 +60,8 @@ fn an_image_sequence_knows_its_numbering() { let item = project.all_items().into_iter().find(|i| i.footage().is_some_and(|f| !f.is_solid)).expect("sequence"); let f = item.footage().expect("footage"); assert_eq!((f.file_type.as_str(), f.width, f.height, f.native_frame_rate, f.is_sequence), ("oEXR", 64, 48, 30, true)); - assert_eq!(f.sequence_frame_count(), Some(3)); let s = item.sequence_info().expect("numbering"); + assert_eq!(s.frame_count(), 3); assert_eq!((s.prefix.as_str(), s.suffix.as_str(), s.start, s.end, s.digits), ("shot_", ".exr", 101, 103, 4)); assert_eq!(s.file_name(101), "shot_0101.exr"); // A still has no numbering (000: the solid; the base has no other footage). diff --git a/crates/aep/tests/it/container/header.rs b/crates/aep/tests/it/container/header.rs index bb07e96b3..2ebdbd59d 100644 --- a/crates/aep/tests/it/container/header.rs +++ b/crates/aep/tests/it/container/header.rs @@ -55,7 +55,6 @@ fn assert_plain(h: &LayerHeader) { assert_eq!(h.label, 1); // No name was set, so the copy of the name at byte 64 is empty (the layer shows its source name). assert_eq!(h.name_copy, ""); - assert!(!h.name_is_user_set()); assert!(!h.is_null_object()); assert_eq!(h.parent_id, None); assert_eq!(h.source_id, Some(0x1a)); @@ -67,16 +66,13 @@ fn base_layer_header() { assert_plain(&h); assert_eq!(h.quality, Quality::Best); assert_eq!(h.blend_mode, BlendMode::Normal); - assert_eq!(h.stretch, 1); - assert_eq!(h.stretch_percent(), 100.0); + assert_eq!((h.stretch, h.stretch_den), (1, 1)); assert_eq!(switches(&h), NONE); // start 0 s, in 0 s, out 5 s; denominator = the composition timescale. assert_eq!(h.start, Rational { num: 0, den: 25600 }); assert_eq!(h.in_relative, Rational { num: 0, den: 25600 }); assert_eq!(h.out_relative, Rational { num: 128_000, den: 25600 }); - assert_eq!(h.start_seconds(), Some(0.0)); - assert_eq!(h.in_point_seconds(), Some(0.0)); - assert_eq!(h.out_point_seconds(), Some(5.0)); + assert_eq!(h.in_point(), Rational::new(0, 1)); assert_eq!(h.out_point(), Rational::new(5, 1)); } @@ -98,7 +94,7 @@ fn one_switch_per_case() { assert_eq!(switches(&h), expected, "{name}"); assert_plain(&h); assert_eq!((h.quality, h.blend_mode, h.stretch), (Quality::Best, BlendMode::Normal, 1), "{name}"); - assert_eq!(h.out_point_seconds(), Some(5.0), "{name}"); + assert_eq!(h.out_point(), Rational::new(5, 1), "{name}"); } } @@ -106,7 +102,6 @@ fn one_switch_per_case() { fn name_blend_mode_and_quality() { let h = skip!(l_header("010-layer-name.aep")); assert_eq!(h.name_copy, "Renamed"); - assert!(h.name_is_user_set()); let bytes = fixture!("010-layer-name.aep"); let file = open(&bytes); let project = Project::new(&file).unwrap(); @@ -114,7 +109,6 @@ fn name_blend_mode_and_quality() { // The layer name is the user's; the source footage keeps its own name. assert_eq!(layer.name().as_deref(), Some("Renamed")); assert_eq!(project.layer_source(&layer).and_then(|i| i.display_name()).as_deref(), Some("L")); - assert_eq!(project.layer_display_name(&layer).as_deref(), Some("Renamed")); assert_eq!(skip!(l_header("016-layer-blend-multiply.aep")).blend_mode, BlendMode::Multiply); assert_eq!(skip!(l_header("023-layer-quality-draft.aep")).quality, Quality::Draft); @@ -127,7 +121,7 @@ fn display_name_falls_back_to_the_source_name() { let project = Project::new(&file).unwrap(); let layer = base_layer(&first_comp(&project)); assert_eq!(layer.name(), None); - assert_eq!(project.layer_display_name(&layer).as_deref(), Some("L")); + assert_eq!(project.layer_source(&layer).and_then(|i| i.display_name()).as_deref(), Some("L")); assert_eq!(project.layer_source_size(&layer), Some((1920, 1080))); let source = project.layer_source(&layer).unwrap(); assert_eq!(source.id(), Some(0x1a)); @@ -139,26 +133,23 @@ fn time_stretch_and_the_in_out_start_convention() { // 018: stretch 100 -> 200: the stored out point stays 5 s, the layer ends at 10 s. let h = skip!(l_header("018-layer-stretch200.aep")); assert_eq!(h.stretch, 2); - assert_eq!(h.stretch_percent(), 200.0); + assert_eq!(h.stretch_ratio(), Rational::new(2, 1)); assert_eq!(h.out_relative, Rational { num: 128_000, den: 25600 }); - assert_eq!(h.out_point_seconds(), Some(10.0)); - assert_eq!(h.in_point_seconds(), Some(0.0)); + assert_eq!(h.out_point(), Rational::new(10, 1)); + assert_eq!(h.in_point(), Rational::new(0, 1)); // 019: inPoint 0 -> 1; AE moved the out point to 6 s and left the start at 0. let h = skip!(l_header("019-layer-inpoint1.aep")); - assert_eq!(h.start_seconds(), Some(0.0)); + assert_eq!(h.start.num, 0); assert_eq!(h.in_relative, Rational { num: 25600, den: 25600 }); assert_eq!(h.in_point(), Rational::new(1, 1)); - assert_eq!(h.out_point_seconds(), Some(6.0)); + assert_eq!(h.out_point(), Rational::new(6, 1)); // 021: startTime 0 -> 0.5: in 0.5 s and out 5.5 s although the stored in point stays 0. let h = skip!(l_header("021-layer-starttime0.5.aep")); assert_eq!(h.start, Rational { num: 12800, den: 25600 }); - assert_eq!(h.start_seconds(), Some(0.5)); assert_eq!(h.in_relative.num, 0); assert_eq!(h.in_point(), Rational::new(1, 2)); - assert_eq!(h.in_point_seconds(), Some(0.5)); - assert_eq!(h.out_point_seconds(), Some(5.5)); assert_eq!(h.out_point(), Rational::new(11, 2)); } @@ -175,7 +166,6 @@ fn parent_link_and_null_object() { let nh = null.header().unwrap(); assert_eq!(null.name().as_deref(), Some("Null")); assert!(nh.is_null_object()); - assert!(nh.name_is_user_set()); assert_eq!(nh.layer_type, LayerType::Av); assert_eq!(nh.parent_id, None); assert_eq!(project.layer_source(null).and_then(|i| i.display_name()).as_deref(), Some("Null 1")); @@ -225,17 +215,14 @@ fn base_comp_header() { assert_eq!((h.width, h.height), (1920, 1080)); assert_eq!((h.time_base.frame_duration, h.time_base.timescale), (1024, 25600)); assert_eq!(h.frame_rate(), Some(25.0)); - assert_eq!(h.frame_rate_from_fixed(), 25.0); + assert_eq!(h.frame_rate_fixed, 25 << 16); assert_eq!(h.duration_units, 128_000); assert_eq!(h.duration_seconds(), Some(5.0)); assert_eq!(h.background, [0, 0, 0]); - assert_eq!(h.background_rgb(), [0.0, 0.0, 0.0]); assert_eq!(h.shutter_angle, 180); - assert_eq!(h.work_area_start_seconds(), Some(0.0)); + assert_eq!(h.work_area_start_units, 0); assert_eq!(h.work_area_end_units, None); - assert_eq!(h.work_area_end_seconds(), Some(5.0)); assert_eq!(h.pixel_aspect, Rational { num: 1, den: 1 }); - assert_eq!(h.pixel_aspect_ratio(), Some(1.0)); assert!(!h.motion_blur); // The manifest calls the classic renderer `ADBE Advanced 3d`; the file stores `ADBE Escher` // and the display name `Classic 3D`. @@ -255,7 +242,7 @@ fn one_comp_change_per_case() { let (h, _, _) = skip!(comp_header("031-comp-fps30.aep")); assert_eq!((h.time_base.frame_duration, h.time_base.timescale), (1024, 30720)); assert_eq!(h.frame_rate(), Some(30.0)); - assert_eq!(h.frame_rate_from_fixed(), 30.0); + assert_eq!(h.frame_rate_fixed, 30 << 16); assert_eq!(h.duration_units, 153_600); assert_eq!(h.duration_seconds(), Some(5.0)); @@ -263,12 +250,11 @@ fn one_comp_change_per_case() { let (h, _, _) = skip!(comp_header("032-comp-duration10.aep")); assert_eq!(h.duration_units, 256_000); assert_eq!(h.duration_seconds(), Some(10.0)); - assert_eq!(h.work_area_end_seconds(), Some(10.0)); + assert_eq!(h.work_area_end_units, None); // 033: bgColor [0,0,0] -> [0,1,0]. let (h, _, _) = skip!(comp_header("033-comp-bg-green.aep")); assert_eq!(h.background, [0, 255, 0]); - assert_eq!(h.background_rgb(), [0.0, 1.0, 0.0]); // 034: shutterAngle 180 -> 90. let (h, _, _) = skip!(comp_header("034-comp-shutter90.aep")); @@ -279,8 +265,6 @@ fn one_comp_change_per_case() { let (h, _, _) = skip!(comp_header("035-comp-workarea-1-2.aep")); assert_eq!(h.work_area_start_units, 25600); assert_eq!(h.work_area_end_units, Some(76_800)); - assert_eq!(h.work_area_start_seconds(), Some(1.0)); - assert_eq!(h.work_area_end_seconds(), Some(3.0)); } #[test] @@ -290,7 +274,6 @@ fn one_comp_change_per_case_aspect_blur_renderer() { // 037: pixelAspect 1 -> 2. let (h, _, _) = skip!(comp_header("037-comp-pixelaspect2.aep")); assert_eq!(h.pixel_aspect, Rational { num: 2, den: 1 }); - assert_eq!(h.pixel_aspect_ratio(), Some(2.0)); // 038: comp motion blur on. let (h, _, _) = skip!(comp_header("038-comp-motionblur.aep")); @@ -328,17 +311,16 @@ fn precomp_item_ids_and_labels() { assert_eq!(h.source_id, pre_item.id()); assert_eq!(project.layer_source(layer).unwrap().id(), pre_item.id()); assert_eq!(h.label, 15); - assert!(!h.name_is_user_set()); assert_eq!(layer.name(), None); - assert_eq!(project.layer_display_name(layer).as_deref(), Some("Pre")); + assert_eq!(project.layer_source(layer).and_then(|i| i.display_name()).as_deref(), Some("Pre")); assert_eq!(project.layer_source_size(layer), Some((1920, 1080))); // The precomp layer's start time has the denominator 1; the Pre comp is 1920x1080, 25 fps, 5 s. assert_eq!(h.start.den, 1); let ph = pre.header().unwrap(); assert_eq!((ph.width, ph.height, ph.frame_rate(), ph.duration_seconds()), (1920, 1080, Some(25.0), Some(5.0))); // C records that it uses the comp Pre (chunk idpi, a little-endian u32). - assert_eq!(c_item.used_item_ids(), [pre_item.id().unwrap()]); - assert!(pre_item.used_item_ids().is_empty()); + assert_eq!(crate::common::used_item_ids(c_item), [pre_item.id().unwrap()]); + assert!(crate::common::used_item_ids(pre_item).is_empty()); assert_eq!(pre_item.label(), Some(15)); // The layer inside Pre is a new layer made from the solid L (moving the layers into a precomp // creates new layers). @@ -347,7 +329,7 @@ fn precomp_item_ids_and_labels() { let ih = inner[0].header().unwrap(); assert_ne!(ih.id, L); assert_eq!(ih.source_id, Some(0x1a)); - assert_eq!(project.layer_display_name(&inner[0]).as_deref(), Some("L")); + assert_eq!(project.layer_source(&inner[0]).and_then(|i| i.display_name()).as_deref(), Some("L")); } #[test] @@ -356,9 +338,8 @@ fn a_negative_stretch_is_a_reversed_layer() { // point 0 and out point 5 s relative, so the layer runs from 5 s back to 0 s; 983: -50 % = -1/2. let h = skip!(l_header("981-layer-stretch-minus100-start5.aep")); assert_eq!((h.stretch, h.stretch_den), (-1, 1)); - assert_eq!(h.stretch_percent(), -100.0); - assert_eq!((h.in_point_seconds(), h.out_point_seconds()), (Some(5.0), Some(0.0))); + assert_eq!((h.in_point(), h.out_point()), (Rational::new(5, 1), Rational::new(0, 1))); let h = skip!(l_header("983-layer-stretch-minus50-opacity-keys.aep")); assert_eq!((h.stretch, h.stretch_den), (-1, 2)); - assert_eq!((h.in_point_seconds(), h.out_point_seconds()), (Some(5.0), Some(2.5))); + assert_eq!((h.in_point(), h.out_point()), (Rational::new(5, 1), Rational::new(5, 2))); } diff --git a/crates/aep/tests/it/container/helpers.rs b/crates/aep/tests/it/container/helpers.rs index d255692c0..5c01ebb8f 100644 --- a/crates/aep/tests/it/container/helpers.rs +++ b/crates/aep/tests/it/container/helpers.rs @@ -379,7 +379,8 @@ pub fn layer_named<'f, 'a>(comp: &Comp<'f, 'a>, name: &str) -> Layer<'f, 'a> { /// The layer that shows this name: its own name, or its source's while it has none. pub fn layer_shown_as<'f, 'a>(project: &Project<'f, 'a>, comp: &Comp<'f, 'a>, name: &str) -> Layer<'f, 'a> { - comp.layers().find(|l| project.layer_display_name(l).as_deref() == Some(name)).unwrap_or_else(|| panic!("no layer shown as {name}")) + let shown = |l: &Layer<'f, 'a>| l.name().or_else(|| project.layer_source(l)?.display_name()); + comp.layers().find(|l| shown(l).as_deref() == Some(name)).unwrap_or_else(|| panic!("no layer shown as {name}")) } /// The first layer of this type in a composition. diff --git a/crates/aep/tests/it/container/keyframes.rs b/crates/aep/tests/it/container/keyframes.rs index 611c7027e..ac7b35ff7 100644 --- a/crates/aep/tests/it/container/keyframes.rs +++ b/crates/aep/tests/it/container/keyframes.rs @@ -19,6 +19,9 @@ fn prop_of(name: &str, path: &[&str], f: impl FnOnce(Property<'_, '_>) -> R) const POSITION: &[&str] = &["ADBE Transform Group", "ADBE Position"]; const OPACITY: &[&str] = &["ADBE Transform Group", "ADBE Opacity"]; +/// The ease of a linear key: all four slots 0. +const NO_EASE: KeyEase = KeyEase { in_speed: 0.0, in_influence: 0.0, out_speed: 0.0, out_influence: 0.0 }; + fn ease(in_speed: f64, in_influence: f64, out_speed: f64, out_influence: f64) -> KeyEase { KeyEase { in_speed, in_influence, out_speed, out_influence } } @@ -33,7 +36,7 @@ fn in_and_out_interpolation_are_separate_bytes() { assert_eq!((keys[0].interpolation_in(), keys[0].interpolation_out()), (Some(Interpolation::Linear), Some(Interpolation::Hold))); assert_eq!((keys[1].interpolation_in(), keys[1].interpolation_out()), (Some(Interpolation::Linear), Some(Interpolation::Linear))); assert_eq!(keys.iter().map(|k| k.seconds(25600).unwrap()).collect::>(), [0.0, 1.0]); - assert_eq!(keys[1].spatial_value(), Some([100.0, 0.0, 0.0])); + assert_eq!(keys[1].value(), Some(vec![100.0, 0.0, 0.0])); })); // 203: key 1 inType HOLD, outType LINEAR (bytes 03 01): byte 4 is the incoming side. skip!(prop_of("203-key-in-hold-out-linear.aep", POSITION, |p| { @@ -76,7 +79,7 @@ fn opacity_ease_speed_is_in_stored_units_per_second() { assert_near(&[e0.in_speed, e0.in_influence, e0.out_speed, e0.out_influence], &[0.05, 0.2, 0.2, 0.8], 1e-12); let e1 = keys[1].ease().unwrap(); assert_near(&[e1.in_speed, e1.in_influence, e1.out_speed, e1.out_influence], &[0.3, 0.6, 0.09, 0.4], 1e-12); - assert_eq!(keys[0].plain_ease_slots().unwrap().len(), 4); + assert_eq!(keys[0].ease_components(), Some(vec![e0])); for k in &keys { assert_eq!((k.interpolation_in(), k.interpolation_out()), (Some(Interpolation::Bezier), Some(Interpolation::Bezier))); } @@ -94,7 +97,7 @@ fn scale_keys_use_the_plain_template_with_three_components() { // A 128-byte record is not always spatial: the header's template flag decides. assert_eq!(k.kind(), KeyframeKind::Plain { components: 3 }); assert_eq!(k.spatial(), None); - assert_eq!(k.plain_ease_slots(), Some(vec![0.0; 12])); + assert_eq!(k.ease_components(), Some(vec![NO_EASE; 3])); assert_eq!(k.ease(), None); assert_eq!((k.interpolation_in(), k.interpolation_out()), (Some(Interpolation::Linear), Some(Interpolation::Linear))); } @@ -125,7 +128,7 @@ fn two_dimensional_mask_feather_keys() { assert!(keys.iter().all(|k| k.raw().len() == 88 && k.kind() == KeyframeKind::Plain { components: 2 })); assert_eq!(keys.iter().map(|k| k.value().unwrap()).collect::>(), [vec![0.0, 0.0], vec![40.0, 20.0]]); assert_eq!(keys.iter().map(|k| k.seconds(25600).unwrap()).collect::>(), [0.0, 1.0]); - assert_eq!(keys[0].plain_ease_slots(), Some(vec![0.0; 8])); + assert_eq!(keys[0].ease_components(), Some(vec![NO_EASE; 2])); let mask = &layer.masks()[0]; assert!(mask.feather.source == ValueSource::Keyframed && mask.feather.value == [0.0, 0.0]); } diff --git a/crates/aep/tests/it/container/layer_switches.rs b/crates/aep/tests/it/container/layer_switches.rs index 292863a9e..1f8565752 100644 --- a/crates/aep/tests/it/container/layer_switches.rs +++ b/crates/aep/tests/it/container/layer_switches.rs @@ -2,7 +2,7 @@ //! colours, checked against the values the corpus was made with (its MANIFEST, batch 2). use crate::container::helpers::{assert_near, base_layer, first_comp, layer_of_type, layer_shown_as, open}; -use effectcraft_aep::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Project, Rational, ValueSource}; +use effectcraft_aep::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Project}; fn l_header(name: &str) -> Option { Some({ @@ -80,22 +80,24 @@ fn ntsc_frame_rates_are_exact_fractions() { // 229: frameRate 29.97 -> read back 29.9700012207031; the duration snaps from 5 s to 150 frames (5.005005 s). let h = skip!(comp_header("229-comp-fps-29.97.aep")); assert_eq!((h.time_base.frame_duration, h.time_base.timescale), (800, 23976)); - assert_eq!(h.frame_rate_exact(), Some(Rational { num: 2997, den: 100 })); assert!((h.frame_rate().unwrap() - 29.97).abs() < 1e-12); - assert!((h.frame_rate_from_fixed() - 29.970_001_220_703_1).abs() < 1e-7); - assert_eq!((h.duration_units, h.duration_frames()), (120_000, Some(150))); + // The 16.16 rate holds the float32 29.97 (29.9700012207031). + assert_eq!(h.frame_rate_fixed, 1_964_114); + // 150 frames of 800 units. + assert_eq!(h.duration_units, 120_000); assert!((h.duration_seconds().unwrap() - 5.005_005_005_005_01).abs() < 1e-9); // 230: frameRate 23.976 -> read back 23.9759979248047; 120 frames. let h = skip!(comp_header("230-comp-fps-23.976.aep")); assert_eq!((h.time_base.frame_duration, h.time_base.timescale), (1000, 23976)); - assert_eq!(h.frame_rate_exact(), Some(Rational { num: 2997, den: 125 })); assert!((h.frame_rate().unwrap() - 23.976).abs() < 1e-12); - assert!((h.frame_rate_from_fixed() - 23.975_997_924_804_7).abs() < 1e-7); - assert_eq!((h.duration_units, h.duration_frames()), (120_000, Some(120))); + // The 16.16 rate holds the float32 23.976 (23.9759979248047). + assert_eq!(h.frame_rate_fixed, 1_571_291); + // 120 frames of 1000 units. + assert_eq!(h.duration_units, 120_000); assert!((h.duration_seconds().unwrap() - 5.005_005_005_005_01).abs() < 1e-9); // 25 fps stays 25. let h = skip!(comp_header("000-base.aep")); - assert_eq!((h.frame_rate_exact(), h.duration_frames()), (Some(Rational { num: 25, den: 1 }), Some(125))); + assert_eq!((h.time_base.timescale, h.time_base.frame_duration, h.duration_units), (25600, 1024, 128_000)); } #[test] @@ -128,7 +130,6 @@ fn background_is_8_bits_per_channel() { // 231: bgColor [0.5, 0.5, 0.5] is stored as 128 and reads back as 128/255 = 0.50196. let h = skip!(comp_header("231-comp-bg-grey-0.5.aep")); assert_eq!(h.background, [128, 128, 128]); - assert_near(&h.background_rgb(), &[0.501_960_784_313_73; 3], 1e-12); // The frame rate stays 25 even though an earlier edit of the AE session left other numbers in the cache-like fields. assert_eq!((h.time_base.frame_duration, h.time_base.timescale), (1024, 25600)); } @@ -143,12 +144,10 @@ fn cameras_auto_orient_and_position() { let camera = layer_of_type(&comp, LayerType::Camera); let h = camera.header().unwrap(); assert_eq!((h.flag_bytes[1], h.is_auto_oriented(), h.is_three_d(), h.source_id, h.light_type()), (0x44, true, true, None, None)); - // The default Position is an "auto state" record that stores [0, 0, -2666.67]; the two-node camera reports the comp centre. + // The default Position is an "auto state" record that stores [0, 0, -2666.67]. let pos = camera.camera_position().unwrap(); assert!(pos.auto_state); assert_near(&pos.stored, &[0.0, 0.0, -2666.67], 0.01); - assert_near(&pos.reported(1920, 1080, true), &[960.0, 540.0, -2666.67], 0.01); - assert_eq!(camera.camera_point_of_interest(), Some([960.0, 540.0, 0.0])); let bytes = fixture!("251-camera-one-node.aep"); let file = open(&bytes); @@ -159,10 +158,9 @@ fn cameras_auto_orient_and_position() { // AE reports [0, 0, -2666.67] for the one-node camera: the stored value. let pos = camera.camera_position().unwrap(); assert!(pos.auto_state); - assert_near(&pos.reported(1920, 1080, h.is_auto_oriented()), &[0.0, 0.0, -2666.67], 0.01); - assert_eq!(camera.camera_point_of_interest(), Some([960.0, 540.0, 0.0])); + assert_near(&pos.stored, &[0.0, 0.0, -2666.67], 0.01); - // 253: Position set to [100, 200, -1000]: stored as given, no auto state; the point of interest stays [960, 540, 0]. + // 253: Position set to [100, 200, -1000]: stored as given, no auto state. let bytes = fixture!("253-camera-position-100-200-m1000.aep"); let file = open(&bytes); let project = Project::new(&file).unwrap(); @@ -170,35 +168,27 @@ fn cameras_auto_orient_and_position() { let pos = camera.camera_position().unwrap(); assert!(!pos.auto_state); assert_eq!(pos.stored, [100.0, 200.0, -1000.0]); - assert_eq!(pos.reported(1920, 1080, true), [100.0, 200.0, -1000.0]); - assert_eq!(camera.camera_point_of_interest(), Some([960.0, 540.0, 0.0])); - // A camera has no light intensity. - assert_eq!(camera.light_intensity(), None); } #[test] -fn lights_type_and_intensity() { - // 254: a spot light, Intensity 100 -> 50 (stored as 50.0 percent, absent at the default 100). +fn lights_type_and_position() { + // 254: a spot light (its Intensity 50 is checked on import). let bytes = fixture!("254-light-spot-intensity-50.aep"); let file = open(&bytes); let project = Project::new(&file).unwrap(); let light = layer_of_type(&first_comp(&project), LayerType::Light); let h = light.header().unwrap(); assert_eq!((h.light_type(), h.light_type_code, h.source_id, h.is_auto_oriented(), h.is_three_d()), (Some(LightType::Spot), 1, None, true, true)); - let intensity = light.light_intensity().unwrap(); - assert_eq!((intensity.value, intensity.source), (50.0, ValueSource::Stored)); assert!(light.camera_position().unwrap().auto_state); assert_near(&light.camera_position().unwrap().stored, &[0.0, 0.0, -1333.33], 0.01); - // 252: a point light (light type 2) at the default intensity, which is not stored. + // 252: a point light (light type 2). let bytes = fixture!("252-light-point.aep"); let file = open(&bytes); let project = Project::new(&file).unwrap(); let light = layer_of_type(&first_comp(&project), LayerType::Light); let h = light.header().unwrap(); assert_eq!((h.light_type(), h.light_type_code), (Some(LightType::Point), 2)); - let intensity = light.light_intensity().unwrap(); - assert_eq!((intensity.value, intensity.source), (100.0, ValueSource::Default)); // The solid under it is no light. let solid = base_layer(&first_comp(&project)).header().unwrap(); assert_eq!((solid.light_type(), solid.light_type_code), (None, 0)); @@ -220,6 +210,6 @@ fn solid_colour_is_four_float32_argb() { // L is still red. let red = project.layer_source(&base_layer(&comp)).unwrap().footage().unwrap(); assert_eq!(red.solid_rgb(), Some([1.0, 0.0, 0.0])); - // The new layer has no user-set name flag (it shows its source's name). - assert!(!blue.header().unwrap().name_is_user_set()); + // The new layer has no name of its own (it shows its source's name). + assert_eq!(blue.name(), None); } diff --git a/crates/aep/tests/it/container/matte.rs b/crates/aep/tests/it/container/matte.rs index 65fe3e6b3..2502954fb 100644 --- a/crates/aep/tests/it/container/matte.rs +++ b/crates/aep/tests/it/container/matte.rs @@ -13,7 +13,7 @@ fn layers(name: &str) -> Option, u32)>> { comp.layers() .map(|l| { let h = l.header().expect("header"); - (project.layer_display_name(&l).unwrap_or_default(), h.is_video_enabled(), l.track_matte(), h.id) + (l.name().or_else(|| project.layer_source(&l)?.display_name()).unwrap_or_default(), h.is_video_enabled(), l.track_matte(), h.id) }) .collect() }) diff --git a/crates/aep/tests/it/container/robustness.rs b/crates/aep/tests/it/container/robustness.rs index 5c3422a10..0a427bb88 100644 --- a/crates/aep/tests/it/container/robustness.rs +++ b/crates/aep/tests/it/container/robustness.rs @@ -154,16 +154,15 @@ use effectcraft_aep::{AepFile, Body, Chunk, Comp, Layer, Project, PropGroup, Pro /// Touches the property accessors; none may panic on a damaged property. fn exercise_property(p: &Property<'_, '_>) { - let _ = (p.header(), p.dims(), p.is_animated(), p.static_value(), p.time_scale(), p.flags(), p.is_static_flag_set()); - let _ = (p.expression(), p.expression_flags().map(|f| (f.all_set(), f.disabled())), p.expression_enabled(), p.user_name(), p.first_key_value()); - let _ = p.color_argb(); + let _ = (p.header(), p.dims(), p.is_animated(), p.static_value(), p.time_scale(), p.flags()); + let _ = (p.expression(), p.expression_flags().map(|f| f.disabled()), p.expression_enabled(), p.user_name(), p.first_key_value()); if let Some(keys) = p.keys() { let scale = p.time_scale().unwrap_or(0); for k in keys.keyframes() { - let _ = (k.time_units(), k.spatial_value(), k.raw().len(), k.kind(), k.seconds(scale), k.seconds(0)); + let _ = (k.time_units(), k.raw().len(), k.kind(), k.seconds(scale), k.seconds(0)); let _ = (k.interpolation_in(), k.interpolation_out(), k.flags(), k.label(), k.is_roving()); - let _ = (k.scalar_value(), k.ease(), k.spatial(), k.components(), k.value(), k.plain_ease_slots()); - let _ = (k.ease_components(), k.color_distance()); + let _ = (k.scalar_value(), k.ease(), k.spatial(), k.components(), k.value()); + let _ = k.ease_components(); for component in [0, 1, 2, 3, 7, 255, usize::MAX] { let _ = k.ease_component(component); } @@ -193,17 +192,14 @@ fn exercise_group(g: &PropGroup<'_, '_>, depth: usize) { fn exercise_layer(project: &Project<'_, '_>, comp_size: (u32, u32), layer: &Layer<'_, '_>) { let _ = (layer.name(), layer.is_three_d(), layer.id(), layer.track_matte()); if let Some(h) = layer.header() { - let _ = (h.in_point(), h.out_point(), h.in_point_seconds(), h.out_point_seconds(), h.start_seconds(), h.stretch_percent()); - let _ = (h.stretch_ratio(), h.stretch_den); + let _ = (h.in_point(), h.out_point(), h.stretch_ratio(), h.stretch_den); let _ = (h.is_shy(), h.is_locked(), h.is_solo(), h.is_three_d(), h.is_motion_blur(), h.is_adjustment(), h.is_guide()); - let _ = (h.is_null_object(), h.collapses_transformations(), h.name_is_user_set(), h.quality, h.blend_mode, h.layer_type); + let _ = (h.is_null_object(), h.collapses_transformations(), h.quality, h.blend_mode, h.layer_type); let _ = (h.is_enabled(), h.is_video_enabled(), h.is_audio_enabled(), h.is_effects_enabled(), h.is_auto_oriented(), h.light_type(), h.blend_mode.code()); } let _ = (layer.is_enabled(), layer.is_audio_enabled(), layer.is_effects_enabled()); - let _ = (layer.camera_position().map(|p| p.reported(comp_size.0, comp_size.1, true)), layer.camera_point_of_interest(), layer.light_intensity()); + let _ = layer.camera_position().map(|p| (p.stored, p.auto_state)); let _ = project.layer_anchor_px(layer); - let _ = (layer.camera_zoom(), layer.camera_depth_of_field(), layer.camera_focus_distance(), layer.camera_aperture()); - let _ = (layer.light_color(), layer.light_cone_angle(), layer.light_cone_feather()); let _ = layer .time_remap() .map(|t| (t.enabled, t.keys.iter().fold(0.0, |acc, k| acc + k.value_seconds + k.time_seconds.unwrap_or(0.0) + f64::from(k.time_units)))); @@ -230,7 +226,7 @@ fn exercise_layer(project: &Project<'_, '_>, comp_size: (u32, u32), layer: &Laye } } } - let _ = (project.layer_source(layer).map(|i| i.id()), project.layer_display_name(layer), project.layer_source_size(layer)); + let _ = (project.layer_source(layer).map(|i| i.id()), project.layer_source(layer).and_then(|i| i.display_name()), project.layer_source_size(layer)); if let Some(t) = layer.transform(comp_size.0, comp_size.1) { let _ = (t.anchor_px(f64::from(comp_size.0), f64::from(comp_size.1)), t.position.value, t.opacity_percent.value); } @@ -263,7 +259,6 @@ fn exercise_layer(project: &Project<'_, '_>, comp_size: (u32, u32), layer: &Laye } for m in layer.masks() { let _ = (m.name.clone(), m.mode, m.inverted, m.counter, m.feather, m.opacity_percent, m.expansion, m.path_animated, m.path_key_seconds.len()); - let _ = project.mask_vertices_px(layer, &m).map(|shapes| shapes.iter().map(Vec::len).sum::()); for s in &m.shapes { let _ = (s.vertex_count(), s.vertices((1920.0, 1080.0)).len(), s.vertices((f64::NAN, f64::INFINITY)).len(), s.open); } @@ -291,10 +286,9 @@ fn exercise_comp(project: &Project<'_, '_>, comp: &Comp<'_, '_>) { let mut size = (1920, 1080); if let Some(h) = comp.header() { size = (u32::from(h.width), u32::from(h.height)); - let _ = (h.frame_rate(), h.frame_rate_from_fixed(), h.duration_seconds(), h.work_area_start_seconds(), h.work_area_end_seconds()); - let _ = (h.frame_rate_exact(), h.duration_frames()); - let _ = (h.background_rgb(), h.pixel_aspect_ratio(), h.pixel_aspect.reduced(), h.shutter_angle, h.motion_blur); - let _ = (h.shutter_phase, h.display_start, h.display_start_seconds(), h.display_start_frame()); + let _ = (h.frame_rate(), h.frame_rate_fixed, h.duration_seconds(), h.work_area_start_units, h.work_area_end_units); + let _ = (h.background, h.pixel_aspect.to_f64(), h.pixel_aspect.reduced(), h.shutter_angle, h.motion_blur); + let _ = (h.shutter_phase, h.display_start.map(|r| r.to_f64())); } for layer in comp.layers() { exercise_layer(project, size, &layer); @@ -305,10 +299,8 @@ fn exercise_comp(project: &Project<'_, '_>, comp: &Comp<'_, '_>) { fn exercise(file: &AepFile<'_>) { let Some(project) = Project::new(file) else { return }; for item in project.all_items() { - let _ = (item.kind(), item.name(), item.id(), item.label(), item.used_item_ids(), item.footage(), item.display_name()); - let _ = item - .footage() - .map(|f| (f.duration, f.native_frame_rate, f.bits_per_pixel, f.file_size, f.audio_sample_rate, f.is_sequence, f.sequence_frame_count())); + let _ = (item.kind(), item.name(), item.id(), item.label(), item.footage(), item.display_name()); + let _ = item.footage().map(|f| (f.duration, f.native_frame_rate, f.bits_per_pixel, f.file_size, f.audio_sample_rate, f.is_sequence)); let _ = item.id().and_then(|id| project.item_by_id(id)); if let Some(comp) = item.comp() { exercise_comp(&project, &comp); @@ -561,12 +553,12 @@ fn extreme_values_in_the_decoded_headers() { use effectcraft_aep::{CompHeader, LayerHeader, Rational, ShapePath}; // Every field at its maximum: no overflow, no division by zero, no panic. let h = LayerHeader::parse(&[0xff; 164]).unwrap(); - let _ = (h.in_point(), h.out_point(), h.in_point_seconds(), h.out_point_seconds(), h.start_seconds(), h.stretch_percent()); + let _ = (h.in_point(), h.out_point(), h.start.to_f64(), h.stretch_ratio()); let mut zero_den = [0u8; 164]; zero_den[8..12].copy_from_slice(&u32::MAX.to_be_bytes()); zero_den[12..16].copy_from_slice(&i32::MIN.to_be_bytes()); let h = LayerHeader::parse(&zero_den).unwrap(); - assert_eq!((h.in_point(), h.out_point(), h.start_seconds()), (None, None, None)); + assert_eq!((h.in_point(), h.out_point(), h.start.to_f64()), (None, None, None)); let mut wide = [0u8; 164]; wide[8..12].copy_from_slice(&u32::MAX.to_be_bytes()); wide[12..16].copy_from_slice(&i32::MIN.to_be_bytes()); @@ -574,14 +566,12 @@ fn extreme_values_in_the_decoded_headers() { wide[20..24].copy_from_slice(&i32::MAX.to_be_bytes()); wide[24..28].copy_from_slice(&u32::MAX.to_be_bytes()); let h = LayerHeader::parse(&wide).unwrap(); - let _ = (h.in_point(), h.in_point_seconds()); + let _ = h.in_point(); let c = CompHeader::parse(&[0xff; 204]).unwrap(); - assert_eq!((c.duration_seconds(), c.work_area_start_seconds(), c.work_area_end_units), (None, None, None)); - let _ = (c.frame_rate(), c.frame_rate_from_fixed(), c.background_rgb(), c.pixel_aspect_ratio(), c.frame_rate_exact()); - assert_eq!(c.duration_frames(), Some(1)); + assert_eq!((c.duration_seconds(), c.work_area_end_units), (None, None)); + let _ = (c.frame_rate(), c.pixel_aspect.to_f64()); let zero = CompHeader::parse(&[0; 204]).unwrap(); - assert_eq!((zero.frame_rate(), zero.pixel_aspect_ratio(), zero.duration_seconds()), (None, None, None)); - assert_eq!((zero.frame_rate_exact(), zero.duration_frames()), (None, None)); + assert_eq!((zero.frame_rate(), zero.pixel_aspect.to_f64(), zero.duration_seconds()), (None, None, None)); let all = LayerHeader::parse(&[0xff; 164]).unwrap(); let _ = (all.is_enabled(), all.is_auto_oriented(), all.light_type(), all.blend_mode.code()); assert_eq!(LayerHeader::parse(&[0u8; 164]).unwrap().light_type(), None); @@ -680,7 +670,7 @@ fn hostile_text_dictionaries_and_marker_headers_never_panic() { #[test] fn extreme_values_in_the_batch_3_fields() { - use effectcraft_aep::{CompHeader, Footage, LayerHeader, Rational}; + use effectcraft_aep::{CompHeader, LayerHeader}; // Time stretch: zero and maximal numerators and denominators (bytes 8 and 108), with extreme times. for (num, den) in [(0u32, 0u32), (1, 0), (0, 1), (u32::MAX, 1), (1, u32::MAX), (u32::MAX, u32::MAX), (3, 2)] { let mut ldta = [0u8; 164]; @@ -693,7 +683,7 @@ fn extreme_values_in_the_batch_3_fields() { ldta[28..32].copy_from_slice(&i32::MIN.to_be_bytes()); ldta[32..36].copy_from_slice(&1u32.to_be_bytes()); let h = LayerHeader::parse(&ldta).unwrap(); - let _ = (h.stretch_ratio(), h.stretch_percent(), h.in_point(), h.out_point(), h.in_point_seconds(), h.out_point_seconds()); + let _ = (h.stretch_ratio(), h.in_point(), h.out_point()); if den == 0 { assert_eq!((h.stretch_ratio(), h.in_point(), h.out_point()), (None, None, None)); } @@ -701,31 +691,8 @@ fn extreme_values_in_the_batch_3_fields() { // Display start and shutter phase from extreme bytes; a missing tail leaves them unknown. for fill in [0x00u8, 0x7f, 0x80, 0xff] { let c = CompHeader::parse(&[fill; 204]).unwrap(); - let _ = (c.shutter_phase, c.display_start, c.display_start_seconds(), c.display_start_frame()); + let _ = (c.shutter_phase, c.display_start.map(|r| r.to_f64())); } - let zero_rate = CompHeader::parse(&[0; 204]).unwrap(); - assert_eq!((zero_rate.display_start_seconds(), zero_rate.display_start_frame()), (None, None)); - // Footage durations: zero denominator, negative and maximal numerators, a huge frame rate. - let base = Footage { - file_type: String::new(), - width: 0, - height: 0, - name: None, - is_solid: false, - solid_argb: None, - path: None, - duration: None, - native_frame_rate: 0, - bits_per_pixel: 0, - file_size: 0, - audio_sample_rate: None, - is_sequence: true, - }; - for (num, den, rate) in [(3i64, 30i64, 30u32), (3, 0, 30), (-3, 30, 30), (i64::MAX, 1, u32::MAX), (i64::MIN, -1, u32::MAX), (1, i64::MAX, 1), (0, 1, 0)] { - let f = Footage { duration: Some(Rational { num, den }), native_frame_rate: rate, ..base.clone() }; - let _ = f.sequence_frame_count(); - } - assert_eq!(Footage { duration: Some(Rational { num: 3, den: 30 }), native_frame_rate: 30, ..base.clone() }.sequence_frame_count(), Some(3)); - assert_eq!(Footage { duration: Some(Rational { num: 3, den: 0 }), native_frame_rate: 30, ..base.clone() }.sequence_frame_count(), None); - assert_eq!(Footage { duration: None, ..base }.sequence_frame_count(), None); + // A zero denominator has no value. + assert_eq!(CompHeader::parse(&[0; 204]).unwrap().display_start.and_then(|r| r.to_f64()), None); } diff --git a/crates/aep/tests/it/container/text_markers_footage.rs b/crates/aep/tests/it/container/text_markers_footage.rs index 9517c0453..48f49297e 100644 --- a/crates/aep/tests/it/container/text_markers_footage.rs +++ b/crates/aep/tests/it/container/text_markers_footage.rs @@ -13,7 +13,6 @@ fn text_layer_document(name: &str) -> Option { let layer = comp.layers().find(|l| l.name().as_deref() == Some("T")).expect("the text layer T"); let h = layer.header().unwrap(); assert_eq!(h.layer_type, LayerType::Text); - assert!(h.name_is_user_set()); layer.text_document().expect("text document") }) } @@ -192,7 +191,6 @@ fn png_footage_layer() { // The path comes from the alias JSON (the fixture holds a neutral replacement of the original). assert!(f.path.as_deref().is_some_and(|p| p.ends_with("test8x8.png")), "{:?}", f.path); assert_eq!(project.layer_source_size(&layer), Some((8, 8))); - assert_eq!(project.layer_display_name(&layer).as_deref(), Some("png")); // L is still there below it. assert_eq!(comp.layers().count(), 2); } diff --git a/crates/aep/tests/it/container/typed.rs b/crates/aep/tests/it/container/typed.rs index 15d26992c..381f86796 100644 --- a/crates/aep/tests/it/container/typed.rs +++ b/crates/aep/tests/it/container/typed.rs @@ -186,7 +186,7 @@ fn position_keyframes() { assert_eq!(times, [0, 25_600, 64_000]); let seconds: Vec = times.iter().map(|t| tb.seconds(*t).unwrap()).collect(); assert_eq!(seconds, [0.0, 1.0, 2.5]); - let values: Vec<[f64; 3]> = keys.keyframes().map(|k| k.spatial_value().unwrap()).collect(); + let values: Vec> = keys.keyframes().map(|k| k.value().unwrap()).collect(); assert_eq!(values, [[0.0, 0.0, 0.0], [100.0, 0.0, 0.0], [200.0, 100.0, 0.0]]); } diff --git a/crates/aep/tests/it/container/values.rs b/crates/aep/tests/it/container/values.rs index b9ceadd57..b7e91881d 100644 --- a/crates/aep/tests/it/container/values.rs +++ b/crates/aep/tests/it/container/values.rs @@ -119,7 +119,7 @@ fn linear_position_keys() { let values: Vec<_> = keys.iter().map(|k| k.spatial().unwrap().value).collect(); assert_eq!(values, [[0.0, 0.0, 0.0], [100.0, 0.0, 0.0], [200.0, 100.0, 0.0]]); assert_eq!(p.first_key_value(), Some(vec![0.0, 0.0, 0.0])); - assert_eq!(p.is_static_flag_set(), Some(false)); + assert!(p.is_animated()); })); // The transform of the layer reports the animated Position as keyframed (value at the first key). let (t, _) = skip!(transform("055-key-position-3key-inserted-t2.5.aep")); @@ -181,7 +181,7 @@ fn roving_and_label_flags() { assert_eq!(roving, [false, true, false]); assert_eq!(keys[1].time_units(), Some(26509)); assert_eq!(keys[1].seconds(25600), Some(1.0355078125)); - assert_eq!(keys[1].spatial_value(), Some([100.0, 0.0, 0.0])); + assert_eq!(keys[1].value(), Some(vec![100.0, 0.0, 0.0])); assert_eq!(keys[1].flags(), Some(0x0027)); assert_eq!(keys[1].label(), Some(0)); })); @@ -193,7 +193,7 @@ fn roving_and_label_flags() { assert_eq!(keys[1].flags(), Some(0x0247)); assert_eq!(keys[0].flags(), Some(0x0007)); assert_eq!(keys[1].is_roving(), Some(false)); - assert_eq!(keys[1].spatial_value(), Some([100.0, 0.0, 0.0])); + assert_eq!(keys[1].value(), Some(vec![100.0, 0.0, 0.0])); })); } @@ -204,7 +204,6 @@ fn scalar_opacity_keys() { assert!(p.is_animated()); assert_eq!(p.dims(), Some(1)); assert_eq!(p.time_scale(), Some(25600)); - assert_eq!(p.is_static_flag_set(), Some(false)); let list = p.keys().unwrap(); assert_eq!(list.header.record_size, 48); let keys: Vec<_> = list.keyframes().collect(); @@ -243,10 +242,8 @@ fn expression_text_and_flag_bytes() { assert_eq!(p.expression(), None); let flags = p.expression_flags().unwrap(); assert_eq!((flags.byte_76, flags.byte_77, flags.byte_120), (0, 0, 0)); - assert!(!flags.all_set()); // A written static property: tdsb 1, byte 5 of tdb4 has bit 0 set (15 for Position). assert_eq!(p.flags(), Some(1)); - assert_eq!(p.is_static_flag_set(), Some(true)); assert_eq!(p.header().and_then(|h| h.get(5).copied()), Some(15)); })); } diff --git a/crates/aep/tests/it/export/built.rs b/crates/aep/tests/it/export/built.rs index 14f9d8795..a3d4b7ea6 100644 --- a/crates/aep/tests/it/export/built.rs +++ b/crates/aep/tests/it/export/built.rs @@ -139,8 +139,7 @@ fn layer_header_fields() { assert!(h[2].is_motion_blur() && h[2].is_guide()); assert_eq!(h[2].blend_mode, AepBlend::Screen); assert_eq!((h[2].start.num, h[2].start.den), (46_080, 30_720)); - assert_eq!(h[2].in_point_seconds(), Some(2.0)); - assert_eq!(h[2].out_point_seconds(), Some(6.0)); + assert_eq!((h[2].in_point(), h[2].out_point()), (effectcraft_aep::Rational::new(2, 1), effectcraft_aep::Rational::new(6, 1))); assert_eq!(h[2].name_copy, "Mid"); // Ids are unique. let mut ids: Vec = h.iter().map(|x| x.id).collect(); diff --git a/crates/aep/tests/it/export/masks.rs b/crates/aep/tests/it/export/masks.rs index 544797802..7a7b18442 100644 --- a/crates/aep/tests/it/export/masks.rs +++ b/crates/aep/tests/it/export/masks.rs @@ -201,8 +201,9 @@ fn every_written_field_parses_back() { for m in [&masks[0], &masks[2], &masks[4]] { assert_eq!((m.feather.source, m.opacity_percent.source, m.expansion.source), (ValueSource::Default, ValueSource::Default, ValueSource::Default)); } - // Paths: vertices in layer pixels, open bit. - let px = view.mask_vertices_px(&layer, &masks[2]).expect("vertices"); + // Paths: vertices in layer pixels (the box scaled by the source size, as the importer does), open bit. + let (w, h) = view.layer_source_size(&layer).expect("size"); + let px: Vec<_> = masks[2].shapes.iter().map(|s| s.vertices((f64::from(w), f64::from(h)))).collect(); let open = open_path(); assert!(masks[2].shapes[0].open && !masks[0].shapes[0].open); for (v, (want, (ti, to))) in px[0].iter().zip(open.vertices.iter().zip(open.in_tangents.iter().zip(&open.out_tangents))) { diff --git a/crates/aep/tests/it/export/objects.rs b/crates/aep/tests/it/export/objects.rs index d42f1c399..f9de6e836 100644 --- a/crates/aep/tests/it/export/objects.rs +++ b/crates/aep/tests/it/export/objects.rs @@ -190,6 +190,20 @@ fn with_props(p: &mut Project, c: effectcraft_project::ItemId, idx: usize, set: }); } +/// The layers of the first composition as the importer reads `bytes` back. +fn reimported_layers(bytes: &[u8]) -> Vec { + let exists = |_: &str| true; + let options = effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&exists) }; + let mut p = Project::default(); + let r = effectcraft_aep::import::import(&mut p, bytes, &options).expect("import"); + p.comp(r.comps[0]).expect("comp").layers.iter().cloned().collect() +} + +/// The value at `path` of a read-back layer. +fn value(l: &effectcraft_project::Layer, path: &str) -> effectcraft_keyframe::Value { + l.props.prop(path).unwrap_or_else(|| panic!("no {path}")).value.clone() +} + fn first_comp_layers(bytes: &[u8], mut f: impl FnMut(&effectcraft_aep::Layer<'_, '_>)) { let file = parse(bytes).expect("parse"); assert_eq!(effectcraft_aep::write(&file).expect("write"), bytes); @@ -229,12 +243,18 @@ fn cameras_are_written_with_position_point_of_interest_and_options() { assert!(h.is_three_d() && h.is_auto_oriented(), "two-node camera"); let pos = l.camera_position().expect("position"); assert_eq!((pos.stored, pos.auto_state), ([100.0, 200.0, -1000.0], false)); - assert_eq!(l.camera_point_of_interest(), Some([960.0, 540.0, 0.0])); - assert_eq!(l.camera_depth_of_field().map(|t| t.value), Some(true)); - assert_eq!(l.camera_focus_distance().map(|t| t.value), Some(800.0)); - assert_eq!(l.camera_aperture().map(|t| t.value), Some(30.0)); - assert_eq!(l.camera_zoom().map(|t| t.value), Some(1500.0)); }); + // The importer reads the written options back. + let back = reimported_layers(&r.bytes); + for (path, want) in [ + ("transform/poi", Value::Vec3([960.0, 540.0, 0.0])), + ("cameraOptions/dof", Value::Bool(true)), + ("cameraOptions/focusDistance", Value::Scalar(800.0)), + ("cameraOptions/aperture", Value::Scalar(30.0)), + ("cameraOptions/zoom", Value::Scalar(1500.0)), + ] { + assert_eq!(value(&back[0], path), want, "{path}"); + } // One-node (auto-orient off): bit 0x40 clear (251). with_layer(&mut p, c, 0, |l| l.auto_orient = effectcraft_project::AutoOrient::Off); first_comp_layers(&export_all(&p).bytes, |l| assert!(!l.header().expect("h").is_auto_oriented())); @@ -271,20 +291,25 @@ fn spot_and_point_lights_are_written_with_their_options() { seen.push(h.light_type()); match h.light_type() { Some(effectcraft_aep::LightType::Spot) => { - assert_eq!(l.light_intensity().map(|t| t.value), Some(50.0)); - assert_eq!(l.light_color().map(|t| t.value), Some([1.0, 1.0, 0.0, 0.0])); - assert_eq!(l.light_cone_angle().map(|t| t.value), Some(60.0)); - assert_eq!(l.light_cone_feather().map(|t| t.value), Some(30.0)); assert_eq!(l.camera_position().map(|p| p.stored), Some([100.0, 50.0, -500.0])); - assert_eq!(l.camera_point_of_interest(), Some([960.0, 540.0, 0.0])); - } - Some(effectcraft_aep::LightType::Point) => { - assert_eq!(l.light_intensity().map(|t| t.value), Some(100.0)); } + Some(effectcraft_aep::LightType::Point) => {} other => panic!("{other:?}"), } }); assert_eq!(seen.len(), 2); + // The importer reads the written options back (layer 0 is the point light, 1 the spot). + let back = reimported_layers(&r.bytes); + assert_eq!(value(&back[0], "lightOptions/intensity"), Value::Scalar(100.0)); + for (path, want) in [ + ("lightOptions/intensity", Value::Scalar(50.0)), + ("lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0])), + ("lightOptions/coneAngle", Value::Scalar(60.0)), + ("lightOptions/coneFeather", Value::Scalar(30.0)), + ("transform/poi", Value::Vec3([960.0, 540.0, 0.0])), + ] { + assert_eq!(value(&back[1], path), want, "{path}"); + } // Parallel and ambient lights, and unsampled options, are reported. let mut q = Project::default(); let c = add_comp(&mut q, "C", 640, 360, FrameRate::new(25, 1), 1.0, None); diff --git a/crates/aep/tests/it/export/precomp.rs b/crates/aep/tests/it/export/precomp.rs index e384bfd1b..a25493f77 100644 --- a/crates/aep/tests/it/export/precomp.rs +++ b/crates/aep/tests/it/export/precomp.rs @@ -47,10 +47,10 @@ fn a_precomposition_layer_points_at_its_composition() { let (c, pre) = (by_name("C"), by_name("Pre")); let pre_id = pre.id().expect("id"); // C records the composition it uses (idpi, little-endian), idpc byte 0 is 1. - assert_eq!(c.used_item_ids(), vec![pre_id]); + assert_eq!(crate::common::used_item_ids(&c), vec![pre_id]); let idpc = c.chunk().child(b"idpc").and_then(|x| x.bytes()).expect("idpc"); assert_eq!(idpc.first(), Some(&1)); - assert_eq!(pre.used_item_ids(), Vec::::new()); + assert_eq!(crate::common::used_item_ids(&pre), Vec::::new()); assert_eq!(pre.chunk().child(b"idpc").and_then(|x| x.bytes()).and_then(|b| b.first().copied()), Some(0)); let layer = c.comp().expect("comp").layers().next().expect("layer"); let h = layer.header().expect("header"); @@ -107,7 +107,7 @@ fn one_composition_used_twice_is_recorded_once_and_chains_nest() { let view = effectcraft_aep::Project::new(&file).expect("project"); let items = view.all_items(); let id = |n: &str| items.iter().find(|i| i.display_name().as_deref() == Some(n)).and_then(|i| i.id()).expect(n); - let used = |n: &str| items.iter().find(|i| i.display_name().as_deref() == Some(n)).expect(n).used_item_ids(); + let used = |n: &str| items.iter().find(|i| i.display_name().as_deref() == Some(n)).map(crate::common::used_item_ids).expect(n); assert_eq!(used("B"), vec![id("A")]); // Ascending ids (601; the order carries no meaning), and idpc counts them. let mut ab = vec![id("A"), id("B")]; diff --git a/crates/aep/tests/it/import/cameras.rs b/crates/aep/tests/it/import/cameras.rs index 863f8bee1..3b4f3f30c 100644 --- a/crates/aep/tests/it/import/cameras.rs +++ b/crates/aep/tests/it/import/cameras.rs @@ -50,10 +50,14 @@ fn cameras() { close(&v(&c, "cameraOptions/focusDistance"), &[2666.6667]); close(&v(&c, "cameraOptions/aperture"), &[25.3093]); assert_eq!(v(&c, "cameraOptions/dof"), Value::Bool(false)); - // 251: one-node camera. - assert_eq!(skip!(layer("251-camera-one-node.aep", "Camera 1")).auto_orient, AutoOrient::Off); - // 253: position set by the user. - close(&v(&skip!(layer("253-camera-position-100-200-m1000.aep", "Camera 1")), "transform/position"), &[100.0, 200.0, -1000.0]); + // 251: one-node camera; its point of interest is stored at the centre too. + let c = skip!(layer("251-camera-one-node.aep", "Camera 1")); + assert_eq!(c.auto_orient, AutoOrient::Off); + close(&v(&c, "transform/poi"), &[960.0, 540.0, 0.0]); + // 253: position set by the user; the point of interest stays at the centre. + let c = skip!(layer("253-camera-position-100-200-m1000.aep", "Camera 1")); + close(&v(&c, "transform/position"), &[100.0, 200.0, -1000.0]); + close(&v(&c, "transform/poi"), &[960.0, 540.0, 0.0]); // 341, 342: zoom, depth of field. 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")); @@ -75,9 +79,17 @@ fn spot_and_point_lights() { close(&v(&l, "transform/poi"), &[960.0, 540.0, 0.0]); close(&v(&l, "transform/position"), &[0.0, 0.0, -1333.3333]); assert_eq!(l.auto_orient, AutoOrient::TowardsPointOfInterest); + // The options it does not store keep After Effects' defaults: white, 90, 50. + 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]); - close(&v(&skip!(layer("344-light-spot-coneangle-60.aep", "Light 1")), "lightOptions/coneAngle"), &[60.0]); - close(&v(&skip!(layer("345-light-spot-conefeather-30.aep", "Light 1")), "lightOptions/coneFeather"), &[30.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]); + 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]); // 252: a point light stores its cone as default-state records, which hold nothing. let l = skip!(layer("252-light-point.aep", "Light 1")); assert!(matches!(l.source, LayerSource::Light { kind: LightKind::Point })); diff --git a/crates/aep/tests/it/import/versions.rs b/crates/aep/tests/it/import/versions.rs index cac6a9a6b..77c39ea02 100644 --- a/crates/aep/tests/it/import/versions.rs +++ b/crates/aep/tests/it/import/versions.rs @@ -169,13 +169,12 @@ fn copies_saved_as_25x_and_24x_import_like_the_2026_file() { #[ignore = "needs the version fixtures of the corpus (-26/-25x/-24x.aep)"] fn the_container_of_a_copy_states_its_version_in_head_and_the_writer_in_svap() { for family in FAMILIES { - for (suffix, version, revision) in [("26", "26.2.1x2", 97), ("25x", "25.6.0x97", 96), ("24x", "24.6.0x65", 95)] { + for (suffix, version) in [("26", "26.2.1x2"), ("25x", "25.6.0x97"), ("24x", "24.6.0x65")] { let name = format!("{family}-{suffix}.aep"); let Some(bytes) = crate::common::try_file(&name) else { continue }; let file = parse(&bytes).unwrap(); assert_eq!(file.file_version().map(|v| v.text()).as_deref(), Some(version), "{name}"); assert_eq!(file.saved_by_version().map(|v| v.text()).as_deref(), Some("26.2.1x2"), "{name}"); - assert_eq!(file.head_revision(), Some(revision), "{name}"); } } } From 66cbb9d89e840fd1d055c79942bb479d881098d2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 04:34:39 +0100 Subject: [PATCH 2/5] aep: review fixes for #3281: NTSC table beside TimeBase, real-path NTSC round trip, light and camera defaults on import, doc wording --- crates/aep/examples/aepcheck3281.rs | 105 ++++++++++++++++++ crates/aep/src/container/typed.rs | 9 ++ crates/aep/src/export/timebase.rs | 5 - crates/aep/src/import/time.rs | 5 - .../aep/tests/it/container/batch3_objects.rs | 3 +- crates/aep/tests/it/export/built.rs | 17 +++ crates/aep/tests/it/import/cameras.rs | 12 +- docs/aep-format.md | 2 +- 8 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 crates/aep/examples/aepcheck3281.rs 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. | From f5ec122466808c831eeec9c66100fad211986fe2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 04:34:49 +0100 Subject: [PATCH 3/5] aep: drop a local check tool committed by mistake (#3281) --- crates/aep/examples/aepcheck3281.rs | 105 ---------------------------- 1 file changed, 105 deletions(-) delete mode 100644 crates/aep/examples/aepcheck3281.rs diff --git a/crates/aep/examples/aepcheck3281.rs b/crates/aep/examples/aepcheck3281.rs deleted file mode 100644 index 0ed70c5eb..000000000 --- a/crates/aep/examples/aepcheck3281.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! 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"), - } -} From b3c89f314c44c36179eb92465463e188460980cf Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 06:19:18 +0100 Subject: [PATCH 4/5] aep: the format notes name the accessors that remain (#3281) --- docs/aep-format.md | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/docs/aep-format.md b/docs/aep-format.md index a0eb9de6a..9640aba0c 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -216,12 +216,12 @@ the value from the corpus manifest (test file in parentheses at the end of each API: `LayerHeader` (`is_video_enabled`/`is_enabled`, `is_audio_enabled`, `is_effects_enabled`, `is_auto_oriented`, `light_type`, `blend_mode`, `stretch` / `stretch_den`, `stretch_ratio`, -`stretch_percent`, `in_point`, `out_point`); the same switches on `Layer`; `Layer::track_matte` +`in_point`, `out_point`); the same switches on `Layer`; `Layer::track_matte` (`TrackMatte`, `MatteMode`; test `tests/matte.rs`). `stretch` is now the numerator of the ratio (it was the whole multiplier before 50 % and 150 % were sampled). Tests: `tests/header.rs`, `tests/layer_switches.rs`. The layer name itself is the `Utf8` child of the `Layr` (empty while the name is the source's); the layer then shows the name of its source item -(`Project::layer_display_name`). +(`Project::layer_source(...)` then `Item::display_name`, the rule the importer uses). ### 3.2 Composition header (`Item/cdta`, 204 bytes; the decoder needs the first 176) and renderer @@ -246,9 +246,9 @@ numerator of the ratio (it was the whole multiplier before 50 % and 150 % were s The inert `ADBE Time Remapping` record of every AV layer has `tduM` = the source's duration in seconds: 5 for 5 s compositions (124, 257, 258), 3 and 2 for a 3 s and a 2 s composition (600, 601), 1 for the 1 s WAV (351), 0.1 for the three-frame sequence (353), 0 on solids and stills (000, 126). Every property's keyframe time scale (`tdb4` bytes 12 to 15) follows the composition (23 976 in every property of `L` after 229 and 230). The corpus manifest calls the classic renderer -`ADBE Advanced 3d`; the file stores `ADBE Escher` / `Classic 3D`. API: `CompHeader::frame_rate_exact` -(exact fraction), `duration_frames`, `shutter_phase`, `display_start`, `display_start_seconds`, -`display_start_frame`. Tests: `tests/header.rs`, `tests/layer_switches.rs`. +`ADBE Advanced 3d`; the file stores `ADBE Escher` / `Classic 3D`. API: `CompHeader::{time_base, +frame_rate_fixed, duration_units, shutter_phase, display_start}` (`display_start` is the exact +fraction). Tests: `tests/header.rs`, `tests/layer_switches.rs`. ### 3.3 Items, footage and precomps @@ -292,7 +292,8 @@ name. A footage layer shows the item's name while it has no name of its own (351 Test: `tests/header.rs` (precomp), `tests/text_markers_footage.rs` and `tests/batch3_objects.rs` (footage), `tests/layer_switches.rs` (solid colour). API: `Footage::{duration, native_frame_rate, -bits_per_pixel, file_size, audio_sample_rate, is_sequence, sequence_frame_count}`. +bits_per_pixel, file_size, audio_sample_rate, is_sequence}`; `Item::sequence_info` (with +`SequenceInfo::frame_count`). ### 3.4 Properties: sparse storage, static values and units @@ -401,8 +402,8 @@ out speed, at 40 out influence**). Scale key 1 of 305 (`n = 3`, in 0 / 33 %, out 15 / 66) is `0,0, .33,.33, 15,15, .66,.66`. Component `i` uses slot `i` of each block (**V** for the block layout and the units, Scale speed in fractions per second and Mask Feather speed in pixels per second; **E** that different values per dimension sit in the slot of their dimension, because -the tool that made the corpus applies one ease to all dimensions; **V** since the exporter wrote three different eases for the three Scale dimensions and After Effects read the same three back, aep-export.md R13). `Keyframe::plain_ease_slots` -returns the doubles as stored, `Keyframe::ease_component(i)` one component's `KeyEase`. Layout of +the tool that made the corpus applies one ease to all dimensions; **V** since the exporter wrote three different eases for the three Scale dimensions and After Effects read the same three back, aep-export.md R13). `Keyframe::ease_component(i)` +returns one component's `KeyEase`, `Keyframe::ease_components` all of them. Layout of the spatial template: `u32` at 8 (1 normally, 0 after the spatial auto-bezier and continuous switches were turned off together, 054), `f64` arc length of the segment to the next key at 16 (100 in 204 key 1, 0 on the last key and right after creation; safe to ignore), **ease at 24 in @@ -427,7 +428,7 @@ in tangent (`n` x `f64`, relative offset from the value) at `56 + 8 n`, out tang | **ease, colour switched to bezier** | spatial n=4 @24..55 | keyframe.set_interpolation bezier on two colour keys stores speed 0 and influence 0 on both sides, and After Effects interpolates them linearly (598 Start Color red to blue: 0.75 / 0.5 / 0.25 red at 0.5 / 1 / 1.5 s); a custom colour ease cannot be set by script | 598 | V | | **ease, mask path** | 64-byte record @24..55 | in speed, in influence, out speed, out influence as in the spatial template: 599 out / in influence 0.75, 700 0.2 / 0.5, speeds 0 (the path at 0.5 s is 5.42 % / 28.0 % of the way). The `f64` at 16 is 1 (093) or 2 (599, 700) on the first key and 0 on the last (not decoded) | 093, 599, 700 | V (influence), ? (speed unit) | -API: `Keyframe::{interpolation_in, interpolation_out, kind, value, ease, ease_component, ease_components, plain_ease_slots, color_distance, spatial}`; +API: `Keyframe::{interpolation_in, interpolation_out, kind, value, ease, ease_component, ease_components, spatial}` (the `f64` at byte 16 of a colour key is `SpatialKey::segment_length`); `Property::keys()` hands the property's template to its records. Marker record (16 bytes): time, `03 03` hold bytes, 8 stale bytes. Mask path record (64 bytes): the common fields and the ease at byte 24 (599, 700). Test: `tests/values.rs`, `tests/keyframes.rs`. @@ -630,7 +631,7 @@ a non-zero byte at 112). 328), so mode and inverted are reported per mask. `mkif` byte 8 is the running mask id counter of the **layer** in the session (1, 2 for the two masks of 328; 329 then gets 3; undo does not reset it; a new layer starts at 1 again): exposed as `Mask::counter`, a session counter and not a stable id. API: -`Mask::{mode, inverted, counter}`, `Project::mask_vertices_px` (vertices in layer pixels), `Layer::mask_atoms` (`MaskAtom`: the mask with its atom group, its path property and its shapes by position, for the keys and expressions of the mask properties). Tests: +`Mask::{mode, inverted, counter}`, `ShapePath::vertices(size)` (vertices in layer pixels, with the size of the layer's source, `Project::layer_source_size`), `Layer::mask_atoms` (`MaskAtom`: the mask with its atom group, its path property and its shapes by position, for the keys and expressions of the mask properties). Tests: `tests/masks.rs` (with a synthetic check of the handle order), `tests/batch3_objects.rs`. ### 3.9 Shape layers (`tdgp[ADBE Root Vectors Group]`) @@ -758,7 +759,7 @@ groups as on other layers, with these conventions: | What | Path | Stored | Evidence | Conf. | |---|---|---|---|---| -| camera Position, default (auto state) | `ADBE Transform Group/ADBE Position`, `tdb4` 76 and 77 = 1 although no expression exists | `[0, 0, -2666.67]` (x and y are 0 whatever the composition size; z = -zoom). A **one-node** camera reports `[0, 0, -2666.67]` (251). **Correction (S16): a reopened two-node camera reports `[0, 0, -2666.67]` too (256 read back), and After Effects renders it there**: the default two-node camera of 537 above the 3D solid of 486 renders from `[0, 0, -2666.67]` towards the point of interest, not like the composition's default view (537 differs from 486 in 24 % of the grid points, and EffectCraft's import with the stored position matches 537 but for 4 edge points). The `[960, 540, ...]` of the batch-3 notes was not reproduced (535 and 537, created by script, read back the stored value too); `CameraPosition::reported` keeps the old rule and is marked as contradicted | 256, 251, 537 | V | +| camera Position, default (auto state) | `ADBE Transform Group/ADBE Position`, `tdb4` 76 and 77 = 1 although no expression exists | `[0, 0, -2666.67]` (x and y are 0 whatever the composition size; z = -zoom). A **one-node** camera reports `[0, 0, -2666.67]` (251). **Correction (S16): a reopened two-node camera reports `[0, 0, -2666.67]` too (256 read back), and After Effects renders it there**: the default two-node camera of 537 above the 3D solid of 486 renders from `[0, 0, -2666.67]` towards the point of interest, not like the composition's default view (537 differs from 486 in 24 % of the grid points, and EffectCraft's import with the stored position matches 537 but for 4 edge points). The `[960, 540, ...]` of the batch-3 notes was not reproduced (535 and 537, created by script, read back the stored value too); the reader keeps only the stored value (`CameraPosition::stored`) | 256, 251, 537 | V | | camera Position, set by the user | same, `tdb4` 76 and 77 = 0 | the absolute value `[100, 200, -1000]` | 253 | V | | camera point of interest | `ADBE Anchor Point` | `[960, 540, 0]` in **pixels** (not a fraction); the record's `tdsb` is 1 on a two-node and 3 on the one-node camera (not asserted) | 251, 253, 256 | V (value), E (`tdsb`) | | light Position, default | same as the camera | `[0, 0, -1333.33]`, as After Effects reports it (255 read back); the point of interest `[960, 540, 0]` | 254, 255 | V | @@ -774,10 +775,8 @@ groups as on other layers, with these conventions: A point light made by converting a spot light has a different property set (`Orientation`, `Rotate X/Y/Z`, `Cone Angle`, `Cone Feather 2` in the default state) that a spot light lacks (252); whether that -is an artefact of the conversion is untested. API: `Layer::camera_position`, -`CameraPosition::reported`, `Layer::camera_point_of_interest`, `Layer::light_intensity`, -`Layer::{camera_zoom, camera_depth_of_field, camera_focus_distance, camera_aperture, light_color, -light_cone_angle, light_cone_feather}`, `DEFAULT_CAMERA_ZOOM` (2 666.67 is verified for the 1920 px +is an artefact of the conversion is untested. API: `Layer::camera_position`; the camera and light +options are read by the importer's table (`import/camera.rs`); `DEFAULT_CAMERA_ZOOM` (2 666.67 is verified for the 1920 px composition only), `Layer::orientation` (`Orientation`; test `orientation_is_otst_otda` in `tests/batch5.rs`). Render checks against After Effects (20 px grid, 5 184 points): 486 1 point off, 535 2, 536 0, 537 4 (anti-aliased edges); **488 (Orientation `[10, 20, 30]`): 272 points From c862ce0aa0fe8217eebe04ad5c4d6ce1a08bc008 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 8 Nov 2026 08:18:44 +0100 Subject: [PATCH 5/5] aep: the format notes name the 1 x 1 mask scale of text and shape layers (#3281) --- docs/aep-format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/aep-format.md b/docs/aep-format.md index 9640aba0c..584a34330 100644 --- a/docs/aep-format.md +++ b/docs/aep-format.md @@ -631,7 +631,7 @@ a non-zero byte at 112). 328), so mode and inverted are reported per mask. `mkif` byte 8 is the running mask id counter of the **layer** in the session (1, 2 for the two masks of 328; 329 then gets 3; undo does not reset it; a new layer starts at 1 again): exposed as `Mask::counter`, a session counter and not a stable id. API: -`Mask::{mode, inverted, counter}`, `ShapePath::vertices(size)` (vertices in layer pixels, with the size of the layer's source, `Project::layer_source_size`), `Layer::mask_atoms` (`MaskAtom`: the mask with its atom group, its path property and its shapes by position, for the keys and expressions of the mask properties). Tests: +`Mask::{mode, inverted, counter}`, `ShapePath::vertices(size)` (vertices in layer pixels: `size` is the size of the layer's source, `Project::layer_source_size`; a text or shape layer has no source size and its mask boxes are already pixels, so the importer passes 1 x 1), `Layer::mask_atoms` (`MaskAtom`: the mask with its atom group, its path property and its shapes by position, for the keys and expressions of the mask properties). Tests: `tests/masks.rs` (with a synthetic check of the handle order), `tests/batch3_objects.rs`. ### 3.9 Shape layers (`tdgp[ADBE Root Vectors Group]`)