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<C>) 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.
This commit is contained in:
vangel76
2026-11-08 04:04:49 +01:00
parent ae35ea7d54
commit 9e2461414a
31 changed files with 392 additions and 599 deletions
-89
View File
@@ -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<CameraPosition> {
@@ -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<Tracked<f64>> {
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<crate::container::typed::PropGroup<'f, 'a>> {
match self.properties()?.get("ADBE Camera Options Group") {
Some(PropNode::Group(g)) => Some(g),
_ => None,
}
}
fn light_options(&self) -> Option<crate::container::typed::PropGroup<'f, 'a>> {
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<Tracked<f64>> {
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<Tracked<bool>> {
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<Tracked<f64>> {
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<Tracked<f64>> {
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<Tracked<[f64; 4]>> {
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<Tracked<f64>> {
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<Tracked<f64>> {
let t = read(&self.light_options()?, "ADBE Light Cone Feather 2", [50.0], 1.0);
Some(Tracked { value: t.value[0], source: t.source })
}
}
-26
View File
@@ -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<u32> {
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<u32> {
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<Footage> {
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<String> {
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)> {
-81
View File
@@ -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<f64> {
self.start.to_f64()
}
/// The absolute in point, `start + in * stretch`, exact and reduced.
pub fn in_point(&self) -> Option<Rational> {
self.start.plus_scaled(&self.in_relative, &self.stretch_ratio()?)
@@ -355,16 +338,6 @@ impl LayerHeader {
pub fn out_point(&self) -> Option<Rational> {
self.start.plus_scaled(&self.out_relative, &self.stretch_ratio()?)
}
/// The absolute in point in seconds.
pub fn in_point_seconds(&self) -> Option<f64> {
self.in_point()?.to_f64()
}
/// The absolute out point in seconds.
pub fn out_point_seconds(&self) -> Option<f64> {
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<f64> {
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<f64> {
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<f64> {
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<f64> {
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<f64> {
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<i64> {
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> {
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<u32> {
self.duration_units.checked_div(self.time_base.frame_duration)
}
}
impl<'f, 'a> Comp<'f, 'a> {
+1 -40
View File
@@ -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<KeyEase> {
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<Vec<f64>> {
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<f64> {
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<SpatialKey> {
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<bool> {
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<String> {
+1 -11
View File
@@ -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<Vec<Vec<PathVertex>>> {
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.
+1 -10
View File
@@ -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])
}
}
-4
View File
@@ -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)
+1 -8
View File
@@ -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> {
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<u16> {
be_u16(self.chunk(b"head")?.bytes()?, 0)
}
}
#[cfg(test)]
+60 -19
View File
@@ -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<String>,
}
/// One category of things that were not exported: the import report's loss entry with the
/// export categories.
pub type Loss = crate::import::Loss<LossCategory>;
/// 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::<Vec<_>>().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<String>) {
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);
}
}
+41 -29
View File
@@ -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<f32> {
[(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<f32> {
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<TimeBase> {
}
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)));
}
}
+99 -11
View File
@@ -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<C = LossCategory> {
pub category: C,
/// How many occurrences (exact).
pub count: usize,
/// Up to [`MAX_DETAILS`] example lines.
pub details: Vec<String>,
}
/// The loss bookkeeping the import and the export report share.
impl<C: Copy + Ord> Loss<C> {
/// 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<C, Self>, 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::<Vec<_>>().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::<Vec<_>>().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<String>) {
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);
}
}
+9 -6
View File
@@ -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<FrameRat
}
let exact = FrameRate::new(i64::from(timescale), i64::from(frame_duration));
let fps = exact.as_f64();
for ntsc in [FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate::FPS_59_94, FrameRate::FPS_119_88] {
if (ntsc.as_f64() - fps).abs() < 0.0005 {
return Some(ntsc);
}
}
Some(exact)
let ntsc = TimeBase::NTSC.into_iter().map(|(rate, _)| rate).find(|ntsc| (ntsc.as_f64() - fps).abs() < 0.0005);
Some(ntsc.unwrap_or(exact))
}
impl TimeMap {
@@ -83,6 +81,11 @@ 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]
+7
View File
@@ -128,6 +128,13 @@ pub fn resolve(name: &str) -> Option<PathBuf> {
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<u32> {
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<Vec<u8>> {
match locate(name)? {
+12 -18
View File
@@ -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();
+18 -78
View File
@@ -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::<Vec<_>>();
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<effectcraft_aep::Tracked<f64>>| 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}");
+2 -2
View File
@@ -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).
+19 -38
View File
@@ -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)));
}
+2 -1
View File
@@ -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.
+7 -4
View File
@@ -19,6 +19,9 @@ fn prop_of<R>(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::<Vec<_>>(), [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<_>>(), [vec![0.0, 0.0], vec![40.0, 20.0]]);
assert_eq!(keys.iter().map(|k| k.seconds(25600).unwrap()).collect::<Vec<_>>(), [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]);
}
+18 -28
View File
@@ -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<LayerHeader> {
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);
}
+1 -1
View File
@@ -13,7 +13,7 @@ fn layers(name: &str) -> Option<Vec<(String, bool, Option<TrackMatte>, 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()
})
+25 -58
View File
@@ -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::<usize>());
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);
}
@@ -13,7 +13,6 @@ fn text_layer_document(name: &str) -> Option<TextDocument> {
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);
}
+1 -1
View File
@@ -186,7 +186,7 @@ fn position_keyframes() {
assert_eq!(times, [0, 25_600, 64_000]);
let seconds: Vec<f64> = 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<Vec<f64>> = 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]]);
}
+3 -6
View File
@@ -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));
}));
}
+1 -2
View File
@@ -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<u32> = h.iter().map(|x| x.id).collect();
+3 -2
View File
@@ -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))) {
+38 -13
View File
@@ -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<effectcraft_project::Layer> {
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);
+3 -3
View File
@@ -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::<u32>::new());
assert_eq!(crate::common::used_item_ids(&pre), Vec::<u32>::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")];
+18 -6
View File
@@ -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 }));
+1 -2
View File
@@ -169,13 +169,12 @@ fn copies_saved_as_25x_and_24x_import_like_the_2026_file() {
#[ignore = "needs the version fixtures of the corpus (<family>-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}");
}
}
}