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/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( 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); }