diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor.rs index 11fa7b500..7c7754c86 100644 --- a/crates/compositor/src/compositor.rs +++ b/crates/compositor/src/compositor.rs @@ -190,6 +190,44 @@ fn screen_source_rect( [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv] } +/// Rect DESTINATION de l'écran quand on dessine une coupe source PLUS LARGE que celle qui +/// remplissait la boîte — le cœur du correctif #179. +/// +/// Le zoom natif se jouait entièrement dans la coupe source (`screen_source_rect` rétrécit +/// la coupe autour du focus) pendant que la boîte, elle, ne bougeait pas : le zoom +/// s'arrêtait donc à la frontière paddée au lieu d'atteindre les bords du cadre. La +/// référence fait l'inverse — `applyZoomTransform` (TS) met à l'échelle et translate le +/// CONTENEUR CAMÉRA, masque compris, donc la boîte paddée grandit avec le zoom, sort de +/// l'étage, et le padding s'efface. +/// +/// On rend donc le zoom à la boîte : la coupe dessinée redevient le simple crop +/// (`cut`, zoom 1) et c'est la boîte qui porte le grossissement. `cut_ref` est la coupe +/// d'AVANT (zoom entier, celle qui remplissait `base`) et sert de référence : on reporte +/// `cut` à travers le mapping `cut_ref → base`. +/// +/// C'est ce report qui fait toute la sûreté du correctif. Le mapping image→écran est +/// conservé PAR CONSTRUCTION — même grossissement, même cadrage, même point de focus au +/// même pixel — quel que soit le crop, le clamp de bord ou le `cover`, puisque tout cela +/// est déjà cuit dans les deux coupes. Seule l'ÉTENDUE dessinée grandit, et c'est +/// exactement elle qui déborde le padding. Tout ce qui roule sur ce mapping (curseur, +/// tilt 3D, motion blur) est donc inchangé. +/// +/// Pas de clamp dans le cadre : la boîte doit pouvoir en sortir (« No stage clamping », +/// `frameRenderer.cameraAwareMaskRect`) — le rasterizer coupe ce qui dépasse, comme il le +/// fait déjà pour le fond flouté. +fn remap_box(base: [f32; 4], cut_ref: [f32; 4], cut: [f32; 4]) -> [f32; 4] { + let (rw, rh) = ((cut_ref[2] - cut_ref[0]), (cut_ref[3] - cut_ref[1])); + if !(rw > 1e-6 && rh > 1e-6) { + return base; + } + [ + base[0] + base[2] * (cut[0] - cut_ref[0]) / rw, + base[1] + base[3] * (cut[1] - cut_ref[1]) / rh, + base[2] * (cut[2] - cut[0]) / rw, + base[3] * (cut[3] - cut[1]) / rh, + ] +} + /// Sous-rect SOURCE (en UV de texture) qui remplit une boîte de ratio `box_ar` **sans /// déformer** l'image : le plus grand rect centré ayant ce ratio, tiré de la frame /// visible — l'équivalent de `object-fit: cover` côté web. @@ -1842,8 +1880,44 @@ impl Compositor { fit_dst_to_aspect(scale_frame(dst, padding_scale), crop_aspect) } }; - let s_dst = fit_screen(p.screen.dst); - let s_dst_prev = fit_screen(pp.screen.dst); + // Issue #179 : le zoom se jouait entièrement dans la coupe source, donc la boîte + // écran restait au rect paddé et le zoom butait sur cette frontière au lieu + // d'atteindre les bords du cadre. On rend le zoom à la BOÎTE (cf. `remap_box`) : + // la coupe dessinée redevient le crop nu, la boîte porte le grossissement et + // déborde le padding — c'est la géométrie de `applyZoomTransform` (TS). + let s_base = fit_screen(p.screen.dst); + let s_base_prev = fit_screen(pp.screen.dst); + // Layouts "bloc" (side-by-side / top-bottom) : la boîte écran est un SLOT au ratio + // arbitraire, et le web y fait tenir l'image en `cover` (`computeCompositeLayout` + // renvoie `screenCover: true`, honoré par `frameRenderer`). Le natif l'ignorait, donc + // il étirait la source pour remplir le slot — visible dès que le clip est recadré, + // puisque le crop éloigne encore le ratio de la source de celui du slot. + // + // Le cover s'applique APRÈS le crop et le zoom, sur leur rect résultant : le crop + // décide quoi montrer, le zoom où regarder, le cover comment habiller la boîte. Son + // ratio de boîte se lit sur `s_base` : `remap_box` met les deux axes à la même + // échelle, donc la boîte finale a le même ratio et le cover ne dépend pas d'elle + // (ce qui casserait la circularité coupe → boîte → coupe). + let cover_box_ar = scene_ref.as_ref().and_then(|s| { + s.layout + .screen_cover + .then_some((s_base[2] * self.rw()) / (s_base[3] * self.rh()).max(0.0001)) + }); + let cover = |uv: [f32; 4]| -> [f32; 4] { + match cover_box_ar { + Some(ar) => cover_uv_rect(uv, [stw as f32, sth as f32], ar), + None => uv, + } + }; + // La coupe RÉFÉRENCE (zoom entier) est celle qui remplissait la boîte paddée avant + // 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. + let cut_ref = cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus)); + let cut_ref_prev = cover(screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus)); + let cut = cover(screen_source_rect(u_max, v_max, active_crop, 1.0, p.focus)); + let s_dst = remap_box(s_base, cut_ref, cut); + let s_dst_prev = remap_box(s_base_prev, cut_ref_prev, cut); // le padding n'affecte QUE l'écran (la quantité de fond révélée). La webcam reste ancrée // en bas-droite à sa marge fixe, quelle que soit la valeur de padding (pas de scale_frame) // — SAUF quand l'app a résolu un placement explicite (`app_webcam_rect`, drag-to-reposition @@ -1914,6 +1988,9 @@ impl Compositor { let s_min_px = (s_dst[2] * self.rw()).min(s_dst[3] * self.rh()); let app_screen_radius_frac = scene_ref.as_ref().and_then(|s| s.layout.screen_radius_frac); let scene_roundness_frac = scene_ref.as_ref().map(|s| s.effects.roundness_frac); + // Le rayon suit la boîte : quand le zoom l'agrandit (issue #179), les coins grandissent + // avec elle puis sortent du cadre — comme le masque de la référence, qui porte le même + // `br: maskBorderRadius * camS` et quitte l'étage au même moment. let s_radius = match (cfg.rounded, app_screen_radius_frac, scene_roundness_frac) { (false, _, _) => 0.0, // Preset en bloc : le rayon appartient à la boîte écran (parité exacte avec la caméra). @@ -2046,29 +2123,12 @@ impl Compositor { // `active_crop` déjà résolu plus haut (utilisé pour dimensionner `s_dst`) — une seule // source de vérité pour ce lookup. let s_px = [s_dst[2] * self.rw(), s_dst[3] * self.rh()]; - // Layouts "bloc" (side-by-side / top-bottom) : la boîte écran est un SLOT au ratio - // arbitraire, et le web y fait tenir l'image en `cover` (`computeCompositeLayout` - // renvoie `screenCover: true`, honoré par `frameRenderer`). Le natif l'ignorait, donc - // il étirait la source pour remplir le slot — visible dès que le clip est recadré, - // puisque le crop éloigne encore le ratio de la source de celui du slot. - // - // Le cover s'applique APRÈS le crop et le zoom, sur leur rect résultant : le crop - // décide quoi montrer, le zoom où regarder, le cover comment habiller la boîte. - let cover_box_ar = scene_ref - .as_ref() - .and_then(|s| s.layout.screen_cover.then_some(s_px[0] / s_px[1].max(0.0001))); - let cover = |uv: [f32; 4]| -> [f32; 4] { - match cover_box_ar { - Some(ar) => cover_uv_rect(uv, [stw as f32, sth as f32], ar), - None => uv, - } - }; - let [su0, sv0, su1, sv1] = - cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus)); + // Coupes calculées plus haut (elles dimensionnent `s_dst`) : le zoom vit désormais + // dans la boîte, la coupe ne porte que le crop. `dst_prev` porte la vélocité du + // motion blur — la coupe, elle, est la même aux deux frames. + let [su0, sv0, su1, sv1] = cut; let (hu, hv) = ((su1 - su0) * 0.5, (sv1 - sv0) * 0.5); - // Le focus courant reste volontairement utilisé pour la frame précédente, comme avant. - let [su0_p, sv0_p, su1_p, sv1_p] = - cover(screen_source_rect(u_max, v_max, active_crop, pp.zoom, p.focus)); + let [su0_p, sv0_p, su1_p, sv1_p] = cut; let (hu_p, hv_p) = ((su1_p - su0_p) * 0.5, (sv1_p - sv0_p) * 0.5); // Géométrie du tilt, calculée UNE fois : l'ombre et l'écran doivent porter exactement le // même quadrilatère. Deux calculs séparés, c'est une ombre qui se décolle dès qu'un des @@ -2079,7 +2139,9 @@ impl Compositor { [(s_dst[0] + s_dst[2] * 0.5) * self.rw(), (s_dst[1] + s_dst[3] * 0.5) * self.rh()]; // L'ombre suit la silhouette réellement affichée : le rect arrondi quand l'écran est // droit, le quadrilatère projeté quand il est incliné. Un rect droit derrière un écran - // penché ne se lisait pas comme son ombre mais comme une seconde surface. + // penché ne se lisait pas comme son ombre mais comme une seconde surface. Elle suit + // aussi la croissance de la boîte pendant un zoom (issue #179) : quand la boîte sort + // du cadre, l'ombre en sort avec elle, sans jamais se lire comme une bande noire. if cfg.shadow { let spread = SCREEN_SHADOW_SPREAD_FRAC * frame_min_px; let offset = [0.0, SCREEN_SHADOW_OFFSET_FRAC * frame_min_px]; @@ -3319,6 +3381,108 @@ mod tests { assert_rect(screen_source_rect(0.8, 0.9, Some(crop), 2.0, [1.0, 1.0]), [0.4, 0.36, 0.6, 0.63]); } + // --- le zoom rendu à la boîte (issue #179) ------------------------------ + // Le zoom déplace et agrandit la boîte au lieu de rétrécir la coupe. Deux choses à + // figer, et elles tirent en sens inverse : la boîte DOIT déborder le padding (l'issue), + // et le mapping image→écran ne doit PAS bouger (tout le reste du compositeur en + // dépend). Une version antérieure de ce correctif protégeait si bien le second qu'elle + // annulait le premier dès que le focus n'était pas centré — d'où le balayage sur des + // focus décentrés dans les deux tests. + + /// Boîte paddée (padding 50 % → `scale_frame` 0.8) dans une sortie carrée : le cas + /// plein cadre de l'issue. + const PADDED: [f32; 4] = [0.1, 0.1, 0.8, 0.8]; + + /// Les zooms d'un preset (`ZOOM_DEPTH_SCALES`, TS) et des focus réalistes — dont des + /// focus très décentrés, que le suivi de curseur produit en permanence. + const ZOOMS: [f32; 6] = [1.0, 1.25, 1.5, 1.8, 2.2, 3.5]; + const FOCUSES: [[f32; 2]; 6] = [ + [0.5, 0.5], + [0.3, 0.5], + [0.5, 0.8], + [0.15, 0.9], + [0.85, 0.2], + [0.0, 1.0], + ]; + + /// Le couple (boîte, coupe) réellement envoyé au GPU. `u_max`/`v_max` à 1 et pas de + /// crop : la coupe est donc directement en fractions d'image. + fn drawn(base: [f32; 4], zoom: f32, focus: [f32; 2]) -> ([f32; 4], [f32; 4]) { + let cut_ref = screen_source_rect(1.0, 1.0, None, zoom, focus); + let cut = screen_source_rect(1.0, 1.0, None, 1.0, focus); + (remap_box(base, cut_ref, cut), cut) + } + + /// Où un point de l'image atterrit à l'écran, en fraction du CADRE. + fn on_screen(base: [f32; 4], zoom: f32, focus: [f32; 2], point: [f32; 2]) -> [f32; 2] { + let (dst, src) = drawn(base, zoom, focus); + let at = |f: f32, s0: f32, s1: f32, d0: f32, dw: f32| d0 + dw * (f - s0) / (s1 - s0); + [ + at(point[0], src[0], src[2], dst[0], dst[2]), + at(point[1], src[1], src[3], dst[1], dst[3]), + ] + } + + /// Le mapping d'avant : la coupe zoomée remplissait la boîte paddée, sans la bouger. + fn on_screen_before(base: [f32; 4], zoom: f32, focus: [f32; 2], point: [f32; 2]) -> [f32; 2] { + let src = screen_source_rect(1.0, 1.0, None, zoom, focus); + let at = |f: f32, s0: f32, s1: f32, d0: f32, dw: f32| d0 + dw * (f - s0) / (s1 - s0); + [ + at(point[0], src[0], src[2], base[0], base[2]), + at(point[1], src[1], src[3], base[1], base[3]), + ] + } + + /// L'invariant : rendre le zoom à la boîte ne déplace AUCUN point de l'image — même + /// grossissement, même cadrage. Seule l'étendue dessinée change. + #[test] + fn handing_the_zoom_to_the_box_moves_no_pixel() { + for &zoom in &ZOOMS { + for &focus in &FOCUSES { + for &point in &[[0.5, 0.5], [0.0, 0.0], [1.0, 1.0], [0.25, 0.75]] { + let (was, now) = ( + on_screen_before(PADDED, zoom, focus, point), + on_screen(PADDED, zoom, focus, point), + ); + assert!( + (was[0] - now[0]).abs() < 1e-4 && (was[1] - now[1]).abs() < 1e-4, + "point {point:?} déplacé (zoom {zoom}, focus {focus:?}) : {was:?} → {now:?}" + ); + } + } + } + } + + /// Ce que l'issue demande, et la régression que le testeur a vue : dès qu'on zoome, la + /// boîte doit déborder le rect paddé — y compris (surtout) avec un focus décentré. + #[test] + fn any_zoom_overflows_the_padding() { + for &zoom in &ZOOMS { + for &focus in &FOCUSES { + let (dst, _) = drawn(PADDED, zoom, focus); + let grew = dst[2] / PADDED[2]; + assert!( + (grew - zoom).abs() < 1e-4, + "la boîte n'a pas pris le zoom (zoom {zoom}, focus {focus:?}) : ×{grew}" + ); + if zoom > 1.0 { + // Elle dépasse le rect paddé d'au moins un bord, donc mange du padding. + assert!( + dst[0] < PADDED[0] - 1e-6 || dst[0] + dst[2] > PADDED[0] + PADDED[2] + 1e-6, + "boîte encore dans le padding (zoom {zoom}, focus {focus:?}) : {dst:?}" + ); + } + } + } + // Focus centré : le padding disparaît des QUATRE côtés dès que le zoom suffit à + // couvrir le cadre (ici 1/0.8 = 1.25). + let (dst, _) = drawn(PADDED, 1.25, [0.5, 0.5]); + assert_rect(dst, [0.0, 0.0, 1.0, 1.0]); + // Sans padding il n'y a rien à déborder, mais la boîte porte quand même le zoom. + let (dst, _) = drawn([0.0, 0.0, 1.0, 1.0], 2.0, [0.5, 0.5]); + assert_rect(dst, [-0.5, -0.5, 2.0, 2.0]); + } + // --- cover_crop_uv : la caméra n'est jamais étirée -------------------- // Le ratio de la coupe source, ramené en pixels d'image, doit TOUJOURS égaler // celui de la boîte : c'est la définition de « pas de déformation ».