From 95fa7b7e2061d591cb944eb3d27229d309fe5949 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 10:53:10 +0200 Subject: [PATCH 1/5] fix(compositor): let zooms overflow the preview padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #179 When a zoom region is active, the screen content was confined to the padded screen rect, so the zoomed viewport stopped at the padded boundary instead of reaching the edges of the output frame. Expand s_dst / s_dst_prev to the full output frame while p.zoom > 1.0 so the zoomed content overflows the padding as expected. The shadow and rounded corners currently follow the expansion too (drawn into the same rect via the pixel shader) — noted as a follow-up TODO in the comment. --- crates/compositor/src/compositor.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor.rs index 19a2e3885..67ba05840 100644 --- a/crates/compositor/src/compositor.rs +++ b/crates/compositor/src/compositor.rs @@ -1765,8 +1765,25 @@ 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); + // ponytail: quand on zoome (p.zoom > 1.0), le contenu écran était + // contraint au rect paddé (s_dst), ce qui rendait le zoom plus faible + // que prévu — il s'arrêtait à la frontière paddée au lieu de déborder + // jusqu'aux bords du cadre (rapport issue #179). On étend s_dst au + // cadre complet quand un zoom est actif, pour que le contenu zoomé + // remplisse toute la frame, comme attendu. TODO : séparer le rendu + // du contenu de celui du cadre (ombre, coins arrondis) pour que le + // cadre reste au rect paddé pendant le zoom — actuellement le cadre + // suit aussi l'expansion. + let s_dst = if p.zoom > 1.0 { + [0.0, 0.0, 1.0, 1.0] + } else { + fit_screen(p.screen.dst) + }; + let s_dst_prev = if pp.zoom > 1.0 { + [0.0, 0.0, 1.0, 1.0] + } else { + fit_screen(pp.screen.dst) + }; // 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 From f7c431798f72494dd34502e0e52e7e6967b9c9a5 Mon Sep 17 00:00:00 2001 From: Mavis Date: Tue, 28 Jul 2026 13:26:11 +0200 Subject: [PATCH 2/5] fix(compositor): don't let the screen shadow/roundness follow the zoom overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix extended s_dst to the full output frame whenever p.zoom > 1.0 so the zoomed viewport overflows the preview padding (issue #179). But s_min_px and s_dst now both refer to the full output frame, so the existing shadow and rounded-corner code scaled up too: * shadow: drawn around [0,0,1,1] with its 40px spread — reads as a black band against the frame edges, not as a shadow. * rounded corners: s_min_px collapses to min(rw, rh) and the two radius formulas (block preset, roundness slider) coincide on that value, so * min(rw, rh) rounded the entire output frame — 43px on 1080p. Neutralize both when p.zoom > 1.0: no shadow, no radius. The content reaches the edges unframed, which matches the issue's " reach the edges of the --- crates/compositor/src/compositor.rs | 43 ++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor.rs index 67ba05840..c7dc6dea8 100644 --- a/crates/compositor/src/compositor.rs +++ b/crates/compositor/src/compositor.rs @@ -1770,10 +1770,11 @@ impl Compositor { // que prévu — il s'arrêtait à la frontière paddée au lieu de déborder // jusqu'aux bords du cadre (rapport issue #179). On étend s_dst au // cadre complet quand un zoom est actif, pour que le contenu zoomé - // remplisse toute la frame, comme attendu. TODO : séparer le rendu - // du contenu de celui du cadre (ombre, coins arrondis) pour que le - // cadre reste au rect paddé pendant le zoom — actuellement le cadre - // suit aussi l'expansion. + // remplisse toute la frame, comme attendu. Le rayon et l'ombre sont + // neutralisés en parallèle (cf. plus bas) : sinon ils suivent + // l'expansion et arrondissent / ombrent tout l'output, pas l'écran. + // TODO : à la place, séparer "rect contenu" de "rect cadre" pour garder + // le cadre (ombre, coins) au rect paddé pendant que le contenu déborde. let s_dst = if p.zoom > 1.0 { [0.0, 0.0, 1.0, 1.0] } else { @@ -1854,14 +1855,26 @@ 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); - 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). - (true, Some(f), _) => f * s_min_px, - // Scène sans rayon imposé : slider Roundness, relatif au cadre. - (true, None, Some(f)) => f * frame_min_px, - // Fixture/bench (pas de scène) : chemin inspector historique, inchangé. - (true, None, None) => p.screen.radius * lp.radius_scale, + // Quand le contenu déborde du padding (zoom actif), il n'y a plus de cadre visible + // (le rect écran a été étendu aux bords du cadre de sortie, cf. issue #179) : + // neutraliser le rayon plutôt que de le laisser suivre l'expansion. Sur le rect + // plein cadre, `f * s_min_px` arrondit TOUT l'output (le `s_min_px` devient + // `min(rw, rh)`), pas seulement l'écran — et `f * frame_min_px` fait pareil + // (les deux quantités coïncident quand s_dst = [0,0,1,1]). TODO : à la place, + // séparer "rect contenu" de "rect cadre" pour garder les coins au rect paddé + // pendant que le contenu déborde (cf. le TODO sur `s_dst` plus haut). + let s_radius = if p.zoom > 1.0 { + 0.0 + } else { + 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). + (true, Some(f), _) => f * s_min_px, + // Scène sans rayon imposé : slider Roundness, relatif au cadre. + (true, None, Some(f)) => f * frame_min_px, + // Fixture/bench (pas de scène) : chemin inspector historique, inchangé. + (true, None, None) => p.screen.radius * lp.radius_scale, + } }; let w_px = [w_dst[2] * self.rw(), w_dst[3] * self.rh()]; // Rayon caméra. Le slider Roundness ne s'y applique jamais (il ne vaut que pour l'ÉCRAN). @@ -2020,7 +2033,11 @@ impl Compositor { // 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. - if cfg.shadow { + // Pendant un zoom, le rect contenu déborde jusqu'aux bords du cadre (issue #179) — + // pas d'ombre : dessinée autour de [0,0,1,1] avec son `spread`, elle se lirait comme + // un masque noir contre les bords, pas comme une ombre. TODO : la garder au rect + // paddé du cadre (cf. le TODO sur `s_dst` plus haut). + if cfg.shadow && p.zoom <= 1.0 { let spread = SCREEN_SHADOW_SPREAD_FRAC * frame_min_px; let offset = [0.0, SCREEN_SHADOW_OFFSET_FRAC * frame_min_px]; let opacity = 0.45 * lp.shadow_scale; From ed099ae06e6fe882358598ea590f17047e342bb2 Mon Sep 17 00:00:00 2001 From: Mavis Date: Tue, 28 Jul 2026 17:33:28 +0200 Subject: [PATCH 3/5] =?UTF-8?q?=EF=BB=BFfix(compositor):=20make=20the=20zo?= =?UTF-8?q?om-frame=20growth=20continuous=20instead=20of=20binary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the original issue #179 fix and the f7c43179 bandaid. The two earlier commits each made the wrong call: the original swap to [0,0,1,1] in one step (abrupt padding disappearance), and f7c43179 kept the same switch but neutralized the shadow and radius to mask the regression (still abrupt on the padding ring, and now also abruptly loses the frame on zoom engage). The right behavior: the "zoom frame" (s_dst) grows continuously with the zoom, from the padded size up to the full frame. At zoom = 1 it's the padded area. As the zoom ramps up, the frame expands until it hits the frame edge at zoom = 1 / padding_scale (= frame / padded_size). Past that the frame stays put and only the source rect keeps shrinking — the GPU upscales further. The source texture itself is never touched, so full resolution is preserved all the way; only the mapping changes. s_radius and the shadow follow s_dst naturally now (s_min_px grows with the zoom frame), so the f7c43179 gates on those are removed. Result: no more binary switch anywhere. The padding smoothly fades as the zoom ramps up, the frame "follows" the zoom, and by the time the content hits the edges the frame is already at full size — visually consistent. Single TODO kept, pointing at the still-pending "frame rect" separation: if you want the shadow and corners to stay anchored at the padded box while the content overflows (the "window" effect), split the frame rect from the content rect and apply shadow+corners to the frame alone. --- crates/compositor/src/compositor.rs | 95 ++++++++++++++++++----------- 1 file changed, 59 insertions(+), 36 deletions(-) diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor.rs index c7dc6dea8..c7398fe84 100644 --- a/crates/compositor/src/compositor.rs +++ b/crates/compositor/src/compositor.rs @@ -1765,26 +1765,46 @@ impl Compositor { fit_dst_to_aspect(scale_frame(dst, padding_scale), crop_aspect) } }; - // ponytail: quand on zoome (p.zoom > 1.0), le contenu écran était - // contraint au rect paddé (s_dst), ce qui rendait le zoom plus faible - // que prévu — il s'arrêtait à la frontière paddée au lieu de déborder - // jusqu'aux bords du cadre (rapport issue #179). On étend s_dst au - // cadre complet quand un zoom est actif, pour que le contenu zoomé - // remplisse toute la frame, comme attendu. Le rayon et l'ombre sont - // neutralisés en parallèle (cf. plus bas) : sinon ils suivent - // l'expansion et arrondissent / ombrent tout l'output, pas l'écran. - // TODO : à la place, séparer "rect contenu" de "rect cadre" pour garder - // le cadre (ombre, coins) au rect paddé pendant que le contenu déborde. - let s_dst = if p.zoom > 1.0 { - [0.0, 0.0, 1.0, 1.0] - } else { - fit_screen(p.screen.dst) - }; - let s_dst_prev = if pp.zoom > 1.0 { - [0.0, 0.0, 1.0, 1.0] - } else { - fit_screen(pp.screen.dst) + // ponytail (issue #179) : le rect destination de l'écran ("zoom frame") + // grandit avec le zoom, du rect paddé jusqu'au cadre complet — plus + // jamais le switch binaire d'avant (padded → [0,0,1,1] en une frame, + // qui faisait disparaître le padding instantanément et envoyait l'ombre + // et les coins sur tout l'output). Le source rect (computé par + // `screen_source_rect` plus bas) et le zoom frame sont liés : le GPU + // mappe le source rect sur le zoom frame, et le padding est le delta + // entre le zoom frame et le cadre. Le padding s'efface smoothly à + // mesure que le zoom monte, et disparaît pile quand le zoom atteint + // `1 / padding_scale` (= cadre / padded_size) — au-delà, le zoom + // frame reste au cadre et seul le source rect continue de rétrécir + // (le GPU upscale davantage). Le media reste à pleine résolution + // toute la durée : c'est le *mapping* qui s'ajuste, pas la texture. + // + // TODO : si on veut que l'ombre et les coins restent ancrés au rect + // paddé pendant que le contenu déborde (effet "fenêtre"), il faudra + // séparer le rect "cadre" du rect "contenu" et appliquer l'ombre et + // les coins au cadre seul. Pour l'instant ils suivent le zoom frame, + // ce qui donne un rendu cohérent (le cadre "suit" le zoom) sans + // l'abrupt switch. + let zoom_grow = |zoom: f32, base: [f32; 4]| -> [f32; 4] { + // growth = min(zoom, 1/padding_scale) : on sature pile quand le + // zoom frame atteint le cadre. padding_scale est dans (0, 1] ; + // `max(0.0001)` évite une division par 0 sur des fixtures + // pathologiques (padding = 100% clampé à 0.6 → 1.667, jamais + // infini en pratique). + let growth = zoom.min(1.0 / padding_scale.max(0.0001)); + let cx = base[0] + base[2] * 0.5; + let cy = base[1] + base[3] * 0.5; + let nw = (base[2] * growth).min(1.0); + let nh = (base[3] * growth).min(1.0); + [ + (cx - nw * 0.5).max(0.0).min(1.0 - nw), + (cy - nh * 0.5).max(0.0).min(1.0 - nh), + nw, + nh, + ] }; + let s_dst = zoom_grow(p.zoom, fit_screen(p.screen.dst)); + let s_dst_prev = zoom_grow(pp.zoom, fit_screen(pp.screen.dst)); // 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 @@ -1863,18 +1883,19 @@ impl Compositor { // (les deux quantités coïncident quand s_dst = [0,0,1,1]). TODO : à la place, // séparer "rect contenu" de "rect cadre" pour garder les coins au rect paddé // pendant que le contenu déborde (cf. le TODO sur `s_dst` plus haut). - let s_radius = if p.zoom > 1.0 { - 0.0 - } else { - 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). - (true, Some(f), _) => f * s_min_px, - // Scène sans rayon imposé : slider Roundness, relatif au cadre. - (true, None, Some(f)) => f * frame_min_px, - // Fixture/bench (pas de scène) : chemin inspector historique, inchangé. - (true, None, None) => p.screen.radius * lp.radius_scale, - } + // + // (révision : la croissance de `s_dst` est *continue* via `zoom_grow` au-dessus, + // donc `s_min_px` suit le zoom frame et le rayon aussi — pas de switch + // binaire. Le `if p.zoom > 1.0 { 0.0 }` n'est plus nécessaire ; on le + // retire pour laisser le rayon suivre naturellement le zoom frame.) + 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). + (true, Some(f), _) => f * s_min_px, + // Scène sans rayon imposé : slider Roundness, relatif au cadre. + (true, None, Some(f)) => f * frame_min_px, + // Fixture/bench (pas de scène) : chemin inspector historique, inchangé. + (true, None, None) => p.screen.radius * lp.radius_scale, }; let w_px = [w_dst[2] * self.rw(), w_dst[3] * self.rh()]; // Rayon caméra. Le slider Roundness ne s'y applique jamais (il ne vaut que pour l'ÉCRAN). @@ -2033,11 +2054,13 @@ impl Compositor { // 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. - // Pendant un zoom, le rect contenu déborde jusqu'aux bords du cadre (issue #179) — - // pas d'ombre : dessinée autour de [0,0,1,1] avec son `spread`, elle se lirait comme - // un masque noir contre les bords, pas comme une ombre. TODO : la garder au rect - // paddé du cadre (cf. le TODO sur `s_dst` plus haut). - if cfg.shadow && p.zoom <= 1.0 { + // + // (révision : `s_dst` croît continuously avec le zoom via `zoom_grow`, donc + // l'ombre suit naturellement le zoom frame. Le `p.zoom <= 1.0` gate d'avant + // n'est plus nécessaire et on le retire — sinon on aurait une ombre + // qui disparaît d'un coup quand le zoom démarre, aussi abrupt que le + // switch binaire de `s_dst`.) + if cfg.shadow { let spread = SCREEN_SHADOW_SPREAD_FRAC * frame_min_px; let offset = [0.0, SCREEN_SHADOW_OFFSET_FRAC * frame_min_px]; let opacity = 0.45 * lp.shadow_scale; From f2c1faae455227a06689575d57f0f1913ba22a2e Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 19:37:47 +0200 Subject: [PATCH 4/5] fix(compositor): split the zoom between the box and the source cut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the three earlier #179 commits. They all grew the screen box (s_dst) while STILL passing the full p.zoom to screen_source_rect, so the two multiplied: with padding 50% (padding_scale 0.8, cap 1.25) a depth-1 region authored at 1.25x rendered at 1.56x, a depth-4 at 2.75x. The reference does not do that — applyZoomTransform (TS) scales the camera container by exactly zoomScale and never crops the source on top. Give the box a share g of the zoom and take that same share OFF the source cut (p.zoom / g). The product stays zoom, so: * the magnification is exactly what the region asks for; * (box, cut) is one affine image->screen map and splitting it does not move that map — the focus lands on the same pixel it did before. Everything riding the map (cursor layer, 3D tilt, motion blur) is therefore unchanged by construction. Only the drawn EXTENT grows, which is precisely what the issue asks for. Two bounds on g, both derived from the geometry rather than from padding_scale: * cover — the size that covers the frame. Past it, growing adds nothing (the rasterizer clips). Taken from the real rect, so a letterboxed crop (16:9 in a 9:16 output) also eats its bars; the old 1/padding_scale cap never reached them. * focus — the source cut must stay centreable on the target, else screen_source_rect pins it to the crop edge and the zoom stops aiming. Same constraint getFocusBoundsForScale (TS) already applies upstream, so it never blocks growth for an app-authored focus. Also drops the [0,1] clamp on the grown box: it slid off-centre rects (block presets) sideways as they grew, and pinned the rounded corners and shadow to the frame edge instead of letting them leave it — the artifact the second commit had neutralized the radius and shadow to hide. Uncapped matches the reference ("No stage clamping", frameRenderer.cameraAwareMaskRect); the rasterizer clips the overflow, as it already does for the blurred background. screen_zoom_growth / grow_around_center are free functions next to screen_source_rect so they can be tested; the previous closure could not. Three tests, the first of which fails on any of the three earlier versions: the map-invariance sweep (magnification and focus position unchanged across zooms and focus positions), the frame-edge reach, and the focus-limit arbitration. cargo test -p openscreen-compositor --lib --tests: 79 + 3 pass. Bench note: the fixture (zoom 1.8, focus [0.5, 0.32], 5% margin) now grows its box at deep zoom, so bench frames change there by design. --- crates/compositor/src/compositor.rs | 248 +++++++++++++++++++++------- 1 file changed, 186 insertions(+), 62 deletions(-) diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor.rs index c7398fe84..5d5a5b140 100644 --- a/crates/compositor/src/compositor.rs +++ b/crates/compositor/src/compositor.rs @@ -113,6 +113,63 @@ fn screen_source_rect( [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv] } +/// Part du zoom absorbée par la BOÎTE de destination (le « zoom frame ») plutôt que par la +/// coupe source — issue #179. +/// +/// Le zoom natif se joue entièrement dans le rect SOURCE (`screen_source_rect` rétrécit la +/// coupe autour du focus) pendant que la boîte, elle, ne bouge pas : le zoom s'arrête donc +/// à la frontière paddée au lieu d'atteindre les bords du cadre. La référence web fait +/// l'inverse — `applyZoomTransform` (TS) met à l'échelle le CONTENEUR CAMÉRA, masque +/// compris, donc la boîte paddée grandit avec le zoom et le padding s'efface. +/// +/// On répartit donc le zoom entre les deux : la boîte grandit d'un facteur `g`, la coupe +/// source ne porte plus que le reste (`zoom / g`). Le PRODUIT reste `zoom`, donc le +/// grossissement à l'écran est exactement celui demandé par la région. C'est tout l'enjeu +/// du partage : un `g` qui *multiplierait* le zoom au lieu de le partager rendrait une +/// région 1.25× à 1.56× (padding 50 %) — pas ce que la timeline annonce. +/// +/// Deux bornes sur `g` : +/// * `cover` — la taille qui couvre le cadre. Au-delà, grandir n'ajoute rien (le rasterizer +/// coupe) ; c'est exactement le point où le padding a fini de s'effacer. Elle se déduit du +/// rect RÉEL, pas de `padding_scale` : une coupe letterboxée (crop 16:9 dans une sortie +/// 9:16) a des bandes qu'aucun facteur de padding ne décrit. +/// * le focus — la coupe source doit rester CENTRABLE sur le point visé, sinon +/// `screen_source_rect` la recale contre le bord du crop et le zoom cesse de viser. Elle +/// tient tant que `zoom / g >= 1 / (2·min(c, 1-c))`. C'est la même contrainte que +/// `getFocusBoundsForScale` (TS) applique en amont en bornant le focus à `1/(2·zoom)` du +/// bord : sur cette borne le budget vaut exactement 1 (le zoom part entièrement dans le +/// pan), et il ne dépasse 1 qu'à l'intérieur. Un focus au bord garde donc son padding, +/// un focus plus central le mange. +fn screen_zoom_growth(zoom: f32, focus: [f32; 2], base: [f32; 4]) -> f32 { + if !zoom.is_finite() || zoom <= 1.0 { + return 1.0; + } + let cover = (1.0 / base[2].max(1e-4)).max(1.0 / base[3].max(1e-4)); + // Marge restante de part et d'autre du focus, normalisée : 1 au centre, 0 sur un bord. + // Même normalisation du focus que `screen_source_rect`, pour que les deux bornes parlent + // du même point. + let room = |c: f32| { + let c = if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 }; + 2.0 * c.min(1.0 - c) + }; + zoom.min(cover).min(zoom * room(focus[0]).min(room(focus[1]))).max(1.0) +} + +/// Grandit un rect autour de son CENTRE. C'est le seul ancrage qui laisse le point de focus +/// tomber exactement là où il tombait sans croissance (cf. le test +/// `growing_the_zoom_frame_leaves_the_focus_where_it_was`) : la boîte grandit de `g`, la +/// coupe source rétrécit de `g`, et les deux décalages s'annulent au centre. +/// +/// Volontairement PAS clampé dans le cadre : un rect décentré (presets en bloc) glisserait +/// de côté en grandissant — ce qui casserait justement cette annulation — et ses coins +/// arrondis resteraient collés au bord du cadre au lieu d'en sortir. La référence ne clampe +/// pas non plus (« No stage clamping », `frameRenderer.cameraAwareMaskRect`), et le +/// rasterizer coupe ce qui dépasse (le fond flouté sort déjà du cadre de la même façon). +fn grow_around_center(rect: [f32; 4], g: f32) -> [f32; 4] { + let (nw, nh) = (rect[2] * g, rect[3] * g); + [rect[0] + (rect[2] - nw) * 0.5, rect[1] + (rect[3] - nh) * 0.5, nw, nh] +} + /// 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. @@ -1765,46 +1822,26 @@ impl Compositor { fit_dst_to_aspect(scale_frame(dst, padding_scale), crop_aspect) } }; - // ponytail (issue #179) : le rect destination de l'écran ("zoom frame") - // grandit avec le zoom, du rect paddé jusqu'au cadre complet — plus - // jamais le switch binaire d'avant (padded → [0,0,1,1] en une frame, - // qui faisait disparaître le padding instantanément et envoyait l'ombre - // et les coins sur tout l'output). Le source rect (computé par - // `screen_source_rect` plus bas) et le zoom frame sont liés : le GPU - // mappe le source rect sur le zoom frame, et le padding est le delta - // entre le zoom frame et le cadre. Le padding s'efface smoothly à - // mesure que le zoom monte, et disparaît pile quand le zoom atteint - // `1 / padding_scale` (= cadre / padded_size) — au-delà, le zoom - // frame reste au cadre et seul le source rect continue de rétrécir - // (le GPU upscale davantage). Le media reste à pleine résolution - // toute la durée : c'est le *mapping* qui s'ajuste, pas la texture. + // Issue #179 : le zoom se jouait entièrement dans la coupe source, donc la boîte + // écran restait au rect paddé et le zoom s'arrêtait à cette frontière au lieu + // d'atteindre les bords du cadre. On en donne désormais une part à la BOÎTE + // (`screen_zoom_growth`) et on retire cette même part à la coupe source (plus bas, + // `p.zoom / s_growth`). // - // TODO : si on veut que l'ombre et les coins restent ancrés au rect - // paddé pendant que le contenu déborde (effet "fenêtre"), il faudra - // séparer le rect "cadre" du rect "contenu" et appliquer l'ombre et - // les coins au cadre seul. Pour l'instant ils suivent le zoom frame, - // ce qui donne un rendu cohérent (le cadre "suit" le zoom) sans - // l'abrupt switch. - let zoom_grow = |zoom: f32, base: [f32; 4]| -> [f32; 4] { - // growth = min(zoom, 1/padding_scale) : on sature pile quand le - // zoom frame atteint le cadre. padding_scale est dans (0, 1] ; - // `max(0.0001)` évite une division par 0 sur des fixtures - // pathologiques (padding = 100% clampé à 0.6 → 1.667, jamais - // infini en pratique). - let growth = zoom.min(1.0 / padding_scale.max(0.0001)); - let cx = base[0] + base[2] * 0.5; - let cy = base[1] + base[3] * 0.5; - let nw = (base[2] * growth).min(1.0); - let nh = (base[3] * growth).min(1.0); - [ - (cx - nw * 0.5).max(0.0).min(1.0 - nw), - (cy - nh * 0.5).max(0.0).min(1.0 - nh), - nw, - nh, - ] - }; - let s_dst = zoom_grow(p.zoom, fit_screen(p.screen.dst)); - let s_dst_prev = zoom_grow(pp.zoom, fit_screen(pp.screen.dst)); + // Le point clé : (boîte, coupe) forment UN SEUL mapping affine image→écran, et le + // partager ne le change pas — même grossissement, même point de focus au même + // endroit (les deux tests plus bas le figent). Tout ce qui roule sur ce mapping — + // le curseur, le tilt 3D, le motion blur — est donc inchangé par construction ; la + // seule différence est l'ÉTENDUE dessinée, qui déborde maintenant le padding. + // C'est exactement ce que demande l'issue, et rien de plus. + let s_base = fit_screen(p.screen.dst); + let s_base_prev = fit_screen(pp.screen.dst); + // `p.focus` (et non `pp.focus`) pour la frame précédente : même choix que la coupe + // source plus bas, qui garde volontairement le focus courant. + let s_growth = screen_zoom_growth(p.zoom, p.focus, s_base); + let s_growth_prev = screen_zoom_growth(pp.zoom, p.focus, s_base_prev); + let s_dst = grow_around_center(s_base, s_growth); + let s_dst_prev = grow_around_center(s_base_prev, s_growth_prev); // 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 @@ -1875,19 +1912,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); - // Quand le contenu déborde du padding (zoom actif), il n'y a plus de cadre visible - // (le rect écran a été étendu aux bords du cadre de sortie, cf. issue #179) : - // neutraliser le rayon plutôt que de le laisser suivre l'expansion. Sur le rect - // plein cadre, `f * s_min_px` arrondit TOUT l'output (le `s_min_px` devient - // `min(rw, rh)`), pas seulement l'écran — et `f * frame_min_px` fait pareil - // (les deux quantités coïncident quand s_dst = [0,0,1,1]). TODO : à la place, - // séparer "rect contenu" de "rect cadre" pour garder les coins au rect paddé - // pendant que le contenu déborde (cf. le TODO sur `s_dst` plus haut). - // - // (révision : la croissance de `s_dst` est *continue* via `zoom_grow` au-dessus, - // donc `s_min_px` suit le zoom frame et le rayon aussi — pas de switch - // binaire. Le `if p.zoom > 1.0 { 0.0 }` n'est plus nécessaire ; on le - // retire pour laisser le rayon suivre naturellement le zoom frame.) + // 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). @@ -2037,12 +2064,20 @@ impl Compositor { None => uv, } }; + // `/ s_growth` : la boîte a déjà absorbé cette part du zoom (cf. `screen_zoom_growth`). + // Passer `p.zoom` entier ici rezoomerait par-dessus la boîte agrandie et rendrait la + // région plus profonde que ce que la timeline annonce. let [su0, sv0, su1, sv1] = - cover(screen_source_rect(u_max, v_max, active_crop, p.zoom, p.focus)); + cover(screen_source_rect(u_max, v_max, active_crop, p.zoom / s_growth, p.focus)); 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] = cover(screen_source_rect( + u_max, + v_max, + active_crop, + pp.zoom / s_growth_prev, + p.focus, + )); 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 @@ -2053,13 +2088,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. - // - // (révision : `s_dst` croît continuously avec le zoom via `zoom_grow`, donc - // l'ombre suit naturellement le zoom frame. Le `p.zoom <= 1.0` gate d'avant - // n'est plus nécessaire et on le retire — sinon on aurait une ombre - // qui disparaît d'un coup quand le zoom démarre, aussi abrupt que le - // switch binaire de `s_dst`.) + // 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]; @@ -3216,6 +3247,99 @@ 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]); } + // --- partage du zoom entre la boîte et la coupe (issue #179) ------------ + // La boîte prend une part du zoom pour déborder le padding. Le mapping image→écran, + // lui, ne doit PAS bouger : ces deux tests le figent, parce que c'est précisément ce + // qu'un partage mal fait casse en silence (zoom trop profond, focus qui dérive). + + /// 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]; + + /// Le couple (boîte, coupe) réellement envoyé au GPU, pour une croissance donnée. + /// `u_max`/`v_max` à 1 et pas de crop : la coupe est donc directement en fractions. + fn mapping(base: [f32; 4], zoom: f32, focus: [f32; 2], g: f32) -> ([f32; 4], [f32; 4]) { + (grow_around_center(base, g), screen_source_rect(1.0, 1.0, None, zoom / g, focus)) + } + + /// Où le point de focus atterrit à l'écran, en fraction du CADRE. + fn focus_on_screen(base: [f32; 4], zoom: f32, focus: [f32; 2], g: f32) -> [f32; 2] { + let (dst, src) = mapping(base, zoom, focus, g); + let at = |f: f32, s0: f32, s1: f32, d0: f32, dw: f32| d0 + dw * (f - s0) / (s1 - s0); + [ + at(focus[0], src[0], src[2], dst[0], dst[2]), + at(focus[1], src[1], src[3], dst[1], dst[3]), + ] + } + + /// Grossissement à l'écran : pixels de cadre par unité de source. + fn magnification(base: [f32; 4], zoom: f32, focus: [f32; 2], g: f32) -> f32 { + let (dst, src) = mapping(base, zoom, focus, g); + dst[2] / (src[2] - src[0]) + } + + /// L'invariant qui compte : donner une part du zoom à la boîte ne change NI le + /// grossissement NI la position du focus — seulement l'étendue dessinée. Balayé sur + /// des zooms et des focus variés, dont les bords. + #[test] + fn growing_the_zoom_frame_leaves_the_focus_where_it_was() { + for &zoom in &[1.0, 1.25, 1.5, 1.8, 2.2, 3.5, 5.0] { + for &focus in &[[0.5, 0.5], [0.3, 0.5], [0.5, 0.8], [0.12, 0.9], [0.0, 1.0]] { + let g = screen_zoom_growth(zoom, focus, PADDED); + assert!(g >= 1.0, "la boîte ne rétrécit jamais (zoom {zoom}, g {g})"); + let (was, now) = ( + focus_on_screen(PADDED, zoom, focus, 1.0), + focus_on_screen(PADDED, zoom, focus, g), + ); + assert!( + (was[0] - now[0]).abs() < 1e-4 && (was[1] - now[1]).abs() < 1e-4, + "focus déplacé (zoom {zoom}, focus {focus:?}, g {g}) : {was:?} → {now:?}" + ); + let (mw, mn) = ( + magnification(PADDED, zoom, focus, 1.0), + magnification(PADDED, zoom, focus, g), + ); + assert!( + (mw - mn).abs() < 1e-3, + "grossissement changé (zoom {zoom}, focus {focus:?}) : {mw} → {mn}" + ); + } + } + } + + /// Ce que l'issue demande : passé le zoom qui couvre le cadre, la boîte l'atteint — + /// le padding a disparu. Et sans padding il n'y a rien à déborder : pas de croissance. + #[test] + fn the_zoom_frame_reaches_the_frame_edges_and_stops_there() { + let g = screen_zoom_growth(2.2, [0.5, 0.5], PADDED); + let dst = grow_around_center(PADDED, g); + assert_rect(dst, [0.0, 0.0, 1.0, 1.0]); + // La borne est bien la couverture du cadre, pas une valeur de padding : une coupe + // letterboxée (bandes hautes/basses) continue de grandir jusqu'à les manger. + let letterboxed = [0.1, 0.36, 0.8, 0.28]; + let g = screen_zoom_growth(5.0, [0.5, 0.5], letterboxed); + let dst = grow_around_center(letterboxed, g); + assert!(dst[1] <= 0.0 && dst[1] + dst[3] >= 1.0, "bandes non résorbées : {dst:?}"); + + assert_eq!(screen_zoom_growth(2.0, [0.5, 0.5], [0.0, 0.0, 1.0, 1.0]), 1.0); + assert_eq!(screen_zoom_growth(1.0, [0.5, 0.5], PADDED), 1.0); + } + + /// Un zoom posé sur sa limite de focus a déjà tout dépensé à viser : rien pour la + /// boîte, et c'est le bon arbitrage — viser juste prime sur manger le padding. + #[test] + fn a_zoom_pinned_on_its_focus_limit_keeps_its_pan_rather_than_the_padding() { + // `getFocusBoundsForScale` (TS) borne déjà le focus à `1/(2·zoom)` du bord : pile + // l'endroit où la coupe source touche le bord du crop. Quelle que soit la + // profondeur, un focus posé DESSUS ne laisse aucune marge. + for &zoom in &[1.5, 2.2, 3.5] { + let edge = 1.0 / (2.0 * zoom); + assert_eq!(screen_zoom_growth(zoom, [edge, 0.5], PADDED), 1.0); + } + // À l'intérieur de ces bornes, en revanche, la boîte profite de la marge. + assert!(screen_zoom_growth(3.5, [0.35, 0.5], PADDED) > 1.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 ». From bc599bd766c5e8229de2b65ce3122db6044b0ed7 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 28 Jul 2026 20:19:04 +0200 Subject: [PATCH 5/5] fix(compositor): hand the whole zoom to the box, not a share of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the regression in f2c1faae, caught in manual testing: the padding still constrained the zoom. That commit capped the box growth by a "focus budget" — g = min(zoom, cover, zoom * 2*min(c, 1-c)) — meant to keep the source cut centreable on the focus. But the zoom focus follows the cursor and is almost never centred, so the third term dominated: focus 0.3 at zoom 1.5 gives 0.9, clamped back to 1.0, i.e. no growth at all. The fix was inert in the common case. The tests missed it because they swept centred focuses and focuses pinned exactly on their bound, never an ordinary off-centre one. Root cause: that commit refused to TRANSLATE the box (to avoid re-deriving ease() in regions.rs). Without translation the only lever left to preserve framing was refusing to grow — so it protected the framing by giving up the point of the PR. So translate. The box now takes the whole zoom and the drawn cut goes back to the bare crop, which is applyZoomTransform's geometry. The box placement is not a formula to get right: remap_box() carries the new cut through the old `cut_ref -> base` mapping, so the framing is preserved by construction — crop, edge clamp and cover are all already baked into the two cuts. Nothing left to protect, hence nothing to refuse. Consequences, all of them wanted: * the box grows by exactly `zoom` for every focus, so the padding is eaten from the first frame of the ramp; * src == src_prev now, and the zoom velocity the motion blur reads lives in dst vs dst_prev instead of in the cut; * an edge-focused zoom keeps the padding on the side where the image runs out — there is no content to put there, and the reference shows background in the same spot. Tests rewritten around the two things that pull against each other: handing_the_zoom_to_the_box_moves_no_pixel (no point of the image shifts) and any_zoom_overflows_the_padding (the box always takes the zoom). Both sweep off-centre focuses including the [0.0, 1.0] corner; the second fails on f2c1faae. cargo test -p openscreen-compositor --lib --tests: 78 + 3 pass. Verified in the app with a rebuilt addon. --- crates/compositor/src/compositor.rs | 321 +++++++++++++--------------- 1 file changed, 152 insertions(+), 169 deletions(-) diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor.rs index 5d5a5b140..0eac3bb0c 100644 --- a/crates/compositor/src/compositor.rs +++ b/crates/compositor/src/compositor.rs @@ -113,61 +113,42 @@ fn screen_source_rect( [su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv] } -/// Part du zoom absorbée par la BOÎTE de destination (le « zoom frame ») plutôt que par la -/// coupe source — issue #179. +/// 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 joue entièrement dans le rect SOURCE (`screen_source_rect` rétrécit la -/// coupe autour du focus) pendant que la boîte, elle, ne bouge pas : le zoom s'arrête donc -/// à la frontière paddée au lieu d'atteindre les bords du cadre. La référence web fait -/// l'inverse — `applyZoomTransform` (TS) met à l'échelle le CONTENEUR CAMÉRA, masque -/// compris, donc la boîte paddée grandit avec le zoom et le padding s'efface. +/// 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 répartit donc le zoom entre les deux : la boîte grandit d'un facteur `g`, la coupe -/// source ne porte plus que le reste (`zoom / g`). Le PRODUIT reste `zoom`, donc le -/// grossissement à l'écran est exactement celui demandé par la région. C'est tout l'enjeu -/// du partage : un `g` qui *multiplierait* le zoom au lieu de le partager rendrait une -/// région 1.25× à 1.56× (padding 50 %) — pas ce que la timeline annonce. +/// 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`. /// -/// Deux bornes sur `g` : -/// * `cover` — la taille qui couvre le cadre. Au-delà, grandir n'ajoute rien (le rasterizer -/// coupe) ; c'est exactement le point où le padding a fini de s'effacer. Elle se déduit du -/// rect RÉEL, pas de `padding_scale` : une coupe letterboxée (crop 16:9 dans une sortie -/// 9:16) a des bandes qu'aucun facteur de padding ne décrit. -/// * le focus — la coupe source doit rester CENTRABLE sur le point visé, sinon -/// `screen_source_rect` la recale contre le bord du crop et le zoom cesse de viser. Elle -/// tient tant que `zoom / g >= 1 / (2·min(c, 1-c))`. C'est la même contrainte que -/// `getFocusBoundsForScale` (TS) applique en amont en bornant le focus à `1/(2·zoom)` du -/// bord : sur cette borne le budget vaut exactement 1 (le zoom part entièrement dans le -/// pan), et il ne dépasse 1 qu'à l'intérieur. Un focus au bord garde donc son padding, -/// un focus plus central le mange. -fn screen_zoom_growth(zoom: f32, focus: [f32; 2], base: [f32; 4]) -> f32 { - if !zoom.is_finite() || zoom <= 1.0 { - return 1.0; - } - let cover = (1.0 / base[2].max(1e-4)).max(1.0 / base[3].max(1e-4)); - // Marge restante de part et d'autre du focus, normalisée : 1 au centre, 0 sur un bord. - // Même normalisation du focus que `screen_source_rect`, pour que les deux bornes parlent - // du même point. - let room = |c: f32| { - let c = if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 }; - 2.0 * c.min(1.0 - c) - }; - zoom.min(cover).min(zoom * room(focus[0]).min(room(focus[1]))).max(1.0) -} - -/// Grandit un rect autour de son CENTRE. C'est le seul ancrage qui laisse le point de focus -/// tomber exactement là où il tombait sans croissance (cf. le test -/// `growing_the_zoom_frame_leaves_the_focus_where_it_was`) : la boîte grandit de `g`, la -/// coupe source rétrécit de `g`, et les deux décalages s'annulent au centre. +/// 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é. /// -/// Volontairement PAS clampé dans le cadre : un rect décentré (presets en bloc) glisserait -/// de côté en grandissant — ce qui casserait justement cette annulation — et ses coins -/// arrondis resteraient collés au bord du cadre au lieu d'en sortir. La référence ne clampe -/// pas non plus (« No stage clamping », `frameRenderer.cameraAwareMaskRect`), et le -/// rasterizer coupe ce qui dépasse (le fond flouté sort déjà du cadre de la même façon). -fn grow_around_center(rect: [f32; 4], g: f32) -> [f32; 4] { - let (nw, nh) = (rect[2] * g, rect[3] * g); - [rect[0] + (rect[2] - nw) * 0.5, rect[1] + (rect[3] - nh) * 0.5, nw, nh] +/// 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 @@ -1823,25 +1804,43 @@ impl Compositor { } }; // Issue #179 : le zoom se jouait entièrement dans la coupe source, donc la boîte - // écran restait au rect paddé et le zoom s'arrêtait à cette frontière au lieu - // d'atteindre les bords du cadre. On en donne désormais une part à la BOÎTE - // (`screen_zoom_growth`) et on retire cette même part à la coupe source (plus bas, - // `p.zoom / s_growth`). - // - // Le point clé : (boîte, coupe) forment UN SEUL mapping affine image→écran, et le - // partager ne le change pas — même grossissement, même point de focus au même - // endroit (les deux tests plus bas le figent). Tout ce qui roule sur ce mapping — - // le curseur, le tilt 3D, le motion blur — est donc inchangé par construction ; la - // seule différence est l'ÉTENDUE dessinée, qui déborde maintenant le padding. - // C'est exactement ce que demande l'issue, et rien de plus. + // é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); - // `p.focus` (et non `pp.focus`) pour la frame précédente : même choix que la coupe - // source plus bas, qui garde volontairement le focus courant. - let s_growth = screen_zoom_growth(p.zoom, p.focus, s_base); - let s_growth_prev = screen_zoom_growth(pp.zoom, p.focus, s_base_prev); - let s_dst = grow_around_center(s_base, s_growth); - let s_dst_prev = grow_around_center(s_base_prev, s_growth_prev); + // 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 @@ -2047,37 +2046,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, - } - }; - // `/ s_growth` : la boîte a déjà absorbé cette part du zoom (cf. `screen_zoom_growth`). - // Passer `p.zoom` entier ici rezoomerait par-dessus la boîte agrandie et rendrait la - // région plus profonde que ce que la timeline annonce. - let [su0, sv0, su1, sv1] = - cover(screen_source_rect(u_max, v_max, active_crop, p.zoom / s_growth, 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 / s_growth_prev, - 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 @@ -3247,97 +3221,106 @@ 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]); } - // --- partage du zoom entre la boîte et la coupe (issue #179) ------------ - // La boîte prend une part du zoom pour déborder le padding. Le mapping image→écran, - // lui, ne doit PAS bouger : ces deux tests le figent, parce que c'est précisément ce - // qu'un partage mal fait casse en silence (zoom trop profond, focus qui dérive). + // --- 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]; - /// Le couple (boîte, coupe) réellement envoyé au GPU, pour une croissance donnée. - /// `u_max`/`v_max` à 1 et pas de crop : la coupe est donc directement en fractions. - fn mapping(base: [f32; 4], zoom: f32, focus: [f32; 2], g: f32) -> ([f32; 4], [f32; 4]) { - (grow_around_center(base, g), screen_source_rect(1.0, 1.0, None, zoom / g, focus)) + /// 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]), + ] } - /// Où le point de focus atterrit à l'écran, en fraction du CADRE. - fn focus_on_screen(base: [f32; 4], zoom: f32, focus: [f32; 2], g: f32) -> [f32; 2] { - let (dst, src) = mapping(base, zoom, focus, g); + /// 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(focus[0], src[0], src[2], dst[0], dst[2]), - at(focus[1], src[1], src[3], dst[1], dst[3]), + at(point[0], src[0], src[2], base[0], base[2]), + at(point[1], src[1], src[3], base[1], base[3]), ] } - /// Grossissement à l'écran : pixels de cadre par unité de source. - fn magnification(base: [f32; 4], zoom: f32, focus: [f32; 2], g: f32) -> f32 { - let (dst, src) = mapping(base, zoom, focus, g); - dst[2] / (src[2] - src[0]) + /// 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:?}" + ); + } + } + } } - /// L'invariant qui compte : donner une part du zoom à la boîte ne change NI le - /// grossissement NI la position du focus — seulement l'étendue dessinée. Balayé sur - /// des zooms et des focus variés, dont les bords. + /// 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 growing_the_zoom_frame_leaves_the_focus_where_it_was() { - for &zoom in &[1.0, 1.25, 1.5, 1.8, 2.2, 3.5, 5.0] { - for &focus in &[[0.5, 0.5], [0.3, 0.5], [0.5, 0.8], [0.12, 0.9], [0.0, 1.0]] { - let g = screen_zoom_growth(zoom, focus, PADDED); - assert!(g >= 1.0, "la boîte ne rétrécit jamais (zoom {zoom}, g {g})"); - let (was, now) = ( - focus_on_screen(PADDED, zoom, focus, 1.0), - focus_on_screen(PADDED, zoom, focus, g), - ); - assert!( - (was[0] - now[0]).abs() < 1e-4 && (was[1] - now[1]).abs() < 1e-4, - "focus déplacé (zoom {zoom}, focus {focus:?}, g {g}) : {was:?} → {now:?}" - ); - let (mw, mn) = ( - magnification(PADDED, zoom, focus, 1.0), - magnification(PADDED, zoom, focus, g), - ); + 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!( - (mw - mn).abs() < 1e-3, - "grossissement changé (zoom {zoom}, focus {focus:?}) : {mw} → {mn}" + (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:?}" + ); + } } } - } - - /// Ce que l'issue demande : passé le zoom qui couvre le cadre, la boîte l'atteint — - /// le padding a disparu. Et sans padding il n'y a rien à déborder : pas de croissance. - #[test] - fn the_zoom_frame_reaches_the_frame_edges_and_stops_there() { - let g = screen_zoom_growth(2.2, [0.5, 0.5], PADDED); - let dst = grow_around_center(PADDED, g); + // 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]); - // La borne est bien la couverture du cadre, pas une valeur de padding : une coupe - // letterboxée (bandes hautes/basses) continue de grandir jusqu'à les manger. - let letterboxed = [0.1, 0.36, 0.8, 0.28]; - let g = screen_zoom_growth(5.0, [0.5, 0.5], letterboxed); - let dst = grow_around_center(letterboxed, g); - assert!(dst[1] <= 0.0 && dst[1] + dst[3] >= 1.0, "bandes non résorbées : {dst:?}"); - - assert_eq!(screen_zoom_growth(2.0, [0.5, 0.5], [0.0, 0.0, 1.0, 1.0]), 1.0); - assert_eq!(screen_zoom_growth(1.0, [0.5, 0.5], PADDED), 1.0); - } - - /// Un zoom posé sur sa limite de focus a déjà tout dépensé à viser : rien pour la - /// boîte, et c'est le bon arbitrage — viser juste prime sur manger le padding. - #[test] - fn a_zoom_pinned_on_its_focus_limit_keeps_its_pan_rather_than_the_padding() { - // `getFocusBoundsForScale` (TS) borne déjà le focus à `1/(2·zoom)` du bord : pile - // l'endroit où la coupe source touche le bord du crop. Quelle que soit la - // profondeur, un focus posé DESSUS ne laisse aucune marge. - for &zoom in &[1.5, 2.2, 3.5] { - let edge = 1.0 / (2.0 * zoom); - assert_eq!(screen_zoom_growth(zoom, [edge, 0.5], PADDED), 1.0); - } - // À l'intérieur de ces bornes, en revanche, la boîte profite de la marge. - assert!(screen_zoom_growth(3.5, [0.35, 0.5], PADDED) > 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 --------------------