Files
Valentin Klein ce9eaec650 Add After Effects project export (#2594)
Add After Effects project export with chunks built in code, explicit loss reporting, engine and UI integration, and regression coverage.
2026-11-06 09:23:16 +01:00

516 lines
24 KiB
Rust

//! The exported bytes are parsed back with `effectcraft-aep` and every exported field is compared
//! with the project it came from.
use crate::export::helpers::*;
use effectcraft_aep::export::{AepExportError, ExportOptions, LossCategory, analyze, export};
use effectcraft_aep::{BlendMode as AepBlend, Interpolation, ItemKind as AepKind, PropNode, Quality as AepQuality, parse, write};
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Ease, Interp, Value};
use effectcraft_project::{ItemKind, LayerSource, Project, Quality};
use effectcraft_time::FrameRate;
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
#[test]
fn simple_project_round_trips_through_the_container() {
let (p, _, _) = simple();
let r = export_all(&p);
assert!(r.report.is_lossless(), "{:?}", r.report.losses);
let file = parse(&r.bytes).expect("parse");
// The container is consistent: writing it back gives the same bytes.
assert_eq!(write(&file).expect("write"), r.bytes);
let view = effectcraft_aep::Project::new(&file).expect("project");
let items = view.all_items();
let kinds: Vec<_> = items.iter().filter_map(|i| i.kind()).collect();
assert_eq!(kinds.iter().filter(|k| **k == AepKind::Composition).count(), 1);
assert_eq!(kinds.iter().filter(|k| **k == AepKind::Folder).count(), 1);
assert_eq!(kinds.iter().filter(|k| **k == AepKind::Footage).count(), 1);
}
#[test]
fn composition_header_fields() {
let mut p = Project::default();
let c = add_comp(&mut p, "Flags", 800, 600, FrameRate::new(30, 1), 10.0, None);
with_comp(&mut p, c, |cc| {
cc.work_area = (secs(1.0), secs(3.0));
cc.background = [0.5, 0.25, 1.0];
cc.pixel_aspect = 0.9091;
cc.shutter_angle = 90.0;
cc.shutter_phase = -45.0;
cc.display_start = secs(4.0);
cc.enable_motion_blur = true;
});
let r = export_all(&p);
let file = parse(&r.bytes).expect("parse");
let view = effectcraft_aep::Project::new(&file).expect("project");
let item = view.all_items().into_iter().find(|i| i.display_name().as_deref() == Some("Flags")).expect("comp");
let h = item.comp().expect("comp").header().expect("header");
assert_eq!((h.width, h.height), (800, 600));
assert_eq!((h.time_base.frame_duration, h.time_base.timescale), (1024, 30720));
assert_eq!(h.duration_units, 307_200);
assert_eq!(h.work_area_start_units, 30_720);
assert_eq!(h.work_area_end_units, Some(92_160));
assert_eq!(h.background, [128, 64, 255]);
assert_eq!((h.pixel_aspect.num, h.pixel_aspect.den), (10, 11));
assert_eq!(h.shutter_angle, 90);
assert_eq!(h.shutter_phase, Some(-45));
assert!(h.motion_blur);
assert_eq!(h.display_start.map(|d| (d.num, d.den)), Some((122_880, 30_720)));
assert_eq!(h.frame_rate_fixed, 30 << 16);
}
#[test]
fn ntsc_and_odd_frame_rates_use_the_time_bases_after_effects_writes() {
for (rate, fd, ts, fixed) in [
(FrameRate::FPS_29_97, 800u32, 23_976u32, 1_964_114u32),
(FrameRate::FPS_23_976, 1000, 23_976, 1_571_291),
(FrameRate::new(24, 1), 1024, 24_576, 24 << 16),
(FrameRate::new(12, 1), 2048, 24_576, 12 << 16),
(FrameRate::new(60, 1), 512, 30_720, 60 << 16),
] {
let mut p = Project::default();
add_comp(&mut p, "R", 640, 360, rate, 2.0, None);
let r = export_all(&p);
let file = parse(&r.bytes).expect("parse");
let view = effectcraft_aep::Project::new(&file).expect("project");
let item = view.all_items().into_iter().find(|i| i.display_name().as_deref() == Some("R")).expect("comp");
let h = item.comp().expect("c").header().expect("h");
assert_eq!((h.time_base.frame_duration, h.time_base.timescale, h.frame_rate_fixed), (fd, ts, fixed), "{}", rate.label());
let frames = (rate.as_f64() * 2.0).round() as u32;
assert_eq!(h.duration_units, frames * fd);
}
}
#[test]
fn layer_header_fields() {
let mut p = Project::default();
let c = add_comp(&mut p, "C", 800, 600, FrameRate::new(30, 1), 10.0, None);
let s = add_solid(&mut p, "Sol", 800, 600, [0.1, 0.2, 0.3], None);
for name in ["Bottom", "Mid", "Top"] {
add_layer(&mut p, c, name, LayerSource::Solid { item: s }, (800, 600));
}
add_layer(&mut p, c, "Nul", LayerSource::Null, (100, 100));
let bottom = p.comp(c).and_then(|cc| cc.layers.last().map(|l| l.id)).expect("bottom");
with_layer(&mut p, c, 1, |l| {
l.switches.adjustment = true;
l.blend_mode = BlendMode::Multiply;
l.label = Label::Lavender;
l.switches.quality = Quality::Draft;
l.parent = Some(bottom);
});
with_layer(&mut p, c, 2, |l| {
l.switches.locked = true;
l.switches.shy = true;
l.switches.solo = true;
l.blend_mode = BlendMode::Screen;
l.start_time = secs(1.5);
l.in_point = secs(2.0);
l.out_point = secs(6.0);
l.switches.video = false;
l.switches.audio = false;
l.switches.effects = false;
l.switches.motion_blur = true;
l.switches.guide = true;
});
let r = export_all(&p);
assert!(r.report.is_lossless());
let file = parse(&r.bytes).expect("parse");
let view = effectcraft_aep::Project::new(&file).expect("project");
let comp = view.all_items().into_iter().find(|i| i.comp().is_some()).expect("comp");
let layers: Vec<_> = comp.comp().expect("c").layers().collect();
assert_eq!(layers.len(), 4);
let names: Vec<_> = layers.iter().map(|l| l.name().unwrap_or_default()).collect();
assert_eq!(names, ["Nul", "Top", "Mid", "Bottom"]);
let h: Vec<_> = layers.iter().map(|l| l.header().expect("header")).collect();
// Null.
assert!(h[0].is_null_object());
assert!(h[0].source_id.is_some());
// Adjustment layer "Top".
assert!(h[1].is_adjustment());
assert_eq!(h[1].blend_mode, AepBlend::Multiply);
assert_eq!(h[1].label, 5);
assert_eq!(h[1].quality, AepQuality::Draft);
assert_eq!(h[1].parent_id, Some(h[3].id));
// "Mid".
assert!(h[2].is_locked() && h[2].is_shy() && h[2].is_solo());
assert!(!h[2].is_video_enabled() && !h[2].is_audio_enabled() && !h[2].is_effects_enabled());
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].name_copy, "Mid");
// Ids are unique.
let mut ids: Vec<u32> = h.iter().map(|x| x.id).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 4);
}
#[test]
fn solids_and_items() {
let mut p = Project::default();
let f = p.add_item("Folder A", Label::None, None, ItemKind::Folder);
add_comp(&mut p, "Comp 1", 100, 100, FrameRate::new(25, 1), 1.0, Some(f));
add_solid(&mut p, "Blue", 640, 360, [0.0, 0.0, 1.0], Some(f));
add_solid(&mut p, "Root solid", 31, 17, [1.0, 0.5, 0.25], None);
let r = export_all(&p);
let file = parse(&r.bytes).expect("parse");
let view = effectcraft_aep::Project::new(&file).expect("project");
let all = view.all_items();
let folder = all.iter().find(|i| i.display_name().as_deref() == Some("Folder A")).expect("folder");
let kids: Vec<_> = folder.children().collect();
assert_eq!(kids.len(), 2);
let blue = kids.iter().find(|i| i.kind() == Some(AepKind::Footage)).expect("blue");
let f = blue.footage().expect("footage");
assert!(f.is_solid);
assert_eq!((f.width, f.height), (640, 360));
assert_eq!(f.solid_rgb(), Some([0.0, 0.0, 1.0]));
let solids = all.iter().find(|i| i.display_name().as_deref() == Some("Solids")).expect("Solids folder");
let rs = solids.children().next().expect("root solid").footage().expect("f");
assert_eq!((rs.width, rs.height), (31, 17));
assert_eq!(rs.solid_rgb(), Some([1.0, 0.5, 0.25]));
}
fn transform_of(bytes: &[u8]) -> (effectcraft_aep::Transform, Vec<(String, Vec<(i32, Option<Interpolation>, Option<Interpolation>, Vec<f64>)>)>) {
let file = parse(bytes).expect("parse");
let view = effectcraft_aep::Project::new(&file).expect("project");
let comp = view.all_items().into_iter().find(|i| i.comp().is_some()).expect("comp");
let h = comp.comp().expect("c").header().expect("h");
let layer = comp.comp().expect("c").layers().next().expect("layer");
let tr = layer.transform(u32::from(h.width), u32::from(h.height)).expect("transform");
let mut keys = Vec::new();
if let Some(PropNode::Group(g)) = layer.properties().and_then(|p| p.get("ADBE Transform Group")) {
for name in ["ADBE Anchor Point", "ADBE Position", "ADBE Scale", "ADBE Rotate Z", "ADBE Opacity"] {
if let Some(PropNode::Property(pr)) = g.get(name)
&& let Some(list) = pr.keys()
{
let recs = list
.keyframes()
.map(|k| (k.time_units().unwrap_or(0), k.interpolation_in(), k.interpolation_out(), k.value().unwrap_or_default()))
.collect();
keys.push((name.to_string(), recs));
}
}
}
(tr, keys)
}
#[test]
fn static_transform_values_use_the_file_units() {
let mut p = Project::default();
let c = add_comp(&mut p, "C", 1920, 1080, FrameRate::new(25, 1), 5.0, None);
let s = add_solid(&mut p, "L", 400, 300, [1.0, 0.5, 0.0], None);
add_layer(&mut p, c, "L", LayerSource::Solid { item: s }, (400, 300));
with_layer(&mut p, c, 0, |l| {
let tr = l.transform_mut().expect("tr");
tr.get_mut("anchor").expect("a").value = Value::Vec3([100.0, 50.0, 0.0]);
tr.get_mut("position").expect("p").value = Value::Vec3([300.0, 200.0, 0.0]);
tr.get_mut("scale").expect("s").value = Value::Vec3([50.0, 60.0, 100.0]);
tr.get_mut("rotation").expect("r").value = Value::Scalar(45.0);
tr.get_mut("opacity").expect("o").value = Value::Scalar(25.0);
});
let r = export_all(&p);
let (tr, keys) = transform_of(&r.bytes);
assert!(keys.is_empty());
let a = tr.anchor_px(400.0, 300.0);
assert!(close(a[0], 100.0) && close(a[1], 50.0));
assert_eq!(tr.anchor_fraction.value, [0.25, 50.0 / 300.0, 0.0]);
assert_eq!(tr.position.value, [300.0, 200.0, 0.0]);
assert_eq!(tr.scale_percent.value, [50.0, 60.0, 100.0]);
assert!(close(tr.rotation_degrees.value, 45.0));
assert!(close(tr.opacity_percent.value, 25.0));
}
#[test]
fn defaults_are_not_written() {
let (p, _, _) = simple();
let r = export_all(&p);
let plain = export_all(&p);
assert_eq!(r.bytes, plain.bytes);
// A layer at the default values has no property record beyond the template's.
let file = parse(&r.bytes).expect("parse");
let view = effectcraft_aep::Project::new(&file).expect("project");
let comp = view.all_items().into_iter().find(|i| i.comp().is_some()).expect("comp");
let layer = comp.comp().expect("c").layers().next().expect("layer");
let Some(PropNode::Group(g)) = layer.properties().and_then(|p| p.get("ADBE Transform Group")) else { panic!("no group") };
for n in ["ADBE Anchor Point", "ADBE Position", "ADBE Scale", "ADBE Rotate Z", "ADBE Opacity"] {
assert!(g.get(n).is_none(), "{n} must be absent at its default");
}
}
#[test]
fn keyframes_with_interpolation_ease_and_expressions() {
let mut p = Project::default();
let c = add_comp(&mut p, "C", 1920, 1080, FrameRate::new(25, 1), 5.0, None);
let s = add_solid(&mut p, "S", 400, 300, [0.2, 0.4, 0.6], None);
add_layer(&mut p, c, "S", LayerSource::Solid { item: s }, (400, 300));
with_layer(&mut p, c, 0, |l| {
let tr = l.transform_mut().expect("tr");
let o = tr.get_mut("opacity").expect("o");
let mut k0 = key(0.0, Value::Scalar(0.0));
let mut k1 = key(1.0, Value::Scalar(100.0));
k0.out_interp = Interp::Bezier;
k0.out_ease = vec![Ease { speed: 0.0, influence: 0.5 }];
k1.in_interp = Interp::Bezier;
k1.in_ease = vec![Ease { speed: 0.0, influence: 0.25 }];
k1.out_interp = Interp::Hold;
o.keys = vec![k0, k1, key(2.0, Value::Scalar(50.0))];
let rot = tr.get_mut("rotation").expect("r");
rot.value = Value::Scalar(10.0);
rot.expr = Some(effectcraft_project::Expression { text: "time*20".into(), enabled: false });
});
let r = export_all(&p);
assert_eq!(r.report.exported.keyframes, 3);
assert_eq!(r.report.exported.expressions, 1);
let (tr, keys) = transform_of(&r.bytes);
let (_, opacity) = keys.iter().find(|(n, _)| n == "ADBE Opacity").expect("opacity keys");
assert_eq!(opacity.len(), 3);
assert_eq!(opacity[0].0, 0);
assert_eq!(opacity[1].0, 25_600);
assert_eq!(opacity[2].0, 51_200);
assert_eq!(opacity[0].2, Some(Interpolation::Bezier));
assert_eq!(opacity[1].1, Some(Interpolation::Bezier));
assert_eq!(opacity[1].2, Some(Interpolation::Hold));
assert_eq!(opacity[1].3, vec![1.0]);
assert!(close(tr.rotation_degrees.value, 10.0));
let file = parse(&r.bytes).expect("parse");
let view = effectcraft_aep::Project::new(&file).expect("project");
let comp = view.all_items().into_iter().find(|i| i.comp().is_some()).expect("comp");
let layer = comp.comp().expect("c").layers().next().expect("layer");
let Some(PropNode::Group(g)) = layer.properties().and_then(|p| p.get("ADBE Transform Group")) else { panic!("no group") };
let Some(PropNode::Property(rot)) = g.get("ADBE Rotate Z") else { panic!("no rotation") };
assert_eq!(rot.expression().as_deref(), Some("time*20"));
assert_eq!(rot.expression_enabled(), Some(false));
}
#[test]
fn losses_are_reported_and_refused_unless_allowed() {
let (mut p, c, _) = simple();
let t = add_comp(&mut p, "T", 100, 100, FrameRate::new(25, 1), 1.0, None);
add_layer(&mut p, t, "Par", LayerSource::Light { kind: effectcraft_project::LightKind::Parallel }, (100, 100));
add_layer(&mut p, t, "Amb", LayerSource::Light { kind: effectcraft_project::LightKind::Ambient }, (100, 100));
with_layer(&mut p, c, 0, |l| {
l.blend_mode = BlendMode::Dissolve;
l.switches.three_d = true;
// A stretch that is not an exact small ratio is written unstretched.
l.stretch = 100.0 * std::f64::consts::PI;
l.transform_mut().expect("tr").get_mut("rotation").expect("r").keys = vec![key(0.0, Value::Scalar(0.0)), key(1.0, Value::Scalar(90.0))];
});
let rep = analyze(&p);
assert_eq!(rep.count(LossCategory::Lights), 2);
assert_eq!(rep.count(LossCategory::BlendModes), 1);
// The 3D switch is written.
assert_eq!(rep.count(LossCategory::ThreeD), 0);
assert_eq!(rep.count(LossCategory::TimeStretch), 1);
assert!(!rep.limitations.is_empty());
match export(&p, &ExportOptions::default()) {
Err(AepExportError::Refused(r)) => assert_eq!(*r, rep),
other => panic!("expected a refusal, got {other:?}"),
}
let ok = export(&p, &ExportOptions { allow_losses: true }).expect("allowed");
assert_eq!(ok.report, rep);
// The report round-trips through JSON.
let json = serde_json::to_string(&rep).expect("json");
assert_eq!(serde_json::from_str::<effectcraft_aep::export::ExportReport>(&json).expect("back"), rep);
}
#[test]
fn the_exported_file_is_deterministic() {
let (p, _, _) = simple();
assert_eq!(export_all(&p).bytes, export_all(&p).bytes);
}
/// The user, host and domain names of the machine that runs the test, lower-cased. They are read from
/// the environment so that no name is written down in the repository; names shorter than three
/// characters are skipped because they would match by chance.
fn local_names() -> Vec<String> {
["USERNAME", "USER", "COMPUTERNAME", "HOSTNAME", "USERDOMAIN"]
.into_iter()
.filter_map(|key| std::env::var(key).ok())
.map(|v| v.trim().to_lowercase())
.filter(|v| v.len() >= 3)
.collect()
}
/// Absolute drive-letter paths (`X:\…`, `X:/…`) in a lower-cased text, except the neutral
/// `c:\fixtures\` paths the fixtures use.
fn drive_paths(hay: &str) -> Vec<String> {
let b = hay.as_bytes();
let mut found = Vec::new();
for i in 0..b.len().saturating_sub(3) {
let starts = i == 0 || !b[i - 1].is_ascii_alphanumeric();
if starts
&& b[i].is_ascii_alphabetic()
&& b[i + 1] == b':'
&& matches!(b[i + 2], b'\\' | b'/')
&& b[i + 3].is_ascii_alphanumeric()
&& !b[i..].starts_with(b"c:\\fixtures\\")
&& !b[i..].starts_with(b"c:/fixtures/")
{
found.push(String::from_utf8_lossy(&b[i..b.len().min(i + 16)]).into_owned());
}
}
found
}
/// E-mail-like strings (`local@domain.tld`) in a lower-cased text.
fn email_like(hay: &str) -> Vec<String> {
let b = hay.as_bytes();
let local = |c: u8| c.is_ascii_alphanumeric() || b"._%+-".contains(&c);
let domain = |c: u8| c.is_ascii_alphanumeric() || c == b'-' || c == b'.';
let mut found = Vec::new();
for at in (0..b.len()).filter(|&i| b[i] == b'@') {
let mut s = at;
while s > 0 && local(b[s - 1]) {
s -= 1;
}
let mut e = at + 1;
while e < b.len() && domain(b[e]) {
e += 1;
}
while e > at + 1 && b[e - 1] == b'.' {
e -= 1;
}
let host = &b[at + 1..e];
let tld = host.iter().rposition(|&c| c == b'.').map(|dot| &host[dot + 1..]);
let has_dot_inside = host.iter().position(|&c| c == b'.').is_some_and(|dot| dot > 0);
if s < at && has_dot_inside && tld.is_some_and(|t| t.len() >= 2 && t.iter().all(u8::is_ascii_alphabetic)) {
found.push(String::from_utf8_lossy(&b[s..e]).into_owned());
}
}
found
}
/// Everything the exporter writes is built in code: an export holds no local name, path or
/// address.
#[test]
fn the_export_holds_no_local_data() {
let mut p = Project::default();
let c = add_comp(&mut p, "C", 100, 100, FrameRate::new(25, 1), 1.0, None);
let s = add_solid(&mut p, "Red", 50, 50, [1.0, 0.0, 0.0], None);
add_layer(&mut p, c, "Red", LayerSource::Solid { item: s }, (50, 50));
add_layer(&mut p, c, "N", LayerSource::Null, (100, 100));
let export = export_all(&p).bytes;
let names = local_names();
let fixed = ["desktop-", "\\users\\", "/users/", "/home/"];
for (name, bytes) in [("export", &export[..])] {
// Strings are searched as bytes and, with the NUL bytes removed, as UTF-16 text.
let utf16: Vec<u8> = bytes.iter().copied().filter(|&c| c != 0).collect();
for hay in [String::from_utf8_lossy(bytes).to_lowercase(), String::from_utf8_lossy(&utf16).to_lowercase()] {
for needle in names.iter().map(String::as_str).chain(fixed) {
assert!(!hay.contains(needle), "{name} contains a local name or path");
}
assert_eq!(drive_paths(&hay), Vec::<String>::new(), "{name} holds an absolute local path");
assert_eq!(email_like(&hay), Vec::<String>::new(), "{name} holds an e-mail address");
}
}
}
#[test]
fn the_local_data_search_finds_what_it_looks_for() {
assert_eq!(drive_paths("x d:\\work\\a.aep y"), vec!["d:\\work\\a.aep y".to_string()]);
assert_eq!(drive_paths("q:/media/a.mov"), vec!["q:/media/a.mov".to_string()]);
assert!(drive_paths("c:\\fixtures\\a.png and c:/fixtures/b.png").is_empty());
assert!(drive_paths("http://example and 12:30 and ab:/x").is_empty());
assert_eq!(email_like("by jane.doe@example.org."), vec!["jane.doe@example.org".to_string()]);
assert!(email_like("@@ a@b x@.org y@host").is_empty());
}
#[test]
fn a_root_folder_named_solids_is_the_solids_folder() {
let mut p = Project::default();
let f = p.add_item("Solids", Label::None, None, ItemKind::Folder);
let c = add_comp(&mut p, "C", 100, 100, FrameRate::new(25, 1), 1.0, None);
let s = add_solid(&mut p, "Orange", 50, 50, [1.0, 0.5, 0.0], Some(f));
add_layer(&mut p, c, "Orange", LayerSource::Solid { item: s }, (50, 50));
add_layer(&mut p, c, "N", LayerSource::Null, (100, 100));
let r = export_all(&p);
let file = parse(&r.bytes).expect("parse");
let view = effectcraft_aep::Project::new(&file).expect("project");
let folders: Vec<_> = view.all_items().into_iter().filter(|i| i.kind() == Some(AepKind::Folder)).collect();
assert_eq!(folders.len(), 1, "one Solids folder, not two");
let kids: Vec<_> = folders[0].children().filter_map(|k| k.footage()).collect();
assert_eq!(kids.len(), 2, "the solid and the hidden null solid");
assert!(kids.iter().all(|k| k.is_solid));
}
#[test]
fn the_3d_switch_is_written() {
// 015: ldta@38 bit 0x04.
let (mut p, c, _) = simple();
with_layer(&mut p, c, 0, |l| {
l.switches.three_d = true;
l.transform_mut().expect("tr").get_mut("position").expect("p").value = Value::Vec3([960.0, 540.0, 300.0]);
});
let r = export_all(&p);
assert!(r.report.is_lossless(), "{:?}", r.report.losses);
let file = parse(&r.bytes).expect("parse");
let view = effectcraft_aep::Project::new(&file).expect("project");
let layer = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).expect("layer");
assert!(layer.header().expect("header").is_three_d());
let Some(PropNode::Group(g)) = layer.properties().and_then(|p| p.get("ADBE Transform Group")) else { panic!("transform") };
let Some(PropNode::Property(pos)) = g.get("ADBE Position") else { panic!("position") };
assert_eq!(pos.static_value().expect("value")[..3], [960.0, 540.0, 300.0]);
}
#[test]
fn time_stretch_is_the_exact_ratio_and_scales_the_time_scale() {
// 018 (200 %: 2/1, 51 200), 316 (50 %: 1/2, 25 600), 317 (150 %: 3/2, 38 400).
for (pct, num, den, ts) in [(200.0, 2i32, 1u32, 51_200u32), (50.0, 1, 2, 25_600), (150.0, 3, 2, 38_400)] {
let (mut p, c, _) = simple();
with_layer(&mut p, c, 0, |l| {
l.stretch = pct;
l.start_time = secs(1.0);
l.in_point = secs(1.0);
l.out_point = secs(1.0 + 2.0 * pct / 100.0);
l.transform_mut().expect("tr").get_mut("opacity").expect("o").value = Value::Scalar(50.0);
});
let r = export_all(&p);
assert!(r.report.is_lossless(), "{pct}: {:?}", r.report.losses);
let file = parse(&r.bytes).expect("parse");
let view = effectcraft_aep::Project::new(&file).expect("project");
let layer = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).expect("layer");
let h = layer.header().expect("header");
assert_eq!((h.stretch, h.stretch_den), (num, den), "{pct}");
// Relative times in source time: the 2 s of source end 2 s after the start.
assert_eq!((h.in_relative.num, h.out_relative.num), (0, 51_200), "{pct}");
let Some(PropNode::Group(g)) = layer.properties().and_then(|p| p.get("ADBE Transform Group")) else { panic!("transform") };
let Some(PropNode::Property(o)) = g.get("ADBE Opacity") else { panic!("opacity") };
assert_eq!(o.time_scale(), Some(ts), "{pct}");
// And back.
let mut q = Project::default();
let none = |_: &str| false;
effectcraft_aep::import::import(
&mut q,
&r.bytes,
&effectcraft_aep::import::ImportOptions { allow_losses: true, ..effectcraft_aep::import::ImportOptions::new(&none) },
)
.expect("import");
let l = q.items.values().find_map(|i| if let ItemKind::Comp(c) = &i.kind { c.layers.first().cloned() } else { None }).expect("layer");
assert!((l.stretch - pct).abs() < 1e-9 && l.out_point == secs(1.0 + 2.0 * pct / 100.0), "{pct}: {} {:?}", l.stretch, l.out_point);
}
// A ratio that is not small and exact is reported.
let (mut p, c, _) = simple();
with_layer(&mut p, c, 0, |l| l.stretch = 31.4159265);
assert_eq!(export_all(&p).report.count(LossCategory::TimeStretch), 1);
}
#[test]
fn unwritten_layer_switches_and_off_frame_times_are_reported() {
// Each of these changes the result in After Effects, so none may pass as lossless.
let (mut p, c, _) = simple();
with_layer(&mut p, c, 0, |l| {
l.switches.quality = Quality::Wireframe;
l.preserve_transparency = true;
l.auto_orient = effectcraft_project::AutoOrient::AlongPath;
l.in_point = secs(0.013);
});
let r = export_all(&p);
assert_eq!(r.report.count(LossCategory::LayerSwitches), 3, "{:?}", r.report.losses);
assert_eq!(r.report.count(LossCategory::RoundedTimes), 1, "{:?}", r.report.losses);
assert!(matches!(export(&p, &ExportOptions { allow_losses: false }), Err(AepExportError::Refused(_))));
}