Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
f7047a0
feat: v1 of screenshots
richiemcilroy Nov 19, 2025
4c3b942
Allow Slider to accept custom history prop
richiemcilroy Nov 19, 2025
50ca057
Add screenshot editor backend implementation
richiemcilroy Nov 19, 2025
c982991
Add screenshot editor integration and DecodedFrame constructor
richiemcilroy Nov 19, 2025
77cac64
Integrate live preview for screenshot editor
richiemcilroy Nov 19, 2025
11007c2
Refactor screenshot editor and add screenshots tab
richiemcilroy Nov 19, 2025
f9450c8
Add screenshot mode and image icon support
richiemcilroy Nov 19, 2025
84d1baa
Refactor screenshot saving and editor window management
richiemcilroy Nov 19, 2025
f28cffd
Add recordings grid and menu to main window
richiemcilroy Nov 19, 2025
849b702
Replace Button with native button in TargetMenuPanel
richiemcilroy Nov 19, 2025
f92090a
Add screenshot support to target selection UI
richiemcilroy Nov 19, 2025
b4c8a17
feat: Screenshot editor styling/layout + Mask annotation
richiemcilroy Nov 20, 2025
6dcb6f2
Refactor mask blur logic for screenshot editor
richiemcilroy Nov 20, 2025
7bd9644
Merge branch 'main' into screenshots
richiemcilroy Nov 20, 2025
1996ab6
Implement Windows screenshot capture support
richiemcilroy Nov 20, 2025
61982eb
Add screenshot export improvements and new icon
richiemcilroy Nov 20, 2025
af1cc60
feat: Screenshots V1 - Enhance screenshot editor with mask tool and e…
richiemcilroy Nov 22, 2025
9d22e01
0.4.0
richiemcilroy Nov 22, 2025
7dd146a
Handle missing ScreenshotEditor instance gracefully
richiemcilroy Nov 27, 2025
032b819
Handle shutdown in screenshot editor render loop
richiemcilroy Nov 27, 2025
3c4a124
Remove timestamp from screenshot src
richiemcilroy Nov 27, 2025
f12c13e
Refactor render shutdown handling in screenshot editor
richiemcilroy Nov 27, 2025
3a39c9c
Refactor WebSocket frame handling to use broadcast channel
richiemcilroy Nov 27, 2025
7c68831
Switch frame channel from flume to tokio broadcast
richiemcilroy Nov 27, 2025
694de05
Refactor camera preview to use broadcast channel
richiemcilroy Nov 27, 2025
37456d3
Disable cursor capture in screenshot functionality
richiemcilroy Nov 27, 2025
737b89a
Remove unused mutable binding in camera_legacy.rs
richiemcilroy Nov 27, 2025
d776857
Optimize PNG screenshot encoding settings
richiemcilroy Nov 27, 2025
eafc7d9
Refactor import and error handling in screenshot_editor.rs
richiemcilroy Nov 27, 2025
2b1eb9e
Handle errors during titlebar initialization
richiemcilroy Nov 27, 2025
ed64349
Refactor camera window cleanup logic
richiemcilroy Nov 27, 2025
bb0310d
misc bits
richiemcilroy Nov 27, 2025
f8f1d01
Add toast notifications for screenshot actions
richiemcilroy Nov 27, 2025
4d73f14
Add fast screenshot capture and shared GPU context
richiemcilroy Nov 27, 2025
6825eb3
Merge branch 'main' into screenshots
richiemcilroy Nov 28, 2025
c73f735
Add stride check in FFmpeg frame conversion
richiemcilroy Nov 28, 2025
ba901dd
Add keyboard shortcuts for export actions in Header
richiemcilroy Nov 28, 2025
641a99a
Show toast on screenshot failure in overlay
richiemcilroy Nov 28, 2025
ba9ba53
Add validation for annotation and camera config
richiemcilroy Nov 28, 2025
e2545ee
Improve screenshot capture reliability and performance
richiemcilroy Nov 28, 2025
38cc696
claude settings
richiemcilroy Nov 28, 2025
860ccd7
Add camera_cleanup_done flag to prevent redundant cleanup
richiemcilroy Nov 29, 2025
980720e
Add image dimension and data validation to editor
richiemcilroy Nov 29, 2025
cdc91c0
Throw SilentError on export cancellation
richiemcilroy Nov 29, 2025
126421d
types/clippy bits
richiemcilroy Nov 29, 2025
48f7aeb
fmt
richiemcilroy Nov 29, 2025
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
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"Bash(pnpm typecheck:*)",
"Bash(pnpm lint:*)",
"Bash(pnpm build:*)",
"Bash(cargo check:*)"
"Bash(cargo check:*)",
"Bash(cargo fmt:*)"
],
"deny": [],
"ask": []
Expand Down
5 changes: 4 additions & 1 deletion Cargo.lock

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

