#853: Shift constrains pen mask segments to 45° steps
Problem: the pen mask tool ignored Shift, so straight horizontal,
vertical or diagonal mask edges could not be drawn precisely (Premiere
26.0 constrains mask lines with Shift). Separately, path_from_json
accepted the `{"p","in","out"}` vertex form through serde first, which
silently dropped the `in`/`out` tangents, so a pen mask's dragged-out
curves were committed as corners.
Fix: with Shift held, a new pen point snaps onto the nearest 45° ray
from the previous point (in screen space), the rubber-band preview
follows, and a Shift-drag constrains the Bézier handle it pulls out.
path_from_json now parses the vertex forms before falling back to the
serialized MaskPath.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2ff3f5e266
commit
1edcaa4e78
|
|
@ -91,9 +91,12 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
|
|||
/// A mask path from JSON: the serialized [`MaskPath`] object, an array of vertices
|
||||
/// (`{"p":[x,y],"in":[x,y]?,"out":[x,y]?}`), or an array of `[x, y]` / `{"x","y"}` points.
|
||||
pub fn path_from_json(v: &Value) -> Option<MaskPath> {
|
||||
if let Ok(p) = serde_json::from_value::<MaskPath>(v.clone()) {
|
||||
return Some(p);
|
||||
}
|
||||
// The vertex forms first: serde also accepts `{"p","in","out"}` vertices (the `masks.list` /
|
||||
// `masks.add` form) but silently drops the unknown `in` / `out` tangents.
|
||||
vertex_path(v).or_else(|| serde_json::from_value::<MaskPath>(v.clone()).ok())
|
||||
}
|
||||
|
||||
fn vertex_path(v: &Value) -> Option<MaskPath> {
|
||||
let arr = v.as_array().or_else(|| v.get("vertices").and_then(Value::as_array))?;
|
||||
let closed = v.get("closed").and_then(Value::as_bool).unwrap_or(true);
|
||||
let mut vertices = Vec::new();
|
||||
|
|
|
|||
|
|
@ -380,3 +380,22 @@ fn rounding_an_animated_mask_rounds_every_keyframe_and_only_the_chosen_corners()
|
|||
assert_eq!(v.len(), 6);
|
||||
assert!((v[0]["p"][0].as_f64().unwrap() - 450.0).abs() < 1e-6, "{}", v[0]);
|
||||
}
|
||||
|
||||
/// `path_to_json` → `path_from_json` keeps the Bézier tangents (they used to come back as zero, so
|
||||
/// a pen mask's dragged-out curves were committed as corners; #853).
|
||||
#[test]
|
||||
fn path_json_round_trip_keeps_tangents() {
|
||||
use filmcraft_geom::Vec2;
|
||||
use filmcraft_project::mask::{MaskPath, MaskVertex};
|
||||
let path = MaskPath {
|
||||
vertices: vec![
|
||||
MaskVertex { p: Vec2::new(10.0, 20.0), t_in: Vec2::new(-5.0, 0.0), t_out: Vec2::new(5.0, 0.0) },
|
||||
MaskVertex::corner(Vec2::new(80.0, 20.0)),
|
||||
MaskVertex { p: Vec2::new(40.0, 90.0), t_in: Vec2::new(3.0, -4.0), t_out: Vec2::new(-3.0, 4.0) },
|
||||
],
|
||||
closed: true,
|
||||
};
|
||||
assert_eq!(crate::masks::path_from_json(&crate::masks::path_to_json(&path)), Some(path.clone()));
|
||||
// the serialized project form still parses
|
||||
assert_eq!(crate::masks::path_from_json(&serde_json::to_value(&path).unwrap()), Some(path));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -464,6 +464,12 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32
|
|||
let painter = ui.painter().with_clip_rect(pic);
|
||||
let to_c = |p: Pos2| to_clip.apply(Vec2::new(p.x as f64, p.y as f64));
|
||||
let mut d = draft.clone();
|
||||
// Shift constrains the segment from the previous point (and a dragged-out handle) to 45° steps
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let snap = |from: Option<&[f64; 4]>, p: Pos2| match from {
|
||||
Some(q) if shift => constrain_45(sp(&to_screen, Vec2::new(q[0], q[1])), p),
|
||||
_ => p,
|
||||
};
|
||||
let mut commit = false;
|
||||
let pressed = resp.drag_started() || (resp.clicked() && !ui.data(|m| m.get_temp::<bool>(egui::Id::new("mask-pen-placed")).unwrap_or(false)));
|
||||
if pressed && let Some(p) = resp.interact_pointer_pos() {
|
||||
|
|
@ -471,7 +477,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32
|
|||
if d.points.len() >= 3 && near_first {
|
||||
commit = true;
|
||||
} else {
|
||||
let c = to_c(p);
|
||||
let c = to_c(snap(d.points.last(), p));
|
||||
d.points.push([c.x, c.y, 0.0, 0.0]);
|
||||
ui.data_mut(|m| m.insert_temp(egui::Id::new("mask-pen-placed"), resp.drag_started()));
|
||||
}
|
||||
|
|
@ -483,7 +489,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32
|
|||
&& !commit
|
||||
&& let (Some(p), Some(last)) = (resp.interact_pointer_pos(), d.points.last_mut())
|
||||
{
|
||||
let c = to_c(p);
|
||||
let c = to_c(snap(Some(last), p));
|
||||
let tan = Vec2::new(c.x - last[0], c.y - last[1]);
|
||||
if tan.length() > 2.0 {
|
||||
last[2] = tan.x;
|
||||
|
|
@ -504,7 +510,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32
|
|||
painter.add(egui::Shape::line(pts, Stroke::new(1.5, col)));
|
||||
}
|
||||
if let (Some(h), Some(l)) = (resp.hover_pos(), d.points.last()) {
|
||||
painter.line_segment([sp(&to_screen, Vec2::new(l[0], l[1])), h], Stroke::new(1.0, col.gamma_multiply(0.5)));
|
||||
painter.line_segment([sp(&to_screen, Vec2::new(l[0], l[1])), snap(Some(l), h)], Stroke::new(1.0, col.gamma_multiply(0.5)));
|
||||
}
|
||||
for (i, q) in d.points.iter().enumerate() {
|
||||
let p = sp(&to_screen, Vec2::new(q[0], q[1]));
|
||||
|
|
@ -549,6 +555,19 @@ fn open_polyline(path: &MaskPath, to_screen: &Affine) -> Vec<Pos2> {
|
|||
out
|
||||
}
|
||||
|
||||
/// `to` moved onto the nearest 45° ray from `from` (horizontal, vertical or diagonal on screen),
|
||||
/// keeping its distance along that ray; `to` itself when it coincides with `from` or is not finite.
|
||||
fn constrain_45(from: Pos2, to: Pos2) -> Pos2 {
|
||||
let v = to - from;
|
||||
if !(v.x.is_finite() && v.y.is_finite()) || v.length_sq() == 0.0 {
|
||||
return to;
|
||||
}
|
||||
let step = std::f32::consts::FRAC_PI_4;
|
||||
let a = (v.y.atan2(v.x) / step).round() * step;
|
||||
let dir = egui::Vec2::new(a.cos(), a.sin());
|
||||
from + dir * v.dot(dir)
|
||||
}
|
||||
|
||||
/// The Mask Path row's value area: mask tracking (backward continuously / one frame, forward one
|
||||
/// frame / continuously; clicking while a track runs stops it) and the tracking-method menu.
|
||||
pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effect: usize, mask: Option<usize>, actions: &mut Vec<(String, Value)>) {
|
||||
|
|
@ -607,3 +626,24 @@ pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effec
|
|||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn constrain_45_snaps_to_the_nearest_eighth_turn() {
|
||||
let o = pos2(10.0, 20.0);
|
||||
let near = |a: Pos2, b: Pos2| (a - b).length() < 1e-3;
|
||||
// nearly horizontal / vertical: the off-axis component goes
|
||||
assert!(near(constrain_45(o, pos2(110.0, 30.0)), pos2(110.0, 20.0)));
|
||||
assert!(near(constrain_45(o, pos2(5.0, -80.0)), pos2(10.0, -80.0)));
|
||||
// nearly diagonal: projected onto the 45° ray
|
||||
let d = constrain_45(o, pos2(60.0, 66.0));
|
||||
assert!(((d.x - o.x) - (d.y - o.y)).abs() < 1e-3, "{d:?}");
|
||||
// degenerate input comes back unchanged
|
||||
assert_eq!(constrain_45(o, o), o);
|
||||
let nan = pos2(f32::NAN, 1.0);
|
||||
assert!(constrain_45(o, nan).x.is_nan());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,6 +216,50 @@ fn create_and_edit_masks_on_the_monitor() {
|
|||
d.shot("mask-tracked");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn shift_constrains_pen_mask_segments_to_45_degrees() {
|
||||
let mut d = Driver::demo();
|
||||
d.ok("ui.set", json!({"workspace": "Effects"}));
|
||||
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]}));
|
||||
d.frames(3);
|
||||
d.ok("ui.click", json!({"id": "effectControls.opacity.mask.pen"}));
|
||||
d.frames(2);
|
||||
let pic = d.rect("program.picture");
|
||||
let at = |fx: f64, fy: f64| json!({"x": pic[0] + pic[2] * fx, "y": pic[1] + pic[3] * fy});
|
||||
// hold Shift (as the OS reports a held key) across the clicks
|
||||
d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT));
|
||||
d.frames(1);
|
||||
for (fx, fy) in [(0.3, 0.3), (0.6, 0.34), (0.63, 0.6)] {
|
||||
d.ok("ui.click", at(fx, fy));
|
||||
d.frames(2);
|
||||
}
|
||||
// a Shift-drag places the fourth point and pulls out its handle
|
||||
d.ok("ui.drag", json!({"from": at(0.32, 0.62), "to": at(0.42, 0.64), "steps": 6, "modifiers": {"shift": true}}));
|
||||
d.frames(2);
|
||||
d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
d.frames(1);
|
||||
d.ok("ui.click", at(0.3, 0.3));
|
||||
d.frames(3);
|
||||
let m = d.masks();
|
||||
let pen = m.iter().find(|x| x["effectId"] == "opacity").unwrap_or_else(|| panic!("opacity mask: {m:?}"));
|
||||
assert_eq!(pen["path"]["vertices"].as_array().unwrap().len(), 4, "{pen}");
|
||||
let (p0, p1, p2, p3) = (v0(pen, 0), v0(pen, 1), v0(pen, 2), v0(pen, 3));
|
||||
assert!((p1.1 - p0.1).abs() < 1e-6, "segment 0→1 horizontal: {p0:?} {p1:?}");
|
||||
assert!(p1.0 > p0.0 + 50.0, "{p0:?} {p1:?}");
|
||||
assert!((p2.0 - p1.0).abs() < 1e-6, "segment 1→2 vertical: {p1:?} {p2:?}");
|
||||
assert!(p2.1 > p1.1 + 50.0, "{p1:?} {p2:?}");
|
||||
assert!((p3.1 - p2.1).abs() < 1e-6, "segment 2→3 horizontal: {p2:?} {p3:?}");
|
||||
let out = &pen["path"]["vertices"][3]["out"];
|
||||
let (ox, oy) = (out[0].as_f64().unwrap(), out[1].as_f64().unwrap());
|
||||
assert!(ox > 10.0 && oy.abs() < 1e-6, "handle constrained horizontal: {pen}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_bin_drag_and_save_dialog() {
|
||||
let mut d = Driver::demo();
|
||||
|
|
|
|||
|
|
@ -235,13 +235,14 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
|
|||
|
||||
- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut has no face tracking
|
||||
(and occlusion edges are soft), Lumetri HDR-aware maths, multicam paging beyond 16 angles,
|
||||
redesigned 26.0 mask tools (constrained lines; an on-monitor corner-roundness handle).
|
||||
redesigned 26.0 mask tools (Shift-constrained vertex and whole-mask drags; an on-monitor
|
||||
corner-roundness handle).
|
||||
- **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense
|
||||
coarse-to-fine Lucas–Kanade, CPU; #780); Morph Cut morphs the subject along that optical flow
|
||||
instead of cross-dissolving (CPU; #805); Transform shutter angle motion blur (#415); Ultra Key
|
||||
Setting applied as a parameter preset (#458); rectangle and polygon masks round their corners
|
||||
with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe;
|
||||
#813).
|
||||
#813); Shift snaps pen mask segments and dragged-out Bézier handles to 45° steps (#853).
|
||||
- **Estimate:** 60–110 h.
|
||||
|
||||
## G19. Speech to text off in release builds
|
||||
|
|
|
|||
Loading…
Reference in New Issue