From c0397598492ac0c174d13f80c9731e4533b9037e Mon Sep 17 00:00:00 2001 From: conradomateu Date: Thu, 5 Mar 2026 15:35:25 +0100 Subject: [PATCH 1/7] Promote auto-zoom to production: configurable intensity, sensitivity, and dead zone Add "Auto Zoom" toggle to Studio Mode recording setup, replacing the experimental settings toggle. Add zoom intensity and sensitivity sliders in the editor sidebar with generate/remove controls. Add dead zone radius to spring focus interpolation to suppress viewport jitter. Increase main window height to fit the new control. --- .../desktop/src-tauri/src/general_settings.rs | 14 ++ apps/desktop/src-tauri/src/lib.rs | 12 +- apps/desktop/src-tauri/src/recording.rs | 132 +++++++++++++++--- apps/desktop/src-tauri/src/windows.rs | 2 +- .../routes/(window-chrome)/new-main/index.tsx | 29 +++- .../(window-chrome)/settings/experimental.tsx | 13 -- .../src/routes/editor/ConfigSidebar.tsx | 50 ++++++- .../src/routes/editor/Timeline/ZoomTrack.tsx | 25 +--- apps/desktop/src/routes/editor/context.ts | 17 +++ crates/project/src/configuration.rs | 9 ++ .../rendering/src/zoom_focus_interpolation.rs | 117 +++++++++++++++- 11 files changed, 359 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 546b4960a8..dc18767d59 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -123,6 +123,10 @@ pub struct GeneralSettingsStore { pub enable_native_camera_preview: bool, #[serde(default)] pub auto_zoom_on_clicks: bool, + #[serde(default = "default_auto_zoom_amount")] + pub auto_zoom_amount: f64, + #[serde(default = "default_auto_zoom_sensitivity")] + pub auto_zoom_sensitivity: f64, #[serde(default)] pub post_deletion_behaviour: PostDeletionBehaviour, #[serde(default = "default_excluded_windows")] @@ -167,6 +171,14 @@ fn default_max_fps() -> u32 { 60 } +fn default_auto_zoom_amount() -> f64 { + 1.5 +} + +fn default_auto_zoom_sensitivity() -> f64 { + 0.5 +} + fn default_server_url() -> String { std::option_env!("VITE_SERVER_URL") .unwrap_or("https://cap.so") @@ -203,6 +215,8 @@ impl Default for GeneralSettingsStore { recording_countdown: Some(3), enable_native_camera_preview: default_enable_native_camera_preview(), auto_zoom_on_clicks: false, + auto_zoom_amount: default_auto_zoom_amount(), + auto_zoom_sensitivity: default_auto_zoom_sensitivity(), post_deletion_behaviour: PostDeletionBehaviour::DoNothing, excluded_windows: default_excluded_windows(), delete_instant_recordings_after_upload: false, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 7497f352dc..e5e823765b 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2020,12 +2020,22 @@ async fn update_project_config_in_memory( #[specta::specta] #[instrument(skip(editor_instance))] async fn generate_zoom_segments_from_clicks( + app: AppHandle, editor_instance: WindowEditorInstance, ) -> Result, String> { let meta = editor_instance.meta(); let recordings = &editor_instance.recordings; - let zoom_segments = recording::generate_zoom_segments_for_project(meta, recordings); + let settings = GeneralSettingsStore::get(&app) + .unwrap_or(None) + .unwrap_or_default(); + + let zoom_segments = recording::generate_zoom_segments_for_project( + meta, + recordings, + settings.auto_zoom_amount, + settings.auto_zoom_sensitivity, + ); Ok(zoom_segments) } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index d65bce27fc..bbdb96e95f 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1989,30 +1989,34 @@ 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, + base_zoom_amount: f64, + sensitivity: f64, ) -> 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 BASE_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 BASE_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 BASE_MOVEMENT_WINDOW_DISTANCE_THRESHOLD: f64 = 0.08; const SHAKE_FILTER_THRESHOLD: f64 = 0.33; const SHAKE_FILTER_WINDOW_MS: f64 = 150.0; + let sensitivity_scale = 1.5 - sensitivity; + let click_group_spatial_threshold = BASE_CLICK_GROUP_SPATIAL_THRESHOLD * sensitivity_scale; + let min_segment_duration = BASE_MIN_SEGMENT_DURATION * sensitivity_scale; + let movement_window_distance_threshold = + BASE_MOVEMENT_WINDOW_DISTANCE_THRESHOLD * sensitivity_scale; + if max_duration <= 0.0 { return Vec::new(); } @@ -2082,7 +2086,7 @@ fn generate_zoom_segments_from_clicks_impl( (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, }; @@ -2200,7 +2204,7 @@ fn generate_zoom_segments_from_clicks_impl( } let significant_movement = distance >= MOVEMENT_EVENT_DISTANCE_THRESHOLD - || window_distance >= MOVEMENT_WINDOW_DISTANCE_THRESHOLD; + || window_distance >= movement_window_distance_threshold; if !significant_movement { continue; @@ -2235,14 +2239,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: base_zoom_amount, mode: ZoomMode::Auto, glide_direction: GlideDirection::None, glide_speed: 0.5, @@ -2253,13 +2257,12 @@ 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, + zoom_amount: f64, + zoom_sensitivity: f64, ) -> 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 +2272,14 @@ 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, zoom_amount, zoom_sensitivity) } -/// 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, + zoom_amount: f64, + zoom_sensitivity: f64, ) -> Vec { let RecordingMetaInner::Studio(studio_meta) = &recording_meta.inner else { return Vec::new(); @@ -2309,7 +2312,13 @@ 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(), + zoom_amount, + zoom_sensitivity, + ) } fn project_config_from_recording( @@ -2355,7 +2364,12 @@ 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_amount, + settings.auto_zoom_sensitivity, + ) } else { Vec::new() }; @@ -2429,8 +2443,13 @@ 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, + 1.5, + 0.5, + ); assert!( segments.is_empty(), @@ -2447,7 +2466,7 @@ 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, 1.5, 0.5); assert!( !segments.is_empty(), @@ -2459,6 +2478,72 @@ mod tests { assert!(first.end <= 19.5); } + #[test] + fn variable_zoom_from_settings() { + let clicks = vec![click_event(1_200.0), click_event(4_200.0)]; + let moves = vec![ + move_event(1_500.0, 0.10, 0.12), + move_event(1_720.0, 0.42, 0.45), + move_event(1_940.0, 0.74, 0.78), + ]; + + let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, 2.0, 0.5); + + assert!(!segments.is_empty()); + for seg in &segments { + assert!((seg.amount - 2.0).abs() < 0.01); + } + } + + #[test] + fn sensitivity_affects_segment_count() { + let clicks = vec![ + click_event(1_000.0), + click_event(2_500.0), + click_event(5_000.0), + click_event(7_500.0), + ]; + let moves = vec![ + move_event(1_200.0, 0.1, 0.1), + move_event(2_700.0, 0.5, 0.5), + move_event(5_200.0, 0.8, 0.2), + move_event(7_700.0, 0.2, 0.8), + ]; + + let low_sens = + generate_zoom_segments_from_clicks_impl(clicks.clone(), moves.clone(), 20.0, 1.5, 0.0); + let high_sens = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0, 1.5, 1.0); + + assert!( + high_sens.len() >= low_sens.len(), + "high sensitivity ({}) should produce >= segments than low ({})", + high_sens.len(), + low_sens.len() + ); + } + + #[test] + fn backward_compat_default_params() { + let clicks = vec![click_event(1_200.0), click_event(4_200.0)]; + let moves = vec![ + move_event(1_500.0, 0.10, 0.12), + move_event(1_720.0, 0.42, 0.45), + move_event(1_940.0, 0.74, 0.78), + ]; + + let segments = + generate_zoom_segments_from_clicks_impl(clicks.clone(), moves.clone(), 20.0, 1.5, 0.5); + + assert!(!segments.is_empty()); + for seg in &segments { + assert!( + (seg.amount - 1.5).abs() < 0.01, + "default params should produce zoom near 1.5, got {}", + seg.amount + ); + } + } + #[test] fn ignores_cursor_jitter() { let jitter_moves = (0..30) @@ -2469,7 +2554,8 @@ 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, 1.5, 0.5); assert!( segments.is_empty(), diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 4fd0d5d5df..2297d56ad7 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -514,7 +514,7 @@ impl CapWindowId { pub fn min_size(&self) -> Option<(f64, f64)> { Some(match self { Self::Setup => (600.0, 600.0), - Self::Main => (330.0, 395.0), + Self::Main => (330.0, 450.0), Self::Editor { .. } => (1275.0, 800.0), Self::ScreenshotEditor { .. } => (800.0, 600.0), Self::Settings => (700.0, 540.0), diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index bfd7e51126..390abf069d 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -35,7 +35,7 @@ import Mode from "~/components/Mode"; import { RecoveryToast } from "~/components/RecoveryToast"; import Tooltip from "~/components/Tooltip"; import { Input } from "~/routes/editor/ui"; -import { authStore } from "~/store"; +import { authStore, generalSettingsStore } from "~/store"; import { createSignInMutation } from "~/utils/auth"; import { createTauriEventListener } from "~/utils/createEventListener"; import { @@ -86,6 +86,7 @@ import { } from "../OptionsContext"; import CameraSelect from "./CameraSelect"; import ChangelogButton from "./ChangeLogButton"; +import InfoPill from "./InfoPill"; import MicrophoneSelect from "./MicrophoneSelect"; import ModeInfoPanel from "./ModeInfoPanel"; import SystemAudio from "./SystemAudio"; @@ -95,7 +96,7 @@ import TargetMenuGrid from "./TargetMenuGrid"; import TargetTypeButton from "./TargetTypeButton"; import useRequestPermission from "./useRequestPermission"; -const WINDOW_SIZE = { width: 330, height: 395 } as const; +const WINDOW_SIZE = { width: 330, height: 450 } as const; const findCamera = (cameras: CameraWithDetails[], id: DeviceOrModelID) => { return cameras.find((c) => { @@ -1493,6 +1494,8 @@ function Page() { const signIn = createSignInMutation(); + const generalSettings = generalSettingsStore.createQuery(); + const BaseControls = () => (
+ + +
); diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 3d1bd22fc7..25dc68987c 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -25,7 +25,6 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { autoCreateShareableLink: false, enableNotifications: true, enableNativeCameraPreview: false, - autoZoomOnClicks: false, custom_cursor_capture2: true, }, ); @@ -82,18 +81,6 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { } /> )} - { - handleChange("autoZoomOnClicks", value); - setTimeout( - () => window.scrollTo({ top: 0, behavior: "instant" }), - 5, - ); - }} - /> diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 8765313716..ce01176e11 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -321,6 +321,8 @@ export function ConfigSidebar() { meta, } = useEditorContext(); + const autoZoomSettings = generalSettingsStore.createQuery(); + const cursorIdleDelay = () => ((project.cursor as { hideWhenIdleDelay?: number }).hideWhenIdleDelay ?? 2) as number; @@ -968,6 +970,53 @@ export function ConfigSidebar() { )} +
+ }> +
+ projectActions.generateAutoZoom()} + leftIcon={} + > + Generate + + projectActions.removeAllZoomSegments()} + leftIcon={} + > + Remove all + +
+
+ }> + + generalSettingsStore.set({ + autoZoomAmount: v[0], + }) + } + minValue={1.0} + maxValue={3.0} + step={0.1} + formatTooltip="x" + /> + + }> + + generalSettingsStore.set({ + autoZoomSensitivity: v[0], + }) + } + minValue={0.0} + maxValue={1.0} + step={0.05} + formatTooltip={(v) => `${Math.round(v * 100)}%`} + /> + +
{ const zoomSelection = selection(); @@ -3087,7 +3136,6 @@ function ZoomSegmentConfig(props: { Auto diff --git a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx index d48cb92395..af3a08ea8c 100644 --- a/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx @@ -13,7 +13,6 @@ import { Switch, } from "solid-js"; import { produce } from "solid-js/store"; -import { commands } from "~/utils/tauri"; import { useEditorContext } from "../context"; import { useSegmentContext, @@ -49,21 +48,6 @@ export function ZoomTrack(props: { const [creatingSegmentViaDrag, setCreatingSegmentViaDrag] = createSignal(false); - const handleGenerateZoomSegments = async () => { - try { - const zoomSegments = await commands.generateZoomSegmentsFromClicks(); - setProject("timeline", "zoomSegments", zoomSegments); - if (zoomSegments.length > 0) { - const currentSize = project.cursor?.size ?? 0; - if (currentSize < 200) { - setProject("cursor", "size", 200); - } - } - } catch (error) { - console.error("Failed to generate zoom segments:", error); - } - }; - const newSegmentMinDuration = () => Math.max( MIN_NEW_SEGMENT_PIXEL_WIDTH * secsPerPixel(), @@ -141,8 +125,6 @@ export function ZoomTrack(props: { onMouseEnter={() => setEditorState("timeline", "hoveredTrack", "zoom")} onMouseLeave={() => setEditorState("timeline", "hoveredTrack", null)} onContextMenu={async (e) => { - if (!import.meta.env.DEV) return; - e.preventDefault(); const menu = await Menu.new({ id: "zoom-track-options", @@ -150,7 +132,12 @@ export function ZoomTrack(props: { { id: "generateZoomSegments", text: "Generate zoom segments from clicks", - action: handleGenerateZoomSegments, + action: () => projectActions.generateAutoZoom(), + }, + { + id: "removeAllZoomSegments", + text: "Remove all zoom segments", + action: () => projectActions.removeAllZoomSegments(), }, ], }); diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index be629d2bf3..9b842271d2 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -287,6 +287,23 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( }), ); }, + generateAutoZoom: async () => { + try { + const zoomSegments = await commands.generateZoomSegmentsFromClicks(); + setProject("timeline", "zoomSegments", zoomSegments); + if (zoomSegments.length > 0) { + const currentSize = project.cursor?.size ?? 0; + if (currentSize < 200) { + setProject("cursor", "size", 200); + } + } + } catch (error) { + console.error("Failed to generate zoom segments:", error); + } + }, + removeAllZoomSegments: () => { + setProject("timeline", "zoomSegments", []); + }, deleteZoomSegments: (segmentIndices: number[]) => { batch(() => { setProject( diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index f2f49fbea0..99793ce5b4 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -443,6 +443,14 @@ pub struct ScreenMovementSpring { pub stiffness: f32, pub damping: f32, pub mass: f32, + #[serde(default = "ScreenMovementSpring::default_dead_zone_radius")] + pub dead_zone_radius: f32, +} + +impl ScreenMovementSpring { + fn default_dead_zone_radius() -> f32 { + 0.03 + } } impl Default for ScreenMovementSpring { @@ -451,6 +459,7 @@ impl Default for ScreenMovementSpring { stiffness: 200.0, damping: 40.0, mass: 2.25, + dead_zone_radius: 0.03, } } } diff --git a/crates/rendering/src/zoom_focus_interpolation.rs b/crates/rendering/src/zoom_focus_interpolation.rs index c698026254..4d62e98f53 100644 --- a/crates/rendering/src/zoom_focus_interpolation.rs +++ b/crates/rendering/src/zoom_focus_interpolation.rs @@ -80,6 +80,8 @@ impl ZoomFocusInterpolator { sim.set_velocity(XY::new(0.0, 0.0)); sim.set_target_position(initial_pos); + let mut last_committed_target = initial_pos; + let mut events = vec![SmoothedFocusEvent { time: 0.0, position: initial_pos, @@ -99,7 +101,12 @@ impl ZoomFocusInterpolator { cursor.position.coord.x as f32, cursor.position.coord.y as f32, ); - sim.set_target_position(target); + let dx = target.x - last_committed_target.x; + let dy = target.y - last_committed_target.y; + if (dx * dx + dy * dy).sqrt() > self.screen_spring.dead_zone_radius { + last_committed_target = target; + sim.set_target_position(target); + } } sim.run(SAMPLE_INTERVAL_MS as f32); @@ -213,3 +220,111 @@ pub fn apply_edge_snap_to_focus( Coord::new(XY::new(snap_axis(position.0), snap_axis(position.1))) } + +#[cfg(test)] +mod tests { + use super::*; + use cap_project::CursorMoveEvent; + + fn make_cursor_events(moves: Vec<(f64, f64, f64)>) -> CursorEvents { + CursorEvents { + clicks: vec![], + moves: moves + .into_iter() + .map(|(t, x, y)| CursorMoveEvent { + active_modifiers: vec![], + cursor_id: "default".to_string(), + time_ms: t, + x, + y, + }) + .collect(), + } + } + + fn run_interpolator( + cursor_events: &CursorEvents, + dead_zone_radius: f32, + duration_secs: f64, + sample_times: &[f32], + ) -> Vec<(f64, f64)> { + let spring = ScreenMovementSpring { + dead_zone_radius, + ..Default::default() + }; + let mut interp = ZoomFocusInterpolator::new(cursor_events, None, spring, duration_secs); + interp.precompute(); + sample_times + .iter() + .map(|&t| { + let c = interp.interpolate(t); + (c.x, c.y) + }) + .collect() + } + + #[test] + fn dead_zone_suppresses_micro_jitter() { + let moves: Vec<(f64, f64, f64)> = (0..100) + .map(|i| { + let t = i as f64 * 20.0; + let jitter = (i % 3) as f64 * 0.005; + (t, 0.5 + jitter, 0.5 - jitter) + }) + .collect(); + let events = make_cursor_events(moves); + + let sample_times: Vec = (0..50).map(|i| i as f32 * 0.04).collect(); + + let with_dead_zone = run_interpolator(&events, 0.03, 2.0, &sample_times); + let without_dead_zone = run_interpolator(&events, 0.0, 2.0, &sample_times); + + let variance = |pts: &[(f64, f64)]| -> f64 { + let mean_x = pts.iter().map(|p| p.0).sum::() / pts.len() as f64; + let mean_y = pts.iter().map(|p| p.1).sum::() / pts.len() as f64; + pts.iter() + .map(|p| (p.0 - mean_x).powi(2) + (p.1 - mean_y).powi(2)) + .sum::() + / pts.len() as f64 + }; + + assert!( + variance(&with_dead_zone) <= variance(&without_dead_zone), + "dead zone should reduce variance from jitter" + ); + } + + #[test] + fn dead_zone_zero_preserves_behavior() { + let moves = vec![(0.0, 0.3, 0.3), (500.0, 0.5, 0.5), (1000.0, 0.7, 0.7)]; + let events = make_cursor_events(moves); + let sample_times: Vec = (0..20).map(|i| i as f32 * 0.1).collect(); + + let result_a = run_interpolator(&events, 0.0, 2.0, &sample_times); + let result_b = run_interpolator(&events, 0.0, 2.0, &sample_times); + + for (a, b) in result_a.iter().zip(result_b.iter()) { + assert!((a.0 - b.0).abs() < 1e-6); + assert!((a.1 - b.1).abs() < 1e-6); + } + } + + #[test] + fn dead_zone_allows_large_movements() { + let moves = vec![(0.0, 0.2, 0.2), (500.0, 0.5, 0.5), (1000.0, 0.8, 0.8)]; + let events = make_cursor_events(moves); + let sample_times = vec![0.0, 0.5, 1.0, 1.5]; + + let positions = run_interpolator(&events, 0.03, 2.0, &sample_times); + + let start = positions[0]; + let end = positions[positions.len() - 1]; + let dist = ((end.0 - start.0).powi(2) + (end.1 - start.1).powi(2)).sqrt(); + + assert!( + dist > 0.2, + "large cursor movements should be tracked even with dead zone, got dist={}", + dist + ); + } +} From ba7fd0c8f35a27cdbd4a34fd3d8e94785ae1023a Mon Sep 17 00:00:00 2001 From: conradomateu Date: Thu, 5 Mar 2026 15:47:09 +0100 Subject: [PATCH 2/7] Address review feedback: input validation, sqrt optimization, and test fix - Propagate GeneralSettingsStore::get errors instead of swallowing them - Clamp base_zoom_amount and sensitivity before threshold math - Compare squared distance instead of computing sqrt every sample - Fix dead_zone_zero test to verify actual cursor tracking behavior --- apps/desktop/src-tauri/src/lib.rs | 4 +--- apps/desktop/src-tauri/src/recording.rs | 6 ++++++ .../rendering/src/zoom_focus_interpolation.rs | 21 ++++++++++++------- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e5e823765b..e67e5d7639 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2026,9 +2026,7 @@ async fn generate_zoom_segments_from_clicks( let meta = editor_instance.meta(); let recordings = &editor_instance.recordings; - let settings = GeneralSettingsStore::get(&app) - .unwrap_or(None) - .unwrap_or_default(); + let settings = GeneralSettingsStore::get(&app)?.unwrap_or_default(); let zoom_segments = recording::generate_zoom_segments_for_project( meta, diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index bbdb96e95f..ac92df1adf 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -2011,6 +2011,12 @@ fn generate_zoom_segments_from_clicks_impl( const SHAKE_FILTER_THRESHOLD: f64 = 0.33; const SHAKE_FILTER_WINDOW_MS: f64 = 150.0; + let base_zoom_amount = if base_zoom_amount.is_finite() { + base_zoom_amount.clamp(1.0, 3.0) + } else { + 1.5 + }; + let sensitivity = sensitivity.clamp(0.0, 1.0); let sensitivity_scale = 1.5 - sensitivity; let click_group_spatial_threshold = BASE_CLICK_GROUP_SPATIAL_THRESHOLD * sensitivity_scale; let min_segment_duration = BASE_MIN_SEGMENT_DURATION * sensitivity_scale; diff --git a/crates/rendering/src/zoom_focus_interpolation.rs b/crates/rendering/src/zoom_focus_interpolation.rs index 4d62e98f53..be946f71b6 100644 --- a/crates/rendering/src/zoom_focus_interpolation.rs +++ b/crates/rendering/src/zoom_focus_interpolation.rs @@ -103,7 +103,8 @@ impl ZoomFocusInterpolator { ); let dx = target.x - last_committed_target.x; let dy = target.y - last_committed_target.y; - if (dx * dx + dy * dy).sqrt() > self.screen_spring.dead_zone_radius { + let dead_zone_radius = self.screen_spring.dead_zone_radius; + if dx * dx + dy * dy > dead_zone_radius * dead_zone_radius { last_committed_target = target; sim.set_target_position(target); } @@ -300,13 +301,19 @@ mod tests { let events = make_cursor_events(moves); let sample_times: Vec = (0..20).map(|i| i as f32 * 0.1).collect(); - let result_a = run_interpolator(&events, 0.0, 2.0, &sample_times); - let result_b = run_interpolator(&events, 0.0, 2.0, &sample_times); + let result = run_interpolator(&events, 0.0, 2.0, &sample_times); - for (a, b) in result_a.iter().zip(result_b.iter()) { - assert!((a.0 - b.0).abs() < 1e-6); - assert!((a.1 - b.1).abs() < 1e-6); - } + let last = result.last().unwrap(); + assert!( + (last.0 - 0.7).abs() < 0.05, + "with dead_zone=0, spring should track final cursor position, got {}", + last.0 + ); + assert!( + (last.1 - 0.7).abs() < 0.05, + "with dead_zone=0, spring should track final cursor position, got {}", + last.1 + ); } #[test] From 3be9a046d626288517a1f8d8daa71b7a1500209e Mon Sep 17 00:00:00 2001 From: conradomateu Date: Thu, 5 Mar 2026 15:53:25 +0100 Subject: [PATCH 3/7] Address remaining review feedback - Use Self::default_dead_zone_radius() in Default impl instead of hardcoded 0.03 - Change cursor-default to cursor-pointer on auto-zoom toggle button --- apps/desktop/src/routes/(window-chrome)/new-main/index.tsx | 2 +- crates/project/src/configuration.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx index 390abf069d..81752b4880 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/index.tsx @@ -1539,7 +1539,7 @@ function Page() {