Merge pull request #40 from storytold/timeline-keyframes
Fix the periodic freeze on Windows; keyframe drag, copy/paste and Graph Editor behave like After Effects
This commit is contained in:
commit
c90fd35ff8
|
|
@ -6,7 +6,7 @@ use effectcraft_project::{LayerId, Property, Uid};
|
|||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::prop::{edit_keys, prop_ref};
|
||||
use super::prop::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};
|
||||
|
||||
|
|
@ -73,11 +73,19 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let path = str_p(p, "path")?;
|
||||
comp.layer(*targets.first()?)?.props.prop(path).map(|pr| pr.uid)
|
||||
});
|
||||
// Keys copied from several layers, pasted onto as many layers: the first copied layer's keys
|
||||
// go to the first target and so on (else every target gets every copied property).
|
||||
let mut sources: Vec<LayerId> = clip.iter().map(|c| c.layer).collect();
|
||||
sources.dedup();
|
||||
let paired = sources.len() > 1 && sources.len() == targets.len();
|
||||
// (layer, prop uid, clip index)
|
||||
let mut plan: Vec<(LayerId, Uid, usize)> = vec![];
|
||||
for lid in &targets {
|
||||
for (ti, lid) in targets.iter().enumerate() {
|
||||
let Some(l) = comp.layer(*lid) else { continue };
|
||||
for (ci, c) in clip.iter().enumerate() {
|
||||
if paired && sources.get(ti) != Some(&c.layer) {
|
||||
continue;
|
||||
}
|
||||
let Some(sample) = c.keys.first().map(|k| &k.value) else { continue };
|
||||
let target = if clip.len() == 1 {
|
||||
explicit
|
||||
|
|
@ -145,6 +153,37 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
l.props.walk("", &mut |_, pr| sel.extend(pr.keys.iter().map(|k| KeyRef { layer: l.id, prop: pr.uid, time: k.time })));
|
||||
}
|
||||
}
|
||||
// Keys of locked layers can't be selected.
|
||||
sel.retain(|k| comp.layer(k.layer).is_some_and(|l| !l.switches.locked));
|
||||
s.state.selected_keys = sel;
|
||||
Ok(json!(s.state.selected_keys.len()))
|
||||
}
|
||||
|
||||
/// The keyframe context menu's Select Equal / Previous / Following Keyframes: in every property
|
||||
/// with a selected key, its keys with the same value as a selected one / before the first
|
||||
/// selected one / after the last selected one (the selected keys stay selected).
|
||||
fn select_related(s: &mut Session, which: &str) -> Result<Value> {
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let mut sel = s.state.selected_keys.clone();
|
||||
let mut props: Vec<(LayerId, Uid)> = sel.iter().map(|k| (k.layer, k.prop)).collect();
|
||||
props.sort();
|
||||
props.dedup();
|
||||
for (lid, uid) in props {
|
||||
let Some(pr) = comp.layer(lid).and_then(|l| l.props.find(uid)) else { continue };
|
||||
let picked: Vec<&Keyframe> = pr.keys.iter().filter(|k| sel.iter().any(|r| r.layer == lid && r.prop == uid && r.time == k.time)).collect();
|
||||
let (Some(first), Some(last)) = (picked.iter().map(|k| k.time).min(), picked.iter().map(|k| k.time).max()) else { continue };
|
||||
for k in &pr.keys {
|
||||
let hit = match which {
|
||||
"equal" => picked.iter().any(|p| p.value == k.value),
|
||||
"previous" => k.time < first,
|
||||
_ => k.time > last,
|
||||
};
|
||||
let r = KeyRef { layer: lid, prop: uid, time: k.time };
|
||||
if hit && !sel.contains(&r) {
|
||||
sel.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
s.state.selected_keys = sel;
|
||||
Ok(json!(s.state.selected_keys.len()))
|
||||
}
|
||||
|
|
@ -153,11 +192,8 @@ fn select_all(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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
|
||||
})
|
||||
let d = Tick(comp.frame_duration().0.saturating_mul(frames));
|
||||
super::prop::shift_keys(s, "Nudge Keyframes", merge_p(p), d)
|
||||
}
|
||||
|
||||
/// Edit one key directly: move it in time and/or set its value (Graph Editor drags).
|
||||
|
|
@ -399,6 +435,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
&json!({"frames": -10})
|
||||
)),
|
||||
cmd!("keys.set", "Edit Keyframe", [], None, "{layer?, path|prop, time (layer s), newTime?, value?, merge?}", has_comp, set_key),
|
||||
cmd!("keys.selectEqual", "Select Equal Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "equal")),
|
||||
cmd!("keys.selectPrevious", "Select Previous Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "previous")),
|
||||
cmd!("keys.selectFollowing", "Select Following Keyframes", [], None, "{}", has_keys, |s, _| select_related(s, "following")),
|
||||
cmd!(
|
||||
"keys.setEase",
|
||||
"Set Keyframe Ease",
|
||||
|
|
|
|||
|
|
@ -299,46 +299,6 @@ fn copy_expression_only(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
}
|
||||
|
||||
/// Paste the keyframe clipboard at the CTI onto the selected layers (same property paths).
|
||||
fn paste_keys(s: &mut Session, reversed: bool) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let ids = s.state.selected_layers.clone();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("select a layer to paste keyframes into".into()));
|
||||
}
|
||||
let clip = s.state.key_clipboard.clone();
|
||||
let t = s.time();
|
||||
let n = s.edit(if reversed { "Paste Reversed Keyframes" } else { "Paste Keyframes" }, None, |proj, st| {
|
||||
let mut n = 0;
|
||||
st.selected_keys.clear();
|
||||
for lid in &ids {
|
||||
let l = super::layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
for c in &clip {
|
||||
let Some(pr) = l.props.prop_mut(&c.path) else { continue };
|
||||
let span = c.keys.last().map(|k| k.time).unwrap_or(Tick::ZERO);
|
||||
for k in &c.keys {
|
||||
let mut k = k.clone();
|
||||
if std::mem::discriminant(&k.value) != std::mem::discriminant(&pr.value) {
|
||||
continue;
|
||||
}
|
||||
if reversed {
|
||||
k.time = span - k.time;
|
||||
std::mem::swap(&mut k.in_interp, &mut k.out_interp);
|
||||
std::mem::swap(&mut k.in_ease, &mut k.out_ease);
|
||||
std::mem::swap(&mut k.spatial_in, &mut k.spatial_out);
|
||||
}
|
||||
k.time += lt;
|
||||
st.selected_keys.push(crate::KeyRef { layer: *lid, prop: pr.uid, time: k.time });
|
||||
effectcraft_keyframe::set_key(&mut pr.keys, k);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({"keys": n}))
|
||||
}
|
||||
|
||||
fn paste_links(s: &mut Session, clip: LinkClip) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let ids = s.state.selected_layers.clone();
|
||||
|
|
@ -470,8 +430,34 @@ fn label(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn paste_reversed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
paste_keys(s, true)
|
||||
/// Edit ▸ Paste Reversed Keyframes: the copied keys mirrored in time over their whole span (the
|
||||
/// last key first, incoming and outgoing eases swapped), pasted like Edit ▸ Paste
|
||||
/// (`keys.paste`: at the current time, into the selected or the same properties).
|
||||
fn paste_reversed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let original = s.state.key_clipboard.clone();
|
||||
let span = original.iter().flat_map(|c| c.keys.iter().map(|k| k.time)).max().unwrap_or(Tick::ZERO);
|
||||
s.state.key_clipboard = original
|
||||
.iter()
|
||||
.map(|c| crate::KeyClip {
|
||||
keys: c
|
||||
.keys
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|k| {
|
||||
let mut k = k.clone();
|
||||
k.time = span - k.time;
|
||||
std::mem::swap(&mut k.in_interp, &mut k.out_interp);
|
||||
std::mem::swap(&mut k.in_ease, &mut k.out_ease);
|
||||
std::mem::swap(&mut k.spatial_in, &mut k.spatial_out);
|
||||
k
|
||||
})
|
||||
.collect(),
|
||||
..c.clone()
|
||||
})
|
||||
.collect();
|
||||
let r = s.execute("keys.paste", p.clone());
|
||||
s.state.key_clipboard = original;
|
||||
r
|
||||
}
|
||||
|
||||
/// Lift (leave a gap) or extract (close the gap) the work area from the selected layers (all
|
||||
|
|
@ -667,7 +653,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
),
|
||||
cmd!("edit.copyExpressionOnly", "Copy Expression Only", ["Edit"], None, "{}", has_props_or_layers, copy_expression_only),
|
||||
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{} (layers, keyframes at the CTI, or property links / expressions)", has_clip, paste),
|
||||
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{}", has_key_clip, paste_reversed),
|
||||
cmd!("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", ["Edit"], None, "{layers?, prop?|path?, time?}", has_key_clip, paste_reversed),
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -19,11 +19,25 @@ fn v3(v: Option<&Value>) -> Option<[f64; 3]> {
|
|||
/// Transform the selected keys: times scale about `timeAnchor` (comp seconds, default the first
|
||||
/// selected key) by `timeScale` and move by `timeOffset`; values scale about `valueAnchor` by
|
||||
/// `valueScale` and move by `valueOffset` (every dimension, or only `dim`). Times land on frames.
|
||||
/// With `fromStart` (drags), each step of one merged gesture gives the whole transform since the
|
||||
/// drag started and applies to the keys as they were then: keys squeezed onto one frame on the
|
||||
/// way, or rounded steps, are never lost; only the final position counts.
|
||||
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let merge = merge_p(p);
|
||||
let from_start = b_p(p, "fromStart").unwrap_or(false) && merge.is_some();
|
||||
let gesture = from_start && merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge;
|
||||
let (sel, base) = match (&s.key_move, s.history.undo.last()) {
|
||||
(Some(g), Some((_, b))) if gesture => (g.from.clone(), Some(b.clone())),
|
||||
_ => (s.state.selected_keys.clone(), None),
|
||||
};
|
||||
if from_start {
|
||||
s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: sel.clone(), total: Tick::ZERO });
|
||||
}
|
||||
// The comp as it was when the drag started (its keys are what the transform applies to).
|
||||
let start = base.clone().unwrap_or_else(|| s.project.clone());
|
||||
let comp = start.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let sel = s.state.selected_keys.clone();
|
||||
let ts = f_p(p, "timeScale").unwrap_or(1.0);
|
||||
let toff = f_p(p, "timeOffset").unwrap_or(0.0);
|
||||
let vs = f_p(p, "valueScale").unwrap_or(1.0);
|
||||
|
|
@ -46,7 +60,10 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fr.snap_nearest(Tick::from_seconds_f64(x.max(0.0)))
|
||||
};
|
||||
let touch_values = vs != 1.0 || voff != 0.0;
|
||||
s.edit("Transform Keyframes", merge_p(p), |proj, st| {
|
||||
s.edit("Transform Keyframes", merge, |proj, st| {
|
||||
if let Some(b) = base {
|
||||
*proj = (*b).clone();
|
||||
}
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &sel {
|
||||
|
|
@ -136,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Transform Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?}",
|
||||
"{timeScale?, timeAnchor? (comp s), timeOffset? (s), valueScale?, valueAnchor?, valueOffset?, dim? | dims?: [d…], merge?, fromStart?: bool (with merge: values are the whole transform since the drag started)}",
|
||||
has_keys,
|
||||
transform
|
||||
),
|
||||
|
|
|
|||
|
|
@ -335,7 +335,19 @@ fn select_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
sel.push(KeyRef { layer: l, prop: pr.uid, time: kt });
|
||||
}
|
||||
}
|
||||
if b_p(p, "add").unwrap_or(false) {
|
||||
// Keys of locked layers can't be selected (so nothing edits them).
|
||||
sel.retain(|k| comp.layer(k.layer).is_some_and(|l| !l.switches.locked));
|
||||
if b_p(p, "toggle").unwrap_or(false) {
|
||||
// Shift+click: in and out of the selection.
|
||||
for k in sel {
|
||||
match s.state.selected_keys.iter().position(|x| *x == k) {
|
||||
Some(i) => {
|
||||
s.state.selected_keys.remove(i);
|
||||
}
|
||||
None => s.state.selected_keys.push(k),
|
||||
}
|
||||
}
|
||||
} else if b_p(p, "add").unwrap_or(false) {
|
||||
for k in sel {
|
||||
if !s.state.selected_keys.contains(&k) {
|
||||
s.state.selected_keys.push(k);
|
||||
|
|
@ -400,14 +412,72 @@ pub(crate) fn edit_keys(s: &mut Session, label: &str, merge: Option<&str>, f: im
|
|||
})
|
||||
}
|
||||
|
||||
/// Move the selected keys by `d` comp seconds, snapped to the comp's frames in comp time (keys
|
||||
/// of time-stretched layers move with the pointer, not by the layer-time distance). Keys of
|
||||
/// locked layers stay. A key landing on another key of its property replaces it.
|
||||
pub(crate) fn shift_keys(s: &mut Session, label: &str, merge: Option<&str>, d: Tick) -> Result<Value> {
|
||||
let cid = s.active_comp_id().ok_or(EngineError::NoComp)?;
|
||||
// A drag (steps with one merge key) starts again from the keys before it each step.
|
||||
let gesture = merge.is_some_and(|m| s.history.merge_key.as_deref() == Some(m)) && s.key_move.as_ref().map(|g| g.merge.as_str()) == merge;
|
||||
let (from, total, base) = match (&s.key_move, s.history.undo.last()) {
|
||||
(Some(g), Some((_, base))) if gesture => (g.from.clone(), g.total + d, Some(base.clone())),
|
||||
_ => (s.state.selected_keys.clone(), d, None),
|
||||
};
|
||||
s.key_move = merge.map(|m| crate::KeyMove { merge: m.to_string(), from: from.clone(), total });
|
||||
s.edit(label, merge, |proj, st| {
|
||||
if let Some(b) = base {
|
||||
*proj = (*b).clone();
|
||||
}
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let fr = comp.frame_rate;
|
||||
let mut groups: std::collections::BTreeMap<(LayerId, Uid), Vec<Tick>> = Default::default();
|
||||
for k in &from {
|
||||
groups.entry((k.layer, k.prop)).or_default().push(k.time);
|
||||
}
|
||||
let mut sel = vec![];
|
||||
for ((lid, uid), times) in groups {
|
||||
let Some(l) = comp.layer_mut(lid) else { continue };
|
||||
let to = |t: Tick| l.layer_time(fr.snap_nearest(l.comp_time(t) + total));
|
||||
let moves: Vec<(Tick, Tick)> = times.iter().map(|t| (*t, to(*t))).collect();
|
||||
if l.switches.locked {
|
||||
sel.extend(times.iter().map(|t| KeyRef { layer: lid, prop: uid, time: *t }));
|
||||
continue;
|
||||
}
|
||||
let Some(pr) = l.props.find_mut(uid) else { continue };
|
||||
// Take the moving keys out, then put them back at their new times.
|
||||
let mut moved = vec![];
|
||||
for (old, new) in &moves {
|
||||
if let Some(i) = key_at(&pr.keys, *old) {
|
||||
let mut k = pr.keys.remove(i);
|
||||
k.time = *new;
|
||||
moved.push(k);
|
||||
}
|
||||
}
|
||||
for k in moved {
|
||||
sel.push(KeyRef { layer: lid, prop: uid, time: k.time });
|
||||
match pr.keys.binary_search_by(|x| x.time.cmp(&k.time)) {
|
||||
Ok(j) => pr.keys[j] = k,
|
||||
Err(j) => pr.keys.insert(j, k),
|
||||
}
|
||||
}
|
||||
// Roving keys follow their neighbours: keep the selection on them.
|
||||
let before: Vec<Tick> = pr.keys.iter().map(|k| k.time).collect();
|
||||
if effectcraft_keyframe::retime_roving(&mut pr.keys, pr.spatial) {
|
||||
for r in 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 = sel;
|
||||
Ok(json!(st.selected_keys.len()))
|
||||
})
|
||||
}
|
||||
|
||||
fn move_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = Tick::from_seconds_f64(f_p(p, "delta").ok_or_else(|| bad("keys.move", "missing `delta` (seconds)"))?);
|
||||
let fr = s.active_comp().map(|c| c.frame_rate);
|
||||
edit_keys(s, "Move Keyframes", merge_p(p), move |_, _, t| {
|
||||
let nt = *t + d;
|
||||
*t = fr.map(|r| r.snap_nearest(nt)).unwrap_or(nt);
|
||||
true
|
||||
})
|
||||
shift_keys(s, "Move Keyframes", merge_p(p), d)
|
||||
}
|
||||
|
||||
fn delete_keys(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
|
@ -543,7 +613,14 @@ fn interpolation(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn toggle_hold(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
edit_keys(s, "Toggle Hold Keyframe", None, |keys, i, _| {
|
||||
let k = &mut keys[i];
|
||||
k.out_interp = if k.out_interp == Interp::Hold { Interp::Linear } else { Interp::Hold };
|
||||
// Off again: back to Bezier when the key eases (its other side, auto / continuous, or
|
||||
// an outgoing ease), else Linear.
|
||||
let bezier = k.auto_bezier || k.continuous || k.in_interp == Interp::Bezier || !k.out_ease.is_empty();
|
||||
k.out_interp = match k.out_interp {
|
||||
Interp::Hold if bezier => Interp::Bezier,
|
||||
Interp::Hold => Interp::Linear,
|
||||
_ => Interp::Hold,
|
||||
};
|
||||
false
|
||||
})
|
||||
}
|
||||
|
|
@ -667,7 +744,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_layers,
|
||||
convert_expr_to_keys
|
||||
),
|
||||
cmd!("keys.select", "Select Keyframes", [], None, "{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?}", has_comp, select_keys),
|
||||
cmd!(
|
||||
"keys.select",
|
||||
"Select Keyframes",
|
||||
[],
|
||||
None,
|
||||
"{keys: [{layer, prop: uid | path (or `path`), time (layer s)}], add?, toggle?: bool (Shift+click: in or out of the selection)}",
|
||||
has_comp,
|
||||
select_keys
|
||||
),
|
||||
cmd!("keys.move", "Move Keyframes", [], None, "{delta (s), merge?}", has_keys, move_keys),
|
||||
cmd!("keys.delete", "Delete Keyframes", [], None, "{}", has_keys, delete_keys),
|
||||
cmd!("keys.easyEase", "Easy Ease", ["Animation", "Keyframe Assistant"], Some("F9"), "{which?: both|in|out}", has_keys, ease),
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"layerIn" => sel_layer.map(|l| l.in_point).unwrap_or(t),
|
||||
"layerOut" => sel_layer.map(|l| l.out_point - comp.frame_duration()).unwrap_or(t),
|
||||
"nextKey" | "prevKey" => {
|
||||
// Keyframes (of visible/selected layers) and markers, in comp time.
|
||||
// Keyframes (of visible/selected layers), markers and the work area, in comp time.
|
||||
let mut times: Vec<Tick> = comp.markers.iter().map(|m| m.time).collect();
|
||||
let layers: Vec<_> = if s.state.selected_layers.is_empty() {
|
||||
comp.layers.iter().collect()
|
||||
|
|
@ -90,6 +90,9 @@ fn go(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let only = p.get("prop").and_then(Value::as_u64);
|
||||
if only.is_some() {
|
||||
times.clear();
|
||||
} else {
|
||||
// The work area's start and end too, as in After Effects.
|
||||
times.extend([comp.work_area.0, comp.work_area.1 - comp.frame_duration()]);
|
||||
}
|
||||
for l in layers {
|
||||
l.props.walk("", &mut |_, pr| {
|
||||
|
|
|
|||
|
|
@ -134,6 +134,17 @@ pub struct KeyRef {
|
|||
pub time: Tick,
|
||||
}
|
||||
|
||||
/// A keyframe drag in progress (`keys.move` steps sharing a merge key): the selection it
|
||||
/// started from and the total offset so far, so every step is applied to the keys as they were
|
||||
/// before the drag. A key passed over on the way is never overwritten; only the drop position
|
||||
/// counts, as in After Effects.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct KeyMove {
|
||||
pub merge: String,
|
||||
pub from: Vec<KeyRef>,
|
||||
pub total: Tick,
|
||||
}
|
||||
|
||||
/// Keyframes of one property on the keyframe clipboard.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KeyClip {
|
||||
|
|
@ -373,6 +384,10 @@ pub struct Session {
|
|||
/// The last physical-memory reading (Settings ▸ Memory & CPU budgets); `None` = unknown
|
||||
/// (headless sessions don't query it, see [`Session::memory_tick`]).
|
||||
pub sys_memory: Option<sysinfo::SysMemory>,
|
||||
/// The keyframe drag in progress (`keys.move` with a merge key).
|
||||
pub key_move: Option<KeyMove>,
|
||||
/// Reads the system's memory in the background for [`Session::memory_tick`].
|
||||
pub memory_watch: sysinfo::MemoryWatch,
|
||||
/// Switches Affect Nested Comps as last applied (a change empties the layer cache).
|
||||
pub applied_nested_switches: Option<bool>,
|
||||
/// Runs renders and analyses off the UI thread where there are no threads (the web app's
|
||||
|
|
@ -465,6 +480,8 @@ impl Default for Session {
|
|||
plugin_loader: None,
|
||||
script_ui: scriptui::ScriptUi::default(),
|
||||
sys_memory: None,
|
||||
key_move: None,
|
||||
memory_watch: Default::default(),
|
||||
applied_nested_switches: None,
|
||||
offload: None,
|
||||
offloaded: vec![],
|
||||
|
|
|
|||
|
|
@ -75,11 +75,13 @@ impl Session {
|
|||
self.footage.set_cache_budget(b.media);
|
||||
}
|
||||
|
||||
/// Re-read the system's memory (desktop frontends call this every few seconds) and re-apply
|
||||
/// the cache budgets when the reading changes them. Returns the effective budgets.
|
||||
/// Take the latest reading of the system's memory and start the next one in the background
|
||||
/// (desktop frontends call this every few seconds; it never waits for the system), then
|
||||
/// re-apply the cache budgets when the reading changes them. Returns the effective budgets.
|
||||
pub fn memory_tick(&mut self) -> crate::prefs::CacheBudgets {
|
||||
let before = self.prefs.cache_budgets(self.sys_memory);
|
||||
self.sys_memory = crate::sysinfo::memory().or(self.sys_memory);
|
||||
self.sys_memory = self.memory_watch.latest().or(self.sys_memory);
|
||||
self.memory_watch.poll();
|
||||
let after = self.prefs.cache_budgets(self.sys_memory);
|
||||
if after != before {
|
||||
self.apply_cache_budgets();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@
|
|||
//! Pure Rust and dependency-free: the values come from the operating system's own reporting
|
||||
//! (`/proc/meminfo` on Linux, `sysctl`/`vm_stat` on macOS, `wmic`/PowerShell on Windows). Every
|
||||
//! query may fail (sandboxes, the web): callers treat `None` as "unknown" and keep their
|
||||
//! configured budgets.
|
||||
//! configured budgets. The periodic reading behind the cache budgets runs on a background
|
||||
//! thread ([`MemoryWatch`]): on Windows it starts PowerShell, which takes about a second.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
|
|
@ -23,10 +27,51 @@ impl SysMemory {
|
|||
}
|
||||
}
|
||||
|
||||
/// The system's memory, read in the background: [`MemoryWatch::poll`] starts a reading (unless
|
||||
/// one is running) and [`MemoryWatch::latest`] returns the last one without waiting.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct MemoryWatch {
|
||||
latest: Arc<Mutex<Option<SysMemory>>>,
|
||||
busy: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl MemoryWatch {
|
||||
/// Start a new reading in the background (no threads on the web: read inline, it is
|
||||
/// unknown there anyway).
|
||||
pub fn poll(&self) {
|
||||
if self.busy.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
let (latest, busy) = (self.latest.clone(), self.busy.clone());
|
||||
let read = move || {
|
||||
if let Some(m) = memory() {
|
||||
*latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(m);
|
||||
}
|
||||
busy.store(false, Ordering::Release);
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if std::thread::Builder::new().name("ec-memory".into()).spawn(read).is_err() {
|
||||
self.busy.store(false, Ordering::Release);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
read();
|
||||
}
|
||||
|
||||
/// The last reading (`None` until one finished).
|
||||
pub fn latest(&self) -> Option<SysMemory> {
|
||||
*self.latest.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(dead_code)]
|
||||
fn run(cmd: &str, args: &[&str]) -> Option<String> {
|
||||
let out = std::process::Command::new(cmd).args(args).output().ok()?;
|
||||
let mut c = std::process::Command::new(cmd);
|
||||
c.args(args);
|
||||
// A console program started from the windowed app would flash a console window.
|
||||
#[cfg(target_os = "windows")]
|
||||
std::os::windows::process::CommandExt::creation_flags(&mut c, 0x0800_0000); // CREATE_NO_WINDOW
|
||||
let out = c.output().ok()?;
|
||||
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -863,3 +863,170 @@ fn new_comp_from_selection_survives_hostile_sequence_numbers() {
|
|||
s.state.project_selection = vec![a, b];
|
||||
assert!(s.execute("file.newCompFromSelection", json!({"single": true, "dimensionsFrom": 99})).is_err());
|
||||
}
|
||||
|
||||
fn key_times(s: &Session, l: u64) -> Vec<f64> {
|
||||
layer(s, l).props.prop("transform/opacity").unwrap().keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect()
|
||||
}
|
||||
|
||||
fn opacity_keys(s: &mut Session, l: u64, times: &[f64]) {
|
||||
for (i, t) in times.iter().enumerate() {
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t, "value": i as f64 * 10.0})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_key_past_another_keeps_it() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[1.0, 2.0]);
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
// One drag, frame by frame, from 1 s over the 2 s key to 3 s.
|
||||
for _ in 0..60 {
|
||||
s.execute("keys.move", json!({"delta": 1.0 / 30.0, "merge": "key-drag"})).unwrap();
|
||||
}
|
||||
assert_eq!(key_times(&s, a), vec![2.0, 3.0], "the 2 s key passed over is still there");
|
||||
// Dropped exactly on a key: it replaces it, as in After Effects.
|
||||
s.history.merge_key = None;
|
||||
s.execute("keys.move", json!({"delta": -1.0, "merge": "key-drag-2"})).unwrap();
|
||||
assert_eq!(key_times(&s, a), vec![2.0]);
|
||||
// The whole drag was one undo step.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(key_times(&s, a), vec![1.0, 2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_of_a_stretched_layer_move_with_the_pointer() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0]);
|
||||
s.execute_checked("layer.timeStretch", json!({"layers": [a], "percent": 200})).unwrap();
|
||||
assert_eq!(layer(&s, a).stretch, 200.0);
|
||||
let comp_time = |s: &Session, i: usize| {
|
||||
let l = layer(s, a);
|
||||
l.comp_time(l.props.prop("transform/opacity").unwrap().keys[i].time).seconds()
|
||||
};
|
||||
let before = comp_time(&s, 1);
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": 1.0}]})).unwrap();
|
||||
s.execute("keys.move", json!({"delta": 0.5})).unwrap();
|
||||
assert!((comp_time(&s, 1) - (before + 0.5)).abs() < 1.0 / 30.0, "{} → {}", before, comp_time(&s, 1));
|
||||
s.execute("keys.nudge", json!({"frames": 3})).unwrap();
|
||||
assert!((comp_time(&s, 1) - (before + 0.6)).abs() < 1.0 / 60.0, "three comp frames");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_selection_toggles_and_skips_locked_layers_and_hold_restores_bezier() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0]);
|
||||
let k = |t: f64| json!({"layer": a, "path": "transform/opacity", "time": t});
|
||||
s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [k(1.0)], "toggle": true})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 2);
|
||||
s.execute("keys.select", json!({"keys": [k(0.0)], "toggle": true})).unwrap();
|
||||
assert_eq!(s.state.selected_keys.len(), 1, "toggled out");
|
||||
// Hold off returns an eased key to Bezier.
|
||||
s.execute("keys.easyEase", json!({})).unwrap();
|
||||
s.execute("keys.toggleHold", json!({})).unwrap();
|
||||
s.execute("keys.toggleHold", json!({})).unwrap();
|
||||
let kf = layer(&s, a).props.prop("transform/opacity").unwrap().keys[1].clone();
|
||||
assert_eq!(kf.out_interp, effectcraft_keyframe::Interp::Bezier);
|
||||
// Locked: its keys can't be selected.
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "lock", "value": true})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [k(0.0)]})).unwrap();
|
||||
assert!(s.state.selected_keys.is_empty());
|
||||
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
|
||||
assert!(s.state.selected_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_drags_apply_their_whole_offset_to_the_keys_they_started_with() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0, 2.0]);
|
||||
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
|
||||
// Squeezed onto one frame on the way, then back: the three keys survive.
|
||||
for k in [0.5, 0.01, 0.0001, 1.0] {
|
||||
s.execute("keys.transform", json!({"timeScale": k, "timeAnchor": 0.0, "merge": "box", "fromStart": true})).unwrap();
|
||||
}
|
||||
assert_eq!(key_times(&s, a), vec![0.0, 1.0, 2.0]);
|
||||
// A slow move in tiny steps still arrives (each step is the whole offset).
|
||||
s.history.merge_key = None;
|
||||
for i in 1..=30 {
|
||||
s.execute("keys.transform", json!({"timeOffset": i as f64 * 0.1 / 30.0, "merge": "move", "fromStart": true})).unwrap();
|
||||
}
|
||||
assert_eq!(key_times(&s, a), vec![0.1, 1.1, 2.1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_equal_previous_and_following_keyframes() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
for (t, v) in [(0.0, 20.0), (1.0, 50.0), (2.0, 20.0), (3.0, 70.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let pick = |s: &mut Session, t: f64| s.execute("keys.select", json!({"keys": [{"layer": a, "path": "transform/opacity", "time": t}]})).unwrap();
|
||||
let times = |s: &Session| {
|
||||
let mut v: Vec<f64> = s.state.selected_keys.iter().map(|k| k.time.seconds()).collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v
|
||||
};
|
||||
pick(&mut s, 0.0);
|
||||
s.execute("keys.selectEqual", json!({})).unwrap();
|
||||
assert_eq!(times(&s), vec![0.0, 2.0]);
|
||||
pick(&mut s, 2.0);
|
||||
s.execute("keys.selectPrevious", json!({})).unwrap();
|
||||
assert_eq!(times(&s), vec![0.0, 1.0, 2.0]);
|
||||
pick(&mut s, 1.0);
|
||||
s.execute("keys.selectFollowing", json!({})).unwrap();
|
||||
assert_eq!(times(&s), vec![1.0, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_from_several_layers_paste_in_order_and_paste_reversed_respects_stretch() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
opacity_keys(&mut s, a, &[0.0, 1.0]);
|
||||
s.execute("prop.addKey", json!({"layer": b, "path": "transform/opacity", "time": 0.0, "value": 77})).unwrap();
|
||||
let c = solid(&mut s, "#0000ff");
|
||||
let d = solid(&mut s, "#ffffff");
|
||||
// Copy A's and B's Opacity keys, paste onto C and D: A → C, B → D (in order).
|
||||
s.execute("keys.selectAll", json!({"layers": [a, b]})).unwrap();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [c, d]})).unwrap();
|
||||
s.execute("keys.paste", json!({})).unwrap();
|
||||
assert_eq!(key_times(&s, c), vec![2.0, 3.0], "A's two keys");
|
||||
assert_eq!(key_times(&s, d), vec![2.0], "B's one key");
|
||||
// Paste Reversed onto a layer stretched to 200 %: the reversed keys land 2 s apart there too.
|
||||
let e = solid(&mut s, "#808080");
|
||||
s.execute_checked("layer.timeStretch", json!({"layers": [e], "percent": 200})).unwrap();
|
||||
s.execute("keys.selectAll", json!({"layers": [a]})).unwrap();
|
||||
s.execute("keys.copy", json!({})).unwrap();
|
||||
s.execute("time.set", json!({"time": 0.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [e]})).unwrap();
|
||||
s.execute("edit.pasteReversedKeyframes", json!({})).unwrap();
|
||||
let pr = layer(&s, e).props.prop("transform/opacity").unwrap().clone();
|
||||
let l = layer(&s, e);
|
||||
let ct: Vec<f64> = pr.keys.iter().map(|k| l.comp_time(k.time).seconds()).collect();
|
||||
let vals: Vec<f64> = pr.keys.iter().map(|k| k.value.as_f64()).collect();
|
||||
assert!(ct.len() == 2 && (ct[1] - ct[0] - 1.0).abs() < 1e-6, "keys 1 s apart in comp time: {ct:?}");
|
||||
assert_eq!(vals, vec![10.0, 0.0], "reversed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn j_and_k_stop_at_keys_markers_and_the_work_area() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
opacity_keys(&mut s, a, &[2.0]);
|
||||
s.execute("comp.workArea", json!({"start": 1.0, "end": 3.0})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
let mut stops = vec![];
|
||||
for _ in 0..4 {
|
||||
s.execute("time.nextKey", json!({})).unwrap();
|
||||
stops.push((s.time().seconds() * 30.0).round() / 30.0);
|
||||
}
|
||||
let last = (3.0 * 30.0 - 1.0) / 30.0;
|
||||
assert_eq!(stops, vec![1.0, 2.0, (last * 30.0f64).round() / 30.0, (last * 30.0f64).round() / 30.0]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -464,3 +464,25 @@ fn open_recent_increment_and_revert() {
|
|||
assert_eq!(autosave::increment_path("/x/v1.ecproj", |p| p.ends_with("v1 2.ecproj")), "/x/v1 3.ecproj");
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_tick_never_waits_for_the_system() {
|
||||
let mut s = Session::default();
|
||||
let t0 = std::time::Instant::now();
|
||||
s.memory_tick();
|
||||
s.memory_tick();
|
||||
// Reading the system's memory starts PowerShell on Windows (about a second): the UI thread
|
||||
// only takes the last reading.
|
||||
assert!(t0.elapsed() < std::time::Duration::from_millis(200), "{:?}", t0.elapsed());
|
||||
if crate::sysinfo::memory().is_some() {
|
||||
let mut got = None;
|
||||
for _ in 0..200 {
|
||||
got = s.memory_watch.latest();
|
||||
if got.is_some() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
assert!(got.is_some_and(|m| m.total > 0), "the reading arrives in the background");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -497,10 +497,32 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
|||
}
|
||||
|
||||
/// Run an engine command and perform the frontend events it emitted right away.
|
||||
/// Commands that fill the internal clipboard.
|
||||
const COPY_COMMANDS: &[&str] =
|
||||
&["edit.copy", "edit.cut", "edit.copyWithPropertyLinks", "edit.copyWithRelativePropertyLinks", "edit.copyExpressionOnly", "keys.copy", "effect.copy"];
|
||||
|
||||
/// A line for the system clipboard naming what EffectCraft copied. The system clipboard must
|
||||
/// hold something: with nothing on it, the windowing layer sends no paste event for Ctrl+V.
|
||||
fn clipboard_note(s: &effectcraft_engine::Session) -> String {
|
||||
let st = &s.state;
|
||||
let n = |n: usize, one: &str, many: &str| format!("EffectCraft: {n} {}", if n == 1 { one } else { many });
|
||||
if st.clip_is_keys && !st.key_clipboard.is_empty() {
|
||||
n(st.key_clipboard.iter().map(|c| c.keys.len()).sum(), "keyframe", "keyframes")
|
||||
} else if !st.effect_clipboard.is_empty() {
|
||||
n(st.effect_clipboard.len(), "effect", "effects")
|
||||
} else if st.link_clipboard.is_some() {
|
||||
"EffectCraft: property links".into()
|
||||
} else {
|
||||
n(st.clipboard.len(), "layer", "layers")
|
||||
}
|
||||
}
|
||||
|
||||
fn run_engine(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
let r = app.session.execute(id, params).map_err(|e| e.to_string());
|
||||
if let Err(e) = &r {
|
||||
app.ui.status = e.clone();
|
||||
} else if COPY_COMMANDS.contains(&id) && app.session.state.text_edit.is_none() {
|
||||
ctx.copy_text(clipboard_note(&app.session));
|
||||
}
|
||||
let events = app.session.drain_events();
|
||||
for ev in events {
|
||||
|
|
@ -1217,7 +1239,13 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
if ctx.egui_wants_keyboard_input() {
|
||||
return;
|
||||
}
|
||||
// Text editing in the viewer takes the clipboard events itself.
|
||||
let clipboard = app.session.state.text_edit.is_none();
|
||||
let events: Vec<(egui::Key, egui::Modifiers)> = ctx.input(|i| {
|
||||
// The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of
|
||||
// key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut,
|
||||
// Paste and their variants (Ctrl+Alt+C…) run.
|
||||
let held = if i.modifiers.command { i.modifiers } else { egui::Modifiers::COMMAND };
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
|
|
@ -1226,6 +1254,9 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
{
|
||||
Some((*key, *modifiers))
|
||||
}
|
||||
egui::Event::Copy if clipboard => Some((egui::Key::C, held)),
|
||||
egui::Event::Cut if clipboard => Some((egui::Key::X, held)),
|
||||
egui::Event::Paste(_) if clipboard => Some((egui::Key::V, held)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
|
|
|
|||
|
|
@ -195,6 +195,23 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
|
|||
],
|
||||
)
|
||||
}
|
||||
// Double-click a keyframe (or its context menu ▸ Edit Value…): the property's value at
|
||||
// that key, like After Effects' value dialog.
|
||||
"keys.set" if !has(p, &["value", "newTime"]) => {
|
||||
let l = p.get("layer").and_then(Value::as_u64).and_then(|l| comp?.layer(effectcraft_engine::project::LayerId(l)));
|
||||
let pr = l.and_then(|l| l.props.find(p.get("prop").and_then(Value::as_u64)?));
|
||||
let t = p.get("time").and_then(Value::as_f64).map(effectcraft_engine::time::Tick::from_seconds_f64);
|
||||
let Some((pr, t)) = pr.zip(t) else { return false };
|
||||
let Some(k) = pr.keys.iter().min_by_key(|k| (k.time.0 - t.0).abs()) else { return false };
|
||||
use effectcraft_engine::keyframe::Value as KV;
|
||||
let fields = match &k.value {
|
||||
KV::Scalar(v) => vec![Field::num("value", &pr.name, *v)],
|
||||
KV::Vec2(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
|
||||
KV::Vec3(v) => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
|
||||
_ => return false,
|
||||
};
|
||||
(pr.name.clone(), fields)
|
||||
}
|
||||
"layer.setTransform" if !has(p, &["value"]) => {
|
||||
let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
|
||||
let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
|
||||
|
|
|
|||
|
|
@ -272,7 +272,6 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, false, actions);
|
||||
// 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 {
|
||||
|
|
@ -323,28 +322,46 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
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})));
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let key = &c.prop.keys[k.idx];
|
||||
if kresp.clicked() && mods.command {
|
||||
// Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off (as in the Timeline).
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()]})));
|
||||
if mods.alt {
|
||||
actions.push(("keys.toggleHold".into(), json!({})));
|
||||
} else {
|
||||
let linear = key.in_interp == Interp::Linear && key.out_interp == Interp::Linear;
|
||||
actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
|
||||
}
|
||||
} else if kresp.clicked() && mods.shift {
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "toggle": true})));
|
||||
} else if (kresp.clicked() || kresp.drag_started()) && !k.sel {
|
||||
actions.push(("keys.select".into(), json!({"keys": [kjson.clone()], "add": mods.shift})));
|
||||
}
|
||||
// A drag moves every selected key, by how far the pointer went from where the button
|
||||
// went down (the key doesn't jump under the pointer); Shift keeps it to the time or
|
||||
// the value axis, whichever moved more.
|
||||
if kresp.drag_started()
|
||||
&& let Some(o) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
ctx.data_mut(|dd| dd.insert_temp(key_grab_id(), KeyGrab { press: o, t: k.t }));
|
||||
}
|
||||
if kresp.dragged()
|
||||
&& let Some(pt) = kresp.interact_pointer_pos()
|
||||
&& let Some(g) = ctx.data(|dd| dd.get_temp::<KeyGrab>(key_grab_id()))
|
||||
{
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, true));
|
||||
let merge = format!("gkey-{uid}-{}", k.idx);
|
||||
let (dx, dy) = (pt.x - g.press.x, pt.y - g.press.y);
|
||||
let (time_ok, value_ok) = if mods.shift { (dx.abs() >= dy.abs(), dy.abs() > dx.abs()) } else { (true, true) };
|
||||
// Graph Editor ▸ Snap: to the current time and other keys.
|
||||
let others: Vec<f64> = all_key_times.iter().filter(|(u, i, _)| !(*u == uid && *i == k.idx)).map(|x| x.2).collect();
|
||||
let ct = super::graph_tools::snap_time(app, tm, tm.t(pt.x).max(0.0), &others);
|
||||
let nt = c.layer.layer_time(fr.snap_nearest(Tick::from_seconds_f64(ct)));
|
||||
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);
|
||||
let t = if time_ok { super::graph_tools::snap_time(app, tm, (g.t + (tm.t(pt.x) - tm.t(g.press.x))).max(0.0), &others) } else { g.t };
|
||||
let mut params = json!({"timeOffset": t - g.t, "merge": "graph-key", "fromStart": true});
|
||||
if value_ok && !speed && c.editable {
|
||||
params["valueOffset"] = json!(vmap(pt.y) - vmap(g.press.y));
|
||||
params["dim"] = json!(d);
|
||||
}
|
||||
params["value"] = if comps.len() == 1 { json!(comps[0]) } else { json!(comps) };
|
||||
}
|
||||
actions.push(("keys.set".into(), params));
|
||||
actions.push(("keys.transform".into(), params));
|
||||
}
|
||||
if kresp.drag_stopped() {
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, false));
|
||||
|
|
@ -500,3 +517,14 @@ fn fmt(v: f64, step: f64) -> String {
|
|||
let s = format!("{v:.dec$}");
|
||||
if s.trim_start_matches('-').chars().all(|c| c == '0' || c == '.') { s.trim_start_matches('-').to_string() } else { s }
|
||||
}
|
||||
|
||||
/// A Graph Editor key drag: where the button went down and the grabbed key's comp time then.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct KeyGrab {
|
||||
press: Pos2,
|
||||
t: f64,
|
||||
}
|
||||
|
||||
fn key_grab_id() -> egui::Id {
|
||||
egui::Id::new("graph-key-grab")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ struct BoxDrag {
|
|||
handle: usize,
|
||||
/// Fixed opposite point (comp s, value).
|
||||
anchor: [f64; 2],
|
||||
/// Pointer data position of the previous frame.
|
||||
/// Where the drag started (comp s, value): the pointer for a move, the handle for a scale.
|
||||
/// Every step sends the whole transform since then (`keys.transform {fromStart}`).
|
||||
last: [f64; 2],
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +135,7 @@ pub(crate) fn transform_box(
|
|||
});
|
||||
}
|
||||
let Some(resp) = resp_any else { return };
|
||||
let Some(mut st) = ctx.data(|d| d.get_temp::<BoxDrag>(box_id())) else { return };
|
||||
let Some(st) = ctx.data(|d| d.get_temp::<BoxDrag>(box_id())) else { return };
|
||||
if (st.handle == 8) == handles {
|
||||
return;
|
||||
}
|
||||
|
|
@ -142,21 +143,20 @@ pub(crate) fn transform_box(
|
|||
&& let Some(pt) = resp.interact_pointer_pos()
|
||||
{
|
||||
let cur = data(pt);
|
||||
let mut params = json!({"merge": "graph-box"});
|
||||
let mut params = json!({"merge": "graph-box", "fromStart": true});
|
||||
if st.handle == 8 {
|
||||
params["timeOffset"] = json!(cur[0] - st.last[0]);
|
||||
if value_ok {
|
||||
params["valueOffset"] = json!(cur[1] - st.last[1]);
|
||||
}
|
||||
st.last = cur;
|
||||
} else {
|
||||
let (time_axis, value_axis) = match st.handle {
|
||||
4 | 6 => (false, true),
|
||||
5 | 7 => (true, false),
|
||||
_ => (true, true),
|
||||
};
|
||||
// Scale relative to where the handle is now (incremental, so merged steps compose).
|
||||
let now = corner(st.handle);
|
||||
// Scale relative to where the handle was when the drag started.
|
||||
let now = st.last;
|
||||
if time_axis && (now[0] - st.anchor[0]).abs() > 1e-6 {
|
||||
let k = (cur[0] - st.anchor[0]) / (now[0] - st.anchor[0]);
|
||||
if k > 0.01 {
|
||||
|
|
@ -250,6 +250,8 @@ struct AltScale {
|
|||
anchor: f64,
|
||||
/// The dragged end is the group's last key (else its first).
|
||||
end_is_max: bool,
|
||||
/// The group's span when the drag started (set on its first step).
|
||||
span: Option<f64>,
|
||||
}
|
||||
|
||||
fn alt_id() -> egui::Id {
|
||||
|
|
@ -272,9 +274,9 @@ fn sel_range(app: &EffectcraftApp, comp: &Comp) -> Option<(f64, f64)> {
|
|||
pub(crate) fn alt_scale_begin(app: &EffectcraftApp, ctx: &egui::Context, comp: &Comp, key_ct: f64) -> bool {
|
||||
let Some((lo, hi)) = sel_range(app, comp) else { return false };
|
||||
let st = if (key_ct - hi).abs() < 1e-6 {
|
||||
AltScale { anchor: lo, end_is_max: true }
|
||||
AltScale { anchor: lo, end_is_max: true, span: None }
|
||||
} else if (key_ct - lo).abs() < 1e-6 {
|
||||
AltScale { anchor: hi, end_is_max: false }
|
||||
AltScale { anchor: hi, end_is_max: false, span: None }
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
|
|
@ -293,16 +295,16 @@ pub(crate) fn alt_scale_update(app: &EffectcraftApp, ctx: &egui::Context, comp:
|
|||
let (Some((lo, hi)), Some(px)) = (sel_range(app, comp), ctx.input(|i| i.pointer.latest_pos())) else { return };
|
||||
let fr = comp.frame_rate;
|
||||
let target = fr.snap_nearest(effectcraft_engine::time::Tick::from_seconds_f64(tm.t(px.x).max(0.0))).seconds();
|
||||
let end = if st.end_is_max { hi } else { lo };
|
||||
let span = end - st.anchor;
|
||||
// The group's span when the drag started: every step sends the whole scale since then.
|
||||
let span = st.span.unwrap_or((if st.end_is_max { hi } else { lo }) - st.anchor);
|
||||
if st.span.is_none() {
|
||||
ctx.data_mut(|d| d.insert_temp(alt_id(), AltScale { span: Some(span), ..st }));
|
||||
}
|
||||
let want = target - st.anchor;
|
||||
if span.abs() < 1e-9 || want.abs() < 1e-9 || want.signum() != span.signum() {
|
||||
return;
|
||||
}
|
||||
let k = want / span;
|
||||
if (k - 1.0).abs() > 1e-6 {
|
||||
actions.push(("keys.transform".into(), json!({"timeScale": k, "timeAnchor": st.anchor, "merge": "key-alt-scale"})));
|
||||
}
|
||||
actions.push(("keys.transform".into(), json!({"timeScale": want / span, "timeAnchor": st.anchor, "merge": "key-alt-scale", "fromStart": true})));
|
||||
}
|
||||
|
||||
/// An Alt-drag group scale is in progress (the plain key move stands aside).
|
||||
|
|
|
|||
|
|
@ -101,13 +101,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("Render: {ms:.0} ms • UI {:.0} fps{rq}", app.fps), Tokens::ui(11.5), t.text_faint);
|
||||
}
|
||||
if !app.session.state.selected_keys.is_empty() {
|
||||
p.text(
|
||||
pos2(x0, ly + 18.0),
|
||||
Align2::LEFT_CENTER,
|
||||
format!("{} keyframes selected", app.session.state.selected_keys.len()),
|
||||
Tokens::ui(11.5),
|
||||
t.text_dim,
|
||||
);
|
||||
// One key: its property, time and value (as After Effects' Info panel shows them).
|
||||
let sel = &app.session.state.selected_keys;
|
||||
let one = match sel.as_slice() {
|
||||
[k] => c.layer(k.layer).and_then(|l| {
|
||||
let pr = l.props.find(k.prop)?;
|
||||
let key = pr.keys.iter().find(|x| x.time == k.time)?;
|
||||
let tc = crate::panels::timecode(&app.session, &c, l.comp_time(k.time));
|
||||
let v = match &key.value {
|
||||
effectcraft_engine::keyframe::Value::Scalar(v) => format!("{v:.2}"),
|
||||
effectcraft_engine::keyframe::Value::Vec2(v) => format!("{:.1}, {:.1}", v[0], v[1]),
|
||||
effectcraft_engine::keyframe::Value::Vec3(v) => format!("{:.1}, {:.1}, {:.1}", v[0], v[1], v[2]),
|
||||
_ => String::new(),
|
||||
};
|
||||
Some(format!("{} {tc} {v}", pr.name).trim_end().to_string())
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let line = one.unwrap_or_else(|| format!("{} keyframes selected", sel.len()));
|
||||
p.text(pos2(x0, ly + 18.0), Align2::LEFT_CENTER, line, Tokens::ui(11.5), t.text_dim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//! bar, layer duration bars, keyframes, the CTI, and the value graph editor.
|
||||
|
||||
use effectcraft_engine::color::BlendMode;
|
||||
use effectcraft_engine::keyframe::Value;
|
||||
use effectcraft_engine::keyframe::{Interp, Value};
|
||||
use effectcraft_engine::project::{Comp, GroupKind, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::time::Tick;
|
||||
|
|
@ -779,6 +779,19 @@ fn layer_icon(l: &Layer, project: &effectcraft_engine::project::Project) -> Icon
|
|||
fn layer_drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-layer-drag")
|
||||
}
|
||||
|
||||
/// A keyframe drag in progress: where the pointer started, the grabbed key's comp time
|
||||
/// (seconds) and how many frames the selected keys have moved so far.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct KeyDrag {
|
||||
origin_x: f32,
|
||||
anchor: f64,
|
||||
applied: i64,
|
||||
}
|
||||
|
||||
fn key_drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-key-drag")
|
||||
}
|
||||
/// Set on the frame the reorder drag is released.
|
||||
fn layer_drop_id() -> egui::Id {
|
||||
egui::Id::new("tl-layer-drop")
|
||||
|
|
@ -1857,27 +1870,55 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
None if ks => t.keyframe_selected,
|
||||
None => t.keyframe,
|
||||
};
|
||||
if k.roving {
|
||||
// Roving keys: a small dot (their time follows their neighbours).
|
||||
gp.circle_filled(c, 2.5, kcol);
|
||||
} else {
|
||||
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol, Color32::from_black_alpha(200));
|
||||
}
|
||||
let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
|
||||
if kresp.clicked() || kresp.drag_started() {
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
if !ks || shift {
|
||||
actions.push((
|
||||
"keys.select".into(),
|
||||
json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}], "add": shift}),
|
||||
));
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let this_key = json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]});
|
||||
if kresp.clicked() && mods.command {
|
||||
// Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off.
|
||||
actions.push(("keys.select".into(), this_key.clone()));
|
||||
if mods.alt {
|
||||
actions.push(("keys.toggleHold".into(), json!({})));
|
||||
} else {
|
||||
let linear = k.in_interp == Interp::Linear && k.out_interp == Interp::Linear;
|
||||
actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
|
||||
}
|
||||
} else if kresp.clicked() && mods.shift {
|
||||
// Shift+click: in or out of the selection.
|
||||
let mut p = this_key.clone();
|
||||
p["toggle"] = json!(true);
|
||||
actions.push(("keys.select".into(), p));
|
||||
} else if (kresp.clicked() || kresp.drag_started()) && !ks {
|
||||
let mut p = this_key.clone();
|
||||
p["add"] = json!(mods.shift);
|
||||
actions.push(("keys.select".into(), p));
|
||||
}
|
||||
if kresp.double_clicked() {
|
||||
// Edit the key's value (numeric properties), as After Effects does.
|
||||
if matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
|
||||
actions.push(("keys.set".into(), json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})));
|
||||
} else {
|
||||
ui_actions.push(UiAct::SetTime(ct));
|
||||
}
|
||||
}
|
||||
kresp.context_menu(|ui| {
|
||||
for (lbl, cmd, params) in [
|
||||
("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})),
|
||||
("-", "", json!({})),
|
||||
("Copy", "keys.copy", json!({})),
|
||||
("Paste", "keys.paste", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Select Equal Keyframes", "keys.selectEqual", json!({})),
|
||||
("Select Previous Keyframes", "keys.selectPrevious", json!({})),
|
||||
("Select Following Keyframes", "keys.selectFollowing", json!({})),
|
||||
("-", "", json!({})),
|
||||
("Keyframe Interpolation…", "keys.interpolation", json!({})),
|
||||
("Keyframe Velocity…", "keys.velocity", json!({})),
|
||||
("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
|
||||
|
|
@ -1918,6 +1959,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if lbl == "Rove Across Time" && !prop.spatial {
|
||||
continue;
|
||||
}
|
||||
if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
|
||||
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()}]})));
|
||||
|
|
@ -1934,21 +1978,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
{
|
||||
super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
|
||||
}
|
||||
let alt_scaling = super::graph_tools::alt_scale_active(&ctx);
|
||||
if kresp.dragged() && !alt_scaling {
|
||||
let acc_id = egui::Id::new("key-drag-acc");
|
||||
let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
|
||||
acc += kresp.drag_delta().x;
|
||||
let frames = ((acc as f64 / pps) / fd).trunc() as i64;
|
||||
if frames != 0 {
|
||||
acc -= (frames as f64 * fd * pps) as f32;
|
||||
actions.push(("keys.move".into(), json!({"delta": frames as f64 * fd, "merge": "key-drag"})));
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(acc_id, acc));
|
||||
}
|
||||
if kresp.drag_stopped() {
|
||||
ctx.data_mut(|d| d.remove::<f32>(egui::Id::new("key-drag-acc")));
|
||||
ui_actions.push(UiAct::EndMerge);
|
||||
// A plain drag moves the selected keys. The timeline follows it (below): this
|
||||
// widget's id changes with the key's time, so its own drag would end after
|
||||
// the first frame of movement.
|
||||
// (From where the button went down: a drag starts a few pixels later.)
|
||||
if kresp.drag_started()
|
||||
&& !super::graph_tools::alt_scale_active(&ctx)
|
||||
&& let Some(p) = ctx.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2031,6 +2069,49 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
ctx.data_mut(|d| d.remove::<Vec<u64>>(layer_drag_id()));
|
||||
}
|
||||
}
|
||||
// Keyframe drag: the selected keys move by whole frames with the pointer until it is
|
||||
// released (one undo step); Shift snaps the grabbed key to the current time, the work area
|
||||
// and composition markers.
|
||||
if let Some(mut kd) = ctx.data(|d| d.get_temp::<KeyDrag>(key_drag_id())) {
|
||||
let (down, ptr, shift) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.modifiers.shift));
|
||||
match ptr.filter(|_| down) {
|
||||
Some(ptr) => {
|
||||
let mut target = kd.anchor + (ptr.x - kd.origin_x) as f64 / pps;
|
||||
if shift {
|
||||
// The current time and whatever it snaps to (keys, layer ends, markers), but
|
||||
// not the moving keys themselves.
|
||||
let moving: Vec<f64> =
|
||||
app.session.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time).seconds())).collect();
|
||||
let mut cands = snap_candidates(&comp, &rows);
|
||||
cands.retain(|c| !moving.iter().any(|m| (m - c).abs() < 1e-9));
|
||||
cands.push(time.seconds());
|
||||
target = snap_time(&cands, target, 8.0 / pps);
|
||||
}
|
||||
let total = ((target - kd.anchor) / fd).round() as i64;
|
||||
if total != kd.applied {
|
||||
actions.push(("keys.move".into(), json!({"delta": (total - kd.applied) as f64 * fd, "merge": "key-drag"})));
|
||||
kd.applied = total;
|
||||
ctx.data_mut(|d| d.insert_temp(key_drag_id(), kd));
|
||||
}
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
// Where the grabbed key is and how far the keys moved (After Effects shows this
|
||||
// in the Info panel).
|
||||
let at = Tick::from_seconds_f64(kd.anchor + kd.applied as f64 * fd);
|
||||
let note = format!("{} {:+} f", super::timecode(&app.session, &comp, at), kd.applied);
|
||||
let fg = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tl-key-drag-tip")));
|
||||
let g = fg.layout_no_wrap(note, Tokens::ui(11.5), t.text);
|
||||
let r = Rect::from_min_size(ptr + vec2(14.0, 14.0), g.size() + vec2(10.0, 6.0));
|
||||
fg.rect_filled(r, 3.0, t.panel_bg);
|
||||
fg.rect_stroke(r, 3.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside);
|
||||
fg.galley(r.min + vec2(5.0, 3.0), g, t.text);
|
||||
app.ui.status = format!("Keyframe at {}", super::timecode(&app.session, &comp, at));
|
||||
}
|
||||
None => {
|
||||
ctx.data_mut(|d| d.remove::<KeyDrag>(key_drag_id()));
|
||||
ui_actions.push(UiAct::EndMerge);
|
||||
}
|
||||
}
|
||||
}
|
||||
if empty.drag_stopped()
|
||||
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("tl-box")))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -41,12 +41,13 @@ fn dropped_files(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Re-read the system's free memory every few seconds (cache budgets follow it).
|
||||
/// Re-read the system's free memory every 30 s (cache budgets follow it; on Windows each
|
||||
/// reading starts PowerShell in the background, so it isn't done more often).
|
||||
fn memory_watch(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let id = egui::Id::new("prefs-memory-watch");
|
||||
let last = ctx.data(|d| d.get_temp::<f64>(id));
|
||||
if last.is_some_and(|t| now - t < 10.0) {
|
||||
if last.is_some_and(|t| now - t < 30.0) {
|
||||
return;
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(id, now));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
//! Headless checks for Timeline keyframes as a person uses them: dragging keys over many frames
|
||||
//! in one gesture (the drag used to end after the first frame), Shift-snapping, and Ctrl+C /
|
||||
//! Ctrl+V, which the windowing layer delivers as clipboard events rather than key presses.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// A 4 s, 30 fps comp with a Box layer whose Opacity has keys at 0 s and 1 s, revealed in the
|
||||
/// Timeline; returns the harness, the layer and the property uid.
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, LayerId, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Keys", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Box", "color": "#e04020", "width": 80, "height": 80})).unwrap();
|
||||
let id = s.active_comp().unwrap().layers[0].id;
|
||||
for (t, v) in [(0.0, 0.0), (1.0, 100.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/opacity", "time": t, "value": v})).unwrap();
|
||||
}
|
||||
let uid = s.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
s.execute("layer.select", json!({"layers": [id.0]})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.reveal.opacity", json!({})).unwrap();
|
||||
h.run_steps(4);
|
||||
(h, id, uid)
|
||||
}
|
||||
|
||||
fn key_times(h: &Harness<'_, EffectcraftApp>, id: LayerId) -> Vec<f64> {
|
||||
let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone();
|
||||
l.props.prop("transform/opacity").unwrap().keys.iter().map(|k| (k.time.seconds() * 30.0).round() / 30.0).collect()
|
||||
}
|
||||
|
||||
/// Screen centres of the property's keys, left to right.
|
||||
fn keys(h: &Harness<'_, EffectcraftApp>, uid: u64) -> Vec<Pos2> {
|
||||
let mut ks: Vec<Pos2> =
|
||||
h.state().auto.query(&format!("timeline.key.{uid}.")).iter().map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)).collect();
|
||||
ks.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
ks
|
||||
}
|
||||
|
||||
/// Press at `from`, move to `to` in small steps (a real drag), release.
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifiers: egui::Modifiers) {
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers });
|
||||
h.step();
|
||||
for i in 1..=24 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 24.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Default::default()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_drag_follows_the_pointer_for_its_whole_length() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let ks = keys(&h, uid);
|
||||
assert_eq!(ks.len(), 2);
|
||||
let px_per_s = ks[1].x - ks[0].x;
|
||||
// Drag the 1 s key to where 2.5 s is: many frames, one gesture.
|
||||
let to = pos2(ks[1].x + px_per_s * 1.5, ks[1].y);
|
||||
drag(&mut h, ks[1], to, Default::default());
|
||||
let ts = key_times(&h, id);
|
||||
assert!((ts[1] - 2.5).abs() <= 2.0 / 30.0, "the key follows the whole drag: {ts:?}");
|
||||
let steps = h.state().session.history.undo.iter().filter(|(l, _)| l == "Move Keyframes").count();
|
||||
assert_eq!(steps, 1, "one undo step per drag");
|
||||
// Shift snaps it to the current time indicator (at 0.5 s), from a few pixels away.
|
||||
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(0.5));
|
||||
h.run_steps(2);
|
||||
let ks = keys(&h, uid);
|
||||
let near = pos2(ks[0].x + px_per_s * 0.5 + 4.0, ks[1].y);
|
||||
drag(&mut h, ks[1], near, egui::Modifiers { shift: true, ..Default::default() });
|
||||
assert_eq!(key_times(&h, id)[1], 0.5, "snapped to the current time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_and_ctrl_v_copy_and_paste_keys_at_the_current_time() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let ks = keys(&h, uid);
|
||||
// Click the 1 s key to select it, Ctrl+C (a clipboard event, not a key press).
|
||||
h.input_mut().events.push(Event::PointerMoved(ks[1]));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: ks[1], button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: ks[1], button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.state.selected_keys.len(), 1);
|
||||
h.input_mut().events.push(Event::Copy);
|
||||
h.step();
|
||||
let copied: Vec<String> = h
|
||||
.output()
|
||||
.platform_output
|
||||
.commands
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
egui::OutputCommand::CopyText(t) => Some(t.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(copied, ["EffectCraft: 1 keyframe"], "the system clipboard is filled, so Ctrl+V sends a paste event");
|
||||
assert!(h.state().session.state.clip_is_keys);
|
||||
// Move to 3 s, Ctrl+V: the key lands there.
|
||||
h.state_mut().session.set_time(effectcraft_engine::time::Tick::from_seconds_f64(3.0));
|
||||
h.input_mut().events.push(Event::Paste("EffectCraft: 1 keyframe".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(key_times(&h, id), vec![0.0, 1.0, 3.0]);
|
||||
}
|
||||
|
||||
fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: egui::Modifiers) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Default::default()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_toggles_and_ctrl_click_switches_interpolation() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let ks = keys(&h, uid);
|
||||
let shift = egui::Modifiers { shift: true, ..Default::default() };
|
||||
click_with(&mut h, ks[0], Default::default());
|
||||
click_with(&mut h, ks[1], shift);
|
||||
assert_eq!(h.state().session.state.selected_keys.len(), 2, "Shift+click adds");
|
||||
click_with(&mut h, ks[1], shift);
|
||||
assert_eq!(h.state().session.state.selected_keys.len(), 1, "and takes out again");
|
||||
let key =
|
||||
|h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys[0].clone();
|
||||
// Ctrl+click: Linear → Auto Bezier → Linear.
|
||||
click_with(&mut h, ks[0], egui::Modifiers::COMMAND);
|
||||
assert!(key(&h).auto_bezier, "Auto Bezier");
|
||||
click_with(&mut h, ks[0], egui::Modifiers::COMMAND);
|
||||
assert_eq!(key(&h).out_interp, effectcraft_engine::keyframe::Interp::Linear);
|
||||
// Ctrl+Alt+click: Hold.
|
||||
click_with(&mut h, ks[0], egui::Modifiers { alt: true, ..egui::Modifiers::COMMAND });
|
||||
assert_eq!(key(&h).out_interp, effectcraft_engine::keyframe::Interp::Hold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_editor_drags_move_every_selected_key_and_shift_keeps_an_axis() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("prop.select", json!({"layer": id.0, "prop": uid})).unwrap();
|
||||
s.execute("keys.selectAll", json!({})).unwrap();
|
||||
h.state_mut().ui.timeline.graph_editor = true;
|
||||
h.run_steps(4);
|
||||
let at = |h: &Harness<'_, EffectcraftApp>, i: usize| {
|
||||
let e = h.state().auto.find(&format!("timeline.graph.key.{uid}.0.{i}")).unwrap_or_else(|| panic!("no graph key {i}")).clone();
|
||||
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
|
||||
};
|
||||
let (k0, k1) = (at(&h, 0), at(&h, 1));
|
||||
let px_per_s = k1.x - k0.x;
|
||||
let values = |h: &Harness<'_, EffectcraftApp>| -> Vec<f64> {
|
||||
h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys.iter().map(|k| k.value.as_f64()).collect()
|
||||
};
|
||||
// Shift: only along time, both keys, by 0.5 s.
|
||||
drag(&mut h, k1, pos2(k1.x + px_per_s * 0.5, k1.y - 6.0), egui::Modifiers { shift: true, ..Default::default() });
|
||||
assert_eq!(key_times(&h, id), vec![0.5, 1.5], "both selected keys moved");
|
||||
assert_eq!(values(&h), vec![0.0, 100.0], "Shift kept the values");
|
||||
let undo = h.state().session.history.undo.iter().filter(|(l, _)| l == "Transform Keyframes").count();
|
||||
assert_eq!(undo, 1, "one undo step per drag");
|
||||
// Without Shift the values move too (both keys, by the same amount).
|
||||
h.run_steps(2);
|
||||
let k1 = at(&h, 1);
|
||||
drag(&mut h, k1, pos2(k1.x, k1.y + 20.0), Default::default());
|
||||
let v = values(&h);
|
||||
assert!(v[0] < 0.0 && (v[1] - 100.0 - v[0]).abs() < 1e-6, "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_a_key_to_edit_its_value() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let k1 = keys(&h, uid)[1];
|
||||
h.input_mut().events.push(Event::PointerMoved(k1));
|
||||
h.step();
|
||||
// Both clicks inside egui's double-click time.
|
||||
for _ in 0..2 {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: k1, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.input_mut().events.push(Event::PointerButton { pos: k1, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
}
|
||||
h.step();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form), "the value dialog opened");
|
||||
h.run_steps(2);
|
||||
assert!(h.state().auto.find("form.field.value").is_some(), "one Opacity field");
|
||||
// OK keeps the key where it is (the dialog edits the value only).
|
||||
let ok = h.state().auto.find("form.ok").expect("form.ok").clone();
|
||||
let p = pos2(ok.rect[0] + ok.rect[2] / 2.0, ok.rect[1] + ok.rect[3] / 2.0);
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialog.is_none());
|
||||
let k = h.state().session.active_comp().unwrap().layer(id).unwrap().props.prop("transform/opacity").unwrap().keys[1].clone();
|
||||
assert_eq!((k.time.seconds(), k.value.as_f64()), (1.0, 100.0));
|
||||
}
|
||||
|
|
@ -133,6 +133,12 @@ build real projects through these interfaces; they are worked examples of everyt
|
|||
(a string param that is exactly `"$N"` or `"$N.key"` is replaced; `$2.path` is the trim's path).
|
||||
* **Expressions**: `set_property`/`get_property` replies carry `evaluated` (what renders) next to
|
||||
`value` (the keyframed value) and `expressionError` when the expression fails.
|
||||
* **Keyframes like a person**: `keys.select {keys, toggle: true}` (Shift+click), `keys.selectEqual`
|
||||
/ `selectPrevious` / `selectFollowing`, `keys.move {delta, merge}` (steps sharing a merge key are
|
||||
one drag: each applies to the keys as they were before it, so a key passed over survives) and
|
||||
`keys.transform {…, merge, fromStart: true}` (values are the whole transform since the drag
|
||||
started). Keys of locked layers can't be selected. Copy / paste: `keys.copy`, then `keys.paste`
|
||||
at the current time (keys copied from several layers go to as many selected layers in order).
|
||||
* **Easing with Keyframe Velocity**: select keys by path, `keys.select {"keys":[{"layer":"Ring","path":"contents/trim/end","time":0}]}`,
|
||||
then `keys.velocity {"outSpeed":0,"outInfluence":33.33}`.
|
||||
* **Gradients**: `set_property {"path":"contents/gfill/colors","value":["#0080ff","#ffff00"]}` or
|
||||
|
|
|
|||
|
|
@ -392,6 +392,32 @@ Browser in the web app (landed in M13.10: browser storage and File System Access
|
|||
wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script
|
||||
`Socket` validates ports, and ScriptUI resource strings accept trailing array commas.
|
||||
|
||||
## Update: M5.9–M5.14 keyframes, M3.15 clipboard, M12.8 responsiveness
|
||||
|
||||
- **The app froze every 10 s on Windows** (M12.8): the cache budgets' memory reading started
|
||||
PowerShell on the UI thread and waited about a second for it. It runs on a background thread
|
||||
now, every 30 s.
|
||||
- **Ctrl+C / Ctrl+X / Ctrl+V** (M3.15) reach Edit ▸ Copy, Cut and Paste in the app (the window
|
||||
layer sends them as clipboard events, which the shortcut dispatcher ignored), and copying puts
|
||||
a line on the system clipboard so Ctrl+V always arrives.
|
||||
- **Timeline keyframes** (M5.9–M5.12): a drag follows the pointer to the end (it let go after a
|
||||
frame or two), moves every selected key as one undo step, never deletes keys it passes over,
|
||||
moves keys of time-stretched layers with the pointer, and Shift snaps to the current time,
|
||||
keys, layer ends and markers with a time / offset tooltip; Shift+click toggles selection,
|
||||
Ctrl+click switches Linear ↔ Auto Bezier, Ctrl+Alt+click toggles Hold; keys of locked layers
|
||||
can't be selected; Toggle Hold off restores Bezier; double-click edits a key's value; Select
|
||||
Equal / Previous / Following Keyframes; roving keys draw as dots.
|
||||
- **Graph Editor** (M5.11): a drag moves every selected key in time and value from where it was
|
||||
grabbed, Shift keeps one axis; the transform box and Alt-drag scaling never merge keys on the
|
||||
way and move them however slowly they are dragged; the same Shift / Ctrl / Ctrl+Alt clicks.
|
||||
- **Paste** (M5.13): keys copied from several layers paste onto as many layers in order; Paste
|
||||
Reversed Keyframes follows Paste's rules (stretch, target property, Hold-only properties).
|
||||
- **J / K and Info** (M5.14): J / K also stop at the work area; the Info panel shows a selected
|
||||
key's property, time and value.
|
||||
|
||||
Not yet: J / K and Select All Keyframes use every property of the layers, not only the revealed
|
||||
ones; Graph Editor keys are drawn as squares whatever their interpolation; Auto-Select Graph Type.
|
||||
|
||||
## Update: M4.9–M4.11 nested comps and motion blur
|
||||
|
||||
- **Composition Settings ▸ Advanced ▸ Preserve frame rate when nested or in render queue** and
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ Commands (CLI, MCP, control channel):
|
|||
sample rate and read back instead of decoding again
|
||||
- `memory.ramReservedGb`: the cache budgets together leave this much physical memory free
|
||||
- `memory.reduceCacheWhenLow`: cache budgets halve while the system is low on memory (checked
|
||||
every 10 s by the desktop app)
|
||||
every 30 s by the desktop app, on a background thread)
|
||||
- `video.enableOutput`, `video.device`, `video.outputDuringPlayback`, `video.mirrorOnMonitor`,
|
||||
`video.disableWhenBackground`: Video Preview, a second window (or full screen on the display
|
||||
it is on) showing the composition frame, Mercury Transmit-style
|
||||
|
|
|
|||
Loading…
Reference in New Issue