Merge main into #2300; Cineon conversion kept in footage_frame_raw (main routes proxies through footage_frame), frame-blend cache key hashes both premul colour and Cineon settings, spec strings combined

This commit is contained in:
bflatastic
2026-10-30 05:41:52 +07:00
388 changed files with 20932 additions and 1865 deletions
+33 -2
View File
@@ -78,7 +78,7 @@ fn transition_preset_by_name_and_bad_input() {
#[test]
fn preset_catalogue_is_unique_and_categorised() {
let all = crate::animation_presets();
let known = ["Transitions", "Backgrounds"];
let known = ["Transitions", "Backgrounds", "Behaviors"];
let mut ids = std::collections::HashSet::new();
let mut names = std::collections::HashSet::new();
for (id, name, cat) in &all {
@@ -90,7 +90,7 @@ fn preset_catalogue_is_unique_and_categorised() {
assert!(!ids.contains(&id.to_lowercase()) && !names.contains(&name.to_lowercase()), "text preset {id} clashes");
}
let n = |c: &str| all.iter().filter(|(_, _, k)| k == c).count();
assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20);
assert!(n("Backgrounds") >= 15 && n("Transitions") >= 20 && n("Behaviors") >= 20);
}
#[test]
@@ -126,3 +126,34 @@ fn preset_expressions_and_point_fractions() {
assert_eq!(ramp.prop("end").unwrap().value, effectcraft_keyframe::Value::Vec2([80.0, 90.0]));
assert_eq!(ramp.prop("startColor").unwrap().keys.len(), 2);
}
/// #1510: Behaviors presets add no effects; each puts an enabled expression on a transform
/// property, and undo removes it again.
#[test]
fn every_behavior_preset_applies_and_undoes() {
let (mut s, solid) = session();
let list = s.execute("anim.builtinPresets", json!({"category": "Behaviors"})).unwrap();
let list = list.as_array().unwrap().clone();
assert!(list.len() >= 20, "{}", list.len());
let driven = |s: &Session| {
let l = &s.active_comp().unwrap().layers[0];
["transform/position", "transform/scale", "transform/rotation", "transform/opacity"]
.iter()
.filter(|p| l.props.prop(p).is_some_and(|pr| pr.has_expression()))
.copied()
.collect::<Vec<_>>()
};
for p in &list {
let id = p["id"].as_str().unwrap();
let r = s.execute("anim.applyBuiltinPreset", json!({"layers": [solid], "preset": id})).unwrap();
assert_eq!(r["skipped"], json!([]), "{id}: every path and expression applies");
assert_eq!(r["effects"], json!([]), "{id}: behaviors add no effects");
let applied = driven(&s);
assert_eq!(applied.len(), 1, "{id}: exactly one property is driven");
s.execute("edit.undo", json!({})).unwrap();
assert!(driven(&s).is_empty(), "{id}: undo removes the expression");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(driven(&s), applied, "{id}: redo restores the expression");
s.execute("edit.undo", json!({})).unwrap();
}
}
+10 -1
View File
@@ -190,7 +190,16 @@ fn rendered_matte_of_pick(s: &mut Session, x: f64, y: f64, scale: f64) -> (Strin
s.execute_checked("prop.set", json!({"path": format!("effects/#{fx}/matteOnly"), "value": true})).unwrap();
let (cid, comp) = (s.active_comp_id().unwrap(), s.active_comp().unwrap());
let layer = &comp.layers[0];
let ctx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Tick::ZERO, expr: None, footage: None };
let ctx = effectcraft_render::EvalCtx {
project: &s.project,
comp_id: cid,
comp,
time: Tick::ZERO,
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
};
let buf = s.renderer(effectcraft_render::RenderOpts { scale, ..Default::default() }).content_buf(&ctx, layer).unwrap();
let (px, py) = buf.to_px([x, y]);
let m = buf.img.get(px.floor() as i64, py.floor() as i64)[0];
+59
View File
@@ -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");
}
+126
View File
@@ -772,6 +772,132 @@ 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();
s.state.selected_props = selected;
s.execute("anim.savePreset", json!({"path": path})).unwrap();
s.execute("time.set", json!({"time": at})).unwrap();
let r = s.execute("anim.applyPreset", json!({"path": path, "layers": [target]}));
let _ = std::fs::remove_file(&path);
r.unwrap()
}
/// The comp times of the keys at `path` on `lid`'s `n`th effect (0-based), or on the layer when
/// `n` is `None`.
fn key_times(s: &Session, lid: u64, n: Option<usize>, path: &str) -> Vec<f64> {
let l = layer(s, lid);
let group = match n {
Some(n) => l.effects().unwrap().groups().nth(n).unwrap().clone(),
None => l.props.clone(),
};
group.prop(path).unwrap().keys.iter().map(|k| l.comp_time(k.time).seconds()).collect()
}
/// #1971: a saved effect's keys start at the playhead of the layer it's applied to, as a saved
/// property's do, nested controls included, an effect without keys still applies, and the whole
/// apply is one undo step.
#[test]
fn preset_effect_keys_start_at_the_playhead() {
let (mut s, a, b) = comp_with_two_solids();
let blur = apply(&mut s, a, "Gaussian Blur");
let fractal = apply(&mut s, a, "Fractal");
let slider = apply(&mut s, a, "Slider Control");
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (5.5, 30.0), (6.0, 10.0)]);
animate_effect(&mut s, a, fractal, "mandelbrot/mandelbrotX", &[(5.5, 1.25), (7.0, 2.5)]);
let before = layer(&s, b);
let undo = s.history.undo.len();
let r = save_and_apply_preset(&mut s, "effects", vec![(LayerId(a), blur), (LayerId(a), fractal), (LayerId(a), slider)], b, 0.5);
assert_eq!(r["applied"], 3, "{r}");
assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.0, 1.5]);
assert_eq!(key_times(&s, b, Some(1), "mandelbrot/mandelbrotX"), vec![1.0, 2.5]);
assert_eq!(s.history.undo.len(), undo + 1, "the whole apply is one undo step");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer(&s, b), before);
}
/// #1971: effects and properties saved together keep their relative timing: the earliest key of
/// either starts at the playhead.
#[test]
fn preset_effects_and_properties_share_one_anchor() {
let (mut s, a, b) = comp_with_two_solids();
let blur = apply(&mut s, a, "Gaussian Blur");
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
for (time, value) in [(4.0, -12.0), (7.0, 12.0)] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap();
}
let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid;
save_and_apply_preset(&mut s, "mixed", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 1.0);
assert_eq!(key_times(&s, b, None, "transform/rotation"), vec![1.0, 4.0]);
assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![2.0, 3.0]);
}
/// #1971: on a stretched layer a saved effect's keys land where a saved property's keys do.
#[test]
fn preset_effect_keys_follow_a_stretched_layer_like_properties() {
let (mut s, a, b) = comp_with_two_solids();
let blur = apply(&mut s, a, "Gaussian Blur");
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
for (time, value) in [(5.0, -12.0), (6.0, 12.0)] {
s.execute("prop.addKey", json!({"layer": a, "path": "transform/rotation", "time": time, "timeBase": "comp", "value": value})).unwrap();
}
let rotation = layer(&s, a).props.prop("transform/rotation").unwrap().uid;
set_layer_clock(&mut s, b, 1.0, 200.0);
save_and_apply_preset(&mut s, "stretched", vec![(LayerId(a), blur), (LayerId(a), rotation)], b, 2.0);
let property = key_times(&s, b, None, "transform/rotation");
assert_eq!(property.first(), Some(&2.0), "the property's keys start at the playhead");
assert_eq!(key_times(&s, b, Some(0), "blurriness"), property, "and the effect's keys land with them");
}
/// #1971: a preset saved before effect keys were stored relative to the earliest key (version 1)
/// has its effects' keys at the source layer's times; they also start at the playhead.
#[test]
fn old_preset_effect_keys_start_at_the_playhead() {
let (mut s, a, b) = comp_with_two_solids();
let blur = apply(&mut s, a, "Gaussian Blur");
animate_effect(&mut s, a, blur, "blurriness", &[(5.0, 10.0), (6.0, 30.0)]);
let old = json!({
"effectcraftAnimationPreset": 1,
"name": "shake",
"properties": [],
"effects": [serde_json::to_value(effect_group(&s, a, blur)).unwrap()],
});
s.execute("time.set", json!({"time": 0.5})).unwrap();
s.execute("anim.applyPreset", json!({"preset": old, "layers": [b]})).unwrap();
assert_eq!(key_times(&s, b, Some(0), "blurriness"), vec![0.5, 1.5]);
}
/// #2064: `prop.addKey` accepts the `effects/<match name>[#n]/<property>` path that
/// `essential.list` reports.
#[test]
+122
View File
@@ -367,6 +367,128 @@ fn media_replacement_swaps_footage_per_instance() {
assert!(center(&s, main)[0] > 0.99);
}
/// #1747: a collapsed precomp with an Essential Property override must keep drawing its nested
/// layers past the precomp's frame instead of falling back to a flattened, clipped frame.
#[test]
fn collapsed_precomp_applies_property_overrides_without_clipping() {
struct AddY;
impl effectcraft_render::ExprHost for AddY {
fn eval(
&self,
_: &effectcraft_render::EvalCtx,
_: &effectcraft_project::Layer,
_: &effectcraft_project::Property,
value: &effectcraft_keyframe::Value,
) -> Result<effectcraft_keyframe::Value, String> {
Ok(match value {
effectcraft_keyframe::Value::Vec3([x, y, z]) => effectcraft_keyframe::Value::Vec3([*x, y + 100.0, *z]),
_ => value.clone(),
})
}
}
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap();
let inner = s.active_comp_id().unwrap();
let box_ = s.execute("layer.newSolid", json!({"name": "Box", "color": "#ffffff", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": box_, "path": "transform/position", "value": [200, 50, 0]})).unwrap();
// The source property's expression still runs after the instance override.
s.execute("prop.setExpression", json!({"layer": box_, "path": "transform/position", "expression": "value + [0, 100, 0]"})).unwrap();
let position = s.execute("essential.addProperty", json!({"layer": box_, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap();
let outer = s.active_comp_id().unwrap();
let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
let other = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [instance, other], "switch": "collapse", "value": true})).unwrap();
s.expr = Some(std::sync::Arc::new(AddY));
s.execute("layer.select", json!({"layers": [instance]})).unwrap();
s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -120, 0]})).unwrap();
s.execute("layer.select", json!({"layers": [other]})).unwrap();
s.execute("essential.set", json!({"layer": other, "control": position, "value": [200, 220, 0]})).unwrap();
let img = s.render(outer, Tick::ZERO, RenderOpts::default());
// The expression adds 100 px: the overrides give inner y = -20 and 320. The first maps to
// outer y = 130 (the precomp is centred at 300) and its 100 px box spans 80..179, 70 px above
// the instance's frame (150..449), where a flattened precomp would stop. The second maps to
// 470 and spans 420..519, past the bottom.
assert!(img.get(200, 100)[3] > 0.99, "the override draws above the nested frame: {:?}", img.get(200, 100));
assert!(img.get(200, 90)[3] > 0.99, "the box reaches the top of its overridden bounds");
assert!(img.get(200, 500)[3] > 0.99, "the other instance keeps its own override: {:?}", img.get(200, 500));
assert!(img.get(200, 200)[3] < 0.01, "nothing is left between the instances");
}
/// #1747: two collapsed instances of the same comp keep their own source-affecting property
/// overrides in the layer cache (the nested layers' ids and static values are identical).
#[test]
fn collapsed_instances_keep_distinct_property_overrides_in_the_cache() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Card", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap();
let card = s.active_comp_id().unwrap();
let solid = s.execute("layer.newSolid", json!({"name": "BG", "color": "#ffffff", "width": 64, "height": 64})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layer": solid, "effect": "Fill"})).unwrap();
let color = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap();
let main = s.active_comp_id().unwrap();
let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [a, b], "switch": "collapse", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": a, "path": "transform/position", "value": [60, 50, 0]})).unwrap();
s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [140, 50, 0]})).unwrap();
s.execute("layer.select", json!({"layers": [a]})).unwrap();
s.execute("essential.set", json!({"layer": a, "control": color, "value": [1.0, 0.0, 0.0, 1.0]})).unwrap();
s.execute("layer.select", json!({"layers": [b]})).unwrap();
s.execute("essential.set", json!({"layer": b, "control": color, "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
let img = s.render(main, Tick::ZERO, RenderOpts::default());
let left = img.get(60, 50);
let right = img.get(140, 50);
assert!(left[0] > 0.99 && left[2] < 0.01, "left instance is red: {left:?}");
assert!(right[2] > 0.99 && right[0] < 0.01, "right instance is blue: {right:?}");
}
/// #1747 with Media Replacement: the replacement footage and the position override both apply
/// while the precomp stays collapsed, so the box is blue and still draws above the frame.
#[test]
fn collapsed_precomp_applies_media_replacement_without_clipping() {
struct Colors;
impl crate::render::FootageSource for Colors {
fn frame(&self, _: ItemId, f: &effectcraft_project::Footage, _: Tick) -> Option<std::sync::Arc<effectcraft_raster::Image>> {
let c = if f.path.contains("blue") { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] };
Some(std::sync::Arc::new(effectcraft_raster::Image::filled(100, 100, c)))
}
}
let mut s = Session { footage: std::sync::Arc::new(Colors), ..Default::default() };
let footage = |path: &str| effectcraft_project::Footage {
path: path.into(),
kind: effectcraft_project::FootageKind::Still,
width: 100,
height: 100,
has_video: true,
..Default::default()
};
let (red, blue) = s
.edit("test", None, |proj, _| {
let red = proj.add_item("red.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("red.png")));
let blue = proj.add_item("blue.png", Default::default(), None, effectcraft_project::ItemKind::Footage(footage("blue.png")));
Ok((red, blue))
})
.unwrap();
s.execute("comp.new", json!({"name": "Inner", "width": 400, "height": 300, "frameRate": 30, "duration": 2})).unwrap();
let inner = s.active_comp_id().unwrap();
let clip = s.execute("layer.addItem", json!({"item": red.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": clip, "path": "transform/position", "value": [200, 50, 0]})).unwrap();
let media = s.execute("essential.addMedia", json!({"layer": clip})).unwrap()["control"].as_u64().unwrap();
let position = s.execute("essential.addProperty", json!({"layer": clip, "path": "transform/position"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 400, "height": 600, "frameRate": 30, "duration": 2})).unwrap();
let outer = s.active_comp_id().unwrap();
let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [instance], "switch": "collapse", "value": true})).unwrap();
s.execute("layer.select", json!({"layers": [instance]})).unwrap();
s.execute("essential.set", json!({"layer": instance, "control": position, "value": [200, -20, 0]})).unwrap();
s.execute("essential.set", json!({"layer": instance, "control": media, "item": blue.0})).unwrap();
let img = s.render(outer, Tick::ZERO, RenderOpts::default());
let above = img.get(200, 100);
assert!(above[2] > 0.99 && above[0] < 0.01, "the replacement media is blue above the frame: {above:?}");
assert!(img.get(200, 200)[3] < 0.01, "the collapsed box is not clipped at the nested frame");
}
/// Responsive Design — Time: stretching a precomp keeps its protected regions at their
/// original speed; the unprotected parts absorb the stretch.
#[test]
+31
View File
@@ -2,6 +2,7 @@
use effectcraft_keyframe::Value as KV;
use effectcraft_project::essential::{self, ControlType};
use effectcraft_time::Tick;
use serde_json::json;
use crate::{Event, Session};
@@ -102,3 +103,33 @@ fn open_in_essential_graphics_sets_the_primary_comp() {
);
assert_eq!(s.execute("essential.list", json!({})).unwrap()["name"], "A");
}
/// #1747: through a collapsed precomp, a Text override and a Font override of the same Source
/// Text both apply: the Font merges into the overridden text, as `essential::with_overrides`
/// does on the flattened path, instead of one entry replacing the other.
#[test]
fn collapsed_instance_merges_text_and_font_overrides() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Title", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
let title = s.active_comp_id().unwrap();
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
let body = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap();
let font = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap();
s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 1})).unwrap();
let main = s.active_comp_id().unwrap();
let inst = s.execute("layer.addItem", json!({"item": title.0})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [inst], "switch": "collapse", "value": true})).unwrap();
s.execute("essential.set", json!({"layer": inst, "control": body, "value": "Bye"})).unwrap();
s.execute("essential.set", json!({"layer": inst, "control": font, "value": {"font": "Serif Test", "size": 40}})).unwrap();
// Evaluate the nested text in the collapsed instance's context, as the renderer does.
let r = effectcraft_render::Renderer::new(&s.project, &*s.footage, crate::render::RenderOpts::default());
let ctx = effectcraft_render::EvalCtx::new(&s.project, main, s.project.comp(main).unwrap(), Tick::ZERO);
let layer = ctx.comp.layer(effectcraft_project::LayerId(inst)).unwrap();
let collapsed = r.collapsed(&ctx, layer).expect("the instance collapses");
let (_, nctx) = r.collapse_into(&ctx, layer, &collapsed, 1.0).expect("a nested context");
let src = nctx.comp.layer(effectcraft_project::LayerId(text)).unwrap();
match nctx.value(src, src.props.prop("text/sourceText").unwrap()) {
KV::Text(d) => assert_eq!((d.text.as_str(), d.font.as_str(), d.size), ("Bye", "Serif Test", 40.0)),
o => panic!("{o:?}"),
}
}
+66
View File
@@ -164,3 +164,69 @@ fn copy_frame_hands_the_rendered_frame_to_the_frontend() {
assert!(Session::default().execute("comp.copyFrame", json!({})).is_err());
assert!(crate::menus::entries().iter().any(|(path, e)| e.command == "comp.copyFrame" && path.first().map(String::as_str) == Some("Composition")));
}
#[test]
fn save_frame_as_png_preserves_alpha_channel() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "AlphaComp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
let red = s.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 16, "height": 16})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": red, "path": "transform/position", "value": [8, 8, 0]})).unwrap();
let dir = out_dir("frame-png-alpha");
let path = dir.join("alpha.png").to_string_lossy().to_string();
s.execute("comp.saveFrameAs", json!({"path": path})).unwrap();
let img = image::load_from_memory(&std::fs::read(&path).unwrap()).unwrap().to_rgba8();
assert_eq!((img.width(), img.height()), (32, 32));
let solid_px = img.get_pixel(8, 8);
assert_eq!(solid_px[3], 255, "solid region must be opaque: {solid_px:?}");
assert_eq!(solid_px[0], 255);
let empty_px = img.get_pixel(24, 24);
assert_eq!(empty_px[3], 0, "outside solid region must have alpha 0: {empty_px:?}");
let flat_path = dir.join("flat.png").to_string_lossy().to_string();
s.execute_checked("comp.saveFrameAs", json!({"path": flat_path, "transparent": false})).unwrap();
let flat_img = image::load_from_memory(&std::fs::read(&flat_path).unwrap()).unwrap().to_rgba8();
let flat_empty_px = flat_img.get_pixel(24, 24);
assert_eq!(flat_empty_px[3], 255, "transparent: false must composite over background: {flat_empty_px:?}");
}
#[test]
fn save_frame_as_queued_respects_frame_default_template() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Comp", "width": 32, "height": 32, "frameRate": 24, "duration": 1})).unwrap();
// Default Still template is "PNG Sequence" (Channels::Rgb)
let r = s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap();
assert_eq!(r["queued"], true);
let it = s.project.render_queue.last().unwrap();
assert_eq!(it.output.name, "PNG Sequence");
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb);
// Test undo and redo of queuing a frame
s.execute("edit.undo", json!({})).unwrap();
assert!(s.project.render_queue.is_empty());
s.execute("edit.redo", json!({})).unwrap();
let it = s.project.render_queue.last().unwrap();
assert_eq!(it.output.name, "PNG Sequence");
// Explicit transparent: false forces Channels::Rgb
s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap();
let it = s.project.render_queue.last().unwrap();
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb);
// When Frame Default is set to an RGBA template, it is respected
s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "PNG Sequence with Alpha"})).unwrap();
s.execute_checked("comp.saveFrameAs", json!({"queue": true})).unwrap();
let it = s.project.render_queue.last().unwrap();
assert_eq!(it.output.name, "PNG Sequence with Alpha");
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgba);
// Passing transparent: false forces Channels::Rgb even with an RGBA template
s.execute_checked("comp.saveFrameAs", json!({"queue": true, "transparent": false})).unwrap();
let it = s.project.render_queue.last().unwrap();
assert_eq!(it.output.channels, effectcraft_project::render_queue::Channels::Rgb);
// With JPEG Sequence as Frame Default (non-alpha format), queue succeeds without error
s.execute_checked("renderQueue.setTemplateDefault", json!({"kind": "outputModule", "slot": "still", "name": "JPEG Sequence"})).unwrap();
assert!(s.execute_checked("comp.saveFrameAs", json!({"queue": true})).is_ok());
}
+257
View File
@@ -0,0 +1,257 @@
//! Create 3D Layer Instance (Layer ▸ Create): a comp-sized solid at the top of the layer stack
//! with a 3D Layer Instance effect referencing the selected 3D model layer — a 2D copy of its
//! render that 2D effects can be applied to (behaviour, pixels, undo/redo, serde).
use std::sync::Arc;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, Project};
use effectcraft_render::{FootageSource, RenderOpts};
use effectcraft_time::{FrameRate, Tick};
use serde_json::json;
use crate::{Importer, Session};
fn session() -> Session {
let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() };
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap();
s
}
fn cid(s: &Session) -> ItemId {
s.active_comp_id().unwrap()
}
fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer {
s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone()
}
fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] {
img.data[(y * img.width + x) as usize]
}
fn close(a: &effectcraft_raster::Image, b: &effectcraft_raster::Image, tol: f32) -> bool {
a.data.iter().zip(b.data.iter()).all(|(p, q)| p.iter().zip(q.iter()).all(|(x, y)| (x - y).abs() <= tol))
}
struct DiskModels;
impl FootageSource for DiskModels {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
None
}
fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {
let bytes = std::fs::read(&f.path).ok()?;
effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new)
}
}
struct ModelImporter;
impl Importer for ModelImporter {
fn probe(&self, path: &str) -> Result<Footage, String> {
effectcraft_model::format_of(path).ok_or("not a model")?;
Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() })
}
}
/// A red triangle in the Z = 0 plane (buffer embedded as a data URI).
const TRIANGLE: &str = r#"{
"asset": {"version": "2.0"},
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0}],
"meshes": [{"primitives": [{"attributes": {"POSITION": 0, "NORMAL": 1}, "material": 0}]}],
"materials": [{"pbrMetallicRoughness": {"baseColorFactor": [1.0, 0.0, 0.0, 1.0]}}],
"buffers": [{"byteLength": 72, "uri": "data:application/octet-stream;base64,AACAvwAAgL8AAAAAAACAPwAAgL8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/"}],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": 36},
{"buffer": 0, "byteOffset": 36, "byteLength": 36}
],
"accessors": [
{"bufferView": 0, "componentType": 5126, "type": "VEC3", "count": 3, "min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 0.0]},
{"bufferView": 1, "componentType": 5126, "type": "VEC3", "count": 3}
]
}"#;
fn triangle_model(s: &mut Session) -> u64 {
let dir = std::env::temp_dir().join(format!("ec-instance3d-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Triangle.gltf");
std::fs::write(&path, TRIANGLE).unwrap();
s.execute("layer.newModel", json!({"path": path.to_string_lossy(), "name": "Triangle"})).unwrap()["layer"].as_u64().unwrap()
}
#[test]
fn instance3d_creates_a_2d_copy_of_the_model() {
let mut s = session();
let model = triangle_model(&mut s);
// The model renders through the comp's camera.
let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
assert!(px(&before, 320, 180)[3] > 0.9, "the model covers the centre: {:?}", px(&before, 320, 180));
assert!(px(&before, 4, 4)[3] < 0.1, "the comp is transparent around it: {:?}", px(&before, 4, 4));
// Create 3D Layer Instance.
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
let solid = r["layers"][0].as_u64().unwrap();
let comp = cid(&s);
let l = layer_in(&s, comp, solid);
assert_eq!(l.name, "Triangle Instance");
assert_eq!(s.active_comp().unwrap().layers[0].id.0, solid, "at the top of the layer stack");
// A 3D Layer Instance effect referencing the 3D layer.
let g = l.effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.3d.instance3d"), "{:?}", g.kind);
assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model)));
// The model is switched off, the copy selected.
assert!(!layer_in(&s, comp, model).switches.video, "the referenced 3D layer is off, as After Effects does");
assert_eq!(s.state.selected_layers, vec![LayerId(solid)]);
// The comp renders the same through the copy: a 2D copy of the 3D layer, transparent
// around it.
let after = s.render(comp, Tick::ZERO, RenderOpts::default());
assert!(close(&before, &after, 2e-2), "the copy shows the model:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180));
assert!(px(&after, 320, 180)[3] > 0.9 && px(&after, 4, 4)[3] < 0.1, "{:?} {:?}", px(&after, 320, 180), px(&after, 4, 4));
// Undo removes the copy and switches the model back on; redo returns it.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_none());
assert!(layer_in(&s, comp, model).switches.video);
s.execute("edit.redo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(LayerId(solid)).is_some());
assert!(!layer_in(&s, comp, model).switches.video);
// Serde round-trips the layer parameter.
let j = serde_json::to_string(&*s.project).unwrap();
let back: Project = serde_json::from_str(&j).unwrap();
assert_eq!(back, *s.project);
// 2D effects can be applied to the copy (the model layer itself takes none): Fill in blue
// recolours the triangle.
s.execute("effect.apply", json!({"layer": solid, "effect": "ec.generate.fill"})).unwrap();
s.execute("prop.set", json!({"layer": solid, "path": "effects/#2/color", "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
let with_fx = s.render(comp, Tick::ZERO, RenderOpts::default());
let p = px(&with_fx, 320, 180);
assert!(p[2] > 0.9 && p[0] < 0.1, "the fill recolours the copy: {p:?}");
}
#[test]
fn instance3d_also_takes_3d_primitives() {
let mut s = session();
let prim = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 200, "height": 200, "depth": 200})).unwrap()["layer"].as_u64().unwrap();
let before = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [prim]})).unwrap();
let solid = r["layers"][0].as_u64().unwrap();
let l = layer_in(&s, cid(&s), solid);
assert_eq!(l.name, "Cube Instance");
assert!(!layer_in(&s, cid(&s), prim).switches.video);
let after = s.render(cid(&s), Tick::ZERO, RenderOpts::default());
assert!(close(&before, &after, 2e-2), "the copy shows the cube");
}
#[test]
fn instance3d_needs_a_3d_layer() {
let mut s = session();
let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
let err = s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [solid]}));
assert!(err.is_err());
let text = s.execute("layer.newText", json!({"text": "hi"})).unwrap()["layer"].as_u64().unwrap();
assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err());
// A 3D-switched text layer is not a model layer either (it has 2D pixels of its own).
s.execute("layer.setSwitch", json!({"layers": [text], "switch": "threeD", "value": true})).unwrap();
assert!(s.execute_checked("layer.create", json!({"op": "instance3d", "layers": [text]})).is_err());
}
#[test]
fn instance3d_creates_every_selected_copy_in_one_undo() {
let mut s = session();
let a = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();
let b = s.execute("layer.new3dPrimitive", json!({"kind": "cube", "width": 120, "height": 120, "depth": 120})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [a, b]})).unwrap();
let copies = r["layers"].as_array().unwrap();
assert_eq!(copies.len(), 2);
let (ia, ib) = (copies[0].as_u64().unwrap(), copies[1].as_u64().unwrap());
let comp = cid(&s);
assert_ne!(ia, ib);
assert_ne!(layer_in(&s, comp, ia).name, layer_in(&s, comp, ib).name, "each copy has a name of its own");
// Both sources are off, both copies selected, above their sources in the stack.
assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);
let mut sel = s.state.selected_layers.clone();
let mut want = vec![LayerId(ia), LayerId(ib)];
sel.sort_by_key(|l| l.0);
want.sort_by_key(|l| l.0);
assert_eq!(sel, want);
let c = s.active_comp().unwrap();
let pos = |id: u64| c.layers.iter().position(|l| l.id.0 == id).unwrap();
assert!(pos(ia) < pos(a) && pos(ib) < pos(b), "the copies sit above their sources");
// One undo removes both copies and switches both models back on; one redo returns them.
s.execute("edit.undo", json!({})).unwrap();
let c = s.active_comp().unwrap();
assert!(c.layer(LayerId(ia)).is_none() && c.layer(LayerId(ib)).is_none(), "one undo removes every instance");
assert!(layer_in(&s, comp, a).switches.video && layer_in(&s, comp, b).switches.video);
s.execute("edit.redo", json!({})).unwrap();
let c = s.active_comp().unwrap();
assert!(c.layer(LayerId(ia)).is_some() && c.layer(LayerId(ib)).is_some(), "one redo returns every instance");
assert!(!layer_in(&s, comp, a).switches.video && !layer_in(&s, comp, b).switches.video);
}
#[test]
fn instance3d_in_a_mixed_selection_only_takes_the_models() {
let mut s = session();
let model = triangle_model(&mut s);
let solid = s.execute("layer.newSolid", json!({"color": "#808080", "width": 100, "height": 100})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d", "layers": [model, solid]})).unwrap();
let copies = r["layers"].as_array().unwrap();
assert_eq!(copies.len(), 1, "only the model gets a copy: {r}");
let copy = copies[0].as_u64().unwrap();
let comp = cid(&s);
assert!(!layer_in(&s, comp, model).switches.video, "the model is switched off");
assert!(layer_in(&s, comp, solid).switches.video, "the plain solid is untouched");
let skipped = r["skipped"].as_array().unwrap();
assert_eq!(skipped.len(), 1);
assert!(skipped[0].as_str().unwrap().contains("not a 3D model layer"), "{skipped:?}");
// The copy references the model, not the solid.
let l = layer_in(&s, comp, copy);
let g = l.effects().unwrap().groups().next().unwrap().clone();
assert_eq!(g.get("layer").unwrap().value, KV::Layer(Some(model)));
}
#[test]
fn instance3d_shows_nothing_outside_the_source_layer_range() {
let mut s = session();
let model = triangle_model(&mut s);
// The model's range ends at 1 s; the comp runs to 2 s.
let comp = cid(&s);
s.edit("trim", None, |p, _| {
p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().out_point = Tick::from_seconds_f64(1.0);
Ok(())
})
.unwrap();
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
let _copy = r["layers"][0].as_u64().unwrap();
// Inside the model's range the copy shows it, transparent around it.
let inside = s.render(comp, Tick::from_seconds_f64(0.5), RenderOpts::default());
assert!(px(&inside, 320, 180)[3] > 0.9, "the copy shows the model: {:?}", px(&inside, 320, 180));
// After its out point the source has nothing to read: the copy is transparent.
let outside = s.render(comp, Tick::from_seconds_f64(1.5), RenderOpts::default());
assert!(outside.data.iter().all(|p| p[3] < 1e-3), "transparent after the source's out point: {:?}", px(&outside, 320, 180));
}
#[test]
fn instance3d_keeps_the_copy_visible_when_the_source_is_soloed() {
let mut s = session();
let model = triangle_model(&mut s);
let comp = cid(&s);
// Solo the model, then create the instance: any_solo hides non-soloed layers and the
// source's video goes off, so the copy has to take the Solo over.
s.edit("solo", None, |p, _| {
p.comp_mut(comp).unwrap().layer_mut(LayerId(model)).unwrap().switches.solo = true;
Ok(())
})
.unwrap();
let before = s.render(comp, Tick::ZERO, RenderOpts::default());
assert!(px(&before, 320, 180)[3] > 0.9, "the soloed model shows: {:?}", px(&before, 320, 180));
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("layer.create", json!({"op": "instance3d"})).unwrap();
let solid = r["layers"][0].as_u64().unwrap();
assert!(layer_in(&s, comp, solid).switches.solo, "the copy takes the source's Solo switch");
assert!(!layer_in(&s, comp, model).switches.video, "the source keeps its video off");
let after = s.render(comp, Tick::ZERO, RenderOpts::default());
assert!(px(&after, 320, 180)[3] > 0.9, "the soloed copy shows the model: {:?}", px(&after, 320, 180));
assert!(close(&before, &after, 2e-2), "the comp renders the same through the copy:\n{:?}\n{:?}", px(&before, 320, 180), px(&after, 320, 180));
}
+482
View File
@@ -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}");
}
}
+60
View File
@@ -1103,6 +1103,66 @@ fn transform_commands_keep_depth_and_follow_separated_dimensions() {
assert_eq!(pos(&s, b)[..2], [200.0, 100.0], "the 200×100 solid's centre");
}
/// The layer's painted bounds mapped to comp space — what the viewer shows.
fn seen_bounds(s: &Session, lid: u64) -> [f64; 4] {
let cid = s.active_comp_id().unwrap();
let comp = s.project.comp(cid).unwrap().clone();
let layer = comp.layer(effectcraft_project::LayerId(lid)).unwrap();
let ectx = effectcraft_render::EvalCtx::new(&s.project, cid, &comp, s.time());
let b = effectcraft_render::content_bounds(&ectx, layer).expect("painted bounds");
let m = ectx.layer_to_comp(layer).0;
let corners = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]];
let pts: Vec<[f64; 2]> = corners
.iter()
.map(|p| {
let q = m.apply(effectcraft_geom::vec2(p[0], p[1]));
[q.x, q.y]
})
.collect();
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in &pts {
x0 = x0.min(p[0]);
y0 = y0.min(p[1]);
x1 = x1.max(p[0]);
y1 = y1.max(p[1]);
}
[x0, y0, x1, y1]
}
#[test]
fn fit_to_comp_fills_the_comp_with_shape_and_text_bounds() {
let mut s = comp();
let close = |x: f64, y: f64| (x - y).abs() < 1e-6;
// A shape or text layer has no source item: fit sizes it by its painted bounds, anchored at
// the bounds' centre, so what the viewer shows fills the frame (#2198).
let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [180, 80]})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).unwrap();
let seen = seen_bounds(&s, a);
assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the rect's painted bounds fill the comp: {seen:?}");
let t = s.execute("layer.newText", json!({"text": "Title", "size": 80})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.transform", json!({"layers": [t], "op": "fit"})).unwrap();
let seen = seen_bounds(&s, t);
assert!(close(seen[0], 0.0) && close(seen[1], 0.0) && close(seen[2], 640.0) && close(seen[3], 360.0), "the text's painted bounds fill the comp: {seen:?}");
// fitWidth spans the comp's width and centres vertically.
for lid in [a, t] {
s.execute("layer.transform", json!({"layers": [lid], "op": "fitWidth"})).unwrap();
let seen = seen_bounds(&s, lid);
assert!(close(seen[0], 0.0) && close(seen[2], 640.0), "fitWidth spans the comp width: {seen:?}");
assert!(close((seen[1] + seen[3]) / 2.0, 180.0), "fitWidth centres vertically: {seen:?}");
}
}
#[test]
fn fit_to_comp_refuses_a_scale_that_overflows() {
let mut s = comp();
let a = s.execute("layer.newShape", json!({"kind": "rect", "size": [1e-305, 80]})).unwrap()["layer"].as_u64().unwrap();
let before = s.project.clone();
assert!(s.execute("layer.transform", json!({"layers": [a], "op": "fit"})).is_err(), "an infinite scale is refused");
assert_eq!(s.project, before, "nothing was written");
}
#[test]
fn duplicated_and_pasted_layers_keep_their_links() {
let mut s = comp();
+151 -1
View File
@@ -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;
@@ -61,6 +62,11 @@ impl FootageSource for Synthetic {
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).
@@ -182,6 +188,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();
@@ -198,6 +316,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]
+183 -2
View File
@@ -7,7 +7,7 @@ use std::sync::Arc;
use effectcraft_geom::{Vec3, vec3};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project};
use effectcraft_render::three_d::camera::layer_frame;
use effectcraft_render::three_d::camera::{is_two_node, layer_frame};
use effectcraft_render::{EvalCtx, FootageSource, RenderOpts};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@@ -40,7 +40,7 @@ fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
fn frame(s: &Session, id: u64) -> (Vec3, Vec3) {
let c = cid(s);
let comp = s.project.comp(c).unwrap();
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None };
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap());
(e, f)
}
@@ -217,8 +217,189 @@ fn control_light_with_camera_follows_and_releases() {
assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err());
}
/// Parenting keeps a camera where it is (After Effects' Parent & Link): Position and Point of
/// Interest are re-expressed in the null's space, so the frame does not move.
#[test]
fn parented_camera_keeps_its_frame_under_an_offset_null() {
let mut s = session();
let cam = camera(&mut s);
let (eye0, f0) = frame(&s, cam);
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap();
s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap();
let (eye1, f1) = frame(&s, cam);
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}");
// The null's origin is Position minus its default anchor (50, 50): (50, 10) in the comp.
let c = layer_in(&s, cid(&s), cam);
assert!(close3(v3(&c, "transform/position"), [270.0, 170.0, -800.0], 1e-6), "{:?}", v3(&c, "transform/position"));
assert!(close3(v3(&c, "transform/poi"), [270.0, 170.0, 0.0], 1e-6), "{:?}", v3(&c, "transform/poi"));
// Moving the null carries the camera and keeps its aim.
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [140, 90, 0]})).unwrap();
let (eye2, f2) = frame(&s, cam);
assert!((eye2 - (eye0 + vec3(40.0, 30.0, 0.0))).length() < 1e-6, "{eye2:?}");
assert!((f2 - f0).length() < 1e-6, "{f2:?}");
}
/// World (eye, forward, down) of a layer in the active comp at time `t`.
fn frame_at(s: &Session, id: u64, t: Tick) -> (Vec3, Vec3, Vec3) {
let c = cid(s);
let comp = s.project.comp(c).unwrap();
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: t, expr: None, footage: None, property_overrides: &[], media_overrides: &[] };
layer_frame(&ctx, comp.layer(LayerId(id)).unwrap())
}
/// Whether two frames agree on eye, forward and down.
fn frames_match(a: (Vec3, Vec3, Vec3), b: (Vec3, Vec3, Vec3)) -> bool {
(a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-6 && (a.2 - b.2).length() < 1e-6
}
/// A 3D null off-centre in the comp, with an Orientation and a Z rotation.
fn rotated_null(s: &mut Session, orientation: [f64; 3], turn: f64) -> u64 {
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [nul], "switch": "threeD", "value": true})).unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 40]})).unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/orientation", "value": orientation})).unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": turn})).unwrap();
nul
}
/// A two-node camera under a rotated null keeps its eye, aim and roll: its Orientation is
/// re-expressed against the new aim, so Up does not drift.
#[test]
fn two_node_camera_under_rotated_null_keeps_its_roll() {
let mut s = session();
let cam = camera(&mut s);
s.execute("prop.set", json!({"layer": cam, "path": "transform/rotation", "value": 25})).unwrap();
assert!(is_two_node(&layer_in(&s, cid(&s), cam)));
let before = frame_at(&s, cam, Tick::ZERO);
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
s.execute("layer.setParent", json!({"layers": [cam], "parent": nul})).unwrap();
let after = frame_at(&s, cam, Tick::ZERO);
assert!(frames_match(before, after), "{before:?} {after:?}");
}
#[test]
fn parented_point_light_keeps_its_direction_under_a_rotated_null() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap();
let (eye0, f0) = frame(&s, light);
let nul = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/position", "value": [100, 60, 0]})).unwrap();
s.execute("prop.set", json!({"layer": nul, "path": "transform/rotation", "value": 30})).unwrap();
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
let (eye1, f1) = frame(&s, light);
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?} {f0:?} {f1:?}");
}
/// Keyed Orientation under a Z-only turn is shifted exactly, so the light keeps its direction at
/// every time, between the keys too.
#[test]
fn keyed_orientation_under_a_z_turn_keeps_the_direction_at_every_time() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.0, json!([5.0, 18.0, 14.0]))] {
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
}
let times = [0.0, 0.25, 0.5, 0.75, 1.0].map(Tick::from_seconds_f64);
let before = times.map(|t| frame_at(&s, light, t));
let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 30.0);
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
for (t, b) in times.into_iter().zip(before) {
assert!(frames_match(b, frame_at(&s, light, t)), "a Z-only turn keeps the light's direction");
}
}
/// Under a tilted null the keys alone cannot follow (Euler angles are blended between them), so keys are
/// added between them: the light keeps its direction within a small angle at every time.
#[test]
fn keyed_orientation_under_a_tilted_null_follows_between_the_keys() {
{
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0.0, json!([-8.0, 12.0, -13.0])), (1.5, json!([50.0, 118.0, 74.0]))] {
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
}
let times = [0.0, 0.2, 0.5, 0.9, 1.3, 1.5].map(Tick::from_seconds_f64);
let before = times.map(|t| frame_at(&s, light, t));
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
for (t, b) in times.into_iter().zip(before) {
let a = frame_at(&s, light, t);
// 0.05 degrees is about 9e-4 of a radian on a unit vector.
assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 2e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1);
}
assert!(layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len() > 2, "keys were added between the two");
}
}
/// A one-node light parented under a mirrored null (Z scale -100%) keeps the Position and
/// Orientation it was authored with: it has no Scale to carry the mirror, so none of it is
/// compensated rather than reversing its frame (a half-compensated light would point backwards).
#[test]
fn one_node_light_under_a_mirrored_null_keeps_its_authored_values() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": light, "path": "transform/orientation", "value": [30, 0, 0]})).unwrap();
let before = layer_in(&s, cid(&s), light);
let nul = rotated_null(&mut s, [0.0, 0.0, 0.0], 0.0);
s.execute("prop.set", json!({"layer": nul, "path": "transform/scale", "value": [100, 100, -100]})).unwrap();
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
let after = layer_in(&s, cid(&s), light);
assert_eq!(after.parent, Some(LayerId(nul)));
assert_eq!(v3(&after, "transform/position"), v3(&before, "transform/position"));
assert_eq!(v3(&after, "transform/orientation"), v3(&before, "transform/orientation"));
}
/// The cap on keys added by baking is for the whole Orientation property, not for each segment:
/// many strongly eased segments under a tilted parent stay within it.
#[test]
fn baked_orientation_keys_are_capped_across_the_whole_property() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
let count = 120;
for i in 0..count {
let swing = if i % 2 == 0 { json!([0.0, 0.0, 0.0]) } else { json!([170.0, 60.0, 120.0]) };
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": i as f64 * 0.05, "value": swing})).unwrap();
}
let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid;
let keys: Vec<_> = (0..count).map(|i| json!({"layer": light, "prop": uid, "time": i as f64 * 0.05})).collect();
s.execute("keys.select", json!({"keys": keys})).unwrap();
s.execute("keys.easyEase", json!({})).unwrap();
let nul = rotated_null(&mut s, [20.0, -35.0, 60.0], 30.0);
s.execute("layer.setParent", json!({"layers": [light], "parent": nul})).unwrap();
let n = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().keys.len();
assert!(n <= 4096, "{n} keys: the cap is for the property, not per segment");
// the segments the cap left unchecked keep their authored easing instead of turning into straight blends
let orientation = layer_in(&s, cid(&s), light);
let keys = &orientation.props.prop("transform/orientation").unwrap().keys;
let last = keys.iter().rev().nth(1).expect("the penultimate key");
assert_ne!(last.out_interp, effectcraft_keyframe::Interp::Linear, "the last segment keeps its authored easing");
}
// ---------------------------------------------------------------- from 3D model
/// Easy Ease agrees with a straight blend at the midpoint, but not at the quarter points.
#[test]
fn eased_orientation_under_x_parent_keeps_motion_between_keys() {
let mut s = session();
let light = s.execute("layer.newLight", json!({"kind": "Point", "position": [320, 180, -500]})).unwrap()["layer"].as_u64().unwrap();
for (time, value) in [(0.0, json!([0.0, 0.0, 0.0])), (1.0, json!([90.0, 0.0, 0.0]))] {
s.execute("prop.addKey", json!({"layer": light, "path": "transform/orientation", "time": time, "value": value})).unwrap();
}
let uid = layer_in(&s, cid(&s), light).props.prop("transform/orientation").unwrap().uid;
s.execute("keys.select", json!({"keys": [{"layer": light, "prop": uid, "time": 0.0}, {"layer": light, "prop": uid, "time": 1.0}]})).unwrap();
s.execute("keys.easyEase", json!({})).unwrap();
let times: Vec<_> = (0..=100).map(|i| Tick::from_seconds_f64(i as f64 / 100.0)).collect();
let before: Vec<_> = times.iter().map(|t| frame_at(&s, light, *t)).collect();
let parent = rotated_null(&mut s, [30.0, 0.0, 0.0], 0.0);
s.execute("layer.setParent", json!({"layers": [light], "parent": parent})).unwrap();
for (t, b) in times.into_iter().zip(before) {
let a = frame_at(&s, light, t);
assert!((a.0 - b.0).length() < 1e-6 && (a.1 - b.1).length() < 1e-3, "at {t:?}: {:?} vs {:?}", a.1, b.1);
}
}
struct DiskModels;
impl FootageSource for DiskModels {
+27
View File
@@ -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");
+51 -6
View File
@@ -104,10 +104,11 @@ fn numbered_stills_import_as_stills_by_default() {
let r = s.execute("file.import", json!({"paths": [dir.clone()], "drag": true})).unwrap();
let (name, f) = footage(&s, &r["items"][0]);
assert_eq!((name.as_str(), f.kind), ("shot_[0001-0005].png", FootageKind::Sequence));
// Scripts and agents naming one frame without `sequence` still get its run.
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0003.png")]})).unwrap();
let (_, f) = footage(&s, &r["items"][0]);
assert_eq!(f.kind, FootageKind::Sequence);
// #2018: scripts and agents naming one frame without `sequence` get that still, as
// `ImportOptions.sequence` defaults to false in After Effects.
let r = s.execute("file.import", json!({"paths": [at(&dir, "shot_0005.png")]})).unwrap();
let (name, f) = footage(&s, &r["items"][0]);
assert_eq!((name.as_str(), f.kind), ("shot_0005.png", FootageKind::Still));
}
#[test]
@@ -181,14 +182,58 @@ fn interpret_footage_start_timecode_relabels_source_time() {
}
#[test]
fn replace_footage_and_reload_keep_sequences() {
fn replace_footage_keeps_a_still_a_still() {
let dir = folder(&RUN);
let mut s = session();
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": false})).unwrap()["items"][0].clone();
s.state.project_selection = vec![ItemId(item.as_u64().unwrap())];
// Replacing with a numbered still does not silently turn the item into a sequence (#2073).
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.sequence.len()), ("shot_[0001-0005].png", 4));
assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still));
// JSON null is unspecified, not "on": the still stays a still.
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": null})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still));
// Alphabetical is a sequence modifier, not a request to make one.
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "alphabetical": true})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind), ("shot_0004.png", FootageKind::Still));
// Asking for the run still brings it.
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png"), "sequence": true})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4));
}
#[test]
fn replace_footage_keeps_a_one_frame_run_a_sequence() {
let dir = folder(&["shot_0001.png", "shot_0002.png", "solo_0007.png", "cover.png"]);
let mut s = session();
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone();
s.state.project_selection = vec![ItemId(item.as_u64().unwrap())];
// A numbered file with no other frame in its run is still the replacement for a sequence.
s.execute("file.replaceFootage", json!({"path": at(&dir, "solo_0007.png")})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("solo_0007.png", FootageKind::Sequence, 1));
// A single-file source keeps the sequence kind too.
s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png")})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("cover.png", FootageKind::Sequence, 1));
// An explicit `sequence: false` still asks for one frame.
s.execute("file.replaceFootage", json!({"path": at(&dir, "cover.png"), "sequence": false})).unwrap();
let (_, f) = footage(&s, &item);
assert_eq!((f.kind, f.sequence.len()), (FootageKind::Still, 0));
}
#[test]
fn replace_footage_and_reload_keep_sequences() {
let dir = folder(&RUN);
let mut s = session();
let item = s.execute("file.import", json!({"paths": [at(&dir, "shot_0001.png")], "sequence": true})).unwrap()["items"][0].clone();
s.state.project_selection = vec![ItemId(item.as_u64().unwrap())];
s.execute("file.replaceFootage", json!({"path": at(&dir, "shot_0004.png")})).unwrap();
let (name, f) = footage(&s, &item);
assert_eq!((name.as_str(), f.kind, f.sequence.len()), ("shot_[0001-0005].png", FootageKind::Sequence, 4));
// Reload finds a new frame and keeps the interpretation.
s.execute("file.interpretFootage", json!({"items": [item], "frameRate": 12, "startTimecode": 2})).unwrap();
std::fs::write(at(&dir, "shot_0003.png"), b"").unwrap();
+17
View File
@@ -389,6 +389,23 @@ fn new_project_template_and_default_renderer() {
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn project_files_are_indented_unless_compact_is_on() {
let d = tmp("compact-project-files");
let path = d.join("p.ecproj");
let mut s = session_with_comp();
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
let pretty = std::fs::read_to_string(&path).unwrap();
assert!(pretty.starts_with("{\n \"savedBy\""), "indented by default");
s.execute("prefs.set", json!({"key": "project.compactProjectFiles", "value": true})).unwrap();
s.execute("file.saveAs", json!({"path": path.to_string_lossy()})).unwrap();
let compact = std::fs::read_to_string(&path).unwrap();
assert!(!compact.contains('\n'), "one line when Save Compact Project Files is on");
let open = |text: &str| effectcraft_project::Project::from_json(text).unwrap();
assert_eq!(open(&compact), open(&pretty), "both read back as the same project");
let _ = std::fs::remove_dir_all(d);
}
// ---------------------------------------------------------------- shortcuts
#[test]
+22
View File
@@ -184,6 +184,28 @@ fn in_and_out_points_stretch_to_the_current_time() {
assert!(s.execute("layer.timeStretch", json!({"to": "middle"})).is_err());
}
#[test]
fn trimming_out_to_the_playhead_keeps_its_frame_visible() {
let (mut s, ids) = session([(1.0, 9.0); 3]);
let frame = 1.0 / 30.0;
// Alt+] at 4 s: the Out edge lands one frame after the playhead, so 4 s is the layer's last
// visible frame — it must not be cut away at the playhead (as in After Effects).
s.execute("time.set", json!({"time": 4.0})).unwrap();
s.execute("layer.timing", json!({"layers": [ids[0]], "op": "trimOutToTime"})).unwrap();
assert!((span(&s, ids[0]).1 - (4.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[0]));
// Alt+[ at 2 s: the playhead's frame becomes the layer's first visible one.
s.execute("time.set", json!({"time": 2.0})).unwrap();
s.execute("layer.timing", json!({"layers": [ids[1]], "op": "trimInToTime"})).unwrap();
assert!((span(&s, ids[1]).0 - 2.0).abs() < 1e-9, "{:?}", span(&s, ids[1]));
// Trimmed before the In point, the Out point keeps one frame of layer.
s.execute("time.set", json!({"time": 0.5})).unwrap();
s.execute("layer.timing", json!({"layers": [ids[2]], "op": "trimOutToTime"})).unwrap();
assert!((span(&s, ids[2]).1 - (1.0 + frame)).abs() < 1e-9, "{:?}", span(&s, ids[2]));
// One undo step back to the original span.
assert!(s.undo());
assert!((span(&s, ids[2]).1 - 9.0).abs() < 1e-9);
}
#[test]
fn masks_are_stepped_through_and_wrap() {
let (mut s, ids) = session([(0.0, 10.0); 3]);
+22 -2
View File
@@ -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();
@@ -583,3 +584,22 @@ fn convert_to_bezier_reads_the_shape_as_it_is_set() {
let mid = |v: &[f64]| (v.iter().copied().fold(f64::MAX, f64::min) + v.iter().copied().fold(f64::MIN, f64::max)) / 2.0;
assert!((mid(&xs) - 10.0).abs() < 1e-6 && (mid(&ys) - 5.0).abs() < 1e-6, "centred on the position: {:?} {:?}", mid(&xs), mid(&ys));
}
/// #2201: a viewer drag on the free-transform box sends one `path.freeTransform` per frame with a
/// shared merge key; one undo must revert the whole drag, not the last frame.
#[test]
fn free_transform_drag_undoes_in_one_step() {
let mut s = comp();
let (l, m) = masked(&mut s);
s.state.selected_vertices.clear();
let before = mask_path(&s, l, m);
for k in [90, 90, 90, 90] {
s.execute("path.freeTransform", json!({"layer": l, "scale": k, "merge": "viewer-drag-1"})).unwrap();
}
let after = mask_path(&s, l, m);
assert_ne!(after, before);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(mask_path(&s, l, m), before, "one undo reverts the whole drag");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(mask_path(&s, l, m), after, "redo restores the whole drag");
}
+64
View File
@@ -421,6 +421,70 @@ fn camera_focus_commands_undo_and_serde() {
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
}
/// Set Focus Distance to Layer: degenerate targets (behind the camera, exactly at it) are clean
/// errors, and a depth-of-field frame renders right after the command.
#[test]
fn camera_set_focus_to_layer_degenerate_targets_and_dof_frame() {
let mut s = comp(400, 300);
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [200, 150, -500]})).unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": true})).unwrap();
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
// Behind the camera (which now sits at z = −500 looking towards +z).
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -1000]})).unwrap();
let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string();
assert!(e.contains("behind the camera"), "{e}");
// Exactly at the camera: depth 0, still a clean error.
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -500]})).unwrap();
assert!(s.execute("camera.setFocusToLayer", json!({})).is_err());
// In front: the command works, and the depth-of-field frame renders with finite pixels.
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 400]})).unwrap();
let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
assert!((d - 900.0).abs() < 1e-6, "{d}");
// An out-of-focus 3D layer, which must visibly blur, and a 0%-scaled one, whose pixels cover
// ~0 screen pixels and so turn an ordinary defocus into a radius of ~1e9 buffer pixels.
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/aperture", "value": 120})).unwrap();
let blur = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [blur], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setTransform", json!({"layer": blur, "prop": "position", "value": [200, 150, 1200]})).unwrap();
let invis = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [invis], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setTransform", json!({"layer": invis, "prop": "position", "value": [200, 150, 1600]})).unwrap();
s.execute("layer.setTransform", json!({"layer": invis, "prop": "scale", "value": [0.0, 0.0, 0.0]})).unwrap();
let cid = s.active_comp_id().unwrap();
let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
assert!(img.data.iter().all(|p| p.iter().all(|c| c.is_finite())), "non-finite pixels after setFocusToLayer");
// The DoF frame actually blurred (not a no-op blur): the same frame with DoF off differs.
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": false})).unwrap();
let plain = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
assert_ne!(img.data, plain.data, "the out-of-focus layer should have blurred");
}
/// Set Focus Distance to Layer fails cleanly on a depth that isn't finite. Every stored number
/// here is finite, but their difference overflows f64 to infinity; JSON has no infinity, so
/// storing it would leave a project that can't be saved.
#[test]
fn camera_set_focus_to_layer_rejects_non_finite_depth() {
let mut s = comp(400, 300);
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200.0, 150.0, 1e308]})).unwrap();
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setTransform", json!({"layer": cam, "prop": "position", "value": [200.0, 150.0, -1e308]})).unwrap();
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string();
assert!(e.contains("out of range"), "{e}");
let after = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
assert!((after - before).abs() < 1e-9, "a non-finite focus distance was written: {after}");
// The project still round-trips through serde (a stored infinity would come back as null).
let back = roundtrip(&s);
let f = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().value.as_f64();
assert!((f - before).abs() < 1e-9, "serde focus distance: {f}");
}
/// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches.
#[test]
fn numeric_string_resolves_as_id() {
+33 -13
View File
@@ -199,6 +199,20 @@ fn typing_deleting_and_caret_movement() {
assert!(s.state.text_edit.is_none());
}
/// Duplicating during a text edit moves the selection to the copy: the edit on the original ends
/// (#2226), as in After Effects — typing must not keep editing an unselected layer.
#[test]
fn duplicate_during_a_text_edit_ends_the_edit() {
let (mut s, t) = session("Hello");
s.execute("text.edit", json!({"layer": t, "caret": 5})).unwrap();
let copy = s.execute("edit.duplicate", json!({"layers": [t]})).unwrap()[0].as_u64().unwrap();
assert_ne!(copy, t, "the duplicate is created");
assert_eq!(s.state.selected_layers, vec![effectcraft_project::LayerId(copy)], "the copy is selected");
assert!(s.state.text_edit.is_none(), "the edit ended with the selection move (#2226)");
assert_eq!(doc(&s, t).text, "Hello", "the original keeps its text");
assert_eq!(doc(&s, copy).text, "Hello", "and the copy carries it");
}
#[test]
fn caret_style_applies_to_the_next_typed_text() {
let (mut s, t) = session("ab");
@@ -260,25 +274,25 @@ fn clipboard_and_paste_formatting() {
}
#[test]
fn type_tool_layers_point_box_and_empty_cleanup() {
fn type_tool_layers_point_box_and_empty_layer_is_kept() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 640, "height": 360, "duration": 4})).unwrap();
let t = s.execute("layer.newText", json!({"text": "", "position": [100, 200], "justify": "left", "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(s.state.text_edit.as_ref().map(|e| e.layer.0), Some(t));
// Leaving an untouched Type-tool layer deletes it.
// Leaving an untouched Type-tool layer keeps it, as After Effects does.
s.execute("text.endEdit", json!({})).unwrap();
assert!(s.active_comp().unwrap().layers.is_empty());
assert_eq!(s.active_comp().unwrap().layers.len(), 1, "an empty Type-tool layer is kept, as in AE");
let b = s.execute("layer.newText", json!({"text": "", "box": [40, 50, 300, 120], "edit": true})).unwrap()["layer"].as_u64().unwrap();
s.execute("text.insert", json!({"text": "Boxed"})).unwrap();
let d = doc(&s, b);
assert_eq!(d.box_size, Some([300.0, 120.0]));
assert_eq!(d.box_pos, [-150.0, -60.0]);
let l = s.active_comp().unwrap().layers[0].clone();
let l = s.active_comp().unwrap().layers.iter().find(|l| l.id.0 == b).expect("the boxed layer").clone();
assert_eq!(l.name, "Boxed", "named after its text");
let pos = l.props.prop("transform/position").unwrap().value.as_vec3();
assert_eq!([pos[0], pos[1]], [190.0, 110.0]);
s.execute("text.endEdit", json!({})).unwrap();
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
assert_eq!(s.active_comp().unwrap().layers.len(), 2, "both text layers stay, the empty one included");
// Resizing the box (layer space) is an attribute too.
s.execute("layer.setText", json!({"layer": b, "box": [-100, -60, 200, 120]})).unwrap();
assert_eq!(doc(&s, b).box_size, Some([200.0, 120.0]));
@@ -928,11 +942,19 @@ fn fonts_lists_bundled_and_installed_families() {
let r = s.execute("text.fonts", json!({"query": "noto ser", "rescan": true})).unwrap();
assert_eq!(r["families"][0]["family"], "Noto Serif");
// The bundled face is Regular; the OS can also provide Bold, Italic, or other faces.
// Compare against the independent directory inventory rather than assuming an empty OS.
let mut expected_styles = std::collections::BTreeSet::from(["Regular".to_string()]);
expected_styles.extend(installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")).map(|face| face.style.clone()));
// Model `add_source()` on the independent directory inventory: a system face whose family
// and primary style duplicate an earlier face is skipped, with its named instances.
let mut expected_styles = std::collections::BTreeSet::from(["regular".to_string()]);
let mut retained = std::collections::BTreeSet::from([("noto serif".to_string(), "regular".to_string())]);
for face in installed.iter().filter(|face| face.family.eq_ignore_ascii_case("Noto Serif")) {
if retained.insert((face.family.to_ascii_lowercase(), face.style.to_ascii_lowercase())) {
expected_styles.insert(face.style.to_ascii_lowercase());
expected_styles.extend(face.instances.iter().map(|i| i.style.to_ascii_lowercase()));
}
}
let styles: Vec<String> = r["families"][0]["styles"].as_array().unwrap().iter().map(|style| style.as_str().unwrap().to_string()).collect();
assert_eq!(styles.iter().cloned().collect::<std::collections::BTreeSet<_>>(), expected_styles);
// `families()` keeps the first spelling of a style that differs only in case.
assert_eq!(styles.iter().map(|style| style.to_ascii_lowercase()).collect::<std::collections::BTreeSet<_>>(), expected_styles);
assert_eq!(styles.len(), expected_styles.len(), "style menu has no duplicates");
assert!(r["families"].as_array().unwrap().iter().all(|f| f["family"].as_str().unwrap().to_lowercase().contains("noto ser")));
// The style menus offer a family's own styles.
@@ -1202,7 +1224,7 @@ fn deselect_all_ends_the_open_edit() {
}
#[test]
fn deselect_all_discards_an_empty_type_tool_layer() {
fn deselect_all_keeps_an_empty_type_tool_layer() {
let (mut s, _keep) = session("Hello");
let new = s.execute("layer.newText", json!({"text": "", "position": [80.0, 80.0], "edit": true})).unwrap()["layer"].as_u64().unwrap();
assert!(
@@ -1214,7 +1236,5 @@ fn deselect_all_discards_an_empty_type_tool_layer() {
s.execute("edit.deselectAll", json!({})).unwrap();
assert!(s.state.text_edit.is_none());
assert!(s.state.selected_layers.is_empty());
assert_eq!(count(&s), before - 1, "a Type-tool layer left empty is removed, as text.endEdit does");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(count(&s), before, "that removal is one undo step of its own, as on text.endEdit");
assert_eq!(count(&s), before, "an empty Type-tool layer is kept, as in After Effects");
}
+53
View File
@@ -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]
+1 -1
View File
@@ -419,7 +419,7 @@ fn shape_tool_options_paint_new_shapes() {
fn masks_of_every_shape_tool_kind() {
let mut s = comp();
let sol = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap();
for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 6), ("star", 10)] {
for (kind, n) in [("rect", 4), ("ellipse", 4), ("rounded", 8), ("polygon", 5), ("star", 10)] {
let m = s.execute("layer.addMask", json!({"layer": sol, "shape": kind, "rect": [100, 100, 80, 80]})).unwrap()["mask"].as_u64().unwrap();
let path = layer(&s, sol).props.find_group(m).unwrap().get("path").unwrap().value.as_path().unwrap().clone();
assert!(path.closed, "{kind}");