Skip to content

Commit bf2a0f7

Browse files
committed
account for cursor hotspot
also adds anisotropic filtering to cursor rendering :)
1 parent 33b1dd1 commit bf2a0f7

File tree

9 files changed

+164
-115
lines changed

9 files changed

+164
-115
lines changed

apps/desktop/src/utils/tauri.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ export type Crop = { position: XY<number>; size: XY<number> }
245245
export type CurrentRecordingChanged = null
246246
export type CursorAnimationStyle = "regular" | "slow" | "fast"
247247
export type CursorConfiguration = { hideWhenIdle: boolean; size: number; type: CursorType; animationStyle: CursorAnimationStyle }
248+
export type CursorMeta = { imagePath: string; hotspot: XY<number> }
248249
export type CursorType = "pointer" | "circle"
249250
export type Display = { path: string; fps?: number }
250251
export type EditorStateChanged = { playhead_position: number }
@@ -259,7 +260,7 @@ export type HotkeysConfiguration = { show: boolean }
259260
export type HotkeysStore = { hotkeys: { [key in HotkeyAction]: Hotkey } }
260261
export type JsonValue<T> = [T]
261262
export type MultipleSegment = { display: Display; camera?: CameraMeta | null; audio?: AudioMeta | null; cursor?: string | null }
262-
export type MultipleSegments = { segments: MultipleSegment[]; cursors: { [key in string]: string } }
263+
export type MultipleSegments = { segments: MultipleSegment[]; cursors: { [key in string]: CursorMeta } }
263264
export type NewNotification = { title: string; body: string; is_error: boolean }
264265
export type NewRecordingAdded = { path: string }
265266
export type NewScreenshotAdded = { path: string }

crates/editor/src/editor_instance.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -336,8 +336,6 @@ pub async fn create_segments(meta: &RecordingMeta) -> Result<Vec<Segment>, Strin
336336
.map(|audio_meta| AudioData::from_file(meta.path(&audio_meta.path)).unwrap()),
337337
);
338338

