From 5d54edd6dad426c213835e5ed9e610c96d9d185b Mon Sep 17 00:00:00 2001 From: namearth5005 Date: Wed, 18 Mar 2026 14:16:23 +0700 Subject: [PATCH 1/8] Add AutoZoomConfig struct to extract hardcoded zoom constants into configurable fields --- crates/project/src/configuration.rs | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index f2f49fbea0..1a42f4dcd6 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -455,6 +455,40 @@ impl Default for ScreenMovementSpring { } } +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug)] +#[serde(rename_all = "camelCase", default)] +pub struct AutoZoomConfig { + pub zoom_amount: f64, + pub click_group_time_threshold: f64, + pub click_group_spatial_threshold: f64, + pub click_pre_padding: f64, + pub click_post_padding: f64, + pub movement_pre_padding: f64, + pub movement_post_padding: f64, + pub merge_gap_threshold: f64, + pub min_segment_duration: f64, + pub movement_event_distance_threshold: f64, + pub movement_window_distance_threshold: f64, +} + +impl Default for AutoZoomConfig { + fn default() -> Self { + Self { + zoom_amount: 1.5, + click_group_time_threshold: 2.5, + click_group_spatial_threshold: 0.15, + click_pre_padding: 0.4, + click_post_padding: 1.8, + movement_pre_padding: 0.3, + movement_post_padding: 1.5, + merge_gap_threshold: 0.8, + min_segment_duration: 1.0, + movement_event_distance_threshold: 0.02, + movement_window_distance_threshold: 0.08, + } + } +} + impl CursorAnimationStyle { pub fn preset(self) -> Option { match self { From ae3905272e4f7943a62cb70c2d72a554d129fa83 Mon Sep 17 00:00:00 2001 From: namearth5005 Date: Wed, 18 Mar 2026 14:18:49 +0700 Subject: [PATCH 2/8] feat(settings): add auto_zoom_config to GeneralSettingsStore with serde defaults --- apps/desktop/src-tauri/src/general_settings.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 546b4960a8..cc5789d9fe 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -124,6 +124,8 @@ pub struct GeneralSettingsStore { #[serde(default)] pub auto_zoom_on_clicks: bool, #[serde(default)] + pub auto_zoom_config: cap_project::AutoZoomConfig, + #[serde(default)] pub post_deletion_behaviour: PostDeletionBehaviour, #[serde(default = "default_excluded_windows")] pub excluded_windows: Vec, @@ -203,6 +205,7 @@ impl Default for GeneralSettingsStore { recording_countdown: Some(3), enable_native_camera_preview: default_enable_native_camera_preview(), auto_zoom_on_clicks: false, + auto_zoom_config: cap_project::AutoZoomConfig::default(), post_deletion_behaviour: PostDeletionBehaviour::DoNothing, excluded_windows: default_excluded_windows(), delete_instant_recordings_after_upload: false, From 04a7fed46afebd907008d2c06d6ebafd81471d7a Mon Sep 17 00:00:00 2001 From: namearth5005 Date: Wed, 18 Mar 2026 14:23:53 +0700 Subject: [PATCH 3/8] feat(recording): wire AutoZoomConfig into zoom segment generation, replacing hardcoded constants --- apps/desktop/src-tauri/src/lib.rs | 6 +- apps/desktop/src-tauri/src/recording.rs | 88 ++++++++++++++----------- 2 files changed, 56 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9918b2e0cd..7c9e447e92 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2118,7 +2118,11 @@ async fn generate_zoom_segments_from_clicks( let meta = editor_instance.meta(); let recordings = &editor_instance.recordings; - let zoom_segments = recording::generate_zoom_segments_for_project(meta, recordings); + let zoom_segments = recording::generate_zoom_segments_for_project( + meta, + recordings, + &cap_project::AutoZoomConfig::default(), + ); Ok(zoom_segments) } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index d65bce27fc..687bc75cb3 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1989,30 +1989,29 @@ async fn finalize_studio_recording( Ok(()) } -/// Core logic for generating zoom segments based on mouse click events. -/// This is an experimental feature that automatically creates zoom effects -/// around user interactions to highlight important moments. fn generate_zoom_segments_from_clicks_impl( mut clicks: Vec, mut moves: Vec, max_duration: f64, + config: &cap_project::AutoZoomConfig, ) -> Vec { const STOP_PADDING_SECONDS: f64 = 0.5; - const CLICK_GROUP_TIME_THRESHOLD_SECS: f64 = 2.5; - const CLICK_GROUP_SPATIAL_THRESHOLD: f64 = 0.15; - const CLICK_PRE_PADDING: f64 = 0.4; - const CLICK_POST_PADDING: f64 = 1.8; - const MOVEMENT_PRE_PADDING: f64 = 0.3; - const MOVEMENT_POST_PADDING: f64 = 1.5; - const MERGE_GAP_THRESHOLD: f64 = 0.8; - const MIN_SEGMENT_DURATION: f64 = 1.0; const MOVEMENT_WINDOW_SECONDS: f64 = 1.5; - const MOVEMENT_EVENT_DISTANCE_THRESHOLD: f64 = 0.02; - const MOVEMENT_WINDOW_DISTANCE_THRESHOLD: f64 = 0.08; - const AUTO_ZOOM_AMOUNT: f64 = 1.5; const SHAKE_FILTER_THRESHOLD: f64 = 0.33; const SHAKE_FILTER_WINDOW_MS: f64 = 150.0; + let click_group_time_threshold_secs = config.click_group_time_threshold; + let click_group_spatial_threshold = config.click_group_spatial_threshold; + let click_pre_padding = config.click_pre_padding; + let click_post_padding = config.click_post_padding; + let movement_pre_padding = config.movement_pre_padding; + let movement_post_padding = config.movement_post_padding; + let merge_gap_threshold = config.merge_gap_threshold; + let min_segment_duration = config.min_segment_duration; + let movement_event_distance_threshold = config.movement_event_distance_threshold; + let movement_window_distance_threshold = config.movement_window_distance_threshold; + let auto_zoom_amount = config.zoom_amount; + if max_duration <= 0.0 { return Vec::new(); } @@ -2076,13 +2075,13 @@ fn generate_zoom_segments_from_clicks_impl( let can_join = group.iter().any(|&group_idx| { let group_click = &clicks[group_idx]; let group_time = group_click.time_ms / 1000.0; - let time_close = (click_time - group_time).abs() < CLICK_GROUP_TIME_THRESHOLD_SECS; + let time_close = (click_time - group_time).abs() < click_group_time_threshold_secs; let spatial_close = match (click_pos, click_positions.get(&group_idx)) { (Some((x1, y1)), Some((x2, y2))) => { let dx = x1 - x2; let dy = y1 - y2; - (dx * dx + dy * dy).sqrt() < CLICK_GROUP_SPATIAL_THRESHOLD + (dx * dx + dy * dy).sqrt() < click_group_spatial_threshold } _ => true, }; @@ -2116,8 +2115,8 @@ fn generate_zoom_segments_from_clicks_impl( let group_start = times.iter().cloned().fold(f64::INFINITY, f64::min); let group_end = times.iter().cloned().fold(f64::NEG_INFINITY, f64::max); - let start = (group_start - CLICK_PRE_PADDING).max(0.0); - let end = (group_end + CLICK_POST_PADDING).min(activity_end_limit); + let start = (group_start - click_pre_padding).max(0.0); + let end = (group_end + click_post_padding).min(activity_end_limit); if end > start { intervals.push((start, end)); @@ -2199,15 +2198,15 @@ fn generate_zoom_segments_from_clicks_impl( window_distance = 0.0; } - let significant_movement = distance >= MOVEMENT_EVENT_DISTANCE_THRESHOLD - || window_distance >= MOVEMENT_WINDOW_DISTANCE_THRESHOLD; + let significant_movement = distance >= movement_event_distance_threshold + || window_distance >= movement_window_distance_threshold; if !significant_movement { continue; } - let start = (time - MOVEMENT_PRE_PADDING).max(0.0); - let end = (time + MOVEMENT_POST_PADDING).min(activity_end_limit); + let start = (time - movement_pre_padding).max(0.0); + let end = (time + movement_post_padding).min(activity_end_limit); if end > start { intervals.push((start, end)); @@ -2223,7 +2222,7 @@ fn generate_zoom_segments_from_clicks_impl( let mut merged: Vec<(f64, f64)> = Vec::new(); for interval in intervals { if let Some(last) = merged.last_mut() - && interval.0 <= last.1 + MERGE_GAP_THRESHOLD + && interval.0 <= last.1 + merge_gap_threshold { last.1 = last.1.max(interval.1); continue; @@ -2235,14 +2234,14 @@ fn generate_zoom_segments_from_clicks_impl( .into_iter() .filter_map(|(start, end)| { let duration = end - start; - if duration < MIN_SEGMENT_DURATION { + if duration < min_segment_duration { return None; } Some(ZoomSegment { start, end, - amount: AUTO_ZOOM_AMOUNT, + amount: auto_zoom_amount, mode: ZoomMode::Auto, glide_direction: GlideDirection::None, glide_speed: 0.5, @@ -2253,13 +2252,11 @@ fn generate_zoom_segments_from_clicks_impl( .collect() } -/// Generates zoom segments based on mouse click events during recording. -/// Used during the recording completion process. pub fn generate_zoom_segments_from_clicks( recording: &studio_recording::CompletedRecording, recordings: &ProjectRecordingsMeta, + config: &cap_project::AutoZoomConfig, ) -> Vec { - // Build a temporary RecordingMeta so we can use the common implementation let recording_meta = RecordingMeta { platform: None, project_path: recording.project_path.clone(), @@ -2269,14 +2266,13 @@ pub fn generate_zoom_segments_from_clicks( upload: None, }; - generate_zoom_segments_for_project(&recording_meta, recordings) + generate_zoom_segments_for_project(&recording_meta, recordings, config) } -/// Generates zoom segments from clicks for an existing project. -/// Used in the editor context where we have RecordingMeta. pub fn generate_zoom_segments_for_project( recording_meta: &RecordingMeta, recordings: &ProjectRecordingsMeta, + config: &cap_project::AutoZoomConfig, ) -> Vec { let RecordingMetaInner::Studio(studio_meta) = &recording_meta.inner else { return Vec::new(); @@ -2309,7 +2305,7 @@ pub fn generate_zoom_segments_for_project( } } - generate_zoom_segments_from_clicks_impl(all_clicks, all_moves, recordings.duration()) + generate_zoom_segments_from_clicks_impl(all_clicks, all_moves, recordings.duration(), config) } fn project_config_from_recording( @@ -2355,7 +2351,11 @@ fn project_config_from_recording( .collect::>(); let zoom_segments = if settings.auto_zoom_on_clicks { - generate_zoom_segments_from_clicks(completed_recording, recordings) + generate_zoom_segments_from_clicks( + completed_recording, + recordings, + &settings.auto_zoom_config, + ) } else { Vec::new() }; @@ -2429,8 +2429,12 @@ mod tests { #[test] fn skips_trailing_stop_click() { - let segments = - generate_zoom_segments_from_clicks_impl(vec![click_event(11_900.0)], vec![], 12.0); + let segments = generate_zoom_segments_from_clicks_impl( + vec![click_event(11_900.0)], + vec![], + 12.0, + &cap_project::AutoZoomConfig::default(), + ); assert!( segments.is_empty(), @@ -2447,7 +2451,12 @@ mod tests { move_event(1_940.0, 0.74, 0.78), ]; - let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0); + let segments = generate_zoom_segments_from_clicks_impl( + clicks, + moves, + 20.0, + &cap_project::AutoZoomConfig::default(), + ); assert!( !segments.is_empty(), @@ -2469,7 +2478,12 @@ mod tests { }) .collect::>(); - let segments = generate_zoom_segments_from_clicks_impl(Vec::new(), jitter_moves, 15.0); + let segments = generate_zoom_segments_from_clicks_impl( + Vec::new(), + jitter_moves, + 15.0, + &cap_project::AutoZoomConfig::default(), + ); assert!( segments.is_empty(), From b893118ba0f13d8fb4f68e396be94e69fa3d2e6e Mon Sep 17 00:00:00 2001 From: namearth5005 Date: Wed, 18 Mar 2026 14:25:40 +0700 Subject: [PATCH 4/8] feat(ipc): pass AutoZoomConfig from settings through generate_zoom_segments command --- apps/desktop/src-tauri/src/lib.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 7c9e447e92..3e67aebbcf 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2111,17 +2111,21 @@ async fn update_project_config_in_memory( #[tauri::command] #[specta::specta] -#[instrument(skip(editor_instance))] +#[instrument(skip(app, editor_instance))] async fn generate_zoom_segments_from_clicks( + app: AppHandle, editor_instance: WindowEditorInstance, ) -> Result, String> { + let settings = GeneralSettingsStore::get(&app) + .unwrap_or(None) + .unwrap_or_default(); let meta = editor_instance.meta(); let recordings = &editor_instance.recordings; let zoom_segments = recording::generate_zoom_segments_for_project( meta, recordings, - &cap_project::AutoZoomConfig::default(), + &settings.auto_zoom_config, ); Ok(zoom_segments) From c86680599b44d9850f09e09926788caa56faccd2 Mon Sep 17 00:00:00 2001 From: namearth5005 Date: Wed, 18 Mar 2026 14:29:10 +0700 Subject: [PATCH 5/8] feat(ui): add zoom amount, sensitivity, and smoothing sliders for auto-zoom settings --- .../(window-chrome)/settings/experimental.tsx | 110 +++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 3d1bd22fc7..2149e5e2ca 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -1,12 +1,50 @@ +import { Slider as KSlider } from "@kobalte/core/slider"; import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { type } from "@tauri-apps/plugin-os"; import { createResource, Show } from "solid-js"; import { createStore } from "solid-js/store"; import { generalSettingsStore } from "~/store"; -import { commands, type GeneralSettingsStore } from "~/utils/tauri"; +import { + commands, + type AutoZoomConfig, + type GeneralSettingsStore, +} from "~/utils/tauri"; import { ToggleSettingItem } from "./Setting"; +function SettingSlider(props: { + label: string; + value: number; + onChange: (v: number) => void; + min: number; + max: number; + step: number; + format?: (v: number) => string; +}) { + return ( +
+
+ {props.label} + + {props.format ? props.format(props.value) : props.value} + +
+ props.onChange(v[0])} + minValue={props.min} + maxValue={props.max} + step={props.step} + > + + + + + +
+ ); +} + export default function ExperimentalSettings() { const [store] = createResource(() => generalSettingsStore.get()); @@ -27,9 +65,32 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { enableNativeCameraPreview: false, autoZoomOnClicks: false, custom_cursor_capture2: true, + autoZoomConfig: { + zoomAmount: 1.5, + clickGroupTimeThreshold: 2.5, + clickGroupSpatialThreshold: 0.15, + clickPrePadding: 0.4, + clickPostPadding: 1.8, + movementPrePadding: 0.3, + movementPostPadding: 1.5, + mergeGapThreshold: 0.8, + minSegmentDuration: 1.0, + movementEventDistanceThreshold: 0.02, + movementWindowDistanceThreshold: 0.08, + }, }, ); + const handleConfigChange = ( + key: K, + value: AutoZoomConfig[K], + ) => { + setSettings("autoZoomConfig", key, value); + generalSettingsStore.set({ + autoZoomConfig: { ...settings.autoZoomConfig, [key]: value }, + }); + }; + const handleChange = async ( key: K, value: (typeof settings)[K], @@ -95,6 +156,53 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { }} /> + +
+ handleConfigChange("zoomAmount", v)} + min={1.0} + max={4.0} + step={0.1} + format={(v) => `${v.toFixed(1)}x`} + /> + + handleConfigChange( + "movementWindowDistanceThreshold", + v, + ) + } + min={0.02} + max={0.2} + step={0.01} + format={(v) => { + if (v <= 0.05) return "High"; + if (v <= 0.12) return "Medium"; + return "Low"; + }} + /> + + handleConfigChange("mergeGapThreshold", v) + } + min={0.2} + max={2.0} + step={0.1} + format={(v) => `${v.toFixed(1)}s`} + /> +
+
From 680a8e5aabbb09bcbf730b4eeea1afbee80abee1 Mon Sep 17 00:00:00 2001 From: namearth5005 Date: Wed, 18 Mar 2026 14:36:19 +0700 Subject: [PATCH 6/8] style: apply cargo fmt and biome formatting --- apps/desktop/src-tauri/src/lib.rs | 7 ++----- .../(window-chrome)/settings/experimental.tsx | 19 ++++++------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 3e67aebbcf..728792d560 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2122,11 +2122,8 @@ async fn generate_zoom_segments_from_clicks( let meta = editor_instance.meta(); let recordings = &editor_instance.recordings; - let zoom_segments = recording::generate_zoom_segments_for_project( - meta, - recordings, - &settings.auto_zoom_config, - ); + let zoom_segments = + recording::generate_zoom_segments_for_project(meta, recordings, &settings.auto_zoom_config); Ok(zoom_segments) } diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 2149e5e2ca..b5252dbd4a 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -6,8 +6,8 @@ import { createStore } from "solid-js/store"; import { generalSettingsStore } from "~/store"; import { - commands, type AutoZoomConfig, + commands, type GeneralSettingsStore, } from "~/utils/tauri"; import { ToggleSettingItem } from "./Setting"; @@ -170,14 +170,11 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { - handleConfigChange( - "movementWindowDistanceThreshold", - v, - ) + handleConfigChange("movementWindowDistanceThreshold", v) } min={0.02} max={0.2} @@ -190,12 +187,8 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { /> - handleConfigChange("mergeGapThreshold", v) - } + value={settings.autoZoomConfig?.mergeGapThreshold ?? 0.8} + onChange={(v) => handleConfigChange("mergeGapThreshold", v)} min={0.2} max={2.0} step={0.1} From e308026c8f2095cb4d2bfa90d63de38bc4abf2b4 Mon Sep 17 00:00:00 2001 From: namearth5005 Date: Wed, 18 Mar 2026 14:46:51 +0700 Subject: [PATCH 7/8] feat(recording): add dead zone logic to prevent jittery auto-zoom segments --- apps/desktop/src-tauri/src/recording.rs | 82 ++++++++++++++++++- .../(window-chrome)/settings/experimental.tsx | 1 + crates/project/src/configuration.rs | 2 + 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 687bc75cb3..edb12403e6 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -2011,6 +2011,7 @@ fn generate_zoom_segments_from_clicks_impl( let movement_event_distance_threshold = config.movement_event_distance_threshold; let movement_window_distance_threshold = config.movement_window_distance_threshold; let auto_zoom_amount = config.zoom_amount; + let dead_zone_radius = config.dead_zone_radius; if max_duration <= 0.0 { return Vec::new(); @@ -2072,7 +2073,7 @@ fn generate_zoom_segments_from_clicks_impl( let mut found_group = false; for group in click_groups.iter_mut() { - let can_join = group.iter().any(|&group_idx| { + let time_and_spatial = group.iter().any(|&group_idx| { let group_click = &clicks[group_idx]; let group_time = group_click.time_ms / 1000.0; let time_close = (click_time - group_time).abs() < click_group_time_threshold_secs; @@ -2089,7 +2090,25 @@ fn generate_zoom_segments_from_clicks_impl( time_close && spatial_close }); - if can_join { + let in_dead_zone = dead_zone_radius > 0.0 && click_pos.is_some() && { + let (cx, cy) = click_pos.unwrap(); + let group_positions: Vec<(f64, f64)> = group + .iter() + .filter_map(|&gi| click_positions.get(&gi).copied()) + .collect(); + if group_positions.is_empty() { + false + } else { + let count = group_positions.len() as f64; + let centroid_x = group_positions.iter().map(|(x, _)| x).sum::() / count; + let centroid_y = group_positions.iter().map(|(_, y)| y).sum::() / count; + let dx = cx - centroid_x; + let dy = cy - centroid_y; + (dx * dx + dy * dy).sqrt() < dead_zone_radius + } + }; + + if time_and_spatial || in_dead_zone { group.push(*idx); found_group = true; break; @@ -2490,4 +2509,63 @@ mod tests { "small jitter should not generate segments" ); } + + #[test] + fn dead_zone_merges_nearby_clicks() { + let clicks = vec![click_event(1_000.0), click_event(5_000.0)]; + let moves = vec![move_event(999.0, 0.5, 0.5), move_event(4_999.0, 0.55, 0.55)]; + + let config = cap_project::AutoZoomConfig { + dead_zone_radius: 0.1, + ..Default::default() + }; + + let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, &config); + + assert!( + segments.len() <= 1, + "nearby clicks within dead zone should merge into at most 1 segment, got {}", + segments.len() + ); + } + + #[test] + fn dead_zone_allows_distant_clicks() { + let clicks = vec![click_event(1_000.0), click_event(5_000.0)]; + let moves = vec![move_event(999.0, 0.2, 0.2), move_event(4_999.0, 0.8, 0.8)]; + + let config = cap_project::AutoZoomConfig { + dead_zone_radius: 0.1, + ..Default::default() + }; + + let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, &config); + + assert_eq!( + segments.len(), + 2, + "distant clicks outside dead zone should produce 2 separate segments, got {}", + segments.len() + ); + } + + #[test] + fn dead_zone_zero_disables() { + let clicks = vec![click_event(1_000.0), click_event(5_000.0)]; + let moves = vec![move_event(999.0, 0.5, 0.5), move_event(4_999.0, 0.55, 0.55)]; + + let config = cap_project::AutoZoomConfig { + dead_zone_radius: 0.0, + ..Default::default() + }; + + let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, &config); + + assert_eq!( + segments.len(), + 2, + "with dead_zone_radius=0.0, nearby clicks should produce 2 segments, got {}", + segments.len() + ); + } } diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index b5252dbd4a..9dc3791284 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -77,6 +77,7 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { minSegmentDuration: 1.0, movementEventDistanceThreshold: 0.02, movementWindowDistanceThreshold: 0.08, + deadZoneRadius: 0.1, }, }, ); diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 1a42f4dcd6..025325154c 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -469,6 +469,7 @@ pub struct AutoZoomConfig { pub min_segment_duration: f64, pub movement_event_distance_threshold: f64, pub movement_window_distance_threshold: f64, + pub dead_zone_radius: f64, } impl Default for AutoZoomConfig { @@ -485,6 +486,7 @@ impl Default for AutoZoomConfig { min_segment_duration: 1.0, movement_event_distance_threshold: 0.02, movement_window_distance_threshold: 0.08, + dead_zone_radius: 0.1, } } } From 6822285fd946a0fe222c4f05421e9801a8421dcb Mon Sep 17 00:00:00 2001 From: namearth5005 Date: Wed, 18 Mar 2026 14:52:59 +0700 Subject: [PATCH 8/8] feat(recording): add double-click dedup and right-click filtering for auto-zoom --- apps/desktop/src-tauri/src/recording.rs | 160 ++++++++++++++++++ .../(window-chrome)/settings/experimental.tsx | 2 + crates/project/src/configuration.rs | 4 + 3 files changed, 166 insertions(+) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index edb12403e6..fff76cc840 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -2027,6 +2027,10 @@ fn generate_zoom_segments_from_clicks_impl( return Vec::new(); } + if config.ignore_right_clicks { + clicks.retain(|c| c.cursor_num == 0); + } + clicks.sort_by(|a, b| { a.time_ms .partial_cmp(&b.time_ms) @@ -2038,6 +2042,32 @@ fn generate_zoom_segments_from_clicks_impl( .unwrap_or(std::cmp::Ordering::Equal) }); + if config.double_click_threshold_ms > 0.0 { + let mut i = 0; + while i < clicks.len() { + if !clicks[i].down { + i += 1; + continue; + } + let mut j = i + 1; + while j < clicks.len() { + if !clicks[j].down { + j += 1; + continue; + } + if clicks[j].time_ms - clicks[i].time_ms > config.double_click_threshold_ms { + break; + } + if clicks[j].cursor_num == clicks[i].cursor_num { + clicks.remove(j); + } else { + j += 1; + } + } + i += 1; + } + } + while let Some(index) = clicks.iter().rposition(|c| c.down) { let time_secs = clicks[index].time_ms / 1000.0; if time_secs > activity_end_limit { @@ -2568,4 +2598,134 @@ mod tests { segments.len() ); } + + #[test] + fn double_click_deduplication() { + let double_clicks = vec![ + CursorClickEvent { + down: true, + cursor_num: 0, + cursor_id: "default".to_string(), + time_ms: 1000.0, + active_modifiers: vec![], + }, + CursorClickEvent { + down: false, + cursor_num: 0, + cursor_id: "default".to_string(), + time_ms: 1050.0, + active_modifiers: vec![], + }, + CursorClickEvent { + down: true, + cursor_num: 0, + cursor_id: "default".to_string(), + time_ms: 1200.0, + active_modifiers: vec![], + }, + CursorClickEvent { + down: false, + cursor_num: 0, + cursor_id: "default".to_string(), + time_ms: 1250.0, + active_modifiers: vec![], + }, + ]; + let moves = vec![move_event(999.0, 0.5, 0.5)]; + + let config = cap_project::AutoZoomConfig { + double_click_threshold_ms: 400.0, + ..Default::default() + }; + + let double_segments = + generate_zoom_segments_from_clicks_impl(double_clicks, moves.clone(), 20.0, &config); + + let single_clicks = vec![click_event(1000.0)]; + let single_segments = + generate_zoom_segments_from_clicks_impl(single_clicks, moves, 20.0, &config); + + assert_eq!( + double_segments.len(), + single_segments.len(), + "double-click should be deduped to same segment count as single click: double={}, single={}", + double_segments.len(), + single_segments.len() + ); + } + + #[test] + fn right_click_ignored() { + let clicks = vec![ + CursorClickEvent { + down: true, + cursor_num: 1, + cursor_id: "default".to_string(), + time_ms: 1000.0, + active_modifiers: vec![], + }, + CursorClickEvent { + down: false, + cursor_num: 1, + cursor_id: "default".to_string(), + time_ms: 1050.0, + active_modifiers: vec![], + }, + ]; + let moves = vec![ + move_event(500.0, 0.1, 0.1), + move_event(999.0, 0.5, 0.5), + move_event(1500.0, 0.9, 0.9), + ]; + + let config = cap_project::AutoZoomConfig { + ignore_right_clicks: true, + ..Default::default() + }; + + let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, &config); + + let has_click_segment = segments.iter().any(|s| { + let click_time_secs = 1.0; + s.start <= click_time_secs && s.end >= click_time_secs + }); + + assert!( + !has_click_segment, + "right-click should be filtered out when ignore_right_clicks is true" + ); + } + + #[test] + fn right_click_allowed_when_disabled() { + let clicks = vec![ + CursorClickEvent { + down: true, + cursor_num: 1, + cursor_id: "default".to_string(), + time_ms: 1000.0, + active_modifiers: vec![], + }, + CursorClickEvent { + down: false, + cursor_num: 1, + cursor_id: "default".to_string(), + time_ms: 1050.0, + active_modifiers: vec![], + }, + ]; + let moves = vec![move_event(999.0, 0.5, 0.5)]; + + let config = cap_project::AutoZoomConfig { + ignore_right_clicks: false, + ..Default::default() + }; + + let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, &config); + + assert!( + !segments.is_empty(), + "right-click should produce segments when ignore_right_clicks is false" + ); + } } diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 9dc3791284..1ee39f564e 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -78,6 +78,8 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { movementEventDistanceThreshold: 0.02, movementWindowDistanceThreshold: 0.08, deadZoneRadius: 0.1, + doubleClickThresholdMs: 400.0, + ignoreRightClicks: true, }, }, ); diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 025325154c..4f426c0b3a 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -470,6 +470,8 @@ pub struct AutoZoomConfig { pub movement_event_distance_threshold: f64, pub movement_window_distance_threshold: f64, pub dead_zone_radius: f64, + pub double_click_threshold_ms: f64, + pub ignore_right_clicks: bool, } impl Default for AutoZoomConfig { @@ -487,6 +489,8 @@ impl Default for AutoZoomConfig { movement_event_distance_threshold: 0.02, movement_window_distance_threshold: 0.08, dead_zone_radius: 0.1, + double_click_threshold_ms: 400.0, + ignore_right_clicks: true, } } }