From 54f816b26ec55e0880b87c9fc9fa3c8571ddc261 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 30 Sep 2026 11:50:55 +0200 Subject: [PATCH 1/2] fix(annotations): masks and their gimbals follow the footage, arrows get a tight gimbal A privacy mask on a moving tilted screen (3D Orbit, with motion blur on) fell back to an upright box around its two frames, so it ignored the perspective. It now keeps the footage's perspective, grown just enough to cover the frame before. Each preview frame now carries the footage's corners in the image. A blur's gimbal (MaskGimbal) goes through them, so it lands on the mask under a zoom and in 3D, instead of on the footage at rest. An arrow's gimbal frames the drawn arrow rather than its mostly empty box; its gestures write the square box that draws the arrow there. The preview frame is its own stacking context, so gimbals no longer paint over the export dialog. --- crates/compositor-view-napi/src/lib.rs | 10 +- crates/compositor/src/compositor_linux.rs | 9 + crates/compositor/src/compositor_macos.rs | 9 + crates/compositor/src/compositor_windows.rs | 9 + crates/compositor/src/frame_geometry.rs | 186 ++++++++++++++--- crates/compositor/src/live.rs | 10 +- crates/compositor/src/regions.rs | 26 +++ electron/native/compositor-view/addon.d.ts | 5 + .../ai-edition/AnnotationLayer.test.tsx | 69 +++++-- src/components/ai-edition/AnnotationLayer.tsx | 94 +++++---- .../ai-edition/AnnotationOverlay.tsx | 112 ++++++----- src/components/ai-edition/MaskGimbal.test.tsx | 88 ++++++++ src/components/ai-edition/MaskGimbal.tsx | 189 ++++++++++++++++++ .../ai-edition/NewEditorShell.module.css | 4 + .../annotations/arrowBounds.test.ts | 33 +++ src/lib/ai-edition/annotations/arrowBounds.ts | 62 ++++++ .../annotations/footageQuad.test.ts | 78 ++++++++ src/lib/ai-edition/annotations/footageQuad.ts | 107 ++++++++++ src/native/contracts.ts | 5 + src/native/footageQuadStore.ts | 38 ++++ src/native/hooks/useNativeCompositorView.ts | 4 + 21 files changed, 1005 insertions(+), 142 deletions(-) create mode 100644 src/components/ai-edition/MaskGimbal.test.tsx create mode 100644 src/components/ai-edition/MaskGimbal.tsx create mode 100644 src/lib/ai-edition/annotations/arrowBounds.test.ts create mode 100644 src/lib/ai-edition/annotations/arrowBounds.ts create mode 100644 src/lib/ai-edition/annotations/footageQuad.test.ts create mode 100644 src/lib/ai-edition/annotations/footageQuad.ts create mode 100644 src/native/footageQuadStore.ts diff --git a/crates/compositor-view-napi/src/lib.rs b/crates/compositor-view-napi/src/lib.rs index 8701c8bfe..185f48d43 100644 --- a/crates/compositor-view-napi/src/lib.rs +++ b/crates/compositor-view-napi/src/lib.rs @@ -197,6 +197,12 @@ pub struct FramePacket { /// R,G,B,A tightly-packed, `width * height * 4` octets — ce que `putImageData` / /// `ImageData` attendent côté JS (canvas 2D, format natif RGBA8). pub data: Buffer, + /// Le métrage dans cette image : ses coins TL, TR, BR, BL (x, y en fractions de l'image, + /// huit nombres), là où l'éditeur pose le gimbal d'un flou. Absent avant toute composition. + pub footage: Option>, + /// Le métrage passe de ses coins à l'image par leur homographie (caméra réelle), pas par + /// leur interpolation bilinéaire. + pub footage_projective: bool, } /// Renvoie la dernière frame readback du thread de rendu SI elle est plus récente que @@ -236,13 +242,15 @@ pub fn read_frame(id: i32, since_gen: f64) -> Result> { v.latest_frame_since(since_gen.max(0.0) as u64) } }; - Ok(slot.map(|(gen, w, h, pixels)| { + Ok(slot.map(|(gen, w, h, pixels, footage)| { debug_assert_eq!(pixels.len(), (w as usize) * (h as usize) * 4); FramePacket { gen: gen as f64, width: w, height: h, data: Buffer::from(pixels), + footage: footage.map(|q| q.corners.iter().flatten().map(|&v| v as f64).collect()), + footage_projective: footage.is_some_and(|q| q.projective), } })) } diff --git a/crates/compositor/src/compositor_linux.rs b/crates/compositor/src/compositor_linux.rs index 3b9a97a31..f49974d47 100644 --- a/crates/compositor/src/compositor_linux.rs +++ b/crates/compositor/src/compositor_linux.rs @@ -327,6 +327,8 @@ pub struct Compositor { scene: RefCell>, cursor: RefCell>, cursor_time: RefCell>, + /// Le métrage dans la dernière image composée, que l'éditeur lit avec elle (`live.rs`). + footage: std::cell::Cell>, timeline_time: RefCell>, /// Temps programme (secondes de sortie) -- cf. `FrameGeometryInput::programme_time`. programme_time: RefCell>, @@ -711,6 +713,7 @@ impl Compositor { scene: RefCell::new(None), cursor: RefCell::new(None), cursor_time: RefCell::new(None), + footage: std::cell::Cell::new(None), timeline_time: RefCell::new(None), programme_time: RefCell::new(None), text_raster: crate::text::TextRasterizer::new().ok(), @@ -2055,6 +2058,11 @@ impl Compositor { *self.webcam_mask.borrow_mut() = None; } + /// Le métrage dans la dernière image composée : ses coins et son warp (`FootageQuad`). + pub fn footage_quad(&self) -> Option { + self.footage.get() + } + /// Rend une frame dans le RT interne. Le screen `screen`/`webcam` sont des /// carriers `linux_frames` ; la geometrie vient de `plan_frame`. Coeur : /// fond uni + ecran cover-fit. `readback_direct` lit ensuite le RT. @@ -2130,6 +2138,7 @@ impl Compositor { timeline_t_override: *self.timeline_time.borrow(), programme_time: *self.programme_time.borrow(), }); + self.footage.set(Some(g.footage_quad([rw, rh]))); // (`wtw`/`wth` sont les dims de la TEXTURE webcam, consommees par le // cover-crop du calque PiP plus bas.) diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index ad4bbf28e..64f048365 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -261,6 +261,8 @@ pub struct Compositor { scene: RefCell>, cursor: RefCell>, cursor_time: RefCell>, + /// Le métrage dans la dernière image composée, que l'éditeur lit avec elle (`live.rs`). + footage: std::cell::Cell>, timeline_time: RefCell>, /// Temps programme (secondes de sortie) — cf. `FrameGeometryInput::programme_time`. programme_time: RefCell>, @@ -682,6 +684,7 @@ impl Compositor { scene: RefCell::new(None), cursor: RefCell::new(None), cursor_time: RefCell::new(None), + footage: std::cell::Cell::new(None), timeline_time: RefCell::new(None), programme_time: RefCell::new(None), live_params: RefCell::new(LiveParams::default()), @@ -2154,6 +2157,11 @@ impl Compositor { } } + /// Le métrage dans la dernière image composée : ses coins et son warp (`FootageQuad`). + pub fn footage_quad(&self) -> Option { + self.footage.get() + } + /// Compose la frame : fond, ombre écran, écran, ombre caméra, caméra — puis miroir /// `Shared` pour la lecture CPU. /// @@ -2223,6 +2231,7 @@ impl Compositor { timeline_t_override: *self.timeline_time.borrow(), programme_time: *self.programme_time.borrow(), }); + self.footage.set(Some(g.footage_quad([rw, rh]))); let cmd_buf = self.gpu.context.new_command_buffer(); // Profondeur de champ : pyramide remplie seulement sur une frame inclinée qui la lit, diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index 72ce180de..cfe335143 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -142,6 +142,8 @@ pub struct Compositor { /// (`frame / FPS`). L'export multiclip et le live le positionnent au PTS écran courant, /// c'est-à-dire au temps source absolu du clip actif. cursor_t_override: RefCell>, + /// Le métrage dans la dernière image composée, que l'éditeur lit avec elle (`live.rs`). + footage: std::cell::Cell>, /// Override du temps des zoom/full-camera regions (secondes source du clip actif). Le nom /// `timeline_t_override` est conservé pour l'API existante, mais ce temps n'est plus cumulé /// entre clips : les régions projetées par l'app portent elles aussi des temps source. @@ -724,6 +726,7 @@ impl Compositor { trail_srv, cursor: RefCell::new(None), cursor_t_override: RefCell::new(None), + footage: std::cell::Cell::new(None), timeline_t_override: RefCell::new(None), programme_time: RefCell::new(None), srv_cache: RefCell::new(HashMap::new()), @@ -1912,6 +1915,11 @@ impl Compositor { }); } + /// Le métrage dans la dernière image composée : ses coins et son warp (`FootageQuad`). + pub fn footage_quad(&self) -> Option { + self.footage.get() + } + /// Compose une frame animée (§6/§8) : fond flouté + screen zoomé (padding, coins, ombre) /// + webcam crop carré (coins, ombre), placements interpolés A↔B par la timeline. pub unsafe fn compose_frame( @@ -1955,6 +1963,7 @@ impl Compositor { timeline_t_override: *self.timeline_t_override.borrow(), programme_time: *self.programme_time.borrow(), }); + self.footage.set(Some(g.footage_quad([self.rw(), self.rh()]))); let scene_preset = g.scene_preset.clone(); let mb_taps = g.mb_taps; let mb_amount = g.mb_amount; diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 4aee32a9d..bfb8f9391 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -2658,10 +2658,6 @@ impl FrameGeometry { [centre[0] + px, centre[1] + py] }; let pts = [at(x0, y0), at(x1, y0), at(x1, y1), at(x0, y1)]; - // Le mode 8 étale aussi chaque pixel vers là où le plan était une frame plus tôt - // (`tilt_trail`) : une copie traînée du secret sort du quad courant. Le masque couvre alors - // la boîte englobante du secret aux deux frames, en rect droit, comme le chemin droit. - // Même warp que le shader sur les coins d'avant (le drapeau projectif est celui du plan). // La perspective grossit localement le côté proche : la force suit l'arête la plus // agrandie par rapport au rect droit zoomé, pour que nulle part le masque ne soit plus // fin, rapporté au contenu, qu'au repos. @@ -2672,28 +2668,29 @@ impl FrameGeometry { .max(len(pts[0], pts[3]) / eh) .max(len(pts[1], pts[2]) / eh); let strength = (zoom_k * tilt_k).max(1.0); - // Sans mouvement, la traînée EST le quad (au bit près) : rien de plus à couvrir. - if let Some(trail) = self.tilt_trail(render_px).filter(|t| t.corners != quad.corners) { - let before = crate::regions::TiltedQuad { corners: trail.corners, ..quad }; - let then = |fx: f32, fy: f32| { - let (px, py) = before.point_px(fx, fy); - [centre[0] + px, centre[1] + py] - }; - let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]); - for [px, py] in pts.into_iter().chain([then(x0, y0), then(x1, y0), then(x1, y1), then(x0, y1)]) { - (lo, hi) = ([lo[0].min(px), lo[1].min(py)], [hi[0].max(px), hi[1].max(py)]); + // Le mode 8 étale aussi chaque pixel vers là où le plan était une frame plus tôt + // (`tilt_trail`) : une copie traînée du secret sort du quad courant. Le masque garde la + // perspective du quad courant, élargi de la plus grande avance d'un de ses coins : chaque + // coin d'avant tombe dedans, donc tout le secret d'avant aussi (un quad convexe contient + // l'enveloppe de ses coins), et la traînée, qui va de l'un à l'autre. Même warp que le + // shader sur les coins d'avant (le drapeau projectif est celui du plan). Sans mouvement, + // la traînée EST le quad (au bit près) : rien de plus à couvrir. + let trail = self.tilt_trail(render_px).filter(|t| t.corners != quad.corners); + let pts = match trail { + Some(trail) => { + let before = crate::regions::TiltedQuad { corners: trail.corners, ..quad }; + let then = |fx: f32, fy: f32| { + let (px, py) = before.point_px(fx, fy); + [centre[0] + px, centre[1] + py] + }; + let prev = [then(x0, y0), then(x1, y0), then(x1, y1), then(x0, y1)]; + let reach = (0..4) + .map(|i| (prev[i][0] - pts[i][0]).hypot(prev[i][1] - pts[i][1])) + .fold(0.0f32, f32::max); + grow_quad(pts, reach + 0.5) } - let rect = - [lo[0] / rw, lo[1] / rh, (hi[0] - lo[0]).max(1.0) / rw, (hi[1] - lo[1]).max(1.0) / rh]; - let rect = match self.screen_mask { - Some(m) => m.clip(rect)?, - None => rect, - }; - let mut mask = PrivacyMask::upright(rect, render_px, strength); - // Un ovale inscrit dans la boîte élargie ne couvrirait plus l'ovale courant. - mask.oval_ok = false; - return Some(mask); - } + None => pts, + }; let (mut min_x, mut min_y) = (f32::MAX, f32::MAX); let (mut max_x, mut max_y) = (f32::MIN, f32::MIN); for [px, py] in pts { @@ -2721,9 +2718,65 @@ impl FrameGeometry { warp: Some(local), projective: quad.projective, strength, - oval_ok: true, + // Un ovale inscrit dans le quad élargi ne couvrirait plus sûrement l'ovale d'avant. + oval_ok: trail.is_none(), }) } + + /// Où tombe le métrage dans l'image (`FootageQuad`) : là où `privacy_mask` pose un masque, + /// donc là où l'éditeur doit poser le gimbal d'un flou pour qu'il couvre ce qu'il cache. + pub fn footage_quad(&self, render_px: [f32; 2]) -> FootageQuad { + let [rw, rh] = render_px; + let s_px = [self.s_dst[2] * rw, self.s_dst[3] * rh]; + if let Some(quad) = self.screen_tilt(s_px).filter(|_| self.tilted() && rw > 0.0 && rh > 0.0) { + let centre = self.screen_center_px(render_px); + let at = |fx: f32, fy: f32| { + let (px, py) = quad.point_px(fx, fy); + [(centre[0] + px) / rw, (centre[1] + py) / rh] + }; + return FootageQuad { + corners: [at(0.0, 0.0), at(1.0, 0.0), at(1.0, 1.0), at(0.0, 1.0)], + projective: quad.projective, + }; + } + let [x, y, w, h] = self.s_dst; + FootageQuad { corners: [[x, y], [x + w, y], [x + w, y + h], [x, y + h]], projective: false } + } +} + +/// Le métrage dans l'image : ses coins TL, TR, BR, BL en fractions de la sortie, et la +/// correspondance d'un point `(u, v)` du métrage à l'image, bilinéaire entre eux ou, sous la +/// caméra réelle, l'homographie qu'ils définissent (`TiltedQuad::point_px`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FootageQuad { + pub corners: [[f32; 2]; 4], + pub projective: bool, +} + +/// Le quad convexe `pts` (TL, TR, BR, BL) dont chaque côté recule de `d` px vers l'extérieur : +/// ses côtés restent parallèles aux siens, donc sa perspective aussi, et il contient tout point +/// à moins de `d` de lui. +fn grow_quad(pts: [[f32; 2]; 4], d: f32) -> [[f32; 2]; 4] { + // L'extérieur d'un côté est d'un bord ou de l'autre selon le sens de parcours. + let area: f32 = (0..4) + .map(|i| { + let (a, b) = (pts[i], pts[(i + 1) % 4]); + a[0] * b[1] - b[0] * a[1] + }) + .sum(); + let side = area.signum(); + let normal = |i: usize| { + let (a, b) = (pts[i], pts[(i + 1) % 4]); + let (ex, ey) = (b[0] - a[0], b[1] - a[1]); + let len = ex.hypot(ey).max(1e-6); + [side * ey / len, -side * ex / len] + }; + std::array::from_fn(|i| { + // Le coin glisse sur la bissectrice jusqu'à la rencontre des deux côtés reculés. + let (na, nb) = (normal((i + 3) % 4), normal(i)); + let k = d / (1.0 + na[0] * nb[0] + na[1] * nb[1]).max(0.2); + [pts[i][0] + (na[0] + nb[0]) * k, pts[i][1] + (na[1] + nb[1]) * k] + }) } /// Marge ajoutée autour d'un masque de confidentialité, en px de la boîte écran AU REPOS : un @@ -6892,6 +6945,87 @@ mod tests { } } + /// Le point `p` est dans le quad convexe `q` (TL, TR, BR, BL, px de sortie). + fn in_convex_quad(q: [[f32; 2]; 4], p: [f32; 2]) -> bool { + let side = |i: usize| { + let (a, b) = (q[i], q[(i + 1) % 4]); + (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]) + }; + (0..4).all(|i| side(i) >= -1e-3) || (0..4).all(|i| side(i) <= 1e-3) + } + + /// Sous l'orbite, la caméra suit le pointeur et le flou de mouvement étale le contenu vers + /// sa position de la frame d'avant : le masque garde la perspective du contenu (un quad warpé + /// aux côtés parallèles aux siens, pas un rect droit) et couvre le secret aux deux frames. + #[test] + fn a_moving_tilted_mask_keeps_its_perspective() { + let cfg = crate::config::all().pop().expect("au moins une config"); + let json = zoomed_golden_scene_json() + .replace(r#""rotation":"none""#, r#""rotation":"orbit","focusMode":"auto""#); + let scene = Scene::from_json(&json).expect("scène"); + // Le pointeur file vers la droite : l'orbite le suit, la caméra bouge à chaque frame. + let track: &'static crate::cursor::CursorTrack = Box::leak(Box::new(crate::cursor::CursorTrack::new( + (0..=90).map(|i| (i as f32 / 30.0, 0.05 + 0.5 * i as f32 / 90.0, 0.3)).collect(), + vec![], + vec![], + ))); + let g = plan_frame(&FrameGeometryInput { cursor: Some(track), ..golden_input(&scene, &cfg) }); + let render = [1170.0, 658.0]; + let quad = g.screen_tilt([g.s_dst[2] * render[0], g.s_dst[3] * render[1]]).expect("orbite"); + let trail = g.tilt_trail(render).expect("garde : flou de mouvement actif sous l'orbite"); + assert_ne!(trail.corners, quad.corners, "garde : la caméra doit bouger d'une frame à l'autre"); + let a = blur_annotation(""); + let m = g.privacy_mask(&a, render).expect("masque"); + let warp = m.warp.expect("le masque d'un écran incliné qui bouge reste un quad warpé"); + let origin = [m.dst[0] * render[0], m.dst[1] * render[1]]; + let mask = warp.map(|[x, y]| [origin[0] + x, origin[1] + y]); + let centre = [(g.s_dst[0] + g.s_dst[2] * 0.5) * render[0], (g.s_dst[1] + g.s_dst[3] * 0.5) * render[1]]; + let before = crate::regions::TiltedQuad { corners: trail.corners, ..quad }; + for q in [quad, before] { + for (fx, fy) in [(a.x, a.y), (a.x + a.w, a.y), (a.x + a.w, a.y + a.h), (a.x, a.y + a.h)] { + let (px, py) = q.point_px(fx, fy); + let p = [centre[0] + px, centre[1] + py]; + assert!(in_convex_quad(mask, p), "{p:?} hors du masque {mask:?}"); + } + } + let (tl, tr) = (quad.point_px(a.x, a.y), quad.point_px(a.x + a.w, a.y)); + let content = (tr.1 - tl.1) / (tr.0 - tl.0); + assert!(content.abs() > 1e-3, "garde : l'orbite doit incliner le haut du secret ({content})"); + let top = (mask[1][1] - mask[0][1]) / (mask[1][0] - mask[0][0]); + assert!((top - content).abs() < 1e-3, "le haut du masque ({top}) doit suivre celui du contenu ({content})"); + assert!(!m.oval_ok, "un masque élargi à la trace doit refuser l'ovale"); + } + + /// Le métrage que l'éditeur lit avec l'image : le rect zoomé à plat, le quad incliné du mode 8 + /// sous un préset, l'homographie de ses coins sous la caméra réelle. + #[test] + fn the_footage_quad_is_where_masks_land() { + let cfg = crate::config::all().pop().expect("au moins une config"); + let render = [1170.0, 658.0]; + let flat = plan_frame(&golden_input(&zoomed_golden_scene(), &cfg)); + let [x, y, w, h] = flat.s_dst; + assert_eq!( + flat.footage_quad(render), + FootageQuad { corners: [[x, y], [x + w, y], [x + w, y + h], [x, y + h]], projective: false } + ); + let tilted = plan_frame(&golden_input(&tilted_golden_scene(), &cfg)); + let q = tilted.footage_quad(render); + let quad = tilted.screen_tilt([tilted.s_dst[2] * render[0], tilted.s_dst[3] * render[1]]).expect("iso"); + let centre = [ + (tilted.s_dst[0] + tilted.s_dst[2] * 0.5) * render[0], + (tilted.s_dst[1] + tilted.s_dst[3] * 0.5) * render[1], + ]; + for (k, (cx, cy)) in quad.corners.iter().enumerate() { + let want = [(centre[0] + cx) / render[0], (centre[1] + cy) / render[1]]; + assert!((q.corners[k][0] - want[0]).abs() < 1e-5 && (q.corners[k][1] - want[1]).abs() < 1e-5, "coin {k}"); + } + assert!(!q.projective); + let json = zoomed_golden_scene_json() + .replace(r#""rotation":"none""#, r#""rotation":"orbit","focusMode":"auto""#); + let orbit = plan_frame(&golden_input(&Scene::from_json(&json).expect("scène"), &cfg)); + assert!(orbit.footage_quad(render).projective, "la caméra réelle warpe par homographie"); + } + /// Une entrée mesurée sur le cadre de sortie n'est pas concernée par le zoom. #[test] fn a_privacy_mask_in_frame_space_ignores_the_zoom() { diff --git a/crates/compositor/src/live.rs b/crates/compositor/src/live.rs index ebcb46388..af586f262 100644 --- a/crates/compositor/src/live.rs +++ b/crates/compositor/src/live.rs @@ -764,7 +764,7 @@ fn scene_for_clip(scene: &Scene, clip_index: usize) -> Scene { /// est l'IDENTITÉ de la frame : le consommateur (`read_frame`) ne repaie le clone + l'IPC /// que lorsqu'elle change. `None` = "aucune frame composée pour l'instant" (toutes les /// lectures avant la 1re frame composée retournent `None` côté napi, jamais un buffer vide). -type LatestFrame = (u64, u32, u32, Vec); +pub type LatestFrame = (u64, u32, u32, Vec, Option); /// État partagé thread appelant → thread de rendu (commandes sans blocage). struct Shared { @@ -894,7 +894,7 @@ impl LiveView { /// FFI vers le Buffer napi). Le `Vec` retourné a `len() == w*h*4`. /// Préférer `latest_frame_since` sur le chemin chaud : il évite ce clone quand /// le consommateur possède déjà la génération courante. - pub fn latest_frame(&self) -> Option<(u64, u32, u32, Vec)> { + pub fn latest_frame(&self) -> Option { self.shared .latest_frame .lock() @@ -910,7 +910,7 @@ impl LiveView { /// frame figée — on n'exécute PAS le clone `O(w·h)` : c'est tout l'intérêt du /// compteur. Le consommateur passe la dernière génération qu'il a peinte (`0` au /// départ) ; `None` ⇒ il ne fait rien, `Some` ⇒ il peint et retient `gen`. - pub fn latest_frame_since(&self, since_gen: u64) -> Option<(u64, u32, u32, Vec)> { + pub fn latest_frame_since(&self, since_gen: u64) -> Option { let mut guard = self.shared.latest_frame.lock().ok()?; match guard.as_ref() { // Le buffer est EMPORTÉ, pas copié. Le thread de rendu le remplace à chaque @@ -1808,7 +1808,7 @@ unsafe fn render_thread( // l'ancienne dérivation tant que le slot n'est pas vidé. let next_gen = shared.frame_gen.fetch_add(1, Ordering::Relaxed) + 1; if let Ok(mut slot) = shared.latest_frame.lock() { - *slot = Some((next_gen, rw, rh, rgba)); + *slot = Some((next_gen, rw, rh, rgba, comp.footage_quad())); } first = false; } @@ -1958,7 +1958,7 @@ pub fn run_standalone(screen: &str, webcam: &str, cursor_json: &str) -> Result<( // standalone n'affiche pas réellement les pixels ici (l'embed Electron est // le consumer réel). On imprime juste une frame de temps en temps pour // confirmer que la chaîne fonctionne. - if let Some((_gen, fw, fh, _pixels)) = view.latest_frame() { + if let Some((_gen, fw, fh, _pixels, _)) = view.latest_frame() { if (fw, fh) != (w, h) { // garde-fou : la staging de readback suit `set_rect` côté thread // de rendu, donc ce serait une désynchro transitoire — acceptable. diff --git a/crates/compositor/src/regions.rs b/crates/compositor/src/regions.rs index 8910070c3..d14c37773 100644 --- a/crates/compositor/src/regions.rs +++ b/crates/compositor/src/regions.rs @@ -2190,6 +2190,32 @@ mod arrow_tests { let (_, half) = arrow_local_geometry("right", -5.0, [100.0, 100.0]); assert_eq!(half, 0.0); } + + /// L'éditeur encadre chaque flèche par l'enveloppe de ses traits (`ARROW_EXTENTS`, + /// `src/lib/ai-edition/annotations/arrowBounds.ts`) : elle doit être celle que dessine ce + /// module, sinon le gimbal ne tombe plus sur la flèche. + #[test] + fn the_editor_frames_each_arrow_by_its_strokes() { + let ts = include_str!("../../../src/lib/ai-edition/annotations/arrowBounds.ts"); + let table = &ts[ts.find("ARROW_EXTENTS").expect("la table")..]; + let table = &table[..table.find("};").expect("sa fin")]; + for dir in ["up", "down", "left", "right", "up-right", "up-left", "down-right", "down-left"] { + let key = if dir.contains('-') { format!("\"{dir}\":") } else { format!("\t{dir}:") }; + let row = &table[table.find(&key).unwrap_or_else(|| panic!("{dir} absente"))..]; + let row = &row[row.find('[').expect("[") + 1..row.find(']').expect("]")]; + let got: Vec = row.split(',').map(|v| v.trim().parse().expect("nombre")).collect(); + let segs = arrow_segments_viewbox(dir); + let xs = segs.iter().flat_map(|s| [s[0], s[2]]); + let ys = segs.iter().flat_map(|s| [s[1], s[3]]); + let want = [ + xs.clone().fold(f32::MAX, f32::min), + ys.clone().fold(f32::MAX, f32::min), + xs.fold(f32::MIN, f32::max), + ys.fold(f32::MIN, f32::max), + ]; + assert_eq!(got, want, "{dir}"); + } + } } #[cfg(test)] diff --git a/electron/native/compositor-view/addon.d.ts b/electron/native/compositor-view/addon.d.ts index 1365846c7..ee29cd80f 100644 --- a/electron/native/compositor-view/addon.d.ts +++ b/electron/native/compositor-view/addon.d.ts @@ -34,6 +34,11 @@ export interface NativeFramePacket { width: number; height: number; data: Buffer; + /** The footage in this frame: its TL, TR, BR, BL corners (x, y as fractions of the frame, + * eight numbers), where a privacy blur's gimbal goes. Absent before anything is composed. */ + footage?: number[] | null; + /** The footage maps from those corners by their homography (real camera), not bilinearly. */ + footageProjective?: boolean; } export interface ExportStats { diff --git a/src/components/ai-edition/AnnotationLayer.test.tsx b/src/components/ai-edition/AnnotationLayer.test.tsx index 9f2ea890c..aff91ba33 100644 --- a/src/components/ai-edition/AnnotationLayer.test.tsx +++ b/src/components/ai-edition/AnnotationLayer.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom -import { render } from "@testing-library/react"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { act, render } from "@testing-library/react"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { AxcutAnnotationRegion } from "@/lib/ai-edition/schema"; +import { publishFootageQuad } from "@/native/footageQuadStore"; import { AnnotationLayer } from "./AnnotationLayer"; // react-rnd reads its offset in the parent off `getBoundingClientRect` on mount, and jsdom lays @@ -18,6 +19,7 @@ beforeAll(() => { }); }); afterAll(() => vi.restoreAllMocks()); +afterEach(() => publishFootageQuad(null)); function region(overrides: Partial): AxcutAnnotationRegion { return { @@ -46,11 +48,11 @@ function region(overrides: Partial): AxcutAnnotationRegio // A 1000×500 frame, the footage padded in to 800×400 at (100, 50). const FOOTAGE = { x: 100, y: 50, width: 800, height: 400 }; -function layer(annotations: AxcutAnnotationRegion[]) { +function layer(annotations: AxcutAnnotationRegion[], selectedAnnotationId: string | null = null) { const view = render( (".react-draggable")]; - return { frame, boxes }; + return { frame, boxes, view }; +} + +/** The corners of the selected blur's gimbal, frame px. */ +function maskCorners(container: HTMLElement): number[][] { + return [...container.querySelectorAll("[data-testid=mask-gimbal] circle")].map((c) => [ + Math.round(Number(c.getAttribute("cx")) * 1000) / 1000, + Math.round(Number(c.getAttribute("cy")) * 1000) / 1000, + ]); } describe("AnnotationLayer", () => { @@ -82,12 +92,47 @@ describe("AnnotationLayer", () => { expect(boxes[0].style.width).toBe("240px"); }); - it("keeps a blur inside the footage it hides", () => { - const { frame, boxes } = layer([region({ type: "blur", content: "" })]); - const container = boxes[0].parentElement as HTMLElement; - expect(container).not.toBe(frame); - expect(container.style.left).toBe("100px"); - expect(container.style.width).toBe("800px"); - expect(boxes[0].style.transform).toBe("translate(80px,80px)"); + it("puts a blur's gimbal on the footage it hides, at rest", () => { + const { view, boxes } = layer([region({ type: "blur", content: "" })], "a"); + expect(boxes).toHaveLength(0); + // 100 + 10 % of 800 = 180, 50 + 20 % of 400 = 130; 30 % × 40 % of the footage. + expect(maskCorners(view.container)).toEqual([ + [180, 130], + [420, 130], + [420, 290], + [180, 290], + ]); + }); + + it("moves a blur's gimbal with the footage the compositor draws: zoomed, then leaning", () => { + const { view } = layer([region({ type: "blur", content: "" })], "a"); + // The frame on screen shows the footage zoomed ×2 around its middle. + act(() => publishFootageQuad([-0.3, -0.3, 1.3, -0.3, 1.3, 1.3, -0.3, 1.3], false)); + expect(maskCorners(view.container)).toEqual([ + [-140, 10], + [340, 10], + [340, 330], + [-140, 330], + ]); + // Leaning under the real camera: the gimbal is a quad, not a rect. + act(() => publishFootageQuad([0.1, 0.1, 0.9, 0.2, 0.9, 0.8, 0.1, 0.9], true)); + const [tl, tr] = maskCorners(view.container); + expect(tr[1]).toBeGreaterThan(tl[1]); + }); + + it("frames an arrow, not the empty box around it", () => { + const arrow = region({ + type: "figure", + space: "frame", + position: { x: 10, y: 20 }, + size: { width: 60, height: 60 }, + figureData: { arrowDirection: "down-right", color: "#fff", strokeWidth: 8 }, + }); + const { boxes } = layer([arrow], "a"); + // Its box is 600 × 300 px: the arrow takes the height (scale 3), centred 150 px in, and + // its strokes span 21..79 of 100. + expect(boxes[0].style.transform).toBe(`translate(${100 + 150 + 63}px,${100 + 63}px)`); + expect(boxes[0].style.width).toBe(`${58 * 3}px`); + expect(boxes[0].style.height).toBe(`${58 * 3}px`); }); }); diff --git a/src/components/ai-edition/AnnotationLayer.tsx b/src/components/ai-edition/AnnotationLayer.tsx index 7020d7967..67ce481cd 100644 --- a/src/components/ai-edition/AnnotationLayer.tsx +++ b/src/components/ai-edition/AnnotationLayer.tsx @@ -6,13 +6,17 @@ // list and a single selection id instead. // // The layer covers the whole preview frame: text, images and arrows are placed on the frame -// and move anywhere in it, padding included. A privacy blur stays on the footage, so its boxes -// live in a child sized to the footage rect and cannot leave it (see -// `lib/ai-edition/annotations/placement.ts`). +// and move anywhere in it, padding included. A privacy blur stays on the footage and follows +// it: the compositor paints the mask on the zoomed footage, leaning under 3D, so its gimbal goes +// through the footage of the frame on screen (`MaskGimbal`, `FootageQuad`), and cannot leave it +// (see `lib/ai-edition/annotations/placement.ts`). +import { rectQuad } from "@/lib/ai-edition/annotations/footageQuad"; import { belongsInFrame, fitTextBox, toFrameSpace } from "@/lib/ai-edition/annotations/placement"; import type { AxcutAnnotationRegion } from "@/lib/ai-edition/schema"; +import { useFootageQuad } from "@/native/footageQuadStore"; import { AnnotationOverlay, type PxRect } from "./AnnotationOverlay"; +import { MaskGimbal } from "./MaskGimbal"; interface AnnotationLayerProps { annotations: AxcutAnnotationRegion[]; @@ -40,6 +44,8 @@ export function AnnotationLayer({ onCommit, }: AnnotationLayerProps) { const currentTimeMs = Math.round(currentTimeSec * 1000); + // Where the footage lies in the frame on screen; at rest (no frame yet), its layout rect. + const liveFootage = useFootageQuad(); const visible = annotations .filter((annotation) => { @@ -66,7 +72,6 @@ export function AnnotationLayer({ } const frameBox: PxRect = { x: 0, y: 0, width: frameWidth, height: frameHeight }; - const footageBox: PxRect = { x: 0, y: 0, width: footage.width, height: footage.height }; // The footage as fractions of the frame: the form the conversions in `placement.ts` take. const footageInFrame = { x: footage.x / frameWidth, @@ -76,48 +81,51 @@ export function AnnotationLayer({ }; const fitText = (region: AxcutAnnotationRegion) => fitTextBox(region, frameWidth / frameHeight); - const overlay = (annotation: AxcutAnnotationRegion, inFrame: boolean) => { - const width = inFrame ? frameWidth : footage.width; - const height = inFrame ? frameHeight : footage.height; - return ( - - ); - }; + const overlay = (annotation: AxcutAnnotationRegion) => ( + + ); + const quad = + liveFootage ?? + rectQuad(footageInFrame.x, footageInFrame.y, footageInFrame.width, footageInFrame.height); return (
- {visible.filter((a) => belongsInFrame(a.type)).map((a) => overlay(a, true))} -
- {visible.filter((a) => !belongsInFrame(a.type)).map((a) => overlay(a, false))} -
+ {visible.filter((a) => belongsInFrame(a.type)).map(overlay)} + {/* Only the selected blur has a gimbal: the mask itself is painted by the compositor. */} + {visible + .filter((a) => !belongsInFrame(a.type) && a.id === selectedAnnotationId) + .map((a) => ( + + ))}
); } diff --git a/src/components/ai-edition/AnnotationOverlay.tsx b/src/components/ai-edition/AnnotationOverlay.tsx index 356a740fe..f260847dc 100644 --- a/src/components/ai-edition/AnnotationOverlay.tsx +++ b/src/components/ai-edition/AnnotationOverlay.tsx @@ -1,6 +1,8 @@ -// Chrome d'édition d'une annotation : cadre de sélection, glissement, poignées de -// redimensionnement. Les PIXELS de l'annotation — texte, image, flèche, flou — sont peints par le -// compositeur natif, aperçu compris. +// Chrome d'édition d'une annotation posée sur le cadre (texte, image, flèche) : cadre de +// sélection, glissement, poignées de redimensionnement. Les PIXELS de l'annotation sont peints par +// le compositeur natif, aperçu compris. Un flou suit le métrage : son gimbal est `MaskGimbal`. +// Une flèche se dessine au milieu de sa boîte, à l'échelle de son plus petit côté : son cadre +// encadre le dessin (`arrowRect`), et ses gestes écrivent la boîte qui le redonne (`arrowBox`). // // Ce fichier était le port du `AnnotationOverlay` de l'éditeur v2 : il rendait les quatre types en // DOM et portait la saisie du tracé libre, soit ~400 lignes qui ne s'exécutaient plus depuis que @@ -11,6 +13,7 @@ import { useEffect, useRef, useState } from "react"; import { Rnd } from "react-rnd"; +import { arrowBox, arrowRect } from "@/lib/ai-edition/annotations/arrowBounds"; import type { AxcutAnnotationRegion } from "@/lib/ai-edition/schema"; import { clampToBound } from "@/lib/projectDefaults"; import { cn } from "@/lib/utils"; @@ -27,15 +30,15 @@ export interface PxRect { interface AnnotationOverlayProps { annotation: Region; isSelected: boolean; - /** Taille, en px, de l'élément où la boîte se déplace : le cadre, ou le footage pour un flou. - * Chaque écriture est un pourcentage de cet élément. */ + /** Taille, en px, de l'élément où la boîte se déplace : le cadre. Chaque écriture est un + * pourcentage de cet élément. */ containerWidth: number; containerHeight: number; /** D'où se lit le rect enregistré, en px dans cet élément : l'élément lui-même, ou le rect du * footage pour une annotation encore rangée sur lui mais qui se déplace dans le cadre. */ box: PxRect; /** Le patch qui range l'annotation dans le cadre, appliqué avant son premier geste ; `null` - * quand elle y est déjà (ou qu'elle n'en sort jamais, comme un flou). */ + * quand elle y est déjà. */ toFrame: Partial | null; /** Texte seulement : la boîte taillée sur le texte, centre conservé. */ fitText?: (region: Region) => Pick; @@ -72,12 +75,27 @@ export function AnnotationOverlay({ zIndex, isSelectedBoost, }: AnnotationOverlayProps) { - const committedX = box.x + (annotation.position.x / 100) * box.width; - const committedY = box.y + (annotation.position.y / 100) * box.height; - const committedWidth = (annotation.size.width / 100) * box.width; - const committedHeight = (annotation.size.height / 100) * box.height; - const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null; const isText = annotation.type === "text"; + const arrow = + annotation.type === "figure" + ? { + direction: annotation.figureData?.arrowDirection ?? "right", + stroke: annotation.figureData?.strokeWidth ?? 4, + } + : null; + // Le cadre du geste : la boîte rangée, ou, pour une flèche, la flèche dessinée dedans. + const stored: PxRect = { + x: box.x + (annotation.position.x / 100) * box.width, + y: box.y + (annotation.position.y / 100) * box.height, + width: (annotation.size.width / 100) * box.width, + height: (annotation.size.height / 100) * box.height, + }; + const { + x: committedX, + y: committedY, + width: committedWidth, + height: committedHeight, + } = arrow ? arrowRect(stored, arrow.direction, arrow.stroke) : stored; const isDraggingRef = useRef(false); // Le geste en cours a-t-il bougé quelque chose ? Un clic sur une annotation sélectionnée passe // aussi par le début et la fin d'un glissement : sans mouvement, il n'écrit rien. @@ -103,14 +121,22 @@ export function AnnotationOverlay({ const { x, y, width, height } = liveRect; - /** Un rect en px de l'élément, en pourcentages de lui : ce que le document range. */ - const toPct = (rect: PxRect): Pick => ({ - position: { x: (rect.x / containerWidth) * 100, y: (rect.y / containerHeight) * 100 }, - size: { - width: (rect.width / containerWidth) * 100, - height: (rect.height / containerHeight) * 100, - }, - }); + /** Le cadre du geste en px de l'élément, en pourcentages de lui : ce que le document range. + * Pour une flèche, la boîte qui la dessine là (carrée : la flèche ne s'étire pas). */ + const toPct = (frame: PxRect): Pick => { + const rect = arrow ? arrowBox(frame, arrow.direction, arrow.stroke) : frame; + return { + position: { x: (rect.x / containerWidth) * 100, y: (rect.y / containerHeight) * 100 }, + size: { + width: (rect.width / containerWidth) * 100, + height: (rect.height / containerHeight) * 100, + }, + }; + }; + /** Ce qu'un glissement écrit : la position ; pour une flèche aussi la taille, parce que sa + * boîte devient carrée au premier geste. */ + const moved = (frame: PxRect): Partial => + arrow ? toPct(frame) : { position: toPct(frame).position }; // Une annotation encore sur le footage passe dans le cadre à son premier mouvement, au pixel // près : les écritures qui suivent sont des pourcentages du cadre. @@ -152,14 +178,12 @@ export function AnnotationOverlay({ // Pousse la position PENDANT le geste : c'est le natif qui peint, il doit donc suivre // le curseur. `onChange` ne met à jour qu'en mémoire ; l'écriture disque se fait une // seule fois, au relâchement (`onCommit`). - onChange(annotation.id, { - position: toPct({ x: d.x, y: d.y, width, height }).position, - }); + onChange(annotation.id, moved({ x: d.x, y: d.y, width, height })); }} onDragStop={(_e, d) => { if (movedRef.current) { setLiveRect((prev) => ({ ...prev, x: d.x, y: d.y })); - settle({ position: toPct({ x: d.x, y: d.y, width, height }).position }); + settle(moved({ x: d.x, y: d.y, width, height })); } movedRef.current = false; setTimeout(() => { @@ -202,28 +226,22 @@ export function AnnotationOverlay({ onClick(annotation.id); }} bounds="parent" - // Un texte garde sa forme : sa boîte est celle de ses mots, seule la taille change. - lockAspectRatio={isText} + // Un texte garde sa forme : sa boîte est celle de ses mots, seule la taille change. Une + // flèche aussi : elle ne s'étire pas, elle grandit. + lockAspectRatio={isText || arrow !== null} className={cn( "cursor-move", - isSelected && - annotation.type !== "blur" && - "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent", + isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent", )} style={{ zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, pointerEvents: isSelected ? "auto" : "none", - border: - isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none", - backgroundColor: - isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent", - boxShadow: - isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none", + border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none", + backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent", + boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none", }} - // Un flou en tracé libre se déplace et se redimensionne comme les autres : ce qui le - // bloquait, c'était la zone de saisie du tracé qui capturait le pointeur — et elle est - // partie avec l'outil. Un texte ne se tire que par ses coins, qui agrandissent ses lettres. - enableResizing={isSelected ? (isText ? CORNERS_ONLY : true) : false} + // Un texte et une flèche ne se tirent que par leurs coins, qui les agrandissent. + enableResizing={isSelected ? (isText || arrow ? CORNERS_ONLY : true) : false} disableDragging={!isSelected} resizeHandleStyles={{ topLeft: { @@ -268,23 +286,7 @@ export function AnnotationOverlay({ }, }} > -
- {/* Le cadre d'un flou sélectionné, à la forme du masque. Les autres types portent le - leur sur le `Rnd` lui-même ; un flou n'en a pas, pour ne pas encadrer la zone qu'il - est censé cacher — sans ce liseré il n'aurait AUCUN retour de sélection. */} - {isSelected && annotation.type === "blur" ? ( -
- ) : null} -
+
); } diff --git a/src/components/ai-edition/MaskGimbal.test.tsx b/src/components/ai-edition/MaskGimbal.test.tsx new file mode 100644 index 000000000..45e897de7 --- /dev/null +++ b/src/components/ai-edition/MaskGimbal.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +import { fireEvent, render } from "@testing-library/react"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { rectQuad } from "@/lib/ai-edition/annotations/footageQuad"; +import type { AxcutAnnotationRegion } from "@/lib/ai-edition/schema"; +import { MaskGimbal } from "./MaskGimbal"; + +// The gimbal reads the pointer against its own box: the whole 1000 × 500 frame. +beforeAll(() => { + vi.spyOn(SVGElement.prototype, "getBoundingClientRect").mockReturnValue( + DOMRect.fromRect({ x: 0, y: 0, width: 1000, height: 500 }), + ); +}); +afterAll(() => vi.restoreAllMocks()); + +const BLUR = { + id: "b", + startMs: 0, + endMs: 1000, + type: "blur", + content: "", + position: { x: 10, y: 20 }, + size: { width: 30, height: 40 }, + style: {}, + zIndex: 1, + blurData: { type: "mosaic", shape: "rectangle", color: "white", intensity: 12, blockSize: 12 }, +} as unknown as AxcutAnnotationRegion; + +// The footage zoomed ×2 around its middle: one unit of footage is 1.6 units of frame. +const ZOOMED = rectQuad(-0.3, -0.3, 1.6, 1.6); + +function gimbal() { + const onChange = vi.fn(); + const onCommit = vi.fn(); + const onClick = vi.fn(); + const { container } = render( + , + ); + return { container, onChange, onCommit, onClick }; +} + +const at = (x: number, y: number) => ({ clientX: x, clientY: y, pointerId: 1 }); + +describe("MaskGimbal", () => { + it("moves the mask by what the pointer covers of the footage, zoomed", () => { + const { container, onChange, onCommit } = gimbal(); + const body = container.querySelector("polygon") as SVGPolygonElement; + fireEvent.pointerDown(body, at(300, 200)); + // 160 px of a 1000 px frame, zoomed ×2 over 1.6: a tenth of the footage. + fireEvent.pointerMove(body, at(460, 200)); + fireEvent.pointerUp(body, at(460, 200)); + const last = onChange.mock.lastCall?.[1]; + expect(last.position.x).toBeCloseTo(20, 9); + expect(last.position.y).toBeCloseTo(20, 9); + expect(onCommit).toHaveBeenCalledTimes(1); + }); + + it("resizes from a corner while the opposite one stays", () => { + const { container, onChange } = gimbal(); + const bottomRight = container.querySelectorAll("circle")[2]; + fireEvent.pointerDown(bottomRight, at(0, 0)); + // Frame point (0.5, 0.5) is the middle of the footage. + fireEvent.pointerMove(bottomRight, at(500, 250)); + const last = onChange.mock.lastCall?.[1]; + expect(last.position).toEqual({ x: 10, y: 20 }); + expect(last.size.width).toBeCloseTo(40, 9); + expect(last.size.height).toBeCloseTo(30, 9); + }); + + it("selects through on a click that moves nothing", () => { + const { container, onChange, onCommit, onClick } = gimbal(); + const body = container.querySelector("polygon") as SVGPolygonElement; + fireEvent.pointerDown(body, at(300, 200)); + fireEvent.pointerUp(body, at(300, 200)); + expect(onChange).not.toHaveBeenCalled(); + expect(onCommit).not.toHaveBeenCalled(); + expect(onClick).toHaveBeenCalledWith("b"); + }); +}); diff --git a/src/components/ai-edition/MaskGimbal.tsx b/src/components/ai-edition/MaskGimbal.tsx new file mode 100644 index 000000000..35645f154 --- /dev/null +++ b/src/components/ai-edition/MaskGimbal.tsx @@ -0,0 +1,189 @@ +// Le gimbal d'un flou de confidentialité. Le masque suit le contenu : le compositeur le pose sur +// le métrage zoomé, et incliné sous la 3D (`privacy_mask`). Le gimbal passe donc par le métrage +// de l'image (`FootageQuad`) : contour, poignées et gestes vivent dans le repère du métrage, et +// chaque point est projeté comme le compositeur le peint. Un `Rnd` droit, posé sur le métrage au +// repos, s'écartait du masque dès le premier zoom. + +import type { PointerEvent as ReactPointerEvent } from "react"; +import { useRef } from "react"; +import { + type FootageQuad, + footageAt, + footagePoint, +} from "@/lib/ai-edition/annotations/footageQuad"; +import type { AxcutAnnotationRegion } from "@/lib/ai-edition/schema"; + +type Region = AxcutAnnotationRegion; + +interface MaskGimbalProps { + annotation: Region; + /** Le métrage dans l'image, en fractions de l'image. */ + quad: FootageQuad; + /** L'image de l'aperçu, px. */ + frameWidth: number; + frameHeight: number; + onChange: (id: string, patch: Partial) => void; + /** Écriture disque, une fois en fin de geste. */ + onCommit?: () => void; + onClick: (id: string) => void; + zIndex: number; +} + +const GREEN = "#34B27B"; +/** Le plus petit masque que laissent les poignées, en % du métrage. */ +const MIN_PCT = 1; +/** Les poignées dans l'ordre des coins : TL, TR, BR, BL. */ +const CORNER_CURSORS = ["nwse-resize", "nesw-resize", "nwse-resize", "nesw-resize"] as const; + +type Gesture = + | { + kind: "move"; + start: readonly [number, number]; + origin: { x: number; y: number }; + moved: boolean; + } + | { kind: "corner"; fixed: readonly [number, number]; moved: boolean }; + +const clamp = (value: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, value)); + +export function MaskGimbal({ + annotation, + quad, + frameWidth, + frameHeight, + onChange, + onCommit, + onClick, + zIndex, +}: MaskGimbalProps) { + const svgRef = useRef(null); + const gesture = useRef(null); + const { position, size } = annotation; + // Le masque en fractions du métrage. + const [u0, v0] = [position.x / 100, position.y / 100]; + const [u1, v1] = [u0 + size.width / 100, v0 + size.height / 100]; + const onFrame = (u: number, v: number) => { + const [x, y] = footagePoint(quad, u, v); + return [x * frameWidth, y * frameHeight] as const; + }; + const corners = [ + [u0, v0], + [u1, v0], + [u1, v1], + [u0, v1], + ] as const; + // Un ovale se dessine en ovale, projeté ; le tracé libre, comme le masque, par sa boîte. + const outline = + annotation.blurData?.shape === "oval" + ? Array.from({ length: 48 }, (_, k) => { + const t = (k / 48) * 2 * Math.PI; + return onFrame( + u0 + (u1 - u0) * (0.5 + 0.5 * Math.cos(t)), + v0 + (v1 - v0) * (0.5 + 0.5 * Math.sin(t)), + ); + }) + : corners.map(([u, v]) => onFrame(u, v)); + + /** Le point du métrage sous le pointeur, en fractions du métrage. */ + const under = (event: ReactPointerEvent): readonly [number, number] => { + const r = svgRef.current?.getBoundingClientRect(); + if (!r || r.width <= 0 || r.height <= 0) return [0, 0]; + return footageAt(quad, (event.clientX - r.left) / r.width, (event.clientY - r.top) / r.height); + }; + + const begin = (event: ReactPointerEvent, next: Gesture) => { + event.stopPropagation(); + event.currentTarget.setPointerCapture?.(event.pointerId); + gesture.current = next; + }; + + const onPointerMove = (event: ReactPointerEvent) => { + const g = gesture.current; + if (!g) return; + const [u, v] = under(event); + if (g.kind === "move") { + const x = clamp(g.origin.x + (u - g.start[0]) * 100, 0, 100 - size.width); + const y = clamp(g.origin.y + (v - g.start[1]) * 100, 0, 100 - size.height); + g.moved ||= x !== g.origin.x || y !== g.origin.y; + // En direct : c'est le natif qui peint, il suit le geste. L'écriture disque attend + // le relâchement (`onCommit`). + onChange(annotation.id, { position: { x, y } }); + return; + } + // Le coin opposé reste en place ; la poignée peut passer de l'autre côté, sans que le + // masque descende sous le minimum ni sorte du métrage. + const span = (c: number, fixed: number): [number, number] => { + const [lo, hi] = + c < fixed ? [Math.min(c, fixed - MIN_PCT), fixed] : [fixed, Math.max(c, fixed + MIN_PCT)]; + const top = Math.min(hi, 100); + return [Math.max(0, Math.min(lo, top - MIN_PCT)), top]; + }; + const [x0, x1] = span(clamp(u * 100, 0, 100), g.fixed[0]); + const [y0, y1] = span(clamp(v * 100, 0, 100), g.fixed[1]); + g.moved = true; + onChange(annotation.id, { + position: { x: x0, y: y0 }, + size: { width: x1 - x0, height: y1 - y0 }, + }); + }; + + const onPointerUp = (event: ReactPointerEvent) => { + const g = gesture.current; + gesture.current = null; + if (!g) return; + (event.target as Element).releasePointerCapture?.(event.pointerId); + if (g.moved) onCommit?.(); + else if (g.kind === "move") onClick(annotation.id); + }; + + const points = (list: readonly (readonly [number, number])[]) => + list.map(([x, y]) => `${x},${y}`).join(" "); + + return ( + + + begin(event, { kind: "move", start: under(event), origin: { ...position }, moved: false }) + } + /> + {corners.map(([u, v], k) => { + const [cx, cy] = onFrame(u, v); + const [ou, ov] = corners[(k + 2) % 4]; + return ( + + begin(event, { kind: "corner", fixed: [ou * 100, ov * 100], moved: false }) + } + /> + ); + })} + + ); +} diff --git a/src/components/ai-edition/NewEditorShell.module.css b/src/components/ai-edition/NewEditorShell.module.css index 667d0c5db..35498978a 100644 --- a/src/components/ai-edition/NewEditorShell.module.css +++ b/src/components/ai-edition/NewEditorShell.module.css @@ -361,6 +361,10 @@ overflow: hidden; display: grid; place-items: center; + /* Its own stacking context: the layers inside it (gimbals, zoom focus, webcam drag) order + themselves with z-indexes up to 1000 and more, which must not reach past the preview. A + selected annotation's gimbal painted over the export dialog (z-index 100). */ + isolation: isolate; } /* Once the native canvas has painted, it covers the whole card, and anything painted UNDER the same rounded clip leaks through its anti-aliased edge: the wallpaper placeholder and the inset diff --git a/src/lib/ai-edition/annotations/arrowBounds.test.ts b/src/lib/ai-edition/annotations/arrowBounds.test.ts new file mode 100644 index 000000000..3c39ca9a8 --- /dev/null +++ b/src/lib/ai-edition/annotations/arrowBounds.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { arrowBox, arrowRect } from "./arrowBounds"; + +describe("arrowBounds", () => { + it("frames the arrow, not its box", () => { + // A wide box: the arrow takes the height, centred; a diagonal fills half of it. + const drawn = arrowRect({ x: 100, y: 50, width: 600, height: 300 }, "down-right", 8); + // Scale 3, offset 150 on x; the strokes go from 25 - 4 to 75 + 4. + expect(drawn).toEqual({ x: 100 + 150 + 21 * 3, y: 50 + 21 * 3, width: 58 * 3, height: 58 * 3 }); + }); + + it("writes back a square box that draws the arrow where the gimbal left it", () => { + for (const direction of ["right", "up", "down-left"] as const) { + const drawn = { x: 40, y: 30, width: 120, height: 0 }; + const box = arrowBox({ ...drawn, height: 1 }, direction, 6); + expect(box.width).toBe(box.height); + const back = arrowRect(box, direction, 6); + expect(back.x).toBeCloseTo(40, 9); + expect(back.y).toBeCloseTo(30, 9); + expect(back.width).toBeCloseTo(120, 9); + } + }); + + it("keeps an arrow drawn where it was when its box becomes square", () => { + const box = { x: 10, y: 20, width: 500, height: 200 }; + const drawn = arrowRect(box, "up", 7); + const again = arrowRect(arrowBox(drawn, "up", 7), "up", 7); + expect(again.x).toBeCloseTo(drawn.x, 9); + expect(again.y).toBeCloseTo(drawn.y, 9); + expect(again.width).toBeCloseTo(drawn.width, 9); + expect(again.height).toBeCloseTo(drawn.height, 9); + }); +}); diff --git a/src/lib/ai-edition/annotations/arrowBounds.ts b/src/lib/ai-edition/annotations/arrowBounds.ts new file mode 100644 index 000000000..31174c4fe --- /dev/null +++ b/src/lib/ai-edition/annotations/arrowBounds.ts @@ -0,0 +1,62 @@ +// Où une flèche d'annotation se dessine dans sa boîte. Le compositeur la met à l'échelle du plus +// petit côté de la boîte, centrée, dans un repère 100 × 100 où elle n'occupe que la moitié du +// carré : encadrer la boîte laisserait un grand vide autour d'elle. Son gimbal encadre donc le +// dessin, et écrit la boîte qui le redonne. + +import type { AxcutAnnotationRegion } from "@/lib/ai-edition/schema"; + +type Direction = NonNullable["arrowDirection"]; + +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +/** L'enveloppe des trois traits de chaque flèche (x0, y0, x1, y1), en unités du repère 100 × + * 100 : celle de `arrow_segments_viewbox` (`crates/compositor/src/regions.rs`), qu'un test Rust + * tient à cette table. */ +export const ARROW_EXTENTS: Record = { + up: [35, 20, 65, 80], + down: [35, 20, 65, 80], + left: [20, 35, 80, 65], + right: [20, 35, 80, 65], + "up-right": [25, 25, 75, 75], + "up-left": [25, 25, 75, 75], + "down-right": [25, 25, 75, 75], + "down-left": [25, 25, 75, 75], +}; + +/** L'enveloppe, bouts ronds du trait compris (la moitié de son épaisseur, dans le même repère). */ +function extent(direction: Direction, strokeWidth: number): [number, number, number, number] { + const [x0, y0, x1, y1] = ARROW_EXTENTS[direction] ?? ARROW_EXTENTS.right; + const half = Math.max(0, strokeWidth) / 2; + return [x0 - half, y0 - half, x1 + half, y1 + half]; +} + +/** La flèche telle que le compositeur la dessine dans `box` : ce que son gimbal encadre. */ +export function arrowRect(box: Rect, direction: Direction, strokeWidth: number): Rect { + const scale = Math.min(box.width, box.height) / 100; + const offX = (box.width - 100 * scale) / 2; + const offY = (box.height - 100 * scale) / 2; + const [x0, y0, x1, y1] = extent(direction, strokeWidth); + return { + x: box.x + offX + x0 * scale, + y: box.y + offY + y0 * scale, + width: (x1 - x0) * scale, + height: (y1 - y0) * scale, + }; +} + +/** La boîte, carrée, qui dessine la flèche exactement sur `drawn` : l'inverse d'`arrowRect`. */ +export function arrowBox(drawn: Rect, direction: Direction, strokeWidth: number): Rect { + const [x0, y0, x1] = extent(direction, strokeWidth); + const scale = drawn.width / (x1 - x0); + return { + x: drawn.x - x0 * scale, + y: drawn.y - y0 * scale, + width: 100 * scale, + height: 100 * scale, + }; +} diff --git a/src/lib/ai-edition/annotations/footageQuad.test.ts b/src/lib/ai-edition/annotations/footageQuad.test.ts new file mode 100644 index 000000000..3c6b095fb --- /dev/null +++ b/src/lib/ai-edition/annotations/footageQuad.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { type FootageQuad, footageAt, footagePoint, quadFromPacket, rectQuad } from "./footageQuad"; + +// A plane leaning away on its right, as the fixed angles draw it, and the same corners under +// the real camera (their homography). +const LEANING: FootageQuad["corners"] = [ + [0.12, 0.08], + [0.86, 0.16], + [0.84, 0.82], + [0.1, 0.9], +]; + +describe("footageQuad", () => { + it("maps the footage onto a rect at rest", () => { + const q = rectQuad(0.1, 0.2, 0.8, 0.5); + const [x, y] = footagePoint(q, 0.25, 0.5); + expect(x).toBeCloseTo(0.3, 12); + expect(y).toBeCloseTo(0.45, 12); + const [u, v] = footageAt(q, 0.3, 0.45); + expect(u).toBeCloseTo(0.25, 9); + expect(v).toBeCloseTo(0.5, 9); + }); + + it("lands on the corners and goes back, bilinear or projective", () => { + for (const projective of [false, true]) { + const q = { corners: LEANING, projective }; + const corners = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ]; + corners.forEach(([u, v], k) => { + const [x, y] = footagePoint(q, u, v); + expect(x).toBeCloseTo(LEANING[k][0], 9); + expect(y).toBeCloseTo(LEANING[k][1], 9); + }); + for (const [u, v] of [ + [0.3, 0.7], + [0.62, 0.18], + [1.1, -0.05], + ]) { + const [x, y] = footagePoint(q, u, v); + const [bu, bv] = footageAt(q, x, y); + expect(bu).toBeCloseTo(u, 6); + expect(bv).toBeCloseTo(v, 6); + } + } + }); + + it("follows the homography of `square_to_quad`, not the bilinear blend, under the camera", () => { + // The middle of the square: where the diagonals cross under a homography, and the mean + // of the corners under the bilinear warp. Leaning, the two differ. + const [px, py] = footagePoint({ corners: LEANING, projective: true }, 0.5, 0.5); + const [bx, by] = footagePoint({ corners: LEANING, projective: false }, 0.5, 0.5); + expect(Math.hypot(px - bx, py - by)).toBeGreaterThan(1e-3); + // The diagonals TL-BR and TR-BL cross at (px, py). + const [tl, tr, br, bl] = LEANING; + const cross = (a: readonly number[], b: readonly number[], p: readonly number[]) => + (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]); + expect(cross(tl, br, [px, py])).toBeCloseTo(0, 9); + expect(cross(tr, bl, [px, py])).toBeCloseTo(0, 9); + }); + + it("reads the eight numbers of a frame packet", () => { + expect(quadFromPacket([0, 0, 1, 0, 1, 1, 0, 1], true)).toEqual({ + corners: [ + [0, 0], + [1, 0], + [1, 1], + [0, 1], + ], + projective: true, + }); + expect(quadFromPacket(undefined)).toBeNull(); + expect(quadFromPacket([0, 0, 1])).toBeNull(); + }); +}); diff --git a/src/lib/ai-edition/annotations/footageQuad.ts b/src/lib/ai-edition/annotations/footageQuad.ts new file mode 100644 index 000000000..433bbb03f --- /dev/null +++ b/src/lib/ai-edition/annotations/footageQuad.ts @@ -0,0 +1,107 @@ +// Le métrage dans l'image de l'aperçu : ses coins et son warp, tels que le compositeur les publie +// avec chaque image (`FootageQuad`, `crates/compositor/src/frame_geometry.rs`). Un flou de +// confidentialité suit le contenu, zoom et perspective compris : son gimbal passe par ici pour +// tomber sur le masque que le compositeur peint. + +export type Point = readonly [number, number]; + +export interface FootageQuad { + /** TL, TR, BR, BL, en fractions de l'image. */ + corners: readonly [Point, Point, Point, Point]; + /** Le métrage suit l'homographie de ses coins (caméra réelle), pas leur interpolation + * bilinéaire. */ + projective: boolean; +} + +/** Le quad d'un rect droit, en fractions de l'image : le métrage au repos. */ +export function rectQuad(x: number, y: number, width: number, height: number): FootageQuad { + return { + corners: [ + [x, y], + [x + width, y], + [x + width, y + height], + [x, y + height], + ], + projective: false, + }; +} + +/** Les huit nombres qu'une image du compositeur porte (TL, TR, BR, BL), en quad. */ +export function quadFromPacket( + footage: readonly number[] | null | undefined, + projective = false, +): FootageQuad | null { + if (!footage || footage.length !== 8 || !footage.every(Number.isFinite)) return null; + const at = (i: number): Point => [footage[2 * i], footage[2 * i + 1]]; + return { corners: [at(0), at(1), at(2), at(3)], projective }; +} + +/** L'homographie qui envoie le carré unité sur les coins : la forme de Heckbert de + * `square_to_quad` (`regions.rs`), en matrice 3 × 3 ligne par ligne. */ +function homography(q: FootageQuad): number[] { + const [c0, c1, c2, c3] = q.corners; + const p = (c: Point) => [c[0] - c0[0], c[1] - c0[1]]; + const [p1, p2, p3] = [p(c1), p(c2), p(c3)]; + const d1 = [p1[0] - p2[0], p1[1] - p2[1]]; + const d2 = [p3[0] - p2[0], p3[1] - p2[1]]; + const d3 = [p2[0] - p1[0] - p3[0], p2[1] - p1[1] - p3[1]]; + const den = d1[0] * d2[1] - d2[0] * d1[1]; + const g = (d3[0] * d2[1] - d2[0] * d3[1]) / den; + const h = (d1[0] * d3[1] - d3[0] * d1[1]) / den; + return [ + p1[0] * (1 + g) + c0[0] * g, + p3[0] * (1 + h) + c0[0] * h, + c0[0], + p1[1] * (1 + g) + c0[1] * g, + p3[1] * (1 + h) + c0[1] * h, + c0[1], + g, + h, + 1, + ]; +} + +function bilinear(q: FootageQuad, u: number, v: number): Point { + const [tl, tr, br, bl] = q.corners; + const top = [tl[0] + (tr[0] - tl[0]) * u, tl[1] + (tr[1] - tl[1]) * u]; + const bottom = [bl[0] + (br[0] - bl[0]) * u, bl[1] + (br[1] - bl[1]) * u]; + return [top[0] + (bottom[0] - top[0]) * v, top[1] + (bottom[1] - top[1]) * v]; +} + +/** Où tombe le point `(u, v)` du métrage (0..1 depuis son coin haut-gauche) dans l'image : + * miroir de `TiltedQuad::point_px`. */ +export function footagePoint(q: FootageQuad, u: number, v: number): Point { + if (!q.projective) return bilinear(q, u, v); + const m = homography(q); + const w = m[6] * u + m[7] * v + 1; + return [(m[0] * u + m[1] * v + m[2]) / w, (m[3] * u + m[4] * v + m[5]) / w]; +} + +/** Le point du métrage sous le point `(x, y)` de l'image : l'inverse de `footagePoint`. */ +export function footageAt(q: FootageQuad, x: number, y: number): Point { + if (q.projective) { + const [a, b, c, d, e, f, g, h, i] = homography(q); + // L'adjugée suffit : le facteur commun du déterminant s'en va à la division par `w`. + const u = (e * i - f * h) * x + (c * h - b * i) * y + (b * f - c * e); + const v = (f * g - d * i) * x + (a * i - c * g) * y + (c * d - a * f); + const w = (d * h - e * g) * x + (b * g - a * h) * y + (a * e - b * d); + return [u / w, v / w]; + } + // Bilinéaire : pas de forme close commode, Newton converge en quelques pas sur un quad + // convexe, prolongements hors de 0..1 compris. + const [tl, tr, br, bl] = q.corners; + let [u, v] = [0.5, 0.5]; + for (let step = 0; step < 16; step++) { + const [px, py] = bilinear(q, u, v); + const dxu = (tr[0] - tl[0]) * (1 - v) + (br[0] - bl[0]) * v; + const dyu = (tr[1] - tl[1]) * (1 - v) + (br[1] - bl[1]) * v; + const dxv = (bl[0] - tl[0]) * (1 - u) + (br[0] - tr[0]) * u; + const dyv = (bl[1] - tl[1]) * (1 - u) + (br[1] - tr[1]) * u; + const det = dxu * dyv - dxv * dyu; + if (Math.abs(det) < 1e-12) break; + const [ex, ey] = [px - x, py - y]; + u -= (ex * dyv - ey * dxv) / det; + v -= (ey * dxu - ex * dyu) / det; + } + return [u, v]; +} diff --git a/src/native/contracts.ts b/src/native/contracts.ts index 10164ad18..ee69d0cb2 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -160,6 +160,11 @@ export interface CompositorFramePacket { width: number; height: number; data: Buffer; + /** The footage in this frame: its TL, TR, BR, BL corners (x, y as fractions of the frame, + * eight numbers), where a privacy blur's gimbal goes. Absent before anything is composed. */ + footage?: number[] | null; + /** The footage maps from those corners by their homography (real camera), not bilinearly. */ + footageProjective?: boolean; } /** Un clip de la timeline pour l'export multiclip natif (fichiers screen+webcam + trim). */ diff --git a/src/native/footageQuadStore.ts b/src/native/footageQuadStore.ts new file mode 100644 index 000000000..76d24e10f --- /dev/null +++ b/src/native/footageQuadStore.ts @@ -0,0 +1,38 @@ +// Le métrage dans la dernière image de l'aperçu natif (`FootageQuad`), que chaque image du +// compositeur porte avec ses pixels. Le gimbal d'un flou de confidentialité s'y pose : là où le +// compositeur peint le masque, sous un zoom comme sous la 3D. + +import { useSyncExternalStore } from "react"; +import { type FootageQuad, quadFromPacket } from "@/lib/ai-edition/annotations/footageQuad"; + +let current: FootageQuad | null = null; +let currentKey = ""; +const listeners = new Set<() => void>(); + +/** Retient le métrage d'une image livrée. Ne réveille ses lecteurs que s'il a bougé : en pause, + * les images se suivent à l'identique. `null` l'oublie (vue détruite). */ +export function publishFootageQuad( + footage: readonly number[] | null | undefined, + projective?: boolean, +): void { + const next = quadFromPacket(footage, projective); + const key = next ? `${next.projective}:${next.corners.flat().join(",")}` : ""; + if (key === currentKey) return; + currentKey = key; + current = next; + for (const listener of listeners) listener(); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Le métrage de la dernière image livrée ; `null` avant la première. */ +export function useFootageQuad(): FootageQuad | null { + return useSyncExternalStore( + subscribe, + () => current, + () => null, + ); +} diff --git a/src/native/hooks/useNativeCompositorView.ts b/src/native/hooks/useNativeCompositorView.ts index 222ffd754..e07953268 100644 --- a/src/native/hooks/useNativeCompositorView.ts +++ b/src/native/hooks/useNativeCompositorView.ts @@ -30,6 +30,7 @@ import { setCompositorRect, } from "../compositorViewClient"; import type { CompositorParamValue, CompositorViewRect } from "../contracts"; +import { publishFootageQuad } from "../footageQuadStore"; import { computeDeviceRect, rectsEqual } from "../nativeViewRect"; export interface UseNativeCompositorViewOptions { @@ -218,6 +219,8 @@ export function useNativeCompositorView( if (data.byteLength !== width * height * 4 || width === 0 || height === 0) { return; } + // Where the footage lies in this frame: a privacy blur's gimbal follows it. + publishFootageQuad(frame.footage, frame.footageProjective); // Wrap the received buffer DIRECTLY — no intermediate copy. `data` is a // fresh per-frame Buffer from IPC (never pooled or reused across frames), // so a view over it is valid for the lifetime of this paint, and nothing @@ -316,6 +319,7 @@ export function useNativeCompositorView( return () => { disposed = true; + publishFootageQuad(null); if (rectRafHandle !== 0) { cancelAnimationFrame(rectRafHandle); } From c341d7af499d0d394236124564b67c41f0d9ac03 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 30 Sep 2026 12:11:08 +0200 Subject: [PATCH 2/2] fix(annotations): let an arrow's box start before the frame An arrow drawn against the frame's left or top edge has a square box that starts before the frame, since the compositor draws the arrow in the middle of it. The schema refused the negative position, so the save and the next opening of the project failed. --- src/lib/ai-edition/schema/index.test.ts | 17 +++++++++++++++++ src/lib/ai-edition/schema/index.ts | 11 +++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/lib/ai-edition/schema/index.test.ts b/src/lib/ai-edition/schema/index.test.ts index edc37804a..38aabd870 100644 --- a/src/lib/ai-edition/schema/index.test.ts +++ b/src/lib/ai-edition/schema/index.test.ts @@ -198,6 +198,23 @@ describe("axcut-schema v8", () => { expect([sizeOf(0), sizeOf(24), sizeOf(500)]).toEqual([8, 24, 200]); }); + it("opens an arrow drawn against the frame's edge, whose box starts before it", () => { + const at = (type: "figure" | "text", x: number) => () => + annotationRegionSchema.parse({ + id: "ann_1", + startMs: 0, + endMs: 1500, + type, + position: { x, y: -5 }, + size: { width: 30, height: 53 }, + style: {}, + zIndex: 1, + }); + // A right arrow with a 6 px stroke, dragged to the left edge (`arrowBox`). + expect(at("figure", -17)().position).toEqual({ x: -17, y: -5 }); + expect(at("text", -17)).toThrow("position must be at least 0"); + }); + it("zoomRegionSchema rejects unknown depths", () => { expect(() => zoomRegionSchema.parse({ diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts index 39acf68d6..9c5760f19 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -466,9 +466,10 @@ export const annotationRegionSchema = endGteStart( // what it hides, and every annotation saved before frame placement existed. See // `annotations/placement.ts`. space: z.literal("frame").optional(), + // At least 0 too, except for an arrow: see the refine below. position: z.object({ - x: z.number().min(0).max(100), - y: z.number().min(0).max(100), + x: z.number().max(100), + y: z.number().max(100), }), size: z.object({ width: z.number().positive(), @@ -482,6 +483,12 @@ export const annotationRegionSchema = endGteStart( }), "endMs", "startMs", +).refine( + // The compositor draws an arrow in the middle of its square box, so an arrow drawn against the + // frame's left or top edge has a box that starts before the frame (`annotations/arrowBounds.ts`). + // Its strokes stay inside. Every other box starts inside the frame. + (region) => region.type === "figure" || (region.position.x >= 0 && region.position.y >= 0), + { message: "position must be at least 0", path: ["position"] }, ); export const zoomRegionSchema = endGteStart(