mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 15:48:33 +00:00
Clipboard: copied keyframes as After Effects keyframe text (#1908)
Copying keys or unanimated properties of one layer records them as After Effects keyframe text: its Transform properties, one row per key with the frame in comp time (start offset and stretch applied) or a frameless row for a static value. EffectCraft's own keyframe clipboard is unchanged.
This commit is contained in:
1 parent
ef74d82902
commit
598f0d51c1
7 files changed
+227
No files matched your search
@@ -45,6 +45,7 @@ fn copy(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
clip.push(KeyClip { layer: lid, path, keys });
|
||||
}
|
||||
let n: usize = clip.iter().map(|c| c.keys.len()).sum();
|
||||
s.state.keyframe_text = crate::keyframe_text::text(&s.project, comp, &s.state.selected_keys, &[]);
|
||||
s.state.key_clipboard = clip;
|
||||
s.state.clip_is_keys = true;
|
||||
Ok(json!(n))
|
||||
|
||||
@@ -217,6 +217,7 @@ fn clear_clipboards(s: &mut Session) {
|
||||
s.state.contents_clipboard.clear();
|
||||
s.state.link_clipboard = None;
|
||||
s.state.value_clipboard.clear();
|
||||
s.state.keyframe_text = None;
|
||||
s.state.clip_is_keys = false;
|
||||
}
|
||||
|
||||
@@ -283,10 +284,12 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
}
|
||||
let mut keys = s.state.selected_keys.clone();
|
||||
let mut values = vec![];
|
||||
let mut statics = vec![];
|
||||
for (lid, uid) in &leaves {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
let Some(pr) = l.props.find(*uid) else { continue };
|
||||
if pr.keys.is_empty() {
|
||||
statics.push((*lid, *uid));
|
||||
if let Some(path) = l.props.match_path_of(*uid)
|
||||
&& !values.iter().any(|(p, _)| *p == path)
|
||||
{
|
||||
@@ -296,6 +299,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time }));
|
||||
}
|
||||
}
|
||||
let text = crate::keyframe_text::text(&s.project, comp, &keys, &statics);
|
||||
clear_clipboards(s);
|
||||
let mut n = 0;
|
||||
if !keys.is_empty() {
|
||||
@@ -304,6 +308,7 @@ fn copy_properties(s: &mut Session) -> Result<Option<Value>> {
|
||||
s.state.selected_keys = selected;
|
||||
n = r?.as_u64().unwrap_or(0);
|
||||
}
|
||||
s.state.keyframe_text = text;
|
||||
let v = values.len();
|
||||
s.state.value_clipboard = values;
|
||||
// Keys alone answer as `keys.copy` does (their count).
|
||||
|
||||
@@ -424,6 +424,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.state.clipboard.clear();
|
||||
s.state.key_clipboard.clear();
|
||||
s.state.keyframe_text = None;
|
||||
s.state.contents_clipboard.clear();
|
||||
s.state.link_clipboard = None;
|
||||
s.state.clip_is_keys = false;
|
||||
|
||||
@@ -289,6 +289,7 @@ fn extract_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
clip.push(crate::KeyClip { layer: lid, path, keys });
|
||||
}
|
||||
s.state.key_clipboard = clip;
|
||||
s.state.keyframe_text = None;
|
||||
s.state.clip_is_keys = true;
|
||||
// The same data as text (tab-separated, one row per frame) for other applications.
|
||||
let mut text = String::from("EffectCraft Face Measurements\n\nFrame\tTime");
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! 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.
|
||||
|
||||
use effectcraft_project::{Comp, LayerId, Project, 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"),
|
||||
];
|
||||
|
||||
/// 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.
|
||||
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) {
|
||||
return None;
|
||||
}
|
||||
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 pr.three_d_only && !layer.is_3d() {
|
||||
continue;
|
||||
}
|
||||
let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect();
|
||||
times.sort();
|
||||
times.dedup();
|
||||
let rows: Vec<(String, Vec<f64>)> = if !times.is_empty() {
|
||||
let frame = |t| num(layer.comp_time(t).seconds() * fps);
|
||||
times.into_iter().filter_map(|t| Some((frame(t), pr.keys.iter().find(|k| k.time == t)?.value.components()))).collect()
|
||||
} else if statics.iter().any(|s| s.1 == pr.uid) {
|
||||
vec![(String::new(), pr.value.components())]
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let Some(dims) = rows.first().map(|(_, v)| v.len()).filter(|n| *n > 0) else { continue };
|
||||
let columns = match dims {
|
||||
1 => vec![unit.to_string()],
|
||||
_ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(),
|
||||
};
|
||||
// 3D layers show Rotation as Z Rotation.
|
||||
let name = if *id == "rotation" && layer.is_3d() { "Z Rotation" } else { pr.name.as_str() };
|
||||
blocks.push_str(&format!("Transform\t{name}\n\tFrame\t{}\n", columns.join("\t")));
|
||||
for (frame, values) in rows {
|
||||
let values: Vec<String> = values.into_iter().map(num).collect();
|
||||
blocks.push_str(&format!("\t{frame}\t{}\n", values.join("\t")));
|
||||
}
|
||||
blocks.push('\n');
|
||||
}
|
||||
if blocks.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = match effectcraft_render::eval::source_size(project, layer) {
|
||||
(0, 0) => (comp.width, comp.height),
|
||||
size => size,
|
||||
};
|
||||
Some(format!(
|
||||
"Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t{}\n\n{blocks}End of Keyframe Data\n",
|
||||
num(fps),
|
||||
num(comp.pixel_aspect)
|
||||
))
|
||||
}
|
||||
|
||||
/// A number with at most three decimals and no trailing zeros (`23.976`, `960`, `-0.5`).
|
||||
fn num(v: f64) -> String {
|
||||
let s = format!("{:.3}", if v.is_finite() { v } else { 0.0 });
|
||||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||||
if s == "-0" { "0".into() } else { s.into() }
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub mod footage_reload;
|
||||
pub mod guard;
|
||||
pub mod history;
|
||||
pub mod jobs;
|
||||
mod keyframe_text;
|
||||
pub mod learn;
|
||||
pub mod links;
|
||||
pub mod logging;
|
||||
@@ -244,6 +245,10 @@ pub struct EditorState {
|
||||
/// Paste sets the same properties of the selected layers.
|
||||
#[serde(skip)]
|
||||
pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>,
|
||||
/// The copied keyframes and values as After Effects keyframe text (one layer's Transform
|
||||
/// properties), for the system clipboard.
|
||||
#[serde(skip)]
|
||||
pub keyframe_text: Option<String>,
|
||||
/// Copied effect instances, with comp-time key offsets from the earliest copied key.
|
||||
/// Paste adds them to the selected layers at the destination comp's CTI.
|
||||
#[serde(skip)]
|
||||
@@ -1264,6 +1269,8 @@ mod tests_import_project;
|
||||
#[cfg(test)]
|
||||
mod tests_key_metadata_order;
|
||||
#[cfg(test)]
|
||||
mod tests_keyframe_text;
|
||||
#[cfg(test)]
|
||||
mod tests_keylight;
|
||||
#[cfg(test)]
|
||||
mod tests_limits;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Copied keyframes as After Effects keyframe text for other applications (#1908).
|
||||
|
||||
use effectcraft_project::{LayerId, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{KeyRef, Session};
|
||||
|
||||
/// A 1920×1080 23.976 fps comp with a 200×100 solid.
|
||||
fn session() -> (Session, LayerId) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 1920, "height": 1080, "frameRate": 23.976, "duration": 10})).unwrap();
|
||||
let id = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap();
|
||||
(s, LayerId(id))
|
||||
}
|
||||
|
||||
fn frame(s: &Session, n: i64) -> Tick {
|
||||
Tick(s.active_comp().unwrap().frame_rate.frame_duration().0 * n)
|
||||
}
|
||||
|
||||
fn uid(s: &Session, lid: LayerId, path: &str) -> Uid {
|
||||
s.active_comp().unwrap().layer(lid).unwrap().props.prop(path).unwrap().uid
|
||||
}
|
||||
|
||||
fn edit_layer(s: &mut Session, lid: LayerId, f: impl FnOnce(&mut effectcraft_project::Layer)) {
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
f(std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(lid).unwrap());
|
||||
}
|
||||
|
||||
fn add_key(s: &mut Session, lid: LayerId, path: &str, time: Tick, value: serde_json::Value) {
|
||||
s.execute("prop.addKey", json!({"layer": lid.0, "path": path, "time": time.seconds(), "value": value})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_keys_are_after_effects_text_in_comp_frames() {
|
||||
let (mut s, l) = session();
|
||||
// The layer starts 10 frames in: its keys at layer frames 2, 5 and 9 are comp frames 12, 15 and 19.
|
||||
let start = frame(&s, 10);
|
||||
edit_layer(&mut s, l, |l| l.start_time = start);
|
||||
for (n, v) in [(2, [960.0, 540.0]), (5, [1000.5, 520.25]), (9, [1100.0, 500.0])] {
|
||||
let t = frame(&s, n);
|
||||
add_key(&mut s, l, "transform/position", t, json!(v));
|
||||
}
|
||||
let t = frame(&s, 5);
|
||||
add_key(&mut s, l, "transform/rotation", t, json!(-45));
|
||||
let (position, rotation) = (uid(&s, l, "transform/position"), uid(&s, l, "transform/rotation"));
|
||||
// A subset: two of the three Position keys and the Rotation key.
|
||||
s.state.selected_keys = [(position, 2), (position, 9), (rotation, 5)].map(|(prop, n)| KeyRef { layer: l, prop, time: frame(&s, n) }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert_eq!(
|
||||
s.state.keyframe_text.as_deref(),
|
||||
Some(
|
||||
"Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\
|
||||
Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\n\t12\t960\t540\t0\n\t19\t1100\t500\t0\n\n\
|
||||
Transform\tRotation\n\tFrame\tdegrees\n\t15\t-45\n\n\
|
||||
End of Keyframe Data\n"
|
||||
)
|
||||
);
|
||||
// The internal clipboard still has the keys themselves, relative to the earliest.
|
||||
let times: Vec<Vec<Tick>> = s.state.key_clipboard.iter().map(|c| c.keys.iter().map(|k| k.time).collect()).collect();
|
||||
assert_eq!(times, [vec![Tick::ZERO, frame(&s, 7)], vec![frame(&s, 3)]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stretched_layer_writes_the_frames_its_keys_show_at() {
|
||||
let (mut s, l) = session();
|
||||
edit_layer(&mut s, l, |l| l.stretch = 200.0);
|
||||
let t = frame(&s, 6);
|
||||
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(0));
|
||||
add_key(&mut s, l, "transform/opacity", t, json!(100));
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "transform/opacity"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\n\t0\t0\n\t12\t100\n\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_static_values_have_no_frame() {
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": l.0, "path": "transform/position"})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\n\t\t100\t200\t0\n\n"), "{text}");
|
||||
assert_eq!(s.state.value_clipboard.len(), 1, "the internal clipboard keeps the value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_transform_group_lists_what_the_layer_shows() {
|
||||
let (mut s, l) = session();
|
||||
let t = frame(&s, 24);
|
||||
add_key(&mut s, l, "transform/scale", Tick::ZERO, json!([50, 50, 100]));
|
||||
add_key(&mut s, l, "transform/scale", t, json!([150, 150, 100]));
|
||||
let transform = s.active_comp().unwrap().layer(l).unwrap().props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
|
||||
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation");
|
||||
assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\n\t0\t50\t50\t100\n\t24\t150\t150\t100\n"), "{text}");
|
||||
// 3D: Orientation, X / Y Rotation, and Rotation is Z Rotation.
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect();
|
||||
assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Z Rotation", "Opacity"]);
|
||||
assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\tY degrees\tZ degrees\n\t\t0\t0\t0\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_from_two_layers_have_no_text() {
|
||||
let (mut s, a) = session();
|
||||
let b = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
for l in [a, b] {
|
||||
add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(50));
|
||||
}
|
||||
s.state.selected_keys = [a, b].map(|l| KeyRef { layer: l, prop: uid(&s, l, "transform/opacity"), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert_eq!(s.state.keyframe_text, None, "After Effects copies one layer's keys as text");
|
||||
assert_eq!(s.state.key_clipboard.len(), 2, "EffectCraft's own clipboard has both");
|
||||
// A copied layer leaves no text behind.
|
||||
s.state.selected_keys = vec![KeyRef { layer: a, prop: uid(&s, a, "transform/opacity"), time: Tick::ZERO }];
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
assert!(s.state.keyframe_text.is_some());
|
||||
s.state.selected_keys.clear();
|
||||
s.execute("edit.copy", json!({"layers": [a.0]})).unwrap();
|
||||
assert_eq!(s.state.keyframe_text, None);
|
||||
}
|
||||
Reference in new issue
Block a user