Skip to content

Commit 960c6bc

Browse files
committed
Merge branch 'new-recording-flow-list-view' of https://github.com/CapSoftware/Cap into new-recording-flow-list-view
2 parents fede3e1 + 30e6475 commit 960c6bc

File tree

8 files changed

+150
-70
lines changed

8 files changed

+150
-70
lines changed

.claude/settings.local.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(pnpm typecheck:*)",
5+
"Bash(pnpm lint:*)",
6+
"Bash(pnpm build:*)"
7+
],
8+
"deny": [],
9+
"ask": []
10+
}
11+
}

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

Lines changed: 81 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -203,15 +203,66 @@ pub struct CaptureWindowWithThumbnail {
203203
pub app_icon: Option<String>,
204204
}
205205

206+
#[cfg(any(target_os = "macos", windows))]
207+
const THUMBNAIL_WIDTH: u32 = 320;
208+
#[cfg(any(target_os = "macos", windows))]
209+
const THUMBNAIL_HEIGHT: u32 = 180;
210+
211+
#[cfg(any(target_os = "macos", windows))]
212+
fn normalize_thumbnail_dimensions(image: &image::RgbaImage) -> image::RgbaImage {
213+
let width = image.width();
214+
let height = image.height();
215+
216+
if width == THUMBNAIL_WIDTH && height == THUMBNAIL_HEIGHT {
217+
return image.clone();
218+
}
219+
220+
if width == 0 || height == 0 {
221+
return image::RgbaImage::from_pixel(
222+
THUMBNAIL_WIDTH,
223+
THUMBNAIL_HEIGHT,
224+
image::Rgba([0, 0, 0, 0]),
225+
);
226+
}
227+
228+
let scale = (THUMBNAIL_WIDTH as f32 / width as f32)
229+
.min(THUMBNAIL_HEIGHT as f32 / height as f32)
230+
.max(f32::MIN_POSITIVE);
231+
232+
let scaled_width = (width as f32 * scale)
233+
.round()
234+
.clamp(1.0, THUMBNAIL_WIDTH as f32) as u32;
235+
let scaled_height = (height as f32 * scale)
236+
.round()
237+
.clamp(1.0, THUMBNAIL_HEIGHT as f32) as u32;
238+
239+
let resized = image::imageops::resize(
240+
image,
241+
scaled_width.max(1),
242+
scaled_height.max(1),
243+
image::imageops::FilterType::Lanczos3,
244+
);
245+
246+
let mut canvas =
247+
image::RgbaImage::from_pixel(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, image::Rgba([0, 0, 0, 0]));
248+
249+
let offset_x = (THUMBNAIL_WIDTH - scaled_width) / 2;
250+
let offset_y = (THUMBNAIL_HEIGHT - scaled_height) / 2;
251+
252+
image::imageops::overlay(&mut canvas, &resized, offset_x as i64, offset_y as i64);
253+
254+
canvas
255+
}
256+
206257
#[cfg(target_os = "macos")]
207258
async fn capture_thumbnail_from_filter(filter: &cidre::sc::ContentFilter) -> Option<String> {
208259
use cidre::{cv, sc};
209260
use image::{ImageEncoder, RgbaImage, codecs::png::PngEncoder};
210261
use std::{io::Cursor, slice};
211262

212263
let mut config = sc::StreamCfg::new();
213-
config.set_width(200);
214-
config.set_height(112);
264+
config.set_width(THUMBNAIL_WIDTH as usize);
265+
config.set_height(THUMBNAIL_HEIGHT as usize);
215266
config.set_shows_cursor(false);
216267

217268
let sample_buf =
@@ -271,12 +322,13 @@ async fn capture_thumbnail_from_filter(filter: &cidre::sc::ContentFilter) -> Opt
271322
warn!("Failed to construct RGBA image for thumbnail");
272323
return None;
273324
};
325+
let thumbnail = normalize_thumbnail_dimensions(&img);
274326
let mut png_data = Cursor::new(Vec::new());
275327
let encoder = PngEncoder::new(&mut png_data);
276328
if let Err(err) = encoder.write_image(
277-
img.as_raw(),
278-
img.width(),
279-
img.height(),
329+
thumbnail.as_raw(),
330+
thumbnail.width(),
331+
thumbnail.height(),
280332
image::ColorType::Rgba8.into(),
281333
) {
282334
warn!(error = ?err, "Failed to encode thumbnail as PNG");
@@ -583,7 +635,7 @@ async fn capture_display_thumbnail(display: &scap_targets::Display) -> Option<St
583635
use scap_direct3d::{Capturer, Settings};
584636
use std::io::Cursor;
585637

586-
let item = display.raw_handle().get_capture_item().ok()?;
638+
let item = display.raw_handle().try_as_capture_item().ok()?;
587639

588640
let (tx, rx) = std::sync::mpsc::channel();
589641

@@ -593,7 +645,7 @@ async fn capture_display_thumbnail(display: &scap_targets::Display) -> Option<St
593645
..Default::default()
594646
};
595647

