mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
Merge pull request #1921 from tylerjenningsw/enhance/keyframe-text-clipboard
Clipboard: export keyframes as After Effects-compatible text
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -263,6 +263,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;
|
||||
}
|
||||
|
||||
@@ -329,10 +330,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)
|
||||
{
|
||||
@@ -342,6 +345,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() {
|
||||
@@ -350,6 +354,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).
|
||||
|
||||
@@ -804,6 +804,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");
|
||||
|
||||
@@ -86,6 +86,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)]
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
pub(crate) mod keyframe_text;
|
||||
pub(crate) mod keyframe_text_effects;
|
||||
pub mod psd_import;
|
||||
pub mod vector;
|
||||
pub mod xml_project;
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! 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, keyframe_text_effects};
|
||||
|
||||
/// 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,
|
||||
Scale(f64),
|
||||
}
|
||||
|
||||
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(),
|
||||
(Values::Scale(s), _) => v.into_iter().map(|c| c * s).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;
|
||||
}
|
||||
// After Effects writes a text layer's source as 1 × 1. Other layers without a source of
|
||||
// their own (shapes, cameras) take the comp's size, and all of them its pixel aspect.
|
||||
let (w, h) = match (&layer.source, effectcraft_render::eval::source_size(project, layer)) {
|
||||
(LayerSource::Text, _) => (1, 1),
|
||||
(_, (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" | "text" | "contents" | "cameraOptions" | "lightOptions" | "materialOptions") => nested(&chain, pr),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed groups by match id, with the kind After Effects names them by: it writes them by kind
|
||||
/// and place (`Mask #2`, `Group #1`), not by name.
|
||||
const INSTANCES: &[(&str, &str)] = &[
|
||||
("mask", "Mask"),
|
||||
("animator", "Animator"),
|
||||
("rangeSelector", "Range Selector"),
|
||||
("wigglySelector", "Wiggly Selector"),
|
||||
("expressionSelector", "Expression Selector"),
|
||||
("group", "Group"),
|
||||
("rect", "Rectangle Path"),
|
||||
("ellipse", "Ellipse Path"),
|
||||
("star", "Polystar Path"),
|
||||
("path", "Path"),
|
||||
("fill", "Fill"),
|
||||
("stroke", "Stroke"),
|
||||
("gfill", "Gradient Fill"),
|
||||
("gstroke", "Gradient Stroke"),
|
||||
("trim", "Trim Paths"),
|
||||
("repeater", "Repeater"),
|
||||
("round", "Round Corners"),
|
||||
("offset", "Offset Paths"),
|
||||
("pucker", "Pucker & Bloat"),
|
||||
("twist", "Twist"),
|
||||
("zigzag", "Zig Zag"),
|
||||
("wiggle", "Wiggle Paths"),
|
||||
("merge", "Merge Paths"),
|
||||
];
|
||||
|
||||
/// A property under Masks, Text, Contents or Camera / Light / Material Options: the group's
|
||||
/// name, then each group down to the property. Instances go by kind and place, and a text
|
||||
/// selector's own properties carry their place too (`Start #1`).
|
||||
fn nested(chain: &[&Node], pr: &Property) -> Option<Channel> {
|
||||
let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect();
|
||||
let parent = groups.last()?;
|
||||
let (labels, values) = columns(&parent.match_id, &pr.match_id, pr, false)?;
|
||||
let mut header = groups.first()?.name.clone();
|
||||
for pair in groups.windows(2) {
|
||||
let [outer, g] = pair else { continue };
|
||||
match INSTANCES.iter().find(|(id, _)| *id == g.match_id).filter(|_| g.is_instance()) {
|
||||
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 })
|
||||
}
|
||||
|
||||
/// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer,
|
||||
/// then the property's name and After Effects index. Properties in twirl-down groups are
|
||||
/// written without their group, as After Effects does.
|
||||
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 path: String = chain.iter().skip(2).filter_map(|n| n.as_group()).map(|g| format!("{}/", g.match_id)).chain([pr.match_id.clone()]).collect();
|
||||
let (labels, values) = columns(effect, &path, pr, true)?;
|
||||
let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(effect, fx, pr.uid)?);
|
||||
Some(Channel { header, labels, values })
|
||||
}
|
||||
|
||||
/// After Effects' index of an effect property: the effect's hidden input layer is #1, and a
|
||||
/// twirl-down group takes an index where it opens and another where it closes. The hidden
|
||||
/// source choice EffectCraft keeps beside a layer parameter has none. Where After Effects
|
||||
/// numbers a parameter otherwise ([`keyframe_text_effects::INDICES`]), counting goes on from it.
|
||||
fn effect_index(effect: &str, fx: &PropGroup, uid: Uid) -> Option<usize> {
|
||||
fn count(effect: &str, g: &PropGroup, prefix: &str, uid: Uid, n: &mut usize) -> bool {
|
||||
for c in &g.children {
|
||||
match c {
|
||||
Node::Prop(p) if is_layer_source(g, p) => {}
|
||||
Node::Prop(p) => {
|
||||
let path = format!("{prefix}{}", p.match_id);
|
||||
*n = keyframe_text_effects::INDICES.iter().find(|(e, q, _)| *e == effect && *q == path).map_or(*n + 1, |a| a.2);
|
||||
if p.uid == uid {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Node::Group(sub) => {
|
||||
*n += 1;
|
||||
if count(effect, sub, &format!("{prefix}{}/", sub.match_id), uid, n) {
|
||||
return true;
|
||||
}
|
||||
*n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
let mut n = 1;
|
||||
count(effect, fx, "", uid, &mut n).then_some(n)
|
||||
}
|
||||
|
||||
fn is_layer_source(g: &PropGroup, p: &Property) -> bool {
|
||||
p.ui == ParamUi::Hidden && g.props().any(|l| l.ui == ParamUi::Layer && effectcraft_effects::layer_source_id(&l.match_id) == p.match_id)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// How After Effects writes a property differently from what its editor suggests ([`by_ui`]).
|
||||
#[derive(Clone, Copy)]
|
||||
enum Exception {
|
||||
/// Not written.
|
||||
Skip,
|
||||
Unit(&'static str),
|
||||
Values(Values),
|
||||
}
|
||||
|
||||
/// Properties After Effects writes differently from what their editor suggests, by the effect
|
||||
/// or group they're directly in and their match id.
|
||||
const EXCEPTIONS: &[(&str, &str, Exception)] = &[
|
||||
// Path Options' Path picks a mask, which After Effects doesn't write.
|
||||
("pathOptions", "path", Exception::Skip),
|
||||
("mask", "feather", Exception::Unit("pixels")),
|
||||
("mask", "expansion", Exception::Unit("pixels")),
|
||||
("cameraOptions", "zoom", Exception::Unit("pixels")),
|
||||
("cameraOptions", "focusDistance", Exception::Unit("pixels")),
|
||||
("cameraOptions", "aperture", Exception::Unit("pixels")),
|
||||
("cameraOptions", "highlightGain", Exception::Unit("")),
|
||||
("cameraOptions", "highlightSaturation", Exception::Unit("")),
|
||||
// After Effects keeps the threshold as 0–1.
|
||||
("cameraOptions", "highlightThreshold", Exception::Values(Values::Scale(1.0 / 255.0))),
|
||||
("lightOptions", "shadowDiffusion", Exception::Unit("pixels")),
|
||||
// Off, On, Only: written from 0.
|
||||
("materialOptions", "castsShadows", Exception::Values(Values::Plain)),
|
||||
("materialOptions", "acceptsShadows", Exception::Values(Values::Plain)),
|
||||
];
|
||||
|
||||
/// Column labels and values for a property in `scope`, the effect (by id) or group (by match
|
||||
/// id) it's directly in, at `path` there (an effect property's groups, then its match id): [`by_ui`]
|
||||
/// unless [`EXCEPTIONS`] or, in effects, [`keyframe_text_effects::UNITS`] say otherwise.
|
||||
fn columns(scope: &str, path: &str, pr: &Property, in_effect: bool) -> Option<(Vec<String>, Values)> {
|
||||
let (by_editor, values) = by_ui(pr, in_effect)?;
|
||||
let exception = EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == path).map(|e| e.2);
|
||||
let effect_unit = || keyframe_text_effects::UNITS.iter().find(|(e, p, _)| in_effect && *e == scope && *p == path).map(|u| Exception::Unit(u.2));
|
||||
Some(match exception.or_else(effect_unit) {
|
||||
None => (by_editor, values),
|
||||
Some(Exception::Skip) => return None,
|
||||
Some(Exception::Unit(unit)) => (labels(unit, pr.value.dims()), values),
|
||||
Some(Exception::Values(values)) => (by_editor, values),
|
||||
})
|
||||
}
|
||||
|
||||
/// Column labels and values for a property, by how it's edited. After Effects labels most 0–100
|
||||
/// sliders (opacities) as percent, and ±100 ones too outside effects (text selector amounts),
|
||||
/// and other sliders, checkboxes and popups with no unit. [`keyframe_text_effects::UNITS`] lists
|
||||
/// the effect parameters After Effects labels otherwise.
|
||||
fn by_ui(pr: &Property, in_effect: bool) -> 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 && !in_effect)) => (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`; `X ` with no unit, as After
|
||||
/// Effects writes a shape's Size).
|
||||
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}")).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() }
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! After Effects' index and unit for effect parameters where they differ from what
|
||||
//! [`crate::keyframe_text`]'s rules give, as After Effects 2019 writes them in its keyframe text.
|
||||
//! After Effects keeps an effect's parameter indices for good (projects store them), so they
|
||||
//! hold for later versions. Parameters After Effects lacks, or names differently and keeps
|
||||
//! elsewhere, aren't listed.
|
||||
|
||||
/// Effect id, parameter path in the effect (its groups' match ids, then its own) and After
|
||||
/// Effects' index for it; the parameters after it count on from there. After Effects numbers
|
||||
/// legacy parameters it hides, so its indices skip ahead.
|
||||
pub(crate) const INDICES: &[(&str, &str, usize)] = &[
|
||||
("ec.3d.channelextract", "clampOutput", 6),
|
||||
("ec.3d.channelextract", "antialias", 5),
|
||||
("ec.3d.extractor", "blackPoint", 4),
|
||||
("ec.audio.parametriceq", "band1Enable", 3),
|
||||
("ec.blur.radial", "amount", 3),
|
||||
("ec.color.cccolorneutralizer", "midtonesUnbalance", 8),
|
||||
("ec.color.cccolorneutralizer", "highlightsUnbalance", 14),
|
||||
("ec.color.cccolorneutralizer", "pinning", 20),
|
||||
("ec.color.cckernel", "k1", 3),
|
||||
("ec.color.cckernel", "k4", 8),
|
||||
("ec.color.cckernel", "k7", 13),
|
||||
("ec.color.cckernel", "scale", 17),
|
||||
("ec.color.cckernel", "absolute", 18),
|
||||
("ec.color.colorstabilizer", "stabilize", 2),
|
||||
("ec.color.huesaturation", "hue", 4),
|
||||
("ec.color.huesaturation", "colorize", 7),
|
||||
("ec.color.levels", "inBlack", 4),
|
||||
("ec.color.levelsic", "rgb/rgbInBlack", 5),
|
||||
("ec.color.lumetri", "highDynamicRange", 3),
|
||||
("ec.color.lumetri", "basicCorrection/inputLut", 8),
|
||||
("ec.color.lumetri", "basicCorrection/whiteBalance/temperature", 12),
|
||||
("ec.color.lumetri", "basicCorrection/tone/hdrSpecular", 22),
|
||||
("ec.color.lumetri", "basicCorrection/saturation", 26),
|
||||
("ec.color.lumetri", "creative/look", 32),
|
||||
("ec.color.lumetri", "creative/lookIntensity", 33),
|
||||
("ec.color.lumetri", "creative/adjustments/tintBalance", 40),
|
||||
("ec.color.lumetri", "hslSecondary/key/showMask", 83),
|
||||
("ec.color.lumetri", "hslSecondary/refine/denoise", 90),
|
||||
("ec.color.lumetri", "hslSecondary/correction/temperature", 96),
|
||||
("ec.color.lumetri", "vignette/vignetteAmount", 105),
|
||||
("ec.color.selectivecolor", "details/reds/redsCyan", 10),
|
||||
("ec.color.videolimiter", "compressionBeforeClipping", 3),
|
||||
("ec.distort.ccflomotion", "knot1", 3),
|
||||
("ec.distort.ccpowerpin", "expandTop", 9),
|
||||
("ec.distort.upscale", "scale", 4),
|
||||
("ec.distort.puppet", "refinement", 3),
|
||||
("ec.control.stereo3d", "configuration", 3),
|
||||
("ec.control.stereo3d", "convergenceOf", 8),
|
||||
("ec.generate.advancedlightning", "turbulence", 17),
|
||||
("ec.generate.advancedlightning", "alphaObstacle", 16),
|
||||
("ec.generate.advancedlightning", "compositeOnOriginal", 21),
|
||||
("ec.generate.ccgluegun", "style", 9),
|
||||
("ec.generate.cclightburst", "setColor", 7),
|
||||
("ec.generate.cclightrays", "colorFromSource", 8),
|
||||
("ec.generate.cclightrays", "color", 10),
|
||||
("ec.generate.circle", "feather/feather", 7),
|
||||
("ec.generate.paintbucket", "color", 9),
|
||||
("ec.vr.blur", "frameLayout", 3),
|
||||
("ec.vr.blur", "blurriness", 6),
|
||||
("ec.vr.chromaticaberrations", "frameLayout", 3),
|
||||
("ec.vr.chromaticaberrations", "aberrationRed", 7),
|
||||
("ec.vr.colorgradients", "frameLayout", 3),
|
||||
("ec.vr.colorgradients", "point1", 10),
|
||||
("ec.vr.colorgradients", "point2", 12),
|
||||
("ec.vr.colorgradients", "point3", 14),
|
||||
("ec.vr.colorgradients", "point4", 16),
|
||||
("ec.vr.colorgradients", "point5", 18),
|
||||
("ec.vr.colorgradients", "blend", 7),
|
||||
("ec.vr.colorgradients", "opacity", 27),
|
||||
("ec.vr.denoise", "frameLayout", 3),
|
||||
("ec.vr.denoise", "noiseType", 6),
|
||||
("ec.vr.digitalglitch", "frameLayout", 3),
|
||||
("ec.vr.digitalglitch", "masterAmplitude", 9),
|
||||
("ec.vr.digitalglitch", "distortionRate", 16),
|
||||
("ec.vr.digitalglitch", "distortionComplexity", 15),
|
||||
("ec.vr.digitalglitch", "distortionEvolution", 17),
|
||||
("ec.vr.digitalglitch", "colorDistortion", 11),
|
||||
("ec.vr.digitalglitch", "randomSeed", 39),
|
||||
("ec.vr.fractalnoise", "frameLayout", 3),
|
||||
("ec.vr.fractalnoise", "fractalType", 6),
|
||||
("ec.vr.fractalnoise", "randomSeed", 25),
|
||||
("ec.vr.glow", "frameLayout", 3),
|
||||
("ec.vr.glow", "luminanceThreshold", 6),
|
||||
("ec.vr.planetosphere", "frameLayout", 3),
|
||||
("ec.vr.planetosphere", "scale", 6),
|
||||
("ec.vr.planetosphere", "feather", 8),
|
||||
("ec.vr.planetosphere", "tilt", 15),
|
||||
("ec.vr.rotatesphere", "frameLayout", 3),
|
||||
("ec.vr.rotatesphere", "invertRotation", 9),
|
||||
("ec.vr.rotatesphere", "tilt", 6),
|
||||
("ec.vr.sharpen", "frameLayout", 3),
|
||||
("ec.vr.sharpen", "sharpenAmount", 6),
|
||||
("ec.key.colordifference", "view", 3),
|
||||
("ec.key.colorrange", "fuzziness", 3),
|
||||
("ec.key.extract", "channel", 3),
|
||||
("ec.key.linearcolor", "view", 3),
|
||||
("ec.matte.refinehard", "feather", 2),
|
||||
("ec.matte.refinehard", "reduceChatter", 5),
|
||||
("ec.matte.refinehard", "decontaminationAmount", 14),
|
||||
("ec.matte.refinesoft", "reduceChatter", 10),
|
||||
("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", 3),
|
||||
("ec.matte.rotobrush", "rotoBrushMatte/feather", 13),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/smooth", 22),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/reduceChatter", 27),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/useMotionBlur", 29),
|
||||
("ec.noise.noisealpha", "noiseOptions/cycleNoise", 8),
|
||||
("ec.obsolete.basictext", "position", 2),
|
||||
("ec.key.spill", "suppression", 3),
|
||||
("ec.perspective.cameratracker", "shotType", 4),
|
||||
("ec.perspective.cameratracker", "advanced/solveMethod", 12),
|
||||
("ec.perspective.cameratracker", "advanced/detailedAnalysis", 17),
|
||||
("ec.perspective.cameratracker", "advanced/autoDeletePoints", 18),
|
||||
("ec.perspective.cccylinder", "rotationX", 9),
|
||||
("ec.perspective.cccylinder", "render", 13),
|
||||
("ec.perspective.cccylinder", "lightIntensity", 15),
|
||||
("ec.perspective.cccylinder", "ambient", 21),
|
||||
("ec.sim.ccdrizzle", "light/lightIntensity", 10),
|
||||
("ec.sim.ccdrizzle", "light/lightHeight", 13),
|
||||
("ec.sim.ccdrizzle", "light/lightDirection", 15),
|
||||
("ec.sim.ccmrmercury", "blobBirthSize", 15),
|
||||
("ec.sim.ccmrmercury", "light/lightIntensity", 19),
|
||||
("ec.sim.ccmrmercury", "light/lightHeight", 22),
|
||||
("ec.sim.ccmrmercury", "light/lightDirection", 24),
|
||||
("ec.sim.ccparticlesystems2", "particle/maxOpacity", 24),
|
||||
("ec.sim.ccparticlesystems2", "particle/birthColor", 27),
|
||||
("ec.sim.ccrainfall", "transferMode", 16),
|
||||
("ec.sim.ccrainfall", "extras/randomSeed", 23),
|
||||
("ec.sim.ccsnowfall", "color", 19),
|
||||
("ec.sim.ccsnowfall", "transferMode", 26),
|
||||
("ec.sim.ccsnowfall", "extras/randomSeed", 32),
|
||||
("ec.stylize.ccblockload", "startCleared", 4),
|
||||
("ec.stylize.cchextile", "rotate", 6),
|
||||
("ec.stylize.glow", "colorLooping", 9),
|
||||
("ec.text.timecode", "timeUnits", 5),
|
||||
("ec.text.timecode", "textPosition", 9),
|
||||
("ec.utility.facemeasurements", "headOrientationX", 9),
|
||||
("ec.utility.facemeasurements", "rightEyebrowRaise", 20),
|
||||
("ec.utility.facemeasurements", "mouthOffsetX", 26),
|
||||
("ec.utility.facetrackpoints", "leftEyebrowInner", 10),
|
||||
("ec.utility.facetrackpoints", "leftEyebrowMiddle", 12),
|
||||
("ec.utility.facetrackpoints", "leftEyebrowOuter", 14),
|
||||
("ec.utility.facetrackpoints", "rightEyebrowInner", 24),
|
||||
("ec.utility.facetrackpoints", "rightEyebrowMiddle", 26),
|
||||
("ec.utility.facetrackpoints", "rightEyebrowOuter", 28),
|
||||
("ec.utility.facetrackpoints", "leftEyeInner", 15),
|
||||
("ec.utility.facetrackpoints", "leftEyeOuter", 21),
|
||||
("ec.utility.facetrackpoints", "leftPupil", 20),
|
||||
("ec.utility.facetrackpoints", "rightEyeInner", 29),
|
||||
("ec.utility.facetrackpoints", "rightEyeOuter", 35),
|
||||
("ec.utility.facetrackpoints", "rightPupil", 34),
|
||||
("ec.utility.facetrackpoints", "noseTip", 41),
|
||||
("ec.utility.facetrackpoints", "leftNostril", 44),
|
||||
("ec.utility.facetrackpoints", "rightNostril", 46),
|
||||
("ec.utility.facetrackpoints", "mouthLeft", 49),
|
||||
("ec.utility.facetrackpoints", "mouthRight", 55),
|
||||
("ec.utility.facetrackpoints", "mouthTop", 52),
|
||||
("ec.utility.facetrackpoints", "mouthBottom", 58),
|
||||
("ec.utility.facetrackpoints", "chin", 84),
|
||||
];
|
||||
|
||||
/// Effect id, parameter path and the unit After Effects labels the parameter's columns with.
|
||||
pub(crate) const UNITS: &[(&str, &str, &str)] = &[
|
||||
("ec.3d.depthoffield", "focalBias", ""),
|
||||
("ec.3d.idmatte", "feather", ""),
|
||||
("ec.audio.flangechorus", "voiceSeparationTime", ""),
|
||||
("ec.audio.flangechorus", "voicePhaseChange", ""),
|
||||
("ec.audio.modulator", "modulationRate", ""),
|
||||
("ec.audio.parametriceq", "band1BoostCut", "percent"),
|
||||
("ec.audio.parametriceq", "band2BoostCut", "percent"),
|
||||
("ec.audio.parametriceq", "band3BoostCut", "percent"),
|
||||
("ec.audio.stereomixer", "leftLevel", "percent"),
|
||||
("ec.audio.stereomixer", "rightLevel", "percent"),
|
||||
("ec.audio.stereomixer", "leftPan", "percent"),
|
||||
("ec.audio.stereomixer", "rightPan", "percent"),
|
||||
("ec.blur.bilateral", "threshold", ""),
|
||||
("ec.blur.ccradial", "amount", "pixels"),
|
||||
("ec.blur.ccradialfast", "amount", ""),
|
||||
("ec.blur.camerashakedeblur", "strength", "percent"),
|
||||
("ec.blur.radial", "amount", ""),
|
||||
("ec.blur.smart", "radius", ""),
|
||||
("ec.blur.smart", "threshold", ""),
|
||||
("ec.color.autocolor", "blackClip", "percent"),
|
||||
("ec.color.autocolor", "whiteClip", "percent"),
|
||||
("ec.color.autocontrast", "blackClip", "percent"),
|
||||
("ec.color.autocontrast", "whiteClip", "percent"),
|
||||
("ec.color.autolevels", "blackClip", "percent"),
|
||||
("ec.color.autolevels", "whiteClip", "percent"),
|
||||
("ec.color.brightnesscontrast", "contrast", "pixels"),
|
||||
("ec.color.cccolorneutralizer", "pinning", ""),
|
||||
("ec.color.cccolorneutralizer", "blendWOriginal", ""),
|
||||
("ec.color.changecolor", "lightnessTransform", "pixels"),
|
||||
("ec.color.changecolor", "saturationTransform", "pixels"),
|
||||
("ec.color.colorbalance", "shadowRed", "pixels"),
|
||||
("ec.color.colorbalance", "shadowGreen", "pixels"),
|
||||
("ec.color.colorbalance", "shadowBlue", "pixels"),
|
||||
("ec.color.colorbalance", "midRed", "pixels"),
|
||||
("ec.color.colorbalance", "midGreen", "pixels"),
|
||||
("ec.color.colorbalance", "midBlue", "pixels"),
|
||||
("ec.color.colorbalance", "hiRed", "pixels"),
|
||||
("ec.color.colorbalance", "hiGreen", "pixels"),
|
||||
("ec.color.colorbalance", "hiBlue", "pixels"),
|
||||
("ec.color.colorbalancehls", "lightness", "pixels"),
|
||||
("ec.color.colorbalancehls", "saturation", "pixels"),
|
||||
("ec.color.colorstabilizer", "sampleSize", "percent"),
|
||||
("ec.color.gammapedestalgain", "blackStretch", "percent"),
|
||||
("ec.color.huesaturation", "saturation", "pixels"),
|
||||
("ec.color.huesaturation", "lightness", "pixels"),
|
||||
("ec.color.huesaturation", "colorizeSaturation", ""),
|
||||
("ec.color.huesaturation", "colorizeLightness", "pixels"),
|
||||
("ec.color.lumetri", "creative/adjustments/fadedFilm", ""),
|
||||
("ec.color.lumetri", "hslSecondary/refine/denoise", ""),
|
||||
("ec.color.lumetri", "hslSecondary/refine/blur", ""),
|
||||
("ec.color.lumetri", "vignette/vignetteMidpoint", ""),
|
||||
("ec.color.lumetri", "vignette/vignetteFeather", ""),
|
||||
("ec.color.psarbitrarymap", "phase", "degrees"),
|
||||
("ec.color.selectivecolor", "details/reds/redsCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/reds/redsMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/reds/redsYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/reds/redsBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/yellows/yellowsBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/greens/greensBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/cyans/cyansBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/blues/bluesBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/magentas/magentasBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/whites/whitesBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/neutrals/neutralsBlack", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksCyan", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksMagenta", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksYellow", "percent"),
|
||||
("ec.color.selectivecolor", "details/blacks/blacksBlack", "percent"),
|
||||
("ec.color.shadowhighlight", "shadowAmount", ""),
|
||||
("ec.color.shadowhighlight", "highlightAmount", ""),
|
||||
("ec.color.shadowhighlight", "moreOptions/shadowTonalWidth", ""),
|
||||
("ec.color.shadowhighlight", "moreOptions/highlightTonalWidth", ""),
|
||||
("ec.color.shadowhighlight", "moreOptions/blackClip", "percent"),
|
||||
("ec.color.shadowhighlight", "moreOptions/whiteClip", "percent"),
|
||||
("ec.distort.bulge", "height", "pixels"),
|
||||
("ec.distort.bulge", "taperRadius", ""),
|
||||
("ec.distort.ccbendit", "bend", "pixels"),
|
||||
("ec.distort.ccbender", "amount", "pixels"),
|
||||
("ec.distort.ccgriddler", "tileSize", "percent"),
|
||||
("ec.distort.cctiler", "scale", "percent"),
|
||||
("ec.distort.upscale", "scale", "percent"),
|
||||
("ec.distort.magnify", "magnification", "pixels"),
|
||||
("ec.distort.magnify", "size", "percent"),
|
||||
("ec.distort.puppet", "refinement", ""),
|
||||
("ec.distort.ripple", "radius", ""),
|
||||
("ec.distort.ripple", "speed", "percent"),
|
||||
("ec.distort.transform", "opacity", ""),
|
||||
("ec.distort.turbulentdisplace", "complexity", "percent"),
|
||||
("ec.distort.twirl", "radius", ""),
|
||||
("ec.distort.wavewarp", "width", "percent"),
|
||||
("ec.distort.wavewarp", "speed", "pixels"),
|
||||
("ec.generate.fourcolor", "blend", "percent"),
|
||||
("ec.generate.advancedlightning", "coreSettings/coreRadius", ""),
|
||||
("ec.generate.advancedlightning", "glowSettings/glowRadius", "percent"),
|
||||
("ec.generate.advancedlightning", "alphaObstacle", "pixels"),
|
||||
("ec.generate.ccgluegun", "reflection", ""),
|
||||
("ec.generate.cclightburst", "rayLength", ""),
|
||||
("ec.generate.cclightsweep", "edgeThickness", ""),
|
||||
("ec.generate.ccthreads", "coverage", ""),
|
||||
("ec.generate.ccthreads", "shadowing", ""),
|
||||
("ec.generate.ccthreads", "texture", ""),
|
||||
("ec.generate.checkerboard", "width", "percent"),
|
||||
("ec.generate.checkerboard", "height", "percent"),
|
||||
("ec.generate.grid", "width", "percent"),
|
||||
("ec.generate.grid", "height", "percent"),
|
||||
("ec.generate.lensflare", "flareBrightness", "percent"),
|
||||
("ec.vr.chromaticaberrations", "falloff", ""),
|
||||
("ec.vr.denoise", "noiseLevel", ""),
|
||||
("ec.vr.digitalglitch", "colorDistortion", ""),
|
||||
("ec.vr.fractalnoise", "subSettings/subInfluence", ""),
|
||||
("ec.vr.glow", "luminanceThreshold", ""),
|
||||
("ec.vr.planetosphere", "feather", ""),
|
||||
("ec.vr.sharpen", "sharpenAmount", ""),
|
||||
("ec.key.advancedspill", "suppression", "percent"),
|
||||
("ec.key.advancedspill", "ultraSettings/tolerance", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/desaturate", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/spillRange", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/spillColorCorrection", ""),
|
||||
("ec.key.advancedspill", "ultraSettings/lumaCorrection", ""),
|
||||
("ec.key.ccsimplewireremoval", "thickness", ""),
|
||||
("ec.key.keycleaner", "additionalEdgeRadius", ""),
|
||||
("ec.key.keycleaner", "alphaContrast", "percent"),
|
||||
("ec.matte.refinehard", "feather", ""),
|
||||
("ec.matte.refinehard", "increaseDecontaminationRadius", ""),
|
||||
("ec.matte.refinesoft", "smooth", ""),
|
||||
("ec.matte.refinesoft", "feather", ""),
|
||||
("ec.matte.refinesoft", "shiftEdge", "percent"),
|
||||
("ec.matte.refinesoft", "decontamination/increaseDecontaminationRadius", ""),
|
||||
("ec.matte.rotobrush", "rotoBrushPropagation/searchRadius", ""),
|
||||
("ec.matte.rotobrush", "rotoBrushMatte/feather", ""),
|
||||
("ec.matte.rotobrush", "rotoBrushMatte/shiftEdge", "percent"),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/smooth", ""),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/feather", ""),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/shiftEdge", "percent"),
|
||||
("ec.matte.rotobrush", "refineEdgeMatte/decontamination/increaseDecontaminationRadius", ""),
|
||||
("ec.matte.simplechoker", "chokeMatte", "pixels"),
|
||||
("ec.noise.fractal", "transform/scale", "percent"),
|
||||
("ec.noise.fractal", "transform/scaleWidth", "percent"),
|
||||
("ec.noise.fractal", "transform/scaleHeight", "percent"),
|
||||
("ec.noise.fractal", "complexity", "percent"),
|
||||
("ec.noise.fractal", "subSettings/subInfluence", ""),
|
||||
("ec.noise.turbulent", "transform/scale", "percent"),
|
||||
("ec.noise.turbulent", "transform/scaleWidth", "percent"),
|
||||
("ec.noise.turbulent", "transform/scaleHeight", "percent"),
|
||||
("ec.noise.turbulent", "complexity", "percent"),
|
||||
("ec.noise.turbulent", "subSettings/subInfluence", ""),
|
||||
("ec.key.colorkey", "edgeFeather", ""),
|
||||
("ec.obsolete.lightning", "branchAngle", ""),
|
||||
("ec.key.luma", "edgeFeather", ""),
|
||||
("ec.blur.reduceflicker", "softness", ""),
|
||||
("ec.perspective.cameratracker", "trackPointSize", "percent"),
|
||||
("ec.perspective.cameratracker", "targetSize", "percent"),
|
||||
("ec.perspective.cccylinder", "lightHeight", "pixels"),
|
||||
("ec.sim.ccbubbles", "bubbleSize", "percent"),
|
||||
("ec.sim.ccdrizzle", "displacement", ""),
|
||||
("ec.sim.ccdrizzle", "light/lightHeight", "pixels"),
|
||||
("ec.sim.ccmrmercury", "longevity", ""),
|
||||
("ec.sim.ccmrmercury", "blobInfluence", "percent"),
|
||||
("ec.sim.ccmrmercury", "light/lightHeight", "pixels"),
|
||||
("ec.sim.ccmrmercury", "shading/metal", ""),
|
||||
("ec.sim.ccparticlesystems2", "birthRate", ""),
|
||||
("ec.sim.ccparticlesystems2", "longevity", ""),
|
||||
("ec.sim.ccparticlesystems2", "physics/gravity", "pixels"),
|
||||
("ec.sim.ccrainfall", "windVariation", ""),
|
||||
("ec.sim.ccrainfall", "opacity", ""),
|
||||
("ec.sim.ccsnowfall", "sizeVariation", ""),
|
||||
("ec.sim.ccsnowfall", "speedVariation", ""),
|
||||
("ec.sim.ccsnowfall", "windVariation", ""),
|
||||
("ec.sim.ccsnowfall", "wiggle/wiggleAmount", ""),
|
||||
("ec.sim.ccsnowfall", "wiggle/wiggleAmountVariation", ""),
|
||||
("ec.sim.ccsnowfall", "wiggle/wiggleFrequencyVariation", ""),
|
||||
("ec.sim.ccsnowfall", "opacity", ""),
|
||||
("ec.sim.ccstarburst", "speed", "percent"),
|
||||
("ec.sim.ccstarburst", "size", "pixels"),
|
||||
("ec.stylize.ccblockload", "completion", ""),
|
||||
("ec.stylize.ccburnfilm", "burn", ""),
|
||||
("ec.stylize.cchextile", "smearing", ""),
|
||||
("ec.stylize.cckaleida", "size", "percent"),
|
||||
("ec.stylize.ccvignette", "pinHighlights", ""),
|
||||
("ec.stylize.cartoon", "detailThreshold", ""),
|
||||
("ec.stylize.cartoon", "fill/shadingSmoothness", ""),
|
||||
("ec.stylize.cartoon", "edge/edgeSoftness", ""),
|
||||
("ec.stylize.cartoon", "advanced/edgeBlackLevel", ""),
|
||||
("ec.stylize.glow", "colorLoops", "percent"),
|
||||
("ec.stylize.roughenedges", "stretch", "pixels"),
|
||||
("ec.time.pixelmotionblur", "vectorDetail", ""),
|
||||
("ec.transition.blockdissolve", "blockWidth", "percent"),
|
||||
("ec.transition.blockdissolve", "blockHeight", "percent"),
|
||||
("ec.transition.ccgridwipe", "border", "pixels"),
|
||||
("ec.transition.ccgridwipe", "tiles", "percent"),
|
||||
("ec.transition.cclinesweep", "completion", ""),
|
||||
("ec.transition.ccscalewipe", "stretch", "pixels"),
|
||||
("ec.transition.venetian", "width", "percent"),
|
||||
("ec.transition.venetian", "feather", ""),
|
||||
("ec.utility.cineon", "highlightRolloff", ""),
|
||||
("ec.utility.facemeasurements", "headOrientationX", "degrees"),
|
||||
("ec.utility.facemeasurements", "headOrientationY", "degrees"),
|
||||
("ec.utility.facemeasurements", "headOrientationZ", "degrees"),
|
||||
("ec.utility.facemeasurements", "leftEyebrowRaise", "percent"),
|
||||
("ec.utility.facemeasurements", "rightEyebrowRaise", "percent"),
|
||||
("ec.utility.facemeasurements", "mouthOffsetX", "percent"),
|
||||
("ec.utility.facemeasurements", "mouthOffsetY", "percent"),
|
||||
];
|
||||
@@ -70,6 +70,7 @@ pub use home::templates;
|
||||
pub use interchange::psd_import;
|
||||
pub use interchange::vector;
|
||||
pub use interchange::xml_project;
|
||||
use interchange::{keyframe_text, keyframe_text_effects};
|
||||
pub use render_queue::{ExportJob, ExportResult, Exporter, JobState};
|
||||
pub use settings::autosave;
|
||||
pub use settings::config;
|
||||
|
||||
@@ -17,6 +17,7 @@ mod history;
|
||||
mod import_project;
|
||||
mod key_metadata_order;
|
||||
mod key_time;
|
||||
mod keyframe_text;
|
||||
mod keylight;
|
||||
mod limits;
|
||||
mod lottie;
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
//! 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 9.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\
|
||||
Layer\n\
|
||||
Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t12\t960\t540\t0\t\n\t19\t1100\t500\t0\t\n\n\
|
||||
Transform\tRotation\n\tFrame\tdegrees\t\n\t15\t-45\t\n\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\t\n\t0\t0\t\n\t12\t100\t\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\t\n\t\t100\t200\t0\t\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\t\n\t0\t50\t50\t100\t\n\t24\t150\t150\t100\t\n"), "{text}");
|
||||
// 3D: Orientation and X / Y Rotation. Z Rotation stays Rotation, and Orientation's three
|
||||
// values sit under one label, as After Effects writes them.
|
||||
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", "Rotation", "Opacity"]);
|
||||
assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\t\n\t\t0\t0\t0\t\n"), "{text}");
|
||||
}
|
||||
|
||||
fn copy_transform(s: &mut Session, lid: LayerId) -> String {
|
||||
let transform = s.active_comp().unwrap().layer(lid).unwrap().props.sub("transform").unwrap().uid;
|
||||
s.execute("prop.select", json!({"layer": lid.0, "prop": transform, "selectKeys": false})).unwrap();
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.state.keyframe_text.clone().unwrap()
|
||||
}
|
||||
|
||||
fn names(text: &str) -> Vec<&str> {
|
||||
text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_the_layer_does_not_use_are_left_out() {
|
||||
// Separate Dimensions: the old Position no longer moves the layer.
|
||||
let (mut s, l) = session();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap();
|
||||
s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionX", "value": 321})).unwrap();
|
||||
let text = copy_transform(&mut s, l);
|
||||
assert!(!names(&text).contains(&"Position"), "{text}");
|
||||
// A one-node camera has no Point of Interest; a two-node one does.
|
||||
let cam = LayerId(s.execute("layer.newCamera", json!({"type": "oneNode"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, cam);
|
||||
assert!(!names(&text).contains(&"Point of Interest"), "{text}");
|
||||
s.execute("layer.cameraSettings", json!({"layer": cam.0, "type": "twoNode"})).unwrap();
|
||||
let text = copy_transform(&mut s, cam);
|
||||
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}");
|
||||
}
|
||||
|
||||
/// Selects the layer's properties at `paths` and copies them.
|
||||
fn copy_props(s: &mut Session, lid: LayerId, paths: &[&str]) -> String {
|
||||
for (i, path) in paths.iter().enumerate() {
|
||||
s.execute("prop.select", json!({"layer": lid.0, "path": path, "add": i > 0, "selectKeys": false})).unwrap();
|
||||
}
|
||||
s.execute("edit.copy", json!({})).unwrap();
|
||||
s.state.keyframe_text.clone().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same effects and values (2019 has no Dropdown
|
||||
// Menu Control: that block follows its other popups, written 1-based with no unit).
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Fill", "Checkbox Control", "Dropdown Menu Control", "Slider Control", "Point Control"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let t = frame(&s, 12);
|
||||
add_key(&mut s, l, "effects/#1/color", Tick::ZERO, json!([1, 0.5, 0.25, 1]));
|
||||
add_key(&mut s, l, "effects/#1/color", t, json!([0, 0, 1, 1]));
|
||||
add_key(&mut s, l, "effects/#2/checkbox", Tick::ZERO, json!(true));
|
||||
add_key(&mut s, l, "effects/#2/checkbox", t, json!(false));
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#3/menu", "value": 1})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#4/slider", "value": 20.5})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "effects/#5/point", "value": [10, 20]})).unwrap();
|
||||
// After Effects writes a renamed effect under the effect's own name.
|
||||
let slider = s.active_comp().unwrap().layer(l).unwrap().props.group("effects/#4").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": slider, "name": "My Slider"})).unwrap();
|
||||
let text = copy_props(&mut s, l, &["effects/#1/color", "effects/#2/checkbox", "effects/#3/menu", "effects/#4/slider", "effects/#5/point"]);
|
||||
assert!(
|
||||
text.contains(
|
||||
"Layer\n\
|
||||
Effects\tFill #1\tColor #4\n\tFrame\talpha \tred \tgreen \tblue \t\n\t0\t255\t255\t127.5\t63.75\t\n\t12\t255\t0\t0\t255\t\n\n\
|
||||
Effects\tCheckbox Control #2\tCheckbox #2\n\tFrame\t\t\n\t0\t1\t\n\t12\t0\t\n\n\
|
||||
Effects\tDropdown Menu Control #3\tMenu #2\n\tFrame\t\t\n\t\t2\t\n\n\
|
||||
Effects\tSlider Control #4\tSlider #2\n\tFrame\t\t\n\t\t20.5\t\n\n\
|
||||
Effects\tPoint Control #5\tPoint #2\n\tFrame\tX pixels\tY pixels\t\n\t\t10\t20\t\n\n"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_properties_have_after_effects_indices() {
|
||||
// After Effects 2019 numbered Fractal Noise's Rotation (in the Transform group) #9,
|
||||
// Complexity (after it) #17, Evolution #25, and Opacity (after Evolution Options) #31: a
|
||||
// group takes an index where it opens and closes, and its properties are written without
|
||||
// it. Set Matte's Use For Matte is #3: the layer parameter before it has no extra index for
|
||||
// its source choice.
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Fractal Noise", "Set Matte"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&[
|
||||
"effects/#1/transform/rotation",
|
||||
"effects/#1/complexity",
|
||||
"effects/#1/evolution",
|
||||
"effects/#1/opacity",
|
||||
"effects/#1/blendingMode",
|
||||
"effects/#2/takeMatteFrom",
|
||||
],
|
||||
);
|
||||
for header in [
|
||||
"Effects\tFractal Noise #1\tRotation #9\n",
|
||||
"Effects\tFractal Noise #1\tComplexity #17\n",
|
||||
"Effects\tFractal Noise #1\tEvolution #25\n",
|
||||
"Effects\tFractal Noise #1\tOpacity #31\n",
|
||||
"Effects\tFractal Noise #1\tBlending Mode #32\n",
|
||||
"Effects\tSet Matte #2\tUse For Matte #3\n",
|
||||
] {
|
||||
assert!(text.contains(header), "{header}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_parameters_after_effects_numbers_or_labels_otherwise_follow_it() {
|
||||
// After Effects 2019: Radial Blur counts a hidden parameter before Amount, which has no
|
||||
// unit; 3D Channel Extract lists Anti-alias before Clamp Output; Selective Color's per-color
|
||||
// sliders start at #10 and are percent.
|
||||
let (mut s, l) = session();
|
||||
for fx in ["Radial Blur", "3D Channel Extract", "Selective Color"] {
|
||||
s.execute("effect.apply", json!({"layer": l.0, "effect": fx})).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
l,
|
||||
&["effects/#1/amount", "effects/#1/center", "effects/#2/antialias", "effects/#2/invertDepthMap", "effects/#3/details/reds/redsCyan"],
|
||||
);
|
||||
for block in [
|
||||
"Effects\tRadial Blur #1\tAmount #3\n\tFrame\t\t\n",
|
||||
"Effects\tRadial Blur #1\tCenter #4\n\tFrame\tX pixels\tY pixels\t\n",
|
||||
"Effects\t3D Channel Extract #2\tAnti-alias #5\n\tFrame\t\t\n",
|
||||
"Effects\t3D Channel Extract #2\tInvert Depth Map #7\n\tFrame\t\t\n",
|
||||
"Effects\tSelective Color #3\tReds Cyan #10\n\tFrame\tpercent\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_properties_are_written_as_after_effects_does() {
|
||||
// The blocks After Effects 2019 wrote for the same masks and values: masks go by place, not
|
||||
// name, and Mask Path is left out.
|
||||
let (mut s, l) = session();
|
||||
for _ in 0..2 {
|
||||
s.execute("mask.new", json!({"layer": l.0, "vertices": [[0, 0], [100, 0], [100, 100]], "closed": true})).unwrap();
|
||||
}
|
||||
let t = frame(&s, 12);
|
||||
for (prop, a, b) in [("feather", json!([5, 5]), json!([10, 20])), ("opacity", json!(50), json!(75)), ("expansion", json!(3), json!(-4))] {
|
||||
add_key(&mut s, l, &format!("masks/#1/{prop}"), Tick::ZERO, a);
|
||||
add_key(&mut s, l, &format!("masks/#1/{prop}"), t, b);
|
||||
}
|
||||
s.execute("prop.set", json!({"layer": l.0, "path": "masks/#2/feather", "value": [7, 8]})).unwrap();
|
||||
let second = s.active_comp().unwrap().layer(l).unwrap().props.group("masks/#2").unwrap().uid;
|
||||
s.execute("prop.renameGroup", json!({"layer": l.0, "prop": second, "name": "Renamed Mask"})).unwrap();
|
||||
let text = copy_props(&mut s, l, &["masks/#1/path", "masks/#1/feather", "masks/#1/opacity", "masks/#1/expansion", "masks/#2/feather"]);
|
||||
assert!(
|
||||
text.contains(
|
||||
"Layer\n\
|
||||
Masks\tMask #1\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t0\t5\t5\t\n\t12\t10\t20\t\n\n\
|
||||
Masks\tMask #1\tMask Opacity\n\tFrame\tpercent\t\n\t0\t50\t\n\t12\t75\t\n\n\
|
||||
Masks\tMask #1\tMask Expansion\n\tFrame\tpixels\t\n\t0\t3\t\n\t12\t-4\t\n\n\
|
||||
Masks\tMask #2\tMask Feather\n\tFrame\tX pixels\tY pixels\t\n\t\t7\t8\t\n\n\
|
||||
\nEnd of Keyframe Data\n"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[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.
|
||||
let (mut s, l) = session();
|
||||
let src = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap());
|
||||
edit_layer(&mut s, src, |l| l.switches.three_d = true);
|
||||
add_key(&mut s, src, "transform/position", Tick::ZERO, json!([100, 200, 300]));
|
||||
add_key(&mut s, src, "transform/scale", Tick::ZERO, json!([50, 60, 70]));
|
||||
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: src, prop: uid(&s, src, p), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("keys.paste", json!({"layers": [l.0], "time": 0})).unwrap();
|
||||
let pasted = s.active_comp().unwrap().layer(l).unwrap().props.prop("transform/position").unwrap().keys[0].value.components();
|
||||
assert_eq!(pasted, [100.0, 200.0, 300.0], "the 2D layer keeps the pasted Z");
|
||||
s.state.selected_keys = ["transform/position", "transform/scale"].map(|p| KeyRef { layer: l, prop: uid(&s, l, p), time: Tick::ZERO }).to_vec();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
let text = s.state.keyframe_text.clone().unwrap();
|
||||
assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\t\n\t0\t100\t200\t0\t\n"), "{text}");
|
||||
assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\t\n\t0\t50\t60\t100\t\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_has_the_source_pixel_aspect() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 1920, "height": 1080, "frameRate": 24, "pixelAspect": 1.2})).unwrap();
|
||||
let solid = LayerId(s.execute("layer.newSolid", json!({"width": 200, "height": 100, "pixelAspect": 1.5})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, solid);
|
||||
assert!(text.contains("\tSource Pixel Aspect Ratio\t1.5\n\tComp Pixel Aspect Ratio\t1.2\n"), "{text}");
|
||||
// A text layer: 1 × 1, as After Effects 2019 wrote it, with the comp's pixel aspect.
|
||||
let t = LayerId(s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, t);
|
||||
assert!(text.contains("\tSource Width\t1\n\tSource Height\t1\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}");
|
||||
// A shape layer has no source of its own: the comp's size.
|
||||
let shape = LayerId(s.execute("layer.newShape", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_transform(&mut s, shape);
|
||||
assert!(text.contains("\tSource Width\t1920\n\tSource Height\t1080\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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_contents_are_written_as_after_effects_does() {
|
||||
// Headers and labels After Effects 2019 wrote for the same shape items: items go by kind and
|
||||
// place in their group, and a Size has no unit (`X `).
|
||||
let (mut s, _) = session();
|
||||
let sh = LayerId(s.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap());
|
||||
for (kind, group) in [("group", None), ("rectangle", Some("contents/#1")), ("fill", Some("contents/#1")), ("star", None), ("repeater", None)] {
|
||||
let mut p = json!({"layer": sh.0, "kind": kind});
|
||||
if let Some(g) = group {
|
||||
p["group"] = json!(g);
|
||||
}
|
||||
s.execute("layer.addShapeItem", p).unwrap();
|
||||
}
|
||||
let text = copy_props(
|
||||
&mut s,
|
||||
sh,
|
||||
&[
|
||||
"contents/#1/contents/#1/size",
|
||||
"contents/#1/contents/#2/color",
|
||||
"contents/#1/transform/position",
|
||||
"contents/#2/points",
|
||||
"contents/#3/transform/startOpacity",
|
||||
],
|
||||
);
|
||||
for block in [
|
||||
"Contents\tGroup #1\tContents\tRectangle Path #1\tSize\n\tFrame\tX \tY \t\n",
|
||||
"Contents\tGroup #1\tContents\tFill #2\tColor\n\tFrame\talpha \tred \tgreen \tblue \t\n",
|
||||
"Contents\tGroup #1\tTransform\tPosition\n\tFrame\tX pixels\tY pixels\t\n",
|
||||
"Contents\tPolystar Path #2\tPoints\n\tFrame\t\t\n",
|
||||
"Contents\tRepeater #3\tTransform\tStart Opacity\n\tFrame\tpercent\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_light_and_material_options_are_written_as_after_effects_does() {
|
||||
// Blocks After Effects 2019 wrote for these options, defaults included: Zoom and Shadow
|
||||
// Diffusion in pixels, Highlight Threshold on 0–1, and the material's shadow choices from 0.
|
||||
let (mut s, l) = session();
|
||||
let cam = LayerId(s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_props(&mut s, cam, &["cameraOptions/zoom", "cameraOptions/highlightThreshold"]);
|
||||
assert!(text.contains("Camera Options\tZoom\n\tFrame\tpixels\t\n"), "{text}");
|
||||
assert!(text.contains("Camera Options\tHighlight Threshold\n\tFrame\t\t\n\t\t1\t\n"), "{text}");
|
||||
let light = LayerId(s.execute("layer.newLight", json!({"type": "spot"})).unwrap()["layer"].as_u64().unwrap());
|
||||
let text = copy_props(&mut s, light, &["lightOptions/intensity", "lightOptions/shadowDiffusion"]);
|
||||
assert!(text.contains("Light Options\tIntensity\n\tFrame\tpercent\t\n\t\t100\t\n"), "{text}");
|
||||
assert!(text.contains("Light Options\tShadow Diffusion\n\tFrame\tpixels\t\n\t\t0\t\n"), "{text}");
|
||||
edit_layer(&mut s, l, |l| l.switches.three_d = true);
|
||||
let text = copy_props(&mut s, l, &["materialOptions/castsShadows", "materialOptions/acceptsShadows", "materialOptions/diffuse"]);
|
||||
for block in [
|
||||
"Material Options\tCasts Shadows\n\tFrame\t\t\n\t\t0\t\n",
|
||||
"Material Options\tAccepts Shadows\n\tFrame\t\t\n\t\t1\t\n",
|
||||
"Material Options\tDiffuse\n\tFrame\tpercent\t\n\t\t50\t\n",
|
||||
] {
|
||||
assert!(text.contains(block), "{block}in\n{text}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user