diff --git a/CHANGELOG-PUBLIC.md b/CHANGELOG-PUBLIC.md index 825089b..a5e5d48 100644 --- a/CHANGELOG-PUBLIC.md +++ b/CHANGELOG-PUBLIC.md @@ -2,6 +2,24 @@ User-facing changes only, grouped by date under Added / Improved / Fixed. +## 0.3.0 — 2026-09-28 + +### Fixed + +- Recordings play back with their real duration, in the Record list and in Studio. +- The camera bubble closes when recording stops or is discarded, clips cleanly to its circle, rounded or square shape, and drags anywhere. + +### Added + +- The camera records as its own track: move it freely in Studio, toggle it, and keep it in MP4 and GIF exports. +- Native frame rate: record at your monitor's refresh rate on fast displays. +- Recordings save to Videos\CapKit, changeable with choose, open and reset in Settings. +- The recorder is a single draggable row that stays where you put it. + +### Improved + +- The Recordings grid shows four columns on wide windows, like Screenshots. + ## 0.2.1 — 2026-09-28 ### Fixed diff --git a/README.md b/README.md index c2e2657..d93539c 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,10 @@ The selected area becomes the editor. Tools appear right beside it. - Record a display, a single window, or an area you draw. - Choose your microphone and speaker. The recording controls stay out of the video. +- Native frame rate up to your monitor's refresh rate. - The cursor is recorded as its own track, so the editor can smooth it and zoom in automatically on clicks. -- Trim, add a webcam bubble, apply a Showcase look, and export to MP4 or GIF. +- Record your webcam as its own track, then move and shape it in the editor. +- Trim, apply a Showcase look, and export to MP4 or GIF. ### Showcase diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d911cb2..bb92a85 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@snaphub/desktop", - "version": "0.2.1", + "version": "0.3.0", "private": true, "type": "module", "scripts": { diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index d62d37b..c8930cd 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -604,7 +604,7 @@ dependencies = [ [[package]] name = "capkit-desktop" -version = "0.2.1" +version = "0.3.0" dependencies = [ "ab_glyph", "arboard", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1b7e251..2541e52 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "capkit-desktop" -version = "0.2.1" +version = "0.3.0" description = "CapKit lightweight desktop toolkit to capture, record, and showcase" authors = ["CapKit"] edition = "2024" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index d462412..d618432 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "core:event:default", "core:window:default", "core:window:allow-close", + "core:window:allow-start-dragging", "core:window:allow-set-resizable", "dialog:default", "autostart:allow-enable", diff --git a/apps/desktop/src-tauri/src/domain.rs b/apps/desktop/src-tauri/src/domain.rs index 563895a..612308a 100644 --- a/apps/desktop/src-tauri/src/domain.rs +++ b/apps/desktop/src-tauri/src/domain.rs @@ -270,6 +270,9 @@ pub struct RecordingSourceDto { pub display_id: String, pub scale_factor: f64, pub is_primary: bool, + /// Monitor refresh rate in Hz, rounded. Windows report their owning + /// display's rate. + pub refresh_rate: u32, /// A one-off downscaled preview, so the picker never asks the user to /// choose a source blind. Absent when the capture failed for that source /// (e.g. a protected window); the source is still selectable. @@ -320,6 +323,7 @@ pub struct RecordingArtifactsDto { pub cursor_path: Option, pub system_audio_path: Option, pub microphone_path: Option, + pub camera_path: Option, pub width: u32, pub height: u32, pub fps: u32, @@ -383,6 +387,26 @@ pub struct CursorTrackDto { mod request_tests { use super::RecordingRequestDto; + #[test] + fn camera_path_serializes_in_camel_case() { + let value = serde_json::to_value(super::RecordingArtifactsDto { + id: "rec-1".into(), + directory: "dir".into(), + video_path: "video.mp4".into(), + cursor_path: None, + system_audio_path: None, + microphone_path: None, + camera_path: Some("camera.mp4".into()), + width: 1920, + height: 1080, + fps: 30, + duration_seconds: 1.0, + stats: super::RecordingStatsDto::default(), + }) + .expect("artifacts serialize"); + assert_eq!(value["cameraPath"], "camera.mp4"); + } + #[test] fn system_audio_device_id_defaults_to_none() { let request = serde_json::from_value::(serde_json::json!({ diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c728f8a..5dfb48e 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1438,13 +1438,87 @@ async fn cancel_recording(app: AppHandle) -> Result<(), SnaphubError> { .and_then(|result| result) } +/// Where new screen recordings are saved. The folder is opted into the asset +/// protocol scope at startup and after every change, so recordings keep +/// playing after a restart wherever the user puts them. +#[tauri::command] +fn get_recording_directory( + service: tauri::State<'_, RecordingService>, +) -> Result { + service + .recording_directory() + .map(|path| path.to_string_lossy().into_owned()) +} + +#[tauri::command] +fn set_recording_directory(app: AppHandle, directory: String) -> Result { + let path = app + .state::() + .set_recording_directory(PathBuf::from(directory))?; + let _ = app.asset_protocol_scope().allow_directory(&path, true); + Ok(path.to_string_lossy().into_owned()) +} + +#[tauri::command] +fn reset_recording_directory(app: AppHandle) -> Result { + let path = app + .state::() + .reset_recording_directory()?; + let _ = app.asset_protocol_scope().allow_directory(&path, true); + Ok(path.to_string_lossy().into_owned()) +} + +#[tauri::command] +fn open_recording_directory( + service: tauri::State<'_, RecordingService>, +) -> Result<(), SnaphubError> { + let directory = service.recording_directory()?; + std::fs::create_dir_all(&directory).map_err(|error| SnaphubError::Record(error.to_string()))?; + #[cfg(target_os = "windows")] + return platform::windows_shell::open_path(&directory, false); + #[cfg(not(target_os = "windows"))] + Err(SnaphubError::Window( + "Opening folders is not implemented on this platform".into(), + )) +} + /// Pauses or resumes the recording; paused time is omitted from the output. #[tauri::command] fn set_recording_paused( + app: AppHandle, service: tauri::State<'_, RecordingService>, paused: bool, ) -> Result { - service.set_paused(paused) + let settled = service.set_paused(paused)?; + let _ = app.emit( + "snaphub://recording-paused", + serde_json::json!({ "paused": settled }), + ); + Ok(settled) +} + +/// Starts the camera sidecar for the recording in flight. +#[tauri::command] +fn begin_camera_track( + service: tauri::State<'_, RecordingService>, + extension: String, +) -> Result<(), SnaphubError> { + service.begin_camera_track(&extension) +} + +/// Appends one camera chunk. The frontend ignores the error when no track is +/// active. +#[tauri::command] +fn append_camera_chunk( + service: tauri::State<'_, RecordingService>, + request: tauri::ipc::Request, +) -> Result<(), SnaphubError> { + let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else { + return Err(SnaphubError::Record( + "Camera chunk must be raw bytes".into(), + )); + }; + service.append_camera_chunk(bytes) } /// Opens the webcam window, excluded from capture so the preview cannot end up @@ -1694,6 +1768,9 @@ fn close_recorder( if let Some(window) = app.get_webview_window("record-region") { let _ = window.destroy(); } + if let Some(window) = app.get_webview_window("camera") { + let _ = window.destroy(); + } if let Some(window) = app.get_webview_window("recorder") { let _ = window.destroy(); } @@ -1935,6 +2012,46 @@ fn dock_frame( } } +/// Computes the recorder frame after the user has dragged it. +/// +/// The window's current bottom-centre point stays fixed and the frame grows +/// upward around the new content size, so opening a menu never pushes a +/// dragged dock back to the default spot. The result is clamped inside the +/// work area. `current` is the physical outer frame; the content size is +/// logical. +fn dock_frame_keep_position( + work_area: Rect, + current: Rect, + scale: f64, + content_width: f64, + content_height: f64, +) -> Rect { + let width = (content_width * scale) + .round() + .min(work_area.width) + .max(1.0); + let height = (content_height * scale) + .round() + .min(work_area.height) + .max(1.0); + let bottom_centre_x = current.x + current.width / 2.0; + let bottom = current.y + current.height; + let x = (bottom_centre_x - width / 2.0).clamp( + work_area.x, + (work_area.x + work_area.width - width).max(work_area.x), + ); + let y = (bottom - height).clamp( + work_area.y, + (work_area.y + work_area.height - height).max(work_area.y), + ); + Rect { + x, + y, + width, + height, + } +} + /// Computes the camera window's physical frame. /// /// `live` is the 260 logical px bottom-left bubble, 24 px from the work area's @@ -1991,7 +2108,12 @@ fn window_work_area(app: &AppHandle, window: &WebviewWindow) -> Option<(Rect, f6 /// A sync command is safe here: it builds no window, and `set_size` and /// `set_position` do not deadlock. #[tauri::command] -fn fit_recorder(app: AppHandle, width: f64, height: f64) -> Result<(), SnaphubError> { +fn fit_recorder( + app: AppHandle, + width: f64, + height: f64, + keep_position: bool, +) -> Result<(), SnaphubError> { if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { return Err(SnaphubError::Window( "Recorder size must be finite and positive".into(), @@ -2004,7 +2126,26 @@ fn fit_recorder(app: AppHandle, width: f64, height: f64) -> Result<(), SnaphubEr }; let (work_area, scale) = window_work_area(&app, &window) .ok_or_else(|| SnaphubError::Window("No display available for the recorder".into()))?; - let frame = dock_frame(work_area, scale, width, height, 24.0); + // A dragged dock keeps its bottom-centre point and grows upward; a window + // that cannot report its frame falls back to the default placement. + let frame = if keep_position { + let current = window + .outer_position() + .ok() + .zip(window.outer_size().ok()) + .map(|(position, size)| Rect { + x: f64::from(position.x), + y: f64::from(position.y), + width: f64::from(size.width), + height: f64::from(size.height), + }); + current.map_or_else( + || dock_frame(work_area, scale, width, height, 24.0), + |current| dock_frame_keep_position(work_area, current, scale, width, height), + ) + } else { + dock_frame(work_area, scale, width, height, 24.0) + }; window .set_position(PhysicalPosition::new( frame.x.round() as i32, @@ -2108,7 +2249,13 @@ pub fn run() { start_recording, stop_recording, cancel_recording, + get_recording_directory, + set_recording_directory, + reset_recording_directory, + open_recording_directory, recording_status, + begin_camera_track, + append_camera_chunk, set_capture_exclusion, recorder_ready, fit_recorder, @@ -2143,6 +2290,13 @@ pub fn run() { .asset_protocol_scope() .allow_directory(std::env::temp_dir().join("CapKit"), true); + // A user-chosen recordings folder lives outside the temp scope, + // so it is opted in too. Without this, recordings there would + // stop playing after a restart. + if let Ok(directory) = app.state::().recording_directory() { + let _ = app.asset_protocol_scope().allow_directory(directory, true); + } + let configured = app .state::() .0 @@ -2536,6 +2690,49 @@ mod recorder_layout_tests { assert_eq!(frame.y, 0.0); } + #[test] + fn keep_position_holds_the_bottom_centre_and_grows_upward() { + // The dock was dragged left of centre; opening a taller menu keeps + // the bottom-centre point and extends the top. + let current = work_area(300.0, 800.0, 724.0, 140.0); + let frame = dock_frame_keep_position( + work_area(0.0, 0.0, 1920.0, 1040.0), + current, + 1.0, + 724.0, + 300.0, + ); + assert_eq!(frame.x, 300.0); + assert_eq!(frame.width, 724.0); + assert_eq!(frame.height, 300.0); + assert_eq!(frame.y, 800.0 + 140.0 - 300.0); + } + + #[test] + fn keep_position_clamps_inside_the_work_area() { + // Dragged into the bottom-right corner, then grown past both edges. + let current = work_area(1800.0, 900.0, 724.0, 140.0); + let frame = dock_frame_keep_position( + work_area(0.0, 0.0, 1920.0, 1040.0), + current, + 1.0, + 900.0, + 300.0, + ); + assert_eq!(frame.x, 1920.0 - 900.0); + assert_eq!(frame.y, 900.0 + 140.0 - 300.0); + // Content taller than the work area fills it from the top. + let frame = dock_frame_keep_position( + work_area(0.0, 0.0, 1920.0, 400.0), + current, + 1.0, + 724.0, + 900.0, + ); + assert_eq!(frame.height, 400.0); + assert_eq!(frame.y, 0.0); + } + #[test] fn logical_box_scales_to_physical_px() { let area = logical_box_to_desktop( diff --git a/apps/desktop/src-tauri/src/platform/windows_recorder/encoder.rs b/apps/desktop/src-tauri/src/platform/windows_recorder/encoder.rs index a27b9f9..e330561 100644 --- a/apps/desktop/src-tauri/src/platform/windows_recorder/encoder.rs +++ b/apps/desktop/src-tauri/src/platform/windows_recorder/encoder.rs @@ -23,14 +23,24 @@ const HNS_PER_SECOND: i64 = 10_000_000; /// Screen recordings are mostly flat colour and text, so they compress far /// better than camera footage; these are deliberately generous rather than /// tuned, since a soft screenshot is the one artefact users notice immediately. -fn target_bitrate(width: u32, height: u32) -> u32 { +/// Scales the base bitrate with the frame rate, capped at 80 Mbps, so high +/// refresh recordings stay sharp without unbounded files. +fn target_bitrate_for_fps(width: u32, height: u32, fps: u32) -> u32 { let pixels = u64::from(width) * u64::from(height); - match pixels { + let base = match pixels { p if p <= 1_280 * 720 => 8_000_000, p if p <= 1_920 * 1_080 => 12_000_000, p if p <= 2_560 * 1_440 => 24_000_000, _ => 40_000_000, - } + }; + let scale = f64::from(fps).max(60.0) / 60.0; + ((base as f64 * scale).round() as u64).clamp(1, 80_000_000) as u32 +} + +/// Highest frame rate the H.264 level 5.2 macroblock rate allows. +pub(crate) fn max_fps_for(width: u32, height: u32) -> u32 { + let blocks = width.div_ceil(16) as u64 * height.div_ceil(16) as u64; + (2_073_600 / blocks.max(1)) as u32 } fn pack_u64(high: u32, low: u32) -> u64 { @@ -117,7 +127,7 @@ impl VideoEncoder { width, height, fps, - Some(target_bitrate(width, height)), + Some(target_bitrate_for_fps(width, height, fps)), )?; // SAFETY: `output` is a fully configured media type. let stream = unsafe { writer.AddStream(&output) }.map_err(SnaphubError::encode)?; @@ -230,3 +240,28 @@ impl VideoEncoder { unsafe { self.writer.Finalize() }.map_err(SnaphubError::encode) } } + +#[cfg(test)] +mod bitrate_tests { + use super::{max_fps_for, target_bitrate_for_fps}; + + #[test] + fn max_fps_follows_the_level_52_macroblock_rate() { + assert_eq!(max_fps_for(1920, 1080), 254); + assert_eq!(max_fps_for(2560, 1440), 144); + assert_eq!(max_fps_for(3840, 2160), 64); + } + + #[test] + fn bitrate_holds_at_60_and_scales_above() { + assert_eq!(target_bitrate_for_fps(1920, 1080, 30), 12_000_000); + assert_eq!(target_bitrate_for_fps(1920, 1080, 60), 12_000_000); + assert_eq!(target_bitrate_for_fps(1920, 1080, 120), 24_000_000); + assert_eq!(target_bitrate_for_fps(1920, 1080, 144), 28_800_000); + } + + #[test] + fn bitrate_caps_at_80_mbps() { + assert_eq!(target_bitrate_for_fps(3840, 2160, 240), 80_000_000); + } +} diff --git a/apps/desktop/src-tauri/src/platform/windows_recorder/mod.rs b/apps/desktop/src-tauri/src/platform/windows_recorder/mod.rs index c0185c8..09e9fe1 100644 --- a/apps/desktop/src-tauri/src/platform/windows_recorder/mod.rs +++ b/apps/desktop/src-tauri/src/platform/windows_recorder/mod.rs @@ -230,6 +230,11 @@ impl ScreenRecordingBackend for WindowsRecorderBackend { display_id, scale_factor: f64::from(monitor.scale_factor().unwrap_or(1.0)), is_primary: monitor.is_primary().unwrap_or(false), + refresh_rate: monitor + .frequency() + .ok() + .map(|hertz| hertz.round() as u32) + .unwrap_or(60), thumbnail_path, }); } @@ -281,6 +286,11 @@ impl ScreenRecordingBackend for WindowsRecorderBackend { display_id, scale_factor: f64::from(monitor.scale_factor().unwrap_or(1.0)), is_primary: false, + refresh_rate: monitor + .frequency() + .ok() + .map(|hertz| hertz.round() as u32) + .unwrap_or(60), thumbnail_path, }); } @@ -361,13 +371,16 @@ impl WindowsRecordingSession { ) }); - let fps = request.fps.clamp(10, 120); + let fps = request.fps.clamp(10, 240); let id = uuid::Uuid::new_v4().to_string(); let video_path = directory.join("video.mp4"); let stream = CaptureStream::start(monitor, region, request.capture_cursor)?; let width = stream.width; let height = stream.height; + // High refresh rates can exceed what H.264 level 5.2 allows at this + // size, so the recording uses the highest allowed rate instead. + let fps = fps.min(encoder::max_fps_for(width, height)); // The request region is in desktop coordinates; the area is computed // from its display-local form, matching the frame pool above. let local = request.region.map(|region| Rect { @@ -579,6 +592,8 @@ impl RecordingSession for WindowsRecordingSession { cursor_path: cursor_path.map(|path| path.to_string_lossy().into_owned()), system_audio_path: system_audio_path.map(|path| path.to_string_lossy().into_owned()), microphone_path: microphone_path.map(|path| path.to_string_lossy().into_owned()), + // The service sets the real path after the camera file closes. + camera_path: None, width: self.width, height: self.height, fps: self.fps, diff --git a/apps/desktop/src-tauri/src/services/capture.rs b/apps/desktop/src-tauri/src/services/capture.rs index b028e01..ced078f 100644 --- a/apps/desktop/src-tauri/src/services/capture.rs +++ b/apps/desktop/src-tauri/src/services/capture.rs @@ -1212,6 +1212,8 @@ fn default_save_directory() -> PathBuf { #[derive(serde::Deserialize, serde::Serialize)] struct StoragePreferences { save_directory: PathBuf, + #[serde(default)] + recording_directory: Option, } fn storage_preferences_path() -> PathBuf { @@ -1262,19 +1264,151 @@ fn load_save_directory() -> PathBuf { default_save_directory() } +/// Reads the whole preferences file, so saving one folder never erases the +/// other. A missing or unparsable file falls back to the photo default with +/// no recordings folder chosen. fn persist_save_directory(directory: &Path) -> Result<(), SnaphubError> { - let path = storage_preferences_path(); + update_storage_preferences(|preferences| { + preferences.save_directory = directory.to_path_buf(); + }) +} + +/// Applies one field change to the preferences file, leaving every other +/// field exactly as it was. +fn update_storage_preferences( + update: impl FnOnce(&mut StoragePreferences), +) -> Result<(), SnaphubError> { + update_storage_preferences_at(&storage_preferences_path(), update) +} + +fn update_storage_preferences_at( + path: &Path, + update: impl FnOnce(&mut StoragePreferences), +) -> Result<(), SnaphubError> { + let mut preferences = read_storage_preferences_from(path); + update(&mut preferences); + write_storage_preferences_to(path, &preferences) +} + +fn read_storage_preferences() -> StoragePreferences { + read_storage_preferences_from(&storage_preferences_path()) +} + +fn read_storage_preferences_from(path: &Path) -> StoragePreferences { + fs::read_to_string(path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .unwrap_or(StoragePreferences { + save_directory: default_save_directory(), + recording_directory: None, + }) +} + +fn write_storage_preferences_to( + path: &Path, + preferences: &StoragePreferences, +) -> Result<(), SnaphubError> { let parent = path .parent() .ok_or_else(|| SnaphubError::Export("Storage settings path is invalid".into()))?; fs::create_dir_all(parent).map_err(SnaphubError::export)?; - let bytes = serde_json::to_vec_pretty(&StoragePreferences { - save_directory: directory.to_path_buf(), - }) - .map_err(SnaphubError::export)?; + let bytes = serde_json::to_vec_pretty(preferences).map_err(SnaphubError::export)?; fs::write(path, bytes).map_err(SnaphubError::export) } +/// The user's recordings folder, or the Videos default when none was chosen. +pub(crate) fn load_recording_directory() -> PathBuf { + read_storage_preferences() + .recording_directory + .filter(|directory| directory.is_absolute()) + .unwrap_or_else(default_recording_directory) +} + +pub(crate) fn default_recording_directory() -> PathBuf { + dirs::video_dir() + .map(|directory| directory.join("CapKit")) + .unwrap_or_else(|| std::env::temp_dir().join("CapKit").join("recordings")) +} + +pub(crate) fn persist_recording_directory(directory: &Path) -> Result<(), SnaphubError> { + update_storage_preferences(|preferences| { + preferences.recording_directory = Some(directory.to_path_buf()); + }) +} + +#[cfg(test)] +mod storage_preferences_tests { + use super::*; + + /// A scratch preferences file, so the tests never touch the real config. + fn scratch_preferences_path() -> PathBuf { + std::env::temp_dir().join(format!( + "capkit-storage-preferences-{}.json", + std::process::id() + )) + } + + #[test] + fn saving_one_folder_keeps_the_other() { + let path = scratch_preferences_path(); + write_storage_preferences_to( + &path, + &StoragePreferences { + save_directory: PathBuf::from("C:/Photos/CapKit"), + recording_directory: None, + }, + ) + .expect("scratch preferences write"); + + // Saving the recordings folder keeps the photo folder, through the + // same read-modify-write the production setters use. + update_storage_preferences_at(&path, |preferences| { + preferences.recording_directory = Some(PathBuf::from("D:/Video/CapKit")); + }) + .expect("recordings folder saves"); + let reloaded = read_storage_preferences_from(&path); + assert_eq!(reloaded.save_directory, PathBuf::from("C:/Photos/CapKit")); + assert_eq!( + reloaded.recording_directory, + Some(PathBuf::from("D:/Video/CapKit")) + ); + + // Saving the photo folder keeps the recordings folder. + update_storage_preferences_at(&path, |preferences| { + preferences.save_directory = PathBuf::from("C:/Photos/Other"); + }) + .expect("photo folder saves"); + let reloaded = read_storage_preferences_from(&path); + assert_eq!(reloaded.save_directory, PathBuf::from("C:/Photos/Other")); + assert_eq!( + reloaded.recording_directory, + Some(PathBuf::from("D:/Video/CapKit")) + ); + + let _ = fs::remove_file(&path); + } + + #[test] + fn the_default_recordings_folder_lives_in_videos() { + let default = default_recording_directory(); + assert_eq!( + default.file_name().and_then(|name| name.to_str()), + Some("CapKit") + ); + if let Some(video) = dirs::video_dir() { + assert_eq!(default, video.join("CapKit")); + } + } + + #[test] + fn old_preferences_without_a_recordings_folder_still_parse() { + let raw = r#"{"save_directory": "C:/Photos/CapKit"}"#; + let parsed: StoragePreferences = serde_json::from_str(raw).expect("old preferences parse"); + assert_eq!(parsed.save_directory, PathBuf::from("C:/Photos/CapKit")); + assert_eq!(parsed.recording_directory, None); + } +} + fn previous_default_save_directory() -> PathBuf { dirs::picture_dir() .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))) diff --git a/apps/desktop/src-tauri/src/services/recording.rs b/apps/desktop/src-tauri/src/services/recording.rs index 34a798e..73832c1 100644 --- a/apps/desktop/src-tauri/src/services/recording.rs +++ b/apps/desktop/src-tauri/src/services/recording.rs @@ -1,3 +1,5 @@ +use std::fs::File; +use std::io::Write; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -9,17 +11,28 @@ use crate::error::SnaphubError; use crate::platform::{ AudioDeviceBackend, RecordingSession, ScreenRecordingBackend, WindowCaptureExclusionBackend, }; +use crate::services::capture::{ + default_recording_directory, load_recording_directory, persist_recording_directory, +}; /// Owns the one recording that can be in flight at a time. /// /// Registered separately from `CaptureService` so the screenshot path and its /// tests stay unaware of recording. +/// A camera sidecar being appended from the camera webview. +struct CameraTrack { + path: PathBuf, + file: Option, +} + pub struct RecordingService { recorder: Arc, audio: Arc, exclusion: Arc, active: Mutex>>, - root: PathBuf, + camera: Mutex>, + camera_dir: Mutex>, + root: Mutex, } impl RecordingService { @@ -28,12 +41,16 @@ impl RecordingService { audio: Arc, exclusion: Arc, ) -> Self { + let root = load_recording_directory(); + let _ = std::fs::create_dir_all(&root); Self { recorder, audio, exclusion, active: Mutex::new(None), - root: recordings_root(), + camera: Mutex::new(None), + camera_dir: Mutex::new(None), + root: Mutex::new(root), } } @@ -63,9 +80,16 @@ impl RecordingService { "A recording is already in progress".into(), )); } - let directory = self.root.join(session_stamp()); + let root = + self.root.lock().map(|root| root.clone()).map_err(|_| { + SnaphubError::Record("The recording location is unavailable".into()) + })?; + let directory = root.join(session_stamp()); let (session, audio_failures) = self.recorder.start(request, &directory)?; *active = Some(session); + if let Ok(mut camera_dir) = self.camera_dir.lock() { + *camera_dir = Some(directory); + } Ok(audio_failures) } @@ -79,7 +103,83 @@ impl RecordingService { .lock()? .take() .ok_or_else(|| SnaphubError::Record("No recording is in progress".into()))?; - session.stop() + let mut artifacts = session.stop()?; + // The camera file handle closes before the path is read, so a + // half-flushed track is either a real file or absent. + artifacts.camera_path = self.finish_camera_track(); + if let Ok(mut camera_dir) = self.camera_dir.lock() { + *camera_dir = None; + } + Ok(artifacts) + } + + /// Starts the camera sidecar for the recording in flight. Only `"mp4"` + /// and `"webm"` are accepted; a second begin for the same session is an + /// error. + pub fn begin_camera_track(&self, extension: &str) -> Result<(), SnaphubError> { + if extension != "mp4" && extension != "webm" { + return Err(SnaphubError::Record(format!( + "Unsupported camera container: {extension}" + ))); + } + if self.lock()?.is_none() { + return Err(SnaphubError::Record("No recording is in progress".into())); + } + let mut camera = self + .camera + .lock() + .map_err(|_| SnaphubError::Record("The camera registry was poisoned".into()))?; + if camera.is_some() { + return Err(SnaphubError::Record( + "A camera track is already started".into(), + )); + } + let directory = self + .camera_dir + .lock() + .map_err(|_| SnaphubError::Record("The camera registry was poisoned".into()))? + .clone() + .ok_or_else(|| SnaphubError::Record("No recording is in progress".into()))?; + let path = directory.join(format!("camera.{extension}")); + let file = File::create(&path).map_err(|error| SnaphubError::Record(error.to_string()))?; + *camera = Some(CameraTrack { + path, + file: Some(file), + }); + Ok(()) + } + + /// Appends one recorder chunk. Without an active track it errors, and the + /// frontend ignores it. + pub fn append_camera_chunk(&self, bytes: &[u8]) -> Result<(), SnaphubError> { + let mut camera = self + .camera + .lock() + .map_err(|_| SnaphubError::Record("The camera registry was poisoned".into()))?; + let Some(track) = camera.as_mut() else { + return Err(SnaphubError::Record("No camera track is active".into())); + }; + let Some(file) = track.file.as_mut() else { + return Err(SnaphubError::Record("No camera track is active".into())); + }; + if let Err(error) = file.write_all(bytes) { + track.file.take(); + return Err(SnaphubError::Record(format!( + "Camera chunk failed: {error}" + ))); + } + Ok(()) + } + + /// Drops the file handle and returns the path when it exists and is not + /// empty. + fn finish_camera_track(&self) -> Option { + let track = self.camera.lock().ok()?.take()?; + drop(track.file); + std::fs::metadata(&track.path) + .ok() + .filter(|meta| meta.len() > 0)?; + Some(track.path.to_string_lossy().into_owned()) } /// Pauses or resumes the recording in flight. @@ -92,10 +192,49 @@ impl RecordingService { Ok(session.is_paused()) } + /// The folder new recordings are saved in. It starts as the Videos + /// default and follows whatever the user picks in Settings. + pub fn recording_directory(&self) -> Result { + self.root + .lock() + .map(|root| root.clone()) + .map_err(|_| SnaphubError::Record("The recording location is unavailable".into())) + } + + pub fn set_recording_directory(&self, directory: PathBuf) -> Result { + if !directory.is_absolute() { + return Err(SnaphubError::Record( + "Choose an absolute folder for recordings".into(), + )); + } + std::fs::create_dir_all(&directory) + .map_err(|error| SnaphubError::Record(error.to_string()))?; + let directory = directory + .canonicalize() + .map_err(|error| SnaphubError::Record(error.to_string()))?; + persist_recording_directory(&directory)?; + *self + .root + .lock() + .map_err(|_| SnaphubError::Record("The recording location is unavailable".into()))? = + directory.clone(); + Ok(directory) + } + + pub fn reset_recording_directory(&self) -> Result { + self.set_recording_directory(default_recording_directory()) + } + pub fn cancel(&self) -> Result<(), SnaphubError> { if let Some(session) = self.lock()?.take() { session.cancel(); } + if let Ok(mut camera) = self.camera.lock() { + camera.take(); + } + if let Ok(mut camera_dir) = self.camera_dir.lock() { + *camera_dir = None; + } Ok(()) } @@ -108,12 +247,6 @@ impl RecordingService { } } -/// Recordings are large and disposable until exported, so they live beside the -/// other temporary session data rather than in the user's pictures folder. -fn recordings_root() -> PathBuf { - std::env::temp_dir().join("CapKit").join("recordings") -} - fn session_stamp() -> String { chrono::Local::now().format("%Y-%m-%d-%H%M%S").to_string() } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 4f0ef66..89021b0 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CapKit", - "version": "0.2.1", + "version": "0.3.0", "identifier": "com.snaphub.desktop", "build": { "beforeDevCommand": "pnpm dev", @@ -42,7 +42,7 @@ } ], "security": { - "csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: http://asset.localhost blob: data:; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost", + "csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: http://asset.localhost blob: data:; media-src 'self' asset: http://asset.localhost blob:; style-src 'self' 'unsafe-inline'; connect-src ipc: http://ipc.localhost", "assetProtocol": { "enable": true, "scope": [ diff --git a/apps/desktop/src/components/CameraPreview.test.tsx b/apps/desktop/src/components/CameraPreview.test.tsx index 4c19d12..e0d886d 100644 --- a/apps/desktop/src/components/CameraPreview.test.tsx +++ b/apps/desktop/src/components/CameraPreview.test.tsx @@ -8,9 +8,79 @@ const mocks = { prepare: vi.fn<() => Promise>(() => Promise.resolve()), settings: vi.fn<() => Promise>(() => Promise.resolve()), stream: vi.fn<() => Promise>(), + drag: vi.fn<() => Promise>(() => Promise.resolve()), + trackBegin: vi.fn<(extension: string) => Promise>(() => Promise.resolve()), + chunk: vi.fn<(bytes: Uint8Array) => Promise>(() => Promise.resolve()), }; +class FakeRecorder { + static instances: FakeRecorder[] = []; + ondataavailable: ((event: { data: Blob }) => void) | null = null; + onstop: (() => void) | null = null; + state: "inactive" | "recording" | "paused" = "inactive"; + constructor( + public stream: unknown, + public options: unknown, + ) { + FakeRecorder.instances.push(this); + } + start(): void { + this.state = "recording"; + } + stop(): void { + this.state = "inactive"; + this.onstop?.(); + } + pause(): void { + this.state = "paused"; + } + resume(): void { + this.state = "recording"; + } + static isTypeSupported(type: string): boolean { + return type === "video/mp4;codecs=avc1"; + } +} + +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: (): { startDragging: () => Promise } => ({ + startDragging: (): Promise => mocks.drag(), + }), +})); + +type StartedHandler = () => void; +type PausedHandler = (event: { payload: { paused: boolean } }) => void; + +const cameraEvents = vi.hoisted((): { + started: StartedHandler | null; + paused: PausedHandler | null; + finish: StartedHandler | null; +} => ({ + started: null, + paused: null, + finish: null, +})); + +const emitted: string[] = []; + +vi.mock("@tauri-apps/api/event", () => ({ + // The handler is stored untyped on purpose: the component's own listen + // calls carry the real payload types, and the tests below drive them. + listen: (event: string, handler: never): Promise<() => void> => { + if (event === "snaphub://recording-started") cameraEvents.started = handler; + else if (event === "snaphub://recording-paused") cameraEvents.paused = handler; + else if (event === "snaphub://camera-track-finish") cameraEvents.finish = handler; + return Promise.resolve((): void => undefined); + }, + emit: (event: string): Promise => { + emitted.push(event); + return Promise.resolve(); + }, +})); + vi.mock("../lib/recordingTauri", () => ({ + beginCameraTrack: (extension: string): Promise => mocks.trackBegin(extension), + appendCameraChunk: (bytes: Uint8Array): Promise => mocks.chunk(bytes), cameraReady: (mode: string): Promise => mocks.ready(mode), closeCamera: (): Promise => mocks.close(), prepareCameraPermission: (): Promise => mocks.prepare(), @@ -36,6 +106,12 @@ describe("CameraPreview", () => { cleanup(); vi.clearAllMocks(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); + cameraEvents.started = null; + cameraEvents.paused = null; + cameraEvents.finish = null; + emitted.length = 0; + FakeRecorder.instances.length = 0; }); it("reveals itself under StrictMode remounting, where the first frame is cancelled", async () => { @@ -80,6 +156,117 @@ describe("CameraPreview", () => { }, ); + it("shows shape glyphs with pressed state and no text", async () => { + mocks.stream.mockResolvedValue({ getTracks: () => [] } as unknown as MediaStream); + render(); + await screen.findByLabelText("Camera preview"); + + for (const name of ["Circle camera", "Rounded camera", "Square camera"] as const) { + const button = screen.getByRole("button", { name }); + expect(button).toHaveTextContent(""); + expect(button.querySelector("svg")).not.toBeNull(); + } + expect(screen.getByRole("button", { name: "Circle camera" })).toHaveAttribute( + "aria-pressed", + "true", + ); + fireEvent.click(screen.getByRole("button", { name: "Square camera" })); + expect(screen.getByRole("button", { name: "Square camera" })).toHaveAttribute( + "aria-pressed", + "true", + ); + const close = screen.getByRole("button", { name: "Close the camera" }); + expect(close.querySelector("svg")).not.toBeNull(); + expect(close).not.toHaveTextContent("close"); + }); + + it("clips the bubble per shape", async () => { + mocks.stream.mockResolvedValue({ getTracks: () => [] } as unknown as MediaStream); + render(); + const video = await screen.findByLabelText("Camera preview"); + const wrapper = video.parentElement; + expect(wrapper?.className).toContain("[clip-path:circle(50%)]"); + + fireEvent.click(screen.getByRole("button", { name: "Rounded camera" })); + expect(video.parentElement?.className).toContain("[clip-path:inset(0_round_22%)]"); + + fireEvent.click(screen.getByRole("button", { name: "Square camera" })); + expect(video.parentElement?.className).not.toContain("clip-path"); + }); + + it("drags the bubble but not from its controls", async () => { + mocks.stream.mockResolvedValue({ getTracks: () => [] } as unknown as MediaStream); + render(); + const video = await screen.findByLabelText("Camera preview"); + + fireEvent.pointerDown(video, { button: 0 }); + expect(mocks.drag).toHaveBeenCalledTimes(1); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Circle camera" }), { button: 0 }); + expect(mocks.drag).toHaveBeenCalledTimes(1); + }); + + it("appends chunks in order and emits finished after the last append", async () => { + mocks.stream.mockResolvedValue({ getTracks: () => [] } as unknown as MediaStream); + vi.stubGlobal("MediaRecorder", FakeRecorder); + render(); + await screen.findByLabelText("Camera preview"); + await vi.waitFor(() => { + expect(mocks.ready).toHaveBeenCalled(); + }); + await vi.waitFor(() => { + expect(cameraEvents.started).not.toBeNull(); + }); + + cameraEvents.started?.(); + await vi.waitFor(() => expect(mocks.trackBegin).toHaveBeenCalledWith("mp4")); + await vi.waitFor(() => expect(FakeRecorder.instances.length).toBeGreaterThan(0)); + const recorder = FakeRecorder.instances.at(-1); + expect(recorder).not.toBeUndefined(); + + let resolveFirst!: () => void; + mocks.chunk.mockImplementationOnce( + () => new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + recorder?.ondataavailable?.({ data: new Blob(["one"]) }); + recorder?.ondataavailable?.({ data: new Blob(["two"]) }); + recorder?.ondataavailable?.({ data: new Blob(["three"]) }); + await vi.waitFor(() => expect(mocks.chunk).toHaveBeenCalledTimes(1)); + resolveFirst(); + await vi.waitFor(() => expect(mocks.chunk).toHaveBeenCalledTimes(3)); + const sizes = mocks.chunk.mock.calls.map((call) => call[0].length); + expect(sizes).toEqual([3, 3, 5]); + + cameraEvents.finish?.(); + await vi.waitFor(() => { + expect(emitted).toContain("snaphub://camera-track-finished"); + }); + }); + + it("pauses and resumes the camera recorder with the recording", async () => { + mocks.stream.mockResolvedValue({ getTracks: () => [] } as unknown as MediaStream); + vi.stubGlobal("MediaRecorder", FakeRecorder); + render(); + await screen.findByLabelText("Camera preview"); + await vi.waitFor(() => { + expect(mocks.ready).toHaveBeenCalled(); + }); + await vi.waitFor(() => { + expect(cameraEvents.started).not.toBeNull(); + }); + + cameraEvents.started?.(); + await vi.waitFor(() => expect(FakeRecorder.instances.length).toBeGreaterThan(0)); + const recorder = FakeRecorder.instances.at(-1); + + cameraEvents.paused?.({ payload: { paused: true } }); + expect(recorder?.state).toBe("paused"); + cameraEvents.paused?.({ payload: { paused: false } }); + expect(recorder?.state).toBe("recording"); + }); + it("retries in place and reveals the live bubble on success", async () => { mocks.stream.mockImplementation(failingStream("NotAllowedError")); render(); diff --git a/apps/desktop/src/components/CameraPreview.tsx b/apps/desktop/src/components/CameraPreview.tsx index 2287f1a..3fd058d 100644 --- a/apps/desktop/src/components/CameraPreview.tsx +++ b/apps/desktop/src/components/CameraPreview.tsx @@ -1,6 +1,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { emit, listen } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { cameraShapes, type CameraShape } from "../domain/videoScene"; +import { Cancel, CircleShape, RoundedSquareShape, SquareShape } from "./icons"; import { + appendCameraChunk, + beginCameraTrack, cameraReady, closeCamera, openCameraPrivacySettings, @@ -13,6 +18,26 @@ const shapeClass: Readonly> = { square: "rounded-none", }; +/** Hard clip for the live image. A transformed video escapes `overflow: + * hidden` + radius in Chromium, so the clip follows the border shape. */ +const shapeClip: Readonly> = { + circle: "[clip-path:circle(50%)]", + rounded: "[clip-path:inset(0_round_22%)]", + square: "", +}; + +const shapeIcon: Readonly> = { + circle: CircleShape, + rounded: RoundedSquareShape, + square: SquareShape, +}; + +const shapeLabel: Readonly> = { + circle: "Circle camera", + rounded: "Rounded camera", + square: "Square camera", +}; + type CameraFailure = | { kind: "blocked" } | { kind: "missing" } @@ -50,12 +75,31 @@ function failureFor(error: unknown): CameraFailure { * blocked. The dock's Camera button is the user's consent, so access is * granted for this webview before asking, and a decline can be retried. */ +type CameraMime = { type: string; extension: "mp4" | "webm" }; + +function pickCameraMime(): CameraMime | null { + if (typeof MediaRecorder === "undefined") return null; + if (MediaRecorder.isTypeSupported("video/mp4;codecs=avc1")) { + return { type: "video/mp4;codecs=avc1", extension: "mp4" }; + } + if (MediaRecorder.isTypeSupported("video/webm;codecs=vp9")) { + return { type: "video/webm;codecs=vp9", extension: "webm" }; + } + if (MediaRecorder.isTypeSupported("video/webm")) { + return { type: "video/webm", extension: "webm" }; + } + return null; +} + export function CameraPreview(): React.JSX.Element { const [shape, setShape] = useState("circle"); const [failure, setFailure] = useState(null); const [retrying, setRetrying] = useState(false); const videoRef = useRef(null); const streamRef = useRef(null); + const recorderRef = useRef(null); + const chainRef = useRef>(Promise.resolve()); + const appendFailedRef = useRef(false); const stopStream = useCallback((): void => { const stream = streamRef.current; @@ -101,12 +145,109 @@ export function CameraPreview(): React.JSX.Element { return (): void => { lifetime.active = false; if (lifetime.frame !== undefined) window.cancelAnimationFrame(lifetime.frame); + const recorder = recorderRef.current; + recorderRef.current = null; + if (recorder !== null && recorder.state !== "inactive") { + try { + recorder.stop(); + } catch { + // Already stopping; the window is going away anyway. + } + } const stream = streamRef.current; if (stream !== null) for (const track of stream.getTracks()) track.stop(); streamRef.current = null; }; }, [startStream]); + // Camera track: records the preview's stream while a recording runs. + useEffect(() => { + let stops: (() => void)[] = []; + void (async (): Promise => { + stops = [ + await listen("snaphub://recording-started", () => { + const stream = streamRef.current; + if (stream === null || recorderRef.current !== null) return; + const mime = pickCameraMime(); + if (mime === null) return; + void beginCameraTrack(mime.extension) + .then(() => { + if (streamRef.current !== stream) return; + const recorder = new MediaRecorder(stream, { + mimeType: mime.type, + videoBitsPerSecond: 4_000_000, + }); + recorderRef.current = recorder; + appendFailedRef.current = false; + recorder.ondataavailable = (event: BlobEvent): void => { + if (event.data.size === 0) return; + event.data.arrayBuffer().then((buffer) => { + chainRef.current = chainRef.current.then(() => + appendCameraChunk(new Uint8Array(buffer)).catch(() => { + appendFailedRef.current = true; + const active = recorderRef.current; + recorderRef.current = null; + if (active !== null && active.state !== "inactive") { + try { + active.stop(); + } catch { + // Already stopping. + } + } + }), + ); + }).catch(() => undefined); + }; + recorder.start(1000); + }) + .catch(() => undefined); + }), + await listen<{ paused: boolean }>("snaphub://recording-paused", (event) => { + const recorder = recorderRef.current; + if (recorder === null) return; + try { + if (event.payload.paused && recorder.state === "recording") recorder.pause(); + else if (!event.payload.paused && recorder.state === "paused") recorder.resume(); + } catch { + // A recorder that cannot pause keeps recording; the track stays usable. + } + }), + await listen("snaphub://camera-track-finish", () => { + const recorder = recorderRef.current; + if (recorder === null) { + void emit("snaphub://camera-track-finished").catch(() => undefined); + return; + } + recorderRef.current = null; + const finished = new Promise((resolve) => { + const done = (): void => resolve(); + recorder.ondataavailable = (event: BlobEvent): void => { + if (event.data.size === 0) return; + event.data.arrayBuffer().then((buffer) => { + chainRef.current = chainRef.current.then(() => + appendCameraChunk(new Uint8Array(buffer)).catch(() => undefined), + ); + }).catch(() => undefined); + }; + recorder.onstop = (): void => done(); + try { + recorder.stop(); + } catch { + done(); + } + }); + void finished.then(() => chainRef.current).then(() => { + void emit("snaphub://camera-track-finished").catch(() => undefined); + }); + }), + ]; + })(); + return (): void => { + for (const stop of stops) stop(); + stops = []; + }; + }, []); + const retry = useCallback(async (): Promise => { setRetrying(true); try { @@ -119,41 +260,55 @@ export function CameraPreview(): React.JSX.Element { return (
{failure === null ? ( -
-