From b4b2950c5df426a8a39bc4c506238892a88fb860 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 22:36:31 -0400 Subject: [PATCH 1/4] M3.4: After Effects menu-bar parity (engine menu tree, ~190 new commands, menu dialogs) - engine::menus: the AE 2026 menu bar as an indented tree (same menus, order, submenus, separators, "..." labels, AE default macOS shortcuts; [mac]/[!mac] app menu vs Edit > Preferences / File > Exit), entries bind command params, check-mark state, Effect categories from the registry. - New engine commands: Layer (quality/sampling, frame blending, switch ops, numeric Transform values, Center Anchor Point, Auto-Orient, mask ops on existing masks, layer markers + lock, track-matte menu, open/reveal source, expression errors), Edit (keyframe clipboard + Paste Reversed, Copy with (Relative) Property Links, Copy Expression Only, Lift/Extract Work Area, Select Label Group, Purge, Edit Original), Animation (JSON animation presets, Add Keyframe, Exponential Scale, Time-Reverse Keyframes, Sequence Layers, text selectors, Remove All Text Animators, Reveal Properties), File (New Folder, Close/Close Project, Save a Copy, placeholders/solids, New Comp from Selection, Dependencies, Run Script File, Interpret/Remember/ Apply Interpretation, Replace/Reload Footage, Reveal in Finder), Composition (crop to ROI / layer bounds, Save Frame As PNG, Responsive Design - Time), View (guides add/clear/import/export, region of interest), Help docs links. - Frontend commands (viewer, panels, workspaces, dialogs) are registered engine commands that emit Event::Frontend; owned-elsewhere items (3D, render queue, time remap, pen, tracking, layer styles) are disabled stubs. - UI renders the engine tree (check gutter, scrolling long submenus, dynamic Undo/Redo labels), bindings include param'd entries, generic parameter forms, Settings, Keyboard Shortcuts, View Options, guides/ROI/pasteboard in the viewer, timeline reveal of engine-picked properties; snapshot example gains a `pointer` step for opening menus headlessly. - Tests: menu order, every entry resolves, spec/tree consistency, shortcut uniqueness, per-family command tests with undo. - Workspace clippy -D warnings clean (rust 1.96 lints). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/effectcraft-cli/src/main.rs | 3 +- apps/effectcraft/src/main.rs | 8 +- crates/engine/Cargo.toml | 1 + crates/engine/src/commands/anim.rs | 428 +++++++++++ crates/engine/src/commands/comp.rs | 6 +- crates/engine/src/commands/comp_more.rs | 130 ++++ crates/engine/src/commands/edit.rs | 371 ++++++++- crates/engine/src/commands/file.rs | 10 +- crates/engine/src/commands/file_more.rs | 681 +++++++++++++++++ crates/engine/src/commands/frontend.rs | 77 ++ crates/engine/src/commands/help.rs | 17 +- crates/engine/src/commands/layer.rs | 14 +- crates/engine/src/commands/layer_menu.rs | 834 ++++++++++++++++++++ crates/engine/src/commands/mod.rs | 70 +- crates/engine/src/commands/prop.rs | 4 +- crates/engine/src/commands/stubs.rs | 112 +++ crates/engine/src/commands/time.rs | 2 +- crates/engine/src/commands/view.rs | 86 +++ crates/engine/src/lib.rs | 49 +- crates/engine/src/menus.rs | 925 +++++++++++++++++++++++ crates/engine/src/menus/tests.rs | 148 ++++ crates/engine/src/tests_menu_cmds.rs | 491 ++++++++++++ crates/host/src/lib.rs | 11 +- crates/project/src/build.rs | 1 + crates/project/src/lib.rs | 14 + crates/ui-egui/examples/snapshot.rs | 19 + crates/ui-egui/src/lib.rs | 16 + crates/ui-egui/src/menus.rs | 714 +++++++++++------ crates/ui-egui/src/panels/dialogs.rs | 16 +- crates/ui-egui/src/panels/forms.rs | 547 ++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/project.rs | 6 +- crates/ui-egui/src/panels/timeline.rs | 41 +- crates/ui-egui/src/panels/viewer.rs | 80 +- crates/ui-egui/src/state.rs | 22 + 36 files changed, 5673 insertions(+), 283 deletions(-) create mode 100644 crates/engine/src/commands/anim.rs create mode 100644 crates/engine/src/commands/comp_more.rs create mode 100644 crates/engine/src/commands/file_more.rs create mode 100644 crates/engine/src/commands/frontend.rs create mode 100644 crates/engine/src/commands/layer_menu.rs create mode 100644 crates/engine/src/commands/stubs.rs create mode 100644 crates/engine/src/commands/view.rs create mode 100644 crates/engine/src/menus.rs create mode 100644 crates/engine/src/menus/tests.rs create mode 100644 crates/engine/src/tests_menu_cmds.rs create mode 100644 crates/ui-egui/src/panels/forms.rs diff --git a/Cargo.lock b/Cargo.lock index e0e23472..eba12ce1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1099,6 +1099,7 @@ dependencies = [ "effectcraft-render", "effectcraft-text", "effectcraft-time", + "image", "log", "serde", "serde_json", diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index dc010d58..819d952a 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -22,7 +22,8 @@ fn open(s: &mut Session, args: &[String]) { eprintln!("effectcraft: {e}"); std::process::exit(1); } - } else if args.iter().any(|a| a == "--demo") || true { + } else { + // No project given (with or without --demo): open the demo project. let _ = s.execute("file.openDemoProject", json!({})); } } diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index a2b3fedd..9e8bcd6f 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -54,10 +54,10 @@ fn main() -> eframe::Result { let _ = session.execute("file.openDemoProject", json!({})); } let media: Vec = files.iter().filter(|f| !f.ends_with(".ecproj")).cloned().collect(); - if !media.is_empty() { - if let Err(e) = session.execute("file.import", json!({"paths": media})) { - eprintln!("effectcraft: {e}"); - } + if !media.is_empty() + && let Err(e) = session.execute("file.import", json!({"paths": media})) + { + eprintln!("effectcraft: {e}"); } let mut app = EffectcraftApp::new(session); app.ui.start_screen = home; diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ecc39243..33150379 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -24,3 +24,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +image = { workspace = true } diff --git a/crates/engine/src/commands/anim.rs b/crates/engine/src/commands/anim.rs new file mode 100644 index 00000000..62ac0183 --- /dev/null +++ b/crates/engine/src/commands/anim.rs @@ -0,0 +1,428 @@ +//! Animation menu: animation presets (EffectCraft's own JSON format), Add Keyframe, Keyframe +//! Assistant (Exponential Scale, Time-Reverse Keyframes), text animators/selectors, and the +//! Reveal Properties commands. + +use std::collections::BTreeMap; + +use effectcraft_keyframe::{Interp, Keyframe, Value as KV}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{GroupKind, LayerId, Node, PropGroup, Property, Uid}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, frontend, has_comp, has_keys, has_layers, layer_mut, layer_p, layers_p, match_path_of, selected_leaf_props, str_p}; +use crate::{EngineError, KeyRef, Result, Session, cmd}; + +/// Preset file format version tag (`*.ecpreset`, JSON). +pub const PRESET_TAG: &str = "effectcraftAnimationPreset"; + +fn has_selected_props(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + if selected_leaf_props(s).is_empty() && selected_effects(s).is_empty() { Err("select properties or effects first".into()) } else { Ok(()) } +} + +fn has_text_layer(s: &Session) -> std::result::Result<(), String> { + has_layers(s)?; + let c = s.active_comp().ok_or("no composition")?; + if s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(|l| l.props.group("text/animators").is_some()) { + Ok(()) + } else { + Err("select a text layer".into()) + } +} + +/// Selected effect groups (layer, effect group). +fn selected_effects(s: &Session) -> Vec<(LayerId, PropGroup)> { + let Some(c) = s.active_comp() else { return vec![] }; + s.state + .selected_props + .iter() + .filter_map(|(l, u)| { + let layer = c.layer(*l)?; + let fx = layer.effects()?; + fx.groups().find(|g| g.uid == *u).map(|g| (*l, g.clone())) + }) + .collect() +} + +// ---------------------------------------------------------------- presets + +fn save_preset(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("anim.savePreset", "missing `path`"))?.to_string(); + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let t = s.time(); + let effects: Vec<(LayerId, PropGroup)> = selected_effects(s); + let effect_uids: Vec = effects.iter().map(|(_, g)| g.uid).collect(); + let mut props = vec![]; + for (lid, uid) in selected_leaf_props(s) { + let Some(l) = comp.layer(lid) else { continue }; + // Properties inside a saved effect travel with the effect. + if l.effects().is_some_and(|fx| fx.groups().any(|g| effect_uids.contains(&g.uid) && g.find(uid).is_some())) { + continue; + } + let (Some(pr), Some(mp)) = (l.props.find(uid), match_path_of(&l.props, uid)) else { continue }; + props.push((l.layer_time(t), mp, pr.clone())); + } + if props.is_empty() && effects.is_empty() { + return Err(bad("anim.savePreset", "select properties or effects to save")); + } + // Key times are stored relative to the earliest key so the preset applies at the CTI. + let first = props.iter().flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time)).min().unwrap_or(Tick::ZERO); + let props_json: Vec = props + .iter() + .map(|(_, mp, pr)| { + let mut pr = pr.clone(); + for k in &mut pr.keys { + k.time -= first; + } + json!({"path": mp, "value": pr.value, "keys": pr.keys, "expression": pr.expr}) + }) + .collect(); + let fx_json: Vec = effects.iter().map(|(_, g)| serde_json::to_value(g).unwrap_or_default()).collect(); + let name = str_p(p, "name") + .map(str::to_string) + .unwrap_or_else(|| std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()); + let doc = json!({PRESET_TAG: 1, "name": name, "properties": props_json, "effects": fx_json}); + let text = serde_json::to_string_pretty(&doc).unwrap_or_default(); + s.services.write_file(&path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()})) +} + +fn apply_preset(s: &mut Session, p: &Value) -> Result { + let doc: Value = match (p.get("preset"), str_p(p, "path")) { + (Some(v), _) => v.clone(), + (None, Some(path)) => { + let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?; + serde_json::from_slice(&bytes).map_err(|e| bad("anim.applyPreset", format!("not a preset: {e}")))? + } + _ => return Err(bad("anim.applyPreset", "missing `path` (or inline `preset`)")), + }; + if doc.get(PRESET_TAG).is_none() { + return Err(bad("anim.applyPreset", "not an EffectCraft animation preset")); + } + let (cid, ids) = layers_p(s, p)?; + if ids.is_empty() { + return Err(bad("anim.applyPreset", "select a layer")); + } + let effects: Vec = + doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default(); + let props: Vec<(String, Property)> = doc["properties"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| { + let path = v["path"].as_str()?.to_string(); + let value: KV = serde_json::from_value(v["value"].clone()).ok()?; + let keys: Vec = serde_json::from_value(v["keys"].clone()).unwrap_or_default(); + let expr = serde_json::from_value(v["expression"].clone()).ok().flatten(); + let mut pr = Property::new(0, "", "", value); + pr.keys = keys; + pr.expr = expr; + Some((path, pr)) + }) + .collect() + }) + .unwrap_or_default(); + let t = s.time(); + let mut applied = 0; + let mut skipped = vec![]; + s.edit("Apply Animation Preset", None, |proj, _| { + let mut next = proj.next_id; + for lid in &ids { + let l = layer_mut(proj, cid, *lid)?; + let lt = l.layer_time(t); + if let Some(fx) = l.props.sub_mut("effects") { + for g in &effects { + let mut g = g.clone(); + g.reassign_uids(&mut next); + next += 1; + fx.children.push(Node::Group(g)); + applied += 1; + } + } + for (path, src) in &props { + match l.props.prop_mut(path) { + Some(pr) if std::mem::discriminant(&pr.value) == std::mem::discriminant(&src.value) => { + pr.value = src.value.clone(); + pr.keys = src + .keys + .iter() + .cloned() + .map(|mut k| { + k.time += lt; + k + }) + .collect(); + if src.expr.is_some() { + pr.expr = src.expr.clone(); + } + applied += 1; + } + _ => skipped.push(path.clone()), + } + } + } + proj.next_id = next; + Ok(()) + })?; + Ok(json!({"applied": applied, "skipped": skipped})) +} + +// ---------------------------------------------------------------- keyframes + +fn add_keyframe(s: &mut Session, _: &Value) -> Result { + let targets = selected_leaf_props(s); + if targets.is_empty() { + return Err(bad("anim.addKeyframe", "select properties first")); + } + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let t = s.time(); + let n = s.edit("Add Keyframe", None, |proj, _| { + let mut n = 0; + for (lid, uid) in &targets { + let l = layer_mut(proj, cid, *lid)?; + let lt = l.layer_time(t); + let Some(pr) = l.props.find_mut(*uid) else { continue }; + if pr.static_only { + continue; + } + if pr.keys.is_empty() { + pr.set_animated(true, lt); + } else { + let v = pr.value_at(lt); + pr.set_value_at(lt, v); + } + n += 1; + } + Ok(n) + })?; + Ok(json!({"keys": n})) +} + +/// Selected keys grouped by (layer, property). +fn keys_by_prop(s: &Session) -> BTreeMap<(LayerId, Uid), Vec> { + let mut m: BTreeMap<(LayerId, Uid), Vec> = BTreeMap::new(); + for k in &s.state.selected_keys { + m.entry((k.layer, k.prop)).or_default().push(k.time); + } + m +} + +fn exponential_scale(s: &mut Session, _: &Value) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let fd = comp.frame_duration(); + let groups = keys_by_prop(s); + let mut done = 0; + let mut work = vec![]; + for ((lid, uid), mut times) in groups { + let Some(l) = comp.layer(lid) else { continue }; + if l.transform().and_then(|tr| tr.get("scale")).map(|p| p.uid) != Some(uid) { + continue; + } + times.sort(); + if times.len() >= 2 { + work.push((lid, uid, times[0], *times.last().unwrap_or(×[0]))); + } + } + if work.is_empty() { + return Err(bad("keys.exponentialScale", "select two Scale keyframes")); + } + s.edit("Exponential Scale", None, |proj, st| { + for (lid, uid, a, b) in &work { + let l = layer_mut(proj, cid, *lid)?; + let Some(pr) = l.props.find_mut(*uid) else { continue }; + let (va, vb) = (pr.value_at(*a).as_vec3(), pr.value_at(*b).as_vec3()); + pr.keys.retain(|k| k.time <= *a || k.time >= *b); + let n = ((*b - *a).0 / fd.0.max(1)).max(1); + for i in 0..=n { + let u = i as f64 / n as f64; + let t = if i == n { *b } else { *a + Tick(fd.0 * i) }; + let f = |x: f64, y: f64| if x.abs() < 1e-9 || y.abs() < 1e-9 || x.signum() != y.signum() { x + (y - x) * u } else { x * (y / x).powf(u) }; + let v = [f(va[0], vb[0]), f(va[1], vb[1]), f(va[2], vb[2])]; + let mut k = Keyframe::new(t, KV::Vec3(v)); + k.in_interp = Interp::Linear; + k.out_interp = Interp::Linear; + effectcraft_keyframe::set_key(&mut pr.keys, k); + st.selected_keys.push(KeyRef { layer: *lid, prop: *uid, time: t }); + } + done += 1; + } + st.selected_keys.sort_by_key(|k| (k.layer.0, k.prop, k.time)); + st.selected_keys.dedup(); + Ok(()) + })?; + Ok(json!({"properties": done})) +} + +fn time_reverse_keys(s: &mut Session, _: &Value) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let groups = keys_by_prop(s); + let n = groups.len(); + s.edit("Time-Reverse Keyframes", None, |proj, st| { + st.selected_keys.clear(); + for ((lid, uid), times) in &groups { + let (Some(lo), Some(hi)) = (times.iter().min().copied(), times.iter().max().copied()) else { continue }; + let l = layer_mut(proj, cid, *lid)?; + let Some(pr) = l.props.find_mut(*uid) else { continue }; + let (mut moved, rest): (Vec, Vec) = pr.keys.drain(..).partition(|k| times.contains(&k.time)); + for k in &mut moved { + k.time = lo + hi - k.time; + std::mem::swap(&mut k.in_interp, &mut k.out_interp); + std::mem::swap(&mut k.in_ease, &mut k.out_ease); + std::mem::swap(&mut k.spatial_in, &mut k.spatial_out); + st.selected_keys.push(KeyRef { layer: *lid, prop: *uid, time: k.time }); + } + pr.keys = rest; + for k in moved { + effectcraft_keyframe::set_key(&mut pr.keys, k); + } + } + Ok(()) + })?; + Ok(json!({"properties": n})) +} + +// ---------------------------------------------------------------- text animators + +fn add_selector(s: &mut Session, p: &Value) -> Result { + let kind = str_p(p, "kind").unwrap_or("range"); + if kind != "range" { + return Err(bad("text.addSelector", "only Range selectors are available")); + } + let (cid, lid) = layer_p(s, p, "text.addSelector")?; + // Target: a selected animator (or anything inside one), else the last animator. + let sel: Vec = s.state.selected_props.iter().filter(|(l, _)| *l == lid).map(|(_, u)| *u).collect(); + let uid = s.edit("Add Text Selector", None, |proj, _| { + let mut next = proj.next_id; + let l = layer_mut(proj, cid, lid)?; + let anims = l.props.group_mut("text/animators").ok_or_else(|| bad("text.addSelector", "not a text layer"))?; + if anims.children.is_empty() { + return Err(bad("text.addSelector", "the layer has no text animators (use Animate Text first)")); + } + let idx = anims + .children + .iter() + .position(|c| c.as_group().is_some_and(|g| sel.contains(&g.uid) || sel.iter().any(|u| g.find(*u).is_some() || g.find_group(*u).is_some()))) + .unwrap_or(anims.children.len() - 1); + let Some(Node::Group(anim)) = anims.children.get_mut(idx) else { return Err(bad("text.addSelector", "no animator")) }; + let sels = anim.sub_mut("selectors").ok_or_else(|| bad("text.addSelector", "animator has no selectors group"))?; + let mut ids = Ids(&mut next); + let g = build::range_selector(&mut ids, &format!("Range Selector {}", sels.children.len() + 1)); + let uid = g.uid; + sels.children.push(g.into()); + proj.next_id = next; + Ok(uid) + })?; + Ok(json!({"selector": uid})) +} + +fn remove_all_animators(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + s.edit("Remove All Text Animators", None, |proj, _| { + for lid in &ids { + if let Some(a) = layer_mut(proj, cid, *lid)?.props.group_mut("text/animators") { + a.children.clear(); + } + } + Ok(()) + })?; + Ok(Value::Null) +} + +// ---------------------------------------------------------------- reveal + +/// Whether a static property differs from the value a fresh layer would have. Transform +/// defaults depend on the source size and comp size. +fn modified(pr: &Property, group: &str, default_anchor: [f64; 2], comp_center: [f64; 2]) -> bool { + if pr.is_animated() || pr.expr.is_some() { + return true; + } + let v = &pr.value; + let near = |a: &[f64], b: &[f64]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6); + match (group, pr.match_id.as_str()) { + ("transform", "anchor") => !near(&v.as_vec2(), &default_anchor), + ("transform", "position") => !near(&v.as_vec2(), &comp_center) || v.as_vec3()[2].abs() > 1e-6, + ("transform", "scale") => !near(&v.as_vec3(), &[100.0; 3]), + ("transform", "opacity") => (v.as_f64() - 100.0).abs() > 1e-6, + ("transform", "orientation") => !near(&v.as_vec3(), &[0.0; 3]), + ("transform", _) => v.as_f64().abs() > 1e-6, + _ => false, + } +} + +fn reveal(s: &mut Session, p: &Value) -> Result { + let kind = str_p(p, "kind").unwrap_or("keyframes").to_string(); + let cid = super::comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let layers: Vec = if s.state.selected_layers.is_empty() { comp.layers.iter().map(|l| l.id).collect() } else { s.state.selected_layers.clone() }; + let mut out = vec![]; + for lid in &layers { + let Some(l) = comp.layer(*lid) else { continue }; + let (w, h) = effectcraft_render::source_size(&s.project, l); + let anchor = [w as f64 / 2.0, h as f64 / 2.0]; + let center = [comp.width as f64 / 2.0, comp.height as f64 / 2.0]; + for top in l.props.groups() { + let hit = |pr: &Property| match kind.as_str() { + "keyframes" => pr.is_animated(), + "animation" => pr.is_animated() || pr.has_expression(), + _ => modified(pr, &top.match_id, anchor, center), + }; + let mut found = vec![]; + top.walk("", &mut |_, pr| { + if hit(pr) { + found.push(pr.uid); + } + }); + // Modified: effects and masks count as modifications in full (AE reveals them). + if kind == "modified" && matches!(top.match_id.as_str(), "effects" | "masks") { + for g in top.groups() { + found.push(g.uid); + } + } + if kind == "modified" && top.match_id == "text" { + for g in top.group("animators").into_iter().flat_map(|a| a.groups()) { + found.push(g.uid); + } + } + out.extend(found.into_iter().map(|u| json!({"layer": lid.0, "prop": u}))); + } + } + frontend(s, "timeline.revealProps", &json!({"props": out, "kind": kind}))?; + Ok(json!({"props": out})) +} + +/// Is `g` an effect/mask group kind (used by tests). +#[allow(dead_code)] +pub(crate) fn is_indexed(g: &PropGroup) -> bool { + matches!(g.kind, GroupKind::Indexed | GroupKind::Mask { .. } | GroupKind::Effect { .. }) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "anim.savePreset", + "Save Animation Preset...", + ["Animation"], + None, + "{path (.ecpreset), name?} — saves the selected properties/effects", + has_selected_props, + save_preset + ), + cmd!("anim.applyPreset", "Apply Animation Preset...", ["Animation"], None, "{path | preset, layers?}", has_layers, apply_preset), + cmd!("anim.addKeyframe", "Add Keyframe", ["Animation"], None, "{} — keys the selected properties at the CTI", has_selected_props, add_keyframe), + cmd!( + "keys.exponentialScale", + "Exponential Scale", + ["Animation", "Keyframe Assistant"], + None, + "{} — two selected Scale keys", + has_keys, + exponential_scale + ), + cmd!("keys.timeReverse", "Time-Reverse Keyframes", ["Animation", "Keyframe Assistant"], None, "{}", has_keys, time_reverse_keys), + cmd!("text.addSelector", "Add Text Selector", [], None, "{layer?, kind: range}", has_text_layer, add_selector), + cmd!("text.removeAllAnimators", "Remove All Text Animators", ["Animation"], None, "{layers?}", has_text_layer, remove_all_animators), + cmd!("anim.reveal", "Reveal Properties", [], None, "{kind: keyframes|animation|modified}", has_comp, reveal), + ] +} diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 07d34134..c066536d 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -202,7 +202,7 @@ pub fn specs() -> Vec { vec![ cmd!( "comp.new", - "New Composition…", + "New Composition...", ["Composition"], Some("Cmd+N"), "{name?, width?, height?, frameRate?, duration? (s), background? [r,g,b]|#hex, open?}", @@ -211,7 +211,7 @@ pub fn specs() -> Vec { ), cmd!( "comp.settings", - "Composition Settings…", + "Composition Settings...", ["Composition"], Some("Cmd+K"), "{comp?, name?, width?, height?, frameRate?, duration?, background?, shutterAngle?, shutterPhase?, motionBlurSamples?}", @@ -221,7 +221,7 @@ pub fn specs() -> Vec { cmd!("comp.setPosterTime", "Set Poster Time", ["Composition"], None, "{}", has_comp, poster), cmd!("comp.trimToWorkArea", "Trim Comp to Work Area", ["Composition"], Some("Cmd+Shift+X"), "{comp?}", has_comp, trim_to_wa), cmd!("comp.open", "Open Composition", [], None, "{comp: id|name}", always, open), - cmd!("comp.close", "Close Composition", [], Some("Cmd+W"), "{comp?}", has_comp, close), + cmd!("comp.close", "Close Composition", [], None, "{comp?}", has_comp, close), cmd!("comp.workArea", "Set Work Area", [], None, "{start?, end?, set?: begin|end (at CTI)}", has_comp, work_area), cmd!("comp.setSwitch", "Composition Switch", [], None, "{switch: hideShy|motionBlur|frameBlending|draft3d, value?}", has_comp, comp_switch), cmd!("comp.addMarker", "Add Marker", [], Some("Num*"), "{time?, comment?}", has_comp, add_marker), diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs new file mode 100644 index 00000000..6a066b5a --- /dev/null +++ b/crates/engine/src/commands/comp_more.rs @@ -0,0 +1,130 @@ +//! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File, +//! Responsive Design — Time. + +use effectcraft_keyframe::Value as KV; +use effectcraft_project::Marker; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_layers, layers_p, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +fn has_roi(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + if s.state.region_of_interest.is_some() { Ok(()) } else { Err("draw a region of interest in the viewer first".into()) } +} + +/// Crop the comp to `[x, y, w, h]`: the comp shrinks and every unparented layer moves by -x, -y +/// (all position keys too) so nothing moves on screen. +fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: &str) -> Result { + let (x, y) = (r[0].round(), r[1].round()); + let (w, h) = (r[2].round().max(1.0) as u32, r[3].round().max(1.0) as u32); + s.edit(label, None, |proj, st| { + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + c.width = w; + c.height = h; + for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { + let Some(pos) = l.transform_mut().and_then(|tr| tr.get_mut("position")) else { continue }; + let mv = |v: &KV| { + let a = v.as_vec3(); + KV::Vec3([a[0] - x, a[1] - y, a[2]]) + }; + pos.value = mv(&pos.value); + for k in &mut pos.keys { + k.value = mv(&k.value); + } + } + for g in &mut c.guides { + g.position -= if g.vertical { x } else { y }; + } + st.region_of_interest = None; + Ok(()) + })?; + Ok(json!({"width": w, "height": h, "offset": [x, y]})) +} + +fn crop_to_roi(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let r = s.state.region_of_interest.ok_or_else(|| bad("comp.cropToRegionOfInterest", "no region of interest"))?; + crop(s, cid, r, "Crop Comp to Region of Interest") +} + +fn crop_to_layers(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let t = s.time(); + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, t); + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { + let Some([x0, y0, x1, y1]) = effectcraft_render::content_bounds(&ctx, l) else { continue }; + let (m, _) = ctx.layer_to_comp(l); + for (px, py) in [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] { + let v = m.0; + let wz = v[2][0] * px + v[2][1] * py + v[2][2]; + let wz = if wz.abs() < 1e-9 { 1.0 } else { wz }; + let cx = (v[0][0] * px + v[0][1] * py + v[0][2]) / wz; + let cy = (v[1][0] * px + v[1][1] * py + v[1][2]) / wz; + b = [b[0].min(cx), b[1].min(cy), b[2].max(cx), b[3].max(cy)]; + } + } + if b[0] > b[2] { + return Err(bad("comp.cropToLayerBounds", "the selected layers have no visible bounds")); + } + let (x0, y0) = (b[0].floor(), b[1].floor()); + crop(s, cid, [x0, y0, b[2].ceil() - x0, b[3].ceil() - y0], "Crop Comp to Selected Layer(s) Bounds") +} + +/// Encode straight 8-bit RGBA as PNG. +pub(crate) fn encode_png(rgba: &[u8], w: u32, h: u32) -> std::result::Result, String> { + use image::ImageEncoder; + let mut out = vec![]; + image::codecs::png::PngEncoder::new(&mut out).write_image(rgba, w, h, image::ExtendedColorType::Rgba8).map_err(|e| e.to_string())?; + Ok(out) +} + +fn save_frame(s: &mut Session, p: &Value) -> Result { + let path = str_p(p, "path").ok_or_else(|| bad("comp.saveFrameAs", "missing `path` (.png)"))?.to_string(); + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(s.time()); + let scale = f_p(p, "scale").unwrap_or(1.0).clamp(0.01, 4.0); + let bg = comp.background; + let img = s.render(cid, t, effectcraft_render::RenderOpts { scale, ..Default::default() }); + let rgba = img.to_rgba8_over(bg); + let png = encode_png(&rgba, img.width, img.height).map_err(|e| EngineError::Other(format!("PNG encoding failed: {e}")))?; + s.services.write_file(&path, &png).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?; + s.toast(format!("Saved frame to {path}")); + Ok(json!({"path": path, "width": img.width, "height": img.height, "time": t.seconds()})) +} + +/// Responsive Design — Time: protected comp markers for an intro, an outro or the work area. +fn responsive_time(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = s.time(); + let (start, end, comment) = match str_p(p, "op").unwrap_or("workArea") { + "intro" => (Tick::ZERO, t, "Intro"), + "outro" => (t, comp.duration, "Outro"), + "workArea" => (comp.work_area.0, comp.work_area.1, "Protected Region"), + o => return Err(bad("comp.responsiveTime", format!("op: intro|outro|workArea, not `{o}`"))), + }; + if end <= start { + return Err(bad("comp.responsiveTime", "move the current time first (the region would be empty)")); + } + s.edit("Create Protected Region", None, |proj, _| { + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + c.markers.push(Marker { time: start, duration: end - start, comment: comment.into(), protected: true, ..Default::default() }); + c.markers.sort_by_key(|m| m.time); + Ok(()) + })?; + Ok(json!({"start": start.seconds(), "end": end.seconds()})) +} + +pub fn specs() -> Vec { + vec![ + cmd!("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", ["Composition"], None, "{comp?}", has_roi, crop_to_roi), + cmd!("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", ["Composition"], None, "{layers?}", has_layers, crop_to_layers), + cmd!("comp.saveFrameAs", "File...", ["Composition", "Save Frame As"], Some("Cmd+Alt+S"), "{path (.png), time?, scale?}", has_comp, save_frame), + cmd!("comp.responsiveTime", "Responsive Design — Time", [], None, "{op: intro|outro|workArea}", has_comp, responsive_time), + ] +} diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index b68df62d..4e48c845 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -1,11 +1,12 @@ //! Edit menu. use effectcraft_color::Label; -use effectcraft_project::{Layer, LayerId}; +use effectcraft_project::{Expression, ItemKind, Layer, LayerId, LayerSource}; +use effectcraft_time::Tick; use serde_json::{Value, json}; -use super::{CommandSpec, has_comp, has_layers, layers_p, str_p}; -use crate::{Result, Session, cmd}; +use super::{CommandSpec, has_comp, has_layers, layers_p, match_path_of, selected_leaf_props, str_p}; +use crate::{EngineError, KeyClip, LinkClip, Result, Session, cmd}; fn can_undo(s: &Session) -> std::result::Result<(), String> { if s.history.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) } @@ -15,7 +16,25 @@ fn can_redo(s: &Session) -> std::result::Result<(), String> { } fn has_clip(s: &Session) -> std::result::Result<(), String> { has_comp(s)?; - if s.state.clipboard.is_empty() { Err("the clipboard is empty".into()) } else { Ok(()) } + if s.state.clipboard.is_empty() && s.state.key_clipboard.is_empty() && s.state.link_clipboard.is_none() { + Err("the clipboard is empty".into()) + } else { + Ok(()) + } +} +fn has_key_clip(s: &Session) -> std::result::Result<(), String> { + has_layers(s)?; + if s.state.key_clipboard.is_empty() { Err("no keyframes on the clipboard".into()) } else { Ok(()) } +} +fn has_props_or_layers(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + if s.state.selected_layers.is_empty() && s.state.selected_props.is_empty() { Err("select layers or properties first".into()) } else { Ok(()) } +} +fn has_props_or_layers_or_items(s: &Session) -> std::result::Result<(), String> { + if has_props_or_layers(s).is_ok() || !s.state.project_selection.is_empty() { Ok(()) } else { Err("select layers or project items first".into()) } +} +fn has_selected_footage(s: &Session) -> std::result::Result<(), String> { + if original_path(s).is_some() { Ok(()) } else { Err("select a footage item or layer".into()) } } fn undo(s: &mut Session, _: &Value) -> Result { @@ -96,19 +115,215 @@ fn delete(s: &mut Session, p: &Value) -> Result { Ok(json!(ids.len())) } +fn clear_clipboards(s: &mut Session) { + s.state.clipboard.clear(); + s.state.key_clipboard.clear(); + s.state.link_clipboard = None; +} + +/// Edit ▸ Copy: selected keyframes (when any) go to the keyframe clipboard, else layers. fn copy(s: &mut Session, p: &Value) -> Result { + if !s.state.selected_keys.is_empty() && p.get("layers").is_none() && p.get("layer").is_none() { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let mut clips: Vec = vec![]; + let earliest = s.state.selected_keys.iter().map(|k| k.time).min().unwrap_or(Tick::ZERO); + for k in &s.state.selected_keys { + let Some(l) = comp.layer(k.layer) else { continue }; + let (Some(pr), Some(path)) = (l.props.find(k.prop), match_path_of(&l.props, k.prop)) else { continue }; + let Some(key) = pr.keys.iter().find(|x| x.time == k.time) else { continue }; + let mut key = key.clone(); + key.time -= earliest; + match clips.iter_mut().find(|c| c.path == path) { + Some(c) => c.keys.push(key), + None => clips.push(KeyClip { path, keys: vec![key] }), + } + } + for c in &mut clips { + c.keys.sort_by_key(|k| k.time); + } + let n: usize = clips.iter().map(|c| c.keys.len()).sum(); + clear_clipboards(s); + s.state.key_clipboard = clips; + return Ok(json!({"keys": n})); + } let (cid, ids) = layers_p(s, p)?; let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?; - s.state.clipboard = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect(); + let layers: Vec = comp.layers.iter().filter(|l| ids.contains(&l.id)).cloned().collect(); + clear_clipboards(s); + s.state.clipboard = layers; Ok(json!(s.state.clipboard.len())) } +/// The expression accessor for a property, e.g. `comp("Main").layer("Solid").transform("Position")`. +pub(crate) fn link_expression(s: &Session, lid: LayerId, uid: effectcraft_project::Uid, relative: bool) -> Option { + let cid = s.active_comp_id()?; + let comp = s.project.comp(cid)?; + let l = comp.layer(lid)?; + let names = l.props.name_path_of(uid)?; + let matches = match_path_of(&l.props, uid)?; + let names: Vec<&str> = names.split('/').collect(); + let first = matches.split('/').next()?.to_string(); + let q = |x: &str| serde_json::to_string(x).unwrap_or_default(); + let base = if relative { format!("thisComp.layer({})", q(&l.name)) } else { format!("comp({}).layer({})", q(&s.project.item(cid)?.name), q(&l.name)) }; + let mut out = match first.as_str() { + "transform" => format!("{base}.transform"), + "effects" => format!("{base}.effect({})", q(names.get(1)?)), + "masks" => format!("{base}.mask({})", q(names.get(1)?)), + "text" => format!("{base}.text"), + "contents" => format!("{base}.content({})", q(names.get(1)?)), + _ => return None, + }; + let skip = if matches!(first.as_str(), "effects" | "masks" | "contents") { 2 } else { 1 }; + for n in names.iter().skip(skip) { + out.push_str(&format!("({})", q(n))); + } + Some(out) +} + +fn copy_links(s: &mut Session, p: &Value, relative: bool) -> Result { + let props = selected_leaf_props(s); + if props.is_empty() { + // Layers: copy them with every animatable transform/effect property linked to the source. + let (cid, ids) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let mut layers = vec![]; + for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { + let mut copy = l.clone(); + let mut uids = vec![]; + for g in ["transform", "effects"] { + if let Some(grp) = l.props.sub(g) { + grp.walk("", &mut |_, pr| { + if !pr.static_only && !pr.hold_only { + uids.push(pr.uid); + } + }); + } + } + for uid in uids { + if let (Some(e), Some(pr)) = (link_expression(s, l.id, uid, relative), copy.props.find_mut(uid)) { + pr.expr = Some(Expression { text: e, enabled: true }); + } + } + layers.push(copy); + } + let n = layers.len(); + clear_clipboards(s); + s.state.clipboard = layers; + return Ok(json!({"layers": n})); + } + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let mut links = vec![]; + for (lid, uid) in &props { + let Some(path) = comp.layer(*lid).and_then(|l| match_path_of(&l.props, *uid)) else { continue }; + if let Some(e) = link_expression(s, *lid, *uid, relative) { + links.push((path, e)); + } + } + let n = links.len(); + clear_clipboards(s); + s.state.link_clipboard = Some(LinkClip::Links { relative, links }); + Ok(json!({"links": n})) +} + +fn copy_expression_only(s: &mut Session, _: &Value) -> Result { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let mut out = vec![]; + for (lid, uid) in selected_leaf_props(s) { + let Some(l) = comp.layer(lid) else { continue }; + if let (Some(pr), Some(path)) = (l.props.find(uid), match_path_of(&l.props, uid)) + && let Some(e) = &pr.expr + { + out.push((path, e.clone())); + } + } + if out.is_empty() { + return Err(EngineError::Other("the selected properties have no expressions".into())); + } + let n = out.len(); + clear_clipboards(s); + s.state.link_clipboard = Some(LinkClip::Expressions(out)); + Ok(json!({"expressions": n})) +} + +/// Paste the keyframe clipboard at the CTI onto the selected layers (same property paths). +fn paste_keys(s: &mut Session, reversed: bool) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let ids = s.state.selected_layers.clone(); + if ids.is_empty() { + return Err(EngineError::Other("select a layer to paste keyframes into".into())); + } + let clip = s.state.key_clipboard.clone(); + let t = s.time(); + let n = s.edit(if reversed { "Paste Reversed Keyframes" } else { "Paste Keyframes" }, None, |proj, st| { + let mut n = 0; + st.selected_keys.clear(); + for lid in &ids { + let l = super::layer_mut(proj, cid, *lid)?; + let lt = l.layer_time(t); + for c in &clip { + let Some(pr) = l.props.prop_mut(&c.path) else { continue }; + let span = c.keys.last().map(|k| k.time).unwrap_or(Tick::ZERO); + for k in &c.keys { + let mut k = k.clone(); + if std::mem::discriminant(&k.value) != std::mem::discriminant(&pr.value) { + continue; + } + if reversed { + k.time = span - k.time; + std::mem::swap(&mut k.in_interp, &mut k.out_interp); + std::mem::swap(&mut k.in_ease, &mut k.out_ease); + std::mem::swap(&mut k.spatial_in, &mut k.spatial_out); + } + k.time += lt; + st.selected_keys.push(crate::KeyRef { layer: *lid, prop: pr.uid, time: k.time }); + effectcraft_keyframe::set_key(&mut pr.keys, k); + n += 1; + } + } + } + Ok(n) + })?; + Ok(json!({"keys": n})) +} + +fn paste_links(s: &mut Session, clip: LinkClip) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let ids = s.state.selected_layers.clone(); + if ids.is_empty() { + return Err(EngineError::Other("select a layer to paste into".into())); + } + let pairs: Vec<(String, Expression)> = match clip { + LinkClip::Links { links, .. } => links.into_iter().map(|(p, e)| (p, Expression { text: e, enabled: true })).collect(), + LinkClip::Expressions(v) => v, + }; + let n = s.edit("Paste", None, |proj, _| { + let mut n = 0; + for lid in &ids { + let l = super::layer_mut(proj, cid, *lid)?; + for (path, e) in &pairs { + if let Some(pr) = l.props.prop_mut(path) { + pr.expr = Some(e.clone()); + n += 1; + } + } + } + Ok(n) + })?; + Ok(json!({"expressions": n})) +} + fn cut(s: &mut Session, p: &Value) -> Result { copy(s, p)?; delete(s, p) } fn paste(s: &mut Session, p: &Value) -> Result { + if let Some(clip) = s.state.link_clipboard.clone() { + return paste_links(s, clip); + } + if !s.state.key_clipboard.is_empty() { + return paste_keys(s, false); + } let cid = super::comp_id(s, p)?; let clip = s.state.clipboard.clone(); let new = s.edit("Paste", None, |proj, st| { @@ -180,6 +395,123 @@ fn label(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +fn paste_reversed(s: &mut Session, _: &Value) -> Result { + paste_keys(s, true) +} + +/// Lift (leave a gap) or extract (close the gap) the work area from the selected layers (all +/// unlocked layers when none are selected). +fn work_area_cut(s: &mut Session, p: &Value, extract: bool) -> Result { + let cid = super::comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let (a, b) = comp.work_area; + let ids: Vec = match layers_p(s, p)?.1 { + v if v.is_empty() => comp.layers.iter().map(|l| l.id).collect(), + v => v, + }; + let gap = b - a; + s.edit(if extract { "Extract Work Area" } else { "Lift Work Area" }, None, |proj, st| { + let mut next = proj.next_id; + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let shift = |l: &mut Layer, d: Tick| { + l.start_time += d; + l.in_point += d; + l.out_point += d; + }; + let mut i = 0; + while i < c.layers.len() { + if !ids.contains(&c.layers[i].id) || c.layers[i].switches.locked { + i += 1; + continue; + } + let (lin, lout) = (c.layers[i].in_point, c.layers[i].out_point); + if lout <= a { + i += 1; + } else if lin >= b { + if extract { + shift(&mut c.layers[i], Tick::ZERO - gap); + } + i += 1; + } else if lin >= a && lout <= b { + c.layers.remove(i); + } else if lin < a && lout > b { + // Split around the work area: the layer keeps the head, a copy takes the tail. + let mut tail = c.layers[i].clone(); + reid(&mut tail, &mut next); + tail.in_point = b; + if extract { + shift(&mut tail, Tick::ZERO - gap); + } + c.layers[i].out_point = a; + c.layers.insert(i, tail); + i += 2; + } else if lin < a { + c.layers[i].out_point = a; + i += 1; + } else { + c.layers[i].in_point = b; + if extract { + shift(&mut c.layers[i], Tick::ZERO - gap); + } + i += 1; + } + } + let alive: Vec = c.layers.iter().map(|l| l.id).collect(); + st.selected_layers.retain(|l| alive.contains(l)); + proj.next_id = next; + Ok(()) + })?; + Ok(Value::Null) +} + +fn lift(s: &mut Session, p: &Value) -> Result { + work_area_cut(s, p, false) +} +fn extract(s: &mut Session, p: &Value) -> Result { + work_area_cut(s, p, true) +} + +fn select_label_group(s: &mut Session, _: &Value) -> Result { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let labels: Vec