1 change: 1 addition & 0 deletions apps/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ impl Export {
// print!("\rrendered frame {f}");

stdout.flush().unwrap();
true
})
.await
.map_err(|v| format!("Exporter error: {v}"))?;
Expand Down
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 = "cap-desktop"
version = "0.3.84"
version = "0.4.0"
description = "Beautiful screen recordings, owned by you."
authors = ["you"]
edition = "2024"
Expand Down
11 changes: 6 additions & 5 deletions apps/desktop/src-tauri/src/camera_legacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ use tokio_util::sync::CancellationToken;
use crate::frame_ws::{WSFrame, create_frame_ws};

pub async fn create_camera_preview_ws() -> (Sender<FFmpegVideoFrame>, u16, CancellationToken) {
let (camera_tx, mut _camera_rx) = flume::bounded::<FFmpegVideoFrame>(4);
let (_camera_tx, camera_rx) = flume::bounded::<WSFrame>(4);
let (camera_tx, camera_rx) = flume::bounded::<FFmpegVideoFrame>(4);
let (frame_tx, _) = tokio::sync::broadcast::channel::<WSFrame>(4);
let frame_tx_clone = frame_tx.clone();
std::thread::spawn(move || {
use ffmpeg::format::Pixel;

let mut converter: Option<(Pixel, ffmpeg::software::scaling::Context)> = None;

while let Ok(raw_frame) = _camera_rx.recv() {
while let Ok(raw_frame) = camera_rx.recv() {
let mut frame = raw_frame.inner;

if frame.format() != Pixel::RGBA || frame.width() > 1280 || frame.height() > 720 {
Expand Down Expand Up @@ -55,7 +56,7 @@ pub async fn create_camera_preview_ws() -> (Sender<FFmpegVideoFrame>, u16, Cance
frame = new_frame;
}

_camera_tx
frame_tx_clone
.send(WSFrame {
data: frame.data(0).to_vec(),
width: frame.width(),
Expand All @@ -66,7 +67,7 @@ pub async fn create_camera_preview_ws() -> (Sender<FFmpegVideoFrame>, u16, Cance
}
});
// _shutdown needs to be kept alive to keep the camera ws running
let (camera_ws_port, _shutdown) = create_frame_ws(camera_rx.clone()).await;
let (camera_ws_port, _shutdown) = create_frame_ws(frame_tx).await;

(camera_tx, camera_ws_port, _shutdown)
}
6 changes: 3 additions & 3 deletions apps/desktop/src-tauri/src/editor_window.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::{collections::HashMap, ops::Deref, path::PathBuf, sync::Arc};
use tauri::{Manager, Runtime, Window, ipc::CommandArg};
use tokio::sync::RwLock;
use tokio::sync::{RwLock, broadcast};
use tokio_util::sync::CancellationToken;

use crate::{
Expand Down Expand Up @@ -88,9 +88,9 @@ impl EditorInstances {

match instances.entry(window.label().to_string()) {
Entry::Vacant(entry) => {
let (frame_tx, frame_rx) = flume::bounded(4);
let (frame_tx, _) = broadcast::channel(4);

let (ws_port, ws_shutdown_token) = create_frame_ws(frame_rx).await;
let (ws_port, ws_shutdown_token) = create_frame_ws(frame_tx.clone()).await;
let instance = create_editor_instance_impl(
window.app_handle(),
path,
Expand Down
20 changes: 12 additions & 8 deletions apps/desktop/src-tauri/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,25 @@ pub async fn export_video(
settings
.export(exporter_base, move |frame_index| {
// Ensure progress never exceeds total frames
let _ = progress.send(FramesRendered {
rendered_count: (frame_index + 1).min(total_frames),
total_frames,
});
progress
.send(FramesRendered {
rendered_count: (frame_index + 1).min(total_frames),
total_frames,
})
.is_ok()
})
.await
}
ExportSettings::Gif(settings) => {
settings
.export(exporter_base, move |frame_index| {
// Ensure progress never exceeds total frames
let _ = progress.send(FramesRendered {
rendered_count: (frame_index + 1).min(total_frames),
total_frames,
});
progress
.send(FramesRendered {
rendered_count: (frame_index + 1).min(total_frames),
total_frames,
})
.is_ok()
})
.await
}
Expand Down
152 changes: 136 additions & 16 deletions apps/desktop/src-tauri/src/frame_ws.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
use std::sync::Arc;

use flume::Receiver;
use tokio::sync::{broadcast, watch};
use tokio_util::sync::CancellationToken;

#[derive(Clone)]
pub struct WSFrame {
pub data: Vec<u8>,
pub width: u32,
pub height: u32,
pub stride: u32,
}

pub async fn create_frame_ws(frame_rx: Receiver<WSFrame>) -> (u16, CancellationToken) {
pub async fn create_watch_frame_ws(
frame_rx: watch::Receiver<Option<WSFrame>>,
) -> (u16, CancellationToken) {
use axum::{
extract::{
State,
Expand All @@ -19,9 +20,8 @@ pub async fn create_frame_ws(frame_rx: Receiver<WSFrame>) -> (u16, CancellationT
response::IntoResponse,
routing::get,
};
use tokio::sync::Mutex;

type RouterState = Arc<Mutex<Receiver<WSFrame>>>;
type RouterState = watch::Receiver<Option<WSFrame>>;

#[axum::debug_handler]
async fn ws_handler(
Expand All @@ -31,19 +31,136 @@ pub async fn create_frame_ws(frame_rx: Receiver<WSFrame>) -> (u16, CancellationT
ws.on_upgrade(move |socket| handle_socket(socket, state))
}

async fn handle_socket(mut socket: WebSocket, state: RouterState) {
let camera_rx = state.lock().await;
async fn handle_socket(mut socket: WebSocket, mut camera_rx: RouterState) {
println!("socket connection established");
tracing::info!("Socket connection established");
let now = std::time::Instant::now();

// Send the current frame immediately upon connection (if one exists)
// This ensures the client doesn't wait for the next config change to see the image
{
let frame_opt = camera_rx.borrow().clone();
if let Some(mut frame) = frame_opt {
frame.data.extend_from_slice(&frame.stride.to_le_bytes());
frame.data.extend_from_slice(&frame.height.to_le_bytes());
frame.data.extend_from_slice(&frame.width.to_le_bytes());

if let Err(e) = socket.send(Message::Binary(frame.data)).await {
tracing::error!("Failed to send initial frame to socket: {:?}", e);
return;
}
}
}

loop {
tokio::select! {
_ = socket.recv() => {
tracing::info!("Received message from socket");
break;
msg = socket.recv() => {
match msg {
Some(Ok(Message::Close(_))) | None => {
tracing::info!("WebSocket closed");
break;
}
Some(Ok(_)) => {
tracing::info!("Received message from socket (ignoring)");
}
Some(Err(e)) => {
tracing::error!("WebSocket error: {:?}", e);
break;
}
}
},
incoming_frame = camera_rx.recv_async() => {
res = camera_rx.changed() => {
if res.is_err() {
tracing::error!("Camera channel closed");
break;
}
let frame_opt = camera_rx.borrow().clone();
if let Some(mut frame) = frame_opt {
frame.data.extend_from_slice(&frame.stride.to_le_bytes());
frame.data.extend_from_slice(&frame.height.to_le_bytes());
frame.data.extend_from_slice(&frame.width.to_le_bytes());

if let Err(e) = socket.send(Message::Binary(frame.data)).await {
tracing::error!("Failed to send frame to socket: {:?}", e);
break;
}
}
}
}
}

let elapsed = now.elapsed();
println!("Websocket closing after {elapsed:.2?}");
tracing::info!("Websocket closing after {elapsed:.2?}");
}

let router = axum::Router::new()
.route("/", get(ws_handler))
.with_state(frame_rx);

let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tracing::info!("WebSocket server listening on port {}", port);

let cancel_token = CancellationToken::new();
let cancel_token_child = cancel_token.child_token();
tokio::spawn(async move {
let server = axum::serve(listener, router.into_make_service());
tokio::select! {
_ = server => {},
_ = cancel_token.cancelled() => {
println!("WebSocket server shutting down");
}
}
});

(port, cancel_token_child)
}

pub async fn create_frame_ws(frame_tx: broadcast::Sender<WSFrame>) -> (u16, CancellationToken) {
use axum::{
extract::{
State,
ws::{Message, WebSocket, WebSocketUpgrade},
},
response::IntoResponse,
routing::get,
};

type RouterState = broadcast::Sender<WSFrame>;

#[axum::debug_handler]
async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<RouterState>,
) -> impl IntoResponse {
let rx = state.subscribe();
ws.on_upgrade(move |socket| handle_socket(socket, rx))
}

async fn handle_socket(mut socket: WebSocket, mut camera_rx: broadcast::Receiver<WSFrame>) {
println!("socket connection established");
tracing::info!("Socket connection established");
let now = std::time::Instant::now();

loop {
tokio::select! {
msg = socket.recv() => {
match msg {
Some(Ok(Message::Close(_))) | None => {
tracing::info!("WebSocket closed");
break;
}
Some(Ok(_)) => {
tracing::info!("Received message from socket (ignoring)");
}
Some(Err(e)) => {
tracing::error!("WebSocket error: {:?}", e);
break;
}
}
},
incoming_frame = camera_rx.recv() => {
match incoming_frame {
Ok(mut frame) => {
frame.data.extend_from_slice(&frame.stride.to_le_bytes());
Expand All @@ -55,13 +172,16 @@ pub async fn create_frame_ws(frame_rx: Receiver<WSFrame>) -> (u16, CancellationT
break;
}
}
Err(e) => {
Err(broadcast::error::RecvError::Closed) => {
tracing::error!(
"Connection has been lost! Shutting down websocket server: {:?}",
e
"Connection has been lost! Shutting down websocket server"
);
break;
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!("Missed {skipped} frames on websocket receiver");
continue;
}
}
}
}
Expand All @@ -74,7 +194,7 @@ pub async fn create_frame_ws(frame_rx: Receiver<WSFrame>) -> (u16, CancellationT

let router = axum::Router::new()
.route("/", get(ws_handler))
.with_state(Arc::new(Mutex::new(frame_rx)));
.with_state(frame_tx);

let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
Expand Down
Loading