Skip to content
Merged
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 @@ -77,6 +77,8 @@ pub struct GeneralSettingsStore {
#[deprecated = "can be removed when native camera preview is ready"]
#[serde(default, skip_serializing_if = "yes")]
pub enable_native_camera_preview: bool,
#[serde(default)]
pub auto_zoom_on_clicks: bool,
}

fn yes(_: &bool) -> bool {
Expand Down Expand Up @@ -120,6 +122,7 @@ impl Default for GeneralSettingsStore {
recording_countdown: Some(3),
_open_editor_after_recording: false,
enable_native_camera_preview: false,
auto_zoom_on_clicks: false,
}
}
}
Expand Down
18 changes: 17 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ use cap_media::platform::Bounds;
use cap_media::{feeds::CameraFeed, sources::ScreenCaptureTarget};
use cap_project::RecordingMetaInner;
use cap_project::XY;
use cap_project::{ProjectConfiguration, RecordingMeta, SharingMeta, StudioRecordingMeta};
use cap_project::{
ProjectConfiguration, RecordingMeta, SharingMeta, StudioRecordingMeta, ZoomSegment,
};
use cap_rendering::ProjectRecordingsMeta;
use clipboard_rs::common::RustImage;
use clipboard_rs::{Clipboard, ClipboardContext};
Expand Down Expand Up @@ -1058,6 +1060,19 @@ async fn set_project_config(
Ok(())
}

#[tauri::command]
#[specta::specta]
async fn generate_zoom_segments_from_clicks(
editor_instance: WindowEditorInstance,
) -> Result<Vec<ZoomSegment>, String> {
let meta = editor_instance.meta();
let recordings = &editor_instance.recordings;

let zoom_segments = recording::generate_zoom_segments_for_project(meta, recordings);

Ok(zoom_segments)
}

