Files
craft-libs/crates/aep/tests/it/container/cases_600.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

190 lines
8.8 KiB
Rust

//! Cases of the 600 series (sessions X1 / X2): a renamed effect instance, a composition that shows
//! two precompositions, image sequences, project settings, a conformed clip and more still types.
//! Every expected value is the one the case was made with (its MANIFEST row).
use crate::container::helpers::open;
use craft_aep::Project;
#[test]
fn a_renamed_effect_keeps_its_name_in_the_value_group() {
let bytes = fixture!("610-fx-tint-named-My-Tint.aep");
let file = open(&bytes);
let project = Project::new(&file).expect("project");
let comp = project.all_items().into_iter().find_map(|i| i.comp()).expect("comp");
let layer = comp.layers().next().expect("layer");
let fx = layer.effects();
assert_eq!(fx.len(), 1);
assert_eq!(fx[0].match_name(), "ADBE Tint");
// fnam keeps the effect's own name, the instance name is the value group's tdsn.
assert_eq!(fx[0].name().as_deref(), Some("Tint"));
assert_eq!(fx[0].user_name().as_deref(), Some("My Tint"));
assert_eq!(fx[0].display_name().as_deref(), Some("My Tint"));
// An unnamed instance (356) shows the effect's name.
let bytes = fixture!("356-fx-tint-default.aep");
let file = open(&bytes);
let project = Project::new(&file).expect("project");
let comp = project.all_items().into_iter().find_map(|i| i.comp()).expect("comp");
let fx = comp.layers().next().expect("layer").effects();
assert_eq!(fx[0].user_name(), None);
assert_eq!(fx[0].display_name().as_deref(), Some("Tint"));
}
#[test]
fn a_composition_counts_the_compositions_it_shows() {
let bytes = fixture!("601-precomp-two-comps-in-C.aep");
let file = open(&bytes);
let project = Project::new(&file).expect("project");
let items = project.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 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 = crate::common::used_item_ids(c);
used.sort();
let mut want = vec![id("P30"), id("Q")];
want.sort();
assert_eq!(used, want);
let idpc = c.chunk().child(b"idpc").and_then(|x| x.bytes()).expect("idpc");
assert_eq!(&idpc[..4], &2u32.to_le_bytes());
// The precomposition layers: P30 640x360 30 fps 3 s, Q 320x240 25 fps 2 s.
let comp = c.comp().expect("comp");
let sizes: Vec<_> = comp.layers().filter_map(|l| project.layer_source_size(&l)).collect();
assert_eq!(sizes, vec![(320, 240), (640, 360), (1920, 1080)]);
}
#[test]
fn an_image_sequence_knows_its_numbering() {
// 603: shot_0101.exr .. shot_0103.exr imported as a sequence (64x48, 3 frames at 30 fps).
let bytes = fixture!("603-footage-exrseq-0101-as-layer.aep");
let file = open(&bytes);
let project = Project::new(&file).expect("project");
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));
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).
let bytes = fixture!("126-footage-png-layer.aep");
let file = open(&bytes);
let project = Project::new(&file).expect("project");
assert!(project.all_items().iter().all(|i| i.sequence_info().is_none()));
}
#[test]
fn project_settings_are_read_from_the_header_chunks() {
use craft_aep::{ProjectSettings, TimeDisplay, WorkingSpace};
let read = |name: &str| {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
ProjectSettings::read(&file)
})
};
// The base (620 = 000): 32 bpc, sRGB, gamma 2.2, both linear switches off, compensation on,
// timecode, frames from 0.
let base = skip!(read("000-base.aep"));
assert_eq!(
base,
ProjectSettings {
bits_per_channel: Some(32),
working_space: Some(WorkingSpace::Profile("sRGB IEC61966-2.1".into())),
working_gamma_24: Some(false),
linearize: false,
blend_linear: false,
scene_compensation: Some(true),
time_display: Some(TimeDisplay::Timecode),
frame_start: Some(0),
}
);
// A project has one value per setting, so the settings cases (MANIFEST 621-634) share three
// files: each holds the base with the changes of all its cases (tests/fixtures/CASES.md).
let a = ProjectSettings {
bits_per_channel: Some(8),
working_space: Some(WorkingSpace::None),
working_gamma_24: Some(true),
scene_compensation: Some(false),
time_display: Some(TimeDisplay::Frames),
frame_start: Some(1),
..base.clone()
};
let b =
ProjectSettings { linearize: true, blend_linear: true, time_display: Some(TimeDisplay::FeetFrames { frames_per_foot: 16 }), ..base.clone() };
// The file of 622, 634 and 902 is Rec.709: After Effects 26.2.1 stored the profile without its
// name, so the name is the profile's own description.
let c = ProjectSettings {
bits_per_channel: Some(16),
working_space: Some(WorkingSpace::Profile("Rec.709 Gamma 2.4".into())),
time_display: Some(TimeDisplay::FeetFrames { frames_per_foot: 40 }),
..base.clone()
};
for (names, want) in [
(
&[
"621-settings-bpc-8.aep",
"623-settings-workingspace-none.aep",
"625-settings-gamma-2.4.aep",
"628-settings-scene-compensation-off.aep",
"629-settings-time-display-frames.aep",
"630-settings-frame-start-1.aep",
][..],
a,
),
(&["632-settings-linearize-and-blend-linear.aep", "633-settings-feet-frames-35.aep"][..], b),
(&["622-settings-bpc-16.aep", "634-settings-feet-frames-16.aep", "902-settings-rec709-16bpc-steps-png-grey-solid.aep"][..], c),
] {
for name in names {
assert_eq!(skip!(read(name)), want, "{name}");
}
} // nnhd repeats the depth at byte 24.
for (name, v) in [("621-settings-bpc-8.aep", 0u8), ("622-settings-bpc-16.aep", 1), ("000-base.aep", 2)] {
let bytes = fixture!(name);
let file = open(&bytes);
assert_eq!(file.chunk(b"nnhd").and_then(|c| c.bytes()).and_then(|b| b.get(24).copied()), Some(v), "{name}");
}
}
#[test]
fn a_conformed_clip_keeps_its_native_rate_and_frames() {
// 645: the 25 fps, 25-frame mp4 of 606 conformed to 30 fps; 644 the same file unconformed.
let read = |name: &str| {
Some({
let bytes = crate::common::try_fixture(name)?;
let file = open(&bytes);
let project = Project::new(&file).expect("project");
let item = project.all_items().into_iter().find(|i| i.footage().is_some_and(|f| !f.is_solid)).expect("clip");
let f = item.footage().expect("footage");
(f.native_frame_rate, item.conform_frame_rate(), f.duration.map(|d| (d.num, d.den)))
})
};
assert_eq!(skip!(read("645-footage-mp4-conform-30.aep")), (25, Some(30), Some((25, 25))));
// Not conformed (the exr sequence 603, the stills of 650-653).
assert_eq!(skip!(read("603-footage-exrseq-0101-as-layer.aep")).1, None);
for name in
["650-footage-tga-still-as-layer.aep", "651-footage-psd-flat-as-layer.aep", "652-footage-ai-as-layer.aep", "653-footage-eps-as-layer.aep"]
{
assert_eq!(skip!(read(name)).1, None, "{name}");
}
}
#[test]
fn still_footage_of_four_more_types() {
// 650-653: a 64 x 48 tga, a flat psd, an ai and an eps imported as footage (MANIFEST); ai and
// eps share the type tag `TEXT`.
for (name, tag) in [
("650-footage-tga-still-as-layer.aep", "TPIC"),
("651-footage-psd-flat-as-layer.aep", "8BPS"),
("652-footage-ai-as-layer.aep", "TEXT"),
("653-footage-eps-as-layer.aep", "TEXT"),
] {
let bytes = fixture!(name);
let file = open(&bytes);
let project = Project::new(&file).expect("project");
let f = project.all_items().into_iter().find_map(|i| i.footage().filter(|f| !f.is_solid)).expect(name);
assert_eq!((f.width, f.height, f.native_frame_rate, f.is_sequence), (64, 48, 0, false), "{name}");
assert_eq!(f.file_type, tag, "{name}");
let ext = name.get(12..).and_then(|r| r.split('-').next()).unwrap_or_default();
assert!(f.path.as_deref().is_some_and(|p| p.ends_with(&format!("still.{ext}"))), "{name}: {:?}", f.path);
}
}