@@ -522,6 +522,104 @@ impl ProjectUniforms {
522522 end - display_offset
523523 }
524524
525+ fn auto_zoom_focus (
526+ cursor_events : & CursorEvents ,
527+ time_secs : f32 ,
528+ smoothing : Option < SpringMassDamperSimulationConfig > ,
529+ current_cursor : Option < InterpolatedCursorPosition > ,
530+ ) -> Coord < RawDisplayUVSpace > {
531+ const PREVIOUS_SAMPLE_DELTA : f32 = 0.1 ;
532+ const MIN_LOOKAHEAD : f64 = 0.05 ;
533+ const MAX_LOOKAHEAD : f64 = 0.18 ;
534+ const MIN_FOLLOW_FACTOR : f64 = 0.2 ;
535+ const MAX_FOLLOW_FACTOR : f64 = 0.65 ;
536+ const SPEED_RESPONSE : f64 = 12.0 ;
537+ const VELOCITY_BLEND : f64 = 0.25 ;
538+ const MAX_SHIFT : f64 = 0.25 ;
539+ const MIN_SPEED : f64 = 0.002 ;
540+
541+ let fallback = Coord :: < RawDisplayUVSpace > :: new ( XY :: new ( 0.5 , 0.5 ) ) ;
542+
543+ let current_cursor = match current_cursor
544+ . or_else ( || interpolate_cursor ( cursor_events, time_secs, smoothing) )
545+ {
546+ Some ( cursor) => cursor,
547+ None => return fallback,
548+ } ;
549+
550+ let previous_time = ( time_secs - PREVIOUS_SAMPLE_DELTA ) . max ( 0.0 ) ;
551+ let previous_cursor = if previous_time < time_secs {
552+ interpolate_cursor ( cursor_events, previous_time, smoothing)
553+ } else {
554+ None
555+ } ;
556+
557+ let current_position = current_cursor. position . coord ;
558+ let previous_position = previous_cursor
559+ . as_ref ( )
560+ . map ( |c| c. position . coord )
561+ . unwrap_or ( current_position) ;
562+
563+ let delta_time = ( time_secs - previous_time) . max ( f32:: EPSILON ) as f64 ;
564+
565+ let simulation_velocity = XY :: new (
566+ current_cursor. velocity . x as f64 ,
567+ current_cursor. velocity . y as f64 ,
568+ ) ;
569+
570+ let finite_velocity = if previous_cursor. is_some ( ) {
571+ ( current_position - previous_position) / delta_time
572+ } else {
573+ XY :: new ( 0.0 , 0.0 )
574+ } ;
575+
576+ let mut velocity = if smoothing. is_some ( ) {
577+ simulation_velocity * ( 1.0 - VELOCITY_BLEND ) + finite_velocity * VELOCITY_BLEND
578+ } else {
579+ finite_velocity
580+ } ;
581+
582+ if velocity. x . is_nan ( ) || velocity. y . is_nan ( ) {
583+ velocity = XY :: new ( 0.0 , 0.0 ) ;
584+ }
585+
586+ let speed = ( velocity. x * velocity. x + velocity. y * velocity. y ) . sqrt ( ) ;
587+
588+ if speed < MIN_SPEED {
589+ return Coord :: new ( XY :: new (
590+ current_position. x . clamp ( 0.0 , 1.0 ) ,
591+ current_position. y . clamp ( 0.0 , 1.0 ) ,
592+ ) ) ;
593+ }
594+
595+ let speed_factor = ( 1.0 - ( -speed / SPEED_RESPONSE ) . exp ( ) ) . clamp ( 0.0 , 1.0 ) ;
596+
597+ let lookahead = MIN_LOOKAHEAD + ( MAX_LOOKAHEAD - MIN_LOOKAHEAD ) * speed_factor;
598+ let follow_strength =
599+ MIN_FOLLOW_FACTOR + ( MAX_FOLLOW_FACTOR - MIN_FOLLOW_FACTOR ) * speed_factor;
600+
601+ let predicted_shift = XY :: new (
602+ ( velocity. x * lookahead) . clamp ( -MAX_SHIFT , MAX_SHIFT ) ,
603+ ( velocity. y * lookahead) . clamp ( -MAX_SHIFT , MAX_SHIFT ) ,
604+ ) ;
605+
606+ let predicted_center = current_position + predicted_shift;
607+ let base_center = previous_cursor
608+ . map ( |prev| {
609+ let retention = 0.45 + 0.25 * speed_factor;
610+ prev. position . coord * retention + current_position * ( 1.0 - retention)
611+ } )
612+ . unwrap_or ( current_position) ;
613+
614+ let final_center =
615+ base_center * ( 1.0 - follow_strength) + predicted_center * follow_strength;
616+
617+ Coord :: new ( XY :: new (
618+ final_center. x . clamp ( 0.0 , 1.0 ) ,
619+ final_center. y . clamp ( 0.0 , 1.0 ) ,
620+ ) )
621+ }
622+
525623 pub fn new (
526624 constants : & RenderVideoConstants ,
527625 project : & ProjectConfiguration ,
@@ -541,14 +639,23 @@ impl ProjectUniforms {
541639
542640 let crop = Self :: get_crop ( options, project) ;
543641
642+ let cursor_smoothing = ( !project. cursor . raw ) . then_some ( SpringMassDamperSimulationConfig {
643+ tension : project. cursor . tension ,
644+ mass : project. cursor . mass ,
645+ friction : project. cursor . friction ,
646+ } ) ;
647+
544648 let interpolated_cursor = interpolate_cursor (
545649 cursor_events,
546650 segment_frames. recording_time ,
547- ( !project. cursor . raw ) . then_some ( SpringMassDamperSimulationConfig {
548- tension : project. cursor . tension ,
549- mass : project. cursor . mass ,
550- friction : project. cursor . friction ,
551- } ) ,
651+ cursor_smoothing,
652+ ) ;
653+
654+ let zoom_focus = Self :: auto_zoom_focus (
655+ cursor_events,
656+ segment_frames. recording_time ,
657+ cursor_smoothing,
658+ interpolated_cursor. clone ( ) ,
552659 ) ;
553660
554661 let zoom = InterpolatedZoom :: new (
@@ -560,18 +667,7 @@ impl ProjectUniforms {
560667 . map ( |t| t. zoom_segments . as_slice ( ) )
561668 . unwrap_or ( & [ ] ) ,
562669 ) ,
563- interpolate_cursor (
564- cursor_events,
565- ( segment_frames. recording_time - 0.2 ) . max ( 0.0 ) ,
566- ( !project. cursor . raw ) . then_some ( SpringMassDamperSimulationConfig {
567- tension : project. cursor . tension ,
568- mass : project. cursor . mass ,
569- friction : project. cursor . friction ,
570- } ) ,
571- )
572- . as_ref ( )
573- . map ( |i| i. position )
574- . unwrap_or_else ( || Coord :: new ( XY :: new ( 0.5 , 0.5 ) ) ) ,
670+ zoom_focus,
575671 ) ;
576672
577673 let scene = InterpolatedScene :: new ( SceneSegmentsCursor :: new (
@@ -1080,6 +1176,85 @@ impl RendererLayers {
10801176 }
10811177}
10821178
1179+ #[ cfg( test) ]
1180+ mod project_uniforms_tests {
1181+ use super :: * ;
1182+ use cap_project:: CursorMoveEvent ;
1183+
1184+ fn cursor_move ( time_ms : f64 , x : f64 , y : f64 ) -> CursorMoveEvent {
1185+ CursorMoveEvent {
1186+ active_modifiers : vec ! [ ] ,
1187+ cursor_id : "primary" . to_string ( ) ,
1188+ time_ms,
1189+ x,
1190+ y,
1191+ }
1192+ }
1193+
1194+ fn default_smoothing ( ) -> SpringMassDamperSimulationConfig {
1195+ SpringMassDamperSimulationConfig {
1196+ tension : 100.0 ,
1197+ mass : 1.0 ,
1198+ friction : 20.0 ,
1199+ }
1200+ }
1201+
1202+ #[ test]
1203+ fn auto_zoom_focus_defaults_without_cursor_data ( ) {
1204+ let events = CursorEvents {
1205+ clicks : vec ! [ ] ,
1206+ moves : vec ! [ ] ,
1207+ } ;
1208+
1209+ let focus = ProjectUniforms :: auto_zoom_focus ( & events, 0.3 , None , None ) ;
1210+
1211+ assert_eq ! ( focus. coord. x, 0.5 ) ;
1212+ assert_eq ! ( focus. coord. y, 0.5 ) ;
1213+ }
1214+
1215+ #[ test]
1216+ fn auto_zoom_focus_is_stable_for_slow_motion ( ) {
1217+ let events = CursorEvents {
1218+ clicks : vec ! [ ] ,
1219+ moves : vec ! [
1220+ cursor_move( 0.0 , 0.5 , 0.5 ) ,
1221+ cursor_move( 200.0 , 0.55 , 0.5 ) ,
1222+ cursor_move( 400.0 , 0.6 , 0.5 ) ,
1223+ ] ,
1224+ } ;
1225+
1226+ let smoothing = Some ( default_smoothing ( ) ) ;
1227+
1228+ let current = interpolate_cursor ( & events, 0.4 , smoothing) . expect ( "cursor position" ) ;
1229+ let focus =
1230+ ProjectUniforms :: auto_zoom_focus ( & events, 0.4 , smoothing, Some ( current. clone ( ) ) ) ;
1231+
1232+ let dx = ( focus. coord . x - current. position . coord . x ) . abs ( ) ;
1233+ let dy = ( focus. coord . y - current. position . coord . y ) . abs ( ) ;
1234+
1235+ assert ! ( dx < 0.05 , "expected minimal horizontal drift, got {dx}" ) ;
1236+ assert ! ( dy < 0.05 , "expected minimal vertical drift, got {dy}" ) ;
1237+ }
1238+
1239+ #[ test]
1240+ fn auto_zoom_focus_leans_into_velocity_for_fast_motion ( ) {
1241+ let events = CursorEvents {
1242+ clicks : vec ! [ ] ,
1243+ moves : vec ! [ cursor_move( 0.0 , 0.1 , 0.5 ) , cursor_move( 40.0 , 0.9 , 0.5 ) ] ,
1244+ } ;
1245+
1246+ let smoothing = Some ( default_smoothing ( ) ) ;
1247+ let query_time = 0.045 ; // slightly after the fast movement
1248+
1249+ let current = interpolate_cursor ( & events, query_time, smoothing) . expect ( "cursor position" ) ;
1250+ let focus =
1251+ ProjectUniforms :: auto_zoom_focus ( & events, query_time, smoothing, Some ( current. clone ( ) ) ) ;
1252+ let delta = focus. coord . x - current. position . coord . x ;
1253+ assert ! ( delta < 0.2 , "focus moved too far ahead: {delta}" ) ;
1254+ assert ! ( delta > -0.25 , "focus lagged too far behind: {delta}" ) ;
1255+ }
1256+ }
1257+
10831258pub struct RenderSession {
10841259 textures : ( wgpu:: Texture , wgpu:: Texture ) ,
10851260 texture_views : ( wgpu:: TextureView , wgpu:: TextureView ) ,
0 commit comments