#[tauri::command]
#[specta::specta]
async fn list_audio_devices() -> Result<Vec<String>, ()> {
Expand Down Expand Up @@ -1905,6 +1920,7 @@ pub async fn run(recording_logging_handle: LoggingHandle) {
stop_playback,
set_playhead_position,
set_project_config,
generate_zoom_segments_from_clicks,
permissions::open_permission_settings,
permissions::do_permissions_check,
permissions::request_permission,
Expand Down
146 changes: 109 additions & 37 deletions apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ use cap_media::{
sources::{CaptureScreen, CaptureWindow},
};
use cap_project::{
Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner, SharingMeta,
StudioRecordingMeta, TimelineConfiguration, TimelineSegment, ZoomSegment,
CursorClickEvent, Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner,
SharingMeta, StudioRecordingMeta, TimelineConfiguration, TimelineSegment, ZoomMode,
ZoomSegment, cursor::CursorEvents,
};
use cap_recording::{
CompletedStudioRecording, RecordingError, RecordingMode, StudioRecordingHandle,
Expand Down Expand Up @@ -675,6 +676,7 @@ async fn handle_recording_finish(
let recordings = ProjectRecordingsMeta::new(&recording_dir, &recording.meta)?;

let config = project_config_from_recording(
&app,
&recording,
&recordings,
PresetsStore::get_default_preset(&app)?.map(|p| p.config),
Expand Down Expand Up @@ -829,55 +831,121 @@ async fn handle_recording_finish(
Ok(())
}

fn generate_zoom_segments_from_clicks(
recording: &CompletedStudioRecording,
/// 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>,
recordings: &ProjectRecordingsMeta,
) -> Vec<ZoomSegment> {
let mut segments = vec![];
const ZOOM_SEGMENT_AFTER_CLICK_PADDING: f64 = 1.5;
const ZOOM_SEGMENT_BEFORE_CLICK_PADDING: f64 = 0.8;
const ZOOM_DURATION: f64 = 1.0;
const CLICK_GROUP_THRESHOLD: f64 = 0.6; // seconds
const MIN_SEGMENT_PADDING: f64 = 2.0; // minimum gap between segments

let max_duration = recordings.duration();

const ZOOM_SEGMENT_AFTER_CLICK_PADDING: f64 = 1.5;
clicks.sort_by(|a, b| {
a.time_ms
.partial_cmp(&b.time_ms)
.unwrap_or(std::cmp::Ordering::Equal)
});

let mut segments = Vec::<ZoomSegment>::new();

// Generate segments around mouse clicks
for click in &clicks {
if !click.down {
continue;
}

let time = click.time_ms / 1000.0;

let proposed_start = (time - ZOOM_SEGMENT_BEFORE_CLICK_PADDING).max(0.0);
let proposed_end = (time + ZOOM_SEGMENT_AFTER_CLICK_PADDING).min(max_duration);

// single-segment only
// for click in &recording.cursor_data.clicks {
// let time = click.process_time_ms / 1000.0;

// if segments.last().is_none() {
// segments.push(ZoomSegment {
// start: (click.process_time_ms / 1000.0 - (ZOOM_DURATION + 0.2)).max(0.0),
// end: click.process_time_ms / 1000.0 + ZOOM_SEGMENT_AFTER_CLICK_PADDING,
// amount: 2.0,
// });
// } else {
// let last_segment = segments.last_mut().unwrap();

// if click.down {
// if last_segment.end > time {
// last_segment.end =
// (time + ZOOM_SEGMENT_AFTER_CLICK_PADDING).min(recordings.duration());
// } else if time < max_duration - ZOOM_DURATION {
// segments.push(ZoomSegment {
// start: (time - ZOOM_DURATION).max(0.0),
// end: time + ZOOM_SEGMENT_AFTER_CLICK_PADDING,
// amount: 2.0,
// });
// }
// } else {
// last_segment.end =
// (time + ZOOM_SEGMENT_AFTER_CLICK_PADDING).min(recordings.duration());
// }
// }
// }
if let Some(last) = segments.last_mut() {
// Merge if within group threshold OR if segments would be too close together
if time <= last.end + CLICK_GROUP_THRESHOLD
|| proposed_start <= last.end + MIN_SEGMENT_PADDING
{
last.end = proposed_end;
continue;
}
}

if time < max_duration - ZOOM_DURATION {
segments.push(ZoomSegment {
start: proposed_start,
end: proposed_end,
amount: 2.0,
mode: ZoomMode::Auto,
});
}
}

segments
}

/// Generates zoom segments based on mouse click events during recording.
/// Used during the recording completion process.
pub fn generate_zoom_segments_from_clicks(
recording: &CompletedStudioRecording,
recordings: &ProjectRecordingsMeta,
) -> 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(),
pretty_name: String::new(),
sharing: None,
inner: RecordingMetaInner::Studio(recording.meta.clone()),
};

generate_zoom_segments_for_project(&recording_meta, recordings)
}

/// 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,
) -> Vec<ZoomSegment> {
let RecordingMetaInner::Studio(studio_meta) = &recording_meta.inner else {
return Vec::new();
};

let all_events = match studio_meta {
StudioRecordingMeta::SingleSegment { segment } => {
if let Some(cursor_path) = &segment.cursor {
CursorEvents::load_from_file(&recording_meta.path(cursor_path))
.unwrap_or_default()
.clicks
} else {
vec![]
}
}
StudioRecordingMeta::MultipleSegments { inner, .. } => inner
.segments
.iter()
.flat_map(|s| s.cursor_events(recording_meta).clicks)
.collect(),
};

generate_zoom_segments_from_clicks_impl(all_events, recordings)
}

fn project_config_from_recording(
app: &AppHandle,
completed_recording: &CompletedStudioRecording,
recordings: &ProjectRecordingsMeta,
default_config: Option<ProjectConfiguration>,
) -> ProjectConfiguration {
let settings = GeneralSettingsStore::get(app)
.unwrap_or(None)
.unwrap_or_default();

ProjectConfiguration {
timeline: Some(TimelineConfiguration {
segments: recordings
Expand All @@ -891,7 +959,11 @@ fn project_config_from_recording(
timescale: 1.0,
})
.collect(),
zoom_segments: generate_zoom_segments_from_clicks(&completed_recording, &recordings),
zoom_segments: if settings.auto_zoom_on_clicks {
generate_zoom_segments_from_clicks(&completed_recording, &recordings)
} else {
Vec::new()
},
}),
..default_config.unwrap_or_default()
}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/routes/(window-chrome).tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ function Header() {
return (
<header
class={cx(
"flex items-center space-x-1 h-9 border select-none shrink-0 bg-gray-2",
"flex items-center space-x-1 h-9 select-none shrink-0 bg-gray-2",
isWindows ? "flex-row" : "flex-row-reverse pl-[5rem]"
)}
data-tauri-drag-region
Expand Down
40 changes: 22 additions & 18 deletions apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) {
autoCreateShareableLink: false,
enableNotifications: true,
enableNativeCameraPreview: false,
autoZoomOnClicks: false,
}
);

Expand All @@ -50,24 +51,27 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) {
expected.
</p>
</div>
<div class="space-y-3">
<h3 class="text-sm text-gray-12 w-fit">Recording Features</h3>
<div class="px-3 rounded-xl border divide-y divide-gray-3 border-gray-3 bg-gray-2">
<ToggleSetting
label="Custom cursor capture in Studio Mode"
description="Studio Mode recordings will capture cursor state separately for customisation (size, smoothing) in the editor. Currently experimental as cursor events may not be captured accurately."
value={!!settings.customCursorCapture}
onChange={(value) => handleChange("customCursorCapture", value)}
/>
<ToggleSetting
label="Native camera preview"
description="Show the camera preview using a native GPU surface instead of rendering it within the webview. This is not functional on certain Windows systems so your mileage may vary."
value={!!settings.enableNativeCameraPreview}
onChange={(value) =>
handleChange("enableNativeCameraPreview", value)
}
/>
</div>
<div class="px-3 rounded-xl border divide-y divide-gray-3 border-gray-3 bg-gray-2">
<ToggleSetting
label="Custom cursor capture in Studio Mode"
description="Studio Mode recordings will capture cursor state separately for customisation (size, smoothing) in the editor. Currently experimental as cursor events may not be captured accurately."
value={!!settings.customCursorCapture}
onChange={(value) => handleChange("customCursorCapture", value)}
/>
<ToggleSetting
label="Native camera preview"
description="Show the camera preview using a native GPU surface instead of rendering it within the webview. This is not functional on certain Windows systems so your mileage may vary."
value={!!settings.enableNativeCameraPreview}
onChange={(value) =>
handleChange("enableNativeCameraPreview", value)
}
/>
<ToggleSetting
label="Auto zoom on clicks"
description="Automatically generate zoom segments around mouse clicks during Studio Mode recordings. This helps highlight important interactions in your recordings."
value={!!settings.autoZoomOnClicks}
onChange={(value) => handleChange("autoZoomOnClicks", value)}
/>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) {
autoCreateShareableLink: false,
enableNotifications: true,
enableNativeCameraPreview: false,
autoZoomOnClicks: false,
}
);

Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/routes/editor/Timeline/ZoomTrack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createSignal,
} from "solid-js";
import { produce } from "solid-js/store";
import { Menu } from "@tauri-apps/api/menu";

