diff --git a/crates/compositor/src/frame_geometry.rs b/crates/compositor/src/frame_geometry.rs index 2988aabc2..f7affa9dc 100644 --- a/crates/compositor/src/frame_geometry.rs +++ b/crates/compositor/src/frame_geometry.rs @@ -1932,6 +1932,13 @@ pub struct FrameGeometry { pub padding_scale: f32, /// Coupe source de l'écran en UV texture (crop utilisateur + zoom). pub cut: [f32; 4], + /// La coupe que montrerait le même écran à plat : le repère du métrage (annotations, effets, + /// gimbal de l'éditeur). Égale à `cut`, sauf sous le remplissage incliné, où `cut` n'est plus + /// que la fenêtre zoomée qu'en montre la carte (`content_to_plane`). + pub content_cut: [f32; 4], + /// Remplissage du format sous un plan incliné : la carte penche dans son slot, fixe, et seul + /// le métrage bouge dedans. Son flou de mouvement reste donc par pixel (`screen_trail`). + pub fill_tilt: bool, /// Point de focus du zoom (résolu : suivi curseur et rampe compris) en 0..1 DANS la coupe /// dessinée `cut`, c'est-à-dire dans le plan que le mode 8 incline. Borné au plan : un focus /// que le cover a rogné retombe sur le bord. C'est lui qui fixe `z_focus` (`depth_mb`). @@ -2177,10 +2184,11 @@ impl FrameGeometry { /// Incliné, le plan a bougé quand ses coins ont bougé : sans mouvement, ceux de `tilt_trail` /// sont les siens au bit près, et le rendu reste celui d'avant, à l'octet. /// - /// Pas sous un masque de bloc : la case ne bouge pas, seul le métrage bouge dedans, et le - /// flou par pixel (modes 0 et 8) reste le bon. + /// Pas sous un masque de bloc ni sous le remplissage incliné : la case ou la carte ne bouge + /// pas, seul le métrage bouge dedans, et le flou par pixel (modes 0 et 8) reste le bon. pub fn screen_trail(&self, render_px: [f32; 2]) -> bool { - if self.mb_taps < 2.0 || self.mb_amount <= 0.001 || self.screen_mask.is_some() { + if self.mb_taps < 2.0 || self.mb_amount <= 0.001 || self.screen_mask.is_some() || self.fill_tilt + { return false; } match self.screen_tilt_in(render_px) { @@ -2760,14 +2768,31 @@ impl FrameGeometry { (self.s_dst[0] + self.s_dst[2] * 0.5) * rw, (self.s_dst[1] + self.s_dst[3] * 0.5) * rh, ]; + // Sous le remplissage incliné, le plan ne montre que la fenêtre zoomée (`cut`) d'un + // métrage dont l'annotation donne les fractions (`content_cut`) : le grossissement et la + // boîte sont ceux du même écran à plat. Ailleurs, l'échelle vaut 1 au bit près. + let scale = self.content_scale(); + let zoom_k = zoom_k * scale[1]; + let content_px = [s_px[0] * scale[0], s_px[1] * scale[1]]; // La marge se prend en fraction du contenu, comme sur le chemin droit : `zoom_k` px de // la boîte zoomée, soit un pixel de la boîte au repos. La projection l'emmène ensuite // avec le contenu qu'elle borde. let (mx, my) = ( - PRIVACY_MASK_PAD_PX * zoom_k / s_px[0].max(1.0), - PRIVACY_MASK_PAD_PX * zoom_k / s_px[1].max(1.0), + PRIVACY_MASK_PAD_PX * zoom_k / content_px[0].max(1.0), + PRIVACY_MASK_PAD_PX * zoom_k / content_px[1].max(1.0), ); - let (x0, y0, x1, y1) = (x - mx, y - my, x + w + mx, y + h + my); + let [x0, y0] = self.content_to_plane([x - mx, y - my]); + let [x1, y1] = self.content_to_plane([x + w + mx, y + h + my]); + // Rogné à la carte du remplissage : ce qu'elle ne montre pas n'a rien à cacher, et un + // masque hors d'elle flouterait le fond. + let (x0, y0, x1, y1) = if self.fill_tilt { + (x0.max(0.0), y0.max(0.0), x1.min(1.0), y1.min(1.0)) + } else { + (x0, y0, x1, y1) + }; + if x1 <= x0 || y1 <= y0 { + return None; + } let at = |fx: f32, fy: f32| { let (px, py) = quad.point_px(fx, fy); [centre[0] + px, centre[1] + py] @@ -2845,7 +2870,10 @@ impl FrameGeometry { 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); + // Les coins du métrage entier (`content_cut`), au-delà de la carte sous le + // remplissage incliné : le warp prolongé est exact, bilinéaire comme projectif. let at = |fx: f32, fy: f32| { + let [fx, fy] = self.content_to_plane([fx, fy]); let (px, py) = quad.point_px(fx, fy); [(centre[0] + px) / rw, (centre[1] + py) / rh] }; @@ -2860,7 +2888,29 @@ impl FrameGeometry { /// Largeur sur hauteur du métrage dans son propre plan : celles du rect qu'incline le tilt. pub fn footage_aspect(&self, render_px: [f32; 2]) -> f32 { - self.s_dst[2] * render_px[0] / (self.s_dst[3] * render_px[1]).max(1e-6) + let scale = self.content_scale(); + self.s_dst[2] * render_px[0] * scale[0] / (self.s_dst[3] * render_px[1] * scale[1]).max(1e-6) + } + + /// Un point du métrage, en fractions de `content_cut` (le repère des annotations, des effets + /// et du gimbal), en fractions du plan dessiné (`cut`). L'identité, au bit près, hors du + /// remplissage incliné. + fn content_to_plane(&self, f: [f32; 2]) -> [f32; 2] { + if self.content_cut == self.cut { + return f; + } + let (c, k) = (self.content_cut, self.cut); + let map = |f: f32, a0: f32, a1: f32, b0: f32, b1: f32| (a0 + f * (a1 - a0) - b0) / (b1 - b0).max(1e-6); + [map(f[0], c[0], c[2], k[0], k[2]), map(f[1], c[1], c[3], k[1], k[3])] + } + + /// Combien de fois le métrage dépasse le plan dessiné, par axe : 1 hors du remplissage incliné. + fn content_scale(&self) -> [f32; 2] { + if self.content_cut == self.cut { + return [1.0; 2]; + } + let (c, k) = (self.content_cut, self.cut); + [(c[2] - c[0]) / (k[2] - k[0]).max(1e-6), (c[3] - c[1]) / (k[3] - k[1]).max(1e-6)] } } @@ -3315,17 +3365,28 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // ce correctif ; la coupe DESSINÉE ne porte plus que le crop. `remap_box` reporte la // seconde à travers le mapping de la première, ce qui conserve le cadrage exact. // Le focus courant reste volontairement utilisé pour la frame précédente, comme avant. + // + // Sous un plan incliné (angle fixe ou caméra réelle), le remplissage penche la CARTE : la + // boîte est le slot, la coupe la fenêtre zoomée qu'il montre, et rien ne la rogne. Le slot + // n'y est pas un conteneur qu'on voit, comme dans un bloc : rogner à son rect droit le plan + // de l'enregistrement entier y coupait des coins à plat. Sous la caméra réelle, c'est sa + // visée qui cadre le zoom (`regions::resolve_focus` laisse la boîte zoomer sur son centre). + let fill_tilt = + follow && (zoom_camera > 0.0 || !crate::regions::is_identity_rotation(zoom_rotation)); + let focus = if fill_tilt && zoom_camera > 0.0 { zoom_aim } else { p.focus }; let cut_ref = - cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus), follow_at(source_t)); + cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, focus), follow_at(source_t)); let cut_ref_prev = cover( - screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus), + screen_source_rect(u_max, v_max, active_crop, pp.zoom, focus), follow_at(source_t_prev), ); // Sous le remplissage, la coupe dessinée est le crop ENTIER : la fenêtre bouge, et une // coupe dessinée qui ne la contiendrait plus laisserait un trou dans le slot. Le masque du // slot (`screen_mask`) rogne le reste. let crop_cut = screen_source_rect(u_max, v_max, active_crop, 1.0, p.focus); - let cut = if follow { crop_cut } else { cover(crop_cut, None) }; + let flat_cut = if follow { crop_cut } else { cover(crop_cut, None) }; + // Le remplissage incliné dessine la fenêtre zoomée elle-même : sa boîte est le slot. + let cut = if fill_tilt { cut_ref } else { flat_cut }; // Ce que l'écran montre au repos : la fenêtre couverte, suivie ou non. Le curseur, les coins // et l'ombre se mesurent sur elle, pas sur la coupe entière que le remplissage dessine. let window = cover(crop_cut, follow_at(source_t)); @@ -3356,24 +3417,38 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // même report dans la coupe (un cover la rogne). La mise au point suit le pointeur lissé // (l'orbite), pas le point visé : celui-ci reste au centre au zoom 1 et bute sur sa portée // au zoom, là où le spectateur regarde le pointeur. - let camera = (zoom_camera > 0.0).then(|| crate::camera::CameraPose { - weight: zoom_camera, - aim: focus_in_cut(u_max, v_max, active_crop, zoom_aim, cut), - orbit: focus_in_cut(u_max, v_max, active_crop, zoom_orbit, cut), - zoom: p.zoom, - press, - }); + // Sous le remplissage incliné, la coupe est déjà la fenêtre zoomée : la caméra filme la + // carte au zoom 1, entière et contenue dans son slot depuis toute l'orbite + // (`camera::View::new`), et l'œil se place sur le pointeur dans l'enregistrement entier, + // où vit le poids de l'orbite (rapportée à la fenêtre, elle sauterait à l'entrée du zoom). + let pose = |weight: f32, aim: [f32; 2], orbit: [f32; 2], zoom: f32| { + if fill_tilt { + crate::camera::CameraPose { + weight, + aim: [0.5; 2], + orbit: focus_in_cut(u_max, v_max, active_crop, orbit, crop_cut), + zoom: 1.0, + press, + } + } else { + crate::camera::CameraPose { + weight, + aim: focus_in_cut(u_max, v_max, active_crop, aim, cut), + orbit: focus_in_cut(u_max, v_max, active_crop, orbit, cut), + zoom, + press, + } + } + }; + let camera = (zoom_camera > 0.0).then(|| pose(zoom_camera, zoom_aim, zoom_orbit, p.zoom)); // La caméra réelle de la frame d'avant, dès qu'elle y était active — y compris quand elle // ne l'est plus à celle-ci (fin de sa moitié d'une transition vers un angle fixe) ; `None` // quand elle ne l'était pas, et le mode 8 reprend alors la rotation d'avant. - let camera_prev = (zoom_camera_prev > 0.0).then(|| crate::camera::CameraPose { - weight: zoom_camera_prev, - aim: focus_in_cut(u_max, v_max, active_crop, zoom_aim_prev, cut), - orbit: focus_in_cut(u_max, v_max, active_crop, zoom_orbit_prev, cut), - zoom: pp.zoom, - press, - }); + let camera_prev = (zoom_camera_prev > 0.0) + .then(|| pose(zoom_camera_prev, zoom_aim_prev, zoom_orbit_prev, pp.zoom)); let focus_plane = match camera { + // Le pointeur sur la carte, là où l'orbite du remplissage n'est pas dans la coupe. + Some(_) if fill_tilt => focus_in_cut(u_max, v_max, active_crop, zoom_orbit, cut), Some(pose) => pose.orbit, None => focus_in_cut(u_max, v_max, active_crop, p.focus, cut), }; @@ -3490,7 +3565,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { // l'app le lève pour ces deux layouts, sur un clip qui a une caméra, et pour le // remplissage du format, qui exclut les cadres (`formatFillAvailability`). Un cadre sous // un slot est donc toujours dans un bloc : il y devient le conteneur, au repos autour. - let screen_mask = scene.is_some_and(|s| s.layout.screen_cover).then(|| { + // Le remplissage incliné n'a pas de masque : sa boîte est le slot, et la carte penche entière. + let screen_mask = (scene.is_some_and(|s| s.layout.screen_cover) && !fill_tilt).then(|| { let slot_px = [s_base[2] * rw, s_base[3] * rh]; ScreenMask { rect: s_base, @@ -3560,6 +3636,8 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry { camera_prev, padding_scale, cut, + content_cut: flat_cut, + fill_tilt, focus_plane, // Sans scène (bench fixture), aucune zoom region, donc aucun tilt : rien à défocaliser. depth_of_field: scene.is_some_and(|s| s.effects.depth_of_field), @@ -6348,6 +6426,20 @@ mod tests { /// Un clip 16:9 dans une sortie 9:16 remplie (`screenCover` + `screenFollow`) : la boîte écran /// est la zone paddée entière, `follow` décide si la fenêtre suit le pointeur. fn fill_plan(follow: bool, pointer: Option<(f32, f32)>) -> FrameGeometry { + fill_plan_with(follow, pointer, "[]") + } + + /// `fill_plan`, avec ces `zoomRegions`. + fn fill_plan_with(follow: bool, pointer: Option<(f32, f32)>, zoom_regions: &str) -> FrameGeometry { + fill_plan_track(follow, pointer.map(|(x, y)| parked_track(x, y)), zoom_regions) + } + + /// `fill_plan_with`, sur cette piste curseur. + fn fill_plan_track( + follow: bool, + track: Option<&'static crate::cursor::CursorTrack>, + zoom_regions: &str, + ) -> FrameGeometry { let scene = Scene::from_json(&format!( r##"{{ "clips":[{{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}}], @@ -6356,8 +6448,8 @@ mod tests { "screenCover":true,"screenFollow":{follow}}}, "effects":{{"padding":0.5,"blur":false,"shadow":0.5,"roundnessFrac":0.02,"motionBlur":0}}, "background":{{"kind":"color","color":"#1e1e2e"}}, - "zoomRegions":[], - "cursor":{{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":1,"clipToBounds":false,"theme":"default"}}, + "zoomRegions":{zoom_regions}, + "cursor":{{"show":true,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":1,"clipToBounds":false,"theme":"default"}}, "cropByClip":[null], "output":{{"width":1080,"height":1920,"fps":60}} }}"## @@ -6366,7 +6458,7 @@ mod tests { let cfg = crate::config::all().pop().expect("au moins une config"); let mut input = golden_input(&scene, &cfg); input.render_px = [1080.0, 1920.0]; - input.cursor = pointer.map(|(x, y)| parked_track(x, y)); + input.cursor = track; plan_frame(&input) } @@ -6408,6 +6500,133 @@ mod tests { } } + /// Le remplissage sous un plan incliné, angle fixe ou caméra en orbite : c'est la CARTE qui + /// penche, entière, ses quatre coins arrondis. Son slot n'est pas un conteneur qu'on voit, + /// comme celui d'un bloc : y rogner le plan de l'enregistrement entier coupait des coins à plat + /// (v2.0.0 à v2.1.0). + /// + /// * aucun masque ne rogne le plan, et sa boîte est le slot ; + /// * la carte montre ce qu'un zoom à plat y montre : la même fenêtre zoomée ; + /// * le plan tient dans la sortie, centré sur le slot ; + /// * à plat, rien ne change : le slot masque toujours la boîte zoomée. + #[test] + fn a_tilted_zoom_tilts_the_whole_filled_card() { + let render = [1080.0f32, 1920.0]; + let zoom = |rotation: &str| { + format!(r#"[{{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.3,"focusY":0.6,"rotation":{rotation}}}]"#) + }; + let pointer = Some((0.85, 0.8)); + // La fenêtre que la carte montre, en UV : le slot reporté par s_dst → cut. + let seen = |g: &FrameGeometry| -> [f32; 4] { + let (s, d, c) = (g.s_ann, g.s_dst, g.cut); + let x = |f: f32| c[0] + (f - d[0]) / d[2] * (c[2] - c[0]); + let y = |f: f32| c[1] + (f - d[1]) / d[3] * (c[3] - c[1]); + [x(s[0]), y(s[1]), x(s[0] + s[2]), y(s[1] + s[3])] + }; + for (rotation, flat_rotation) in [ + (r#""iso""#, "null"), + (r#""right""#, "null"), + (ORBIT, r#"null,"focusMode":"auto""#), + ] { + let flat = fill_plan_with(true, pointer, &zoom(flat_rotation)); + assert!(!flat.tilted(), "{rotation} : garde, le zoom à plat est droit"); + assert!(flat.screen_mask.is_some(), "{rotation} : à plat, le slot masque la boîte zoomée"); + assert!(!contains(flat.s_ann, flat.s_dst, 1e-3), "{rotation} : garde, la boîte à plat déborde"); + + let g = fill_plan_with(true, pointer, &zoom(rotation)); + assert!(g.tilted(), "{rotation} : garde, l'écran doit pencher"); + assert_eq!(g.screen_mask, None, "{rotation} : un rect droit rogne la carte inclinée"); + for k in 0..4 { + assert!((g.s_dst[k] - g.s_ann[k]).abs() < 1e-5, "{rotation} : boîte {:?}, slot {:?}", g.s_dst, g.s_ann); + } + let (w, wf) = (seen(&g), seen(&flat)); + for k in 0..4 { + assert!((w[k] - wf[k]).abs() < 1e-4, "{rotation} : fenêtre {w:?}, à plat {wf:?}"); + } + + let s_px = [g.s_dst[2] * render[0], g.s_dst[3] * render[1]]; + let quad = g.screen_tilt(s_px).expect("incliné"); + let c = g.screen_center_px(render); + let slot_c = [(g.s_ann[0] + g.s_ann[2] * 0.5) * render[0], (g.s_ann[1] + g.s_ann[3] * 0.5) * render[1]]; + let mut mean = [0.0f32; 2]; + for &(x, y) in &quad.corners { + let (x, y) = (c[0] + x, c[1] + y); + assert!((0.0..=render[0]).contains(&x) && (0.0..=render[1]).contains(&y), "{rotation} : coin ({x}, {y}) hors de la sortie"); + mean = [mean[0] + x * 0.25, mean[1] + y * 0.25]; + } + let off = (mean[0] - slot_c[0]).hypot(mean[1] - slot_c[1]); + assert!(off < 0.1 * s_px[0].min(s_px[1]), "{rotation} : carte à {off} px du slot"); + + let cb = tilted_screen_cb(&quad, s_px, c, g.cut, g.focus_plane, g.s_radius, 0.0, false, render, g.screen_mask, None); + assert_eq!(cb.color[3], 0.0, "{rotation} : aucun slot ne rogne le plan"); + assert!(cb.radius_px > 1.0, "{rotation} : coins arrondis attendus, rayon {}", cb.radius_px); + } + } + + /// Sous le remplissage incliné, la carte ne montre que la fenêtre zoomée : un flou posé sur + /// le métrage (ses fractions sont celles de l'enregistrement entier) couvre toujours le + /// contenu qu'il cache, là où le curseur, passé par la coupe, le voit. Le gimbal de l'éditeur + /// (`footage_quad`) pose ce point au même endroit, et l'aspect est celui de l'enregistrement. + #[test] + fn a_privacy_mask_covers_its_content_on_a_tilted_filled_card() { + let render = [1080.0f32, 1920.0]; + let p = (0.85f32, 0.8f32); + let uv_max = [1.0, 1080.0 / 1088.0]; + for rotation in [r#""iso""#, ORBIT] { + let zoom = format!(r#"[{{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.85,"focusY":0.8,"rotation":{rotation}}}]"#); + let g = fill_plan_with(true, Some(p), &zoom); + assert!(g.fill_tilt, "{rotation} : garde, remplissage incliné"); + let s_px = [g.s_dst[2] * render[0], g.s_dst[3] * render[1]]; + let quad = g.screen_tilt(s_px).expect("incliné"); + let c = g.screen_center_px(render); + // Où le contenu en `p` est dessiné, par la coupe comme le curseur. + let plane = cursor_plane_point(g.cut, uv_max, p).expect("garde : le point est sur la carte"); + let (sx, sy) = quad.point_px(plane[0], plane[1]); + let seen = [c[0] + sx, c[1] + sy]; + + let mut a = blur_annotation(""); + (a.w, a.h) = (0.04, 0.04); + (a.x, a.y) = (p.0 - 0.02, p.1 - 0.02); + let m = g.privacy_mask(&a, render).expect("masque"); + let pts = m.warp.expect("incliné").map(|[x, y]| [m.dst[0] * render[0] + x, m.dst[1] * render[1] + y]); + let cross = |a: [f32; 2], b: [f32; 2], q: [f32; 2]| (b[0] - a[0]) * (q[1] - a[1]) - (b[1] - a[1]) * (q[0] - a[0]); + let sides: Vec = (0..4).map(|i| cross(pts[i], pts[(i + 1) % 4], seen)).collect(); + assert!( + sides.iter().all(|&s| s >= 0.0) || sides.iter().all(|&s| s <= 0.0), + "{rotation} : le masque {pts:?} rate le contenu, dessiné en {seen:?}" + ); + // Le grain grandit avec le contenu, comme à plat. + assert!(m.strength > 1.5, "{rotation} : force {}", m.strength); + + // Le gimbal : le métrage entier, que la carte ne montre qu'en partie. + let f = g.footage_quad(render); + assert!(f.corners.iter().any(|q| q[0] < 0.0 || q[0] > 1.0 || q[1] < 0.0 || q[1] > 1.0), "{rotation} : gimbal réduit à la carte, {:?}", f.corners); + let ar = g.footage_aspect(render); + assert!((ar - 1920.0 / 1080.0).abs() < 1e-2, "{rotation} : aspect {ar}"); + } + } + + /// Le flou de mouvement de la carte inclinée du remplissage reste par pixel : la carte est + /// fixe dans son slot, seul le métrage bouge dedans. Le flou rigide (mode 18) l'étalait le + /// long du chemin du contenu, bords et coins compris. + #[test] + fn a_tilted_filled_card_keeps_a_per_pixel_motion_blur() { + let moving: &'static crate::cursor::CursorTrack = Box::leak(Box::new(crate::cursor::CursorTrack::new( + (0..=150).map(|i| (i as f32 / 30.0, 0.2 + 0.6 * i as f32 / 150.0, 0.5)).collect(), + vec![], + vec![], + ))); + let render = [1080.0f32, 1920.0]; + let zoom = format!(r#"[{{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.5,"focusY":0.5,"rotation":{ORBIT}}}]"#); + // Le pointeur en mouvement : la fenêtre suivie glisse d'une frame à l'autre. + let mut g = fill_plan_track(true, Some(moving), &zoom); + (g.mb_taps, g.mb_amount) = (8.0, 0.5); + assert!(g.fill_tilt, "garde : remplissage incliné"); + assert_ne!(g.s_dst_prev, g.s_dst, "garde : le métrage bouge dans la carte"); + assert!(!g.screen_trail(render), "flou rigide sur une carte fixe"); + assert!(g.tilt_pixel_trail(render).is_some(), "le flou par pixel suit le métrage"); + } + /// `slot_scene` à 1,5 s (zoom tenu), pointeur garé en `pointer` (la caméra en orbite le suit). fn slot_plan(scene: &Scene, pointer: Option<(f32, f32)>) -> FrameGeometry { let cfg = crate::config::all().pop().expect("au moins une config"); @@ -7934,6 +8153,8 @@ mod tests { camera_prev: None, padding_scale: 1.0, cut: [0.0, 0.0, 1.0, 1.0], + content_cut: [0.0, 0.0, 1.0, 1.0], + fill_tilt: false, focus_plane: [0.5, 0.5], depth_of_field: false, s_dst: [0.0, 0.0, 1.0, 1.0], diff --git a/crates/compositor/tests/format_fill_tilt_render.rs b/crates/compositor/tests/format_fill_tilt_render.rs new file mode 100644 index 000000000..2adb8b15d --- /dev/null +++ b/crates/compositor/tests/format_fill_tilt_render.rs @@ -0,0 +1,142 @@ +//! Le remplissage du format sous la caméra en orbite, rendu par le vrai compositeur D3D11. +//! +//! Un enregistrement 4:3 remplit une sortie 16:9 (`screenCover` + `screenFollow`), zoom ×2 en +//! orbite, pointeur garé en bas à droite : la caméra tourne à fond. La carte doit pencher ENTIÈRE, +//! ses quatre coins arrondis. De v2.0.0 à v2.1.0, le plan de l'enregistrement entier penchait +//! dans le rect droit du slot, qui le rognait : des coins coupés à plat, par une arête alignée sur +//! les axes, alors que la carte tenait dans la sortie. +//! +//! Frame NV12 synthétique et unie, fond noir, sans ombre : le métrage est clair, tout le reste +//! noir. Sans adaptateur matériel, le test se saute. + +// Windows seulement : le readback et le décodage D3D11VA de ce harnais n'existent que là. +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config::Cfg; +use openscreen_compositor::cursor::CursorTrack; +use openscreen_compositor::frame_geometry::{live_params_from_scene, plan_frame, FrameGeometryInput}; +use openscreen_compositor::scene::Scene; + +mod common; +use common::{gpu, Nv12Frame}; + +const SRC: (u32, u32) = (640, 480); +const OUT: (u32, u32) = (1280, 720); +/// En plein palier de la région (0..10 s), le cadreur posé. +const T: f32 = 3.0; + +fn scene() -> Scene { + Scene::from_json( + r##"{"clips":[{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}], + "layout":{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false, + "screenRect":{"x":0.1,"y":0.1,"width":0.8,"height":0.8},"screenCover":true,"screenFollow":true}, + "effects":{"padding":0.2,"blur":false,"shadow":0,"roundnessFrac":0.04,"motionBlur":0,"depthOfField":false}, + "background":{"kind":"color","color":"#000000"}, + "zoomRegions":[{"clipIndex":0,"startSec":0,"endSec":10,"scale":2.0,"focusX":0.5,"focusY":0.5,"focusMode":"auto","rotation":"orbit"}], + "annotations":[], + "cursor":{"show":true,"size":0.05,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"none"}, + "cropByClip":[null], + "output":{"width":1280,"height":720,"fps":30}}"##, + ) + .expect("scène valide") +} + +/// Pointeur garé en (x, y), échantillonné à 60 Hz sur 10 s, écrit dans un sidecar temporaire. +fn parked(x: f32, y: f32) -> CursorTrack { + let body: Vec = + (0..=600).map(|i| format!(r#"{{"timeMs":{},"cx":{x},"cy":{y}}}"#, (i as f32 * 1000.0 / 60.0).round())).collect(); + let path = std::env::temp_dir().join(format!("os_fill_tilt_{}.json", std::process::id())); + std::fs::write(&path, format!(r#"{{"samples":[{}]}}"#, body.join(","))).expect("sidecar"); + let track = CursorTrack::load(path.to_str().unwrap(), 0.0, 10.0).expect("piste curseur"); + let _ = std::fs::remove_file(&path); + track +} + +fn cfg() -> Cfg { + let mut cfg = Cfg::c8(); + cfg.bg_blur = 0.0; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = true; + cfg.mblur_n = 1; + cfg.shadow = false; + cfg +} + +/// Hors de la sortie, rien n'est dessiné : noir, comme le fond. +fn luma(rgba: &[u8], x: f32, y: f32) -> f32 { + let (x, y) = (x.round() as i32, y.round() as i32); + if !(0..OUT.0 as i32).contains(&x) || !(0..OUT.1 as i32).contains(&y) { + return 0.0; + } + let i = ((y * OUT.0 as i32 + x) * 4) as usize; + 0.2126 * rgba[i] as f32 + 0.7152 * rgba[i + 1] as f32 + 0.0722 * rgba[i + 2] as f32 +} + +/// Chaque coin de la carte inclinée est arrondi : sur la bissectrice, à 0,15 rayon du sommet +/// projeté, le fond (l'arrondi l'a retiré) ; à 1,3 rayon, le métrage. Un coin coupé par le rect +/// du slot laissait du noir là où la carte devait être, ou la poussait hors de la sortie. +#[test] +fn every_corner_of_a_filled_card_stays_rounded_under_the_orbit() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, OUT.0, OUT.1).expect("compositor"); + let screen = Nv12Frame::new(&gpu, SRC, |_, _| 220, |_, _| [128, 128]); + let scene = scene(); + let track = parked(0.9, 0.9); + + let mut live = live_params_from_scene(&scene); + live.has_webcam = false; + comp.set_live_params(live); + comp.set_has_webcam(false); + comp.set_scene(Some(scene.clone())); + comp.set_cursor(track.clone()); + comp.set_cursor_time(Some(T)); + comp.set_timeline_time(Some(T)); + let cfg = cfg(); + let (w, h, rgba) = unsafe { + comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback") + }; + assert_eq!((w, h), OUT); + if let Ok(dir) = std::env::var("OPENSCREEN_FILL_TILT_OUT") { + image::RgbaImage::from_raw(w, h, rgba.clone()).unwrap().save(format!("{dir}/fill-orbit.png")).unwrap(); + } + + let render_px = [w as f32, h as f32]; + let src = [SRC.0 as f32, SRC.1 as f32]; + let g = plan_frame(&FrameGeometryInput { + render_px, + screen_tex_px: src, + screen_visible_px: src, + webcam_visible_px: src, + u_max: 1.0, + v_max: 1.0, + frame: 0.0, + cfg: &cfg, + live, + scene: Some(&scene), + cursor: Some(&track), + timeline_t_override: Some(T), + programme_time: None, + }); + let camera = g.camera.expect("la caméra réelle doit être posée"); + assert!(camera.orbit[0] > 0.8, "garde : la caméra doit tourner à fond, orbite {:?}", camera.orbit); + let s_px = [g.s_dst[2] * render_px[0], g.s_dst[3] * render_px[1]]; + let quad = g.screen_tilt(s_px).expect("quad de la caméra"); + let center = [(g.s_dst[0] + g.s_dst[2] * 0.5) * render_px[0], (g.s_dst[1] + g.s_dst[3] * 0.5) * render_px[1]]; + let r = g.s_radius * quad.scale; + assert!(r > 20.0, "garde : rayon {r} px, trop petit pour se mesurer"); + + let corners = quad.corners.map(|(x, y)| [center[0] + x, center[1] + y]); + let mid = [corners.iter().map(|c| c[0]).sum::() / 4.0, corners.iter().map(|c| c[1]).sum::() / 4.0]; + for (name, c) in ["haut-gauche", "haut-droit", "bas-droit", "bas-gauche"].iter().zip(corners) { + let (dx, dy) = (mid[0] - c[0], mid[1] - c[1]); + let n = dx.hypot(dy); + let at = |k: f32| luma(&rgba, c[0] + dx / n * k * r, c[1] + dy / n * k * r); + let (rounded, inside) = (at(0.15), at(1.3)); + println!("coin {name} ({:.0}, {:.0}) : arrondi {rounded:.0}, dedans {inside:.0}", c[0], c[1]); + assert!(rounded < 40.0, "coin {name} : pas arrondi (luma {rounded:.0} au sommet)"); + assert!(inside > 150.0, "coin {name} : la carte est coupée (luma {inside:.0} à 1,3 rayon)"); + } +} diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index d59ed495d..545325051 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -989,3 +989,6 @@ These checks are the human slice: an agent cannot click Apple's picker (see abov | 2026-10-17 | `v2.1.0-rc.6`, tag @ `36b3814`, installer `Openscreen.Setup.2.1.0-rc.6-x64.exe` from the GitHub release (build run 39010637863). sha512 matched `latest.yml`; installed silently over rc.5. The uninstall entry and the MCP server report `2.1.0-rc.6`. | Windows 11 Home 26200 (x64), Ryzen 7 5800X, RTX 4070 Ti, 1920×1080 at 279 Hz; Logitech StreamCam | **Partial — minimal pass: smoke test and the fixes since rc.4. All pass, no critical defect** | Real OS input through computer-use. Export checks also ran from an SDK MCP client over stdio. **Smoke:** - The HUD appeared; a left click on the tray icon brought the minimized editor back. The silent install landed in `C:\Program Files\Openscreen`, where an rc.6 per-machine install already existed, rather than the per-user folder; the per-user rc.5 was removed. - A 44 s take with webcam, microphone and system audio, with clicks in an Explorer window (`wgc-capture.exe` granted mid-take). Stop opened the editor with the take, the webcam and one auto zoom. The take's audio peaks at −8 dB. - Playback moved; on a 60 fps capture of the preview, no step back. - Export from the dialog, MP4 1080p60: 1752×1080, 44.35 s, H.264 and AAC, decoded without error, audio peaking at −1.7 dB. - After quitting, reopening from the HUD showed the project intact: take, webcam, auto zoom, changed wallpaper, rewound zoom absent. **Fixes:** - Full Camera trail (#1294, #1304): the rc.5 project re-exported with the same cuts at 1080p60. The frame that trailed on rc.5 (first frame after the trim ending at source 46.34986635) is sharp, and no one-frame outlier is left in the export. Frames before it are identical to the rc.5 export. - Pause on a held frame (#1296): paused at 0:32.1 on a still passage, then changed the wallpaper. The playhead, the cursor and the webcam did not move. - Chat rewind (#1292): "Add a zoom from 2 s to 4 s" added a 1.8× zoom. Rewinding that message showed "Rewound to the start of that message": the zoom was removed and the text returned to the composer. No "Message not found" in the UI or the logs. **Not run:** - smoothness at 60 Hz (rc.5 row, #1301); - the rest of the checklist. | | 2026-10-17 | `v2.1.0-rc.6`, `release/v2.1.0` @ `36b3814`, DMG `Openscreen-macOS-Apple-Silicon-2.1.0-rc.6.dmg` from the GitHub pre-release (build run 39010637863): 140 603 025 bytes, sha256 matches the release asset's digest; `spctl` accepted, Notarized Developer ID (M4LK7C6S84); `stapler validate` OK; `CFBundleShortVersionString` 2.1.0-rc.6. Installed over rc.5 | macOS 27.0.1 (26A434), Apple M1 (arm64), 8 GB, one 1920×1080 display at 120 Hz (1×); OBS Virtual Camera (no physical camera), BlackHole 2ch | **Partial — minimal pass toward stable: no critical bug; all 8 checked items pass** | Real OS input through computer-use, launched with `OPENSCREEN_MAC_SOURCE_PICKER=legacy`, `OPENSCREEN_DIAGNOSTIC=1` and `--remote-debugging-port` (read only: the `scripts/perf-ab/probe.js` paint probe, the playhead text and the editor's media elements). Measured with the app's bundled `ffmpeg`. Nobody else used the machine during the measures. **Smoke:** 1. The HUD appears. The tray icon (the green one in the menu bar; the aperture icon beside it is OBS's) opens Open / Check for Updates / Update Settings / Send Anonymous Usage Count / About / Save Diagnostics / Permissions… / Quit. *Open* brings back the HUD after *Hide recording bar*. 2. HUD take, 34 s, OBS webcam and microphone, six clicks on a 60 fps test pattern: the editor opens on it with the webcam PiP and two automatic zooms (1.25×, 1.80×). The take is 1920×1080 H.264 at about 56 fps, plus AAC 48 kHz stereo (mean −21.4 dB, real signal). 3. Playback: the preview moves. Probe 28.3–29.0 paints/s, p50 33.4 ms, p95 36.6. The preview's `