From 5d54edd6dad426c213835e5ed9e610c96d9d185b Mon Sep 17 00:00:00 2001 From: namearth5005 Date: Wed, 18 Mar 2026 14:16:23 +0700 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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 08/11] 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, } } } From 3cf03d646ee9ae75f7b3446a25898468f0e1c93c Mon Sep 17 00:00:00 2001 From: namearth5005 Date: Wed, 18 Mar 2026 14:58:24 +0700 Subject: [PATCH 09/11] feat(recording): adaptive zoom intensity based on click spatial density --- apps/desktop/src-tauri/src/recording.rs | 147 ++++++++++++++++-- .../(window-chrome)/settings/experimental.tsx | 18 ++- crates/project/src/configuration.rs | 23 +++ 3 files changed, 175 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index fff76cc840..626c891faa 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -2150,7 +2150,7 @@ fn generate_zoom_segments_from_clicks_impl( } } - let mut intervals: Vec<(f64, f64)> = Vec::new(); + let mut intervals: Vec<(f64, f64, Vec<(f64, f64)>)> = Vec::new(); for group in click_groups { if group.is_empty() { @@ -2168,7 +2168,11 @@ fn generate_zoom_segments_from_clicks_impl( let end = (group_end + click_post_padding).min(activity_end_limit); if end > start { - intervals.push((start, end)); + let positions: Vec<(f64, f64)> = group + .iter() + .filter_map(|&idx| click_positions.get(&idx).copied()) + .collect(); + intervals.push((start, end, positions)); } } @@ -2258,7 +2262,7 @@ fn generate_zoom_segments_from_clicks_impl( let end = (time + movement_post_padding).min(activity_end_limit); if end > start { - intervals.push((start, end)); + intervals.push((start, end, vec![])); } } @@ -2268,20 +2272,43 @@ fn generate_zoom_segments_from_clicks_impl( intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); - let mut merged: Vec<(f64, f64)> = Vec::new(); - for interval in intervals { + let mut merged: Vec<(f64, f64, Vec<(f64, f64)>)> = Vec::new(); + for (start, end, positions) in intervals { if let Some(last) = merged.last_mut() - && interval.0 <= last.1 + merge_gap_threshold + && start <= last.1 + merge_gap_threshold { - last.1 = last.1.max(interval.1); + last.1 = last.1.max(end); + last.2.extend(positions); continue; } - merged.push(interval); + merged.push((start, end, positions)); + } + + fn compute_zoom_amount(positions: &[(f64, f64)], config: &cap_project::AutoZoomConfig) -> f64 { + if (config.max_zoom_amount - config.min_zoom_amount).abs() < f64::EPSILON { + return config.zoom_amount; + } + if positions.len() < 2 { + return config.max_zoom_amount; + } + let mut max_dist = 0.0_f64; + for i in 0..positions.len() { + for j in (i + 1)..positions.len() { + let dx = positions[i].0 - positions[j].0; + let dy = positions[i].1 - positions[j].1; + let dist = (dx * dx + dy * dy).sqrt(); + if dist > max_dist { + max_dist = dist; + } + } + } + let t = (max_dist / config.intensity_spatial_scale).clamp(0.0, 1.0); + config.max_zoom_amount + t * (config.min_zoom_amount - config.max_zoom_amount) } merged .into_iter() - .filter_map(|(start, end)| { + .filter_map(|(start, end, positions)| { let duration = end - start; if duration < min_segment_duration { return None; @@ -2290,7 +2317,7 @@ fn generate_zoom_segments_from_clicks_impl( Some(ZoomSegment { start, end, - amount: auto_zoom_amount, + amount: compute_zoom_amount(&positions, config), mode: ZoomMode::Auto, glide_direction: GlideDirection::None, glide_speed: 0.5, @@ -2696,6 +2723,106 @@ mod tests { ); } + #[test] + fn intensity_tight_cluster_zooms_more() { + let clicks = vec![ + click_event(1_000.0), + click_event(1_500.0), + click_event(2_000.0), + ]; + let moves = vec![ + move_event(999.0, 0.50, 0.50), + move_event(1_499.0, 0.51, 0.51), + move_event(1_999.0, 0.52, 0.52), + ]; + + let config = cap_project::AutoZoomConfig { + min_zoom_amount: 1.2, + max_zoom_amount: 2.5, + intensity_spatial_scale: 0.3, + ..Default::default() + }; + + let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, &config); + + assert!( + !segments.is_empty(), + "tight cluster should produce at least one segment" + ); + assert!( + segments[0].amount > 2.0, + "tight cluster should zoom near max, got {}", + segments[0].amount + ); + } + + #[test] + fn intensity_spread_activity_zooms_less() { + let clicks = vec![ + click_event(1_000.0), + click_event(1_500.0), + click_event(2_000.0), + ]; + let moves = vec![ + move_event(999.0, 0.1, 0.1), + move_event(1_499.0, 0.5, 0.5), + move_event(1_999.0, 0.9, 0.9), + ]; + + let config = cap_project::AutoZoomConfig { + min_zoom_amount: 1.2, + max_zoom_amount: 2.5, + intensity_spatial_scale: 0.3, + ..Default::default() + }; + + let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, &config); + + assert!( + !segments.is_empty(), + "spread activity should produce at least one segment" + ); + for segment in &segments { + assert!( + segment.amount < 1.8, + "spread activity should zoom closer to min, got {}", + segment.amount + ); + } + } + + #[test] + fn intensity_disabled_when_equal() { + let clicks = vec![ + click_event(1_000.0), + click_event(1_500.0), + click_event(2_000.0), + ]; + let moves = vec![ + move_event(999.0, 0.50, 0.50), + move_event(1_499.0, 0.51, 0.51), + move_event(1_999.0, 0.52, 0.52), + ]; + + let config = cap_project::AutoZoomConfig { + min_zoom_amount: 1.5, + max_zoom_amount: 1.5, + zoom_amount: 1.5, + ..Default::default() + }; + + let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, &config); + + assert!(!segments.is_empty(), "should produce at least one segment"); + for segment in &segments { + assert!( + (segment.amount - 1.5).abs() < f64::EPSILON, + "when min == max, all segments should get exactly 1.5x, got {}", + segment.amount + ); + } + } + #[test] fn right_click_allowed_when_disabled() { let clicks = vec![ diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 1ee39f564e..555ddea696 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -80,6 +80,9 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { deadZoneRadius: 0.1, doubleClickThresholdMs: 400.0, ignoreRightClicks: true, + minZoomAmount: 1.2, + maxZoomAmount: 2.5, + intensitySpatialScale: 0.3, }, }, ); @@ -162,10 +165,19 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) {
handleConfigChange("zoomAmount", v)} + label="Min Zoom" + value={settings.autoZoomConfig?.minZoomAmount ?? 1.2} + onChange={(v) => handleConfigChange("minZoomAmount", v)} min={1.0} + max={3.0} + step={0.1} + format={(v) => `${v.toFixed(1)}x`} + /> + handleConfigChange("maxZoomAmount", v)} + min={1.5} max={4.0} step={0.1} format={(v) => `${v.toFixed(1)}x`} diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 4f426c0b3a..259a2aac31 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -472,6 +472,26 @@ pub struct AutoZoomConfig { pub dead_zone_radius: f64, pub double_click_threshold_ms: f64, pub ignore_right_clicks: bool, + #[serde(default = "AutoZoomConfig::default_min_zoom_amount")] + pub min_zoom_amount: f64, + #[serde(default = "AutoZoomConfig::default_max_zoom_amount")] + pub max_zoom_amount: f64, + #[serde(default = "AutoZoomConfig::default_intensity_spatial_scale")] + pub intensity_spatial_scale: f64, +} + +impl AutoZoomConfig { + fn default_min_zoom_amount() -> f64 { + 1.2 + } + + fn default_max_zoom_amount() -> f64 { + 2.5 + } + + fn default_intensity_spatial_scale() -> f64 { + 0.3 + } } impl Default for AutoZoomConfig { @@ -491,6 +511,9 @@ impl Default for AutoZoomConfig { dead_zone_radius: 0.1, double_click_threshold_ms: 400.0, ignore_right_clicks: true, + min_zoom_amount: Self::default_min_zoom_amount(), + max_zoom_amount: Self::default_max_zoom_amount(), + intensity_spatial_scale: Self::default_intensity_spatial_scale(), } } } From 8323659cc42bf83bb7794f132fb5d39f5e93901b Mon Sep 17 00:00:00 2001 From: namearth5005 Date: Wed, 18 Mar 2026 15:05:15 +0700 Subject: [PATCH 10/11] feat(rendering): activate edge snapping for auto-zoom viewport - Remove #[allow(dead_code)] from apply_edge_snap_to_focus - Apply edge snapping per-frame in render pipeline for Auto mode segments - Add edge_snap_enabled config field (default true) - Set edge_snap_ratio to 0.0 on generated segments when disabled - Add Edge Snapping toggle in experimental settings UI --- apps/desktop/src-tauri/src/recording.rs | 2 +- .../(window-chrome)/settings/experimental.tsx | 9 ++++++ crates/project/src/configuration.rs | 7 +++++ crates/rendering/src/lib.rs | 28 +++++++++++++++++++ .../rendering/src/zoom_focus_interpolation.rs | 1 - 5 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 626c891faa..49262f243c 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -2322,7 +2322,7 @@ fn generate_zoom_segments_from_clicks_impl( glide_direction: GlideDirection::None, glide_speed: 0.5, instant_animation: false, - edge_snap_ratio: 0.25, + edge_snap_ratio: if config.edge_snap_enabled { 0.25 } else { 0.0 }, }) }) .collect() diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 555ddea696..b59c659eed 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -83,6 +83,7 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { minZoomAmount: 1.2, maxZoomAmount: 2.5, intensitySpatialScale: 0.3, + edgeSnapEnabled: true, }, }, ); @@ -163,6 +164,14 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { />
+
+ handleConfigChange("edgeSnapEnabled", v)} + /> +
f64 { 0.3 } + + fn default_edge_snap_enabled() -> bool { + true + } } impl Default for AutoZoomConfig { @@ -514,6 +520,7 @@ impl Default for AutoZoomConfig { min_zoom_amount: Self::default_min_zoom_amount(), max_zoom_amount: Self::default_max_zoom_amount(), intensity_spatial_scale: Self::default_intensity_spatial_scale(), + edge_snap_enabled: Self::default_edge_snap_enabled(), } } } diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index d7dd830cc7..389e8b755c 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -1933,8 +1933,36 @@ impl ProjectUniforms { .unwrap_or(&[]); let zoom_focus = zoom_focus_interpolator.interpolate(current_recording_time); + let zoom_focus = { + let active_segment = zoom_segments + .iter() + .find(|s| frame_time as f64 > s.start && frame_time as f64 <= s.end); + if let Some(segment) = active_segment { + if matches!(segment.mode, cap_project::ZoomMode::Auto) { + zoom_focus_interpolation::apply_edge_snap_to_focus(zoom_focus, segment) + } else { + zoom_focus + } + } else { + zoom_focus + } + }; let prev_zoom_focus = zoom_focus_interpolator.interpolate(prev_recording_time); + let prev_zoom_focus = { + let active_segment = zoom_segments + .iter() + .find(|s| prev_frame_time as f64 > s.start && prev_frame_time as f64 <= s.end); + if let Some(segment) = active_segment { + if matches!(segment.mode, cap_project::ZoomMode::Auto) { + zoom_focus_interpolation::apply_edge_snap_to_focus(prev_zoom_focus, segment) + } else { + prev_zoom_focus + } + } else { + prev_zoom_focus + } + }; let actual_cursor_coord = interpolated_cursor .as_ref() diff --git a/crates/rendering/src/zoom_focus_interpolation.rs b/crates/rendering/src/zoom_focus_interpolation.rs index c698026254..cdd23d304c 100644 --- a/crates/rendering/src/zoom_focus_interpolation.rs +++ b/crates/rendering/src/zoom_focus_interpolation.rs @@ -189,7 +189,6 @@ impl ZoomFocusInterpolator { } } -#[allow(dead_code)] pub fn apply_edge_snap_to_focus( focus: Coord, segment: &ZoomSegment, From 440bf6d58e4bfc342b66f852ac4d959312240bbb Mon Sep 17 00:00:00 2001 From: namearth5005 Date: Fri, 20 Mar 2026 13:34:29 +0700 Subject: [PATCH 11/11] fix(auto-zoom): add config validation, error logging, and division-by-zero guards - Add AutoZoomConfig::validated() to clamp min/max zoom and guard intensity_spatial_scale - Replace silent unwrap_or chains with proper error logging via tracing::error - Guard division by zero in apply_edge_snap_to_focus for zoom_amount < epsilon - Guard 0/0 NaN in compute_zoom_amount with intensity_spatial_scale.max(EPSILON) - Deduplicate edge-snap rendering block into closure - Remove dead auto_zoom_amount variable - Add await/catch to handleConfigChange for store persistence - Fix fragile right_click_ignored test (movement events were masking assertion) --- apps/desktop/src-tauri/src/lib.rs | 14 +++++--- apps/desktop/src-tauri/src/recording.rs | 32 ++++++++--------- .../(window-chrome)/settings/experimental.tsx | 12 ++++--- crates/project/src/configuration.rs | 11 ++++++ crates/rendering/src/lib.rs | 36 ++++++------------- .../rendering/src/zoom_focus_interpolation.rs | 4 +++ 6 files changed, 58 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 728792d560..9926cfe41d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2116,14 +2116,20 @@ 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 settings = match GeneralSettingsStore::get(&app) { + Ok(Some(s)) => s, + Ok(None) => GeneralSettingsStore::default(), + Err(e) => { + tracing::error!("Failed to load general settings for zoom generation: {e}"); + GeneralSettingsStore::default() + } + }; + let config = settings.auto_zoom_config.validated(); 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); + recording::generate_zoom_segments_for_project(meta, recordings, &config); Ok(zoom_segments) } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 49262f243c..b5cc48e2e3 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -2010,7 +2010,6 @@ fn generate_zoom_segments_from_clicks_impl( 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; let dead_zone_radius = config.dead_zone_radius; if max_duration <= 0.0 { @@ -2302,7 +2301,8 @@ fn generate_zoom_segments_from_clicks_impl( } } } - let t = (max_dist / config.intensity_spatial_scale).clamp(0.0, 1.0); + let scale = config.intensity_spatial_scale.max(f64::EPSILON); + let t = (max_dist / scale).clamp(0.0, 1.0); config.max_zoom_amount + t * (config.min_zoom_amount - config.max_zoom_amount) } @@ -2390,9 +2390,14 @@ fn project_config_from_recording( recordings: &ProjectRecordingsMeta, default_config: Option, ) -> ProjectConfiguration { - let settings = GeneralSettingsStore::get(app) - .unwrap_or(None) - .unwrap_or_default(); + let settings = match GeneralSettingsStore::get(app) { + Ok(Some(s)) => s, + Ok(None) => GeneralSettingsStore::default(), + Err(e) => { + tracing::error!("Failed to load general settings for project config: {e}"); + GeneralSettingsStore::default() + } + }; let mut config = default_config.unwrap_or_default(); @@ -2430,7 +2435,7 @@ fn project_config_from_recording( generate_zoom_segments_from_clicks( completed_recording, recordings, - &settings.auto_zoom_config, + &settings.auto_zoom_config.validated(), ) } else { Vec::new() @@ -2699,11 +2704,7 @@ mod tests { 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 moves = vec![move_event(999.0, 0.5, 0.5)]; let config = cap_project::AutoZoomConfig { ignore_right_clicks: true, @@ -2712,14 +2713,9 @@ mod tests { 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" + segments.is_empty(), + "right-click only input should produce no segments when ignore_right_clicks is true" ); } diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index b59c659eed..04109b2c74 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -88,14 +88,18 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { }, ); - const handleConfigChange = ( + const handleConfigChange = async ( key: K, value: AutoZoomConfig[K], ) => { setSettings("autoZoomConfig", key, value); - generalSettingsStore.set({ - autoZoomConfig: { ...settings.autoZoomConfig, [key]: value }, - }); + try { + await generalSettingsStore.set({ + autoZoomConfig: { ...settings.autoZoomConfig, [key]: value }, + }); + } catch (e) { + console.error("Failed to persist auto-zoom config:", e); + } }; const handleChange = async ( diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index c3fdc824f8..79e5965026 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -498,6 +498,17 @@ impl AutoZoomConfig { fn default_edge_snap_enabled() -> bool { true } + + pub fn validated(mut self) -> Self { + self.min_zoom_amount = self.min_zoom_amount.max(1.0); + self.max_zoom_amount = self.max_zoom_amount.max(1.0); + if self.min_zoom_amount > self.max_zoom_amount { + std::mem::swap(&mut self.min_zoom_amount, &mut self.max_zoom_amount); + } + self.zoom_amount = self.zoom_amount.max(1.0); + self.intensity_spatial_scale = self.intensity_spatial_scale.max(0.001); + self + } } impl Default for AutoZoomConfig { diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 389e8b755c..6c32ef2151 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -1932,37 +1932,23 @@ impl ProjectUniforms { .map(|t| t.scene_segments.as_slice()) .unwrap_or(&[]); - let zoom_focus = zoom_focus_interpolator.interpolate(current_recording_time); - let zoom_focus = { - let active_segment = zoom_segments + let maybe_snap = |focus: Coord, time: f64| { + let active = zoom_segments .iter() - .find(|s| frame_time as f64 > s.start && frame_time as f64 <= s.end); - if let Some(segment) = active_segment { - if matches!(segment.mode, cap_project::ZoomMode::Auto) { - zoom_focus_interpolation::apply_edge_snap_to_focus(zoom_focus, segment) - } else { - zoom_focus + .find(|s| time > s.start && time <= s.end); + match active { + Some(s) if matches!(s.mode, cap_project::ZoomMode::Auto) => { + zoom_focus_interpolation::apply_edge_snap_to_focus(focus, s) } - } else { - zoom_focus + _ => focus, } }; + let zoom_focus = zoom_focus_interpolator.interpolate(current_recording_time); + let zoom_focus = maybe_snap(zoom_focus, frame_time as f64); + let prev_zoom_focus = zoom_focus_interpolator.interpolate(prev_recording_time); - let prev_zoom_focus = { - let active_segment = zoom_segments - .iter() - .find(|s| prev_frame_time as f64 > s.start && prev_frame_time as f64 <= s.end); - if let Some(segment) = active_segment { - if matches!(segment.mode, cap_project::ZoomMode::Auto) { - zoom_focus_interpolation::apply_edge_snap_to_focus(prev_zoom_focus, segment) - } else { - prev_zoom_focus - } - } else { - prev_zoom_focus - } - }; + let prev_zoom_focus = maybe_snap(prev_zoom_focus, prev_frame_time as f64); let actual_cursor_coord = interpolated_cursor .as_ref() diff --git a/crates/rendering/src/zoom_focus_interpolation.rs b/crates/rendering/src/zoom_focus_interpolation.rs index cdd23d304c..54c41dd8c7 100644 --- a/crates/rendering/src/zoom_focus_interpolation.rs +++ b/crates/rendering/src/zoom_focus_interpolation.rs @@ -197,6 +197,10 @@ pub fn apply_edge_snap_to_focus( let zoom_amount = segment.amount; let edge_snap_ratio = segment.edge_snap_ratio; + if zoom_amount < f64::EPSILON || edge_snap_ratio <= 0.0 { + return focus; + } + let viewport_half = 0.5 / zoom_amount; let snap_threshold = edge_snap_ratio / zoom_amount;