Skip to content

Commit 0ad9096

Browse files
Refine auto-zoom segment generation and cursor stability (#1106)
* feat: refine auto-zoom segments and cursor stability * fix: Coderabbit suggestion * fix: Cargo fmt
1 parent f1717f2 commit 0ad9096

File tree

4 files changed

+565
-73
lines changed

4 files changed

+565
-73
lines changed

apps/desktop/src-tauri/src/recording.rs

Lines changed: 273 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use cap_fail::fail;
2+
use cap_project::cursor::SHORT_CURSOR_SHAPE_DEBOUNCE_MS;
23
use cap_project::{
3-
CursorClickEvent, Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner,
4-
SharingMeta, StudioRecordingMeta, TimelineConfiguration, TimelineSegment, ZoomMode,
5-
ZoomSegment, cursor::CursorEvents,
4+
CursorClickEvent, CursorMoveEvent, Platform, ProjectConfiguration, RecordingMeta,
5+
RecordingMetaInner, SharingMeta, StudioRecordingMeta, TimelineConfiguration, TimelineSegment,
6+
ZoomMode, ZoomSegment, cursor::CursorEvents,
67
};
78
use cap_recording::{
89
RecordingError, RecordingMode,
@@ -15,7 +16,13 @@ use cap_rendering::ProjectRecordingsMeta;
1516
use cap_utils::{ensure_dir, spawn_actor};
1617
use serde::{Deserialize, Serialize};
1718
use specta::Type;
18-
use std::{path::PathBuf, str::FromStr, sync::Arc, time::Duration};
19+
use std::{
20+
collections::{HashMap, VecDeque},
21+
path::PathBuf,
22+
str::FromStr,
23+
sync::Arc,
24+
time::Duration,
25+
};
1926
use tauri::{AppHandle, Manager};
2027
use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder};
2128
use tauri_specta::Event;
@@ -1718,56 +1725,161 @@ async fn handle_recording_finish(
17181725
/// around user interactions to highlight important moments.
17191726
fn generate_zoom_segments_from_clicks_impl(
17201727
mut clicks: Vec<CursorClickEvent>,
1721-
recordings: &ProjectRecordingsMeta,
1728+
mut moves: Vec<CursorMoveEvent>,
1729+
max_duration: f64,
17221730
) -> Vec<ZoomSegment> {
1723-
const ZOOM_SEGMENT_AFTER_CLICK_PADDING: f64 = 1.5;
1724-
const ZOOM_SEGMENT_BEFORE_CLICK_PADDING: f64 = 0.8;
1725-
const ZOOM_DURATION: f64 = 1.0;
1726-
const CLICK_GROUP_THRESHOLD: f64 = 0.6; // seconds
1727-
const MIN_SEGMENT_PADDING: f64 = 2.0; // minimum gap between segments
1731+
const STOP_PADDING_SECONDS: f64 = 0.8;
1732+
const CLICK_PRE_PADDING: f64 = 0.6;
1733+
const CLICK_POST_PADDING: f64 = 1.6;
1734+
const MOVEMENT_PRE_PADDING: f64 = 0.4;
1735+
const MOVEMENT_POST_PADDING: f64 = 1.2;
1736+
const MERGE_GAP_THRESHOLD: f64 = 0.6;
1737+
const MIN_SEGMENT_DURATION: f64 = 1.3;
1738+
const MOVEMENT_WINDOW_SECONDS: f64 = 1.2;
1739+
const MOVEMENT_EVENT_DISTANCE_THRESHOLD: f64 = 0.025;
1740+
const MOVEMENT_WINDOW_DISTANCE_THRESHOLD: f64 = 0.1;
1741+
1742+
if max_duration <= 0.0 {
1743+
return Vec::new();
1744+
}
17281745

1729-
let max_duration = recordings.duration();
1746+
// We trim the tail of the recording to avoid using the final
1747+
// "stop recording" click as a zoom target.
1748+
let activity_end_limit = if max_duration > STOP_PADDING_SECONDS {
1749+
max_duration - STOP_PADDING_SECONDS
1750+
} else {
1751+
max_duration
1752+
};
1753+
1754+
if activity_end_limit <= f64::EPSILON {
1755+
return Vec::new();
1756+
}
17301757

17311758
clicks.sort_by(|a, b| {
17321759
a.time_ms
17331760
.partial_cmp(&b.time_ms)
17341761
.unwrap_or(std::cmp::Ordering::Equal)
17351762
});
1763+
moves.sort_by(|a, b| {
1764+
a.time_ms
1765+
.partial_cmp(&b.time_ms)
1766+
.unwrap_or(std::cmp::Ordering::Equal)
1767+
});
17361768

1737-
let mut segments = Vec::<ZoomSegment>::new();
1769+
// Remove trailing click-down events that are too close to the end.
1770+
while let Some(index) = clicks.iter().rposition(|c| c.down) {
1771+
let time_secs = clicks[index].time_ms / 1000.0;
1772+
if time_secs > activity_end_limit {
1773+
clicks.remove(index);
1774+
} else {
1775+
break;
1776+
}
1777+
}
17381778

1739-
// Generate segments around mouse clicks
1740-
for click in &clicks {
1741-
if !click.down {
1779+
let mut intervals: Vec<(f64, f64)> = Vec::new();
1780+
1781+
for click in clicks.into_iter().filter(|c| c.down) {
1782+
let time = click.time_ms / 1000.0;
1783+
if time >= activity_end_limit {
17421784
continue;
17431785
}
17441786

1745-
let time = click.time_ms / 1000.0;
1787+
let start = (time - CLICK_PRE_PADDING).max(0.0);
1788+
let end = (time + CLICK_POST_PADDING).min(activity_end_limit);
17461789

1747-
let proposed_start = (time - ZOOM_SEGMENT_BEFORE_CLICK_PADDING).max(0.0);
1748-
let proposed_end = (time + ZOOM_SEGMENT_AFTER_CLICK_PADDING).min(max_duration);
1790+
if end > start {
1791+
intervals.push((start, end));
1792+
}
1793+
}
17491794

1750-
if let Some(last) = segments.last_mut() {
1751-
// Merge if within group threshold OR if segments would be too close together
1752-
if time <= last.end + CLICK_GROUP_THRESHOLD
1753-
|| proposed_start <= last.end + MIN_SEGMENT_PADDING
1754-
{
1755-
last.end = proposed_end;
1756-
continue;
1795+
let mut last_move_by_cursor: HashMap<String, (f64, f64, f64)> = HashMap::new();
1796+
let mut distance_window: VecDeque<(f64, f64)> = VecDeque::new();
1797+
let mut window_distance = 0.0_f64;
1798+
1799+
for mv in moves.iter() {
1800+
let time = mv.time_ms / 1000.0;
1801+
if time >= activity_end_limit {
1802+
break;
1803+
}
1804+
1805+
let distance = if let Some((_, last_x, last_y)) = last_move_by_cursor.get(&mv.cursor_id) {
1806+
let dx = mv.x - last_x;
1807+
let dy = mv.y - last_y;
1808+
(dx * dx + dy * dy).sqrt()
1809+
} else {
1810+
0.0
1811+
};
1812+
1813+
last_move_by_cursor.insert(mv.cursor_id.clone(), (time, mv.x, mv.y));
1814+
1815+
if distance <= f64::EPSILON {
1816+
continue;
1817+
}
1818+
1819+
distance_window.push_back((time, distance));
1820+
window_distance += distance;
1821+
1822+
while let Some(&(old_time, old_distance)) = distance_window.front() {
1823+
if time - old_time > MOVEMENT_WINDOW_SECONDS {
1824+
distance_window.pop_front();
1825+
window_distance -= old_distance;
1826+
} else {
1827+
break;
17571828
}
17581829
}
17591830

1760-
if time < max_duration - ZOOM_DURATION {
1761-
segments.push(ZoomSegment {
1762-
start: proposed_start,
1763-
end: proposed_end,
1764-
amount: 2.0,
1765-
mode: ZoomMode::Auto,
1766-
});
1831+
if window_distance < 0.0 {
1832+
window_distance = 0.0;
1833+
}
1834+
1835+
let significant_movement = distance >= MOVEMENT_EVENT_DISTANCE_THRESHOLD
1836+
|| window_distance >= MOVEMENT_WINDOW_DISTANCE_THRESHOLD;
1837+
1838+
if !significant_movement {
1839+
continue;
1840+
}
1841+
1842+
let start = (time - MOVEMENT_PRE_PADDING).max(0.0);
1843+
let end = (time + MOVEMENT_POST_PADDING).min(activity_end_limit);
1844+
1845+
if end > start {
1846+
intervals.push((start, end));
1847+
}
1848+
}
1849+
1850+
if intervals.is_empty() {
1851+
return Vec::new();
1852+
}
1853+
1854+
intervals.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
1855+
1856+
let mut merged: Vec<(f64, f64)> = Vec::new();
1857+
for interval in intervals {
1858+
if let Some(last) = merged.last_mut() {
1859+
if interval.0 <= last.1 + MERGE_GAP_THRESHOLD {
1860+
last.1 = last.1.max(interval.1);
1861+
continue;
1862+
}
17671863
}
1864+
merged.push(interval);
17681865
}
17691866

1770-
segments
1867+
merged
1868+
.into_iter()
1869+
.filter_map(|(start, end)| {
1870+
let duration = end - start;
1871+
if duration < MIN_SEGMENT_DURATION {
1872+
return None;
1873+
}
1874+
1875+
Some(ZoomSegment {
1876+
start,
1877+
end,
1878+
amount: 2.0,
1879+
mode: ZoomMode::Auto,
1880+
})
1881+
})
1882+
.collect()
17711883
}
17721884

17731885
/// Generates zoom segments based on mouse click events during recording.
@@ -1798,24 +1910,34 @@ pub fn generate_zoom_segments_for_project(
17981910
return Vec::new();
17991911
};
18001912

1801-
let all_events = match studio_meta {
1913+
let mut all_clicks = Vec::new();
1914+
let mut all_moves = Vec::new();
1915+
1916+
match studio_meta {
18021917
StudioRecordingMeta::SingleSegment { segment } => {
18031918
if let Some(cursor_path) = &segment.cursor {
1804-
CursorEvents::load_from_file(&recording_meta.path(cursor_path))
1805-
.unwrap_or_default()
1806-
.clicks
1807-
} else {
1808-
vec![]
1919+
let mut events = CursorEvents::load_from_file(&recording_meta.path(cursor_path))
1920+
.unwrap_or_default();
1921+
let pointer_ids = studio_meta.pointer_cursor_ids();
1922+
let pointer_ids_ref = (!pointer_ids.is_empty()).then_some(&pointer_ids);
1923+
events.stabilize_short_lived_cursor_shapes(
1924+
pointer_ids_ref,
1925+
SHORT_CURSOR_SHAPE_DEBOUNCE_MS,
1926+
);
1927+
all_clicks = events.clicks;
1928+
all_moves = events.moves;
18091929
}
18101930
}
1811-
StudioRecordingMeta::MultipleSegments { inner, .. } => inner
1812-
.segments
1813-
.iter()
1814-
.flat_map(|s| s.cursor_events(recording_meta).clicks)
1815-
.collect(),
1816-
};
1931+
StudioRecordingMeta::MultipleSegments { inner, .. } => {
1932+
for segment in inner.segments.iter() {
1933+
let events = segment.cursor_events(recording_meta);
1934+
all_clicks.extend(events.clicks);
1935+
all_moves.extend(events.moves);
1936+
}
1937+
}
1938+
}
18171939

1818-
generate_zoom_segments_from_clicks_impl(all_events, recordings)
1940+
generate_zoom_segments_from_clicks_impl(all_clicks, all_moves, recordings.duration())
18191941
}
18201942

18211943
fn project_config_from_recording(
@@ -1828,26 +1950,110 @@ fn project_config_from_recording(
18281950
.unwrap_or(None)
18291951
.unwrap_or_default();
18301952

1831-
ProjectConfiguration {
1832-
timeline: Some(TimelineConfiguration {
1833-
segments: recordings
1834-
.segments
1835-
.iter()
1836-
.enumerate()
1837-
.map(|(i, segment)| TimelineSegment {
1838-
recording_segment: i as u32,
1839-
start: 0.0,
1840-
end: segment.duration(),
1841-
timescale: 1.0,
1842-
})
1843-
.collect(),
1844-
zoom_segments: if settings.auto_zoom_on_clicks {
1845-
generate_zoom_segments_from_clicks(completed_recording, recordings)
1846-
} else {
1847-
Vec::new()
1848-
},
1849-
scene_segments: Vec::new(),
1850-
}),
1851-
..default_config.unwrap_or_default()
1953+
let mut config = default_config.unwrap_or_default();
1954+
1955+
let timeline_segments = recordings
1956+
.segments
1957+
.iter()
1958+
.enumerate()
1959+
.map(|(i, segment)| TimelineSegment {
1960+
recording_segment: i as u32,
1961+
start: 0.0,
1962+
end: segment.duration(),
1963+
timescale: 1.0,
1964+
})
1965+
.collect::<Vec<_>>();
1966+
1967+
let zoom_segments = if settings.auto_zoom_on_clicks {
1968+
generate_zoom_segments_from_clicks(completed_recording, recordings)
1969+
} else {
1970+
Vec::new()
1971+
};
1972+
1973+
if !zoom_segments.is_empty() {
1974+
config.cursor.size = 200;
1975+
}
1976+
1977+
config.timeline = Some(TimelineConfiguration {
1978+
segments: timeline_segments,
1979+
zoom_segments,
1980+
scene_segments: Vec::new(),
1981+
});
1982+
1983+
config
1984+
}
1985+
1986+
#[cfg(test)]
1987+
mod tests {
1988+
use super::*;
1989+
1990+
fn click_event(time_ms: f64) -> CursorClickEvent {
1991+
CursorClickEvent {
1992+
active_modifiers: vec![],
1993+
cursor_num: 0,
1994+
cursor_id: "default".to_string(),
1995+
time_ms,
1996+
down: true,
1997+
}
1998+
}
1999+
2000+
fn move_event(time_ms: f64, x: f64, y: f64) -> CursorMoveEvent {
2001+
CursorMoveEvent {
2002+
active_modifiers: vec![],
2003+
cursor_id: "default".to_string(),
2004+
time_ms,
2005+
x,
2006+
y,
2007+
}
2008+
}
2009+
2010+
#[test]
2011+
fn skips_trailing_stop_click() {
2012+
let segments =
2013+
generate_zoom_segments_from_clicks_impl(vec![click_event(11_900.0)], vec![], 12.0);
2014+
2015+
assert!(
2016+
segments.is_empty(),
2017+
"expected trailing stop click to be ignored"
2018+
);
2019+
}
2020+
2021+
#[test]
2022+
fn generates_segment_for_sustained_activity() {
2023+
let clicks = vec![click_event(1_200.0), click_event(4_200.0)];
2024+
let moves = vec![
2025+
move_event(1_500.0, 0.10, 0.12),
2026+
move_event(1_720.0, 0.42, 0.45),
2027+
move_event(1_940.0, 0.74, 0.78),
2028+
];
2029+
2030+
let segments = generate_zoom_segments_from_clicks_impl(clicks, moves, 20.0);
2031+
2032+
assert!(
2033+
!segments.is_empty(),
2034+
"expected activity to produce zoom segments"
2035+
);
2036+
let first = &segments[0];
2037+
assert!(first.start < first.end);
2038+
assert!(first.end - first.start >= 1.3);
2039+
assert!(first.end <= 19.5);
2040+
}
2041+
2042+
#[test]
2043+
fn ignores_cursor_jitter() {
2044+
let jitter_moves = (0..30)
2045+
.map(|i| {
2046+
let t = 1_000.0 + (i as f64) * 30.0;
2047+
let delta = (i as f64) * 0.0004;
2048+
move_event(t, 0.5 + delta, 0.5)
2049+
})
2050+
.collect::<Vec<_>>();
2051+
2052+
let segments = generate_zoom_segments_from_clicks_impl(Vec::new(), jitter_moves, 15.0);
2053+
2054+
assert!(
2055+
segments.is_empty(),
2056+
"small jitter should not generate segments"
2057+
);
18522058
}
18532059
}

0 commit comments

Comments
 (0)