Clipboard: keyframe text for Time Remap and separated X / Y / Z Position (#1908)

This commit is contained in:
tylerjenningsw
2026-10-20 09:45:51 -04:00
parent 30dc57f790
commit 9ee6c5ef62
2 changed files with 126 additions and 41 deletions
+91 -41
View File
@@ -1,28 +1,57 @@
//! Copied keyframes as After Effects keyframe text, for the system clipboard (#1908). Other
//! applications read this tab-separated format (AE2Blend brings it into Blender). Like After
//! Effects it covers one layer: its Transform properties, keyframed ones as one row per key
//! (frame 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.
//! Effects it covers one layer: its copied properties, keyframed ones as one row per key (frame
//! 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, Uid};
use effectcraft_project::{AutoOrient, Comp, ItemKind, Layer, LayerId, LayerSource, Project, Property, Uid};
use crate::KeyRef;
/// Transform properties in After Effects' order, with the unit each column is labelled with.
const CHANNELS: &[(&str, &str)] = &[
("poi", "pixels"),
("anchor", "pixels"),
("position", "pixels"),
("scale", "percent"),
("orientation", "degrees"),
("rotationX", "degrees"),
("rotationY", "degrees"),
("rotation", "degrees"),
("opacity", "percent"),
/// Transform properties with the unit their columns are labelled with and, for vectors, the
/// third component a 2D layer shows. X / Y / Z Position (Separate Dimensions) have no unit.
const TRANSFORM: &[(&str, &str, Option<f64>)] = &[
("poi", "pixels", Some(0.0)),
("anchor", "pixels", Some(0.0)),
("position", "pixels", Some(0.0)),
("positionX", "", None),
("positionY", "", None),
("positionZ", "", None),
("scale", "percent", Some(100.0)),
("orientation", "degrees", None),
("rotationX", "degrees", None),
("rotationY", "degrees", None),
("rotation", "degrees", None),
("opacity", "percent", None),
];
/// How a copied property is written: its block's header line, column labels and values.
struct Channel {
header: String,
labels: Vec<String>,
values: Values,
}
/// How a property's value becomes the numbers in a row.
#[derive(Clone, Copy)]
enum Values {
Plain,
/// 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),
}
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;
}
v
}
}
/// The text for copied keys (layer times) and unanimated properties. `None` unless they all come
/// from one layer and at least one is a Transform property.
/// from one layer and at least one property can be written.
pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(LayerId, Uid)]) -> Option<String> {
let lid = keys.first().map(|k| k.layer).or_else(|| statics.first().map(|s| s.0))?;
if keys.iter().any(|k| k.layer != lid) || statics.iter().any(|s| s.0 != lid) {
@@ -31,11 +60,7 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(
let layer = comp.layer(lid)?;
let fps = comp.frame_rate.as_f64();
let mut blocks = String::new();
for (id, unit) in CHANNELS {
let Some(pr) = layer.props.prop(&format!("transform/{id}")) else { continue };
if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) {
continue;
}
layer.props.walk("", &mut |_, pr| {
let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect();
times.sort();
times.dedup();
@@ -45,29 +70,19 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(
} else if statics.iter().any(|s| s.1 == pr.uid) {
vec![(String::new(), pr.value.components())]
} else {
continue;
return;
};
let Some(dims) = rows.first().map(|(_, v)| v.len()).filter(|n| *n > 0) else { continue };
// After Effects labels only the first of Orientation's three columns, and names Z
// Rotation plain Rotation (as AE2Blend expects).
let columns = match dims {
1 => vec![unit.to_string()],
_ if *id == "orientation" => vec![format!("X {unit}")],
_ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(),
};
blocks.push_str(&format!("Transform\t{}\n\tFrame\t{}\t\n", pr.name, columns.join("\t")));
for (frame, mut values) in rows {
// A 2D layer ignores the Z it may keep (from pasted 3D keys): write the one it shows.
if !layer.is_3d()
&& let Some(z) = values.get_mut(2)
{
*z = if *id == "scale" { 100.0 } else { 0.0 };
}
let values: Vec<String> = values.into_iter().map(num).collect();
let Some(ch) = channel(layer, pr).filter(|ch| !ch.labels.is_empty()) else { return };
if rows.iter().any(|(_, v)| v.is_empty()) {
return;
}
blocks.push_str(&format!("{}\n\tFrame\t{}\t\n", ch.header, ch.labels.join("\t")));
for (frame, values) in rows {
let values: Vec<String> = ch.values.row(values).into_iter().map(num).collect();
blocks.push_str(&format!("\t{frame}\t{}\t\n", values.join("\t")));
}
blocks.push('\n');
}
});
if blocks.is_empty() {
return None;
}
@@ -96,9 +111,44 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(
))
}
/// How the layer's property `pr` is written, by where it sits in the layer: `None` for
/// properties the text doesn't carry.
fn channel(layer: &Layer, pr: &Property) -> Option<Channel> {
let chain = layer.props.node_chain(pr.uid)?;
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),
_ => 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()) {
return None;
}
// After Effects labels only the first of Orientation's three columns, and names Z Rotation
// plain Rotation (as AE2Blend expects).
let labels = if *id == "orientation" { vec![format!("X {unit}")] } else { labels(unit, pr.value.dims()) };
let values = match z {
Some(z) if !layer.is_3d() => Values::Flat(*z),
_ => Values::Plain,
};
Some(Channel { header: format!("Transform\t{}", pr.name), labels, values })
}
/// Column labels: the unit, or one per axis (`X pixels`, `Y pixels`).
fn labels(unit: &str, dims: usize) -> Vec<String> {
match dims {
0 => vec![],
1 => vec![unit.to_string()],
_ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}").trim_end().to_string()).collect(),
}
}
/// Whether the layer uses this Transform property: one-node cameras (and lights not aimed at
/// it) have no Point of Interest, and Separate Dimensions replaces Position with X / Y / Z
/// Position (which this text doesn't cover).
/// Position.
fn active(layer: &Layer, id: &str) -> bool {
match id {
"poi" => !(layer.is_camera() || layer.is_light()) || layer.auto_orient == AutoOrient::TowardsPointOfInterest,
+35
View File
@@ -140,6 +140,41 @@ fn properties_the_layer_does_not_use_are_left_out() {
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}");
}
#[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.