Merge pull request #851 from vangel76/gap-mask-roundness-handle

#844: on-monitor corner-roundness handle for masks
This commit is contained in:
Brandon Thomas 2026-10-11 19:15:53 -04:00
commit cc8eda65f0
7 changed files with 286 additions and 6 deletions

View File

@ -3489,6 +3489,7 @@
Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Haz clic para seleccionar, o haz clic en un espacio vacío y arrastra para seleccionar con un marco. Usa Mayús, Alt y Ctrl para otras opciones.
Click to zoom in; Alt-click to zoom out. Haz clic para acercar; Alt+clic para alejar.
Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Arrastrar: mover · Alt+clic: suave/esquina · Ctrl+clic: eliminar
Drag inward to round the corners Arrastrar hacia dentro para redondear las esquinas
Include Alpha Channel Incluir canal alfa
Sequence Settings Ajustes de secuencia
Color Management Gestión del color

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -3478,6 +3478,7 @@
Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Cliquez pour sélectionner, ou cliquez dans un espace vide et faites glisser pour sélectionner par rectangle. Utilisez Maj, Alt et Ctrl pour d'autres options.
Click to zoom in; Alt-click to zoom out. Cliquez pour un zoom avant ; Alt+clic pour un zoom arrière.
Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Glisser : déplacer · Alt+clic : lisse/angle · Ctrl+clic : supprimer
Drag inward to round the corners Glisser vers l'intérieur pour arrondir les angles
Include Alpha Channel Inclure la couche alpha
Sequence Settings Paramètres de séquence
Color Management Gestion des couleurs

Can't render this file because it contains an unexpected character in line 5 and column 83.

View File

@ -3487,6 +3487,7 @@
Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. クリックで選択します。何もない場所からドラッグすると範囲選択になります。Shift、Alt、Ctrl キーを併用すると、ほかのオプションも使えます。
Click to zoom in; Alt-click to zoom out. クリックでズームイン、Alt+クリックでズームアウトします。
Drag: move · Alt-click: smooth/corner · Ctrl-click: delete ドラッグ: 移動 · Alt+クリック: スムーズ/コーナー · Ctrl+クリック: 削除
Drag inward to round the corners 内側にドラッグして角を丸める
Include Alpha Channel アルファチャンネルを含める
Sequence Settings シーケンス設定
Color Management カラーマネジメント

Can't render this file because it contains an unexpected character in line 11 and column 59.

View File

