Merge pull request #1921 from tylerjenningsw/enhance/keyframe-text-clipboard

Clipboard: export keyframes as After Effects-compatible text
This commit is contained in:
BFlat
2026-10-30 01:22:04 +07:00
committed by GitHub
13 changed files with 1259 additions and 8 deletions
+1
View File
@@ -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))
+5
View File
@@ -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).
+1
View File
@@ -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;
+1
View File
@@ -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");
+4
View File
@@ -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)]
+2
View File
@@ -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"),
];
+1
View File
@@ -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;
+1
View File
@@ -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;
+482
View File
@@ -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}");
}
}