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/src/commands/anim.rs b/crates/engine/src/commands/anim.rs new file mode 100644 index 00000000..f3560ccd --- /dev/null +++ b/crates/engine/src/commands/anim.rs @@ -0,0 +1,414 @@ +//! Keyframe clipboard, selection, nudging, direct key/ease edits (Graph Editor handles) and key +//! inspection for the Keyframe Velocity / Keyframe Interpolation dialogs. + +use effectcraft_keyframe::{Ease, Interp, Keyframe, key_at, retime_roving, side_ease}; +use effectcraft_project::{LayerId, Property, Uid}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::prop::{edit_keys, prop_ref}; +use super::{CommandSpec, bad, comp_id, f_p, has_comp, has_keys, layers_p, merge_p, str_p, time_p}; +use crate::{EngineError, KeyClip, KeyRef, Result, Session, cmd, query}; + +fn has_key_clip(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + if s.state.key_clipboard.is_empty() { Err("no keyframes have been copied".into()) } else { Ok(()) } +} + +/// Copy the selected keyframes (Edit ▸ Copy with keys selected). +fn copy(s: &mut Session, _: &Value) -> Result { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); + for k in &s.state.selected_keys { + groups.entry((k.layer, k.prop)).or_default().push(k.time); + } + // Earliest selected key in comp time: pasted keys keep their offsets from it. + let mut earliest: Option = None; + for ((lid, _), times) in &groups { + if let Some(l) = comp.layer(*lid) { + for t in times { + let ct = l.comp_time(*t); + earliest = Some(earliest.map_or(ct, |e: Tick| e.min(ct))); + } + } + } + let earliest = earliest.ok_or_else(|| bad("keys.copy", "select keyframes first"))?; + let mut clip = vec![]; + for ((lid, uid), times) in groups { + let Some(l) = comp.layer(lid) else { continue }; + let Some(pr) = l.props.find(uid) else { continue }; + let Some(path) = l.props.match_path_of(uid) else { continue }; + let mut keys: Vec = pr.keys.iter().filter(|k| times.contains(&k.time)).cloned().collect(); + for k in &mut keys { + k.time = l.comp_time(k.time) - earliest; + } + clip.push(KeyClip { layer: lid, path, keys }); + } + let n: usize = clip.iter().map(|c| c.keys.len()).sum(); + s.state.key_clipboard = clip; + s.state.clip_is_keys = true; + Ok(json!(n)) +} + +/// Values of the same kind (and dimensionality) can receive each other's keys. +fn compatible(a: &Property, b: &effectcraft_keyframe::Value) -> bool { + a.value.kind_name() == b.kind_name() && a.value.components().len() == b.components().len() +} + +/// Paste copied keyframes at the current time into the target layers' properties. +fn paste(s: &mut Session, p: &Value) -> Result { + let clip = s.state.key_clipboard.clone(); + if clip.is_empty() { + return Err(bad("keys.paste", "no keyframes have been copied")); + } + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let t = time_p(s, p, Some(comp)); + let (_, mut targets) = layers_p(s, p)?; + if targets.is_empty() { + targets = clip.iter().map(|c| c.layer).collect(); + targets.dedup(); + } + let explicit = p.get("prop").and_then(Value::as_u64).or_else(|| { + let path = str_p(p, "path")?; + comp.layer(*targets.first()?)?.props.prop(path).map(|pr| pr.uid) + }); + // (layer, prop uid, clip index) + let mut plan: Vec<(LayerId, Uid, usize)> = vec![]; + for lid in &targets { + let Some(l) = comp.layer(*lid) else { continue }; + for (ci, c) in clip.iter().enumerate() { + let Some(sample) = c.keys.first().map(|k| &k.value) else { continue }; + let target = if clip.len() == 1 { + explicit + .and_then(|u| l.props.find(u)) + .or_else(|| { + s.state.selected_props.iter().filter(|(sl, _)| sl == lid).filter_map(|(_, u)| l.props.find(*u)).find(|pr| compatible(pr, sample)) + }) + .or_else(|| l.props.prop(&c.path)) + } else { + l.props.prop(&c.path) + }; + if let Some(pr) = target.filter(|pr| compatible(pr, sample) && !pr.static_only) { + plan.push((*lid, pr.uid, ci)); + } + } + } + if plan.is_empty() { + return Err(bad("keys.paste", "no compatible property to paste into (select a property of the same type)")); + } + s.edit("Paste Keyframes", None, |proj, st| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let mut sel = vec![]; + for (lid, uid, ci) in &plan { + let Some(l) = comp.layer_mut(*lid) else { continue }; + let times: Vec = clip[*ci].keys.iter().map(|k| l.layer_time(t + k.time)).collect(); + let Some(pr) = l.props.find_mut(*uid) else { continue }; + for (k, lt) in clip[*ci].keys.iter().zip(times) { + let mut k = k.clone(); + k.time = lt; + if let Some(v) = pr.value.coerce_json(&k.value.to_json()) { + k.value = v; + } + if pr.hold_only { + k = k.hold(); + } + match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) { + Ok(i) => pr.keys[i] = k, + Err(i) => pr.keys.insert(i, k), + } + sel.push(KeyRef { layer: *lid, prop: *uid, time: lt }); + } + retime_roving(&mut pr.keys, pr.spatial); + } + st.selected_keys = sel; + Ok(json!(st.selected_keys.len())) + }) +} + +/// Select every keyframe of the selected properties, or of the given / selected layers. +fn select_all(s: &mut Session, p: &Value) -> Result { + let (cid, mut layers) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let mut sel = vec![]; + if p.get("layers").is_none() && p.get("layer").is_none() && !s.state.selected_props.is_empty() { + for (lid, uid) in &s.state.selected_props { + if let Some(pr) = comp.layer(*lid).and_then(|l| l.props.find(*uid)) { + sel.extend(pr.keys.iter().map(|k| KeyRef { layer: *lid, prop: *uid, time: k.time })); + } + } + } else { + if layers.is_empty() { + layers = comp.layers.iter().map(|l| l.id).collect(); + } + for l in comp.layers.iter().filter(|l| layers.contains(&l.id)) { + l.props.walk("", &mut |_, pr| sel.extend(pr.keys.iter().map(|k| KeyRef { layer: l.id, prop: pr.uid, time: k.time }))); + } + } + s.state.selected_keys = sel; + Ok(json!(s.state.selected_keys.len())) +} + +/// Move the selected keys by whole frames (Alt+→ / Alt+←, Shift for 10 frames). +fn nudge(s: &mut Session, p: &Value) -> Result { + let frames = p.get("frames").and_then(Value::as_i64).ok_or_else(|| bad("keys.nudge", "missing `frames`"))?; + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let d = Tick(comp.frame_duration().0 * frames); + edit_keys(s, "Nudge Keyframes", merge_p(p), move |_, _, t| { + *t += d; + true + }) +} + +/// Edit one key directly: move it in time and/or set its value (Graph Editor drags). +fn set_key(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = prop_ref(s, p, "keys.set")?; + let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.set", "missing `time` (layer time of the key)"))?); + let new_t = f_p(p, "newTime").map(Tick::from_seconds_f64); + let value = p.get("value").cloned(); + let fr = s.project.comp(cid).map(|c| c.frame_rate); + s.edit("Edit Keyframe", merge_p(p), |proj, st| { + let l = super::layer_mut(proj, cid, lid)?; + let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.set", "no property"))?; + let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.set", "the property has no keyframes"))?; + let old_t = pr.keys[i].time; + if let Some(v) = &value { + let nv = pr.keys[i].value.coerce_json(v).ok_or_else(|| bad("keys.set", format!("can't use {v} here")))?; + pr.keys[i].value = nv; + } + let mut nt = old_t; + if let Some(n) = new_t { + nt = fr.map(|r| r.snap_nearest(n)).unwrap_or(n); + // Keys can't pass their neighbours or share a time. + let lo = if i > 0 { pr.keys[i - 1].time.0 + 1 } else { i64::MIN }; + let hi = if i + 1 < pr.keys.len() { pr.keys[i + 1].time.0 - 1 } else { i64::MAX }; + nt = Tick(nt.0.clamp(lo, hi)); + pr.keys[i].time = nt; + } + retime_roving(&mut pr.keys, pr.spatial); + let nt = pr.keys.get(i).map(|k| k.time).unwrap_or(nt); + for k in st.selected_keys.iter_mut().filter(|k| k.layer == lid && k.prop == uid && k.time == old_t) { + k.time = nt; + } + Ok(json!({"time": nt.seconds()})) + }) +} + +fn nearest_key(keys: &[Keyframe], t: Tick) -> Option { + if let Some(i) = key_at(keys, t) { + return Some(i); + } + keys.iter().enumerate().min_by_key(|(_, k)| (k.time.0 - t.0).abs()).map(|(i, _)| i) +} + +/// Set one side's temporal ease of a key (Graph Editor handle drags, scripting). +fn set_ease(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = prop_ref(s, p, "keys.setEase")?; + let t = Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| bad("keys.setEase", "missing `time`"))?); + let out = match str_p(p, "side") { + Some("in") => false, + Some("out") => true, + _ => return Err(bad("keys.setEase", "`side` must be in or out")), + }; + let dim = p.get("dim").and_then(Value::as_u64).map(|d| d as usize); + let speed = f_p(p, "speed"); + let influence = f_p(p, "influence").map(|v| (v / 100.0).clamp(0.001, 1.0)); + s.edit("Keyframe Velocity", merge_p(p), |proj, _| { + let l = super::layer_mut(proj, cid, lid)?; + let pr = l.props.find_mut(uid).ok_or_else(|| bad("keys.setEase", "no property"))?; + let i = nearest_key(&pr.keys, t).ok_or_else(|| bad("keys.setEase", "no keyframes"))?; + let spatial = pr.spatial; + let n = if spatial && matches!(pr.keys[i].value, effectcraft_keyframe::Value::Vec2(_) | effectcraft_keyframe::Value::Vec3(_)) { + 1 + } else { + pr.keys[i].value.dims().max(1) + }; + // Start from what the side presents now so untouched dimensions keep their shape. + let cur: Vec = (0..n).map(|d| side_ease(&pr.keys, i, d, spatial, out).unwrap_or_default()).collect(); + let k = &mut pr.keys[i]; + let list = if out { &mut k.out_ease } else { &mut k.in_ease }; + if list.len() != n { + *list = cur.clone(); + } + for (d, e) in list.iter_mut().enumerate() { + if dim.is_some_and(|x| x != d) && n > 1 { + continue; + } + if let Some(v) = speed { + e.speed = v; + } + if let Some(v) = influence { + e.influence = v; + } + } + if out { + k.out_interp = Interp::Bezier; + } else { + k.in_interp = Interp::Bezier; + } + k.auto_bezier = false; + // Continuous Bezier keys keep matching speeds on both sides. + if k.continuous { + let src = if out { k.out_ease.clone() } else { k.in_ease.clone() }; + let other = if out { &mut k.in_ease } else { &mut k.out_ease }; + if other.len() != src.len() { + *other = src.clone(); + } + for (o, sv) in other.iter_mut().zip(&src) { + o.speed = sv.speed; + } + k.in_interp = Interp::Bezier; + k.out_interp = Interp::Bezier; + } + Ok(Value::Null) + }) +} + +/// Speed units for a property (Keyframe Velocity dialog). +fn units(pr: &Property) -> &'static str { + use effectcraft_project::ParamUi; + if pr.spatial { + return "pixels/sec"; + } + match pr.ui { + ParamUi::Angle => "degrees/sec", + ParamUi::Percent => "%/sec", + ParamUi::Slider { .. } if pr.match_id == "opacity" => "%/sec", + _ if pr.match_id == "opacity" => "%/sec", + ParamUi::Pixels => "pixels/sec", + _ => "units/sec", + } +} + +fn interp_name(i: Interp) -> &'static str { + match i { + Interp::Linear => "linear", + Interp::Bezier => "bezier", + Interp::Hold => "hold", + } +} + +/// Describe the selected (or given) keys: interpolation, eases per side and dimension, units. +fn info(s: &mut Session, p: &Value) -> Result { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let refs: Vec = match p.get("keys") { + Some(Value::Array(a)) => a + .iter() + .filter_map(|k| { + let l = super::resolve_layer(comp, k.get("layer")?)?; + Some(KeyRef { layer: l, prop: k.get("prop")?.as_u64()?, time: Tick::from_seconds_f64(k.get("time")?.as_f64()?) }) + }) + .collect(), + _ => s.state.selected_keys.clone(), + }; + let mut out = vec![]; + for r in refs { + let Some(l) = comp.layer(r.layer) else { continue }; + let Some(pr) = l.props.find(r.prop) else { continue }; + let Some(i) = nearest_key(&pr.keys, r.time) else { continue }; + let k = &pr.keys[i]; + let spatial = pr.spatial && matches!(k.value, effectcraft_keyframe::Value::Vec2(_) | effectcraft_keyframe::Value::Vec3(_)); + let dims = if spatial { 1 } else { k.value.dims().max(1).min(if pr.shown_dims > 0 && !l.is_3d() { pr.shown_dims as usize } else { 4 }) }; + let side = |out: bool| -> Value { + json!( + (0..dims) + .map(|d| side_ease(&pr.keys, i, d, pr.spatial, out) + .map(|e| json!({"speed": e.speed, "influence": e.influence * 100.0})) + .unwrap_or(Value::Null)) + .collect::>() + ) + }; + let temporal = if k.auto_bezier { + "autoBezier" + } else if k.continuous { + "continuousBezier" + } else if k.in_interp == k.out_interp { + interp_name(k.in_interp) + } else { + "mixed" + }; + let spatial_kind = if !spatial { + Value::Null + } else if k.spatial_auto { + json!("autoBezier") + } else if k.spatial_continuous { + json!("continuousBezier") + } else if k.spatial_in == [0.0; 3] && k.spatial_out == [0.0; 3] { + json!("linear") + } else { + json!("bezier") + }; + out.push(json!({ + "layer": l.id.0, "prop": pr.uid, "name": pr.name, "path": l.props.match_path_of(pr.uid), + "time": k.time.seconds(), "compTime": l.comp_time(k.time).seconds(), "value": k.value.to_json(), + "in": interp_name(k.in_interp), "out": interp_name(k.out_interp), "temporal": temporal, + "autoBezier": k.auto_bezier, "continuous": k.continuous, "roving": k.roving, "spatial": spatial_kind, + "dims": dims, "units": units(pr), "inEase": side(false), "outEase": side(true), + })); + } + Ok(json!(out)) +} + +/// Keyframe Assistant ▸ Time-Reverse Keyframes: mirror the selected keys of each property. +fn time_reverse(s: &mut Session, _: &Value) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let sel = s.state.selected_keys.clone(); + s.edit("Time-Reverse Keyframes", None, |proj, st| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec> = Default::default(); + for k in &sel { + groups.entry((k.layer, k.prop)).or_default().push(k.time); + } + let mut new_sel = vec![]; + for ((lid, uid), times) in groups { + let Some(pr) = comp.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) else { continue }; + let (mut picked, rest): (Vec, Vec) = pr.keys.drain(..).partition(|k| times.contains(&k.time)); + effectcraft_keyframe::time_reverse(&mut picked); + new_sel.extend(picked.iter().map(|k| KeyRef { layer: lid, prop: uid, time: k.time })); + pr.keys = rest; + for k in picked { + match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) { + Ok(i) => pr.keys[i] = k, + Err(i) => pr.keys.insert(i, k), + } + } + } + st.selected_keys = new_sel; + Ok(json!(st.selected_keys.len())) + }) +} + +fn keys_or_layers(s: &Session) -> std::result::Result<(), String> { + has_comp(s) +} + +pub fn specs() -> Vec { + vec![ + cmd!("keys.copy", "Copy Keyframes", [], None, "{}", has_keys, copy), + cmd!("keys.paste", "Paste Keyframes", [], None, "{layers?, prop?|path?, time?}", has_key_clip, paste), + cmd!("keys.selectAll", "Select All Keyframes", ["Edit"], Some("Cmd+Alt+A"), "{layers?}", keys_or_layers, select_all), + cmd!("keys.nudge", "Nudge Keyframes", [], None, "{frames, merge?}", has_keys, nudge), + cmd!("keys.nudgeForward", "Move Keyframes 1 Frame Later", [], Some("Alt+ArrowRight"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": 1}))), + cmd!("keys.nudgeBackward", "Move Keyframes 1 Frame Earlier", [], Some("Alt+ArrowLeft"), "{}", has_keys, |s, _| nudge(s, &json!({"frames": -1}))), + cmd!("keys.nudgeForward10", "Move Keyframes 10 Frames Later", [], Some("Alt+Shift+ArrowRight"), "{}", has_keys, |s, _| nudge( + s, + &json!({"frames": 10}) + )), + cmd!("keys.nudgeBackward10", "Move Keyframes 10 Frames Earlier", [], Some("Alt+Shift+ArrowLeft"), "{}", has_keys, |s, _| nudge( + s, + &json!({"frames": -10}) + )), + cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key), + cmd!( + "keys.setEase", + "Set Keyframe Ease", + [], + None, + "{layer?, path|prop, time (layer s), side: in|out, dim?, speed?, influence? %, merge?}", + has_comp, + set_ease + ), + cmd!("keys.timeReverse", "Time-Reverse Keyframes", ["Animation", "Keyframe Assistant"], None, "{}", has_keys, time_reverse), + query!("keys.info", "Keyframe Info", "{keys?: [{layer, prop, time}]}", info), + ] +} diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 8b30025b..f00b9b51 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -62,7 +62,8 @@ fn apply_settings(c: &mut Comp, p: &Value) { } if let Some(d) = f_p(p, "duration") { let old = c.duration; - c.duration = Tick::from_seconds_f64(d.max(c.frame_rate.frame_duration().seconds())); + // Whole frames, at least one. + c.duration = c.frame_rate.snap_nearest(Tick::from_seconds_f64(d)).max(c.frame_rate.frame_duration()); if c.work_area.1 == old { c.work_area.1 = c.duration; } @@ -91,7 +92,7 @@ fn apply_settings(c: &mut Comp, p: &Value) { fn new_comp(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "name").unwrap_or("Comp 1").to_string(); let rate = rate_p(p).unwrap_or(FrameRate::FPS_29_97); - let mut c = Comp::new(1920, 1080, rate, Tick::from_seconds_f64(10.0)); + let mut c = Comp::new(1920, 1080, rate, rate.snap_nearest(Tick::from_seconds_f64(10.0))); apply_settings(&mut c, p); let id = s.edit("New Composition", None, |proj, st| { let mut name = name.clone(); diff --git a/crates/engine/src/commands/edit.rs b/crates/engine/src/commands/edit.rs index b68df62d..027c23b6 100644 --- a/crates/engine/src/commands/edit.rs +++ b/crates/engine/src/commands/edit.rs @@ -15,7 +15,7 @@ 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() { Err("the clipboard is empty".into()) } else { Ok(()) } } fn undo(s: &mut Session, _: &Value) -> Result { @@ -39,6 +39,7 @@ fn deselect_all(s: &mut Session, _: &Value) -> Result { s.state.selected_layers.clear(); s.state.selected_props.clear(); s.state.selected_keys.clear(); + s.state.selected_vertices.clear(); Ok(Value::Null) } @@ -72,10 +73,13 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { } fn delete(s: &mut Session, p: &Value) -> Result { - // Keyframes selected → delete keys; else layers. + // Keyframes selected → delete keys; mask vertices → delete them; else layers. if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { return s.execute("keys.delete", json!({})); } + if !s.state.selected_vertices.is_empty() && p.get("layers").is_none() { + return s.execute("mask.deleteVertices", json!({})); + } let (cid, ids) = layers_p(s, p)?; s.edit("Clear", None, |proj, st| { let comp = proj.comp_mut(cid).ok_or(crate::EngineError::NoComp)?; @@ -97,18 +101,30 @@ fn delete(s: &mut Session, p: &Value) -> Result { } fn copy(s: &mut Session, p: &Value) -> Result { + // Keyframes selected → copy keys (pasted at the CTI). + if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { + return s.execute("keys.copy", json!({})); + } let (cid, ids) = layers_p(s, p)?; + s.state.clip_is_keys = false; 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(); Ok(json!(s.state.clipboard.len())) } fn cut(s: &mut Session, p: &Value) -> Result { + if !s.state.selected_keys.is_empty() && p.get("layers").is_none() { + s.execute("keys.copy", json!({}))?; + return s.execute("keys.delete", json!({})); + } copy(s, p)?; delete(s, p) } fn paste(s: &mut Session, p: &Value) -> Result { + if s.state.clip_is_keys && !s.state.key_clipboard.is_empty() { + return s.execute("keys.paste", p.clone()); + } let cid = super::comp_id(s, p)?; let clip = s.state.clipboard.clone(); let new = s.edit("Paste", None, |proj, st| { @@ -191,10 +207,10 @@ pub fn specs() -> Vec { vec![ cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo), cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo), - cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?}", has_layers, cut), - cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?}", has_layers, copy), + cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{layers?} (keyframes when keys are selected)", layers_or_keys, cut), + cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{layers?} (keyframes when keys are selected)", layers_or_keys, copy), cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{}", has_clip, paste), - cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", has_layers, delete), + cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{layers?}", layers_or_keys, delete), cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{layers?}", has_layers, duplicate), cmd!("edit.splitLayer", "Split Layer", ["Edit"], Some("Cmd+Shift+D"), "{layers?}", has_layers, split), cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_comp, select_all), @@ -204,6 +220,10 @@ pub fn specs() -> Vec { ] } +fn layers_or_keys(s: &Session) -> std::result::Result<(), String> { + if !s.state.selected_keys.is_empty() || !s.state.selected_vertices.is_empty() { super::has_comp(s) } else { has_layers(s) } +} + fn always_ok(_: &Session) -> std::result::Result<(), String> { Ok(()) } diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index a3cc9c77..93542b85 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -263,12 +263,13 @@ fn add_item(s: &mut Session, p: &Value) -> Result { ItemKind::Solid(so) => (LayerSource::Solid { item }, (so.width, so.height), None), ItemKind::Folder => return Err(bad("layer.addItem", "folders can't be layers")), }; - let start = f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO); + let fr = comp.frame_rate; + let start = fr.snap_nearest(f_p(p, "time").map(Tick::from_seconds_f64).unwrap_or(Tick::ZERO)); let id = s.edit("Add Footage to Comp", None, |proj, st| { let mut l = build::layer(proj, &comp, &it.name, src, size, dur); l.start_time = start; l.in_point = start; - l.out_point = (start + dur.unwrap_or(comp.duration)).min(comp.duration); + l.out_point = fr.snap_nearest((start + dur.unwrap_or(comp.duration)).min(comp.duration)).max(start + fr.frame_duration()); insert_layer(proj, st, cid, l) })?; Ok(json!({"layer": id.0})) @@ -302,6 +303,8 @@ fn select(s: &mut Session, p: &Value) -> Result { s.state.selected_layers = ids; s.state.selected_props.clear(); s.state.selected_keys.clear(); + let keep = s.state.selected_layers.clone(); + s.state.selected_vertices.retain(|v| keep.contains(&v.layer)); } Ok(json!(s.state.selected_layers.iter().map(|l| l.0).collect::>())) } @@ -484,10 +487,62 @@ fn set_parent(s: &mut Session, p: &Value) -> Result { cur = comp.layer(c).and_then(|l| l.parent); } } + // Like AE's pick-whip, parenting keeps the layer where it is: its transform is re-expressed + // in the new parent's space (Position, Rotation, Scale), unless `compensate: false`. + let compensate = b_p(p, "compensate").unwrap_or(true); + let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: s.time(), expr: s.expr.as_deref() }; + let space = |id: Option| -> effectcraft_geom::Mat3 { + id.and_then(|i| comp.layer(i)).filter(|l| !l.is_3d()).map(|l| ectx.layer_to_comp(l).0).unwrap_or(effectcraft_geom::Mat3::IDENTITY) + }; + let decompose = |m: &effectcraft_geom::Mat3| { + let a = m.0; + let sx = (a[0][0] * a[0][0] + a[1][0] * a[1][0]).sqrt(); + let det = a[0][0] * a[1][1] - a[0][1] * a[1][0]; + (a[1][0].atan2(a[0][0]).to_degrees(), sx, if sx > 1e-12 { det / sx } else { 0.0 }) + }; + // Per layer: (id, position transform, rotation delta, scale factors). + let mut fixes: Vec<(LayerId, effectcraft_geom::Mat3, f64, [f64; 2])> = vec![]; + if compensate { + let new_m = space(parent); + for l in comp.layers.iter().filter(|l| ids.contains(&l.id) && !l.is_3d() && l.parent != parent) { + let old_m = space(l.parent); + let Some(inv) = new_m.inverse() else { continue }; + let (ro, sxo, syo) = decompose(&old_m); + let (rn, sxn, syn) = decompose(&new_m); + let k = [if sxn.abs() > 1e-12 { sxo / sxn } else { 1.0 }, if syn.abs() > 1e-12 { syo / syn } else { 1.0 }]; + fixes.push((l.id, inv * old_m, ro - rn, k)); + } + } s.edit("Parent", None, |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { l.parent = parent; + let Some((_, m, dr, k)) = fixes.iter().find(|f| f.0 == l.id) else { continue }; + let Some(tr) = l.props.sub_mut("transform") else { continue }; + let map = |pr: &mut effectcraft_project::Property, f: &dyn Fn(&KV) -> KV| { + pr.value = f(&pr.value); + for key in &mut pr.keys { + key.value = f(&key.value); + } + }; + if tr.get("positionX").is_none() + && let Some(pr) = tr.get_mut("position") + { + map(pr, &|v| { + let c = v.as_vec3(); + let q = m.apply(effectcraft_geom::vec2(c[0], c[1])); + KV::Vec3([q.x, q.y, c[2]]) + }); + } + if let Some(pr) = tr.get_mut("rotation") { + map(pr, &|v| KV::Scalar(v.as_f64() + dr)); + } + if let Some(pr) = tr.get_mut("scale") { + map(pr, &|v| { + let c = v.as_vec3(); + KV::Vec3([c[0] * k[0], c[1] * k[1], c[2]]) + }); + } } Ok(()) })?; @@ -498,10 +553,10 @@ fn timing(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let t = s.time(); let op = str_p(p, "op").unwrap_or("set").to_string(); - let delta = f_p(p, "delta").map(Tick::from_seconds_f64); - let start = f_p(p, "start").map(Tick::from_seconds_f64); - let inp = f_p(p, "in").map(Tick::from_seconds_f64); - let outp = f_p(p, "out").map(Tick::from_seconds_f64); + // Layer times stay on frame boundaries of the comp (as in AE). + let fr = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate; + let snap = |k: &str| f_p(p, k).map(|v| fr.snap_nearest(Tick::from_seconds_f64(v))); + let (delta, start, inp, outp) = (snap("delta"), snap("start"), snap("in"), snap("out")); s.edit("Layer Timing", merge_p(p), |proj, _| { let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let fd = comp.frame_duration(); @@ -818,34 +873,6 @@ fn transform_op(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } -fn time_stretch(s: &mut Session, p: &Value) -> Result { - let (cid, ids) = layers_p(s, p)?; - let reverse = str_p(p, "op") == Some("reverse"); - let pct = f_p(p, "percent"); - s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| { - let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; - for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { - let old = l.stretch; - let new = if reverse { -old } else { pct.unwrap_or(old) }; - if new.abs() < 1.0 { - continue; - } - // Keep the in point fixed: the layer's source span scales around it. - let lin = l.layer_time(l.in_point); - let lout = l.layer_time(l.out_point); - l.stretch = new; - if reverse { - l.start_time = l.in_point + Tick((lout.0 as f64 * new.abs() / 100.0) as i64); - } else { - l.start_time = l.in_point - Tick((lin.0 as f64 * new / 100.0) as i64); - l.out_point = l.comp_time(lout); - } - } - Ok(()) - })?; - Ok(Value::Null) -} - fn layer_settings(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.settings")?; let color = color_p(p, "color"); @@ -977,7 +1004,6 @@ pub fn specs() -> Vec { has_layers, transform_op ), - cmd!("layer.timeStretch", "Time Stretch…", ["Layer", "Time"], None, "{layers?, percent?, op?: reverse}", has_layers, time_stretch), ] } diff --git a/crates/engine/src/commands/layer_time.rs b/crates/engine/src/commands/layer_time.rs new file mode 100644 index 00000000..2bc031aa --- /dev/null +++ b/crates/engine/src/commands/layer_time.rs @@ -0,0 +1,188 @@ +//! Layer ▸ Time: Time Stretch, Time-Reverse Layer, Enable Time Remapping, Freeze Frame and +//! Freeze on Last Frame. +//! +//! Time Remap is a `timeRemap` property at the top of the layer's tree whose value is the +//! source time (seconds) to show; its keys live in layer time like every other property. + +use effectcraft_keyframe::{Keyframe, Value as KV}; +use effectcraft_project::build::Ids; +use effectcraft_project::{Layer, LayerSource, Node, ParamUi, Property}; +use effectcraft_time::Tick; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, f_p, has_layers, layers_p, str_p}; +use crate::{EngineError, Result, Session, cmd}; + +/// Layers whose source has its own time (precomps and footage) can be remapped. +fn remappable(l: &Layer) -> bool { + matches!(l.source, LayerSource::Comp { .. } | LayerSource::Footage { .. }) +} + +fn time_stretch(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let reverse = str_p(p, "op") == Some("reverse"); + let pct = f_p(p, "percent"); + let dur = f_p(p, "duration"); + let hold = str_p(p, "hold").unwrap_or("in").to_string(); + let cti = s.time(); + let comp_rate = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_rate; + let fd = comp_rate.frame_duration(); + s.edit(if reverse { "Time-Reverse Layer" } else { "Time Stretch" }, None, |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id)) { + let old = l.stretch; + if reverse { + // Keep the in/out points; play the visible span backwards. + let last = l.layer_time(l.out_point - fd); + l.stretch = -old; + l.start_time = l.in_point - Tick((last.0 as f64 * l.stretch / 100.0).round() as i64); + continue; + } + let span = (l.out_point - l.in_point).seconds(); + let new = match (pct, dur) { + (Some(v), _) => v, + (None, Some(d)) if span > 0.0 => old * d / span, + _ => old, + }; + if new.abs() < 1.0 { + return Err(bad("layer.timeStretch", "the stretch factor must be at least 1 %")); + } + // The hold point keeps showing the same layer time. + let h = match hold.as_str() { + "current" => cti, + "out" => l.out_point, + _ => l.in_point, + }; + let lt_h = l.layer_time(h); + let (lin, lout) = (l.layer_time(l.in_point), l.layer_time(l.out_point)); + l.stretch = new; + l.start_time = h - Tick((lt_h.0 as f64 * new / 100.0).round() as i64); + let (a, b) = (l.comp_time(lin), l.comp_time(lout)); + let fr = comp_rate; + l.in_point = fr.snap_nearest(a.min(b)); + l.out_point = fr.snap_nearest(a.max(b)).max(l.in_point + fr.frame_duration()); + } + Ok(()) + })?; + Ok(Value::Null) +} + +fn remap_prop(ids: &mut Ids) -> Property { + let mut p = ids.prop("timeRemap", "Time Remap", KV::Scalar(0.0)).with_ui(ParamUi::Number); + p.ui = ParamUi::Number; + p +} + +/// Source time (seconds) a layer shows at comp time `t` without remapping. +fn source_secs(l: &Layer, t: Tick) -> f64 { + l.layer_time(t).seconds() +} + +fn enable_time_remap(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let want = b_p(p, "value"); + let fd = s.project.comp(cid).ok_or(EngineError::NoComp)?.frame_duration(); + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + if !comp.layers.iter().filter(|l| ids.contains(&l.id)).any(remappable) { + return Err(bad("layer.enableTimeRemap", "time remapping needs a footage or composition layer")); + } + let on = s.edit("Enable Time Remapping", None, |proj, _| { + let mut next = proj.next_id; + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let mut on = false; + for l in comp.layers.iter_mut().filter(|l| ids.contains(&l.id) && remappable(l)) { + let has = l.props.get("timeRemap").is_some(); + let target = want.unwrap_or(!has); + on |= target; + if target && !has { + let mut pr = remap_prop(&mut Ids(&mut next)); + let (lin, lout) = (l.layer_time(l.in_point), l.layer_time(l.out_point)); + let (a, b) = (lin.min(lout), lin.max(lout)); + pr.value = KV::Scalar(source_secs(l, l.in_point)); + pr.keys = vec![Keyframe::new(a, KV::Scalar(a.seconds())), Keyframe::new(b, KV::Scalar(b.seconds()))]; + if lin > lout { + // Reversed layers keep showing what they showed. + pr.keys[0].value = KV::Scalar(source_secs(l, l.out_point - fd)); + pr.keys[1].value = KV::Scalar(source_secs(l, l.in_point)); + } + l.props.children.insert(0, Node::Prop(pr)); + } else if !target && has { + l.props.children.retain(|c| c.match_id() != "timeRemap"); + } + } + proj.next_id = next; + Ok(on) + })?; + Ok(json!(on)) +} + +/// Freeze Frame: time remapping holding the current source frame for the whole layer. +fn freeze(s: &mut Session, p: &Value, last: bool) -> Result { + let (cid, ids) = layers_p(s, p)?; + let cti = s.time(); + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let (fd, cdur) = (comp.frame_duration(), comp.duration); + if !comp.layers.iter().filter(|l| ids.contains(&l.id)).any(remappable) { + return Err(bad("layer.freezeFrame", "freezing needs a footage or composition layer")); + } + // What each layer shows now (honouring an existing remap) — evaluated before the edit. + let ectx = effectcraft_render::EvalCtx { project: &s.project, comp_id: cid, comp, time: cti, expr: s.expr.as_deref() }; + let now: Vec<(effectcraft_project::LayerId, f64)> = comp + .layers + .iter() + .filter(|l| ids.contains(&l.id) && remappable(l)) + .map(|l| { + let at = if last { ectx.at(l.out_point - fd) } else { ectx }; + (l.id, at.source_time(l).seconds()) + }) + .collect(); + s.edit(if last { "Freeze on Last Frame" } else { "Freeze Frame" }, None, |proj, _| { + let mut next = proj.next_id; + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for (lid, src) in &now { + let Some(l) = comp.layer_mut(*lid) else { continue }; + if l.props.get("timeRemap").is_none() { + l.props.children.insert(0, Node::Prop(remap_prop(&mut Ids(&mut next)))); + } + let lt = if last { l.layer_time(l.out_point - fd) } else { l.layer_time(cti) }; + let lin = l.layer_time(l.in_point); + let pr = l.props.get_mut("timeRemap").ok_or(EngineError::NoComp)?; + pr.value = KV::Scalar(*src); + if last { + // Play normally up to the last frame, then hold it to the end of the comp. + let mut end = Keyframe::new(lt.max(lin), KV::Scalar(*src)); + end.out_interp = effectcraft_keyframe::Interp::Hold; + pr.keys = vec![Keyframe::new(lin.min(lt), KV::Scalar(lin.min(lt).seconds())), end]; + pr.keys.dedup_by_key(|k| k.time); + l.out_point = cdur; + } else { + pr.keys = vec![Keyframe::new(lt, KV::Scalar(*src)).hold()]; + } + } + proj.next_id = next; + Ok(()) + })?; + Ok(Value::Null) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "layer.timeStretch", + "Time Stretch…", + ["Layer", "Time"], + None, + "{layers?, percent?|duration? (s), hold?: in|current|out, op?: reverse}", + has_layers, + time_stretch + ), + cmd!("layer.timeReverse", "Time-Reverse Layer", ["Layer", "Time"], Some("Cmd+Alt+R"), "{layers?}", has_layers, |s, p| { + let mut p = p.clone(); + p["op"] = json!("reverse"); + time_stretch(s, &p) + }), + cmd!("layer.enableTimeRemap", "Enable Time Remapping", ["Layer", "Time"], Some("Cmd+Alt+T"), "{layers?, value?}", has_layers, enable_time_remap), + cmd!("layer.freezeFrame", "Freeze Frame", ["Layer", "Time"], None, "{layers?}", has_layers, |s, p| freeze(s, p, false)), + cmd!("layer.freezeOnLastFrame", "Freeze on Last Frame", ["Layer", "Time"], None, "{layers?}", has_layers, |s, p| freeze(s, p, true)), + ] +} diff --git a/crates/engine/src/commands/link.rs b/crates/engine/src/commands/link.rs new file mode 100644 index 00000000..1e070ab5 --- /dev/null +++ b/crates/engine/src/commands/link.rs @@ -0,0 +1,182 @@ +//! Separate Dimensions and the property pick-whip (AE-style reference expressions). + +use effectcraft_keyframe::{Ease, Interp, Keyframe, Value as KV}; +use effectcraft_project::{Comp, Layer, Node, ParamUi, Property, Uid}; +use effectcraft_time::{TICKS_PER_SECOND, Tick}; +use serde_json::{Value, json}; + +use super::prop::prop_ref; +use super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p}; +use crate::{EngineError, Result, Session, cmd}; + +const AXES: [(&str, &str); 3] = [("positionX", "X Position"), ("positionY", "Y Position"), ("positionZ", "Z Position")]; + +/// Per-dimension scalar keys from a (spatial) position key list: same times and interpolation; +/// Bezier sides get the per-dimension velocity the combined property had at the key. +fn split_keys(keys: &[Keyframe], spatial: bool, d: usize) -> Vec { + let h = Tick(TICKS_PER_SECOND / 2000); + keys.iter() + .map(|k| { + let mut s = Keyframe::new(k.time, KV::Scalar(k.value.components().get(d).copied().unwrap_or(0.0))); + s.in_interp = k.in_interp; + s.out_interp = k.out_interp; + s.auto_bezier = k.auto_bezier; + s.continuous = k.continuous; + s.roving = false; + let vel = |t: Tick| effectcraft_keyframe::velocity(keys, t, spatial).get(d).copied().unwrap_or(0.0); + if k.in_interp == Interp::Bezier { + let inf = k.in_ease.first().map(|e| e.influence).unwrap_or(1.0 / 3.0); + s.in_ease = vec![Ease { speed: vel(k.time - h), influence: inf }]; + } + if k.out_interp == Interp::Bezier { + let inf = k.out_ease.first().map(|e| e.influence).unwrap_or(1.0 / 3.0); + s.out_ease = vec![Ease { speed: vel(k.time + h), influence: inf }]; + } + s + }) + .collect() +} + +fn separate(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = layer_p(s, p, "prop.separateDimensions")?; + let want = b_p(p, "value"); + let on = s.edit("Separate Dimensions", None, |proj, _| { + let mut next = proj.next_id; + let l = layer_mut(proj, cid, lid)?; + let tr = l.props.sub_mut("transform").ok_or_else(|| bad("prop.separateDimensions", "this layer has no Transform"))?; + let has = tr.get("positionX").is_some(); + let target = want.unwrap_or(!has); + if target && !has { + let pos = tr.get("position").ok_or_else(|| bad("prop.separateDimensions", "no Position"))?.clone(); + let at = tr.children.iter().position(|c| c.uid() == pos.uid).map(|i| i + 1).unwrap_or(tr.children.len()); + let comps = pos.value.components(); + for (d, (m, name)) in AXES.iter().enumerate().rev() { + let mut pr = Property::new(next, m, name, KV::Scalar(comps.get(d).copied().unwrap_or(0.0))).with_ui(ParamUi::Number); + next += 1; + pr.three_d_only = d == 2; + pr.keys = split_keys(&pos.keys, pos.spatial, d); + tr.children.insert(at, Node::Prop(pr)); + } + } else if !target && has { + let parts: Vec = AXES.iter().filter_map(|(m, _)| tr.get(m).cloned()).collect(); + let mut times: Vec = parts.iter().flat_map(|p| p.keys.iter().map(|k| k.time)).collect(); + times.sort(); + times.dedup(); + let at = |t: Tick| KV::Vec3([0, 1, 2].map(|d| parts.get(d).map(|p| p.value_at(t).as_f64()).unwrap_or(0.0))); + let pos = tr.get_mut("position").ok_or_else(|| bad("prop.separateDimensions", "no Position"))?; + pos.value = at(Tick::ZERO); + pos.keys = times.iter().map(|t| Keyframe::new(*t, at(*t))).collect(); + if pos.keys.is_empty() { + pos.value = KV::Vec3([0, 1, 2].map(|d| parts.get(d).map(|p| p.value.as_f64()).unwrap_or(0.0))); + } + tr.children.retain(|c| !AXES.iter().any(|(m, _)| c.match_id() == *m)); + } + proj.next_id = next; + Ok(target) + })?; + Ok(json!(on)) +} + +// ---------------------------------------------------------------- pick-whip + +/// `X Position` → `xPosition`, `Mask Path` → `maskPath`. +fn camel(name: &str) -> String { + let mut out = String::new(); + for (i, w) in name.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).enumerate() { + let mut cs = w.chars(); + if let Some(f) = cs.next() { + if i == 0 { + out.extend(f.to_lowercase()); + } else { + out.extend(f.to_uppercase()); + } + out.push_str(cs.as_str()); + } + } + out +} + +fn quote(s: &str) -> String { + serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\"")) +} + +/// The AE expression reference to property `uid` of `target` as seen from a property of `from`. +pub(crate) fn reference(comp: &Comp, from: &Layer, target: &Layer, uid: Uid) -> Option { + let chain = target.props.node_chain(uid)?; + let mut parts: Vec = vec![]; + let mut pending: Option<&str> = None; + let mut in_effect = false; + for n in chain { + if let Some(f) = pending.take() { + parts.push(format!("{f}({})", quote(n.name()))); + in_effect |= f == "effect"; + continue; + } + match (n, n.match_id()) { + (Node::Group(_), "masks") => pending = Some("mask"), + (Node::Group(_), "effects") => pending = Some("effect"), + (Node::Group(_), "contents") => pending = Some("content"), + (Node::Group(_), "transform") if !in_effect => parts.push("transform".into()), + (Node::Group(_), "text") if !in_effect => parts.push("text".into()), + (Node::Prop(_), "sourceText") => parts.push("sourceText".into()), + _ if in_effect => parts.push(format!("({})", quote(n.name()))), + _ => parts.push(camel(n.name())), + } + } + let mut out = if from.id == target.id { String::new() } else { format!("thisComp.layer({}).", quote(&target.name)) }; + let _ = comp; + for (i, p) in parts.iter().enumerate() { + if i > 0 && !p.starts_with('(') { + out.push('.'); + } + out.push_str(p); + } + Some(out) +} + +/// Dimensions an expression sees (2D layers see 2D Position/Scale/Anchor). +fn dims(l: &Layer, p: &Property) -> usize { + match &p.value { + KV::Vec3(_) if p.shown_dims == 2 && !l.is_3d() => 2, + v => v.components().len().max(1), + } +} + +fn pick_whip(s: &mut Session, p: &Value) -> Result { + let (cid, lid, uid) = prop_ref(s, p, "prop.pickWhip")?; + let t = p.get("target").ok_or_else(|| bad("prop.pickWhip", "missing `target` {layer, path|prop}"))?; + let (_, tl, tu) = prop_ref(s, t, "prop.pickWhip")?; + if (tl, tu) == (lid, uid) { + return Err(bad("prop.pickWhip", "a property can't reference itself")); + } + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let from = comp.layer(lid).ok_or(EngineError::NoComp)?; + let target = comp.layer(tl).ok_or(EngineError::NoComp)?; + let r = reference(comp, from, target, tu).ok_or_else(|| bad("prop.pickWhip", "no such target property"))?; + let (me, them) = (from.props.find(uid).ok_or(EngineError::NoComp)?, target.props.find(tu).ok_or(EngineError::NoComp)?); + let (dm, dt) = (dims(from, me), dims(target, them)); + let text = if dt == 1 && dm > 1 { + format!("temp = {r};\n[{}]", vec!["temp"; dm].join(", ")) + } else if dt > 1 && dm == 1 { + format!("{r}[0]") + } else { + r + }; + s.execute("prop.setExpression", json!({"layer": lid.0, "prop": uid, "expression": text}))?; + Ok(json!(text)) +} + +pub fn specs() -> Vec { + vec![ + cmd!("prop.separateDimensions", "Separate Dimensions", ["Animation"], None, "{layer?, value?}", has_layers, separate), + cmd!( + "prop.pickWhip", + "Pick Whip (Link Property)", + [], + None, + "{layer?, path|prop, target: {layer, path|prop}} → sets an AE reference expression", + has_layers, + pick_whip + ), + ] +} diff --git a/crates/engine/src/commands/mask.rs b/crates/engine/src/commands/mask.rs new file mode 100644 index 00000000..9387055b --- /dev/null +++ b/crates/engine/src/commands/mask.rs @@ -0,0 +1,260 @@ +//! Masks drawn and edited in the viewer: Pen tool (new mask, add vertex, Bezier tangents, close +//! path), vertex selection, moving and deleting vertices, and Layer ▸ Mask ▸ Remove (All). +//! +//! Coordinates are in layer space. Edits of an animated Mask Path set a key at the current time. + +use effectcraft_keyframe::{ShapePath, Value as KV}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Layer, MaskMode, PropGroup, Uid}; +use serde_json::{Value, json}; + +use super::{CommandSpec, b_p, bad, has_comp, has_layers, layer_mut, layer_p, merge_p, resolve_layer, str_p}; +use crate::{EngineError, Result, Session, VertexRef, cmd}; + +fn pt(v: Option<&Value>) -> Option<[f64; 2]> { + let a = v?.as_array()?; + Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?]) +} + +fn pts(v: Option<&Value>) -> Vec<[f64; 2]> { + v.and_then(Value::as_array).map(|a| a.iter().filter_map(|p| pt(Some(p))).collect()).unwrap_or_default() +} + +/// Find a mask group by uid, 1-based index or name. +fn find_mask<'a>(masks: &'a mut PropGroup, key: &Value) -> Option<&'a mut PropGroup> { + let i = match key { + Value::Number(n) => { + let n = n.as_u64()?; + masks.children.iter().position(|c| c.uid() == n).or_else(|| (n as usize).checked_sub(1).filter(|i| *i < masks.children.len()))? + } + Value::String(s) => masks.children.iter().position(|c| c.name() == s)?, + _ => return None, + }; + masks.children.get_mut(i)?.as_group_mut() +} + +/// Edit the mask path of (layer, mask) at the current time. +fn edit_path(s: &mut Session, p: &Value, label: &str, f: impl FnOnce(&mut ShapePath) -> Result) -> Result<(Uid, T)> { + let (cid, lid) = layer_p(s, p, label)?; + let key = p.get("mask").cloned().ok_or_else(|| bad(label, "missing `mask` (uid, index or name)"))?; + let t = s.time(); + s.edit(label, merge_p(p), |proj, _| { + let l = layer_mut(proj, cid, lid)?; + let lt = l.layer_time(t); + let masks = l.props.sub_mut("masks").ok_or_else(|| bad(label, "this layer has no masks"))?; + let g = find_mask(masks, &key).ok_or_else(|| bad(label, "no such mask"))?; + let uid = g.uid; + let pr = g.get_mut("path").ok_or_else(|| bad(label, "mask has no path"))?; + let KV::Path(mut sp) = pr.value_at(lt) else { return Err(bad(label, "not a path")) }; + let r = f(&mut sp)?; + pr.set_value_at(lt, KV::Path(sp)); + Ok((uid, r)) + }) +} + +/// Pen tool: a new (open by default) mask from points. +fn new_mask(s: &mut Session, p: &Value) -> Result { + let (cid, lid) = layer_p(s, p, "mask.new")?; + let v = pts(p.get("vertices")); + if v.is_empty() { + return Err(bad("mask.new", "need `vertices` [[x,y], …] in layer space")); + } + let n = v.len(); + let mut ins = pts(p.get("inTangents")); + let mut outs = pts(p.get("outTangents")); + ins.resize(n, [0.0; 2]); + outs.resize(n, [0.0; 2]); + let path = ShapePath { vertices: v, in_tangents: ins, out_tangents: outs, closed: b_p(p, "closed").unwrap_or(false) }; + let mode = str_p(p, "mode").and_then(MaskMode::from_name).unwrap_or(MaskMode::Add); + let uid = s.edit("New Mask", None, |proj, st| { + let mut next = proj.next_id; + let l = layer_mut(proj, cid, lid)?; + let masks = l.props.sub_mut("masks").ok_or_else(|| bad("mask.new", "this layer can't have masks"))?; + let k = masks.children.len(); + let g = build::mask(&mut Ids(&mut next), &format!("Mask {}", k + 1), path, mode, build::MASK_COLORS[k % build::MASK_COLORS.len()]); + let uid = g.uid; + masks.children.push(g.into()); + proj.next_id = next; + st.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: n - 1 }]; + Ok(uid) + })?; + Ok(json!({"mask": uid})) +} + +fn add_vertex(s: &mut Session, p: &Value) -> Result { + let point = pt(p.get("point")).ok_or_else(|| bad("mask.addVertex", "missing `point` [x,y]"))?; + let tin = pt(p.get("in")).unwrap_or([0.0; 2]); + let tout = pt(p.get("out")).unwrap_or([0.0; 2]); + let at = p.get("index").and_then(Value::as_u64).map(|i| i as usize); + let (_, lid) = layer_p(s, p, "mask.addVertex")?; + let (uid, i) = edit_path(s, p, "mask.addVertex", |sp| { + let i = at.unwrap_or(sp.vertices.len()).min(sp.vertices.len()); + sp.vertices.insert(i, point); + sp.in_tangents.insert(i, tin); + sp.out_tangents.insert(i, tout); + Ok(i) + })?; + s.state.selected_vertices = vec![VertexRef { layer: lid, mask: uid, index: i }]; + Ok(json!(i)) +} + +fn set_vertex(s: &mut Session, p: &Value) -> Result { + let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask.setVertex", "missing `index`"))? as usize; + let (point, tin, tout) = (pt(p.get("point")), pt(p.get("in")), pt(p.get("out"))); + edit_path(s, p, "mask.setVertex", |sp| { + if i >= sp.vertices.len() { + return Err(bad("mask.setVertex", "no such vertex")); + } + if let Some(v) = point { + sp.vertices[i] = v; + } + if let Some(v) = tin { + sp.in_tangents[i] = v; + } + if let Some(v) = tout { + sp.out_tangents[i] = v; + } + Ok(()) + })?; + Ok(Value::Null) +} + +fn set_closed(s: &mut Session, p: &Value) -> Result { + let c = b_p(p, "closed").unwrap_or(true); + edit_path(s, p, "mask.setClosed", |sp| { + sp.closed = c; + Ok(()) + })?; + Ok(json!(c)) +} + +/// Selected (or given) vertices grouped per (layer, mask). +fn vertex_groups(s: &Session, p: &Value) -> Result>> { + let comp = s.active_comp().ok_or(EngineError::NoComp)?; + let mut g: std::collections::BTreeMap<(u64, Uid), Vec> = Default::default(); + if let Some(Value::Array(a)) = p.get("vertices") { + for v in a { + let l = v.get("layer").and_then(|x| resolve_layer(comp, x)).ok_or_else(|| bad("mask", "vertex needs `layer`"))?; + let m = v.get("mask").and_then(Value::as_u64).ok_or_else(|| bad("mask", "vertex needs `mask` uid"))?; + let i = v.get("index").and_then(Value::as_u64).ok_or_else(|| bad("mask", "vertex needs `index`"))?; + g.entry((l.0, m)).or_default().push(i as usize); + } + } else { + for v in &s.state.selected_vertices { + g.entry((v.layer.0, v.mask)).or_default().push(v.index); + } + } + Ok(g) +} + +fn select_vertices(s: &mut Session, p: &Value) -> Result { + let g = vertex_groups(s, &json!({"vertices": p.get("vertices").cloned().unwrap_or(json!([]))}))?; + let sel: Vec = + g.into_iter().flat_map(|((l, m), is)| is.into_iter().map(move |i| VertexRef { layer: effectcraft_project::LayerId(l), mask: m, index: i })).collect(); + if b_p(p, "add").unwrap_or(false) { + for v in sel { + if let Some(i) = s.state.selected_vertices.iter().position(|x| *x == v) { + if b_p(p, "toggle").unwrap_or(false) { + s.state.selected_vertices.remove(i); + } + } else { + s.state.selected_vertices.push(v); + } + } + } else { + s.state.selected_vertices = sel; + } + Ok(json!(s.state.selected_vertices.len())) +} + +fn has_vertices(s: &Session) -> std::result::Result<(), String> { + has_comp(s)?; + if s.state.selected_vertices.is_empty() { Err("select mask vertices first".into()) } else { Ok(()) } +} + +/// Apply `f` to the selected vertices' paths (each mask once). +fn edit_vertices(s: &mut Session, p: &Value, label: &str, f: impl Fn(&mut ShapePath, &[usize])) -> Result { + let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; + let groups = vertex_groups(s, p)?; + let t = s.time(); + s.edit(label, merge_p(p), |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for ((l, m), idx) in &groups { + let Some(layer) = comp.layer_mut(effectcraft_project::LayerId(*l)) else { continue }; + let lt = layer.layer_time(t); + let Some(pr) = layer.props.sub_mut("masks").and_then(|ms| ms.find_group_mut(*m)).and_then(|g| g.get_mut("path")) else { continue }; + let KV::Path(mut sp) = pr.value_at(lt) else { continue }; + f(&mut sp, idx); + pr.set_value_at(lt, KV::Path(sp)); + } + Ok(()) + })?; + Ok(json!(groups.values().map(Vec::len).sum::())) +} + +fn move_vertices(s: &mut Session, p: &Value) -> Result { + let d = pt(p.get("delta")).ok_or_else(|| bad("mask.moveVertices", "missing `delta` [dx,dy] (layer space)"))?; + edit_vertices(s, p, "Move Mask Vertices", |sp, idx| { + for &i in idx { + if let Some(v) = sp.vertices.get_mut(i) { + v[0] += d[0]; + v[1] += d[1]; + } + } + }) +} + +fn delete_vertices(s: &mut Session, p: &Value) -> Result { + let n = edit_vertices(s, p, "Delete Mask Vertices", |sp, idx| { + let mut idx = idx.to_vec(); + idx.sort_unstable(); + idx.dedup(); + for &i in idx.iter().rev() { + if i < sp.vertices.len() { + sp.vertices.remove(i); + sp.in_tangents.remove(i); + sp.out_tangents.remove(i); + } + } + if sp.vertices.len() < 3 { + sp.closed = false; + } + })?; + s.state.selected_vertices.clear(); + Ok(n) +} + +fn remove_masks(s: &mut Session, p: &Value, all: bool) -> Result { + let (cid, lid) = layer_p(s, p, "mask.remove")?; + let key = p.get("mask").cloned(); + if !all && key.is_none() { + return Err(bad("mask.remove", "missing `mask`")); + } + s.edit(if all { "Remove All Masks" } else { "Remove Mask" }, None, |proj, st| { + let l: &mut Layer = layer_mut(proj, cid, lid)?; + let masks = l.props.sub_mut("masks").ok_or_else(|| bad("mask.remove", "this layer has no masks"))?; + if all { + masks.children.clear(); + } else if let Some(k) = &key { + let uid = find_mask(masks, k).map(|g| g.uid).ok_or_else(|| bad("mask.remove", "no such mask"))?; + masks.children.retain(|c| c.uid() != uid); + } + st.selected_vertices.retain(|v| v.layer != lid); + Ok(()) + })?; + Ok(Value::Null) +} + +pub fn specs() -> Vec { + vec![ + cmd!("mask.new", "New Mask from Points", [], None, "{layer?, vertices: [[x,y]…], inTangents?, outTangents?, closed?, mode?}", has_layers, new_mask), + cmd!("mask.addVertex", "Add Mask Vertex", [], None, "{layer?, mask, point: [x,y], in?, out?, index?}", has_layers, add_vertex), + cmd!("mask.setVertex", "Set Mask Vertex", [], None, "{layer?, mask, index, point?, in?, out?, merge?}", has_layers, set_vertex), + cmd!("mask.setClosed", "Closed", ["Layer", "Mask and Shape Path"], None, "{layer?, mask, closed?}", has_layers, set_closed), + cmd!("mask.selectVertices", "Select Mask Vertices", [], None, "{vertices: [{layer, mask, index}], add?, toggle?}", has_comp, select_vertices), + cmd!("mask.moveVertices", "Move Mask Vertices", [], None, "{vertices?: [{layer, mask, index}], delta: [dx,dy], merge?}", has_vertices, move_vertices), + cmd!("mask.deleteVertices", "Delete Mask Vertices", [], None, "{vertices?}", has_vertices, delete_vertices), + cmd!("mask.remove", "Remove Mask", ["Layer", "Mask"], None, "{layer?, mask}", has_layers, |s, p| remove_masks(s, p, false)), + cmd!("mask.removeAll", "Remove All Masks", ["Layer", "Mask"], None, "{layer?}", has_layers, |s, p| remove_masks(s, p, true)), + ] +} diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 4d66731b..5b9c7c87 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -1,12 +1,16 @@ //! The command registry. Ids follow After Effects' menu structure; every menu item, panel button, //! shortcut and viewer/timeline gesture maps to one of these. +mod anim; mod comp; mod edit; mod effect; mod file; mod help; mod layer; +mod layer_time; +mod link; +mod mask; mod prop; mod query; mod render_queue; @@ -69,7 +73,11 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(edit::specs()); v.extend(comp::specs()); v.extend(layer::specs()); + v.extend(layer_time::specs()); v.extend(prop::specs()); + v.extend(anim::specs()); + v.extend(link::specs()); + v.extend(mask::specs()); v.extend(effect::specs()); v.extend(time::specs()); v.extend(render_queue::specs()); diff --git a/crates/engine/src/commands/prop.rs b/crates/engine/src/commands/prop.rs index dc986987..1c5313e4 100644 --- a/crates/engine/src/commands/prop.rs +++ b/crates/engine/src/commands/prop.rs @@ -9,7 +9,7 @@ use super::{CommandSpec, b_p, bad, f_p, has_comp, has_keys, has_layers, layer_mu use crate::{EngineError, KeyRef, Result, Session, cmd, query}; /// Resolve `{layer, path}` (or `{layer, prop: uid}`) to (comp, layer, prop uid). -fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { +pub(crate) fn prop_ref(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { let (cid, lid) = layer_p(s, p, cmd)?; let layer = s.project.comp(cid).and_then(|c| c.layer(lid)).ok_or(EngineError::NoComp)?; if let Some(u) = p.get("prop").and_then(Value::as_u64) { @@ -38,10 +38,44 @@ fn with_prop( }) } +/// An explicit key time (layer seconds) moved onto the nearest comp frame. +fn snapped_key_time(s: &Session, cid: ItemId, lid: LayerId, p: &Value) -> Option { + let t = Tick::from_seconds_f64(f_p(p, "time")?); + let comp = s.project.comp(cid)?; + let l = comp.layer(lid)?; + Some(l.layer_time(comp.frame_rate.snap_nearest(l.comp_time(t)))) +} + fn set(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.set")?; + // Position with Separate Dimensions: write the X/Y/Z Position properties instead. + if let Some(l) = s.project.comp(cid).and_then(|c| c.layer(lid)) + && let Some(tr) = l.transform() + && tr.get("position").is_some_and(|pr| pr.uid == uid) + && tr.get("positionX").is_some() + && let Some(Value::Array(a)) = p.get("value") + { + let mut out = vec![]; + let three = l.is_3d(); + for (d, m) in ["positionX", "positionY", "positionZ"].iter().enumerate() { + if d == 2 && (!three || a.len() < 3) { + continue; + } + if let Some(v) = a.get(d) { + let mut q = p.clone(); + q["path"] = json!(format!("transform/{m}")); + q["layer"] = json!(lid.0); + if let Some(o) = q.as_object_mut() { + o.remove("prop"); + } + q["value"] = v.clone(); + out.push(set(s, &q)?); + } + } + return Ok(json!(out)); + } let v = p.get("value").ok_or_else(|| bad("prop.set", "missing `value`"))?.clone(); - let at = f_p(p, "time").map(Tick::from_seconds_f64); + let at = snapped_key_time(s, cid, lid, p); let out = with_prop(s, "Change Property", merge_p(p), cid, lid, uid, |pr, lt| { let cur = pr.value_at(lt); let mut nv = cur.coerce_json(&v).ok_or_else(|| bad("prop.set", format!("can't use {v} for a {} property", cur.kind_name())))?; @@ -87,7 +121,7 @@ fn toggle_anim(s: &mut Session, p: &Value) -> Result { fn add_key(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.addKey")?; - let at = f_p(p, "time").map(Tick::from_seconds_f64); + let at = snapped_key_time(s, cid, lid, p); let value = p.get("value").cloned(); with_prop(s, "Add Keyframe", None, cid, lid, uid, |pr, lt| { let t = at.unwrap_or(lt); @@ -128,8 +162,18 @@ fn toggle_key(s: &mut Session, p: &Value) -> Result { fn set_expr(s: &mut Session, p: &Value) -> Result { let (cid, lid, uid) = prop_ref(s, p, "prop.setExpression")?; let text = p.get("expression").and_then(Value::as_str).map(str::to_string); - let enabled = b_p(p, "enabled"); - with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| { + let mut enabled = b_p(p, "enabled"); + // A syntax error keeps the text but disables the expression (AE shows the warning bar). + let error = match (&text, s.expr_check) { + (Some(t), Some(check)) if !t.trim().is_empty() => check(t).err(), + _ => None, + }; + if error.is_some() { + enabled = Some(false); + } + // Alt-click on a stopwatch starts with the property's own reference (`transform.opacity`). + let def = s.project.comp(cid).and_then(|c| c.layer(lid).and_then(|l| super::link::reference(c, l, l, uid))).unwrap_or_else(|| "value".into()); + let r = with_prop(s, "Expression", merge_p(p), cid, lid, uid, |pr, _| { match (&text, enabled) { (Some(t), _) if t.trim().is_empty() => pr.expr = None, (Some(t), e) => pr.expr = Some(effectcraft_project::Expression { text: t.clone(), enabled: e.unwrap_or(true) }), @@ -137,20 +181,20 @@ fn set_expr(s: &mut Session, p: &Value) -> Result { if let Some(x) = &mut pr.expr { x.enabled = e; } else if e { - pr.expr = Some(effectcraft_project::Expression { text: default_expr(pr), enabled: true }); + pr.expr = Some(effectcraft_project::Expression { text: def.clone(), enabled: true }); } } (None, None) => { - pr.expr = if pr.expr.is_some() { None } else { Some(effectcraft_project::Expression { text: default_expr(pr), enabled: true }) }; + pr.expr = if pr.expr.is_some() { None } else { Some(effectcraft_project::Expression { text: def.clone(), enabled: true }) }; } } Ok(json!(pr.expr.as_ref().map(|e| e.text.clone()))) - }) -} - -fn default_expr(pr: &Property) -> String { - let _ = pr; - "value".to_string() + })?; + if let Some(e) = error { + s.toast(format!("Expression disabled: {e}")); + return Ok(json!({"expression": r, "error": e})); + } + Ok(r) } fn reset(s: &mut Session, p: &Value) -> Result { @@ -225,7 +269,7 @@ fn select_keys(s: &mut Session, p: &Value) -> Result { } /// Apply `f` to every selected key; keeps the selection pointing at moved keys. -fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut Vec, usize, &mut Tick) -> bool) -> Result { +pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut Vec, usize, &mut Tick) -> bool) -> Result { let cid = s.active_comp_id().ok_or(EngineError::NoComp)?; let sel = s.state.selected_keys.clone(); s.edit(label, merge, |proj, st| { @@ -262,8 +306,14 @@ fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: impl Fn(&mut for t in keep_times { new_sel.push(KeyRef { layer: lid, prop: uid, time: t }); } - if pr.keys.is_empty() { - // Deleting every key leaves the value at the last key. + // Roving keys follow their neighbours: re-time and keep the selection on them. + let before: Vec = pr.keys.iter().map(|k| k.time).collect(); + if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) { + for r in new_sel.iter_mut().filter(|r| r.layer == lid && r.prop == uid) { + if let Some(i) = before.iter().position(|t| *t == r.time) { + r.time = pr.keys[i].time; + } + } } } st.selected_keys = new_sel; @@ -319,8 +369,56 @@ fn interpolation(s: &mut Session, p: &Value) -> Result { let both = parse("interpolation"); let inn = parse("in").or(both); let out = parse("out").or(both); - let auto = b_p(p, "autoBezier"); + // Temporal interpolation by dialog name: linear|bezier|continuousBezier|autoBezier|hold. + let temporal = str_p(p, "interpolation").map(|v| v.to_ascii_lowercase()); + let mut auto = b_p(p, "autoBezier"); + let mut continuous = b_p(p, "continuous"); + match temporal.as_deref() { + Some("autobezier" | "auto") => auto = Some(true), + Some("continuousbezier" | "continuous") => { + continuous = Some(true); + auto = Some(false); + } + Some("linear" | "bezier" | "hold") => { + auto = auto.or(Some(false)); + continuous = continuous.or(Some(false)); + } + _ => {} + } + let inn = if matches!(temporal.as_deref(), Some("autobezier" | "auto" | "continuousbezier" | "continuous")) { inn.or(Some(Interp::Bezier)) } else { inn }; + let out = if matches!(temporal.as_deref(), Some("autobezier" | "auto" | "continuousbezier" | "continuous")) { out.or(Some(Interp::Bezier)) } else { out }; + let spatial = str_p(p, "spatial").map(|v| v.to_ascii_lowercase()); + let roving = b_p(p, "roving"); edit_keys(s, "Keyframe Interpolation", None, move |keys, i, _| { + if let Some(sp) = spatial.as_deref() { + let (tin, tout) = effectcraft_keyframe::spatial_tangents(keys, i); + let k = &mut keys[i]; + match sp { + "linear" => { + k.spatial_auto = false; + k.spatial_continuous = false; + k.spatial_in = [0.0; 3]; + k.spatial_out = [0.0; 3]; + } + "bezier" | "continuous" | "continuousbezier" => { + k.spatial_auto = false; + k.spatial_in = tin; + k.spatial_out = tout; + k.spatial_continuous = sp != "bezier"; + } + "auto" | "autobezier" => { + k.spatial_auto = true; + k.spatial_continuous = false; + } + _ => {} + } + } + if let Some(r) = roving { + keys[i].roving = r; + } + if let Some(c) = continuous { + keys[i].continuous = c; + } let n = keys[i].value.dims().max(1); if let Some(x) = inn { keys[i].in_interp = x; @@ -339,6 +437,24 @@ fn interpolation(s: &mut Session, p: &Value) -> Result { if a { keys[i].in_interp = Interp::Bezier; keys[i].out_interp = Interp::Bezier; + keys[i].continuous = false; + } + } + if keys[i].continuous && !keys[i].auto_bezier { + // Continuous Bezier: one speed through the key (average of both sides). + let k = &mut keys[i]; + k.in_interp = Interp::Bezier; + k.out_interp = Interp::Bezier; + if k.in_ease.len() != n { + k.in_ease = vec![Ease::default(); n]; + } + if k.out_ease.len() != n { + k.out_ease = vec![Ease::default(); n]; + } + for d in 0..n { + let sp = (k.in_ease[d].speed + k.out_ease[d].speed) / 2.0; + k.in_ease[d].speed = sp; + k.out_ease[d].speed = sp; } } false @@ -353,41 +469,67 @@ fn toggle_hold(s: &mut Session, _: &Value) -> Result { }) } +/// A number or a per-dimension array of numbers. +fn nums_p(p: &Value, k: &str, scale: f64) -> Option> { + match p.get(k)? { + Value::Array(a) => Some(a.iter().filter_map(Value::as_f64).map(|v| v * scale).collect()), + v => v.as_f64().map(|v| vec![v * scale]), + } +} + fn velocity(s: &mut Session, p: &Value) -> Result { - let in_speed = f_p(p, "inSpeed"); - let in_inf = f_p(p, "inInfluence").map(|v| v / 100.0); - let out_speed = f_p(p, "outSpeed"); - let out_inf = f_p(p, "outInfluence").map(|v| v / 100.0); + let mut in_speed = nums_p(p, "inSpeed", 1.0); + let mut in_inf = nums_p(p, "inInfluence", 0.01); + let mut out_speed = nums_p(p, "outSpeed", 1.0); + let mut out_inf = nums_p(p, "outInfluence", 0.01); + // "Continuous" in the dialog: outgoing velocity follows incoming. + let continuous = b_p(p, "continuous"); + if continuous == Some(true) { + if out_speed.is_none() { + out_speed = in_speed.clone(); + } + if in_speed.is_none() { + in_speed = out_speed.clone(); + } + if out_inf.is_none() { + out_inf = in_inf.clone(); + } + if in_inf.is_none() { + in_inf = out_inf.clone(); + } + } edit_keys(s, "Keyframe Velocity", merge_p(p), move |keys, i, _| { let n = keys[i].value.dims().max(1); + // Current presentation of each side, so unspecified parts keep their values. + let cur = |keys: &[Keyframe], out: bool| -> Vec { + (0..n).map(|d| effectcraft_keyframe::side_ease(keys, i, d, false, out).unwrap_or(Ease::EASY)).collect() + }; + let cur_in = cur(keys, false); + let cur_out = cur(keys, true); let k = &mut keys[i]; - if in_speed.is_some() || in_inf.is_some() { - k.in_interp = Interp::Bezier; - if k.in_ease.is_empty() { - k.in_ease = vec![Ease::default(); n]; + let apply = |list: &mut Vec, base: Vec, sp: &Option>, inf: &Option>| { + if list.len() != n { + *list = base; } - for e in &mut k.in_ease { - if let Some(v) = in_speed { - e.speed = v; + for (d, e) in list.iter_mut().enumerate() { + if let Some(v) = sp.as_ref().and_then(|v| v.get(d).or(v.first())) { + e.speed = *v; } - if let Some(v) = in_inf { + if let Some(v) = inf.as_ref().and_then(|v| v.get(d).or(v.first())) { e.influence = v.clamp(0.001, 1.0); } } + }; + if in_speed.is_some() || in_inf.is_some() { + k.in_interp = Interp::Bezier; + apply(&mut k.in_ease, cur_in, &in_speed, &in_inf); } if out_speed.is_some() || out_inf.is_some() { k.out_interp = Interp::Bezier; - if k.out_ease.is_empty() { - k.out_ease = vec![Ease::default(); n]; - } - for e in &mut k.out_ease { - if let Some(v) = out_speed { - e.speed = v; - } - if let Some(v) = out_inf { - e.influence = v.clamp(0.001, 1.0); - } - } + apply(&mut k.out_ease, cur_out, &out_speed, &out_inf); + } + if let Some(c) = continuous { + k.continuous = c; } k.auto_bezier = false; false @@ -451,7 +593,7 @@ pub fn specs() -> Vec { "Keyframe Interpolation…", ["Animation"], Some("Cmd+Alt+K"), - "{interpolation?|in?|out?: linear|bezier|hold, autoBezier?}", + "{interpolation?: linear|bezier|continuousBezier|autoBezier|hold, in?|out?: linear|bezier|hold, autoBezier?, continuous?, spatial?: linear|bezier|continuousBezier|autoBezier, roving?}", has_keys, interpolation ), @@ -460,7 +602,7 @@ pub fn specs() -> Vec { "Keyframe Velocity…", ["Animation"], Some("Cmd+Shift+K"), - "{inSpeed?, inInfluence? %, outSpeed?, outInfluence? %}", + "{inSpeed?, inInfluence? %, outSpeed?, outInfluence? % (numbers or per-dimension arrays), continuous?}", has_keys, velocity ), diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index eca58e6d..64ccb104 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -11,8 +11,9 @@ use effectcraft_time::{FrameRate, Tick}; pub const MAIN_COMP: &str = "EffectCraft Intro"; +/// Seconds → the nearest frame of the demo's 29.97 fps comps (AE keeps times frame-aligned). fn t(s: f64) -> Tick { - Tick::from_seconds_f64(s) + FrameRate::FPS_29_97.snap_nearest(Tick::from_seconds_f64(s)) } fn hex(h: &str) -> [f64; 4] { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e49df1c6..264866c7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -93,6 +93,24 @@ pub struct KeyRef { pub time: Tick, } +/// Keyframes of one property on the keyframe clipboard. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct KeyClip { + pub layer: LayerId, + /// Match-id path relative to the layer (`transform/position`), portable between layers. + pub path: String, + /// Keys with times relative to the earliest copied key (layer time). + pub keys: Vec, +} + +/// A selected mask vertex: layer, mask group uid, vertex index. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct VertexRef { + pub layer: LayerId, + pub mask: Uid, + pub index: usize, +} + /// Editing state that commands depend on (headless-relevant, serde for agents). #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct EditorState { @@ -109,6 +127,15 @@ pub struct EditorState { /// Layer clipboard (serialized layers) and keyframe clipboard. #[serde(skip)] pub clipboard: Vec, + /// Copied keyframes (times relative to the earliest copied key). + #[serde(skip)] + pub key_clipboard: Vec, + /// The last copy was keyframes (Paste pastes keys at the CTI). + #[serde(skip)] + pub clip_is_keys: bool, + /// Selected mask vertices (viewer Selection tool / pen). + #[serde(default)] + pub selected_vertices: Vec, pub snapping: bool, /// Last applied effect id (Effect ▸ last effect). pub last_effect: Option, @@ -133,6 +160,8 @@ pub struct Session { pub services: Arc, pub footage: Arc, pub expr: Option>, + /// Expression syntax checker (set by the host that links the expression engine). + pub expr_check: Option std::result::Result<(), String>>, pub importer: Option>, /// Render Queue encoder (the export layer); `None` = export unavailable. pub exporter: Option>, @@ -157,6 +186,7 @@ impl Default for Session { services: Arc::new(FsServices), footage: Arc::new(NoFootage), expr: None, + expr_check: None, importer: None, exporter: None, render_job: None, @@ -262,6 +292,7 @@ impl Session { self.state.selected_layers.retain(|l| ids.contains(l)); self.state.selected_props.retain(|(l, _)| ids.contains(l)); self.state.selected_keys.retain(|k| ids.contains(&k.layer)); + self.state.selected_vertices.retain(|v| ids.contains(&v.layer)); } self.state.project_selection.retain(|i| p.item(*i).is_some()); } @@ -286,7 +317,7 @@ impl Session { if let Some(c) = self.state.active_comp { let comp = self.project.comp(c); let t = match comp { - Some(comp) => comp.frame_rate.snap(t.clamp(Tick::ZERO, comp.duration - comp.frame_duration())), + Some(comp) => comp.frame_rate.snap_nearest(t.clamp(Tick::ZERO, comp.duration - comp.frame_duration())), None => t, }; self.state.times.insert(c, t); @@ -306,6 +337,7 @@ impl Session { self.state.selected_layers.clear(); self.state.selected_props.clear(); self.state.selected_keys.clear(); + self.state.selected_vertices.clear(); } self.events.push(Event::OpenComp(id)); } @@ -372,6 +404,8 @@ impl Session { mod rq_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_timeline; /// Font families available to text layers (bundled + scanned system fonts). pub fn text_families() -> Vec { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index deaa616c..0e849f92 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -205,7 +205,7 @@ fn set_text_paragraph_fill_and_leading() { #[test] fn prop_get_and_render_rgba8() { let mut s = Session::default(); - s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 2.0})).unwrap(); + s.execute("comp.new", json!({"name": "T", "width": 320, "height": 180, "duration": 2.0, "frameRate": 30})).unwrap(); let l = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 0.0, "value": [0, 0]})).unwrap(); s.execute("prop.addKey", json!({"layer": l, "path": "transform/position", "time": 1.0, "value": [100, 50]})).unwrap(); diff --git a/crates/engine/src/tests_timeline.rs b/crates/engine/src/tests_timeline.rs new file mode 100644 index 00000000..53405454 --- /dev/null +++ b/crates/engine/src/tests_timeline.rs @@ -0,0 +1,573 @@ +//! Timeline depth: keyframe clipboard, easing, velocity/interpolation, roving, nudging, graph +//! editor key edits, time remapping/stretch/reverse/freeze, separate dimensions, pick-whips, +//! expressions and masks (pen tool). Every command is checked with undo/redo. + +use effectcraft_keyframe::{Interp, Value as KV}; +use effectcraft_project::Property; +use serde_json::{Value, json}; + +use crate::Session; + +/// A 4 s, 30 fps comp with one solid; returns (session, layer id). +pub(crate) fn setup() -> (Session, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 400, "height": 300, "frameRate": 30, "duration": 4})).unwrap(); + let r = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 100, "height": 100})).unwrap(); + (s, r["layer"].as_u64().unwrap()) +} + +pub(crate) fn prop(s: &Session, layer: u64, path: &str) -> Property { + s.active_comp().unwrap().layer(effectcraft_project::LayerId(layer)).unwrap().props.prop(path).unwrap().clone() +} + +/// Key `path` at the given (time, value) pairs. +pub(crate) fn animate(s: &mut Session, layer: u64, path: &str, keys: &[(f64, Value)]) { + for (t, v) in keys { + s.execute("prop.addKey", json!({"layer": layer, "path": path, "time": t, "value": v})).unwrap(); + } +} + +fn select_prop_keys(s: &mut Session, layer: u64, path: &str) { + s.execute("prop.select", json!({"layer": layer, "path": path})).unwrap(); +} + +fn undo_redo_roundtrip(s: &mut Session, check_after: impl Fn(&Session), check_before: impl Fn(&Session)) { + check_after(s); + s.execute("edit.undo", json!({})).unwrap(); + check_before(s); + s.execute("edit.redo", json!({})).unwrap(); + check_after(s); +} + +#[test] +fn easy_ease_family_sets_influence_and_zero_speed() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(100)), (2.0, json!(0))]); + select_prop_keys(&mut s, l, "transform/opacity"); + s.execute("keys.easyEase", json!({})).unwrap(); + undo_redo_roundtrip( + &mut s, + |s| { + let p = prop(s, l, "transform/opacity"); + assert!(p.keys.iter().all(|k| k.in_interp == Interp::Bezier && k.out_interp == Interp::Bezier)); + assert!((p.keys[1].in_ease[0].influence - 1.0 / 3.0).abs() < 1e-9 && p.keys[1].in_ease[0].speed == 0.0); + }, + |s| assert!(prop(s, l, "transform/opacity").keys.iter().all(|k| k.in_interp == Interp::Linear)), + ); + // Easy Ease In only touches the incoming side; Out only the outgoing side. + s.execute("keys.interpolation", json!({"interpolation": "linear"})).unwrap(); + s.execute("keys.easyEaseIn", json!({})).unwrap(); + let p = prop(&s, l, "transform/opacity"); + assert!(p.keys.iter().all(|k| k.in_interp == Interp::Bezier && k.out_interp == Interp::Linear)); + s.execute("keys.interpolation", json!({"interpolation": "linear"})).unwrap(); + s.execute("keys.easyEaseOut", json!({})).unwrap(); + let p = prop(&s, l, "transform/opacity"); + assert!(p.keys.iter().all(|k| k.in_interp == Interp::Linear && k.out_interp == Interp::Bezier)); + // Eased motion is slow near the key. + s.execute("keys.easyEase", json!({})).unwrap(); + let p = prop(&s, l, "transform/opacity"); + let early = p.value_at(effectcraft_time::Tick::from_seconds_f64(0.1)).as_f64(); + assert!(early < 5.0, "{early}"); +} + +#[test] +fn keyframe_copy_paste_keeps_relative_timing_and_crosses_properties() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/rotation", &[(0.5, json!(0)), (1.0, json!(90)), (2.0, json!(45))]); + select_prop_keys(&mut s, l, "transform/rotation"); + s.execute("keys.easyEase", json!({})).unwrap(); + assert_eq!(s.execute("edit.copy", json!({})).unwrap(), json!(3)); + assert!(s.state.clip_is_keys); + // Paste into the same property at 2.5 s: keys at 2.5, 3.0 and 4.0 (clamped by nothing). + s.execute("time.set", json!({"time": 2.5})).unwrap(); + s.execute("keys.select", json!({"keys": []})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + let after = |s: &Session| { + let p = prop(s, l, "transform/rotation"); + let times: Vec = p.keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect(); + assert_eq!(times, vec![0.5, 1.0, 2.0, 2.5, 3.0, 4.0]); + assert_eq!(p.keys[4].value, KV::Scalar(90.0)); + assert_eq!(p.keys[4].in_interp, Interp::Bezier, "interpolation travels with the keys"); + assert_eq!(s.state.selected_keys.len(), 3, "pasted keys are selected"); + }; + undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/rotation").keys.len(), 3)); + // Cross-property paste of one property's keys into another of the same type (Scalar). + let r = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + let l2 = r["layer"].as_u64().unwrap(); + select_prop_keys(&mut s, l, "transform/rotation"); + s.execute("keys.select", json!({"keys": [{"layer": l, "prop": prop(&s, l, "transform/rotation").uid, "time": 1.0}]})).unwrap(); + s.execute("keys.copy", json!({})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("prop.select", json!({"layer": l2, "path": "transform/opacity", "selectKeys": false})).unwrap(); + s.execute("keys.paste", json!({"layers": [l2]})).unwrap(); + let op = prop(&s, l2, "transform/opacity"); + assert_eq!(op.keys.len(), 1); + assert_eq!(op.keys[0].value, KV::Scalar(90.0)); + // Incompatible target (Vec3 position) is refused when explicitly given. + let e = s.execute("keys.paste", json!({"layers": [l2], "path": "transform/position"})); + assert!(e.is_err() || prop(&s, l2, "transform/position").keys.is_empty()); +} + +#[test] +fn cut_removes_keys_and_paste_restores_them() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(10)), (1.0, json!(20))]); + select_prop_keys(&mut s, l, "transform/opacity"); + s.execute("edit.cut", json!({})).unwrap(); + assert!(prop(&s, l, "transform/opacity").keys.is_empty()); + s.execute("time.set", json!({"time": 0.0})).unwrap(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + s.execute("edit.paste", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys.len(), 2); +} + +#[test] +fn select_all_and_nudge_keys() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(10)), (1.0, json!(20))]); + animate(&mut s, l, "transform/rotation", &[(0.5, json!(10))]); + s.state.selected_props.clear(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + assert_eq!(s.execute("keys.selectAll", json!({})).unwrap(), json!(3)); + s.execute("keys.nudgeForward", json!({})).unwrap(); + let after = |s: &Session| { + let p = prop(s, l, "transform/opacity"); + assert!((p.keys[0].time.seconds() - 1.0 / 30.0).abs() < 1e-6); + assert_eq!(s.state.selected_keys.len(), 3); + }; + undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/opacity").keys[0].time.0, 0)); + s.execute("keys.nudgeBackward10", json!({})).unwrap(); + let p = prop(&s, l, "transform/opacity"); + assert!((p.keys[0].time.seconds() + 9.0 / 30.0).abs() < 1e-6, "{}", p.keys[0].time.seconds()); +} + +#[test] +fn velocity_dialog_values_per_dimension_and_continuous() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/scale", &[(0.0, json!([100, 100])), (1.0, json!([200, 50])), (2.0, json!([100, 100]))]); + s.execute("keys.select", json!({"keys": [{"layer": l, "prop": prop(&s, l, "transform/scale").uid, "time": 1.0}]})).unwrap(); + let info = s.execute("keys.info", json!({})).unwrap(); + assert_eq!(info[0]["units"], "%/sec"); + assert_eq!(info[0]["dims"], 2); + // Linear sides present the segment slope: +100 %/s in X on the incoming side. + assert!((info[0]["inEase"][0]["speed"].as_f64().unwrap() - 100.0).abs() < 1e-6, "{info}"); + s.execute("keys.velocity", json!({"inSpeed": [10, -20], "inInfluence": 50, "continuous": true})).unwrap(); + let after = |s: &Session| { + let k = &prop(s, l, "transform/scale").keys[1]; + assert_eq!(k.in_ease[0].speed, 10.0); + assert_eq!(k.in_ease[1].speed, -20.0); + assert_eq!(k.out_ease[1].speed, -20.0, "continuous mirrors incoming"); + assert!((k.out_ease[0].influence - 0.5).abs() < 1e-9); + assert!(k.continuous); + }; + undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/scale").keys[1].in_interp, Interp::Linear)); +} + +#[test] +fn interpolation_dialog_temporal_spatial_and_roving() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 0])), (0.3, json!([300, 0])), (2.0, json!([400, 0]))]); + select_prop_keys(&mut s, l, "transform/position"); + s.execute("keys.interpolation", json!({"interpolation": "hold"})).unwrap(); + assert!(prop(&s, l, "transform/position").keys.iter().all(|k| k.out_interp == Interp::Hold)); + s.execute("keys.interpolation", json!({"interpolation": "autoBezier"})).unwrap(); + assert!(prop(&s, l, "transform/position").keys.iter().all(|k| k.auto_bezier)); + s.execute("keys.interpolation", json!({"interpolation": "continuousBezier", "spatial": "linear"})).unwrap(); + let p = prop(&s, l, "transform/position"); + assert!(p.keys.iter().all(|k| k.continuous && !k.auto_bezier && !k.spatial_auto && k.spatial_out == [0.0; 3])); + // Rove the middle key: its time follows the path length (300 of 400 px → 1.5 s). + let uid = p.uid; + s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.3}]})).unwrap(); + s.execute("keys.interpolation", json!({"roving": true})).unwrap(); + let after = |s: &Session| { + let p = prop(s, l, "transform/position"); + assert!(p.keys[1].roving); + assert!((p.keys[1].time.seconds() - 1.5).abs() < 0.01, "{}", p.keys[1].time.seconds()); + // The selection follows the roving key. + assert!((s.state.selected_keys[0].time.seconds() - 1.5).abs() < 0.01); + }; + undo_redo_roundtrip(&mut s, after, |s| assert!(!prop(s, l, "transform/position").keys[1].roving)); + // Moving the last key re-times the roving key. + s.execute("keys.set", json!({"layer": l, "path": "transform/position", "time": 2.0, "newTime": 4.0})).unwrap(); + let p = prop(&s, l, "transform/position"); + assert!((p.keys[1].time.seconds() - 3.0).abs() < 0.02, "{}", p.keys[1].time.seconds()); + // Toggle hold on a key and back. + s.execute("keys.select", json!({"keys": [{"layer": l, "prop": uid, "time": 0.0}]})).unwrap(); + s.execute("keys.toggleHold", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold); + s.execute("keys.toggleHold", json!({})).unwrap(); + assert_ne!(prop(&s, l, "transform/position").keys[0].out_interp, Interp::Hold); +} + +#[test] +fn graph_editor_key_and_handle_edits() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (2.0, json!(100))]); + // Drag a key in the value graph: new time + value, merged into one undo step. + s.execute("keys.set", json!({"layer": l, "path": "transform/opacity", "time": 2.0, "newTime": 1.0, "value": 50, "merge": "g1"})).unwrap(); + s.execute("keys.set", json!({"layer": l, "path": "transform/opacity", "time": 1.0, "newTime": 1.5, "value": 60, "merge": "g1"})).unwrap(); + let p = prop(&s, l, "transform/opacity"); + assert!((p.keys[1].time.seconds() - 1.5).abs() < 1e-6); + assert_eq!(p.keys[1].value, KV::Scalar(60.0)); + s.execute("edit.undo", json!({})).unwrap(); + assert!((prop(&s, l, "transform/opacity").keys[1].time.seconds() - 2.0).abs() < 1e-6); + s.execute("edit.redo", json!({})).unwrap(); + // A key can't be dragged past its neighbour. + s.execute("keys.set", json!({"layer": l, "path": "transform/opacity", "time": 1.5, "newTime": -1.0})).unwrap(); + let p = prop(&s, l, "transform/opacity"); + assert!(p.keys[1].time > p.keys[0].time); + // Handle drag: out side of key 0 → speed 200 %/s, 60 % influence. + s.execute("keys.setEase", json!({"layer": l, "path": "transform/opacity", "time": 0.0, "side": "out", "speed": 200, "influence": 60})).unwrap(); + let k = &prop(&s, l, "transform/opacity").keys[0]; + assert_eq!(k.out_interp, Interp::Bezier); + assert_eq!(k.out_ease[0].speed, 200.0); + assert!((k.out_ease[0].influence - 0.6).abs() < 1e-9); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").keys[0].out_interp, Interp::Linear); +} + +#[test] +fn time_reverse_keyframes() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/opacity", &[(0.0, json!(0)), (1.0, json!(30)), (3.0, json!(100))]); + select_prop_keys(&mut s, l, "transform/opacity"); + s.execute("keys.timeReverse", json!({})).unwrap(); + let after = |s: &Session| { + let p = prop(s, l, "transform/opacity"); + assert_eq!(p.keys[0].value, KV::Scalar(100.0)); + assert!((p.keys[1].time.seconds() - 2.0).abs() < 1e-6); + }; + undo_redo_roundtrip(&mut s, after, |s| assert_eq!(prop(s, l, "transform/opacity").keys[0].value, KV::Scalar(0.0))); +} + +#[test] +fn pen_tool_draws_edits_and_closes_a_mask() { + let (mut s, l) = setup(); + // Click (corner), click, drag (Bezier), click on the first vertex (close). + let r = s.execute("mask.new", json!({"layer": l, "vertices": [[10, 10]]})).unwrap(); + let m = r["mask"].as_u64().unwrap(); + s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [90, 10]})).unwrap(); + s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [90, 90]})).unwrap(); + s.execute("mask.setVertex", json!({"layer": l, "mask": m, "index": 2, "in": [0, -20], "out": [0, 20], "merge": "pen"})).unwrap(); + s.execute("mask.addVertex", json!({"layer": l, "mask": m, "point": [10, 90]})).unwrap(); + s.execute("mask.setClosed", json!({"layer": l, "mask": m, "closed": true})).unwrap(); + let path = |s: &Session| prop(s, l, "masks/#1/path").value.as_path().unwrap().clone(); + let p = path(&s); + assert_eq!(p.vertices, vec![[10.0, 10.0], [90.0, 10.0], [90.0, 90.0], [10.0, 90.0]]); + assert_eq!(p.out_tangents[2], [0.0, 20.0]); + assert!(p.closed); + assert_eq!(s.state.selected_vertices.last().unwrap().index, 3); + // The mask cuts the 100×100 solid (layer centred in the 400×300 comp at 150..250, 100..200). + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::ZERO, effectcraft_render::RenderOpts { scale: 1.0, ..Default::default() }); + assert!(img.get(200, 150)[3] > 0.99 && img.get(152, 102)[3] < 0.01); + // Undo removes the close, redo restores it. + s.execute("edit.undo", json!({})).unwrap(); + assert!(!path(&s).closed); + s.execute("edit.redo", json!({})).unwrap(); + // Select two vertices and drag them; Delete removes them. + s.execute("mask.selectVertices", json!({"vertices": [{"layer": l, "mask": m, "index": 0}, {"layer": l, "mask": m, "index": 1}]})).unwrap(); + s.execute("mask.moveVertices", json!({"delta": [5, -5], "merge": "v1"})).unwrap(); + s.execute("mask.moveVertices", json!({"delta": [5, -5], "merge": "v1"})).unwrap(); + assert_eq!(path(&s).vertices[1], [100.0, 0.0]); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(path(&s).vertices[1], [90.0, 10.0], "a drag is one undo step"); + s.execute("edit.clear", json!({})).unwrap(); + assert_eq!(path(&s).vertices.len(), 2); + assert!(!path(&s).closed); + // Animated mask paths get a key at the current time. + s.execute("prop.toggleAnimation", json!({"layer": l, "path": "masks/#1/path"})).unwrap(); + s.execute("time.set", json!({"time": 1.0})).unwrap(); + s.execute("mask.setVertex", json!({"layer": l, "mask": 1, "index": 0, "point": [0, 0]})).unwrap(); + assert_eq!(prop(&s, l, "masks/#1/path").keys.len(), 2); + // Mode / feather / opacity / expansion through the timeline commands. + s.execute("layer.setMask", json!({"layer": l, "mask": m, "mode": "Subtract", "inverted": true})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "masks/#1/feather", "value": [4, 4]})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "masks/#1/expansion", "value": 3})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "masks/#1/opacity", "value": 50})).unwrap(); + assert_eq!(prop(&s, l, "masks/#1/opacity").value, KV::Scalar(50.0)); + s.execute("mask.removeAll", json!({"layer": l})).unwrap(); + assert!(s.active_comp().unwrap().layers[0].masks().unwrap().children.is_empty()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.active_comp().unwrap().layers[0].masks().unwrap().children.len(), 1); +} + +#[test] +fn alt_click_stopwatch_toggles_a_self_reference_expression() { + let (mut s, l) = setup(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + assert_eq!(prop(&s, l, "transform/opacity").expr.unwrap().text, "transform.opacity"); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "enabled": false})).unwrap(); + assert!(!prop(&s, l, "transform/opacity").expr.unwrap().enabled); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "enabled": true})).unwrap(); + s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity"})).unwrap(); + assert!(prop(&s, l, "transform/opacity").expr.is_none()); + s.execute("edit.undo", json!({})).unwrap(); + assert!(prop(&s, l, "transform/opacity").expr.unwrap().enabled); +} + +#[test] +fn layer_comp_and_key_times_stay_frame_aligned() { + let aligned = |fr: effectcraft_time::FrameRate, t: effectcraft_time::Tick| fr.snap(t) == t; + // The demo project: every comp duration, layer in/out/start and key time is on a frame. + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + for (_, c) in s.project.comps() { + let fr = c.frame_rate; + assert!(aligned(fr, c.duration), "duration {:?}", c.duration); + for l in &c.layers { + for t in [l.in_point, l.out_point, l.start_time] { + assert!(aligned(fr, t), "{} {t:?}", l.name); + } + l.props.walk("", &mut |_, pr| { + for k in &pr.keys { + assert!(aligned(fr, l.comp_time(k.time)), "{} {}", l.name, pr.name); + } + }); + } + } + let lower = s.active_comp().unwrap().layer_by_name("Lower Third").unwrap(); + let fr = s.active_comp().unwrap().frame_rate; + assert_eq!(fr.frame_at(lower.in_point), 150, "5 s at 29.97 is frame 150 (0:00:05:00)"); + // Commands snap: comp duration, layer timing, key times. + s.execute("comp.new", json!({"name": "F", "frameRate": 29.97, "duration": 10.0})).unwrap(); + let c = s.active_comp().unwrap(); + assert_eq!(c.frame_rate.frame_at(c.duration), 300); + assert!(aligned(c.frame_rate, c.duration)); + let l = s.execute("layer.newSolid", json!({"color": "#fff"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.timing", json!({"layers": [l], "in": 1.01, "out": 3.3333, "delta": 0.02})).unwrap(); + let ly = s.active_comp().unwrap().layers[0].clone(); + let fr = s.active_comp().unwrap().frame_rate; + for t in [ly.in_point, ly.out_point, ly.start_time] { + assert!(aligned(fr, t), "{t:?}"); + } + s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": 1.2345, "value": 10})).unwrap(); + let k = prop(&s, l, "transform/opacity").keys[0].time; + assert!(aligned(fr, ly.comp_time(k))); + // Layer shifted one frame (delta 0.02 s rounds to 1 frame): 1.2345 s + 1 frame -> frame 38. + assert_eq!(fr.frame_at(ly.comp_time(k)), 38); +} + +#[test] +fn time_set_snaps_to_the_nearest_frame_and_ae_timecode() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "T", "width": 100, "height": 100, "frameRate": 29.97, "duration": 10})).unwrap(); + let r = s.execute("time.set", json!({"time": 2.5})).unwrap(); + assert_eq!(r["frame"], 75); + let fr = s.active_comp().unwrap().frame_rate; + assert_eq!(effectcraft_time::format_timecode_ae(75, fr, false), "0:00:02:15"); + assert_eq!(effectcraft_time::format_timecode_ae(75, fr, true), "0;00;02;15"); +} + +#[test] +fn separate_dimensions_split_and_rejoin() { + let (mut s, l) = setup(); + animate(&mut s, l, "transform/position", &[(0.0, json!([0, 100])), (2.0, json!([200, 50]))]); + s.execute("prop.separateDimensions", json!({"layer": l})).unwrap(); + let after = |s: &Session| { + let x = prop(s, l, "transform/positionX"); + let y = prop(s, l, "transform/positionY"); + assert_eq!(x.keys.len(), 2); + assert_eq!(y.keys[1].value, KV::Scalar(50.0)); + // The layer still renders at the same place (render sees the separated values). + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let layer = &comp.layers[0]; + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, effectcraft_time::Tick::from_seconds_f64(1.0)); + let p = ctx.value(layer, layer.transform().unwrap().get("position").unwrap()).as_vec3(); + assert!((p[0] - 100.0).abs() < 1e-6 && (p[1] - 75.0).abs() < 1e-6, "{p:?}"); + }; + undo_redo_roundtrip(&mut s, after, |s| assert!(s.active_comp().unwrap().layers[0].transform().unwrap().get("positionX").is_none())); + // X and Y animate independently. + s.execute("keys.select", json!({"keys": []})).unwrap(); + s.execute("prop.select", json!({"layer": l, "path": "transform/positionX"})).unwrap(); + s.execute("keys.easyEase", json!({})).unwrap(); + assert_eq!(prop(&s, l, "transform/positionY").keys[0].out_interp, Interp::Linear); + // Setting Position (viewer drags) writes X/Y. + s.execute("time.set", json!({"time": 3.0})).unwrap(); + s.execute("prop.set", json!({"layer": l, "path": "transform/position", "value": [7, 8, 0]})).unwrap(); + assert_eq!(prop(&s, l, "transform/positionX").keys.len(), 3); + // Re-join: one Position with keys at the union of times. + s.execute("prop.separateDimensions", json!({"layer": l})).unwrap(); + let p = prop(&s, l, "transform/position"); + assert_eq!(p.keys.len(), 3); + assert_eq!(p.keys[2].value, KV::Vec3([7.0, 8.0, 0.0])); + assert!(s.active_comp().unwrap().layers[0].transform().unwrap().get("positionX").is_none()); +} + +#[test] +fn parenting_keeps_the_layer_in_place() { + let (mut s, child) = setup(); + let r = s.execute("layer.newNull", json!({})).unwrap(); + let par = r["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": par, "path": "transform/position", "value": [300, 200]})).unwrap(); + s.execute("prop.set", json!({"layer": par, "path": "transform/rotation", "value": 90})).unwrap(); + s.execute("prop.set", json!({"layer": par, "path": "transform/scale", "value": [200, 200]})).unwrap(); + s.execute("prop.set", json!({"layer": child, "path": "transform/position", "value": [100, 50]})).unwrap(); + let world = |s: &Session| { + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let l = comp.layer(effectcraft_project::LayerId(child)).unwrap(); + let ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, effectcraft_time::Tick::ZERO); + let (m, _) = ctx.layer_to_comp(l); + let a = m.apply(effectcraft_geom::vec2(50.0, 50.0)); + let b = m.apply(effectcraft_geom::vec2(100.0, 50.0)); + (a, (b.y - a.y).atan2(b.x - a.x).to_degrees(), (b.x - a.x).hypot(b.y - a.y)) + }; + let before = world(&s); + s.execute("layer.setParent", json!({"layers": [child], "parent": par})).unwrap(); + let after = world(&s); + assert!((before.0.x - after.0.x).abs() < 1e-6 && (before.0.y - after.0.y).abs() < 1e-6, "{before:?} {after:?}"); + assert!((before.1 - after.1).abs() < 1e-6 && (before.2 - after.2).abs() < 1e-6); + assert!((prop(&s, child, "transform/rotation").value.as_f64() + 90.0).abs() < 1e-9); + // Unparenting restores comp-space values. + s.execute("layer.setParent", json!({"layers": [child], "parent": null})).unwrap(); + let p = prop(&s, child, "transform/position").value.as_vec3(); + assert!((p[0] - 100.0).abs() < 1e-6 && (p[1] - 50.0).abs() < 1e-6, "{p:?}"); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(child)).unwrap().parent.is_some()); +} + +#[test] +fn pick_whip_generates_ae_reference_expressions() { + let (mut s, l) = setup(); + let r = s.execute("layer.newSolid", json!({"name": "Other", "color": "#00ff00"})).unwrap(); + let o = r["layer"].as_u64().unwrap(); + s.execute("effect.apply", json!({"layer": o, "effect": "Gaussian Blur"})).unwrap(); + s.execute("layer.addMask", json!({"layer": o})).unwrap(); + let cases = [ + ("transform/opacity", json!({"layer": o, "path": "transform/opacity"}), "thisComp.layer(\"Other\").transform.opacity"), + ("transform/rotation", json!({"layer": l, "path": "transform/opacity"}), "transform.opacity"), + ("transform/rotation", json!({"layer": o, "path": "effects/#1/blurriness"}), "thisComp.layer(\"Other\").effect(\"Gaussian Blur\")(\"Blurriness\")"), + ("transform/opacity", json!({"layer": o, "path": "masks/#1/feather"}), "thisComp.layer(\"Other\").mask(\"Mask 1\").maskFeather[0]"), + ("transform/scale", json!({"layer": o, "path": "transform/rotation"}), "temp = thisComp.layer(\"Other\").transform.rotation;\n[temp, temp]"), + ("transform/position", json!({"layer": o, "path": "transform/position"}), "thisComp.layer(\"Other\").transform.position"), + ]; + for (path, target, want) in cases { + let got = s.execute("prop.pickWhip", json!({"layer": l, "path": path, "target": target})).unwrap(); + assert_eq!(got, json!(want)); + assert_eq!(prop(&s, l, path).expr.unwrap().text, want); + } + s.execute("edit.undo", json!({})).unwrap(); + assert_ne!(prop(&s, l, "transform/position").expr.map(|e| e.text), Some("thisComp.layer(\"Other\").transform.position".into())); + assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err()); +} + +/// Outer comp (4 s) containing a precomp whose 10×10 white solid moves x = 10 + 20·t (t in s). +/// Returns (session, precomp layer id in the outer comp). +fn precomp_setup() -> (Session, u64) { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap(); + let r = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 10, "height": 10})).unwrap(); + let l = r["layer"].as_u64().unwrap(); + animate(&mut s, l, "transform/position", &[(0.0, json!([10, 50])), (4.0, json!([90, 50]))]); + s.execute("comp.new", json!({"name": "Outer", "width": 100, "height": 100, "frameRate": 30, "duration": 4})).unwrap(); + let r = s.execute("layer.addItem", json!({"item": "Inner"})).unwrap(); + let pl = r["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [pl]})).unwrap(); + (s, pl) +} + +/// X of the moving square's centre in the outer comp at `t`. +fn square_x(s: &Session, t: f64) -> f64 { + let cid = s.active_comp_id().unwrap(); + let img = s.render(cid, effectcraft_time::Tick::from_seconds_f64(t), effectcraft_render::RenderOpts { scale: 1.0, ..Default::default() }); + let xs: Vec = (0..img.width as usize).filter(|x| img.get(*x as i64, 50)[3] > 0.5).collect(); + assert!(!xs.is_empty(), "nothing drawn at {t}"); + (xs[0] + xs[xs.len() - 1]) as f64 / 2.0 + 0.5 +} + +#[test] +fn time_remap_enable_freeze_and_disable() { + let (mut s, l) = precomp_setup(); + assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5); + s.execute("layer.enableTimeRemap", json!({})).unwrap(); + let after = |s: &Session| { + let p = prop(s, l, "timeRemap"); + assert_eq!(p.keys.len(), 2); + assert_eq!(p.keys[1].value, KV::Scalar(4.0)); + // Remapping at identity shows the same frames. + assert!((square_x(s, 1.0) - 30.0).abs() < 1.5); + }; + undo_redo_roundtrip(&mut s, after, |s| assert!(s.active_comp().unwrap().layers[0].props.get("timeRemap").is_none())); + // Remap the first key to show source time 3 s at comp time 0. + s.execute("keys.set", json!({"layer": l, "path": "timeRemap", "time": 0.0, "value": 3.0})).unwrap(); + assert!((square_x(&s, 0.0) - 70.0).abs() < 1.5, "{}", square_x(&s, 0.0)); + // Freeze Frame at 2 s: one hold key; every frame shows source time 2 s. + s.execute("time.set", json!({"time": 2.0})).unwrap(); + s.execute("keys.set", json!({"layer": l, "path": "timeRemap", "time": 0.0, "value": 0.0})).unwrap(); + s.execute("layer.freezeFrame", json!({})).unwrap(); + let p = prop(&s, l, "timeRemap"); + assert_eq!(p.keys.len(), 1); + assert_eq!(p.keys[0].out_interp, Interp::Hold); + for t in [0.0, 1.0, 3.5] { + assert!((square_x(&s, t) - 50.0).abs() < 1.5, "{t}: {}", square_x(&s, t)); + } + // Ctrl+Alt+T again disables remapping. + s.execute("layer.enableTimeRemap", json!({})).unwrap(); + assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5); + // Solids can't be remapped. + let r = s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + assert!(s.execute("layer.enableTimeRemap", json!({"layers": [r["layer"]]})).is_err()); +} + +#[test] +fn freeze_on_last_frame_extends_the_layer() { + let (mut s, l) = precomp_setup(); + s.execute("layer.timing", json!({"layers": [l], "out": 2.0})).unwrap(); + s.execute("layer.freezeOnLastFrame", json!({})).unwrap(); + let layer = s.active_comp().unwrap().layers[0].clone(); + assert_eq!(layer.out_point, s.active_comp().unwrap().duration); + // Plays normally, then holds the frame before the old out point. + assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5); + let hold = 10.0 + 20.0 * (2.0 - 1.0 / 30.0); + assert!((square_x(&s, 3.5) - hold).abs() < 1.5, "{}", square_x(&s, 3.5)); +} + +#[test] +fn time_reverse_layer_plays_backwards_and_round_trips() { + let (mut s, l) = precomp_setup(); + let before = s.active_comp().unwrap().layers[0].clone(); + s.execute("layer.timeReverse", json!({})).unwrap(); + let after = |s: &Session| { + let ly = &s.active_comp().unwrap().layers[0]; + assert_eq!(ly.stretch, -100.0); + assert_eq!((ly.in_point, ly.out_point), (before.in_point, before.out_point)); + // First frame shows the last source frame; the end shows the start. + let last = 10.0 + 20.0 * (4.0 - 1.0 / 30.0); + assert!((square_x(s, 0.0) - last).abs() < 1.5, "{}", square_x(s, 0.0)); + assert!((square_x(s, 4.0 - 1.0 / 30.0) - 10.0).abs() < 1.5); + }; + undo_redo_roundtrip(&mut s, after, |s| assert_eq!(s.active_comp().unwrap().layers[0].stretch, 100.0)); + // Reversing twice restores normal playback. + s.execute("layer.timeReverse", json!({"layers": [l]})).unwrap(); + let ly = &s.active_comp().unwrap().layers[0]; + assert_eq!(ly.stretch, 100.0); + assert_eq!(ly.start_time, before.start_time); + assert!((square_x(&s, 1.0) - 30.0).abs() < 1.5); +} + +#[test] +fn time_stretch_holds_in_current_or_out() { + let (mut s, l) = precomp_setup(); + s.execute("layer.timeStretch", json!({"percent": 50})).unwrap(); + let ly = s.active_comp().unwrap().layers[0].clone(); + assert_eq!(ly.stretch, 50.0); + assert!((ly.out_point.seconds() - 2.0).abs() < 1e-6); + // Twice as fast: comp 1 s shows source 2 s. + assert!((square_x(&s, 1.0) - 50.0).abs() < 1.5); + s.execute("edit.undo", json!({})).unwrap(); + // Hold the current frame (2 s) in place while doubling the duration. + s.execute("time.set", json!({"time": 2.0})).unwrap(); + s.execute("layer.timeStretch", json!({"layers": [l], "duration": 8.0, "hold": "current"})).unwrap(); + let ly = s.active_comp().unwrap().layers[0].clone(); + assert!((ly.stretch - 200.0).abs() < 1e-6); + assert!((ly.in_point.seconds() + 2.0).abs() < 1e-6, "{}", ly.in_point.seconds()); + assert!((square_x(&s, 2.0) - 50.0).abs() < 1.5); + // Hold the out point. + s.execute("edit.undo", json!({})).unwrap(); + s.execute("layer.timeStretch", json!({"layers": [l], "percent": 50, "hold": "out"})).unwrap(); + let ly = s.active_comp().unwrap().layers[0].clone(); + assert!((ly.out_point.seconds() - 4.0).abs() < 1e-6 && (ly.in_point.seconds() - 2.0).abs() < 1e-6); + assert!(s.execute("layer.timeStretch", json!({"layers": [l], "percent": 0})).is_err()); +} diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index ca9f95e4..eb9dfb14 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -27,6 +27,9 @@ function __begin(t, v, c, l, path, uid, idx, fd) { function __finish(r) { r = __v(r); if (r !== null && typeof r === 'object' && r.__isLayer) return r.index; + // `[temp, temp]` where temp is a property (pick-whip 1D → 2D): read the values here, while + // host requests can still be answered and the script re-run. + if (Array.isArray(r)) return r.map(function (x) { return __v(x); }); return r; } diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index ffc13412..c8ed03bc 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -48,6 +48,7 @@ pub fn session() -> Session { footage: Arc::new(effectcraft_media::MediaPool::new()), importer: Some(Arc::new(MediaImporter)), expr: Some(Arc::new(effectcraft_expr::Expressions)), + expr_check: Some(effectcraft_expr::check_syntax), ..Default::default() } } @@ -106,4 +107,61 @@ mod tests { s.execute("renderQueue.setRender", json!({"index": 1, "render": true})).unwrap(); assert_eq!(s.project.render_queue[0].status, effectcraft_engine::project::render_queue::RenderStatus::Queued); } + + /// Value of a property of layer `l` at time 0 (keys + expression). + fn value(s: &effectcraft_engine::Session, l: u64, path: &str) -> effectcraft_engine::keyframe::Value { + let cid = s.active_comp_id().unwrap(); + let comp = s.project.comp(cid).unwrap(); + let layer = comp.layer(effectcraft_engine::project::LayerId(l)).unwrap(); + let ctx = effectcraft_engine::render::EvalCtx { project: &s.project, comp_id: cid, comp, time: Default::default(), expr: s.expr.as_deref() }; + ctx.value(layer, layer.props.prop(path).unwrap()) + } + + #[test] + fn pick_whip_expressions_evaluate() { + let mut s = super::session(); + s.execute("comp.new", json!({"name": "C", "width": 200, "height": 200, "frameRate": 30, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + let b = s.execute("layer.newSolid", json!({"name": "Src", "color": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/rotation", "value": 33})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "transform/position", "value": [12, 34]})).unwrap(); + s.execute("effect.apply", json!({"layer": b, "effect": "Gaussian Blur"})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "effects/#1/blurriness", "value": 21})).unwrap(); + s.execute("layer.addMask", json!({"layer": b})).unwrap(); + s.execute("prop.set", json!({"layer": b, "path": "masks/#1/feather", "value": [7, 7]})).unwrap(); + let link = |s: &mut effectcraft_engine::Session, from: &str, to: &str| { + s.execute("prop.pickWhip", json!({"layer": a, "path": from, "target": {"layer": b, "path": to}})).unwrap(); + }; + link(&mut s, "transform/opacity", "transform/rotation"); + assert_eq!(value(&s, a, "transform/opacity").as_f64(), 33.0); + link(&mut s, "transform/scale", "transform/rotation"); + let cid = s.active_comp_id().unwrap(); + let ep = effectcraft_expr::eval_property(&s.project, cid, effectcraft_engine::project::LayerId(a), "transform/scale", 0.0); + assert!(ep.is_ok(), "{ep:?}"); + assert_eq!(value(&s, a, "transform/scale").as_vec2(), [33.0, 33.0]); + link(&mut s, "transform/position", "transform/position"); + assert_eq!(value(&s, a, "transform/position").as_vec2(), [12.0, 34.0]); + link(&mut s, "transform/rotation", "effects/#1/blurriness"); + assert_eq!(value(&s, a, "transform/rotation").as_f64(), 21.0); + link(&mut s, "transform/opacity", "masks/#1/feather"); + assert_eq!(value(&s, a, "transform/opacity").as_f64(), 7.0); + // Separated dimensions: X Position reference. + s.execute("prop.separateDimensions", json!({"layer": b})).unwrap(); + link(&mut s, "transform/rotation", "transform/positionX"); + assert_eq!(value(&s, a, "transform/rotation").as_f64(), 12.0); + } + + #[test] + fn expression_syntax_errors_disable_the_expression() { + let mut s = super::session(); + s.execute("comp.new", json!({"name": "C", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"color": "#ffffff"})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "1 +* ("})).unwrap(); + assert!(r["error"].is_string(), "{r}"); + let pr = s.active_comp().unwrap().layers[0].props.prop("transform/opacity").unwrap().clone(); + assert!(!pr.expr.unwrap().enabled); + assert_eq!(value(&s, a, "transform/opacity").as_f64(), 100.0); + s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap(); + assert_eq!(value(&s, a, "transform/opacity").as_f64(), 50.0); + } } diff --git a/crates/keyframe/src/lib.rs b/crates/keyframe/src/lib.rs index bb737d1c..d97ec53d 100644 --- a/crates/keyframe/src/lib.rs +++ b/crates/keyframe/src/lib.rs @@ -302,6 +302,110 @@ fn effective_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: boo list.get(d).or(list.first()).copied().unwrap_or_default() } +/// The ease a key side presents in the Graph Editor and the Keyframe Velocity dialog: stored +/// Bezier eases, auto-Bezier slopes, or the equivalent of a linear side (speed of the straight +/// segment, 33.3 % influence). `None` for hold sides and sides without a neighbour. +pub fn side_ease(keys: &[Keyframe], i: usize, d: usize, spatial: bool, out: bool) -> Option { + let k = keys.get(i)?; + let j = if out { i + 1 } else { i.checked_sub(1)? }; + let n = keys.get(j)?; + let interp = if out { k.out_interp } else { k.in_interp }; + if interp == Interp::Hold || (!out && n.out_interp == Interp::Hold) || !k.value.interpolates() { + return None; + } + let is_spatial = spatial && matches!(k.value, Value::Vec2(_) | Value::Vec3(_)); + if interp == Interp::Linear { + let dur = (secs(k.time) - secs(n.time)).abs().max(1e-12); + let dv = if is_spatial { + let (a, b) = if out { (i, j) } else { (j, i) }; + spatial_segment_length(keys, a.min(b)) + } else { + let a = k.value.components(); + let b = n.value.components(); + let dv = b.get(d).unwrap_or(&0.0) - a.get(d).unwrap_or(&0.0); + if out { dv } else { -dv } + }; + return Some(Ease { speed: dv / dur, influence: 1.0 / 3.0 }); + } + Some(effective_ease(keys, i, if is_spatial { 0 } else { d }, is_spatial, out)) +} + +/// Arc length of the spatial segment `i` → `i + 1` (motion-path length). +pub fn spatial_segment_length(keys: &[Keyframe], i: usize) -> f64 { + let (Some(a), Some(b)) = (keys.get(i), keys.get(i + 1)) else { return 0.0 }; + let p0 = v3(&a.value); + let p3 = v3(&b.value); + let (_, out_t) = spatial_tangents(keys, i); + let (in_t, _) = spatial_tangents(keys, i + 1); + SpatialSeg::new([p0, [p0[0] + out_t[0], p0[1] + out_t[1], p0[2] + out_t[2]], [p3[0] + in_t[0], p3[1] + in_t[1], p3[2] + in_t[2]], p3]).length() +} + +/// Re-time roving keyframes: each run of roving keys between two fixed (non-roving) keys gets +/// times proportional to the motion-path length, so the layer moves through them at one +/// constant speed (After Effects' "Rove Across Time"). The first and last keys never rove. +/// Returns true when any key time changed. Key order is preserved. +pub fn retime_roving(keys: &mut [Keyframe], spatial: bool) -> bool { + let n = keys.len(); + if n < 3 { + for k in keys.iter_mut() { + k.roving = false; + } + return false; + } + keys[0].roving = false; + keys[n - 1].roving = false; + if !keys.iter().any(|k| k.roving) { + return false; + } + let mut changed = false; + let mut a = 0; + while a < n - 1 { + let Some(b) = (a + 1..n).find(|&j| !keys[j].roving) else { break }; + if b > a + 1 { + // Cumulative path length (spatial) or value distance (other) of the run a..=b. + let mut acc = vec![0.0]; + for i in a..b { + let len = if spatial && matches!(keys[i].value, Value::Vec2(_) | Value::Vec3(_)) { + spatial_segment_length(keys, i) + } else { + let x = keys[i].value.components(); + let y = keys[i + 1].value.components(); + x.iter().zip(&y).map(|(p, q)| (q - p) * (q - p)).sum::().sqrt() + }; + acc.push(acc.last().unwrap_or(&0.0) + len); + } + let total = *acc.last().unwrap_or(&0.0); + let (t0, t1) = (keys[a].time, keys[b].time); + for (o, i) in (a + 1..b).enumerate() { + let f = if total > 1e-9 { acc[o + 1] / total } else { (o + 1) as f64 / (b - a) as f64 }; + let nt = Tick(t0.0 + ((t1.0 - t0.0) as f64 * f).round() as i64); + if nt != keys[i].time { + keys[i].time = nt; + changed = true; + } + // Roving keys pass speed straight through. + keys[i].in_interp = Interp::Linear; + keys[i].out_interp = Interp::Linear; + } + } + a = b; + } + changed +} + +/// Reverse a run of keys in time within `[first, last]` (Time-Reverse Keyframes): key `i` moves +/// to `first + last - t` and in/out sides swap. +pub fn time_reverse(keys: &mut [Keyframe]) { + let (Some(first), Some(last)) = (keys.first().map(|k| k.time), keys.last().map(|k| k.time)) else { return }; + for k in keys.iter_mut() { + k.time = Tick(first.0 + last.0 - k.time.0); + 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); + } + keys.reverse(); +} + /// Evaluate a keyframed property at `t`. `spatial` selects motion-path semantics for 2D/3D values. pub fn evaluate(keys: &[Keyframe], t: Tick, spatial: bool) -> Option { let first = keys.first()?; @@ -524,6 +628,50 @@ mod tests { assert_eq!(v.as_path().unwrap().vertices[0], [-7.5, -7.5]); } + #[test] + fn roving_keys_get_constant_speed_times() { + let mut keys = vec![ + Keyframe::new(s(0.0), Value::Vec2([0.0, 0.0])), + Keyframe::new(s(0.2), Value::Vec2([300.0, 0.0])), + Keyframe::new(s(2.0), Value::Vec2([400.0, 0.0])), + ]; + for k in &mut keys { + k.spatial_auto = false; + } + keys[1].roving = true; + assert!(retime_roving(&mut keys, true)); + // 300 of 400 px → 75 % of the 2 s span. + assert!((keys[1].time.seconds() - 1.5).abs() < 1e-6, "{}", keys[1].time.seconds()); + let v0 = speed(&keys, s(0.5), true); + let v1 = speed(&keys, s(1.8), true); + assert!((v0 - 200.0).abs() < 1.0 && (v1 - 200.0).abs() < 1.0, "{v0} {v1}"); + // First/last keys can't rove. + keys[0].roving = true; + retime_roving(&mut keys, true); + assert!(!keys[0].roving); + } + + #[test] + fn side_ease_reports_linear_and_eased_sides() { + let keys = vec![Keyframe::new(s(0.0), Value::Scalar(0.0)), Keyframe::new(s(2.0), Value::Scalar(100.0)).eased()]; + let out = side_ease(&keys, 0, 0, false, true).unwrap(); + assert!((out.speed - 50.0).abs() < 1e-9); + let inn = side_ease(&keys, 1, 0, false, false).unwrap(); + assert_eq!(inn, Ease::EASY); + assert!(side_ease(&keys, 0, 0, false, false).is_none()); + } + + #[test] + fn time_reverse_mirrors_keys() { + let mut keys = + vec![Keyframe::new(s(1.0), Value::Scalar(0.0)).eased(), Keyframe::new(s(2.0), Value::Scalar(5.0)), Keyframe::new(s(4.0), Value::Scalar(10.0))]; + keys[0].out_interp = Interp::Hold; + time_reverse(&mut keys); + assert_eq!(keys.iter().map(|k| k.time.seconds().round() as i64).collect::>(), vec![1, 3, 4]); + assert_eq!(keys[0].value, Value::Scalar(10.0)); + assert_eq!(keys[2].in_interp, Interp::Hold); + } + proptest::proptest! { #[test] fn eased_values_stay_in_range(t in 0.0f64..1.0, i0 in 0.01f64..1.0, i1 in 0.01f64..1.0) { diff --git a/crates/keyframe/src/value.rs b/crates/keyframe/src/value.rs index 02706a6d..a5c685d4 100644 --- a/crates/keyframe/src/value.rs +++ b/crates/keyframe/src/value.rs @@ -352,6 +352,7 @@ impl Value { } } Value::Layer(_) => Some(Value::Layer(j.as_u64())), + Value::Path(_) => serde_json::from_value::(j.clone()).ok().map(Value::Path).or_else(|| serde_json::from_value::(j.clone()).ok()), _ => serde_json::from_value::(j.clone()).ok(), } } diff --git a/crates/project/src/props.rs b/crates/project/src/props.rs index df7f997d..8b6e28ec 100644 --- a/crates/project/src/props.rs +++ b/crates/project/src/props.rs @@ -399,6 +399,38 @@ impl PropGroup { } None } + /// Match-id path (`transform/position`, `effects/ec.blur.gaussian#2/blurriness`) of the node + /// with `uid`: portable between layers with the same structure (keyframe paste, pick-whip). + pub fn match_path_of(&self, uid: Uid) -> Option { + for (i, c) in self.children.iter().enumerate() { + let n = self.children[..i].iter().filter(|o| o.match_id() == c.match_id()).count() + 1; + let seg = if n == 1 { c.match_id().to_string() } else { format!("{}#{n}", c.match_id()) }; + if c.uid() == uid { + return Some(seg); + } + if let Node::Group(g) = c + && let Some(p) = g.match_path_of(uid) + { + return Some(format!("{seg}/{p}")); + } + } + None + } + /// Chain of nodes from this group's children down to `uid` (inclusive). + pub fn node_chain(&self, uid: Uid) -> Option> { + for c in &self.children { + if c.uid() == uid { + return Some(vec![c]); + } + if let Node::Group(g) = c + && let Some(mut rest) = g.node_chain(uid) + { + rest.insert(0, c); + return Some(rest); + } + } + None + } /// Human path (`Transform/Position`) of the node with `uid`. pub fn name_path_of(&self, uid: Uid) -> Option { for c in &self.children { diff --git a/crates/render/src/eval.rs b/crates/render/src/eval.rs index 994b6038..547e2e0a 100644 --- a/crates/render/src/eval.rs +++ b/crates/render/src/eval.rs @@ -33,6 +33,16 @@ impl<'a> EvalCtx<'a> { } /// Value of a property at the context time (keyframes, then expression). pub fn value(&self, layer: &Layer, prop: &Property) -> Value { + // Separated Position reads as the combination of X/Y/Z Position. + if prop.match_id == "position" + && let Some(tr) = layer.transform() + && let Some(px) = tr.get("positionX") + && tr.get("position").is_some_and(|p| p.uid == prop.uid) + { + let z = tr.get("positionZ").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0); + let y = tr.get("positionY").map(|p| self.value(layer, p).as_f64()).unwrap_or(0.0); + return Value::Vec3([self.value(layer, px).as_f64(), y, z]); + } let lt = layer.layer_time(self.time); let v = prop.value_at(lt); if prop.has_expression() @@ -63,6 +73,21 @@ impl<'a> EvalCtx<'a> { pub fn b(&self, layer: &Layer, g: &PropGroup, m: &str) -> bool { self.group_value(layer, g, m).map(|v| v.as_bool()).unwrap_or(false) } + /// Layer Position, honouring Separate Dimensions (X/Y/Z Position properties). + pub fn position(&self, layer: &Layer, tr: &PropGroup) -> [f64; 3] { + match tr.get("positionX") { + Some(px) => [self.value(layer, px).as_f64(), self.f(layer, tr, "positionY", 0.0), self.f(layer, tr, "positionZ", 0.0)], + None => self.v3(layer, tr, "position", [0.0; 3]), + } + } + /// Source time of a layer at the context time: Time Remap's value when enabled, else the + /// (stretch-aware) layer time. + pub fn source_time(&self, layer: &Layer) -> Tick { + if let Some(tr) = layer.props.get("timeRemap") { + return Tick::from_seconds_f64(self.value(layer, tr).as_f64()); + } + layer.layer_time(self.time) + } pub fn layer(&self, id: LayerId) -> Option<&'a Layer> { self.comp.layer(id) } @@ -71,7 +96,7 @@ impl<'a> EvalCtx<'a> { pub fn local_matrix(&self, layer: &Layer) -> Mat4 { let Some(tr) = layer.transform() else { return Mat4::IDENTITY }; let three = layer.is_3d(); - let pos = self.v3(layer, tr, "position", [0.0; 3]); + let pos = self.position(layer, tr); let rz = self.f(layer, tr, "rotation", 0.0); if layer.is_camera() || layer.is_light() { return Mat4::translate(Vec3::from(pos)) * Mat4::rotate_z(rz); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a1da7892..4d774e27 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -201,7 +201,7 @@ impl<'a> Renderer<'a> { return None; } let sub = Renderer { depth: self.depth + 1, ..*self }; - let lt = layer.layer_time(ctx.time); + let lt = ctx.source_time(layer); Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s }) } LayerSource::Footage { item } => { @@ -210,7 +210,7 @@ impl<'a> Renderer<'a> { if !f.has_video { return None; } - let lt = layer.layer_time(ctx.time); + let lt = ctx.source_time(layer); let img = self.footage.frame(*item, f, lt)?; let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 }; // Keep native pixels when downsampling is small; resample otherwise. @@ -392,6 +392,8 @@ pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Ima #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_remap; /// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size /// for solids/footage/precomps, glyph bounds for text, painted bounds for shapes). Used for viewer diff --git a/crates/render/src/tests_remap.rs b/crates/render/src/tests_remap.rs new file mode 100644 index 00000000..67b6ebef --- /dev/null +++ b/crates/render/src/tests_remap.rs @@ -0,0 +1,65 @@ +//! Time remapping and separated Position dimensions. + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{Comp, ItemKind, LayerSource, Node, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::render_frame; + +fn s(x: f64) -> Tick { + Tick::from_seconds_f64(x) +} + +#[test] +fn separated_position_drives_the_transform() { + let mut p = Project::default(); + let comp = Comp::new(200, 100, FrameRate::FPS_30, s(2.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 20, height: 20, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &comp, "S", LayerSource::Solid { item: sid }, (20, 20), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let mut px = ids.prop("positionX", "X Position", Value::Scalar(20.0)); + px.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(20.0)), Keyframe::new(s(1.0), Value::Scalar(180.0))]; + let py = ids.prop("positionY", "Y Position", Value::Scalar(50.0)); + let pz = ids.prop("positionZ", "Z Position", Value::Scalar(0.0)); + p.next_id = next; + let tr = l.props.sub_mut("transform").unwrap(); + // The combined property is ignored while separated. + tr.get_mut("position").unwrap().value = Value::Vec3([100.0, 10.0, 0.0]); + tr.children.extend([Node::Prop(px), Node::Prop(py), Node::Prop(pz)]); + p.comp_mut(cid).unwrap().layers.push(l); + let a = render_frame(&p, cid, Tick::ZERO, 1.0); + assert!(a.get(20, 50)[3] > 0.99 && a.get(100, 10)[3] < 0.01); + let b = render_frame(&p, cid, s(0.5), 1.0); + assert!(b.get(100, 50)[3] > 0.99 && b.get(20, 50)[3] < 0.01); +} + +#[test] +fn time_remap_picks_the_source_frame() { + let mut p = Project::default(); + let outer = Comp::new(100, 100, FrameRate::FPS_30, s(4.0)); + let mut inner = Comp::new(100, 100, FrameRate::FPS_30, s(4.0)); + // Inner comp: a solid that moves from x=10 (t=0) to x=90 (t=2). + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 10, height: 10, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (10, 10), None); + l.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([10.0, 50.0, 0.0])), Keyframe::new(s(2.0), Value::Vec3([90.0, 50.0, 0.0]))]; + inner.layers.push(l); + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into())); + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); + let mut next = p.next_id; + // Freeze on source time 2 s. + let tr = Ids(&mut next).prop("timeRemap", "Time Remap", Value::Scalar(2.0)); + p.next_id = next; + let mut tr = tr; + tr.keys = vec![Keyframe::new(Tick::ZERO, Value::Scalar(2.0)).hold()]; + pre.props.children.push(Node::Prop(tr)); + p.comp_mut(cid).unwrap().layers.push(pre); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + assert!(img.get(90, 50)[3] > 0.99, "frozen at the end position"); + assert!(img.get(10, 50)[3] < 0.01); +} diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index 6e759c1e..c3077490 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -330,6 +330,16 @@ pub fn format_timecode_frames(frame: i64, rate: FrameRate, drop_frame: bool) -> format!("{}{h:02}{sep}{m:02}{sep}{s:02}{sep}{f:0fw$}", if neg { "-" } else { "" }) } +/// Format a frame count the way After Effects displays the current time: hours not padded +/// (`0:00:02:15`, or `0;00;02;15` for drop-frame). +pub fn format_timecode_ae(frame: i64, rate: FrameRate, drop_frame: bool) -> String { + let df = drop_frame && rate.supports_drop_frame(); + let (neg, h, m, s, f) = frames_to_fields(frame, rate, df); + let sep = if df { ';' } else { ':' }; + let fw = if rate.timecode_base() >= 100 { 3 } else { 2 }; + format!("{}{h}{sep}{m:02}{sep}{s:02}{sep}{f:0fw$}", if neg { "-" } else { "" }) +} + /// Format a tick according to a display mode. pub fn format_time(t: Tick, rate: FrameRate, drop_frame: bool, display: TimeDisplay, sample_rate: i64) -> String { let frame = rate.frame_at(t); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3b906851..fc799002 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -41,6 +41,9 @@ pub enum Dialog { CompSettings, SolidSettings, CommandPalette, + KeyVelocity, + KeyInterpolation, + TimeStretch, } /// Host hooks provided by the native app (file pickers etc.). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 569d4911..8ff4c1e0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -245,6 +245,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V "app.compSettings" | "comp.settings" if params.as_object().is_none_or(|m| m.is_empty()) => { crate::panels::dialogs::open_comp_settings(app)?; } + "keys.velocity" if params.as_object().is_none_or(|m| m.is_empty()) => crate::panels::key_dialogs::open_velocity(app)?, + "keys.interpolation" if params.as_object().is_none_or(|m| m.is_empty()) => crate::panels::key_dialogs::open_interpolation(app)?, + "layer.timeStretch" if params.as_object().is_none_or(|m| m.is_empty()) => crate::panels::key_dialogs::open_time_stretch(app)?, "app.solidSettings" | "layer.newSolid" if params.as_object().is_none_or(|m| m.is_empty()) => { crate::panels::dialogs::open_new_solid(app)?; } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 82ff905d..6d5c8e42 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -6,7 +6,7 @@ use serde_json::json; use crate::icons::{self, Icon}; use crate::theme::Tokens; -use crate::{Dialog, EffectcraftApp, widgets}; +use crate::{Dialog, EffectcraftApp}; pub use super::comp_settings::CompDraft; @@ -19,6 +19,9 @@ pub struct DialogState { pub solid_size: [u32; 2], pub palette_query: String, pub palette_sel: usize, + pub velocity: super::key_dialogs::VelocityDraft, + pub interp: super::key_dialogs::InterpDraft, + pub stretch: super::key_dialogs::StretchDraft, } pub fn open_new_comp(app: &mut EffectcraftApp) { @@ -63,7 +66,7 @@ pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> { Ok(()) } -fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) { +pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) { // Dim the app. let screen = ctx.content_rect(); ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("modal-dim"))).rect_filled(screen, 0.0, Color32::from_black_alpha(120)); @@ -86,6 +89,9 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) { Dialog::NewComp | Dialog::CompSettings => comp_settings(app, ctx, &t, d == Dialog::CompSettings), Dialog::SolidSettings => solid(app, ctx, &t), Dialog::CommandPalette => palette(app, ctx, &t), + Dialog::KeyVelocity => super::key_dialogs::velocity(app, ctx, &t), + Dialog::KeyInterpolation => super::key_dialogs::interpolation(app, ctx, &t), + Dialog::TimeStretch => super::key_dialogs::time_stretch(app, ctx, &t), } } @@ -185,7 +191,7 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis } } -fn ui_enter(ctx: &egui::Context) -> bool { +pub(crate) fn ui_enter(ctx: &egui::Context) -> bool { !ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter)) } diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs new file mode 100644 index 00000000..7a3072e6 --- /dev/null +++ b/crates/ui-egui/src/panels/graph.rs @@ -0,0 +1,452 @@ +//! The Graph Editor (Timeline ▸ Shift+F3): value graph and speed graph of the selected (or all +//! animated) properties, keyframes you can drag in time and value, Bezier/influence handles on +//! selected keys, auto-zoom / fit, and the bottom button bar (graph type, show selected, +//! auto-zoom, fit, separate dimensions, hold/linear/auto Bezier, Easy Ease family). +//! +//! Every edit is an engine command (`keys.set`, `keys.setEase`, `keys.select`, `keys.*`). + +use effectcraft_engine::KeyRef; +use effectcraft_engine::keyframe::{Interp, Value, side_ease}; +use effectcraft_engine::project::{Comp, Layer, LayerId, Property}; +use effectcraft_engine::render::EvalCtx; +use effectcraft_engine::time::Tick; +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::json; + +use super::timeline::TMap; +use crate::EffectcraftApp; +use crate::theme::Tokens; + +type Actions = Vec<(String, serde_json::Value)>; + +const DIM_COLORS: [Color32; 4] = + [Color32::from_rgb(0xe0, 0x55, 0x55), Color32::from_rgb(0x5c, 0xc8, 0x5c), Color32::from_rgb(0x50, 0x8c, 0xf0), Color32::from_rgb(0xe8, 0xc8, 0x40)]; +const PROP_COLORS: [Color32; 6] = [ + Color32::from_rgb(0xe8, 0xc8, 0x40), + Color32::from_rgb(0xd0, 0x70, 0xe0), + Color32::from_rgb(0x40, 0xc8, 0xc8), + Color32::from_rgb(0xf0, 0x90, 0x40), + Color32::from_rgb(0x9c, 0xd8, 0x50), + Color32::from_rgb(0xf0, 0x70, 0x90), +]; + +struct Handle { + out: bool, + /// Comp seconds / graph value of the handle end. + t: f64, + v: f64, + /// Original speed (sign is kept when dragging in the speed graph). + speed: f64, + /// Segment duration (layer seconds) the influence is relative to. + dur: f64, +} + +struct KeyPt { + idx: usize, + time: Tick, + /// Comp seconds. + t: f64, + v: f64, + sel: bool, + handles: Vec, +} + +struct Curve<'a> { + layer: &'a Layer, + prop: &'a Property, + dim: usize, + color: Color32, + pts: Vec<(f64, f64)>, + keys: Vec, + /// Values editable in the value graph (not spatial / combined dimensions). + editable: bool, +} + +/// Properties shown: the selected ones, or every animated property of the selected layers. +fn shown_props(app: &EffectcraftApp, comp: &Comp) -> Vec<(LayerId, u64)> { + let st = &app.session.state; + let numeric = |pr: &Property| !pr.value.components().is_empty() && pr.value.interpolates(); + if app.ui.timeline.graph_show_selected && !st.selected_props.is_empty() { + return st.selected_props.iter().filter(|(l, u)| comp.layer(*l).and_then(|l| l.props.find(*u)).is_some_and(numeric)).copied().collect(); + } + let mut v = vec![]; + for l in comp.layers.iter().filter(|l| st.selected_layers.contains(&l.id)) { + l.props.walk("", &mut |_, pr| { + if (pr.is_animated() || pr.has_expression()) && numeric(pr) { + v.push((l.id, pr.uid)); + } + }); + } + v +} + +fn shown_dims(l: &Layer, pr: &Property) -> usize { + let n = pr.value.components().len(); + if pr.shown_dims > 0 && !l.is_3d() { (pr.shown_dims as usize).min(n) } else { n.min(4) } +} + +/// Build the curves for the visible time span. +fn curves<'a>(app: &EffectcraftApp, comp: &'a Comp, ectx: &EvalCtx, tm: TMap, plot: Rect, speed: bool) -> Vec> { + let sel = &app.session.state.selected_keys; + let n = ((plot.width() / 2.0) as usize).max(2); + let mut out = vec![]; + for (pi, (lid, uid)) in shown_props(app, comp).into_iter().enumerate() { + let Some(l) = comp.layer(lid) else { continue }; + let Some(pr) = l.props.find(uid) else { continue }; + let spatial = pr.spatial && matches!(pr.value, Value::Vec2(_) | Value::Vec3(_)); + let dims = if speed && spatial { 1 } else { shown_dims(l, pr) }; + for d in 0..dims { + let color = if dims > 1 { DIM_COLORS[d % 4] } else { PROP_COLORS[pi % PROP_COLORS.len()] }; + let mut pts = Vec::with_capacity(n); + for i in 0..n { + let x = plot.min.x + plot.width() * i as f32 / (n - 1) as f32; + let ct = tm.t(x); + let tt = Tick::from_seconds_f64(ct); + let v = if speed { + let lt = l.layer_time(tt); + if spatial { + effectcraft_engine::keyframe::speed(&pr.keys, lt, true) + } else { + effectcraft_engine::keyframe::velocity(&pr.keys, lt, false).get(d).copied().unwrap_or(0.0).abs() + } + } else { + ectx.at(tt).value(l, pr).components().get(d).copied().unwrap_or(0.0) + }; + pts.push((ct, v)); + } + let mut keys = vec![]; + for (i, k) in pr.keys.iter().enumerate() { + let ks = sel.contains(&KeyRef { layer: lid, prop: uid, time: k.time }); + let kt = l.comp_time(k.time).seconds(); + let ed = if spatial { 0 } else { d }; + let mut handles = vec![]; + let mut side_speeds = vec![]; + for out_side in [false, true] { + let Some(e) = side_ease(&pr.keys, i, ed, pr.spatial, out_side) else { continue }; + let j = if out_side { i + 1 } else { i - 1 }; + let dur = (pr.keys[j].time.seconds() - k.time.seconds()).abs(); + side_speeds.push(e.speed.abs()); + let dt = e.influence * dur * if out_side { 1.0 } else { -1.0 }; + let (hv, show) = if speed { (e.speed.abs(), true) } else { (k.value.components().get(d).copied().unwrap_or(0.0) + e.speed * dt, !spatial) }; + let bezier = if out_side { k.out_interp == Interp::Bezier } else { k.in_interp == Interp::Bezier }; + if ks && show && (bezier || speed) { + handles.push(Handle { out: out_side, t: kt + dt, v: hv, speed: e.speed, dur }); + } + } + let v = if speed { + if side_speeds.is_empty() { 0.0 } else { side_speeds.iter().sum::() / side_speeds.len() as f64 } + } else { + k.value.components().get(d).copied().unwrap_or(0.0) + }; + keys.push(KeyPt { idx: i, time: k.time, t: kt, v, sel: ks, handles }); + } + out.push(Curve { layer: l, prop: pr, dim: d, color, pts, keys, editable: !spatial }); + } + } + out +} + +fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x: &mut f32, y: f32, label: &str, on: bool, id: &str, tip: &str) -> bool { + let t = app.tokens; + let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0; + let r = Rect::from_min_size(pos2(*x, y - 9.0), vec2(w, 18.0)); + let resp = ui.interact(r, egui::Id::new(("graph-btn", id)), Sense::click()).on_hover_text(tip); + p.rect_filled( + r, + 3.0, + if on { + t.accent.gamma_multiply(0.55) + } else if resp.hovered() { + t.hover + } else { + Color32::from_rgb(0x2c, 0x2c, 0x2c) + }, + ); + p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.0), if on { Color32::WHITE } else { t.text }); + app.auto.add(&format!("timeline.graph.{id}"), r, tip); + *x += w + 4.0; + resp.clicked() +} + +/// Draw the Graph Editor into `area` (the right side of the Timeline) and queue edits. +pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, ectx: &EvalCtx, tm: TMap, area: Rect, actions: &mut Actions) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let footer_h = 28.0; + let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h)); + let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max); + // Background first so keys and handles (added later) sit on top for hit-testing. + let bg = ui.interact(plot, egui::Id::new("graph-bg"), Sense::click_and_drag()); + p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d)); + app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone()); + let speed = app.ui.timeline.graph_mode == "speed"; + let cs = curves(app, comp, ectx, tm, plot, speed); + + // Vertical range: auto-zoom to the curves + handles, or the stored manual range. + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for c in &cs { + for (_, v) in &c.pts { + lo = lo.min(*v); + hi = hi.max(*v); + } + for k in &c.keys { + for v in std::iter::once(k.v).chain(k.handles.iter().map(|h| h.v)) { + lo = lo.min(v); + hi = hi.max(v); + } + } + } + if !lo.is_finite() { + (lo, hi) = (0.0, 100.0); + } + if speed { + lo = lo.min(0.0); + } + if (hi - lo).abs() < 1e-9 { + hi = lo + 1.0; + } + let pad = (hi - lo) * 0.08; + let auto = (lo - pad, hi + pad); + // While a key or handle is being dragged the scale stays put (no feedback loop). + let drag_id = egui::Id::new("graph-dragging"); + let dragging = ctx.data(|d| d.get_temp::(drag_id)).unwrap_or(false) && ctx.input(|i| i.pointer.any_down()); + let (lo, hi) = if app.ui.timeline.graph_auto_zoom && !dragging { auto } else { app.ui.timeline.graph_range.unwrap_or(auto) }; + if app.ui.timeline.graph_auto_zoom && !dragging { + app.ui.timeline.graph_range = Some((lo, hi)); + } + let ymap = |v: f64| plot.max.y - ((v - lo) / (hi - lo)) as f32 * plot.height(); + let vmap = |y: f32| lo + (plot.max.y - y) as f64 / plot.height() as f64 * (hi - lo); + + // Grid + value labels. + let pc = p.with_clip_rect(plot); + let step = nice_step((hi - lo) / 6.0); + let mut gv = (lo / step).ceil() * step; + while gv <= hi { + let y = ymap(gv); + pc.line_segment([pos2(plot.min.x, y), pos2(plot.max.x, y)], Stroke::new(1.0, Color32::from_white_alpha(12))); + pc.text(pos2(plot.min.x + 4.0, y - 1.0), Align2::LEFT_BOTTOM, fmt(gv, step), Tokens::ui(10.0), t.text_faint); + gv += step; + } + if cs.is_empty() { + p.text(plot.center(), Align2::CENTER_CENTER, "Select animated properties to show them in the Graph Editor", Tokens::ui(12.0), t.text_faint); + } + + // Curves. + for c in &cs { + let pts: Vec = c.pts.iter().map(|(ct, v)| pos2(tm.x(*ct), ymap(*v))).collect(); + pc.add(egui::Shape::line(pts, Stroke::new(1.5, c.color))); + } + // Keys and handles. + let mut key_screens: Vec<(serde_json::Value, Pos2)> = vec![]; + let fr = comp.frame_rate; + for c in &cs { + let (lid, uid, d) = (c.layer.id, c.prop.uid, c.dim); + for k in &c.keys { + let kp = pos2(tm.x(k.t), ymap(k.v)); + if !plot.expand(6.0).contains(kp) { + continue; + } + let kjson = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds()}); + key_screens.push((kjson.clone(), kp)); + // Handles (selected keys). + for h in &k.handles { + let hp = pos2(tm.x(h.t), ymap(h.v)); + pc.line_segment([kp, hp], Stroke::new(1.0, Color32::from_rgb(0xd8, 0xd8, 0x60))); + pc.circle_filled(hp, 3.5, Color32::from_rgb(0xd8, 0xd8, 0x60)); + let side = if h.out { "out" } else { "in" }; + let hr = Rect::from_center_size(hp, vec2(10.0, 10.0)); + let hresp = ui.interact(hr, egui::Id::new(("ghandle", uid, d, k.idx, side)), Sense::drag()); + app.auto.add(&format!("timeline.graph.handle.{uid}.{d}.{}.{side}", k.idx), hr, "Bezier handle"); + if hresp.dragged() + && let Some(pt) = hresp.interact_pointer_pos() + { + ctx.data_mut(|dd| dd.insert_temp(drag_id, true)); + let mut dt = (tm.t(pt.x) - k.t) * if h.out { 1.0 } else { -1.0 }; + dt = dt.max(h.dur * 0.001); + let influence = (dt / h.dur.max(1e-9) * 100.0).clamp(0.1, 100.0); + let sp = if speed { + let mag = vmap(pt.y).max(0.0); + if h.speed < 0.0 { -mag } else { mag } + } else { + let dv = vmap(pt.y) - k.v; + dv / dt * if h.out { 1.0 } else { -1.0 } + }; + let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "side": side, "speed": sp, "influence": influence, "merge": format!("ghandle-{uid}-{}", k.idx)}); + if c.editable { + params["dim"] = json!(d); + } + actions.push(("keys.setEase".into(), params)); + } + if hresp.drag_stopped() { + ctx.data_mut(|dd| dd.insert_temp(drag_id, false)); + actions.push(("__endMerge".into(), json!({}))); + } + } + // The key itself. + let col = if k.sel { t.keyframe_selected } else { Color32::from_gray(0xe0) }; + pc.rect_filled(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, col); + pc.rect_stroke(Rect::from_center_size(kp, vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, c.color), StrokeKind::Outside); + let kr = Rect::from_center_size(kp, vec2(11.0, 11.0)); + let kresp = ui.interact(kr, egui::Id::new(("gkey", uid, d, k.idx)), Sense::click_and_drag()); + app.auto.add(&format!("timeline.graph.key.{uid}.{d}.{}", k.idx), kr, &c.prop.name); + let shift = ui.input(|i| i.modifiers.shift); + if (kresp.clicked() || kresp.drag_started()) && (!k.sel || shift) { + actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": shift}))); + } + if kresp.dragged() + && let Some(pt) = kresp.interact_pointer_pos() + { + ctx.data_mut(|dd| dd.insert_temp(drag_id, true)); + let merge = format!("gkey-{uid}-{}", k.idx); + let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(tm.t(pt.x).max(0.0)))); + let mut params = json!({"layer": lid.0, "prop": uid, "time": k.time.seconds(), "newTime": nt.seconds(), "merge": merge}); + if !speed && c.editable { + let mut comps = c.prop.keys[k.idx].value.components(); + if d < comps.len() { + comps[d] = vmap(pt.y); + } + params["value"] = if comps.len() == 1 { json!(comps[0]) } else { json!(comps) }; + } + actions.push(("keys.set".into(), params)); + } + if kresp.drag_stopped() { + ctx.data_mut(|dd| dd.insert_temp(drag_id, false)); + actions.push(("__endMerge".into(), json!({}))); + } + } + } + + // Background: click deselects keys, drag box-selects. + if bg.clicked() { + actions.push(("keys.select".into(), json!({"keys": []}))); + } + if bg.dragged() + && let (Some(o), Some(cur)) = (ctx.input(|i| i.pointer.press_origin()), bg.interact_pointer_pos()) + { + let br = Rect::from_two_pos(o, cur); + pc.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12)); + pc.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("graph-box"), br)); + } + if bg.drag_stopped() + && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::new("graph-box"))) + { + ctx.data_mut(|d| d.remove::(egui::Id::new("graph-box"))); + let keys: Vec = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect(); + actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)}))); + } + // Wheel over the graph with Alt/Ctrl zooms the value axis (turns auto-zoom off). + if ui.rect_contains_pointer(plot) { + let (dy, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.ctrl || i.modifiers.command)); + if zoom && dy.abs() > 0.0 { + let k = (-dy as f64 / 200.0).exp(); + let mid = (lo + hi) / 2.0; + app.ui.timeline.graph_auto_zoom = false; + app.ui.timeline.graph_range = Some((mid - (mid - lo) * k, mid + (hi - mid) * k)); + } + } + + // Button bar. + p.rect_filled(bar, 0.0, t.panel_bg); + p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator)); + let y = bar.center().y; + let mut x = bar.min.x + 8.0; + let tl = &app.ui.timeline; + let (is_value, show_sel, auto_on) = (tl.graph_mode != "speed", tl.graph_show_selected, tl.graph_auto_zoom); + if text_button(app, ui, p, &mut x, y, "Value", is_value, "valueGraph", "Edit Value Graph") { + app.ui.timeline.graph_mode = "value".into(); + } + if text_button(app, ui, p, &mut x, y, "Speed", !is_value, "speedGraph", "Edit Speed Graph") { + app.ui.timeline.graph_mode = "speed".into(); + } + x += 6.0; + if text_button(app, ui, p, &mut x, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") { + app.ui.timeline.graph_show_selected = !show_sel; + } + if text_button(app, ui, p, &mut x, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") { + app.ui.timeline.graph_auto_zoom = !auto_on; + } + if text_button(app, ui, p, &mut x, y, "Fit", false, "fit", "Fit all graphs to view") { + app.ui.timeline.graph_auto_zoom = true; + app.ui.timeline.pps = None; + } + if text_button(app, ui, p, &mut x, y, "Fit Selection", false, "fitSelection", "Fit selection to view") { + fit_selection(app, &cs, tm, plot); + } + x += 6.0; + let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| { + let layer = comp.layer(*l)?; + let tr = layer.transform()?; + (tr.get("position").is_some_and(|p| p.uid == *u) || ["positionX", "positionY", "positionZ"].iter().any(|m| tr.get(m).is_some_and(|p| p.uid == *u))) + .then_some(l.0) + }); + if text_button(app, ui, p, &mut x, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)") + && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0)) + { + actions.push(("prop.separateDimensions".into(), json!({"layer": l}))); + } + x += 6.0; + for (label, cmd, params, tip) in [ + ("Hold", "keys.interpolation", json!({"interpolation": "hold"}), "Convert selected keyframes to Hold"), + ("Linear", "keys.interpolation", json!({"interpolation": "linear"}), "Convert selected keyframes to Linear"), + ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"}), "Convert selected keyframes to Auto Bezier"), + ("Easy Ease", "keys.easyEase", json!({}), "Easy Ease (F9)"), + ("Ease In", "keys.easyEaseIn", json!({}), "Easy Ease In (Shift+F9)"), + ("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"), + ] { + let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or(""); + if text_button(app, ui, p, &mut x, y, label, false, &id, tip) { + actions.push((cmd.into(), params)); + } + } +} + +/// Zoom time and value to the selected keys (and their handles). +fn fit_selection(app: &mut EffectcraftApp, cs: &[Curve], tm: TMap, plot: Rect) { + let mut t0 = f64::INFINITY; + let mut t1 = f64::NEG_INFINITY; + let mut v0 = f64::INFINITY; + let mut v1 = f64::NEG_INFINITY; + for c in cs { + for k in c.keys.iter().filter(|k| k.sel) { + for (t, v) in std::iter::once((k.t, k.v)).chain(k.handles.iter().map(|h| (h.t, h.v))) { + t0 = t0.min(t); + t1 = t1.max(t); + v0 = v0.min(v); + v1 = v1.max(v); + } + } + } + if !t0.is_finite() { + return; + } + let span = (t1 - t0).max(0.2); + let pps = (plot.width() as f64 * 0.8 / span).clamp(5.0, 4000.0); + app.ui.timeline.pps = Some(pps); + app.ui.timeline.start = (t0 - span * 0.1).max(0.0); + let pad = ((v1 - v0) * 0.15).max(1.0); + app.ui.timeline.graph_auto_zoom = false; + app.ui.timeline.graph_range = Some((v0 - pad, v1 + pad)); + let _ = tm; +} + +fn nice_step(raw: f64) -> f64 { + let raw = raw.abs().max(1e-9); + let mag = 10f64.powf(raw.log10().floor()); + let n = raw / mag; + mag * if n < 1.5 { + 1.0 + } else if n < 3.5 { + 2.0 + } else if n < 7.5 { + 5.0 + } else { + 10.0 + } +} + +fn fmt(v: f64, step: f64) -> String { + let dec = if step >= 1.0 { 0 } else { (-step.log10()).ceil() as usize }; + let s = format!("{v:.dec$}"); + if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { s.trim_start_matches('-').to_string() } else { s } +} diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs new file mode 100644 index 00000000..52190f0d --- /dev/null +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -0,0 +1,287 @@ +//! Animation dialogs: Keyframe Velocity, Keyframe Interpolation and Time Stretch. Each one is +//! pre-filled from the engine (`keys.info`, the layer) and commits through one engine command. + +use egui::{Color32, vec2}; +use serde_json::{Value, json}; + +use crate::theme::Tokens; +use crate::{Dialog, EffectcraftApp}; + +#[derive(Clone, Debug, Default)] +pub struct VelocityDraft { + pub dims: usize, + pub units: String, + pub name: String, + pub in_speed: Vec, + pub in_inf: Vec, + pub out_speed: Vec, + pub out_inf: Vec, + /// Which sides exist (first key has no incoming side, last none outgoing). + pub has_in: bool, + pub has_out: bool, + pub continuous: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct InterpDraft { + /// Index into TEMPORAL / SPATIAL / ROVING (0 = Current Settings). + pub temporal: usize, + pub spatial: usize, + pub roving: usize, + pub has_spatial: bool, + pub count: usize, +} + +#[derive(Clone, Debug, Default)] +pub struct StretchDraft { + pub percent: f64, + pub duration: f64, + /// 0 = Layer In-point, 1 = Current Frame, 2 = Layer Out-point. + pub hold: usize, + pub orig_duration: f64, + pub orig_percent: f64, +} + +pub const TEMPORAL: [(&str, &str); 6] = [ + ("Current Settings", ""), + ("Linear", "linear"), + ("Bezier", "bezier"), + ("Continuous Bezier", "continuousBezier"), + ("Auto Bezier", "autoBezier"), + ("Hold", "hold"), +]; +pub const SPATIAL: [(&str, &str); 5] = + [("Current Settings", ""), ("Linear", "linear"), ("Bezier", "bezier"), ("Continuous Bezier", "continuousBezier"), ("Auto Bezier", "autoBezier")]; +pub const ROVING: [&str; 3] = ["Current Settings", "Lock To Time", "Rove Across Time"]; + +fn num(v: &Value) -> f64 { + v.as_f64().unwrap_or(0.0) +} + +/// Animation ▸ Keyframe Velocity… (⇧⌘K) with keys selected. +pub fn open_velocity(app: &mut EffectcraftApp) -> Result<(), String> { + let info = app.session.execute("keys.info", json!({})).map_err(|e| e.to_string())?; + let first = info.as_array().and_then(|a| a.first()).ok_or("select keyframes first")?; + let dims = first["dims"].as_u64().unwrap_or(1).max(1) as usize; + let side = |k: &str| -> (Vec, Vec, bool) { + let a = first[k].as_array().cloned().unwrap_or_default(); + let has = a.iter().any(|e| !e.is_null()); + let sp = (0..dims).map(|d| a.get(d).map(|e| num(&e["speed"])).unwrap_or(0.0)).collect(); + let inf = (0..dims).map(|d| a.get(d).map(|e| num(&e["influence"])).filter(|v| *v > 0.0).unwrap_or(33.33)).collect(); + (sp, inf, has) + }; + let (in_speed, in_inf, has_in) = side("inEase"); + let (out_speed, out_inf, has_out) = side("outEase"); + app.dialog_state.velocity = VelocityDraft { + dims, + units: first["units"].as_str().unwrap_or("units/sec").into(), + name: first["name"].as_str().unwrap_or("").into(), + in_speed, + in_inf, + out_speed, + out_inf, + has_in, + has_out, + continuous: first["continuous"].as_bool().unwrap_or(false), + }; + app.dialog = Some(Dialog::KeyVelocity); + Ok(()) +} + +/// Animation ▸ Keyframe Interpolation… (⌥⌘K). +pub fn open_interpolation(app: &mut EffectcraftApp) -> Result<(), String> { + let info = app.session.execute("keys.info", json!({})).map_err(|e| e.to_string())?; + let a = info.as_array().cloned().unwrap_or_default(); + if a.is_empty() { + return Err("select keyframes first".into()); + } + app.dialog_state.interp = InterpDraft { has_spatial: a.iter().any(|k| !k["spatial"].is_null()), count: a.len(), ..Default::default() }; + app.dialog = Some(Dialog::KeyInterpolation); + Ok(()) +} + +/// Layer ▸ Time ▸ Time Stretch… +pub fn open_time_stretch(app: &mut EffectcraftApp) -> Result<(), String> { + let lid = *app.session.state.selected_layers.first().ok_or("select a layer first")?; + let l = app.session.active_comp().and_then(|c| c.layer(lid)).ok_or("no layer")?; + let dur = (l.out_point - l.in_point).seconds(); + app.dialog_state.stretch = StretchDraft { percent: l.stretch, duration: dur, hold: 0, orig_duration: dur, orig_percent: l.stretch }; + app.dialog = Some(Dialog::TimeStretch); + Ok(()) +} + +fn buttons(ui: &mut egui::Ui, app: &mut EffectcraftApp, t: &Tokens, prefix: &str) -> (bool, bool) { + let (mut ok, mut cancel) = (false, false); + ui.add_space(14.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let r = ui.add(egui::Button::new(egui::RichText::new(" OK ").color(Color32::WHITE)).fill(t.accent)); + app.auto.add(&format!("{prefix}.ok"), r.rect, "OK"); + ok = r.clicked(); + let r = ui.button("Cancel"); + app.auto.add(&format!("{prefix}.cancel"), r.rect, "Cancel"); + cancel = r.clicked(); + }); + (ok, cancel) +} + +fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, speed: f64, range: std::ops::RangeInclusive, suffix: &str, enabled: bool) { + let r = ui.add_enabled(enabled, egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix)); + app.auto.add(id, r.rect, id); +} + +pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.velocity.clone(); + let (mut ok, mut cancel) = (false, false); + let title = if d.name.is_empty() { "Keyframe Velocity".to_string() } else { format!("Keyframe Velocity: {}", d.name) }; + let h = 250.0 + 2.0 * 26.0 * d.dims as f32; + super::dialogs::modal(ctx, &title, vec2(440.0, h), t, |ui| { + for (label, out) in [("Incoming Velocity", false), ("Outgoing Velocity", true)] { + ui.label(egui::RichText::new(label).font(Tokens::medium(12.5))); + let enabled = if out { d.has_out } else { d.has_in }; + egui::Grid::new(("vel", out)).num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| { + for dim in 0..d.dims { + let tag = if d.dims > 1 { format!("Dimension {}", ["X", "Y", "Z", "W"][dim.min(3)]) } else { String::new() }; + ui.label(egui::RichText::new(tag).color(t.text_dim)); + let side = if out { "out" } else { "in" }; + ui.horizontal(|ui| { + ui.label("Speed:"); + let v = if out { &mut d.out_speed[dim] } else { &mut d.in_speed[dim] }; + drag(ui, app, &format!("dialog.velocity.{side}Speed.{dim}"), v, 1.0, -1e7..=1e7, "", enabled); + ui.label(egui::RichText::new(&d.units).color(t.text_dim)); + }); + ui.horizontal(|ui| { + ui.label("Influence:"); + let v = if out { &mut d.out_inf[dim] } else { &mut d.in_inf[dim] }; + drag(ui, app, &format!("dialog.velocity.{side}Influence.{dim}"), v, 0.2, 0.1..=100.0, " %", enabled); + }); + ui.end_row(); + } + }); + ui.add_space(8.0); + } + let r = ui.checkbox(&mut d.continuous, "Continuous (Lock Outgoing to Incoming)"); + app.auto.add("dialog.velocity.continuous", r.rect, "Continuous"); + if d.continuous { + d.out_speed = d.in_speed.clone(); + } + (ok, cancel) = buttons(ui, app, t, "dialog.velocity"); + }); + app.dialog_state.velocity = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + let mut params = json!({"continuous": d.continuous}); + if d.has_in { + params["inSpeed"] = json!(d.in_speed); + params["inInfluence"] = json!(d.in_inf); + } + if d.has_out { + params["outSpeed"] = json!(d.out_speed); + params["outInfluence"] = json!(d.out_inf); + } + if let Err(e) = app.session.execute("keys.velocity", params) { + app.ui.status = e.to_string(); + } + cancel = true; + } + if cancel { + app.dialog = None; + } +} + +fn combo(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, sel: &mut usize, opts: &[&str], enabled: bool) { + ui.add_enabled_ui(enabled, |ui| { + let r = egui::ComboBox::from_id_salt(id).width(200.0).selected_text(opts[*sel]).show_ui(ui, |ui| { + for (i, o) in opts.iter().enumerate() { + ui.selectable_value(sel, i, *o); + } + }); + app.auto.add(id, r.response.rect, id); + }); +} + +pub fn interpolation(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.interp.clone(); + let (mut ok, mut cancel) = (false, false); + super::dialogs::modal(ctx, "Keyframe Interpolation", vec2(440.0, 250.0), t, |ui| { + egui::Grid::new("interp-grid").num_columns(2).spacing([14.0, 10.0]).show(ui, |ui| { + ui.label("Temporal Interpolation:"); + combo(ui, app, "dialog.interpolation.temporal", &mut d.temporal, &TEMPORAL.map(|x| x.0), true); + ui.end_row(); + ui.label("Spatial Interpolation:"); + combo(ui, app, "dialog.interpolation.spatial", &mut d.spatial, &SPATIAL.map(|x| x.0), d.has_spatial); + ui.end_row(); + ui.label("Roving:"); + combo(ui, app, "dialog.interpolation.roving", &mut d.roving, &ROVING, d.has_spatial); + ui.end_row(); + }); + ui.add_space(6.0); + ui.label(egui::RichText::new(format!("{} keyframe{} selected", d.count, if d.count == 1 { "" } else { "s" })).color(t.text_dim)); + (ok, cancel) = buttons(ui, app, t, "dialog.interpolation"); + }); + app.dialog_state.interp = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + let mut params = json!({}); + if d.temporal > 0 { + params["interpolation"] = json!(TEMPORAL[d.temporal].1); + } + if d.spatial > 0 && d.has_spatial { + params["spatial"] = json!(SPATIAL[d.spatial].1); + } + if d.roving > 0 && d.has_spatial { + params["roving"] = json!(d.roving == 2); + } + if params.as_object().is_some_and(|m| !m.is_empty()) + && let Err(e) = app.session.execute("keys.interpolation", params) + { + app.ui.status = e.to_string(); + } + cancel = true; + } + if cancel { + app.dialog = None; + } +} + +pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { + let mut d = app.dialog_state.stretch.clone(); + let (mut ok, mut cancel) = (false, false); + super::dialogs::modal(ctx, "Time Stretch", vec2(420.0, 300.0), t, |ui| { + ui.label(egui::RichText::new("Stretch").font(Tokens::medium(12.5))); + egui::Grid::new("stretch-grid").num_columns(2).spacing([14.0, 8.0]).show(ui, |ui| { + ui.label("Original Duration:"); + ui.label(format!("{:.2} s", d.orig_duration)); + ui.end_row(); + ui.label("Stretch Factor:"); + let before = d.percent; + drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, 0.5, -10000.0..=10000.0, " %", true); + if (before - d.percent).abs() > 1e-9 && d.orig_percent.abs() > 1e-9 { + d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs(); + } + ui.end_row(); + ui.label("New Duration:"); + let before = d.duration; + drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, 0.05, 0.01..=100000.0, " s", true); + if (before - d.duration).abs() > 1e-9 && d.orig_duration > 1e-9 { + d.percent = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration; + } + ui.end_row(); + }); + ui.add_space(10.0); + ui.label(egui::RichText::new("Hold In Place").font(Tokens::medium(12.5))); + for (i, l) in ["Layer In-point", "Current Frame", "Layer Out-point"].into_iter().enumerate() { + let r = ui.radio_value(&mut d.hold, i, l); + app.auto.add(&format!("dialog.timeStretch.hold.{i}"), r.rect, l); + } + (ok, cancel) = buttons(ui, app, t, "dialog.timeStretch"); + }); + app.dialog_state.stretch = d.clone(); + if ok || super::dialogs::ui_enter(ctx) { + let hold = ["in", "current", "out"][d.hold.min(2)]; + if let Err(e) = app.session.execute("layer.timeStretch", json!({"percent": d.percent, "hold": hold})) { + app.ui.status = e.to_string(); + } + cancel = true; + } + if cancel { + app.dialog = None; + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 58789fe7..5321d849 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -4,7 +4,9 @@ pub mod comp_settings; pub mod dialogs; pub mod effect_controls; pub mod effects_presets; +pub mod graph; pub mod info; +pub mod key_dialogs; pub mod misc; pub mod project; pub mod properties; @@ -35,11 +37,8 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String { let fr = comp.frame_rate; match session.project.settings.time_display { TimeDisplayStyle::Frames => format!("{:05}", fr.frame_at(t) + session.project.settings.frame_start), - TimeDisplayStyle::Timecode => { - let df = fr.supports_drop_frame(); - let s = effectcraft_engine::time::format_timecode_frames(fr.frame_at(t + comp.display_start), fr, df); - if df { s } else { s.replace(';', ":") } - } + // AE's display: `0:00:02:15` (non-drop-frame counting, as AE shows new comps). + TimeDisplayStyle::Timecode => effectcraft_engine::time::format_timecode_ae(fr.frame_at(t + comp.display_start), fr, false), } } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 0a453942..721540f0 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -240,10 +240,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if resp.double_clicked() { match &it.kind { ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))), - ItemKind::Folder => { - if !app.ui.project_open_folders.remove(&id.0) { - app.ui.project_open_folders.insert(id.0); - } + ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => { + app.ui.project_open_folders.insert(id.0); } _ => {} } diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 53b0cdc8..c83c9de8 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -21,8 +21,54 @@ use crate::{EffectcraftApp, widgets}; #[derive(Clone, Debug)] enum RowKind { Layer, - Group { uid: u64, name: String, open: bool, has_children: bool, fx: Option }, - Prop { uid: u64 }, + Group { + uid: u64, + name: String, + open: bool, + has_children: bool, + fx: Option, + }, + Prop { + uid: u64, + }, + /// Inline expression editor under a property with an expression. + Expr { + uid: u64, + lines: usize, + }, +} + +/// Row height (expression editors grow with their text). +fn row_height(row: &Row, rh: f32) -> f32 { + match row.kind { + RowKind::Expr { lines, .. } => rh * lines.clamp(1, 8) as f32 + 6.0, + _ => rh, + } +} + +/// Insert an expression editor row after every property that has an expression. +fn with_expr_rows(rows: Vec, comp: &Comp, closed: &std::collections::BTreeSet) -> Vec { + let mut out = Vec::with_capacity(rows.len()); + for r in rows { + let extra = match r.kind { + RowKind::Prop { uid } if !closed.contains(&uid) => comp.layer(r.layer).and_then(|l| l.props.find(uid)).and_then(|p| p.expr.as_ref()).map(|e| Row { + layer: r.layer, + depth: r.depth, + kind: RowKind::Expr { uid, lines: e.text.lines().count().max(1) }, + }), + _ => None, + }; + out.push(r); + out.extend(extra); + } + out +} + +/// A pick-whip drag in progress: (parent = 0 / property = 1, layer, prop uid, start point). +type PickWhip = (u8, u64, u64, Pos2); + +fn pick_whip_id() -> egui::Id { + egui::Id::new("tl-pickwhip") } #[derive(Clone, Debug)] @@ -42,7 +88,6 @@ struct Cols { label: f32, num: f32, name: f32, - name_w: f32, switches: f32, mode: f32, trkmat: f32, @@ -61,22 +106,22 @@ fn cols(x0: f32, width: f32, show_modes: bool) -> Cols { let mode = switches + SW * 8.0 + 6.0; let trkmat = mode + if show_modes { 92.0 } else { 0.0 }; let parent = trkmat + if show_modes { 112.0 } else { 0.0 }; - Cols { av, label, num, name, name_w, switches, mode, trkmat, parent, end: parent + 116.0 } + Cols { av, label, num, name, switches, mode, trkmat, parent, end: parent + 116.0 } } /// Timeline horizontal mapping. #[derive(Clone, Copy, Debug)] -struct TMap { - x0: f32, - start: f64, - pps: f64, +pub(crate) struct TMap { + pub x0: f32, + pub start: f64, + pub pps: f64, } impl TMap { - fn x(&self, secs: f64) -> f32 { + pub fn x(&self, secs: f64) -> f32 { self.x0 + ((secs - self.start) * self.pps) as f32 } - fn t(&self, x: f32) -> f64 { + pub fn t(&self, x: f32) -> f64 { self.start + (x - self.x0) as f64 / self.pps } } @@ -141,6 +186,10 @@ fn prop_visible(p: &Property, layer: &Layer) -> bool { if p.two_d_only && layer.is_3d() { return false; } + // Separate Dimensions: X/Y/Z Position replace Position. + if p.match_id == "position" && layer.transform().is_some_and(|tr| tr.get("positionX").is_some() && tr.get("position").is_some_and(|q| q.uid == p.uid)) { + return false; + } true } @@ -168,7 +217,7 @@ fn build_rows(app: &EffectcraftApp, comp: &Comp) -> Vec { } if let Some(kind) = tl.reveal.first() { let (group, props): (&str, Vec<&str>) = match kind.as_str() { - "position" => ("transform", vec!["position"]), + "position" => ("transform", vec!["position", "positionX", "positionY", "positionZ"]), "scale" => ("transform", vec!["scale"]), "rotation" => ("transform", vec!["rotation", "rotationX", "rotationY", "orientation"]), "opacity" => ("transform", vec!["opacity"]), @@ -322,7 +371,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ruler_h = 34.0; let colhdr_h = 22.0; let footer_h = 24.0; - let left_w = (rect.width() * 0.5).clamp(420.0, 860.0); + let fixed = AV_W + LABEL_W + NUM_W + SW * 8.0 + if app.ui.timeline.show_modes { 92.0 + 112.0 } else { 0.0 } + 116.0 + 6.0; + let left_w = (fixed + 190.0).clamp(420.0, (rect.width() * 0.62).max(420.0)); let cw = cols(rect.min.x, left_w, app.ui.timeline.show_modes); let graph_x0 = rect.min.x + left_w + 1.0; let graph_x1 = rect.max.x - 10.0; @@ -346,7 +396,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0)); let tc_resp = ui.interact(tc_rect, egui::Id::new("tl-timecode"), Sense::click_and_drag()); p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(20.0), t.timecode); - let sub = format!("{} ({:.2} fps)", fr.frame_at(time), fr.as_f64()); + let sub = format!("{:05} ({:.2} fps)", fr.frame_at(time) + app.session.project.settings.frame_start, fr.as_f64()); p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, sub, Tokens::ui(10.5), t.text_faint); app.auto.add("timeline.timecode", tc_rect, &tc); if tc_resp.dragged() { @@ -439,7 +489,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let fd = comp.frame_duration().seconds(); let cy0 = ruler.min.y + 11.0; let mut run: Option<(i64, i64)> = None; - let mut draw_run = |a: i64, b: i64| { + let draw_run = |a: i64, b: i64| { let x0 = tm.x(a as f64 * fd); let x1 = tm.x((b + 1) as f64 * fd); p.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_green); @@ -458,14 +508,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { draw_run(a, b); } // Ticks + labels. - let secs_per_label = [0.0333, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0, 60.0].into_iter().find(|s| s * pps >= 70.0).unwrap_or(60.0); + // Label spacing in whole frames (AE: `00:15f`-style seconds:frames labels). + let fps_i = fr.as_f64().round().max(1.0) as i64; + let half = (fps_i / 2).max(1); + let label_frames = [1, 2, 5, 10, half, fps_i, 2 * fps_i, 5 * fps_i, 10 * fps_i, 30 * fps_i, 60 * fps_i] + .into_iter() + .find(|f| *f as f64 * fd * pps >= 52.0) + .unwrap_or(60 * fps_i); + let secs_per_label = label_frames as f64 * fd; let first = (tm.start / secs_per_label).floor() * secs_per_label; let mut s = first; let pr = p.with_clip_rect(Rect::from_min_max(pos2(graph_x0, ruler.min.y), ruler.max)); while s <= tm.t(graph_x1) + secs_per_label { let x = tm.x(s); pr.line_segment([pos2(x, ruler.max.y - 10.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick)); - let label = if secs_per_label < 1.0 { format!("{:02}f", fr.frame_at(Tick::from_seconds_f64(s + 1e-9))) } else { format!("{:02}s", s.round() as i64) }; + let fno = (s / fd).round() as i64; + let label = if fno >= 60 * fps_i * 60 { + format!("{}:{:02}:{:02}f", fno / (fps_i * 60), (fno / fps_i) % 60, fno % fps_i) + } else { + format!("{:02}:{:02}f", fno / fps_i, fno % fps_i) + }; pr.text(pos2(x + 3.0, ruler.max.y - 16.0), Align2::LEFT_CENTER, label, Tokens::ui(10.0), t.tl_ruler_text); for k in 1..5 { let xx = tm.x(s + secs_per_label * k as f64 / 5.0); @@ -520,9 +582,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = cw.end; // ---- rows. - let rows = build_rows(app, &comp); + let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline.expr_closed); let rh = t.row_h; - let total_h = rows.len() as f32 * rh; + let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum(); let max_scroll = (total_h - rows_rect.height() + rh).max(0.0); if ui.rect_contains_pointer(rows_rect) { let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt)); @@ -548,16 +610,27 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut ui_actions: Vec = Vec::new(); let idx_of = |id: LayerId| comp.index_of(id).unwrap_or(0); let graph_on = app.ui.timeline.graph_editor; + let full_clip = ui.clip_rect(); + let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y))); + let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)); + // Registered before the rows so keys, bars and editors on top of it get the pointer first. + let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) }; + let empty = ui.interact(empty_rect, egui::Id::new("tl-graph-bg"), Sense::click_and_drag()); + let mut hit_rows: Vec<(Rect, Row)> = vec![]; for (ri, row) in rows.iter().enumerate() { - let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), rh)); - y += rh; + // Outline widgets never spill into the time graph (narrow timelines). + ui.set_clip_rect(left_clip); + let hh = row_height(row, rh); + let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), hh)); + y += hh; if r.max.y < rows_rect.min.y || r.min.y > rows_rect.max.y { continue; } + hit_rows.push((r, row.clone())); let Some(layer) = comp.layer(row.layer) else { continue }; let is_sel = selected.contains(&layer.id); let left = Rect::from_min_max(r.min, pos2(graph_x0 - 1.0, r.max.y)); - let cy = r.center().y; + let cy = r.min.y + rh / 2.0; match &row.kind { RowKind::Layer => { lp.rect_filled( @@ -739,7 +812,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } // Parent. - let pr_rect = Rect::from_min_size(pos2(cw.parent + 2.0, cy - 9.0), vec2(110.0, 18.0)); + let pw_rect = Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)); + let pw = ui.interact(pw_rect, egui::Id::new(("parent-whip", layer.id.0)), Sense::drag()).on_hover_text("Parent pick whip: drag onto a layer"); + icons::paint(&lp, pw_rect.shrink(1.0), Icon::PickWhip, if pw.hovered() || pw.dragged() { t.text } else { t.text_dim }); + app.auto.add(&format!("timeline.layer.{}.pickWhip", layer.id.0), pw_rect, "Parent pick whip"); + if pw.drag_started() { + let pw_state: PickWhip = (0, layer.id.0, 0, pw_rect.center()); + ctx.data_mut(|d| d.insert_temp(pick_whip_id(), pw_state)); + } + let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0)); let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", idx_of(l.id), l.name)).unwrap_or_else(|| "None".into()); let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::new(("parent", layer.id.0))); app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent"); @@ -755,6 +836,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par}))); } // Layer bar. + ui.set_clip_rect(right_clip); if !graph_on { let x_in = tm.x(layer.in_point.seconds()); let x_out = tm.x(layer.out_point.seconds()); @@ -785,10 +867,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() { ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } - if body.clicked() || body.drag_started() { - if !is_sel { - actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": ui.input(|i| i.modifiers.shift)}))); - } + if (body.clicked() || body.drag_started()) && !is_sel { + actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": ui.input(|i| i.modifiers.shift)}))); } layer_context_menu(&body, layer, &mut actions); let drag_key = format!("bar-{}", layer.id.0); @@ -875,6 +955,75 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui_actions.push(UiAct::ToggleGroup(*uid)); } } + RowKind::Expr { uid, lines } => { + let Some(prop) = layer.props.find(*uid) else { continue }; + let Some(ex) = prop.expr.clone() else { continue }; + lp.rect_filled(left, 0.0, t.row_alt); + let indent = cw.name + 6.0 + 14.0 * row.depth as f32; + lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, "Expression:", Tokens::ui(11.5), t.text_dim); + lp.text(pos2(indent + 82.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(11.5), t.text); + // "=" enable switch and the property pick whip (AE's expression controls). + let en_r = Rect::from_center_size(pos2(cw.switches + 10.0, cy), vec2(16.0, 16.0)); + let en = ui.interact(en_r, egui::Id::new(("expr-en", uid)), Sense::click()).on_hover_text("Enable Expression"); + lp.rect_stroke(en_r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside); + let expr_col = Color32::from_rgb(0xe8, 0x7c, 0x5c); + lp.text( + en_r.center(), + Align2::CENTER_CENTER, + if ex.enabled { "=" } else { "≠" }, + Tokens::semibold(13.0), + if ex.enabled { expr_col } else { t.text_dim }, + ); + app.auto.add(&format!("timeline.prop.{uid}.exprEnable"), en_r, "Enable Expression"); + if en.clicked() { + actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "enabled": !ex.enabled}))); + } + let pw_rect = Rect::from_center_size(pos2(cw.switches + 30.0, cy), vec2(15.0, 15.0)); + let pw = ui.interact(pw_rect, egui::Id::new(("expr-whip", uid)), Sense::drag()).on_hover_text("Expression pick whip: drag onto a property"); + icons::paint(&lp, pw_rect.shrink(1.0), Icon::PickWhip, if pw.hovered() || pw.dragged() { t.text } else { t.text_dim }); + app.auto.add(&format!("timeline.prop.{uid}.pickWhip"), pw_rect, "Expression pick whip"); + if pw.drag_started() { + let pw_state: PickWhip = (1, layer.id.0, *uid, pw_rect.center()); + ctx.data_mut(|d| d.insert_temp(pick_whip_id(), pw_state)); + } + // Syntax errors: warning in the outline (the expression is disabled). + if let Some(check) = app.session.expr_check + && let Err(e) = check(&ex.text) + { + let wr = Rect::from_center_size(pos2(cw.switches + 50.0, cy), vec2(14.0, 14.0)); + lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30)); + let _ = ui.interact(wr, egui::Id::new(("expr-err", uid)), Sense::hover()).on_hover_text(e); + } + // The editor in the time-graph area. + ui.set_clip_rect(right_clip); + gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, Color32::from_rgb(0x1a, 0x1a, 0x1a)); + let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0)); + let buf_id = egui::Id::new(("expr-buf", uid)); + let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er)); + let resp = child.add( + egui::TextEdit::multiline(&mut buf) + .id(egui::Id::new(("expr-edit", uid))) + .font(Tokens::mono(11.5)) + .text_color(if ex.enabled { expr_col } else { t.text_dim }) + .desired_width(er.width()) + .desired_rows((*lines).clamp(1, 8)) + .frame(egui::Frame::NONE), + ); + app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name); + let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl)); + if commit { + resp.surrender_focus(); + } + if resp.has_focus() && !commit { + ctx.data_mut(|d| d.insert_temp(buf_id, buf)); + } else { + if (resp.lost_focus() || commit) && buf.trim_end() != ex.text { + actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": buf.trim_end()}))); + } + ctx.data_mut(|d| d.remove::(buf_id)); + } + } RowKind::Prop { uid } => { let Some(prop) = layer.props.find(*uid) else { continue }; let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid)); @@ -961,8 +1110,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let value = ectx.value(layer, prop); let vx = (cw.switches + 4.0).max(name_x + 120.0); value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions); + ui.set_clip_rect(right_clip); // Expression text row hint. - if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled) { + if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) { gp.text( pos2(graph_x0 + 8.0, cy), Align2::LEFT_CENTER, @@ -1001,15 +1151,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } kresp.context_menu(|ui| { for (lbl, cmd, params) in [ + ("Copy", "keys.copy", json!({})), + ("Paste", "keys.paste", json!({})), + ("-", "", json!({})), + ("Keyframe Interpolation…", "keys.interpolation", json!({})), + ("Keyframe Velocity…", "keys.velocity", json!({})), + ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), + ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})), + ("-", "", json!({})), ("Easy Ease", "keys.easyEase", json!({})), ("Easy Ease In", "keys.easyEaseIn", json!({})), ("Easy Ease Out", "keys.easyEaseOut", json!({})), - ("Toggle Hold Keyframe", "keys.toggleHold", json!({})), + ("Time-Reverse Keyframes", "keys.timeReverse", json!({})), + ("-", "", json!({})), ("Linear", "keys.interpolation", json!({"interpolation": "linear"})), ("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})), - ("Auto Bezier", "keys.interpolation", json!({"autoBezier": true})), + ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})), + ("Select All Keyframes", "keys.selectAll", json!({})), ("Delete", "keys.delete", json!({})), ] { + if lbl == "-" { + ui.separator(); + continue; + } + if lbl == "Rove Across Time" && !prop.spatial { + continue; + } if ui.button(lbl).clicked() { if !ks { actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}))); @@ -1039,17 +1206,40 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + ui.set_clip_rect(full_clip); + // Pick whips: a line follows the pointer; releasing over a row links to it. + if let Some((kind, src_layer, src_prop, start)) = ctx.data(|d| d.get_temp::(pick_whip_id())) { + let ptr = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(start); + let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("tl-pickwhip-line"))); + fg.line_segment([start, ptr], Stroke::new(1.5, t.accent)); + fg.circle_stroke(ptr, 4.0, Stroke::new(1.5, t.accent)); + let target = hit_rows.iter().find(|(r, _)| r.contains(ptr)).map(|(r, row)| (*r, row.clone())); + if let Some((tr, _)) = &target { + fg.rect_stroke(Rect::from_min_max(tr.min, pos2(graph_x0 - 1.0, tr.max.y)), 0.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } + if ctx.input(|i| !i.pointer.any_down()) { + ctx.data_mut(|d| d.remove::(pick_whip_id())); + match (kind, target) { + (0, Some((_, row))) if row.layer.0 != src_layer => { + actions.push(("layer.setParent".into(), json!({"layers": [src_layer], "parent": row.layer.0}))); + } + (1, Some((_, Row { layer, kind: RowKind::Prop { uid }, .. }))) if uid != src_prop => { + actions.push(("prop.pickWhip".into(), json!({"layer": src_layer, "prop": src_prop, "target": {"layer": layer.0, "prop": uid}}))); + } + _ => {} + } + } + } // Graph editor. if graph_on { - graph_editor(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)); + super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions); } // Empty-area click in the graph: deselect keys; drag: box-select keys. - let empty = ui.interact(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), egui::Id::new("tl-graph-bg"), Sense::click_and_drag()); if empty.clicked() { actions.push(("keys.select".into(), json!({"keys": []}))); } if let (true, Some(origin), Some(cur)) = - (empty.dragged(), empty.interact_pointer_pos().map(|_| ctx.input(|i| i.pointer.press_origin())).flatten(), empty.interact_pointer_pos()) + (empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos()) { let br = Rect::from_two_pos(origin, cur); gp.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12)); @@ -1064,7 +1254,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut yy = rows_rect.min.y - app.ui.timeline.scroll_y; for row in &rows { let cy = yy + rh / 2.0; - yy += rh; + yy += row_height(row, rh); if let RowKind::Prop { uid } = row.kind && cy >= br.min.y && cy <= br.max.y @@ -1140,8 +1330,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_stroke(rows_rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); if ctx.input(|i| i.pointer.any_released()) { let hover_y = ctx.input(|i| i.pointer.hover_pos()).map(|p| p.y).unwrap_or(0.0); - let row_i = ((hover_y - rows_rect.min.y + app.ui.timeline.scroll_y) / rh).floor() as usize; - let target = rows.get(row_i).map(|r| r.layer); + let target = hit_rows.iter().find(|(r, _)| hover_y >= r.min.y && hover_y < r.max.y).map(|(_, row)| row.layer); match payload.as_ref() { crate::panels::DragPayload::Item(id) => actions.push(("layer.addItem".into(), json!({"item": id}))), crate::panels::DragPayload::Effect(e) => { @@ -1188,6 +1377,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } for (id, params) in actions { + if id == "__endMerge" { + app.session.history.merge_key = None; + continue; + } if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) { app.ui.status = e; } @@ -1257,7 +1450,11 @@ fn layer_context_menu(resp: &egui::Response, layer: &Layer, actions: &mut Vec<(S item(ui, "Flip Vertical", "layer.transform", json!({"layers": [id], "op": "flipV"})); }); ui.menu_button("Time", |ui| { - item(ui, "Time-Reverse Layer", "layer.timeStretch", json!({"layers": [id], "op": "reverse"})); + item(ui, "Enable Time Remapping", "layer.enableTimeRemap", json!({"layers": [id]})); + item(ui, "Time-Reverse Layer", "layer.timeReverse", json!({"layers": [id]})); + item(ui, "Time Stretch…", "layer.timeStretch", json!({})); + item(ui, "Freeze Frame", "layer.freezeFrame", json!({"layers": [id]})); + item(ui, "Freeze on Last Frame", "layer.freezeOnLastFrame", json!({"layers": [id]})); }); ui.menu_button("Blending Mode", |ui| { for m in BlendMode::ALL { @@ -1412,86 +1609,3 @@ fn value_editor( } } } - -/// Value graph of the selected properties (numeric dimensions over time) with key markers. -fn graph_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, ectx: &EvalCtx, tm: TMap, area: Rect) { - let t = app.tokens; - let props: Vec<(LayerId, u64)> = if app.session.state.selected_props.is_empty() { - // Default: animated properties of selected layers. - let mut v = vec![]; - for l in comp.layers.iter().filter(|l| app.session.state.selected_layers.contains(&l.id)) { - l.props.walk("", &mut |_, pr| { - if pr.is_animated() && !pr.value.components().is_empty() { - v.push((l.id, pr.uid)); - } - }); - } - v - } else { - app.session.state.selected_props.clone() - }; - p.rect_filled(area, 0.0, Color32::from_rgb(0x1e, 0x1e, 0x1e)); - // Grid. - for i in 1..8 { - let y = area.min.y + area.height() * i as f32 / 8.0; - p.line_segment([pos2(area.min.x, y), pos2(area.max.x, y)], Stroke::new(1.0, Color32::from_white_alpha(10))); - } - if props.is_empty() { - p.text(area.center(), Align2::CENTER_CENTER, "Select animated properties to show them in the Graph Editor", Tokens::ui(12.0), t.text_faint); - return; - } - let colors = - [Color32::from_rgb(0xe0, 0x50, 0x50), Color32::from_rgb(0x60, 0xd0, 0x60), Color32::from_rgb(0x50, 0x8c, 0xf0), Color32::from_rgb(0xe8, 0xc8, 0x40)]; - // Sample all curves over the visible span; normalise to a common range. - let n = ((area.width() / 2.0) as usize).max(2); - let mut curves: Vec<(Vec>, &Layer, &Property)> = vec![]; - let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); - for (lid, uid) in &props { - let Some(l) = comp.layer(*lid) else { continue }; - let Some(pr) = l.props.find(*uid) else { continue }; - let mut dims: Vec> = vec![]; - for i in 0..n { - let x = area.min.x + area.width() * i as f32 / (n - 1) as f32; - let tt = Tick::from_seconds_f64(tm.t(x)); - let v = ectx.at(tt).value(l, pr).components(); - if dims.is_empty() { - dims = vec![Vec::with_capacity(n); v.len().min(3)]; - } - for (d, c) in v.iter().take(dims.len()).enumerate() { - dims[d].push(*c); - lo = lo.min(*c); - hi = hi.max(*c); - } - } - curves.push((dims, l, pr)); - } - if !lo.is_finite() || !hi.is_finite() { - return; - } - if (hi - lo).abs() < 1e-9 { - hi = lo + 1.0; - } - let pad = (hi - lo) * 0.1; - let (lo, hi) = (lo - pad, hi + pad); - let ymap = |v: f64| area.max.y - ((v - lo) / (hi - lo)) as f32 * area.height(); - for (dims, l, pr) in &curves { - for (d, vals) in dims.iter().enumerate() { - let pts: Vec = vals.iter().enumerate().map(|(i, v)| pos2(area.min.x + area.width() * i as f32 / (n - 1) as f32, ymap(*v))).collect(); - p.add(egui::Shape::line(pts, Stroke::new(1.5, colors[d % colors.len()]))); - } - for k in &pr.keys { - let x = tm.x(l.comp_time(k.time).seconds()); - for (d, v) in k.value.components().iter().take(dims.len()).enumerate() { - let c = pos2(x, ymap(*v)); - p.rect_filled(Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, Color32::WHITE); - p.rect_stroke(Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, colors[d % colors.len()]), StrokeKind::Outside); - } - } - } - // Value labels. - for i in 0..=4 { - let v = lo + (hi - lo) * i as f64 / 4.0; - p.text(pos2(area.min.x + 4.0, ymap(v)), Align2::LEFT_BOTTOM, format!("{v:.1}"), Tokens::ui(10.0), t.text_faint); - } - let _ = ui; -} diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index b5915867..2147741d 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -55,13 +55,82 @@ pub fn last_fit(ctx: &egui::Context) -> f32 { /// What a drag in the viewer is doing. #[derive(Clone, Debug)] enum Gesture { - Move { layers: Vec<(LayerId, [f64; 3], Mat3)>, start: [f64; 2] }, - Scale { layer: LayerId, anchor_screen: Pos2, start_scale: [f64; 3], start_local: [f64; 2], inv: Mat3, uniform: bool }, - Rotate { layer: LayerId, center: Pos2, start_angle: f64, start_rot: f64 }, - Anchor { layer: LayerId, start_anchor: [f64; 3], start_pos: [f64; 3], start: [f64; 2], inv: Mat3, l2p: Mat3 }, - Pan { start_pan: [f32; 2] }, - Create { tool: Tool, start: [f64; 2] }, - Marquee { start: Pos2 }, + Move { + layers: Vec<(LayerId, [f64; 3], Mat3)>, + start: [f64; 2], + }, + Scale { + layer: LayerId, + start_scale: [f64; 3], + start_local: [f64; 2], + inv: Mat3, + }, + Rotate { + layer: LayerId, + center: Pos2, + start_angle: f64, + start_rot: f64, + }, + Anchor { + layer: LayerId, + start_anchor: [f64; 3], + start_pos: [f64; 3], + start: [f64; 2], + inv: Mat3, + l2p: Mat3, + }, + Pan { + start_pan: [f32; 2], + }, + Create { + tool: Tool, + start: [f64; 2], + }, + Marquee { + start: Pos2, + }, + /// Dragging selected mask vertices (comp-space point of the previous frame, comp → layer). + Vertices { + last: [f64; 2], + inv: Mat3, + }, + /// Dragging a vertex's Bezier tangent (both sides symmetric unless Alt breaks them). + Tangent { + layer: LayerId, + mask: u64, + index: usize, + out: bool, + vertex: [f64; 2], + inv: Mat3, + }, + /// Pen tool: pulling the tangents of the vertex just placed. + Pen { + layer: LayerId, + mask: u64, + index: usize, + vertex: [f64; 2], + inv: Mat3, + press: Pos2, + }, +} + +/// Pen tool path in progress: (layer, mask uid). +fn pen_id() -> egui::Id { + egui::Id::new("viewer-pen") +} + +/// Mask vertex / tangent hit targets drawn this frame. +#[derive(Clone, Copy)] +struct VertexHit { + layer: LayerId, + mask: u64, + index: usize, + pos: Pos2, + /// None = the vertex itself, Some(out?) = a tangent handle. + tangent: Option, + /// Layer-space vertex position and layer → comp matrix. + vertex: [f64; 2], + l2c: Mat3, } const HANDLE: f32 = 7.0; @@ -251,6 +320,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref() }; let selected = app.session.state.selected_layers.clone(); let mut handle_hits: Vec<(LayerId, usize, Pos2)> = Vec::new(); + let mut vertex_hits: Vec = Vec::new(); + let sel_vertices = app.session.state.selected_vertices.clone(); + // Pen path in progress ends when the tool changes or its mask is gone. + if app.ui.tool != Tool::Pen || ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) { + ui.data_mut(|d| d.remove::<(LayerId, u64)>(pen_id())); + } if app.ui.viewer.show_layer_controls { for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time)) { let col = Tokens::label(l.label); @@ -300,14 +375,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if sp.closed && !pts.is_empty() { pts.push(pts[0]); } - painter.add(egui::Shape::line(pts, Stroke::new(1.0, Color32::from_rgb(color[0], color[1], color[2])))); - for v in &sp.vertices { - let c = m.apply(gv2(v[0], v[1])); - painter.rect_filled( - Rect::from_center_size(map.to_screen([c.x, c.y]), vec2(5.0, 5.0)), - 0.0, - Color32::from_rgb(color[0], color[1], color[2]), - ); + let mc = Color32::from_rgb(color[0], color[1], color[2]); + painter.add(egui::Shape::line(pts, Stroke::new(1.0, mc))); + let scr = |p: [f64; 2]| { + let c = m.apply(gv2(p[0], p[1])); + map.to_screen([c.x, c.y]) + }; + for (i, v) in sp.vertices.iter().enumerate() { + let s = scr(*v); + let selected = sel_vertices.iter().any(|x| x.layer == l.id && x.mask == g.uid && x.index == i); + // Selected vertices: filled with their tangent handles; others hollow. + if selected { + for (out, tg) in [(false, sp.in_tangents.get(i)), (true, sp.out_tangents.get(i))] { + let Some(tg) = tg.filter(|t| t[0] != 0.0 || t[1] != 0.0) else { continue }; + let h = scr([v[0] + tg[0], v[1] + tg[1]]); + painter.line_segment([s, h], Stroke::new(1.0, mc)); + painter.circle_filled(h, 3.0, mc); + vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: h, tangent: Some(out), vertex: *v, l2c: m }); + } + painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, mc); + } else { + painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Color32::from_black_alpha(160)); + painter.rect_stroke(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, mc), StrokeKind::Inside); + } + app.auto.add(&format!("viewer.mask.{}.vertex.{i}", g.uid), Rect::from_center_size(s, vec2(8.0, 8.0)), &g.name); + vertex_hits.push(VertexHit { layer: l.id, mask: g.uid, index: i, pos: s, tangent: None, vertex: *v, l2c: m }); + } + // Pen rubber band from the last vertex to the pointer. + if ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id())) == Some((l.id, g.uid)) + && let (Some(last), Some(hp)) = (sp.vertices.last(), ui.input(|i| i.pointer.hover_pos())) + { + painter.line_segment([scr(*last), hp], Stroke::new(1.0, mc.gamma_multiply(0.6))); } } } @@ -399,10 +497,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } let mods = ui.input(|i| i.modifiers); let space_pan = ui.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input(); + // Pen tool: each press places a vertex (dragging pulls Bezier tangents); pressing on the + // first vertex closes the path. + if app.ui.tool == Tool::Pen + && !space_pan + && resp.hovered() + && ui.input(|i| i.pointer.primary_pressed()) + && let Some(pos) = ui.input(|i| i.pointer.press_origin()) + { + pen_press(app, ui, &ectx, &map, &vertex_hits, pos); + } + // A pen click (press + release without a drag) ends its tangent gesture on release. + if ui.input(|i| i.pointer.primary_released()) + && matches!(ui.data(|d| d.get_temp::(egui::Id::new("viewer-gesture"))), Some(Gesture::Pen { .. })) + && !resp.drag_stopped() + { + ui.data_mut(|d| d.remove::(egui::Id::new("viewer-gesture"))); + } + let vertex_at = |pos: Pos2, tangents: bool| -> Option { + vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < 6.0).copied() + }; if resp.drag_started() && let Some(pos) = resp.interact_pointer_pos() { let cpt = map.to_comp(pos); + let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(pos); let tool = if space_pan || resp.dragged_by(egui::PointerButton::Middle) { Tool::Hand } else { app.ui.tool }; let g = match tool { Tool::Hand => Some(Gesture::Pan { start_pan: app.ui.viewer.pan }), @@ -432,6 +551,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }) }), t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }), + Tool::Selection if vertex_at(press, true).is_some() => vertex_at(press, true).map(|h| Gesture::Tangent { + layer: h.layer, + mask: h.mask, + index: h.index, + out: h.tangent == Some(true), + vertex: h.vertex, + inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY), + }), + Tool::Selection if vertex_at(press, false).is_some() => vertex_at(press, false).map(|h| { + let me = json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}); + let selected = app.session.state.selected_vertices.iter().any(|v| v.layer == h.layer && v.mask == h.mask && v.index == h.index); + if !selected { + let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift})); + } + Gesture::Vertices { last: cpt, inv: h.l2c.inverse().unwrap_or(Mat3::IDENTITY) } + }), Tool::Selection => { if let Some((lid, hi, _)) = handle_hits.iter().find(|(_, _, h)| h.distance(pos) < HANDLE + 2.0).cloned() { let layer = comp.layer(lid).cloned(); @@ -440,17 +575,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let (l2c, _) = ectx.layer_to_comp(&layer); let inv = l2c.inverse()?; let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]); - let ac = l2c.apply(gv2(a[0], a[1])); let lp = inv.apply(gv2(cpt[0], cpt[1])); let _ = hi; - Some(Gesture::Scale { - layer: lid, - anchor_screen: map.to_screen([ac.x, ac.y]), - start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]), - start_local: [lp.x - a[0], lp.y - a[1]], - inv, - uniform: hi < 4, - }) + Some(Gesture::Scale { layer: lid, start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]), start_local: [lp.x - a[0], lp.y - a[1]], inv }) }) } else if let Some(l) = pick(app, &ectx, cpt, mods.shift) { let layers: Vec<(LayerId, [f64; 3], Mat3)> = app @@ -495,14 +622,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/position", "value": v, "merge": merge})); } } - Gesture::Scale { layer, start_scale, start_local, inv, uniform, .. } => { + Gesture::Scale { layer, start_scale, start_local, inv, .. } => { let lp = inv.apply(gv2(cpt[0], cpt[1])); // Local point relative to the anchor in *unscaled* layer space. let a = comp.layer(layer).and_then(|l| l.transform().map(|tr| ectx.v3(l, tr, "anchor", [0.0; 3]))).unwrap_or([0.0; 3]); let cur = [lp.x - a[0], lp.y - a[1]]; let fx = if start_local[0].abs() > 1e-6 { cur[0] / start_local[0] } else { 1.0 }; let fy = if start_local[1].abs() > 1e-6 { cur[1] / start_local[1] } else { 1.0 }; - let (fx, fy) = if mods.shift || uniform && start_local[0].abs() > 1e-6 && start_local[1].abs() > 1e-6 && mods.shift { + let (fx, fy) = if mods.shift { let f = (fx + fy) / 2.0; (f, f) } else { @@ -564,6 +691,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let r = Rect::from_two_pos(a, pos); painter.rect_stroke(r, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle); } + Gesture::Vertices { last, inv } => { + let d = inv.apply_vec(gv2(cpt[0] - last[0], cpt[1] - last[1])); + if d.x != 0.0 || d.y != 0.0 { + let _ = app.session.execute("mask.moveVertices", json!({"delta": [d.x, d.y], "merge": merge})); + } + ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Vertices { last: cpt, inv })); + } + Gesture::Tangent { layer, mask, index, out, vertex, inv } => { + let lp = inv.apply(gv2(cpt[0], cpt[1])); + let t = [lp.x - vertex[0], lp.y - vertex[1]]; + let mut params = json!({"layer": layer.0, "mask": mask, "index": index, "merge": merge}); + params[if out { "out" } else { "in" }] = json!(t); + if !mods.alt { + params[if out { "in" } else { "out" }] = json!([-t[0], -t[1]]); + } + let _ = app.session.execute("mask.setVertex", params); + } + Gesture::Pen { layer, mask, index, vertex, inv, press } => { + if pos.distance(press) > 3.0 { + let lp = inv.apply(gv2(cpt[0], cpt[1])); + let t = [lp.x - vertex[0], lp.y - vertex[1]]; + let _ = app + .session + .execute("mask.setVertex", json!({"layer": layer.0, "mask": mask, "index": index, "out": t, "in": [-t[0], -t[1]], "merge": merge})); + } + } Gesture::Marquee { start } => { let r = Rect::from_two_pos(start, pos); painter.rect_filled(r, 0.0, t.accent.gamma_multiply(0.12)); @@ -620,6 +773,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.data_mut(|d| d.insert_temp(egui::Id::new("viewer-text-edit"), (v["layer"].as_u64().unwrap_or(0), "Text".to_string()))); } } + Tool::Pen => {} + Tool::Selection if vertex_at(pos, false).is_some() => { + if let Some(h) = vertex_at(pos, false) { + let me = json!({"layer": h.layer.0, "mask": h.mask, "index": h.index}); + let _ = app.session.execute("mask.selectVertices", json!({"vertices": [me], "add": mods.shift, "toggle": mods.shift})); + } + } _ => { if pick(app, &ectx, cpt, mods.shift).is_none() { let _ = app.session.execute("edit.deselectAll", json!({})); @@ -628,6 +788,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } if resp.double_clicked() + && app.ui.tool != Tool::Pen && let Some(pos) = resp.interact_pointer_pos() { let cpt = map.to_comp(pos); @@ -654,6 +815,51 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { bottom_bar(app, ui, bar, zoom, fit, time, &comp); } +/// Pen tool press at `pos`: start a mask on the selected layer, add a vertex, or close the path. +fn pen_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, map: &ViewerMap, hits: &[VertexHit], pos: Pos2) { + let gid = egui::Id::new("viewer-gesture"); + let current: Option<(LayerId, u64)> = ui.data(|d| d.get_temp(pen_id())); + let target = current.map(|c| c.0).or_else(|| { + app.session.state.selected_layers.iter().copied().find(|id| ectx.comp.layer(*id).is_some_and(|l| l.masks().is_some() && !l.switches.locked)) + }); + let Some(lid) = target else { + app.ui.status = "Select a layer to draw a mask with the Pen tool".into(); + return; + }; + let Some(layer) = ectx.comp.layer(lid) else { return }; + let l2c = ectx.layer_to_comp(layer).0; + let Some(inv) = l2c.inverse() else { return }; + let c = map.to_comp(pos); + let lp = inv.apply(gv2(c[0], c[1])); + let lp = [lp.x, lp.y]; + let still_there = current.is_some_and(|(l, m)| layer.masks().is_some_and(|ms| ms.find_group(m).is_some()) && l == lid); + if let (true, Some((_, mask))) = (still_there, current) { + let count = hits.iter().filter(|h| h.layer == lid && h.mask == mask && h.tangent.is_none()).count(); + let first = hits.iter().find(|h| h.layer == lid && h.mask == mask && h.index == 0 && h.tangent.is_none()); + if count >= 2 && first.is_some_and(|f| f.pos.distance(pos) < 8.0) { + let _ = app.session.execute("mask.setClosed", json!({"layer": lid.0, "mask": mask, "closed": true})); + ui.data_mut(|d| { + d.remove::<(LayerId, u64)>(pen_id()); + d.remove::(gid); + }); + return; + } + if let Ok(v) = app.session.execute("mask.addVertex", json!({"layer": lid.0, "mask": mask, "point": lp})) { + let index = v.as_u64().unwrap_or(0) as usize; + ui.data_mut(|d| d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index, vertex: lp, inv, press: pos })); + } + return; + } + if let Ok(v) = app.session.execute("mask.new", json!({"layer": lid.0, "vertices": [lp]})) + && let Some(mask) = v["mask"].as_u64() + { + ui.data_mut(|d| { + d.insert_temp(pen_id(), (lid, mask)); + d.insert_temp(gid, Gesture::Pen { layer: lid, mask, index: 0, vertex: lp, inv, press: pos }); + }); + } +} + /// Topmost layer under a comp point (selects it; shift toggles). Returns the hit layer. fn pick(app: &mut EffectcraftApp, ectx: &EvalCtx, cpt: [f64; 2], toggle: bool) -> Option { let hit = ectx @@ -851,7 +1057,7 @@ fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, app.ui.viewer.res = Resolution::ALL[i]; } x = r.max.x + 6.0; - if tog(ui, &mut app.auto, &mut x, Icon::Region, false, "roi", "Region of Interest") {} + let _ = tog(ui, &mut app.auto, &mut x, Icon::Region, false, "roi", "Region of Interest"); if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid") { app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid; } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index ba092f97..b06b0e38 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -249,6 +249,28 @@ pub struct TimelineState { pub open_groups: BTreeSet, /// "Reveal" filter: only show these property match ids (P/S/R/T/A…) — empty = normal. pub reveal: Vec, + /// Graph Editor: `value` or `speed` graph. + #[serde(default = "value_graph")] + pub graph_mode: String, + /// Show only the selected properties (else every animated property of the selected layers). + #[serde(default = "yes")] + pub graph_show_selected: bool, + /// Auto-zoom the graph height to the visible curves. + #[serde(default = "yes")] + pub graph_auto_zoom: bool, + /// Manual graph value range (when auto-zoom is off). + #[serde(default)] + pub graph_range: Option<(f64, f64)>, + /// Properties whose inline expression editor is collapsed. + #[serde(default)] + pub expr_closed: BTreeSet, +} + +fn value_graph() -> String { + "value".into() +} +fn yes() -> bool { + true } impl Default for TimelineState { @@ -258,12 +280,17 @@ impl Default for TimelineState { pps: None, scroll_y: 0.0, columns_w: 560.0, - show_modes: true, + show_modes: false, graph_editor: false, search: String::new(), open_layers: BTreeSet::new(), open_groups: BTreeSet::new(), reveal: vec![], + graph_mode: value_graph(), + graph_show_selected: true, + graph_auto_zoom: true, + graph_range: None, + expr_closed: BTreeSet::new(), } } }