Clipboard: keyframe text for effect properties (#1908)

This commit is contained in:
tylerjenningsw committed 2026-10-20 09:49:25 -04:00
1 parent 9ee6c5ef62
commit 39142e9e6b
2 files changed
+88 -4

No files matched your search

+47 -4
View File
@@ -4,7 +4,7 @@
//! number in comp time, then the value) and static ones as a single row with no frame. Easing
//! and tangents aren't part of the format; EffectCraft's own paste keeps them.
use effectcraft_project::{AutoOrient, Comp, ItemKind, Layer, LayerId, LayerSource, Project, Property, Uid};
use effectcraft_project::{AutoOrient, Comp, GroupKind, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, Project, PropGroup, Property, Uid};
use crate::KeyRef;
@@ -39,14 +39,25 @@ enum Values {
/// A 2D layer's Transform vector: the third component is the one the layer shows (it may
/// keep another from pasted 3D keys).
Flat(f64),
/// RGBA 0–1 as alpha, red, green, blue 0–255.
Argb,
/// A popup's 0-based choice as After Effects' 1-based one.
OneBased,
}
impl Values {
fn row(self, mut v: Vec<f64>) -> Vec<f64> {
if let (Values::Flat(z), Some(c)) = (self, v.get_mut(2)) {
*c = z;
match (self, v.as_slice()) {
(Values::Flat(z), _) => {
if let Some(c) = v.get_mut(2) {
*c = z;
}
v
}
(Values::Argb, [r, g, b, a]) => [a, r, g, b].map(|c| c * 255.0).to_vec(),
(Values::OneBased, _) => v.into_iter().map(|c| c + 1.0).collect(),
_ => v,
}
v
}
}
@@ -118,10 +129,42 @@ fn channel(layer: &Layer, pr: &Property) -> Option<Channel> {
match chain.first().and_then(|n| n.as_group()).map(|g| g.match_id.as_str()) {
None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }),
Some("transform") => transform(layer, pr),
Some("effects") => effect(&chain, pr),
_ => None,
}
}
/// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer,
/// then the property's name and place, counting the effect's hidden input layer as the first.
fn effect(chain: &[&Node], pr: &Property) -> Option<Channel> {
let [Node::Group(effects), Node::Group(fx), _] = chain else { return None };
let GroupKind::Effect { effect } = &fx.kind else { return None };
let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name);
let (labels, values) = by_ui(pr)?;
let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, place(fx, pr.uid)? + 1);
Some(Channel { header, labels, values })
}
/// 1-based position of a child in its group.
fn place(group: &PropGroup, uid: Uid) -> Option<usize> {
group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1)
}
/// Column labels and values for an effect or text property, by how it's edited (After Effects
/// labels sliders, checkboxes and popups with no unit).
fn by_ui(pr: &Property) -> Option<(Vec<String>, Values)> {
let dims = pr.value.dims();
Some(match &pr.ui {
ParamUi::Color => (["alpha ", "red ", "green ", "blue "].map(String::from).to_vec(), Values::Argb),
ParamUi::Popup { .. } => (labels("", dims), Values::OneBased),
ParamUi::Percent => (labels("percent", dims), Values::Plain),
ParamUi::Angle => (labels("degrees", dims), Values::Plain),
ParamUi::Point | ParamUi::Point3 => (labels("pixels", dims), Values::Plain),
ParamUi::Number | ParamUi::Slider { .. } | ParamUi::Pixels | ParamUi::Checkbox => (labels("", dims), Values::Plain),
_ => return None,
})
}
fn transform(layer: &Layer, pr: &Property) -> Option<Channel> {
let (id, unit, z) = TRANSFORM.iter().find(|(id, ..)| *id == pr.match_id)?;
if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) {
+41
View File
@@ -175,6 +175,47 @@ fn time_remap_writes_source_seconds_at_comp_frames() {
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.
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 a_2d_layer_writes_the_z_it_shows() {
// Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it.