mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 15:48:33 +00:00
Clipboard: keyframe text for text layer properties, and percent sliders labelled percent (#1908)
This commit is contained in:
1 parent
6cad56b19d
commit
05f2daa4ca
2 files changed
+94
-2
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user