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.
128 lines
7.0 KiB
Rust
128 lines
7.0 KiB
Rust
//! Keys on time-stretched layers (cases 641-643): the key times count source time in the layer's
|
|
//! time scale (the composition's x stretch at 100 % or more, the composition's below).
|
|
|
|
use crate::export::helpers::*;
|
|
use effectcraft_aep::export::LossCategory;
|
|
use effectcraft_aep::{PropNode, parse};
|
|
use effectcraft_keyframe::{Interp, Value};
|
|
use effectcraft_project::{LayerSource, Project};
|
|
use effectcraft_time::FrameRate;
|
|
|
|
/// (stretch numerator, denominator, start units, Position time scale, key times) of the first
|
|
/// layer of the first composition.
|
|
fn position_keys(bytes: &[u8]) -> (i32, u32, i32, u32, Vec<i32>) {
|
|
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("layer");
|
|
let h = layer.header().expect("header");
|
|
let Some(PropNode::Property(pos)) = layer.properties().and_then(|g| g.find(&["ADBE Transform Group", "ADBE Position"])) else { panic!("position") };
|
|
let times = pos.keys().expect("keys").keyframes().filter_map(|k| k.time_units()).collect();
|
|
(h.stretch, h.stretch_den, h.start.num as i32, pos.time_scale().expect("time scale"), times)
|
|
}
|
|
|
|
fn reimport(bytes: &[u8]) -> Project {
|
|
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");
|
|
p
|
|
}
|
|
|
|
#[test]
|
|
fn keys_on_stretched_layers_are_written_as_after_effects_stores_them() {
|
|
for name in ["641-stretch-200-keys-position-1s-2s.aep", "642-stretch-50-keys-position-1s-2s.aep", "643-stretch-200-start-1-keys-position-2s-3s.aep"] {
|
|
let bytes = fixture!(name);
|
|
let p1 = reimport(&bytes);
|
|
let r = export_all(&p1);
|
|
assert!(r.report.is_lossless(), "{name}: {:?}", r.report.losses);
|
|
assert_eq!(position_keys(&r.bytes), position_keys(&bytes), "{name}");
|
|
// A second import gives the same layer and keys.
|
|
let p2 = reimport(&r.bytes);
|
|
let layer = |p: &Project| p.comps().next().map(|(_, c)| c.layers[0].clone()).expect("layer");
|
|
let (a, b) = (layer(&p1), layer(&p2));
|
|
assert_eq!((a.stretch, a.start_time, a.in_point, a.out_point), (b.stretch, b.start_time, b.in_point, b.out_point), "{name}");
|
|
let keys =
|
|
|l: &effectcraft_project::Layer| l.props.prop("transform/position").map(|p| p.keys.iter().map(|k| (k.time, k.value.clone())).collect::<Vec<_>>());
|
|
assert_eq!(keys(&a), keys(&b), "{name}");
|
|
}
|
|
// What the cases hold (MANIFEST): 200 % keys at comp 1 s / 2 s = units 25 600 / 51 200 at
|
|
// time scale 51 200; 50 % at time scale 25 600 = 51 200 / 102 400.
|
|
assert_eq!(position_keys(&fixture!("641-stretch-200-keys-position-1s-2s.aep")), (2, 1, 0, 51200, vec![25600, 51200]));
|
|
assert_eq!(position_keys(&fixture!("642-stretch-50-keys-position-1s-2s.aep")), (1, 2, 0, 25600, vec![51200, 102400]));
|
|
}
|
|
|
|
#[test]
|
|
fn a_comp_time_survives_any_stretch() {
|
|
// Keys built in code on a 150 % layer starting at 0.4 s: the key at layer time 2 s is at
|
|
// comp 0.4 + 3 s, in source units 2 x 38 400.
|
|
let mut p = Project::default();
|
|
let c = add_comp(&mut p, "C", 640, 360, FrameRate::new(25, 1), 10.0, None);
|
|
let s = add_solid(&mut p, "S", 100, 100, [1.0, 0.0, 0.0], None);
|
|
add_layer(&mut p, c, "S", LayerSource::Solid { item: s }, (100, 100));
|
|
with_layer(&mut p, c, 0, |l| {
|
|
l.stretch = 150.0;
|
|
l.start_time = secs(0.4);
|
|
let pos = l.props.prop_mut("transform/position").expect("pos");
|
|
pos.keys = vec![key(1.0, Value::Vec3([0.0, 0.0, 0.0])), key(2.0, Value::Vec3([100.0, 0.0, 0.0]))];
|
|
});
|
|
// The in point (comp 0 s) is 0.4 s before the start: -10 240 units, -6 826.67 in source time
|
|
// at 150 %, which the format cannot hold. It is rounded, and the rounding is reported.
|
|
let r = export_all(&p);
|
|
assert_eq!(r.report.count(LossCategory::RoundedTimes), 1, "{:?}", r.report.losses);
|
|
let in_point = |p: &Project| p.comps().next().map(|(_, c)| c.layers[0].in_point.seconds()).expect("layer");
|
|
let moved = (in_point(&reimport(&r.bytes)) - in_point(&p)).abs();
|
|
assert!(moved > 0.0 && moved < 1.5 / 25_600.0, "{moved}");
|
|
// With the in point at the start, nothing is rounded.
|
|
with_layer(&mut p, c, 0, |l| l.in_point = l.start_time);
|
|
let r = export_all(&p);
|
|
assert!(r.report.is_lossless(), "{:?}", r.report.losses);
|
|
let (n, d, _, ts, times) = position_keys(&r.bytes);
|
|
assert_eq!((n, d, ts, times), (3, 2, 38400, vec![38400, 76800]));
|
|
// Bezier ease on a stretched layer is written but reported (its speed unit is not sampled).
|
|
with_layer(&mut p, c, 0, |l| {
|
|
let pos = l.props.prop_mut("transform/position").expect("pos");
|
|
pos.keys[0].out_interp = Interp::Bezier;
|
|
});
|
|
assert_eq!(export_all(&p).report.count(LossCategory::ApproximatedKeyframes), 1);
|
|
assert_eq!(export_all(&p).report.count(LossCategory::TimeStretch), 0);
|
|
}
|
|
|
|
/// (stretch numerator, denominator, start, in, out as stored; Opacity time scale and key times).
|
|
fn reversed_header(bytes: &[u8]) -> (i32, u32, i64, i64, i64, u32, Vec<i32>) {
|
|
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("layer");
|
|
let h = layer.header().expect("header");
|
|
let (ts, times) = match layer.properties().and_then(|g| g.find(&["ADBE Transform Group", "ADBE Opacity"])) {
|
|
Some(PropNode::Property(p)) if p.is_animated() => {
|
|
(p.time_scale().unwrap_or(0), p.keys().expect("keys").keyframes().filter_map(|k| k.time_units()).collect())
|
|
}
|
|
_ => (0, Vec::new()),
|
|
};
|
|
(h.stretch, h.stretch_den, h.start.num, h.in_relative.num, h.out_relative.num, ts, times)
|
|
}
|
|
|
|
#[test]
|
|
fn a_reversed_layer_is_written_as_after_effects_stores_it() {
|
|
// 981-983: a negative stretch numerator, the start at the later end, in / out relative in
|
|
// source time, keys in the composition's time scale.
|
|
for name in ["981-layer-stretch-minus100-start5.aep", "982-layer-stretch-minus100-opacity-keys.aep", "983-layer-stretch-minus50-opacity-keys.aep"] {
|
|
let bytes = fixture!(name);
|
|
let p1 = reimport(&bytes);
|
|
let l = p1.comps().next().map(|(_, c)| c.layers[0].clone()).expect("layer");
|
|
assert!(l.stretch < 0.0 && l.in_point < l.out_point, "{name}: {} {:?} {:?}", l.stretch, l.in_point, l.out_point);
|
|
let r = export_all(&p1);
|
|
assert!(r.report.is_lossless(), "{name}: {:?}", r.report.losses);
|
|
assert_eq!(reversed_header(&r.bytes), reversed_header(&bytes), "{name}");
|
|
let p2 = reimport(&r.bytes);
|
|
let m = p2.comps().next().map(|(_, c)| c.layers[0].clone()).expect("layer");
|
|
assert_eq!((l.stretch, l.start_time, l.in_point, l.out_point), (m.stretch, m.start_time, m.in_point, m.out_point), "{name}");
|
|
}
|
|
}
|