diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 76cc3066..692ac2b9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3488,6 +3488,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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5f17a4f4..170b8011 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2c160f4b..41d31f6c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 カラーマネジメント diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 9e0e7803..d74a619b 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -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 色彩管理 diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index 5062918e..eb479c17 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -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..mask.{ellipse,polygon,pen}`, //! `effectControls..mask` (+ `.mode`, `.inverted`, `.delete`, and the parameter rows //! `effectControls..mask..*`), `program.mask.vertex.`, `program.mask.in.`, //! `program.mask.out.`, `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 = 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 { + 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::(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 { diff --git a/crates/ui-egui/tests/mask_roundness_ui.rs b/crates/ui-egui/tests/mask_roundness_ui.rs new file mode 100644 index 00000000..4311afcf --- /dev/null +++ b/crates/ui-egui/tests/mask_roundness_ui.rs @@ -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=` 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, + shots: Option, +} + +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 = 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 { + 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")); +} diff --git a/docs/gaps.md b/docs/gaps.md index 56454106..52ef93ed 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -235,13 +235,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 (constrained lines; an on-monitor corner-roundness handle). + redesigned 26.0 mask tools (constrained lines; 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). + #813), from an on-monitor corner-roundness handle with a live preview (one undo step per drag; + #844). - **Estimate:** 60–110 h. ## G19. Speech to text off in release builds