Merge pull request #381 from storytold/fix/284-timeline-keys-tools
Timeline, keyframes, Graph Editor, Puppet and pick whip fixes from #284
This commit is contained in:
commit
735482d7ed
|
|
@ -387,6 +387,14 @@ pub fn unmap(mesh: &Mesh, deformed: &[[f64; 2]], p: [f64; 2]) -> [f64; 2] {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether `p` (layer space) is on the mesh as `deformed` places its vertices.
|
||||
pub fn contains(mesh: &Mesh, deformed: &[[f64; 2]], p: [f64; 2]) -> bool {
|
||||
mesh.tris.iter().any(|t| match (deformed.get(t[0]), deformed.get(t[1]), deformed.get(t[2])) {
|
||||
(Some(a), Some(b), Some(c)) => bary(*a, *b, *c, p).is_some_and(|l| l.iter().all(|x| *x >= -1e-9)),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn bary(a: [f64; 2], b: [f64; 2], c: [f64; 2], p: [f64; 2]) -> Option<[f64; 3]> {
|
||||
let d = (b[1] - c[1]) * (a[0] - c[0]) + (c[0] - b[0]) * (a[1] - c[1]);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ 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 super::{CommandSpec, b_p, bad, has_layers, layer_mut, layer_p, str_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
const AXES: [(&str, &str); 3] = [("positionX", "X Position"), ("positionY", "Y Position"), ("positionZ", "Z Position")];
|
||||
|
|
@ -164,6 +164,23 @@ fn pick_whip(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let r = reference(comp, from, target, tu, s.prefs.general.expression_pick_whip_compact).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));
|
||||
// While the expression is being edited the reference goes in at the cursor, replacing the
|
||||
// selected text (After Effects): `expression` is the text being edited, `range` the
|
||||
// selection [start, end] in characters.
|
||||
if let Some(base) = str_p(p, "expression") {
|
||||
let range: Option<Vec<u64>> = p.get("range").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect());
|
||||
let n = base.chars().count() as u64;
|
||||
let (a, b) = match range.as_deref() {
|
||||
Some(&[a, b]) => (a.min(b).min(n), a.max(b).min(n)),
|
||||
None => (n, n),
|
||||
Some(_) => return Err(bad("prop.pickWhip", "`range` must be [start, end] (characters)")),
|
||||
};
|
||||
let head: String = base.chars().take(a as usize).collect();
|
||||
let tail: String = base.chars().skip(b as usize).collect();
|
||||
let text = format!("{head}{r}{tail}");
|
||||
s.execute("prop.setExpression", json!({"layer": lid.0, "prop": uid, "expression": text}))?;
|
||||
return Ok(json!(text));
|
||||
}
|
||||
let text = if dt == 1 && dm > 1 {
|
||||
format!("temp = {r};\n[{}]", vec!["temp"; dm].join(", "))
|
||||
} else if dt > 1 && dm == 1 {
|
||||
|
|
@ -183,7 +200,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Pick Whip (Link Property)",
|
||||
[],
|
||||
None,
|
||||
"{layer?, path|prop, target: {layer, path|prop}} → sets an AE reference expression",
|
||||
"{layer?, path|prop, target: {layer, path|prop}, expression?, range?: [start, end]} → sets an AE reference expression (with `expression`: inserts it there, replacing the characters in `range`, default the end)",
|
||||
has_layers,
|
||||
pick_whip
|
||||
),
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
fn toggle_anim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, lid, uid) = prop_ref(s, p, "prop.toggleAnimation")?;
|
||||
let on = b_p(p, "value");
|
||||
with_prop(s, "Toggle Animation", None, cid, lid, uid, |pr, lt| {
|
||||
with_prop(s, "Toggle Animation", merge_p(p), cid, lid, uid, |pr, lt| {
|
||||
if pr.static_only {
|
||||
return Err(bad("prop.toggleAnimation", "this property can't be animated"));
|
||||
}
|
||||
|
|
@ -769,7 +769,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![
|
||||
query!("prop.get", "Get Property", "{layer?, path|prop, time? (comp s)}", get),
|
||||
cmd!("prop.set", "Set Property Value", [], None, "{layer?, path|prop, value, time?, merge?}", has_layers, set),
|
||||
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?}", has_layers, toggle_anim),
|
||||
cmd!("prop.toggleAnimation", "Toggle Stopwatch", [], None, "{layer?, path|prop, value?, merge?}", has_layers, toggle_anim),
|
||||
cmd!("prop.addKey", "Add Keyframe", [], None, "{layer?, path|prop, time?, value?}", has_layers, add_key),
|
||||
cmd!("prop.toggleKey", "Add or Remove Keyframe at Current Time", [], None, "{layer?, path|prop}", has_layers, toggle_key),
|
||||
cmd!(
|
||||
|
|
|
|||
|
|
@ -87,11 +87,7 @@ fn add_pin(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if want_mesh.is_some_and(|w| w != *uid) || mesh.tris.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let inside = mesh.tris.iter().any(|tr| {
|
||||
let (a, b, c) = (def[tr[0]], def[tr[1]], def[tr[2]]);
|
||||
let cr = |o: [f64; 2], u: [f64; 2], v: [f64; 2]| (u[0] - o[0]) * (v[1] - o[1]) - (u[1] - o[1]) * (v[0] - o[0]);
|
||||
cr(a, b, at) >= 0.0 && cr(b, c, at) >= 0.0 && cr(c, a, at) >= 0.0
|
||||
});
|
||||
let inside = puppet::contains(mesh, def, at);
|
||||
let near = def.iter().map(|v| (v[0] - at[0]).hypot(v[1] - at[1])).fold(f64::MAX, f64::min) < 12.0;
|
||||
if inside || near || want_mesh.is_some() {
|
||||
target = Some((*uid, puppet::unmap(mesh, def, at)));
|
||||
|
|
|
|||
|
|
@ -428,6 +428,19 @@ fn parenting_keeps_the_layer_in_place() {
|
|||
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(child)).unwrap().parent.is_some());
|
||||
}
|
||||
|
||||
/// #284: toggling stopwatches with one `merge` key (dragging over them) is one undo step.
|
||||
#[test]
|
||||
fn stopwatch_toggles_with_a_merge_key_are_one_undo_step() {
|
||||
let (mut s, l) = setup();
|
||||
let steps = s.history.undo.len();
|
||||
for path in ["transform/position", "transform/scale", "transform/opacity"] {
|
||||
s.execute("prop.toggleAnimation", json!({"layer": l, "path": path, "value": true, "merge": "drag"})).unwrap();
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), steps + 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(["transform/position", "transform/scale", "transform/opacity"].iter().all(|p| !prop(&s, l, p).is_animated()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_whip_generates_ae_reference_expressions() {
|
||||
let (mut s, l) = setup();
|
||||
|
|
@ -453,6 +466,33 @@ fn pick_whip_generates_ae_reference_expressions() {
|
|||
assert!(s.execute("prop.pickWhip", json!({"layer": l, "path": "transform/opacity", "target": {"layer": l, "path": "transform/opacity"}})).is_err());
|
||||
}
|
||||
|
||||
/// #284: with the expression being edited, the pick whip puts the reference at the cursor,
|
||||
/// replacing the selected characters (After Effects), in one undo step.
|
||||
#[test]
|
||||
fn pick_whip_inserts_into_the_expression_being_edited() {
|
||||
let (mut s, l) = setup();
|
||||
let o = s.execute("layer.newSolid", json!({"name": "Ö", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let whip = |s: &mut Session, base: &str, range: Value| {
|
||||
let mut p = json!({"layer": l, "path": "transform/opacity", "target": {"layer": o, "path": "transform/rotation"}, "expression": base});
|
||||
if !range.is_null() {
|
||||
p["range"] = range;
|
||||
}
|
||||
s.execute("prop.pickWhip", p)
|
||||
};
|
||||
let r = "thisComp.layer(\"Ö\").transform.rotation";
|
||||
// Characters, not bytes: "é" before the cursor.
|
||||
assert_eq!(whip(&mut s, "é + 5", json!([1, 1])).unwrap(), json!(format!("é{r} + 5")));
|
||||
assert_eq!(prop(&s, l, "transform/opacity").expr.unwrap().text, format!("é{r} + 5"));
|
||||
// A selection (either way round) is replaced; no range: at the end.
|
||||
assert_eq!(whip(&mut s, "a*2+b", json!([4, 2])).unwrap(), json!(format!("a*{r}b")));
|
||||
assert_eq!(whip(&mut s, "x + ", Value::Null).unwrap(), json!(format!("x + {r}")));
|
||||
// Past the end clamps; a malformed range is an error.
|
||||
assert_eq!(whip(&mut s, "7", json!([9, 99])).unwrap(), json!(format!("7{r}")));
|
||||
assert!(whip(&mut s, "7", json!([1])).is_err());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(prop(&s, l, "transform/opacity").expr.unwrap().text, format!("x + {r}"));
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
|
|
|||
|
|
@ -667,30 +667,37 @@ impl Frames {
|
|||
return;
|
||||
}
|
||||
let Ok(mut q) = self.queue.lock() else { return };
|
||||
let Ok(mut inf) = self.inflight.lock() else { return };
|
||||
// Jobs for an older project revision can never be shown: drop them.
|
||||
let before = q.jobs.len();
|
||||
q.jobs.retain(|j| j.key.revision >= key.revision);
|
||||
let dropped = before - q.jobs.len();
|
||||
if dropped > 0
|
||||
&& let Ok(mut inf) = self.inflight.lock()
|
||||
{
|
||||
if q.jobs.len() < before {
|
||||
inf.retain(|k| k.revision >= key.revision || q.running.contains(k));
|
||||
}
|
||||
if urgent {
|
||||
// Only the newest viewer frame is urgent; earlier ones become prefetch.
|
||||
// Only the newest viewer frame is urgent. The comp's earlier viewer frames that
|
||||
// haven't started are dropped (scrubbing: rendering every frame the pointer passed
|
||||
// would hold the workers the frame under it needs); frames already rendering finish
|
||||
// into the cache, and the viewer shows its last frame until the new one is in
|
||||
// (#284). Other comps' viewer frames become prefetch.
|
||||
q.jobs.retain(|j| {
|
||||
let superseded = j.urgent && j.key != key && j.key.comp == key.comp;
|
||||
if superseded {
|
||||
inf.remove(&j.key);
|
||||
}
|
||||
!superseded
|
||||
});
|
||||
for j in q.jobs.iter_mut() {
|
||||
j.urgent = j.key == key;
|
||||
}
|
||||
}
|
||||
{
|
||||
let Ok(mut inf) = self.inflight.lock() else { return };
|
||||
if !inf.insert(key) {
|
||||
return;
|
||||
}
|
||||
if !inf.insert(key) {
|
||||
return;
|
||||
}
|
||||
q.seq += 1;
|
||||
let seq = q.seq;
|
||||
q.jobs.push(Job { key, src: src.clone(), comp, t, opts, urgent, seq });
|
||||
drop(inf);
|
||||
drop(q);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.remote.is_none() {
|
||||
|
|
@ -1161,6 +1168,43 @@ mod tests {
|
|||
assert!(!f.is_cached(&key));
|
||||
}
|
||||
|
||||
/// #284: scrubbing asks for a viewer frame at every step; a newer one drops the comp's viewer
|
||||
/// frames still waiting (they would all render first), but not prefetch frames or another
|
||||
/// comp's viewer frame.
|
||||
#[test]
|
||||
fn a_newer_viewer_frame_drops_the_ones_still_waiting() {
|
||||
/// Starts nothing, so every request stays queued.
|
||||
struct Idle;
|
||||
impl RemoteFrames for Idle {
|
||||
fn slots(&self) -> usize {
|
||||
0
|
||||
}
|
||||
fn start(&self, _: RemoteJob, _: RemoteDone) {}
|
||||
}
|
||||
let mut f = Frames::default();
|
||||
f.set_remote(Some(Arc::new(Idle)));
|
||||
let src = RenderSource {
|
||||
project: Arc::default(),
|
||||
footage: Arc::new(effectcraft_engine::render::NoFootage),
|
||||
expr: None,
|
||||
layer_cache: Arc::default(),
|
||||
gpu: None,
|
||||
gpu_display: false,
|
||||
disk: None,
|
||||
};
|
||||
let request = |k: FrameKey, urgent: bool| f.request_with(&src, k, ItemId(k.comp), Tick::ZERO, RenderOpts::default(), urgent);
|
||||
let other = FrameKey { comp: 2, ..key(1, 0, 0) };
|
||||
request(other, true);
|
||||
request(key(1, 20, 0), false);
|
||||
for frame in 0..10 {
|
||||
request(key(1, frame, 0), true);
|
||||
}
|
||||
let mut queued: Vec<(u64, i64, bool)> = f.queue.lock().unwrap().jobs.iter().map(|j| (j.key.comp, j.key.frame, j.urgent)).collect();
|
||||
queued.sort();
|
||||
assert_eq!(queued, [(1, 9, true), (1, 20, false), (2, 0, false)]);
|
||||
assert_eq!(f.inflight(), 3, "dropped frames aren't in flight: the viewer can ask again");
|
||||
}
|
||||
|
||||
/// Each time the GPU runs out of memory, half as many GPU frames are kept, down to a floor.
|
||||
#[test]
|
||||
fn running_out_of_video_memory_halves_the_gpu_frame_budget() {
|
||||
|
|
|
|||
|
|
@ -641,13 +641,24 @@ pub fn keyframe(
|
|||
left: effectcraft_engine::keyframe::KeyHalf,
|
||||
right: effectcraft_engine::keyframe::KeyHalf,
|
||||
fill: Color32,
|
||||
outline: Color32,
|
||||
) {
|
||||
painter.extend(keyframe_shapes(center, size, left, right, fill));
|
||||
}
|
||||
|
||||
/// The halves of a [`keyframe`] glyph. Flat, without a dark outline (as in After Effects), so
|
||||
/// keys that overlap read as one shape instead of a tangle of borders (#284); the hairline in
|
||||
/// the fill colour closes the seam between the halves.
|
||||
fn keyframe_shapes(
|
||||
center: Pos2,
|
||||
size: f32,
|
||||
left: effectcraft_engine::keyframe::KeyHalf,
|
||||
right: effectcraft_engine::keyframe::KeyHalf,
|
||||
fill: Color32,
|
||||
) -> Vec<egui::Shape> {
|
||||
use effectcraft_engine::keyframe::KeyHalf as H;
|
||||
let r = size / 2.0;
|
||||
let half = |pts: Vec<Pos2>| {
|
||||
painter.add(PathShape::convex_polygon(pts, fill, Stroke::new(1.0, outline)));
|
||||
};
|
||||
let mut out = Vec::with_capacity(2);
|
||||
let mut half = |pts: Vec<Pos2>| out.push(PathShape::convex_polygon(pts, fill, Stroke::new(0.5, fill)).into());
|
||||
let c = center;
|
||||
for (side, h) in [(-1.0f32, left), (1.0, right)] {
|
||||
match h {
|
||||
|
|
@ -678,4 +689,26 @@ pub fn keyframe(
|
|||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use effectcraft_engine::keyframe::KeyHalf as H;
|
||||
|
||||
/// #284: keyframes have no dark border (overlapping keys looked messy): each half is filled
|
||||
/// and edged in the key's own colour.
|
||||
#[test]
|
||||
fn keyframes_are_drawn_without_a_dark_outline() {
|
||||
let fill = egui::Color32::from_gray(0xc0);
|
||||
for (l, r) in [(H::Linear, H::Linear), (H::Bezier, H::Hold), (H::Auto, H::Linear)] {
|
||||
let shapes = super::keyframe_shapes(egui::pos2(10.0, 10.0), 11.0, l, r, fill);
|
||||
assert_eq!(shapes.len(), 2);
|
||||
for s in shapes {
|
||||
let egui::Shape::Path(p) = s else { panic!("a path: {s:?}") };
|
||||
assert_eq!(p.fill, fill);
|
||||
assert!(p.stroke.width <= 0.5 && p.stroke.color == egui::epaint::ColorMode::Solid(fill), "{:?}", p.stroke);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1167,7 +1167,7 @@ pub fn viewer_hook(
|
|||
if pick.kind == "color"
|
||||
&& keyer.is_none()
|
||||
&& let Some(img) = &app.viewer_image
|
||||
&& let Some(s) = fw::sample_frame(img, map.comp, c)
|
||||
&& let Some(s) = fw::sample_frame(img, super::viewer_tools::shown_region(&ctx, map.comp), c)
|
||||
{
|
||||
let sw = Rect::from_min_size(hp + vec2(14.0, 14.0), vec2(26.0, 18.0));
|
||||
painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8));
|
||||
|
|
@ -1192,7 +1192,7 @@ pub fn viewer_hook(
|
|||
));
|
||||
}
|
||||
} else if let Some(img) = &app.viewer_image
|
||||
&& let Some(s) = fw::sample_frame(img, map.comp, c)
|
||||
&& let Some(s) = fw::sample_frame(img, super::viewer_tools::shown_region(&ctx, map.comp), c)
|
||||
{
|
||||
actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [s[0], s[1], s[2], 1.0]})));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,18 +185,23 @@ pub fn layer_to_comp(l2c: &Mat3, p: [f64; 2]) -> [f64; 2] {
|
|||
[v.x, v.y]
|
||||
}
|
||||
|
||||
/// Straight RGBA (0..1) of the displayed frame at comp point `pt` (the frame may be rendered
|
||||
/// at a reduced resolution: `comp` is the comp size in pixels). `None` outside the frame.
|
||||
pub fn sample_frame(img: &egui::ColorImage, comp: [f32; 2], pt: [f64; 2]) -> Option<[f32; 4]> {
|
||||
if comp[0] <= 0.0 || comp[1] <= 0.0 || pt[0] < 0.0 || pt[1] < 0.0 {
|
||||
/// The premultiplied pixel of the displayed frame at comp point `pt`. The frame covers
|
||||
/// `region` ([x, y, w, h] in comp pixels: the whole comp, or the region of interest or Extended
|
||||
/// Viewer area it was rendered for), maybe at a reduced resolution. `None` outside it.
|
||||
pub fn frame_pixel(img: &egui::ColorImage, region: [f64; 4], pt: [f64; 2]) -> Option<Color32> {
|
||||
let [x, y, w, h] = region;
|
||||
let (fx, fy) = ((pt[0] - x) / w, (pt[1] - y) / h);
|
||||
if !(0.0..1.0).contains(&fx) || !(0.0..1.0).contains(&fy) {
|
||||
return None;
|
||||
}
|
||||
let sx = (pt[0] / comp[0] as f64 * img.size[0] as f64).floor() as usize;
|
||||
let sy = (pt[1] / comp[1] as f64 * img.size[1] as f64).floor() as usize;
|
||||
if sx >= img.size[0] || sy >= img.size[1] {
|
||||
return None;
|
||||
}
|
||||
let c = img.pixels[sy * img.size[0] + sx];
|
||||
let sx = ((fx * img.size[0] as f64).floor() as usize).min(img.size[0].saturating_sub(1));
|
||||
let sy = ((fy * img.size[1] as f64).floor() as usize).min(img.size[1].saturating_sub(1));
|
||||
img.pixels.get(sy.checked_mul(img.size[0])?.checked_add(sx)?).copied()
|
||||
}
|
||||
|
||||
/// Straight RGBA (0..1) of the displayed frame at comp point `pt` ([`frame_pixel`]).
|
||||
pub fn sample_frame(img: &egui::ColorImage, region: [f64; 4], pt: [f64; 2]) -> Option<[f32; 4]> {
|
||||
let c = frame_pixel(img, region, pt)?;
|
||||
let a = c.a() as f32 / 255.0;
|
||||
if a <= 0.0 {
|
||||
return Some([0.0, 0.0, 0.0, 0.0]);
|
||||
|
|
@ -639,12 +644,20 @@ mod tests {
|
|||
img.pixels[4 + 3] = Color32::from_rgba_premultiplied(128, 64, 0, 128); // (3, 1), half alpha
|
||||
img.pixels[0] = Color32::from_rgb(255, 0, 0);
|
||||
// The frame is rendered at half resolution of an 8 × 4 comp.
|
||||
let c = sample_frame(&img, [8.0, 4.0], [7.5, 3.0]).unwrap();
|
||||
let comp = [0.0, 0.0, 8.0, 4.0];
|
||||
let c = sample_frame(&img, comp, [7.5, 3.0]).unwrap();
|
||||
assert!((c[0] - 1.0).abs() < 0.01 && (c[1] - 0.5).abs() < 0.01 && c[2] == 0.0, "{c:?}");
|
||||
assert!((c[3] - 128.0 / 255.0).abs() < 1e-6);
|
||||
assert_eq!(sample_frame(&img, [8.0, 4.0], [0.5, 0.5]), Some([1.0, 0.0, 0.0, 1.0]));
|
||||
assert_eq!(sample_frame(&img, [8.0, 4.0], [8.0, 1.0]), None, "outside");
|
||||
assert_eq!(sample_frame(&img, [8.0, 4.0], [-1.0, 1.0]), None);
|
||||
assert_eq!(sample_frame(&img, comp, [0.5, 0.5]), Some([1.0, 0.0, 0.0, 1.0]));
|
||||
assert_eq!(sample_frame(&img, comp, [8.0, 1.0]), None, "outside");
|
||||
assert_eq!(sample_frame(&img, comp, [-1.0, 1.0]), None);
|
||||
assert_eq!(sample_frame(&img, [0.0, 0.0, 0.0, 4.0], [0.0, 1.0]), None, "an empty region");
|
||||
// A frame of the region of interest (16, 8) to (24, 12) of a bigger comp (#284: it was
|
||||
// read as if it covered the whole comp).
|
||||
let roi = [16.0, 8.0, 8.0, 4.0];
|
||||
assert_eq!(sample_frame(&img, roi, [16.5, 8.5]), Some([1.0, 0.0, 0.0, 1.0]));
|
||||
assert!(sample_frame(&img, roi, [23.5, 11.0]).is_some_and(|c| c[3] > 0.4 && c[3] < 0.6));
|
||||
assert_eq!(sample_frame(&img, roi, [0.5, 0.5]), None, "outside the region");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -306,28 +306,46 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
|
|||
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");
|
||||
// The same side's handles of every selected key move with it, by as much as the
|
||||
// pointer moved (After Effects), in one undo step.
|
||||
if hresp.drag_started() {
|
||||
let grabs: Vec<HandleGrab> = cs
|
||||
.iter()
|
||||
.flat_map(|c2| {
|
||||
c2.keys.iter().flat_map(move |k2| {
|
||||
k2.handles.iter().filter(|h2| h2.out == h.out).map(move |h2| HandleGrab {
|
||||
layer: c2.layer.id,
|
||||
prop: c2.prop.uid,
|
||||
dim: c2.editable.then_some(c2.dim),
|
||||
time: k2.time,
|
||||
out: h2.out,
|
||||
key: (k2.t, k2.v),
|
||||
end: (h2.t, h2.v),
|
||||
negative: h2.speed < 0.0,
|
||||
dur: h2.dur,
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(hp);
|
||||
ctx.data_mut(|dd| dd.insert_temp::<HandleDrag>(handle_drag_id(), (press, grabs)));
|
||||
}
|
||||
if hresp.dragged()
|
||||
&& let Some(pt) = hresp.interact_pointer_pos()
|
||||
&& let Some((press, grabs)) = ctx.data(|dd| dd.get_temp::<HandleDrag>(handle_drag_id()))
|
||||
{
|
||||
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);
|
||||
let (dt, dv) = (tm.t(pt.x) - tm.t(press.x), vmap(pt.y) - vmap(press.y));
|
||||
let merge = format!("ghandle-{uid}-{}", k.idx);
|
||||
for g in &grabs {
|
||||
actions.push(("keys.setEase".into(), g.ease(g.end.0 + dt, g.end.1 + dv, speed, &merge)));
|
||||
}
|
||||
actions.push(("keys.setEase".into(), params));
|
||||
}
|
||||
if hresp.drag_stopped() {
|
||||
ctx.data_mut(|dd| dd.insert_temp(drag_id, false));
|
||||
ctx.data_mut(|dd| {
|
||||
dd.insert_temp(drag_id, false);
|
||||
dd.remove::<HandleDrag>(handle_drag_id());
|
||||
});
|
||||
actions.push(("__endMerge".into(), json!({})));
|
||||
}
|
||||
}
|
||||
|
|
@ -558,3 +576,50 @@ struct KeyGrab {
|
|||
fn key_grab_id() -> egui::Id {
|
||||
egui::Id::new("graph-key-grab")
|
||||
}
|
||||
|
||||
/// A Bezier handle moving with a handle drag: its key, side and dimension, the key's place
|
||||
/// (comp seconds, graph value) and the handle's end when the drag began.
|
||||
#[derive(Clone, Debug)]
|
||||
struct HandleGrab {
|
||||
layer: LayerId,
|
||||
prop: u64,
|
||||
/// `None` for a property drawn as one curve (spatial).
|
||||
dim: Option<usize>,
|
||||
time: Tick,
|
||||
out: bool,
|
||||
key: (f64, f64),
|
||||
end: (f64, f64),
|
||||
/// The speed was negative (the speed graph shows its magnitude).
|
||||
negative: bool,
|
||||
/// Segment duration (layer seconds) the influence is relative to.
|
||||
dur: f64,
|
||||
}
|
||||
|
||||
impl HandleGrab {
|
||||
/// `keys.setEase` putting the handle's end at (`t` comp seconds, `v` in the graph).
|
||||
fn ease(&self, t: f64, v: f64, speed: bool, merge: &str) -> serde_json::Value {
|
||||
let dir = if self.out { 1.0 } else { -1.0 };
|
||||
let dt = ((t - self.key.0) * dir).max(self.dur * 0.001);
|
||||
let influence = (dt / self.dur.max(1e-9) * 100.0).clamp(0.1, 100.0);
|
||||
let sp = if speed {
|
||||
let mag = v.max(0.0);
|
||||
if self.negative { -mag } else { mag }
|
||||
} else {
|
||||
(v - self.key.1) / dt * dir
|
||||
};
|
||||
let side = if self.out { "out" } else { "in" };
|
||||
let mut params =
|
||||
json!({"layer": self.layer.0, "prop": self.prop, "time": self.time.seconds(), "side": side, "speed": sp, "influence": influence, "merge": merge});
|
||||
if let Some(d) = self.dim {
|
||||
params["dim"] = json!(d);
|
||||
}
|
||||
params
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle drag: where the button went down, and the handles that move.
|
||||
type HandleDrag = (Pos2, Vec<HandleGrab>);
|
||||
|
||||
fn handle_drag_id() -> egui::Id {
|
||||
egui::Id::new("graph-handle-drag")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,16 +17,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
let (rgba, xy) = match (app.pointer_comp, &app.viewer_image) {
|
||||
(Some([cx, cy]), Some(img)) => {
|
||||
let comp = app.session.active_comp();
|
||||
let (cw, ch) = comp.map(|c| (c.width as f32, c.height as f32)).unwrap_or((1.0, 1.0));
|
||||
let sx = (cx / cw * img.size[0] as f32) as i64;
|
||||
let sy = (cy / ch * img.size[1] as f32) as i64;
|
||||
let px = if sx >= 0 && sy >= 0 && (sx as usize) < img.size[0] && (sy as usize) < img.size[1] {
|
||||
Some(img.pixels[sy as usize * img.size[0] + sx as usize])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(px, Some((cx, cy)))
|
||||
let comp = app.session.active_comp().map_or([1.0, 1.0], |c| [c.width as f32, c.height as f32]);
|
||||
let region = super::viewer_tools::shown_region(ui.ctx(), comp);
|
||||
(super::fx_widgets::frame_pixel(img, region, [cx as f64, cy as f64]), Some((cx, cy)))
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -82,6 +82,20 @@ pub fn overlay(app: &EffectcraftApp, ctx: &egui::Context, cid: ItemId, layer: &L
|
|||
Some(overlay)
|
||||
}
|
||||
|
||||
/// The layer a Puppet tool press at comp point `cpt` works on, of the selected `layers` with a
|
||||
/// Puppet effect (stack order): the one whose mesh, as deformed now, is under it (pins go on the
|
||||
/// deformed shape, also where it reaches past the layer's own bounds), else the one whose art is
|
||||
/// (a new mesh there). Never a layer without a Puppet effect: the tools stay on the layers being
|
||||
/// rigged, as in After Effects (#284).
|
||||
pub fn target(ectx: &EvalCtx, layers: &[(&Layer, Overlay)], cpt: [f64; 2], on_art: impl Fn(&Layer) -> bool) -> Option<LayerId> {
|
||||
let on_mesh = |l: &Layer, ov: &Overlay| {
|
||||
let Some(inv) = l2c(ectx, l).0.inverse() else { return false };
|
||||
let p = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
ov.iter().any(|(_, mesh, def, _)| puppet::contains(mesh, def, [p.x, p.y]))
|
||||
};
|
||||
layers.iter().find(|(l, ov)| on_mesh(l, ov)).or_else(|| layers.iter().find(|(l, _)| on_art(l))).map(|(l, _)| l.id)
|
||||
}
|
||||
|
||||
/// Keep a pin's keyframed Position in view in the Timeline after placing, moving or recording the
|
||||
/// pin in the viewer (#273).
|
||||
pub fn reveal_pin(app: &mut EffectcraftApp, layer: LayerId, pin: u64) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
use effectcraft_engine::color::BlendMode;
|
||||
use effectcraft_engine::keyframe::{Interp, Value};
|
||||
use effectcraft_engine::project::{Comp, GroupKind, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property};
|
||||
use effectcraft_engine::project::{Comp, GroupKind, ItemId, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
|
|
@ -115,6 +115,20 @@ fn pick_whip_id() -> egui::Id {
|
|||
egui::Id::new("tl-pickwhip")
|
||||
}
|
||||
|
||||
/// The text of an expression being edited when its pick whip was pressed, and the selection in
|
||||
/// it (characters): the pick whip's reference goes in there.
|
||||
type WhipInto = (String, [usize; 2]);
|
||||
|
||||
fn whip_into_id(prop: u64) -> egui::Id {
|
||||
egui::Id::new(("tl-pickwhip-into", prop))
|
||||
}
|
||||
|
||||
/// After a pick whip into the expression being edited: its editor takes the focus back, the
|
||||
/// cursor this many characters before the end (just after the reference).
|
||||
fn expr_refocus_id(prop: u64) -> egui::Id {
|
||||
egui::Id::new(("tl-expr-refocus", prop))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Row {
|
||||
layer: LayerId,
|
||||
|
|
@ -980,8 +994,8 @@ fn key_drag_id() -> egui::Id {
|
|||
egui::Id::new("tl-key-drag")
|
||||
}
|
||||
|
||||
/// A layer switch pressed and dragged over other layers: the switch, the state the press gave
|
||||
/// it, and the undo step the drag folds into.
|
||||
/// A switch pressed and dragged over other rows: the switch, the state the press gave it, and
|
||||
/// the undo step the drag folds into.
|
||||
#[derive(Clone, Debug)]
|
||||
struct SwitchDrag {
|
||||
switch: &'static str,
|
||||
|
|
@ -993,9 +1007,24 @@ fn switch_drag_id() -> egui::Id {
|
|||
egui::Id::new("tl-switch-drag")
|
||||
}
|
||||
|
||||
/// A layer's switch (A/V Features and Switches columns) in `row`. Pressing it sets it, and
|
||||
/// dragging on gives the same switch of every layer the pointer passes the same new state, in
|
||||
/// one undo step (After Effects).
|
||||
/// The press-and-drag gesture of the Timeline's switches and stopwatches (`switch`, on `row`,
|
||||
/// now `on`, pressed through `resp`): the press gives it the opposite state, and dragging on
|
||||
/// gives every `switch` the pointer passes that same state, in one undo step, whatever its own
|
||||
/// (After Effects). The state to set and the undo step to fold it into, when it changes now.
|
||||
fn switch_gesture(ui: &egui::Ui, resp: &egui::Response, row: Rect, switch: &'static str, on: bool) -> Option<(bool, String)> {
|
||||
if resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) {
|
||||
let merge = format!("tl-switch-{}", ui.input(|i| i.time));
|
||||
ui.data_mut(|d| d.insert_temp(switch_drag_id(), SwitchDrag { switch, value: !on, merge: merge.clone() }));
|
||||
return Some((!on, merge));
|
||||
}
|
||||
let d = ui.data(|d| d.get_temp::<SwitchDrag>(switch_drag_id())).filter(|d| d.switch == switch && d.value != on)?;
|
||||
// The heights the pointer moved over since the last frame, so a quick drag skips no row.
|
||||
let (a, b) = ui.input(|i| i.pointer.interact_pos().map(|p| (p.y - i.pointer.delta().y, p.y)))?;
|
||||
(a.min(b) < row.max.y && a.max(b) >= row.min.y).then_some((d.value, d.merge))
|
||||
}
|
||||
|
||||
/// A layer's switch (A/V Features and Switches columns) in `row`, with the
|
||||
/// [`switch_gesture`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn layer_switch(
|
||||
ui: &mut egui::Ui,
|
||||
|
|
@ -1010,25 +1039,34 @@ fn layer_switch(
|
|||
actions: &mut Vec<(String, serde_json::Value)>,
|
||||
) {
|
||||
let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag());
|
||||
let set = |actions: &mut Vec<(String, serde_json::Value)>, value: bool, merge: &str| {
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "value": value, "merge": merge})))
|
||||
};
|
||||
if resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) {
|
||||
let merge = format!("tl-switch-{}", ui.input(|i| i.time));
|
||||
set(actions, !on, &merge);
|
||||
ui.data_mut(|d| d.insert_temp(switch_drag_id(), SwitchDrag { switch: name, value: !on, merge }));
|
||||
} else if let Some(d) = ui.data(|d| d.get_temp::<SwitchDrag>(switch_drag_id())).filter(|d| d.switch == name && d.value != on)
|
||||
// The heights the pointer moved over since the last frame, so a quick drag skips no row.
|
||||
&& let Some((a, b)) = ui.input(|i| i.pointer.interact_pos().map(|p| (p.y - i.pointer.delta().y, p.y)))
|
||||
&& a.min(b) < row.max.y
|
||||
&& a.max(b) >= row.min.y
|
||||
{
|
||||
set(actions, d.value, &d.merge);
|
||||
if let Some((value, merge)) = switch_gesture(ui, &resp, row, name, on) {
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "value": value, "merge": merge})));
|
||||
} else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
|
||||
// Keyboard or accessibility activation (a pointer press was handled above).
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name})));
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_id(comp: ItemId) -> egui::Id {
|
||||
egui::Id::new(("timeline-selection-anchor", comp.0))
|
||||
}
|
||||
|
||||
/// Remember `layer` as where the next Shift-click selection starts.
|
||||
fn set_anchor(ctx: &egui::Context, comp: ItemId, layer: LayerId) {
|
||||
ctx.data_mut(|d| d.insert_temp(anchor_id(comp), layer));
|
||||
}
|
||||
|
||||
/// The layers a Shift-click on `layer` (its name or its bar) selects: those from the last layer
|
||||
/// clicked (else the first selected) to it in stack order, but locked layers and layers the
|
||||
/// Timeline doesn't show. `None` without such a layer.
|
||||
fn shift_range(ctx: &egui::Context, cid: ItemId, comp: &Comp, rows: &[Row], selected: &[LayerId], layer: LayerId) -> Option<Vec<u64>> {
|
||||
let anchor = ctx.data(|d| d.get_temp::<LayerId>(anchor_id(cid))).or_else(|| selected.first().copied())?;
|
||||
let a = comp.layers.iter().position(|l| l.id == anchor)?;
|
||||
let b = comp.layers.iter().position(|l| l.id == layer)?;
|
||||
let span = comp.layers.get(a.min(b)..=a.max(b))?;
|
||||
Some(span.iter().filter(|l| !l.switches.locked && rows.iter().any(|r| r.layer == l.id)).map(|l| l.id.0).collect())
|
||||
}
|
||||
|
||||
/// Set on the frame the reorder drag is released.
|
||||
/// Where layers dropped at height `py` land among the visible layer rows: above the first row
|
||||
/// whose middle is below it (its index in `layer_rows`), else below the last row. Returns that
|
||||
|
|
@ -1320,9 +1358,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
s += secs_per_label;
|
||||
}
|
||||
// Scrub in the ruler.
|
||||
// Scrub in the ruler: the current time jumps to the pointer as soon as the button goes down
|
||||
// (After Effects), then follows it.
|
||||
let rresp = ui.interact(Rect::from_min_max(pos2(graph_x0, ruler.min.y + 12.0), ruler.max), egui::Id::new("tl-ruler"), Sense::click_and_drag());
|
||||
if (rresp.dragged() || rresp.clicked())
|
||||
if ((rresp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down())) || rresp.clicked())
|
||||
&& let Some(pt) = rresp.interact_pointer_pos()
|
||||
{
|
||||
let mut secs = tm.t(pt.x).max(0.0);
|
||||
|
|
@ -1620,27 +1659,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name);
|
||||
if row_resp.clicked() && !locked {
|
||||
let m = ui.input(|i| i.modifiers);
|
||||
let anchor_key = egui::Id::new(("timeline-selection-anchor", cid.0));
|
||||
let anchor = ctx.data(|d| d.get_temp::<LayerId>(anchor_key)).or_else(|| selected.first().copied());
|
||||
let range = if m.shift {
|
||||
anchor.and_then(|a| comp.layers.iter().position(|l| l.id == a)).map(|a| {
|
||||
let b = comp.layers.iter().position(|l| l.id == layer.id).unwrap_or(a);
|
||||
comp.layers
|
||||
.get(a.min(b)..=a.max(b))
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|l| !l.switches.locked && rows.iter().any(|r| r.layer == l.id))
|
||||
.map(|l| l.id.0)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(layers) = range {
|
||||
if let Some(layers) = m.shift.then(|| shift_range(&ctx, cid, &comp, &rows, &selected, layer.id)).flatten() {
|
||||
actions.push(("layer.select".into(), json!({"layers": layers})));
|
||||
} else {
|
||||
actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "toggle": m.command})));
|
||||
ctx.data_mut(|d| d.insert_temp(anchor_key, layer.id));
|
||||
set_anchor(&ctx, cid, layer.id);
|
||||
}
|
||||
}
|
||||
if row_resp.double_clicked() {
|
||||
|
|
@ -1841,8 +1864,18 @@ 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()) && !is_sel {
|
||||
actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": ui.input(|i| i.modifiers.shift)})));
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
if let Some(layers) = (body.clicked() && shift).then(|| shift_range(&ctx, cid, &comp, &rows, &selected, layer.id)).flatten() {
|
||||
// Shift-click: the layers from the last one clicked to this one, as in
|
||||
// the layer outline.
|
||||
actions.push(("layer.select".into(), json!({"layers": layers})));
|
||||
} else {
|
||||
if (body.clicked() || body.drag_started()) && !is_sel {
|
||||
actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": shift})));
|
||||
}
|
||||
if (body.clicked() || body.drag_started()) && !shift {
|
||||
set_anchor(&ctx, cid, layer.id);
|
||||
}
|
||||
}
|
||||
layer_context_menu(&body, layer, &mut actions);
|
||||
let drag_key = format!("bar-{}", layer.id.0);
|
||||
|
|
@ -1977,7 +2010,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max);
|
||||
gp.rect_filled(wr, 0.0, t.tl_bg);
|
||||
app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform");
|
||||
match super::waveform::summary(app, &ctx, effectcraft_engine::project::ItemId(*item)) {
|
||||
match super::waveform::summary(app, &ctx, ItemId(*item)) {
|
||||
Some(s) => super::waveform::draw(&gp, wr.shrink2(vec2(0.0, 2.0)), &tm, layer, &s, Color32::from_rgb(0x5f, 0xc8, 0x8a)),
|
||||
None => {
|
||||
gp.text(
|
||||
|
|
@ -2017,6 +2050,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
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.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) {
|
||||
// Pressed while the expression is being edited (its text is buffered while
|
||||
// the editor has the focus): the reference will go in at the cursor,
|
||||
// replacing the selection (#284).
|
||||
let into = ctx.data(|d| d.get_temp::<String>(egui::Id::new(("expr-buf", uid)))).map(|buf| {
|
||||
let n = buf.chars().count();
|
||||
let sel = egui::text_edit::TextEditState::load(&ctx, egui::Id::new(("expr-edit", uid)))
|
||||
.and_then(|st| st.cursor.char_range())
|
||||
.map_or([n, n], |r| [r.primary.index.0.min(r.secondary.index.0).min(n), r.primary.index.0.max(r.secondary.index.0).min(n)]);
|
||||
(buf, sel)
|
||||
});
|
||||
ctx.data_mut(|d| {
|
||||
if let Some(into) = into {
|
||||
d.insert_temp(whip_into_id(*uid), into);
|
||||
} else {
|
||||
d.remove::<WhipInto>(whip_into_id(*uid));
|
||||
}
|
||||
});
|
||||
}
|
||||
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));
|
||||
|
|
@ -2070,19 +2122,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
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 edit_id = egui::Id::new(("expr-edit", uid));
|
||||
let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
|
||||
if let Some(after) = ctx.data_mut(|d| d.remove_temp::<usize>(expr_refocus_id(*uid))) {
|
||||
// A pick whip just put a reference in: editing goes on in the new text,
|
||||
// the cursor after the reference.
|
||||
buf = ex.text.clone();
|
||||
let mut st = egui::text_edit::TextEditState::load(&ctx, edit_id).unwrap_or_default();
|
||||
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(buf.chars().count().saturating_sub(after)))));
|
||||
st.store(&ctx, edit_id);
|
||||
ctx.memory_mut(|m| m.request_focus(edit_id));
|
||||
}
|
||||
// Settings ▸ Scripting & Expressions ▸ Expressions Editor.
|
||||
let sp = app.session.prefs.scripting.clone();
|
||||
let resp = super::expr_editor::editor(
|
||||
ui,
|
||||
egui::Id::new(("expr-edit", uid)),
|
||||
&mut buf,
|
||||
er,
|
||||
&sp,
|
||||
if ex.enabled { expr_col } else { t.text_dim },
|
||||
(*lines).clamp(1, 8),
|
||||
&t,
|
||||
);
|
||||
let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, (*lines).clamp(1, 8), &t);
|
||||
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 {
|
||||
|
|
@ -2145,10 +2198,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe");
|
||||
}
|
||||
// Stopwatch.
|
||||
// Stopwatch: Alt+click adds an expression; a press toggles the animation, and
|
||||
// dragging on over other stopwatches gives them the same state (#284).
|
||||
if !prop.static_only {
|
||||
let swr = Rect::from_center_size(pos2(indent, cy), vec2(15.0, 15.0));
|
||||
let resp = ui.interact(swr, egui::Id::new(("stopwatch", uid)), Sense::click());
|
||||
let resp = ui.interact(swr, egui::Id::new(("stopwatch", uid)), Sense::click_and_drag());
|
||||
let col = if prop.is_animated() {
|
||||
t.hot_text
|
||||
} else if resp.hovered() {
|
||||
|
|
@ -2158,12 +2212,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
};
|
||||
icons::paint(&lp, swr, Icon::Stopwatch, col);
|
||||
app.auto.add(&format!("timeline.prop.{uid}.stopwatch"), swr, &prop.name);
|
||||
if resp.clicked() {
|
||||
if ui.input(|i| i.modifiers.alt) {
|
||||
if ui.input(|i| i.modifiers.alt) {
|
||||
if resp.clicked() {
|
||||
actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid})));
|
||||
} else {
|
||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
|
||||
}
|
||||
} else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) {
|
||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge})));
|
||||
} else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
|
||||
// Keyboard or accessibility activation.
|
||||
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
|
||||
}
|
||||
}
|
||||
let name_x = indent + 12.0;
|
||||
|
|
@ -2232,7 +2289,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
// 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));
|
||||
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol);
|
||||
}
|
||||
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());
|
||||
|
|
@ -2420,12 +2477,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
if ctx.input(|i| !i.pointer.any_down()) {
|
||||
ctx.data_mut(|d| d.remove::<PickWhip>(pick_whip_id()));
|
||||
let into = (kind == 1).then(|| ctx.data_mut(|d| d.remove_temp::<WhipInto>(whip_into_id(src_prop)))).flatten();
|
||||
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}})));
|
||||
let mut params = json!({"layer": src_layer, "prop": src_prop, "target": {"layer": layer.0, "prop": uid}});
|
||||
if let Some((text, [a, b])) = into {
|
||||
// Into the expression being edited, which goes on being edited.
|
||||
params["expression"] = json!(text);
|
||||
params["range"] = json!([a, b]);
|
||||
let after = text.chars().count().saturating_sub(b);
|
||||
ctx.data_mut(|d| d.insert_temp(expr_refocus_id(src_prop), after));
|
||||
}
|
||||
actions.push(("prop.pickWhip".into(), params));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -919,6 +919,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
|
||||
// Puppet pins and meshes of the selected layers (Puppet tools).
|
||||
let mut pin_hits: Vec<super::puppet_tool::PinHit> = vec![];
|
||||
// The selected layers with a Puppet effect, top of the stack first (the layers the Puppet
|
||||
// tools work on).
|
||||
let mut puppet_layers: Vec<(&Layer, super::puppet_tool::Overlay)> = vec![];
|
||||
if app.ui.tool.puppet_kind().is_some() {
|
||||
let sel_props: Vec<u64> = app.session.state.selected_props.iter().map(|(_, u)| *u).collect();
|
||||
for lid in &selected {
|
||||
|
|
@ -927,8 +930,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
{
|
||||
let hover = ui.input(|i| i.pointer.hover_pos());
|
||||
pin_hits.extend(super::puppet_tool::draw(&painter, &map, &ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props, hover));
|
||||
puppet_layers.push((l, ov));
|
||||
}
|
||||
}
|
||||
puppet_layers.sort_by_key(|(l, _)| comp.index_of(l.id));
|
||||
for h in &pin_hits {
|
||||
app.auto.add(&format!("viewer.puppetPin.{}", h.pin), Rect::from_center_size(h.pos, vec2(10.0, 10.0)), "Puppet pin");
|
||||
if let Some(hp) = h.handle {
|
||||
|
|
@ -938,6 +943,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
}
|
||||
}
|
||||
// The layer a Puppet tool press works on: while selected layers are rigged, only those
|
||||
// ([`super::puppet_tool::target`]); otherwise the layer under the pointer, selected.
|
||||
let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option<LayerId> {
|
||||
if puppet_layers.is_empty() {
|
||||
return pick(app, &ectx, cpt, false);
|
||||
}
|
||||
super::puppet_tool::target(&ectx, &puppet_layers, cpt, |l| layers_at(&ectx, cpt).any(|h| h.id == l.id))
|
||||
};
|
||||
|
||||
// Project items, files and effects dropped on the viewer.
|
||||
super::viewer_drop::show(app, ui, &painter, &map, &ectx);
|
||||
|
|
@ -1121,7 +1134,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
t if t.is_shape() => Some(Gesture::Create { tool: t, start: cpt }),
|
||||
t if t.puppet_kind().is_some() && super::puppet_tool::hit(&pin_hits, press).is_none() => {
|
||||
// Off the pins: Alt or a press outside the art drags a marquee that selects pins.
|
||||
(mods.alt || pick(app, &ectx, cpt, false).is_none()).then_some(Gesture::PinMarquee { start: press })
|
||||
(mods.alt || puppet_layer(app, cpt).is_none()).then_some(Gesture::PinMarquee { start: press })
|
||||
}
|
||||
t if t.puppet_kind().is_some() => super::puppet_tool::hit(&pin_hits, press).and_then(|(h, part)| {
|
||||
use super::puppet_tool::PinPart;
|
||||
|
|
@ -1557,7 +1570,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
if let Some((h, _)) = super::puppet_tool::hit(&pin_hits, pos) {
|
||||
// Click selects the pin; Shift-click adds or removes it.
|
||||
let _ = app.session.execute("puppet.selectPins", json!({"layer": h.layer.0, "pins": [h.pin], "toggle": mods.shift}));
|
||||
} else if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l))
|
||||
} else if let Some(l) = puppet_layer(app, cpt).and_then(|l| comp.layer(l))
|
||||
&& let Some(inv) = l2c(&ectx, l).0.inverse()
|
||||
{
|
||||
let lp = inv.apply(gv2(cpt[0], cpt[1]));
|
||||
|
|
|
|||
|
|
@ -325,6 +325,12 @@ fn texture_roi(ctx: &egui::Context) -> Option<[f64; 4]> {
|
|||
ctx.data(|d| d.get_temp::<Option<[f64; 4]>>(egui::Id::new("viewer-tex-roi"))).flatten()
|
||||
}
|
||||
|
||||
/// The comp area ([x, y, w, h], comp pixels) the shown frame covers: its region of interest or
|
||||
/// Extended Viewer area, else the whole `comp` (width, height).
|
||||
pub(crate) fn shown_region(ctx: &egui::Context, comp: [f32; 2]) -> [f64; 4] {
|
||||
texture_roi(ctx).unwrap_or([0.0, 0.0, comp[0] as f64, comp[1] as f64])
|
||||
}
|
||||
|
||||
/// Show Snapshot: the stored snapshot is on screen (button or F5 held).
|
||||
pub(crate) fn showing_snapshot(app: &EffectcraftApp, ctx: &egui::Context) -> bool {
|
||||
let f5 = ctx.input(|i| i.key_down(egui::Key::F5)) && !ctx.egui_wants_keyboard_input();
|
||||
|
|
|
|||
|
|
@ -187,6 +187,8 @@ impl Tokens {
|
|||
tl_ruler_bg: Color32::from_rgb(0xe2, 0xe2, 0xe2),
|
||||
tl_ruler_text: Color32::from_rgb(0x50, 0x50, 0x50),
|
||||
pasteboard: Color32::from_rgb(0xa8, 0xa8, 0xa8),
|
||||
// Keyframes have no outline: dark enough to stand out on the light time graph.
|
||||
keyframe: Color32::from_rgb(0x6c, 0x6c, 0x6c),
|
||||
..dark
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,28 @@ fn crosshair_and_eyedropper_pick_from_the_viewer() {
|
|||
assert_eq!(c[3], 1.0);
|
||||
}
|
||||
|
||||
/// #284: with a Region of Interest the shown frame covers only that region; the eyedropper
|
||||
/// samples the composite under the pointer there (it read the frame as if it covered the whole
|
||||
/// comp, so it sampled somewhere else).
|
||||
#[test]
|
||||
fn the_eyedropper_samples_under_the_pointer_in_a_region_of_interest() {
|
||||
let (mut app, x) = app();
|
||||
app.session.execute("view.setRegionOfInterest", json!({"rect": [0.0, 0.0, 320.0, 180.0]})).unwrap();
|
||||
// (Not the red it will pick.)
|
||||
app.session.execute("prop.set", json!({"layer": x.small, "prop": x.color, "value": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1700.0, 1100.0)).build_eframe(|_| app);
|
||||
settle(&mut h);
|
||||
h.state_mut().ui.fx_pick = Some(FxPick { kind: "color".into(), layer: x.small, prop: x.color, name: "Color".into() });
|
||||
h.step();
|
||||
// (200, 150) is on the red layer (it covers 120..520 × 80..280); read as a frame of the
|
||||
// whole comp, the green background at (100, 75).
|
||||
let pos = effectcraft_ui_egui::panels::viewer::comp_to_screen(&h.ctx, [200.0, 150.0]).unwrap();
|
||||
click(&mut h, pos);
|
||||
let v = layer(&h.state().session, x.small).effects().unwrap().find(x.color).unwrap().value.clone();
|
||||
let KV::Color(c) = v else { panic!("{v:?}") };
|
||||
assert!(c[0] > 0.98 && c[1] < 0.02 && c[2] < 0.02, "{c:?}");
|
||||
}
|
||||
|
||||
/// Key Light's Screen Colour eyedropper picks from the effect's input: the shown frame is
|
||||
/// already keyed (the default screen colour takes most of a green), so sampling it would miss.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -73,3 +73,57 @@ fn timecode_field_replaces_commits_and_cancels() {
|
|||
assert_eq!(frame(&h), 24, "Escape cancels");
|
||||
assert!(!h.ctx.egui_wants_keyboard_input(), "Escape releases the keyboard");
|
||||
}
|
||||
|
||||
/// #284: dragging the expression pick whip while the expression is being edited puts the
|
||||
/// reference at the cursor (After Effects), and the editing goes on after it; without an edit
|
||||
/// in progress it replaces the expression.
|
||||
#[test]
|
||||
fn the_expression_pick_whip_inserts_at_the_cursor_while_editing() {
|
||||
let (mut h, _) = harness();
|
||||
let (layer, opacity, rotation, transform) = {
|
||||
let s = &mut h.state_mut().session;
|
||||
let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": l, "path": "transform/opacity", "expression": "50 + 0"})).unwrap();
|
||||
let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().clone();
|
||||
(l, tr.get("opacity").unwrap().uid, tr.get("rotation").unwrap().uid, tr.uid)
|
||||
};
|
||||
h.state_mut().ui.timeline.open_layers.insert(layer);
|
||||
h.state_mut().ui.timeline.open_groups.insert(transform);
|
||||
h.run_steps(3);
|
||||
let expr = |h: &Harness<'_, EffectcraftApp>| {
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
comp.layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.find(opacity).unwrap().expr.as_ref().unwrap().text.clone()
|
||||
};
|
||||
let whip = |h: &mut Harness<'_, EffectcraftApp>| {
|
||||
let (from, to) = (center(h, &format!("timeline.prop.{opacity}.pickWhip")), center(h, &format!("timeline.prop.{rotation}.name")));
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=8 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
};
|
||||
// Editing: the cursor at the end after typing " * ".
|
||||
let p = center(&h, &format!("timeline.prop.{opacity}.expression"));
|
||||
click(&mut h, p, 1);
|
||||
key(&mut h, Key::End);
|
||||
type_text(&mut h, " * ");
|
||||
whip(&mut h);
|
||||
assert_eq!(expr(&h), "50 + 0 * transform.rotation");
|
||||
// The editor has the keyboard again, its cursor after the reference.
|
||||
type_text(&mut h, " + 1");
|
||||
// Ctrl/Cmd+Enter commits.
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND));
|
||||
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(3);
|
||||
assert_eq!(expr(&h), "50 + 0 * transform.rotation + 1");
|
||||
// Not editing: the reference replaces the expression.
|
||||
whip(&mut h);
|
||||
assert_eq!(expr(&h), "transform.rotation");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -293,3 +293,43 @@ fn clicking_an_ease_preset_eases_the_selected_keys_and_handles_edit_the_curve()
|
|||
assert!((applied * 100.0 - out).abs() < 1e-9, "{applied} vs {out}");
|
||||
assert_eq!(h.state().session.history.undo.len(), steps + 2);
|
||||
}
|
||||
|
||||
/// #284: in the Graph Editor, dragging the handle of one selected key moves the same handle of
|
||||
/// every selected key with it (After Effects), in one undo step.
|
||||
#[test]
|
||||
fn a_graph_handle_drag_moves_the_handles_of_every_selected_key() {
|
||||
let (mut h, id, uid) = harness();
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("prop.addKey", json!({"layer": id.0, "path": "transform/opacity", "time": 2.0, "value": 0.0})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": id.0, "prop": uid})).unwrap();
|
||||
s.execute("keys.selectAll", json!({})).unwrap();
|
||||
s.execute("keys.easyEase", json!({})).unwrap();
|
||||
h.state_mut().ui.timeline.graph_editor = true;
|
||||
h.run_steps(4);
|
||||
// (in, out) influence of each key.
|
||||
let influences = |h: &Harness<'_, EffectcraftApp>| -> Vec<(f64, f64)> {
|
||||
let l = h.state().session.active_comp().unwrap().layer(id).unwrap().clone();
|
||||
let keys = l.props.prop("transform/opacity").unwrap().keys.clone();
|
||||
keys.iter().map(|k| (k.in_ease.first().map_or(0.0, |e| e.influence), k.out_ease.first().map_or(0.0, |e| e.influence))).collect()
|
||||
};
|
||||
let before = influences(&h);
|
||||
let handle = |h: &Harness<'_, EffectcraftApp>, key: usize| {
|
||||
let e = h.state().auto.find(&format!("timeline.graph.handle.{uid}.0.{key}.out")).unwrap_or_else(|| panic!("no handle {key}")).clone();
|
||||
pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0)
|
||||
};
|
||||
let px_per_s = {
|
||||
let at = |i: usize| h.state().auto.find(&format!("timeline.graph.key.{uid}.0.{i}")).unwrap().rect[0];
|
||||
at(1) - at(0)
|
||||
};
|
||||
let (k0, k1) = (handle(&h, 0), handle(&h, 1));
|
||||
let undo = h.state().session.history.undo.len();
|
||||
// Key 1's out handle 0.2 s further out (the keys are 1 s apart): key 0's goes along.
|
||||
drag(&mut h, k1, k1 + egui::vec2(px_per_s * 0.2, 0.0), Default::default());
|
||||
let after = influences(&h);
|
||||
for i in [0, 1] {
|
||||
assert!((after[i].1 - before[i].1 - 0.2).abs() < 0.01, "key {i}: {:?} → {:?}", before[i], after[i]);
|
||||
}
|
||||
assert_eq!((after[1].0, after[2].0), (before[1].0, before[2].0), "the in handles stay");
|
||||
assert_eq!(h.state().session.history.undo.len(), undo + 1, "one undo step");
|
||||
assert!((handle(&h, 0).x - k0.x - px_per_s * 0.2).abs() < 2.0, "key 0's handle moved on screen");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,36 @@ fn an_edit_in_another_comp_keeps_the_cached_frames_and_undo_finds_them_again() {
|
|||
assert!(h.state().frames.is_cached(&effectcraft_ui_egui::frames::FrameKey { frame: 0, ..b_frame }));
|
||||
}
|
||||
|
||||
/// #284: the Timeline shows the cached frames as a green line under the work area bar, from
|
||||
/// the first frame rendered on.
|
||||
#[test]
|
||||
fn the_timeline_shows_the_cached_frames_in_green() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Cache", "width": 64, "height": 36, "duration": 2})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#3080ff"})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
settle(&mut h);
|
||||
h.run_steps(2);
|
||||
let green = h.state().tokens.cache_green;
|
||||
let bar = h.state().auto.find("timeline.workArea.bar").unwrap().rect;
|
||||
fn rects(s: &egui::Shape, out: &mut Vec<egui::epaint::RectShape>) {
|
||||
match s {
|
||||
egui::Shape::Rect(r) => out.push(r.clone()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| rects(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut all = vec![];
|
||||
for c in &h.output().shapes {
|
||||
rects(&c.shape, &mut all);
|
||||
}
|
||||
let line: Vec<egui::Rect> = all.iter().filter(|r| r.fill == green).map(|r| r.rect).collect();
|
||||
assert!(!line.is_empty(), "a green cache line is drawn");
|
||||
// Under the work area bar, at its start (frame 0 is cached), a visible width.
|
||||
let under = |r: &egui::Rect| r.min.y >= bar[1] + bar[3] - 1.0 && r.max.y <= bar[1] + bar[3] + 8.0;
|
||||
assert!(line.iter().any(|r| under(r) && (r.min.x - bar[0]).abs() < 2.0 && r.width() >= 1.0), "{line:?} bar {bar:?}");
|
||||
}
|
||||
|
||||
/// Issue #65: while edits keep coming (a layer dragged in the viewer), only the viewer's frame
|
||||
/// renders; prefetching the frames around it waits until the edits pause (they would be stale at
|
||||
/// the next step, and would hold the CPU and GPU the next viewer frame needs), then resumes.
|
||||
|
|
|
|||
|
|
@ -96,3 +96,51 @@ fn dragging_over_layer_switches_sets_them_all() {
|
|||
assert_eq!(states(&h, &ids, motion_blur), [false, true, true, true]);
|
||||
assert_eq!(states(&h, &ids, video), [false, true, false, true], "other switches untouched");
|
||||
}
|
||||
|
||||
/// #284: pressing a stopwatch and dragging over the stopwatches below gives every property the
|
||||
/// state the first one got (animated or not), in one undo step; Alt+click still adds an
|
||||
/// expression and a click toggles one property.
|
||||
#[test]
|
||||
fn dragging_over_stopwatches_sets_them_all() {
|
||||
let (mut h, ids) = harness();
|
||||
// Layer A twirled open to its Transform properties.
|
||||
let (layer, uids) = {
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
let l = comp.layer(LayerId(ids[0])).unwrap();
|
||||
let tr = l.props.sub("transform").unwrap();
|
||||
let uids: Vec<u64> = ["anchor", "position", "scale", "rotation", "opacity"].iter().map(|m| tr.get(m).unwrap().uid).collect();
|
||||
(l.id, (tr.uid, uids))
|
||||
};
|
||||
let (group, uids) = uids;
|
||||
h.state_mut().ui.timeline.open_layers.insert(layer.0);
|
||||
h.state_mut().ui.timeline.open_groups.insert(group);
|
||||
h.run_steps(3);
|
||||
let animated = |h: &Harness<'_, EffectcraftApp>| -> Vec<bool> {
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
let l = comp.layer(layer).unwrap();
|
||||
uids.iter().map(|u| l.props.find(*u).unwrap().is_animated()).collect()
|
||||
};
|
||||
let sw = |h: &Harness<'_, EffectcraftApp>, i: usize| rect(h, &format!("timeline.prop.{}.stopwatch", uids[i])).center();
|
||||
let steps = h.state().session.history.undo.len();
|
||||
|
||||
// Scale animated first; from Anchor Point down to Opacity: all animated, one undo step.
|
||||
h.state_mut().session.execute("prop.toggleAnimation", json!({"layer": layer.0, "prop": uids[2]})).unwrap();
|
||||
h.run_steps(2);
|
||||
let steps = steps + 1;
|
||||
let (a, o) = (sw(&h, 0), sw(&h, 4));
|
||||
drag(&mut h, a, o, 10);
|
||||
assert_eq!(animated(&h), [true; 5]);
|
||||
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
|
||||
// From Rotation (animated → not) up to Position, quickly: those three stop animating.
|
||||
let (r, p) = (sw(&h, 3), sw(&h, 1));
|
||||
drag(&mut h, r, p, 2);
|
||||
assert_eq!(animated(&h), [true, false, false, false, true]);
|
||||
// A click toggles only that property.
|
||||
let s = sw(&h, 2);
|
||||
drag(&mut h, s, s, 1);
|
||||
assert_eq!(animated(&h), [true, false, true, false, true]);
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(animated(&h), [true; 5], "each drag was one step");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,3 +213,31 @@ fn shift_dragging_the_work_area_snaps_to_the_current_time() {
|
|||
drag_with(&mut h, mid, to, Modifiers::SHIFT);
|
||||
assert!(near(work_area(&h), (3.0, 6.0)), "the bar snaps its end to the CTI: {:?}", work_area(&h));
|
||||
}
|
||||
|
||||
/// #284: pressing in the time ruler moves the current time there at once, before the pointer
|
||||
/// moves or the button comes up (After Effects); dragging on then scrubs.
|
||||
#[test]
|
||||
fn a_press_in_the_ruler_moves_the_current_time() {
|
||||
let (mut h, _) = harness();
|
||||
// (Opening the comp showed all of it.)
|
||||
h.state_mut().ui.timeline.pps = Some(200.0);
|
||||
h.state_mut().ui.timeline.start = 10.0;
|
||||
h.run_steps(2);
|
||||
let ruler = rect(&h, "timeline.ruler");
|
||||
let (start, _) = visible(&h);
|
||||
// 2 s into the visible span at 200 px/s (the ruler maps time from 6 px in).
|
||||
let at = pos2(ruler.min.x + 6.0 + 400.0, ruler.min.y + 17.0);
|
||||
h.event(Event::PointerMoved(at));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
let now = h.state().session.time().seconds();
|
||||
assert!((now - (start + 2.0)).abs() < 0.05, "the press moved the CTI to {}: {now}", start + 2.0);
|
||||
// Still held: dragging scrubs on.
|
||||
h.event(Event::PointerMoved(at + vec2(100.0, 0.0)));
|
||||
h.step();
|
||||
let now = h.state().session.time().seconds();
|
||||
assert!((now - (start + 2.5)).abs() < 0.05, "{now}");
|
||||
h.event(Event::PointerButton { pos: at + vec2(100.0, 0.0), button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -891,6 +891,43 @@ fn placing_a_pin_opens_its_groups_on_a_twirled_open_layer() {
|
|||
assert_eq!(timeline_keys(&h, pin_keys(&h, id, pin).0).len(), 1, "the new pin's Position key shows");
|
||||
}
|
||||
|
||||
/// #284: the Puppet tools stay on the layer being rigged: a pin goes on its mesh where the
|
||||
/// deformation has stretched it past the layer's own bounds (over another layer), and a click on
|
||||
/// another layer neither selects it nor rigs it.
|
||||
#[test]
|
||||
fn puppet_pins_go_on_the_deformed_mesh_and_the_tool_stays_on_the_rigged_layer() {
|
||||
let mut h = harness();
|
||||
let s = &mut h.state_mut().session;
|
||||
s.execute("layer.select", json!({"layers": ["Box"]})).unwrap();
|
||||
let (boxl, plate) = (s.state.selected_layers[0], s.active_comp().unwrap().layers[1].id);
|
||||
s.execute("puppet.addPin", json!({"layer": boxl.0, "position": [10, 40]})).unwrap();
|
||||
let right = s.execute("puppet.addPin", json!({"layer": boxl.0, "position": [70, 40]})).unwrap()["pin"].as_u64().unwrap();
|
||||
// Stretch the Box to the right: its right edge (layer x 80, comp x 360) moves well past 400.
|
||||
s.execute("puppet.movePin", json!({"layer": boxl.0, "pin": right, "position": [140, 40]})).unwrap();
|
||||
h.state_mut().ui.tool = Tool::Puppet;
|
||||
h.run_steps(3);
|
||||
let pins = |h: &mut Harness<'_, EffectcraftApp>| {
|
||||
let info = h.state_mut().session.execute("puppet.info", json!({"layer": boxl.0})).unwrap();
|
||||
info["meshes"].as_array().unwrap().iter().map(|m| m["pins"].as_array().unwrap().len()).sum::<usize>()
|
||||
};
|
||||
let rigged = |h: &Harness<'_, EffectcraftApp>, l: LayerId| {
|
||||
h.state().session.active_comp().unwrap().layer(l).unwrap().effects().is_some_and(|f| f.groups().next().is_some())
|
||||
};
|
||||
// Comp (380, 180) is layer (100, 40): past the Box's own edge, over the Plate, on the
|
||||
// stretched mesh.
|
||||
let at = screen(&h, [380.0, 180.0]);
|
||||
click(&mut h, at);
|
||||
assert_eq!(pins(&mut h), 3, "the pin went on the deformed mesh");
|
||||
assert_eq!(h.state().session.state.selected_layers, vec![boxl]);
|
||||
assert!(!rigged(&h, plate), "the Plate got no Puppet");
|
||||
// Far from the Box, on the Plate only: nothing.
|
||||
let at = screen(&h, [40.0, 40.0]);
|
||||
click(&mut h, at);
|
||||
assert_eq!(pins(&mut h), 3);
|
||||
assert_eq!(h.state().session.state.selected_layers, vec![boxl], "the tool stays on the Box");
|
||||
assert!(!rigged(&h, plate));
|
||||
}
|
||||
|
||||
/// Filled circles painted this frame (flattening nested shape lists).
|
||||
fn circles(h: &Harness<'_, EffectcraftApp>) -> Vec<egui::epaint::CircleShape> {
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<egui::epaint::CircleShape>) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,26 @@ fn timeline_shift_click_selects_range_and_ctrl_toggles() {
|
|||
click(&mut h, p, Modifiers::COMMAND);
|
||||
assert_eq!(h.state().session.state.selected_layers, vec![ids[0], ids[2]]);
|
||||
}
|
||||
/// #284: Shift-clicking layer bars in the time graph selects the range from the last layer
|
||||
/// clicked, as Shift-clicking their names does.
|
||||
#[test]
|
||||
fn timeline_shift_click_on_layer_bars_selects_the_range() {
|
||||
let mut h = harness();
|
||||
let ids: Vec<LayerId> = h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id).collect();
|
||||
let bar = |h: &Harness<'_, EffectcraftApp>, id: LayerId| rect(h, &format!("timeline.layer.{}.bar", id.0)).center();
|
||||
let p = bar(&h, ids[3]);
|
||||
click(&mut h, p, Modifiers::NONE);
|
||||
assert_eq!(h.state().session.state.selected_layers, vec![ids[3]]);
|
||||
let p = bar(&h, ids[1]);
|
||||
click(&mut h, p, Modifiers::SHIFT);
|
||||
assert_eq!(h.state().session.state.selected_layers, ids[1..]);
|
||||
// From a name to a bar too.
|
||||
let r = rect(&h, &format!("timeline.layer.{}.row", ids[0].0));
|
||||
click(&mut h, pos2(r.min.x + 210.0, r.center().y), Modifiers::NONE);
|
||||
let p = bar(&h, ids[2]);
|
||||
click(&mut h, p, Modifiers::SHIFT);
|
||||
assert_eq!(h.state().session.state.selected_layers, ids[..3]);
|
||||
}
|
||||
#[test]
|
||||
fn timeline_empty_outline_marquee_selects_layers() {
|
||||
let mut h = harness();
|
||||
|
|
|
|||
Loading…
Reference in New Issue