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, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 9918b2e0cd..728792d560 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2111,14 +2111,19 @@ 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); + let zoom_segments = + recording::generate_zoom_segments_for_project(meta, recordings, &settings.auto_zoom_config); 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(), diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 3d1bd22fc7..b5252dbd4a 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 { + type AutoZoomConfig, + commands, + 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,46 @@ 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`} + /> +
+
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 {