diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77afebc97f..f3e5bec0bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,9 +103,8 @@ jobs: settings: - target: aarch64-apple-darwin runner: macos-latest - # Windows can't take the disk usage lol - # - target: x86_64-pc-windows-msvc - # runner: windows-latest + - target: x86_64-pc-windows-msvc + runner: windows-latest runs-on: ${{ matrix.settings.runner }} permissions: contents: read diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 9f54332817..d6bb1b45df 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] + "recommendations": ["biomejs.biome", "rust-lang.rust-analyzer"] } diff --git a/AGENTS.md b/AGENTS.md index f09db99711..c73653976b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,26 @@ - Runtime: Node 20, pnpm 10.x, Rust 1.88+, Docker for MySQL/MinIO. - **NO COMMENTS**: Never add comments to code (`//`, `/* */`, `///`, `//!`, `#`, etc.). Code must be self-explanatory through naming, types, and structure. This applies to all languages (TypeScript, Rust, JavaScript, etc.). +## Rust Clippy Rules (Workspace Lints) +All Rust code must respect these workspace-level lints defined in `Cargo.toml`: + +**Rust compiler lints:** +- `unused_must_use = "deny"` — Always handle `Result`/`Option` or types marked `#[must_use]`; never ignore them. + +**Clippy lints (all denied):** +- `dbg_macro` — Never use `dbg!()` in code; use proper logging instead. +- `let_underscore_future` — Never write `let _ = async_fn()` which silently drops futures; await or explicitly handle them. +- `unchecked_duration_subtraction` — Use `saturating_sub` instead of `-` for `Duration` to avoid panics. +- `collapsible_if` — Merge nested `if` statements: use `if a && b { }` instead of `if a { if b { } }`. +- `clone_on_copy` — Don't call `.clone()` on `Copy` types; just copy them directly. +- `redundant_closure` — Use function references directly: `iter.map(foo)` instead of `iter.map(|x| foo(x))`. +- `ptr_arg` — Accept `&[T]` or `&str` instead of `&Vec` or `&String` in function parameters. +- `len_zero` — Use `.is_empty()` instead of `.len() == 0` or `.len() > 0`. +- `let_unit_value` — Don't assign `()` to a variable: write `foo();` instead of `let _ = foo();` when return is unit. +- `unnecessary_lazy_evaluations` — Use `.unwrap_or(val)` instead of `.unwrap_or_else(|| val)` for cheap values. +- `needless_range_loop` — Use `for item in &collection` instead of `for i in 0..collection.len()` when index isn't needed. +- `manual_clamp` — Use `.clamp(min, max)` instead of manual `if` chains or `.min().max()` patterns. + ## Testing - TS/JS: Vitest where present (e.g., desktop). Name tests `*.test.ts(x)` near sources. - Rust: `cargo test` per crate; tests in `src` or `tests`. diff --git a/CLAUDE.md b/CLAUDE.md index ee2f0b29f0..c6279ee04b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -371,6 +371,66 @@ Minimize `useEffect` usage: compute during render, handle logic in event handler - Strict TypeScript; avoid `any`; leverage shared types - Use Biome for linting/formatting; match existing formatting +## Rust Clippy Rules (Workspace Lints) +All Rust code must respect these workspace-level lints defined in `Cargo.toml`. Violating any of these will fail CI: + +**Rust compiler lints:** +- `unused_must_use = "deny"` — Always handle `Result`/`Option` or types marked `#[must_use]`; never ignore them. + +**Clippy lints (all denied — code MUST NOT contain these patterns):** +- `dbg_macro` — Never use `dbg!()` in code; use proper logging (`tracing::debug!`, etc.) instead. +- `let_underscore_future` — Never write `let _ = async_fn()` which silently drops futures; await or explicitly handle them. +- `unchecked_duration_subtraction` — Use `duration.saturating_sub(other)` instead of `duration - other` to avoid panics on underflow. +- `collapsible_if` — Merge nested `if` statements: write `if a && b { }` instead of `if a { if b { } }`. +- `clone_on_copy` — Don't call `.clone()` on `Copy` types (integers, bools, etc.); just copy them directly. +- `redundant_closure` — Use function references directly: `iter.map(foo)` instead of `iter.map(|x| foo(x))`. +- `ptr_arg` — Accept `&[T]` or `&str` instead of `&Vec` or `&String` in function parameters for flexibility. +- `len_zero` — Use `.is_empty()` instead of `.len() == 0` or `.len() > 0` / `.len() != 0`. +- `let_unit_value` — Don't assign `()` to a variable: write `foo();` instead of `let _ = foo();` or `let x = foo();` when return is unit. +- `unnecessary_lazy_evaluations` — Use `.unwrap_or(val)` instead of `.unwrap_or_else(|| val)` when the default is a simple/cheap value. +- `needless_range_loop` — Use `for item in &collection` or `for (i, item) in collection.iter().enumerate()` instead of `for i in 0..collection.len()`. +- `manual_clamp` — Use `value.clamp(min, max)` instead of manual `if` chains or `.min(max).max(min)` patterns. + +**Examples of violations to avoid:** + +```rust +dbg!(value); +let _ = some_async_function(); +let duration = duration_a - duration_b; +if condition { + if other_condition { + do_something(); + } +} +let x = 5.clone(); +vec.iter().map(|x| process(x)) +fn example(v: &Vec) { } +if vec.len() == 0 { } +let _ = returns_unit(); +option.unwrap_or_else(|| 42) +for i in 0..vec.len() { println!("{}", vec[i]); } +value.min(max).max(min) +``` + +**Correct alternatives:** + +```rust +tracing::debug!(?value); +some_async_function().await; +let duration = duration_a.saturating_sub(duration_b); +if condition && other_condition { + do_something(); +} +let x = 5; +vec.iter().map(process) +fn example(v: &[i32]) { } +if vec.is_empty() { } +returns_unit(); +option.unwrap_or(42) +for item in &vec { println!("{}", item); } +value.clamp(min, max) +``` + ## Security & Privacy Considerations ### Data Handling diff --git a/apps/desktop/src-tauri/src/audio_meter.rs b/apps/desktop/src-tauri/src/audio_meter.rs index e7cd067dea..c383f37513 100644 --- a/apps/desktop/src-tauri/src/audio_meter.rs +++ b/apps/desktop/src-tauri/src/audio_meter.rs @@ -140,6 +140,6 @@ fn samples_to_f64(samples: &MicrophoneSamples) -> impl Iterator + us SampleFormat::F64 => f64::from_ne_bytes([ data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7], ]), - _ => todo!(), + _ => 0.0, }) } diff --git a/apps/desktop/src-tauri/src/camera.rs b/apps/desktop/src-tauri/src/camera.rs index 6cb00db296..b1766d58e2 100644 --- a/apps/desktop/src-tauri/src/camera.rs +++ b/apps/desktop/src-tauri/src/camera.rs @@ -43,9 +43,9 @@ pub enum CameraPreviewShape { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] pub struct CameraPreviewState { - size: f32, - shape: CameraPreviewShape, - mirrored: bool, + pub size: f32, + pub shape: CameraPreviewShape, + pub mirrored: bool, } impl Default for CameraPreviewState { diff --git a/apps/desktop/src-tauri/src/captions.rs b/apps/desktop/src-tauri/src/captions.rs index a0224c4ea0..80a135e5b8 100644 --- a/apps/desktop/src-tauri/src/captions.rs +++ b/apps/desktop/src-tauri/src/captions.rs @@ -115,30 +115,28 @@ async fn extract_audio_from_video(video_path: &str, output_path: &PathBuf) -> Re if mixed_samples.is_empty() { mixed_samples = audio.samples().to_vec(); channel_count = audio.channels() as usize; - } else { - if audio.channels() as usize != channel_count { - log::info!( - "Channel count mismatch: {} vs {}, mixing to mono", - channel_count, - audio.channels() - ); - - if channel_count > 1 { - let mono_samples = convert_to_mono(&mixed_samples, channel_count); - mixed_samples = mono_samples; - channel_count = 1; - } + } else if audio.channels() as usize != channel_count { + log::info!( + "Channel count mismatch: {} vs {}, mixing to mono", + channel_count, + audio.channels() + ); - let samples = if audio.channels() > 1 { - convert_to_mono(audio.samples(), audio.channels() as usize) - } else { - audio.samples().to_vec() - }; + if channel_count > 1 { + let mono_samples = convert_to_mono(&mixed_samples, channel_count); + mixed_samples = mono_samples; + channel_count = 1; + } - mix_samples(&mut mixed_samples, &samples); + let samples = if audio.channels() > 1 { + convert_to_mono(audio.samples(), audio.channels() as usize) } else { - mix_samples(&mut mixed_samples, audio.samples()); - } + audio.samples().to_vec() + }; + + mix_samples(&mut mixed_samples, &samples); + } else { + mix_samples(&mut mixed_samples, audio.samples()); } } Err(e) => { @@ -1012,13 +1010,11 @@ fn start_whisperx_server( std::thread::spawn(move || { use std::io::BufRead; let reader = std::io::BufReader::new(stderr); - for line in reader.lines() { - if let Ok(line) = line { - if line.starts_with("STDERR:") { - log::info!("[WhisperX] {}", &line[7..]); - } else { - log::info!("[WhisperX stderr] {}", line); - } + for line in reader.lines().flatten() { + if let Some(stripped) = line.strip_prefix("STDERR:") { + log::info!("[WhisperX] {}", stripped); + } else { + log::info!("[WhisperX stderr] {}", line); } } }); diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index a342d17769..b9226028c2 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -1,6 +1,6 @@ use crate::{FramesRendered, get_video_metadata}; use cap_export::ExporterBase; -use cap_project::{RecordingMeta, XY}; +use cap_project::RecordingMeta; use serde::Deserialize; use specta::Type; use std::path::PathBuf; diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index a5a0ecc00c..e5f3019063 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -3,10 +3,10 @@ use cap_fail::fail; use cap_project::CursorMoveEvent; use cap_project::cursor::SHORT_CURSOR_SHAPE_DEBOUNCE_MS; use cap_project::{ - CursorClickEvent, InstantRecordingMeta, MultipleSegments, Platform, ProjectConfiguration, - RecordingMeta, RecordingMetaInner, SharingMeta, StudioRecordingMeta, StudioRecordingStatus, - TimelineConfiguration, TimelineSegment, UploadMeta, ZoomMode, ZoomSegment, - cursor::CursorEvents, + CameraShape, CursorClickEvent, InstantRecordingMeta, MultipleSegments, Platform, + ProjectConfiguration, RecordingMeta, RecordingMetaInner, SharingMeta, StudioRecordingMeta, + StudioRecordingStatus, TimelineConfiguration, TimelineSegment, UploadMeta, ZoomMode, + ZoomSegment, cursor::CursorEvents, }; use cap_recording::feeds::camera::CameraFeedLock; #[cfg(target_os = "macos")] @@ -42,6 +42,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder}; use tauri_specta::Event; use tracing::*; +use crate::camera::{CameraPreviewManager, CameraPreviewShape}; use crate::web_api::AuthedApiError; use crate::{ App, CurrentRecordingChanged, MutableState, NewStudioRecordingAdded, RecordingState, @@ -1680,6 +1681,24 @@ fn project_config_from_recording( let mut config = default_config.unwrap_or_default(); + let camera_preview_manager = CameraPreviewManager::new(app); + if let Ok(camera_preview_state) = camera_preview_manager.get_state() { + match camera_preview_state.shape { + CameraPreviewShape::Round => { + config.camera.shape = CameraShape::Square; + config.camera.rounding = 100.0; + } + CameraPreviewShape::Square => { + config.camera.shape = CameraShape::Square; + config.camera.rounding = 25.0; + } + CameraPreviewShape::Full => { + config.camera.shape = CameraShape::Source; + config.camera.rounding = 25.0; + } + } + } + let timeline_segments = recordings .segments .iter() diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index 591b17a34c..e52510b7dd 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -30,6 +30,7 @@ pub struct ScreenshotEditorInstance { pub ws_shutdown_token: CancellationToken, pub config_tx: watch::Sender, pub path: PathBuf, + pub pretty_name: String, } impl ScreenshotEditorInstance { @@ -140,8 +141,31 @@ impl ScreenshotEditorInstances { 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 image_path = if path.is_dir() { + let original = path.join("original.png"); + if original.exists() { + original + } else { + std::fs::read_dir(&path) + .ok() + .and_then(|dir| { + dir.flatten() + .find(|e| { + e.path().extension().and_then(|s| s.to_str()) + == Some("png") + }) + .map(|e| e.path()) + }) + .ok_or_else(|| { + format!("No PNG file found in directory: {:?}", path) + })? + } + } else { + path.clone() + }; + + let img = image::open(&image_path) + .map_err(|e| format!("Failed to open image: {e}"))?; let (w, h) = img.dimensions(); if w > MAX_DIMENSION || h > MAX_DIMENSION { @@ -156,15 +180,22 @@ impl ScreenshotEditorInstances { } }; - // Try to load existing meta if in a .cap directory - let (recording_meta, loaded_config) = if let Some(parent) = path.parent() { + let cap_dir = if path.extension().and_then(|s| s.to_str()) == Some("cap") { + Some(path.clone()) + } else 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) + Some(parent.to_path_buf()) } else { - (None, None) + None } + } else { + None + }; + + let (recording_meta, loaded_config) = if let Some(cap_dir) = &cap_dir { + let meta = RecordingMeta::load_for_project(cap_dir).ok(); + let config = ProjectConfiguration::load(cap_dir).ok(); + (meta, config) } else { (None, None) }; @@ -264,6 +295,7 @@ impl ScreenshotEditorInstances { ws_shutdown_token, config_tx, path: path.clone(), + pretty_name: recording_meta.pretty_name.clone(), }); // Spawn render loop @@ -375,6 +407,7 @@ pub struct SerializedScreenshotEditorInstance { pub frames_socket_url: String, pub path: PathBuf, pub config: Option, + pub pretty_name: String, } #[tauri::command] @@ -404,6 +437,7 @@ pub async fn create_screenshot_editor_instance( frames_socket_url: format!("ws://localhost:{}", instance.ws_port), path: instance.path.clone(), config: Some(config), + pretty_name: instance.pretty_name.clone(), }) } diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index c7268e5bb4..8cf7e57cfa 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -1,14 +1,17 @@ use crate::{ - RecordingStarted, RecordingStopped, RequestOpenRecordingPicker, RequestOpenSettings, recording, + NewScreenshotAdded, NewStudioRecordingAdded, RecordingStarted, RecordingStopped, + RequestOpenRecordingPicker, RequestOpenSettings, recording, recording_settings::RecordingTargetMode, windows::ShowCapWindow, }; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, +use cap_project::{RecordingMeta, RecordingMetaInner}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, }; use tauri::Manager; -use tauri::menu::{MenuId, PredefinedMenuItem}; +use tauri::menu::{IconMenuItem, MenuId, PredefinedMenuItem, Submenu}; use tauri::{ AppHandle, image::Image, @@ -16,18 +19,26 @@ use tauri::{ tray::TrayIconBuilder, }; use tauri_plugin_dialog::DialogExt; +use tauri_plugin_opener::OpenerExt; use tauri_specta::Event; +const PREVIOUS_ITEM_PREFIX: &str = "previous_item_"; +const MAX_PREVIOUS_ITEMS: usize = 6; +const MAX_TITLE_LENGTH: usize = 30; +const THUMBNAIL_SIZE: u32 = 32; + +#[derive(Debug)] pub enum TrayItem { OpenCap, RecordDisplay, RecordWindow, RecordArea, - PreviousRecordings, - PreviousScreenshots, + ViewAllRecordings, + ViewAllScreenshots, OpenSettings, UploadLogs, Quit, + PreviousItem(String), } impl From for MenuId { @@ -37,11 +48,14 @@ impl From for MenuId { TrayItem::RecordDisplay => "record_display", TrayItem::RecordWindow => "record_window", TrayItem::RecordArea => "record_area", - TrayItem::PreviousRecordings => "previous_recordings", - TrayItem::PreviousScreenshots => "previous_screenshots", + TrayItem::ViewAllRecordings => "view_all_recordings", + TrayItem::ViewAllScreenshots => "view_all_screenshots", TrayItem::OpenSettings => "open_settings", TrayItem::UploadLogs => "upload_logs", TrayItem::Quit => "quit", + TrayItem::PreviousItem(id) => { + return format!("{PREVIOUS_ITEM_PREFIX}{id}").into(); + } } .into() } @@ -51,13 +65,19 @@ impl TryFrom for TrayItem { type Error = String; fn try_from(value: MenuId) -> Result { - match value.0.as_str() { + let id_str = value.0.as_str(); + + if let Some(path) = id_str.strip_prefix(PREVIOUS_ITEM_PREFIX) { + return Ok(TrayItem::PreviousItem(path.to_string())); + } + + match id_str { "open_cap" => Ok(TrayItem::OpenCap), "record_display" => Ok(TrayItem::RecordDisplay), "record_window" => Ok(TrayItem::RecordWindow), "record_area" => Ok(TrayItem::RecordArea), - "previous_recordings" => Ok(TrayItem::PreviousRecordings), - "previous_screenshots" => Ok(TrayItem::PreviousScreenshots), + "view_all_recordings" => Ok(TrayItem::ViewAllRecordings), + "view_all_screenshots" => Ok(TrayItem::ViewAllScreenshots), "open_settings" => Ok(TrayItem::OpenSettings), "upload_logs" => Ok(TrayItem::UploadLogs), "quit" => Ok(TrayItem::Quit), @@ -66,8 +86,226 @@ impl TryFrom for TrayItem { } } -pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { - let menu = Menu::with_items( +#[derive(Debug, Clone)] +enum PreviousItemType { + StudioRecording, + InstantRecording { + #[allow(dead_code)] + link: Option, + }, + Screenshot, +} + +#[derive(Clone)] +struct CachedPreviousItem { + path: PathBuf, + pretty_name: String, + thumbnail: Option>, + thumbnail_width: u32, + thumbnail_height: u32, + item_type: PreviousItemType, + created_at: std::time::SystemTime, +} + +#[derive(Default)] +struct PreviousItemsCache { + items: Vec, +} + +fn recordings_path(app: &AppHandle) -> PathBuf { + let path = app.path().app_data_dir().unwrap().join("recordings"); + std::fs::create_dir_all(&path).unwrap_or_default(); + path +} + +fn screenshots_path(app: &AppHandle) -> PathBuf { + let path = app.path().app_data_dir().unwrap().join("screenshots"); + std::fs::create_dir_all(&path).unwrap_or_default(); + path +} + +fn truncate_title(title: &str) -> String { + if title.chars().count() <= MAX_TITLE_LENGTH { + title.to_string() + } else { + let truncate_at = MAX_TITLE_LENGTH - 1; + let byte_index = title + .char_indices() + .nth(truncate_at) + .map(|(i, _)| i) + .unwrap_or(title.len()); + format!("{}…", &title[..byte_index]) + } +} + +fn load_thumbnail_data(path: &PathBuf) -> Option<(Vec, u32, u32)> { + use image::imageops::FilterType; + use image::{GenericImageView, RgbaImage}; + + let image_data = std::fs::read(path).ok()?; + let img = image::load_from_memory(&image_data).ok()?; + + let (orig_w, orig_h) = img.dimensions(); + let size = THUMBNAIL_SIZE; + + let scale = (size as f32 / orig_w as f32).max(size as f32 / orig_h as f32); + let scaled_w = (orig_w as f32 * scale).round() as u32; + let scaled_h = (orig_h as f32 * scale).round() as u32; + + let scaled = img.resize_exact(scaled_w, scaled_h, FilterType::Triangle); + + let x_offset = (scaled_w.saturating_sub(size)) / 2; + let y_offset = (scaled_h.saturating_sub(size)) / 2; + + let mut result = RgbaImage::new(size, size); + for y in 0..size { + for x in 0..size { + let src_x = x + x_offset; + let src_y = y + y_offset; + if src_x < scaled_w && src_y < scaled_h { + result.put_pixel(x, y, scaled.get_pixel(src_x, src_y)); + } + } + } + + Some((result.into_raw(), size, size)) +} + +fn load_single_item( + path: &PathBuf, + screenshots_dir: &PathBuf, + load_thumbnail: bool, +) -> Option { + if !path.is_dir() { + return None; + } + + let meta = RecordingMeta::load_for_project(path).ok()?; + let created_at = path + .metadata() + .and_then(|m| m.created()) + .unwrap_or_else(|_| std::time::SystemTime::now()); + + let is_screenshot = path.extension().and_then(|s| s.to_str()) == Some("cap") + && path.parent().map(|p| p == screenshots_dir).unwrap_or(false); + + let (thumbnail_path, item_type) = if is_screenshot { + let png_path = std::fs::read_dir(path).ok().and_then(|dir| { + dir.flatten() + .find(|e| e.path().extension().and_then(|s| s.to_str()) == Some("png")) + .map(|e| e.path()) + }); + (png_path, PreviousItemType::Screenshot) + } else { + let thumb = path.join("screenshots/display.jpg"); + let thumb_path = if thumb.exists() { Some(thumb) } else { None }; + let item_type = match &meta.inner { + RecordingMetaInner::Studio(_) => PreviousItemType::StudioRecording, + RecordingMetaInner::Instant(_) => PreviousItemType::InstantRecording { + link: meta.sharing.as_ref().map(|s| s.link.clone()), + }, + }; + (thumb_path, item_type) + }; + + let (thumbnail, thumbnail_width, thumbnail_height) = if load_thumbnail { + thumbnail_path + .as_ref() + .and_then(load_thumbnail_data) + .map(|(data, w, h)| (Some(data), w, h)) + .unwrap_or((None, 0, 0)) + } else { + (None, 0, 0) + }; + + Some(CachedPreviousItem { + path: path.clone(), + pretty_name: meta.pretty_name, + thumbnail, + thumbnail_width, + thumbnail_height, + item_type, + created_at, + }) +} + +fn load_all_previous_items(app: &AppHandle, load_thumbnails: bool) -> Vec { + let mut items = Vec::new(); + let screenshots_dir = screenshots_path(app); + + let recordings_dir = recordings_path(app); + if recordings_dir.exists() + && let Ok(entries) = std::fs::read_dir(&recordings_dir) + { + for entry in entries.flatten() { + if let Some(item) = load_single_item(&entry.path(), &screenshots_dir, load_thumbnails) { + items.push(item); + } + } + } + + if screenshots_dir.exists() + && let Ok(entries) = std::fs::read_dir(&screenshots_dir) + { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("cap") + && let Some(item) = load_single_item(&path, &screenshots_dir, load_thumbnails) + { + items.push(item); + } + } + } + + items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + items.truncate(MAX_PREVIOUS_ITEMS); + items +} + +fn create_previous_submenu( + app: &AppHandle, + cache: &PreviousItemsCache, +) -> tauri::Result> { + if cache.items.is_empty() { + let submenu = Submenu::with_id(app, "previous", "Previous", false)?; + submenu.append(&MenuItem::with_id( + app, + "previous_empty", + "No recent items", + false, + None::<&str>, + )?)?; + return Ok(submenu); + } + + let submenu = Submenu::with_id(app, "previous", "Previous", true)?; + + for item in &cache.items { + let id = TrayItem::PreviousItem(item.path.to_string_lossy().to_string()); + let title = truncate_title(&item.pretty_name); + + let type_indicator = match &item.item_type { + PreviousItemType::StudioRecording => "🎬 ", + PreviousItemType::InstantRecording { .. } => "⚡ ", + PreviousItemType::Screenshot => "📷 ", + }; + let display_title = format!("{type_indicator}{title}"); + + let icon = item.thumbnail.as_ref().map(|data| { + Image::new_owned(data.clone(), item.thumbnail_width, item.thumbnail_height) + }); + + let menu_item = IconMenuItem::with_id(app, id, display_title, true, icon, None::<&str>)?; + submenu.append(&menu_item)?; + } + + Ok(submenu) +} + +fn build_tray_menu(app: &AppHandle, cache: &PreviousItemsCache) -> tauri::Result> { + let previous_submenu = create_previous_submenu(app, cache)?; + + Menu::with_items( app, &[ &MenuItem::with_id( @@ -93,17 +331,19 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { )?, &MenuItem::with_id(app, TrayItem::RecordArea, "Record Area", true, None::<&str>)?, &PredefinedMenuItem::separator(app)?, - // &MenuItem::with_id( - // app, - // TrayItem::TakeScreenshot, - // "Take Screenshot", - // true, - // None::<&str>, - // )?, + &previous_submenu, + &PredefinedMenuItem::separator(app)?, &MenuItem::with_id( app, - TrayItem::PreviousRecordings, - "Previous Recordings", + TrayItem::ViewAllRecordings, + "View all recordings", + true, + None::<&str>, + )?, + &MenuItem::with_id( + app, + TrayItem::ViewAllScreenshots, + "View all screenshots", true, None::<&str>, )?, @@ -119,9 +359,103 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { )?, &MenuItem::with_id(app, TrayItem::Quit, "Quit Cap", true, None::<&str>)?, ], - )?; + ) +} + +fn add_new_item_to_cache(cache: &Arc>, app: &AppHandle, path: PathBuf) { + let screenshots_dir = screenshots_path(app); + + let Some(new_item) = load_single_item(&path, &screenshots_dir, true) else { + return; + }; + + let mut cache_guard = cache.lock().unwrap(); + + cache_guard.items.retain(|item| item.path != path); + + cache_guard.items.insert(0, new_item); + + cache_guard.items.truncate(MAX_PREVIOUS_ITEMS); +} + +fn refresh_tray_menu(app: &AppHandle, cache: &Arc>) { + let app_clone = app.clone(); + let cache_clone = cache.clone(); + + let _ = app.run_on_main_thread(move || { + let Some(tray) = app_clone.tray_by_id("tray") else { + return; + }; + + let cache_guard = cache_clone.lock().unwrap(); + if let Ok(menu) = build_tray_menu(&app_clone, &cache_guard) { + let _ = tray.set_menu(Some(menu)); + } + }); +} + +fn handle_previous_item_click(app: &AppHandle, path_str: &str) { + let path = PathBuf::from(path_str); + + let screenshots_dir = screenshots_path(app); + let is_screenshot = path.extension().and_then(|s| s.to_str()) == Some("cap") + && path.parent().map(|p| p == screenshots_dir).unwrap_or(false); + + if is_screenshot { + let app = app.clone(); + let screenshot_path = path; + tokio::spawn(async move { + let _ = ShowCapWindow::ScreenshotEditor { + path: screenshot_path, + } + .show(&app) + .await; + }); + return; + } + + let meta = match RecordingMeta::load_for_project(&path) { + Ok(m) => m, + Err(e) => { + tracing::error!("Failed to load recording meta for previous item: {e}"); + return; + } + }; + + match &meta.inner { + RecordingMetaInner::Studio(_) => { + let app = app.clone(); + let project_path = path.clone(); + tokio::spawn(async move { + let _ = ShowCapWindow::Editor { project_path }.show(&app).await; + }); + } + RecordingMetaInner::Instant(_) => { + if let Some(sharing) = &meta.sharing { + let _ = app.opener().open_url(&sharing.link, None::); + } else { + let mp4_path = path.join("content/output.mp4"); + if mp4_path.exists() { + let _ = app + .opener() + .open_path(mp4_path.to_str().unwrap_or_default(), None::); + } + } + } + } +} + +pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { + let items = load_all_previous_items(app, false); + let cache = Arc::new(Mutex::new(PreviousItemsCache { items })); + + let menu = { + let cache_guard = cache.lock().unwrap(); + build_tray_menu(app, &cache_guard)? + }; let app = app.clone(); let is_recording = Arc::new(AtomicBool::new(false)); + let _ = TrayIconBuilder::with_id("tray") .icon(Image::from_bytes(include_bytes!( "../icons/tray-default-icon.png" @@ -159,13 +493,13 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { } .emit(&app_handle); } - Ok(TrayItem::PreviousRecordings) => { + Ok(TrayItem::ViewAllRecordings) => { let _ = RequestOpenSettings { page: "recordings".to_string(), } .emit(&app_handle); } - Ok(TrayItem::PreviousScreenshots) => { + Ok(TrayItem::ViewAllScreenshots) => { let _ = RequestOpenSettings { page: "screenshots".to_string(), } @@ -197,6 +531,9 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { Ok(TrayItem::Quit) => { app.exit(0); } + Ok(TrayItem::PreviousItem(path)) => { + handle_previous_item_click(app, &path); + } _ => {} } }) @@ -218,6 +555,49 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { }) .build(&app); + { + let app_clone = app.clone(); + let cache_clone = cache.clone(); + std::thread::spawn(move || { + let screenshots_dir = screenshots_path(&app_clone); + let items_needing_thumbnails: Vec = { + let cache_guard = cache_clone.lock().unwrap(); + cache_guard + .items + .iter() + .filter(|item| item.thumbnail.is_none()) + .map(|item| item.path.clone()) + .collect() + }; + + if items_needing_thumbnails.is_empty() { + return; + } + + for path in items_needing_thumbnails { + if let Some(updated_item) = load_single_item(&path, &screenshots_dir, true) { + let mut cache_guard = cache_clone.lock().unwrap(); + if let Some(existing) = cache_guard.items.iter_mut().find(|i| i.path == path) { + existing.thumbnail = updated_item.thumbnail; + existing.thumbnail_width = updated_item.thumbnail_width; + existing.thumbnail_height = updated_item.thumbnail_height; + } + } + } + + let app_for_refresh = app_clone.clone(); + let cache_for_refresh = cache_clone.clone(); + let _ = app_clone.run_on_main_thread(move || { + if let Some(tray) = app_for_refresh.tray_by_id("tray") { + let cache_guard = cache_for_refresh.lock().unwrap(); + if let Ok(menu) = build_tray_menu(&app_for_refresh, &cache_guard) { + let _ = tray.set_menu(Some(menu)); + } + } + }); + }); + } + RecordingStarted::listen_any(&app, { let app = app.clone(); let is_recording = is_recording.clone(); @@ -248,5 +628,31 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { } }); + NewStudioRecordingAdded::listen_any(&app, { + let app_handle = app.clone(); + let cache_clone = cache.clone(); + move |event| { + add_new_item_to_cache(&cache_clone, &app_handle, event.payload.path.clone()); + refresh_tray_menu(&app_handle, &cache_clone); + } + }); + + NewScreenshotAdded::listen_any(&app, { + let app_handle = app.clone(); + let cache_clone = cache.clone(); + move |event| { + let path = if event.payload.path.extension().and_then(|s| s.to_str()) == Some("png") { + event.payload.path.parent().map(|p| p.to_path_buf()) + } else { + Some(event.payload.path.clone()) + }; + + if let Some(path) = path { + add_new_item_to_cache(&cache_clone, &app_handle, path); + refresh_tray_menu(&app_handle, &cache_clone); + } + } + }); + Ok(()) } diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 8fa1eb331f..a88dc4bc37 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -705,7 +705,7 @@ impl ShowCapWindow { .maximized(false) .resizable(false) .fullscreen(false) - .shadow(!cfg!(windows)) + .shadow(false) .always_on_top(true) .transparent(true) .visible_on_all_workspaces(true) @@ -727,6 +727,8 @@ impl ShowCapWindow { crate::platform::set_window_level(window.as_ref().window(), 1000); } + fake_window::spawn_fake_window_listener(app.clone(), window.clone()); + window } Self::RecordingsOverlay => { diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx index 9d45814b74..d5d7936754 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx @@ -90,7 +90,7 @@ function ViewAllButton(props: { onClick: () => void; label: string }) { - - + + +
+ +
-
+ + - -
- - Intensity {Math.round(maskLevel())} - - update("maskLevel", v[0])} - minValue={4} - maxValue={50} - step={1} - class="w-full" - /> -
-
+ + + update("maskLevel", v[0])} + minValue={4} + maxValue={50} + step={1} + class="w-24" + /> + + - -
- - Size {ann().height}px - - update("height", v[0])} - minValue={12} - maxValue={100} - step={1} - class="w-full" - /> -
-
+ + + update("height", v[0])} + minValue={12} + maxValue={100} + step={1} + class="w-20" + /> + + -
+
- -
+
- + ); }} ); } +function ConfigItem(props: { + label: string; + value?: string; + children: JSX.Element; +}) { + return ( +
+ + {props.label} + {props.value && ( + {props.value} + )} + + {props.children} +
+ ); +} + function ColorPickerButton(props: { value: string; onChange: (value: string) => void; allowTransparent?: boolean; }) { - // Helper to handle RGB <-> Hex const rgbValue = createMemo(() => { if (props.value === "transparent") return [0, 0, 0] as [number, number, number]; @@ -194,24 +208,24 @@ function ColorPickerButton(props: { const isTransparent = createMemo(() => props.value === "transparent"); return ( - + -
+
- -
+ +
{ @@ -219,17 +233,17 @@ function ColorPickerButton(props: { }} /> -
+
)} diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx index a6cfb321d2..1fcc612fd8 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationLayer.tsx @@ -243,12 +243,12 @@ export function AnnotationLayer(props: { let width = currentX - temp.x; let height = currentY - temp.y; - if (e.shiftKey) { - if ( - temp.type === "rectangle" || - temp.type === "circle" || - temp.type === "mask" - ) { + if (temp.type === "circle" && !e.shiftKey) { + const size = Math.max(Math.abs(width), Math.abs(height)); + width = width < 0 ? -size : size; + height = height < 0 ? -size : size; + } else if (e.shiftKey) { + if (temp.type === "rectangle" || temp.type === "mask") { const size = Math.max(Math.abs(width), Math.abs(height)); width = width < 0 ? -size : size; height = height < 0 ? -size : size; @@ -333,19 +333,25 @@ export function AnnotationLayer(props: { newH = original.height - dy; } - // Shift constraint during resize - if ( - e.shiftKey && - (original.type === "rectangle" || original.type === "circle") - ) { - // This is complex for corner resizing, simplifying: - // Just force aspect ratio based on original - const _ratio = original.width / original.height; - if (state.handle.includes("e") || state.handle.includes("w")) { - // Width driven, adjust height - // This is tricky with 8 handles. Skipping proper aspect resize for now to save time/complexity - // Or simple implementation: + const shouldConstrainCircle = + original.type === "circle" && !e.shiftKey; + const shouldConstrainRectangle = + original.type === "rectangle" && e.shiftKey; + + if (shouldConstrainCircle || shouldConstrainRectangle) { + const size = Math.max(Math.abs(newW), Math.abs(newH)); + const signW = newW < 0 ? -1 : 1; + const signH = newH < 0 ? -1 : 1; + + if (state.handle.includes("w")) { + newX = original.x + original.width - signW * size; } + if (state.handle.includes("n")) { + newY = original.y + original.height - signH * size; + } + + newW = signW * size; + newH = signH * size; } } diff --git a/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx b/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx index 41927cc458..37c0ed861b 100644 --- a/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx +++ b/apps/desktop/src/routes/screenshot-editor/AnnotationTools.tsx @@ -4,55 +4,64 @@ import Tooltip from "~/components/Tooltip"; import IconLucideArrowUpRight from "~icons/lucide/arrow-up-right"; import IconLucideCircle from "~icons/lucide/circle"; import IconLucideEyeOff from "~icons/lucide/eye-off"; +import IconLucideLayers from "~icons/lucide/layers"; import IconLucideMousePointer2 from "~icons/lucide/mouse-pointer-2"; import IconLucideSquare from "~icons/lucide/square"; import IconLucideType from "~icons/lucide/type"; -import { AnnotationConfig } from "./AnnotationConfig"; import { type AnnotationType, useScreenshotEditorContext } from "./context"; export function AnnotationTools() { + const { layersPanelOpen, setLayersPanelOpen } = useScreenshotEditorContext(); + return ( - <> -
- - - - - - -
- - +
+ + + +
+ + + + + + +
); } diff --git a/apps/desktop/src/routes/screenshot-editor/Editor.tsx b/apps/desktop/src/routes/screenshot-editor/Editor.tsx index 2785ae3d12..bd2f44b436 100644 --- a/apps/desktop/src/routes/screenshot-editor/Editor.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Editor.tsx @@ -26,8 +26,10 @@ import { composeEventHandlers } from "~/utils/composeEventHandlers"; import IconCapCircleX from "~icons/cap/circle-x"; import IconLucideMaximize from "~icons/lucide/maximize"; import IconLucideRatio from "~icons/lucide/ratio"; +import { AnnotationConfigBar } from "./AnnotationConfig"; import { useScreenshotEditorContext } from "./context"; import { Header } from "./Header"; +import { LayersPanel } from "./LayersPanel"; import { Preview } from "./Preview"; import { Dialog, EditorButton } from "./ui"; @@ -36,9 +38,11 @@ export function Editor() { const { projectHistory, setActiveTool, - setProject, - project, setSelectedAnnotationId, + layersPanelOpen, + setLayersPanelOpen, + activePopover, + setActivePopover, } = useScreenshotEditorContext(); createEffect(() => { @@ -102,14 +106,23 @@ export function Editor() { setActiveTool("select"); setSelectedAnnotationId(null); break; - case "p": { - // Toggle Padding - // We need to push history here too if we want undo for padding - projectHistory.push(); - const currentPadding = project.background.padding; - setProject("background", "padding", currentPadding === 0 ? 20 : 0); + case "p": + setActivePopover(activePopover() === "padding" ? null : "padding"); + break; + case "b": + setActivePopover( + activePopover() === "background" ? null : "background", + ); + break; + case "l": + setLayersPanelOpen(!layersPanelOpen()); + break; + case "h": + setActivePopover(activePopover() === "shadow" ? null : "shadow"); + break; + case "e": + setActivePopover(activePopover() === "border" ? null : "border"); break; - } } } }; @@ -120,11 +133,17 @@ export function Editor() { return ( <> -
+
+
+ +
+ + +
@@ -192,12 +211,21 @@ function Dialogs() { ); }); - const initialBounds = { - x: cropDialog().position.x, - y: cropDialog().position.y, - width: cropDialog().size.x, - height: cropDialog().size.y, - }; + const originalSize = cropDialog().originalSize; + const existingCrop = cropDialog().currentCrop; + const initialBounds = existingCrop + ? { + x: existingCrop.position.x, + y: existingCrop.position.y, + width: existingCrop.size.x, + height: existingCrop.size.y, + } + : { + x: 0, + y: 0, + width: originalSize.x, + height: originalSize.y, + }; const [snapToRatio, setSnapToRatioEnabled] = makePersisted( createSignal(true), @@ -260,17 +288,11 @@ function Dialogs() {
Size
- +
×
- +
@@ -321,8 +343,8 @@ function Dialogs() { leftIcon={} onClick={() => cropperRef?.fill()} disabled={ - crop().width === cropDialog().size.x && - crop().height === cropDialog().size.y + crop().width === originalSize.x && + crop().height === originalSize.y } > Full @@ -334,10 +356,10 @@ function Dialogs() { setAspect(null); }} disabled={ - crop().x === cropDialog().position.x && - crop().y === cropDialog().position.y && - crop().width === cropDialog().size.x && - crop().height === cropDialog().size.y + crop().x === initialBounds.x && + crop().y === initialBounds.y && + crop().width === initialBounds.width && + crop().height === initialBounds.height } > Reset @@ -350,8 +372,8 @@ function Dialogs() { class="rounded overflow-hidden relative select-none" style={{ width: (() => { - const srcW = cropDialog().size.x; - const srcH = cropDialog().size.y; + const srcW = originalSize.x; + const srcH = originalSize.y; const maxW = Math.min( windowSize().width * 0.8, 768, @@ -361,8 +383,8 @@ function Dialogs() { return `${srcW * ratio}px`; })(), height: (() => { - const srcW = cropDialog().size.x; - const srcH = cropDialog().size.y; + const srcW = originalSize.x; + const srcH = originalSize.y; const maxW = Math.min( windowSize().width * 0.8, 768, @@ -378,8 +400,8 @@ function Dialogs() { onCropChange={setCrop} aspectRatio={aspect() ?? undefined} targetSize={{ - x: cropDialog().size.x, - y: cropDialog().size.y, + x: originalSize.x, + y: originalSize.y, }} initialCrop={initialBounds} snapToRatioEnabled={snapToRatio()} diff --git a/apps/desktop/src/routes/screenshot-editor/Header.tsx b/apps/desktop/src/routes/screenshot-editor/Header.tsx index cfa497ab97..56d0ad594e 100644 --- a/apps/desktop/src/routes/screenshot-editor/Header.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Header.tsx @@ -15,7 +15,6 @@ import IconLucideMoreHorizontal from "~icons/lucide/more-horizontal"; import IconLucideSave from "~icons/lucide/save"; import { AnnotationTools } from "./AnnotationTools"; import { useScreenshotEditorContext } from "./context"; -import PresetsSubMenu from "./PresetsDropdown"; import { AspectRatioSelect } from "./popovers/AspectRatioSelect"; import { BackgroundSettingsPopover } from "./popovers/BackgroundSettingsPopover"; import { BorderPopover } from "./popovers/BorderPopover"; @@ -32,8 +31,9 @@ import { import { useScreenshotExport } from "./useScreenshotExport"; export function Header() { - const { path, setDialog, project, latestFrame } = - useScreenshotEditorContext(); + const ctx = useScreenshotEditorContext(); + const { setDialog, project, latestFrame } = ctx; + const path = () => ctx.editorInstance()?.path ?? ""; const { exportImage, isExporting } = useScreenshotExport(); @@ -66,21 +66,17 @@ export function Header() { const cropDialogHandler = () => { const frame = latestFrame(); + if (!frame?.data) return; setDialog({ open: true, type: "crop", - position: { - ...(project.background.crop?.position ?? { x: 0, y: 0 }), - }, - size: { - ...(project.background.crop?.size ?? { - x: frame?.data?.width ?? 0, - y: frame?.data?.height ?? 0, - }), - }, + originalSize: { x: frame.data.width, y: frame.data.height }, + currentCrop: project.background.crop, }); }; + const isCropDisabled = () => !latestFrame()?.data; + return (
} />
@@ -149,7 +146,7 @@ export function Header() { > { - revealItemInDir(path); + revealItemInDir(path()); }} > @@ -162,7 +159,7 @@ export function Header() { "Are you sure you want to delete this screenshot?", ) ) { - await remove(path); + await remove(path()); await getCurrentWindow().close(); } }} @@ -171,15 +168,6 @@ export function Header() { Delete - - - - - as={DropdownMenu.Group} - class="p-1" - > - - diff --git a/apps/desktop/src/routes/screenshot-editor/LayersPanel.tsx b/apps/desktop/src/routes/screenshot-editor/LayersPanel.tsx new file mode 100644 index 0000000000..3ae2db4bb8 --- /dev/null +++ b/apps/desktop/src/routes/screenshot-editor/LayersPanel.tsx @@ -0,0 +1,325 @@ +import { cx } from "cva"; +import { createEffect, createSignal, For, on, onCleanup, Show } from "solid-js"; +import IconLucideArrowUpRight from "~icons/lucide/arrow-up-right"; +import IconLucideCircle from "~icons/lucide/circle"; +import IconLucideEyeOff from "~icons/lucide/eye-off"; +import IconLucideGripVertical from "~icons/lucide/grip-vertical"; +import IconLucideLayers from "~icons/lucide/layers"; +import IconLucideSquare from "~icons/lucide/square"; +import IconLucideType from "~icons/lucide/type"; +import IconLucideX from "~icons/lucide/x"; +import { type Annotation, useScreenshotEditorContext } from "./context"; + +const ANNOTATION_TYPE_ICONS = { + arrow: IconLucideArrowUpRight, + rectangle: IconLucideSquare, + circle: IconLucideCircle, + mask: IconLucideEyeOff, + text: IconLucideType, +}; + +const ANNOTATION_TYPE_LABELS = { + arrow: "Arrow", + rectangle: "Rectangle", + circle: "Circle", + mask: "Mask", + text: "Text", +}; + +export function LayersPanel() { + const { + annotations, + setAnnotations, + selectedAnnotationId, + setSelectedAnnotationId, + setLayersPanelOpen, + projectHistory, + setActiveTool, + setFocusAnnotationId, + } = useScreenshotEditorContext(); + + const [dragState, setDragState] = createSignal<{ + draggedId: string; + startY: number; + currentY: number; + } | null>(null); + + const [dropTargetIndex, setDropTargetIndex] = createSignal( + null, + ); + + const getTypeLabel = (ann: Annotation) => { + if (ann.type === "text" && ann.text) { + const truncated = + ann.text.length > 12 ? `${ann.text.slice(0, 12)}...` : ann.text; + return truncated; + } + return ANNOTATION_TYPE_LABELS[ann.type]; + }; + + const reversedAnnotations = () => [...annotations].reverse(); + + const getActualIndex = (reversedIdx: number) => + annotations.length - 1 - reversedIdx; + + const handleDragMove = (moveEvent: MouseEvent) => { + setDragState((prev) => + prev ? { ...prev, currentY: moveEvent.clientY } : null, + ); + + const listEl = document.querySelector("[data-layers-list]"); + if (!listEl) return; + + const items = listEl.querySelectorAll("[data-layer-item]"); + let targetIdx: number | null = null; + + for (let i = 0; i < items.length; i++) { + const item = items[i] as HTMLElement; + const rect = item.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + + if (moveEvent.clientY < midY) { + targetIdx = i; + break; + } + targetIdx = i + 1; + } + + setDropTargetIndex(targetIdx); + }; + + const finalizeDrag = () => { + const state = dragState(); + const targetIdx = dropTargetIndex(); + + if (state && targetIdx !== null) { + const draggedReversedIdx = reversedAnnotations().findIndex( + (a) => a.id === state.draggedId, + ); + + if (draggedReversedIdx !== -1 && draggedReversedIdx !== targetIdx) { + const fromActual = getActualIndex(draggedReversedIdx); + let toActual: number; + + if (targetIdx > draggedReversedIdx) { + toActual = getActualIndex(targetIdx - 1); + } else { + toActual = getActualIndex(targetIdx); + } + + if (fromActual !== toActual) { + projectHistory.push(); + const newAnnotations = [...annotations]; + const [removed] = newAnnotations.splice(fromActual, 1); + newAnnotations.splice(toActual, 0, removed); + setAnnotations(newAnnotations); + } + } + } + + setDragState(null); + setDropTargetIndex(null); + }; + + const handleDragEnd = () => { + finalizeDrag(); + }; + + const handleWindowBlur = () => { + finalizeDrag(); + }; + + const handleMouseLeave = () => { + finalizeDrag(); + }; + + createEffect(() => { + const state = dragState(); + + if (state) { + window.addEventListener("mousemove", handleDragMove); + window.addEventListener("mouseup", handleDragEnd); + window.addEventListener("blur", handleWindowBlur); + document.documentElement.addEventListener("mouseleave", handleMouseLeave); + + onCleanup(() => { + window.removeEventListener("mousemove", handleDragMove); + window.removeEventListener("mouseup", handleDragEnd); + window.removeEventListener("blur", handleWindowBlur); + document.documentElement.removeEventListener( + "mouseleave", + handleMouseLeave, + ); + setDragState(null); + setDropTargetIndex(null); + }); + } + }); + + const handleMouseDown = (ann: Annotation, e: MouseEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + + const gripHandle = (e.target as HTMLElement).closest("[data-grip-handle]"); + if (!gripHandle) return; + + e.preventDefault(); + e.stopPropagation(); + + setDragState({ + draggedId: ann.id, + startY: e.clientY, + currentY: e.clientY, + }); + }; + + const handleLayerClick = (ann: Annotation, e: MouseEvent) => { + if ((e.target as HTMLElement).closest("[data-grip-handle]")) return; + setSelectedAnnotationId(ann.id); + setActiveTool("select"); + setFocusAnnotationId(ann.id); + }; + + const handleDelete = (id: string, e: MouseEvent) => { + e.stopPropagation(); + projectHistory.push(); + setAnnotations((prev) => prev.filter((a) => a.id !== id)); + if (selectedAnnotationId() === id) { + setSelectedAnnotationId(null); + } + }; + + createEffect( + on( + () => annotations.length, + () => { + setDragState(null); + setDropTargetIndex(null); + }, + ), + ); + + return ( +
+
+
+ + Layers +
+ +
+ +
+ 0} + fallback={ +
+ +

No layers yet

+

+ Use the tools above to add annotations +

+
+ } + > + + {(ann, reversedIdx) => { + const Icon = ANNOTATION_TYPE_ICONS[ann.type]; + const isSelected = () => selectedAnnotationId() === ann.id; + const isDragging = () => dragState()?.draggedId === ann.id; + const isDropTarget = () => { + const target = dropTargetIndex(); + return target !== null && target === reversedIdx(); + }; + const showDropIndicatorAfter = () => { + const target = dropTargetIndex(); + const state = dragState(); + if (!state || target === null) return false; + const draggedIdx = reversedAnnotations().findIndex( + (a) => a.id === state.draggedId, + ); + return ( + target === reversedIdx() + 1 && target !== draggedIdx + 1 + ); + }; + + return ( + <> + +
+ +
handleMouseDown(ann, e)} + onClick={(e) => handleLayerClick(ann, e)} + class={cx( + "flex items-center gap-2 px-2 py-1.5 mx-1 rounded-md cursor-pointer transition-all group", + isSelected() + ? "bg-blue-3 dark:bg-blue-4" + : "hover:bg-gray-3", + isDragging() && "opacity-50 bg-gray-3", + )} + > +
+ +
+ +
+ +
+ + + {getTypeLabel(ann)} + + + +
+ +
+ + + ); + }} + + +
+ + +
+ +
+ Drag to reorder • Top = front +
+
+ ); +} diff --git a/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx b/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx deleted file mode 100644 index 7f212b772c..0000000000 --- a/apps/desktop/src/routes/screenshot-editor/PresetsDropdown.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { DropdownMenu as KDropdownMenu } from "@kobalte/core/dropdown-menu"; -import { cx } from "cva"; -import { Suspense } from "solid-js"; -import IconCapCirclePlus from "~icons/cap/circle-plus"; -import IconCapPresets from "~icons/cap/presets"; -import IconLucideChevronRight from "~icons/lucide/chevron-right"; -import { - DropdownItem, - MenuItemList, - PopperContent, - topSlideAnimateClasses, -} from "./ui"; - -export function PresetsSubMenu() { - return ( - - -
- - Presets -
- -
- - - - as={KDropdownMenu.SubContent} - class={cx("w-72 max-h-56", topSlideAnimateClasses)} - > - - as={KDropdownMenu.Group} - class="overflow-y-auto flex-1 scrollbar-none" - > -
- No Presets -
- - - as={KDropdownMenu.Group} - class="border-t shrink-0" - > - - Create new preset - - - - -
-
-
- ); -} - -export default PresetsSubMenu; diff --git a/apps/desktop/src/routes/screenshot-editor/Preview.tsx b/apps/desktop/src/routes/screenshot-editor/Preview.tsx index a0835baef8..60fff182fd 100644 --- a/apps/desktop/src/routes/screenshot-editor/Preview.tsx +++ b/apps/desktop/src/routes/screenshot-editor/Preview.tsx @@ -1,5 +1,12 @@ import { createElementBounds } from "@solid-primitives/bounds"; -import { createEffect, createMemo, createSignal, Show } from "solid-js"; +import { + createEffect, + createMemo, + createSignal, + on, + onCleanup, + Show, +} from "solid-js"; import IconCapZoomIn from "~icons/cap/zoom-in"; import IconCapZoomOut from "~icons/cap/zoom-out"; import { ASPECT_RATIOS } from "../editor/projectConfig"; @@ -17,7 +24,13 @@ const gridStyle = { }; export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { - const { project, latestFrame, annotations } = useScreenshotEditorContext(); + const { + project, + latestFrame, + annotations, + focusAnnotationId, + setFocusAnnotationId, + } = useScreenshotEditorContext(); let canvasRef: HTMLCanvasElement | undefined; const [canvasContainerRef, setCanvasContainerRef] = @@ -26,16 +39,50 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { const [pan, setPan] = createSignal({ x: 0, y: 0 }); + const zoomIn = () => { + props.setZoom(Math.min(3, props.zoom + 0.1)); + setPan({ x: 0, y: 0 }); + }; + + const zoomOut = () => { + props.setZoom(Math.max(0.1, props.zoom - 0.1)); + setPan({ x: 0, y: 0 }); + }; + + createEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } + + if (!e.metaKey && !e.ctrlKey) return; + + if (e.key === "-") { + e.preventDefault(); + zoomOut(); + } else if (e.key === "=" || e.key === "+") { + e.preventDefault(); + zoomIn(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); + }); + const handleWheel = (e: WheelEvent) => { e.preventDefault(); if (e.ctrlKey) { - // Zoom const delta = -e.deltaY; const zoomStep = 0.005; const newZoom = Math.max(0.1, Math.min(3, props.zoom + delta * zoomStep)); props.setZoom(newZoom); } else { - // Pan setPan((p) => ({ x: p.x - e.deltaX, y: p.y - e.deltaY, @@ -92,7 +139,8 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) {
props.setZoom(Math.max(0.1, props.zoom - 0.1))} + kbd={["meta", "-"]} + onClick={zoomOut} > @@ -107,7 +155,8 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { /> props.setZoom(Math.min(3, props.zoom + 0.1))} + kbd={["meta", "+"]} + onClick={zoomIn} > @@ -266,6 +315,35 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { const canvasLeft = () => -bounds().x * cssScale(); const canvasTop = () => -bounds().y * cssScale(); + createEffect( + on(focusAnnotationId, (annId) => { + if (!annId) return; + + const ann = annotations.find((a) => a.id === annId); + if (!ann) { + setFocusAnnotationId(null); + return; + } + + const annCenterX = ann.x + ann.width / 2; + const annCenterY = ann.y + ann.height / 2; + + const boundsData = bounds(); + const sizeData = size(); + const scale = fitScale() * props.zoom; + + const annScreenX = + (annCenterX - boundsData.x) * scale - + (sizeData.width * props.zoom) / 2; + const annScreenY = + (annCenterY - boundsData.y) * scale - + (sizeData.height * props.zoom) / 2; + + setPan({ x: -annScreenX, y: -annScreenY }); + setFocusAnnotationId(null); + }), + ); + let maskCanvasRef: HTMLCanvasElement | undefined; const blurRegion = ( @@ -432,7 +510,7 @@ export function Preview(props: { zoom: number; setZoom: (z: number) => void }) { transform: `translate(${pan().x}px, ${pan().y}px)`, "will-change": "transform", }} - class="shadow-lg block" + class="block" > ; size: XY }; + | { + type: "crop"; + originalSize: XY; + currentCrop: { position: XY; size: XY } | null; + }; export type DialogState = { open: false } | ({ open: boolean } & CurrentDialog); @@ -105,6 +110,18 @@ function createScreenshotEditorContext() { "select", ); + const [layersPanelOpen, setLayersPanelOpen] = makePersisted( + createSignal(false), + { name: "screenshotEditorLayersPanelOpen" }, + ); + const [focusAnnotationId, setFocusAnnotationId] = createSignal( + null, + ); + + const [activePopover, setActivePopover] = createSignal< + "background" | "padding" | "rounding" | "shadow" | "border" | null + >(null); + const [dialog, setDialog] = createSignal({ open: false, }); @@ -296,6 +313,9 @@ function createScreenshotEditorContext() { get path() { return editorInstance()?.path ?? ""; }, + get prettyName() { + return editorInstance()?.prettyName ?? "Screenshot"; + }, project, setProject, annotations, @@ -304,6 +324,12 @@ function createScreenshotEditorContext() { setSelectedAnnotationId, activeTool, setActiveTool, + layersPanelOpen, + setLayersPanelOpen, + focusAnnotationId, + setFocusAnnotationId, + activePopover, + setActivePopover, projectHistory, dialog, setDialog, diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx index a1ad709c31..9bbf394746 100644 --- a/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx +++ b/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx @@ -122,7 +122,13 @@ const BACKGROUND_THEMES = { }; export function BackgroundSettingsPopover() { - const { project, setProject, projectHistory } = useScreenshotEditorContext(); + const { + project, + setProject, + projectHistory, + activePopover, + setActivePopover, + } = useScreenshotEditorContext(); let scrollRef!: HTMLDivElement; @@ -194,11 +200,16 @@ export function BackgroundSettingsPopover() { }; return ( - + setActivePopover(open ? "background" : null)} + > } tooltipText="Background" + kbd={["B"]} /> diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/BorderPopover.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/BorderPopover.tsx index 46b67b3657..a38a1e077b 100644 --- a/apps/desktop/src/routes/screenshot-editor/popovers/BorderPopover.tsx +++ b/apps/desktop/src/routes/screenshot-editor/popovers/BorderPopover.tsx @@ -10,14 +10,20 @@ import { useScreenshotEditorContext } from "../context"; import { EditorButton, Field, Slider } from "../ui"; export function BorderPopover() { - const { project, setProject } = useScreenshotEditorContext(); + const { project, setProject, activePopover, setActivePopover } = + useScreenshotEditorContext(); return ( - + setActivePopover(open ? "border" : null)} + > } tooltipText="Border" + kbd={["E"]} /> diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/PaddingPopover.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/PaddingPopover.tsx index 37b3eb4e06..c7cb38906e 100644 --- a/apps/desktop/src/routes/screenshot-editor/popovers/PaddingPopover.tsx +++ b/apps/desktop/src/routes/screenshot-editor/popovers/PaddingPopover.tsx @@ -4,10 +4,15 @@ import { useScreenshotEditorContext } from "../context"; import { EditorButton, Slider } from "../ui"; export function PaddingPopover() { - const { project, setProject } = useScreenshotEditorContext(); + const { project, setProject, activePopover, setActivePopover } = + useScreenshotEditorContext(); return ( - + setActivePopover(open ? "padding" : null)} + > } diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/RoundingPopover.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/RoundingPopover.tsx index c09a05e0b5..9c9612c009 100644 --- a/apps/desktop/src/routes/screenshot-editor/popovers/RoundingPopover.tsx +++ b/apps/desktop/src/routes/screenshot-editor/popovers/RoundingPopover.tsx @@ -21,10 +21,15 @@ const CORNER_STYLE_OPTIONS = [ ] satisfies Array<{ name: string; value: CornerRoundingType }>; export function RoundingPopover() { - const { project, setProject } = useScreenshotEditorContext(); + const { project, setProject, activePopover, setActivePopover } = + useScreenshotEditorContext(); return ( - + setActivePopover(open ? "rounding" : null)} + > } diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/ShadowPopover.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/ShadowPopover.tsx index 7372e9fdb8..677d123303 100644 --- a/apps/desktop/src/routes/screenshot-editor/popovers/ShadowPopover.tsx +++ b/apps/desktop/src/routes/screenshot-editor/popovers/ShadowPopover.tsx @@ -6,23 +6,25 @@ import { EditorButton, Slider } from "../ui"; import ShadowSettings from "./ShadowSettings"; export function ShadowPopover() { - const { project, setProject } = useScreenshotEditorContext(); - // We need a dummy scrollRef since ShadowSettings expects it, - // but in this simple popover we might not need auto-scroll. - // Passing undefined might break it if it relies on it, checking ShadowSettings source would be good. - // Assuming it's optional or we can pass a dummy one. - let scrollRef: HTMLDivElement | undefined; + const { project, setProject, activePopover, setActivePopover } = + useScreenshotEditorContext(); + let optionalScrollContainerRef: HTMLDivElement | undefined; return ( - + setActivePopover(open ? "shadow" : null)} + > } tooltipText="Shadow" + kbd={["H"]} /> -
+
Shadow { diff --git a/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts b/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts index 0e52522f47..10e8d754dd 100644 --- a/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts +++ b/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts @@ -8,8 +8,8 @@ import { getArrowHeadPoints } from "./arrow"; import { type Annotation, useScreenshotEditorContext } from "./context"; export function useScreenshotExport() { - const { path, latestFrame, annotations, dialog, setDialog, project } = - useScreenshotEditorContext(); + const editorCtx = useScreenshotEditorContext(); + const { latestFrame, annotations, dialog, setDialog, project } = editorCtx; const [isExporting, setIsExporting] = createSignal(false); const drawAnnotations = ( @@ -193,9 +193,8 @@ export function useScreenshotExport() { canvas.height = frame.data.height; ctx.putImageData(frame.data, 0, 0); } else { - // Fallback to loading file const img = new Image(); - img.src = convertFileSrc(path); + img.src = convertFileSrc(editorCtx.path); await new Promise((resolve, reject) => { img.onload = resolve; img.onerror = reject; @@ -284,7 +283,7 @@ export function useScreenshotExport() { if (destination === "file") { const savePath = await save({ filters: [{ name: "PNG Image", extensions: ["png"] }], - defaultPath: "screenshot.png", + defaultPath: `${editorCtx.prettyName}.png`, }); if (savePath) { await writeFile(savePath, uint8Array); diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index eee4d02baa..95f579b5cd 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -67,6 +67,7 @@ import { } from "./(window-chrome)/OptionsContext"; const MIN_SIZE = { width: 150, height: 150 }; +const MIN_SCREENSHOT_SIZE = { width: 1, height: 1 }; const capitalize = (str: string) => { return str.charAt(0).toUpperCase() + str.slice(1); @@ -431,9 +432,13 @@ function Inner() { return bounds.width <= 1 && bounds.height <= 1 && !isInteracting(); }); + const minSize = () => + options.mode === "screenshot" ? MIN_SCREENSHOT_SIZE : MIN_SIZE; + const isValid = createMemo(() => { const b = crop(); - return b.width >= MIN_SIZE.width && b.height >= MIN_SIZE.height; + const min = minSize(); + return b.width >= min.width && b.height >= min.height; }); const [targetState, setTargetState] = createSignal<{ @@ -751,7 +756,7 @@ function Inner() { }); return ( -
+
-

Minimum size is 150 x 150

+

+ Minimum size is {minSize().width} x {minSize().height} +

{crop().width} x {crop().height} diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index b7b805ec97..94d9c77564 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -472,7 +472,7 @@ export type SceneSegment = { start: number; end: number; mode?: SceneMode } export type ScreenCaptureTarget = { variant: "window"; id: WindowId } | { variant: "display"; id: DisplayId } | { variant: "area"; screen: DisplayId; bounds: LogicalBounds } export type SegmentRecordings = { display: Video; camera: Video | null; mic: Audio | null; system_audio: Audio | null } export type SerializedEditorInstance = { framesSocketUrl: string; recordingDuration: number; savedProjectConfig: ProjectConfiguration; recordings: ProjectRecordingsMeta; path: string } -export type SerializedScreenshotEditorInstance = { framesSocketUrl: string; path: string; config: ProjectConfiguration | null } +export type SerializedScreenshotEditorInstance = { framesSocketUrl: string; path: string; config: ProjectConfiguration | null; prettyName: string } export type SetCaptureAreaPending = boolean export type ShadowConfiguration = { size: number; opacity: number; blur: number } export type SharingMeta = { id: string; link: string } diff --git a/crates/audio/src/latency.rs b/crates/audio/src/latency.rs index 899d88e669..c948e57e51 100644 --- a/crates/audio/src/latency.rs +++ b/crates/audio/src/latency.rs @@ -440,8 +440,7 @@ pub fn default_output_latency_hint( #[cfg(not(target_os = "macos"))] { let fallback = (buffer_size_frames as f64 / sample_rate as f64) - .max(FALLBACK_WIRED_LATENCY_SECS) - .min(MAX_LATENCY_SECS); + .clamp(FALLBACK_WIRED_LATENCY_SECS, MAX_LATENCY_SECS); Some(OutputLatencyHint::new( fallback, OutputTransportKind::Unknown, diff --git a/crates/camera-directshow/src/lib.rs b/crates/camera-directshow/src/lib.rs index 40690d8299..9563e20b58 100644 --- a/crates/camera-directshow/src/lib.rs +++ b/crates/camera-directshow/src/lib.rs @@ -4,11 +4,11 @@ use std::{ cell::RefCell, ffi::{OsString, c_void}, - mem::{ManuallyDrop, MaybeUninit}, + mem::ManuallyDrop, ops::Deref, os::windows::ffi::OsStringExt, ptr::{self, null, null_mut}, - time::{Duration, Instant}, + time::Duration, }; use tracing::*; use windows::{ @@ -563,10 +563,10 @@ impl AMMediaType { Self(unsafe { copy_media_type(typ) }) } - pub fn into_inner(mut self) -> AM_MEDIA_TYPE { - // SAFETY: Getting the inner value without triggering Drop - let inner = std::mem::replace(&mut self.0, unsafe { MaybeUninit::uninit().assume_init() }); - std::mem::forget(self); + pub fn into_inner(self) -> AM_MEDIA_TYPE { + let wrapper = self; + let inner = unsafe { ptr::read(&wrapper.0) }; + std::mem::forget(wrapper); inner } } @@ -614,7 +614,6 @@ impl SinkFilter { current_media_type: Default::default(), connected_pin: Default::default(), owner: Default::default(), - first_ref_time: Default::default(), } .into(), } @@ -801,7 +800,6 @@ struct SinkInputPin { connected_pin: RefCell>, owner: RefCell>, callback: RefCell, - first_ref_time: RefCell>, } // impl SinkInputPin { diff --git a/crates/camera-ffmpeg/src/macos.rs b/crates/camera-ffmpeg/src/macos.rs index 1ebd280172..80f263be10 100644 --- a/crates/camera-ffmpeg/src/macos.rs +++ b/crates/camera-ffmpeg/src/macos.rs @@ -8,6 +8,12 @@ use crate::CapturedFrameExt; pub enum AsFFmpegError { #[error("Unsupported media subtype '{0}'")] UnsupportedSubType(String), + #[error("Insufficient plane count for format '{format}': expected {expected}, found {found}")] + InsufficientPlaneCount { + format: String, + expected: usize, + found: usize, + }, #[error("{0}")] Native(#[from] cidre::os::Error), } @@ -26,7 +32,6 @@ impl CapturedFrameExt for CapturedFrame { let bytes_lock = ImageBufExt::base_addr_lock(this.as_mut(), cv::pixel_buffer::LockFlags::READ_ONLY)?; - // These are the only formats supported by Chromium, surely we'll be fine let res = match cidre::four_cc_to_str(&mut format_desc.media_sub_type().to_be_bytes()) { "2vuy" => { let mut ff_frame = ffmpeg::frame::Video::new( @@ -51,7 +56,7 @@ impl CapturedFrameExt for CapturedFrame { ff_frame } - "420v" => { + "420v" | "420f" => { let mut ff_frame = ffmpeg::frame::Video::new( ffmpeg::format::Pixel::NV12, width as u32, @@ -111,6 +116,110 @@ impl CapturedFrameExt for CapturedFrame { ff_frame } + "BGRA" => { + let mut ff_frame = ffmpeg::frame::Video::new( + ffmpeg::format::Pixel::BGRA, + width as u32, + height as u32, + ); + + let src_stride = native.image_buf().plane_bytes_per_row(0); + let dest_stride = ff_frame.stride(0); + + let src_bytes = bytes_lock.plane_data(0); + let dest_bytes = &mut ff_frame.data_mut(0); + + for y in 0..height { + let row_width = width * 4; + let src_row = &src_bytes[y * src_stride..y * src_stride + row_width]; + let dest_row = &mut dest_bytes[y * dest_stride..y * dest_stride + row_width]; + + dest_row.copy_from_slice(src_row); + } + + ff_frame + } + "ARGB" => { + let mut ff_frame = ffmpeg::frame::Video::new( + ffmpeg::format::Pixel::ARGB, + width as u32, + height as u32, + ); + + let src_stride = native.image_buf().plane_bytes_per_row(0); + let dest_stride = ff_frame.stride(0); + + let src_bytes = bytes_lock.plane_data(0); + let dest_bytes = &mut ff_frame.data_mut(0); + + for y in 0..height { + let row_width = width * 4; + let src_row = &src_bytes[y * src_stride..y * src_stride + row_width]; + let dest_row = &mut dest_bytes[y * dest_stride..y * dest_stride + row_width]; + + dest_row.copy_from_slice(src_row); + } + + ff_frame + } + "24BG" => { + let mut ff_frame = ffmpeg::frame::Video::new( + ffmpeg::format::Pixel::BGR24, + width as u32, + height as u32, + ); + + let src_stride = native.image_buf().plane_bytes_per_row(0); + let dest_stride = ff_frame.stride(0); + + let src_bytes = bytes_lock.plane_data(0); + let dest_bytes = &mut ff_frame.data_mut(0); + + for y in 0..height { + let row_width = width * 3; + let src_row = &src_bytes[y * src_stride..y * src_stride + row_width]; + let dest_row = &mut dest_bytes[y * dest_stride..y * dest_stride + row_width]; + + dest_row.copy_from_slice(src_row); + } + + ff_frame + } + "y420" => { + let plane_count = native.image_buf().plane_count(); + if plane_count < 3 { + return Err(AsFFmpegError::InsufficientPlaneCount { + format: "y420".to_string(), + expected: 3, + found: plane_count, + }); + } + + let mut ff_frame = ffmpeg::frame::Video::new( + ffmpeg::format::Pixel::YUV420P, + width as u32, + height as u32, + ); + + for plane in 0..3 { + let src_stride = native.image_buf().plane_bytes_per_row(plane); + let dest_stride = ff_frame.stride(plane); + let plane_height = native.image_buf().plane_height(plane); + + let src_bytes = bytes_lock.plane_data(plane); + let dest_bytes = &mut ff_frame.data_mut(plane); + + let row_width = native.image_buf().plane_width(plane); + for y in 0..plane_height { + let src_row = &src_bytes[y * src_stride..y * src_stride + row_width]; + let dest_row = + &mut dest_bytes[y * dest_stride..y * dest_stride + row_width]; + dest_row.copy_from_slice(src_row); + } + } + + ff_frame + } format => { return Err(AsFFmpegError::UnsupportedSubType(format.to_string())); } diff --git a/crates/camera-ffmpeg/src/windows.rs b/crates/camera-ffmpeg/src/windows.rs index 5b34ea60a4..cfcb075f80 100644 --- a/crates/camera-ffmpeg/src/windows.rs +++ b/crates/camera-ffmpeg/src/windows.rs @@ -1,6 +1,6 @@ use cap_camera::CapturedFrame; use cap_camera_windows::PixelFormat; -use ffmpeg::{format::Pixel, frame::Video as FFVideo}; +use ffmpeg::{Packet, format::Pixel, frame::Video as FFVideo}; use crate::CapturedFrameExt; @@ -10,6 +10,32 @@ pub enum AsFFmpegError { FailedToGetBytes(windows_core::Error), #[error("Empty")] Empty, + #[error("MJPEG decode error: {0}")] + MjpegDecodeError(String), +} + +fn decode_mjpeg(bytes: &[u8]) -> Result { + let codec = ffmpeg::codec::decoder::find(ffmpeg::codec::Id::MJPEG) + .ok_or_else(|| AsFFmpegError::MjpegDecodeError("MJPEG codec not found".to_string()))?; + + let decoder_context = ffmpeg::codec::context::Context::new_with_codec(codec); + + let mut decoder = decoder_context + .decoder() + .video() + .map_err(|e| AsFFmpegError::MjpegDecodeError(format!("Failed to create decoder: {e}")))?; + + let packet = Packet::copy(bytes); + decoder + .send_packet(&packet) + .map_err(|e| AsFFmpegError::MjpegDecodeError(format!("Failed to send packet: {e}")))?; + + let mut decoded_frame = FFVideo::empty(); + decoder + .receive_frame(&mut decoded_frame) + .map_err(|e| AsFFmpegError::MjpegDecodeError(format!("Failed to receive frame: {e}")))?; + + Ok(decoded_frame) } impl CapturedFrameExt for CapturedFrame { @@ -24,7 +50,7 @@ impl CapturedFrameExt for CapturedFrame { let bytes = native.bytes().map_err(AsFFmpegError::FailedToGetBytes)?; - if bytes.len() == 0 { + if bytes.is_empty() { return Err(AsFFmpegError::Empty); } @@ -89,12 +115,7 @@ impl CapturedFrameExt for CapturedFrame { ff_frame } PixelFormat::ARGB => { - let mut ff_frame = FFVideo::new( - // ik it's weird but that's how windows works - Pixel::BGRA, - width as u32, - height as u32, - ); + let mut ff_frame = FFVideo::new(Pixel::BGRA, width as u32, height as u32); let stride = ff_frame.stride(0); @@ -109,22 +130,22 @@ impl CapturedFrameExt for CapturedFrame { ff_frame } PixelFormat::RGB24 => { - let mut ff_frame = FFVideo::new(Pixel::RGB24, width as u32, height as u32); + let mut ff_frame = FFVideo::new(Pixel::BGR24, width as u32, height as u32); let stride = ff_frame.stride(0); + let src_stride = width * 3; for y in 0..height { - let row_width = width * 4; - let src_row = &bytes[y * row_width..]; + let src_row = &bytes[(height - y - 1) * src_stride..]; let dest_row = &mut ff_frame.data_mut(0)[y * stride..]; - dest_row[0..row_width].copy_from_slice(&src_row[0..row_width]); + dest_row[0..src_stride].copy_from_slice(&src_row[0..src_stride]); } ff_frame } PixelFormat::RGB32 => { - let mut ff_frame = FFVideo::new(Pixel::RGB32, width as u32, height as u32); + let mut ff_frame = FFVideo::new(Pixel::BGRA, width as u32, height as u32); let stride = ff_frame.stride(0); @@ -166,6 +187,49 @@ impl CapturedFrameExt for CapturedFrame { dest_row[0..row_width].copy_from_slice(&src_row[0..row_width]); } + ff_frame + } + PixelFormat::MJPEG => decode_mjpeg(&bytes)?, + PixelFormat::YV12 => { + let mut ff_frame = FFVideo::new(Pixel::YUV420P, width as u32, height as u32); + + let stride = ff_frame.stride(0); + for y in 0..height { + let row_width = width; + let src_row = &bytes[y * row_width..]; + let dest_row = &mut ff_frame.data_mut(0)[y * stride..]; + dest_row[0..row_width].copy_from_slice(&src_row[0..row_width]); + } + + let v_offset = width * height; + let u_offset = v_offset + (width / 2) * (height / 2); + let stride_u = ff_frame.stride(1); + let stride_v = ff_frame.stride(2); + + for y in 0..height / 2 { + let row_width = width / 2; + let src_v = &bytes[v_offset + y * row_width..]; + let src_u = &bytes[u_offset + y * row_width..]; + ff_frame.data_mut(1)[y * stride_u..][0..row_width] + .copy_from_slice(&src_u[0..row_width]); + ff_frame.data_mut(2)[y * stride_v..][0..row_width] + .copy_from_slice(&src_v[0..row_width]); + } + + ff_frame + } + PixelFormat::BGR24 => { + let mut ff_frame = FFVideo::new(Pixel::BGR24, width as u32, height as u32); + + let stride = ff_frame.stride(0); + let src_stride = width * 3; + + for y in 0..height { + let src_row = &bytes[(height - y - 1) * src_stride..]; + let dest_row = &mut ff_frame.data_mut(0)[y * stride..]; + dest_row[0..src_stride].copy_from_slice(&src_row[0..src_stride]); + } + ff_frame } }) diff --git a/crates/camera-windows/src/lib.rs b/crates/camera-windows/src/lib.rs index 785a09df4d..fb8b41eb4d 100644 --- a/crates/camera-windows/src/lib.rs +++ b/crates/camera-windows/src/lib.rs @@ -223,20 +223,16 @@ pub enum FrameInner { #[derive(Debug, Clone, Copy)] pub enum PixelFormat { - /// Packed ARGB, - /// Packed RGB24, - /// Packed RGB32, - /// Planar (3) YUV420P, - /// Planar (2) NV12, - /// Packed YUYV422, - /// Packed UYVY422, + MJPEG, + YV12, + BGR24, } #[derive(Clone)] @@ -464,6 +460,8 @@ impl MFPixelFormat { t if t == MFVideoFormat_UYVY => PixelFormat::UYVY422, t if t == MFVideoFormat_ARGB32 => PixelFormat::ARGB, t if t == MFVideoFormat_NV12 => PixelFormat::NV12, + t if t == MFVideoFormat_MJPG => PixelFormat::MJPEG, + t if t == MFVideoFormat_YV12 => PixelFormat::YV12, _ => return None, }) }; @@ -510,6 +508,8 @@ impl DSPixelFormat { t if t == MEDIASUBTYPE_UYVY => PixelFormat::UYVY422, t if t == MEDIASUBTYPE_ARGB32 => PixelFormat::ARGB, t if t == MEDIASUBTYPE_NV12 => PixelFormat::NV12, + t if t == MEDIASUBTYPE_MJPG => PixelFormat::MJPEG, + t if t == MEDIASUBTYPE_YV12 => PixelFormat::YV12, _ => return None, }) }; diff --git a/crates/cursor-capture/src/position.rs b/crates/cursor-capture/src/position.rs index 8ff1c12c07..bb4ea75719 100644 --- a/crates/cursor-capture/src/position.rs +++ b/crates/cursor-capture/src/position.rs @@ -38,11 +38,11 @@ impl RelativeCursorPosition { { let physical_bounds = display.raw_handle().physical_bounds()?; - return Some(Self { + Some(Self { x: raw.x - physical_bounds.position().x() as i32, y: raw.y - physical_bounds.position().y() as i32, display, - }); + }) } #[cfg(target_os = "macos")] diff --git a/crates/enc-ffmpeg/src/video/h264.rs b/crates/enc-ffmpeg/src/video/h264.rs index 78e0099a0a..8f8614da4d 100644 --- a/crates/enc-ffmpeg/src/video/h264.rs +++ b/crates/enc-ffmpeg/src/video/h264.rs @@ -166,7 +166,9 @@ impl H264EncoderBuilder { format }; - if is_420(output_format) && (output_width % 2 != 0 || output_height % 2 != 0) { + if is_420(output_format) + && (!output_width.is_multiple_of(2) || !output_height.is_multiple_of(2)) + { return Err(H264EncoderError::InvalidOutputDimensions { width: output_width, height: output_height, diff --git a/crates/enc-mediafoundation/src/d3d.rs b/crates/enc-mediafoundation/src/d3d.rs index 81bf3917ee..e06e163840 100644 --- a/crates/enc-mediafoundation/src/d3d.rs +++ b/crates/enc-mediafoundation/src/d3d.rs @@ -46,10 +46,10 @@ pub fn create_d3d_device() -> Result { flags }; let mut result = create_d3d_device_with_type(D3D_DRIVER_TYPE_HARDWARE, flags, &mut device); - if let Err(error) = &result { - if error.code() == DXGI_ERROR_UNSUPPORTED { - result = create_d3d_device_with_type(D3D_DRIVER_TYPE_WARP, flags, &mut device); - } + if let Err(error) = &result + && error.code() == DXGI_ERROR_UNSUPPORTED + { + result = create_d3d_device_with_type(D3D_DRIVER_TYPE_WARP, flags, &mut device); } result?; Ok(device.unwrap()) diff --git a/crates/enc-mediafoundation/src/media.rs b/crates/enc-mediafoundation/src/media.rs index d7f57a9f22..1d8e418c03 100644 --- a/crates/enc-mediafoundation/src/media.rs +++ b/crates/enc-mediafoundation/src/media.rs @@ -22,7 +22,7 @@ unsafe fn MFSetAttribute2UINT32asUINT64( unsafe { attributes.SetUINT64(key, pack_2_u32_as_u64(high, low)) } } -#[allow(non_snake_case)] +#[allow(non_snake_case, clippy::missing_safety_doc)] pub unsafe fn MFSetAttributeSize( attributes: &IMFAttributes, key: &GUID, @@ -32,7 +32,7 @@ pub unsafe fn MFSetAttributeSize( unsafe { MFSetAttribute2UINT32asUINT64(attributes, key, width, height) } } -#[allow(non_snake_case)] +#[allow(non_snake_case, clippy::missing_safety_doc)] pub unsafe fn MFSetAttributeRatio( attributes: &IMFAttributes, key: &GUID, diff --git a/crates/enc-mediafoundation/src/video/h264.rs b/crates/enc-mediafoundation/src/video/h264.rs index 43a8b39f28..08c52fb4ee 100644 --- a/crates/enc-mediafoundation/src/video/h264.rs +++ b/crates/enc-mediafoundation/src/video/h264.rs @@ -11,7 +11,7 @@ use windows::{ Foundation::TimeSpan, Graphics::SizeInt32, Win32::{ - Foundation::{E_FAIL, E_NOTIMPL}, + Foundation::E_NOTIMPL, Graphics::{ Direct3D11::{ID3D11Device, ID3D11Texture2D}, Dxgi::Common::{DXGI_FORMAT, DXGI_FORMAT_NV12}, @@ -101,6 +101,7 @@ pub enum HandleNeedsInputError { unsafe impl Send for H264Encoder {} impl H264Encoder { + #[allow(clippy::too_many_arguments)] fn new_with_scaled_output_with_flags( d3d_device: &ID3D11Device, format: DXGI_FORMAT, @@ -210,7 +211,10 @@ impl H264Encoder { unsafe { let temp = media_device_manager.clone(); transform - .ProcessMessage(MFT_MESSAGE_SET_D3D_MANAGER, std::mem::transmute(temp)) + .ProcessMessage( + MFT_MESSAGE_SET_D3D_MANAGER, + std::mem::transmute::<_, usize>(temp), + ) .map_err(NewVideoEncoderError::EncoderTransform)?; }; @@ -239,10 +243,10 @@ impl H264Encoder { let mut count = 0; loop { let result = transform.GetInputAvailableType(input_stream_id, count); - if let Err(error) = &result { - if error.code() == MF_E_NO_MORE_TYPES { - break Ok(None); - } + if let Err(error) = &result + && error.code() == MF_E_NO_MORE_TYPES + { + break Ok(None); } let input_type = result?; @@ -261,11 +265,11 @@ impl H264Encoder { &input_type, MFT_SET_TYPE_TEST_ONLY.0 as u32, ); - if let Err(error) = &result { - if error.code() == MF_E_INVALIDMEDIATYPE { - count += 1; - continue; - } + if let Err(error) = &result + && error.code() == MF_E_INVALIDMEDIATYPE + { + count += 1; + continue; } result?; break Ok(Some(input_type)); @@ -404,24 +408,24 @@ impl H264Encoder { match event_type { MediaFoundation::METransformNeedInput => { should_exit = true; - if !should_stop.load(Ordering::SeqCst) { - if let Some((texture, timestamp)) = get_frame()? { - self.video_processor.process_texture(&texture)?; - let input_buffer = { - MFCreateDXGISurfaceBuffer( - &ID3D11Texture2D::IID, - self.video_processor.output_texture(), - 0, - false, - )? - }; - let mf_sample = MFCreateSample()?; - mf_sample.AddBuffer(&input_buffer)?; - mf_sample.SetSampleTime(timestamp.Duration)?; - self.transform - .ProcessInput(self.input_stream_id, &mf_sample, 0)?; - should_exit = false; - } + if !should_stop.load(Ordering::SeqCst) + && let Some((texture, timestamp)) = get_frame()? + { + self.video_processor.process_texture(&texture)?; + let input_buffer = { + MFCreateDXGISurfaceBuffer( + &ID3D11Texture2D::IID, + self.video_processor.output_texture(), + 0, + false, + )? + }; + let mf_sample = MFCreateSample()?; + mf_sample.AddBuffer(&input_buffer)?; + mf_sample.SetSampleTime(timestamp.Duration)?; + self.transform + .ProcessInput(self.input_stream_id, &mf_sample, 0)?; + should_exit = false; } } MediaFoundation::METransformHaveOutput => { diff --git a/crates/frame-converter/src/pool.rs b/crates/frame-converter/src/pool.rs index e90defc684..c0a93f146a 100644 --- a/crates/frame-converter/src/pool.rs +++ b/crates/frame-converter/src/pool.rs @@ -185,7 +185,7 @@ impl AsyncConverterPool { Err(flume::TrySendError::Full(_)) => { self.stats.frames_dropped.fetch_add(1, Ordering::Relaxed); let dropped = self.stats.frames_dropped.load(Ordering::Relaxed); - if dropped % 30 == 0 { + if dropped.is_multiple_of(30) { warn!( "Converter pool input full, dropped {} frames so far", dropped diff --git a/crates/frame-converter/src/videotoolbox.rs b/crates/frame-converter/src/videotoolbox.rs index 6703dad042..6420085b1c 100644 --- a/crates/frame-converter/src/videotoolbox.rs +++ b/crates/frame-converter/src/videotoolbox.rs @@ -18,6 +18,8 @@ const K_CV_RETURN_SUCCESS: i32 = 0; const K_CV_PIXEL_FORMAT_TYPE_422_YP_CB_YP_CR8: u32 = 0x79757679; const K_CV_PIXEL_FORMAT_TYPE_420_YP_CB_CR8_BI_PLANAR_VIDEO_RANGE: u32 = 0x34323076; const K_CV_PIXEL_FORMAT_TYPE_2VUY: u32 = 0x32767579; +const K_CV_PIXEL_FORMAT_TYPE_32_BGRA: u32 = 0x42475241; +const K_CV_PIXEL_FORMAT_TYPE_32_ARGB: u32 = 0x00000020; #[link(name = "CoreFoundation", kind = "framework")] unsafe extern "C" { @@ -57,6 +59,9 @@ unsafe extern "C" { fn CVPixelBufferGetBytesPerRowOfPlane(pixel_buffer: CVPixelBufferRef, plane: usize) -> usize; fn CVPixelBufferGetHeightOfPlane(pixel_buffer: CVPixelBufferRef, plane: usize) -> usize; fn CVPixelBufferGetPlaneCount(pixel_buffer: CVPixelBufferRef) -> usize; + fn CVPixelBufferGetBaseAddress(pixel_buffer: CVPixelBufferRef) -> *mut u8; + fn CVPixelBufferGetBytesPerRow(pixel_buffer: CVPixelBufferRef) -> usize; + fn CVPixelBufferGetHeight(pixel_buffer: CVPixelBufferRef) -> usize; } #[link(name = "VideoToolbox", kind = "framework")] @@ -80,6 +85,8 @@ fn pixel_to_cv_format(pixel: Pixel) -> Option { Pixel::YUYV422 => Some(K_CV_PIXEL_FORMAT_TYPE_422_YP_CB_YP_CR8), Pixel::UYVY422 => Some(K_CV_PIXEL_FORMAT_TYPE_2VUY), Pixel::NV12 => Some(K_CV_PIXEL_FORMAT_TYPE_420_YP_CB_CR8_BI_PLANAR_VIDEO_RANGE), + Pixel::BGRA => Some(K_CV_PIXEL_FORMAT_TYPE_32_BGRA), + Pixel::ARGB => Some(K_CV_PIXEL_FORMAT_TYPE_32_ARGB), _ => None, } } @@ -227,13 +234,13 @@ impl VideoToolboxConverter { unsafe { let plane_count = CVPixelBufferGetPlaneCount(pixel_buffer); - for plane in 0..plane_count { - let src_ptr = CVPixelBufferGetBaseAddressOfPlane(pixel_buffer, plane); - let src_stride = CVPixelBufferGetBytesPerRowOfPlane(pixel_buffer, plane); - let height = CVPixelBufferGetHeightOfPlane(pixel_buffer, plane); - let dst_stride = output.stride(plane); + if plane_count == 0 { + let src_ptr = CVPixelBufferGetBaseAddress(pixel_buffer); + let src_stride = CVPixelBufferGetBytesPerRow(pixel_buffer); + let height = CVPixelBufferGetHeight(pixel_buffer); + let dst_stride = output.stride(0); - let dst_data = output.data_mut(plane); + let dst_data = output.data_mut(0); let dst_ptr = dst_data.as_mut_ptr(); for row in 0..height { @@ -242,6 +249,23 @@ impl VideoToolboxConverter { let copy_len = src_stride.min(dst_stride); ptr::copy_nonoverlapping(src_row, dst_row, copy_len); } + } else { + for plane in 0..plane_count { + let src_ptr = CVPixelBufferGetBaseAddressOfPlane(pixel_buffer, plane); + let src_stride = CVPixelBufferGetBytesPerRowOfPlane(pixel_buffer, plane); + let height = CVPixelBufferGetHeightOfPlane(pixel_buffer, plane); + let dst_stride = output.stride(plane); + + let dst_data = output.data_mut(plane); + let dst_ptr = dst_data.as_mut_ptr(); + + for row in 0..height { + let src_row = src_ptr.add(row * src_stride); + let dst_row = dst_ptr.add(row * dst_stride); + let copy_len = src_stride.min(dst_stride); + ptr::copy_nonoverlapping(src_row, dst_row, copy_len); + } + } } CVPixelBufferUnlockBaseAddress(pixel_buffer, 0); diff --git a/crates/mediafoundation-ffmpeg/src/audio.rs b/crates/mediafoundation-ffmpeg/src/audio.rs index 444144b6b0..b30bb3fec6 100644 --- a/crates/mediafoundation-ffmpeg/src/audio.rs +++ b/crates/mediafoundation-ffmpeg/src/audio.rs @@ -22,7 +22,7 @@ impl AudioExt for ffmpeg::frame::Audio { unsafe { buffer.Lock(&mut buffer_ptr, None, None)? }; unsafe { - std::ptr::copy_nonoverlapping(self.data(0).as_ptr(), buffer_ptr, length as usize); + std::ptr::copy_nonoverlapping(self.data(0).as_ptr(), buffer_ptr, length); } unsafe { buffer.SetCurrentLength(length as u32)? } diff --git a/crates/recording/src/feeds/camera.rs b/crates/recording/src/feeds/camera.rs index 62d4044430..44cdaa5dc4 100644 --- a/crates/recording/src/feeds/camera.rs +++ b/crates/recording/src/feeds/camera.rs @@ -506,7 +506,7 @@ async fn setup_camera( })) .try_send(); - if callback_num % 30 == 0 { + if callback_num.is_multiple_of(30) { tracing::debug!( "Camera callback: sent frame {} to actor, result={:?}", callback_num, @@ -514,7 +514,7 @@ async fn setup_camera( ); } - if send_result.is_err() && callback_num % 30 == 0 { + if send_result.is_err() && callback_num.is_multiple_of(30) { tracing::warn!( "Camera callback: failed to send frame {} to actor (mailbox full?)", callback_num @@ -612,7 +612,7 @@ async fn setup_camera( })) .try_send(); - if callback_num % 30 == 0 { + if callback_num.is_multiple_of(30) { tracing::debug!( "Camera callback: sent frame {} to actor, result={:?}", callback_num, @@ -620,7 +620,7 @@ async fn setup_camera( ); } - if send_result.is_err() && callback_num % 30 == 0 { + if send_result.is_err() && callback_num.is_multiple_of(30) { tracing::warn!( "Camera callback: failed to send frame {} to actor (mailbox full?)", callback_num @@ -789,7 +789,7 @@ impl Message for CameraFeed { async fn handle(&mut self, msg: NewFrame, _: &mut Context) -> Self::Reply { let frame_num = CAMERA_FRAME_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if frame_num % 30 == 0 { + if frame_num.is_multiple_of(30) { debug!( "CameraFeed: received frame {}, broadcasting to {} senders", frame_num, @@ -803,7 +803,7 @@ impl Message for CameraFeed { match sender.try_send(msg.0.clone()) { Ok(()) => {} Err(flume::TrySendError::Full(_)) => { - if frame_num % 30 == 0 { + if frame_num.is_multiple_of(30) { warn!( "Camera sender {} channel full at frame {}, dropping frame", i, frame_num @@ -843,7 +843,7 @@ impl Message for CameraFeed { let frame_num = NATIVE_CAMERA_FRAME_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if frame_num % 30 == 0 { + if frame_num.is_multiple_of(30) { debug!( "CameraFeed: received native frame {}, broadcasting to {} native senders", frame_num, @@ -857,7 +857,7 @@ impl Message for CameraFeed { match sender.try_send(msg.0.clone()) { Ok(()) => {} Err(flume::TrySendError::Full(_)) => { - if frame_num % 30 == 0 { + if frame_num.is_multiple_of(30) { warn!( "Native camera sender {} channel full at frame {}, dropping frame", i, frame_num diff --git a/crates/recording/src/output_pipeline/async_camera.rs b/crates/recording/src/output_pipeline/async_camera.rs index fd458eeec2..e9ae052428 100644 --- a/crates/recording/src/output_pipeline/async_camera.rs +++ b/crates/recording/src/output_pipeline/async_camera.rs @@ -267,7 +267,7 @@ impl VideoMuxer for AsyncCameraMp4Muxer { } } - if self.frames_submitted % 60 == 0 { + if self.frames_submitted.is_multiple_of(60) { trace!( "Camera encoder progress: submitted={}, encoded={}, backlog={}", self.frames_submitted, self.frames_encoded, backlog diff --git a/crates/recording/src/output_pipeline/win.rs b/crates/recording/src/output_pipeline/win.rs index 3dc0dbf184..4948303236 100644 --- a/crates/recording/src/output_pipeline/win.rs +++ b/crates/recording/src/output_pipeline/win.rs @@ -358,12 +358,11 @@ impl VideoMuxer for WindowsMuxer { impl AudioMuxer for WindowsMuxer { fn send_audio_frame(&mut self, frame: AudioFrame, timestamp: Duration) -> anyhow::Result<()> { - if let Some(timestamp) = self.pause.adjust(timestamp)? { - if let Some(encoder) = self.audio_encoder.as_mut() - && let Ok(mut output) = self.output.lock() - { - encoder.send_frame(frame.inner, timestamp, &mut output)?; - } + if let Some(timestamp) = self.pause.adjust(timestamp)? + && let Some(encoder) = self.audio_encoder.as_mut() + && let Ok(mut output) = self.output.lock() + { + encoder.send_frame(frame.inner, timestamp, &mut output)?; } Ok(()) @@ -682,12 +681,11 @@ impl VideoMuxer for WindowsCameraMuxer { impl AudioMuxer for WindowsCameraMuxer { fn send_audio_frame(&mut self, frame: AudioFrame, timestamp: Duration) -> anyhow::Result<()> { - if let Some(timestamp) = self.pause.adjust(timestamp)? { - if let Some(encoder) = self.audio_encoder.as_mut() - && let Ok(mut output) = self.output.lock() - { - encoder.send_frame(frame.inner, timestamp, &mut output)?; - } + if let Some(timestamp) = self.pause.adjust(timestamp)? + && let Some(encoder) = self.audio_encoder.as_mut() + && let Ok(mut output) = self.output.lock() + { + encoder.send_frame(frame.inner, timestamp, &mut output)?; } Ok(()) diff --git a/crates/recording/src/screenshot.rs b/crates/recording/src/screenshot.rs index 7fa33ba8a3..21754e4aeb 100644 --- a/crates/recording/src/screenshot.rs +++ b/crates/recording/src/screenshot.rs @@ -410,8 +410,7 @@ fn capture_bitmap_with( #[cfg(target_os = "windows")] fn bgra_to_rgb(buffer: Vec, width: usize, height: usize) -> anyhow::Result { let stride = width.checked_mul(4).ok_or_else(unsupported_error)?; - rgb_from_rgba(&buffer, width, height, stride, ChannelOrder::Bgra) - .ok_or_else(|| unsupported_error()) + rgb_from_rgba(&buffer, width, height, stride, ChannelOrder::Bgra).ok_or_else(unsupported_error) } #[cfg(target_os = "windows")] @@ -500,22 +499,22 @@ fn capture_window_print(hwnd: HWND, width: i32, height: i32) -> anyhow::Result anyhow::Result { match target { ScreenCaptureTarget::Display { id } => { - let display = scap_targets::Display::from_id(&id).ok_or_else(|| unsupported_error())?; + let display = scap_targets::Display::from_id(&id).ok_or_else(unsupported_error)?; let bounds = display .raw_handle() .physical_bounds() - .ok_or_else(|| unsupported_error())?; + .ok_or_else(unsupported_error)?; let image = capture_display_bounds(bounds)?; debug!("Windows GDI display capture"); Ok(image) } ScreenCaptureTarget::Window { id } => { - let window = scap_targets::Window::from_id(&id).ok_or_else(|| unsupported_error())?; + let window = scap_targets::Window::from_id(&id).ok_or_else(unsupported_error)?; let bounds = window .raw_handle() .physical_bounds() - .ok_or_else(|| unsupported_error())?; + .ok_or_else(unsupported_error)?; let width = bounds.size().width().round() as i32; let height = bounds.size().height().round() as i32; @@ -534,12 +533,11 @@ fn capture_screenshot_fallback(target: ScreenCaptureTarget) -> anyhow::Result { - let display = - scap_targets::Display::from_id(&screen).ok_or_else(|| unsupported_error())?; + let display = scap_targets::Display::from_id(&screen).ok_or_else(unsupported_error)?; let bounds = display .raw_handle() .physical_bounds() - .ok_or_else(|| unsupported_error())?; + .ok_or_else(unsupported_error)?; let image = capture_display_bounds(bounds)?; debug!("Windows GDI area capture"); @@ -642,7 +640,7 @@ pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result>(); let tx = Arc::new(Mutex::new(Some(tx))); diff --git a/crates/recording/src/sources/camera.rs b/crates/recording/src/sources/camera.rs index be2de19f25..8d9de98b8e 100644 --- a/crates/recording/src/sources/camera.rs +++ b/crates/recording/src/sources/camera.rs @@ -69,7 +69,7 @@ impl VideoSource for Camera { match video_tx.try_send(frame) { Ok(()) => { sent_count += 1; - if sent_count % 30 == 0 { + if sent_count.is_multiple_of(30) { tracing::debug!( "Camera source: sent {} frames, dropped {} in {:?}", sent_count, @@ -81,7 +81,7 @@ impl VideoSource for Camera { Err(e) => { if e.is_full() { dropped_count += 1; - if dropped_count % 30 == 0 { + if dropped_count.is_multiple_of(30) { tracing::warn!( "Camera source: encoder can't keep up, dropped {} frames so far", dropped_count diff --git a/crates/recording/src/sources/microphone.rs b/crates/recording/src/sources/microphone.rs index 63f6ff8c68..bb88608f29 100644 --- a/crates/recording/src/sources/microphone.rs +++ b/crates/recording/src/sources/microphone.rs @@ -124,12 +124,10 @@ fn downmix_to_mono(data: &[u8], format: SampleFormat, source_channels: usize) -> fn sample_format_size(format: SampleFormat) -> Option { Some(match format { - SampleFormat::U8 => 1, - SampleFormat::I16 => 2, - SampleFormat::I32 => 4, - SampleFormat::I64 => 8, - SampleFormat::F32 => 4, - SampleFormat::F64 => 8, + SampleFormat::I8 | SampleFormat::U8 => 1, + SampleFormat::I16 | SampleFormat::U16 => 2, + SampleFormat::I32 | SampleFormat::U32 | SampleFormat::F32 => 4, + SampleFormat::I64 | SampleFormat::U64 | SampleFormat::F64 => 8, _ => return None, }) } @@ -152,22 +150,38 @@ fn average_frame_sample( fn sample_to_f64(format: SampleFormat, bytes: &[u8]) -> Option { match format { + SampleFormat::I8 => bytes.first().copied().map(|v| v as i8 as f64), SampleFormat::U8 => bytes.first().copied().map(|v| v as f64), SampleFormat::I16 => { let mut buf = [0u8; 2]; buf.copy_from_slice(bytes); Some(i16::from_ne_bytes(buf) as f64) } + SampleFormat::U16 => { + let mut buf = [0u8; 2]; + buf.copy_from_slice(bytes); + Some(u16::from_ne_bytes(buf) as f64) + } SampleFormat::I32 => { let mut buf = [0u8; 4]; buf.copy_from_slice(bytes); Some(i32::from_ne_bytes(buf) as f64) } + SampleFormat::U32 => { + let mut buf = [0u8; 4]; + buf.copy_from_slice(bytes); + Some(u32::from_ne_bytes(buf) as f64) + } SampleFormat::I64 => { let mut buf = [0u8; 8]; buf.copy_from_slice(bytes); Some(i64::from_ne_bytes(buf) as f64) } + SampleFormat::U64 => { + let mut buf = [0u8; 8]; + buf.copy_from_slice(bytes); + Some(u64::from_ne_bytes(buf) as f64) + } SampleFormat::F32 => { let mut buf = [0u8; 4]; buf.copy_from_slice(bytes); @@ -184,6 +198,10 @@ fn sample_to_f64(format: SampleFormat, bytes: &[u8]) -> Option { fn write_sample_from_f64(format: SampleFormat, value: f64, out: &mut [u8]) { match format { + SampleFormat::I8 => { + let sample = value.round().clamp(i8::MIN as f64, i8::MAX as f64) as i8; + out[0] = sample as u8; + } SampleFormat::U8 => { let sample = value.round().clamp(u8::MIN as f64, u8::MAX as f64) as u8; out[0] = sample; @@ -192,14 +210,26 @@ fn write_sample_from_f64(format: SampleFormat, value: f64, out: &mut [u8]) { let sample = value.round().clamp(i16::MIN as f64, i16::MAX as f64) as i16; out.copy_from_slice(&sample.to_ne_bytes()); } + SampleFormat::U16 => { + let sample = value.round().clamp(u16::MIN as f64, u16::MAX as f64) as u16; + out.copy_from_slice(&sample.to_ne_bytes()); + } SampleFormat::I32 => { let sample = value.round().clamp(i32::MIN as f64, i32::MAX as f64) as i32; out.copy_from_slice(&sample.to_ne_bytes()); } + SampleFormat::U32 => { + let sample = value.round().clamp(u32::MIN as f64, u32::MAX as f64) as u32; + out.copy_from_slice(&sample.to_ne_bytes()); + } SampleFormat::I64 => { let sample = value.round().clamp(i64::MIN as f64, i64::MAX as f64) as i64; out.copy_from_slice(&sample.to_ne_bytes()); } + SampleFormat::U64 => { + let sample = value.round().clamp(u64::MIN as f64, u64::MAX as f64) as u64; + out.copy_from_slice(&sample.to_ne_bytes()); + } SampleFormat::F32 => { let sample = value as f32; out.copy_from_slice(&sample.to_ne_bytes()); diff --git a/crates/recording/src/sources/native_camera.rs b/crates/recording/src/sources/native_camera.rs index e2e95ab27b..128b015bfe 100644 --- a/crates/recording/src/sources/native_camera.rs +++ b/crates/recording/src/sources/native_camera.rs @@ -68,7 +68,7 @@ impl VideoSource for NativeCamera { match video_tx.try_send(frame) { Ok(()) => { sent_count += 1; - if sent_count % 30 == 0 { + if sent_count.is_multiple_of(30) { tracing::debug!( "Native camera source: sent {} frames, dropped {} in {:?}", sent_count, @@ -80,7 +80,7 @@ impl VideoSource for NativeCamera { Err(e) => { if e.is_full() { dropped_count += 1; - if dropped_count % 30 == 0 { + if dropped_count.is_multiple_of(30) { tracing::warn!( "Native camera source: encoder can't keep up, dropped {} frames so far", dropped_count diff --git a/crates/recording/src/sources/screen_capture/windows.rs b/crates/recording/src/sources/screen_capture/windows.rs index 633eac79c0..bb9b3513ff 100644 --- a/crates/recording/src/sources/screen_capture/windows.rs +++ b/crates/recording/src/sources/screen_capture/windows.rs @@ -8,7 +8,7 @@ use cap_media_info::{AudioInfo, VideoInfo}; use cap_timestamp::{PerformanceCounterTimestamp, Timestamp}; use cpal::traits::{DeviceTrait, HostTrait}; use futures::{ - FutureExt, SinkExt, StreamExt, + FutureExt, StreamExt, channel::{mpsc, oneshot}, }; use scap_ffmpeg::*; @@ -109,7 +109,7 @@ impl ScreenCaptureConfig { settings, d3d_device: self.d3d_device.clone(), }, - self.system_audio.then(|| SystemAudioSourceConfig), + self.system_audio.then_some(SystemAudioSourceConfig), )) } } @@ -203,7 +203,7 @@ impl output_pipeline::VideoSource for VideoSource { { let mut error_tx = error_tx.clone(); move || { - let _ = error_tx.send(anyhow!("closed")); + drop(error_tx.try_send(anyhow!("closed"))); Ok(()) } diff --git a/crates/rendering/src/composite_frame.rs b/crates/rendering/src/composite_frame.rs index 92068f297c..8d2eaae2fd 100644 --- a/crates/rendering/src/composite_frame.rs +++ b/crates/rendering/src/composite_frame.rs @@ -60,7 +60,7 @@ impl Default for CompositeVideoFrameUniforms { _padding0: 0.0, _padding1: [0.0; 2], _padding1b: [0.0; 2], - border_color: [1.0, 1.0, 1.0, 0.8], + border_color: [0.0, 0.0, 0.0, 0.0], _padding2: [0.0; 4], } } diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index a1e05ff964..fa31c5c4b5 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -1215,7 +1215,7 @@ impl ProjectUniforms { (b.opacity / 100.0).clamp(0.0, 1.0), ] } else { - [1.0, 1.0, 1.0, 0.8] + [0.0, 0.0, 0.0, 0.0] }, _padding2: [0.0; 4], }, diff --git a/crates/rendering/src/shaders/composite-video-frame.wgsl b/crates/rendering/src/shaders/composite-video-frame.wgsl index 04c2206083..40d5311c10 100644 --- a/crates/rendering/src/shaders/composite-video-frame.wgsl +++ b/crates/rendering/src/shaders/composite-video-frame.wgsl @@ -226,40 +226,37 @@ fn sample_texture(uv: vec2, crop_bounds_uv: vec4) -> vec4 { sample_uv.x = 1.0 - sample_uv.x; } - let cropped_uv = sample_uv * (crop_bounds_uv.zw - crop_bounds_uv.xy) + crop_bounds_uv.xy; + let crop_size = crop_bounds_uv.zw - crop_bounds_uv.xy; + var cropped_uv = sample_uv * crop_size + crop_bounds_uv.xy; - // Calculate downscaling ratio - let source_size = uniforms.frame_size * (crop_bounds_uv.zw - crop_bounds_uv.xy); + let texel_offset = 1.0 / uniforms.frame_size; + let safe_min = crop_bounds_uv.xy + texel_offset; + let safe_max = crop_bounds_uv.zw - texel_offset; + cropped_uv = clamp(cropped_uv, safe_min, safe_max); + + let source_size = uniforms.frame_size * crop_size; let target_size = uniforms.target_size; let scale_ratio = source_size / target_size; let is_downscaling = max(scale_ratio.x, scale_ratio.y) > 1.1; - // Sample the center pixel let center_color = textureSample(frame_texture, frame_sampler, cropped_uv).rgb; - // Apply sharpening when downscaling to preserve text clarity if is_downscaling { let texel_size = 1.0 / uniforms.frame_size; - // Sample neighboring pixels for unsharp mask let offset_x = vec2(texel_size.x, 0.0); let offset_y = vec2(0.0, texel_size.y); - // 4-tap sampling for edge detection let left = textureSample(frame_texture, frame_sampler, cropped_uv - offset_x).rgb; let right = textureSample(frame_texture, frame_sampler, cropped_uv + offset_x).rgb; let top = textureSample(frame_texture, frame_sampler, cropped_uv - offset_y).rgb; let bottom = textureSample(frame_texture, frame_sampler, cropped_uv + offset_y).rgb; - // Calculate the blurred version (average of neighbors) let blurred = (left + right + top + bottom) * 0.25; - // Unsharp mask: enhance the difference between center and blur - // Strength is adaptive based on downscale ratio - let sharpness = min(scale_ratio.x * 0.3, 0.7); // Cap at 0.7 to avoid over-sharpening + let sharpness = min(scale_ratio.x * 0.3, 0.7); let sharpened = center_color + (center_color - blurred) * sharpness; - // Clamp to avoid color artifacts return vec4(clamp(sharpened, vec3(0.0), vec3(1.0)), 1.0); } @@ -270,14 +267,12 @@ fn sample_texture(uv: vec2, crop_bounds_uv: vec4) -> vec4 { } fn apply_rounded_corners(current_color: vec4, target_uv: vec2) -> vec4 { - // Compute the signed distance to the rounded rect in pixel space so we can - // blend edges smoothly instead of hard-clipping them (which produced jaggies). let centered_uv = (target_uv - vec2(0.5)) * uniforms.target_size; let half_size = uniforms.target_size * 0.5; let distance = sdf_rounded_rect(centered_uv, half_size, uniforms.rounding_px, uniforms.rounding_type); - let anti_alias_width = max(fwidth(distance), 0.001); - let coverage = clamp(1.0 - smoothstep(-anti_alias_width, anti_alias_width, distance), 0.0, 1.0); + let anti_alias_width = max(fwidth(distance), 0.5); + let coverage = clamp(1.0 - smoothstep(0.0, anti_alias_width, distance), 0.0, 1.0); return vec4(current_color.rgb, current_color.a * coverage); } diff --git a/crates/scap-targets/src/platform/win.rs b/crates/scap-targets/src/platform/win.rs index 817a230dd6..c3573e54e3 100644 --- a/crates/scap-targets/src/platform/win.rs +++ b/crates/scap-targets/src/platform/win.rs @@ -57,7 +57,7 @@ use crate::bounds::{LogicalSize, PhysicalBounds, PhysicalPosition, PhysicalSize} // On Windows it's nigh impossible to get the logical position of a display // or window, since there's no simple API that accounts for each monitor having different DPI. -static IGNORED_EXES: &'static [&str] = &[ +static IGNORED_EXES: &[&str] = &[ // As it's a system webview it isn't owned by the Cap process. "webview2", "msedgewebview2", @@ -80,10 +80,10 @@ impl DisplayImpl { info.monitorInfo.cbSize = mem::size_of::() as u32; unsafe { - if GetMonitorInfoW(display.0, &mut info as *mut _ as *mut _).as_bool() { - if (info.monitorInfo.dwFlags & MONITORINFOF_PRIMARY) != 0 { - return display; - } + if GetMonitorInfoW(display.0, &mut info as *mut _ as *mut _).as_bool() + && (info.monitorInfo.dwFlags & MONITORINFOF_PRIMARY) != 0 + { + return display; } } } @@ -197,8 +197,10 @@ impl DisplayImpl { unsafe { if GetMonitorInfoW(self.0, &mut info as *mut _ as *mut _).as_bool() { let device_name = info.szDevice; - let mut devmode = DEVMODEW::default(); - devmode.dmSize = mem::size_of::() as u16; + let mut devmode = DEVMODEW { + dmSize: mem::size_of::() as u16, + ..Default::default() + }; if EnumDisplaySettingsW( PCWSTR(device_name.as_ptr()), @@ -492,13 +494,13 @@ impl WindowImpl { } let mut buffer = vec![0u8; size as usize]; - if !GetFileVersionInfoW( + if GetFileVersionInfoW( PCWSTR(wide_path.as_ptr()), Some(0), size, buffer.as_mut_ptr() as *mut _, ) - .is_ok() + .is_err() { return None; } @@ -533,18 +535,16 @@ impl WindowImpl { // Target size for acceptable icon quality - early termination threshold const GOOD_SIZE_THRESHOLD: i32 = 256; - // Method 1: Try shell icon extraction for highest quality - if let Some(exe_path) = self.get_executable_path() { - if let Some(icon_data) = self.extract_shell_icon_high_res(&exe_path, 512) { - return Some(icon_data); - } + if let Some(exe_path) = self.get_executable_path() + && let Some(icon_data) = self.extract_shell_icon_high_res(&exe_path, 512) + { + return Some(icon_data); } - // Method 2: Try executable file extraction with multiple icon sizes - if let Some(exe_path) = self.get_executable_path() { - if let Some(icon_data) = self.extract_executable_icons_high_res(&exe_path) { - return Some(icon_data); - } + if let Some(exe_path) = self.get_executable_path() + && let Some(icon_data) = self.extract_executable_icons_high_res(&exe_path) + { + return Some(icon_data); } // Method 3: Try to get the window's large icon @@ -555,13 +555,11 @@ impl WindowImpl { Some(LPARAM(0isize)), ); // ICON_BIG = 1 - if large_icon.0 != 0 { - if let Some(result) = self.hicon_to_png_bytes_optimized(HICON(large_icon.0 as _)) { - // If we got a good quality icon, return it immediately - if result.1 >= GOOD_SIZE_THRESHOLD { - return Some(result.0); - } - } + if large_icon.0 != 0 + && let Some(result) = self.hicon_to_png_bytes_optimized(HICON(large_icon.0 as _)) + && result.1 >= GOOD_SIZE_THRESHOLD + { + return Some(result.0); } // Method 4: Try executable file extraction (fallback to original method) @@ -615,18 +613,17 @@ impl WindowImpl { Some(LPARAM(0isize)), ); // ICON_SMALL = 0 - if small_icon.0 != 0 { - if let Some(result) = self.hicon_to_png_bytes_optimized(HICON(small_icon.0 as _)) { - return Some(result.0); - } + if small_icon.0 != 0 + && let Some(result) = self.hicon_to_png_bytes_optimized(HICON(small_icon.0 as _)) + { + return Some(result.0); } - // Method 6: Try class icon as last resort let class_icon = GetClassLongPtrW(self.0, GCLP_HICON) as isize; - if class_icon != 0 { - if let Some(result) = self.hicon_to_png_bytes_optimized(HICON(class_icon as _)) { - return Some(result.0); - } + if class_icon != 0 + && let Some(result) = self.hicon_to_png_bytes_optimized(HICON(class_icon as _)) + { + return Some(result.0); } None @@ -717,14 +714,14 @@ impl WindowImpl { let icon_result = self.hicon_to_png_bytes_optimized(icon_handle); let _ = DestroyIcon(icon_handle); - if let Some((png_data, realized_size)) = icon_result { - if realized_size > best_size { - best_size = realized_size; - best_icon = Some(png_data); + if let Some((png_data, realized_size)) = icon_result + && realized_size > best_size + { + best_size = realized_size; + best_icon = Some(png_data); - if best_size >= 256 { - return best_icon; - } + if best_size >= 256 { + return best_icon; } } } @@ -769,7 +766,7 @@ impl WindowImpl { fn get_icon_size(&self, icon: HICON) -> Option<(i32, i32)> { unsafe { let mut icon_info = ICONINFO::default(); - if !GetIconInfo(icon, &mut icon_info).is_ok() { + if GetIconInfo(icon, &mut icon_info).is_err() { return None; } @@ -796,7 +793,7 @@ impl WindowImpl { fn hicon_to_png_bytes_optimized(&self, icon: HICON) -> Option<(Vec, i32)> { unsafe { let mut icon_info = ICONINFO::default(); - if !GetIconInfo(icon, &mut icon_info).is_ok() { + if GetIconInfo(icon, &mut icon_info).is_err() { return None; } @@ -1118,13 +1115,11 @@ impl WindowImpl { return false; } - // Also skip WebView2 and Cap-related processes - if let Ok(exe_path) = unsafe { pid_to_exe_path(id) } { - if let Some(exe_name) = exe_path.file_name().and_then(|n| n.to_str()) { - if IGNORED_EXES.contains(&&*exe_name.to_lowercase()) { - return false; - } - } + if let Ok(exe_path) = unsafe { pid_to_exe_path(id) } + && let Some(exe_name) = exe_path.file_name().and_then(|n| n.to_str()) + && IGNORED_EXES.contains(&&*exe_name.to_lowercase()) + { + return false; } let mut rect = RECT::default(); @@ -1154,25 +1149,21 @@ impl WindowImpl { fn is_window_valid_for_enumeration(hwnd: HWND, current_process_id: u32) -> bool { unsafe { - // Skip invisible or minimized windows if !IsWindowVisible(hwnd).as_bool() || IsIconic(hwnd).as_bool() { return false; } - // Skip own process windows let mut process_id = 0u32; GetWindowThreadProcessId(hwnd, Some(&mut process_id)); if process_id == current_process_id { return false; } - // Also skip WebView2 and Cap-related processes - if let Ok(exe_path) = pid_to_exe_path(process_id) { - if let Some(exe_name) = exe_path.file_name().and_then(|n| n.to_str()) { - if IGNORED_EXES.contains(&&*exe_name.to_lowercase()) { - return false; - } - } + if let Ok(exe_path) = pid_to_exe_path(process_id) + && let Some(exe_name) = exe_path.file_name().and_then(|n| n.to_str()) + && IGNORED_EXES.contains(&&*exe_name.to_lowercase()) + { + return false; } true @@ -1185,28 +1176,25 @@ fn is_window_valid_for_topmost_selection( point: POINT, ) -> bool { unsafe { - // Skip invisible or minimized windows if !IsWindowVisible(hwnd).as_bool() || IsIconic(hwnd).as_bool() { return false; } - // Skip own process windows (includes overlays) let mut process_id = 0u32; GetWindowThreadProcessId(hwnd, Some(&mut process_id)); if process_id == current_process_id { return false; } - // Also skip WebView2 and Cap-related processes - if let Ok(exe_path) = pid_to_exe_path(process_id) { - if let Some(exe_name) = exe_path.file_name().and_then(|n| n.to_str()) { - let exe_name_lower = exe_name.to_lowercase(); - if exe_name_lower.contains("webview2") - || exe_name_lower.contains("msedgewebview2") - || exe_name_lower.contains("cap") - { - return false; - } + if let Ok(exe_path) = pid_to_exe_path(process_id) + && let Some(exe_name) = exe_path.file_name().and_then(|n| n.to_str()) + { + let exe_name_lower = exe_name.to_lowercase(); + if exe_name_lower.contains("webview2") + || exe_name_lower.contains("msedgewebview2") + || exe_name_lower.contains("cap") + { + return false; } } @@ -1226,10 +1214,8 @@ fn is_window_valid_for_topmost_selection( } } - // Skip windows with certain extended styles let ex_style = GetWindowLongW(hwnd, GWL_EXSTYLE) as u32; if (ex_style & WS_EX_TRANSPARENT.0) != 0 || (ex_style & WS_EX_LAYERED.0) != 0 { - // Allow layered windows only if they have proper alpha if (ex_style & WS_EX_LAYERED.0) != 0 { let mut alpha = 0u8; let mut color_key = 0u32; @@ -1241,14 +1227,12 @@ fn is_window_valid_for_topmost_selection( Some(&mut flags as *mut u32 as *mut _), ) .is_ok() + && alpha < 50 { - if alpha < 50 { - // Skip nearly transparent windows - return false; - } + return false; } } else { - return false; // Skip fully transparent windows + return false; } } diff --git a/scripts/setup.js b/scripts/setup.js index 8c7de397ce..be1b5ed6ea 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -110,7 +110,10 @@ async function main() { const ffmpegDir = path.join(targetDir, "ffmpeg"); if (!(await fileExists(ffmpegDir)) || downloadedFfmpeg) { - await execFile("tar", ["xf", ffmpegZipPath, "-C", targetDir]); + await exec( + `Expand-Archive -Path "${ffmpegZipPath}" -DestinationPath "${targetDir}" -Force`, + { shell: "powershell.exe" }, + ); await fs.rm(ffmpegDir, { recursive: true, force: true }).catch(() => {}); await fs.rename(path.join(targetDir, FFMPEG_ZIP_NAME), ffmpegDir); console.log("Extracted ffmpeg"); @@ -231,7 +234,7 @@ async function signMacOSFrameworkLibs(frameworkDir) { .map((entry) => exec( `codesign ${keychain} -s "${signId}" -f "${path.join( - entry.parentPath || entry.path, + entry.parentPath, entry.name, )}"`, ),