mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-07 11:01:36 +00:00
Add After Effects project export with chunks built in code, explicit loss reporting, engine and UI integration, and regression coverage.
716 lines
34 KiB
Rust
716 lines
34 KiB
Rust
//! 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<Vec<String>> {
|
|
Some({
|
|
let mut v: Vec<String> = 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<String>,
|
|
params: Vec<(usize, Option<ParamValue>, Vec<(i32, Vec<f64>, (u8, u8))>)>,
|
|
}
|
|
|
|
/// The effects of every layer, by (composition name, layer index).
|
|
fn effects_of(file: &AepFile<'_>) -> Vec<((String, usize), Vec<Seen>)> {
|
|
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<ParamValue>, b: &Option<ParamValue>) -> bool {
|
|
let comps = |v: &ParamValue| -> Vec<f64> {
|
|
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<String> = 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<i64>, 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<Project> {
|
|
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<_>>(), 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::<Vec<_>>(),
|
|
effect_values(&q)
|
|
.into_iter()
|
|
.map(|mut v| {
|
|
v.1.clear();
|
|
v
|
|
})
|
|
.collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[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<String> = view.effect_definitions().into_iter().map(|(n, _)| n).collect();
|
|
assert_eq!(defs, vec!["ADBE Ramp".to_string(), "ADBE Tint".to_string()]);
|
|
let pefl: Vec<String> =
|
|
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::<std::collections::BTreeSet<_>>().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::<usize>()) } 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");
|
|
}
|
|
|
|
#[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::<usize>().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<String> = 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<u8> {
|
|
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<u32, String> = 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<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");
|
|
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<String>) {
|
|
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<String> = 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<f64> = 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);
|
|
}
|