Clipboard: keyframe text for text layer properties, and percent sliders labelled percent (#1908)

This commit is contained in:
tylerjenningsw
2026-10-20 09:57:40 -04:00
parent 6cad56b19d
commit 05f2daa4ca
2 changed files with 94 additions and 2 deletions
+36 -2
View File
@@ -131,10 +131,42 @@ fn channel(layer: &Layer, pr: &Property) -> Option<Channel> {
Some("transform") => transform(layer, pr),
Some("effects") => effect(&chain, pr),
Some("masks") => mask(&chain, pr),
Some("text") => text_prop(&chain, pr),
_ => None,
}
}
/// Text animators and selectors by match id, with the kind After Effects names them by.
const TEXT_INSTANCES: &[(&str, &str)] =
&[("animator", "Animator"), ("rangeSelector", "Range Selector"), ("wigglySelector", "Wiggly Selector"), ("expressionSelector", "Expression Selector")];
/// A text property: `Text`, then each group down to the property. Animators and selectors go by
/// kind and place (`Animator #1`), not name, and a selector's own properties carry their place
/// too (`Start #1`). Source Text isn't a number, and After Effects leaves it out.
fn text_prop(chain: &[&Node], pr: &Property) -> Option<Channel> {
let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect();
let parent = groups.last()?;
// Path Options' Path picks a mask: not a value the text carries.
if parent.match_id == "pathOptions" && pr.match_id == "path" {
return None;
}
let (labels, values) = by_ui(pr)?;
let mut header = String::from("Text");
for pair in groups.windows(2) {
let [outer, g] = pair else { continue };
match TEXT_INSTANCES.iter().find(|(id, _)| *id == g.match_id) {
Some((_, kind)) => header.push_str(&format!("\t{kind} #{}", place(outer, g.uid)?)),
None => header.push_str(&format!("\t{}", g.name)),
}
}
if parent.match_id.ends_with("Selector") {
header.push_str(&format!("\t{} #{}", pr.name, place(parent, pr.uid)?));
} else {
header.push_str(&format!("\t{}", pr.name));
}
Some(Channel { header, labels, values })
}
/// A mask property: `Masks`, the mask by place (not name), then the property. Mask Path isn't a
/// number, and After Effects leaves it out.
fn mask(chain: &[&Node], pr: &Property) -> Option<Channel> {
@@ -164,13 +196,15 @@ 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).
/// Column labels and values for an effect or text property, by how it's edited. After Effects
/// labels most 0–100 and ±100 sliders (opacities, selector amounts) as percent, and other
/// 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::Slider { min, max, .. } if *max == 100.0 && (*min == 0.0 || *min == -100.0) => (labels("percent", dims), Values::Plain),
ParamUi::Percent => (labels("percent", dims), Values::Plain),
ParamUi::Angle => (labels("degrees", dims), Values::Plain),
ParamUi::Point | ParamUi::Point3 => (labels("pixels", dims), Values::Plain),
+58
View File
@@ -246,6 +246,64 @@ fn mask_properties_are_written_as_after_effects_does() {
);
}
#[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.