import { useEditorContext } from "../context";
import {
Expand All @@ -20,6 +21,7 @@ import {
} from "./context";
import { SegmentContent, SegmentHandle, SegmentRoot, TrackRoot } from "./Track";
import { cx } from "cva";
import { commands } from "~/utils/tauri";

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

const handleGenerateZoomSegments = async () => {
try {
const zoomSegments = await commands.generateZoomSegmentsFromClicks();
setProject("timeline", "zoomSegments", zoomSegments);
} catch (error) {
console.error("Failed to generate zoom segments:", error);
}
};

return (
<TrackRoot
onContextMenu={async (e) => {
if (!import.meta.env.DEV) return;

e.preventDefault();
const menu = await Menu.new({
id: "zoom-track-options",
items: [
{
id: "generateZoomSegments",
text: "Generate zoom segments from clicks",
action: handleGenerateZoomSegments,
},
],
});
menu.popup();
}}
onMouseMove={(e) => {
if (hoveringSegment()) {
setHoveredTime(undefined);
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/utils/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ async setPlayheadPosition(frameNumber: number) : Promise<null> {
async setProjectConfig(config: ProjectConfiguration) : Promise<null> {
return await TAURI_INVOKE("set_project_config", { config });
},
async generateZoomSegmentsFromClicks() : Promise<ZoomSegment[]> {
return await TAURI_INVOKE("generate_zoom_segments_from_clicks");
},
async openPermissionSettings(permission: OSPermission) : Promise<void> {
await TAURI_INVOKE("open_permission_settings", { permission });
},
Expand Down Expand Up @@ -354,7 +357,7 @@ openEditorAfterRecording?: boolean;
/**
* @deprecated can be removed when native camera preview is ready
*/
enableNativeCameraPreview: boolean }
enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean }
export type GifExportSettings = { fps: number; resolution_base: XY<number> }
export type HapticPattern = "Alignment" | "LevelChange" | "Generic"
export type HapticPerformanceTime = "Default" | "Now" | "DrawCompleted"
Expand Down
Loading
Loading