@@ -190,6 +190,63 @@ fn screen_source_rect(
190190 [ su0, sv0, su0 + 2.0 * hu, sv0 + 2.0 * hv]
191191}
192192
193+ /// Part du zoom absorbée par la BOÎTE de destination (le « zoom frame ») plutôt que par la
194+ /// coupe source — issue #179.
195+ ///
196+ /// Le zoom natif se joue entièrement dans le rect SOURCE (`screen_source_rect` rétrécit la
197+ /// coupe autour du focus) pendant que la boîte, elle, ne bouge pas : le zoom s'arrête donc
198+ /// à la frontière paddée au lieu d'atteindre les bords du cadre. La référence web fait
199+ /// l'inverse — `applyZoomTransform` (TS) met à l'échelle le CONTENEUR CAMÉRA, masque
200+ /// compris, donc la boîte paddée grandit avec le zoom et le padding s'efface.
201+ ///
202+ /// On répartit donc le zoom entre les deux : la boîte grandit d'un facteur `g`, la coupe
203+ /// source ne porte plus que le reste (`zoom / g`). Le PRODUIT reste `zoom`, donc le
204+ /// grossissement à l'écran est exactement celui demandé par la région. C'est tout l'enjeu
205+ /// du partage : un `g` qui *multiplierait* le zoom au lieu de le partager rendrait une
206+ /// région 1.25× à 1.56× (padding 50 %) — pas ce que la timeline annonce.
207+ ///
208+ /// Deux bornes sur `g` :
209+ /// * `cover` — la taille qui couvre le cadre. Au-delà, grandir n'ajoute rien (le rasterizer
210+ /// coupe) ; c'est exactement le point où le padding a fini de s'effacer. Elle se déduit du
211+ /// rect RÉEL, pas de `padding_scale` : une coupe letterboxée (crop 16:9 dans une sortie
212+ /// 9:16) a des bandes qu'aucun facteur de padding ne décrit.
213+ /// * le focus — la coupe source doit rester CENTRABLE sur le point visé, sinon
214+ /// `screen_source_rect` la recale contre le bord du crop et le zoom cesse de viser. Elle
215+ /// tient tant que `zoom / g >= 1 / (2·min(c, 1-c))`. C'est la même contrainte que
216+ /// `getFocusBoundsForScale` (TS) applique en amont en bornant le focus à `1/(2·zoom)` du
217+ /// bord : sur cette borne le budget vaut exactement 1 (le zoom part entièrement dans le
218+ /// pan), et il ne dépasse 1 qu'à l'intérieur. Un focus au bord garde donc son padding,
219+ /// un focus plus central le mange.
220+ fn screen_zoom_growth ( zoom : f32 , focus : [ f32 ; 2 ] , base : [ f32 ; 4 ] ) -> f32 {
221+ if !zoom. is_finite ( ) || zoom <= 1.0 {
222+ return 1.0 ;
223+ }
224+ let cover = ( 1.0 / base[ 2 ] . max ( 1e-4 ) ) . max ( 1.0 / base[ 3 ] . max ( 1e-4 ) ) ;
225+ // Marge restante de part et d'autre du focus, normalisée : 1 au centre, 0 sur un bord.
226+ // Même normalisation du focus que `screen_source_rect`, pour que les deux bornes parlent
227+ // du même point.
228+ let room = |c : f32 | {
229+ let c = if c. is_finite ( ) { c. clamp ( 0.0 , 1.0 ) } else { 0.5 } ;
230+ 2.0 * c. min ( 1.0 - c)
231+ } ;
232+ zoom. min ( cover) . min ( zoom * room ( focus[ 0 ] ) . min ( room ( focus[ 1 ] ) ) ) . max ( 1.0 )
233+ }
234+
235+ /// Grandit un rect autour de son CENTRE. C'est le seul ancrage qui laisse le point de focus
236+ /// tomber exactement là où il tombait sans croissance (cf. le test
237+ /// `growing_the_zoom_frame_leaves_the_focus_where_it_was`) : la boîte grandit de `g`, la
238+ /// coupe source rétrécit de `g`, et les deux décalages s'annulent au centre.
239+ ///
240+ /// Volontairement PAS clampé dans le cadre : un rect décentré (presets en bloc) glisserait
241+ /// de côté en grandissant — ce qui casserait justement cette annulation — et ses coins
242+ /// arrondis resteraient collés au bord du cadre au lieu d'en sortir. La référence ne clampe
243+ /// pas non plus (« No stage clamping », `frameRenderer.cameraAwareMaskRect`), et le
244+ /// rasterizer coupe ce qui dépasse (le fond flouté sort déjà du cadre de la même façon).
245+ fn grow_around_center ( rect : [ f32 ; 4 ] , g : f32 ) -> [ f32 ; 4 ] {
246+ let ( nw, nh) = ( rect[ 2 ] * g, rect[ 3 ] * g) ;
247+ [ rect[ 0 ] + ( rect[ 2 ] - nw) * 0.5 , rect[ 1 ] + ( rect[ 3 ] - nh) * 0.5 , nw, nh]
248+ }
249+
193250/// Sous-rect SOURCE (en UV de texture) qui remplit une boîte de ratio `box_ar` **sans
194251/// déformer** l'image : le plus grand rect centré ayant ce ratio, tiré de la frame
195252/// visible — l'équivalent de `object-fit: cover` côté web.
@@ -1842,46 +1899,26 @@ impl Compositor {
18421899 fit_dst_to_aspect ( scale_frame ( dst, padding_scale) , crop_aspect)
18431900 }
18441901 } ;
1845- // ponytail (issue #179) : le rect destination de l'écran ("zoom frame")
1846- // grandit avec le zoom, du rect paddé jusqu'au cadre complet — plus
1847- // jamais le switch binaire d'avant (padded → [0,0,1,1] en une frame,
1848- // qui faisait disparaître le padding instantanément et envoyait l'ombre
1849- // et les coins sur tout l'output). Le source rect (computé par
1850- // `screen_source_rect` plus bas) et le zoom frame sont liés : le GPU
1851- // mappe le source rect sur le zoom frame, et le padding est le delta
1852- // entre le zoom frame et le cadre. Le padding s'efface smoothly à
1853- // mesure que le zoom monte, et disparaît pile quand le zoom atteint
1854- // `1 / padding_scale` (= cadre / padded_size) — au-delà, le zoom
1855- // frame reste au cadre et seul le source rect continue de rétrécir
1856- // (le GPU upscale davantage). Le media reste à pleine résolution
1857- // toute la durée : c'est le *mapping* qui s'ajuste, pas la texture.
1902+ // Issue #179 : le zoom se jouait entièrement dans la coupe source, donc la boîte
1903+ // écran restait au rect paddé et le zoom s'arrêtait à cette frontière au lieu
1904+ // d'atteindre les bords du cadre. On en donne désormais une part à la BOÎTE
1905+ // (`screen_zoom_growth`) et on retire cette même part à la coupe source (plus bas,
1906+ // `p.zoom / s_growth`).
18581907 //
1859- // TODO : si on veut que l'ombre et les coins restent ancrés au rect
1860- // paddé pendant que le contenu déborde (effet "fenêtre"), il faudra
1861- // séparer le rect "cadre" du rect "contenu" et appliquer l'ombre et
1862- // les coins au cadre seul. Pour l'instant ils suivent le zoom frame,
1863- // ce qui donne un rendu cohérent (le cadre "suit" le zoom) sans
1864- // l'abrupt switch.
1865- let zoom_grow = |zoom : f32 , base : [ f32 ; 4 ] | -> [ f32 ; 4 ] {
1866- // growth = min(zoom, 1/padding_scale) : on sature pile quand le
1867- // zoom frame atteint le cadre. padding_scale est dans (0, 1] ;
1868- // `max(0.0001)` évite une division par 0 sur des fixtures
1869- // pathologiques (padding = 100% clampé à 0.6 → 1.667, jamais
1870- // infini en pratique).
1871- let growth = zoom. min ( 1.0 / padding_scale. max ( 0.0001 ) ) ;
1872- let cx = base[ 0 ] + base[ 2 ] * 0.5 ;
1873- let cy = base[ 1 ] + base[ 3 ] * 0.5 ;
1874- let nw = ( base[ 2 ] * growth) . min ( 1.0 ) ;
1875- let nh = ( base[ 3 ] * growth) . min ( 1.0 ) ;
1876- [
1877- ( cx - nw * 0.5 ) . max ( 0.0 ) . min ( 1.0 - nw) ,
1878- ( cy - nh * 0.5 ) . max ( 0.0 ) . min ( 1.0 - nh) ,
1879- nw,
1880- nh,
1881- ]
1882- } ;
1883- let s_dst = zoom_grow ( p. zoom , fit_screen ( p. screen . dst ) ) ;
1884- let s_dst_prev = zoom_grow ( pp. zoom , fit_screen ( pp. screen . dst ) ) ;
1908+ // Le point clé : (boîte, coupe) forment UN SEUL mapping affine image→écran, et le
1909+ // partager ne le change pas — même grossissement, même point de focus au même
1910+ // endroit (les deux tests plus bas le figent). Tout ce qui roule sur ce mapping —
1911+ // le curseur, le tilt 3D, le motion blur — est donc inchangé par construction ; la
1912+ // seule différence est l'ÉTENDUE dessinée, qui déborde maintenant le padding.
1913+ // C'est exactement ce que demande l'issue, et rien de plus.
1914+ let s_base = fit_screen ( p. screen . dst ) ;
1915+ let s_base_prev = fit_screen ( pp. screen . dst ) ;
1916+ // `p.focus` (et non `pp.focus`) pour la frame précédente : même choix que la coupe
1917+ // source plus bas, qui garde volontairement le focus courant.
1918+ let s_growth = screen_zoom_growth ( p. zoom , p. focus , s_base) ;
1919+ let s_growth_prev = screen_zoom_growth ( pp. zoom , p. focus , s_base_prev) ;
1920+ let s_dst = grow_around_center ( s_base, s_growth) ;
1921+ let s_dst_prev = grow_around_center ( s_base_prev, s_growth_prev) ;
18851922 // le padding n'affecte QUE l'écran (la quantité de fond révélée). La webcam reste ancrée
18861923 // en bas-droite à sa marge fixe, quelle que soit la valeur de padding (pas de scale_frame)
18871924 // — SAUF quand l'app a résolu un placement explicite (`app_webcam_rect`, drag-to-reposition
@@ -1952,19 +1989,9 @@ impl Compositor {
19521989 let s_min_px = ( s_dst[ 2 ] * self . rw ( ) ) . min ( s_dst[ 3 ] * self . rh ( ) ) ;
19531990 let app_screen_radius_frac = scene_ref. as_ref ( ) . and_then ( |s| s. layout . screen_radius_frac ) ;
19541991 let scene_roundness_frac = scene_ref. as_ref ( ) . map ( |s| s. effects . roundness_frac ) ;
1955- // Quand le contenu déborde du padding (zoom actif), il n'y a plus de cadre visible
1956- // (le rect écran a été étendu aux bords du cadre de sortie, cf. issue #179) :
1957- // neutraliser le rayon plutôt que de le laisser suivre l'expansion. Sur le rect
1958- // plein cadre, `f * s_min_px` arrondit TOUT l'output (le `s_min_px` devient
1959- // `min(rw, rh)`), pas seulement l'écran — et `f * frame_min_px` fait pareil
1960- // (les deux quantités coïncident quand s_dst = [0,0,1,1]). TODO : à la place,
1961- // séparer "rect contenu" de "rect cadre" pour garder les coins au rect paddé
1962- // pendant que le contenu déborde (cf. le TODO sur `s_dst` plus haut).
1963- //
1964- // (révision : la croissance de `s_dst` est *continue* via `zoom_grow` au-dessus,
1965- // donc `s_min_px` suit le zoom frame et le rayon aussi — pas de switch
1966- // binaire. Le `if p.zoom > 1.0 { 0.0 }` n'est plus nécessaire ; on le
1967- // retire pour laisser le rayon suivre naturellement le zoom frame.)
1992+ // Le rayon suit la boîte : quand le zoom l'agrandit (issue #179), les coins grandissent
1993+ // avec elle puis sortent du cadre — comme le masque de la référence, qui porte le même
1994+ // `br: maskBorderRadius * camS` et quitte l'étage au même moment.
19681995 let s_radius = match ( cfg. rounded , app_screen_radius_frac, scene_roundness_frac) {
19691996 ( false , _, _) => 0.0 ,
19701997 // Preset en bloc : le rayon appartient à la boîte écran (parité exacte avec la caméra).
@@ -2114,12 +2141,20 @@ impl Compositor {
21142141 None => uv,
21152142 }
21162143 } ;
2144+ // `/ s_growth` : la boîte a déjà absorbé cette part du zoom (cf. `screen_zoom_growth`).
2145+ // Passer `p.zoom` entier ici rezoomerait par-dessus la boîte agrandie et rendrait la
2146+ // région plus profonde que ce que la timeline annonce.
21172147 let [ su0, sv0, su1, sv1] =
2118- cover ( screen_source_rect ( u_max, v_max, active_crop, p. zoom , p. focus ) ) ;
2148+ cover ( screen_source_rect ( u_max, v_max, active_crop, p. zoom / s_growth , p. focus ) ) ;
21192149 let ( hu, hv) = ( ( su1 - su0) * 0.5 , ( sv1 - sv0) * 0.5 ) ;
21202150 // Le focus courant reste volontairement utilisé pour la frame précédente, comme avant.
2121- let [ su0_p, sv0_p, su1_p, sv1_p] =
2122- cover ( screen_source_rect ( u_max, v_max, active_crop, pp. zoom , p. focus ) ) ;
2151+ let [ su0_p, sv0_p, su1_p, sv1_p] = cover ( screen_source_rect (
2152+ u_max,
2153+ v_max,
2154+ active_crop,
2155+ pp. zoom / s_growth_prev,
2156+ p. focus ,
2157+ ) ) ;
21232158 let ( hu_p, hv_p) = ( ( su1_p - su0_p) * 0.5 , ( sv1_p - sv0_p) * 0.5 ) ;
21242159 // Géométrie du tilt, calculée UNE fois : l'ombre et l'écran doivent porter exactement le
21252160 // même quadrilatère. Deux calculs séparés, c'est une ombre qui se décolle dès qu'un des
@@ -2130,13 +2165,9 @@ impl Compositor {
21302165 [ ( s_dst[ 0 ] + s_dst[ 2 ] * 0.5 ) * self . rw ( ) , ( s_dst[ 1 ] + s_dst[ 3 ] * 0.5 ) * self . rh ( ) ] ;
21312166 // L'ombre suit la silhouette réellement affichée : le rect arrondi quand l'écran est
21322167 // droit, le quadrilatère projeté quand il est incliné. Un rect droit derrière un écran
2133- // penché ne se lisait pas comme son ombre mais comme une seconde surface.
2134- //
2135- // (révision : `s_dst` croît continuously avec le zoom via `zoom_grow`, donc
2136- // l'ombre suit naturellement le zoom frame. Le `p.zoom <= 1.0` gate d'avant
2137- // n'est plus nécessaire et on le retire — sinon on aurait une ombre
2138- // qui disparaît d'un coup quand le zoom démarre, aussi abrupt que le
2139- // switch binaire de `s_dst`.)
2168+ // penché ne se lisait pas comme son ombre mais comme une seconde surface. Elle suit
2169+ // aussi la croissance de la boîte pendant un zoom (issue #179) : quand la boîte sort
2170+ // du cadre, l'ombre en sort avec elle, sans jamais se lire comme une bande noire.
21402171 if cfg. shadow {
21412172 let spread = SCREEN_SHADOW_SPREAD_FRAC * frame_min_px;
21422173 let offset = [ 0.0 , SCREEN_SHADOW_OFFSET_FRAC * frame_min_px] ;
@@ -3376,6 +3407,99 @@ mod tests {
33763407 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 ] ) ;
33773408 }
33783409
3410+ // --- partage du zoom entre la boîte et la coupe (issue #179) ------------
3411+ // La boîte prend une part du zoom pour déborder le padding. Le mapping image→écran,
3412+ // lui, ne doit PAS bouger : ces deux tests le figent, parce que c'est précisément ce
3413+ // qu'un partage mal fait casse en silence (zoom trop profond, focus qui dérive).
3414+
3415+ /// Boîte paddée (padding 50 % → `scale_frame` 0.8) dans une sortie carrée : le cas
3416+ /// plein cadre de l'issue.
3417+ const PADDED : [ f32 ; 4 ] = [ 0.1 , 0.1 , 0.8 , 0.8 ] ;
3418+
3419+ /// Le couple (boîte, coupe) réellement envoyé au GPU, pour une croissance donnée.
3420+ /// `u_max`/`v_max` à 1 et pas de crop : la coupe est donc directement en fractions.
3421+ fn mapping ( base : [ f32 ; 4 ] , zoom : f32 , focus : [ f32 ; 2 ] , g : f32 ) -> ( [ f32 ; 4 ] , [ f32 ; 4 ] ) {
3422+ ( grow_around_center ( base, g) , screen_source_rect ( 1.0 , 1.0 , None , zoom / g, focus) )
3423+ }
3424+
3425+ /// Où le point de focus atterrit à l'écran, en fraction du CADRE.
3426+ fn focus_on_screen ( base : [ f32 ; 4 ] , zoom : f32 , focus : [ f32 ; 2 ] , g : f32 ) -> [ f32 ; 2 ] {
3427+ let ( dst, src) = mapping ( base, zoom, focus, g) ;
3428+ let at = |f : f32 , s0 : f32 , s1 : f32 , d0 : f32 , dw : f32 | d0 + dw * ( f - s0) / ( s1 - s0) ;
3429+ [
3430+ at ( focus[ 0 ] , src[ 0 ] , src[ 2 ] , dst[ 0 ] , dst[ 2 ] ) ,
3431+ at ( focus[ 1 ] , src[ 1 ] , src[ 3 ] , dst[ 1 ] , dst[ 3 ] ) ,
3432+ ]
3433+ }
3434+
3435+ /// Grossissement à l'écran : pixels de cadre par unité de source.
3436+ fn magnification ( base : [ f32 ; 4 ] , zoom : f32 , focus : [ f32 ; 2 ] , g : f32 ) -> f32 {
3437+ let ( dst, src) = mapping ( base, zoom, focus, g) ;
3438+ dst[ 2 ] / ( src[ 2 ] - src[ 0 ] )
3439+ }
3440+
3441+ /// L'invariant qui compte : donner une part du zoom à la boîte ne change NI le
3442+ /// grossissement NI la position du focus — seulement l'étendue dessinée. Balayé sur
3443+ /// des zooms et des focus variés, dont les bords.
3444+ #[ test]
3445+ fn growing_the_zoom_frame_leaves_the_focus_where_it_was ( ) {
3446+ for & zoom in & [ 1.0 , 1.25 , 1.5 , 1.8 , 2.2 , 3.5 , 5.0 ] {
3447+ for & focus in & [ [ 0.5 , 0.5 ] , [ 0.3 , 0.5 ] , [ 0.5 , 0.8 ] , [ 0.12 , 0.9 ] , [ 0.0 , 1.0 ] ] {
3448+ let g = screen_zoom_growth ( zoom, focus, PADDED ) ;
3449+ assert ! ( g >= 1.0 , "la boîte ne rétrécit jamais (zoom {zoom}, g {g})" ) ;
3450+ let ( was, now) = (
3451+ focus_on_screen ( PADDED , zoom, focus, 1.0 ) ,
3452+ focus_on_screen ( PADDED , zoom, focus, g) ,
3453+ ) ;
3454+ assert ! (
3455+ ( was[ 0 ] - now[ 0 ] ) . abs( ) < 1e-4 && ( was[ 1 ] - now[ 1 ] ) . abs( ) < 1e-4 ,
3456+ "focus déplacé (zoom {zoom}, focus {focus:?}, g {g}) : {was:?} → {now:?}"
3457+ ) ;
3458+ let ( mw, mn) = (
3459+ magnification ( PADDED , zoom, focus, 1.0 ) ,
3460+ magnification ( PADDED , zoom, focus, g) ,
3461+ ) ;
3462+ assert ! (
3463+ ( mw - mn) . abs( ) < 1e-3 ,
3464+ "grossissement changé (zoom {zoom}, focus {focus:?}) : {mw} → {mn}"
3465+ ) ;
3466+ }
3467+ }
3468+ }
3469+
3470+ /// Ce que l'issue demande : passé le zoom qui couvre le cadre, la boîte l'atteint —
3471+ /// le padding a disparu. Et sans padding il n'y a rien à déborder : pas de croissance.
3472+ #[ test]
3473+ fn the_zoom_frame_reaches_the_frame_edges_and_stops_there ( ) {
3474+ let g = screen_zoom_growth ( 2.2 , [ 0.5 , 0.5 ] , PADDED ) ;
3475+ let dst = grow_around_center ( PADDED , g) ;
3476+ assert_rect ( dst, [ 0.0 , 0.0 , 1.0 , 1.0 ] ) ;
3477+ // La borne est bien la couverture du cadre, pas une valeur de padding : une coupe
3478+ // letterboxée (bandes hautes/basses) continue de grandir jusqu'à les manger.
3479+ let letterboxed = [ 0.1 , 0.36 , 0.8 , 0.28 ] ;
3480+ let g = screen_zoom_growth ( 5.0 , [ 0.5 , 0.5 ] , letterboxed) ;
3481+ let dst = grow_around_center ( letterboxed, g) ;
3482+ assert ! ( dst[ 1 ] <= 0.0 && dst[ 1 ] + dst[ 3 ] >= 1.0 , "bandes non résorbées : {dst:?}" ) ;
3483+
3484+ assert_eq ! ( screen_zoom_growth( 2.0 , [ 0.5 , 0.5 ] , [ 0.0 , 0.0 , 1.0 , 1.0 ] ) , 1.0 ) ;
3485+ assert_eq ! ( screen_zoom_growth( 1.0 , [ 0.5 , 0.5 ] , PADDED ) , 1.0 ) ;
3486+ }
3487+
3488+ /// Un zoom posé sur sa limite de focus a déjà tout dépensé à viser : rien pour la
3489+ /// boîte, et c'est le bon arbitrage — viser juste prime sur manger le padding.
3490+ #[ test]
3491+ fn a_zoom_pinned_on_its_focus_limit_keeps_its_pan_rather_than_the_padding ( ) {
3492+ // `getFocusBoundsForScale` (TS) borne déjà le focus à `1/(2·zoom)` du bord : pile
3493+ // l'endroit où la coupe source touche le bord du crop. Quelle que soit la
3494+ // profondeur, un focus posé DESSUS ne laisse aucune marge.
3495+ for & zoom in & [ 1.5 , 2.2 , 3.5 ] {
3496+ let edge = 1.0 / ( 2.0 * zoom) ;
3497+ assert_eq ! ( screen_zoom_growth( zoom, [ edge, 0.5 ] , PADDED ) , 1.0 ) ;
3498+ }
3499+ // À l'intérieur de ces bornes, en revanche, la boîte profite de la marge.
3500+ assert ! ( screen_zoom_growth( 3.5 , [ 0.35 , 0.5 ] , PADDED ) > 1.0 ) ;
3501+ }
3502+
33793503 // --- cover_crop_uv : la caméra n'est jamais étirée --------------------
33803504 // Le ratio de la coupe source, ramené en pixels d'image, doit TOUJOURS égaler
33813505 // celui de la boîte : c'est la définition de « pas de déformation ».
0 commit comments