From 598f0d51c176243a0ed98cf08c0887bf9c104247 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 19 Oct 2026 22:49:41 -0400 Subject: [PATCH 01/19] Clipboard: copied keyframes as After Effects keyframe text (#1908) Copying keys or unanimated properties of one layer records them as After Effects keyframe text: its Transform properties, one row per key with the frame in comp time (start offset and stretch applied) or a frameless row for a static value. EffectCraft's own keyframe clipboard is unchanged. --- crates/engine/src/commands/anim.rs | 1 + crates/engine/src/commands/edit.rs | 5 + crates/engine/src/commands/effect.rs | 1 + crates/engine/src/commands/mask_interp.rs | 1 + crates/engine/src/keyframe_text.rs | 83 ++++++++++++++ crates/engine/src/lib.rs | 7 ++ crates/engine/src/tests_keyframe_text.rs | 129 ++++++++++++++++++++++ 7 files changed, 227 insertions(+) create mode 100644 crates/engine/src/keyframe_text.rs create mode 100644 crates/engine/src/tests_keyframe_text.rs diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs index 325845115..e711392e0 100644 --- a/crates/engine/src/commands/anim.rs +++ b/crates/engine/src/commands/anim.rs @@ -45,6 +45,7 @@ fn copy(s: &mut Session, _: &Value) -> Result { 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)) diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index 019d56d1d..ce5a068de 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -217,6 +217,7 @@ fn clear_clipboards(s: &mut Session) { s.state.contents_clipboard.clear(); s.state.link_clipboard = None; s.state.value_clipboard.clear(); + s.state.keyframe_text = None; s.state.clip_is_keys = false; } @@ -283,10 +284,12 @@ fn copy_properties(s: &mut Session) -> Result> { } let mut keys = s.state.selected_keys.clone(); let mut values = vec![]; + let mut statics = vec![]; for (lid, uid) in &leaves { let Some(l) = comp.layer(*lid) else { continue }; let Some(pr) = l.props.find(*uid) else { continue }; if pr.keys.is_empty() { + statics.push((*lid, *uid)); if let Some(path) = l.props.match_path_of(*uid) && !values.iter().any(|(p, _)| *p == path) { @@ -296,6 +299,7 @@ fn copy_properties(s: &mut Session) -> Result> { keys.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); } } + let text = crate::keyframe_text::text(&s.project, comp, &keys, &statics); clear_clipboards(s); let mut n = 0; if !keys.is_empty() { @@ -304,6 +308,7 @@ fn copy_properties(s: &mut Session) -> Result> { 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). diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index ce773e545..53813c51c 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -424,6 +424,7 @@ fn copy(s: &mut Session, p: &Value) -> Result { } 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; diff --git a/crates/engine/src/commands/mask_interp.rs b/crates/engine/src/commands/mask_interp.rs index 982468c8e..e2b53f8a4 100644 --- a/crates/engine/src/commands/mask_interp.rs +++ b/crates/engine/src/commands/mask_interp.rs @@ -289,6 +289,7 @@ fn extract_face(s: &mut Session, p: &Value) -> Result { 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"); diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs new file mode 100644 index 000000000..0fd6f9cc2 --- /dev/null +++ b/crates/engine/src/keyframe_text.rs @@ -0,0 +1,83 @@ +//! Copied keyframes as After Effects keyframe text, for the system clipboard (#1908). Other +//! applications read this tab-separated format (AE2Blend brings it into Blender). Like After +//! Effects it covers one layer: its Transform properties, keyframed ones as one row per key +//! (frame number in comp time, then the value) and static ones as a single row with no frame. +//! Easing and tangents aren't part of the format; EffectCraft's own paste keeps them. + +use effectcraft_project::{Comp, LayerId, Project, Uid}; + +use crate::KeyRef; + +/// Transform properties in After Effects' order, with the unit each column is labelled with. +const CHANNELS: &[(&str, &str)] = &[ + ("poi", "pixels"), + ("anchor", "pixels"), + ("position", "pixels"), + ("scale", "percent"), + ("orientation", "degrees"), + ("rotationX", "degrees"), + ("rotationY", "degrees"), + ("rotation", "degrees"), + ("opacity", "percent"), +]; + +/// The text for copied keys (layer times) and unanimated properties. `None` unless they all come +/// from one layer and at least one is a Transform property. +pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(LayerId, Uid)]) -> Option { + let lid = keys.first().map(|k| k.layer).or_else(|| statics.first().map(|s| s.0))?; + if keys.iter().any(|k| k.layer != lid) || statics.iter().any(|s| s.0 != lid) { + return None; + } + let layer = comp.layer(lid)?; + let fps = comp.frame_rate.as_f64(); + let mut blocks = String::new(); + for (id, unit) in CHANNELS { + let Some(pr) = layer.props.prop(&format!("transform/{id}")) else { continue }; + if pr.three_d_only && !layer.is_3d() { + continue; + } + let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect(); + times.sort(); + times.dedup(); + let rows: Vec<(String, Vec)> = if !times.is_empty() { + let frame = |t| num(layer.comp_time(t).seconds() * fps); + times.into_iter().filter_map(|t| Some((frame(t), pr.keys.iter().find(|k| k.time == t)?.value.components()))).collect() + } else if statics.iter().any(|s| s.1 == pr.uid) { + vec![(String::new(), pr.value.components())] + } else { + continue; + }; + let Some(dims) = rows.first().map(|(_, v)| v.len()).filter(|n| *n > 0) else { continue }; + let columns = match dims { + 1 => vec![unit.to_string()], + _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), + }; + // 3D layers show Rotation as Z Rotation. + let name = if *id == "rotation" && layer.is_3d() { "Z Rotation" } else { pr.name.as_str() }; + blocks.push_str(&format!("Transform\t{name}\n\tFrame\t{}\n", columns.join("\t"))); + for (frame, values) in rows { + let values: Vec = values.into_iter().map(num).collect(); + blocks.push_str(&format!("\t{frame}\t{}\n", values.join("\t"))); + } + blocks.push('\n'); + } + if blocks.is_empty() { + return None; + } + let (w, h) = match effectcraft_render::eval::source_size(project, layer) { + (0, 0) => (comp.width, comp.height), + size => size, + }; + Some(format!( + "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t{}\n\n{blocks}End of Keyframe Data\n", + num(fps), + num(comp.pixel_aspect) + )) +} + +/// A number with at most three decimals and no trailing zeros (`23.976`, `960`, `-0.5`). +fn num(v: f64) -> String { + let s = format!("{:.3}", if v.is_finite() { v } else { 0.0 }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".into() } else { s.into() } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cdfc7468e..223df7d15 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -21,6 +21,7 @@ pub mod footage_reload; pub mod guard; pub mod history; pub mod jobs; +mod keyframe_text; pub mod learn; pub mod links; pub mod logging; @@ -244,6 +245,10 @@ pub struct EditorState { /// Paste sets the same properties of the selected layers. #[serde(skip)] pub value_clipboard: Vec<(String, effectcraft_keyframe::Value)>, + /// The copied keyframes and values as After Effects keyframe text (one layer's Transform + /// properties), for the system clipboard. + #[serde(skip)] + pub keyframe_text: Option, /// Copied effect instances, with comp-time key offsets from the earliest copied key. /// Paste adds them to the selected layers at the destination comp's CTI. #[serde(skip)] @@ -1264,6 +1269,8 @@ mod tests_import_project; #[cfg(test)] mod tests_key_metadata_order; #[cfg(test)] +mod tests_keyframe_text; +#[cfg(test)] mod tests_keylight; #[cfg(test)] mod tests_limits; diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs new file mode 100644 index 000000000..ce3ec4899 --- /dev/null +++ b/crates/engine/src/tests_keyframe_text.rs @@ -0,0 +1,129 @@ +//! Copied keyframes as After Effects keyframe text for other applications (#1908). + +use effectcraft_project::{LayerId, Uid}; +use effectcraft_time::Tick; +use serde_json::json; + +use crate::{KeyRef, Session}; + +/// A 1920×1080 23.976 fps comp with a 200×100 solid. +fn session() -> (Session, LayerId) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 1920, "height": 1080, "frameRate": 23.976, "duration": 10})).unwrap(); + let id = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap(); + (s, LayerId(id)) +} + +fn frame(s: &Session, n: i64) -> Tick { + Tick(s.active_comp().unwrap().frame_rate.frame_duration().0 * n) +} + +fn uid(s: &Session, lid: LayerId, path: &str) -> Uid { + s.active_comp().unwrap().layer(lid).unwrap().props.prop(path).unwrap().uid +} + +fn edit_layer(s: &mut Session, lid: LayerId, f: impl FnOnce(&mut effectcraft_project::Layer)) { + let cid = s.active_comp_id().unwrap(); + f(std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().layer_mut(lid).unwrap()); +} + +fn add_key(s: &mut Session, lid: LayerId, path: &str, time: Tick, value: serde_json::Value) { + s.execute("prop.addKey", json!({"layer": lid.0, "path": path, "time": time.seconds(), "value": value})).unwrap(); +} + +#[test] +fn copied_keys_are_after_effects_text_in_comp_frames() { + let (mut s, l) = session(); + // The layer starts 10 frames in: its keys at layer frames 2, 5 and 9 are comp frames 12, 15 and 19. + let start = frame(&s, 10); + edit_layer(&mut s, l, |l| l.start_time = start); + for (n, v) in [(2, [960.0, 540.0]), (5, [1000.5, 520.25]), (9, [1100.0, 500.0])] { + let t = frame(&s, n); + add_key(&mut s, l, "transform/position", t, json!(v)); + } + let t = frame(&s, 5); + add_key(&mut s, l, "transform/rotation", t, json!(-45)); + let (position, rotation) = (uid(&s, l, "transform/position"), uid(&s, l, "transform/rotation")); + // A subset: two of the three Position keys and the Rotation key. + s.state.selected_keys = [(position, 2), (position, 9), (rotation, 5)].map(|(prop, n)| KeyRef { layer: l, prop, time: frame(&s, n) }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + assert_eq!( + s.state.keyframe_text.as_deref(), + Some( + "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\ + Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\n\t12\t960\t540\t0\n\t19\t1100\t500\t0\n\n\ + Transform\tRotation\n\tFrame\tdegrees\n\t15\t-45\n\n\ + End of Keyframe Data\n" + ) + ); + // The internal clipboard still has the keys themselves, relative to the earliest. + let times: Vec> = s.state.key_clipboard.iter().map(|c| c.keys.iter().map(|k| k.time).collect()).collect(); + assert_eq!(times, [vec![Tick::ZERO, frame(&s, 7)], vec![frame(&s, 3)]]); +} + +#[test] +fn a_stretched_layer_writes_the_frames_its_keys_show_at() { + let (mut s, l) = session(); + edit_layer(&mut s, l, |l| l.stretch = 200.0); + let t = frame(&s, 6); + add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(0)); + add_key(&mut s, l, "transform/opacity", t, json!(100)); + s.execute("prop.select", json!({"layer": l.0, "path": "transform/opacity"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\n\t0\t0\n\t12\t100\n\n"), "{text}"); +} + +#[test] +fn copied_static_values_have_no_frame() { + let (mut s, l) = session(); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/position", "value": [100, 200]})).unwrap(); + s.execute("prop.select", json!({"layer": l.0, "path": "transform/position"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\n\t\t100\t200\t0\n\n"), "{text}"); + assert_eq!(s.state.value_clipboard.len(), 1, "the internal clipboard keeps the value"); +} + +#[test] +fn a_copied_transform_group_lists_what_the_layer_shows() { + let (mut s, l) = session(); + let t = frame(&s, 24); + add_key(&mut s, l, "transform/scale", Tick::ZERO, json!([50, 50, 100])); + add_key(&mut s, l, "transform/scale", t, json!([150, 150, 100])); + let transform = s.active_comp().unwrap().layer(l).unwrap().props.sub("transform").unwrap().uid; + s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); + assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation"); + assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\n\t0\t50\t50\t100\n\t24\t150\t150\t100\n"), "{text}"); + // 3D: Orientation, X / Y Rotation, and Rotation is Z Rotation. + edit_layer(&mut s, l, |l| l.switches.three_d = true); + s.execute("prop.select", json!({"layer": l.0, "prop": transform, "selectKeys": false})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); + assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Z Rotation", "Opacity"]); + assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\tY degrees\tZ degrees\n\t\t0\t0\t0\n"), "{text}"); +} + +#[test] +fn keys_from_two_layers_have_no_text() { + let (mut s, a) = session(); + let b = LayerId(s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()); + for l in [a, b] { + add_key(&mut s, l, "transform/opacity", Tick::ZERO, json!(50)); + } + s.state.selected_keys = [a, b].map(|l| KeyRef { layer: l, prop: uid(&s, l, "transform/opacity"), time: Tick::ZERO }).to_vec(); + s.execute("keys.copy", json!({})).unwrap(); + assert_eq!(s.state.keyframe_text, None, "After Effects copies one layer's keys as text"); + assert_eq!(s.state.key_clipboard.len(), 2, "EffectCraft's own clipboard has both"); + // A copied layer leaves no text behind. + s.state.selected_keys = vec![KeyRef { layer: a, prop: uid(&s, a, "transform/opacity"), time: Tick::ZERO }]; + s.execute("keys.copy", json!({})).unwrap(); + assert!(s.state.keyframe_text.is_some()); + s.state.selected_keys.clear(); + s.execute("edit.copy", json!({"layers": [a.0]})).unwrap(); + assert_eq!(s.state.keyframe_text, None); +} From 40d5466cc503927c05f97073310e5a0123ccaf14 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Mon, 19 Oct 2026 22:49:45 -0400 Subject: [PATCH 02/19] Clipboard: Copy puts keyframe text on the system clipboard (#1908) Edit > Copy with keys or Transform properties selected puts the After Effects keyframe text on the system clipboard, for other applications such as AE2Blend, in place of the 'EffectCraft: N keyframes' note. Ctrl+V in EffectCraft still pastes from its own clipboard. --- crates/ui-egui/src/menus.rs | 13 +++++++++---- crates/ui-egui/tests/ui_keyframes.rs | 22 ++++++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 92893dcb6..38967187d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -853,10 +853,15 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V const COPY_COMMANDS: &[&str] = &["edit.copy", "edit.cut", "edit.copyWithPropertyLinks", "edit.copyWithRelativePropertyLinks", "edit.copyExpressionOnly", "keys.copy", "effect.copy"]; -/// A line for the system clipboard naming what EffectCraft copied. The system clipboard must -/// hold something: with nothing on it, the windowing layer sends no paste event for Ctrl+V. -fn clipboard_note(s: &effectcraft_engine::Session) -> String { +/// Text for the system clipboard: copied keyframes and values as After Effects keyframe text, +/// for other applications (#1908), else a line naming what EffectCraft copied. The system +/// clipboard must hold something: with nothing on it, the windowing layer sends no paste event +/// for Ctrl+V. Paste in EffectCraft uses its own clipboard either way. +fn clipboard_text(s: &effectcraft_engine::Session) -> String { let st = &s.state; + if let Some(text) = &st.keyframe_text { + return text.clone(); + } let n = |n: usize, one: &str, many: &str| format!("EffectCraft: {n} {}", if n == 1 { one } else { many }); if st.clip_is_keys && !st.key_clipboard.is_empty() { n(st.key_clipboard.iter().map(|c| c.keys.len()).sum(), "keyframe", "keyframes") @@ -878,7 +883,7 @@ pub(crate) fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str if let Err(e) = &r { app.ui.status = e.clone(); } else if COPY_COMMANDS.contains(&id) && app.session.state.text_edit.is_none() { - ctx.copy_text(clipboard_note(&app.session)); + ctx.copy_text(clipboard_text(&app.session)); } let events = app.session.drain_events(); for (index, ev) in events.into_iter().enumerate() { diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index b47963d85..2eebb068f 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -105,11 +105,15 @@ fn ctrl_c_and_ctrl_v_copy_and_paste_keys_at_the_current_time() { _ => None, }) .collect(); - assert_eq!(copied, ["EffectCraft: 1 keyframe"], "the system clipboard is filled, so Ctrl+V sends a paste event"); + // The system clipboard has the key as After Effects keyframe text (for other applications), + // so it isn't empty and Ctrl+V sends a paste event. + let [text] = copied.as_slice() else { panic!("one copy: {copied:?}") }; + assert!(text.starts_with("Adobe After Effects 8.0 Keyframe Data\n"), "{text}"); + assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\n\t30\t100\n"), "{text}"); assert!(h.state().session.state.clip_is_keys); // Move to 3 s, Ctrl+V: the key lands there. h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0)); - h.input_mut().events.push(Event::Paste("EffectCraft: 1 keyframe".into())); + h.input_mut().events.push(Event::Paste(text.clone())); h.run_steps(2); assert_eq!(key_times(&h, id), vec![0.0, 1.0, 3.0]); } @@ -190,8 +194,18 @@ fn copy_and_paste_key_subsets_across_position_rotation_and_scale() { assert_eq!(h.state().session.state.selected_props.len(), 3); h.input_mut().events.push(Event::Copy); h.step(); - let note = format!("EffectCraft: {count} keyframes"); - assert!(h.output().platform_output.commands.iter().any(|command| matches!(command, egui::OutputCommand::CopyText(text) if text == ¬e))); + let note = h + .output() + .platform_output + .commands + .iter() + .find_map(|command| match command { + egui::OutputCommand::CopyText(text) => Some(text.clone()), + _ => None, + }) + .unwrap(); + let rows = note.lines().filter(|line| line.strip_prefix('\t').is_some_and(|row| row.starts_with(|c: char| c.is_ascii_digit()))).count(); + assert_eq!(rows, count, "one row per copied key: {note}"); h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(2.0)); let undo_steps = h.state().session.history.undo.len(); h.input_mut().events.push(Event::Paste(note)); From 9313784304fdd8fbdbc6daaad94a67dd86bcf781 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 08:47:51 -0400 Subject: [PATCH 03/19] Clipboard: Orientation and Rotation headers as After Effects writes them (#1908) --- crates/engine/src/keyframe_text.rs | 7 ++++--- crates/engine/src/tests_keyframe_text.rs | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 0fd6f9cc2..8fca3c343 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -48,13 +48,14 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( continue; }; let Some(dims) = rows.first().map(|(_, v)| v.len()).filter(|n| *n > 0) else { continue }; + // After Effects labels only the first of Orientation's three columns, and names Z + // Rotation plain Rotation (as AE2Blend expects). let columns = match dims { 1 => vec![unit.to_string()], + _ if *id == "orientation" => vec![format!("X {unit}")], _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), }; - // 3D layers show Rotation as Z Rotation. - let name = if *id == "rotation" && layer.is_3d() { "Z Rotation" } else { pr.name.as_str() }; - blocks.push_str(&format!("Transform\t{name}\n\tFrame\t{}\n", columns.join("\t"))); + blocks.push_str(&format!("Transform\t{}\n\tFrame\t{}\n", pr.name, columns.join("\t"))); for (frame, values) in rows { let values: Vec = values.into_iter().map(num).collect(); blocks.push_str(&format!("\t{frame}\t{}\n", values.join("\t"))); diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index ce3ec4899..34966269b 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -98,14 +98,15 @@ fn a_copied_transform_group_lists_what_the_layer_shows() { let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation"); assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\n\t0\t50\t50\t100\n\t24\t150\t150\t100\n"), "{text}"); - // 3D: Orientation, X / Y Rotation, and Rotation is Z Rotation. + // 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", "Z Rotation", "Opacity"]); - assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\tY degrees\tZ degrees\n\t\t0\t0\t0\n"), "{text}"); + assert_eq!(names, ["Anchor Point", "Position", "Scale", "Orientation", "X Rotation", "Y Rotation", "Rotation", "Opacity"]); + assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\n\t\t0\t0\t0\n"), "{text}"); } #[test] From 6b77e6a1a715afd61dd777b8a9ad51245831a987 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 08:49:33 -0400 Subject: [PATCH 04/19] Clipboard: keyframe text leaves out Transform properties the layer doesn't use (#1908) --- crates/engine/src/keyframe_text.rs | 15 ++++++++++-- crates/engine/src/tests_keyframe_text.rs | 29 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 8fca3c343..793d0cfb8 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -4,7 +4,7 @@ //! (frame number in comp time, then the value) and static ones as a single row with no frame. //! Easing and tangents aren't part of the format; EffectCraft's own paste keeps them. -use effectcraft_project::{Comp, LayerId, Project, Uid}; +use effectcraft_project::{AutoOrient, Comp, Layer, LayerId, Project, Uid}; use crate::KeyRef; @@ -33,7 +33,7 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( let mut blocks = String::new(); for (id, unit) in CHANNELS { let Some(pr) = layer.props.prop(&format!("transform/{id}")) else { continue }; - if pr.three_d_only && !layer.is_3d() { + if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) { continue; } let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect(); @@ -76,6 +76,17 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( )) } +/// Whether the layer uses this Transform property: one-node cameras (and lights not aimed at +/// it) have no Point of Interest, and Separate Dimensions replaces Position with X / Y / Z +/// Position (which this text doesn't cover). +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 }); diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 34966269b..81ca97bd4 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -109,6 +109,35 @@ fn a_copied_transform_group_lists_what_the_layer_shows() { assert!(text.contains("Transform\tOrientation\n\tFrame\tX degrees\n\t\t0\t0\t0\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 keys_from_two_layers_have_no_text() { let (mut s, a) = session(); From 22ddf7bb99ed79333d14962b31a32fd9b9576af0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 08:51:09 -0400 Subject: [PATCH 05/19] Clipboard: keyframe text for a 2D layer writes the Z it shows (#1908) --- crates/engine/src/keyframe_text.rs | 8 +++++++- crates/engine/src/tests_keyframe_text.rs | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 793d0cfb8..59e5402b7 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -56,7 +56,13 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), }; blocks.push_str(&format!("Transform\t{}\n\tFrame\t{}\n", pr.name, columns.join("\t"))); - for (frame, values) in rows { + for (frame, mut values) in rows { + // A 2D layer ignores the Z it may keep (from pasted 3D keys): write the one it shows. + if !layer.is_3d() + && let Some(z) = values.get_mut(2) + { + *z = if *id == "scale" { 100.0 } else { 0.0 }; + } let values: Vec = values.into_iter().map(num).collect(); blocks.push_str(&format!("\t{frame}\t{}\n", values.join("\t"))); } diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 81ca97bd4..8ff9f9c20 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -138,6 +138,26 @@ fn properties_the_layer_does_not_use_are_left_out() { assert!(names(&text).contains(&"Point of Interest"), "{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\n\t0\t100\t200\t0\n"), "{text}"); + assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\n\t0\t50\t60\t100\n"), "{text}"); +} + #[test] fn keys_from_two_layers_have_no_text() { let (mut s, a) = session(); From 47093d997ada42049f7bf79710f430de6b80acc0 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 08:52:55 -0400 Subject: [PATCH 06/19] Clipboard: keyframe text has the source's pixel aspect (#1908) --- crates/engine/src/keyframe_text.rs | 16 ++++++++++++++-- crates/engine/src/tests_keyframe_text.rs | 13 +++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 59e5402b7..7cb8dc492 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -4,7 +4,7 @@ //! (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, Layer, LayerId, Project, Uid}; +use effectcraft_project::{AutoOrient, Comp, ItemKind, Layer, LayerId, LayerSource, Project, Uid}; use crate::KeyRef; @@ -71,13 +71,25 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( if blocks.is_empty() { return None; } + // Layers without a source of their own (text, shapes, cameras) take the comp's size and + // pixel aspect. let (w, h) = match effectcraft_render::eval::source_size(project, layer) { (0, 0) => (comp.width, comp.height), size => size, }; + let par = match layer.source { + LayerSource::Footage { item } | LayerSource::Solid { item } | LayerSource::Comp { item } => match project.item(item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) => f.pixel_aspect, + Some(ItemKind::Solid(s)) => s.pixel_aspect, + Some(ItemKind::Comp(c)) => c.pixel_aspect, + _ => comp.pixel_aspect, + }, + _ => comp.pixel_aspect, + }; Some(format!( - "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t{}\n\n{blocks}End of Keyframe Data\n", + "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\n{blocks}End of Keyframe Data\n", num(fps), + num(par), num(comp.pixel_aspect) )) } diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 8ff9f9c20..e51c241fe 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -158,6 +158,19 @@ fn a_2d_layer_writes_the_z_it_shows() { assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\n\t0\t50\t60\t100\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 has no source of its own: the comp's. + 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\t1920\n\tSource Height\t1080\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}"); +} + #[test] fn keys_from_two_layers_have_no_text() { let (mut s, a) = session(); From 8ace9aac7f8b09e3862bdf047fe355023f2f973e Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:10:29 -0400 Subject: [PATCH 07/19] Clipboard: keyframe text starts the layer with a Layer line (#1908) --- crates/engine/src/keyframe_text.rs | 4 +++- crates/engine/src/tests_keyframe_text.rs | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 7cb8dc492..21727bc1c 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -86,8 +86,10 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( }, _ => comp.pixel_aspect, }; + // After Effects starts the layer's properties with a `Layer` line; parsers such as + // ae-keyframes reject properties without one. Some(format!( - "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\n{blocks}End of Keyframe Data\n", + "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\nLayer\n{blocks}End of Keyframe Data\n", num(fps), num(par), num(comp.pixel_aspect) diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index e51c241fe..968482f60 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -51,6 +51,7 @@ fn copied_keys_are_after_effects_text_in_comp_frames() { s.state.keyframe_text.as_deref(), Some( "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\ + Layer\n\ Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\n\t12\t960\t540\t0\n\t19\t1100\t500\t0\n\n\ Transform\tRotation\n\tFrame\tdegrees\n\t15\t-45\n\n\ End of Keyframe Data\n" From 30dc57f790e9e55f461662e305923b0709a79239 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:42:08 -0400 Subject: [PATCH 08/19] Clipboard: keyframe text in current After Effects' layout, with a tab after each column (#1908) --- crates/engine/src/keyframe_text.rs | 10 +++++----- crates/engine/src/tests_keyframe_text.rs | 19 ++++++++++--------- crates/ui-egui/tests/ui_keyframes.rs | 4 ++-- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 21727bc1c..0c090e5c4 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -55,7 +55,7 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( _ if *id == "orientation" => vec![format!("X {unit}")], _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), }; - blocks.push_str(&format!("Transform\t{}\n\tFrame\t{}\n", pr.name, columns.join("\t"))); + blocks.push_str(&format!("Transform\t{}\n\tFrame\t{}\t\n", pr.name, columns.join("\t"))); for (frame, mut values) in rows { // A 2D layer ignores the Z it may keep (from pasted 3D keys): write the one it shows. if !layer.is_3d() @@ -64,7 +64,7 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( *z = if *id == "scale" { 100.0 } else { 0.0 }; } let values: Vec = values.into_iter().map(num).collect(); - blocks.push_str(&format!("\t{frame}\t{}\n", values.join("\t"))); + blocks.push_str(&format!("\t{frame}\t{}\t\n", values.join("\t"))); } blocks.push('\n'); } @@ -86,10 +86,10 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( }, _ => comp.pixel_aspect, }; - // After Effects starts the layer's properties with a `Layer` line; parsers such as - // ae-keyframes reject properties without one. + // 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 8.0 Keyframe Data\n\n\tUnits Per Second\t{}\n\tSource Width\t{w}\n\tSource Height\t{h}\n\tSource Pixel Aspect Ratio\t{}\n\tComp Pixel Aspect Ratio\t{}\n\nLayer\n{blocks}End of Keyframe Data\n", + "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) diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 968482f60..ef9ad509c 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -50,10 +50,11 @@ fn copied_keys_are_after_effects_text_in_comp_frames() { assert_eq!( s.state.keyframe_text.as_deref(), Some( - "Adobe After Effects 8.0 Keyframe Data\n\n\tUnits Per Second\t23.976\n\tSource Width\t200\n\tSource Height\t100\n\tSource Pixel Aspect Ratio\t1\n\tComp Pixel Aspect Ratio\t1\n\n\ + "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\n\t12\t960\t540\t0\n\t19\t1100\t500\t0\n\n\ - Transform\tRotation\n\tFrame\tdegrees\n\t15\t-45\n\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" ) ); @@ -72,7 +73,7 @@ fn a_stretched_layer_writes_the_frames_its_keys_show_at() { s.execute("prop.select", json!({"layer": l.0, "path": "transform/opacity"})).unwrap(); s.execute("edit.copy", json!({})).unwrap(); let text = s.state.keyframe_text.clone().unwrap(); - assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\n\t0\t0\n\t12\t100\n\n"), "{text}"); + assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t0\t0\t\n\t12\t100\t\n\n"), "{text}"); } #[test] @@ -82,7 +83,7 @@ fn copied_static_values_have_no_frame() { s.execute("prop.select", json!({"layer": l.0, "path": "transform/position"})).unwrap(); s.execute("edit.copy", json!({})).unwrap(); let text = s.state.keyframe_text.clone().unwrap(); - assert!(text.contains("Transform\tPosition\n\tFrame\tX pixels\tY pixels\tZ pixels\n\t\t100\t200\t0\n\n"), "{text}"); + assert!(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"); } @@ -98,7 +99,7 @@ fn a_copied_transform_group_lists_what_the_layer_shows() { let text = s.state.keyframe_text.clone().unwrap(); let names: Vec<&str> = text.lines().filter_map(|line| line.strip_prefix("Transform\t")).collect(); assert_eq!(names, ["Anchor Point", "Position", "Scale", "Rotation", "Opacity"], "a 2D layer has no Orientation or X / Y Rotation"); - assert!(text.contains("\tFrame\tX percent\tY percent\tZ percent\n\t0\t50\t50\t100\n\t24\t150\t150\t100\n"), "{text}"); + 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); @@ -107,7 +108,7 @@ fn a_copied_transform_group_lists_what_the_layer_shows() { 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\n\t\t0\t0\t0\n"), "{text}"); + 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 { @@ -155,8 +156,8 @@ fn a_2d_layer_writes_the_z_it_shows() { 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\n\t0\t100\t200\t0\n"), "{text}"); - assert!(text.contains("Transform\tScale\n\tFrame\tX percent\tY percent\tZ percent\n\t0\t50\t60\t100\n"), "{text}"); + 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] diff --git a/crates/ui-egui/tests/ui_keyframes.rs b/crates/ui-egui/tests/ui_keyframes.rs index 2eebb068f..b137a06fc 100644 --- a/crates/ui-egui/tests/ui_keyframes.rs +++ b/crates/ui-egui/tests/ui_keyframes.rs @@ -108,8 +108,8 @@ fn ctrl_c_and_ctrl_v_copy_and_paste_keys_at_the_current_time() { // The system clipboard has the key as After Effects keyframe text (for other applications), // so it isn't empty and Ctrl+V sends a paste event. let [text] = copied.as_slice() else { panic!("one copy: {copied:?}") }; - assert!(text.starts_with("Adobe After Effects 8.0 Keyframe Data\n"), "{text}"); - assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\n\t30\t100\n"), "{text}"); + assert!(text.starts_with("Adobe After Effects 9.0 Keyframe Data\n"), "{text}"); + assert!(text.contains("Transform\tOpacity\n\tFrame\tpercent\t\n\t30\t100\t\n"), "{text}"); assert!(h.state().session.state.clip_is_keys); // Move to 3 s, Ctrl+V: the key lands there. h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0)); From 9ee6c5ef62351a25370477ed6dd525dc37e5f840 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:45:51 -0400 Subject: [PATCH 09/19] Clipboard: keyframe text for Time Remap and separated X / Y / Z Position (#1908) --- crates/engine/src/keyframe_text.rs | 132 ++++++++++++++++------- crates/engine/src/tests_keyframe_text.rs | 35 ++++++ 2 files changed, 126 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 0c090e5c4..9703fa503 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -1,28 +1,57 @@ //! Copied keyframes as After Effects keyframe text, for the system clipboard (#1908). Other //! applications read this tab-separated format (AE2Blend brings it into Blender). Like After -//! Effects it covers one layer: its Transform properties, keyframed ones as one row per key -//! (frame number in comp time, then the value) and static ones as a single row with no frame. -//! Easing and tangents aren't part of the format; EffectCraft's own paste keeps them. +//! Effects it covers one layer: its copied properties, keyframed ones as one row per key (frame +//! number in comp time, then the value) and static ones as a single row with no frame. Easing +//! and tangents aren't part of the format; EffectCraft's own paste keeps them. -use effectcraft_project::{AutoOrient, Comp, ItemKind, Layer, LayerId, LayerSource, Project, Uid}; +use effectcraft_project::{AutoOrient, Comp, ItemKind, Layer, LayerId, LayerSource, Project, Property, Uid}; use crate::KeyRef; -/// Transform properties in After Effects' order, with the unit each column is labelled with. -const CHANNELS: &[(&str, &str)] = &[ - ("poi", "pixels"), - ("anchor", "pixels"), - ("position", "pixels"), - ("scale", "percent"), - ("orientation", "degrees"), - ("rotationX", "degrees"), - ("rotationY", "degrees"), - ("rotation", "degrees"), - ("opacity", "percent"), +/// Transform properties with the unit their columns are labelled with and, for vectors, the +/// third component a 2D layer shows. X / Y / Z Position (Separate Dimensions) have no unit. +const TRANSFORM: &[(&str, &str, Option)] = &[ + ("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, + values: Values, +} + +/// How a property's value becomes the numbers in a row. +#[derive(Clone, Copy)] +enum Values { + Plain, + /// A 2D layer's Transform vector: the third component is the one the layer shows (it may + /// keep another from pasted 3D keys). + Flat(f64), +} + +impl Values { + fn row(self, mut v: Vec) -> Vec { + if let (Values::Flat(z), Some(c)) = (self, v.get_mut(2)) { + *c = z; + } + v + } +} + /// The text for copied keys (layer times) and unanimated properties. `None` unless they all come -/// from one layer and at least one is a Transform property. +/// from one layer and at least one property can be written. pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[(LayerId, Uid)]) -> Option { let lid = keys.first().map(|k| k.layer).or_else(|| statics.first().map(|s| s.0))?; if keys.iter().any(|k| k.layer != lid) || statics.iter().any(|s| s.0 != lid) { @@ -31,11 +60,7 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( let layer = comp.layer(lid)?; let fps = comp.frame_rate.as_f64(); let mut blocks = String::new(); - for (id, unit) in CHANNELS { - let Some(pr) = layer.props.prop(&format!("transform/{id}")) else { continue }; - if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) { - continue; - } + layer.props.walk("", &mut |_, pr| { let mut times: Vec<_> = keys.iter().filter(|k| k.prop == pr.uid).map(|k| k.time).collect(); times.sort(); times.dedup(); @@ -45,29 +70,19 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( } else if statics.iter().any(|s| s.1 == pr.uid) { vec![(String::new(), pr.value.components())] } else { - continue; + return; }; - let Some(dims) = rows.first().map(|(_, v)| v.len()).filter(|n| *n > 0) else { continue }; - // After Effects labels only the first of Orientation's three columns, and names Z - // Rotation plain Rotation (as AE2Blend expects). - let columns = match dims { - 1 => vec![unit.to_string()], - _ if *id == "orientation" => vec![format!("X {unit}")], - _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), - }; - blocks.push_str(&format!("Transform\t{}\n\tFrame\t{}\t\n", pr.name, columns.join("\t"))); - for (frame, mut values) in rows { - // A 2D layer ignores the Z it may keep (from pasted 3D keys): write the one it shows. - if !layer.is_3d() - && let Some(z) = values.get_mut(2) - { - *z = if *id == "scale" { 100.0 } else { 0.0 }; - } - let values: Vec = values.into_iter().map(num).collect(); + let Some(ch) = channel(layer, pr).filter(|ch| !ch.labels.is_empty()) else { return }; + if rows.iter().any(|(_, v)| v.is_empty()) { + return; + } + blocks.push_str(&format!("{}\n\tFrame\t{}\t\n", ch.header, ch.labels.join("\t"))); + for (frame, values) in rows { + let values: Vec = ch.values.row(values).into_iter().map(num).collect(); blocks.push_str(&format!("\t{frame}\t{}\t\n", values.join("\t"))); } blocks.push('\n'); - } + }); if blocks.is_empty() { return None; } @@ -96,9 +111,44 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( )) } +/// How the layer's property `pr` is written, by where it sits in the layer: `None` for +/// properties the text doesn't carry. +fn channel(layer: &Layer, pr: &Property) -> Option { + let chain = layer.props.node_chain(pr.uid)?; + match chain.first().and_then(|n| n.as_group()).map(|g| g.match_id.as_str()) { + None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }), + Some("transform") => transform(layer, pr), + _ => None, + } +} + +fn transform(layer: &Layer, pr: &Property) -> Option { + let (id, unit, z) = TRANSFORM.iter().find(|(id, ..)| *id == pr.match_id)?; + if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) { + return None; + } + // After Effects labels only the first of Orientation's three columns, and names Z Rotation + // plain Rotation (as AE2Blend expects). + let labels = if *id == "orientation" { vec![format!("X {unit}")] } else { labels(unit, pr.value.dims()) }; + let values = match z { + Some(z) if !layer.is_3d() => Values::Flat(*z), + _ => Values::Plain, + }; + Some(Channel { header: format!("Transform\t{}", pr.name), labels, values }) +} + +/// Column labels: the unit, or one per axis (`X pixels`, `Y pixels`). +fn labels(unit: &str, dims: usize) -> Vec { + match dims { + 0 => vec![], + 1 => vec![unit.to_string()], + _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}").trim_end().to_string()).collect(), + } +} + /// Whether the layer uses this Transform property: one-node cameras (and lights not aimed at /// it) have no Point of Interest, and Separate Dimensions replaces Position with X / Y / Z -/// Position (which this text doesn't cover). +/// Position. fn active(layer: &Layer, id: &str) -> bool { match id { "poi" => !(layer.is_camera() || layer.is_light()) || layer.auto_orient == AutoOrient::TowardsPointOfInterest, diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index ef9ad509c..bc441fcf3 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -140,6 +140,41 @@ fn properties_the_layer_does_not_use_are_left_out() { assert!(names(&text).contains(&"Point of Interest"), "{text}"); } +#[test] +fn separated_position_writes_x_y_and_z_position() { + let (mut s, l) = session(); + s.execute("prop.separateDimensions", json!({"layer": l.0, "value": true})).unwrap(); + let t = frame(&s, 12); + add_key(&mut s, l, "transform/positionX", Tick::ZERO, json!(100)); + add_key(&mut s, l, "transform/positionX", t, json!(321)); + s.execute("prop.set", json!({"layer": l.0, "path": "transform/positionY", "value": 200})).unwrap(); + let text = copy_transform(&mut s, l); + // After Effects labels their single column with no unit. + assert!(text.contains("Transform\tX Position\n\tFrame\t\t\n\t0\t100\t\n\t12\t321\t\n\nTransform\tY Position\n\tFrame\t\t\n\t\t200\t\n\n"), "{text}"); + assert!(!names(&text).contains(&"Z Position"), "a 2D layer has no Z Position: {text}"); + edit_layer(&mut s, l, |l| l.switches.three_d = true); + let text = copy_transform(&mut s, l); + assert!(text.contains("Transform\tZ Position\n\tFrame\t\t\n\t\t0\t\n"), "{text}"); +} + +#[test] +fn time_remap_writes_source_seconds_at_comp_frames() { + let (mut s, l) = session(); + let r = s.execute("layer.precompose", json!({"layers": [l.0], "name": "Inner"})).unwrap(); + let l = LayerId(r["layer"].as_u64().unwrap()); + s.execute("layer.select", json!({"layers": [l.0]})).unwrap(); + s.execute("layer.enableTimeRemap", json!({})).unwrap(); + let start = frame(&s, 10); + edit_layer(&mut s, l, |l| l.start_time = start); + let t = frame(&s, 6); + add_key(&mut s, l, "timeRemap", t, json!(2.5)); + s.execute("prop.select", json!({"layer": l.0, "path": "timeRemap"})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + let text = s.state.keyframe_text.clone().unwrap(); + // As After Effects writes it: the key at layer frame 6 of a layer starting at frame 10 is at 16. + assert!(text.contains("Layer\nTime Remap\n\tFrame\tseconds\t\n\t10\t0\t\n\t16\t2.5\t\n"), "{text}"); +} + #[test] fn a_2d_layer_writes_the_z_it_shows() { // Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it. From 39142e9e6bcf6681c48c77085bf4bbea055aa3af Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:49:25 -0400 Subject: [PATCH 10/19] Clipboard: keyframe text for effect properties (#1908) --- crates/engine/src/keyframe_text.rs | 51 ++++++++++++++++++++++-- crates/engine/src/tests_keyframe_text.rs | 41 +++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 9703fa503..5d896327b 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -4,7 +4,7 @@ //! number in comp time, then the value) and static ones as a single row with no frame. Easing //! and tangents aren't part of the format; EffectCraft's own paste keeps them. -use effectcraft_project::{AutoOrient, Comp, ItemKind, Layer, LayerId, LayerSource, Project, Property, Uid}; +use effectcraft_project::{AutoOrient, Comp, GroupKind, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, Project, PropGroup, Property, Uid}; use crate::KeyRef; @@ -39,14 +39,25 @@ enum Values { /// A 2D layer's Transform vector: the third component is the one the layer shows (it may /// keep another from pasted 3D keys). Flat(f64), + /// RGBA 0–1 as alpha, red, green, blue 0–255. + Argb, + /// A popup's 0-based choice as After Effects' 1-based one. + OneBased, } impl Values { fn row(self, mut v: Vec) -> Vec { - if let (Values::Flat(z), Some(c)) = (self, v.get_mut(2)) { - *c = z; + match (self, v.as_slice()) { + (Values::Flat(z), _) => { + if let Some(c) = v.get_mut(2) { + *c = z; + } + v + } + (Values::Argb, [r, g, b, a]) => [a, r, g, b].map(|c| c * 255.0).to_vec(), + (Values::OneBased, _) => v.into_iter().map(|c| c + 1.0).collect(), + _ => v, } - v } } @@ -118,10 +129,42 @@ fn channel(layer: &Layer, pr: &Property) -> Option { 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), _ => None, } } +/// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer, +/// then the property's name and place, counting the effect's hidden input layer as the first. +fn effect(chain: &[&Node], pr: &Property) -> Option { + let [Node::Group(effects), Node::Group(fx), _] = chain else { return None }; + let GroupKind::Effect { effect } = &fx.kind else { return None }; + let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name); + let (labels, values) = by_ui(pr)?; + let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, place(fx, pr.uid)? + 1); + Some(Channel { header, labels, values }) +} + +/// 1-based position of a child in its group. +fn place(group: &PropGroup, uid: Uid) -> Option { + group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1) +} + +/// Column labels and values for an effect or text property, by how it's edited (After Effects +/// labels sliders, checkboxes and popups with no unit). +fn by_ui(pr: &Property) -> Option<(Vec, 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::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 { let (id, unit, z) = TRANSFORM.iter().find(|(id, ..)| *id == pr.match_id)?; if !active(layer, id) || (pr.three_d_only && !layer.is_3d()) { diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index bc441fcf3..6079ec60a 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -175,6 +175,47 @@ fn time_remap_writes_source_seconds_at_comp_frames() { 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. + 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 a_2d_layer_writes_the_z_it_shows() { // Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it. From 6cad56b19d91b2533c5cb1a710ddee5821c7fd04 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:53:28 -0400 Subject: [PATCH 11/19] Clipboard: keyframe text for mask properties (#1908) --- crates/engine/src/keyframe_text.rs | 14 +++++++++++ crates/engine/src/tests_keyframe_text.rs | 30 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 5d896327b..a5a596ba5 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -130,10 +130,24 @@ fn channel(layer: &Layer, pr: &Property) -> Option { None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }), Some("transform") => transform(layer, pr), Some("effects") => effect(&chain, pr), + Some("masks") => mask(&chain, pr), _ => None, } } +/// A mask property: `Masks`, the mask by place (not name), then the property. Mask Path isn't a +/// number, and After Effects leaves it out. +fn mask(chain: &[&Node], pr: &Property) -> Option { + let [Node::Group(masks), Node::Group(m), _] = chain else { return None }; + let unit = match pr.match_id.as_str() { + "feather" | "expansion" => "pixels", + "opacity" => "percent", + _ => return None, + }; + let header = format!("Masks\tMask #{}\t{}", place(masks, m.uid)?, pr.name); + Some(Channel { header, labels: labels(unit, pr.value.dims()), values: Values::Plain }) +} + /// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer, /// then the property's name and place, counting the effect's hidden input layer as the first. fn effect(chain: &[&Node], pr: &Property) -> Option { diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 6079ec60a..8fc09caa9 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -216,6 +216,36 @@ fn effect_properties_are_written_as_after_effects_does() { ); } +#[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 a_2d_layer_writes_the_z_it_shows() { // Keys pasted from a 3D layer keep their Z on a 2D one, which ignores it. From 05f2daa4caaead4578cdcf0602df7b5d0bf8eb70 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 09:57:40 -0400 Subject: [PATCH 12/19] Clipboard: keyframe text for text layer properties, and percent sliders labelled percent (#1908) --- crates/engine/src/keyframe_text.rs | 38 +++++++++++++++- crates/engine/src/tests_keyframe_text.rs | 58 ++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index a5a596ba5..6fa625fb5 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -131,10 +131,42 @@ fn channel(layer: &Layer, pr: &Property) -> Option { Some("transform") => transform(layer, pr), Some("effects") => effect(&chain, pr), Some("masks") => mask(&chain, pr), + Some("text") => text_prop(&chain, pr), _ => None, } } +/// Text animators and selectors by match id, with the kind After Effects names them by. +const TEXT_INSTANCES: &[(&str, &str)] = + &[("animator", "Animator"), ("rangeSelector", "Range Selector"), ("wigglySelector", "Wiggly Selector"), ("expressionSelector", "Expression Selector")]; + +/// A text property: `Text`, then each group down to the property. Animators and selectors go by +/// kind and place (`Animator #1`), not name, and a selector's own properties carry their place +/// too (`Start #1`). Source Text isn't a number, and After Effects leaves it out. +fn text_prop(chain: &[&Node], pr: &Property) -> Option { + let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect(); + let parent = groups.last()?; + // Path Options' Path picks a mask: not a value the text carries. + if parent.match_id == "pathOptions" && pr.match_id == "path" { + return None; + } + let (labels, values) = by_ui(pr)?; + let mut header = String::from("Text"); + for pair in groups.windows(2) { + let [outer, g] = pair else { continue }; + match TEXT_INSTANCES.iter().find(|(id, _)| *id == g.match_id) { + Some((_, kind)) => header.push_str(&format!("\t{kind} #{}", place(outer, g.uid)?)), + None => header.push_str(&format!("\t{}", g.name)), + } + } + if parent.match_id.ends_with("Selector") { + header.push_str(&format!("\t{} #{}", pr.name, place(parent, pr.uid)?)); + } else { + header.push_str(&format!("\t{}", pr.name)); + } + Some(Channel { header, labels, values }) +} + /// A mask property: `Masks`, the mask by place (not name), then the property. Mask Path isn't a /// number, and After Effects leaves it out. fn mask(chain: &[&Node], pr: &Property) -> Option { @@ -164,13 +196,15 @@ fn place(group: &PropGroup, uid: Uid) -> Option { group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1) } -/// Column labels and values for an effect or text property, by how it's edited (After Effects -/// labels sliders, checkboxes and popups with no unit). +/// Column labels and values for an effect or text property, by how it's edited. After Effects +/// labels most 0–100 and ±100 sliders (opacities, selector amounts) as percent, and other +/// sliders, checkboxes and popups with no unit. fn by_ui(pr: &Property) -> Option<(Vec, Values)> { let dims = pr.value.dims(); Some(match &pr.ui { ParamUi::Color => (["alpha ", "red ", "green ", "blue "].map(String::from).to_vec(), Values::Argb), ParamUi::Popup { .. } => (labels("", dims), Values::OneBased), + ParamUi::Slider { min, max, .. } if *max == 100.0 && (*min == 0.0 || *min == -100.0) => (labels("percent", dims), Values::Plain), ParamUi::Percent => (labels("percent", dims), Values::Plain), ParamUi::Angle => (labels("degrees", dims), Values::Plain), ParamUi::Point | ParamUi::Point3 => (labels("pixels", dims), Values::Plain), diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 8fc09caa9..d1f9ddb9b 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -246,6 +246,64 @@ fn mask_properties_are_written_as_after_effects_does() { ); } +#[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. From dac6421ec353f4c8ba7e587d1ec03bcbfe443f8f Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:23:52 -0400 Subject: [PATCH 13/19] Clipboard: effect properties in twirl-down groups, numbered as After Effects does (#1908) --- crates/engine/src/keyframe_text.rs | 40 ++++++++++++++++++++++-- crates/engine/src/tests_keyframe_text.rs | 24 +++++++++++++- 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 6fa625fb5..59367b19b 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -181,16 +181,50 @@ fn mask(chain: &[&Node], pr: &Property) -> Option { } /// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer, -/// then the property's name and place, counting the effect's hidden input layer as the first. +/// 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 { - let [Node::Group(effects), Node::Group(fx), _] = chain else { return None }; + let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None }; let GroupKind::Effect { effect } = &fx.kind else { return None }; let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name); let (labels, values) = by_ui(pr)?; - let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, place(fx, pr.uid)? + 1); + let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(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. +fn effect_index(fx: &PropGroup, uid: Uid) -> Option { + fn count(g: &PropGroup, 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) => { + *n += 1; + if p.uid == uid { + return true; + } + } + Node::Group(sub) => { + *n += 1; + if count(sub, uid, n) { + return true; + } + *n += 1; + } + } + } + false + } + let mut n = 1; + count(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 { group.children.iter().position(|c| c.uid() == uid).map(|i| i + 1) diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index d1f9ddb9b..dfdbe23a1 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -186,7 +186,8 @@ fn copy_props(s: &mut Session, lid: LayerId, paths: &[&str]) -> String { #[test] fn effect_properties_are_written_as_after_effects_does() { - // The blocks After Effects 2019 wrote for the same effects and values. + // 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(); @@ -216,6 +217,27 @@ fn effect_properties_are_written_as_after_effects_does() { ); } +#[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 and Evolution #25: 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/#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\tSet Matte #2\tUse For Matte #3\n", + ] { + assert!(text.contains(header), "{header}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 From b40c1aff227441fc1526056ba7b0eacbfcc1009a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:26:10 -0400 Subject: [PATCH 14/19] Clipboard: keyframe text gives a text layer's source as 1 x 1, as After Effects does (#1908) --- crates/engine/src/keyframe_text.rs | 11 ++++++----- crates/engine/src/tests_keyframe_text.rs | 8 ++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 59367b19b..851b94ee3 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -97,11 +97,12 @@ pub(crate) fn text(project: &Project, comp: &Comp, keys: &[KeyRef], statics: &[( if blocks.is_empty() { return None; } - // Layers without a source of their own (text, shapes, cameras) take the comp's size and - // pixel aspect. - let (w, h) = match effectcraft_render::eval::source_size(project, layer) { - (0, 0) => (comp.width, comp.height), - size => size, + // 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) { diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index dfdbe23a1..0ca688466 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -353,10 +353,14 @@ fn the_header_has_the_source_pixel_aspect() { 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 has no source of its own: the comp's. + // 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\t1920\n\tSource Height\t1080\n\tSource Pixel Aspect Ratio\t1.2\n"), "{text}"); + 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] From c5f028b5cd7cba79b5877b4c5b6acbfa33d3053c Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:36:14 -0400 Subject: [PATCH 15/19] Clipboard: test Fractal Noise's indices after Evolution Options (#1908) --- crates/engine/src/tests_keyframe_text.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 0ca688466..d40e79e67 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -220,18 +220,32 @@ fn effect_properties_are_written_as_after_effects_does() { #[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 and Evolution #25: 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. + // 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/#2/takeMatteFrom"]); + 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}"); From f9d6c0801be17f0ec029e130b546417daa20e387 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 10:57:52 -0400 Subject: [PATCH 16/19] Clipboard: keyframe text for shape contents and camera, light and material options (#1908) --- crates/engine/src/keyframe_text.rs | 114 ++++++++++++++++------- crates/engine/src/tests_keyframe_text.rs | 59 ++++++++++++ 2 files changed, 141 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 851b94ee3..e57909b15 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -43,6 +43,7 @@ enum Values { Argb, /// A popup's 0-based choice as After Effects' 1-based one. OneBased, + Scale(f64), } impl Values { @@ -56,6 +57,7 @@ impl Values { } (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, } } @@ -131,31 +133,50 @@ fn channel(layer: &Layer, pr: &Property) -> Option { None if pr.match_id == "timeRemap" => Some(Channel { header: "Time Remap".into(), labels: labels("seconds", 1), values: Values::Plain }), Some("transform") => transform(layer, pr), Some("effects") => effect(&chain, pr), - Some("masks") => mask(&chain, pr), - Some("text") => text_prop(&chain, pr), + Some("masks" | "text" | "contents" | "cameraOptions" | "lightOptions" | "materialOptions") => nested(&chain, pr), _ => None, } } -/// Text animators and selectors by match id, with the kind After Effects names them by. -const TEXT_INSTANCES: &[(&str, &str)] = - &[("animator", "Animator"), ("rangeSelector", "Range Selector"), ("wigglySelector", "Wiggly Selector"), ("expressionSelector", "Expression Selector")]; +/// 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 text property: `Text`, then each group down to the property. Animators and selectors go by -/// kind and place (`Animator #1`), not name, and a selector's own properties carry their place -/// too (`Start #1`). Source Text isn't a number, and After Effects leaves it out. -fn text_prop(chain: &[&Node], pr: &Property) -> Option { +/// 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 { let groups: Vec<&PropGroup> = chain.iter().filter_map(|n| n.as_group()).collect(); let parent = groups.last()?; - // Path Options' Path picks a mask: not a value the text carries. - if parent.match_id == "pathOptions" && pr.match_id == "path" { - return None; - } - let (labels, values) = by_ui(pr)?; - let mut header = String::from("Text"); + let (labels, values) = columns(&parent.match_id, pr)?; + let mut header = groups.first()?.name.clone(); for pair in groups.windows(2) { let [outer, g] = pair else { continue }; - match TEXT_INSTANCES.iter().find(|(id, _)| *id == g.match_id) { + 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)), } @@ -168,19 +189,6 @@ fn text_prop(chain: &[&Node], pr: &Property) -> Option { Some(Channel { header, labels, values }) } -/// A mask property: `Masks`, the mask by place (not name), then the property. Mask Path isn't a -/// number, and After Effects leaves it out. -fn mask(chain: &[&Node], pr: &Property) -> Option { - let [Node::Group(masks), Node::Group(m), _] = chain else { return None }; - let unit = match pr.match_id.as_str() { - "feather" | "expansion" => "pixels", - "opacity" => "percent", - _ => return None, - }; - let header = format!("Masks\tMask #{}\t{}", place(masks, m.uid)?, pr.name); - Some(Channel { header, labels: labels(unit, pr.value.dims()), values: Values::Plain }) -} - /// An effect property: `Effects`, the effect's name (not a custom one) and place on the layer, /// then the property's name and After Effects index. Properties in twirl-down groups are /// written without their group, as After Effects does. @@ -188,7 +196,7 @@ fn effect(chain: &[&Node], pr: &Property) -> Option { let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None }; let GroupKind::Effect { effect } = &fx.kind else { return None }; let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name); - let (labels, values) = by_ui(pr)?; + let (labels, values) = columns(effect, pr)?; let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(fx, pr.uid)?); Some(Channel { header, labels, values }) } @@ -231,6 +239,47 @@ fn place(group: &PropGroup, uid: Uid) -> Option { 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: [`by_ui`] unless [`EXCEPTIONS`] says otherwise. +fn columns(scope: &str, pr: &Property) -> Option<(Vec, Values)> { + let (by_editor, values) = by_ui(pr)?; + Some(match EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == pr.match_id).map(|e| e.2) { + 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 an effect or text property, by how it's edited. After Effects /// labels most 0–100 and ±100 sliders (opacities, selector amounts) as percent, and other /// sliders, checkboxes and popups with no unit. @@ -263,12 +312,13 @@ fn transform(layer: &Layer, pr: &Property) -> Option { Some(Channel { header: format!("Transform\t{}", pr.name), labels, values }) } -/// Column labels: the unit, or one per axis (`X pixels`, `Y pixels`). +/// 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 { match dims { 0 => vec![], 1 => vec![unit.to_string()], - _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}").trim_end().to_string()).collect(), + _ => ["X", "Y", "Z"].iter().take(dims).map(|axis| format!("{axis} {unit}")).collect(), } } diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index d40e79e67..6de5e54be 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -396,3 +396,62 @@ fn keys_from_two_layers_have_no_text() { 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}"); + } +} From 5556b580504ab7e6205e407e4b038221b299d72a Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 11:12:52 -0400 Subject: [PATCH 17/19] Clipboard: effect sliders of -100 to 100 have no unit in keyframe text, as in After Effects (#1908) --- crates/engine/src/keyframe_text.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index e57909b15..38af4b783 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -172,7 +172,7 @@ const INSTANCES: &[(&str, &str)] = &[ fn nested(chain: &[&Node], pr: &Property) -> Option { 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)?; + let (labels, values) = columns(&parent.match_id, pr, false)?; let mut header = groups.first()?.name.clone(); for pair in groups.windows(2) { let [outer, g] = pair else { continue }; @@ -196,7 +196,7 @@ fn effect(chain: &[&Node], pr: &Property) -> Option { let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None }; let GroupKind::Effect { effect } = &fx.kind else { return None }; let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name); - let (labels, values) = columns(effect, pr)?; + let (labels, values) = columns(effect, pr, true)?; let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(fx, pr.uid)?); Some(Channel { header, labels, values }) } @@ -270,8 +270,8 @@ const EXCEPTIONS: &[(&str, &str, Exception)] = &[ /// Column labels and values for a property in `scope`, the effect (by id) or group (by match /// id) it's directly in: [`by_ui`] unless [`EXCEPTIONS`] says otherwise. -fn columns(scope: &str, pr: &Property) -> Option<(Vec, Values)> { - let (by_editor, values) = by_ui(pr)?; +fn columns(scope: &str, pr: &Property, in_effect: bool) -> Option<(Vec, Values)> { + let (by_editor, values) = by_ui(pr, in_effect)?; Some(match EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == pr.match_id).map(|e| e.2) { None => (by_editor, values), Some(Exception::Skip) => return None, @@ -280,15 +280,16 @@ fn columns(scope: &str, pr: &Property) -> Option<(Vec, Values)> { }) } -/// Column labels and values for an effect or text property, by how it's edited. After Effects -/// labels most 0–100 and ±100 sliders (opacities, selector amounts) as percent, and other -/// sliders, checkboxes and popups with no unit. -fn by_ui(pr: &Property) -> Option<(Vec, 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. For the 1,390 effect parameters +/// EffectCraft shares with After Effects 2019, these rules give After Effects' unit for 88%. +fn by_ui(pr: &Property, in_effect: bool) -> Option<(Vec, Values)> { let dims = pr.value.dims(); Some(match &pr.ui { ParamUi::Color => (["alpha ", "red ", "green ", "blue "].map(String::from).to_vec(), Values::Argb), ParamUi::Popup { .. } => (labels("", dims), Values::OneBased), - ParamUi::Slider { min, max, .. } if *max == 100.0 && (*min == 0.0 || *min == -100.0) => (labels("percent", dims), Values::Plain), + ParamUi::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), From cfb762b1143e153ef768335ecaa8f13d3aeb4f50 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 20 Oct 2026 13:41:34 -0400 Subject: [PATCH 18/19] Clipboard: effect parameters After Effects numbers or labels otherwise follow it in keyframe text (#1908) A table of After Effects' index and unit for the effect parameters its keyframe text gives differently from EffectCraft's rules, read from After Effects 2019's text for 230 effects. All 1,530 parameters the two share now match; 949 did before. --- crates/engine/src/keyframe_text.rs | 36 +- crates/engine/src/keyframe_text_effects.rs | 386 +++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/tests_keyframe_text.rs | 25 ++ 4 files changed, 433 insertions(+), 15 deletions(-) create mode 100644 crates/engine/src/keyframe_text_effects.rs diff --git a/crates/engine/src/keyframe_text.rs b/crates/engine/src/keyframe_text.rs index 38af4b783..5f8ba9441 100644 --- a/crates/engine/src/keyframe_text.rs +++ b/crates/engine/src/keyframe_text.rs @@ -6,7 +6,7 @@ use effectcraft_project::{AutoOrient, Comp, GroupKind, ItemKind, Layer, LayerId, LayerSource, Node, ParamUi, Project, PropGroup, Property, Uid}; -use crate::KeyRef; +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. @@ -172,7 +172,7 @@ const INSTANCES: &[(&str, &str)] = &[ fn nested(chain: &[&Node], pr: &Property) -> Option { 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, false)?; + 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 }; @@ -196,28 +196,31 @@ fn effect(chain: &[&Node], pr: &Property) -> Option { let [Node::Group(effects), Node::Group(fx), ..] = chain else { return None }; let GroupKind::Effect { effect } = &fx.kind else { return None }; let name = effectcraft_effects::find(effect).map_or(fx.name.as_str(), |spec| spec.name); - let (labels, values) = columns(effect, pr, true)?; - let header = format!("Effects\t{name} #{}\t{} #{}", place(effects, fx.uid)?, pr.name, effect_index(fx, pr.uid)?); + 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. -fn effect_index(fx: &PropGroup, uid: Uid) -> Option { - fn count(g: &PropGroup, uid: Uid, n: &mut usize) -> bool { +/// 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 { + 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) => { - *n += 1; + 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(sub, uid, n) { + if count(effect, sub, &format!("{prefix}{}/", sub.match_id), uid, n) { return true; } *n += 1; @@ -227,7 +230,7 @@ fn effect_index(fx: &PropGroup, uid: Uid) -> Option { false } let mut n = 1; - count(fx, uid, &mut n).then_some(n) + count(effect, fx, "", uid, &mut n).then_some(n) } fn is_layer_source(g: &PropGroup, p: &Property) -> bool { @@ -269,10 +272,13 @@ const EXCEPTIONS: &[(&str, &str, Exception)] = &[ ]; /// Column labels and values for a property in `scope`, the effect (by id) or group (by match -/// id) it's directly in: [`by_ui`] unless [`EXCEPTIONS`] says otherwise. -fn columns(scope: &str, pr: &Property, in_effect: bool) -> Option<(Vec, Values)> { +/// 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, Values)> { let (by_editor, values) = by_ui(pr, in_effect)?; - Some(match EXCEPTIONS.iter().find(|(s, id, _)| *s == scope && *id == pr.match_id).map(|e| e.2) { + 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), @@ -282,8 +288,8 @@ fn columns(scope: &str, pr: &Property, in_effect: bool) -> Option<(Vec, /// 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. For the 1,390 effect parameters -/// EffectCraft shares with After Effects 2019, these rules give After Effects' unit for 88%. +/// 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, Values)> { let dims = pr.value.dims(); Some(match &pr.ui { diff --git a/crates/engine/src/keyframe_text_effects.rs b/crates/engine/src/keyframe_text_effects.rs new file mode 100644 index 000000000..83f5b6c20 --- /dev/null +++ b/crates/engine/src/keyframe_text_effects.rs @@ -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"), +]; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 223df7d15..9dcae4837 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,6 +22,7 @@ pub mod guard; pub mod history; pub mod jobs; mod keyframe_text; +mod keyframe_text_effects; pub mod learn; pub mod links; pub mod logging; diff --git a/crates/engine/src/tests_keyframe_text.rs b/crates/engine/src/tests_keyframe_text.rs index 6de5e54be..30d3b436e 100644 --- a/crates/engine/src/tests_keyframe_text.rs +++ b/crates/engine/src/tests_keyframe_text.rs @@ -252,6 +252,31 @@ fn effect_properties_have_after_effects_indices() { } } +#[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 From c44d25b9eae764ec606bc9a79cc6df36e43bcdea Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Tue, 27 Oct 2026 19:50:41 -0400 Subject: [PATCH 19/19] Clipboard: format the keyframe text imports (#1908) --- crates/engine/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index dfd765210..36da82274 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -67,10 +67,10 @@ pub use home::demo; pub use home::learn; pub use home::links; pub use home::templates; -use interchange::{keyframe_text, keyframe_text_effects}; 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;