//! Effects (Tint, Gradient Ramp, Exposure): every effect fixture is imported and exported, and the //! exported effects are parsed back with `effectcraft-aep` and compared with the ones After //! Effects wrote, parameter by parameter (stored values, keys, definitions); projects edited in //! code check the parameters no fixture changes, several instances, and what is reported. use crate::export::helpers::*; use effectcraft_aep::export::LossCategory; use effectcraft_aep::import::{ImportOptions, import}; use effectcraft_aep::{AepFile, ParamValue, parse}; use effectcraft_keyframe::{Interp, Value}; use effectcraft_project::{ItemKind, Node, Project, PropGroup}; fn fixtures_with_effects() -> Option> { Some({ let mut v: Vec = crate::common::names("aep")? .into_iter() // Every fixture with an effect of the table (the dropdown and layer controls are not in it). .filter(|n| { n.ends_with(".aep") && (n.contains("-fx-") || n.contains("key-fill") || n.contains("key-ramp") || n.contains("adj-ramp")) && !n.contains("dropdown") && !n.contains("layercontrol") }) .collect(); v.sort(); v }) } fn import_bytes(bytes: &[u8]) -> Project { let mut p = Project::default(); let none = |_: &str| false; let opts = ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }; import(&mut p, bytes, &opts).expect("import"); p } /// One effect as the reader sees it: match name, instance name, and per parameter index the /// stored value (None = default) and the keys `(time units, value)`. #[derive(Debug, PartialEq)] struct Seen { match_name: String, name: Option, params: Vec<(usize, Option, Vec<(i32, Vec, (u8, u8))>)>, } /// The effects of every layer, by (composition name, layer index). fn effects_of(file: &AepFile<'_>) -> Vec<((String, usize), Vec)> { let view = effectcraft_aep::Project::new(file).expect("project"); let mut out = Vec::new(); for item in view.all_items() { let Some(comp) = item.comp() else { continue }; let cname = item.display_name().unwrap_or_default(); for (i, layer) in comp.layers().enumerate() { let mut fx = Vec::new(); // Effects outside the table are reported by the importer, not written. for e in layer.effects().into_iter().filter(|e| effectcraft_aep::effect_map::by_match_name(e.match_name()).is_some()) { let mut params = Vec::new(); for p in view.effect_params(&e) { let keys = p .keys() .map(|k| { k.keyframes() .map(|r| { let interp = ( r.interpolation_in().map_or(0, |x| match x { effectcraft_aep::Interpolation::Linear => 1, effectcraft_aep::Interpolation::Bezier => 2, effectcraft_aep::Interpolation::Hold => 3, effectcraft_aep::Interpolation::Other(c) => c, }), r.interpolation_out().map_or(0, |x| match x { effectcraft_aep::Interpolation::Linear => 1, effectcraft_aep::Interpolation::Bezier => 2, effectcraft_aep::Interpolation::Hold => 3, effectcraft_aep::Interpolation::Other(c) => c, }), ); (r.time_units().unwrap_or(-1), r.value().unwrap_or_default(), interp) }) .collect() }) .unwrap_or_default(); let value = if p.is_animated() { None } else { p.stored_value() }; params.push((p.index, value, keys)); } fx.push(Seen { match_name: e.match_name().to_string(), name: e.display_name(), params }); } if !fx.is_empty() { out.push(((cname.clone(), i), fx)); } } } out.sort_by(|a, b| a.0.cmp(&b.0)); out } fn close_value(a: &Option, b: &Option) -> bool { let comps = |v: &ParamValue| -> Vec { match v { ParamValue::Number(n) => vec![*n], ParamValue::Point(p) => p.to_vec(), ParamValue::Argb(c) => c.iter().skip(1).copied().collect(), ParamValue::Index(i) => vec![f64::from(*i)], ParamValue::Bool(b) => vec![if *b { 1.0 } else { 0.0 }], other => panic!("unexpected {other:?}"), } }; match (a, b) { (None, None) => true, // The input layer names the owning layer by its id, which differs between the files. (Some(ParamValue::Layer(_)), Some(ParamValue::Layer(_))) => true, (Some(x), Some(y)) => comps(x).iter().zip(comps(y)).all(|(p, q)| (p - q).abs() < 1e-6), _ => false, } } #[test] fn every_effect_fixture_is_written_as_after_effects_wrote_it() { let names = skip!(fixtures_with_effects()); assert!(names.len() >= 20, "{names:?}"); let mut compared = 0; for name in &names { let bytes = fixture!(name); let original = parse(&bytes).expect("parse"); let mut p = Project::default(); let none = |_: &str| false; let imported = import(&mut p, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).expect("import"); let lossy: Vec = imported.report.losses.iter().flat_map(|l| l.details.clone()).collect(); let r = export_all(&p); assert_eq!(r.report.count(LossCategory::Effects), 0, "{name}: {:?}", r.report.losses); let exported = parse(&r.bytes).expect("parse export"); let (a, b) = (effects_of(&original), effects_of(&exported)); assert_eq!(a.len(), b.len(), "{name}: layers with effects"); for ((ka, fa), (kb, fb)) in a.iter().zip(&b) { assert_eq!(ka, kb, "{name}"); assert_eq!(fa.len(), fb.len(), "{name}: effect count"); for (x, y) in fa.iter().zip(fb) { assert_eq!((&x.match_name, &x.name), (&y.match_name, &y.name), "{name}"); // Written as the importer took it over (712: CC Radial Blur's Type Scratch has no // EffectCraft item, reported on import). if lossy.iter().any(|d| d.contains(&format!("({})", x.match_name))) { continue; } assert_eq!(x.params.len(), y.params.len(), "{name}: parameters of {}", x.match_name); for (px, py) in x.params.iter().zip(&y.params) { assert_eq!(px.0, py.0, "{name}"); assert!(close_value(&px.1, &py.1), "{name} {} #{}: {:?} vs {:?}", x.match_name, px.0, px.1, py.1); assert_eq!(px.2.len(), py.2.len(), "{name} #{} keys", px.0); // A colour key switched to bezier stores speed 0 / influence 0, which After // Effects interpolates linearly (598): it is imported and written linear. let colour = matches!(px.2.first().map(|k| k.1.len()), Some(4)); let lin = |i: (u8, u8)| if colour { (i.0.min(1), i.1.min(1)) } else { i }; for (kx, ky) in px.2.iter().zip(&py.2) { assert_eq!((kx.0, lin(kx.2)), (ky.0, lin(ky.2)), "{name} #{} key", px.0); assert!(kx.1.iter().zip(&ky.1).all(|(p, q)| (p - q).abs() < 1e-6), "{name} #{} key value {:?} {:?}", px.0, kx.1, ky.1); } } compared += 1; } } // The input layer of every exported instance names the layer that owns it (320). let view = effectcraft_aep::Project::new(&exported).expect("project"); for item in view.all_items() { let Some(comp) = item.comp() else { continue }; for layer in comp.layers() { let id = layer.header().map(|h| h.id).expect("header"); for e in layer.effects() { let input = view.effect_params(&e).into_iter().find(|p| p.index == 0).and_then(|p| p.stored_value()); assert_eq!(input, Some(ParamValue::Layer(id)), "{name}: input layer of {}", e.match_name()); } } } // The container is consistent and the result imports to the same effect values. assert_eq!(effectcraft_aep::write(&exported).expect("write"), r.bytes, "{name}"); let q = import_bytes(&r.bytes); assert_eq!(effect_values(&p), effect_values(&q), "{name}: values after a second import"); } assert!(compared >= 20, "{compared}"); } /// Every effect parameter of the project: (comp, layer, effect, param path, value, key count). fn effect_values(p: &Project) -> Vec<(String, String, String, String, Vec, usize)> { let mut out = Vec::new(); for item in p.items.values() { let ItemKind::Comp(c) = &item.kind else { continue }; for l in &c.layers { let Some(fx) = l.effects() else { continue }; for g in fx.groups() { let mut stack: Vec<(&PropGroup, String)> = vec![(g, String::new())]; while let Some((grp, pre)) = stack.pop() { for n in &grp.children { match n { Node::Group(s) => stack.push((s, format!("{pre}{}/", s.match_id))), Node::Prop(pr) => out.push(( item.name.clone(), l.name.clone(), g.name.clone(), format!("{pre}{}", pr.match_id), pr.value.components().iter().map(|v| (v * 1e6).round() as i64).collect(), pr.keys.len(), )), } } } } } } out.sort(); out } /// The Tint of 356 imported, edited by `f`. fn tint(f: impl FnOnce(&mut PropGroup)) -> Option { Some({ let mut p = import_bytes(&crate::common::try_fixture("356-fx-tint-default.aep")?); let id = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C"); with_layer(&mut p, id, 0, |l| { let g = l.props.sub_mut("effects").and_then(|e| e.children.iter_mut().find_map(|n| if let Node::Group(g) = n { Some(g) } else { None })).expect("tint"); f(g); }); p }) } #[test] fn edited_parameters_and_keys_no_fixture_has() { let p = skip!(tint(|g| { g.get_mut("black").expect("black").value = Value::Color([0.25, 0.5, 0.75, 1.0]); g.get_mut("white").expect("white").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]))]; // A keyed property shows its first key's value. g.get_mut("white").expect("white").value = Value::Color([1.0, 0.0, 0.0, 1.0]); g.get_mut("amount").expect("amount").value = Value::Scalar(10.0); let mut k0 = key(0.0, Value::Scalar(10.0)); k0.out_interp = Interp::Hold; g.get_mut("amount").expect("amount").keys = vec![k0, key(2.0, Value::Scalar(90.0))]; g.name = "My Tint".into(); })); let r = export_all(&p); assert_eq!(r.report.exported.effects, 1); assert_eq!(r.report.count(LossCategory::Effects), 0, "{:?}", r.report.losses); let file = parse(&r.bytes).expect("parse"); let seen = effects_of(&file); let fx = &seen.first().expect("layer").1[0]; assert_eq!(fx.name.as_deref(), Some("My Tint")); // As After Effects stores a renamed instance (610): fnam keeps the effect's name. let view = effectcraft_aep::Project::new(&file).expect("project"); let e = view.all_items().into_iter().find_map(|i| i.comp()).and_then(|c| c.layers().next()).map(|l| l.effects()).expect("effects"); assert_eq!((e[0].name().as_deref(), e[0].user_name().as_deref()), (Some("Tint"), Some("My Tint"))); let param = |i: usize| fx.params.iter().find(|p| p.0 == i).expect("param"); match ¶m(1).1 { Some(ParamValue::Argb(c)) => assert!((c[1] - 0.25).abs() < 1e-6 && (c[2] - 0.5).abs() < 1e-6 && (c[3] - 0.75).abs() < 1e-6, "{c:?}"), other => panic!("{other:?}"), } let white = ¶m(2).2; assert_eq!(white.len(), 2); assert_eq!(white[1].0, 25_600); assert_eq!(white[1].1, vec![255.0, 0.0, 0.0, 255.0]); let amount = ¶m(3).2; assert_eq!(amount.iter().map(|k| (k.0, k.1[0], k.2)).collect::>(), vec![(0, 10.0, (1, 3)), (51_200, 90.0, (1, 1))]); // And back. let q = import_bytes(&r.bytes); assert_eq!(effect_values(&p).into_iter().filter(|v| v.2 == "My Tint").count(), 3); assert_eq!( effect_values(&p) .into_iter() .map(|mut v| { v.1.clear(); v }) .collect::>(), effect_values(&q) .into_iter() .map(|mut v| { v.1.clear(); v }) .collect::>() ); } #[test] fn several_instances_share_one_definition_copy() { // Two Tints and a Ramp on one layer, a Ramp on a second layer. let mut p = import_bytes(&fixture!("383-fx-ramp-on-100x100-start-10-20.aep")); let tint_src = import_bytes(&fixture!("356-fx-tint-default.aep")); let tint_group = tint_src .items .values() .find_map(|i| if let ItemKind::Comp(c) = &i.kind { c.layers.first().and_then(|l| l.effects()).and_then(|e| e.groups().next().cloned()) } else { None }) .expect("tint"); let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C"); with_layer(&mut p, c, 0, |l| { let fx = l.props.sub_mut("effects").expect("effects"); fx.children.push(Node::Group(tint_group.clone())); fx.children.push(Node::Group(tint_group)); }); let r = export_all(&p); assert_eq!(r.report.exported.effects, 4, "{:?}", r.report.losses); let file = parse(&r.bytes).expect("parse"); let view = effectcraft_aep::Project::new(&file).expect("project"); let mut full = Vec::new(); for item in view.all_items() { let Some(comp) = item.comp() else { continue }; for layer in comp.layers() { for e in layer.effects() { full.push((e.match_name().to_string(), e.has_own_definitions())); } } } // The first instance of each type in the file holds the definitions. assert_eq!(full.iter().filter(|(_, own)| *own).count(), 2, "{full:?}"); assert_eq!(full.len(), 4); let defs: Vec = view.effect_definitions().into_iter().map(|(n, _)| n).collect(); assert_eq!(defs, vec!["ADBE Ramp".to_string(), "ADBE Tint".to_string()]); let pefl: Vec = file.list(b"Pefl").expect("Pefl").children().iter().filter_map(|c| c.bytes()).map(|b| String::from_utf8_lossy(b).to_string()).collect(); assert_eq!(pefl, vec!["ADBE Ramp".to_string(), "ADBE Tint".to_string()]); let efdc = file.list(b"EfdG").and_then(|g| g.child(b"EfDC")).and_then(|c| c.bytes()).expect("EfDC"); assert_eq!(efdc, &2u32.to_le_bytes()); // Every instance reads its parameters (borrowed definitions included) and imports again. let q = import_bytes(&r.bytes); assert_eq!(effect_values(&q).iter().map(|v| v.2.clone()).collect::>().len(), 2); let n: usize = q .items .values() .filter_map(|i| { if let ItemKind::Comp(c) = &i.kind { Some(c.layers.iter().filter_map(|l| l.effects()).map(|e| e.groups().count()).sum::()) } else { None } }) .sum(); assert_eq!(n, 4); } #[test] fn what_cannot_be_written_is_reported() { let p = skip!(tint(|g| { g.enabled = false; g.get_mut("amount").expect("amount").expr = Some(effectcraft_project::Expression { text: "50".into(), enabled: true }); })); let r = export_all(&p); // The switch (fx-switch cases) and the expression (534) are written; a second import gets the // effect switched off. assert_eq!(r.report.count(LossCategory::Effects), 0, "{:?}", r.report.losses); let back = import_bytes(&r.bytes); let off = back .comps() .flat_map(|(_, c)| c.layers.iter()) .filter_map(|l| l.props.sub("effects")) .flat_map(|e| e.children.iter()) .any(|n| matches!(n, Node::Group(g) if !g.enabled)); assert!(off, "the switched-off effect comes back switched off"); // An effect outside the table. let mut q = import_bytes(&fixture!("356-fx-tint-default.aep")); let c = q.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C"); with_layer(&mut q, c, 0, |l| { if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) { g.kind = effectcraft_project::GroupKind::Effect { effect: "ec.distort.notmapped".into() }; } }); let r = export_all(&q); assert_eq!(r.report.count(LossCategory::Effects), 1); assert_eq!(r.report.exported.effects, 0); parse(&r.bytes).expect("still a valid file"); } /// Lumetri Color is imported but not written (no prototype to write from): the export reports it /// as a loss and still produces a valid file. #[test] fn lumetri_color_is_reported_not_written() { let mut p = skip!(tint(|_| {})); let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C"); with_layer(&mut p, c, 0, |l| { if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) { g.kind = effectcraft_project::GroupKind::Effect { effect: "ec.color.lumetri".into() }; } }); let r = export_all(&p); assert_eq!(r.report.count(LossCategory::Effects), 1, "{:?}", r.report.losses); assert_eq!(r.report.exported.effects, 0); parse(&r.bytes).expect("still a valid file"); } /// CC Color Neutralizer, EXtractoR and IDentifier are in the import table but have no prototype /// to write from: each is reported, not written. #[test] fn imported_plug_in_effects_are_reported_not_written() { for effect in ["ec.color.cccolorneutralizer", "ec.3d.extractor", "ec.3d.identifier"] { let mut p = skip!(tint(|_| {})); let c = p.items.values().find(|i| i.name == "C").map(|i| i.id).expect("C"); with_layer(&mut p, c, 0, |l| { if let Some(Node::Group(g)) = l.props.sub_mut("effects").and_then(|e| e.children.first_mut()) { g.kind = effectcraft_project::GroupKind::Effect { effect: effect.into() }; } }); let r = export_all(&p); assert_eq!(r.report.count(LossCategory::Effects), 1, "{effect}: {:?}", r.report.losses); assert_eq!(r.report.exported.effects, 0, "{effect}"); parse(&r.bytes).expect("still a valid file"); } } #[test] fn hostile_effect_values_never_panic() { for v in [Value::Scalar(f64::NAN), Value::Scalar(1e300), Value::Color([f32::NAN as f64, 2.0, -1.0, 1.0]), Value::Bool(true), Value::Text(Box::default())] { let p = skip!(tint(|g| { g.get_mut("black").expect("black").value = v.clone(); g.get_mut("amount").expect("amount").value = v.clone(); g.get_mut("white").expect("white").keys = vec![key(0.0, v.clone()), key(0.0, v.clone()), key(1e12, v.clone())]; })); let r = export_all(&p); let file = parse(&r.bytes).expect("parse"); assert_eq!(effectcraft_aep::write(&file).expect("write"), r.bytes); let _ = import_bytes(&r.bytes); } } /// The static value records (`tdbs` with a `cdat`) of the mapped parameters of every effect /// instance, by (composition, layer index, effect index, record name). fn static_records(file: &AepFile<'_>) -> std::collections::BTreeMap<(String, usize, usize, String), effectcraft_aep::Chunk<'static>> { let view = effectcraft_aep::Project::new(file).expect("project"); let mut out = std::collections::BTreeMap::new(); for item in view.all_items() { let Some(comp) = item.comp() else { continue }; let cname = item.display_name().unwrap_or_default(); for (li, layer) in comp.layers().enumerate() { for (ei, e) in layer.effects().into_iter().enumerate() { let Some(spec) = effectcraft_aep::effect_map::by_match_name(e.match_name()) else { continue }; let Some(values) = e.chunk().child_list(b"tdgp") else { continue }; let k = values.children(); for (i, c) in k.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(index) = name.rsplit('-').next().and_then(|s| s.parse::().ok()) else { continue }; if !spec.params.iter().any(|p| p.index == index) { continue; } if let Some(rec) = k.get(i + 1).filter(|r| r.is_list(b"tdbs") && r.child(b"cdat").is_some()) { let mut rec = rec.clone().into_owned(); // A layer parameter's `tdpi` is a layer id, which differs between the // files (`layer_parameters_name_the_same_layers` checks the layers). if let effectcraft_aep::Body::List { children, .. } = &mut rec.body { for c in children.iter_mut().filter(|c| c.is(b"tdpi")) { *c = effectcraft_aep::Chunk::data(*b"tdpi", vec![0; 4]); } } out.insert((cname.clone(), li, ei, name), rec); } } } } } out } #[test] fn every_static_value_record_is_rebuilt_in_its_decoded_bytes() { // The records are built in code from the parameter's type and slider range (R45): every // static record After Effects wrote for a mapped parameter of an effect fixture comes back // with the same decoded bytes (the rest of the header is zero, the user name the placeholder). let mut compared = 0; for name in skip!(fixtures_with_effects()) { let bytes = fixture!(&name); let original = parse(&bytes).expect("parse"); let mut p = Project::default(); let none = |_: &str| false; let imported = import(&mut p, &bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).expect("import"); // An effect the importer could not take over completely (712: CC Radial Blur's Type // Scratch has no EffectCraft item) is written as EffectCraft has it. let lossy: Vec = imported.report.losses.iter().flat_map(|l| l.details.clone()).collect(); let r = export_all(&p); let exported = parse(&r.bytes).expect("parse export"); let skip = |k: &(String, usize, usize, String)| lossy.iter().any(|d| d.contains(k.3.rsplit_once('-').map_or("", |x| x.0))); let (mut a, mut b) = (static_records(&original), static_records(&exported)); a.retain(|k, _| !skip(k)); b.retain(|k, _| !skip(k)); for (key, rec) in &a { let got = b.get(key).unwrap_or_else(|| panic!("{name}: {key:?} not written")); assert_eq!(decoded(got), decoded(rec), "{name}: {key:?}"); compared += 1; } for key in b.keys() { assert!(a.contains_key(key), "{name}: {key:?} written but After Effects has none"); } } assert!(compared >= 60, "{compared}"); } #[test] fn hue_saturation_and_levels_data_is_written_as_after_effects_wrote_it() { // Master Hue / Lightness (444, 446) and Levels' RGB input black, gamma, output white (512, // 514, 516) live in the effect's arbitrary data: the exported `aRbp` equals After Effects'. for (name, param) in [ ("444-fx-huesat-hue-90.aep", "ADBE HUE SATURATION-0003"), ("446-fx-huesat-light-m30.aep", "ADBE HUE SATURATION-0003"), ("512-fx-levels-inblack-0.1.aep", "ADBE Easy Levels2-0002"), ("514-fx-levels-gamma-2.aep", "ADBE Easy Levels2-0002"), ("516-fx-levels-outwhite-0.9.aep", "ADBE Easy Levels2-0002"), ] { let bytes = fixture!(name); let data = |b: &[u8]| -> Vec { let f = parse(b).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() { for e in layer.effects() { if let Some(d) = e.arbitrary_data(param) { out = d.to_vec(); } } } } out }; let r = export_all(&import_bytes(&bytes)); assert!(!data(&bytes).is_empty(), "{name}"); assert_eq!(data(&r.bytes), data(&bytes), "{name}"); } } /// The layer each layer parameter names, by layer index (comp, layer, effect, /// parameter index, named layer or "None"). fn layer_params(file: &AepFile<'_>) -> Vec<(String, String, String, usize, String)> { let view = effectcraft_aep::Project::new(file).expect("project"); let mut out = Vec::new(); for item in view.all_items() { let Some(comp) = item.comp() else { continue }; let cname = item.display_name().unwrap_or_default(); let names: std::collections::HashMap = comp.layers().enumerate().filter_map(|(i, l)| Some((l.header()?.id, format!("#{i}")))).collect(); for (i, layer) in comp.layers().enumerate() { let lname = format!("#{i}"); for e in layer.effects() { for p in view.effect_params(&e).into_iter().filter(|p| p.index > 0 && p.kind == effectcraft_aep::ParamKind::Layer) { let named = match p.stored_value() { Some(ParamValue::Layer(0)) => "None".to_string(), Some(ParamValue::Layer(id)) => names.get(&id).cloned().unwrap_or_else(|| format!("? {id}")), other => format!("{other:?}"), }; out.push((cname.clone(), lname.clone(), e.match_name().to_string(), p.index, named)); } } } } out.sort(); out } #[test] fn layer_parameters_name_the_same_layers() { // 905: Compound Blur, Displacement Map and Set Matte name the grey solid R (904 E22-E24); // 220 / 320 / 321: Layer Control on another layer, on itself, None. let mut checked = 0; for name in ["905-fx-24-effects-set-one-comp.aep", "220-fx-layercontrol-to-layer1.aep", "320-fx-layercontrol-self-explicit.aep", "321-fx-layercontrol-none.aep"] { let bytes = fixture!(name); let original = parse(&bytes).expect("parse"); let p = import_bytes(&bytes); let r = export_all(&p); assert_eq!(r.report.count(LossCategory::Effects), 0, "{name}: {:?}", r.report.losses); let exported = parse(&r.bytes).expect("parse export"); let (a, b) = (layer_params(&original), layer_params(&exported)); assert!(!a.is_empty(), "{name}"); assert_eq!(a, b, "{name}"); checked += 1; } assert_eq!(checked, 4); } /// Sets every property with this match id (in every effect of every layer) to `v`. fn set_all(p: &mut Project, id: &str, v: Value) -> usize { fn walk(g: &mut PropGroup, id: &str, v: &Value, n: &mut usize) { for c in &mut g.children { match c { Node::Prop(pr) if pr.match_id == id => { pr.value = v.clone(); *n += 1; } Node::Group(sub) => walk(sub, id, v, n), _ => {} } } } let mut n = 0; let ids: Vec<_> = p.comps().map(|(i, _)| *i).collect(); for cid in ids { let Some(mut c) = p.comp(cid).cloned() else { continue }; for l in &mut c.layers { walk(&mut l.props, id, &v, &mut n); } if let Some(it) = p.item_mut(cid) { it.kind = ItemKind::Comp(std::sync::Arc::new(c)); } } n } fn find_id<'a>(g: &'a PropGroup, id: &str) -> Option<&'a effectcraft_project::Property> { g.children.iter().find_map(|c| match c { Node::Prop(pr) if pr.match_id == id => Some(pr), Node::Group(sub) => find_id(sub, id), _ => None, }) } #[test] fn an_explicit_none_is_written_as_none() { // After Effects' None on Compound Blur / Displacement Map / Set Matte (`tdpi` 0) differs from // the owning layer (fx27 `layer-none`); it is EffectCraft's `NO_LAYER` both ways. let mut p = import_bytes(&fixture!("905-fx-24-effects-set-one-comp.aep")); let none = Value::Layer(Some(effectcraft_project::NO_LAYER)); let n: usize = ["blurLayer", "displacementMapLayer", "takeMatteFromLayer"].iter().map(|id| set_all(&mut p, id, none.clone())).sum(); assert_eq!(n, 3); let r = export_all(&p); assert_eq!(r.report.count(LossCategory::Effects), 0, "{:?}", r.report.losses); let named: Vec<_> = layer_params(&parse(&r.bytes).expect("parse")).into_iter().filter(|x| x.2 != "ADBE Layer Control").map(|x| x.4).collect(); assert_eq!(named, ["None", "None", "None"]); let q = import_bytes(&r.bytes); let mut back = 0; for (_, c) in q.comps() { for l in &c.layers { for id in ["blurLayer", "displacementMapLayer", "takeMatteFromLayer"] { if let Some(pr) = find_id(&l.props, id) { assert_eq!(pr.value, none, "{id}"); back += 1; } } } } assert_eq!(back, 3); } #[test] fn layer_control_on_a_named_layer_needs_no_source_choice() { // Layer Control has no source choice in After Effects: a companion other than Source (a // Layer Control made here defaults to Effects & Masks) is no loss. let mut p = import_bytes(&fixture!("220-fx-layercontrol-to-layer1.aep")); assert_eq!(set_all(&mut p, "layerSource", Value::Enum(2)), 1); let r = export_all(&p); assert_eq!(r.report.count(LossCategory::Effects), 0, "{:?}", r.report.losses); } #[test] fn unmapped_layer_parameters_keep_their_default() { // Timewarp's Matte Layer and Warp Layer are not in the table; their default is None (`tdpi` // 0), which the export keeps: the re-import reports nothing (529-533). Written as the owning // layer, the re-import reported both as "not imported". for name in ["529-fx-timewarp-default.aep", "531-fx-timewarp-speed-keyed-50-200.aep"] { let p = import_bytes(&fixture!(name)); let r = export_all(&p); let mut q = Project::default(); let none = |_: &str| false; let back = import(&mut q, &r.bytes, &ImportOptions { allow_losses: true, ..ImportOptions::new(&none) }).expect("re-import"); assert_eq!(back.report.count(effectcraft_aep::import::LossCategory::Effects), 0, "{name}: {:?}", back.report.losses); } } /// The arbitrary data of the first instance of `match_name` in a file. fn arb(bytes: &[u8], match_name: &str, record: &str) -> Vec { 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 fx = comp.layers().flat_map(|l| l.effects()).find(|e| e.match_name() == match_name).expect("effect"); fx.arbitrary_data(record).expect("data").to_vec() } #[test] fn curves_and_mesh_warp_are_written_as_after_effects_stores_them() { // Curves: the points and the tables rebuilt from them are After Effects' bytes (963, 964). for name in ["963-curves-red-mid-down-on-S.aep", "964-curves-rgb-s-curve-on-S.aep"] { let bytes = fixture!(name); let r = export_all(&import_bytes(&bytes)); assert_eq!(r.report.count(LossCategory::Effects), 0, "{name}: {:?}", r.report.losses); assert_eq!(arb(&r.bytes, "ADBE CurvesCustom", "ADBE CurvesCustom-0001"), arb(&bytes, "ADBE CurvesCustom", "ADBE CurvesCustom-0001"), "{name}"); } // Mesh Warp (985): the same grid, every coordinate within 1e-5 of the layer. let bytes = fixture!("985-meshwarp-3x3-vertex-moved-on-S.aep"); let r = export_all(&import_bytes(&bytes)); assert_eq!(r.report.count(LossCategory::Effects), 0, "{:?}", r.report.losses); let (a, b) = (arb(&r.bytes, "ADBE MESH WARP", "ADBE MESH WARP-0004"), arb(&bytes, "ADBE MESH WARP", "ADBE MESH WARP-0004")); let (ma, mb) = (effectcraft_aep::MeshData::parse(&a).expect("written"), effectcraft_aep::MeshData::parse(&b).expect("stored")); assert_eq!((ma.columns, ma.rows, ma.tail), (mb.columns, mb.rows, mb.tail)); for (x, y) in ma.vertices.iter().zip(&mb.vertices) { for (p, q) in x.iter().zip(y) { assert!((p[0] - q[0]).abs() < 1e-5 && (p[1] - q[1]).abs() < 1e-5, "{p:?} vs {q:?}"); } } } #[test] fn mesh_handles_moved_on_their_own_survive_the_round_trip() { // 985 imported, then the right handle of vertex (2, 2) moved by 30 px on its own: the importer // keeps such moves as extra numbers of the vertex (EffectCraft draws a bilinear mesh). let mut p = import_bytes(&fixture!("985-meshwarp-3x3-vertex-moved-on-S.aep")); let id: Vec<_> = p.comps().map(|(i, _)| *i).collect(); let mut tokens = Vec::new(); for cid in id { let Some(mut c) = p.comp(cid).cloned() else { continue }; for l in &mut c.layers { fn walk(g: &mut PropGroup, tokens: &mut Vec) { for n in &mut g.children { match n { Node::Prop(pr) if pr.match_id == "mesh" => { if let Value::Str(s) = &pr.value { let mut t: Vec = s.split(' ').map(str::to_string).collect(); t[10] = format!("{},0,0,30,0,0,0,0,0", t[10]); *tokens = t.clone(); pr.value = Value::Str(t.join(" ")); } } Node::Group(sub) => walk(sub, tokens), _ => {} } } } walk(&mut l.props, &mut tokens); } if let Some(it) = p.item_mut(cid) { it.kind = ItemKind::Comp(std::sync::Arc::new(c)); } } assert_eq!(tokens.len(), 16); let r = export_all(&p); let data = arb(&r.bytes, "ADBE MESH WARP", "ADBE MESH WARP-0004"); let m = effectcraft_aep::MeshData::parse(&data).expect("mesh"); let d = effectcraft_aep::MeshData::default_vertex(3, 3, 2, 2); let v = m.vertices[10]; // The right handle: default + the vertex's move + 30 px. assert!((v[2][0] - (d[2][0] + (132.0 + 30.0) / 1920.0)).abs() < 1e-5, "{:?}", v[2]); assert!((v[1][0] - (d[1][0] + 132.0 / 1920.0)).abs() < 1e-5, "{:?}", v[1]); // Re-imported, the vertex keeps the handle move. let q = import_bytes(&r.bytes); let mut found = false; for (_, c) in q.comps() { for l in &c.layers { if let Some(Value::Str(s)) = find_id(&l.props, "mesh").map(|p| p.value.clone()) { let t: Vec<&str> = s.split(' ').collect(); let n: Vec = t[10].split(',').map(|x| x.parse().expect("number")).collect(); assert_eq!(n.len(), 10, "{}", t[10]); assert!((n[4] - 30.0).abs() < 0.01 && (n[0] - 132.0).abs() < 0.01, "{n:?}"); found = true; } } } assert!(found); }