@ -3471,6 +3471,7 @@
Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. 单击以选择,或在空白处单击并拖动以框选。使用 Shift、Alt 和 Ctrl 进行其他操作。
Click to zoom in; Alt-click to zoom out. 单击以放大;Alt-单击以缩小。
Drag: move · Alt-click: smooth/corner · Ctrl-click: delete 拖动:移动 · Alt-单击:平滑/拐角 · Ctrl-单击:删除
Drag inward to round the corners 向内拖动以使拐角变圆
Include Alpha Channel 包含 Alpha 通道
Sequence Settings 序列设置
Color Management 色彩管理

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -1,13 +1,13 @@
//! 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 and
//! expansion handles; the pen places a free-draw Bézier mask).
//! 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).
//!
//! 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.maskPen`.
//! `program.mask.roundness`, `program.maskPen`.
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2};
use filmcraft_geom::{Affine, Vec2};
@ -344,6 +344,8 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra
}
}
}
// ---- corner roundness (masks with sharp corners: rectangles, polygons)
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));
@ -436,6 +438,121 @@ fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 {
(p - (a + ab * t)).length()
}
/// A sharp corner `masks.roundCorners` can fillet: its point, the unit bisector pointing to the
/// fillet's centre, `sin(θ/2)` of its opening angle θ, and the largest radius it takes (the fillet
/// never uses more than half of either edge).
struct RoundableCorner {
p: Vec2,
bisector: Vec2,
sin_half: f64,
max_radius: f64,
}
/// The corners of `path` that rounding changes (degenerate ones — straight, reversing, zero-length
/// edges — are left out).
fn roundable_corners(path: &MaskPath) -> Vec<RoundableCorner> {
let n = path.vertices.len();
path.roundable_corners()
.into_iter()
.filter_map(|i| {
let v = path.vertices.get(i)?.p;
let prev = path.vertices.get((i + n - 1) % n)?.p;
let next = path.vertices.get((i + 1) % n)?.p;
let (a, b) = (prev - v, next - v);
let (la, lb) = (a.length(), b.length());
if !(la > 1e-6 && lb > 1e-6) {
return None;
}
let (u, w) = (a * (1.0 / la), b * (1.0 / lb));
let theta = (u.x * w.x + u.y * w.y).clamp(-1.0, 1.0).acos();
if !(1e-6..std::f64::consts::PI - 1e-6).contains(&theta) {
return None;
}
let s = u + w;
let sl = s.length();
if !sl.is_finite() || sl <= 1e-9 {
return None;
}
let half = theta / 2.0;
let max_radius = la.min(lb) / 2.0 * half.tan();
max_radius.is_finite().then_some(RoundableCorner { p: v, bisector: s * (1.0 / sl), sin_half: half.sin(), max_radius })
})
.collect()
}
/// Radius of a roundness drag that has moved `dist` clip pixels along the corner's bisector: the
/// handle follows the fillet's centre, so it sits `r / sin(θ/2)` from the corner.
fn drag_radius(dist: f64, c: &RoundableCorner, max_radius: f64) -> f64 {
let r = dist * c.sin_half;
if r.is_finite() { r.clamp(0.0, max_radius.max(0.0)) } else { 0.0 }
}
/// The corner-roundness handle of the selected mask, just inside its top-left sharp corner.
/// Dragging it into the shape previews the rounded corners live; releasing rounds every sharp
/// corner with one `masks.roundCorners` (one undo step per drag). Masks without a sharp corner
/// between straight edges (ellipses, pen masks, corners already rounded) show no handle.
fn roundness_handle(
app: &mut FilmcraftApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
path: &MaskPath,
to_screen: &Affine,
target: &Value,
) -> Option<(String, Value)> {
let corners = roundable_corners(path);
let to_clip = to_screen.inverse()?;
let lin_inv = Affine { e: 0.0, f: 0.0, ..to_clip };
let c = corners.iter().min_by(|a, b| {
let (pa, pb) = (sp(to_screen, a.p), sp(to_screen, b.p));
(pa.x + pa.y).total_cmp(&(pb.x + pb.y))
})?;
let max_radius = corners.iter().map(|c| c.max_radius).fold(0.0, f64::max);
let id = egui::Id::new("mask-roundness");
let dist_id = id.with("dist");
let dragging = ui.ctx().is_being_dragged(id);
let dist: f64 = if dragging { ui.data(|d| d.get_temp(dist_id)).unwrap_or(0.0) } else { 0.0 };
let radius = drag_radius(dist, c, max_radius);
let anchor = sp(to_screen, c.p);
let dir = sp(to_screen, c.p + c.bisector) - anchor;
let dir = if dir.length() > 1e-6 { dir.normalized() } else { egui::Vec2::new(1.0, 1.0).normalized() };
let hp = sp(to_screen, c.p + c.bisector * (radius / c.sin_half.max(1e-9))) + dir * 18.0;
let hr = Rect::from_center_size(hp, vec2(9.0, 9.0));
let r = ui.interact(hr.expand(3.0), id, Sense::drag()).on_hover_text(tl!("Drag inward to round the corners"));
app.auto.add("program.mask.roundness", hr, "Mask corner roundness");
let col = Color32::from_rgb(0x5a, 0xb4, 0xff);
if dragging && radius > 0.0 {
let preview = path.with_rounded_corners(radius, &path.roundable_corners()).flatten(0.5);
painter.add(egui::Shape::closed_line(preview.iter().map(|v| sp(to_screen, *v)).collect(), Stroke::new(1.5, Color32::WHITE)));
painter.text(hp + vec2(10.0, -10.0), Align2::LEFT_BOTTOM, format!("{radius:.1}"), Tokens::ui(10.5), app.tokens.hot_text);
}
painter.line_segment([anchor, hp], Stroke::new(1.0, col.gamma_multiply(0.6)));
painter.circle_filled(hp, 4.0, Color32::WHITE);
painter.circle_stroke(hp, 4.0, Stroke::new(1.5, col));
let primary = egui::PointerButton::Primary;
if r.drag_started_by(primary) {
ui.data_mut(|d| d.insert_temp(dist_id, 0.0_f64));
}
if r.dragged_by(primary) && r.drag_delta() != egui::Vec2::ZERO {
let d = lin_inv.apply(Vec2::new(r.drag_delta().x as f64, r.drag_delta().y as f64));
let along = d.x * c.bisector.x + d.y * c.bisector.y;
// accumulate the raw distance: past the limit the handle waits for the pointer to come back
let total = ui.data(|m| m.get_temp::<f64>(dist_id)).unwrap_or(0.0) + along;
ui.data_mut(|m| m.insert_temp(dist_id, if total.is_finite() { total } else { 0.0 }));
}
if r.drag_stopped_by(primary) {
let dist: f64 = ui.data_mut(|d| d.remove_temp(dist_id)).unwrap_or(0.0);
let radius = (drag_radius(dist, c, max_radius) * 10.0).round() / 10.0;
if radius > 0.0 {
let mut p = target.clone();
if let Some(o) = p.as_object_mut() {
o.insert("radius".into(), json!(radius));
}
return Some(("masks.roundCorners".into(), p));
}
}
None
}
/// Draft vertices → a mask path.
pub fn draft_path(points: &[[f64; 4]]) -> MaskPath {
MaskPath {

View File

@ -0,0 +1,158 @@
//! The corner-roundness handle of rectangle / polygon masks on the Program monitor, driven
//! headless through the control channel: dragging it rounds the corners in one undo step; masks
//! without sharp corners show no handle.
//!
//! With `FILMCRAFT_UI_SHOTS=<dir>` the test also renders the UI with wgpu and writes PNGs there.
use std::sync::mpsc::{Sender, channel};
use egui_kittest::Harness;
use filmcraft_engine::Session;
use filmcraft_ui_egui::FilmcraftApp;
use filmcraft_ui_egui::control::ControlRequest;
use serde_json::{Value, json};
struct Driver {
harness: Harness<'static, FilmcraftApp>,
tx: Sender<ControlRequest>,
shots: Option<std::path::PathBuf>,
}
impl Driver {
fn demo() -> Self {
let mut session = Session::default();
session.execute("file.openDemoProject", json!({})).expect("demo project");
let (tx, rx) = channel();
let app = FilmcraftApp::new(session).with_control(rx);
let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from);
let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000);
if shots.is_some() {
b = b.wgpu().with_pixels_per_point(1.0);
}
let harness = b.build_eframe(move |_cc| app);
let mut d = Driver { harness, tx, shots };
d.frames(4);
d
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let ctx = self.harness.ctx.clone();
let mut raw = std::mem::take(self.harness.input_mut());
eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw);
*self.harness.input_mut() = raw;
self.harness.step();
}
}
fn call(&mut self, method: &str, params: Value) -> Value {
let (req, reply) = ControlRequest::new(method, params.clone());
self.tx.send(req).unwrap();
for _ in 0..600 {
self.frames(1);
if let Ok(v) = reply.try_recv() {
return v;
}
}
panic!("no reply to {method} {params}");
}
fn ok(&mut self, method: &str, params: Value) -> Value {
let r = self.call(method, params.clone());
assert_eq!(r["ok"], true, "{method} {params}: {r}");
r["result"].clone()
}
fn exec(&mut self, command: &str, params: Value) -> Value {
self.ok("engine.execute", json!({"command": command, "params": params}))
}
fn rect(&mut self, id: &str) -> [f64; 4] {
let v = self.ok("ui.elements", json!({"prefix": id}));
let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}: {v}")).clone();
let r: Vec<f64> = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
[r[0], r[1], r[2], r[3]]
}
fn center(&mut self, id: &str) -> (f64, f64) {
let r = self.rect(id);
(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
}
fn drag(&mut self, id: &str, dx: f64, dy: f64) {
let (x, y) = self.center(id);
self.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + dx, "y": y + dy}, "steps": 6}));
self.frames(2);
}
fn has(&mut self, id: &str) -> bool {
let v = self.ok("ui.elements", json!({"prefix": id}));
v.as_array().is_some_and(|a| a.iter().any(|e| e["id"] == id))
}
fn masks(&mut self) -> Vec<Value> {
self.exec("masks.list", json!({}))["masks"].as_array().unwrap().clone()
}
fn shot(&mut self, name: &str) {
let Some(dir) = self.shots.clone() else { return };
// let the background frame workers finish the (blurred) frame
for _ in 0..40 {
self.frames(1);
std::thread::sleep(std::time::Duration::from_millis(25));
}
let img = self.harness.render().expect("wgpu render");
std::fs::create_dir_all(&dir).unwrap();
img.save(dir.join(format!("{name}.png"))).unwrap();
}
}
fn vertex_count(m: &Value) -> usize {
m["path"]["vertices"].as_array().map_or(0, Vec::len)
}
#[test]
fn roundness_handle_rounds_mask_corners_in_one_undo_step() {
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]}));
// an ellipse has no sharp corner: no roundness handle
d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "ellipse"}));
d.exec("masks.select", json!({"clip": id, "effect": "opacity", "mask": 0}));
d.frames(3);
assert!(d.has("program.mask.vertex.0"), "the mask overlay is on the monitor");
assert!(!d.has("program.mask.roundness"), "no roundness handle on an ellipse");
// a rectangle has one, just inside its top-left corner
d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "rectangle", "center": [960, 540], "size": [960, 540]}));
d.exec("masks.select", json!({"clip": id, "effect": "opacity", "mask": 1}));
d.frames(3);
assert!(d.has("program.mask.roundness"));
let corner = d.center("program.mask.vertex.0");
let handle = d.center("program.mask.roundness");
assert!(handle.0 > corner.0 && handle.1 > corner.1, "handle {handle:?} inside the top-left corner {corner:?}");
assert_eq!(vertex_count(&d.masks()[1]), 4);
d.shot("mask-roundness-handle");
// a click without a drag changes nothing
let undo0 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len();
d.ok("ui.click", json!({"id": "program.mask.roundness"}));
d.frames(2);
assert_eq!(vertex_count(&d.masks()[1]), 4);
assert_eq!(d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(), undo0);
// dragging inward rounds all four corners: eight vertices, one undo step
d.drag("program.mask.roundness", 30.0, 30.0);
let m = d.masks();
assert_eq!(vertex_count(&m[1]), 8, "{}", m[1]["path"]);
let undo1 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len();
assert_eq!(undo1, undo0 + 1, "one undo step per drag");
// the first fillet starts below the old top-left corner (960 − 480, 540 − 270)
let p = &m[1]["path"]["vertices"][0]["p"];
let (x, y) = (p[0].as_f64().unwrap(), p[1].as_f64().unwrap());
assert!((x - 480.0).abs() < 1e-6 && y > 270.0 + 10.0, "fillet start ({x}, {y})");
d.shot("mask-rounded-corners");
// the rounded corners are no longer sharp: the handle is gone
d.frames(2);
assert!(!d.has("program.mask.roundness"));
d.exec("edit.undo", json!({}));
d.frames(2);
assert_eq!(vertex_count(&d.masks()[1]), 4);
assert!(d.has("program.mask.roundness"));
}

View File

@ -246,14 +246,15 @@ 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 (Shift-constrained vertex and whole-mask drags; an on-monitor
corner-roundness handle).
redesigned 26.0 mask tools (Shift-constrained vertex and whole-mask drags; a stored, re-editable
corner radius: rounding bakes fillets into the path, so a rounded corner cannot be re-rounded later).
- **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); Shift snaps pen mask segments and dragged-out Bézier handles to 45° steps (#853).
#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).
- **Estimate:** 60–110 h.
## G19. Speech to text off in release builds