@@ -1691,7 +1691,8 @@ pub struct FrameGeometry {
16911691 /// La rotation de base une frame d'écran plus tôt : avec `s_dst_prev` et `camera_prev`, le
16921692 /// zoom d'AVANT, que le mode 8 floute (`tilt_trail`).
16931693 pub zoom_rotation_prev : [ f32 ; 3 ] ,
1694- /// La caméra réelle une frame plus tôt : son poids et son zoom d'avant, sa visée d'à présent.
1694+ /// La caméra réelle une frame plus tôt (poids, visée, orbite, zoom), `None` si elle n'y était
1695+ /// pas active — même quand elle l'est à cette frame-ci, ou l'inverse.
16951696 pub camera_prev : Option < crate :: camera:: CameraPose > ,
16961697 pub padding_scale : f32 ,
16971698 /// Coupe source de l'écran en UV texture (crop utilisateur + zoom).
@@ -1843,9 +1844,9 @@ impl FrameGeometry {
18431844 /// floute (`tilted_screen_cb`). `None` quand l'écran est droit ou le flou coupé.
18441845 ///
18451846 /// La frame d'avant est celle du mode 0 : la boîte au zoom d'avant (`s_dst_prev`), et ici la
1846- /// rotation de base d'avant (un angle fixe entre avec le zoom) ou le poids de la caméra réelle.
1847- /// Parallaxe, impact et visée restent ceux de la frame, comme le focus du mode 0 : le flou suit
1848- /// le zoom, borné à une frame.
1847+ /// rotation de base d'avant (un angle fixe entre avec le zoom) ou la caméra réelle d'avant
1848+ /// (`camera_prev`). Parallaxe et impact restent ceux de la frame, comme le focus du mode 0 : le
1849+ /// flou suit le zoom et la caméra , borné à une frame.
18491850 pub fn tilt_trail ( & self , render_px : [ f32 ; 2 ] ) -> Option < TiltTrail > {
18501851 if !self . tilted ( ) || self . mb_taps < 2.0 || self . mb_amount <= 0.001 {
18511852 return None ;
@@ -2390,8 +2391,7 @@ impl FrameGeometry {
23902391 return Some ( mask) ;
23912392 }
23922393
2393- // Écran incliné (le mode 8 n'a pas de flou de mouvement : pas de trace à couvrir).
2394- // Même quad et même centre que le dessin de l'écran et que `plan_cursor`.
2394+ // Écran incliné. Même quad et même centre que le dessin de l'écran et que `plan_cursor`.
23952395 let s_px = [ self . s_dst [ 2 ] * rw, self . s_dst [ 3 ] * rh] ;
23962396 let quad = self . screen_tilt ( s_px) ?;
23972397 let centre = [
@@ -2411,6 +2411,42 @@ impl FrameGeometry {
24112411 [ centre[ 0 ] + px, centre[ 1 ] + py]
24122412 } ;
24132413 let pts = [ at ( x0, y0) , at ( x1, y0) , at ( x1, y1) , at ( x0, y1) ] ;
2414+ // Le mode 8 étale aussi chaque pixel vers là où le plan était une frame plus tôt
2415+ // (`tilt_trail`) : une copie traînée du secret sort du quad courant. Le masque couvre alors
2416+ // la boîte englobante du secret aux deux frames, en rect droit, comme le chemin droit.
2417+ // Même warp que le shader sur les coins d'avant (le drapeau projectif est celui du plan).
2418+ // La perspective grossit localement le côté proche : la force suit l'arête la plus
2419+ // agrandie par rapport au rect droit zoomé, pour que nulle part le masque ne soit plus
2420+ // fin, rapporté au contenu, qu'au repos.
2421+ let ( ew, eh) = ( ( x1 - x0) * s_px[ 0 ] , ( y1 - y0) * s_px[ 1 ] ) ;
2422+ let len = |a : [ f32 ; 2 ] , b : [ f32 ; 2 ] | ( b[ 0 ] - a[ 0 ] ) . hypot ( b[ 1 ] - a[ 1 ] ) ;
2423+ let tilt_k = ( len ( pts[ 0 ] , pts[ 1 ] ) / ew)
2424+ . max ( len ( pts[ 3 ] , pts[ 2 ] ) / ew)
2425+ . max ( len ( pts[ 0 ] , pts[ 3 ] ) / eh)
2426+ . max ( len ( pts[ 1 ] , pts[ 2 ] ) / eh) ;
2427+ let strength = ( zoom_k * tilt_k) . max ( 1.0 ) ;
2428+ // Sans mouvement, la traînée EST le quad (au bit près) : rien de plus à couvrir.
2429+ if let Some ( trail) = self . tilt_trail ( render_px) . filter ( |t| t. corners != quad. corners ) {
2430+ let before = crate :: regions:: TiltedQuad { corners : trail. corners , ..quad } ;
2431+ let then = |fx : f32 , fy : f32 | {
2432+ let ( px, py) = before. point_px ( fx, fy) ;
2433+ [ centre[ 0 ] + px, centre[ 1 ] + py]
2434+ } ;
2435+ let ( mut lo, mut hi) = ( [ f32:: MAX ; 2 ] , [ f32:: MIN ; 2 ] ) ;
2436+ for [ px, py] in pts. into_iter ( ) . chain ( [ then ( x0, y0) , then ( x1, y0) , then ( x1, y1) , then ( x0, y1) ] ) {
2437+ ( lo, hi) = ( [ lo[ 0 ] . min ( px) , lo[ 1 ] . min ( py) ] , [ hi[ 0 ] . max ( px) , hi[ 1 ] . max ( py) ] ) ;
2438+ }
2439+ let rect =
2440+ [ lo[ 0 ] / rw, lo[ 1 ] / rh, ( hi[ 0 ] - lo[ 0 ] ) . max ( 1.0 ) / rw, ( hi[ 1 ] - lo[ 1 ] ) . max ( 1.0 ) / rh] ;
2441+ let rect = match self . screen_mask {
2442+ Some ( m) => m. clip ( rect) ?,
2443+ None => rect,
2444+ } ;
2445+ let mut mask = PrivacyMask :: upright ( rect, render_px, strength) ;
2446+ // Un ovale inscrit dans la boîte élargie ne couvrirait plus l'ovale courant.
2447+ mask. oval_ok = false ;
2448+ return Some ( mask) ;
2449+ }
24142450 let ( mut min_x, mut min_y) = ( f32:: MAX , f32:: MAX ) ;
24152451 let ( mut max_x, mut max_y) = ( f32:: MIN , f32:: MIN ) ;
24162452 for [ px, py] in pts {
@@ -2432,21 +2468,12 @@ impl FrameGeometry {
24322468 quad_px = [ c[ 2 ] * rw, c[ 3 ] * rh] ;
24332469 dst = c;
24342470 }
2435- // La perspective grossit localement le côté proche : la force suit l'arête la plus
2436- // agrandie par rapport au rect droit zoomé, pour que nulle part le masque ne soit plus
2437- // fin, rapporté au contenu, qu'au repos.
2438- let ( ew, eh) = ( ( x1 - x0) * s_px[ 0 ] , ( y1 - y0) * s_px[ 1 ] ) ;
2439- let len = |a : [ f32 ; 2 ] , b : [ f32 ; 2 ] | ( b[ 0 ] - a[ 0 ] ) . hypot ( b[ 1 ] - a[ 1 ] ) ;
2440- let tilt_k = ( len ( pts[ 0 ] , pts[ 1 ] ) / ew)
2441- . max ( len ( pts[ 3 ] , pts[ 2 ] ) / ew)
2442- . max ( len ( pts[ 0 ] , pts[ 3 ] ) / eh)
2443- . max ( len ( pts[ 1 ] , pts[ 2 ] ) / eh) ;
24442471 Some ( PrivacyMask {
24452472 dst,
24462473 quad_px,
24472474 warp : Some ( local) ,
24482475 projective : quad. projective ,
2449- strength : ( zoom_k * tilt_k ) . max ( 1.0 ) ,
2476+ strength,
24502477 oval_ok : true ,
24512478 } )
24522479 }
@@ -2620,6 +2647,7 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
26202647 let mut zoom_camera_prev = 0.0f32 ;
26212648 let mut zoom_aim = [ 0.5f32 ; 2 ] ;
26222649 let mut zoom_orbit = [ 0.5f32 ; 2 ] ;
2650+ let ( mut zoom_aim_prev, mut zoom_orbit_prev) = ( [ 0.5f32 ; 2 ] , [ 0.5f32 ; 2 ] ) ;
26232651 // Curseur masqué → pas de piste pour ce qui anime le plan (parallaxe, impact, caméra
26242652 // `follow-cursor`) : l'export ne charge la piste que si le curseur est affiché
26252653 // (`timeline_walk`), la preview toujours. Sans cette porte, la preview pencherait un
@@ -2649,17 +2677,21 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
26492677 zoom_camera = zs. camera ;
26502678 zoom_aim = zs. aim ;
26512679 zoom_orbit = zs. orbit ;
2680+ // Même `CameraFrame` qu'à la frame courante : la caméra réelle d'avant (poids, visée,
2681+ // orbite) sert au flou du mode 8. Échelle et focus n'en dépendent pas.
26522682 let zs_p = crate :: regions:: zoom_state_in (
26532683 zoom_regions,
26542684 source_t_prev,
26552685 cursor_for_zoom,
2656- & crate :: regions :: CameraFrame :: NONE ,
2686+ & camera ,
26572687 & clock,
26582688 ) ;
26592689 pp. zoom = zs_p. scale ;
26602690 pp. focus = zs_p. focus ;
26612691 zoom_rotation_prev = zs_p. rotation ;
26622692 zoom_camera_prev = zs_p. camera ;
2693+ zoom_aim_prev = zs_p. aim ;
2694+ zoom_orbit_prev = zs_p. orbit ;
26632695 }
26642696 // Full Camera ignore le rétrécissement réactif de la webcam (design web : mélanger
26652697 // "rétrécit pour le zoom" et "grandit en plein cadre" dans la même frame n'a pas de sens).
@@ -2872,10 +2904,15 @@ pub fn plan_frame(input: &FrameGeometryInput) -> FrameGeometry {
28722904 zoom : p. zoom ,
28732905 press,
28742906 } ) ;
2875- let camera_prev = camera. map ( |pose| crate :: camera:: CameraPose {
2907+ // La caméra réelle de la frame d'avant, dès qu'elle y était active — y compris quand elle
2908+ // ne l'est plus à celle-ci (fin de sa moitié d'une transition vers un angle fixe) ; `None`
2909+ // quand elle ne l'était pas, et le mode 8 reprend alors la rotation d'avant.
2910+ let camera_prev = ( zoom_camera_prev > 0.0 ) . then ( || crate :: camera:: CameraPose {
28762911 weight : zoom_camera_prev,
2912+ aim : focus_in_cut ( u_max, v_max, active_crop, zoom_aim_prev, cut) ,
2913+ orbit : focus_in_cut ( u_max, v_max, active_crop, zoom_orbit_prev, cut) ,
28772914 zoom : pp. zoom ,
2878- ..pose
2915+ press ,
28792916 } ) ;
28802917 let focus_plane = match camera {
28812918 Some ( pose) => pose. orbit ,
@@ -5714,6 +5751,45 @@ mod tests {
57145751 assert_eq ! ( at( & ramp( zoomed_golden_scene_json( ) ) , 1.6 ) . tilt_trail( render) , None ) ;
57155752 }
57165753
5754+ /// Transition chaînée caméra réelle → angle fixe : la frame d'avant peut encore être dans la
5755+ /// moitié `follow-cursor` quand celle-ci est dans la moitié fixe. La traînée doit alors partir
5756+ /// du plan vu par la caméra d'AVANT, pas d'un plan droit.
5757+ #[ test]
5758+ fn the_trail_keeps_the_previous_camera_across_a_follow_to_fixed_handover ( ) {
5759+ let cfg = crate :: config:: all ( ) . pop ( ) . expect ( "au moins une config" ) ;
5760+ let render = [ 1170.0 , 658.0 ] ;
5761+ let track: & ' static crate :: cursor:: CursorTrack = Box :: leak ( Box :: new (
5762+ crate :: cursor:: CursorTrack :: new ( ( 0 ..=300 ) . map ( |i| ( i as f32 / 30.0 , 0.8 , 0.3 ) ) . collect ( ) , vec ! [ ] , vec ! [ ] ) ,
5763+ ) ) ;
5764+ let json = zoomed_golden_scene_json ( ) . replace (
5765+ r#"[{"clipIndex":0,"startSec":0.0,"endSec":5.0,"scale":2.0,"focusX":0.5,"focusY":0.3,"rotation":"none"}]"# ,
5766+ r#"[{"clipIndex":0,"startSec":1.0,"endSec":4.0,"scale":2.0,"focusX":0.5,"focusY":0.5,"rotation":"follow-cursor"},
5767+ {"clipIndex":0,"startSec":4.5,"endSec":8.0,"scale":2.0,"focusX":0.5,"focusY":0.5,"rotation":"iso"}]"# ,
5768+ ) ;
5769+ assert_ne ! ( json, zoomed_golden_scene_json( ) , "la substitution doit poser les deux régions" ) ;
5770+ let scene = Scene :: from_json ( & json) . expect ( "scène" ) ;
5771+ let mut handover = None ;
5772+ for k in 0 ..=600 {
5773+ let t = 4.0 + k as f32 / 600.0 ;
5774+ let g = plan_frame ( & FrameGeometryInput { cursor : Some ( track) , timeline_t_override : Some ( t) , ..golden_input ( & scene, & cfg) } ) ;
5775+ assert_eq ! ( g. camera_prev. is_some( ) , g. camera_prev. is_some_and( |p| p. weight > 0.0 ) ) ;
5776+ if g. camera . is_none ( ) && g. camera_prev . is_some ( ) {
5777+ handover = Some ( g) ;
5778+ break ;
5779+ }
5780+ }
5781+ let g = handover. expect ( "une frame où seule la frame d'avant a la caméra" ) ;
5782+ let trail = g. tilt_trail ( render) . expect ( "traînée" ) ;
5783+ let d = g. s_dst_prev ;
5784+ let px = [ d[ 2 ] * render[ 0 ] , d[ 3 ] * render[ 1 ] ] ;
5785+ let seen = crate :: camera:: View :: new ( px, g. camera_prev . unwrap ( ) ) . quad ( px) ;
5786+ let upright = crate :: regions:: rotated_quad_corners_px ( px[ 0 ] , px[ 1 ] , g. zoom_rotation_prev , g. zoom_rotation_dyn ) ;
5787+ let shape = |c : [ ( f32 , f32 ) ; 4 ] | c. map ( |( x, y) | ( x - c[ 0 ] . 0 , y - c[ 0 ] . 1 ) ) ;
5788+ let close = |a : [ ( f32 , f32 ) ; 4 ] , b : [ ( f32 , f32 ) ; 4 ] | a. iter ( ) . zip ( b) . all ( |( p, q) | ( p. 0 - q. 0 ) . abs ( ) < 1e-3 && ( p. 1 - q. 1 ) . abs ( ) < 1e-3 ) ;
5789+ assert ! ( close( shape( trail. corners) , shape( seen. corners) ) , "la traînée n'est pas le plan de la caméra d'avant" ) ;
5790+ assert ! ( !close( shape( trail. corners) , shape( upright. corners) ) , "la traînée est retombée sur un plan droit" ) ;
5791+ }
5792+
57175793 /// Le calque du mode 8 porte la traînée dans le repère de ses propres coins, y compris
57185794 /// reporté dans le slot d'un layout en bloc ; sans elle, rien de ce qu'il portait ne change.
57195795 #[ test]
0 commit comments