339-
let cursor = Arc::new(s.cursor_data(&meta).into());
340-
341339
let decoders = RecordingSegmentDecoders::new(
342340
&meta,
343341
SegmentVideoPaths {
@@ -350,7 +348,7 @@ pub async fn create_segments(meta: &RecordingMeta) -> Result<Vec<Segment>, Strin
350348

351349
Ok(vec![Segment {
352350
audio,
353-
cursor,
351+
cursor: Default::default(),
354352
decoders,
355353
}])
356354
}

crates/project/src/cursor.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use std::collections::HashMap;
44
use std::fs::File;
55
use std::path::{Path, PathBuf};
66

7+
use crate::XY;
8+
79
#[derive(Serialize, Deserialize, Clone, Type, Debug)]
810
pub struct CursorMoveEvent {
911
pub active_modifiers: Vec<String>,
@@ -28,7 +30,13 @@ pub struct CursorClickEvent {
2830

2931
#[derive(Default, Serialize, Deserialize, Debug, Clone)]
3032
#[serde(transparent)]
31-
pub struct CursorImages(pub HashMap<String, PathBuf>);
33+
pub struct CursorImages(pub HashMap<String, CursorImage>);
34+
35+
#[derive(Default, Serialize, Deserialize, Debug, Clone)]
36+
pub struct CursorImage {
37+
pub path: PathBuf,
38+
pub hotspot: XY<f64>,
39+
}
3240

3341
#[derive(Default, Serialize, Deserialize, Debug, Clone)]
3442
pub struct CursorData {

crates/project/src/meta.rs

Lines changed: 23 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::{
88
path::{Path, PathBuf},
99
};
1010

11-
use crate::{CursorData, CursorEvents, CursorImages, ProjectConfiguration};
11+
use crate::{CursorEvents, CursorImage, CursorImages, ProjectConfiguration, XY};
1212

1313
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
1414
pub struct Display {
@@ -85,7 +85,7 @@ impl RecordingMeta {
8585
}
8686

8787
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
88-
#[serde(untagged)]
88+
#[serde(untagged, rename_all = "camelCase")]
8989
pub enum Content {
9090
SingleSegment {
9191
#[serde(flatten)]
@@ -128,6 +128,7 @@ impl Content {
128128
}
129129

130130
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
131+
#[serde(rename_all = "camelCase")]
131132
pub struct SingleSegment {
132133
pub display: Display,
133134
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -139,72 +140,40 @@ pub struct SingleSegment {
139140
pub cursor: Option<RelativePathBuf>,
140141
}
141142

142-
impl SingleSegment {
143-
pub fn cursor_data(&self, meta: &RecordingMeta) -> CursorData {
144-
let Some(cursor_path) = &self.cursor else {
145-
return CursorData::default();
146-
};
147-
148-
let full_path = meta.path(cursor_path);
149-
println!("Loading cursor data from: {:?}", full_path);
150-
151-
// Try to load the cursor data
152-
let mut data = match CursorData::load_from_file(&full_path) {
153-
Ok(data) => data,
154-
Err(e) => {
155-
eprintln!("Failed to load cursor data: {}", e);
156-
return CursorData::default();
157-
}
158-
};
159-
160-
// If cursor_images is empty but cursor files exist, populate it
161-
let cursors_dir = meta.path(&RelativePathBuf::from("./cursors"));
162-
if data.cursor_images.0.is_empty() && cursors_dir.exists() {
163-
println!("Scanning cursors directory: {:?}", cursors_dir);
164-
if let Ok(entries) = std::fs::read_dir(&cursors_dir) {
165-
for entry in entries {
166-
let Ok(entry) = entry else {
167-
continue;
168-
};
169-
170-
let filename = entry.file_name();
171-
let filename_str = filename.to_string_lossy();
172-
if filename_str.starts_with("cursor_") && filename_str.ends_with(".png") {
173-
// Extract cursor ID from filename (cursor_X.png -> X)
174-
if let Some(id) = filename_str
175-
.strip_prefix("cursor_")
176-
.and_then(|s| s.strip_suffix(".png"))
177-
{
178-
println!("Found cursor image: {} -> {}", id, filename_str);
179-
data.cursor_images.0.insert(id.to_string(), entry.path());
180-
}
181-
}
182-
}
183-
}
184-
println!("Found {} cursor images", data.cursor_images.0.len());
185-
}
186-
data
187-
}
188-
}
189-
190143
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
144+
#[serde(rename_all = "camelCase")]
191145
pub struct MultipleSegments {
192146
pub segments: Vec<MultipleSegment>,
193147
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
194-
#[specta(type = HashMap<String, String>)]
195-
pub cursors: HashMap<String, RelativePathBuf>,
148+
pub cursors: HashMap<String, CursorMeta>,
149+
}
150+
151+
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
152+
#[serde(rename_all = "camelCase")]
153+
pub struct CursorMeta {
154+
#[specta(type = String)]
155+
pub image_path: RelativePathBuf,
156+
pub hotspot: XY<f64>,
196157
}
197158

198159
impl MultipleSegments {
199160
pub fn path(&self, meta: &RecordingMeta, path: impl AsRef<Path>) -> PathBuf {
200161
meta.project_path.join(path)
201162
}
202163

203-
pub fn cursor_images(&self, meta: &RecordingMeta) -> Result<CursorImages, String> {
164+
pub fn cursor_images(&self, meta: &RecordingMeta) -> Result<CursorImages, CursorImage> {
204165
Ok(CursorImages(
205166
self.cursors
206167
.iter()
207-
.map(|(k, v)| (k.clone(), meta.path(v)))
168+
.map(|(k, v)| {
169+
(
170+
k.clone(),
171+
CursorImage {
172+
path: meta.path(&v.image_path),
173+
hotspot: v.hotspot,
174+
},
175+
)
176+
})
208177
.collect::<_>(),
209178
))
210179
}

crates/recording/src/actor.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -478,10 +478,14 @@ async fn stop_recording(
478478
},
479479
cursors: cursors
480480
.into_values()
481-
.map(|(file_name, id)| {
481+
.map(|cursor| {
482482
(
483-
id.to_string(),
484-
RelativePathBuf::from("content/cursors").join(&file_name),
483+
cursor.id.to_string(),
484+
CursorMeta {
485+
image_path: RelativePathBuf::from("content/cursors")
486+
.join(&cursor.file_name),
487+
hotspot: cursor.hotspot,
488+
},
485489
)
486490
})
487491
.collect(),

crates/recording/src/cursor.rs

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@ use std::{
77
};
88

99
use cap_media::platform::Bounds;
10-
use cap_project::{CursorClickEvent, CursorMoveEvent};
10+
use cap_project::{CursorClickEvent, CursorMoveEvent, XY};
1111
use cap_utils::spawn_actor;
1212
use device_query::{DeviceQuery, DeviceState};
13+
use image::GenericImageView;
1314
use tokio::sync::oneshot;
1415
use tracing::{debug, error, info};
1516

16-
pub type Cursors = HashMap<u64, (String, u32)>;
17+
pub struct Cursor {
18+
pub file_name: String,
19+
pub id: u32,
20+
pub hotspot: XY<f64>,
21+
}
22+
23+
pub type Cursors = HashMap<u64, Cursor>;
1724

1825
pub struct CursorActorResponse {
1926
// pub cursor_images: HashMap<String, Vec<u8>>,
@@ -71,29 +78,35 @@ pub fn spawn_cursor_recorder(
7178
let cursor_data = get_cursor_image_data();
7279
let cursor_id = if let Some(data) = cursor_data {
7380
let mut hasher = DefaultHasher::default();
74-
data.hash(&mut hasher);
81+
data.image.hash(&mut hasher);
7582
let id = hasher.finish();
7683

7784
// Check if we've seen this cursor data before
7885
if let Some(existing_id) = response.cursors.get(&id) {
79-
existing_id.1.to_string()
86+
existing_id.id.to_string()
8087
} else {
8188
// New cursor data - save it
8289
let cursor_id = response.next_cursor_id.to_string();
83-
let filename = format!("cursor_{}.png", cursor_id);
84-
let cursor_path = cursors_dir.join(&filename);
90+
let file_name = format!("cursor_{}.png", cursor_id);
91+
let cursor_path = cursors_dir.join(&file_name);
8592

86-
if let Ok(image) = image::load_from_memory(&data) {
93+
if let Ok(image) = image::load_from_memory(&data.image) {
94+
dbg!(image.dimensions());
8795
// Convert to RGBA
8896
let rgba_image = image.into_rgba8();
8997

9098
if let Err(e) = rgba_image.save(&cursor_path) {
9199
error!("Failed to save cursor image: {}", e);
92100
} else {
93-
info!("Saved cursor {cursor_id} image to: {:?}", filename);
94-
response
95-
.cursors
96-
.insert(id, (filename.clone(), response.next_cursor_id));
101+
info!("Saved cursor {cursor_id} image to: {:?}", file_name);
102+
response.cursors.insert(
103+
id,
104+
Cursor {
105+
file_name,
106+
id: response.next_cursor_id,
107+
hotspot: data.hotspot,
108+
},
109+
);
97110
response.next_cursor_id += 1;
98111
}
99112
}
@@ -149,10 +162,16 @@ pub fn spawn_cursor_recorder(
149162
CursorActor { rx, stop_signal }
150163
}
151164

165+
#[derive(Debug)]
166+
struct CursorData {
167+
image: Vec<u8>,
168+
hotspot: XY<f64>,
169+
}
170+
152171
#[cfg(target_os = "macos")]
153-
fn get_cursor_image_data() -> Option<Vec<u8>> {
172+
fn get_cursor_image_data() -> Option<CursorData> {
154173
use cocoa::base::{id, nil};
155-
use cocoa::foundation::NSUInteger;
174+
use cocoa::foundation::{NSPoint, NSSize, NSUInteger};
156175
use objc::rc::autoreleasepool;
157176
use objc::runtime::Class;
158177
use objc::*;
@@ -176,6 +195,9 @@ fn get_cursor_image_data() -> Option<Vec<u8>> {
176195
return None;
177196
}
178197

198+
let cursor_size: NSSize = msg_send![cursor_image, size];
199+
let cursor_hotspot: NSPoint = msg_send![current_cursor, hotSpot];
200+
179201
// Get the TIFF representation of the image
180202
let image_data: id = msg_send![cursor_image, TIFFRepresentation];
181203
if image_data == nil {
@@ -192,7 +214,13 @@ fn get_cursor_image_data() -> Option<Vec<u8>> {
192214
let slice = std::slice::from_raw_parts(bytes, length as usize);
193215
let data = slice.to_vec();
194216

195-
Some(data)
217+
Some(CursorData {
218+
image: data,
219+
hotspot: XY::new(
220+
cursor_hotspot.x / cursor_size.width,
221+
cursor_hotspot.y / cursor_size.height,
222+
),
223+
})
196224
}
197225
})
198226
}

0 commit comments

Comments
 (0)