596-
let capturer = Capturer::new(
648+
let mut capturer = Capturer::new(
597649
item,
598650
settings.clone(),
599651
move |frame| {
@@ -617,9 +669,6 @@ async fn capture_display_thumbnail(display: &scap_targets::Display) -> Option<St
617669
return None;
618670
}
619671

620-
let target_width = 320u32;
621-
let target_height = (height as f32 * (target_width as f32 / width as f32)) as u32;
622-
623672
let frame_buffer = frame.as_buffer().ok()?;
624673
let data = frame_buffer.data();
625674
let stride = frame_buffer.stride() as usize;
@@ -638,21 +687,16 @@ async fn capture_display_thumbnail(display: &scap_targets::Display) -> Option<St
638687
}
639688

640689
let img = image::RgbaImage::from_raw(width, height, rgba_data)?;
641-
let resized = image::imageops::resize(
642-
&img,
643-
target_width,
644-
target_height,
645-
image::imageops::FilterType::Lanczos3,
646-
);
690+
let thumbnail = normalize_thumbnail_dimensions(&img);
647691

648692
let mut png_data = Cursor::new(Vec::new());
649693
let encoder = PngEncoder::new(&mut png_data);
650694
encoder
651695
.write_image(
652-
resized.as_raw(),
653-
target_width,
654-
target_height,
655-
ColorType::Rgba8,
696+
thumbnail.as_raw(),
697+
thumbnail.width(),
698+
thumbnail.height(),
699+
ColorType::Rgba8.into(),
656700
)
657701
.ok()?;
658702

@@ -675,7 +719,7 @@ async fn capture_window_thumbnail(window: &scap_targets::Window) -> Option<Strin
675719
use scap_direct3d::{Capturer, Settings};
676720
use std::io::Cursor;
677721

678-
let item = window.raw_handle().get_capture_item().ok()?;
722+
let item = window.raw_handle().try_as_capture_item().ok()?;
679723

680724
let (tx, rx) = std::sync::mpsc::channel();
681725

@@ -685,7 +729,7 @@ async fn capture_window_thumbnail(window: &scap_targets::Window) -> Option<Strin
685729
..Default::default()
686730
};
687731

