Files
effectcraft/crates/engine/src/keyframe_text.rs
T

257 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 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, GroupKind, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, Project, PropGroup, Property, Uid};
use crate::KeyRef;
/// 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),
/// RGBA 0–1 as alpha, red, green, blue 0–255.
Argb,
/// A popup's 0-based choice as After Effects' 1-based one.
OneBased,
}
impl Values {
fn row(self, mut v: Vec<f64>) -> Vec<f64> {
match (self, v.as_slice()) {
(Values::Flat(z), _) => {
if let Some(c) = v.get_mut(2) {
*c = z;
}
v
}
(Values::Argb, [r, g, b, a]) => [a, r, g, b].map(|c| c * 255.0).to_vec(),
(Values::OneBased, _) => v.into_iter().map(|c| c + 1.0).collect(),
_ => v,
}
}
}
/// The text for copied keys (layer times) and unanimated properties. `None` unless they all come
/// 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) {
return None;
}
let layer = comp.layer(lid)?;
let fps = comp.frame_rate.as_f64();
let mut blocks = String::new();
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();
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 {
return;
};
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;
}
// Layers without a source of their own (text, shapes, cameras) take the comp's size and
// pixel aspect.
let (w, h) = match effectcraft_render::eval::source_size(project, layer) {
(0, 0) => (comp.width, comp.height),
size => size,
};
let par = match layer.source {
LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match project.item(item).map(|i| &i.kind) {
Some(ItemKind::Footage(f)) => f.pixel_aspect,
Some(ItemKind::Solid(s)) => s.pixel_aspect,
Some(ItemKind::Comp(c)) => c.pixel_aspect,
_ => comp.pixel_aspect,
},
_ => comp.pixel_aspect,
};
// The layout of current After Effects: a `Layer` line before the layer's properties (parsers
// such as ae-keyframes reject properties without one), and a tab after every column.
Some(format!(
"Adobe After Effects 9.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\nLayer\n{blocks}\nEnd of Keyframe Data\n",
num(fps),
num(par),
num(comp.pixel_aspect)
))
}
/// 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),
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> {
let [Node::Group(masks), Node::Group(m), _] = chain else { return None };
let unit = match pr.match_id.as_str() {
"feather" | "expansion" => "pixels",
"opacity" => "percent",
_ => return None,
};
let header = format!("Masks\tMask #{}\t{}", place(masks, m.uid)?, pr.name);
Some(Channel { header, labels: labels(unit, pr.value.dims()), values: Values::Plain })
}
/// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer,
/// then the property's name and place, counting the effect's hidden input layer as the first.
fn effect(chain: &[&Node], pr: &Property) -> Option<Channel> {
let [Node::Group(effects), Node::Group(fx), _] = chain else { return None };
let GroupKind::Effect { effect } = &fx.kind else { return None };
let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name);
let (labels, values) = by_ui(pr)?;
let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, place(fx, pr.uid)? + 1);
Some(Channel { header, labels, values })
}
/// 1-based position of a child in its group.
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 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),
ParamUi::Number | ParamUi::Slider { .. } | ParamUi::Pixels | ParamUi::Checkbox => (labels("", dims), Values::Plain),
_ => return 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.
fn active(layer: &Layer, id: &str) -> bool {
match id {
"poi" => !(layer.is_camera() || layer.is_light()) || layer.auto_orient == AutoOrient::TowardsPointOfInterest,
"position" => layer.transform().is_none_or(|tr| tr.get("positionX").is_none()),
_ => true,
}
}
/// 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() }
}