diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ce1d28dd79..c7df3d3d9f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(pnpm typecheck:*)", "Bash(pnpm lint:*)", "Bash(pnpm build:*)", - "Bash(cargo check:*)" + "Bash(cargo check:*)", + "Bash(cargo fmt:*)" ], "deny": [], "ask": [] diff --git a/Cargo.lock b/Cargo.lock index 0d759a4c6e..94269460bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1172,7 +1172,7 @@ dependencies = [ [[package]] name = "cap-desktop" -version = "0.3.84" +version = "0.4.0" dependencies = [ "anyhow", "async-stream", @@ -1496,11 +1496,14 @@ dependencies = [ "chrono", "cidre", "cocoa", + "core-foundation 0.10.1", + "core-graphics 0.24.0", "cpal 0.15.3 (git+https://github.com/CapSoftware/cpal?rev=3cc779a7b4ca)", "device_query", "either", "ffmpeg-next", "flume", + "foreign-types-shared 0.3.1", "futures", "hex", "image 0.25.8", diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index b82ac725fe..e771e99343 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -172,6 +172,7 @@ impl Export { // print!("\rrendered frame {f}"); stdout.flush().unwrap(); + true }) .await .map_err(|v| format!("Exporter error: {v}"))?; diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 46024fd0ab..e8d13e2828 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cap-desktop" -version = "0.3.84" +version = "0.4.0" description = "Beautiful screen recordings, owned by you." authors = ["you"] edition = "2024" diff --git a/apps/desktop/src-tauri/src/camera_legacy.rs b/apps/desktop/src-tauri/src/camera_legacy.rs index 683514e656..444d0f24d4 100644 --- a/apps/desktop/src-tauri/src/camera_legacy.rs +++ b/apps/desktop/src-tauri/src/camera_legacy.rs @@ -5,14 +5,15 @@ use tokio_util::sync::CancellationToken; use crate::frame_ws::{WSFrame, create_frame_ws}; pub async fn create_camera_preview_ws() -> (Sender, u16, CancellationToken) { - let (camera_tx, mut _camera_rx) = flume::bounded::(4); - let (_camera_tx, camera_rx) = flume::bounded::(4); + let (camera_tx, camera_rx) = flume::bounded::(4); + let (frame_tx, _) = tokio::sync::broadcast::channel::(4); + let frame_tx_clone = frame_tx.clone(); std::thread::spawn(move || { use ffmpeg::format::Pixel; let mut converter: Option<(Pixel, ffmpeg::software::scaling::Context)> = None; - while let Ok(raw_frame) = _camera_rx.recv() { + while let Ok(raw_frame) = camera_rx.recv() { let mut frame = raw_frame.inner; if frame.format() != Pixel::RGBA || frame.width() > 1280 || frame.height() > 720 { @@ -55,7 +56,7 @@ pub async fn create_camera_preview_ws() -> (Sender, u16, Cance frame = new_frame; } - _camera_tx + frame_tx_clone .send(WSFrame { data: frame.data(0).to_vec(), width: frame.width(), @@ -66,7 +67,7 @@ pub async fn create_camera_preview_ws() -> (Sender, u16, Cance } }); // _shutdown needs to be kept alive to keep the camera ws running - let (camera_ws_port, _shutdown) = create_frame_ws(camera_rx.clone()).await; + let (camera_ws_port, _shutdown) = create_frame_ws(frame_tx).await; (camera_tx, camera_ws_port, _shutdown) } diff --git a/apps/desktop/src-tauri/src/editor_window.rs b/apps/desktop/src-tauri/src/editor_window.rs index 7c253d595a..4f616c1d25 100644 --- a/apps/desktop/src-tauri/src/editor_window.rs +++ b/apps/desktop/src-tauri/src/editor_window.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, ops::Deref, path::PathBuf, sync::Arc}; use tauri::{Manager, Runtime, Window, ipc::CommandArg}; -use tokio::sync::RwLock; +use tokio::sync::{RwLock, broadcast}; use tokio_util::sync::CancellationToken; use crate::{ @@ -88,9 +88,9 @@ impl EditorInstances { match instances.entry(window.label().to_string()) { Entry::Vacant(entry) => { - let (frame_tx, frame_rx) = flume::bounded(4); + let (frame_tx, _) = broadcast::channel(4); - let (ws_port, ws_shutdown_token) = create_frame_ws(frame_rx).await; + let (ws_port, ws_shutdown_token) = create_frame_ws(frame_tx.clone()).await; let instance = create_editor_instance_impl( window.app_handle(), path, diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index 44097fa772..9b1d1c461f 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -50,10 +50,12 @@ pub async fn export_video( settings .export(exporter_base, move |frame_index| { // Ensure progress never exceeds total frames - let _ = progress.send(FramesRendered { - rendered_count: (frame_index + 1).min(total_frames), - total_frames, - }); + progress + .send(FramesRendered { + rendered_count: (frame_index + 1).min(total_frames), + total_frames, + }) + .is_ok() }) .await } @@ -61,10 +63,12 @@ pub async fn export_video( settings .export(exporter_base, move |frame_index| { // Ensure progress never exceeds total frames - let _ = progress.send(FramesRendered { - rendered_count: (frame_index + 1).min(total_frames), - total_frames, - }); + progress + .send(FramesRendered { + rendered_count: (frame_index + 1).min(total_frames), + total_frames, + }) + .is_ok() }) .await } diff --git a/apps/desktop/src-tauri/src/frame_ws.rs b/apps/desktop/src-tauri/src/frame_ws.rs index 1276f21087..16fa506e39 100644 --- a/apps/desktop/src-tauri/src/frame_ws.rs +++ b/apps/desktop/src-tauri/src/frame_ws.rs @@ -1,8 +1,7 @@ -use std::sync::Arc; - -use flume::Receiver; +use tokio::sync::{broadcast, watch}; use tokio_util::sync::CancellationToken; +#[derive(Clone)] pub struct WSFrame { pub data: Vec, pub width: u32, @@ -10,7 +9,9 @@ pub struct WSFrame { pub stride: u32, } -pub async fn create_frame_ws(frame_rx: Receiver) -> (u16, CancellationToken) { +pub async fn create_watch_frame_ws( + frame_rx: watch::Receiver>, +) -> (u16, CancellationToken) { use axum::{ extract::{ State, @@ -19,9 +20,8 @@ pub async fn create_frame_ws(frame_rx: Receiver) -> (u16, CancellationT response::IntoResponse, routing::get, }; - use tokio::sync::Mutex; - type RouterState = Arc>>; + type RouterState = watch::Receiver>; #[axum::debug_handler] async fn ws_handler( @@ -31,19 +31,136 @@ pub async fn create_frame_ws(frame_rx: Receiver) -> (u16, CancellationT ws.on_upgrade(move |socket| handle_socket(socket, state)) } - async fn handle_socket(mut socket: WebSocket, state: RouterState) { - let camera_rx = state.lock().await; + async fn handle_socket(mut socket: WebSocket, mut camera_rx: RouterState) { println!("socket connection established"); tracing::info!("Socket connection established"); let now = std::time::Instant::now(); + // Send the current frame immediately upon connection (if one exists) + // This ensures the client doesn't wait for the next config change to see the image + { + let frame_opt = camera_rx.borrow().clone(); + if let Some(mut frame) = frame_opt { + frame.data.extend_from_slice(&frame.stride.to_le_bytes()); + frame.data.extend_from_slice(&frame.height.to_le_bytes()); + frame.data.extend_from_slice(&frame.width.to_le_bytes()); + + if let Err(e) = socket.send(Message::Binary(frame.data)).await { + tracing::error!("Failed to send initial frame to socket: {:?}", e); + return; + } + } + } + loop { tokio::select! { - _ = socket.recv() => { - tracing::info!("Received message from socket"); - break; + msg = socket.recv() => { + match msg { + Some(Ok(Message::Close(_))) | None => { + tracing::info!("WebSocket closed"); + break; + } + Some(Ok(_)) => { + tracing::info!("Received message from socket (ignoring)"); + } + Some(Err(e)) => { + tracing::error!("WebSocket error: {:?}", e); + break; + } + } }, - incoming_frame = camera_rx.recv_async() => { + res = camera_rx.changed() => { + if res.is_err() { + tracing::error!("Camera channel closed"); + break; + } + let frame_opt = camera_rx.borrow().clone(); + if let Some(mut frame) = frame_opt { + frame.data.extend_from_slice(&frame.stride.to_le_bytes()); + frame.data.extend_from_slice(&frame.height.to_le_bytes()); + frame.data.extend_from_slice(&frame.width.to_le_bytes()); + + if let Err(e) = socket.send(Message::Binary(frame.data)).await { + tracing::error!("Failed to send frame to socket: {:?}", e); + break; + } + } + } + } + } + + let elapsed = now.elapsed(); + println!("Websocket closing after {elapsed:.2?}"); + tracing::info!("Websocket closing after {elapsed:.2?}"); + } + + let router = axum::Router::new() + .route("/", get(ws_handler)) + .with_state(frame_rx); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tracing::info!("WebSocket server listening on port {}", port); + + let cancel_token = CancellationToken::new(); + let cancel_token_child = cancel_token.child_token(); + tokio::spawn(async move { + let server = axum::serve(listener, router.into_make_service()); + tokio::select! { + _ = server => {}, + _ = cancel_token.cancelled() => { + println!("WebSocket server shutting down"); + } + } + }); + + (port, cancel_token_child) +} + +pub async fn create_frame_ws(frame_tx: broadcast::Sender) -> (u16, CancellationToken) { + use axum::{ + extract::{ + State, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + response::IntoResponse, + routing::get, + }; + + type RouterState = broadcast::Sender; + + #[axum::debug_handler] + async fn ws_handler( + ws: WebSocketUpgrade, + State(state): State, + ) -> impl IntoResponse { + let rx = state.subscribe(); + ws.on_upgrade(move |socket| handle_socket(socket, rx)) + } + + async fn handle_socket(mut socket: WebSocket, mut camera_rx: broadcast::Receiver) { + println!("socket connection established"); + tracing::info!("Socket connection established"); + let now = std::time::Instant::now(); + + loop { + tokio::select! { + msg = socket.recv() => { + match msg { + Some(Ok(Message::Close(_))) | None => { + tracing::info!("WebSocket closed"); + break; + } + Some(Ok(_)) => { + tracing::info!("Received message from socket (ignoring)"); + } + Some(Err(e)) => { + tracing::error!("WebSocket error: {:?}", e); + break; + } + } + }, + incoming_frame = camera_rx.recv() => { match incoming_frame { Ok(mut frame) => { frame.data.extend_from_slice(&frame.stride.to_le_bytes()); @@ -55,13 +172,16 @@ pub async fn create_frame_ws(frame_rx: Receiver) -> (u16, CancellationT break; } } - Err(e) => { + Err(broadcast::error::RecvError::Closed) => { tracing::error!( - "Connection has been lost! Shutting down websocket server: {:?}", - e + "Connection has been lost! Shutting down websocket server" ); break; } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + tracing::warn!("Missed {skipped} frames on websocket receiver"); + continue; + } } } } @@ -74,7 +194,7 @@ pub async fn create_frame_ws(frame_rx: Receiver) -> (u16, CancellationT let router = axum::Router::new() .route("/", get(ws_handler)) - .with_state(Arc::new(Mutex::new(frame_rx))); + .with_state(frame_tx); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); diff --git a/apps/desktop/src-tauri/src/gpu_context.rs b/apps/desktop/src-tauri/src/gpu_context.rs new file mode 100644 index 0000000000..28372a58c2 --- /dev/null +++ b/apps/desktop/src-tauri/src/gpu_context.rs @@ -0,0 +1,85 @@ +use std::{ + collections::HashMap, + sync::{Arc, RwLock}, + time::Instant, +}; +use tokio::sync::OnceCell; + +#[derive(Clone)] +pub struct PendingScreenshot { + pub data: Vec, + pub width: u32, + pub height: u32, + pub created_at: Instant, +} + +pub struct PendingScreenshots(pub Arc>>); + +impl Default for PendingScreenshots { + fn default() -> Self { + Self(Arc::new(RwLock::new(HashMap::new()))) + } +} + +impl PendingScreenshots { + pub fn insert(&self, key: String, screenshot: PendingScreenshot) { + let mut guard = self.0.write().unwrap(); + guard.retain(|_, v| v.created_at.elapsed() < std::time::Duration::from_secs(10)); + guard.insert(key, screenshot); + } + + pub fn remove(&self, key: &str) -> Option { + self.0.write().unwrap().remove(key) + } + + pub fn get(&self, key: &str) -> Option { + self.0.read().unwrap().get(key).cloned() + } +} + +pub struct SharedGpuContext { + pub device: Arc, + pub queue: Arc, + pub adapter: Arc, + pub instance: Arc, +} + +static GPU: OnceCell> = OnceCell::const_new(); + +pub async fn get_shared_gpu() -> Option<&'static SharedGpuContext> { + GPU.get_or_init(|| async { + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default()); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: None, + }) + .await + .ok()?; + + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("cap-shared-gpu-device"), + required_features: wgpu::Features::empty(), + ..Default::default() + }) + .await + .ok()?; + + Some(SharedGpuContext { + device: Arc::new(device), + queue: Arc::new(queue), + adapter: Arc::new(adapter), + instance: Arc::new(instance), + }) + }) + .await + .as_ref() +} + +pub fn prewarm_gpu() { + tokio::spawn(async { + get_shared_gpu().await; + }); +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e3c3f1ffa0..4e776fe3b1 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -22,6 +22,7 @@ mod posthog; mod presets; mod recording; mod recording_settings; +mod screenshot_editor; mod target_select_overlay; mod thumbnails; mod tray; @@ -58,6 +59,12 @@ use kameo::{Actor, actor::ActorRef}; use notifications::NotificationType; use recording::{InProgressRecording, RecordingEvent, RecordingInputKind}; use scap_targets::{Display, DisplayId, WindowId, bounds::LogicalBounds}; +use screenshot_editor::{ + ScreenshotEditorInstances, create_screenshot_editor_instance, update_screenshot_config, +}; + +mod gpu_context; +pub use gpu_context::{PendingScreenshot, PendingScreenshots}; use serde::{Deserialize, Serialize}; use serde_json::json; use specta::Type; @@ -84,7 +91,9 @@ use tracing::*; use upload::{create_or_get_video, upload_image, upload_video}; use web_api::AuthedApiError; use web_api::ManagerExt as WebManagerExt; -use windows::{CapWindowId, EditorWindowIds, ShowCapWindow, set_window_transparent}; +use windows::{ + CapWindowId, EditorWindowIds, ScreenshotEditorWindowIds, ShowCapWindow, set_window_transparent, +}; use crate::{ camera::CameraPreviewManager, @@ -115,6 +124,7 @@ pub struct App { selected_mic_label: Option, selected_camera_id: Option, camera_in_use: bool, + camera_cleanup_done: bool, camera_feed: ActorRef, server_url: String, logs_dir: PathBuf, @@ -503,6 +513,9 @@ async fn set_camera_input( let app = &mut *state.write().await; app.selected_camera_id = id; app.camera_in_use = app.selected_camera_id.is_some(); + if app.camera_in_use { + app.camera_cleanup_done = false; + } let cleared = app.disconnected_inputs.remove(&RecordingInputKind::Camera); if cleared { @@ -557,6 +570,23 @@ fn spawn_device_watchers(app_handle: AppHandle) { spawn_camera_watcher(app_handle); } +async fn cleanup_camera_window(app: AppHandle) { + let state = app.state::>(); + let mut app_state = state.write().await; + + if app_state.camera_cleanup_done { + return; + } + + app_state.camera_cleanup_done = true; + app_state.camera_preview.on_window_close(); + + if !app_state.is_recording_active_or_pending() { + let _ = app_state.camera_feed.ask(feeds::camera::RemoveInput).await; + app_state.camera_in_use = false; + } +} + fn spawn_microphone_watcher(app_handle: AppHandle) { tokio::spawn(async move { let state = app_handle.state::>(); @@ -1107,6 +1137,25 @@ async fn copy_screenshot_to_clipboard( Ok(()) } +#[tauri::command] +#[specta::specta] +#[instrument(skip(clipboard, data))] +async fn copy_image_to_clipboard( + clipboard: MutableState<'_, ClipboardContext>, + data: Vec, +) -> Result<(), String> { + println!("Copying image to clipboard ({} bytes)", data.len()); + + let img_data = clipboard_rs::RustImageData::from_bytes(&data) + .map_err(|e| format!("Failed to create image data from bytes: {e}"))?; + clipboard + .write() + .await + .set_image(img_data) + .map_err(|err| format!("Failed to copy image to clipboard: {err}"))?; + Ok(()) +} + #[tauri::command] #[specta::specta] #[instrument(skip(_app))] @@ -2223,6 +2272,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { recording::resume_recording, recording::restart_recording, recording::delete_recording, + recording::take_screenshot, recording::list_cameras, recording::list_capture_windows, recording::list_capture_displays, @@ -2241,6 +2291,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { copy_file_to_path, copy_video_to_clipboard, copy_screenshot_to_clipboard, + copy_image_to_clipboard, open_file_path, get_video_metadata, create_editor_instance, @@ -2256,6 +2307,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { permissions::request_permission, upload_exported_video, upload_screenshot, + create_screenshot_editor_instance, + update_screenshot_config, get_recording_meta, save_file_dialog, list_recordings, @@ -2431,6 +2484,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { ]) .map_label(|label| match label { label if label.starts_with("editor-") => "editor", + label if label.starts_with("screenshot-editor-") => "screenshot-editor", label if label.starts_with("window-capture-occluder-") => { "window-capture-occluder" } @@ -2448,10 +2502,14 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { fake_window::init(&app); app.manage(target_select_overlay::WindowFocusManager::default()); app.manage(EditorWindowIds::default()); + app.manage(ScreenshotEditorWindowIds::default()); #[cfg(target_os = "macos")] app.manage(crate::platform::ScreenCapturePrewarmer::default()); app.manage(http_client::HttpClient::default()); app.manage(http_client::RetryableHttpClient::default()); + app.manage(PendingScreenshots::default()); + + gpu_context::prewarm_gpu(); tokio::spawn({ let camera_feed = camera_feed.clone(); @@ -2533,6 +2591,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { selected_mic_label: None, selected_camera_id: None, camera_in_use: false, + camera_cleanup_done: false, camera_feed, server_url, logs_dir: logs_dir.clone(), @@ -2639,6 +2698,11 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { let app = window.app_handle(); match event { + WindowEvent::CloseRequested { .. } => { + if let Ok(CapWindowId::Camera) = CapWindowId::from_str(label) { + tokio::spawn(cleanup_camera_window(app.clone())); + } + } WindowEvent::Destroyed => { if let Ok(window_id) = CapWindowId::from_str(label) { match window_id { @@ -2682,6 +2746,18 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { reopen_main_window(&app); } } + CapWindowId::ScreenshotEditor { id } => { + let window_ids = + ScreenshotEditorWindowIds::get(window.app_handle()); + window_ids.ids.lock().unwrap().retain(|(_, _id)| *_id != id); + + tokio::spawn(ScreenshotEditorInstances::remove(window.clone())); + + #[cfg(target_os = "windows")] + if CapWindowId::Settings.get(&app).is_none() { + reopen_main_window(&app); + } + } CapWindowId::Settings => { for (label, window) in app.webview_windows() { if let Ok(id) = CapWindowId::from_str(&label) @@ -2723,21 +2799,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .destroy(&display_id, app.global_shortcut()); } CapWindowId::Camera => { - let app = app.clone(); - tokio::spawn(async move { - let state = app.state::>(); - let mut app_state = state.write().await; - - app_state.camera_preview.on_window_close(); - - if !app_state.is_recording_active_or_pending() { - let _ = app_state - .camera_feed - .ask(feeds::camera::RemoveInput) - .await; - app_state.camera_in_use = false; - } - }); + tokio::spawn(cleanup_camera_window(app.clone())); } _ => {} }; @@ -2792,6 +2854,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { tauri::RunEvent::Reopen { .. } => { let has_window = _handle.webview_windows().iter().any(|(label, _)| { label.starts_with("editor-") + || label.starts_with("screenshot-editor-") || label.as_str() == "settings" || label.as_str() == "signin" }); @@ -2802,6 +2865,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .iter() .find(|(label, _)| { label.starts_with("editor-") + || label.starts_with("screenshot-editor-") || label.as_str() == "settings" || label.as_str() == "signin" }) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 1c821f87b2..0fc5b6cb73 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -441,6 +441,7 @@ pub async fn start_recording( } } RecordingMode::Studio => None, + RecordingMode::Screenshot => return Err("Use take_screenshot for screenshots".to_string()), }; let date_time = if cfg!(windows) { @@ -467,6 +468,9 @@ pub async fn start_recording( RecordingMode::Instant => { RecordingMetaInner::Instant(InstantRecordingMeta::InProgress { recording: true }) } + RecordingMode::Screenshot => { + return Err("Use take_screenshot for screenshots".to_string()); + } }, sharing: None, upload: None, @@ -725,6 +729,9 @@ pub async fn start_recording( camera_feed: camera_feed.clone(), }) } + RecordingMode::Screenshot => Err(anyhow!( + "Screenshot mode should be handled via take_screenshot" + )), } } .await; @@ -1014,6 +1021,158 @@ pub async fn delete_recording(app: AppHandle, state: MutableState<'_, App>) -> R Ok(()) } +#[tauri::command(async)] +#[specta::specta] +#[tracing::instrument(name = "take_screenshot", skip(app))] +pub async fn take_screenshot( + app: AppHandle, + target: ScreenCaptureTarget, +) -> Result { + use crate::NewScreenshotAdded; + use crate::notifications; + use crate::{PendingScreenshot, PendingScreenshots}; + use cap_recording::screenshot::capture_screenshot; + use image::ImageEncoder; + use std::time::Instant; + + let image = capture_screenshot(target) + .await + .map_err(|e| format!("Failed to capture screenshot: {e}"))?; + + let image_width = image.width(); + let image_height = image.height(); + let image_data = image.into_raw(); + + let screenshots_dir = app.path().app_data_dir().unwrap().join("screenshots"); + + std::fs::create_dir_all(&screenshots_dir).map_err(|e| e.to_string())?; + + let date_time = if cfg!(windows) { + chrono::Local::now().format("%Y-%m-%d %H.%M.%S") + } else { + chrono::Local::now().format("%Y-%m-%d %H:%M:%S") + }; + + let id = uuid::Uuid::new_v4().to_string(); + let cap_dir = screenshots_dir.join(format!("{id}.cap")); + std::fs::create_dir_all(&cap_dir).map_err(|e| e.to_string())?; + + let image_filename = "original.png"; + let image_path = cap_dir.join(image_filename); + let cap_dir_key = cap_dir.to_string_lossy().to_string(); + + let pending_screenshots = app.state::(); + pending_screenshots.insert( + cap_dir_key.clone(), + PendingScreenshot { + data: image_data.clone(), + width: image_width, + height: image_height, + created_at: Instant::now(), + }, + ); + + let relative_path = relative_path::RelativePathBuf::from(image_filename); + + let video_meta = cap_project::VideoMeta { + path: relative_path, + fps: 0, + start_time: Some(0.0), + }; + + let segment = cap_project::SingleSegment { + display: video_meta, + camera: None, + audio: None, + cursor: None, + }; + + let meta = cap_project::RecordingMeta { + platform: Some(Platform::default()), + project_path: cap_dir.clone(), + pretty_name: format!("Screenshot {}", date_time), + sharing: None, + inner: cap_project::RecordingMetaInner::Studio( + cap_project::StudioRecordingMeta::SingleSegment { segment }, + ), + upload: None, + }; + + meta.save_for_project() + .map_err(|e| format!("Failed to save recording meta: {e}"))?; + + cap_project::ProjectConfiguration::default() + .write(&cap_dir) + .map_err(|e| format!("Failed to save project config: {e}"))?; + + let is_large_capture = (image_width as u64).saturating_mul(image_height as u64) > 8_000_000; + let compression = if is_large_capture { + image::codecs::png::CompressionType::Fast + } else { + image::codecs::png::CompressionType::Default + }; + let image_path_for_emit = image_path.clone(); + let image_path_for_write = image_path.clone(); + let app_handle = app.clone(); + let pending_state = PendingScreenshots(pending_screenshots.0.clone()); + + tauri::async_runtime::spawn(async move { + let encode_result = tokio::task::spawn_blocking(move || -> Result<(), String> { + let file = std::fs::File::create(&image_path_for_write) + .map_err(|e| format!("Failed to create screenshot file: {e}"))?; + let encoder = image::codecs::png::PngEncoder::new_with_quality( + std::io::BufWriter::new(file), + compression, + image::codecs::png::FilterType::Adaptive, + ); + + ImageEncoder::write_image( + encoder, + &image_data, + image_width, + image_height, + image::ColorType::Rgb8.into(), + ) + .map_err(|e| format!("Failed to encode PNG: {e}")) + }) + .await; + + pending_state.remove(&cap_dir_key); + + match encode_result { + Ok(Ok(())) => { + let _ = NewScreenshotAdded { + path: image_path_for_emit.clone(), + } + .emit(&app_handle); + + notifications::send_notification( + &app_handle, + notifications::NotificationType::ScreenshotSaved, + ); + + AppSounds::StopRecording.play(); + } + Ok(Err(e)) => { + error!("Failed to encode PNG: {e}"); + notifications::send_notification( + &app_handle, + notifications::NotificationType::ScreenshotSaveFailed, + ); + } + Err(e) => { + error!("Failed to join screenshot encoding task: {e}"); + notifications::send_notification( + &app_handle, + notifications::NotificationType::ScreenshotSaveFailed, + ); + } + } + }); + + Ok(image_path) +} + // runs when a recording ends, whether from success or failure async fn handle_recording_end( handle: AppHandle, diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs new file mode 100644 index 0000000000..591b17a34c --- /dev/null +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -0,0 +1,443 @@ +use crate::PendingScreenshots; +use crate::frame_ws::{WSFrame, create_watch_frame_ws}; +use crate::gpu_context; +use crate::windows::{CapWindowId, ScreenshotEditorWindowIds}; +use cap_project::{ + ProjectConfiguration, RecordingMeta, RecordingMetaInner, SingleSegment, StudioRecordingMeta, + VideoMeta, +}; +use cap_rendering::{ + DecodedFrame, DecodedSegmentFrames, FrameRenderer, ProjectUniforms, RenderVideoConstants, + RendererLayers, +}; +use image::{GenericImageView, RgbImage, buffer::ConvertBuffer}; +use relative_path::RelativePathBuf; +use serde::Serialize; +use specta::Type; +use std::str::FromStr; +use std::{collections::HashMap, ops::Deref, path::PathBuf, sync::Arc}; +use tauri::{ + Manager, Runtime, Window, + ipc::{CommandArg, InvokeError}, +}; +use tokio::sync::{RwLock, watch}; +use tokio_util::sync::CancellationToken; + +const MAX_DIMENSION: u32 = 16_384; + +pub struct ScreenshotEditorInstance { + pub ws_port: u16, + pub ws_shutdown_token: CancellationToken, + pub config_tx: watch::Sender, + pub path: PathBuf, +} + +impl ScreenshotEditorInstance { + pub async fn dispose(&self) { + self.ws_shutdown_token.cancel(); + } +} + +#[derive(Clone)] +pub struct ScreenshotEditorInstances(Arc>>>); + +pub struct WindowScreenshotEditorInstance(pub Arc); + +impl specta::function::FunctionArg for WindowScreenshotEditorInstance { + fn to_datatype(_: &mut specta::TypeMap) -> Option { + None + } +} + +impl Deref for WindowScreenshotEditorInstance { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'de, R: Runtime> CommandArg<'de, R> for WindowScreenshotEditorInstance { + fn from_command(command: tauri::ipc::CommandItem<'de, R>) -> Result { + let window = Window::from_command(command)?; + + let instances = window.state::(); + let instance = futures::executor::block_on(instances.0.read()); + + if let Some(instance) = instance.get(window.label()).cloned() { + Ok(Self(instance)) + } else { + Err(InvokeError::from(format!( + "no ScreenshotEditor instance for window '{}'", + window.label(), + ))) + } + } +} + +impl ScreenshotEditorInstances { + pub async fn get_or_create( + window: &Window, + path: PathBuf, + ) -> Result, String> { + let instances = match window.try_state::() { + Some(s) => (*s).clone(), + None => { + let instances = Self(Arc::new(RwLock::new(HashMap::new()))); + window.manage(instances.clone()); + instances + } + }; + + let mut instances = instances.0.write().await; + + use std::collections::hash_map::Entry; + + match instances.entry(window.label().to_string()) { + Entry::Vacant(entry) => { + let (frame_tx, frame_rx) = watch::channel(None); + let (ws_port, ws_shutdown_token) = create_watch_frame_ws(frame_rx).await; + + let (data, width, height) = { + let key = path + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let pending = window.try_state::(); + let pending_frame = pending.and_then(|p| p.remove(&key)); + + if let Some(frame) = pending_frame { + let width = frame.width; + let height = frame.height; + + if width > MAX_DIMENSION || height > MAX_DIMENSION { + return Err(format!( + "Image dimensions exceed maximum: {width}x{height}" + )); + } + + let expected_len = width + .checked_mul(height) + .and_then(|p| p.checked_mul(3)) + .ok_or_else(|| { + format!("Image dimensions overflow: {width}x{height}") + })?; + let expected_len = usize::try_from(expected_len) + .map_err(|_| format!("Image size too large: {width}x{height}"))?; + + let data = frame.data; + + if data.len() != expected_len { + return Err(format!( + "Image data length mismatch: expected {expected_len} bytes for {width}x{height} frame, got {}", + data.len() + )); + } + + let rgb_img = RgbImage::from_raw(width, height, data).ok_or_else(|| { + format!("Invalid RGB data for {width}x{height} frame") + })?; + let rgba_img: image::RgbaImage = rgb_img.convert(); + (rgba_img.into_raw(), width, height) + } else { + let img = + image::open(&path).map_err(|e| format!("Failed to open image: {e}"))?; + let (w, h) = img.dimensions(); + + if w > MAX_DIMENSION || h > MAX_DIMENSION { + return Err(format!("Image dimensions exceed maximum: {w}x{h}")); + } + + w.checked_mul(h) + .and_then(|p| p.checked_mul(4)) + .ok_or_else(|| format!("Image dimensions overflow: {w}x{h}"))?; + + (img.to_rgba8().into_raw(), w, h) + } + }; + + // Try to load existing meta if in a .cap directory + let (recording_meta, loaded_config) = if let Some(parent) = path.parent() { + if parent.extension().and_then(|s| s.to_str()) == Some("cap") { + let meta = RecordingMeta::load_for_project(parent).ok(); + let config = ProjectConfiguration::load(parent).ok(); + (meta, config) + } else { + (None, None) + } + } else { + (None, None) + }; + + let recording_meta = if let Some(meta) = recording_meta { + meta + } else { + // Create dummy meta + let filename = path + .file_name() + .ok_or_else(|| "Invalid path".to_string())? + .to_string_lossy(); + let relative_path = RelativePathBuf::from(filename.as_ref()); + let video_meta = VideoMeta { + path: relative_path.clone(), + fps: 30, + start_time: Some(0.0), + }; + let segment = SingleSegment { + display: video_meta.clone(), + camera: None, + audio: None, + cursor: None, + }; + let studio_meta = StudioRecordingMeta::SingleSegment { segment }; + RecordingMeta { + platform: None, + project_path: path.parent().unwrap().to_path_buf(), + pretty_name: "Screenshot".to_string(), + sharing: None, + inner: RecordingMetaInner::Studio(studio_meta.clone()), + upload: None, + } + }; + + let (instance, adapter, device, queue) = + if let Some(shared) = gpu_context::get_shared_gpu().await { + ( + shared.instance.clone(), + shared.adapter.clone(), + shared.device.clone(), + shared.queue.clone(), + ) + } else { + let instance = + Arc::new(wgpu::Instance::new(&wgpu::InstanceDescriptor::default())); + let adapter = Arc::new( + instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: None, + }) + .await + .map_err(|_| "No GPU adapter found".to_string())?, + ); + + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("cap-rendering-device"), + required_features: wgpu::Features::empty(), + ..Default::default() + }) + .await + .map_err(|e| e.to_string())?; + (instance, adapter, Arc::new(device), Arc::new(queue)) + }; + + let options = cap_rendering::RenderOptions { + screen_size: cap_project::XY::new(width, height), + camera_size: None, + }; + + // We need to extract the studio meta from the recording meta + let studio_meta = match &recording_meta.inner { + RecordingMetaInner::Studio(meta) => meta.clone(), + _ => return Err("Invalid recording meta for screenshot".to_string()), + }; + + let constants = RenderVideoConstants { + _instance: (*instance).clone(), + _adapter: (*adapter).clone(), + queue: (*queue).clone(), + device: (*device).clone(), + options, + meta: studio_meta, + recording_meta: recording_meta.clone(), + background_textures: Arc::new(tokio::sync::RwLock::new(HashMap::new())), + }; + + let (config_tx, mut config_rx) = watch::channel(loaded_config.unwrap_or_default()); + + let render_shutdown_token = ws_shutdown_token.clone(); + + let instance = Arc::new(ScreenshotEditorInstance { + ws_port, + ws_shutdown_token, + config_tx, + path: path.clone(), + }); + + // Spawn render loop + let decoded_frame = DecodedFrame::new(data, width, height); + + tokio::spawn(async move { + let mut frame_renderer = FrameRenderer::new(&constants); + let mut layers = RendererLayers::new(&constants.device, &constants.queue); + let shutdown_token = render_shutdown_token; + + // Initial render + let mut current_config = config_rx.borrow().clone(); + + loop { + if shutdown_token.is_cancelled() { + break; + } + let segment_frames = DecodedSegmentFrames { + screen_frame: DecodedFrame::new( + decoded_frame.data().to_vec(), + decoded_frame.width(), + decoded_frame.height(), + ), + camera_frame: None, + segment_time: 0.0, + recording_time: 0.0, + }; + + let (base_w, base_h) = + ProjectUniforms::get_base_size(&constants.options, ¤t_config); + + let uniforms = ProjectUniforms::new( + &constants, + ¤t_config, + 0, + 30, + cap_project::XY::new(base_w, base_h), + &cap_project::CursorEvents::default(), + &segment_frames, + ); + + let rendered_frame = frame_renderer + .render( + segment_frames, + uniforms, + &cap_project::CursorEvents::default(), + &mut layers, + ) + .await; + + match rendered_frame { + Ok(frame) => { + let _ = frame_tx.send(Some(WSFrame { + data: frame.data, + width: frame.width, + height: frame.height, + stride: frame.padded_bytes_per_row, + })); + } + Err(e) => { + eprintln!("Failed to render frame: {e}"); + } + } + + tokio::select! { + res = config_rx.changed() => { + if res.is_err() { + break; + } + current_config = config_rx.borrow().clone(); + } + _ = shutdown_token.cancelled() => { + break; + } + } + } + let _ = frame_tx.send(None); + }); + + entry.insert(instance.clone()); + Ok(instance) + } + Entry::Occupied(entry) => { + let instance = entry.get().clone(); + // Force a re-render for the new client by sending the current config again + let config = instance.config_tx.borrow().clone(); + let _ = instance.config_tx.send(config); + Ok(instance) + } + } + } + + pub async fn remove(window: Window) { + let instances = match window.try_state::() { + Some(s) => (*s).clone(), + None => return, + }; + + let mut instances = instances.0.write().await; + if let Some(instance) = instances.remove(window.label()) { + instance.dispose().await; + } + } +} + +#[derive(Serialize, Type, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SerializedScreenshotEditorInstance { + pub frames_socket_url: String, + pub path: PathBuf, + pub config: Option, +} + +#[tauri::command] +#[specta::specta] +pub async fn create_screenshot_editor_instance( + window: Window, +) -> Result { + let CapWindowId::ScreenshotEditor { id } = + CapWindowId::from_str(window.label()).map_err(|e| e.to_string())? + else { + return Err("Invalid window".to_string()); + }; + + let path = { + let window_ids = ScreenshotEditorWindowIds::get(window.app_handle()); + let window_ids = window_ids.ids.lock().unwrap(); + let Some((path, _)) = window_ids.iter().find(|(_, _id)| *_id == id) else { + return Err("Screenshot editor instance not found".to_string()); + }; + path.clone() + }; + + let instance = ScreenshotEditorInstances::get_or_create(&window, path).await?; + let config = instance.config_tx.borrow().clone(); + + Ok(SerializedScreenshotEditorInstance { + frames_socket_url: format!("ws://localhost:{}", instance.ws_port), + path: instance.path.clone(), + config: Some(config), + }) +} + +#[tauri::command] +#[specta::specta] +pub async fn update_screenshot_config( + instance: WindowScreenshotEditorInstance, + config: ProjectConfiguration, + save: bool, +) -> Result<(), String> { + config.validate().map_err(|error| error.to_string())?; + + let _ = instance.config_tx.send(config.clone()); + + if !save { + return Ok(()); + } + + let Some(parent) = instance.path.parent() else { + return Ok(()); + }; + + if parent.extension().and_then(|s| s.to_str()) == Some("cap") { + let path = parent.to_path_buf(); + if let Err(e) = config.write(&path) { + eprintln!("Failed to save screenshot config: {}", e); + } else { + println!("Saved screenshot config to {:?}", path); + } + } else { + println!( + "Not saving config: parent {:?} is not a .cap directory", + parent + ); + } + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 12cde9fef2..37cc8f10b1 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -49,6 +49,7 @@ pub enum CapWindowId { Upgrade, ModeSelect, Debug, + ScreenshotEditor { id: u32 }, } impl FromStr for CapWindowId { @@ -73,6 +74,12 @@ impl FromStr for CapWindowId { .parse::() .map_err(|e| e.to_string())?, }, + s if s.starts_with("screenshot-editor-") => Self::ScreenshotEditor { + id: s + .replace("screenshot-editor-", "") + .parse::() + .map_err(|e| e.to_string())?, + }, s if s.starts_with("window-capture-occluder-") => Self::WindowCaptureOccluder { screen_id: s .replace("window-capture-occluder-", "") @@ -110,6 +117,7 @@ impl std::fmt::Display for CapWindowId { Self::ModeSelect => write!(f, "mode-select"), Self::Editor { id } => write!(f, "editor-{id}"), Self::Debug => write!(f, "debug"), + Self::ScreenshotEditor { id } => write!(f, "screenshot-editor-{id}"), } } } @@ -127,6 +135,7 @@ impl CapWindowId { Self::CaptureArea => "Cap Capture Area".to_string(), Self::RecordingControls => "Cap Recording Controls".to_string(), Self::Editor { .. } => "Cap Editor".to_string(), + Self::ScreenshotEditor { .. } => "Cap Screenshot Editor".to_string(), Self::ModeSelect => "Cap Mode Selection".to_string(), Self::Camera => "Cap Camera".to_string(), Self::RecordingsOverlay => "Cap Recordings Overlay".to_string(), @@ -140,6 +149,7 @@ impl CapWindowId { Self::Setup | Self::Main | Self::Editor { .. } + | Self::ScreenshotEditor { .. } | Self::Settings | Self::Upgrade | Self::ModeSelect @@ -154,7 +164,9 @@ impl CapWindowId { #[cfg(target_os = "macos")] pub fn traffic_lights_position(&self) -> Option>> { match self { - Self::Editor { .. } => Some(Some(LogicalPosition::new(20.0, 32.0))), + Self::Editor { .. } | Self::ScreenshotEditor { .. } => { + Some(Some(LogicalPosition::new(20.0, 32.0))) + } Self::RecordingControls => Some(Some(LogicalPosition::new(-100.0, -100.0))), Self::Camera | Self::WindowCaptureOccluder { .. } @@ -170,6 +182,7 @@ impl CapWindowId { Self::Setup => (600.0, 600.0), Self::Main => (300.0, 360.0), Self::Editor { .. } => (1275.0, 800.0), + Self::ScreenshotEditor { .. } => (800.0, 600.0), Self::Settings => (600.0, 450.0), Self::Camera => (200.0, 200.0), Self::Upgrade => (950.0, 850.0), @@ -207,6 +220,9 @@ pub enum ShowCapWindow { }, Upgrade, ModeSelect, + ScreenshotEditor { + path: PathBuf, + }, } impl ShowCapWindow { @@ -224,6 +240,19 @@ impl ShowCapWindow { } } + if let Self::ScreenshotEditor { path } = &self { + let state = app.state::(); + let mut s = state.ids.lock().unwrap(); + if !s.iter().any(|(p, _)| p == path) { + s.push(( + path.clone(), + state + .counter + .fetch_add(1, std::sync::atomic::Ordering::SeqCst), + )); + } + } + if let Some(window) = self.id(app).get(app) { window.show().ok(); window.unminimize().ok(); @@ -414,6 +443,17 @@ impl ShowCapWindow { .center() .build()? } + Self::ScreenshotEditor { path: _ } => { + if let Some(main) = CapWindowId::Main.get(app) { + let _ = main.close(); + }; + + self.window_builder(app, "/screenshot-editor") + .maximizable(true) + .inner_size(1240.0, 800.0) + .center() + .build()? + } Self::Upgrade => { // Hide main window when upgrade window opens if let Some(main) = CapWindowId::Main.get(app) { @@ -803,6 +843,12 @@ impl ShowCapWindow { ShowCapWindow::InProgressRecording { .. } => CapWindowId::RecordingControls, ShowCapWindow::Upgrade => CapWindowId::Upgrade, ShowCapWindow::ModeSelect => CapWindowId::ModeSelect, + ShowCapWindow::ScreenshotEditor { path } => { + let state = app.state::(); + let s = state.ids.lock().unwrap(); + let id = s.iter().find(|(p, _)| p == path).unwrap().1; + CapWindowId::ScreenshotEditor { id } + } } } } @@ -1008,3 +1054,15 @@ impl EditorWindowIds { app.state::().deref().clone() } } + +#[derive(Default, Clone)] +pub struct ScreenshotEditorWindowIds { + pub ids: Arc>>, + pub counter: Arc, +} + +impl ScreenshotEditorWindowIds { + pub fn get(app: &AppHandle) -> Self { + app.state::().deref().clone() + } +} diff --git a/apps/desktop/src/components/Mode.tsx b/apps/desktop/src/components/Mode.tsx index 17a2494f11..37acdc2590 100644 --- a/apps/desktop/src/components/Mode.tsx +++ b/apps/desktop/src/components/Mode.tsx @@ -63,6 +63,28 @@ const Mode = () => { )} + {!isInfoHovered() && ( + +
{ + setOptions({ mode: "screenshot" }); + }} + class={`flex justify-center items-center transition-all duration-200 rounded-full size-7 hover:cursor-pointer ${ + rawOptions.mode === "screenshot" + ? "ring-2 ring-offset-1 ring-offset-gray-1 bg-gray-7 hover:bg-gray-7 ring-blue-500" + : "bg-gray-3 hover:bg-gray-7" + }`} + > + +
+
+ )} + {isInfoHovered() && ( <>
{ >
+ +
{ + setOptions({ mode: "screenshot" }); + }} + class={`flex justify-center items-center transition-all duration-200 rounded-full size-7 hover:cursor-pointer ${ + rawOptions.mode === "screenshot" + ? "ring-2 ring-offset-1 ring-offset-gray-1 bg-gray-5 hover:bg-gray-7 ring-blue-10" + : "bg-gray-3 hover:bg-gray-7" + }`} + > + +
)} diff --git a/apps/desktop/src/components/ModeSelect.tsx b/apps/desktop/src/components/ModeSelect.tsx index 5c95359016..61f1d17a6f 100644 --- a/apps/desktop/src/components/ModeSelect.tsx +++ b/apps/desktop/src/components/ModeSelect.tsx @@ -68,13 +68,20 @@ const ModeSelect = (props: { onClose?: () => void; standalone?: boolean }) => { "Records at the highest quality and framerate, completely locally. Captures both your screen and camera separately for editing and exporting later.", icon: IconCapFilmCut, }, + { + mode: "screenshot" as const, + title: "Screenshot Mode", + description: + "Capture high-quality screenshots of your screen or specific windows. Annotate and share instantly.", + icon: IconCapScreenshot, + }, ]; return (
{ +interface Props extends ComponentProps { content?: JSX.Element; childClass?: string; kbd?: string[]; @@ -23,8 +23,8 @@ const kbdSymbolModifier = (key: string, os: Os) => { export default function Tooltip(props: Props) { const os = ostype(); return ( - - + + {props.children} @@ -42,6 +42,6 @@ export default function Tooltip(props: Props) { - + ); } diff --git a/apps/desktop/src/entry-server.tsx b/apps/desktop/src/entry-server.tsx index 2d450c0233..f9f4ae475f 100644 --- a/apps/desktop/src/entry-server.tsx +++ b/apps/desktop/src/entry-server.tsx @@ -9,6 +9,13 @@ export default createHandler(() => ( +