688-
let capturer = Capturer::new(
732+
let mut capturer = Capturer::new(
689733
item,
690734
settings.clone(),
691735
move |frame| {
@@ -709,9 +753,6 @@ async fn capture_window_thumbnail(window: &scap_targets::Window) -> Option<Strin
709753
return None;
710754
}
711755

712-
let target_width = 200u32;
713-
let target_height = (height as f32 * (target_width as f32 / width as f32)) as u32;
714-
715756
let frame_buffer = frame.as_buffer().ok()?;
716757
let data = frame_buffer.data();
717758
let stride = frame_buffer.stride() as usize;
@@ -730,21 +771,16 @@ async fn capture_window_thumbnail(window: &scap_targets::Window) -> Option<Strin
730771
}
731772

732773
let img = image::RgbaImage::from_raw(width, height, rgba_data)?;
733-
let resized = image::imageops::resize(
734-
&img,
735-
target_width,
736-
target_height,
737-
image::imageops::FilterType::Lanczos3,
738-
);
774+
let thumbnail = normalize_thumbnail_dimensions(&img);
739775

740776
let mut png_data = Cursor::new(Vec::new());
741777
let encoder = PngEncoder::new(&mut png_data);
742778
encoder
743779
.write_image(
744-
resized.as_raw(),
745-
target_width,
746-
target_height,
747-
ColorType::Rgba8,
780+
thumbnail.as_raw(),
781+
thumbnail.width(),
782+
thumbnail.height(),
783+
ColorType::Rgba8.into(),
748784
)
749785
.ok()?;
750786

@@ -797,20 +833,16 @@ fn collect_windows_with_thumbnails() -> Result<Vec<CaptureWindowWithThumbnail>,
797833
let mut results = Vec::new();
798834
for (capture_window, window) in windows {
799835
let thumbnail = capture_window_thumbnail(&window).await;
800-
let app_icon = window
801-
.app_icon()
802-
.and_then(|bytes| {
803-
if bytes.is_empty() {
804-
None
805-
} else {
806-
Some(
807-
base64::Engine::encode(
808-
&base64::engine::general_purpose::STANDARD,
809-
bytes,
810-
),
811-
)
812-
}
813-
});
836+
let app_icon = window.app_icon().and_then(|bytes| {
837+
if bytes.is_empty() {
838+
None
839+
} else {
840+
Some(base64::Engine::encode(
841+
&base64::engine::general_purpose::STANDARD,
842+
bytes,
843+
))
844+
}
845+
});
814846

815847
if thumbnail.is_none() {
816848
warn!(

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,20 +154,20 @@ pub async fn focus_window(window_id: WindowId) -> Result<(), String> {
154154
.owner_pid()
155155
.ok_or("Could not get window owner PID")?;
156156

157-
if let Some(app) = unsafe {
158-
NSRunningApplication::runningApplicationWithProcessIdentifier(pid)
159-
} {
157+
if let Some(app) =
158+
unsafe { NSRunningApplication::runningApplicationWithProcessIdentifier(pid) }
159+
{
160160
unsafe {
161-
app.activateWithOptions(
162-
NSApplicationActivationOptions::ActivateIgnoringOtherApps,
163-
);
161+
app.activateWithOptions(NSApplicationActivationOptions::ActivateIgnoringOtherApps);
164162
}
165163
}
166164
}
167165

168166
#[cfg(target_os = "windows")]
169167
{
170-
use windows::Win32::UI::WindowsAndMessaging::{SetForegroundWindow, ShowWindow, SW_RESTORE};
168+
use windows::Win32::UI::WindowsAndMessaging::{
169+
SW_RESTORE, SetForegroundWindow, ShowWindow,
170+
};
171171

172172
let hwnd = window.raw_handle().inner();
173173

apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export default function TargetCard(props: TargetCardProps) {
154154
alt={`${
155155
local.variant === "display" ? "Display" : "Window"
156156
} preview for ${label()}`}
157-
class="object-cover w-full h-full"
157+
class="object-contain w-full h-full"
158158
loading="lazy"
159159
draggable={false}
160160
/>
@@ -194,7 +194,7 @@ export default function TargetCard(props: TargetCardProps) {
194194
}
195195

196196
function escapeRegExp(value: string) {
197-
return value.replace(/[-^$*+?.()|[\]{}]/g, "\\$&");
197+
return value.replace(/[\\-^$*+?.()|[\]{}]/g, "\\$&");
198198
}
199199
export function TargetCardSkeleton(props: { class?: string }) {
200200
return (

apps/desktop/src/routes/(window-chrome)/new-main/TargetDropdownButton.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import IconCapChevronDown from "~icons/cap/chevron-down";
77
type TargetDropdownButtonProps<T extends ValidComponent> = PolymorphicProps<
88
T,
99
{
10+
class?: string;
11+
disabled?: boolean;
1012
expanded?: boolean;
1113
}
1214
>;

apps/desktop/src/routes/(window-chrome)/new-main/index.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -373,13 +373,11 @@ function Page() {
373373
"captureTarget",
374374
reconcile({ variant: "window", id: target.id }),
375375
);
376+
setOptions("targetMode", "window");
376377
setWindowMenuOpen(false);
377378
windowTriggerRef?.focus();
378379

379380
await commands.focusWindow(target.id);
380-
381-
await new Promise((resolve) => setTimeout(resolve, 100));
382-
setOptions("targetMode", "window");
383381
};
384382

385383
createEffect(() => {

apps/desktop/src/routes/target-select-overlay.tsx

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,46 @@ function Inner() {
6464
});
6565
onCleanup(() => unsubTargetUnderCursor.then((unsub) => unsub()));
6666

67+
const selectedWindow = createQuery(() => ({
68+
queryKey: ["selectedWindow", rawOptions.captureTarget],
69+
queryFn: async () => {
70+
if (rawOptions.captureTarget.variant !== "window") return null;
71+
const windowId = rawOptions.captureTarget.id;
72+
73+
const windows = await commands.listCaptureWindows();
74+
const window = windows.find((w) => w.id === windowId);
75+
76+
if (!window) return null;
77+
78+
return {
79+
id: window.id,
80+
app_name: window.owner_name || window.name || "Unknown",
81+
bounds: window.bounds,
82+
};
83+
},
84+
enabled:
85+
rawOptions.captureTarget.variant === "window" &&
86+
rawOptions.targetMode === "window",
87+
staleTime: 5 * 1000,
88+
}));
89+
90+
const windowToShow = () => {
91+
if (rawOptions.captureTarget.variant === "window" && selectedWindow.data) {
92+
return selectedWindow.data;
93+
}
94+
// Otherwise use what's under the cursor
95+
return targetUnderCursor.window;
96+
};
97+
6798
const windowIcon = createQuery(() => ({
68-
queryKey: ["windowIcon", targetUnderCursor.window?.id],
99+
queryKey: ["windowIcon", windowToShow()?.id],
69100
queryFn: async () => {
70-
if (!targetUnderCursor.window?.id) return null;
71-
return await commands.getWindowIcon(
72-
targetUnderCursor.window.id.toString(),
73-
);
101+
const window = windowToShow();
102+
if (!window?.id) return null;
103+
return await commands.getWindowIcon(window.id.toString());
74104
},
75-
enabled: !!targetUnderCursor.window?.id,
76-
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
105+
enabled: !!windowToShow()?.id,
106+
staleTime: 5 * 60 * 1000,
77107
}));
78108

79109
const displayInformation = createQuery(() => ({
@@ -184,10 +214,12 @@ function Inner() {
184214
<Match
185215
when={
186216
rawOptions.targetMode === "window" &&
187-
targetUnderCursor.display_id === params.displayId
217+
(targetUnderCursor.display_id === params.displayId ||
218+
(rawOptions.captureTarget.variant === "window" &&
219+
selectedWindow.data))
188220
}
189221
>
190-
<Show when={targetUnderCursor.window} keyed>
222+
<Show when={windowToShow()} keyed>
191223
{(windowUnderCursor) => (
192224
<div
193225
data-over={targetUnderCursor.display_id === params.displayId}

crates/scap-targets/src/platform/win.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,11 @@ impl WindowImpl {
10261026

10271027
true
10281028
}
1029+
1030+
pub fn try_as_capture_item(&self) -> windows::core::Result<GraphicsCaptureItem> {
1031+
let interop = windows::core::factory::<GraphicsCaptureItem, IGraphicsCaptureItemInterop>()?;
1032+
unsafe { interop.CreateForWindow(self.0) }
1033+
}
10291034
}
10301035

10311036
fn is_window_valid_for_enumeration(hwnd: HWND, current_process_id: u32) -> bool {

0 commit comments

Comments
 (0)