mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-11 16:33:01 -06:00
Merge main into #2452; resolve crates/engine/src/cmd/animation.rs crates/engine/src/tests/effects.rs
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
|
||||
use effectcraft_effects::camera_tracker as ct;
|
||||
use effectcraft_geom::vec3;
|
||||
use effectcraft_project::LayerId;
|
||||
use effectcraft_project::{Comp, Layer, LayerId, PropGroup};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_render::three_d::camera::active_camera;
|
||||
@@ -11,6 +11,7 @@ use effectcraft_track::camtrack::linalg::{self, M3, V3};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::camera_track::Placed;
|
||||
use crate::tests::track::{H, W, frame_time, setup, texture};
|
||||
|
||||
const F: f64 = 260.0;
|
||||
@@ -77,10 +78,9 @@ fn status(s: &mut Session) -> Value {
|
||||
s.execute("camera.solveStatus", json!({})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_camera_solves_and_creates_layers_on_the_plane() {
|
||||
let (mut s, clip) = tracked(json!({}));
|
||||
let st = status(&mut s);
|
||||
/// Solved with a sane error and focal length; returns the Camera Tracker effect group.
|
||||
fn assert_solved(s: &mut Session, clip: LayerId) -> PropGroup {
|
||||
let st = status(s);
|
||||
assert_eq!(st["solved"], true, "{st}");
|
||||
let err = st["averageError"].as_f64().unwrap();
|
||||
assert!(err < 0.5, "average error {err}");
|
||||
@@ -90,9 +90,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
|
||||
// Method Used / Average Error are reported in the effect.
|
||||
let g = s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().next().unwrap().clone();
|
||||
assert!(matches!(g.prop(ct::METHOD_USED).map(|p| &p.value), Some(effectcraft_keyframe::Value::Str(m)) if !m.is_empty()));
|
||||
g
|
||||
}
|
||||
|
||||
// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the
|
||||
// ones seen below the horizon at the bottom of the frame).
|
||||
/// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the
|
||||
/// ones seen below the horizon at the bottom of the frame).
|
||||
fn select_floor_points(s: &mut Session) -> Vec<u64> {
|
||||
s.execute("time.set", json!({"time": frame_time(12).seconds()})).unwrap();
|
||||
let pts = s.execute("camera.points", json!({})).unwrap();
|
||||
let list = pts["points"].as_array().unwrap();
|
||||
@@ -101,32 +104,19 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
|
||||
assert!(floor.len() >= 3, "{floor:?}");
|
||||
s.execute("camera.selectPoints", json!({"points": floor})).unwrap();
|
||||
assert_eq!(s.state.camera_points.len(), floor.len());
|
||||
floor
|
||||
}
|
||||
|
||||
// Create Solid and Camera: one undo step, camera + solid.
|
||||
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap();
|
||||
let cam = LayerId(r["camera"].as_u64().unwrap());
|
||||
let solid = LayerId(r["layers"][0].as_u64().unwrap());
|
||||
let comp = s.active_comp().unwrap().clone();
|
||||
assert!(comp.layer(cam).unwrap().is_camera());
|
||||
assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera");
|
||||
assert_eq!(comp.layers[0].id, cam);
|
||||
let sl = comp.layer(solid).unwrap();
|
||||
assert!(sl.switches.three_d);
|
||||
assert!(sl.name.starts_with("Track Solid"));
|
||||
// The camera is keyed on every frame.
|
||||
assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25);
|
||||
|
||||
// Every solved point projects through the created camera exactly where the solve puts it,
|
||||
// and onto its tracked position (the footage fills the comp, so layer pixels are comp
|
||||
// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers:
|
||||
// features on occlusion edges), 0.5 px on average.
|
||||
/// Every solved point projects through the created camera exactly where the solve puts it,
|
||||
/// and onto its tracked position (the footage fills the comp, so layer pixels are comp
|
||||
/// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers:
|
||||
/// features on occlusion edges), 0.5 px on average.
|
||||
fn assert_reprojection(s: &Session, comp: &Comp, pl: &Placed, g: &PropGroup) {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let uid = g.uid;
|
||||
let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap();
|
||||
let tracks = ct::tracks(&crate::camera_track::static_params(&g)).unwrap();
|
||||
let tracks = ct::tracks(&crate::camera_track::static_params(g)).unwrap();
|
||||
let mut errs = vec![];
|
||||
for k in [0u32, 6, 12, 18, 24] {
|
||||
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(k));
|
||||
let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(k));
|
||||
let camst = active_camera(&ctx);
|
||||
for sp in pl.solve.visible(k as usize) {
|
||||
let Some(obs) = tracks.track(sp.id).and_then(|t| t.at(k)) else { continue };
|
||||
@@ -142,34 +132,31 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
|
||||
let p95 = errs[errs.len() * 95 / 100];
|
||||
let mean = errs.iter().sum::<f64>() / errs.len() as f64;
|
||||
assert!(p95 < 1.0 && mean < 0.5, "p95 {p95} px, mean {mean} px");
|
||||
}
|
||||
|
||||
// The solid lies on the selected points' plane.
|
||||
/// The solid lies on the selected points' plane and faces the camera.
|
||||
fn assert_solid_on_plane(s: &Session, comp: &Comp, sl: &Layer, pl: &Placed, size: f64) {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let tr = sl.props.sub("transform").unwrap();
|
||||
let pos = tr.get("position").unwrap().value.as_vec3();
|
||||
let ori = tr.get("orientation").unwrap().value.as_vec3();
|
||||
let normal = linalg::mv(&linalg::from_euler_xyz(ori), [0.0, 0.0, -1.0]);
|
||||
let size = r["target"]["size"].as_f64().unwrap();
|
||||
for id in &s.state.camera_points {
|
||||
let w = pl.to_world(pl.solve.point(*id).unwrap().pos);
|
||||
let d = linalg::dot(linalg::sub(w, pos), normal).abs();
|
||||
assert!(d < 0.05 * size.max(1.0) + 1.0, "point {id} is {d} px off the plane");
|
||||
}
|
||||
// The solid faces the camera.
|
||||
let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(12));
|
||||
let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(12));
|
||||
let eye = active_camera(&ctx).eye;
|
||||
assert!(linalg::dot(normal, linalg::sub([eye.x, eye.y, eye.z], pos)) > 0.0);
|
||||
}
|
||||
|
||||
// Undo removes both layers; redo brings them back.
|
||||
let before = comp.layers.len();
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), before - 2);
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), before);
|
||||
|
||||
// A second create reuses the camera: Create Multiple Nulls and Camera.
|
||||
/// A second create reuses the camera (Create Multiple Nulls and Camera), then Text and
|
||||
/// Shadow Catcher (+ light).
|
||||
fn assert_create_kinds(s: &mut Session, cam: LayerId, n_floor: usize) {
|
||||
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null", "multiple": true})).unwrap();
|
||||
assert_eq!(r["camera"].as_u64().unwrap(), cam.0);
|
||||
assert_eq!(r["layers"].as_array().unwrap().len(), floor.len());
|
||||
assert_eq!(r["layers"].as_array().unwrap().len(), n_floor);
|
||||
// Text and Shadow Catcher (+ light).
|
||||
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "text"})).unwrap();
|
||||
assert_eq!(r["layers"].as_array().unwrap().len(), 1);
|
||||
@@ -180,11 +167,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
|
||||
assert_eq!(comp.layer(ids[0]).unwrap().props.prop("materialOptions/acceptsShadows").unwrap().value.as_enum(), 2);
|
||||
assert!(comp.layer(ids[1]).unwrap().is_light());
|
||||
assert!(s.execute_checked("camera.createFromSolve", json!({"kind": "teapot"})).is_err());
|
||||
}
|
||||
|
||||
// Set Ground Plane and Origin: the target becomes the origin; a null created there sits at
|
||||
// (0, 0, 0) lying on the X-Z plane (its front facing up, −y).
|
||||
/// Set Ground Plane and Origin, then an explicit target.
|
||||
fn assert_ground_and_target(s: &mut Session) {
|
||||
s.execute_checked("camera.setGroundPlane", json!({})).unwrap();
|
||||
assert_eq!(status(&mut s)["groundPlane"], true);
|
||||
assert_eq!(status(s)["groundPlane"], true);
|
||||
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null"})).unwrap();
|
||||
let null = LayerId(r["layers"][0].as_u64().unwrap());
|
||||
let tr = s.active_comp().unwrap().layer(null).unwrap().props.sub("transform").unwrap().clone();
|
||||
@@ -203,6 +191,44 @@ fn track_camera_solves_and_creates_layers_on_the_plane() {
|
||||
assert!(linalg::norm(linalg::sub(pos, [10.0, 0.0, 20.0])) < 1e-6, "{pos:?}");
|
||||
let front = linalg::mv(&linalg::from_euler_xyz(tr.get("orientation").unwrap().value.as_vec3()), [0.0, 0.0, -1.0]);
|
||||
assert!(linalg::norm(linalg::sub(front, [0.0, 0.0, -1.0])) < 1e-6, "{front:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_camera_solves_and_creates_layers_on_the_plane() {
|
||||
let (mut s, clip) = tracked(json!({}));
|
||||
let g = assert_solved(&mut s, clip);
|
||||
let floor = select_floor_points(&mut s);
|
||||
|
||||
// Create Solid and Camera: one undo step, camera + solid.
|
||||
let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap();
|
||||
let cam = LayerId(r["camera"].as_u64().unwrap());
|
||||
let solid = LayerId(r["layers"][0].as_u64().unwrap());
|
||||
let comp = s.active_comp().unwrap().clone();
|
||||
assert!(comp.layer(cam).unwrap().is_camera());
|
||||
assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera");
|
||||
assert_eq!(comp.layers[0].id, cam);
|
||||
let sl = comp.layer(solid).unwrap();
|
||||
assert!(sl.switches.three_d);
|
||||
assert!(sl.name.starts_with("Track Solid"));
|
||||
// The camera is keyed on every frame.
|
||||
assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25);
|
||||
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let uid = g.uid;
|
||||
let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap();
|
||||
assert_reprojection(&s, &comp, &pl, &g);
|
||||
assert_solid_on_plane(&s, &comp, sl, &pl, r["target"]["size"].as_f64().unwrap());
|
||||
|
||||
// Undo removes both layers; redo brings them back.
|
||||
let before = comp.layers.len();
|
||||
assert!(s.undo());
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), before - 2);
|
||||
assert!(s.redo());
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), before);
|
||||
|
||||
assert_create_kinds(&mut s, cam, floor.len());
|
||||
|
||||
assert_ground_and_target(&mut s);
|
||||
|
||||
// Serde keeps the analysis.
|
||||
let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap();
|
||||
|
||||
@@ -334,3 +334,454 @@ fn pick_matches_the_render_on_precomps_with_and_without_preserve_resolution() {
|
||||
assert!(bad.is_empty(), "preserve {preserve}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/// `w` × `h` footage whose Cryptomatte has an ID per 37 × 29 px cell (`c<col>_<row>`, all in the
|
||||
/// manifest), one aux pixel per layer pixel. Built once, as the media pool keeps decoded aux.
|
||||
struct Cells(u32, u32, std::sync::OnceLock<Arc<AuxChannels>>);
|
||||
impl Cells {
|
||||
fn name(x: usize, y: usize) -> String {
|
||||
format!("c{}_{}", x / 37, y / 29)
|
||||
}
|
||||
}
|
||||
impl FootageSource for Cells {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::new(f.width, f.height)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
|
||||
Some(self.2.get_or_init(|| Arc::new(self.build())).clone())
|
||||
}
|
||||
}
|
||||
impl Cells {
|
||||
fn build(&self) -> AuxChannels {
|
||||
let (w, h) = (self.0 as usize, self.1 as usize);
|
||||
let ids = (0..w * h).map(|i| crypto_float(crypto_hash(&Cells::name(i % w, i / w)))).collect();
|
||||
let mut aux = AuxChannels::new(w as u32, h as u32, 1.0);
|
||||
aux.channels = vec![("CryptoObject00.R".into(), ids), ("CryptoObject00.G".into(), vec![1.0; w * h])];
|
||||
let names = (0..w.div_ceil(37)).flat_map(|x| (0..h.div_ceil(29)).map(move |y| Cells::name(x * 37, y * 29)));
|
||||
aux.manifests = vec![("CryptoObject".into(), names.map(|n| (n.clone(), crypto_hash(&n))).collect())];
|
||||
aux
|
||||
}
|
||||
}
|
||||
|
||||
struct CellsProbe(u32, u32);
|
||||
impl crate::Importer for CellsProbe {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
// `…_proxy` is a half-size proxy (rounded up); `…still…` a still image.
|
||||
let (width, height) = if path.contains("proxy") { (self.0.div_ceil(2), self.1.div_ceil(2)) } else { (self.0, self.1) };
|
||||
let (frame_rate, duration) = (FrameRate::from_f64(24.0), Tick::from_seconds_f64(2.0));
|
||||
let kind = if path.contains("still") { FootageKind::Still } else { FootageKind::Video };
|
||||
Ok(Footage { path: path.into(), kind, width, height, has_video: true, frame_rate, duration, ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
fn cells_session(w: u32, h: u32, blur: bool) -> Session {
|
||||
cells_session_at(w, h, blur, "/renders/cells.exr")
|
||||
}
|
||||
|
||||
fn cells_session_at(w: u32, h: u32, blur: bool, path: &str) -> Session {
|
||||
let mut s = Session { footage: Arc::new(Cells(w, h, Default::default())), importer: Some(Arc::new(CellsProbe(w, h))), ..Default::default() };
|
||||
let item = s.execute("file.import", json!({"paths": [path]})).unwrap()["items"][0].clone();
|
||||
s.execute("comp.new", json!({"name": "C", "width": w, "height": h, "frameRate": 24, "duration": 2})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
if blur {
|
||||
s.execute_checked("effect.apply", json!({"effect": "Gaussian Blur"})).unwrap();
|
||||
s.execute_checked("prop.set", json!({"path": "effects/#1/blurriness", "value": 40.0})).unwrap();
|
||||
}
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// Hover latency of `effect.cryptomatteAt` (dev profile): the first call after the viewer's frame
|
||||
/// and the next ones, on footage (1080p, 4K, 4K behind a blur) and a 1080p 3D precomp. Run with
|
||||
/// `--ignored --nocapture`; it measures, it doesn't assert.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn hover_latency() {
|
||||
let mut cases: Vec<(&str, Session)> = vec![
|
||||
("footage 1920x1080", cells_session(1920, 1080, false)),
|
||||
("footage 3840x2160", cells_session(3840, 2160, false)),
|
||||
("footage 3840x2160 + blur 40", cells_session(3840, 2160, true)),
|
||||
];
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 1920, "height": 1080, "frameRate": 24, "duration": 2})).unwrap();
|
||||
let a = s.execute("layer.newSolid", json!({"name": "A", "width": 1920, "height": 1080})).unwrap()["layer"].clone();
|
||||
let b = s.execute("layer.newSolid", json!({"name": "B", "width": 400, "height": 1080})).unwrap()["layer"].clone();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.precompose", json!({"layers": [a, b], "name": "Inner"})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [s.active_comp().unwrap().layers[0].id.0]})).ok();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
cases.push(("3D precomp 1920x1080", s));
|
||||
for (name, mut s) in cases {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let _ = s.render(cid, Tick::ZERO, effectcraft_render::RenderOpts::default());
|
||||
let ms: Vec<String> = (0..6)
|
||||
.map(|i| {
|
||||
let t0 = std::time::Instant::now();
|
||||
let r = s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 100.0 + 40.0 * i as f64, "y": 500.0})).unwrap();
|
||||
assert!(!r.is_null(), "{name}");
|
||||
format!("{:.3}", t0.elapsed().as_secs_f64() * 1e3)
|
||||
})
|
||||
.collect();
|
||||
eprintln!("HOVER {name}: first {} ms, then {} ms", ms[0], ms[1..].join(" / "));
|
||||
}
|
||||
}
|
||||
|
||||
/// The hover's probe: built off the thread that asks, kept while only the Selection changes,
|
||||
/// rebuilt when anything it reads changes, and a job finished for an older state is not used.
|
||||
#[test]
|
||||
fn hover_probe_is_built_by_a_job_kept_across_picks_and_dropped_on_other_edits() {
|
||||
let mut s = session();
|
||||
let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
|
||||
let job = s.crypto_probe_job(&q).unwrap().expect("nothing built yet");
|
||||
std::thread::spawn(job).join().unwrap();
|
||||
assert!(s.crypto_probe_job(&q).unwrap().is_none(), "built");
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "left");
|
||||
// Another scale or time is another probe.
|
||||
assert!(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "scale": 0.5})).unwrap().is_some());
|
||||
assert!(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 1.0})).unwrap().is_some());
|
||||
// A pick edits only the Selection: the probe stays.
|
||||
pick(&mut s, 0.5, "replace");
|
||||
assert!(s.crypto_probe_job(&q).unwrap().is_none(), "kept across the pick");
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], true);
|
||||
// Other edits rebuild it, and the answer follows them.
|
||||
s.execute_checked("layer.enableTimeRemap", json!({})).unwrap();
|
||||
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 1.5})).unwrap();
|
||||
let stale = s.crypto_probe_job(&q).unwrap().expect("time remap changed it");
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "late");
|
||||
// A job that finishes after the next edit doesn't answer for it.
|
||||
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 0.5})).unwrap();
|
||||
stale();
|
||||
assert!(s.crypto_probe_job(&q).unwrap().is_some(), "stale job result is not used");
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["name"], "left");
|
||||
// Errors are kept too, and still errors.
|
||||
s.execute("layer.newSolid", json!({"name": "S"})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
let solid = json!({"effect": 1, "x": 1, "y": 1});
|
||||
s.crypto_probe_job(&solid).unwrap().expect("solid")();
|
||||
assert!(s.crypto_probe_job(&solid).unwrap().is_none());
|
||||
assert!(s.execute("effect.cryptomatteAt", solid).unwrap_err().to_string().contains("no Cryptomatte channels"));
|
||||
}
|
||||
|
||||
/// Picks on 1080p, 4K and odd-sized footage, at the viewer's resolutions and on a half-size proxy,
|
||||
/// name the ID the rendered matte shows under the point (points next to cell edges).
|
||||
#[test]
|
||||
fn picks_match_the_render_at_1080p_4k_odd_sizes_and_on_proxies() {
|
||||
for (w, h) in [(1920, 1080), (3840, 2160), (1919, 1079)] {
|
||||
let mut s = cells_session(w, h, false);
|
||||
let (wl, hl) = (f64::from(w), f64::from(h));
|
||||
let xs = [36.6, 37.2, 0.4, wl / 2.0 + 0.3, wl - 0.6, wl - 37.4];
|
||||
let ys = [28.7, 29.1, hl - 0.5, hl / 3.0];
|
||||
let mut bad = mismatches(&mut s, &[1.0, 0.5, 0.33, 0.25], xs.iter().copied(), &ys);
|
||||
let item = *s.project.items.iter().find(|(_, i)| matches!(i.kind, effectcraft_project::ItemKind::Footage(_))).unwrap().0;
|
||||
s.execute("file.setProxy", json!({"item": item.0, "path": "/renders/cells_proxy.exr"})).unwrap();
|
||||
bad.extend(mismatches(&mut s, &[1.0, 0.5, 0.33, 0.25], xs.iter().copied(), &ys).into_iter().map(|b| format!("proxy {b}")));
|
||||
assert!(bad.is_empty(), "{w}x{h}: {} mismatches:\n{}", bad.len(), bad.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The kept probe, and the aux channels it holds, go when the eyedropper is disarmed, when its
|
||||
/// layer or effect is deleted, on Open / New, and when its footage changes.
|
||||
#[test]
|
||||
fn hover_probe_is_released_on_disarm_delete_open_and_footage_changes() {
|
||||
let q = json!({"effect": "Cryptomatte", "x": 100.0, "y": 100.0});
|
||||
let ends: [(&str, fn(&mut Session)); 5] = [
|
||||
("disarm", |s| s.drop_crypto_probe()),
|
||||
("layer delete", |s| {
|
||||
let lid = s.active_comp().unwrap().layers[0].id.0;
|
||||
s.execute_checked("edit.clear", json!({"layers": [lid]})).unwrap();
|
||||
}),
|
||||
("effect delete", |s| {
|
||||
s.execute_checked("effect.remove", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
}),
|
||||
("open / new", |s| s.replace_project(effectcraft_project::Project::default(), None)),
|
||||
("footage change", |s| {
|
||||
let f = s.project.items.values().find_map(|i| match &i.kind {
|
||||
effectcraft_project::ItemKind::Footage(f) => Some(f.clone()),
|
||||
_ => None,
|
||||
});
|
||||
s.forget_decoded(&f.unwrap());
|
||||
}),
|
||||
];
|
||||
for (what, end) in ends {
|
||||
let mut s = cells_session(640, 360, false);
|
||||
let aux = s.footage.aux(ItemId(0), &Footage::default(), Tick::ZERO).unwrap();
|
||||
let unpinned = Arc::strong_count(&aux);
|
||||
assert!(!s.execute("effect.cryptomatteAt", q.clone()).unwrap().is_null());
|
||||
assert!(s.crypto_probe_stats().0 && Arc::strong_count(&aux) > unpinned, "{what}: kept");
|
||||
end(&mut s);
|
||||
assert!(!s.crypto_probe_stats().0, "{what}: the probe is gone");
|
||||
assert_eq!(Arc::strong_count(&aux), unpinned, "{what}: its aux channels are let go");
|
||||
}
|
||||
}
|
||||
|
||||
/// A probe is keyed by the frame it reads: on a still, or footage held by Time Remap, every comp
|
||||
/// time shares one; each footage frame has its own, and so does each comp time once something
|
||||
/// before the Cryptomatte effect is animated.
|
||||
#[test]
|
||||
fn hover_probe_is_keyed_by_the_source_frame() {
|
||||
let builds = |s: &mut Session, times: &[f64]| -> Vec<u64> {
|
||||
let b = s.crypto_probe_stats().1;
|
||||
times
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let r = s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 40.0, "y": 30.0, "time": t})).unwrap();
|
||||
assert_eq!(r["name"], "c1_1");
|
||||
s.crypto_probe_stats().1 - b
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let f = 1.0 / 24.0;
|
||||
let mut s = cells_session_at(320, 180, false, "/renders/still.exr");
|
||||
assert_eq!(builds(&mut s, &[0.0, f, 0.5, 1.9]), [1, 1, 1, 1], "a still: one probe");
|
||||
let mut s = cells_session(320, 180, false);
|
||||
assert_eq!(builds(&mut s, &[0.0, f * 0.4, f, f * 1.5, 0.0]), [1, 1, 2, 2, 3], "footage: a probe per frame");
|
||||
s.execute_checked("layer.enableTimeRemap", json!({})).unwrap();
|
||||
s.execute_checked("prop.toggleAnimation", json!({"path": "timeRemap", "value": false})).unwrap();
|
||||
s.execute_checked("prop.set", json!({"path": "timeRemap", "value": 0.5})).unwrap();
|
||||
assert_eq!(builds(&mut s, &[0.0, f, 1.2]), [1, 1, 1], "held by Time Remap: one source frame");
|
||||
let mut s = cells_session_at(320, 180, true, "/renders/still.exr");
|
||||
assert_eq!(builds(&mut s, &[0.0, f]), [1, 1], "a still blur");
|
||||
s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": 0.0, "value": 2.0})).unwrap();
|
||||
s.execute_checked("prop.addKey", json!({"path": "effects/#1/blurriness", "time": 1.0, "value": 6.0})).unwrap();
|
||||
assert_eq!(builds(&mut s, &[0.0, f, f]), [1, 2, 2], "an animated blur before it: per comp time");
|
||||
}
|
||||
|
||||
/// Probes of two Cryptomatte effects (on two footage layers) at the same revision and frame
|
||||
/// don't answer for each other.
|
||||
#[test]
|
||||
fn hover_probes_are_per_layer_and_effect() {
|
||||
/// `…late…` files have the IDs [`Crypto`] has from 1 s.
|
||||
struct ByFile;
|
||||
impl FootageSource for ByFile {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::new(f.width, f.height)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
|
||||
Some(Arc::new(aux_at(if f.path.contains("late") { Tick::from_seconds_f64(1.0) } else { t })))
|
||||
}
|
||||
}
|
||||
let mut s = Session { footage: Arc::new(ByFile), importer: Some(Arc::new(Probe)), ..Default::default() };
|
||||
let items = s.execute("file.import", json!({"paths": ["/renders/crypto.exr", "/renders/late.exr"]})).unwrap()["items"].clone();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
|
||||
let layers: Vec<Value> = (0..2).map(|i| s.execute("layer.addItem", json!({"item": items[i]})).unwrap()["layer"].clone()).collect();
|
||||
for l in &layers {
|
||||
s.execute_checked("effect.apply", json!({"layers": [l], "effect": "Cryptomatte"})).unwrap();
|
||||
}
|
||||
let on = |l: &Value| json!({"layer": l, "effect": 1, "x": 0.5, "y": 1.5});
|
||||
for (l, want) in [(&layers[0], "left"), (&layers[1], "late"), (&layers[0], "left")] {
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", on(l)).unwrap()["name"], want);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Crypto`]'s IDs, counting the reads, and holding them while `hold` is set (a build caught
|
||||
/// in the middle).
|
||||
#[derive(Default)]
|
||||
struct Gate {
|
||||
reads: std::sync::atomic::AtomicUsize,
|
||||
hold: std::sync::atomic::AtomicBool,
|
||||
entered: std::sync::atomic::AtomicBool,
|
||||
/// Picture frames read (the effect's input render).
|
||||
frames: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
impl FootageSource for Gate {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
self.frames.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Some(Arc::new(Image::new(f.width, f.height)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, _: &Footage, t: Tick) -> Option<Arc<AuxChannels>> {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
self.reads.fetch_add(1, SeqCst);
|
||||
self.entered.store(true, SeqCst);
|
||||
while self.hold.load(SeqCst) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
Some(Arc::new(aux_at(t)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Only the newest request's build is kept: a job that finishes late doesn't evict a newer probe,
|
||||
/// a job superseded before it runs doesn't build, one superseded (or outdated by an edit) while
|
||||
/// it builds stores nothing, and `probeOnly` never builds on the asking thread.
|
||||
#[test]
|
||||
fn hover_probe_builds_store_only_the_newest_and_never_build_for_probe_only() {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let gate = Arc::new(Gate::default());
|
||||
let mut s = session();
|
||||
s.footage = gate.clone();
|
||||
let at = |t: f64| json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t});
|
||||
let reads = || gate.reads.load(SeqCst);
|
||||
// A late job for another frame doesn't evict the probe built since.
|
||||
let job0 = s.crypto_probe_job(&at(0.0)).unwrap().unwrap();
|
||||
s.execute("effect.cryptomatteAt", at(1.0)).unwrap();
|
||||
assert!(s.crypto_probe_job(&at(1.0)).unwrap().is_none());
|
||||
job0();
|
||||
assert!(s.crypto_probe_job(&at(1.0)).unwrap().is_none(), "the late job evicted the newer probe");
|
||||
// A request for another probe cancels the earlier job: it doesn't even build.
|
||||
let (old, new) = (s.crypto_probe_job(&at(0.5)).unwrap().unwrap(), s.crypto_probe_job(&at(0.25)).unwrap().unwrap());
|
||||
let before = reads();
|
||||
old();
|
||||
assert_eq!(reads(), before, "a superseded job doesn't build");
|
||||
new();
|
||||
assert!(reads() > before);
|
||||
assert!(s.crypto_probe_job(&at(0.25)).unwrap().is_none(), "the newest request's probe is kept");
|
||||
// The same request again doesn't cancel the job in flight.
|
||||
let (a, b) = (s.crypto_probe_job(&at(1.5)).unwrap().unwrap(), s.crypto_probe_job(&at(1.5)).unwrap().unwrap());
|
||||
a();
|
||||
drop(b);
|
||||
assert!(s.crypto_probe_job(&at(1.5)).unwrap().is_none());
|
||||
// Superseded, or outdated by an edit, while it builds: it stores nothing.
|
||||
for edit in [false, true] {
|
||||
let job = s.crypto_probe_job(&at(0.75)).unwrap().unwrap();
|
||||
gate.hold.store(true, SeqCst);
|
||||
gate.entered.store(false, SeqCst);
|
||||
let run = std::thread::spawn(job);
|
||||
while !gate.entered.load(SeqCst) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
if edit {
|
||||
s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap();
|
||||
} else {
|
||||
drop(s.crypto_probe_job(&at(0.8)).unwrap());
|
||||
}
|
||||
gate.hold.store(false, SeqCst);
|
||||
run.join().unwrap();
|
||||
let stored = if edit { s.crypto_probe_stats().0 } else { s.crypto_probe_job(&at(0.75)).unwrap().is_none() };
|
||||
assert!(!stored, "edit {edit}: the outdated build was stored");
|
||||
}
|
||||
// `probeOnly`: pending without a probe, and nothing built.
|
||||
let before = reads();
|
||||
let mut q = at(0.0);
|
||||
q["probeOnly"] = json!(true);
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap(), json!({"pending": true}));
|
||||
q["mode"] = json!("add");
|
||||
assert_eq!(s.execute("effect.pickCryptomatte", q.clone()).unwrap()["pending"], true);
|
||||
assert_eq!(reads(), before, "nothing built for probeOnly");
|
||||
s.crypto_probe_job(&q).unwrap().unwrap()();
|
||||
assert_eq!(s.execute("effect.pickCryptomatte", q.clone()).unwrap()["selection"], "left");
|
||||
}
|
||||
|
||||
/// A build cancelled while it reads the IDs (a newer request, disarming, an edit, another
|
||||
/// project) stops before it renders the effect's input, so the next build isn't kept waiting.
|
||||
#[test]
|
||||
fn a_cancelled_build_stops_before_rendering_its_input() {
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
let cancels: [(&str, fn(&mut Session)); 4] = [
|
||||
("newer request", |s| drop(s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 1.5})).unwrap())),
|
||||
("disarm", |s| s.drop_crypto_probe()),
|
||||
("edit", |s| drop(s.execute_checked("prop.set", json!({"path": "effects/#1/matteOnly", "value": true})).unwrap())),
|
||||
("project switch", |s| s.replace_project(effectcraft_project::Project::default(), None)),
|
||||
];
|
||||
for (what, cancel) in [("not cancelled", None), ("newer request", Some(0)), ("disarm", Some(1)), ("edit", Some(2)), ("project switch", Some(3))] {
|
||||
let gate = Arc::new(Gate::default());
|
||||
let mut s = session();
|
||||
s.footage = gate.clone();
|
||||
let job = s.crypto_probe_job(&json!({"effect": 1, "x": 0.5, "y": 1.5, "time": 0.5})).unwrap().unwrap();
|
||||
gate.hold.store(true, SeqCst);
|
||||
let run = std::thread::spawn(job);
|
||||
while !gate.entered.load(SeqCst) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
if let Some(k) = cancel {
|
||||
cancels[k].1(&mut s);
|
||||
}
|
||||
gate.hold.store(false, SeqCst);
|
||||
run.join().unwrap();
|
||||
let rendered = gate.frames.load(SeqCst) > 0;
|
||||
assert_eq!(rendered, cancel.is_none(), "{what}: the input was rendered: {rendered}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe doesn't read the Cryptomatte effect's own Selection: keyframed, a still keeps one
|
||||
/// probe for every comp time (an animated effect before it still makes one per time).
|
||||
#[test]
|
||||
fn an_animated_selection_keeps_one_probe_on_a_still() {
|
||||
let mut s = cells_session_at(320, 180, false, "/renders/still.exr");
|
||||
for (t, v) in [(0.0, "c1_1"), (1.0, "c2_2")] {
|
||||
s.execute_checked("prop.addKey", json!({"path": "effects/#1/selection", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let b = s.crypto_probe_stats().1;
|
||||
for t in [0.0, 1.0 / 24.0, 0.5, 1.5] {
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", json!({"effect": "Cryptomatte", "x": 40.0, "y": 30.0, "time": t})).unwrap()["name"], "c1_1");
|
||||
}
|
||||
assert_eq!(s.crypto_probe_stats().1 - b, 1, "one probe for the still");
|
||||
}
|
||||
|
||||
/// A panic while a probe is built on another thread becomes the probe's error: the job returns,
|
||||
/// the hover reports it, and it isn't retried for that frame.
|
||||
#[test]
|
||||
fn a_panicking_probe_build_becomes_an_error() {
|
||||
struct Broken;
|
||||
impl FootageSource for Broken {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
Some(Arc::new(Image::new(f.width, f.height)))
|
||||
}
|
||||
fn aux(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<AuxChannels>> {
|
||||
panic!("a broken EXR reader")
|
||||
}
|
||||
}
|
||||
let mut s = Session { footage: Arc::new(Broken), importer: Some(Arc::new(Probe)), ..Default::default() };
|
||||
let item = s.execute("file.import", json!({"paths": ["/renders/crypto.exr"]})).unwrap()["items"][0].clone();
|
||||
s.execute("comp.new", json!({"name": "C", "width": 8, "height": 4, "frameRate": 24, "duration": 2})).unwrap();
|
||||
s.execute("layer.addItem", json!({"item": item})).unwrap();
|
||||
s.execute_checked("effect.apply", json!({"effect": "Cryptomatte"})).unwrap();
|
||||
let q = json!({"effect": 1, "x": 0.5, "y": 1.5, "probeOnly": true});
|
||||
let job = s.crypto_probe_job(&q).unwrap().unwrap();
|
||||
assert!(std::thread::spawn(job).join().is_ok(), "the job caught the panic");
|
||||
assert!(s.crypto_probe_job(&q).unwrap().is_none(), "the error is kept");
|
||||
let e = s.execute("effect.cryptomatteAt", q).unwrap_err().to_string();
|
||||
assert!(e.contains("Reading the Cryptomatte IDs failed") && e.contains("a broken EXR reader"), "{e}");
|
||||
}
|
||||
|
||||
/// Asking whether the probe is built changes nothing: asked for other frames while a build runs
|
||||
/// (the eyedropper's hover while a click waits), it doesn't cancel that build.
|
||||
#[test]
|
||||
fn asking_whether_a_probe_is_ready_cancels_no_build() {
|
||||
let s = session();
|
||||
let at = |t: f64| json!({"effect": 1, "x": 0.5, "y": 1.5, "time": t, "probeOnly": true});
|
||||
let job = s.crypto_probe_job(&at(0.0)).unwrap().unwrap();
|
||||
for t in [1.0, 0.5, 1.0, 0.25] {
|
||||
assert!(!s.crypto_probe_ready(&at(t)).unwrap());
|
||||
}
|
||||
job();
|
||||
assert!(s.crypto_probe_ready(&at(0.0)).unwrap(), "the build was cancelled");
|
||||
assert!(s.crypto_probe_job(&at(0.0)).unwrap().is_none());
|
||||
}
|
||||
|
||||
/// The probe renders with the render prefs (nested switches, draft shadows): changing them asks
|
||||
/// for a new one.
|
||||
#[test]
|
||||
fn a_render_prefs_change_asks_for_a_new_probe() {
|
||||
let mut s = session();
|
||||
let q = json!({"effect": 1, "x": 0.5, "y": 1.5});
|
||||
s.crypto_probe_job(&q).unwrap().unwrap()();
|
||||
assert!(s.crypto_probe_ready(&q).unwrap());
|
||||
s.prefs.three_d.realtime_shadows = !s.prefs.three_d.realtime_shadows;
|
||||
assert!(!s.crypto_probe_ready(&q).unwrap(), "draft shadows changed");
|
||||
s.crypto_probe_job(&q).unwrap().unwrap()();
|
||||
s.prefs.general.switches_affect_nested_comps = !s.prefs.general.switches_affect_nested_comps;
|
||||
assert!(!s.crypto_probe_ready(&q).unwrap(), "nested switches changed");
|
||||
}
|
||||
|
||||
/// A pick is an undo step named for it, and undoing, redoing or jumping over picks keeps the
|
||||
/// probe (only the Selection changed); undoing any other edit doesn't.
|
||||
#[test]
|
||||
fn undoing_picks_keeps_the_probe_and_the_step_says_pick() {
|
||||
let mut s = session();
|
||||
let q = json!({"effect": 1, "x": 0.5, "y": 1.5, "probeOnly": true});
|
||||
s.crypto_probe_job(&q).unwrap().unwrap()();
|
||||
let before = crate::history::state_id(&s.project);
|
||||
pick(&mut s, 0.5, "replace");
|
||||
assert_eq!(s.history.undo.last().unwrap().0, "Pick Cryptomatte");
|
||||
pick(&mut s, 4.5, "add");
|
||||
for (what, cmd) in [("undo", "edit.undo"), ("undo", "edit.undo"), ("redo", "edit.redo")] {
|
||||
s.execute_checked(cmd, json!({})).unwrap();
|
||||
assert!(s.crypto_probe_ready(&q).unwrap(), "{what} {}: probe dropped", selection(&s));
|
||||
}
|
||||
assert_eq!(s.execute("effect.cryptomatteAt", q.clone()).unwrap()["selected"], true);
|
||||
assert!(s.goto_history(&before) && selection(&s).is_empty());
|
||||
assert!(s.crypto_probe_ready(&q).unwrap(), "history jump: probe dropped");
|
||||
s.execute_checked("prop.set", json!({"path": "transform/opacity", "value": 50})).unwrap();
|
||||
s.crypto_probe_job(&q).unwrap().unwrap()();
|
||||
s.execute_checked("edit.undo", json!({})).unwrap();
|
||||
assert!(!s.crypto_probe_ready(&q).unwrap(), "an undone opacity change needs a new probe");
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ fn disk_cache_settings_persistence_and_invalidation() {
|
||||
let mut s = session(&dir);
|
||||
let st = s.execute_checked("cache.diskStats", json!({})).unwrap();
|
||||
assert_eq!(st["enabled"], json!(true));
|
||||
assert_eq!(st["maxBytes"], json!(100u64 << 30));
|
||||
assert_eq!(st["maxBytes"], json!(20u64 << 30));
|
||||
let cid = project(&mut s);
|
||||
let t = s.time();
|
||||
let first = s.render(cid, t, RenderOpts::default());
|
||||
@@ -136,3 +136,62 @@ fn footage_changes_change_the_salt() {
|
||||
assert_ne!(k3, k2);
|
||||
assert_ne!(set_proxy(&mut s, false), k3);
|
||||
}
|
||||
|
||||
/// Marker edits keep the disk key (the sound of a playing preview must not wait for frames to
|
||||
/// render again, #1083); an expression that reads markers makes them count.
|
||||
#[test]
|
||||
fn markers_keep_the_content_key_unless_an_expression_reads_them() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
|
||||
let k0 = key(&s);
|
||||
s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap();
|
||||
s.execute("markers.set", json!({"layer": l, "new": true, "time": 0.25})).unwrap();
|
||||
assert_eq!(key(&s), k0, "markers");
|
||||
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "thisComp.marker.numKeys"})).unwrap();
|
||||
let k1 = key(&s);
|
||||
assert_ne!(k1, k0);
|
||||
s.execute("comp.addMarker", json!({"time": 1.0})).unwrap();
|
||||
assert_ne!(key(&s), k1, "a marker read by an expression");
|
||||
}
|
||||
|
||||
/// A comp marker alone (no layer marker beside it) keeps the key too.
|
||||
#[test]
|
||||
fn a_lone_comp_marker_keeps_the_content_key() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "M", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
|
||||
let k0 = key(&s);
|
||||
s.execute("comp.addMarker", json!({"time": 0.5, "comment": "beat"})).unwrap();
|
||||
assert_eq!(key(&s), k0);
|
||||
}
|
||||
|
||||
/// A protected marker with a duration retimes a stretched precomp layer (Responsive Design), so
|
||||
/// changing it changes the key of the comp that nests it; a plain marker beside it does not.
|
||||
#[test]
|
||||
fn protected_markers_with_a_duration_change_the_content_key() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "frameRate": 30, "duration": 10})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
let l = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [l]})).unwrap();
|
||||
s.execute("layer.timeStretch", json!({"percent": 200})).unwrap();
|
||||
let key = |s: &Session| effectcraft_render::disk_cache::comp_content_key(&s.project, outer);
|
||||
let k0 = key(&s);
|
||||
s.execute("comp.addMarker", json!({"comp": inner.0, "time": 2.0, "comment": "beat"})).unwrap();
|
||||
assert_eq!(key(&s), k0, "a plain marker");
|
||||
s.execute("comp.addMarker", json!({"comp": inner.0, "time": 1.0})).unwrap();
|
||||
let index = s.project.comp(inner).unwrap().markers.iter().position(|m| m.time.seconds() == 1.0).unwrap();
|
||||
s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 1.0, "protected": true})).unwrap();
|
||||
let k1 = key(&s);
|
||||
assert_ne!(k1, k0, "a protected marker with a duration");
|
||||
s.execute("markers.set", json!({"comp": inner.0, "index": index, "duration": 2.0})).unwrap();
|
||||
assert_ne!(key(&s), k1, "its duration");
|
||||
}
|
||||
|
||||
@@ -772,6 +772,37 @@ fn preset_round_trips_effect_properties_by_match_name() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An animation preset saved by an earlier version gets the upgrade a project gets on open: a
|
||||
/// Change to Color saved with its single Tolerance applies with the Tolerance group, that value
|
||||
/// as its Hue and the added parameters at their legacy defaults, and a lone property saved at the
|
||||
/// old id applies to the new one.
|
||||
#[test]
|
||||
fn applying_an_old_preset_upgrades_its_effects_and_property_paths() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
apply(&mut s, a, "Change to Color");
|
||||
let mut old = layer(&s, a).effects().unwrap().groups().next().unwrap().clone();
|
||||
old.children.retain(|c| c.match_id() != "toleranceGroup");
|
||||
old.children.push(effectcraft_project::Property::new(0, "tolerance", "Tolerance", KV::Scalar(12.0)).into());
|
||||
let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [], "effects": [old]});
|
||||
let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [b]})).unwrap();
|
||||
assert_eq!(r["applied"], 1, "{r}");
|
||||
let l = layer(&s, b);
|
||||
let g = l.effects().unwrap().groups().next().unwrap();
|
||||
assert!(g.get("tolerance").is_none(), "the old Tolerance is still there");
|
||||
let tol = g.sub("toleranceGroup").expect("no Tolerance group");
|
||||
assert_eq!(tol.get("hue").unwrap().value, KV::Scalar(12.0));
|
||||
assert_eq!(tol.get("lightness").unwrap().value, KV::Scalar(100.0));
|
||||
assert_eq!(tol.get("saturation").unwrap().value, KV::Scalar(100.0));
|
||||
let path = "effects/ec.color.changetocolor/tolerance";
|
||||
let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [{"path": path, "value": KV::Scalar(30.0), "keys": []}], "effects": []});
|
||||
let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [a]})).unwrap();
|
||||
assert_eq!(r["applied"], 1, "{r}");
|
||||
assert_eq!(r["skipped"], json!([]), "{r}");
|
||||
let l = layer(&s, a);
|
||||
let hue = l.effects().unwrap().groups().next().unwrap().sub("toleranceGroup").unwrap().get("hue").unwrap().value.clone();
|
||||
assert_eq!(hue, KV::Scalar(30.0));
|
||||
}
|
||||
|
||||
/// Save the selected effects and properties as a preset, then apply it to `target` at `at`.
|
||||
fn save_and_apply_preset(s: &mut Session, name: &str, selected: Vec<(LayerId, u64)>, target: u64, at: f64) -> serde_json::Value {
|
||||
let path = std::env::temp_dir().join(format!("ec-1971-{name}-{}.ecpreset", std::process::id())).to_string_lossy().to_string();
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
//! Copied keyframes as After Effects keyframe text for other applications (#1908).
|
||||
|
||||
use effectcraft_project::{LayerId, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{KeyRef, Session};
|
||||
|
||||
/// A 1920×1080 23.976 fps comp with a 200×100 solid.
|
||||
fn session() -> (Session, LayerId) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 1920, "height": 1080, "frameRate": 23.976, "duration": 10})).unwrap();
|
||||
let id = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
(s, LayerId(id))
|
||||
}
|
||||
|
||||
fn frame(s: &Session, n: i64) -> Tick {
|
||||
Tick(s.active_comp().unwrap().frame_rate.frame_duration().0 * n)
|
||||
}
|
||||
|
||||
fn uid(s: &Session, lid: LayerId, path: &str) -> Uid {
|
||||
s.active_comp().unwrap().layer(lid).unwrap().props.prop(path).unwrap().uid
|
||||
}
|
||||
|
||||
fn edit_layer(s: &mut Session, lid: LayerId, f: impl FnOnce(&mut effectcraft_project::Layer)) {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
f(std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(lid).unwrap());
|
||||
}
|
||||
|
||||
fn add_key(s: &mut Session, lid: LayerId, path: &str, time: Tick, value: serde_json::Value) {
|
||||
s.execute("prop.addKey", json!({"layer": lid.0, "path": path, "time": time.seconds(), "value": value})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_keys_are_after_effects_text_in_comp_frames() {
|
||||
let (mut s, l) = session();
|
||||
// The layer starts 10 frames in: its keys at layer frames 2, 5 and 9 are comp frames 12, 15 and 19.
|
||||
let start = frame(&s, 10);
|
||||
edit_layer(&mut s, l, |l| l.start_time = start);
|
||||
for (n, v) in [(2, [960.0, 540.0]), (5, [1000.5, 520.25]), (9, [1100.0, 500.0])] {
|
||||
let t = frame(&s, n);
|
||||
add_key(&mut s, l, "transform/position", t, json!(v));
|
||||
}
|
||||
let t = frame(&s, 5);
|
||||
add_key(&mut s, l, "transform/rotation", t, json!(-45));
|
||||
let (position, rotation) = (uid(&s, l, "transform/position"), uid(&s, l, "transform/rotation"));
|
||||
// A subset: two of the three Position keys and the Rotation key.
|
||||
s.state.selected_keys = [(position, 2), (position, 9), (rotation, 5)].map(|(prop, n)| KeyRef { layer: l, prop, time: frame(&s, n) }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert_eq!(
|
||||
s.state.keyframe_text.as_deref(),
|
||||
Some(
|
||||
"Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\
|
||||
Layer\n\
|
||||
Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t12\t960\t540\t0\t\n\t19\t1100\t500\t0\t\n\n\
|
||||
Transform\tRotation\n\tFrame\tdegrees\t\n\t15\t-45\t\n\n\
|
||||
\n\
|
||||
End of Keyframe Data\n"
|
||||
)
|
||||
);
|
||||
// The internal clipboard still has the keys themselves, relative to the earliest.
|
||||
let times: Vec<Vec<Tick>> = s.state.key_clipboard.iter().map(|c| c.keys.iter().map(|k| k.time).collect()).collect();
|
||||
assert_eq!(times, [vec![Tick::ZERO, frame(&s, 7)], vec![frame(&s, 3)]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stretched_layer_writes_the_frames_its_keys_show_at() {
|
||||
let (mut s, l) = session();
|
||||
edit_layer(&mut s, l, |l| l.stretch = 200.0);
|
||||
let t = frame(&s, 6);
|
||||
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(0));
|
||||
add_key(&mut s, l, "transform/opacity", t, json!(100));
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "transform/opacity"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_static_values_have_no_frame() {
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "transform/position"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t\t100\t200\t0\t\n\n"), "{text}");
|
||||
assert_eq!(s.state.value_clipboard.len(), 1, "the internal clipboard keeps the value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_transform_group_lists_what_the_layer_shows() {
|
||||
let (mut s, l) = session();
|
||||
let t = frame(&s, 24);
|
||||
add_key(&mut s, l, "transform/scale", Tick::ZERO, json!([50, 50, 100]));
|
||||
add_key(&mut s, l, "transform/scale", t, json!([150, 150, 100]));
|
||||
let transform = s.active_comp().unwrap().layer(l).unwrap().props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
|
||||
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation");
|
||||
assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t50\t100\t\n\t24\t150\t150\t100\t\n"), "{text}");
|
||||
// 3D: Orientation and X / Y Rotation. Z Rotation stays Rotation, and Orientation's three
|
||||
// values sit under one label, as After Effects writes them.
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
|
||||
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Rotation", "Opacity"]);
|
||||
assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\t\n\t\t0\t0\t0\t\n"), "{text}");
|
||||
}
|
||||
|
||||
fn copy_transform(s: &mut Session, lid: LayerId) -> String {
|
||||
let transform = s.active_comp().unwrap().layer(lid).unwrap().props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": lid.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.state.keyframe_text.clone().unwrap()
|
||||
}
|
||||
|
||||
fn names(text: &str) -> Vec<&str> {
|
||||
text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_the_layer_does_not_use_are_left_out() {
|
||||
// Separate Dimensions: the old Position no longer moves the layer.
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
|
||||
s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionX", "value": 321})).unwrap();
|
||||
let text = copy_transform(&mut s, l);
|
||||
assert!(!names(&text).contains(&"Position"), "{text}");
|
||||
// A one-node camera has no Point of Interest; a two-node one does.
|
||||
let cam = LayerId(s.execute("layer.newCamera", json!({"type": "oneNode"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, cam);
|
||||
assert!(!names(&text).contains(&"Point of Interest"), "{text}");
|
||||
s.execute("layer.cameraSettings", json!({"layer": cam.0, "type": "twoNode"})).unwrap();
|
||||
let text = copy_transform(&mut s, cam);
|
||||
assert!(names(&text).contains(&"Point of Interest"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separated_position_writes_x_y_and_z_position() {
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap();
|
||||
let t = frame(&s, 12);
|
||||
add_key(&mut s, l, "transform/positionX", Tick::ZERO, json!(100));
|
||||
add_key(&mut s, l, "transform/positionX", t, json!(321));
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionY", "value": 200})).unwrap();
|
||||
let text = copy_transform(&mut s, l);
|
||||
// After Effects labels their single column with no unit.
|
||||
assert!(text.contains("Transform\tX Position\n\tFrame\t\t\n\t0\t100\t\n\t12\t321\t\n\nTransform\tY Position\n\tFrame\t\t\n\t\t200\t\n\n"), "{text}");
|
||||
assert!(!names(&text).contains(&"Z Position"), "a 2D layer has no Z Position: {text}");
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
let text = copy_transform(&mut s, l);
|
||||
assert!(text.contains("Transform\tZ Position\n\tFrame\t\t\n\t\t0\t\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_remap_writes_source_seconds_at_comp_frames() {
|
||||
let (mut s, l) = session();
|
||||
let r = s.execute("layer.precompose", json!({"layers": [l.0], "name": "Inner"})).unwrap();
|
||||
let l = LayerId(r["layer"].as_u64().unwrap());
|
||||
s.execute("layer.select", json!({"layers": [l.0]})).unwrap();
|
||||
s.execute("layer.enableTimeRemap", json!({})).unwrap();
|
||||
let start = frame(&s, 10);
|
||||
edit_layer(&mut s, l, |l| l.start_time = start);
|
||||
let t = frame(&s, 6);
|
||||
add_key(&mut s, l, "timeRemap", t, json!(2.5));
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "timeRemap"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
// As After Effects writes it: the key at layer frame 6 of a layer starting at frame 10 is at 16.
|
||||
assert!(text.contains("Layer\nTime Remap\n\tFrame\tseconds\t\n\t10\t0\t\n\t16\t2.5\t\n"), "{text}");
|
||||
}
|
||||
|
||||
/// Selects the layer's properties at `paths` and copies them.
|
||||
fn copy_props(s: &mut Session, lid: LayerId, paths: &[&str]) -> String {
|
||||
for (i, path) in paths.iter().enumerate() {
|
||||
s.execute("prop.select", json!({"layer": lid.0, "path": path, "add": i > 0, "selectKeys": false})).unwrap();
|
||||
}
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.state.keyframe_text.clone().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same effects and values (2019 has no Dropdown
|
||||
// Menu Control: that block follows its other popups, written 1-based with no unit).
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Fill", "Checkbox Control", "Dropdown Menu Control", "Slider Control", "Point Control"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let t = frame(&s, 12);
|
||||
add_key(&mut s, l, "effects/#1/color", Tick::ZERO, json!([1, 0.5, 0.25, 1]));
|
||||
add_key(&mut s, l, "effects/#1/color", t, json!([0, 0, 1, 1]));
|
||||
add_key(&mut s, l, "effects/#2/checkbox", Tick::ZERO, json!(true));
|
||||
add_key(&mut s, l, "effects/#2/checkbox", t, json!(false));
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#3/menu", "value": 1})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#4/slider", "value": 20.5})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#5/point", "value": [10, 20]})).unwrap();
|
||||
// After Effects writes a renamed effect under the effect's own name.
|
||||
let slider = s.active_comp().unwrap().layer(l).unwrap().props.group("effects/#4").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": slider, "name": "My Slider"})).unwrap();
|
||||
let text = copy_props(&mut s, l, &["effects/#1/color", "effects/#2/checkbox", "effects/#3/menu", "effects/#4/slider", "effects/#5/point"]);
|
||||
assert!(
|
||||
text.contains(
|
||||
"Layer\n\
|
||||
Effects\tFill #1\tColor #4\n\tFrame\talpha \tred \tgreen \tblue \t\n\t0\t255\t255\t127.5\t63.75\t\n\t12\t255\t0\t0\t255\t\n\n\
|
||||
Effects\tCheckbox Control #2\tCheckbox #2\n\tFrame\t\t\n\t0\t1\t\n\t12\t0\t\n\n\
|
||||
Effects\tDropdown Menu Control #3\tMenu #2\n\tFrame\t\t\n\t\t2\t\n\n\
|
||||
Effects\tSlider Control #4\tSlider #2\n\tFrame\t\t\n\t\t20.5\t\n\n\
|
||||
Effects\tPoint Control #5\tPoint #2\n\tFrame\tX pixels\tY pixels\t\n\t\t10\t20\t\n\n"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_properties_have_after_effects_indices() {
|
||||
// After Effects 2019 numbered Fractal Noise's Rotation (in the Transform group) #9,
|
||||
// Complexity (after it) #17, Evolution #25, and Opacity (after Evolution Options) #31: a
|
||||
// group takes an index where it opens and closes, and its properties are written without
|
||||
// it. Set Matte's Use For Matte is #3: the layer parameter before it has no extra index for
|
||||
// its source choice.
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Fractal Noise", "Set Matte"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&[
|
||||
"effects/#1/transform/rotation",
|
||||
"effects/#1/complexity",
|
||||
"effects/#1/evolution",
|
||||
"effects/#1/opacity",
|
||||
"effects/#1/blendingMode",
|
||||
"effects/#2/takeMatteFrom",
|
||||
],
|
||||
);
|
||||
for header in [
|
||||
"Effects\tFractal Noise #1\tRotation #9\n",
|
||||
"Effects\tFractal Noise #1\tComplexity #17\n",
|
||||
"Effects\tFractal Noise #1\tEvolution #25\n",
|
||||
"Effects\tFractal Noise #1\tOpacity #31\n",
|
||||
"Effects\tFractal Noise #1\tBlending Mode #32\n",
|
||||
"Effects\tSet Matte #2\tUse For Matte #3\n",
|
||||
] {
|
||||
assert!(text.contains(header), "{header}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_parameters_after_effects_numbers_or_labels_otherwise_follow_it() {
|
||||
// After Effects 2019: Radial Blur counts a hidden parameter before Amount, which has no
|
||||
// unit; 3D Channel Extract lists Anti-alias before Clamp Output; Selective Color's per-color
|
||||
// sliders start at #10 and are percent.
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Radial Blur", "3D Channel Extract", "Selective Color"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&["effects/#1/amount", "effects/#1/center", "effects/#2/antialias", "effects/#2/invertDepthMap", "effects/#3/details/reds/redsCyan"],
|
||||
);
|
||||
for block in [
|
||||
"Effects\tRadial Blur #1\tAmount #3\n\tFrame\t\t\n",
|
||||
"Effects\tRadial Blur #1\tCenter #4\n\tFrame\tX pixels\tY pixels\t\n",
|
||||
"Effects\t3D Channel Extract #2\tAnti-alias #5\n\tFrame\t\t\n",
|
||||
"Effects\t3D Channel Extract #2\tInvert Depth Map #7\n\tFrame\t\t\n",
|
||||
"Effects\tSelective Color #3\tReds Cyan #10\n\tFrame\tpercent\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same masks and values: masks go by place, not
|
||||
// name, and Mask Path is left out.
|
||||
let (mut s, l) = session();
|
||||
for _ in 0..2 {
|
||||
s.execute("mask.new", json!({"layer": l.0, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap();
|
||||
}
|
||||
let t = frame(&s, 12);
|
||||
for (prop, a, b) in [("feather", json!([5, 5]), json!([10, 20])), ("opacity", json!(50), json!(75)), ("expansion", json!(3), json!(-4))] {
|
||||
add_key(&mut s, l, &format!("masks/#1/{prop}"), Tick::ZERO, a);
|
||||
add_key(&mut s, l, &format!("masks/#1/{prop}"), t, b);
|
||||
}
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "masks/#2/feather", "value": [7, 8]})).unwrap();
|
||||
let second = s.active_comp().unwrap().layer(l).unwrap().props.group("masks/#2").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Renamed Mask"})).unwrap();
|
||||
let text = copy_props(&mut s, l, &["masks/#1/path", "masks/#1/feather", "masks/#1/opacity", "masks/#1/expansion", "masks/#2/feather"]);
|
||||
assert!(
|
||||
text.contains(
|
||||
"Layer\n\
|
||||
Masks\tMask #1\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t0\t5\t5\t\n\t12\t10\t20\t\n\n\
|
||||
Masks\tMask #1\tMask Opacity\n\tFrame\tpercent\t\n\t0\t50\t\n\t12\t75\t\n\n\
|
||||
Masks\tMask #1\tMask Expansion\n\tFrame\tpixels\t\n\t0\t3\t\n\t12\t-4\t\n\n\
|
||||
Masks\tMask #2\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t\t7\t8\t\n\n\
|
||||
\nEnd of Keyframe Data\n"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same text properties and values.
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 23.976})).unwrap();
|
||||
let l = LayerId(s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap());
|
||||
s.execute("layer.addTextAnimator", json!({"properties": ["position", "fillColor"]})).unwrap();
|
||||
s.execute("layer.addTextAnimator", json!({"properties": ["opacity"]})).unwrap();
|
||||
let second = s.active_comp().unwrap().layer(l).unwrap().props.group("text/animators/#2").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Fade"})).unwrap();
|
||||
let t = frame(&s, 12);
|
||||
add_key(&mut s, l, "text/sourceText", Tick::ZERO, json!("Hello"));
|
||||
add_key(&mut s, l, "text/pathOptions/firstMargin", Tick::ZERO, json!(0));
|
||||
add_key(&mut s, l, "text/pathOptions/firstMargin", t, json!(50));
|
||||
add_key(&mut s, l, "text/animators/#1/selectors/#1/start", Tick::ZERO, json!(0));
|
||||
add_key(&mut s, l, "text/animators/#1/selectors/#1/start", t, json!(100));
|
||||
add_key(&mut s, l, "text/animators/#1/properties/position", Tick::ZERO, json!([0, 0, 0]));
|
||||
add_key(&mut s, l, "text/animators/#1/properties/position", t, json!([10, -20, 0]));
|
||||
for (path, v) in [
|
||||
("text/animators/#1/selectors/#1/end", json!(80)),
|
||||
("text/animators/#1/selectors/#1/offset", json!(-10)),
|
||||
("text/animators/#1/properties/fillColor", json!([1, 0, 0, 1])),
|
||||
("text/moreOptions/groupingAlignment", json!([10, -20])),
|
||||
] {
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": path, "value": v})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&[
|
||||
"text/sourceText",
|
||||
"text/pathOptions/firstMargin",
|
||||
"text/animators/#1/selectors/#1/start",
|
||||
"text/animators/#1/selectors/#1/end",
|
||||
"text/animators/#1/selectors/#1/offset",
|
||||
"text/animators/#1/selectors/#1/advanced/amount",
|
||||
"text/animators/#1/properties/position",
|
||||
"text/animators/#1/properties/fillColor",
|
||||
"text/animators/#2/properties/opacity",
|
||||
"text/moreOptions/groupingAlignment",
|
||||
],
|
||||
);
|
||||
for block in [
|
||||
"Text\tPath Options\tFirst Margin\n\tFrame\t\t\n\t0\t0\t\n\t12\t50\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tStart #1\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tEnd #2\n\tFrame\tpercent\t\n\t\t80\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tOffset #3\n\tFrame\tpercent\t\n\t\t-10\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tProperties\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t0\t0\t0\t\n\t12\t10\t-20\t0\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tProperties\tFill Color\n\tFrame\talpha \tred \tgreen \tblue \t\n\t\t255\t255\t0\t0\t\n\n",
|
||||
"Text\tAnimators\tAnimator #2\tProperties\tOpacity\n\tFrame\tpercent\t\n\t\t100\t\n\n",
|
||||
"Text\tAnimators\tAnimator #1\tSelectors\tRange Selector #1\tAdvanced\tAmount\n\tFrame\tpercent\t\n\t\t100\t\n\n",
|
||||
"Text\tMore Options\tGrouping Alignment\n\tFrame\tX percent\tY percent\t\n\t\t10\t-20\t\n\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}\nin\n{text}");
|
||||
}
|
||||
assert!(!text.contains("Source Text"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_2d_layer_writes_the_z_it_shows() {
|
||||
// Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it.
|
||||
let (mut s, l) = session();
|
||||
let src = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
edit_layer(&mut s, src, |l| l.switches.three_d = true);
|
||||
add_key(&mut s, src, "transform/position", Tick::ZERO, json!([100, 200, 300]));
|
||||
add_key(&mut s, src, "transform/scale", Tick::ZERO, json!([50, 60, 70]));
|
||||
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: src, prop: uid(&s, src, p), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("keys.paste", json!({"layers": [l.0], "time": 0})).unwrap();
|
||||
let pasted = s.active_comp().unwrap().layer(l).unwrap().props.prop("transform/position").unwrap().keys[0].value.components();
|
||||
assert_eq!(pasted, [100.0, 200.0, 300.0], "the 2D layer keeps the pasted Z");
|
||||
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: l, prop: uid(&s, l, p), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t100\t200\t0\t\n"), "{text}");
|
||||
assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t60\t100\t\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_has_the_source_pixel_aspect() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 24, "pixelAspect": 1.2})).unwrap();
|
||||
let solid = LayerId(s.execute("layer.newSolid", json!({"width": 200, "height": 100, "pixelAspect": 1.5})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, solid);
|
||||
assert!(text.contains("\tSource Pixel Aspect Ratio\t1.5\n\tComp Pixel Aspect Ratio\t1.2\n"), "{text}");
|
||||
// A text layer: 1 × 1, as After Effects 2019 wrote it, with the comp's pixel aspect.
|
||||
let t = LayerId(s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, t);
|
||||
assert!(text.contains("\tSource Width\t1\n\tSource Height\t1\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}");
|
||||
// A shape layer has no source of its own: the comp's size.
|
||||
let shape = LayerId(s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, shape);
|
||||
assert!(text.contains("\tSource Width\t1920\n\tSource Height\t1080\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_from_two_layers_have_no_text() {
|
||||
let (mut s, a) = session();
|
||||
let b = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
for l in [a, b] {
|
||||
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(50));
|
||||
}
|
||||
s.state.selected_keys = [a, b].map(|l| KeyRef { layer: l, prop: uid(&s, l, "transform/opacity"), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert_eq!(s.state.keyframe_text, None, "After Effects copies one layer's keys as text");
|
||||
assert_eq!(s.state.key_clipboard.len(), 2, "EffectCraft's own clipboard has both");
|
||||
// A copied layer leaves no text behind.
|
||||
s.state.selected_keys = vec![KeyRef { layer: a, prop: uid(&s, a, "transform/opacity"), time: Tick::ZERO }];
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert!(s.state.keyframe_text.is_some());
|
||||
s.state.selected_keys.clear();
|
||||
s.execute("edit.copy", json!({"layers": [a.0]})).unwrap();
|
||||
assert_eq!(s.state.keyframe_text, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_contents_are_written_as_after_effects_does() {
|
||||
// Headers and labels After Effects 2019 wrote for the same shape items: items go by kind and
|
||||
// place in their group, and a Size has no unit (`X `).
|
||||
let (mut s, _) = session();
|
||||
let sh = LayerId(s.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap());
|
||||
for (kind, group) in [("group", None), ("rectangle", Some("contents/#1")), ("fill", Some("contents/#1")), ("star", None), ("repeater", None)] {
|
||||
let mut p = json!({"layer": sh.0, "kind": kind});
|
||||
if let Some(g) = group {
|
||||
p["group"] = json!(g);
|
||||
}
|
||||
s.execute("layer.addShapeItem", p).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
sh,
|
||||
&[
|
||||
"contents/#1/contents/#1/size",
|
||||
"contents/#1/contents/#2/color",
|
||||
"contents/#1/transform/position",
|
||||
"contents/#2/points",
|
||||
"contents/#3/transform/startOpacity",
|
||||
],
|
||||
);
|
||||
for block in [
|
||||
"Contents\tGroup #1\tContents\tRectangle Path #1\tSize\n\tFrame\tX \tY \t\n",
|
||||
"Contents\tGroup #1\tContents\tFill #2\tColor\n\tFrame\talpha \tred \tgreen \tblue \t\n",
|
||||
"Contents\tGroup #1\tTransform\tPosition\n\tFrame\tX pixels\tY pixels\t\n",
|
||||
"Contents\tPolystar Path #2\tPoints\n\tFrame\t\t\n",
|
||||
"Contents\tRepeater #3\tTransform\tStart Opacity\n\tFrame\tpercent\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_light_and_material_options_are_written_as_after_effects_does() {
|
||||
// Blocks After Effects 2019 wrote for these options, defaults included: Zoom and Shadow
|
||||
// Diffusion in pixels, Highlight Threshold on 0–1, and the material's shadow choices from 0.
|
||||
let (mut s, l) = session();
|
||||
let cam = LayerId(s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_props(&mut s, cam, &["cameraOptions/zoom", "cameraOptions/highlightThreshold"]);
|
||||
assert!(text.contains("Camera Options\tZoom\n\tFrame\tpixels\t\n"), "{text}");
|
||||
assert!(text.contains("Camera Options\tHighlight Threshold\n\tFrame\t\t\n\t\t1\t\n"), "{text}");
|
||||
let light = LayerId(s.execute("layer.newLight", json!({"type": "spot"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_props(&mut s, light, &["lightOptions/intensity", "lightOptions/shadowDiffusion"]);
|
||||
assert!(text.contains("Light Options\tIntensity\n\tFrame\tpercent\t\n\t\t100\t\n"), "{text}");
|
||||
assert!(text.contains("Light Options\tShadow Diffusion\n\tFrame\tpixels\t\n\t\t0\t\n"), "{text}");
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
let text = copy_props(&mut s, l, &["materialOptions/castsShadows", "materialOptions/acceptsShadows", "materialOptions/diffuse"]);
|
||||
for block in [
|
||||
"Material Options\tCasts Shadows\n\tFrame\t\t\n\t\t0\t\n",
|
||||
"Material Options\tAccepts Shadows\n\tFrame\t\t\n\t\t1\t\n",
|
||||
"Material Options\tDiffuse\n\tFrame\tpercent\t\n\t\t50\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
@@ -708,6 +708,11 @@ fn dynamic_menus_and_window_viewer_labels() {
|
||||
s.prefs.push_recent("/p/One.ecproj");
|
||||
let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
|
||||
assert_eq!((e[0].label.as_str(), e[0].command.as_str()), ("One.ecproj", "file.openRecent"));
|
||||
// Two projects with the same file name show their paths.
|
||||
s.prefs.push_recent("/q/One.ecproj");
|
||||
s.prefs.push_recent("/p/Two.ecproj");
|
||||
let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
|
||||
assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Two.ecproj", "/q/One.ecproj", "/p/One.ecproj"]);
|
||||
assert_eq!(crate::menus::dynamic(&s, "history", &Default::default()).0.len(), s.history.undo.len());
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
s.state.selected_layers = vec![LayerId(a)];
|
||||
|
||||
@@ -239,6 +239,28 @@ fn set_param(s: &mut Session, layer: LayerId, path: &str, v: serde_json::Value)
|
||||
s.execute("prop.set", json!({"layer": layer.0, "path": format!("effects/#1/{path}"), "value": v})).unwrap();
|
||||
}
|
||||
|
||||
/// Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre.
|
||||
fn assert_crop_framing(s: &mut Session, clip: LayerId) {
|
||||
set_param(s, clip, "borders/framing", json!(2));
|
||||
let st = s.execute("warp.status", json!({})).unwrap();
|
||||
assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}");
|
||||
for f in 0..25 {
|
||||
assert_eq!(transparent(&render(s, f)), 0, "frame {f}");
|
||||
}
|
||||
set_param(s, clip, "borders/framing", json!(1));
|
||||
let st = s.execute("warp.status", json!({})).unwrap();
|
||||
let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap();
|
||||
for f in [0, 9, 24] {
|
||||
let img = render(s, f);
|
||||
for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) {
|
||||
for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) {
|
||||
assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})");
|
||||
}
|
||||
}
|
||||
assert!(img.get(0, 0)[3] < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warp_stabilizer_analyzes_and_stabilizes() {
|
||||
let (mut s, clip, solid) = setup(shaky);
|
||||
@@ -289,25 +311,7 @@ fn warp_stabilizer_analyzes_and_stabilizes() {
|
||||
}
|
||||
set_param(&mut s, clip, "stabilization/result", json!(0));
|
||||
|
||||
// Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre.
|
||||
set_param(&mut s, clip, "borders/framing", json!(2));
|
||||
let st = s.execute("warp.status", json!({})).unwrap();
|
||||
assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}");
|
||||
for f in 0..25 {
|
||||
assert_eq!(transparent(&render(&s, f)), 0, "frame {f}");
|
||||
}
|
||||
set_param(&mut s, clip, "borders/framing", json!(1));
|
||||
let st = s.execute("warp.status", json!({})).unwrap();
|
||||
let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap();
|
||||
for f in [0, 9, 24] {
|
||||
let img = render(&s, f);
|
||||
for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) {
|
||||
for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) {
|
||||
assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})");
|
||||
}
|
||||
}
|
||||
assert!(img.get(0, 0)[3] < 0.01);
|
||||
}
|
||||
assert_crop_framing(&mut s, clip);
|
||||
// Synthesize Edges fills most of the borders from neighbouring frames.
|
||||
set_param(&mut s, clip, "borders/framing", json!(3));
|
||||
let synth: usize = (0..25).map(|f| transparent(&render(&s, f))).sum();
|
||||
|
||||
@@ -14,7 +14,8 @@ use crate::Session;
|
||||
use crate::render::{FootageSource, RenderOpts};
|
||||
|
||||
/// Files by name: `red16.png` (16×16 red), `blue8.png` (8×8 blue), `lines.mov` (interlaced:
|
||||
/// even rows red, odd rows blue; 4×4, 10 fps), `matte_black.png` / `matte_white.png` (a
|
||||
/// even rows red, odd rows blue; 4×4, 10 fps), `flash.mov` (10×10, 10 fps: red, then blue from
|
||||
/// the second frame), `matte_black.png` / `matte_white.png` (a
|
||||
/// semi-transparent green premultiplied against black / white, read as straight), `half.png`
|
||||
/// (white at 25 % alpha), `wide.png` (10×10 white).
|
||||
struct Synthetic;
|
||||
@@ -52,9 +53,14 @@ fn size_of(path: &str) -> (u32, u32) {
|
||||
}
|
||||
|
||||
impl FootageSource for Synthetic {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option<Arc<Image>> {
|
||||
fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
let (w, h) = size_of(&f.path);
|
||||
let mut img = Image::new(w, h);
|
||||
// `flash.mov` is red for its first frame and blue from its second (0.1 s) on.
|
||||
if f.path.ends_with("flash.mov") {
|
||||
let c = if t.seconds() >= 0.1 - 1e-9 { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] };
|
||||
return Some(Arc::new(Image::filled(w, h, c)));
|
||||
}
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
// Decoders hand out premultiplied pixels (straight file colour × alpha).
|
||||
@@ -171,6 +177,118 @@ fn comp_proxy_and_create_proxy() {
|
||||
assert!(blue(px(&s, outer, 0.0, o, 8, 8)));
|
||||
}
|
||||
|
||||
/// Outer (4×4, 20 fps) holding a layer of Inner (4×4, 10 fps, a red solid): (inner, outer).
|
||||
fn nested(s: &mut Session) -> (ItemId, ItemId) {
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 2})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap();
|
||||
let outer = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
|
||||
(inner, outer)
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Proxy's Invert Alpha and Interpret As Linear Light change what a
|
||||
/// composition proxy draws, as they do for the same file used as footage.
|
||||
#[test]
|
||||
fn comp_proxy_draws_with_its_interpretation() {
|
||||
let mut s = session();
|
||||
let (inner, outer) = nested(&mut s);
|
||||
let o = RenderOpts::default();
|
||||
// Invert Alpha: the 25 % proxy covers 75 %.
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/half.png"})).unwrap();
|
||||
s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight", "invertAlpha": true})).unwrap();
|
||||
let p = px(&s, outer, 0.0, o, 1, 1);
|
||||
assert!((p[3] - 0.75).abs() < 0.01, "{p:?}");
|
||||
// Interpret As Linear Light: the proxy draws as the file does as footage with that setting.
|
||||
let f = import(&mut s, "/m/matte_black.png");
|
||||
s.execute("file.interpretFootage", json!({"item": f.0, "linearLight": true})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Ref", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap();
|
||||
let r = s.active_comp_id().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": f.0})).unwrap();
|
||||
let want = px(&s, r, 0.0, o, 1, 1);
|
||||
assert!(want[1] / want[3] > 0.7, "linear light re-encodes the 0.5 green: {want:?}");
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/matte_black.png"})).unwrap();
|
||||
s.execute("file.interpretProxy", json!({"item": inner.0, "linearLight": true})).unwrap();
|
||||
let p = px(&s, outer, 0.0, o, 1, 1);
|
||||
assert!((0..4).all(|i| (p[i] - want[i]).abs() < 1e-3), "{p:?} != {want:?}");
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Proxy ▸ Separate Fields: a composition proxy shows one field per frame
|
||||
/// at twice the rate, as footage does.
|
||||
#[test]
|
||||
fn comp_proxy_separates_fields() {
|
||||
let mut s = session();
|
||||
let (inner, outer) = nested(&mut s);
|
||||
let o = RenderOpts::default();
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/lines.mov"})).unwrap();
|
||||
assert!(red(px(&s, outer, 0.05, o, 1, 0)) && blue(px(&s, outer, 0.05, o, 1, 1)), "progressive");
|
||||
s.execute("file.interpretProxy", json!({"item": inner.0, "fields": "upper"})).unwrap();
|
||||
// At 0.05 s the lower field (odd rows, blue) is shown, with the even rows interpolated.
|
||||
for y in 0..3 {
|
||||
assert!(blue(px(&s, outer, 0.05, o, 1, y)), "lower field at row {y}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame Blending on a precomp layer blends the frames of the composition's proxy.
|
||||
#[test]
|
||||
fn comp_proxy_frames_blend() {
|
||||
let mut s = session();
|
||||
let (inner, outer) = nested(&mut s);
|
||||
let o = RenderOpts::default();
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/flash.mov"})).unwrap();
|
||||
assert!(red(px(&s, outer, 0.05, o, 1, 1)));
|
||||
s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap();
|
||||
s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap();
|
||||
// Halfway between the red first frame and the blue second one.
|
||||
let p = px(&s, outer, 0.05, o, 1, 1);
|
||||
assert!((p[0] - 0.5).abs() < 0.05 && (p[2] - 0.5).abs() < 0.05 && p[3] > 0.99, "{p:?}");
|
||||
assert!(blue(px(&s, outer, 0.1, o, 1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comp_proxy_frame_blend_cache_tracks_alpha_interpretation() {
|
||||
struct Interpreted(std::sync::atomic::AtomicUsize);
|
||||
impl FootageSource for Interpreted {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let pixel = match f.alpha {
|
||||
AlphaMode::Straight => [0.4, 0.2, 0.1, 0.5],
|
||||
AlphaMode::Premultiplied => [f.premul_color[0] * 0.5, 0.1, 0.0, 0.5],
|
||||
AlphaMode::Ignore => [0.8, 0.4, 0.2, 1.0],
|
||||
};
|
||||
Some(Arc::new(Image::filled(4, 4, pixel)))
|
||||
}
|
||||
}
|
||||
for mode in ["frameMix", "pixelMotion"] {
|
||||
let mut s = session();
|
||||
let footage = Arc::new(Interpreted(std::sync::atomic::AtomicUsize::new(0)));
|
||||
s.footage = footage.clone();
|
||||
let (inner, outer) = nested(&mut s);
|
||||
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/alpha.mov"})).unwrap();
|
||||
s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight"})).unwrap();
|
||||
s.execute("layer.frameBlending", json!({"mode": mode})).unwrap();
|
||||
s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap();
|
||||
let opts = RenderOpts::default();
|
||||
let first = px(&s, outer, 0.05, opts, 1, 1);
|
||||
let calls = footage.0.load(std::sync::atomic::Ordering::Relaxed);
|
||||
assert_eq!(px(&s, outer, 0.05, opts, 1, 1), first);
|
||||
assert_eq!(footage.0.load(std::sync::atomic::Ordering::Relaxed), calls);
|
||||
for params in [
|
||||
json!({"item": inner.0, "alpha": "ignore"}),
|
||||
json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [0.0, 0.0, 0.0]}),
|
||||
json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [1.0, 0.0, 0.0]}),
|
||||
] {
|
||||
s.execute("file.interpretProxy", params.clone()).unwrap();
|
||||
let mut fresh = s.renderer(opts);
|
||||
fresh.cache = None;
|
||||
let want = fresh.comp_frame(outer, Tick::from_seconds_f64(0.05)).get(1, 1);
|
||||
let actual = px(&s, outer, 0.05, opts, 1, 1);
|
||||
assert_eq!(actual, want, "{mode} after {params}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_proxy_settings_apply_to_the_proxy() {
|
||||
let mut s = session();
|
||||
@@ -187,6 +305,38 @@ fn interpret_proxy_settings_apply_to_the_proxy() {
|
||||
assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation");
|
||||
}
|
||||
|
||||
/// Agents and the control channel run commands through `execute_checked`, which refuses keys a
|
||||
/// params doc does not list. The Interpret Footage commands list every key they read.
|
||||
#[test]
|
||||
fn interpret_commands_accept_their_keys_when_checked() {
|
||||
let mut s = session();
|
||||
let mov = import(&mut s, "/m/lines.mov");
|
||||
let b = import(&mut s, "/m/red16.png");
|
||||
s.execute("file.setProxy", json!({"item": mov.0, "path": "/m/lines.mov"})).unwrap();
|
||||
s.execute("file.setProxy", json!({"item": b.0, "path": "/m/matte_black.png"})).unwrap();
|
||||
s.execute_checked("file.interpretProxy", json!({"item": mov.0, "preserveRgb": true, "overrideStart": true, "startTimecode": 25})).unwrap();
|
||||
let px = s.project.item(mov).unwrap().proxy.clone().unwrap().footage;
|
||||
assert!(px.preserve_rgb);
|
||||
assert_eq!(px.start_timecode, Some(25));
|
||||
s.execute_checked("file.interpretProxy", json!({"item": mov.0, "overrideStart": false})).unwrap();
|
||||
assert_eq!(s.project.item(mov).unwrap().proxy.as_ref().unwrap().footage.start_timecode, None);
|
||||
s.execute_checked("file.interpretProxy", json!({"item": b.0, "guessAlpha": true})).unwrap();
|
||||
assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.alpha, AlphaMode::Premultiplied);
|
||||
s.execute_checked("file.interpretProxy", json!({"item": b.0, "premulColor": [1.0, 0.0, 0.0]})).unwrap();
|
||||
assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.premul_color, [1.0, 0.0, 0.0]);
|
||||
// `item` names one footage item, as `items` names several.
|
||||
s.execute_checked("file.interpretFootage", json!({"item": b.0, "invertAlpha": true, "premulColor": [0.0, 1.0, 0.0]})).unwrap();
|
||||
s.execute_checked("file.rememberInterpretation", json!({"item": b.0})).unwrap();
|
||||
s.execute_checked("file.applyInterpretation", json!({"item": mov.0})).unwrap();
|
||||
let f = match &s.project.item(mov).unwrap().kind {
|
||||
ItemKind::Footage(f) => f.clone(),
|
||||
_ => panic!("not footage"),
|
||||
};
|
||||
assert!(f.invert_alpha);
|
||||
assert_eq!(f.premul_color, [0.0, 1.0, 0.0]);
|
||||
assert_eq!(s.execute_checked("file.reloadFootage", json!({"item": mov.0})).unwrap()["reloaded"], 1);
|
||||
}
|
||||
|
||||
/// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines
|
||||
/// interpolated; the dominant field comes first.
|
||||
#[test]
|
||||
|
||||
@@ -49,45 +49,38 @@ fn camera(s: &mut Session) -> u64 {
|
||||
s.execute("layer.newCamera", json!({"name": "Cam", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_rig_builds_eye_comps_and_glasses() {
|
||||
let mut s = session();
|
||||
let src = cid(&s);
|
||||
let cam = camera(&mut s);
|
||||
let items_before = s.project.items.len();
|
||||
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
|
||||
assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps");
|
||||
assert_eq!(r["master"].as_u64(), Some(cam));
|
||||
// Controls on a hidden null in the source comp.
|
||||
let ctl = layer_in(&s, src, r["controls"].as_u64().unwrap());
|
||||
/// Controls on a hidden null in the source comp.
|
||||
fn assert_stereo_controls(ctl: &Layer) {
|
||||
assert_eq!(ctl.name, "Stereo 3D Controls");
|
||||
assert!(!ctl.switches.video);
|
||||
let fx = ctl.effects().unwrap().groups().next().unwrap().clone();
|
||||
assert!(matches!(&fx.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::controls::STEREO_CONTROLS));
|
||||
assert_eq!(fx.get("sceneDepth").unwrap().value, KV::Scalar(4.0));
|
||||
// Eye comps: an eye camera (two-node, linked by expressions) and the collapsed source comp.
|
||||
let d = 0.04 * 640.0;
|
||||
for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] {
|
||||
let ec = ItemId(r[key].as_u64().unwrap());
|
||||
assert_eq!(s.project.item(ec).unwrap().name, name);
|
||||
let c = s.project.comp(ec).unwrap();
|
||||
assert_eq!((c.width, c.height), (640, 360));
|
||||
assert_eq!(c.layers.len(), 2);
|
||||
let cam = &c.layers[0];
|
||||
assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam));
|
||||
assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position"));
|
||||
// Parallel eyes (no convergence): each looks straight ahead.
|
||||
assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi"));
|
||||
assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0));
|
||||
for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] {
|
||||
let e = cam.props.prop(p).unwrap().expr.clone().unwrap();
|
||||
assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text);
|
||||
}
|
||||
let nest = &c.layers[1];
|
||||
assert_eq!(nest.source, LayerSource::Comp { item: src });
|
||||
assert!(nest.switches.collapse && nest.switches.three_d);
|
||||
}
|
||||
|
||||
/// An eye comp: an eye camera (two-node, linked by expressions) at `x` and the collapsed source comp.
|
||||
fn assert_eye_comp(s: &Session, src: ItemId, ec: ItemId, name: &str, x: f64) {
|
||||
assert_eq!(s.project.item(ec).unwrap().name, name);
|
||||
let c = s.project.comp(ec).unwrap();
|
||||
assert_eq!((c.width, c.height), (640, 360));
|
||||
assert_eq!(c.layers.len(), 2);
|
||||
let cam = &c.layers[0];
|
||||
assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam));
|
||||
assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position"));
|
||||
// Parallel eyes (no convergence): each looks straight ahead.
|
||||
assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi"));
|
||||
assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0));
|
||||
for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] {
|
||||
let e = cam.props.prop(p).unwrap().expr.clone().unwrap();
|
||||
assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text);
|
||||
}
|
||||
// The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden.
|
||||
let nest = &c.layers[1];
|
||||
assert_eq!(nest.source, LayerSource::Comp { item: src });
|
||||
assert!(nest.switches.collapse && nest.switches.three_d);
|
||||
}
|
||||
|
||||
/// The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden.
|
||||
fn assert_stereo_comp(s: &Session, r: &serde_json::Value) {
|
||||
let oc = ItemId(r["stereoComp"].as_u64().unwrap());
|
||||
let out = s.project.comp(oc).unwrap();
|
||||
assert_eq!(s.project.item(oc).unwrap().name, "Scene Stereo 3D");
|
||||
@@ -98,6 +91,23 @@ fn stereo_rig_builds_eye_comps_and_glasses() {
|
||||
assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.perspective.3dglasses"));
|
||||
assert_eq!(g.get("leftView").unwrap().value, KV::Layer(Some(l)));
|
||||
assert_eq!(g.get("rightView").unwrap().value, KV::Layer(Some(rr)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_rig_builds_eye_comps_and_glasses() {
|
||||
let mut s = session();
|
||||
let src = cid(&s);
|
||||
let cam = camera(&mut s);
|
||||
let items_before = s.project.items.len();
|
||||
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
|
||||
assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps");
|
||||
assert_eq!(r["master"].as_u64(), Some(cam));
|
||||
assert_stereo_controls(&layer_in(&s, src, r["controls"].as_u64().unwrap()));
|
||||
let d = 0.04 * 640.0;
|
||||
for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] {
|
||||
assert_eye_comp(&s, src, ItemId(r[key].as_u64().unwrap()), name, x);
|
||||
}
|
||||
assert_stereo_comp(&s, &r);
|
||||
// One undo step; redo; serde.
|
||||
let json = serde_json::to_string(&*s.project).unwrap();
|
||||
let back: Project = serde_json::from_str(&json).unwrap();
|
||||
|
||||
@@ -164,6 +164,33 @@ fn alpha_at(s: &Session, f: u32) -> Vec<f32> {
|
||||
img.data.iter().map(|p| p[3]).collect()
|
||||
}
|
||||
|
||||
/// Green, then Alt (red) over part of the object, then green there again: the last stroke wins,
|
||||
/// in the render and through Undo / Redo (#1646).
|
||||
#[test]
|
||||
fn roto_brush_strokes_correct_earlier_ones() {
|
||||
let (mut s, clip, _) = painted(center, "disk-1646.mov");
|
||||
let c = center(0);
|
||||
let spot = [c[0], c[1] + 25.0];
|
||||
let i = spot[1] as usize * W as usize + spot[0] as usize;
|
||||
let paint = |s: &mut Session, kind: &str| {
|
||||
s.execute_checked(
|
||||
"roto.stroke",
|
||||
json!({"layer": clip.0, "kind": kind, "points": line([spot[0] - 6.0, spot[1]], [spot[0] + 6.0, spot[1]]), "radius": 7}),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
assert!(alpha_at(&s, 0)[i] > 0.5, "the spot starts inside the matte");
|
||||
paint(&mut s, "bg");
|
||||
assert!(alpha_at(&s, 0)[i] < 0.5, "Alt removes it");
|
||||
paint(&mut s, "fg");
|
||||
assert!(alpha_at(&s, 0)[i] > 0.5, "green over the Alt stroke restores it");
|
||||
assert!(iou_at(&mut s, clip, 0, c) > 0.95);
|
||||
assert!(s.undo());
|
||||
assert!(alpha_at(&s, 0)[i] < 0.5, "undo brings the removal back");
|
||||
assert!(s.redo());
|
||||
assert!(alpha_at(&s, 0)[i] > 0.5, "redo restores it again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roto_brush_segments_and_propagates() {
|
||||
let (mut s, clip, uid) = painted(center, "disk-a.mov");
|
||||
|
||||
@@ -280,6 +280,31 @@ fn toolbar_fill_and_stroke_paint_selected_shape_layers() {
|
||||
let _ = s.render(cid, s.time(), Default::default());
|
||||
}
|
||||
|
||||
/// A Wiggle Paths saved without Spatial Phase gets the property, at 0°, after Temporal Phase when
|
||||
/// the project opens.
|
||||
#[test]
|
||||
fn opening_a_wiggle_paths_without_spatial_phase_adds_it() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
|
||||
let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let uid = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": "wiggle"})).unwrap()["uid"].as_u64().unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let mut old = effectcraft_project::Project::from_json(&s.project.to_file_json().unwrap()).unwrap();
|
||||
let l = old.comp_mut(cid).unwrap().layer_mut(effectcraft_project::LayerId(sh)).unwrap();
|
||||
let g = l.props.find_group_mut(uid).unwrap();
|
||||
g.children.retain(|c| c.match_id() != "spatialPhase");
|
||||
assert_eq!(g.children.len(), 7);
|
||||
let next = old.next_id;
|
||||
s.replace_project(old, None);
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone();
|
||||
let g = l.props.find_group(uid).unwrap();
|
||||
let ids: Vec<&str> = g.children.iter().map(|c| c.match_id()).collect();
|
||||
assert_eq!(ids, ["size", "detail", "points", "speed", "correlation", "phase", "spatialPhase", "seed"]);
|
||||
let sp = g.get("spatialPhase").unwrap();
|
||||
assert_eq!((sp.name.as_str(), &sp.value), ("Spatial Phase", &effectcraft_keyframe::Value::Scalar(0.0)));
|
||||
assert!(sp.uid >= next && s.project.next_id > sp.uid, "a fresh uid: {} (next {next})", sp.uid);
|
||||
}
|
||||
|
||||
/// The toolbar's Fill Options / Stroke Options change a selected shape layer's paint kind (#732):
|
||||
/// None switches the fill off, a gradient turns the Fill into a Gradient Fill that keeps its
|
||||
/// opacity, Solid Color turns it back, and a Stroke kind adds a stroke where there is none.
|
||||
|
||||
@@ -71,6 +71,14 @@ fn prefs_migrate_old_layout_and_keep_unknown_keys() {
|
||||
assert_eq!(Prefs::from_json("not json"), Prefs::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_migrate_old_disk_cache_default() {
|
||||
// The old 100 GB default becomes 20 GB; a size the user chose stays (#2551).
|
||||
assert_eq!(Prefs::from_json(r#"{"version": 2, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 20);
|
||||
assert_eq!(Prefs::from_json(r#"{"version": 2, "disk": {"diskCacheMaxGb": 50}}"#).disk.disk_cache_max_gb, 50);
|
||||
assert_eq!(Prefs::from_json(r#"{"version": 3, "disk": {"diskCacheMaxGb": 100}}"#).disk.disk_cache_max_gb, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefs_set_validates_keys_types_and_ranges() {
|
||||
let mut p = Prefs::default();
|
||||
|
||||
@@ -519,9 +519,10 @@ fn convert_to_bezier_turns_a_parametric_shape_into_an_editable_path() {
|
||||
};
|
||||
let radius = |v: [f64; 2]| v[0].hypot(v[1]);
|
||||
|
||||
// A 120 x 80 rectangle is its four corners, joined by straight edges.
|
||||
// A 120 x 80 rectangle is its four corners, joined by straight edges, from the top-right
|
||||
// corner clockwise as After Effects draws it.
|
||||
let (mut s, l, was, rect) = convert(json!({"kind": "rect", "size": [120, 80]}));
|
||||
assert_eq!(rect.vertices, vec![[-60.0, -40.0], [60.0, -40.0], [60.0, 40.0], [-60.0, 40.0]]);
|
||||
assert_eq!(rect.vertices, vec![[60.0, -40.0], [60.0, 40.0], [-60.0, 40.0], [-60.0, -40.0]]);
|
||||
assert!(rect.in_tangents.iter().chain(&rect.out_tangents).all(|t| *t == [0.0, 0.0]), "straight edges: {rect:?}");
|
||||
// Undo brings the parametric shape back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
|
||||
@@ -765,6 +765,59 @@ fn pick_whip_generates_ae_reference_expressions() {
|
||||
assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err());
|
||||
}
|
||||
|
||||
/// #1072: an Alt pick whip (`byIndex`) selects an effect's properties by their index,
|
||||
/// `effect("Color Control")(1)`, its place among its group's children (nested groups too). The
|
||||
/// effect keeps its name; other properties, dimensions, insertion into an expression and match
|
||||
/// names (Compact English off) work as before. (`effectcraft-host`'s `pick_whip` tests check the
|
||||
/// references read the right values.)
|
||||
#[test]
|
||||
fn pick_whip_by_index_selects_effect_properties_by_their_index() {
|
||||
let (mut s, l) = setup();
|
||||
let o = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
|
||||
for fx in ["Color Control", "Fractal Noise", "Point Control"] {
|
||||
s.execute("effect.apply", json!({"layer": o, "effect": fx})).unwrap();
|
||||
}
|
||||
// Fractal Noise ▸ Transform ▸ Scale, by its places in the property tree.
|
||||
let fx = s.active_comp().unwrap().layer(effectcraft_project::LayerId(o)).unwrap().props.sub("effects").unwrap().groups().nth(1).unwrap().clone();
|
||||
let group = fx.children.iter().position(|c| c.match_id() == "transform").unwrap() + 1;
|
||||
let scale = fx.sub("transform").unwrap().children.iter().position(|c| c.match_id() == "scale").unwrap() + 1;
|
||||
let whip = |s: &mut Session, path: &str, target: Value, extra: Value| {
|
||||
let mut p = json!({"layer": l, "path": path, "target": target, "byIndex": true});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
s.execute("prop.pickWhip", p).unwrap()
|
||||
};
|
||||
let other = "thisComp.layer(\"Other\")";
|
||||
let undo = s.history.undo.len();
|
||||
assert_eq!(
|
||||
whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#1/color"}), json!({})),
|
||||
json!(format!("{other}.effect(\"Color Control\")(1)[0]")),
|
||||
"a colour into one value: its first"
|
||||
);
|
||||
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
|
||||
let nested = format!("{other}.effect(\"Fractal Noise\")({group})({scale})");
|
||||
assert_eq!(whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({})), json!(nested));
|
||||
assert_eq!(
|
||||
whip(&mut s, "transform/opacity", json!({"layer": o, "path": "effects/#3/point", "dimension": 1}), json!({})),
|
||||
json!(format!("{other}.effect(\"Point Control\")(1)[1]"))
|
||||
);
|
||||
assert_eq!(
|
||||
whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({"expression": "1 + ", "range": [4, 4]})),
|
||||
json!(format!("1 + {nested}")),
|
||||
"inserted at the cursor"
|
||||
);
|
||||
assert_eq!(
|
||||
whip(&mut s, "transform/opacity", json!({"layer": o, "path": "transform/opacity"}), json!({})),
|
||||
json!(format!("{other}.transform.opacity")),
|
||||
"only an effect's properties go by index"
|
||||
);
|
||||
// Match names (Compact English off): the effect by its match name, its properties by index.
|
||||
s.execute("prefs.set", json!({"key": "general.expressionPickWhipCompact", "value": false})).unwrap();
|
||||
let e = whip(&mut s, "transform/rotation", json!({"layer": o, "path": "effects/#2/transform/scale"}), json!({}));
|
||||
assert!(e.as_str().unwrap().ends_with(&format!("({group})({scale})")), "{e}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/rotation").expr.unwrap().text, format!("1 + {nested}"), "one undo per pick");
|
||||
}
|
||||
|
||||
/// #407: Reset without a value gives a property its own default (a Transform property's, an
|
||||
/// effect parameter's) besides removing its keyframes and expression.
|
||||
#[test]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use effectcraft_color::BlendMode;
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{ItemKind, LayerSource, MaskMode};
|
||||
use effectcraft_project::{Comp, ItemKind, Layer, LayerSource, MaskMode};
|
||||
use effectcraft_psd::write::*;
|
||||
use effectcraft_psd::{DValue, Descriptor, Rect};
|
||||
use serde_json::json;
|
||||
@@ -74,22 +74,8 @@ pub(crate) fn sample_psd() -> Vec<u8> {
|
||||
write(&d)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_imports_as_composition() {
|
||||
let path = tmp("sample.psd");
|
||||
std::fs::write(&path, sample_psd()).unwrap();
|
||||
let mut s = Session::default();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
|
||||
assert_eq!(s.state.active_comp, Some(cid));
|
||||
assert_eq!(s.project.item(cid).unwrap().name, "sample");
|
||||
let c = s.project.comp(cid).unwrap().clone();
|
||||
assert_eq!((c.width, c.height), (120, 80));
|
||||
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]);
|
||||
// Text layer: editable text, positioned at the type origin.
|
||||
let title = &c.layers[0];
|
||||
/// Text layer: editable text, positioned at the type origin.
|
||||
fn assert_title_layer(title: &Layer) {
|
||||
assert!(matches!(title.source, LayerSource::Text));
|
||||
let Some(KV::Text(doc)) = title.props.prop("text/sourceText").map(|p| p.value.clone()) else { panic!("text") };
|
||||
assert_eq!(doc.text, "Hello PSD");
|
||||
@@ -97,14 +83,18 @@ fn psd_imports_as_composition() {
|
||||
assert_eq!(doc.font, "Inter");
|
||||
assert_eq!(doc.style, "Bold");
|
||||
assert_eq!(title.props.prop("transform/position").unwrap().value.as_vec2(), [8.0, 70.0]);
|
||||
// Adjustment layer with Hue/Saturation.
|
||||
let hue = &c.layers[1];
|
||||
}
|
||||
|
||||
/// Adjustment layer with Hue/Saturation.
|
||||
fn assert_hue_layer(hue: &Layer) {
|
||||
assert!(hue.switches.adjustment);
|
||||
let fx = hue.effects().unwrap().groups().next().unwrap();
|
||||
assert_eq!(fx.get("hue").unwrap().value.as_f64(), 40.0);
|
||||
assert_eq!(fx.get("saturation").unwrap().value.as_f64(), 10.0);
|
||||
// Vector mask → mask; Stroke style.
|
||||
let vm = &c.layers[2];
|
||||
}
|
||||
|
||||
/// Vector mask → mask; Stroke style.
|
||||
fn assert_vector_mask_layer(vm: &Layer) {
|
||||
let m = vm.masks().unwrap().groups().next().unwrap();
|
||||
assert!(matches!(m.kind, effectcraft_project::GroupKind::Mask { mode: MaskMode::Add, .. }));
|
||||
let Some(KV::Path(p)) = m.get("path").map(|p| p.value.clone()) else { panic!("mask path") };
|
||||
@@ -113,19 +103,23 @@ fn psd_imports_as_composition() {
|
||||
let st = vm.props.group("layerStyles/stroke").unwrap();
|
||||
assert_eq!(st.get("size").unwrap().value.as_f64(), 2.0);
|
||||
assert_eq!(st.get("position").unwrap().value, KV::Enum(1));
|
||||
// Clipped → preserve transparency; opacity and blend mode.
|
||||
}
|
||||
|
||||
/// Clipped → preserve transparency (opacity, blend mode); drop shadow.
|
||||
fn assert_clipped_and_shadow(c: &Comp) {
|
||||
let clipped = &c.layers[3];
|
||||
assert!(clipped.preserve_transparency);
|
||||
assert_eq!(clipped.blend_mode, BlendMode::Screen);
|
||||
assert!((clipped.props.prop("transform/opacity").unwrap().value.as_f64() - 128.0 / 255.0 * 100.0).abs() < 1e-9);
|
||||
// Drop shadow.
|
||||
let ds = c.layers[4].props.group("layerStyles/dropShadow").unwrap();
|
||||
assert_eq!(ds.get("distance").unwrap().value.as_f64(), 9.0);
|
||||
assert_eq!(ds.get("angle").unwrap().value.as_f64(), 45.0);
|
||||
assert_eq!(ds.get("useGlobalLight").unwrap().value, KV::Bool(false));
|
||||
assert_eq!(ds.get("opacity").unwrap().value.as_f64(), 60.0);
|
||||
// Group → collapsed precomp (pass through) with its layers.
|
||||
let g = &c.layers[5];
|
||||
}
|
||||
|
||||
/// Group → collapsed precomp (pass through) with its layers; footage items name their Photoshop layer.
|
||||
fn assert_group_precomp(s: &Session, g: &Layer) {
|
||||
assert!(g.switches.collapse);
|
||||
let LayerSource::Comp { item } = g.source else { panic!("precomp") };
|
||||
let inner = s.project.comp(item).unwrap();
|
||||
@@ -140,6 +134,27 @@ fn psd_imports_as_composition() {
|
||||
assert_eq!((sl.name.as_str(), sl.layer_size), ("Masked Square", false));
|
||||
assert_eq!((f.width, f.height), (120, 80));
|
||||
assert_eq!(s.project.item(item).unwrap().name, "Masked Square/sample.psd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_imports_as_composition() {
|
||||
let path = tmp("sample.psd");
|
||||
std::fs::write(&path, sample_psd()).unwrap();
|
||||
let mut s = Session::default();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap());
|
||||
assert_eq!(s.state.active_comp, Some(cid));
|
||||
assert_eq!(s.project.item(cid).unwrap().name, "sample");
|
||||
let c = s.project.comp(cid).unwrap().clone();
|
||||
assert_eq!((c.width, c.height), (120, 80));
|
||||
let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect();
|
||||
assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]);
|
||||
assert_title_layer(&c.layers[0]);
|
||||
assert_hue_layer(&c.layers[1]);
|
||||
assert_vector_mask_layer(&c.layers[2]);
|
||||
assert_clipped_and_shadow(&c);
|
||||
assert_group_precomp(&s, &c.layers[5]);
|
||||
// One undo step removes everything.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.project.comp(cid).is_none());
|
||||
|
||||
Reference in New Issue
Block a user