11use cap_fail:: fail;
2+ use cap_project:: cursor:: SHORT_CURSOR_SHAPE_DEBOUNCE_MS ;
23use cap_project:: {
3- CursorClickEvent , Platform , ProjectConfiguration , RecordingMeta , RecordingMetaInner ,
4- SharingMeta , StudioRecordingMeta , TimelineConfiguration , TimelineSegment , ZoomMode ,
5- ZoomSegment , cursor:: CursorEvents ,
4+ CursorClickEvent , CursorMoveEvent , Platform , ProjectConfiguration , RecordingMeta ,
5+ RecordingMetaInner , SharingMeta , StudioRecordingMeta , TimelineConfiguration , TimelineSegment ,
6+ ZoomMode , ZoomSegment , cursor:: CursorEvents ,
67} ;
78use cap_recording:: {
89 RecordingError , RecordingMode ,
@@ -15,7 +16,13 @@ use cap_rendering::ProjectRecordingsMeta;
1516use cap_utils:: { ensure_dir, spawn_actor} ;
1617use serde:: { Deserialize , Serialize } ;
1718use specta:: Type ;
18- use std:: { path:: PathBuf , str:: FromStr , sync:: Arc , time:: Duration } ;
19+ use std:: {
20+ collections:: { HashMap , VecDeque } ,
21+ path:: PathBuf ,
22+ str:: FromStr ,
23+ sync:: Arc ,
24+ time:: Duration ,
25+ } ;
1926use tauri:: { AppHandle , Manager } ;
2027use tauri_plugin_dialog:: { DialogExt , MessageDialogBuilder } ;
2128use tauri_specta:: Event ;
@@ -1718,56 +1725,161 @@ async fn handle_recording_finish(
17181725/// around user interactions to highlight important moments.
17191726fn generate_zoom_segments_from_clicks_impl (
17201727 mut clicks : Vec < CursorClickEvent > ,
1721- recordings : & ProjectRecordingsMeta ,
1728+ mut moves : Vec < CursorMoveEvent > ,
1729+ max_duration : f64 ,
17221730) -> Vec < ZoomSegment > {
1723- const ZOOM_SEGMENT_AFTER_CLICK_PADDING : f64 = 1.5 ;
1724- const ZOOM_SEGMENT_BEFORE_CLICK_PADDING : f64 = 0.8 ;
1725- const ZOOM_DURATION : f64 = 1.0 ;
1726- const CLICK_GROUP_THRESHOLD : f64 = 0.6 ; // seconds
1727- const MIN_SEGMENT_PADDING : f64 = 2.0 ; // minimum gap between segments
1731+ const STOP_PADDING_SECONDS : f64 = 0.8 ;
1732+ const CLICK_PRE_PADDING : f64 = 0.6 ;
1733+ const CLICK_POST_PADDING : f64 = 1.6 ;
1734+ const MOVEMENT_PRE_PADDING : f64 = 0.4 ;
1735+ const MOVEMENT_POST_PADDING : f64 = 1.2 ;
1736+ const MERGE_GAP_THRESHOLD : f64 = 0.6 ;
1737+ const MIN_SEGMENT_DURATION : f64 = 1.3 ;
1738+ const MOVEMENT_WINDOW_SECONDS : f64 = 1.2 ;
1739+ const MOVEMENT_EVENT_DISTANCE_THRESHOLD : f64 = 0.025 ;
1740+ const MOVEMENT_WINDOW_DISTANCE_THRESHOLD : f64 = 0.1 ;
1741+
1742+ if max_duration <= 0.0 {
1743+ return Vec :: new ( ) ;
1744+ }
17281745
1729- let max_duration = recordings. duration ( ) ;
1746+ // We trim the tail of the recording to avoid using the final
1747+ // "stop recording" click as a zoom target.
1748+ let activity_end_limit = if max_duration > STOP_PADDING_SECONDS {
1749+ max_duration - STOP_PADDING_SECONDS
1750+ } else {
1751+ max_duration
1752+ } ;
1753+
1754+ if activity_end_limit <= f64:: EPSILON {
1755+ return Vec :: new ( ) ;
1756+ }
17301757
17311758 clicks. sort_by ( |a, b| {
17321759 a. time_ms
17331760 . partial_cmp ( & b. time_ms )
17341761 . unwrap_or ( std:: cmp:: Ordering :: Equal )
17351762 } ) ;
1763+ moves. sort_by ( |a, b| {
1764+ a. time_ms
1765+ . partial_cmp ( & b. time_ms )
1766+ . unwrap_or ( std:: cmp:: Ordering :: Equal )
1767+ } ) ;
17361768
1737- let mut segments = Vec :: < ZoomSegment > :: new ( ) ;
1769+ // Remove trailing click-down events that are too close to the end.
1770+ while let Some ( index) = clicks. iter ( ) . rposition ( |c| c. down ) {
1771+ let time_secs = clicks[ index] . time_ms / 1000.0 ;
1772+ if time_secs > activity_end_limit {
1773+ clicks. remove ( index) ;
1774+ } else {
1775+ break ;
1776+ }
1777+ }
17381778
1739- // Generate segments around mouse clicks
1740- for click in & clicks {
1741- if !click. down {
1779+ let mut intervals: Vec < ( f64 , f64 ) > = Vec :: new ( ) ;
1780+
1781+ for click in clicks. into_iter ( ) . filter ( |c| c. down ) {
1782+ let time = click. time_ms / 1000.0 ;
1783+ if time >= activity_end_limit {
17421784 continue ;
17431785 }
17441786
1745- let time = click. time_ms / 1000.0 ;
1787+ let start = ( time - CLICK_PRE_PADDING ) . max ( 0.0 ) ;
1788+ let end = ( time + CLICK_POST_PADDING ) . min ( activity_end_limit) ;
17461789
1747- let proposed_start = ( time - ZOOM_SEGMENT_BEFORE_CLICK_PADDING ) . max ( 0.0 ) ;
1748- let proposed_end = ( time + ZOOM_SEGMENT_AFTER_CLICK_PADDING ) . min ( max_duration) ;
1790+ if end > start {
1791+ intervals. push ( ( start, end) ) ;
1792+ }
1793+ }
17491794
1750- if let Some ( last) = segments. last_mut ( ) {
1751- // Merge if within group threshold OR if segments would be too close together
1752- if time <= last. end + CLICK_GROUP_THRESHOLD
1753- || proposed_start <= last. end + MIN_SEGMENT_PADDING
1754- {
1755- last. end = proposed_end;
1756- continue ;
1795+ let mut last_move_by_cursor: HashMap < String , ( f64 , f64 , f64 ) > = HashMap :: new ( ) ;
1796+ let mut distance_window: VecDeque < ( f64 , f64 ) > = VecDeque :: new ( ) ;
1797+ let mut window_distance = 0.0_f64 ;
1798+
1799+ for mv in moves. iter ( ) {
1800+ let time = mv. time_ms / 1000.0 ;
1801+ if time >= activity_end_limit {
1802+ break ;
1803+ }
1804+
1805+ let distance = if let Some ( ( _, last_x, last_y) ) = last_move_by_cursor. get ( & mv. cursor_id ) {
1806+ let dx = mv. x - last_x;
1807+ let dy = mv. y - last_y;
1808+ ( dx * dx + dy * dy) . sqrt ( )
1809+ } else {
1810+ 0.0
1811+ } ;
1812+
1813+ last_move_by_cursor. insert ( mv. cursor_id . clone ( ) , ( time, mv. x , mv. y ) ) ;
1814+
1815+ if distance <= f64:: EPSILON {
1816+ continue ;
1817+ }
1818+
1819+ distance_window. push_back ( ( time, distance) ) ;
1820+ window_distance += distance;
1821+
1822+ while let Some ( & ( old_time, old_distance) ) = distance_window. front ( ) {
1823+ if time - old_time > MOVEMENT_WINDOW_SECONDS {
1824+ distance_window. pop_front ( ) ;
1825+ window_distance -= old_distance;
1826+ } else {
1827+ break ;
17571828 }
17581829 }
17591830
1760- if time < max_duration - ZOOM_DURATION {
1761- segments. push ( ZoomSegment {
1762- start : proposed_start,
1763- end : proposed_end,
1764- amount : 2.0 ,
1765- mode : ZoomMode :: Auto ,
1766- } ) ;
1831+ if window_distance < 0.0 {
1832+ window_distance = 0.0 ;
1833+ }
1834+
1835+ let significant_movement = distance >= MOVEMENT_EVENT_DISTANCE_THRESHOLD
1836+ || window_distance >= MOVEMENT_WINDOW_DISTANCE_THRESHOLD ;
1837+
1838+ if !significant_movement {
1839+ continue ;
1840+ }
1841+
1842+ let start = ( time - MOVEMENT_PRE_PADDING ) . max ( 0.0 ) ;
1843+ let end = ( time + MOVEMENT_POST_PADDING ) . min ( activity_end_limit) ;
1844+
1845+ if end > start {
1846+ intervals. push ( ( start, end) ) ;
1847+ }
1848+ }
1849+
1850+ if intervals. is_empty ( ) {
1851+ return Vec :: new ( ) ;
1852+ }
1853+
1854+ intervals. sort_by ( |a, b| a. 0 . partial_cmp ( & b. 0 ) . unwrap_or ( std:: cmp:: Ordering :: Equal ) ) ;
1855+
1856+ let mut merged: Vec < ( f64 , f64 ) > = Vec :: new ( ) ;
1857+ for interval in intervals {
1858+ if let Some ( last) = merged. last_mut ( ) {
1859+ if interval. 0 <= last. 1 + MERGE_GAP_THRESHOLD {
1860+ last. 1 = last. 1 . max ( interval. 1 ) ;
1861+ continue ;
1862+ }
17671863 }
1864+ merged. push ( interval) ;
17681865 }
17691866
1770- segments
1867+ merged
1868+ . into_iter ( )
1869+ . filter_map ( |( start, end) | {
1870+ let duration = end - start;
1871+ if duration < MIN_SEGMENT_DURATION {
1872+ return None ;
1873+ }
1874+
1875+ Some ( ZoomSegment {
1876+ start,
1877+ end,
1878+ amount : 2.0 ,
1879+ mode : ZoomMode :: Auto ,
1880+ } )
1881+ } )
1882+ . collect ( )
17711883}
17721884
17731885/// Generates zoom segments based on mouse click events during recording.
@@ -1798,24 +1910,34 @@ pub fn generate_zoom_segments_for_project(
17981910 return Vec :: new ( ) ;
17991911 } ;
18001912
1801- let all_events = match studio_meta {
1913+ let mut all_clicks = Vec :: new ( ) ;
1914+ let mut all_moves = Vec :: new ( ) ;
1915+
1916+ match studio_meta {
18021917 StudioRecordingMeta :: SingleSegment { segment } => {
18031918 if let Some ( cursor_path) = & segment. cursor {
1804- CursorEvents :: load_from_file ( & recording_meta. path ( cursor_path) )
1805- . unwrap_or_default ( )
1806- . clicks
1807- } else {
1808- vec ! [ ]
1919+ let mut events = CursorEvents :: load_from_file ( & recording_meta. path ( cursor_path) )
1920+ . unwrap_or_default ( ) ;
1921+ let pointer_ids = studio_meta. pointer_cursor_ids ( ) ;
1922+ let pointer_ids_ref = ( !pointer_ids. is_empty ( ) ) . then_some ( & pointer_ids) ;
1923+ events. stabilize_short_lived_cursor_shapes (
1924+ pointer_ids_ref,
1925+ SHORT_CURSOR_SHAPE_DEBOUNCE_MS ,
1926+ ) ;
1927+ all_clicks = events. clicks ;
1928+ all_moves = events. moves ;
18091929 }
18101930 }
1811- StudioRecordingMeta :: MultipleSegments { inner, .. } => inner
1812- . segments
1813- . iter ( )
1814- . flat_map ( |s| s. cursor_events ( recording_meta) . clicks )
1815- . collect ( ) ,
1816- } ;
1931+ StudioRecordingMeta :: MultipleSegments { inner, .. } => {
1932+ for segment in inner. segments . iter ( ) {
1933+ let events = segment. cursor_events ( recording_meta) ;
1934+ all_clicks. extend ( events. clicks ) ;
1935+ all_moves. extend ( events. moves ) ;
1936+ }
1937+ }
1938+ }
18171939
1818- generate_zoom_segments_from_clicks_impl ( all_events , recordings)
1940+ generate_zoom_segments_from_clicks_impl ( all_clicks , all_moves , recordings. duration ( ) )
18191941}
18201942
18211943fn project_config_from_recording (
@@ -1828,26 +1950,110 @@ fn project_config_from_recording(
18281950 . unwrap_or ( None )
18291951 . unwrap_or_default ( ) ;
18301952
1831- ProjectConfiguration {
1832- timeline : Some ( TimelineConfiguration {
1833- segments : recordings
1834- . segments
1835- . iter ( )
1836- . enumerate ( )
1837- . map ( |( i, segment) | TimelineSegment {
1838- recording_segment : i as u32 ,
1839- start : 0.0 ,
1840- end : segment. duration ( ) ,
1841- timescale : 1.0 ,
1842- } )
1843- . collect ( ) ,
1844- zoom_segments : if settings. auto_zoom_on_clicks {
1845- generate_zoom_segments_from_clicks ( completed_recording, recordings)
1846- } else {
1847- Vec :: new ( )
1848- } ,
1849- scene_segments : Vec :: new ( ) ,
1850- } ) ,
1851- ..default_config. unwrap_or_default ( )
1953+ let mut config = default_config. unwrap_or_default ( ) ;
1954+
1955+ let timeline_segments = recordings
1956+ . segments
1957+ . iter ( )
1958+ . enumerate ( )
1959+ . map ( |( i, segment) | TimelineSegment {
1960+ recording_segment : i as u32 ,
1961+ start : 0.0 ,
1962+ end : segment. duration ( ) ,
1963+ timescale : 1.0 ,
1964+ } )
1965+ . collect :: < Vec < _ > > ( ) ;
1966+
1967+ let zoom_segments = if settings. auto_zoom_on_clicks {
1968+ generate_zoom_segments_from_clicks ( completed_recording, recordings)
1969+ } else {
1970+ Vec :: new ( )
1971+ } ;
1972+
1973+ if !zoom_segments. is_empty ( ) {
1974+ config. cursor . size = 200 ;
1975+ }
1976+
1977+ config. timeline = Some ( TimelineConfiguration {
1978+ segments : timeline_segments,
1979+ zoom_segments,
1980+ scene_segments : Vec :: new ( ) ,
1981+ } ) ;
1982+
1983+ config
1984+ }
1985+
1986+ #[ cfg( test) ]
1987+ mod tests {
1988+ use super :: * ;
1989+
1990+ fn click_event ( time_ms : f64 ) -> CursorClickEvent {
1991+ CursorClickEvent {
1992+ active_modifiers : vec ! [ ] ,
1993+ cursor_num : 0 ,
1994+ cursor_id : "default" . to_string ( ) ,
1995+ time_ms,
1996+ down : true ,
1997+ }
1998+ }
1999+
2000+ fn move_event ( time_ms : f64 , x : f64 , y : f64 ) -> CursorMoveEvent {
2001+ CursorMoveEvent {
2002+ active_modifiers : vec ! [ ] ,
2003+ cursor_id : "default" . to_string ( ) ,
2004+ time_ms,
2005+ x,
2006+ y,
2007+ }
2008+ }
2009+
2010+ #[ test]
2011+ fn skips_trailing_stop_click ( ) {
2012+ let segments =
2013+ generate_zoom_segments_from_clicks_impl ( vec ! [ click_event( 11_900.0 ) ] , vec ! [ ] , 12.0 ) ;
2014+
2015+ assert ! (
2016+ segments. is_empty( ) ,
2017+ "expected trailing stop click to be ignored"
2018+ ) ;
2019+ }
2020+
2021+ #[ test]
2022+ fn generates_segment_for_sustained_activity ( ) {
2023+ let clicks = vec ! [ click_event( 1_200.0 ) , click_event( 4_200.0 ) ] ;
2024+ let moves = vec ! [
2025+ move_event( 1_500.0 , 0.10 , 0.12 ) ,
2026+ move_event( 1_720.0 , 0.42 , 0.45 ) ,
2027+ move_event( 1_940.0 , 0.74 , 0.78 ) ,
2028+ ] ;
2029+
2030+ let segments = generate_zoom_segments_from_clicks_impl ( clicks, moves, 20.0 ) ;
2031+
2032+ assert ! (
2033+ !segments. is_empty( ) ,
2034+ "expected activity to produce zoom segments"
2035+ ) ;
2036+ let first = & segments[ 0 ] ;
2037+ assert ! ( first. start < first. end) ;
2038+ assert ! ( first. end - first. start >= 1.3 ) ;
2039+ assert ! ( first. end <= 19.5 ) ;
2040+ }
2041+
2042+ #[ test]
2043+ fn ignores_cursor_jitter ( ) {
2044+ let jitter_moves = ( 0 ..30 )
2045+ . map ( |i| {
2046+ let t = 1_000.0 + ( i as f64 ) * 30.0 ;
2047+ let delta = ( i as f64 ) * 0.0004 ;
2048+ move_event ( t, 0.5 + delta, 0.5 )
2049+ } )
2050+ . collect :: < Vec < _ > > ( ) ;
2051+
2052+ let segments = generate_zoom_segments_from_clicks_impl ( Vec :: new ( ) , jitter_moves, 15.0 ) ;
2053+
2054+ assert ! (
2055+ segments. is_empty( ) ,
2056+ "small jitter should not generate segments"
2057+ ) ;
18522058 }
18532059}
0 commit comments