mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 21:16:52 -06:00
Clipboard: keyframe text for Time Remap and separated X / Y / Z Position (#1908)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user