Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop/src-tauri/src/general_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WindowExclusion>,
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<ZoomSegment>, String> {
let settings = GeneralSettingsStore::get(&app)
.unwrap_or(None)
.unwrap_or_default();
Comment on lines +2119 to +2121
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Idiomatic error handling with ok().flatten()

unwrap_or(None) on a Result<Option<T>> is non-idiomatic and silently discards the error. The codebase already uses a cleaner pattern elsewhere (e.g. recording.rs:598 uses .ok().flatten()):

Suggested change
let settings = GeneralSettingsStore::get(&app)
.unwrap_or(None)
.unwrap_or_default();
let settings = GeneralSettingsStore::get(&app)
.ok()
.flatten()
.unwrap_or_default();

This makes the intent explicit — convert Err to None, then fall back to defaults — and is consistent with the rest of the file.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/lib.rs
Line: 2119-2121

Comment:
**Idiomatic error handling with `ok().flatten()`**

`unwrap_or(None)` on a `Result<Option<T>>` is non-idiomatic and silently discards the error. The codebase already uses a cleaner pattern elsewhere (e.g. `recording.rs:598` uses `.ok().flatten()`):

```suggestion
    let settings = GeneralSettingsStore::get(&app)
        .ok()
        .flatten()
        .unwrap_or_default();
```

This makes the intent explicit — convert `Err` to `None`, then fall back to defaults — and is consistent with the rest of the file.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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)
}
Expand Down
88 changes: 51 additions & 37 deletions apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CursorClickEvent>,
mut moves: Vec<CursorMoveEvent>,
max_duration: f64,
config: &cap_project::AutoZoomConfig,
) -> Vec<ZoomSegment> {
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();
}
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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));
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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<ZoomSegment> {
// Build a temporary RecordingMeta so we can use the common implementation
let recording_meta = RecordingMeta {
platform: None,
project_path: recording.project_path.clone(),
Expand All @@ -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<ZoomSegment> {
let RecordingMetaInner::Studio(studio_meta) = &recording_meta.inner else {
return Vec::new();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -2355,7 +2351,11 @@ fn project_config_from_recording(
.collect::<Vec<_>>();

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()
};
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -2469,7 +2478,12 @@ mod tests {
})
.collect::<Vec<_>>();

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(),
Expand Down
103 changes: 102 additions & 1 deletion apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div class="space-y-1.5">
<div class="flex justify-between items-center text-sm">
<span class="text-gray-11">{props.label}</span>
<span class="text-gray-12 font-medium">
{props.format ? props.format(props.value) : props.value}
</span>
</div>
<KSlider
value={[props.value]}
onChange={(v) => props.onChange(v[0])}
minValue={props.min}
maxValue={props.max}
step={props.step}
>
<KSlider.Track class="h-[0.3rem] cursor-pointer relative bg-gray-4 rounded-full w-full">
<KSlider.Fill class="absolute h-full rounded-full bg-blue-9" />
<KSlider.Thumb class="block size-4 rounded-full bg-white border-2 border-blue-9 -top-[0.35rem] outline-none" />
</KSlider.Track>
</KSlider>
</div>
);
}

export default function ExperimentalSettings() {
const [store] = createResource(() => generalSettingsStore.get());

Expand All @@ -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 = <K extends keyof AutoZoomConfig>(
key: K,
value: AutoZoomConfig[K],
) => {
setSettings("autoZoomConfig", key, value);
generalSettingsStore.set({
autoZoomConfig: { ...settings.autoZoomConfig, [key]: value },
});
};

const handleChange = async <K extends keyof typeof settings>(
key: K,
value: (typeof settings)[K],
Expand Down Expand Up @@ -95,6 +156,46 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) {
}}
/>
</div>
<Show when={settings.autoZoomOnClicks}>
<div class="px-3 py-3 space-y-4">
<SettingSlider
label="Zoom Amount"
value={settings.autoZoomConfig?.zoomAmount ?? 1.5}
onChange={(v) => handleConfigChange("zoomAmount", v)}
min={1.0}
max={4.0}
step={0.1}
format={(v) => `${v.toFixed(1)}x`}
/>
<SettingSlider
label="Sensitivity"
value={
settings.autoZoomConfig?.movementWindowDistanceThreshold ??
0.08
}
onChange={(v) =>
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";
}}
/>
Comment on lines +170 to +187
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 "Sensitivity" slider only controls half the detection condition

The "Sensitivity" slider adjusts movementWindowDistanceThreshold (cumulative window distance) but leaves movementEventDistanceThreshold (per-frame distance) fixed at 0.02. In generate_zoom_segments_from_clicks_impl, movement detection is an OR condition:

let significant_movement = distance >= movement_event_distance_threshold   // fixed: 0.02
    || window_distance >= movement_window_distance_threshold;              // slider-controlled

This means even at "Low" sensitivity (movementWindowDistanceThreshold = 0.2), any single frame where the cursor travels ≥ 2% of normalised screen width still triggers a zoom. In practice the per-frame branch can dominate, making the "Low" end of the slider much weaker than users expect.

To make the slider fully effective, both thresholds should scale together. You could either:

  • Expose movementEventDistanceThreshold as part of the sensitivity curve, or
  • Derive movementEventDistanceThreshold proportionally from the movementWindowDistanceThreshold value rather than keeping it at its hardcoded default.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx
Line: 170-187

Comment:
**"Sensitivity" slider only controls half the detection condition**

The "Sensitivity" slider adjusts `movementWindowDistanceThreshold` (cumulative window distance) but leaves `movementEventDistanceThreshold` (per-frame distance) fixed at `0.02`. In `generate_zoom_segments_from_clicks_impl`, movement detection is an **OR** condition:

```rust
let significant_movement = distance >= movement_event_distance_threshold   // fixed: 0.02
    || window_distance >= movement_window_distance_threshold;              // slider-controlled
```

This means even at "Low" sensitivity (`movementWindowDistanceThreshold = 0.2`), any single frame where the cursor travels ≥ 2% of normalised screen width still triggers a zoom. In practice the per-frame branch can dominate, making the "Low" end of the slider much weaker than users expect.

To make the slider fully effective, both thresholds should scale together. You could either:
- Expose `movementEventDistanceThreshold` as part of the sensitivity curve, or
- Derive `movementEventDistanceThreshold` proportionally from the `movementWindowDistanceThreshold` value rather than keeping it at its hardcoded default.

How can I resolve this? If you propose a fix, please make it concise.

<SettingSlider
label="Smoothing"
value={settings.autoZoomConfig?.mergeGapThreshold ?? 0.8}
onChange={(v) => handleConfigChange("mergeGapThreshold", v)}
min={0.2}
max={2.0}
step={0.1}
format={(v) => `${v.toFixed(1)}s`}
/>
</div>
</Show>
</div>
</div>
</div>
Expand Down
Loading