Program: drag a marquee or Shift-click to select several mask vertices, and move them together (#3209)

This commit is contained in:
Marco Arnulfo
2026-10-25 14:42:57 +01:00
committed by GitHub
parent d81c8975df
commit c41a8fa561
6 changed files with 306 additions and 11 deletions
+31 -1
View File
@@ -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| {
+36
View File
@@ -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);
}
+80 -8
View File
@@ -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()
+154
View File
@@ -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]
+2 -1
View File
@@ -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
View File
@@ -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