mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 08:52:32 +00:00
* 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>
190 lines
8.8 KiB
Rust
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);
|
|
}
|
|
}
|