Files
craft-libs/crates/aep/tests/it/container/layer_switches.rs
Dylan M. Taylorandvangel76 4a7024cd69 craft-aep: flat read-only tree index and the Pard decoder (ports of effectcraft#3302 and #3319) (#948)
* craft-aep: a flat read-only tree index, borrowed text documents, fewer allocations

The container part of storytold/effectcraft#3302:

- `TreeIndex`: the chunk tree as one flat array in depth-first order,
  borrowed from the input, for readers that only look. Same chunks counted
  against the same limits and the same errors as `parse`. An `.aepx` is
  indexed by `index_aepx` into one buffer. The typed view reads through
  either tree (`Node`, `Root`, `Project::from_index`).
- Chunk parser: each list of siblings is counted, then reserved once.
  `.aepx`: hex decoded into an output sized once, attribute values without
  an entity borrowed. `write` reserves the exact output size.
- Text documents are parsed into a tree that borrows from the body; the
  style and paragraph defaults are built once.
- Layer markers and Source Text are read where they are stored instead of
  searched for.
- Container accessors that only tests used are removed.

What the importer and exporter in EffectCraft reach across the crate
boundary is public here: `Root`, `Draft`, `AepxDrafts`, `index_aepx`,
`TreeIndex::from_drafts`, `Project::of`, `ProjectSettings::of`,
`Node::marker_list`, `Root::file_version` and `saved_by_version`,
`StyleRun::SAMPLED`. `TimeBase::NTSC` holds the rates as plain
numerator and denominator instead of an EffectCraft type.

* craft-aep: one decoder for an effect parameter definition (`Pard`)

The container part of storytold/effectcraft#3319: the fields of a `pard`
record (type code, name, default, valid range, slider range, popup item
count) are read by one type, `Pard`, which `EffectParam` now reads
through. It is public so that an exporter's tests can read back what
they encode instead of keeping a second decoder.

The rest of that PR (import and export bounds, loss reports) is in
EffectCraft's importer and exporter.

---------

Co-authored-by: vangel76 <vangel1976@googlemail.com>
2026-11-11 19:01:12 -05:00

217 lines
10 KiB
Rust

//! Layer switches, blend modes, composition frame rates and background, cameras, lights and solid
//! 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 craft_aep::{BlendMode, CompHeader, LayerHeader, LayerType, LightType, Project};
fn l_header(name: &str) -> Option<LayerHeader> {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
let project = Project::new(&file).unwrap();
base_layer(&first_comp(&project)).header().unwrap()
})
}
fn comp_header(name: &str) -> Option<CompHeader> {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
let project = Project::new(&file).unwrap();
first_comp(&project).header().unwrap()
})
}
#[test]
fn video_audio_and_effects_switches_are_bits_1_2_and_4_of_byte_39() {
// The base has all three on (byte 39 = 07); 222 turns the video switch off (06), 223 the audio switch
// (05), 224 the effects switch (03). Each case changes only that byte.
for (name, byte, video, audio, effects) in [
("000-base.aep", 0x07, true, true, true),
("222-layer-video-off.aep", 0x06, false, true, true),
("223-layer-audio-off.aep", 0x05, true, false, true),
("224-layer-effects-off.aep", 0x03, true, true, false),
] {
let h = skip!(l_header(name));
assert_eq!(h.flag_bytes[2], byte, "{name}");
assert_eq!((h.is_video_enabled(), h.is_audio_enabled(), h.is_effects_enabled()), (video, audio, effects), "{name}");
assert_eq!(h.is_enabled(), video, "{name}");
// The other switches stay as in the base.
assert!(!h.is_shy() && !h.is_locked() && !h.is_motion_blur() && !h.collapses_transformations(), "{name}");
}
let bytes = fixture!("222-layer-video-off.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let layer = base_layer(&first_comp(&project));
assert_eq!((layer.is_enabled(), layer.is_audio_enabled(), layer.is_effects_enabled()), (Some(false), Some(true), Some(true)));
// Only byte 39 of ldta differs from the base layer (besides the layer id, bytes 0-3: the two
// are layers of one combined file).
let base_bytes = fixture!("000-base.aep");
let base_file = open(&base_bytes);
let base_project = Project::new(&base_file).unwrap();
let base_ldta = base_layer(&first_comp(&base_project)).chunk().child(b"ldta").unwrap().bytes().unwrap().to_vec();
let ldta = layer.chunk().child(b"ldta").unwrap().bytes().unwrap();
let differing: Vec<_> = (4..ldta.len()).filter(|i| ldta[*i] != base_ldta[*i]).collect();
assert_eq!(differing, [39]);
}
#[test]
fn blend_mode_codes() {
// Normal 2 (base), Multiply 5 (016), Screen 6 (225), Add 4 (226), Overlay 7 (227), Difference 26 (228).
for (name, mode, code) in [
("000-base.aep", BlendMode::Normal, 2),
("016-layer-blend-multiply.aep", BlendMode::Multiply, 5),
("225-layer-blend-screen.aep", BlendMode::Screen, 6),
("226-layer-blend-add.aep", BlendMode::Add, 4),
("227-layer-blend-overlay.aep", BlendMode::Overlay, 7),
("228-layer-blend-difference.aep", BlendMode::Difference, 26),
] {
let h = skip!(l_header(name));
assert_eq!((h.blend_mode, h.blend_mode.code()), (mode, code), "{name}");
assert_eq!(BlendMode::from_code(code), mode);
}
// A mode that was not sampled keeps its raw code.
assert_eq!(BlendMode::from_code(30), BlendMode::Other(30));
assert_eq!(BlendMode::Other(30).code(), 30);
}
#[test]
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!((h.frame_rate().unwrap() - 29.97).abs() < 1e-12);
// 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!((h.frame_rate().unwrap() - 23.976).abs() < 1e-12);
// 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.time_base.timescale, h.time_base.frame_duration, h.duration_units), (25600, 1024, 128_000));
}
#[test]
fn keyframe_times_follow_the_ntsc_timescale() {
// All keyframe time scales in the composition follow its timescale (tdb4 byte 12 = 23976 in every property).
for name in ["229-comp-fps-29.97.aep", "230-comp-fps-23.976.aep"] {
let bytes = fixture!(name);
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let layer = base_layer(&first_comp(&project));
let mut scales = std::collections::BTreeSet::new();
let mut stack = vec![layer.properties().unwrap()];
while let Some(group) = stack.pop() {
for e in group.entries() {
match e.node {
craft_aep::PropNode::Group(g) => stack.push(g),
craft_aep::PropNode::Property(p) => {
scales.extend(p.time_scale());
}
craft_aep::PropNode::Other(_) => {}
}
}
}
assert_eq!(scales.into_iter().collect::<Vec<_>>(), [23976], "{name}");
}
}
#[test]
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]);
// 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));
}
#[test]
fn cameras_auto_orient_and_position() {
// 256: the default two-node camera (ldta byte 38 = 0x44); 251: one-node (autoOrient off, 0x04).
let bytes = fixture!("256-camera-two-node-default.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let comp = first_comp(&project);
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].
let pos = camera.camera_position().unwrap();
assert!(pos.auto_state);
assert_near(&pos.stored, &[0.0, 0.0, -2666.67], 0.01);
let bytes = fixture!("251-camera-one-node.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let camera = layer_of_type(&first_comp(&project), LayerType::Camera);
let h = camera.header().unwrap();
assert_eq!((h.flag_bytes[1], h.is_auto_oriented(), h.is_three_d()), (0x04, false, true));
// 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.stored, &[0.0, 0.0, -2666.67], 0.01);
// 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();
let camera = layer_of_type(&first_comp(&project), LayerType::Camera);
let pos = camera.camera_position().unwrap();
assert!(!pos.auto_state);
assert_eq!(pos.stored, [100.0, 200.0, -1000.0]);
}
/// Lights type and position.
#[test]
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));
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).
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));
// 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));
}
#[test]
fn solid_colour_is_four_float32_argb() {
// 259: a new solid `Blue` [0, 0, 1] in the 1920x1080 composition: float32 ARGB 1.0, 0, 0, 1.0.
let bytes = fixture!("259-solid-blue-0-0-1.aep");
let file = open(&bytes);
let project = Project::new(&file).unwrap();
let comp = first_comp(&project);
let blue = layer_shown_as(&project, &comp, "Blue");
assert_eq!(project.layer_source_size(&blue), Some((1920, 1080)));
let f = project.layer_source(&blue).unwrap().footage().unwrap();
assert!(f.is_solid);
assert_eq!(f.solid_argb, Some([1.0, 0.0, 0.0, 1.0]));
assert_eq!(f.solid_rgb(), Some([0.0, 0.0, 1.0]));
// 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 name of its own (it shows its source's name).
assert_eq!(blue.name(), None);
}