mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 18:09:21 +00:00
Program: drag a marquee or Shift-click to select several mask vertices, and move them together (#3209)
This commit is contained in:
@@ -51,7 +51,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
||||
spec(
|
||||
"masks.moveVertex",
|
||||
"Move Mask Vertex",
|
||||
r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n,"handle":"point"|"in"|"out"?,"to":[x,y]?|"delta":[dx,dy]?,"breakHandles":bool?,"merge":key?}"#,
|
||||
r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n|"vertices":[n],"handle":"point"|"in"|"out"?,"to":[x,y]?|"delta":[dx,dy]?,"breakHandles":bool?,"merge":key?}"#,
|
||||
move_vertex,
|
||||
),
|
||||
spec("masks.translate", "Move Mask", r#"{"clip":id?,"effect":index|id?,"mask":n?,"delta":[dx,dy],"merge":key?}"#, translate),
|
||||
@@ -324,6 +324,9 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn move_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("vertices").is_some_and(|v| !v.is_null()) {
|
||||
return move_vertices(s, p);
|
||||
}
|
||||
let vi = u64_p(p, "vertex").ok_or_else(|| bad("masks.moveVertex", "need `vertex`"))? as usize;
|
||||
let handle = str_p(p, "handle").unwrap_or("point").to_string();
|
||||
let to = p.get("to").and_then(pt);
|
||||
@@ -362,6 +365,33 @@ fn move_vertex(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// `masks.moveVertex` with `vertices`: the listed points move by `delta` together, in one edit
|
||||
/// (the multi-vertex drag on the Program monitor). Any index past the path moves nothing.
|
||||
fn move_vertices(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "masks.moveVertex";
|
||||
let list = p.get("vertices").and_then(Value::as_array).ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices"))?;
|
||||
let mut only = list
|
||||
.iter()
|
||||
.map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices")))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
only.sort_unstable();
|
||||
only.dedup();
|
||||
if str_p(p, "handle").is_some_and(|h| h != "point") || p.get("to").is_some() {
|
||||
return Err(bad(ID, "`vertices` moves points by `delta`"));
|
||||
}
|
||||
let d = p.get("delta").and_then(pt).ok_or_else(|| bad(ID, "need `delta`"))?;
|
||||
edit_mask(s, p, "Move Mask Vertex", move |m, t| {
|
||||
let mut path = m.path_at(t);
|
||||
for &vi in &only {
|
||||
let v = path.vertices.get_mut(vi).ok_or_else(|| bad(ID, format!("no vertex {vi}")))?;
|
||||
v.p = v.p + d;
|
||||
}
|
||||
set_path_at(m, t, path);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn translate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = p.get("delta").and_then(pt).ok_or_else(|| bad("masks.translate", "need `delta`"))?;
|
||||
edit_mask(s, p, "Move Mask", move |m, t| {
|
||||
|
||||
@@ -399,3 +399,39 @@ fn path_json_round_trip_keeps_tangents() {
|
||||
// the serialized project form still parses
|
||||
assert_eq!(crate::masks::path_from_json(&serde_json::to_value(&path).unwrap()), Some(path));
|
||||
}
|
||||
|
||||
/// `masks.moveVertex` with `vertices` moves several points by the same delta in one edit (the
|
||||
/// Program monitor's multi-vertex drag, #3150); a bad index leaves every vertex where it was.
|
||||
#[test]
|
||||
fn move_vertex_moves_a_list_of_vertices_together() {
|
||||
let (mut s, _) = demo();
|
||||
s.execute("masks.add", json!({"effect": "opacity", "shape": "polygon", "center": [400, 400], "size": [200, 200]})).unwrap();
|
||||
let pts = |s: &mut Session| -> Vec<(f64, f64)> {
|
||||
let l = s.execute("masks.list", json!({})).unwrap();
|
||||
l["masks"][0]["path"]["vertices"].as_array().unwrap().iter().map(|v| (v["p"][0].as_f64().unwrap(), v["p"][1].as_f64().unwrap())).collect()
|
||||
};
|
||||
let before = pts(&mut s);
|
||||
let n = s.history.undo.len();
|
||||
// two frames of one drag, a vertex listed twice moves once
|
||||
for _ in 0..2 {
|
||||
s.execute("masks.moveVertex", json!({"vertices": [0, 2, 2], "delta": [10, -5], "merge": "vertex-1"})).unwrap();
|
||||
}
|
||||
assert_eq!(s.history.undo.len(), n + 1, "one undo step for the drag");
|
||||
let after = pts(&mut s);
|
||||
for i in [0, 2] {
|
||||
assert_eq!(after[i], (before[i].0 + 20.0, before[i].1 - 10.0), "vertex {i}");
|
||||
}
|
||||
for i in [1, 3] {
|
||||
assert_eq!(after[i], before[i], "vertex {i} stays");
|
||||
}
|
||||
// an index past the last vertex moves nothing
|
||||
let e = s.execute("masks.moveVertex", json!({"vertices": [1, 9], "delta": [10, 0]})).unwrap_err().to_string();
|
||||
assert!(e.contains("no vertex 9"), "{e}");
|
||||
assert_eq!(pts(&mut s), after);
|
||||
// a list moves points, by a delta
|
||||
assert!(s.execute("masks.moveVertex", json!({"vertices": [1], "to": [0, 0]})).is_err());
|
||||
assert!(s.execute("masks.moveVertex", json!({"vertices": [1], "handle": "in", "delta": [1, 0]})).is_err());
|
||||
assert!(s.execute("masks.moveVertex", json!({"vertices": "1", "delta": [1, 0]})).is_err());
|
||||
s.undo();
|
||||
assert_eq!(pts(&mut s), before);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
//! Effect masks in the UI: the mask rows of Effect Controls (create ellipse / 4-point polygon /
|
||||
//! pen masks, per-mask Path / Feather / Opacity / Expansion / Inverted / mode) and on-monitor
|
||||
//! editing on the Program monitor (drag vertices, Bézier handles, the whole mask, feather,
|
||||
//! expansion and corner-roundness handles; the pen places a free-draw Bézier mask).
|
||||
//! expansion and corner-roundness handles; a marquee or Shift-click selects several vertices,
|
||||
//! which move together; the pen places a free-draw Bézier mask).
|
||||
//!
|
||||
//! Automation ids: `effectControls.<effect>.mask.{ellipse,polygon,pen}`,
|
||||
//! `effectControls.<effect>.mask<k>` (+ `.mode`, `.inverted`, `.delete`, and the parameter rows
|
||||
//! `effectControls.<effect>.mask<k>.<param>.*`), `program.mask.vertex.<i>`, `program.mask.in.<i>`,
|
||||
//! `program.mask.out.<i>`, `program.mask.body`, `program.mask.feather`, `program.mask.expansion`,
|
||||
//! `program.mask.roundness`, `program.maskPen`.
|
||||
//! `program.mask.roundness`, `program.mask.marquee`, `program.maskPen`.
|
||||
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2};
|
||||
use filmcraft_geom::{Affine, Vec2};
|
||||
@@ -297,6 +298,27 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
let v = lin_inv.apply(Vec2::new(d.x as f64, d.y as f64));
|
||||
[v.x, v.y]
|
||||
};
|
||||
// ---- vertex marquee: a drag that starts on the picture outside the shape (under every
|
||||
// handle, so they come first; Selection tool only, the Hand pans). It sits over the graphics
|
||||
// overlay, so it leaves graphic layers to it and takes over its click on empty space. Where the
|
||||
// marquee started (stored for the gesture, until the button is up) keeps it registered over a
|
||||
// layer through the release.
|
||||
let marquee_id = egui::Id::new("mask-marquee");
|
||||
let marquee_from = egui::Id::new("mask-marquee-from");
|
||||
let selection_tool = app.ui.tool == crate::state::Tool::Selection;
|
||||
if !ui.input(|i| i.pointer.primary_down() || i.pointer.primary_released()) {
|
||||
ui.data_mut(|d| d.remove::<Pos2>(marquee_from));
|
||||
}
|
||||
let marquee_on = ui.data(|d| d.get_temp::<Pos2>(marquee_from)).is_some();
|
||||
let on_layer = |p: Pos2| pic.contains(p) && crate::panels::graphics::visible_layers(app, pic, frame).iter().any(|v| v.hit(p));
|
||||
let marquee = (selection_tool && (marquee_on || !ui.input(|i| i.pointer.hover_pos()).is_some_and(on_layer)))
|
||||
.then(|| ui.interact(pic, marquee_id, Sense::click_and_drag()));
|
||||
if let Some(r) = &marquee {
|
||||
app.auto.add("program.mask.marquee", pic, "Mask vertex marquee");
|
||||
if r.clicked() {
|
||||
crate::panels::graphics::deselect_on_monitor(app, ui);
|
||||
}
|
||||
}
|
||||
// ---- whole mask (drag inside)
|
||||
let bb = Rect::from_points(&screen).expand(2.0).intersect(pic);
|
||||
let body = ui.interact(bb, egui::Id::new("mask-body"), Sense::click_and_drag());
|
||||
@@ -306,6 +328,8 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
let ok = ptr_in(body.interact_pointer_pos());
|
||||
ui.data_mut(|d| d.insert_temp(gesture_id.with("body"), ok));
|
||||
}
|
||||
// (inside the outline's box but outside the shape, the drag is a marquee too)
|
||||
let body_marquee = selection_tool && !ui.data(|d| d.get_temp::<bool>(gesture_id.with("body"))).unwrap_or(false);
|
||||
if body.dragged_by(LEFT) && ui.data(|d| d.get_temp::<bool>(gesture_id.with("body"))).unwrap_or(false) && body.drag_delta() != egui::Vec2::ZERO {
|
||||
let d = drag_step(ui, egui::Id::new(("mask-body-drag", gesture)), body.drag_delta());
|
||||
if d != egui::Vec2::ZERO {
|
||||
@@ -360,9 +384,13 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
actions.extend(roundness_handle(app, ui, &painter, &path, &to_screen, &target));
|
||||
// ---- tangent handles and vertices
|
||||
let sel_v_id = egui::Id::new(("mask-sel-vertex", sel.clip.0, sel.effect, sel.mask));
|
||||
let sel_v: Option<usize> = ui.data(|d| d.get_temp(sel_v_id));
|
||||
let n_v = path.vertices.len();
|
||||
// (stored with the vertex count: once a vertex is added or deleted, by any edit or by undo, the
|
||||
// indices name other vertices and the selection goes)
|
||||
let mut sel_vs: Vec<usize> = ui.data(|d| d.get_temp::<(usize, Vec<usize>)>(sel_v_id)).filter(|(n, _)| *n == n_v).map(|(_, v)| v).unwrap_or_default();
|
||||
let alt = ui.input(|i| i.modifiers.alt);
|
||||
let cmd = ui.input(|i| i.modifiers.command);
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
for (i, v) in path.vertices.iter().enumerate() {
|
||||
if v.is_corner() {
|
||||
continue;
|
||||
@@ -387,7 +415,7 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
for (i, v) in path.vertices.iter().enumerate() {
|
||||
let p = sp(&to_screen, v.p);
|
||||
let vr = Rect::from_center_size(p, vec2(7.0, 7.0));
|
||||
let selected = sel_v == Some(i);
|
||||
let selected = sel_vs.contains(&i);
|
||||
painter.rect_filled(vr, 0.0, if selected { col } else { Color32::WHITE });
|
||||
painter.rect_stroke(vr, 0.0, Stroke::new(1.0, col), egui::StrokeKind::Middle);
|
||||
let r = ui.interact(vr.expand(3.0), egui::Id::new(("mask-vertex", i)), Sense::click_and_drag()).on_hover_text(if cfg!(target_os = "macos") {
|
||||
@@ -396,13 +424,23 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
tl!("Drag: move · Alt-click: smooth/corner · Ctrl-click: delete")
|
||||
});
|
||||
app.auto.add(&format!("program.mask.vertex.{i}"), vr, "Mask vertex");
|
||||
if r.drag_started_by(LEFT) {
|
||||
ui.data_mut(|d| d.insert_temp(sel_v_id, i));
|
||||
// dragging a selected vertex moves the whole selection; another one is selected first
|
||||
// (added to it with Shift)
|
||||
if r.drag_started_by(LEFT) && !selected {
|
||||
if !shift {
|
||||
sel_vs.clear();
|
||||
}
|
||||
sel_vs.push(i);
|
||||
ui.data_mut(|d| d.insert_temp(sel_v_id, (n_v, sel_vs.clone())));
|
||||
}
|
||||
if r.dragged_by(LEFT) && r.drag_delta() != egui::Vec2::ZERO {
|
||||
let d = drag_step(ui, egui::Id::new(("mask-vertex-drag", gesture, i)), r.drag_delta());
|
||||
if d != egui::Vec2::ZERO {
|
||||
actions.push(("masks.moveVertex".into(), with(json!({"vertex": i, "delta": clip_delta(d), "merge": format!("vertex-{gesture}")}))));
|
||||
let which = if sel_vs.len() > 1 && sel_vs.contains(&i) { json!({"vertices": sel_vs}) } else { json!({"vertex": i}) };
|
||||
let mut p = with(which);
|
||||
p["delta"] = json!(clip_delta(d));
|
||||
p["merge"] = json!(format!("vertex-{gesture}"));
|
||||
actions.push(("masks.moveVertex".into(), p));
|
||||
}
|
||||
}
|
||||
if r.clicked() {
|
||||
@@ -411,10 +449,44 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
|
||||
} else if cmd {
|
||||
actions.push(("masks.removeVertex".into(), with(json!({"vertex": i}))));
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(sel_v_id, i));
|
||||
if !shift {
|
||||
sel_vs.clear();
|
||||
}
|
||||
if let Some(k) = sel_vs.iter().position(|v| *v == i) {
|
||||
sel_vs.remove(k);
|
||||
} else {
|
||||
sel_vs.push(i);
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(sel_v_id, (n_v, sel_vs.clone())));
|
||||
}
|
||||
}
|
||||
}
|
||||
// the marquee selects the vertices inside it on release (Shift adds them to the selection)
|
||||
let mq = marquee.iter().chain(body_marquee.then_some(&body));
|
||||
let (started, dragging, stopped) =
|
||||
mq.fold((false, false, false), |(a, b, c), r| (a || r.drag_started_by(LEFT), b || r.dragged_by(LEFT), c || r.drag_stopped_by(LEFT)));
|
||||
if started && let Some(o) = ui.input(|i| i.pointer.press_origin()) {
|
||||
ui.data_mut(|d| d.insert_temp(marquee_from, o));
|
||||
}
|
||||
let from: Option<Pos2> = ui.data(|d| d.get_temp(marquee_from));
|
||||
if let (Some(o), Some(p)) = (from.filter(|_| dragging || stopped), ui.input(|i| i.pointer.latest_pos())) {
|
||||
let r = Rect::from_two_pos(o, p);
|
||||
if stopped {
|
||||
ui.data_mut(|d| d.remove::<Pos2>(marquee_from));
|
||||
if !shift {
|
||||
sel_vs.clear();
|
||||
}
|
||||
for (i, v) in path.vertices.iter().enumerate() {
|
||||
if r.contains(sp(&to_screen, v.p)) && !sel_vs.contains(&i) {
|
||||
sel_vs.push(i);
|
||||
}
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(sel_v_id, (n_v, sel_vs.clone())));
|
||||
} else {
|
||||
painter.rect_filled(r, 0.0, col.gamma_multiply(0.15));
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), egui::StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
// Cmd-click on an edge adds a vertex there
|
||||
if cmd
|
||||
&& body.clicked()
|
||||
|
||||
@@ -248,6 +248,160 @@ fn mask_handles_follow_the_clip_selection() {
|
||||
assert!(d.has("program.mask.vertex.0"), "the handles come back with the clip");
|
||||
}
|
||||
|
||||
/// A marquee dragged from outside the mask selects the vertices it encloses, Shift-click adds one
|
||||
/// more, and dragging any selected vertex moves them all in one undo step; a plain click goes back
|
||||
/// to one vertex (#3150).
|
||||
#[test]
|
||||
fn marquee_and_shift_click_select_vertices_that_move_together() {
|
||||
let mut d = Driver::demo();
|
||||
let seq = d.exec("sequence.inspect", json!({}));
|
||||
let clip = &seq["video"][0]["items"][0];
|
||||
let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap());
|
||||
d.exec("playhead.set", json!({"time": start + dur / 2}));
|
||||
d.exec("timeline.select", json!({"clips": [id]}));
|
||||
// corners: 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left
|
||||
d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "polygon", "center": [800, 540], "size": [600, 400]}));
|
||||
d.frames(3);
|
||||
let pts = |d: &mut Driver| -> Vec<(f64, f64)> {
|
||||
let m = d.masks();
|
||||
(0..4).map(|i| v0(&m[0], i)).collect()
|
||||
};
|
||||
let undo_len = |d: &mut Driver| d.exec("history.list", json!({}))["undo"].as_array().unwrap().len();
|
||||
// the marquee starts above and left of the shape and encloses the two top corners
|
||||
let (a, b) = (d.center("program.mask.vertex.0"), d.center("program.mask.vertex.1"));
|
||||
d.ok("ui.drag", json!({"from": {"x": a.0 - 25.0, "y": a.1 - 25.0}, "to": {"x": b.0 + 25.0, "y": b.1 + 25.0}, "steps": 6}));
|
||||
d.frames(2);
|
||||
let p0 = pts(&mut d);
|
||||
let undo0 = undo_len(&mut d);
|
||||
d.drag("program.mask.vertex.0", 0.0, -30.0);
|
||||
let p1 = pts(&mut d);
|
||||
for i in [0, 1] {
|
||||
assert!(p1[i].1 < p0[i].1 - 20.0 && (p1[i].0 - p0[i].0).abs() < 1e-6, "vertex {i} moved up with the marquee selection: {p0:?} → {p1:?}");
|
||||
}
|
||||
for i in [2, 3] {
|
||||
assert_eq!(p1[i], p0[i], "vertex {i} outside the marquee stays");
|
||||
}
|
||||
assert_eq!(undo_len(&mut d), undo0 + 1, "one undo step for the drag");
|
||||
d.shot("mask-vertices-marquee");
|
||||
|
||||
// Shift-click adds the bottom-left corner; dragging a selected one moves all three
|
||||
let (x, y) = d.center("program.mask.vertex.3");
|
||||
d.ok("ui.click", json!({"x": x, "y": y, "modifiers": {"shift": true}}));
|
||||
d.frames(2);
|
||||
d.drag("program.mask.vertex.1", 20.0, 0.0);
|
||||
let p2 = pts(&mut d);
|
||||
for i in [0, 1, 3] {
|
||||
assert!(p2[i].0 > p1[i].0 + 10.0, "vertex {i} moved right: {p1:?} → {p2:?}");
|
||||
}
|
||||
assert_eq!(p2[2], p1[2], "the unselected vertex stays");
|
||||
|
||||
// a plain click selects only that vertex again
|
||||
d.ok("ui.click", json!({"id": "program.mask.vertex.0"}));
|
||||
d.frames(2);
|
||||
d.drag("program.mask.vertex.0", 0.0, 20.0);
|
||||
let p3 = pts(&mut d);
|
||||
assert!(p3[0].1 > p2[0].1 + 10.0, "{p2:?} → {p3:?}");
|
||||
for i in [1, 2, 3] {
|
||||
assert_eq!(p3[i], p2[i], "vertex {i} left the selection");
|
||||
}
|
||||
|
||||
// a graphic layer on the picture still takes its own drag: the marquee is only for empty space
|
||||
let at = d.exec("sequence.inspect", json!({}))["playhead"].as_i64().unwrap();
|
||||
let title = d.exec("graphics.newText", json!({"text": "Title", "time": at}))["clip"].as_u64().unwrap();
|
||||
d.exec("masks.translate", json!({"clip": id, "effect": "opacity", "mask": 0, "delta": [700, 0]}));
|
||||
d.exec("timeline.select", json!({"clips": [id]}));
|
||||
d.frames(3);
|
||||
assert!(d.has("program.mask.marquee"), "the mask overlay is on the monitor");
|
||||
let q0 = d.exec("graphics.list", json!({"clip": title}))["layers"][0]["quad"][0][0].as_f64().unwrap();
|
||||
let lr = d.rect(&format!("program.layer.{title}.0"));
|
||||
let (cx, cy) = (lr[0] + lr[2] / 2.0, lr[1] + lr[3] / 2.0);
|
||||
d.ok("ui.drag", json!({"from": {"x": cx, "y": cy}, "to": {"x": cx + 60.0, "y": cy}, "steps": 6}));
|
||||
d.frames(2);
|
||||
let q1 = d.exec("graphics.list", json!({"clip": title}))["layers"][0]["quad"][0][0].as_f64().unwrap();
|
||||
assert!(q1 > q0 + 20.0, "the title moved: {q0} → {q1}");
|
||||
}
|
||||
|
||||
/// A marquee released over a graphic layer still selects: from outside the outline's box and from
|
||||
/// inside it but outside the shape (#3150).
|
||||
#[test]
|
||||
fn a_marquee_released_over_a_graphic_layer_selects_its_vertices() {
|
||||
let mut d = Driver::demo();
|
||||
let seq = d.exec("sequence.inspect", json!({}));
|
||||
let clip = &seq["video"][0]["items"][0];
|
||||
let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap());
|
||||
d.exec("playhead.set", json!({"time": start + dur / 2}));
|
||||
let at = d.exec("sequence.inspect", json!({}))["playhead"].as_i64().unwrap();
|
||||
let title = d.exec("graphics.newText", json!({"text": "Title", "time": at, "position": [1000, 600]}))["clip"].as_u64().unwrap();
|
||||
d.exec("timeline.select", json!({"clips": [id]}));
|
||||
// vertices: 0 top, 1 right, 2 bottom, 3 left; the title sits inside the ellipse
|
||||
d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "ellipse", "center": [960, 540], "size": [800, 500]}));
|
||||
d.frames(3);
|
||||
let pts = |d: &mut Driver| -> Vec<(f64, f64)> {
|
||||
let m = d.masks();
|
||||
(0..4).map(|i| v0(&m[0], i)).collect()
|
||||
};
|
||||
let lr = d.rect(&format!("program.layer.{title}.0"));
|
||||
let to = (lr[0] + lr[2] / 2.0, lr[1] + lr[3] / 2.0);
|
||||
for inset in [-20.0, 1.0] {
|
||||
let bb = d.rect("program.mask.body");
|
||||
let from = (bb[0] + inset, bb[1] + inset);
|
||||
let enclosed: Vec<usize> = (0..4)
|
||||
.filter(|i| {
|
||||
let (x, y) = d.center(&format!("program.mask.vertex.{i}"));
|
||||
x >= from.0 && x <= to.0 && y >= from.1 && y <= to.1
|
||||
})
|
||||
.collect();
|
||||
assert!(!enclosed.is_empty() && enclosed.len() < 4, "the marquee from {from:?} to {to:?} takes some vertices: {enclosed:?}");
|
||||
d.ok("ui.drag", json!({"from": {"x": from.0, "y": from.1}, "to": {"x": to.0, "y": to.1}, "steps": 6}));
|
||||
d.frames(2);
|
||||
let p0 = pts(&mut d);
|
||||
d.drag(&format!("program.mask.vertex.{}", enclosed[0]), 0.0, -20.0);
|
||||
let p1 = pts(&mut d);
|
||||
let moved: Vec<usize> = (0..4).filter(|i| p1[*i] != p0[*i]).collect();
|
||||
assert_eq!(moved, enclosed, "marquee from {from:?}: {p0:?} → {p1:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The vertex selection does not outlive a change in the number of vertices: after a Cmd-click
|
||||
/// deletes one (or an edit elsewhere adds or deletes one), the old indices would name other
|
||||
/// vertices (#3150).
|
||||
#[test]
|
||||
fn deleting_a_vertex_drops_the_vertex_selection() {
|
||||
let mut d = Driver::demo();
|
||||
let seq = d.exec("sequence.inspect", json!({}));
|
||||
let clip = &seq["video"][0]["items"][0];
|
||||
let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap());
|
||||
d.exec("playhead.set", json!({"time": start + dur / 2}));
|
||||
d.exec("timeline.select", json!({"clips": [id]}));
|
||||
for by_command in [false, true] {
|
||||
d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "polygon", "center": [800, 540], "size": [600, 400]}));
|
||||
d.frames(3);
|
||||
let mask = d.masks().len() - 1;
|
||||
// select corners 1 and 2, then delete corner 0: they become indices 0 and 1
|
||||
d.ok("ui.click", json!({"id": "program.mask.vertex.1"}));
|
||||
d.frames(2);
|
||||
let (x, y) = d.center("program.mask.vertex.2");
|
||||
d.ok("ui.click", json!({"x": x, "y": y, "modifiers": {"shift": true}}));
|
||||
d.frames(2);
|
||||
if by_command {
|
||||
d.exec("masks.removeVertex", json!({"clip": id, "effect": "opacity", "mask": mask, "vertex": 0}));
|
||||
} else {
|
||||
let (x, y) = d.center("program.mask.vertex.0");
|
||||
d.ok("ui.click", json!({"x": x, "y": y, "modifiers": {"command": true}}));
|
||||
}
|
||||
d.frames(2);
|
||||
let pts = |d: &mut Driver| -> Vec<(f64, f64)> {
|
||||
let m = d.masks();
|
||||
(0..3).map(|i| v0(&m[mask], i)).collect()
|
||||
};
|
||||
let p0 = pts(&mut d);
|
||||
d.drag("program.mask.vertex.1", 0.0, -20.0);
|
||||
let p1 = pts(&mut d);
|
||||
let moved: Vec<usize> = (0..3).filter(|i| p1[*i] != p0[*i]).collect();
|
||||
assert_eq!(moved, vec![1], "only the dragged vertex moves (by command: {by_command}): {p0:?} → {p1:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Shift held while placing pen points snaps each new segment to 45° steps from the previous
|
||||
/// point; a Shift-drag also constrains the Bézier handle it pulls out (#853).
|
||||
#[test]
|
||||
|
||||
@@ -534,7 +534,8 @@ with different vertex counts hold). Feather, Opacity and Expansion are ordinary
|
||||
removeVertex / toggleVertexSmooth / roundCorners / select / list; `masks.add` takes a
|
||||
`cornerRadius` for a rounded rectangle); keyframe commands take `"mask": n`. The
|
||||
Program monitor overlay drags vertices, Bézier handles, the whole mask and the feather /
|
||||
expansion handles; drags merge into one undo step.
|
||||
expansion handles; a marquee from outside the shape or Shift-click selects several vertices,
|
||||
which move together (`masks.moveVertex` `vertices`); drags merge into one undo step.
|
||||
- **Tracking** (`render::track`, `masks.track`): Shi–Tomasi features inside the mask, pyramidal
|
||||
Lucas–Kanade (4 levels, 15×15 window) with a forward–backward check, then RANSAC + least squares
|
||||
for Position / Position & Rotation / Position, Scale & Rotation (2D Procrustes). Each frame's
|
||||
|
||||
+3
-1
@@ -298,7 +298,9 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
|
||||
with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe;
|
||||
#813), from an on-monitor corner-roundness handle with a live preview (one undo step per drag;
|
||||
#844); Shift snaps pen mask segments and dragged-out Bézier handles to 45° steps (#853), and
|
||||
Shift constrains vertex and whole-mask drags to 45° steps from the press point (#1408).
|
||||
Shift constrains vertex and whole-mask drags to 45° steps from the press point (#1408); a
|
||||
marquee or Shift-click selects several vertices on the Program monitor, and dragging any of
|
||||
them moves them all in one undo step (#3150).
|
||||
- **Estimate:** 60–110 h.
|
||||
|
||||
## G19. Speech to text off in release builds
|
||||
|
||||
Reference in New Issue
Block a user