mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-14 22:33:09 +00:00
Import reads an .aep through TreeIndex, one depth-first array of chunks that borrows the file's bytes, instead of building and freeing the owned Chunk tree (one Vec per list). The parser mirrors read::Parser step for step: the same chunk count order against max_chunks, the same depth limit and opaque fallbacks, the same errors. The typed view reads through Node, a handle to a chunk of either tree, so the accessors work unchanged on both; .aepx keeps its owned tree. Chunk, parse and write stay for export and editing. The array starts small and doubles, never past what the input can hold or the chunk limit allows; a failed allocation is TooLarge, a tree past u32 indices TooManyChunks.
919 lines
46 KiB
Rust
919 lines
46 KiB
Rust
//! Track mattes, markers, cameras, lights and shape layers: written from projects built in code
|
|
//! and read back with `effectcraft-aep`.
|
|
|
|
use crate::export::helpers::*;
|
|
use effectcraft_aep::export::LossCategory;
|
|
use effectcraft_aep::parse;
|
|
use effectcraft_project::{LayerSource, MatteKind, Project, TrackMatte};
|
|
use effectcraft_time::FrameRate;
|
|
|
|
/// `ldta` of every layer of the first composition, top first.
|
|
fn ldtas(bytes: &[u8]) -> Vec<Vec<u8>> {
|
|
let file = parse(bytes).expect("parse");
|
|
let view = effectcraft_aep::Project::new(&file).expect("project");
|
|
let comp = view.all_items().into_iter().find_map(|i| i.comp()).expect("comp");
|
|
comp.layers().map(|l| l.chunk().child(b"ldta").and_then(|c| c.bytes()).expect("ldta").to_vec()).collect()
|
|
}
|
|
|
|
fn u32_at(b: &[u8], at: usize) -> u32 {
|
|
u32::from_be_bytes(b[at..at + 4].try_into().expect("4"))
|
|
}
|
|
|
|
fn two_solids() -> (Project, effectcraft_project::ItemId) {
|
|
let mut p = Project::default();
|
|
let c = add_comp(&mut p, "C", 640, 360, FrameRate::new(25, 1), 2.0, None);
|
|
let s = add_solid(&mut p, "S", 640, 360, [1.0, 0.0, 0.0], None);
|
|
add_layer(&mut p, c, "Target", LayerSource::Solid { item: s }, (640, 360));
|
|
add_layer(&mut p, c, "Matte", LayerSource::Solid { item: s }, (640, 360));
|
|
(p, c)
|
|
}
|
|
|
|
#[test]
|
|
fn track_mattes_are_written_with_mode_and_layer() {
|
|
// 403-406: the mode at ldta@107 (1 alpha, 2 alpha inverted, 3 luma, 4 luma inverted) and
|
|
// the matte layer's id at ldta@160, on the target layer.
|
|
for (kind, mode) in [(MatteKind::Alpha, 1u8), (MatteKind::AlphaInverted, 2), (MatteKind::Luma, 3), (MatteKind::LumaInverted, 4)] {
|
|
let (mut p, c) = two_solids();
|
|
let matte_id = p.comp(c).expect("c").layers[0].id;
|
|
with_layer(&mut p, c, 1, |l| l.track_matte = Some(TrackMatte { layer: matte_id, kind }));
|
|
let r = export_all(&p);
|
|
assert!(r.report.is_lossless(), "{:?}", r.report.losses);
|
|
let l = ldtas(&r.bytes);
|
|
// Top: Matte, then Target.
|
|
assert_eq!((l[0][107], u32_at(&l[0], 160)), (0, 0));
|
|
assert_eq!(l[1][107], mode);
|
|
assert_eq!(u32_at(&l[1], 160), u32_at(&l[0], 0), "the matte layer's id");
|
|
}
|
|
// A matte below the target (407) is written as well; one that is not exported is reported.
|
|
let (mut p, c) = two_solids();
|
|
let target = p.comp(c).expect("c").layers[1].id;
|
|
with_layer(&mut p, c, 0, |l| l.track_matte = Some(TrackMatte { layer: target, kind: MatteKind::Alpha }));
|
|
let l = ldtas(&export_all(&p).bytes);
|
|
assert_eq!((l[0][107], u32_at(&l[0], 160)), (1, u32_at(&l[1], 0)));
|
|
let (mut p, c) = two_solids();
|
|
with_layer(&mut p, c, 1, |l| l.track_matte = Some(TrackMatte { layer: effectcraft_project::LayerId(987_654), kind: MatteKind::Luma }));
|
|
let r = export_all(&p);
|
|
assert_eq!(r.report.count(LossCategory::TrackMattes), 1);
|
|
assert_eq!(ldtas(&r.bytes)[1][107], 0);
|
|
}
|
|
|
|
fn marker(t: f64, comment: &str) -> effectcraft_project::Marker {
|
|
effectcraft_project::Marker { time: secs(t), comment: comment.into(), ..Default::default() }
|
|
}
|
|
|
|
#[test]
|
|
fn markers_are_written_like_after_effects_stores_them() {
|
|
let (mut p, c) = two_solids();
|
|
with_comp(&mut p, c, |cc| {
|
|
cc.markers = vec![
|
|
effectcraft_project::Marker { label: effectcraft_color::Label::Yellow, ..marker(1.0, "M1") },
|
|
effectcraft_project::Marker { duration: secs(0.5), ..marker(2.5, "M2") },
|
|
];
|
|
});
|
|
with_layer(&mut p, c, 0, |l| {
|
|
l.markers = vec![
|
|
effectcraft_project::Marker {
|
|
duration: secs(1.0),
|
|
chapter: "Chap".into(),
|
|
url: "http://a.example/u".into(),
|
|
frame_target: "_blank".into(),
|
|
cue_point: Some(effectcraft_project::CuePoint {
|
|
name: "CueX".into(),
|
|
navigation: false,
|
|
params: vec![("speaker".into(), "A".into()), ("n".into(), "2".into())],
|
|
}),
|
|
..marker(1.0, "LM")
|
|
},
|
|
effectcraft_project::Marker {
|
|
cue_point: Some(effectcraft_project::CuePoint { name: "CueN".into(), navigation: true, params: Vec::new() }),
|
|
..marker(0.52, "Nav")
|
|
},
|
|
];
|
|
});
|
|
let r = export_all(&p);
|
|
assert!(r.report.is_lossless(), "{:?}", r.report.losses);
|
|
assert_eq!(r.report.exported.markers, 4);
|
|
let file = parse(&r.bytes).expect("parse");
|
|
assert_eq!(effectcraft_aep::write(&file).expect("write"), r.bytes);
|
|
let view = effectcraft_aep::Project::new(&file).expect("project");
|
|
let comp = view.all_items().into_iter().find_map(|i| i.comp()).expect("comp");
|
|
let cm = comp.markers();
|
|
assert_eq!(
|
|
cm.iter().map(|m| (m.time_units, m.comment.as_str(), m.duration_seconds, m.label)).collect::<Vec<_>>(),
|
|
vec![
|
|
(25_600, "M1", Some(0.0), Some(effectcraft_color::Label::ALL.iter().position(|l| *l == effectcraft_color::Label::Yellow).expect("label") as u8)),
|
|
// EffectCraft's default marker label is Red, index 1 (After Effects' own default is 0, None).
|
|
(64_000, "M2", Some(0.5), Some(1)),
|
|
]
|
|
);
|
|
let layer = comp.layers().next().expect("layer");
|
|
let lm = layer.markers();
|
|
assert_eq!(lm.len(), 2);
|
|
// In time order: Nav at 0.52 s (13 312 units, 510), LM at 1 s.
|
|
assert_eq!(
|
|
(lm[0].time_units, lm[0].comment.as_str(), lm[0].cue_type, lm[0].cue_point_name.as_str()),
|
|
(13_312, "Nav", Some(effectcraft_aep::CueType::Navigation), "CueN")
|
|
);
|
|
let m = &lm[1];
|
|
assert_eq!(
|
|
(m.time_units, m.comment.as_str(), m.chapter.as_str(), m.url.as_str(), m.frame_target.as_str(), m.cue_point_name.as_str()),
|
|
(25_600, "LM", "Chap", "http://a.example/u", "_blank", "CueX")
|
|
);
|
|
assert_eq!((m.duration_seconds, m.cue_type), (Some(1.0), Some(effectcraft_aep::CueType::Event)));
|
|
assert_eq!(m.cue_params, vec![("speaker".to_string(), "A".to_string()), ("n".to_string(), "2".to_string())]);
|
|
// A protected marker is reported (the switch is not decoded).
|
|
with_layer(&mut p, c, 1, |l| l.markers = vec![effectcraft_project::Marker { protected: true, ..marker(0.2, "P") }]);
|
|
assert_eq!(export_all(&p).report.count(LossCategory::Markers), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn marker_fixtures_are_written_as_after_effects_wrote_them() {
|
|
// 122 (composition), 123, 249, 347-349 (layer): the reader sees the same markers after an
|
|
// import of the fixture, its markers put back from the reader's view (the importer does not
|
|
// read markers yet), and an export.
|
|
for name in [
|
|
"122-marker-comp-1s.aep",
|
|
"123-marker-layer-1s.aep",
|
|
"249-marker-layer-full.aep",
|
|
"347-marker-layer-label3.aep",
|
|
"348-marker-layer-navigation-cue.aep",
|
|
"349-marker-layer-event-cue-params.aep",
|
|
] {
|
|
let bytes = fixture!(name);
|
|
let file = parse(&bytes).expect("parse");
|
|
let view = effectcraft_aep::Project::new(&file).expect("project");
|
|
let comp = view.all_items().into_iter().find_map(|i| i.comp()).expect("comp");
|
|
let to_ec = |m: &effectcraft_aep::Marker| effectcraft_project::Marker {
|
|
time: secs(m.seconds.unwrap_or(0.0)),
|
|
duration: secs(m.duration_seconds.unwrap_or(0.0)),
|
|
comment: m.comment.clone(),
|
|
label: effectcraft_color::Label::ALL.get(usize::from(m.label.unwrap_or(0))).copied().unwrap_or_default(),
|
|
chapter: m.chapter.clone(),
|
|
url: m.url.clone(),
|
|
frame_target: m.frame_target.clone(),
|
|
cue_point: (!m.cue_point_name.is_empty() || m.cue_type == Some(effectcraft_aep::CueType::Navigation) || !m.cue_params.is_empty()).then(|| {
|
|
effectcraft_project::CuePoint {
|
|
name: m.cue_point_name.clone(),
|
|
navigation: m.cue_type == Some(effectcraft_aep::CueType::Navigation),
|
|
params: m.cue_params.clone(),
|
|
}
|
|
}),
|
|
protected: false,
|
|
};
|
|
let comp_markers: Vec<_> = comp.markers().iter().map(to_ec).collect();
|
|
let layer_markers: Vec<_> = comp.layers().next().map(|l| l.markers().iter().map(to_ec).collect()).unwrap_or_default();
|
|
let mut p = Project::default();
|
|
let none = |_: &str| false;
|
|
effectcraft_aep::import::import(
|
|
&mut p,
|
|
&bytes,
|
|
&effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&none) },
|
|
)
|
|
.expect(name);
|
|
let cid = p.comps().next().map(|(id, _)| *id).expect("comp");
|
|
with_comp(&mut p, cid, |c| c.markers = comp_markers);
|
|
with_layer(&mut p, cid, 0, |l| l.markers = layer_markers);
|
|
let r = export_all(&p);
|
|
let out = parse(&r.bytes).expect("parse");
|
|
let oview = effectcraft_aep::Project::new(&out).expect("project");
|
|
let ocomp = oview.all_items().into_iter().find_map(|i| i.comp()).expect("comp");
|
|
assert_eq!(ocomp.markers(), comp.markers(), "{name}");
|
|
assert_eq!(ocomp.layers().next().map(|l| l.markers()), comp.layers().next().map(|l| l.markers()), "{name}");
|
|
}
|
|
}
|
|
|
|
fn with_props(p: &mut Project, c: effectcraft_project::ItemId, idx: usize, set: &[(&str, effectcraft_keyframe::Value)]) {
|
|
with_layer(p, c, idx, |l| {
|
|
for (path, v) in set {
|
|
l.props.prop_mut(path).unwrap_or_else(|| panic!("{path}")).value = v.clone();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 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);
|
|
let view = effectcraft_aep::Project::new(&file).expect("project");
|
|
let comp = view.all_items().into_iter().find_map(|i| i.comp()).expect("comp");
|
|
for l in comp.layers() {
|
|
f(&l);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cameras_are_written_with_position_point_of_interest_and_options() {
|
|
use effectcraft_keyframe::Value;
|
|
let mut p = Project::default();
|
|
let c = add_comp(&mut p, "C", 1920, 1080, FrameRate::new(25, 1), 2.0, None);
|
|
add_layer(&mut p, c, "Cam", LayerSource::Camera, (1920, 1080));
|
|
with_props(
|
|
&mut p,
|
|
c,
|
|
0,
|
|
&[
|
|
("transform/position", Value::Vec3([100.0, 200.0, -1000.0])),
|
|
("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)),
|
|
("cameraOptions/blurLevel", Value::Scalar(80.0)),
|
|
],
|
|
);
|
|
let r = export_all(&p);
|
|
assert!(r.report.is_lossless(), "{:?}", r.report.losses);
|
|
assert_eq!(r.report.exported.cameras, 1);
|
|
first_comp_layers(&r.bytes, |l| {
|
|
let h = l.header().expect("header");
|
|
assert_eq!(h.layer_type, effectcraft_aep::LayerType::Camera);
|
|
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));
|
|
});
|
|
// 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()));
|
|
}
|
|
|
|
#[test]
|
|
fn spot_and_point_lights_are_written_with_their_options() {
|
|
use effectcraft_keyframe::Value;
|
|
use effectcraft_project::LightKind;
|
|
let mut p = Project::default();
|
|
let c = add_comp(&mut p, "C", 1920, 1080, FrameRate::new(25, 1), 2.0, None);
|
|
add_layer(&mut p, c, "Spot", LayerSource::Light { kind: LightKind::Spot }, (1920, 1080));
|
|
add_layer(&mut p, c, "Point", LayerSource::Light { kind: LightKind::Point }, (1920, 1080));
|
|
// Layer 0 is the point light (added last, on top), 1 the spot.
|
|
with_props(
|
|
&mut p,
|
|
c,
|
|
1,
|
|
&[
|
|
("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/position", Value::Vec3([100.0, 50.0, -500.0])),
|
|
],
|
|
);
|
|
let r = export_all(&p);
|
|
assert!(r.report.is_lossless(), "{:?}", r.report.losses);
|
|
assert_eq!(r.report.exported.lights, 2);
|
|
let mut seen = Vec::new();
|
|
first_comp_layers(&r.bytes, |l| {
|
|
let h = l.header().expect("header");
|
|
assert_eq!(h.layer_type, effectcraft_aep::LayerType::Light);
|
|
seen.push(h.light_type());
|
|
match h.light_type() {
|
|
Some(effectcraft_aep::LightType::Spot) => {
|
|
assert_eq!(l.camera_position().map(|p| p.stored), Some([100.0, 50.0, -500.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);
|
|
add_layer(&mut q, c, "Par", LayerSource::Light { kind: LightKind::Parallel }, (640, 360));
|
|
add_layer(&mut q, c, "Spot", LayerSource::Light { kind: LightKind::Spot }, (640, 360));
|
|
with_props(&mut q, c, 0, &[("lightOptions/castsShadows", Value::Bool(true))]);
|
|
assert_eq!(export_all(&q).report.count(LossCategory::Lights), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn shape_layers_are_written_with_their_items() {
|
|
use effectcraft_keyframe::{ShapePath, Value};
|
|
use effectcraft_project::build;
|
|
let mut p = Project::default();
|
|
let c = add_comp(&mut p, "C", 1920, 1080, FrameRate::new(25, 1), 2.0, None);
|
|
add_layer(&mut p, c, "S", LayerSource::Shape, (1920, 1080));
|
|
let mut next = p.next_id;
|
|
{
|
|
let mut ids = build::Ids(&mut next);
|
|
let mut rect = build::shape_rect(&mut ids, [400.0, 300.0], [10.0, -20.0], 15.0);
|
|
rect.name = "Rectangle Path 1".into();
|
|
let mut fill = build::shape_fill(&mut ids, [0.0, 1.0, 0.0, 1.0]);
|
|
if let Some(r) = fill.get_mut("rule") {
|
|
r.value = Value::Enum(1);
|
|
}
|
|
if let Some(o) = fill.get_mut("opacity") {
|
|
o.value = Value::Scalar(50.0);
|
|
}
|
|
let mut stroke = build::shape_stroke(&mut ids, [1.0, 0.0, 0.0, 1.0], 6.0);
|
|
if let Some(cap) = stroke.get_mut("cap") {
|
|
cap.value = Value::Enum(1);
|
|
}
|
|
let mut g1 = build::shape_group(&mut ids, "Group 1", vec![rect, stroke, fill]);
|
|
if let Some(t) = g1.sub_mut("transform") {
|
|
t.get_mut("position").expect("pos").value = Value::Vec2([50.0, 50.0]);
|
|
t.get_mut("scale").expect("scale").value = Value::Vec2([50.0, 80.0]);
|
|
t.get_mut("rotation").expect("rot").value = Value::Scalar(30.0);
|
|
}
|
|
let ellipse = build::shape_ellipse(&mut ids, [500.0, 200.0], [100.0, 50.0]);
|
|
let path = build::shape_path(
|
|
&mut ids,
|
|
ShapePath {
|
|
vertices: vec![[0.0, -100.0], [100.0, 100.0], [-100.0, 100.0]],
|
|
in_tangents: vec![[-10.0, -20.0], [30.0, 20.0], [-60.0, 10.0]],
|
|
out_tangents: vec![[40.0, 0.0], [-20.0, 30.0], [0.0, -50.0]],
|
|
closed: true,
|
|
feather: Vec::new(),
|
|
},
|
|
);
|
|
let fill2 = build::shape_fill(&mut ids, [0.0, 0.0, 1.0, 1.0]);
|
|
let inner = build::shape_group(&mut ids, "Inner", vec![ellipse]);
|
|
let g2 = build::shape_group(&mut ids, "Group 2", vec![inner, path, fill2]);
|
|
let star = build::shape_star(&mut ids, true, 5.0, [0.0, 0.0], 100.0, 50.0);
|
|
with_layer(&mut p, c, 0, |l| {
|
|
let contents = l.props.sub_mut("contents").expect("contents");
|
|
contents.children = vec![g1.into(), g2.into(), star.into()];
|
|
});
|
|
}
|
|
p.next_id = next;
|
|
let r = export_all(&p);
|
|
// Everything is written, the polystar too.
|
|
assert_eq!(r.report.count(LossCategory::ShapeLayers), 0, "{:?}", r.report.losses);
|
|
assert_eq!(r.report.exported.shape_layers, 1);
|
|
first_comp_layers(&r.bytes, |l| {
|
|
assert_eq!(l.header().expect("h").layer_type, effectcraft_aep::LayerType::Shape);
|
|
let groups = l.shape_groups();
|
|
assert_eq!(groups.len(), 2, "{groups:?}");
|
|
let names: Vec<_> = groups.iter().map(|g| g.name.clone()).collect();
|
|
assert_eq!(names, vec![Some("Group 1".to_string()), Some("Group 2".to_string())]);
|
|
let s = format!("{groups:?}");
|
|
// The path box equals the one After Effects stored for the same path (240).
|
|
for want in [
|
|
"400.0, 300.0",
|
|
"[10.0, -20.0]",
|
|
"50.0, 50.0",
|
|
"width: Some(Tracked { value: 6.0",
|
|
"cap: Tracked { value: Round",
|
|
"bbox: [-160.0, -120.0, 130.0, 130.0]",
|
|
] {
|
|
assert!(s.contains(want), "{want} in {s}");
|
|
}
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_objects_never_panic() {
|
|
use effectcraft_keyframe::Value;
|
|
use effectcraft_project::{LightKind, build};
|
|
let mut p = Project::default();
|
|
let c = add_comp(&mut p, "C", 640, 360, FrameRate::new(25, 1), 2.0, None);
|
|
add_layer(&mut p, c, "S", LayerSource::Shape, (640, 360));
|
|
let mut next = p.next_id;
|
|
{
|
|
let mut ids = build::Ids(&mut next);
|
|
// 200 nested groups, a NaN rectangle, an empty path.
|
|
let bad = build::shape_rect(&mut ids, [f64::NAN, 1e300], [0.0, 0.0], f64::INFINITY);
|
|
let mut inner = build::shape_group(&mut ids, "deep", vec![bad]);
|
|
for i in 0..200 {
|
|
inner = build::shape_group(&mut ids, &format!("g{i}"), vec![inner]);
|
|
}
|
|
let empty = build::shape_path(&mut ids, effectcraft_keyframe::ShapePath::default());
|
|
with_layer(&mut p, c, 0, |l| l.props.sub_mut("contents").expect("contents").children = vec![inner.into(), empty.into()]);
|
|
}
|
|
p.next_id = next;
|
|
add_layer(&mut p, c, "Cam", LayerSource::Camera, (640, 360));
|
|
with_props(&mut p, c, 0, &[("cameraOptions/zoom", Value::Scalar(f64::NAN)), ("transform/position", Value::Vec3([f64::INFINITY, 0.0, 0.0]))]);
|
|
add_layer(&mut p, c, "L", LayerSource::Light { kind: LightKind::Spot }, (640, 360));
|
|
with_props(&mut p, c, 0, &[("lightOptions/color", Value::Color([f64::NAN, 2.0, -1.0, 1.0])), ("lightOptions/intensity", Value::Scalar(-1e9))]);
|
|
with_layer(&mut p, c, 0, |l| {
|
|
l.markers = (0..500).map(|i| marker(f64::from(i) * 1e7, "x")).collect();
|
|
l.track_matte = Some(TrackMatte { layer: l.id, kind: MatteKind::Alpha });
|
|
});
|
|
let r = export_all(&p);
|
|
let file = parse(&r.bytes).expect("parse");
|
|
assert_eq!(effectcraft_aep::write(&file).expect("write"), r.bytes);
|
|
assert!(r.report.count(LossCategory::ShapeLayers) >= 2, "{:?}", r.report.losses);
|
|
assert!(r.report.count(LossCategory::TrackMattes) == 1);
|
|
}
|
|
|
|
#[test]
|
|
fn camera_fixtures_come_back_without_a_loss() {
|
|
// The exporter writes every camera option, Blur Level 100 too; importing the export must not
|
|
// report it (it was reported as "not imported" before the importer and exporter lines met).
|
|
use effectcraft_aep::import::{ImportOptions, import};
|
|
let none = |_: &str| false;
|
|
for name in ["251-camera-one-node.aep", "256-camera-two-node-default.aep", "342-camera-dof-800-30.aep", "537-camera-default-on-3d-rotx.aep"] {
|
|
let bytes = fixture!(name);
|
|
let mut p = Project::default();
|
|
let r1 = import(&mut p, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).expect("import");
|
|
assert!(r1.report.losses.is_empty(), "{name}: {:?}", r1.report.losses);
|
|
let out = export_all(&p);
|
|
assert!(out.report.is_lossless(), "{name}: {:?}", out.report.losses);
|
|
let mut p2 = Project::default();
|
|
let r2 = import(&mut p2, &out.bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).expect("reimport");
|
|
assert!(r2.report.losses.is_empty(), "{name}: {:?}", r2.report.losses);
|
|
assert_eq!(r2.report.imported.cameras, 1, "{name}");
|
|
}
|
|
}
|
|
|
|
/// The static value records below the shape contents of every layer, by (layer index, path of
|
|
/// item match names, record match name).
|
|
fn shape_records(bytes: &[u8]) -> std::collections::BTreeMap<(usize, String), effectcraft_aep::Chunk<'static>> {
|
|
fn walk(
|
|
cs: &[effectcraft_aep::Chunk<'_>],
|
|
path: &str,
|
|
li: usize,
|
|
inside: bool,
|
|
out: &mut std::collections::BTreeMap<(usize, String), effectcraft_aep::Chunk<'static>>,
|
|
) {
|
|
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
|
for (i, c) in cs.iter().enumerate() {
|
|
let name = if c.is(b"tdmn") { c.bytes().map(|b| String::from_utf8_lossy(b.split(|x| *x == 0).next().unwrap_or(b"")).to_string()) } else { None };
|
|
let Some(name) = name else { continue };
|
|
let Some(node) = cs.get(i + 1) else { continue };
|
|
let n = seen.entry(name.clone()).or_insert(0);
|
|
let key = format!("{path}/{name}#{n}");
|
|
*n += 1;
|
|
let here = inside || name == "ADBE Root Vectors Group";
|
|
if here && node.is_list(b"tdbs") && node.child(b"cdat").is_some() {
|
|
let has_expression = node.child(b"Utf8").is_some();
|
|
let mut node = node.clone().into_owned();
|
|
// Header bytes 76 and 77 next to an expression vary between saves (see
|
|
// `keyed_shape_records`).
|
|
if has_expression
|
|
&& let effectcraft_aep::Body::List { children, .. } = &mut node.body
|
|
&& let Some(effectcraft_aep::Body::Data(d)) = children.iter_mut().find(|x| x.is(b"tdb4")).map(|x| &mut x.body)
|
|
&& let Some(b) = d.to_mut().get_mut(76..78)
|
|
{
|
|
b.fill(0);
|
|
}
|
|
out.insert((li, key.clone()), node);
|
|
}
|
|
if node.is_list(b"tdgp") {
|
|
walk(node.children(), &key, li, here, out);
|
|
}
|
|
}
|
|
}
|
|
let f = parse(bytes).expect("parse");
|
|
let mut out = std::collections::BTreeMap::new();
|
|
let view = effectcraft_aep::Project::new(&f).expect("project");
|
|
for item in view.all_items() {
|
|
let Some(comp) = item.comp() else { continue };
|
|
for (li, layer) in comp.layers().enumerate() {
|
|
if let Some(g) = layer.chunk().child_list(b"tdgp").map(|g| g.to_chunk()) {
|
|
walk(g.children(), "", li, false, &mut out);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn every_shape_value_record_is_written_as_after_effects_wrote_it() {
|
|
// Every static record of a shape fixture comes back with After Effects' bytes, built from the
|
|
// record of its property's type (R46).
|
|
let mut names: Vec<String> = fixture_names!("aep").into_iter().filter(|n| n.ends_with(".aep") && n.contains("shape")).collect();
|
|
names.sort();
|
|
let mut compared = 0;
|
|
for name in &names {
|
|
let bytes = fixture!(name);
|
|
let mut p = Project::default();
|
|
let none = |_: &str| false;
|
|
let imported = effectcraft_aep::import::import(
|
|
&mut p,
|
|
&bytes,
|
|
&effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&none) },
|
|
)
|
|
.expect("import");
|
|
if imported.report.count(effectcraft_aep::import::LossCategory::ShapeLayers) > 0 {
|
|
continue;
|
|
}
|
|
let r = export_all(&p);
|
|
let (a, b) = (shape_records(&bytes), shape_records(&r.bytes));
|
|
for (k, rec) in &a {
|
|
// A record After Effects keeps for a value set to its default (239: Miter Limit 4)
|
|
// is not needed: absent = default.
|
|
let first = rec.child(b"cdat").and_then(|c| c.bytes()).and_then(|d| d.get(..8)).and_then(|b| b.try_into().ok()).map(f64::from_be_bytes);
|
|
let explicit_default =
|
|
[("ADBE Vector Stroke Miter Limit#0", 4.0), ("ADBE Vector Grad HiLite Angle#0", 0.0), ("ADBE Vector Grad HiLite Length#0", 0.0)]
|
|
.iter()
|
|
.any(|(n, v)| k.1.ends_with(n) && first == Some(*v));
|
|
// A polygon has no inner radius or roundness in EffectCraft (After Effects keeps their
|
|
// records, 331; a polygon ignores them).
|
|
let polygon_inner = name.contains("polygon") && k.1.contains("ADBE Vector Star Inner");
|
|
let Some(got) = b.get(k) else {
|
|
assert!(explicit_default || polygon_inner, "{name}: {k:?} not written");
|
|
continue;
|
|
};
|
|
assert_eq!(crate::export::helpers::decoded(got), crate::export::helpers::decoded(rec), "{name}: {k:?}");
|
|
compared += 1;
|
|
}
|
|
for k in b.keys() {
|
|
assert!(a.contains_key(k), "{name}: {k:?} written but After Effects has none");
|
|
}
|
|
}
|
|
assert!(compared >= 40, "{compared}");
|
|
}
|
|
|
|
/// The keyed (or expression) records below the shape contents of the first layer: `(path,
|
|
/// tdb4, lhd3, ldat with spatial arc lengths zeroed, expression)`.
|
|
fn keyed_shape_records(bytes: &[u8]) -> Vec<(String, Vec<u8>, Vec<u8>, Vec<u8>, Option<Vec<u8>>)> {
|
|
fn walk(cs: &[effectcraft_aep::Chunk<'_>], path: &str, inside: bool, out: &mut Vec<(String, Vec<u8>, Vec<u8>, Vec<u8>, Option<Vec<u8>>)>) {
|
|
for (i, c) in cs.iter().enumerate() {
|
|
let Some(name) = c.bytes().filter(|_| c.is(b"tdmn")).map(|b| String::from_utf8_lossy(b.split(|x| *x == 0).next().unwrap_or(b"")).to_string())
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(node) = cs.get(i + 1) else { continue };
|
|
let key = format!("{path}/{name}");
|
|
let here = inside || name == "ADBE Root Vectors Group";
|
|
if here && node.is_list(b"tdbs") {
|
|
let list = node.child_list(b"list");
|
|
let expr = node.child(b"Utf8").and_then(|u| u.bytes()).map(<[u8]>::to_vec);
|
|
if list.is_some() || expr.is_some() {
|
|
// The header bytes the exporter writes (the others are zero: `decoded`).
|
|
let mut tdb4 =
|
|
crate::export::helpers::decoded(&node.clone().into_owned()).child(b"tdb4").and_then(|c| c.bytes()).unwrap_or_default().to_vec();
|
|
// Header bytes 76 and 77 of a property with an expression: After Effects
|
|
// wrote 1 in one save and 0 in another (060 and 856 of the combined files);
|
|
// byte 120 marks the expression.
|
|
if expr.is_some()
|
|
&& let Some(b) = tdb4.get_mut(76..78)
|
|
{
|
|
b.fill(0);
|
|
}
|
|
let lhd3 = list.and_then(|l| l.child(b"lhd3")).and_then(|c| c.bytes()).unwrap_or_default().to_vec();
|
|
let mut ldat = list.and_then(|l| l.child(b"ldat")).and_then(|c| c.bytes()).unwrap_or_default().to_vec();
|
|
let size = lhd3.get(16..20).map_or(0, |b| u32::from_be_bytes(b.try_into().unwrap()) as usize);
|
|
if size == 104 {
|
|
for r in ldat.chunks_mut(104) {
|
|
r[16..24].fill(0);
|
|
// Automatic spatial tangents: After Effects keeps 0 for two keys
|
|
// made by script (853), the exporter writes the tangents it computes
|
|
// (collinear: the same straight path, R14).
|
|
if r[8..12] == [0, 0, 0, 1] {
|
|
r[72..104].fill(0);
|
|
}
|
|
}
|
|
}
|
|
// The colour distance to the next key (f64 at 16) of a colour key: After
|
|
// Effects wrote 0 for 854's linear keys in one save and 255 sqrt(2) in
|
|
// another (the combined file); the exporter writes 0.
|
|
if size == 152 {
|
|
for r in ldat.chunks_mut(152) {
|
|
r[16..24].fill(0);
|
|
}
|
|
}
|
|
// The ease before the first key and after the last one is never used (the
|
|
// importer keeps none, the exporter writes 1/3 influence there).
|
|
if size == 48 && ldat.len() >= 96 {
|
|
ldat[16..32].fill(0);
|
|
let n = ldat.len();
|
|
ldat[n - 16..n].fill(0);
|
|
}
|
|
out.push((key.clone(), tdb4, lhd3, ldat, expr));
|
|
}
|
|
}
|
|
if node.is_list(b"tdgp") {
|
|
walk(node.children(), &key, here, out);
|
|
}
|
|
}
|
|
}
|
|
let f = parse(bytes).expect("parse");
|
|
let view = effectcraft_aep::Project::new(&f).expect("project");
|
|
let mut out = Vec::new();
|
|
for item in view.all_items() {
|
|
let Some(comp) = item.comp() else { continue };
|
|
for layer in comp.layers() {
|
|
if let Some(g) = layer.chunk().child_list(b"tdgp").map(|g| g.to_chunk()) {
|
|
walk(g.children(), "", false, &mut out);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn keyed_shape_properties_are_written_as_after_effects_wrote_them() {
|
|
// 851-857: Trim End linear and bezier keys, group Position (spatial), Fill Color, Trim
|
|
// Offset with a hold key, an expression on Trim End, group Scale (two components).
|
|
for name in [
|
|
"851-shape-key-trim-end-0-100-linear.aep",
|
|
"852-shape-key-trim-end-bezier-out0-60-in20-30.aep",
|
|
"853-shape-key-group-position-0-200-100-linear.aep",
|
|
"854-shape-key-fill-color-red-blue-linear.aep",
|
|
"855-shape-key-trim-offset-0-90-hold.aep",
|
|
"856-shape-trim-end-expression-time50.aep",
|
|
"857-shape-key-group-scale-100-50-150-linear.aep",
|
|
] {
|
|
let bytes = fixture!(name);
|
|
let mut p = Project::default();
|
|
let none = |_: &str| false;
|
|
effectcraft_aep::import::import(
|
|
&mut p,
|
|
&bytes,
|
|
&effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&none) },
|
|
)
|
|
.expect("import");
|
|
let r = export_all(&p);
|
|
assert_eq!(r.report.count(LossCategory::ShapeLayers), 0, "{name}: {:?}", r.report.losses);
|
|
let (a, b) = (keyed_shape_records(&bytes), keyed_shape_records(&r.bytes));
|
|
assert_eq!(a.len(), 1, "{name}");
|
|
assert_eq!(a, b, "{name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shape_operators_and_keys_built_in_code_come_back() {
|
|
// Polystar, gradient fill / stroke, trim paths with keys, round corners, merge / offset paths
|
|
// and a repeater with a transform: exported, imported again, the same values and keys.
|
|
use effectcraft_keyframe::{Gradient, Keyframe, Value};
|
|
use effectcraft_project::build;
|
|
let mut p = Project::default();
|
|
let c = add_comp(&mut p, "C", 1920, 1080, FrameRate::new(25, 1), 2.0, None);
|
|
add_layer(&mut p, c, "S", LayerSource::Shape, (1920, 1080));
|
|
let mut next = p.next_id;
|
|
{
|
|
let mut ids = build::Ids(&mut next);
|
|
let rect = build::shape_rect(&mut ids, [400.0, 300.0], [0.0, 0.0], 0.0);
|
|
let mut star = build::shape_star(&mut ids, true, 7.0, [10.0, 20.0], 120.0, 40.0);
|
|
star.get_mut("rotation").unwrap().value = Value::Scalar(15.0);
|
|
let mut gfill = build::shape_gradient_fill(&mut ids, true, [-50.0, 0.0], [60.0, 10.0], Gradient::default());
|
|
gfill.get_mut("opacity").unwrap().value = Value::Scalar(70.0);
|
|
let gstroke = build::shape_gradient_stroke(&mut ids, false, [0.0, 0.0], [100.0, 50.0], Gradient::default(), 5.0);
|
|
let mut trim = build::shape_trim(&mut ids, 10.0, 90.0, 30.0);
|
|
trim.get_mut("end").unwrap().keys = vec![
|
|
key(0.0, Value::Scalar(0.0)),
|
|
Keyframe { out_interp: effectcraft_keyframe::Interp::Hold, ..key(1.0, Value::Scalar(60.0)) },
|
|
key(1.5, Value::Scalar(100.0)),
|
|
];
|
|
let mut round = build::shape_simple_op(&mut ids, "round").unwrap();
|
|
round.get_mut("radius").unwrap().value = Value::Scalar(25.0);
|
|
let mut merge = build::shape_simple_op(&mut ids, "merge").unwrap();
|
|
merge.get_mut("mode").unwrap().value = Value::Enum(2);
|
|
let mut offset = build::shape_simple_op(&mut ids, "offset").unwrap();
|
|
offset.get_mut("amount").unwrap().value = Value::Scalar(-5.0);
|
|
offset.get_mut("join").unwrap().value = Value::Enum(1);
|
|
let mut rep = build::shape_repeater(&mut ids, 4.0, [80.0, 0.0]);
|
|
if let Some(t) = rep.sub_mut("transform") {
|
|
t.get_mut("rotation").unwrap().value = Value::Scalar(12.0);
|
|
t.get_mut("endOpacity").unwrap().value = Value::Scalar(30.0);
|
|
t.get_mut("position").unwrap().keys = vec![key(0.0, Value::Vec2([0.0, 0.0])), key(1.0, Value::Vec2([80.0, 40.0]))];
|
|
}
|
|
let mut fill = build::shape_fill(&mut ids, [0.0, 1.0, 0.0, 1.0]);
|
|
fill.get_mut("color").unwrap().keys = vec![key(0.0, Value::Color([1.0, 0.0, 0.0, 1.0])), key(1.0, Value::Color([0.0, 0.0, 1.0, 1.0]))];
|
|
let g = build::shape_group(&mut ids, "Group 1", vec![rect, star, gfill, gstroke, trim, round, merge, offset, rep, fill]);
|
|
with_layer(&mut p, c, 0, |l| l.props.sub_mut("contents").expect("contents").children = vec![g.into()]);
|
|
}
|
|
p.next_id = next;
|
|
let r = export_all(&p);
|
|
assert_eq!(r.report.count(LossCategory::ShapeLayers), 0, "{:?}", r.report.losses);
|
|
let file = parse(&r.bytes).expect("parse");
|
|
assert_eq!(effectcraft_aep::write(&file).expect("write"), r.bytes);
|
|
let mut q = Project::default();
|
|
let none = |_: &str| false;
|
|
let back = effectcraft_aep::import::import(
|
|
&mut q,
|
|
&r.bytes,
|
|
&effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&none) },
|
|
)
|
|
.expect("import");
|
|
assert!(back.report.losses.is_empty(), "{:?}", back.report.losses);
|
|
// Every property of the contents: value and key count (ids differ).
|
|
let flat = |p: &Project| -> Vec<(String, String, usize)> {
|
|
let mut out = Vec::new();
|
|
for item in p.items.values() {
|
|
let effectcraft_project::ItemKind::Comp(c) = &item.kind else { continue };
|
|
for l in &c.layers {
|
|
let Some(contents) = l.props.sub("contents") else { continue };
|
|
let mut stack = vec![(contents, String::new())];
|
|
while let Some((g, pre)) = stack.pop() {
|
|
for n in &g.children {
|
|
match n {
|
|
effectcraft_project::Node::Group(s) => stack.push((s, format!("{pre}{}/", s.match_id))),
|
|
// A keyed property's static value is its first key's in the file.
|
|
effectcraft_project::Node::Prop(pr) => out.push((
|
|
format!("{pre}{}", pr.match_id),
|
|
if pr.keys.is_empty() {
|
|
format!("{:?}", pr.value)
|
|
} else {
|
|
format!("{:?}", pr.keys.iter().map(|k| (k.time, k.value.clone(), k.out_interp)).collect::<Vec<_>>())
|
|
},
|
|
pr.keys.len(),
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out.sort();
|
|
out
|
|
};
|
|
assert_eq!(flat(&p), flat(&q));
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_shape_keys_never_panic() {
|
|
use effectcraft_keyframe::Value;
|
|
use effectcraft_project::build;
|
|
let mut p = Project::default();
|
|
let c = add_comp(&mut p, "C", 640, 360, FrameRate::new(25, 1), 2.0, None);
|
|
add_layer(&mut p, c, "S", LayerSource::Shape, (640, 360));
|
|
let mut next = p.next_id;
|
|
{
|
|
let mut ids = build::Ids(&mut next);
|
|
let mut trim = build::shape_trim(&mut ids, 0.0, 100.0, 0.0);
|
|
trim.get_mut("end").unwrap().keys = (0..20_000).map(|i| key(f64::from(i) * 0.0001, Value::Scalar(f64::from(i)))).collect();
|
|
trim.get_mut("start").unwrap().keys = vec![key(0.0, Value::Scalar(f64::NAN)), key(-5.0, Value::Bool(true)), key(1e12, Value::Scalar(1.0))];
|
|
trim.get_mut("mode").unwrap().keys = vec![key(0.0, Value::Enum(1)), key(1.0, Value::Enum(900))];
|
|
let mut fill = build::shape_fill(&mut ids, [0.0, 1.0, 0.0, 1.0]);
|
|
fill.get_mut("color").unwrap().keys = vec![key(0.0, Value::Color([f64::INFINITY, 0.0, 0.0, 1.0])), key(0.0, Value::Scalar(3.0))];
|
|
let g = build::shape_group(&mut ids, "G", vec![trim, fill]);
|
|
with_layer(&mut p, c, 0, |l| l.props.sub_mut("contents").expect("contents").children = vec![g.into()]);
|
|
}
|
|
p.next_id = next;
|
|
let r = export_all(&p);
|
|
let file = parse(&r.bytes).expect("parse");
|
|
assert_eq!(effectcraft_aep::write(&file).expect("write"), r.bytes);
|
|
assert!(r.report.count(LossCategory::InvalidValues) >= 1, "{:?}", r.report.losses);
|
|
}
|
|
|
|
/// A shape colour channel outside 0 to 1 (an over-bright or negative value) is written as the
|
|
/// nearest limit and reported, as for lights; it was written unclamped (review finding). A colour
|
|
/// inside the range stays lossless.
|
|
#[test]
|
|
fn a_shape_colour_outside_the_range_is_clamped_and_reported() {
|
|
use effectcraft_keyframe::Value;
|
|
use effectcraft_project::build;
|
|
let shape = |fill_color: [f64; 4], key_color: Option<[f64; 4]>| {
|
|
let mut p = Project::default();
|
|
let c = add_comp(&mut p, "C", 640, 360, FrameRate::new(25, 1), 2.0, None);
|
|
add_layer(&mut p, c, "S", LayerSource::Shape, (640, 360));
|
|
let mut next = p.next_id;
|
|
{
|
|
let mut ids = build::Ids(&mut next);
|
|
let rect = build::shape_rect(&mut ids, [100.0, 50.0], [0.0, 0.0], 0.0);
|
|
let mut fill = build::shape_fill(&mut ids, fill_color);
|
|
if let Some(k) = key_color {
|
|
fill.get_mut("color").unwrap().keys = vec![key(0.0, Value::Color([0.0, 0.0, 1.0, 1.0])), key(1.0, Value::Color(k))];
|
|
}
|
|
let g = build::shape_group(&mut ids, "G", vec![rect, fill]);
|
|
with_layer(&mut p, c, 0, |l| l.props.sub_mut("contents").expect("contents").children = vec![g.into()]);
|
|
}
|
|
p.next_id = next;
|
|
p
|
|
};
|
|
let strict = |p: &Project| effectcraft_aep::export::export(p, &effectcraft_aep::export::ExportOptions { allow_losses: false }).is_ok();
|
|
let inside = shape([0.25, 0.5, 1.0, 1.0], None);
|
|
assert_eq!(export_all(&inside).report.count(LossCategory::ShapeLayers), 0);
|
|
assert!(strict(&inside));
|
|
// Keyed in range: the static value (here over-bright) is not written, so nothing is lost.
|
|
let keyed = shape([2.0, 0.0, 0.0, 1.0], Some([0.5, 0.5, 0.5, 1.0]));
|
|
assert_eq!(export_all(&keyed).report.count(LossCategory::ShapeLayers), 0, "{:?}", export_all(&keyed).report.losses);
|
|
assert!(strict(&keyed));
|
|
for p in [shape([2.0, 0.5, -0.5, 1.0], None), shape([0.0, 1.0, 0.0, 1.0], Some([1.5, 0.0, 0.0, 1.0]))] {
|
|
let r = export_all(&p);
|
|
assert_eq!(r.report.count(LossCategory::ShapeLayers), 1, "{:?}", r.report.losses);
|
|
assert!(!strict(&p));
|
|
// Every stored colour channel is within 0 to 255.
|
|
let mut q = Project::default();
|
|
let none = |_: &str| false;
|
|
let opts = effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&none) };
|
|
effectcraft_aep::import::import(&mut q, &r.bytes, &opts).expect("import");
|
|
let mut colours = Vec::new();
|
|
for item in q.items.values() {
|
|
let effectcraft_project::ItemKind::Comp(c) = &item.kind else { continue };
|
|
for l in &c.layers {
|
|
let mut stack: Vec<&effectcraft_project::PropGroup> = l.props.sub("contents").into_iter().collect();
|
|
while let Some(g) = stack.pop() {
|
|
for n in &g.children {
|
|
match n {
|
|
effectcraft_project::Node::Group(s) => stack.push(s),
|
|
effectcraft_project::Node::Prop(pr) => {
|
|
for v in std::iter::once(&pr.value).chain(pr.keys.iter().map(|k| &k.value)) {
|
|
if let Value::Color(c) = v {
|
|
colours.push(*c);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
assert!(!colours.is_empty());
|
|
assert!(colours.iter().all(|c| c.iter().take(3).all(|x| (0.0..=1.0).contains(x))), "{colours:?}");
|
|
}
|
|
}
|
|
|
|
/// The Transform group of a camera ends with its Group End: the camera's `ADBE Position_2` record
|
|
/// sorts before it (review finding: an unlisted record used to sort after the end marker).
|
|
#[test]
|
|
fn a_camera_transform_group_ends_with_its_group_end() {
|
|
use effectcraft_keyframe::Value;
|
|
let mut p = Project::default();
|
|
let c = add_comp(&mut p, "C", 1920, 1080, FrameRate::new(25, 1), 2.0, None);
|
|
add_layer(&mut p, c, "Cam", LayerSource::Camera, (1920, 1080));
|
|
with_props(&mut p, c, 0, &[("transform/position", Value::Vec3([100.0, 200.0, -1000.0]))]);
|
|
let bytes = export_all(&p).bytes;
|
|
let file = parse(&bytes).expect("parse");
|
|
let view = effectcraft_aep::Project::new(&file).expect("project");
|
|
let comp = view.all_items().into_iter().find_map(|i| i.comp()).expect("comp");
|
|
let layer = comp.layers().next().expect("camera");
|
|
let tree = layer.chunk().child_list(b"tdgp").expect("tree").to_chunk();
|
|
let root = tree.children();
|
|
let name =
|
|
|c: &effectcraft_aep::Chunk<'_>| String::from_utf8_lossy(c.bytes().unwrap_or_default().split(|b| *b == 0).next().unwrap_or_default()).into_owned();
|
|
let at = root.iter().position(|c| c.is(b"tdmn") && name(c) == "ADBE Transform Group").expect("transform");
|
|
let names: Vec<String> = root[at + 1].children().iter().filter(|c| c.is(b"tdmn")).map(name).collect();
|
|
assert_eq!(names.last().map(String::as_str), Some("ADBE Group End"), "{names:?}");
|
|
assert!(names.iter().any(|n| n == "ADBE Position_2"));
|
|
}
|
|
|
|
/// Camera and light options that are not written (the iris, the highlight, the light's radius,
|
|
/// falloff and shadow settings) are reported when they are animated, also while their static value
|
|
/// is the default: keys and expressions change the render, and the export was identical to the
|
|
/// unanimated layer with no loss (review finding). The default layer stays lossless.
|
|
#[test]
|
|
fn animated_camera_and_light_options_that_are_not_written_are_reported() {
|
|
use effectcraft_project::{Expression, LightKind};
|
|
let camera_ids = [
|
|
"irisShape",
|
|
"irisRotation",
|
|
"irisRoundness",
|
|
"irisAspectRatio",
|
|
"irisDiffractionFringe",
|
|
"highlightGain",
|
|
"highlightThreshold",
|
|
"highlightSaturation",
|
|
];
|
|
let light_ids = ["radius", "falloffDistance", "shadowDarkness", "shadowDiffusion", "falloff", "castsShadows"];
|
|
let make = |source: LayerSource, path: Option<String>, keyed: bool| {
|
|
let mut p = Project::default();
|
|
let c = add_comp(&mut p, "C", 640, 360, FrameRate::new(25, 1), 2.0, None);
|
|
add_layer(&mut p, c, "L", source, (640, 360));
|
|
if let Some(path) = path {
|
|
with_layer(&mut p, c, 0, |l| {
|
|
let prop = l.props.prop_mut(&path).unwrap_or_else(|| panic!("no {path}"));
|
|
if keyed {
|
|
let v = prop.value.clone();
|
|
prop.keys = vec![key(0.0, v.clone()), key(1.0, v)];
|
|
} else {
|
|
prop.expr = Some(Expression { text: "value".into(), enabled: true });
|
|
}
|
|
});
|
|
}
|
|
p
|
|
};
|
|
let strict = |p: &Project| effectcraft_aep::export::export(p, &effectcraft_aep::export::ExportOptions { allow_losses: false }).is_ok();
|
|
let cam = make(LayerSource::Camera, None, true);
|
|
assert!(export_all(&cam).report.is_lossless() && strict(&cam));
|
|
let light = make(LayerSource::Light { kind: LightKind::Spot }, None, true);
|
|
assert!(export_all(&light).report.is_lossless() && strict(&light));
|
|
for keyed in [true, false] {
|
|
for id in camera_ids {
|
|
let p = make(LayerSource::Camera, Some(format!("cameraOptions/{id}")), keyed);
|
|
assert_eq!(export_all(&p).report.count(LossCategory::Cameras), 1, "camera {id} keyed {keyed}");
|
|
assert!(!strict(&p), "camera {id} keyed {keyed}");
|
|
}
|
|
for id in light_ids {
|
|
let p = make(LayerSource::Light { kind: LightKind::Spot }, Some(format!("lightOptions/{id}")), keyed);
|
|
assert_eq!(export_all(&p).report.count(LossCategory::Lights), 1, "light {id} keyed {keyed}");
|
|
assert!(!strict(&p), "light {id} keyed {keyed}");
|
|
}
|
|
}
|
|
}
|