Skip to content

Commit ccdf20c

Browse files
committed
context menu option + improve algorithm
1 parent 586a006 commit ccdf20c

File tree

5 files changed

+179
-99
lines changed

5 files changed

+179
-99
lines changed

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ use cap_media::platform::Bounds;
3232
use cap_media::{feeds::CameraFeed, sources::ScreenCaptureTarget};
3333
use cap_project::RecordingMetaInner;
3434
use cap_project::XY;
35-
use cap_project::{ProjectConfiguration, RecordingMeta, SharingMeta, StudioRecordingMeta};
35+
use cap_project::{
36+
ProjectConfiguration, RecordingMeta, SharingMeta, StudioRecordingMeta, ZoomSegment,
37+
};
3638
use cap_rendering::ProjectRecordingsMeta;
3739
use clipboard_rs::common::RustImage;
3840
use clipboard_rs::{Clipboard, ClipboardContext};
@@ -1058,6 +1060,19 @@ async fn set_project_config(
10581060
Ok(())
10591061
}
10601062

1063+
#[tauri::command]
1064+
#[specta::specta]
1065+
async fn generate_zoom_segments_from_clicks(
1066+
editor_instance: WindowEditorInstance,
1067+
) -> Result<Vec<ZoomSegment>, String> {
1068+
let meta = editor_instance.meta();
1069+
let recordings = &editor_instance.recordings;
1070+
1071+
let zoom_segments = recording::generate_zoom_segments_for_project(meta, recordings);
1072+
1073+
Ok(zoom_segments)
1074+
}
1075+
10611076
#[tauri::command]
10621077
#[specta::specta]
10631078
async fn list_audio_devices() -> Result<Vec<String>, ()> {
@@ -1905,6 +1920,7 @@ pub async fn run(recording_logging_handle: LoggingHandle) {
19051920
stop_playback,
19061921
set_playhead_position,
19071922
set_project_config,
1923+
generate_zoom_segments_from_clicks,
19081924
permissions::open_permission_settings,
19091925
permissions::do_permissions_check,
19101926
permissions::request_permission,

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

Lines changed: 74 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ use cap_media::{
2323
sources::{CaptureScreen, CaptureWindow},
2424
};
2525
use cap_project::{
26-
Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner, SharingMeta,
27-
StudioRecordingMeta, TimelineConfiguration, TimelineSegment, ZoomMode, ZoomSegment,
28-
cursor::CursorEvents,
26+
CursorClickEvent, Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner,
27+
SharingMeta, StudioRecordingMeta, TimelineConfiguration, TimelineSegment, ZoomMode,
28+
ZoomSegment, cursor::CursorEvents,
2929
};
3030
use cap_recording::{
3131
CompletedStudioRecording, RecordingError, RecordingMode, StudioRecordingHandle,
@@ -831,48 +831,22 @@ async fn handle_recording_finish(
831831
Ok(())
832832
}
833833

834-
/// Generates zoom segments based on mouse click events during recording.
834+
/// Core logic for generating zoom segments based on mouse click events.
835835
/// This is an experimental feature that automatically creates zoom effects
836836
/// around user interactions to highlight important moments.
837-
fn generate_zoom_segments_from_clicks(
838-
recording: &CompletedStudioRecording,
837+
fn generate_zoom_segments_from_clicks_impl(
838+
mut clicks: Vec<CursorClickEvent>,
839839
recordings: &ProjectRecordingsMeta,
840840
) -> Vec<ZoomSegment> {
841841
const ZOOM_SEGMENT_AFTER_CLICK_PADDING: f64 = 1.5;
842+
const ZOOM_SEGMENT_BEFORE_CLICK_PADDING: f64 = 0.8;
842843
const ZOOM_DURATION: f64 = 1.0;
843844
const CLICK_GROUP_THRESHOLD: f64 = 0.6; // seconds
845+
const MIN_SEGMENT_PADDING: f64 = 2.0; // minimum gap between segments
844846

845847
let max_duration = recordings.duration();
846848

847-
// Build a temporary RecordingMeta so we can load cursor events from disk
848-
let recording_meta = RecordingMeta {
849-
platform: None,
850-
project_path: recording.project_path.clone(),
851-
pretty_name: String::new(),
852-
sharing: None,
853-
inner: RecordingMetaInner::Studio(recording.meta.clone()),
854-
};
855-
856-
let mut all_events = CursorEvents::default();
857-
858-
match &recording.meta {
859-
StudioRecordingMeta::SingleSegment { segment } => {
860-
if let Some(cursor_path) = &segment.cursor {
861-
if let Ok(mut ev) = CursorEvents::load_from_file(&recording_meta.path(cursor_path))
862-
{
863-
all_events.clicks.append(&mut ev.clicks);
864-
}
865-
}
866-
}
867-
StudioRecordingMeta::MultipleSegments { inner, .. } => {
868-
for seg in &inner.segments {
869-
let mut ev = seg.cursor_events(&recording_meta);
870-
all_events.clicks.append(&mut ev.clicks);
871-
}
872-
}
873-
}
874-
875-
all_events.clicks.sort_by(|a, b| {
849+
clicks.sort_by(|a, b| {
876850
a.time_ms
877851
.partial_cmp(&b.time_ms)
878852
.unwrap_or(std::cmp::Ordering::Equal)
@@ -881,24 +855,30 @@ fn generate_zoom_segments_from_clicks(
881855
let mut segments = Vec::<ZoomSegment>::new();
882856

883857
// Generate segments around mouse clicks
884-
for click in &all_events.clicks {
858+
for click in &clicks {
885859
if !click.down {
886860
continue;
887861
}
888862

889863
let time = click.time_ms / 1000.0;
890864

865+
let proposed_start = (time - ZOOM_SEGMENT_BEFORE_CLICK_PADDING).max(0.0);
866+
let proposed_end = (time + ZOOM_SEGMENT_AFTER_CLICK_PADDING).min(max_duration);
867+
891868
if let Some(last) = segments.last_mut() {
892-
if time <= last.end + CLICK_GROUP_THRESHOLD {
893-
last.end = (time + ZOOM_SEGMENT_AFTER_CLICK_PADDING).min(max_duration);
869+
// Merge if within group threshold OR if segments would be too close together
870+
if time <= last.end + CLICK_GROUP_THRESHOLD
871+
|| proposed_start <= last.end + MIN_SEGMENT_PADDING
872+
{
873+
last.end = proposed_end;
894874
continue;
895875
}
896876
}
897877

898878
if time < max_duration - ZOOM_DURATION {
899879
segments.push(ZoomSegment {
900-
start: (time - 0.8).max(0.0),
901-
end: (time + ZOOM_SEGMENT_AFTER_CLICK_PADDING).min(max_duration),
880+
start: proposed_start,
881+
end: proposed_end,
902882
amount: 2.0,
903883
mode: ZoomMode::Auto,
904884
});
@@ -908,6 +888,54 @@ fn generate_zoom_segments_from_clicks(
908888
segments
909889
}
910890

891+
/// Generates zoom segments based on mouse click events during recording.
892+
/// Used during the recording completion process.
893+
pub fn generate_zoom_segments_from_clicks(
894+
recording: &CompletedStudioRecording,
895+
recordings: &ProjectRecordingsMeta,
896+
) -> Vec<ZoomSegment> {
897+
// Build a temporary RecordingMeta so we can use the common implementation
898+
let recording_meta = RecordingMeta {
899+
platform: None,
900+
project_path: recording.project_path.clone(),
901+
pretty_name: String::new(),
902+
sharing: None,
903+
inner: RecordingMetaInner::Studio(recording.meta.clone()),
904+
};
905+
906+
generate_zoom_segments_for_project(&recording_meta, recordings)
907+
}
908+
909+
/// Generates zoom segments from clicks for an existing project.
910+
/// Used in the editor context where we have RecordingMeta.
911+
pub fn generate_zoom_segments_for_project(
912+
recording_meta: &RecordingMeta,
913+
recordings: &ProjectRecordingsMeta,
914+
) -> Vec<ZoomSegment> {
915+
let RecordingMetaInner::Studio(studio_meta) = &recording_meta.inner else {
916+
return Vec::new();
917+
};
918+
919+
let all_events = match studio_meta {
920+
StudioRecordingMeta::SingleSegment { segment } => {
921+
if let Some(cursor_path) = &segment.cursor {
922+
CursorEvents::load_from_file(&recording_meta.path(cursor_path))
923+
.unwrap_or_default()
924+
.clicks
925+
} else {
926+
vec![]
927+
}
928+
}
929+
StudioRecordingMeta::MultipleSegments { inner, .. } => inner
930+
.segments
931+
.iter()
932+
.flat_map(|s| s.cursor_events(recording_meta).clicks)
933+
.collect(),
934+
};
935+
936+
generate_zoom_segments_from_clicks_impl(all_events, recordings)
937+
}
938+
911939
fn project_config_from_recording(
912940
app: &AppHandle,
913941
completed_recording: &CompletedStudioRecording,
@@ -940,3 +968,9 @@ fn project_config_from_recording(
940968
..default_config.unwrap_or_default()
941969
}
942970
}
971+
972+
#[cfg(test)]
973+
mod test {
974+
#[test]
975+
fn zoom_segment_gaps() {}
976+
}

apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createSignal,
1212
} from "solid-js";
1313
import { produce } from "solid-js/store";
14+
import { Menu } from "@tauri-apps/api/menu";
1415

1516
import { useEditorContext } from "../context";
1617
import {
@@ -20,6 +21,7 @@ import {
2021
} from "./context";
2122
import { SegmentContent, SegmentHandle, SegmentRoot, TrackRoot } from "./Track";
2223
import { cx } from "cva";
24+
import { commands } from "~/utils/tauri";
2325

2426
export type ZoomSegmentDragState =
2527
| { type: "idle" }
@@ -38,8 +40,33 @@ export function ZoomTrack(props: {
3840
const [hoveringSegment, setHoveringSegment] = createSignal(false);
3941
const [hoveredTime, setHoveredTime] = createSignal<number>();
4042

43+
const handleGenerateZoomSegments = async () => {
44+
try {
45+
const zoomSegments = await commands.generateZoomSegmentsFromClicks();
46+
setProject("timeline", "zoomSegments", zoomSegments);
47+
} catch (error) {
48+
console.error("Failed to generate zoom segments:", error);
49+
}
50+
};
51+
4152
return (
4253
<TrackRoot
54+
onContextMenu={async (e) => {
55+
if (!import.meta.env.DEV) return;
56+
57+
e.preventDefault();
58+
const menu = await Menu.new({
59+
id: "zoom-track-options",
60+
items: [
61+
{
62+
id: "generateZoomSegments",
63+
text: "Generate zoom segments from clicks",
64+
action: handleGenerateZoomSegments,
65+
},
66+
],
67+
});
68+
menu.popup();
69+
}}
4370
onMouseMove={(e) => {
4471
if (hoveringSegment()) {
4572
setHoveredTime(undefined);

apps/desktop/src/utils/tauri.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ async setPlayheadPosition(frameNumber: number) : Promise<null> {
101101
async setProjectConfig(config: ProjectConfiguration) : Promise<null> {
102102
return await TAURI_INVOKE("set_project_config", { config });
103103
},
104+
async generateZoomSegmentsFromClicks() : Promise<ZoomSegment[]> {
105+
return await TAURI_INVOKE("generate_zoom_segments_from_clicks");
106+
},
104107
async openPermissionSettings(permission: OSPermission) : Promise<void> {
105108
await TAURI_INVOKE("open_permission_settings", { permission });
106109
},

0 commit comments

Comments
 (0)