Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG-PUBLIC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@snaphub/desktop",
"version": "0.2.1",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src-tauri/src/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -320,6 +323,7 @@ pub struct RecordingArtifactsDto {
pub cursor_path: Option<String>,
pub system_audio_path: Option<String>,
pub microphone_path: Option<String>,
pub camera_path: Option<String>,
pub width: u32,
pub height: u32,
pub fps: u32,
Expand Down Expand Up @@ -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::<RecordingRequestDto>(serde_json::json!({
Expand Down
203 changes: 200 additions & 3 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, SnaphubError> {
service
.recording_directory()
.map(|path| path.to_string_lossy().into_owned())
}

#[tauri::command]
fn set_recording_directory(app: AppHandle, directory: String) -> Result<String, SnaphubError> {
let path = app
.state::<RecordingService>()
.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<String, SnaphubError> {
let path = app
.state::<RecordingService>()
.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<bool, SnaphubError> {
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
Expand Down Expand 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();
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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::<RecordingService>().recording_directory() {
let _ = app.asset_protocol_scope().allow_directory(directory, true);
}

let configured = app
.state::<ShortcutConfiguration>()
.0
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading