From 0a5fea6170bb2ee076e901926675f3493d6e8de8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:07:10 +0000 Subject: [PATCH 01/34] Refactor project config save with custom debounce --- apps/desktop/src/routes/editor/context.ts | 59 +++++++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index f9e3d1142a..8d484ba35b 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -6,7 +6,6 @@ import { createContextProvider } from "@solid-primitives/context"; import { trackStore } from "@solid-primitives/deep"; import { createEventListener } from "@solid-primitives/event-listener"; import { createUndoHistory } from "@solid-primitives/history"; -import { debounce } from "@solid-primitives/scheduled"; import { createQuery, skipToken } from "@tanstack/solid-query"; import { type Accessor, @@ -15,6 +14,7 @@ import { createResource, createSignal, on, + onCleanup, } from "solid-js"; import { createStore, produce, reconcile, unwrap } from "solid-js/store"; @@ -51,6 +51,7 @@ export const OUTPUT_SIZE = { }; export const MAX_ZOOM_IN = 3; +const PROJECT_SAVE_DEBOUNCE_MS = 250; export type RenderState = | { type: "starting" } @@ -295,14 +296,64 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( }, }; + let projectSaveTimeout: number | undefined; + let saveInFlight = false; + let shouldResave = false; + let hasPendingProjectSave = false; + + const flushProjectConfig = async () => { + if (!hasPendingProjectSave && !saveInFlight) return; + if (saveInFlight) { + if (hasPendingProjectSave) { + shouldResave = true; + } + return; + } + saveInFlight = true; + shouldResave = false; + hasPendingProjectSave = false; + try { + await commands.setProjectConfig( + serializeProjectConfiguration(project), + ); + } catch (error) { + console.error("Failed to persist project config", error); + } finally { + saveInFlight = false; + if (shouldResave) { + shouldResave = false; + void flushProjectConfig(); + } + } + }; + + const scheduleProjectConfigSave = () => { + hasPendingProjectSave = true; + if (projectSaveTimeout) { + clearTimeout(projectSaveTimeout); + } + projectSaveTimeout = window.setTimeout(() => { + projectSaveTimeout = undefined; + void flushProjectConfig(); + }, PROJECT_SAVE_DEBOUNCE_MS); + }; + + onCleanup(() => { + if (projectSaveTimeout) { + clearTimeout(projectSaveTimeout); + projectSaveTimeout = undefined; + } + void flushProjectConfig(); + }); + createEffect( on( () => { trackStore(project); }, - debounce(() => { - commands.setProjectConfig(serializeProjectConfiguration(project)); - }), + () => { + scheduleProjectConfigSave(); + }, { defer: true }, ), ); From bd1fc730cde3d178f125875a3a13f63502d1fa30 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:07:30 +0000 Subject: [PATCH 02/34] fmt --- apps/desktop/src/routes/editor/context.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index 8d484ba35b..05c880a9e8 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -313,9 +313,7 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( shouldResave = false; hasPendingProjectSave = false; try { - await commands.setProjectConfig( - serializeProjectConfiguration(project), - ); + await commands.setProjectConfig(serializeProjectConfiguration(project)); } catch (error) { console.error("Failed to persist project config", error); } finally { From 5d95f24f12caabfb478a81290960598da83b9b10 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 20:15:25 +0000 Subject: [PATCH 03/34] Handle excluded windows in macOS screen capture --- apps/desktop/src-tauri/src/recording.rs | 129 ++++++++++++++++++++---- 1 file changed, 112 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 3a2ea626d8..76147917c6 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -31,6 +31,7 @@ use std::{ any::Any, collections::{HashMap, VecDeque}, error::Error as StdError, + mem, panic::AssertUnwindSafe, path::{Path, PathBuf}, str::FromStr, @@ -64,6 +65,8 @@ use crate::{ web_api::ManagerExt, windows::{CapWindowId, ShowCapWindow}, }; +#[cfg(target_os = "macos")] +use scap_targets::Window; #[derive(Clone)] pub struct InProgressRecordingCommon { @@ -107,6 +110,7 @@ unsafe impl Sync for SendableShareableContent {} #[cfg(target_os = "macos")] async fn acquire_shareable_content_for_target( capture_target: &ScreenCaptureTarget, + excluded_windows: &[scap_targets::WindowId], ) -> anyhow::Result { let mut refreshed = false; @@ -118,13 +122,20 @@ async fn acquire_shareable_content_for_target( .ok_or_else(|| anyhow!("GetShareableContent/NotAvailable"))?, ); - if !shareable_content_missing_target_display(capture_target, shareable_content.retained()) - .await - { + let sc_content = shareable_content.retained(); + let missing_display = + shareable_content_missing_target_display(capture_target, sc_content.clone()).await; + let missing_windows = + shareable_content_missing_windows(excluded_windows, sc_content.clone()).await; + + if !missing_display && (!missing_windows || refreshed) { + if missing_windows && refreshed { + debug!("Excluded windows missing from refreshed ScreenCaptureKit content"); + } return Ok(shareable_content); } - if refreshed { + if refreshed && missing_display { return Err(anyhow!("GetShareableContent/DisplayMissing")); } @@ -150,6 +161,74 @@ async fn shareable_content_missing_target_display( } } +#[cfg(target_os = "macos")] +async fn shareable_content_missing_windows( + excluded_windows: &[scap_targets::WindowId], + shareable_content: cidre::arc::R, +) -> bool { + if excluded_windows.is_empty() { + return false; + } + + for window_id in excluded_windows { + let Some(window) = Window::from_id(window_id) else { + continue; + }; + + if window + .raw_handle() + .as_sc(shareable_content.clone()) + .await + .is_none() + { + return true; + } + } + + false +} + +#[cfg(target_os = "macos")] +async fn prune_excluded_windows_without_shareable_content( + excluded_windows: &mut Vec, + shareable_content: cidre::arc::R, +) { + if excluded_windows.is_empty() { + return; + } + + let mut removed = 0usize; + let mut pruned = Vec::with_capacity(excluded_windows.len()); + let current = mem::take(excluded_windows); + + for window_id in current { + let Some(window) = Window::from_id(&window_id) else { + removed += 1; + continue; + }; + + if window + .raw_handle() + .as_sc(shareable_content.clone()) + .await + .is_some() + { + pruned.push(window_id); + } else { + removed += 1; + } + } + + if removed > 0 { + debug!( + removed, + "Dropping excluded windows missing from ScreenCaptureKit content" + ); + } + + *excluded_windows = pruned; +} + #[cfg(target_os = "macos")] fn is_shareable_content_error(err: &anyhow::Error) -> bool { err.chain().any(|cause| { @@ -613,17 +692,7 @@ pub async fn start_recording( state.camera_in_use = camera_feed.is_some(); #[cfg(target_os = "macos")] - let mut shareable_content = - acquire_shareable_content_for_target(&inputs.capture_target).await?; - - let common = InProgressRecordingCommon { - target_name, - inputs: inputs.clone(), - recording_dir: recording_dir.clone(), - }; - - #[cfg(target_os = "macos")] - let excluded_windows = { + let mut excluded_windows = { let window_exclusions = general_settings .as_ref() .map_or_else(general_settings::default_excluded_windows, |settings| { @@ -633,6 +702,24 @@ pub async fn start_recording( crate::window_exclusion::resolve_window_ids(&window_exclusions) }; + #[cfg(target_os = "macos")] + let mut shareable_content = + acquire_shareable_content_for_target(&inputs.capture_target, &excluded_windows) + .await?; + + #[cfg(target_os = "macos")] + prune_excluded_windows_without_shareable_content( + &mut excluded_windows, + shareable_content.retained(), + ) + .await; + + let common = InProgressRecordingCommon { + target_name, + inputs: inputs.clone(), + recording_dir: recording_dir.clone(), + }; + let mut mic_restart_attempts = 0; let done_fut = loop { @@ -751,8 +838,16 @@ pub async fn start_recording( } #[cfg(target_os = "macos")] Err(err) if is_shareable_content_error(&err) => { - shareable_content = - acquire_shareable_content_for_target(&inputs.capture_target).await?; + shareable_content = acquire_shareable_content_for_target( + &inputs.capture_target, + &excluded_windows, + ) + .await?; + prune_excluded_windows_without_shareable_content( + &mut excluded_windows, + shareable_content.retained(), + ) + .await; continue; } Err(err) if mic_restart_attempts == 0 && mic_actor_not_running(&err) => { From dbe8ae18df582afb1bc7668942706bea6e46bc28 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 20:50:12 +0000 Subject: [PATCH 04/34] Refactor waveform rendering in ClipTrack --- .../src/routes/editor/Timeline/ClipTrack.tsx | 215 +++++++++--------- 1 file changed, 112 insertions(+), 103 deletions(-) diff --git a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx index af712758a1..0d1fcc13fd 100644 --- a/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx +++ b/apps/desktop/src/routes/editor/Timeline/ClipTrack.tsx @@ -30,6 +30,69 @@ import { useSegmentWidth, } from "./Track"; +const CANVAS_HEIGHT = 52; +const WAVEFORM_MIN_DB = -60; +const WAVEFORM_SAMPLE_STEP = 0.1; +const WAVEFORM_CONTROL_STEP = 0.05; +const WAVEFORM_PADDING_SECONDS = 0.3; + +function gainToScale(gain?: number) { + if (!Number.isFinite(gain)) return 1; + const value = gain as number; + if (value <= WAVEFORM_MIN_DB) return 0; + return Math.max(0, 1 + value / -WAVEFORM_MIN_DB); +} + +function createWaveformPath( + segment: { start: number; end: number }, + waveform?: number[], +) { + if (typeof Path2D === "undefined") return; + if (!waveform || waveform.length === 0) return; + + const duration = Math.max(segment.end - segment.start, WAVEFORM_SAMPLE_STEP); + if (!Number.isFinite(duration) || duration <= 0) return; + + const path = new Path2D(); + path.moveTo(0, 1); + + const amplitudeAt = (index: number) => { + const sample = waveform[index]; + const db = + typeof sample === "number" && Number.isFinite(sample) + ? sample + : WAVEFORM_MIN_DB; + const clamped = Math.max(db, WAVEFORM_MIN_DB); + const amplitude = 1 + clamped / -WAVEFORM_MIN_DB; + return Math.min(Math.max(amplitude, 0), 1); + }; + + const controlStep = Math.min(WAVEFORM_CONTROL_STEP / duration, 0.25); + + for ( + let time = segment.start; + time <= segment.end + WAVEFORM_SAMPLE_STEP; + time += WAVEFORM_SAMPLE_STEP + ) { + const index = Math.floor(time * 10); + const normalizedX = (index / 10 - segment.start) / duration; + const prevX = + (index / 10 - WAVEFORM_SAMPLE_STEP - segment.start) / duration; + const y = 1 - amplitudeAt(index); + const prevY = 1 - amplitudeAt(index - 1); + const cpX1 = prevX + controlStep / 2; + const cpX2 = normalizedX - controlStep / 2; + path.bezierCurveTo(cpX1, prevY, cpX2, y, normalizedX, y); + } + + const closingX = + (segment.end + WAVEFORM_PADDING_SECONDS - segment.start) / duration; + path.lineTo(closingX, 1); + path.closePath(); + + return path; +} + function formatTime(totalSeconds: number): string { const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); @@ -48,115 +111,61 @@ function WaveformCanvas(props: { systemWaveform?: number[]; micWaveform?: number[]; segment: { start: number; end: number }; - secsPerPixel: number; }) { const { project } = useEditorContext(); - - let canvas: HTMLCanvasElement | undefined; const { width } = useSegmentContext(); - const { secsPerPixel } = useTimelineContext(); - - const render = ( - ctx: CanvasRenderingContext2D, - h: number, - waveform: number[], - color: string, - gain = 0, - ) => { - const maxAmplitude = h; - - // yellow please - ctx.fillStyle = color; - ctx.beginPath(); - - const step = 0.05 / secsPerPixel(); - - ctx.moveTo(0, h); - - const norm = (w: number) => { - const ww = Number.isFinite(w) ? w : -60; - return 1.0 - Math.max(ww + gain, -60) / -60; - }; - - for ( - let segmentTime = props.segment.start; - segmentTime <= props.segment.end + 0.1; - segmentTime += 0.1 - ) { - const index = Math.floor(segmentTime * 10); - const xTime = index / 10; - - const currentDb = - typeof waveform[index] === "number" ? waveform[index] : -60; - const amplitude = norm(currentDb) * maxAmplitude; - - const x = (xTime - props.segment.start) / secsPerPixel(); - const y = h - amplitude; - - const prevX = (xTime - 0.1 - props.segment.start) / secsPerPixel(); - const prevDb = - typeof waveform[index - 1] === "number" ? waveform[index - 1] : -60; - const prevAmplitude = norm(prevDb) * maxAmplitude; - const prevY = h - prevAmplitude; - - const cpX1 = prevX + step / 2; - const cpX2 = x - step / 2; - - ctx.bezierCurveTo(cpX1, prevY, cpX2, y, x, y); - } - - ctx.lineTo( - (props.segment.end + 0.3 - props.segment.start) / secsPerPixel(), - h, - ); + const segmentRange = createMemo(() => ({ + start: props.segment.start, + end: props.segment.end, + })); + const micPath = createMemo(() => + createWaveformPath(segmentRange(), props.micWaveform), + ); + const systemPath = createMemo(() => + createWaveformPath(segmentRange(), props.systemWaveform), + ); - ctx.closePath(); - ctx.fill(); - }; + let canvas: HTMLCanvasElement | undefined; - function renderWaveforms() { + createEffect(() => { if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; - const w = width(); - if (w <= 0) return; - - const h = canvas.height; - canvas.width = w; - ctx.clearRect(0, 0, w, h); - - if (props.micWaveform) - render( - ctx, - h, - props.micWaveform, - "rgba(255,255,255,0.4)", - project.audio.micVolumeDb, - ); - - if (props.systemWaveform) - render( - ctx, - h, - props.systemWaveform, - "rgba(255,150,0,0.5)", - project.audio.systemVolumeDb, - ); - } + const canvasWidth = Math.max(width(), 1); + canvas.width = canvasWidth; + const canvasHeight = canvas.height; + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + + const drawPath = ( + path: Path2D | undefined, + color: string, + gain?: number, + ) => { + if (!path) return; + const scale = gainToScale(gain); + if (scale <= 0) return; + ctx.save(); + ctx.translate(0, -1); + ctx.scale(1, scale); + ctx.translate(0, 1); + ctx.scale(canvasWidth, canvasHeight); + ctx.fillStyle = color; + ctx.fill(path); + ctx.restore(); + }; - createEffect(() => { - renderWaveforms(); + drawPath(micPath(), "rgba(255,255,255,0.4)", project.audio.micVolumeDb); + drawPath(systemPath(), "rgba(255,150,0,0.5)", project.audio.systemVolumeDb); }); return ( { canvas = el; - renderWaveforms(); }} class="absolute inset-0 w-full h-full pointer-events-none" - height={52} + height={CANVAS_HEIGHT} /> ); } @@ -184,6 +193,17 @@ export function ClipTrack( const segments = (): Array => project.timeline?.segments ?? [{ start: 0, end: duration(), timescale: 1 }]; + const segmentOffsets = createMemo(() => { + const segs = segments(); + const offsets: number[] = new Array(segs.length); + let sum = 0; + for (let idx = 0; idx < segs.length; idx++) { + offsets[idx] = sum; + sum += (segs[idx].end - segs[idx].start) / segs[idx].timescale; + } + return offsets; + }); + function onHandleReleased() { const { transform } = editorState.timeline; @@ -210,17 +230,7 @@ export function ClipTrack( initialStart: number; }>(null); - const prefixOffsets = createMemo(() => { - const segs = segments(); - const out: number[] = new Array(segs.length); - let sum = 0; - for (let k = 0; k < segs.length; k++) { - out[k] = sum; - sum += (segs[k].end - segs[k].start) / segs[k].timescale; - } - return out; - }); - const prevDuration = createMemo(() => prefixOffsets()[i()] ?? 0); + const prevDuration = createMemo(() => segmentOffsets()[i()] ?? 0); const relativeSegment = createMemo(() => { const ds = startHandleDrag(); @@ -481,7 +491,6 @@ export function ClipTrack( micWaveform={micWaveform()} systemWaveform={systemAudioWaveform()} segment={segment} - secsPerPixel={secsPerPixel()} /> )} From cced653e25a172e4148704cbc63f14c7ffc2ba05 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 20:52:18 +0000 Subject: [PATCH 05/34] Improve camera frame forwarding and logging --- crates/recording/src/sources/camera.rs | 29 +++++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/recording/src/sources/camera.rs b/crates/recording/src/sources/camera.rs index f26a8c5f5c..b31f89268d 100644 --- a/crates/recording/src/sources/camera.rs +++ b/crates/recording/src/sources/camera.rs @@ -7,7 +7,7 @@ use anyhow::anyhow; use cap_media_info::VideoInfo; use futures::{SinkExt, channel::mpsc}; use std::sync::Arc; -use tracing::{error, warn}; +use tracing::{error, info, warn}; pub struct Camera(Arc); @@ -26,7 +26,7 @@ impl VideoSource for Camera { let (tx, rx) = flume::bounded(8); feed_lock - .ask(camera::AddSender(tx)) + .ask(camera::AddSender(tx.clone())) .await .map_err(|e| anyhow!("Failed to add camera sender: {e}"))?; @@ -34,33 +34,46 @@ impl VideoSource for Camera { let feed_lock = feed_lock.clone(); async move { let mut receiver = rx; + let mut frame_count = 0u64; - loop { + let result = loop { match receiver.recv_async().await { Ok(frame) => { + frame_count += 1; if let Err(err) = video_tx.send(frame).await { error!( ?err, + frame_count, "Camera pipeline receiver dropped; stopping camera forwarding" ); - break; + break Ok(()); } } Err(_) => { - let (tx, new_rx) = flume::bounded(8); + let (new_tx, new_rx) = flume::bounded(8); - if let Err(err) = feed_lock.ask(camera::AddSender(tx)).await { + if let Err(err) = feed_lock.ask(camera::AddSender(new_tx)).await { warn!( ?err, "Camera sender disconnected and could not be reattached" ); - break; + break Err(err); } receiver = new_rx; } } - } + }; + + // Explicitly drop the sender to disconnect from the feed + drop(tx); + drop(receiver); + + info!( + frame_count, + ?result, + "Camera forwarding stopped after processing frames" + ); } }); From 37eb7c8a025a4a5e323f5ba6628e821353f18cea Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Mon, 17 Nov 2025 20:52:24 +0000 Subject: [PATCH 06/34] Update settings.local.json --- .claude/settings.local.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ce1d28dd79..d7e9474d78 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(pnpm typecheck:*)", "Bash(pnpm lint:*)", "Bash(pnpm build:*)", - "Bash(cargo check:*)" + "Bash(cargo check:*)", + "Bash(cargo build:*)" ], "deny": [], "ask": [] From 8b403a895708123508df258d176d364f643d62f5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:40:07 +0000 Subject: [PATCH 07/34] Update recording UI container styles --- apps/desktop/src/routes/in-progress-recording.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index 351cc6604d..f0323523ff 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -540,7 +540,7 @@ export default function () {
-
+
{(state) => (
setAnimation(0), 10); return ( -
+
Recording starting...
From 184ad5901f0ccd6d7329649bf803c866b89505eb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:49:45 +0000 Subject: [PATCH 08/34] lost a days work (I F'D UP) --- .claude/settings.local.json | 3 +- .../desktop/src-tauri/src/deeplink_actions.rs | 9 +- apps/desktop/src-tauri/src/lib.rs | 22 +-- apps/desktop/src-tauri/src/recording.rs | 184 ++---------------- apps/desktop/src-tauri/src/windows.rs | 48 +---- apps/desktop/src/routes/camera.tsx | 9 +- crates/recording/examples/camera_stream.rs | 56 ------ crates/recording/src/output_pipeline/core.rs | 65 +++---- .../recording/src/output_pipeline/ffmpeg.rs | 41 +--- crates/recording/src/sources/camera.rs | 50 +---- 10 files changed, 64 insertions(+), 423 deletions(-) delete mode 100644 crates/recording/examples/camera_stream.rs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d7e9474d78..ce1d28dd79 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,8 +4,7 @@ "Bash(pnpm typecheck:*)", "Bash(pnpm lint:*)", "Bash(pnpm build:*)", - "Bash(cargo check:*)", - "Bash(cargo build:*)" + "Bash(cargo check:*)" ], "deny": [], "ask": [] diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index 86b6245cf8..dbd90f667f 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -6,10 +6,7 @@ use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Url}; use tracing::trace; -use crate::{ - App, ArcLock, apply_camera_input, apply_mic_input, recording::StartRecordingInputs, - windows::ShowCapWindow, -}; +use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow}; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -119,8 +116,8 @@ impl DeepLinkAction { } => { let state = app.state::>(); - apply_camera_input(app.clone(), state.clone(), camera).await?; - apply_mic_input(state.clone(), mic_label).await?; + crate::set_camera_input(app.clone(), state.clone(), camera).await?; + crate::set_mic_input(state.clone(), mic_label).await?; let capture_target: ScreenCaptureTarget = match capture_mode { CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays() diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index ec2a2ae79b..c45ccc982a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -331,13 +331,6 @@ impl App { #[specta::specta] #[instrument(skip(state))] async fn set_mic_input(state: MutableState<'_, App>, label: Option) -> Result<(), String> { - apply_mic_input(state, label).await -} - -pub(crate) async fn apply_mic_input( - state: MutableState<'_, App>, - label: Option, -) -> Result<(), String> { let (mic_feed, studio_handle, current_label) = { let app = state.read().await; let handle = match app.current_recording() { @@ -421,14 +414,6 @@ async fn set_camera_input( app_handle: AppHandle, state: MutableState<'_, App>, id: Option, -) -> Result<(), String> { - apply_camera_input(app_handle, state, id).await -} - -pub(crate) async fn apply_camera_input( - app_handle: AppHandle, - state: MutableState<'_, App>, - id: Option, ) -> Result<(), String> { let app = state.read().await; let camera_feed = app.camera_feed.clone(); @@ -2558,8 +2543,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .flatten() .unwrap_or_default(); - let _ = apply_mic_input(app.state(), settings.mic_name).await; - let _ = apply_camera_input(app.clone(), app.state(), settings.camera_id).await; + let _ = set_mic_input(app.state(), settings.mic_name).await; + let _ = set_camera_input(app.clone(), app.state(), settings.camera_id).await; let _ = start_recording(app.clone(), app.state(), { recording::StartRecordingInputs { @@ -2635,9 +2620,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .camera_feed .ask(feeds::camera::RemoveInput) .await; - app_state.selected_mic_label = None; - app_state.selected_camera_id = None; - app_state.camera_in_use = false; } }); } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 76147917c6..e48f7229ed 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -31,7 +31,6 @@ use std::{ any::Any, collections::{HashMap, VecDeque}, error::Error as StdError, - mem, panic::AssertUnwindSafe, path::{Path, PathBuf}, str::FromStr, @@ -48,7 +47,6 @@ use crate::{ App, CurrentRecordingChanged, MutableState, NewStudioRecordingAdded, RecordingState, RecordingStopped, VideoUploadInfo, api::PresignedS3PutRequestMethod, - apply_camera_input, apply_mic_input, audio::AppSounds, auth::AuthStore, create_screenshot, @@ -57,7 +55,6 @@ use crate::{ }, open_external_link, presets::PresetsStore, - recording_settings::RecordingSettingsStore, thumbnails::*, upload::{ InstantMultipartUpload, build_video_meta, compress_image, create_or_get_video, upload_video, @@ -65,8 +62,6 @@ use crate::{ web_api::ManagerExt, windows::{CapWindowId, ShowCapWindow}, }; -#[cfg(target_os = "macos")] -use scap_targets::Window; #[derive(Clone)] pub struct InProgressRecordingCommon { @@ -110,7 +105,6 @@ unsafe impl Sync for SendableShareableContent {} #[cfg(target_os = "macos")] async fn acquire_shareable_content_for_target( capture_target: &ScreenCaptureTarget, - excluded_windows: &[scap_targets::WindowId], ) -> anyhow::Result { let mut refreshed = false; @@ -122,20 +116,13 @@ async fn acquire_shareable_content_for_target( .ok_or_else(|| anyhow!("GetShareableContent/NotAvailable"))?, ); - let sc_content = shareable_content.retained(); - let missing_display = - shareable_content_missing_target_display(capture_target, sc_content.clone()).await; - let missing_windows = - shareable_content_missing_windows(excluded_windows, sc_content.clone()).await; - - if !missing_display && (!missing_windows || refreshed) { - if missing_windows && refreshed { - debug!("Excluded windows missing from refreshed ScreenCaptureKit content"); - } + if !shareable_content_missing_target_display(capture_target, shareable_content.retained()) + .await + { return Ok(shareable_content); } - if refreshed && missing_display { + if refreshed { return Err(anyhow!("GetShareableContent/DisplayMissing")); } @@ -161,74 +148,6 @@ async fn shareable_content_missing_target_display( } } -#[cfg(target_os = "macos")] -async fn shareable_content_missing_windows( - excluded_windows: &[scap_targets::WindowId], - shareable_content: cidre::arc::R, -) -> bool { - if excluded_windows.is_empty() { - return false; - } - - for window_id in excluded_windows { - let Some(window) = Window::from_id(window_id) else { - continue; - }; - - if window - .raw_handle() - .as_sc(shareable_content.clone()) - .await - .is_none() - { - return true; - } - } - - false -} - -#[cfg(target_os = "macos")] -async fn prune_excluded_windows_without_shareable_content( - excluded_windows: &mut Vec, - shareable_content: cidre::arc::R, -) { - if excluded_windows.is_empty() { - return; - } - - let mut removed = 0usize; - let mut pruned = Vec::with_capacity(excluded_windows.len()); - let current = mem::take(excluded_windows); - - for window_id in current { - let Some(window) = Window::from_id(&window_id) else { - removed += 1; - continue; - }; - - if window - .raw_handle() - .as_sc(shareable_content.clone()) - .await - .is_some() - { - pruned.push(window_id); - } else { - removed += 1; - } - } - - if removed > 0 { - debug!( - removed, - "Dropping excluded windows missing from ScreenCaptureKit content" - ); - } - - *excluded_windows = pruned; -} - #[cfg(target_os = "macos")] fn is_shareable_content_error(err: &anyhow::Error) -> bool { err.chain().any(|cause| { @@ -430,41 +349,6 @@ pub enum RecordingAction { UpgradeRequired, } -async fn restore_inputs_from_store_if_missing(app: &AppHandle, state: &MutableState<'_, App>) { - let guard = state.read().await; - let recording_active = !matches!(guard.recording_state, RecordingState::None); - let needs_mic = guard.selected_mic_label.is_none(); - let needs_camera = guard.selected_camera_id.is_none(); - drop(guard); - - if recording_active || (!needs_mic && !needs_camera) { - return; - } - - let settings = match RecordingSettingsStore::get(app) { - Ok(Some(settings)) => settings, - Ok(None) => return, - Err(err) => { - warn!(%err, "Failed to load recording settings while restoring inputs"); - return; - } - }; - - if let Some(mic) = settings.mic_name.clone().filter(|_| needs_mic) { - match apply_mic_input(app.state(), Some(mic)).await { - Err(err) => warn!(%err, "Failed to restore microphone input"), - Ok(_) => {} - } - } - - if let Some(camera) = settings.camera_id.clone().filter(|_| needs_camera) { - match apply_camera_input(app.clone(), app.state(), Some(camera)).await { - Err(err) => warn!(%err, "Failed to restore camera input"), - Ok(_) => {} - } - } -} - #[tauri::command] #[specta::specta] #[tracing::instrument(name = "recording", skip_all)] @@ -473,28 +357,10 @@ pub async fn start_recording( state_mtx: MutableState<'_, App>, inputs: StartRecordingInputs, ) -> Result { - restore_inputs_from_store_if_missing(&app, &state_mtx).await; - if !matches!(state_mtx.read().await.recording_state, RecordingState::None) { return Err("Recording already in progress".to_string()); } - let has_camera_selected = { - let guard = state_mtx.read().await; - guard.selected_camera_id.is_some() - }; - let camera_window_open = CapWindowId::Camera.get(&app).is_some(); - let should_open_camera_preview = - matches!(inputs.mode, RecordingMode::Instant) && has_camera_selected && !camera_window_open; - - if should_open_camera_preview { - ShowCapWindow::Camera - .show(&app) - .await - .map_err(|err| error!("Failed to show camera preview window: {err}")) - .ok(); - } - let id = uuid::Uuid::new_v4().to_string(); let general_settings = GeneralSettingsStore::get(&app).ok().flatten(); let general_settings = general_settings.as_ref(); @@ -692,7 +558,17 @@ pub async fn start_recording( state.camera_in_use = camera_feed.is_some(); #[cfg(target_os = "macos")] - let mut excluded_windows = { + let mut shareable_content = + acquire_shareable_content_for_target(&inputs.capture_target).await?; + + let common = InProgressRecordingCommon { + target_name, + inputs: inputs.clone(), + recording_dir: recording_dir.clone(), + }; + + #[cfg(target_os = "macos")] + let excluded_windows = { let window_exclusions = general_settings .as_ref() .map_or_else(general_settings::default_excluded_windows, |settings| { @@ -702,24 +578,6 @@ pub async fn start_recording( crate::window_exclusion::resolve_window_ids(&window_exclusions) }; - #[cfg(target_os = "macos")] - let mut shareable_content = - acquire_shareable_content_for_target(&inputs.capture_target, &excluded_windows) - .await?; - - #[cfg(target_os = "macos")] - prune_excluded_windows_without_shareable_content( - &mut excluded_windows, - shareable_content.retained(), - ) - .await; - - let common = InProgressRecordingCommon { - target_name, - inputs: inputs.clone(), - recording_dir: recording_dir.clone(), - }; - let mut mic_restart_attempts = 0; let done_fut = loop { @@ -838,16 +696,8 @@ pub async fn start_recording( } #[cfg(target_os = "macos")] Err(err) if is_shareable_content_error(&err) => { - shareable_content = acquire_shareable_content_for_target( - &inputs.capture_target, - &excluded_windows, - ) - .await?; - prune_excluded_windows_without_shareable_content( - &mut excluded_windows, - shareable_content.retained(), - ) - .await; + shareable_content = + acquire_shareable_content_for_target(&inputs.capture_target).await?; continue; } Err(err) if mic_restart_attempts == 0 && mic_actor_not_running(&err) => { diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 3148ad0b85..d6c29095cd 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -21,10 +21,10 @@ use tokio::sync::RwLock; use tracing::{debug, error, instrument, warn}; use crate::{ - App, ArcLock, RequestScreenCapturePrewarm, apply_camera_input, apply_mic_input, fake_window, + App, ArcLock, RequestScreenCapturePrewarm, fake_window, general_settings::{self, AppTheme, GeneralSettingsStore}, permissions, - recording_settings::{RecordingSettingsStore, RecordingTargetMode}, + recording_settings::RecordingTargetMode, target_select_overlay::WindowFocusManager, window_exclusion::WindowExclusion, }; @@ -282,8 +282,6 @@ impl ShowCapWindow { crate::platform::set_window_level(window.as_ref().window(), 50); } - restore_recording_inputs_if_idle(app); - #[cfg(target_os = "macos")] { let app_handle = app.clone(); @@ -799,48 +797,6 @@ impl ShowCapWindow { } } -fn restore_recording_inputs_if_idle(app: &AppHandle) { - let settings = match RecordingSettingsStore::get(app) { - Ok(Some(settings)) => settings, - Ok(None) => return, - Err(err) => { - warn!(%err, "Failed to load recording settings while restoring inputs"); - return; - } - }; - - let mic_name = settings.mic_name.clone(); - let camera_id = settings.camera_id.clone(); - - if mic_name.is_none() && camera_id.is_none() { - return; - } - - let app_handle = app.clone(); - let state = app_handle.state::>(); - let app_state = state.inner().clone(); - - tauri::async_runtime::spawn(async move { - if app_state.read().await.is_recording_active_or_pending() { - return; - } - - if let Some(mic) = mic_name { - match apply_mic_input(app_handle.state(), Some(mic)).await { - Err(err) => warn!(%err, "Failed to restore microphone input"), - Ok(_) => {} - } - } - - if let Some(camera) = camera_id { - match apply_camera_input(app_handle.clone(), app_handle.state(), Some(camera)).await { - Err(err) => warn!(%err, "Failed to restore camera input"), - Ok(_) => {} - } - } - }); -} - #[cfg(target_os = "macos")] fn add_traffic_lights(window: &WebviewWindow, controls_inset: Option>) { use crate::platform::delegates; diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index ba53382b19..47f09aa2d4 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -22,6 +22,7 @@ import { import { createStore } from "solid-js/store"; import { generalSettingsStore } from "~/store"; import { createTauriEventListener } from "~/utils/createEventListener"; +import { createCameraMutation } from "~/utils/queries"; import { createImageDataWS, createLazySignal } from "~/utils/socket"; import { commands, events } from "~/utils/tauri"; import { @@ -87,6 +88,8 @@ function NativeCameraPreviewPage(props: { disconnected: Accessor }) { commands.awaitCameraPreviewReady(), ); + const setCamera = createCameraMutation(); + return (
}) {
- void getCurrentWindow().close()}> + setCamera.mutate(null)}> }) { let cameraCanvasRef: HTMLCanvasElement | undefined; + const setCamera = createCameraMutation(); + createEffect( on( () => rawOptions.cameraLabel, @@ -289,7 +294,7 @@ function LegacyCameraPreviewPage(props: { disconnected: Accessor }) {
- void getCurrentWindow().close()}> + setCamera.mutate(null)}> = tokio::spawn(async move { - let start = Instant::now(); - let mut frames = 0usize; - - while start.elapsed() < Duration::from_secs(5) { - match rx.recv_async().await { - Ok(_frame) => { - frames += 1; - } - Err(err) => { - eprintln!("Channel closed: {err}"); - break; - } - } - } - - println!("Captured {frames} frames in 5 seconds"); - }); - - reader.await.expect("reader crashed"); - - drop(lock); -} diff --git a/crates/recording/src/output_pipeline/core.rs b/crates/recording/src/output_pipeline/core.rs index c91af5e677..b68eb5ad2b 100644 --- a/crates/recording/src/output_pipeline/core.rs +++ b/crates/recording/src/output_pipeline/core.rs @@ -429,7 +429,7 @@ async fn setup_muxer( fn spawn_video_encoder, TVideo: VideoSource>( setup_ctx: &mut SetupCtx, mut video_source: TVideo, - video_rx: mpsc::Receiver, + mut video_rx: mpsc::Receiver, first_tx: oneshot::Sender, stop_token: CancellationToken, muxer: Arc>, @@ -450,53 +450,34 @@ fn spawn_video_encoder, TVideo: V } }); - setup_ctx.tasks().spawn("mux-video", { - let stop_token_on_close = stop_token.clone(); - async move { - use futures::StreamExt; + setup_ctx.tasks().spawn("mux-video", async move { + use futures::StreamExt; - let mut first_tx = Some(first_tx); - let cancelled = stop_token.cancelled_owned(); - tokio::pin!(cancelled); - let mut video_rx = video_rx.fuse(); + let mut first_tx = Some(first_tx); - loop { - tokio::select! { - _ = &mut cancelled => { - break; - } - maybe_frame = video_rx.next() => { - match maybe_frame { - Some(frame) => { - let timestamp = frame.timestamp(); - - if let Some(first_tx) = first_tx.take() { - let _ = first_tx.send(timestamp); - } - - muxer - .lock() - .await - .send_video_frame(frame, timestamp.duration_since(timestamps)) - .map_err(|e| anyhow!("Error queueing video frame: {e}"))?; - } - None => { - warn!( - video_source = %std::any::type_name::(), - "Video mux channel closed before cancellation; cancelling pipeline" - ); - stop_token_on_close.cancel(); - break; - } - } + stop_token + .run_until_cancelled(async { + while let Some(frame) = video_rx.next().await { + let timestamp = frame.timestamp(); + + if let Some(first_tx) = first_tx.take() { + let _ = first_tx.send(timestamp); } + + muxer + .lock() + .await + .send_video_frame(frame, timestamp.duration_since(timestamps)) + .map_err(|e| anyhow!("Error queueing video frame: {e}"))?; } - } - muxer.lock().await.stop(); + Ok::<(), anyhow::Error>(()) + }) + .await; - Ok(()) - } + muxer.lock().await.stop(); + + Ok(()) }); } diff --git a/crates/recording/src/output_pipeline/ffmpeg.rs b/crates/recording/src/output_pipeline/ffmpeg.rs index 37ec3b8269..8453479de1 100644 --- a/crates/recording/src/output_pipeline/ffmpeg.rs +++ b/crates/recording/src/output_pipeline/ffmpeg.rs @@ -28,8 +28,6 @@ pub struct Mp4Muxer { output: ffmpeg::format::context::Output, video_encoder: Option, audio_encoder: Option, - video_frame_duration: Option, - last_video_ts: Option, } impl Muxer for Mp4Muxer { @@ -48,16 +46,10 @@ impl Muxer for Mp4Muxer { { let mut output = ffmpeg::format::output(&output_path)?; - let (video_encoder, video_frame_duration) = match video_config { - Some(config) => { - let duration = Self::frame_duration(&config); - let encoder = H264Encoder::builder(config) - .build(&mut output) - .context("video encoder")?; - (Some(encoder), Some(duration)) - } - None => (None, None), - }; + let video_encoder = video_config + .map(|video_config| H264Encoder::builder(video_config).build(&mut output)) + .transpose() + .context("video encoder")?; let audio_encoder = audio_config .map(|config| AACEncoder::init(config, &mut output)) @@ -70,8 +62,6 @@ impl Muxer for Mp4Muxer { output, video_encoder, audio_encoder, - video_frame_duration, - last_video_ts: None, }) } @@ -106,19 +96,9 @@ impl VideoMuxer for Mp4Muxer { fn send_video_frame( &mut self, frame: Self::VideoFrame, - mut timestamp: Duration, + timestamp: Duration, ) -> anyhow::Result<()> { if let Some(video_encoder) = self.video_encoder.as_mut() { - if let Some(frame_duration) = self.video_frame_duration { - if let Some(last_ts) = self.last_video_ts - && timestamp <= last_ts - { - timestamp = last_ts + frame_duration; - } - - self.last_video_ts = Some(timestamp); - } - video_encoder.queue_frame(frame.inner, timestamp, &mut self.output)?; } @@ -126,17 +106,6 @@ impl VideoMuxer for Mp4Muxer { } } -impl Mp4Muxer { - fn frame_duration(info: &VideoInfo) -> Duration { - let num = info.frame_rate.numerator().max(1); - let den = info.frame_rate.denominator().max(1); - - let nanos = ((den as u128 * 1_000_000_000u128) / num as u128).max(1); - - Duration::from_nanos(nanos as u64) - } -} - impl AudioMuxer for Mp4Muxer { fn send_audio_frame(&mut self, frame: AudioFrame, timestamp: Duration) -> anyhow::Result<()> { if let Some(audio_encoder) = self.audio_encoder.as_mut() { diff --git a/crates/recording/src/sources/camera.rs b/crates/recording/src/sources/camera.rs index b31f89268d..48daae8e37 100644 --- a/crates/recording/src/sources/camera.rs +++ b/crates/recording/src/sources/camera.rs @@ -7,7 +7,6 @@ use anyhow::anyhow; use cap_media_info::VideoInfo; use futures::{SinkExt, channel::mpsc}; use std::sync::Arc; -use tracing::{error, info, warn}; pub struct Camera(Arc); @@ -26,54 +25,13 @@ impl VideoSource for Camera { let (tx, rx) = flume::bounded(8); feed_lock - .ask(camera::AddSender(tx.clone())) + .ask(camera::AddSender(tx)) .await .map_err(|e| anyhow!("Failed to add camera sender: {e}"))?; - tokio::spawn({ - let feed_lock = feed_lock.clone(); - async move { - let mut receiver = rx; - let mut frame_count = 0u64; - - let result = loop { - match receiver.recv_async().await { - Ok(frame) => { - frame_count += 1; - if let Err(err) = video_tx.send(frame).await { - error!( - ?err, - frame_count, - "Camera pipeline receiver dropped; stopping camera forwarding" - ); - break Ok(()); - } - } - Err(_) => { - let (new_tx, new_rx) = flume::bounded(8); - - if let Err(err) = feed_lock.ask(camera::AddSender(new_tx)).await { - warn!( - ?err, - "Camera sender disconnected and could not be reattached" - ); - break Err(err); - } - - receiver = new_rx; - } - } - }; - - // Explicitly drop the sender to disconnect from the feed - drop(tx); - drop(receiver); - - info!( - frame_count, - ?result, - "Camera forwarding stopped after processing frames" - ); + tokio::spawn(async move { + while let Ok(frame) = rx.recv_async().await { + let _ = video_tx.send(frame).await; } }); From fee339191c3bca94f36abc2591200712651ba280 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:00:28 +0000 Subject: [PATCH 09/34] Reset camera and mic state on window close and recording end --- apps/desktop/src-tauri/src/lib.rs | 58 +++++++++++++------------ apps/desktop/src-tauri/src/recording.rs | 27 +++++++----- 2 files changed, 46 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c45ccc982a..0841ea0685 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2596,33 +2596,37 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { match event { WindowEvent::Destroyed => { - if let Ok(window_id) = CapWindowId::from_str(label) { - match window_id { - CapWindowId::Main => { - let app = app.clone(); - - for (id, window) in app.webview_windows() { - if let Ok(CapWindowId::TargetSelectOverlay { .. }) = - CapWindowId::from_str(&id) - { - let _ = window.close(); - } - } - - tokio::spawn(async move { - let state = app.state::>(); - let app_state = &mut *state.write().await; - - if !app_state.is_recording_active_or_pending() { - let _ = - app_state.mic_feed.ask(microphone::RemoveInput).await; - let _ = app_state - .camera_feed - .ask(feeds::camera::RemoveInput) - .await; - } - }); - } + if let Ok(window_id) = CapWindowId::from_str(label) { + match window_id { + CapWindowId::Main => { + let app = app.clone(); + + for (id, window) in app.webview_windows() { + if let Ok(CapWindowId::TargetSelectOverlay { .. }) = + CapWindowId::from_str(&id) + { + let _ = window.close(); + } + } + + tokio::spawn(async move { + let state = app.state::>(); + let app_state = &mut *state.write().await; + + if !app_state.is_recording_active_or_pending() { + let _ = + app_state.mic_feed.ask(microphone::RemoveInput).await; + let _ = app_state + .camera_feed + .ask(feeds::camera::RemoveInput) + .await; + + app_state.selected_mic_label = None; + app_state.selected_camera_id = None; + app_state.camera_in_use = false; + } + }); + } CapWindowId::Editor { id } => { let window_ids = EditorWindowIds::get(window.app_handle()); window_ids.ids.lock().unwrap().retain(|(_, _id)| *_id != id); diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index e48f7229ed..d70995116f 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1024,18 +1024,21 @@ async fn handle_recording_end( let _ = window.close(); } - if let Some(window) = CapWindowId::Main.get(&handle) { - window.unminimize().ok(); - } else { - if let Some(v) = CapWindowId::Camera.get(&handle) { - let _ = v.close(); - } - let _ = app.mic_feed.ask(microphone::RemoveInput).await; - let _ = app.camera_feed.ask(camera::RemoveInput).await; - if let Some(win) = CapWindowId::Camera.get(&handle) { - win.close().ok(); - } - } + if let Some(window) = CapWindowId::Main.get(&handle) { + window.unminimize().ok(); + } else { + if let Some(v) = CapWindowId::Camera.get(&handle) { + let _ = v.close(); + } + let _ = app.mic_feed.ask(microphone::RemoveInput).await; + let _ = app.camera_feed.ask(camera::RemoveInput).await; + app.selected_mic_label = None; + app.selected_camera_id = None; + app.camera_in_use = false; + if let Some(win) = CapWindowId::Camera.get(&handle) { + win.close().ok(); + } + } CurrentRecordingChanged.emit(&handle).ok(); From d2b8fd4bacb2e80a8d7fda09e38ce30a29f75d82 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 14:50:45 +0000 Subject: [PATCH 10/34] Update close button to use window close action --- apps/desktop/src/routes/camera.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index 47f09aa2d4..b96e9c8afc 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -101,7 +101,7 @@ function NativeCameraPreviewPage(props: { disconnected: Accessor }) {
- setCamera.mutate(null)}> + getCurrentWindow().close()}> }) {
- setCamera.mutate(null)}> + getCurrentWindow().close()}> Date: Tue, 18 Nov 2025 14:51:33 +0000 Subject: [PATCH 11/34] Add initializing state to recording flow --- .../src/routes/in-progress-recording.tsx | 125 ++++++------------ apps/desktop/src/utils/tauri.ts | 3 +- 2 files changed, 45 insertions(+), 83 deletions(-) diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index f0323523ff..c82e2b748c 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -22,7 +22,6 @@ import { Show, } from "solid-js"; import { createStore, produce } from "solid-js/store"; -import createPresence from "solid-presence"; import { authStore } from "~/store"; import { createTauriEventListener } from "~/utils/createEventListener"; import { @@ -38,6 +37,7 @@ import type { import { commands, events } from "~/utils/tauri"; type State = + | { variant: "initializing" } | { variant: "countdown"; from: number; current: number } | { variant: "recording" } | { variant: "paused" } @@ -59,7 +59,7 @@ const FAKE_WINDOW_BOUNDS_NAME = "recording-controls-interactive-area"; export default function () { const [state, setState] = createSignal( window.COUNTDOWN === 0 - ? { variant: "recording" } + ? { variant: "initializing" } : { variant: "countdown", from: window.COUNTDOWN, @@ -189,6 +189,18 @@ export default function () { } }); + createEffect(() => { + if (state().variant === "initializing") { + const recording = currentRecording.data as any; + if (recording?.status === "recording") { + setDisconnectedInputs({ microphone: false, camera: false }); + setRecordingFailure(null); + setState({ variant: "recording" }); + setStart(Date.now()); + } + } + }); + createTimer( () => { if (state().variant !== "recording") return; @@ -467,7 +479,8 @@ export default function () { }; const adjustedTime = () => { - if (state().variant === "countdown") return 0; + if (state().variant === "countdown" || state().variant === "initializing") + return 0; let t = time() - start(); for (const { pause, resume } of pauseResumes) { if (pause && resume) t -= resume - pause; @@ -502,21 +515,12 @@ export default function () { return MAX_RECORDING_FOR_FREE - adjustedTime(); }; - const [countdownRef, setCountdownRef] = createSignal( - null, - ); - const showCountdown = () => state().variant === "countdown"; - const countdownPresence = createPresence({ - show: showCountdown, - element: countdownRef, - }); - const countdownState = createMemo< - Extract | undefined - >((prev) => { + const isInitializing = () => state().variant === "initializing"; + const isCountdown = () => state().variant === "countdown"; + const countdownCurrent = () => { const s = state(); - if (s.variant === "countdown") return s; - if (prev && countdownPresence.present()) return prev; - }); + return s.variant === "countdown" ? s.current : 0; + }; return (
@@ -541,23 +545,12 @@ export default function () {
- - {(state) => ( -
- -
- )} -
@@ -622,7 +622,11 @@ export default function () { {canPauseRecording() && ( togglePause.mutate()} title={ state().variant === "paused" @@ -644,7 +648,7 @@ export default function () { )} restartRecording.mutate()} title="Restart recording" aria-label="Restart recording" @@ -652,7 +656,7 @@ export default function () { deleteRecording.mutate()} title="Delete recording" aria-label="Delete recording" @@ -728,49 +732,6 @@ function createAudioInputLevel() { return level; } -function Countdown(props: { from: number; current: number }) { - const [animation, setAnimation] = createSignal(1); - setTimeout(() => setAnimation(0), 10); - - return ( -
-
-
Recording starting...
-
- - - - - - {props.current} - -
-
-
- ); -} - function cameraMatchesSelection( camera: CameraInfo, selected?: DeviceOrModelID | null, diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 57ce4676f9..b764f2dcbd 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -383,7 +383,7 @@ export type ClipOffsets = { camera?: number; mic?: number; system_audio?: number export type CommercialLicense = { licenseKey: string; expiryDate: number | null; refresh: number; activatedOn: number } export type CornerStyle = "squircle" | "rounded" export type Crop = { position: XY; size: XY } -export type CurrentRecording = { target: CurrentRecordingTarget; mode: RecordingMode } +export type CurrentRecording = { target: CurrentRecordingTarget; mode: RecordingMode; status: RecordingStatus } export type CurrentRecordingChanged = null export type CurrentRecordingTarget = { window: { id: WindowId; bounds: LogicalBounds } } | { screen: { id: DisplayId } } | { area: { screen: DisplayId; bounds: LogicalBounds } } export type CursorAnimationStyle = "slow" | "mellow" | "custom" @@ -456,6 +456,7 @@ export type RecordingMode = "studio" | "instant" export type RecordingOptionsChanged = null export type RecordingSettingsStore = { target: ScreenCaptureTarget | null; micName: string | null; cameraId: DeviceOrModelID | null; mode: RecordingMode | null; systemAudio: boolean; organizationId: string | null } export type RecordingStarted = null +export type RecordingStatus = "pending" | "recording" export type RecordingStopped = null export type RecordingTargetMode = "display" | "window" | "area" export type RenderFrameEvent = { frame_number: number; fps: number; resolution_base: XY } From 41fbc5c5c04c864a04feac9f216c1d56de5fe13b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 14:51:41 +0000 Subject: [PATCH 12/34] Improve camera initialization and recording state handling --- apps/desktop/src-tauri/src/lib.rs | 142 ++++++++++++++++-------- apps/desktop/src-tauri/src/recording.rs | 81 ++++++++++---- apps/desktop/src-tauri/src/windows.rs | 8 ++ crates/recording/src/feeds/camera.rs | 7 +- 4 files changed, 170 insertions(+), 68 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 0841ea0685..284f8b3222 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -444,12 +444,39 @@ async fn set_camera_input( .map_err(|e| e.to_string())?; } Some(id) => { - camera_feed - .ask(feeds::camera::SetInput { id: id.clone() }) - .await - .map_err(|e| e.to_string())? - .await - .map_err(|e| e.to_string())?; + let mut attempts = 0; + loop { + attempts += 1; + + // We first ask the actor to set the input + // This returns a future that resolves when the camera is actually ready + let request = camera_feed + .ask(feeds::camera::SetInput { id: id.clone() }) + .await + .map_err(|e| e.to_string()); + + let result = match request { + Ok(future) => future.await.map_err(|e| e.to_string()), + Err(e) => Err(e), + }; + + match result { + Ok(_) => break, + Err(e) => { + if attempts >= 3 { + return Err(format!( + "Failed to initialize camera after {} attempts: {}", + attempts, e + )); + } + warn!( + "Failed to set camera input (attempt {}): {}. Retrying...", + attempts, e + ); + tokio::time::sleep(Duration::from_millis(500)).await; + } + } + } ShowCapWindow::Camera .show(&app_handle) @@ -726,11 +753,19 @@ enum CurrentRecordingTarget { }, } +#[derive(Serialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum RecordingStatus { + Pending, + Recording, +} + #[derive(Serialize, Type)] #[serde(rename_all = "camelCase")] struct CurrentRecording { target: CurrentRecordingTarget, mode: RecordingMode, + status: RecordingStatus, } #[tauri::command] @@ -741,10 +776,14 @@ async fn get_current_recording( ) -> Result>, ()> { let state = state.read().await; - let (mode, capture_target) = match &state.recording_state { + let (mode, capture_target, status) = match &state.recording_state { RecordingState::None => return Ok(JsonValue::new(&None)), - RecordingState::Pending { mode, target } => (*mode, target), - RecordingState::Active(inner) => (inner.mode(), inner.capture_target()), + RecordingState::Pending { mode, target } => (*mode, target, RecordingStatus::Pending), + RecordingState::Active(inner) => ( + inner.mode(), + inner.capture_target(), + RecordingStatus::Recording, + ), }; let target = match capture_target { @@ -762,7 +801,11 @@ async fn get_current_recording( }, }; - Ok(JsonValue::new(&Some(CurrentRecording { target, mode }))) + Ok(JsonValue::new(&Some(CurrentRecording { + target, + mode, + status, + }))) } #[derive(Serialize, Type, tauri_specta::Event, Clone)] @@ -2596,37 +2639,37 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { match event { WindowEvent::Destroyed => { - if let Ok(window_id) = CapWindowId::from_str(label) { - match window_id { - CapWindowId::Main => { - let app = app.clone(); - - for (id, window) in app.webview_windows() { - if let Ok(CapWindowId::TargetSelectOverlay { .. }) = - CapWindowId::from_str(&id) - { - let _ = window.close(); - } - } - - tokio::spawn(async move { - let state = app.state::>(); - let app_state = &mut *state.write().await; - - if !app_state.is_recording_active_or_pending() { - let _ = - app_state.mic_feed.ask(microphone::RemoveInput).await; - let _ = app_state - .camera_feed - .ask(feeds::camera::RemoveInput) - .await; - - app_state.selected_mic_label = None; - app_state.selected_camera_id = None; - app_state.camera_in_use = false; - } - }); - } + if let Ok(window_id) = CapWindowId::from_str(label) { + match window_id { + CapWindowId::Main => { + let app = app.clone(); + + for (id, window) in app.webview_windows() { + if let Ok(CapWindowId::TargetSelectOverlay { .. }) = + CapWindowId::from_str(&id) + { + let _ = window.close(); + } + } + + tokio::spawn(async move { + let state = app.state::>(); + let app_state = &mut *state.write().await; + + if !app_state.is_recording_active_or_pending() { + let _ = + app_state.mic_feed.ask(microphone::RemoveInput).await; + let _ = app_state + .camera_feed + .ask(feeds::camera::RemoveInput) + .await; + + app_state.selected_mic_label = None; + app_state.selected_camera_id = None; + app_state.camera_in_use = false; + } + }); + } CapWindowId::Editor { id } => { let window_ids = EditorWindowIds::get(window.app_handle()); window_ids.ids.lock().unwrap().retain(|(_, _id)| *_id != id); @@ -2681,11 +2724,18 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { CapWindowId::Camera => { let app = app.clone(); tokio::spawn(async move { - app.state::>() - .write() - .await - .camera_preview - .on_window_close(); + let state = app.state::>(); + let mut app_state = state.write().await; + + app_state.camera_preview.on_window_close(); + + if !app_state.is_recording_active_or_pending() { + let _ = app_state + .camera_feed + .ask(feeds::camera::RemoveInput) + .await; + app_state.camera_in_use = false; + } }); } _ => {} diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index d70995116f..1c821f87b2 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -545,16 +545,57 @@ pub async fn start_recording( let inputs = inputs.clone(); async move { fail!("recording::spawn_actor"); - let mut state = state_mtx.write().await; - use kameo::error::SendError; - let camera_feed = match state.camera_feed.ask(camera::Lock).await { - Ok(lock) => Some(Arc::new(lock)), - Err(SendError::HandlerError(camera::LockFeedError::NoInput)) => None, + // Initialize camera if selected but not active + let (camera_feed_actor, selected_camera_id) = { + let state = state_mtx.read().await; + (state.camera_feed.clone(), state.selected_camera_id.clone()) + }; + + let camera_lock_result = camera_feed_actor.ask(camera::Lock).await; + + let camera_feed_lock = match camera_lock_result { + Ok(lock) => Some(lock), + Err(SendError::HandlerError(camera::LockFeedError::NoInput)) => { + if let Some(id) = selected_camera_id { + info!( + "Camera selected but not initialized, initializing: {:?}", + id + ); + match camera_feed_actor + .ask(camera::SetInput { id: id.clone() }) + .await + { + Ok(fut) => match fut.await { + Ok(_) => match camera_feed_actor.ask(camera::Lock).await { + Ok(lock) => Some(lock), + Err(e) => { + warn!("Failed to lock camera after initialization: {}", e); + None + } + }, + Err(e) => { + warn!("Failed to initialize camera: {}", e); + None + } + }, + Err(e) => { + warn!("Failed to ask SetInput: {}", e); + None + } + } + } else { + None + } + } Err(e) => return Err(anyhow!(e.to_string())), }; + let mut state = state_mtx.write().await; + + let camera_feed = camera_feed_lock.map(Arc::new); + state.camera_in_use = camera_feed.is_some(); #[cfg(target_os = "macos")] @@ -1024,21 +1065,21 @@ async fn handle_recording_end( let _ = window.close(); } - if let Some(window) = CapWindowId::Main.get(&handle) { - window.unminimize().ok(); - } else { - if let Some(v) = CapWindowId::Camera.get(&handle) { - let _ = v.close(); - } - let _ = app.mic_feed.ask(microphone::RemoveInput).await; - let _ = app.camera_feed.ask(camera::RemoveInput).await; - app.selected_mic_label = None; - app.selected_camera_id = None; - app.camera_in_use = false; - if let Some(win) = CapWindowId::Camera.get(&handle) { - win.close().ok(); - } - } + if let Some(window) = CapWindowId::Main.get(&handle) { + window.unminimize().ok(); + } else { + if let Some(v) = CapWindowId::Camera.get(&handle) { + let _ = v.close(); + } + let _ = app.mic_feed.ask(microphone::RemoveInput).await; + let _ = app.camera_feed.ask(camera::RemoveInput).await; + app.selected_mic_label = None; + app.selected_camera_id = None; + app.camera_in_use = false; + if let Some(win) = CapWindowId::Camera.get(&handle) { + win.close().ok(); + } + } CurrentRecordingChanged.emit(&handle).ok(); diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index d6c29095cd..3d567e5797 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -28,6 +28,7 @@ use crate::{ target_select_overlay::WindowFocusManager, window_exclusion::WindowExclusion, }; +use cap_recording::feeds; #[cfg(target_os = "macos")] const DEFAULT_TRAFFIC_LIGHTS_INSET: LogicalPosition = LogicalPosition::new(12.0, 12.0); @@ -500,6 +501,13 @@ impl ShowCapWindow { let window = window_builder.build()?; if enable_native_camera_preview { + if let Some(id) = state.selected_camera_id.clone() + && !state.camera_in_use + { + let _ = state.camera_feed.ask(feeds::camera::SetInput { id }).await; + state.camera_in_use = true; + } + let camera_feed = state.camera_feed.clone(); if let Err(err) = state .camera_preview diff --git a/crates/recording/src/feeds/camera.rs b/crates/recording/src/feeds/camera.rs index a7300ad734..d75dde0b15 100644 --- a/crates/recording/src/feeds/camera.rs +++ b/crates/recording/src/feeds/camera.rs @@ -592,8 +592,7 @@ impl Message for CameraFeed { match self.state { State::Locked { .. } | State::Open(OpenState { - connecting: None, - attached: Some(..), + connecting: None, .. }) => { msg.0.send(()).ok(); } @@ -743,6 +742,10 @@ impl Message for CameraFeed { && connecting.id == msg.id { state.connecting = None; + + for tx in &mut self.on_ready.drain(..) { + tx.send(()).ok(); + } } Ok(()) From 44a5348e5b56135133fbb4a8f2cf27900e063e63 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 15:14:03 +0000 Subject: [PATCH 13/34] Refactor type casting for currentRecording data --- apps/desktop/src/routes/in-progress-recording.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index c82e2b748c..b4cbd007ad 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -31,6 +31,7 @@ import { import { handleRecordingResult } from "~/utils/recording"; import type { CameraInfo, + CurrentRecording, DeviceOrModelID, RecordingInputKind, } from "~/utils/tauri"; @@ -191,7 +192,7 @@ export default function () { createEffect(() => { if (state().variant === "initializing") { - const recording = currentRecording.data as any; + const recording = currentRecording.data as CurrentRecording; if (recording?.status === "recording") { setDisconnectedInputs({ microphone: false, camera: false }); setRecordingFailure(null); From 6dd3ff1a579866a57a7e55e2a27a555fa44b6db1 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:40:46 +0000 Subject: [PATCH 14/34] Hide native camera preview toggle on Windows --- .../(window-chrome)/settings/experimental.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 5c8e47264d..b76f6c0cb5 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -1,5 +1,6 @@ import { createResource, Show } from "solid-js"; import { createStore } from "solid-js/store"; +import { type } from "@tauri-apps/plugin-os"; import { generalSettingsStore } from "~/store"; import type { GeneralSettingsStore } from "~/utils/tauri"; @@ -62,14 +63,16 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { handleChange("custom_cursor_capture2", value) } /> - - handleChange("enableNativeCameraPreview", value) - } - /> + {type() !== "windows" && ( + + handleChange("enableNativeCameraPreview", value) + } + /> + )} Date: Tue, 18 Nov 2025 16:41:11 +0000 Subject: [PATCH 15/34] Add resizing to camera window --- apps/desktop/src-tauri/src/camera.rs | 56 +- apps/desktop/src-tauri/src/camera.wgsl | 6 +- apps/desktop/src/routes/camera.tsx | 994 +++++++++++++++---------- apps/desktop/src/utils/tauri.ts | 3 +- 4 files changed, 647 insertions(+), 412 deletions(-) diff --git a/apps/desktop/src-tauri/src/camera.rs b/apps/desktop/src-tauri/src/camera.rs index 4160fa2f57..7263105ff6 100644 --- a/apps/desktop/src-tauri/src/camera.rs +++ b/apps/desktop/src-tauri/src/camera.rs @@ -28,13 +28,9 @@ static TOOLBAR_HEIGHT: f32 = 56.0; // also defined in Typescript // Basically poor man's MSAA static GPU_SURFACE_SCALE: u32 = 4; -#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, Type)] -#[serde(rename_all = "lowercase")] -pub enum CameraPreviewSize { - #[default] - Sm, - Lg, -} +pub const MIN_CAMERA_SIZE: f32 = 150.0; +pub const MAX_CAMERA_SIZE: f32 = 600.0; +pub const DEFAULT_CAMERA_SIZE: f32 = 230.0; #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, Type)] #[serde(rename_all = "lowercase")] @@ -45,13 +41,27 @@ pub enum CameraPreviewShape { Full, } -#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, Type)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] pub struct CameraPreviewState { - size: CameraPreviewSize, + size: f32, shape: CameraPreviewShape, mirrored: bool, } +impl Default for CameraPreviewState { + fn default() -> Self { + Self { + size: DEFAULT_CAMERA_SIZE, + shape: CameraPreviewShape::default(), + mirrored: false, + } + } +} + +fn clamp_size(size: f32) -> f32 { + size.max(MIN_CAMERA_SIZE).min(MAX_CAMERA_SIZE) +} + pub struct CameraPreviewManager { store: Result>, String>, preview: Option, @@ -70,17 +80,22 @@ impl CameraPreviewManager { /// Get the current state of the camera window. pub fn get_state(&self) -> anyhow::Result { - Ok(self + let mut state: CameraPreviewState = self .store .as_ref() .map_err(|err| anyhow!("{err}"))? .get("state") - .and_then(|v| serde_json::from_value(v).ok().unwrap_or_default()) - .unwrap_or_default()) + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + + state.size = clamp_size(state.size); + Ok(state) } /// Save the current state of the camera window. - pub fn set_state(&self, state: CameraPreviewState) -> anyhow::Result<()> { + pub fn set_state(&self, mut state: CameraPreviewState) -> anyhow::Result<()> { + state.size = clamp_size(state.size); + let store = self.store.as_ref().map_err(|err| anyhow!("{err}"))?; store.set("state", serde_json::to_value(&state)?); store.save()?; @@ -607,16 +622,17 @@ impl Renderer { /// Update the uniforms which hold the camera preview state fn update_state_uniforms(&self, state: &CameraPreviewState) { + let clamped_size = clamp_size(state.size); + let normalized_size = + (clamped_size - MIN_CAMERA_SIZE) / (MAX_CAMERA_SIZE - MIN_CAMERA_SIZE); + let state_uniforms = StateUniforms { shape: match state.shape { CameraPreviewShape::Round => 0.0, CameraPreviewShape::Square => 1.0, CameraPreviewShape::Full => 2.0, }, - size: match state.size { - CameraPreviewSize::Sm => 0.0, - CameraPreviewSize::Lg => 1.0, - }, + size: normalized_size, mirrored: if state.mirrored { 1.0 } else { 0.0 }, _padding: 0.0, }; @@ -664,11 +680,7 @@ fn resize_window( ) -> tauri::Result<(u32, u32)> { trace!("CameraPreview/resize_window"); - let base: f32 = if state.size == CameraPreviewSize::Sm { - 230.0 - } else { - 400.0 - }; + let base = clamp_size(state.size); let window_width = if state.shape == CameraPreviewShape::Full { if aspect >= 1.0 { base * aspect } else { base } } else { diff --git a/apps/desktop/src-tauri/src/camera.wgsl b/apps/desktop/src-tauri/src/camera.wgsl index a55893f3fa..5caa8f2f18 100644 --- a/apps/desktop/src-tauri/src/camera.wgsl +++ b/apps/desktop/src-tauri/src/camera.wgsl @@ -124,7 +124,8 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { } else if (shape == 1.0) { // Square shape with enhanced corner anti-aliasing - let corner_radius = select(0.1, 0.12, size == 1.0); + // Interpolate corner radius based on normalized size (0-1) + let corner_radius = mix(0.10, 0.14, size); let abs_uv = abs(center_uv); let corner_pos = abs_uv - (1.0 - corner_radius); let corner_dist = length(max(corner_pos, vec2(0.0, 0.0))); @@ -138,7 +139,8 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { } else if (shape == 2.0) { // Full shape with aspect ratio-corrected rounded corners let window_aspect = window_uniforms.window_width / window_uniforms.window_height; - let corner_radius = select(0.08, 0.1, size == 1.0); // radius based on size (8% for small, 10% for large) + // Interpolate corner radius based on normalized size (0-1) + let corner_radius = mix(0.08, 0.12, size); let abs_uv = abs(center_uv); let corner_pos = abs_uv - (1.0 - corner_radius); diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index b96e9c8afc..02704639b4 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -1,23 +1,24 @@ import { ToggleButton as KToggleButton } from "@kobalte/core/toggle-button"; import { makePersisted } from "@solid-primitives/storage"; +import { type } from "@tauri-apps/plugin-os"; import { - currentMonitor, - getCurrentWindow, - LogicalPosition, - LogicalSize, + currentMonitor, + getCurrentWindow, + LogicalPosition, + LogicalSize, } from "@tauri-apps/api/window"; import { cx } from "cva"; import { - type Accessor, - type ComponentProps, - createEffect, - createResource, - createSignal, - on, - onCleanup, - onMount, - Show, - Suspense, + type Accessor, + type ComponentProps, + createEffect, + createResource, + createSignal, + on, + onCleanup, + onMount, + Show, + Suspense, } from "solid-js"; import { createStore } from "solid-js/store"; import { generalSettingsStore } from "~/store"; @@ -26,400 +27,621 @@ import { createCameraMutation } from "~/utils/queries"; import { createImageDataWS, createLazySignal } from "~/utils/socket"; import { commands, events } from "~/utils/tauri"; import { - RecordingOptionsProvider, - useRecordingOptions, + RecordingOptionsProvider, + useRecordingOptions, } from "./(window-chrome)/OptionsContext"; -namespace CameraWindow { - export type Size = "sm" | "lg"; - export type Shape = "round" | "square" | "full"; - export type State = { - size: Size; - shape: Shape; - mirrored: boolean; - }; -} +type CameraWindowShape = "round" | "square" | "full"; +type CameraWindowState = { + size: number; + shape: CameraWindowShape; + mirrored: boolean; +}; + +const CAMERA_MIN_SIZE = 150; +const CAMERA_MAX_SIZE = 600; +const CAMERA_DEFAULT_SIZE = 230; +const CAMERA_PRESET_SMALL = 230; +const CAMERA_PRESET_LARGE = 400; export default function () { - document.documentElement.classList.toggle("dark", true); - - const generalSettings = generalSettingsStore.createQuery(); - const isNativePreviewEnabled = - generalSettings.data?.enableNativeCameraPreview || false; - - const [cameraDisconnected, setCameraDisconnected] = createSignal(false); - - createTauriEventListener(events.recordingEvent, (payload) => { - if (payload.variant === "InputLost" && payload.input === "camera") { - setCameraDisconnected(true); - } else if ( - payload.variant === "InputRestored" && - payload.input === "camera" - ) { - setCameraDisconnected(false); - } - }); - - return ( - - } - > - - - - ); + document.documentElement.classList.toggle("dark", true); + + const generalSettings = generalSettingsStore.createQuery(); + const isNativePreviewEnabled = + (type() !== "windows" && generalSettings.data?.enableNativeCameraPreview) || + false; + + const [cameraDisconnected, setCameraDisconnected] = createSignal(false); + + createTauriEventListener(events.recordingEvent, (payload) => { + if (payload.variant === "InputLost" && payload.input === "camera") { + setCameraDisconnected(true); + } else if ( + payload.variant === "InputRestored" && + payload.input === "camera" + ) { + setCameraDisconnected(false); + } + }); + + return ( + + } + > + + + + ); } function NativeCameraPreviewPage(props: { disconnected: Accessor }) { - const [state, setState] = makePersisted( - createStore({ - size: "sm", - shape: "round", - mirrored: false, - }), - { name: "cameraWindowState" }, - ); - - createEffect(() => commands.setCameraPreviewState(state)); - - const [cameraPreviewReady] = createResource(() => - commands.awaitCameraPreviewReady(), - ); - - const setCamera = createCameraMutation(); - - return ( -
- - - -
-
-
- getCurrentWindow().close()}> - - - { - setState("size", (s) => (s === "sm" ? "lg" : "sm")); - }} - > - - - - setState("shape", (s) => - s === "round" ? "square" : s === "square" ? "full" : "round", - ) - } - > - {state.shape === "round" && } - {state.shape === "square" && } - {state.shape === "full" && ( - - )} - - setState("mirrored", (m) => !m)} - > - - -
-
-
- - {/* The camera preview is rendered in Rust by wgpu */} - -
-
Loading camera...
-
-
-
- ); + const [state, setState] = makePersisted( + createStore({ + size: CAMERA_DEFAULT_SIZE, + shape: "round", + mirrored: false, + }), + { name: "cameraWindowState" } + ); + + const [isResizing, setIsResizing] = createSignal(false); + const [resizeStart, setResizeStart] = createSignal({ + size: 0, + x: 0, + y: 0, + corner: "", + }); + + createEffect(() => { + const clampedSize = Math.max( + CAMERA_MIN_SIZE, + Math.min(CAMERA_MAX_SIZE, state.size) + ); + if (clampedSize !== state.size) { + setState("size", clampedSize); + } + commands.setCameraPreviewState(state); + }); + + const [cameraPreviewReady] = createResource(() => + commands.awaitCameraPreviewReady() + ); + + const setCamera = createCameraMutation(); + + const scale = () => { + const normalized = + (state.size - CAMERA_MIN_SIZE) / (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE); + return 0.7 + normalized * 0.3; + }; + + const handleResizeStart = (corner: string) => (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + setResizeStart({ size: state.size, x: e.clientX, y: e.clientY, corner }); + }; + + const handleResizeMove = (e: MouseEvent) => { + if (!isResizing()) return; + const start = resizeStart(); + const deltaX = e.clientX - start.x; + const deltaY = e.clientY - start.y; + + let delta = 0; + if (start.corner.includes("e") && start.corner.includes("s")) { + delta = Math.max(deltaX, deltaY); + } else if (start.corner.includes("e") && start.corner.includes("n")) { + delta = Math.max(deltaX, -deltaY); + } else if (start.corner.includes("w") && start.corner.includes("s")) { + delta = Math.max(-deltaX, deltaY); + } else if (start.corner.includes("w") && start.corner.includes("n")) { + delta = Math.max(-deltaX, -deltaY); + } else if (start.corner.includes("e")) { + delta = deltaX; + } else if (start.corner.includes("w")) { + delta = -deltaX; + } else if (start.corner.includes("s")) { + delta = deltaY; + } else if (start.corner.includes("n")) { + delta = -deltaY; + } + + const newSize = Math.max( + CAMERA_MIN_SIZE, + Math.min(CAMERA_MAX_SIZE, start.size + delta) + ); + setState("size", newSize); + }; + + const handleResizeEnd = () => { + setIsResizing(false); + }; + + createEffect(() => { + if (isResizing()) { + window.addEventListener("mousemove", handleResizeMove); + window.addEventListener("mouseup", handleResizeEnd); + onCleanup(() => { + window.removeEventListener("mousemove", handleResizeMove); + window.removeEventListener("mouseup", handleResizeEnd); + }); + } + }); + + return ( +
+ + + +
+
+
+ getCurrentWindow().close()}> + + + = CAMERA_PRESET_LARGE} + onClick={() => { + setState( + "size", + state.size < CAMERA_PRESET_LARGE + ? CAMERA_PRESET_LARGE + : CAMERA_PRESET_SMALL + ); + }} + > + + + + setState("shape", (s) => + s === "round" ? "square" : s === "square" ? "full" : "round" + ) + } + > + {state.shape === "round" && } + {state.shape === "square" && } + {state.shape === "full" && ( + + )} + + setState("mirrored", (m) => !m)} + > + + +
+
+
+ +
+
+
+
+ + {/* The camera preview is rendered in Rust by wgpu */} + +
+
Loading camera...
+
+
+
+ ); } function ControlButton( - props: Omit, "type" | "class"> & { - active?: boolean; - }, + props: Omit, "type" | "class"> & { + active?: boolean; + } ) { - return ( - - ); + return ( + + ); } // Legacy stuff below function LegacyCameraPreviewPage(props: { disconnected: Accessor }) { - const { rawOptions } = useRecordingOptions(); - - const [state, setState] = makePersisted( - createStore({ - size: "sm", - shape: "round", - mirrored: false, - }), - { name: "cameraWindowState" }, - ); - - const [latestFrame, setLatestFrame] = createLazySignal<{ - width: number; - data: ImageData; - } | null>(); - - const [frameDimensions, setFrameDimensions] = createSignal<{ - width: number; - height: number; - } | null>(null); - - function imageDataHandler(imageData: { width: number; data: ImageData }) { - setLatestFrame(imageData); - - const currentDimensions = frameDimensions(); - if ( - !currentDimensions || - currentDimensions.width !== imageData.data.width || - currentDimensions.height !== imageData.data.height - ) { - setFrameDimensions({ - width: imageData.data.width, - height: imageData.data.height, - }); - } - - const ctx = cameraCanvasRef?.getContext("2d"); - ctx?.putImageData(imageData.data, 0, 0); - } - - const { cameraWsPort } = (window as any).__CAP__; - const [ws, isConnected] = createImageDataWS( - `ws://localhost:${cameraWsPort}`, - imageDataHandler, - ); - - const reconnectInterval = setInterval(() => { - if (!isConnected()) { - console.log("Attempting to reconnect..."); - ws.close(); - - const newWs = createImageDataWS( - `ws://localhost:${cameraWsPort}`, - imageDataHandler, - ); - Object.assign(ws, newWs[0]); - } - }, 5000); - - onCleanup(() => { - clearInterval(reconnectInterval); - ws.close(); - }); - - const [windowSize] = createResource( - () => - [ - state.size, - state.shape, - frameDimensions()?.width, - frameDimensions()?.height, - ] as const, - async ([size, shape, frameWidth, frameHeight]) => { - const monitor = await currentMonitor(); - - const BAR_HEIGHT = 56; - const base = size === "sm" ? 230 : 400; - const aspect = frameWidth && frameHeight ? frameWidth / frameHeight : 1; - const windowWidth = - shape === "full" ? (aspect >= 1 ? base * aspect : base) : base; - const windowHeight = - shape === "full" ? (aspect >= 1 ? base : base / aspect) : base; - const totalHeight = windowHeight + BAR_HEIGHT; - - if (!monitor) return; - - const scalingFactor = monitor.scaleFactor; - const width = monitor.size.width / scalingFactor - windowWidth - 100; - const height = monitor.size.height / scalingFactor - totalHeight - 100; - - const currentWindow = getCurrentWindow(); - currentWindow.setSize(new LogicalSize(windowWidth, totalHeight)); - currentWindow.setPosition( - new LogicalPosition( - width + monitor.position.toLogical(scalingFactor).x, - height + monitor.position.toLogical(scalingFactor).y, - ), - ); - - return { width, height, size: base, windowWidth, windowHeight }; - }, - ); - - let cameraCanvasRef: HTMLCanvasElement | undefined; - - const setCamera = createCameraMutation(); - - createEffect( - on( - () => rawOptions.cameraLabel, - (label) => { - if (label === null) getCurrentWindow().close(); - }, - { defer: true }, - ), - ); - - onMount(() => getCurrentWindow().show()); - - return ( -
- - - -
-
-
- getCurrentWindow().close()}> - - - { - setState("size", (s) => (s === "sm" ? "lg" : "sm")); - }} - > - - - - setState("shape", (s) => - s === "round" ? "square" : s === "square" ? "full" : "round", - ) - } - > - {state.shape === "round" && } - {state.shape === "square" && } - {state.shape === "full" && ( - - )} - - setState("mirrored", (m) => !m)} - > - - -
-
-
-
- }> - - {(latestFrame) => { - const style = () => { - const aspectRatio = - latestFrame().data.width / latestFrame().data.height; - - const base = windowSize.latest?.size ?? 0; - const winWidth = windowSize.latest?.windowWidth ?? base; - const winHeight = windowSize.latest?.windowHeight ?? base; - - if (state.shape === "full") { - return { - width: `${winWidth}px`, - height: `${winHeight}px`, - transform: state.mirrored ? "scaleX(-1)" : "scaleX(1)", - }; - } - - const size = (() => { - if (aspectRatio > 1) - return { - width: base * aspectRatio, - height: base, - }; - else - return { - width: base, - height: base * aspectRatio, - }; - })(); - - const left = aspectRatio > 1 ? (size.width - base) / 2 : 0; - const top = aspectRatio > 1 ? 0 : (base - size.height) / 2; - - return { - width: `${size.width}px`, - height: `${size.height}px`, - left: `-${left}px`, - top: `-${top}px`, - transform: state.mirrored ? "scaleX(-1)" : "scaleX(1)", - }; - }; - - return ( - - ); - }} - - -
-
- ); + const { rawOptions } = useRecordingOptions(); + + const [state, setState] = makePersisted( + createStore({ + size: CAMERA_DEFAULT_SIZE, + shape: "round", + mirrored: false, + }), + { name: "cameraWindowState" } + ); + + const [isResizing, setIsResizing] = createSignal(false); + const [resizeStart, setResizeStart] = createSignal({ + size: 0, + x: 0, + y: 0, + corner: "", + }); + + const [latestFrame, setLatestFrame] = createLazySignal<{ + width: number; + data: ImageData; + } | null>(); + + const [frameDimensions, setFrameDimensions] = createSignal<{ + width: number; + height: number; + } | null>(null); + + function imageDataHandler(imageData: { width: number; data: ImageData }) { + setLatestFrame(imageData); + + const currentDimensions = frameDimensions(); + if ( + !currentDimensions || + currentDimensions.width !== imageData.data.width || + currentDimensions.height !== imageData.data.height + ) { + setFrameDimensions({ + width: imageData.data.width, + height: imageData.data.height, + }); + } + + const ctx = cameraCanvasRef?.getContext("2d"); + ctx?.putImageData(imageData.data, 0, 0); + } + + const { cameraWsPort } = (window as any).__CAP__; + const [ws, isConnected] = createImageDataWS( + `ws://localhost:${cameraWsPort}`, + imageDataHandler + ); + + const reconnectInterval = setInterval(() => { + if (!isConnected()) { + console.log("Attempting to reconnect..."); + ws.close(); + + const newWs = createImageDataWS( + `ws://localhost:${cameraWsPort}`, + imageDataHandler + ); + Object.assign(ws, newWs[0]); + } + }, 5000); + + onCleanup(() => { + clearInterval(reconnectInterval); + ws.close(); + }); + + const scale = () => { + const normalized = + (state.size - CAMERA_MIN_SIZE) / (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE); + return 0.7 + normalized * 0.3; + }; + + const handleResizeStart = (corner: string) => (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + setResizeStart({ size: state.size, x: e.clientX, y: e.clientY, corner }); + }; + + const handleResizeMove = (e: MouseEvent) => { + if (!isResizing()) return; + const start = resizeStart(); + const deltaX = e.clientX - start.x; + const deltaY = e.clientY - start.y; + + let delta = 0; + if (start.corner.includes("e") && start.corner.includes("s")) { + delta = Math.max(deltaX, deltaY); + } else if (start.corner.includes("e") && start.corner.includes("n")) { + delta = Math.max(deltaX, -deltaY); + } else if (start.corner.includes("w") && start.corner.includes("s")) { + delta = Math.max(-deltaX, deltaY); + } else if (start.corner.includes("w") && start.corner.includes("n")) { + delta = Math.max(-deltaX, -deltaY); + } else if (start.corner.includes("e")) { + delta = deltaX; + } else if (start.corner.includes("w")) { + delta = -deltaX; + } else if (start.corner.includes("s")) { + delta = deltaY; + } else if (start.corner.includes("n")) { + delta = -deltaY; + } + + const newSize = Math.max( + CAMERA_MIN_SIZE, + Math.min(CAMERA_MAX_SIZE, start.size + delta) + ); + setState("size", newSize); + }; + + const handleResizeEnd = () => { + setIsResizing(false); + }; + + createEffect(() => { + if (isResizing()) { + window.addEventListener("mousemove", handleResizeMove); + window.addEventListener("mouseup", handleResizeEnd); + onCleanup(() => { + window.removeEventListener("mousemove", handleResizeMove); + window.removeEventListener("mouseup", handleResizeEnd); + }); + } + }); + + const [windowSize] = createResource( + () => + [ + state.size, + state.shape, + frameDimensions()?.width, + frameDimensions()?.height, + ] as const, + async ([size, shape, frameWidth, frameHeight]) => { + const monitor = await currentMonitor(); + + const BAR_HEIGHT = 56; + const base = Math.max(CAMERA_MIN_SIZE, Math.min(CAMERA_MAX_SIZE, size)); + const aspect = frameWidth && frameHeight ? frameWidth / frameHeight : 1; + const windowWidth = + shape === "full" ? (aspect >= 1 ? base * aspect : base) : base; + const windowHeight = + shape === "full" ? (aspect >= 1 ? base : base / aspect) : base; + const totalHeight = windowHeight + BAR_HEIGHT; + + if (!monitor) return; + + const scalingFactor = monitor.scaleFactor; + const width = monitor.size.width / scalingFactor - windowWidth - 100; + const height = monitor.size.height / scalingFactor - totalHeight - 100; + + const currentWindow = getCurrentWindow(); + currentWindow.setSize(new LogicalSize(windowWidth, totalHeight)); + currentWindow.setPosition( + new LogicalPosition( + width + monitor.position.toLogical(scalingFactor).x, + height + monitor.position.toLogical(scalingFactor).y + ) + ); + + return { width, height, size: base, windowWidth, windowHeight }; + } + ); + + let cameraCanvasRef: HTMLCanvasElement | undefined; + + const setCamera = createCameraMutation(); + + createEffect( + on( + () => rawOptions.cameraLabel, + (label) => { + if (label === null) getCurrentWindow().close(); + }, + { defer: true } + ) + ); + + onMount(() => getCurrentWindow().show()); + + return ( +
+ + + +
+
+
+ getCurrentWindow().close()}> + + + = CAMERA_PRESET_LARGE} + onClick={() => { + setState( + "size", + state.size < CAMERA_PRESET_LARGE + ? CAMERA_PRESET_LARGE + : CAMERA_PRESET_SMALL + ); + }} + > + + + + setState("shape", (s) => + s === "round" ? "square" : s === "square" ? "full" : "round" + ) + } + > + {state.shape === "round" && } + {state.shape === "square" && } + {state.shape === "full" && ( + + )} + + setState("mirrored", (m) => !m)} + > + + +
+
+
+
+
+
+
+
+ }> + + {(latestFrame) => { + const style = () => { + const aspectRatio = + latestFrame().data.width / latestFrame().data.height; + + // Use state.size directly for immediate feedback + const base = state.size; + + // Replicate window size logic synchronously for the canvas + const winWidth = + state.shape === "full" + ? aspectRatio >= 1 + ? base * aspectRatio + : base + : base; + const winHeight = + state.shape === "full" + ? aspectRatio >= 1 + ? base + : base / aspectRatio + : base; + + if (state.shape === "full") { + return { + width: `${winWidth}px`, + height: `${winHeight}px`, + transform: state.mirrored ? "scaleX(-1)" : "scaleX(1)", + }; + } + + const size = (() => { + if (aspectRatio > 1) + return { + width: base * aspectRatio, + height: base, + }; + else + return { + width: base, + height: base * aspectRatio, + }; + })(); + + const left = aspectRatio > 1 ? (size.width - base) / 2 : 0; + const top = aspectRatio > 1 ? 0 : (base - size.height) / 2; + + return { + width: `${size.width}px`, + height: `${size.height}px`, + left: `-${left}px`, + top: `-${top}px`, + transform: state.mirrored ? "scaleX(-1)" : "scaleX(1)", + }; + }; + + return ( + + ); + }} + + +
+
+ ); } function CameraLoadingState() { - return ( -
-
Loading camera...
-
- ); + return ( +
+
Loading camera...
+
+ ); } -function cameraBorderRadius(state: CameraWindow.State) { - if (state.shape === "round") return "9999px"; - if (state.size === "sm") return "3rem"; - return "4rem"; +function cameraBorderRadius(state: CameraWindowState) { + if (state.shape === "round") return "9999px"; + const normalized = + (state.size - CAMERA_MIN_SIZE) / (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE); + const radius = 3 + normalized * 1.5; + return `${radius}rem`; } function CameraDisconnectedOverlay() { - return ( -
-

- Camera disconnected -

-
- ); + return ( +
+

+ Camera disconnected +

+
+ ); } diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index b764f2dcbd..05762f6987 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -365,8 +365,7 @@ export type Camera = { hide: boolean; mirror: boolean; position: CameraPosition; export type CameraInfo = { device_id: string; model_id: ModelIDType | null; display_name: string } export type CameraPosition = { x: CameraXPosition; y: CameraYPosition } export type CameraPreviewShape = "round" | "square" | "full" -export type CameraPreviewSize = "sm" | "lg" -export type CameraPreviewState = { size: CameraPreviewSize; shape: CameraPreviewShape; mirrored: boolean } +export type CameraPreviewState = { size: number; shape: CameraPreviewShape; mirrored: boolean } export type CameraShape = "square" | "source" export type CameraXPosition = "left" | "center" | "right" export type CameraYPosition = "top" | "bottom" From dd394259551a2d2f053cac6613783726ed23b14e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:41:22 +0000 Subject: [PATCH 16/34] Set Mellow as default for CursorAnimationStyle --- crates/project/src/configuration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index c95f318a00..cd3bf369b0 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -401,8 +401,8 @@ pub enum CursorType { #[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum CursorAnimationStyle { - #[default] Slow, + #[default] #[serde(alias = "regular", alias = "quick", alias = "rapid", alias = "fast")] Mellow, Custom, From 831fb76c520dbdba6c3c28cdf3e8edbe0c3c635e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:53:42 +0000 Subject: [PATCH 17/34] Fix import order in experimental settings route --- .../src/routes/(window-chrome)/settings/experimental.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index b76f6c0cb5..1f9f8371fe 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -1,6 +1,6 @@ +import { type } from "@tauri-apps/plugin-os"; import { createResource, Show } from "solid-js"; import { createStore } from "solid-js/store"; -import { type } from "@tauri-apps/plugin-os"; import { generalSettingsStore } from "~/store"; import type { GeneralSettingsStore } from "~/utils/tauri"; From 891b122a55f125e6dde979d6e7dc9332c8c48805 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:54:02 +0000 Subject: [PATCH 18/34] Improve camera feed sender handling and logging --- crates/recording/src/feeds/camera.rs | 5 +++++ crates/recording/src/sources/camera.rs | 24 +++++++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/recording/src/feeds/camera.rs b/crates/recording/src/feeds/camera.rs index d75dde0b15..e8baae2948 100644 --- a/crates/recording/src/feeds/camera.rs +++ b/crates/recording/src/feeds/camera.rs @@ -577,6 +577,7 @@ impl Message for CameraFeed { type Reply = (); async fn handle(&mut self, msg: AddSender, _: &mut Context) -> Self::Reply { + debug!("CameraFeed: Adding new sender"); self.senders.push(msg.0); } } @@ -624,6 +625,10 @@ impl Message for CameraFeed { for (i, sender) in self.senders.iter().enumerate() { if let Err(flume::TrySendError::Disconnected(_)) = sender.try_send(msg.0.clone()) { warn!("Camera sender {} disconnected, will be removed", i); + info!( + "Camera sender {} disconnected (rx dropped), removing from list", + i + ); to_remove.push(i); }; } diff --git a/crates/recording/src/sources/camera.rs b/crates/recording/src/sources/camera.rs index 48daae8e37..e60733ece5 100644 --- a/crates/recording/src/sources/camera.rs +++ b/crates/recording/src/sources/camera.rs @@ -22,7 +22,7 @@ impl VideoSource for Camera { where Self: Sized, { - let (tx, rx) = flume::bounded(8); + let (tx, rx) = flume::bounded(32); feed_lock .ask(camera::AddSender(tx)) @@ -30,9 +30,27 @@ impl VideoSource for Camera { .map_err(|e| anyhow!("Failed to add camera sender: {e}"))?; tokio::spawn(async move { - while let Ok(frame) = rx.recv_async().await { - let _ = video_tx.send(frame).await; + tracing::debug!("Camera source task started"); + loop { + match rx.recv_async().await { + Ok(frame) => { + if let Err(e) = video_tx.send(frame).await { + tracing::warn!("Failed to send to video pipeline: {e}"); + // If pipeline is closed, we should stop? + // But lets continue to keep rx alive for now to see if it helps, + // or maybe break? + // If we break, we disconnect from CameraFeed. + // If pipeline is closed, we SHOULD disconnect. + break; + } + } + Err(e) => { + tracing::debug!("Camera feed disconnected (rx closed): {e}"); + break; + } + } } + tracing::debug!("Camera source task finished"); }); Ok(Self(feed_lock)) From 2b504dd1eb0937d170fda68c357548b63896bd17 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 16:54:29 +0000 Subject: [PATCH 19/34] Update camera.tsx --- apps/desktop/src/routes/camera.tsx | 1196 ++++++++++++++-------------- 1 file changed, 598 insertions(+), 598 deletions(-) diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index 02704639b4..3d9d2dc9b5 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -1,24 +1,24 @@ import { ToggleButton as KToggleButton } from "@kobalte/core/toggle-button"; import { makePersisted } from "@solid-primitives/storage"; -import { type } from "@tauri-apps/plugin-os"; import { - currentMonitor, - getCurrentWindow, - LogicalPosition, - LogicalSize, + currentMonitor, + getCurrentWindow, + LogicalPosition, + LogicalSize, } from "@tauri-apps/api/window"; +import { type } from "@tauri-apps/plugin-os"; import { cx } from "cva"; import { - type Accessor, - type ComponentProps, - createEffect, - createResource, - createSignal, - on, - onCleanup, - onMount, - Show, - Suspense, + type Accessor, + type ComponentProps, + createEffect, + createResource, + createSignal, + on, + onCleanup, + onMount, + Show, + Suspense, } from "solid-js"; import { createStore } from "solid-js/store"; import { generalSettingsStore } from "~/store"; @@ -27,15 +27,15 @@ import { createCameraMutation } from "~/utils/queries"; import { createImageDataWS, createLazySignal } from "~/utils/socket"; import { commands, events } from "~/utils/tauri"; import { - RecordingOptionsProvider, - useRecordingOptions, + RecordingOptionsProvider, + useRecordingOptions, } from "./(window-chrome)/OptionsContext"; type CameraWindowShape = "round" | "square" | "full"; type CameraWindowState = { - size: number; - shape: CameraWindowShape; - mirrored: boolean; + size: number; + shape: CameraWindowShape; + mirrored: boolean; }; const CAMERA_MIN_SIZE = 150; @@ -45,603 +45,603 @@ const CAMERA_PRESET_SMALL = 230; const CAMERA_PRESET_LARGE = 400; export default function () { - document.documentElement.classList.toggle("dark", true); - - const generalSettings = generalSettingsStore.createQuery(); - const isNativePreviewEnabled = - (type() !== "windows" && generalSettings.data?.enableNativeCameraPreview) || - false; - - const [cameraDisconnected, setCameraDisconnected] = createSignal(false); - - createTauriEventListener(events.recordingEvent, (payload) => { - if (payload.variant === "InputLost" && payload.input === "camera") { - setCameraDisconnected(true); - } else if ( - payload.variant === "InputRestored" && - payload.input === "camera" - ) { - setCameraDisconnected(false); - } - }); - - return ( - - } - > - - - - ); + document.documentElement.classList.toggle("dark", true); + + const generalSettings = generalSettingsStore.createQuery(); + const isNativePreviewEnabled = + (type() !== "windows" && generalSettings.data?.enableNativeCameraPreview) || + false; + + const [cameraDisconnected, setCameraDisconnected] = createSignal(false); + + createTauriEventListener(events.recordingEvent, (payload) => { + if (payload.variant === "InputLost" && payload.input === "camera") { + setCameraDisconnected(true); + } else if ( + payload.variant === "InputRestored" && + payload.input === "camera" + ) { + setCameraDisconnected(false); + } + }); + + return ( + + } + > + + + + ); } function NativeCameraPreviewPage(props: { disconnected: Accessor }) { - const [state, setState] = makePersisted( - createStore({ - size: CAMERA_DEFAULT_SIZE, - shape: "round", - mirrored: false, - }), - { name: "cameraWindowState" } - ); - - const [isResizing, setIsResizing] = createSignal(false); - const [resizeStart, setResizeStart] = createSignal({ - size: 0, - x: 0, - y: 0, - corner: "", - }); - - createEffect(() => { - const clampedSize = Math.max( - CAMERA_MIN_SIZE, - Math.min(CAMERA_MAX_SIZE, state.size) - ); - if (clampedSize !== state.size) { - setState("size", clampedSize); - } - commands.setCameraPreviewState(state); - }); - - const [cameraPreviewReady] = createResource(() => - commands.awaitCameraPreviewReady() - ); - - const setCamera = createCameraMutation(); - - const scale = () => { - const normalized = - (state.size - CAMERA_MIN_SIZE) / (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE); - return 0.7 + normalized * 0.3; - }; - - const handleResizeStart = (corner: string) => (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsResizing(true); - setResizeStart({ size: state.size, x: e.clientX, y: e.clientY, corner }); - }; - - const handleResizeMove = (e: MouseEvent) => { - if (!isResizing()) return; - const start = resizeStart(); - const deltaX = e.clientX - start.x; - const deltaY = e.clientY - start.y; - - let delta = 0; - if (start.corner.includes("e") && start.corner.includes("s")) { - delta = Math.max(deltaX, deltaY); - } else if (start.corner.includes("e") && start.corner.includes("n")) { - delta = Math.max(deltaX, -deltaY); - } else if (start.corner.includes("w") && start.corner.includes("s")) { - delta = Math.max(-deltaX, deltaY); - } else if (start.corner.includes("w") && start.corner.includes("n")) { - delta = Math.max(-deltaX, -deltaY); - } else if (start.corner.includes("e")) { - delta = deltaX; - } else if (start.corner.includes("w")) { - delta = -deltaX; - } else if (start.corner.includes("s")) { - delta = deltaY; - } else if (start.corner.includes("n")) { - delta = -deltaY; - } - - const newSize = Math.max( - CAMERA_MIN_SIZE, - Math.min(CAMERA_MAX_SIZE, start.size + delta) - ); - setState("size", newSize); - }; - - const handleResizeEnd = () => { - setIsResizing(false); - }; - - createEffect(() => { - if (isResizing()) { - window.addEventListener("mousemove", handleResizeMove); - window.addEventListener("mouseup", handleResizeEnd); - onCleanup(() => { - window.removeEventListener("mousemove", handleResizeMove); - window.removeEventListener("mouseup", handleResizeEnd); - }); - } - }); - - return ( -
- - - -
-
-
- getCurrentWindow().close()}> - - - = CAMERA_PRESET_LARGE} - onClick={() => { - setState( - "size", - state.size < CAMERA_PRESET_LARGE - ? CAMERA_PRESET_LARGE - : CAMERA_PRESET_SMALL - ); - }} - > - - - - setState("shape", (s) => - s === "round" ? "square" : s === "square" ? "full" : "round" - ) - } - > - {state.shape === "round" && } - {state.shape === "square" && } - {state.shape === "full" && ( - - )} - - setState("mirrored", (m) => !m)} - > - - -
-
-
- -
-
-
-
- - {/* The camera preview is rendered in Rust by wgpu */} - -
-
Loading camera...
-
-
-
- ); + const [state, setState] = makePersisted( + createStore({ + size: CAMERA_DEFAULT_SIZE, + shape: "round", + mirrored: false, + }), + { name: "cameraWindowState" }, + ); + + const [isResizing, setIsResizing] = createSignal(false); + const [resizeStart, setResizeStart] = createSignal({ + size: 0, + x: 0, + y: 0, + corner: "", + }); + + createEffect(() => { + const clampedSize = Math.max( + CAMERA_MIN_SIZE, + Math.min(CAMERA_MAX_SIZE, state.size), + ); + if (clampedSize !== state.size) { + setState("size", clampedSize); + } + commands.setCameraPreviewState(state); + }); + + const [cameraPreviewReady] = createResource(() => + commands.awaitCameraPreviewReady(), + ); + + const setCamera = createCameraMutation(); + + const scale = () => { + const normalized = + (state.size - CAMERA_MIN_SIZE) / (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE); + return 0.7 + normalized * 0.3; + }; + + const handleResizeStart = (corner: string) => (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + setResizeStart({ size: state.size, x: e.clientX, y: e.clientY, corner }); + }; + + const handleResizeMove = (e: MouseEvent) => { + if (!isResizing()) return; + const start = resizeStart(); + const deltaX = e.clientX - start.x; + const deltaY = e.clientY - start.y; + + let delta = 0; + if (start.corner.includes("e") && start.corner.includes("s")) { + delta = Math.max(deltaX, deltaY); + } else if (start.corner.includes("e") && start.corner.includes("n")) { + delta = Math.max(deltaX, -deltaY); + } else if (start.corner.includes("w") && start.corner.includes("s")) { + delta = Math.max(-deltaX, deltaY); + } else if (start.corner.includes("w") && start.corner.includes("n")) { + delta = Math.max(-deltaX, -deltaY); + } else if (start.corner.includes("e")) { + delta = deltaX; + } else if (start.corner.includes("w")) { + delta = -deltaX; + } else if (start.corner.includes("s")) { + delta = deltaY; + } else if (start.corner.includes("n")) { + delta = -deltaY; + } + + const newSize = Math.max( + CAMERA_MIN_SIZE, + Math.min(CAMERA_MAX_SIZE, start.size + delta), + ); + setState("size", newSize); + }; + + const handleResizeEnd = () => { + setIsResizing(false); + }; + + createEffect(() => { + if (isResizing()) { + window.addEventListener("mousemove", handleResizeMove); + window.addEventListener("mouseup", handleResizeEnd); + onCleanup(() => { + window.removeEventListener("mousemove", handleResizeMove); + window.removeEventListener("mouseup", handleResizeEnd); + }); + } + }); + + return ( +
+ + + +
+
+
+ getCurrentWindow().close()}> + + + = CAMERA_PRESET_LARGE} + onClick={() => { + setState( + "size", + state.size < CAMERA_PRESET_LARGE + ? CAMERA_PRESET_LARGE + : CAMERA_PRESET_SMALL, + ); + }} + > + + + + setState("shape", (s) => + s === "round" ? "square" : s === "square" ? "full" : "round", + ) + } + > + {state.shape === "round" && } + {state.shape === "square" && } + {state.shape === "full" && ( + + )} + + setState("mirrored", (m) => !m)} + > + + +
+
+
+ +
+
+
+
+ + {/* The camera preview is rendered in Rust by wgpu */} + +
+
Loading camera...
+
+
+
+ ); } function ControlButton( - props: Omit, "type" | "class"> & { - active?: boolean; - } + props: Omit, "type" | "class"> & { + active?: boolean; + }, ) { - return ( - - ); + return ( + + ); } // Legacy stuff below function LegacyCameraPreviewPage(props: { disconnected: Accessor }) { - const { rawOptions } = useRecordingOptions(); - - const [state, setState] = makePersisted( - createStore({ - size: CAMERA_DEFAULT_SIZE, - shape: "round", - mirrored: false, - }), - { name: "cameraWindowState" } - ); - - const [isResizing, setIsResizing] = createSignal(false); - const [resizeStart, setResizeStart] = createSignal({ - size: 0, - x: 0, - y: 0, - corner: "", - }); - - const [latestFrame, setLatestFrame] = createLazySignal<{ - width: number; - data: ImageData; - } | null>(); - - const [frameDimensions, setFrameDimensions] = createSignal<{ - width: number; - height: number; - } | null>(null); - - function imageDataHandler(imageData: { width: number; data: ImageData }) { - setLatestFrame(imageData); - - const currentDimensions = frameDimensions(); - if ( - !currentDimensions || - currentDimensions.width !== imageData.data.width || - currentDimensions.height !== imageData.data.height - ) { - setFrameDimensions({ - width: imageData.data.width, - height: imageData.data.height, - }); - } - - const ctx = cameraCanvasRef?.getContext("2d"); - ctx?.putImageData(imageData.data, 0, 0); - } - - const { cameraWsPort } = (window as any).__CAP__; - const [ws, isConnected] = createImageDataWS( - `ws://localhost:${cameraWsPort}`, - imageDataHandler - ); - - const reconnectInterval = setInterval(() => { - if (!isConnected()) { - console.log("Attempting to reconnect..."); - ws.close(); - - const newWs = createImageDataWS( - `ws://localhost:${cameraWsPort}`, - imageDataHandler - ); - Object.assign(ws, newWs[0]); - } - }, 5000); - - onCleanup(() => { - clearInterval(reconnectInterval); - ws.close(); - }); - - const scale = () => { - const normalized = - (state.size - CAMERA_MIN_SIZE) / (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE); - return 0.7 + normalized * 0.3; - }; - - const handleResizeStart = (corner: string) => (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsResizing(true); - setResizeStart({ size: state.size, x: e.clientX, y: e.clientY, corner }); - }; - - const handleResizeMove = (e: MouseEvent) => { - if (!isResizing()) return; - const start = resizeStart(); - const deltaX = e.clientX - start.x; - const deltaY = e.clientY - start.y; - - let delta = 0; - if (start.corner.includes("e") && start.corner.includes("s")) { - delta = Math.max(deltaX, deltaY); - } else if (start.corner.includes("e") && start.corner.includes("n")) { - delta = Math.max(deltaX, -deltaY); - } else if (start.corner.includes("w") && start.corner.includes("s")) { - delta = Math.max(-deltaX, deltaY); - } else if (start.corner.includes("w") && start.corner.includes("n")) { - delta = Math.max(-deltaX, -deltaY); - } else if (start.corner.includes("e")) { - delta = deltaX; - } else if (start.corner.includes("w")) { - delta = -deltaX; - } else if (start.corner.includes("s")) { - delta = deltaY; - } else if (start.corner.includes("n")) { - delta = -deltaY; - } - - const newSize = Math.max( - CAMERA_MIN_SIZE, - Math.min(CAMERA_MAX_SIZE, start.size + delta) - ); - setState("size", newSize); - }; - - const handleResizeEnd = () => { - setIsResizing(false); - }; - - createEffect(() => { - if (isResizing()) { - window.addEventListener("mousemove", handleResizeMove); - window.addEventListener("mouseup", handleResizeEnd); - onCleanup(() => { - window.removeEventListener("mousemove", handleResizeMove); - window.removeEventListener("mouseup", handleResizeEnd); - }); - } - }); - - const [windowSize] = createResource( - () => - [ - state.size, - state.shape, - frameDimensions()?.width, - frameDimensions()?.height, - ] as const, - async ([size, shape, frameWidth, frameHeight]) => { - const monitor = await currentMonitor(); - - const BAR_HEIGHT = 56; - const base = Math.max(CAMERA_MIN_SIZE, Math.min(CAMERA_MAX_SIZE, size)); - const aspect = frameWidth && frameHeight ? frameWidth / frameHeight : 1; - const windowWidth = - shape === "full" ? (aspect >= 1 ? base * aspect : base) : base; - const windowHeight = - shape === "full" ? (aspect >= 1 ? base : base / aspect) : base; - const totalHeight = windowHeight + BAR_HEIGHT; - - if (!monitor) return; - - const scalingFactor = monitor.scaleFactor; - const width = monitor.size.width / scalingFactor - windowWidth - 100; - const height = monitor.size.height / scalingFactor - totalHeight - 100; - - const currentWindow = getCurrentWindow(); - currentWindow.setSize(new LogicalSize(windowWidth, totalHeight)); - currentWindow.setPosition( - new LogicalPosition( - width + monitor.position.toLogical(scalingFactor).x, - height + monitor.position.toLogical(scalingFactor).y - ) - ); - - return { width, height, size: base, windowWidth, windowHeight }; - } - ); - - let cameraCanvasRef: HTMLCanvasElement | undefined; - - const setCamera = createCameraMutation(); - - createEffect( - on( - () => rawOptions.cameraLabel, - (label) => { - if (label === null) getCurrentWindow().close(); - }, - { defer: true } - ) - ); - - onMount(() => getCurrentWindow().show()); - - return ( -
- - - -
-
-
- getCurrentWindow().close()}> - - - = CAMERA_PRESET_LARGE} - onClick={() => { - setState( - "size", - state.size < CAMERA_PRESET_LARGE - ? CAMERA_PRESET_LARGE - : CAMERA_PRESET_SMALL - ); - }} - > - - - - setState("shape", (s) => - s === "round" ? "square" : s === "square" ? "full" : "round" - ) - } - > - {state.shape === "round" && } - {state.shape === "square" && } - {state.shape === "full" && ( - - )} - - setState("mirrored", (m) => !m)} - > - - -
-
-
-
-
-
-
-
- }> - - {(latestFrame) => { - const style = () => { - const aspectRatio = - latestFrame().data.width / latestFrame().data.height; - - // Use state.size directly for immediate feedback - const base = state.size; - - // Replicate window size logic synchronously for the canvas - const winWidth = - state.shape === "full" - ? aspectRatio >= 1 - ? base * aspectRatio - : base - : base; - const winHeight = - state.shape === "full" - ? aspectRatio >= 1 - ? base - : base / aspectRatio - : base; - - if (state.shape === "full") { - return { - width: `${winWidth}px`, - height: `${winHeight}px`, - transform: state.mirrored ? "scaleX(-1)" : "scaleX(1)", - }; - } - - const size = (() => { - if (aspectRatio > 1) - return { - width: base * aspectRatio, - height: base, - }; - else - return { - width: base, - height: base * aspectRatio, - }; - })(); - - const left = aspectRatio > 1 ? (size.width - base) / 2 : 0; - const top = aspectRatio > 1 ? 0 : (base - size.height) / 2; - - return { - width: `${size.width}px`, - height: `${size.height}px`, - left: `-${left}px`, - top: `-${top}px`, - transform: state.mirrored ? "scaleX(-1)" : "scaleX(1)", - }; - }; - - return ( - - ); - }} - - -
-
- ); + const { rawOptions } = useRecordingOptions(); + + const [state, setState] = makePersisted( + createStore({ + size: CAMERA_DEFAULT_SIZE, + shape: "round", + mirrored: false, + }), + { name: "cameraWindowState" }, + ); + + const [isResizing, setIsResizing] = createSignal(false); + const [resizeStart, setResizeStart] = createSignal({ + size: 0, + x: 0, + y: 0, + corner: "", + }); + + const [latestFrame, setLatestFrame] = createLazySignal<{ + width: number; + data: ImageData; + } | null>(); + + const [frameDimensions, setFrameDimensions] = createSignal<{ + width: number; + height: number; + } | null>(null); + + function imageDataHandler(imageData: { width: number; data: ImageData }) { + setLatestFrame(imageData); + + const currentDimensions = frameDimensions(); + if ( + !currentDimensions || + currentDimensions.width !== imageData.data.width || + currentDimensions.height !== imageData.data.height + ) { + setFrameDimensions({ + width: imageData.data.width, + height: imageData.data.height, + }); + } + + const ctx = cameraCanvasRef?.getContext("2d"); + ctx?.putImageData(imageData.data, 0, 0); + } + + const { cameraWsPort } = (window as any).__CAP__; + const [ws, isConnected] = createImageDataWS( + `ws://localhost:${cameraWsPort}`, + imageDataHandler, + ); + + const reconnectInterval = setInterval(() => { + if (!isConnected()) { + console.log("Attempting to reconnect..."); + ws.close(); + + const newWs = createImageDataWS( + `ws://localhost:${cameraWsPort}`, + imageDataHandler, + ); + Object.assign(ws, newWs[0]); + } + }, 5000); + + onCleanup(() => { + clearInterval(reconnectInterval); + ws.close(); + }); + + const scale = () => { + const normalized = + (state.size - CAMERA_MIN_SIZE) / (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE); + return 0.7 + normalized * 0.3; + }; + + const handleResizeStart = (corner: string) => (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + setResizeStart({ size: state.size, x: e.clientX, y: e.clientY, corner }); + }; + + const handleResizeMove = (e: MouseEvent) => { + if (!isResizing()) return; + const start = resizeStart(); + const deltaX = e.clientX - start.x; + const deltaY = e.clientY - start.y; + + let delta = 0; + if (start.corner.includes("e") && start.corner.includes("s")) { + delta = Math.max(deltaX, deltaY); + } else if (start.corner.includes("e") && start.corner.includes("n")) { + delta = Math.max(deltaX, -deltaY); + } else if (start.corner.includes("w") && start.corner.includes("s")) { + delta = Math.max(-deltaX, deltaY); + } else if (start.corner.includes("w") && start.corner.includes("n")) { + delta = Math.max(-deltaX, -deltaY); + } else if (start.corner.includes("e")) { + delta = deltaX; + } else if (start.corner.includes("w")) { + delta = -deltaX; + } else if (start.corner.includes("s")) { + delta = deltaY; + } else if (start.corner.includes("n")) { + delta = -deltaY; + } + + const newSize = Math.max( + CAMERA_MIN_SIZE, + Math.min(CAMERA_MAX_SIZE, start.size + delta), + ); + setState("size", newSize); + }; + + const handleResizeEnd = () => { + setIsResizing(false); + }; + + createEffect(() => { + if (isResizing()) { + window.addEventListener("mousemove", handleResizeMove); + window.addEventListener("mouseup", handleResizeEnd); + onCleanup(() => { + window.removeEventListener("mousemove", handleResizeMove); + window.removeEventListener("mouseup", handleResizeEnd); + }); + } + }); + + const [windowSize] = createResource( + () => + [ + state.size, + state.shape, + frameDimensions()?.width, + frameDimensions()?.height, + ] as const, + async ([size, shape, frameWidth, frameHeight]) => { + const monitor = await currentMonitor(); + + const BAR_HEIGHT = 56; + const base = Math.max(CAMERA_MIN_SIZE, Math.min(CAMERA_MAX_SIZE, size)); + const aspect = frameWidth && frameHeight ? frameWidth / frameHeight : 1; + const windowWidth = + shape === "full" ? (aspect >= 1 ? base * aspect : base) : base; + const windowHeight = + shape === "full" ? (aspect >= 1 ? base : base / aspect) : base; + const totalHeight = windowHeight + BAR_HEIGHT; + + if (!monitor) return; + + const scalingFactor = monitor.scaleFactor; + const width = monitor.size.width / scalingFactor - windowWidth - 100; + const height = monitor.size.height / scalingFactor - totalHeight - 100; + + const currentWindow = getCurrentWindow(); + currentWindow.setSize(new LogicalSize(windowWidth, totalHeight)); + currentWindow.setPosition( + new LogicalPosition( + width + monitor.position.toLogical(scalingFactor).x, + height + monitor.position.toLogical(scalingFactor).y, + ), + ); + + return { width, height, size: base, windowWidth, windowHeight }; + }, + ); + + let cameraCanvasRef: HTMLCanvasElement | undefined; + + const setCamera = createCameraMutation(); + + createEffect( + on( + () => rawOptions.cameraLabel, + (label) => { + if (label === null) getCurrentWindow().close(); + }, + { defer: true }, + ), + ); + + onMount(() => getCurrentWindow().show()); + + return ( +
+ + + +
+
+
+ getCurrentWindow().close()}> + + + = CAMERA_PRESET_LARGE} + onClick={() => { + setState( + "size", + state.size < CAMERA_PRESET_LARGE + ? CAMERA_PRESET_LARGE + : CAMERA_PRESET_SMALL, + ); + }} + > + + + + setState("shape", (s) => + s === "round" ? "square" : s === "square" ? "full" : "round", + ) + } + > + {state.shape === "round" && } + {state.shape === "square" && } + {state.shape === "full" && ( + + )} + + setState("mirrored", (m) => !m)} + > + + +
+
+
+
+
+
+
+
+ }> + + {(latestFrame) => { + const style = () => { + const aspectRatio = + latestFrame().data.width / latestFrame().data.height; + + // Use state.size directly for immediate feedback + const base = state.size; + + // Replicate window size logic synchronously for the canvas + const winWidth = + state.shape === "full" + ? aspectRatio >= 1 + ? base * aspectRatio + : base + : base; + const winHeight = + state.shape === "full" + ? aspectRatio >= 1 + ? base + : base / aspectRatio + : base; + + if (state.shape === "full") { + return { + width: `${winWidth}px`, + height: `${winHeight}px`, + transform: state.mirrored ? "scaleX(-1)" : "scaleX(1)", + }; + } + + const size = (() => { + if (aspectRatio > 1) + return { + width: base * aspectRatio, + height: base, + }; + else + return { + width: base, + height: base * aspectRatio, + }; + })(); + + const left = aspectRatio > 1 ? (size.width - base) / 2 : 0; + const top = aspectRatio > 1 ? 0 : (base - size.height) / 2; + + return { + width: `${size.width}px`, + height: `${size.height}px`, + left: `-${left}px`, + top: `-${top}px`, + transform: state.mirrored ? "scaleX(-1)" : "scaleX(1)", + }; + }; + + return ( + + ); + }} + + +
+
+ ); } function CameraLoadingState() { - return ( -
-
Loading camera...
-
- ); + return ( +
+
Loading camera...
+
+ ); } function cameraBorderRadius(state: CameraWindowState) { - if (state.shape === "round") return "9999px"; - const normalized = - (state.size - CAMERA_MIN_SIZE) / (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE); - const radius = 3 + normalized * 1.5; - return `${radius}rem`; + if (state.shape === "round") return "9999px"; + const normalized = + (state.size - CAMERA_MIN_SIZE) / (CAMERA_MAX_SIZE - CAMERA_MIN_SIZE); + const radius = 3 + normalized * 1.5; + return `${radius}rem`; } function CameraDisconnectedOverlay() { - return ( -
-

- Camera disconnected -

-
- ); + return ( +
+

+ Camera disconnected +

+
+ ); } From d24e92c33fbd01ee21e8b1c35042f6c948f10199 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 17:10:19 +0000 Subject: [PATCH 20/34] Add code formatting guidelines to documentation --- AGENTS.md | 4 ++++ CLAUDE.md | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 2a7295118d..7a1dd5542b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,3 +44,7 @@ - Convert the effectful API to a Next.js handler with `apiToHandler(ApiLive)` from `@/lib/server` and export the returned `handler`—avoid calling `runPromise` inside route files. - On the server, run effects through `EffectRuntime.runPromise` from `@/lib/server`, typically after `provideOptionalAuth`, so cookies and per-request context are attached automatically. - On the client, use `useEffectQuery`/`useEffectMutation` from `@/lib/EffectRuntime`; they already bind the managed runtime and tracing so you shouldn't call `EffectRuntime.run*` directly in components. + +## Code Formatting +- Always format code before completing work: run `pnpm format` for TypeScript/JavaScript and `cargo fmt` for Rust. +- Run these commands regularly during development and always at the end of a coding session to ensure consistent formatting. diff --git a/CLAUDE.md b/CLAUDE.md index 7617ae5098..d13e14a888 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -414,3 +414,11 @@ Transcription/AI Enhancement → Database Storage - **Monorepo Guide**: Turborepo documentation - **Effect System**: Used in web-backend packages - **Media Processing**: FFmpeg documentation for Rust bindings + +## Code Formatting + +Always format code before completing work: +- **TypeScript/JavaScript**: Run `pnpm format` to format all code with Biome +- **Rust**: Run `cargo fmt` to format all Rust code with rustfmt + +These commands should be run regularly during development and always at the end of a coding session to ensure consistent formatting across the codebase. From e6e9320f81728d3f8d4894823d4fdaa7a7f0199e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 18:26:21 +0000 Subject: [PATCH 21/34] Improve logging and error context in recording pipeline --- crates/recording/src/output_pipeline/core.rs | 7 ++++++- crates/recording/src/sources/camera.rs | 21 ++++++++++++++++++++ crates/recording/src/studio_recording.rs | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/crates/recording/src/output_pipeline/core.rs b/crates/recording/src/output_pipeline/core.rs index b68eb5ad2b..1c5ff7b8bc 100644 --- a/crates/recording/src/output_pipeline/core.rs +++ b/crates/recording/src/output_pipeline/core.rs @@ -455,7 +455,7 @@ fn spawn_video_encoder, TVideo: V let mut first_tx = Some(first_tx); - stop_token + let res = stop_token .run_until_cancelled(async { while let Some(frame) = video_rx.next().await { let timestamp = frame.timestamp(); @@ -471,10 +471,15 @@ fn spawn_video_encoder, TVideo: V .map_err(|e| anyhow!("Error queueing video frame: {e}"))?; } + info!("mux-video stream ended (rx closed)"); Ok::<(), anyhow::Error>(()) }) .await; + if res.is_none() { + info!("mux-video cancelled"); + } + muxer.lock().await.stop(); Ok(()) diff --git a/crates/recording/src/sources/camera.rs b/crates/recording/src/sources/camera.rs index e60733ece5..30ba67392d 100644 --- a/crates/recording/src/sources/camera.rs +++ b/crates/recording/src/sources/camera.rs @@ -10,6 +10,24 @@ use std::sync::Arc; pub struct Camera(Arc); +struct LogDrop(T, &'static str); +impl Drop for LogDrop { + fn drop(&mut self) { + tracing::debug!("Dropping {}", self.1); + } +} +impl std::ops::Deref for LogDrop { + type Target = T; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl std::ops::DerefMut for LogDrop { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + impl VideoSource for Camera { type Config = Arc; type Frame = FFmpegVideoFrame; @@ -29,11 +47,14 @@ impl VideoSource for Camera { .await .map_err(|e| anyhow!("Failed to add camera sender: {e}"))?; + let mut video_tx = LogDrop(video_tx, "camera_video_tx"); + tokio::spawn(async move { tracing::debug!("Camera source task started"); loop { match rx.recv_async().await { Ok(frame) => { + // tracing::trace!("Sending camera frame"); if let Err(e) = video_tx.send(frame).await { tracing::warn!("Failed to send to video pipeline: {e}"); // If pipeline is closed, we should stop? diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 75ef41659c..79a7b19c27 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -855,7 +855,7 @@ async fn create_segment_pipeline( })) .await .transpose() - .context("microphone pipeline setup")?; + .context("system audio pipeline setup")?; let cursor = custom_cursor_capture .then(move || { From 5de394f3e1f7e87e6bb3302f58d8d4fd6550369c Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 20:51:15 +0000 Subject: [PATCH 22/34] Update apps/desktop/src-tauri/src/camera.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- apps/desktop/src-tauri/src/camera.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/camera.rs b/apps/desktop/src-tauri/src/camera.rs index 7263105ff6..ee03d7680a 100644 --- a/apps/desktop/src-tauri/src/camera.rs +++ b/apps/desktop/src-tauri/src/camera.rs @@ -59,7 +59,7 @@ impl Default for CameraPreviewState { } fn clamp_size(size: f32) -> f32 { - size.max(MIN_CAMERA_SIZE).min(MAX_CAMERA_SIZE) + size.clamp(MIN_CAMERA_SIZE, MAX_CAMERA_SIZE) } pub struct CameraPreviewManager { From 0066eb13db21131c49ce4c3bf34cbc2bbeb4fbb9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 21:36:48 +0000 Subject: [PATCH 23/34] Add camera overlay bounds update and revert logic --- apps/desktop/src-tauri/src/lib.rs | 1 + .../src-tauri/src/target_select_overlay.rs | 30 +++ apps/desktop/src-tauri/src/windows.rs | 2 + .../src/routes/target-select-overlay.tsx | 206 ++++++++++++++++-- apps/desktop/src/utils/tauri.ts | 3 + 5 files changed, 226 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 284f8b3222..e3c3f1ffa0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2294,6 +2294,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { captions::export_captions_srt, target_select_overlay::open_target_select_overlays, target_select_overlay::close_target_select_overlays, + target_select_overlay::update_camera_overlay_bounds, target_select_overlay::display_information, target_select_overlay::get_window_icon, target_select_overlay::focus_window, diff --git a/apps/desktop/src-tauri/src/target_select_overlay.rs b/apps/desktop/src-tauri/src/target_select_overlay.rs index a1c3fd2169..d52424235e 100644 --- a/apps/desktop/src-tauri/src/target_select_overlay.rs +++ b/apps/desktop/src-tauri/src/target_select_overlay.rs @@ -148,6 +148,36 @@ fn should_skip_window(window: &Window, exclusions: &[WindowExclusion]) -> bool { }) } +#[specta::specta] +#[tauri::command] +#[instrument(skip(app))] +pub async fn update_camera_overlay_bounds( + app: AppHandle, + x: f64, + y: f64, + width: f64, + height: f64, +) -> Result<(), String> { + let window = app + .get_webview_window("camera") + .ok_or("Camera window not found")?; + + window + .set_size(tauri::Size::Physical(tauri::PhysicalSize { + width: width as u32, + height: height as u32, + })) + .map_err(|e| e.to_string())?; + window + .set_position(tauri::Position::Physical(tauri::PhysicalPosition { + x: x as i32, + y: y as i32, + })) + .map_err(|e| e.to_string())?; + + Ok(()) +} + #[specta::specta] #[tauri::command] #[instrument(skip(app))] diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 3d567e5797..12cde9fef2 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -521,6 +521,8 @@ impl ShowCapWindow { #[cfg(target_os = "macos")] { + crate::platform::set_window_level(window.as_ref().window(), 60); + _ = window.run_on_main_thread({ let window = window.as_ref().window(); move || unsafe { diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 6a08d85c55..1d1f9a17fd 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -3,7 +3,12 @@ import { createEventListener } from "@solid-primitives/event-listener"; import { createElementSize } from "@solid-primitives/resize-observer"; import { useSearchParams } from "@solidjs/router"; import { createMutation, useQuery } from "@tanstack/solid-query"; -import { LogicalPosition } from "@tauri-apps/api/dpi"; +import { invoke } from "@tauri-apps/api/core"; +import { + LogicalPosition, + type PhysicalPosition, + type PhysicalSize, +} from "@tauri-apps/api/dpi"; import { emit } from "@tauri-apps/api/event"; import { CheckMenuItem, @@ -11,6 +16,7 @@ import { MenuItem, PredefinedMenuItem, } from "@tauri-apps/api/menu"; +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; import { type as ostype } from "@tauri-apps/plugin-os"; import { createEffect, @@ -19,6 +25,7 @@ import { Match, mergeProps, onCleanup, + onMount, Show, Suspense, Switch, @@ -213,6 +220,16 @@ function Inner() { const unsubOnEscapePress = events.onEscapePress.listen(() => { setOptions("targetMode", null); commands.closeTargetSelectOverlays(); + + // We can't easily access `revertCamera` here because it's inside the Match. + // However, if we close overlays, the camera might stay moved? + // The user request says "if the user cancels the selection". + // Pressing Escape cancels the selection mode entirely. + // Ideally we should revert the camera position if we moved it. + // But `revertCamera` is scoped to `Inner` -> `Match`. + // We can rely on the fact that `originalCameraBounds` state is local to that Match block. + // If the block unmounts, we might want to revert? + // `onCleanup` inside the Match block is the right place. }); onCleanup(() => unsubOnEscapePress.then((f) => f())); @@ -381,12 +398,25 @@ function Inner() { let controlsEl: HTMLDivElement | undefined; let cropperRef: CropperRef | undefined; + const [cameraWindow, setCameraWindow] = + createSignal(null); + const [originalCameraBounds, setOriginalCameraBounds] = createSignal<{ + position: PhysicalPosition; + size: PhysicalSize; + } | null>(null); + const [cachedScaleFactor, setCachedScaleFactor] = createSignal< + number | null + >(null); + + onMount(async () => { + const win = await WebviewWindow.getByLabel("camera"); + if (win) setCameraWindow(win); + }); + const [aspect, setAspect] = createSignal(null); const [snapToRatioEnabled, setSnapToRatioEnabled] = createSignal(true); const [isInteracting, setIsInteracting] = createSignal(false); - const [committedCrop, setCommittedCrop] = - createSignal(CROP_ZERO); const shouldShowSelectionHint = createMemo(() => { if (initialAreaBounds() !== undefined) return false; const bounds = crop(); @@ -397,14 +427,153 @@ function Inner() { const b = crop(); return b.width >= MIN_SIZE.width && b.height >= MIN_SIZE.height; }); - const committedIsValid = createMemo(() => { - const b = committedCrop(); - return b.width >= MIN_SIZE.width && b.height >= MIN_SIZE.height; + + const [targetState, setTargetState] = createSignal<{ + x: number; + y: number; + width: number; + height: number; + } | null>(null); + + let lastApplied: { + x: number; + y: number; + width: number; + height: number; + } | null = null; + + onMount(() => { + let processing = false; + + const loop = async () => { + const target = targetState(); + if (target && !processing) { + const changed = + !lastApplied || + Math.abs(lastApplied.x - target.x) > 1 || + Math.abs(lastApplied.y - target.y) > 1 || + Math.abs(lastApplied.width - target.width) > 1 || + Math.abs(lastApplied.height - target.height) > 1; + + if (changed) { + processing = true; + try { + await invoke("update_camera_overlay_bounds", { + x: target.x, + y: target.y, + width: target.width, + height: target.height, + }); + lastApplied = target; + } catch (e) { + console.error("Failed to update camera window", e); + } + processing = false; + } + } + requestAnimationFrame(loop); + }; + const raf = requestAnimationFrame(loop); + onCleanup(() => cancelAnimationFrame(raf)); }); - createEffect(() => { - if (isInteracting()) return; - setCommittedCrop(crop()); + createEffect(async () => { + const bounds = crop(); + const interacting = isInteracting(); + + // Find the camera window if we haven't yet + let win = cameraWindow(); + if (!win) { + // Try to find it + try { + win = await WebviewWindow.getByLabel("camera"); + if (!win) { + // Fallback: check all windows + const all = await WebviewWindow.getAll(); + win = all.find((w) => w.label.includes("camera")) ?? null; + } + if (win) setCameraWindow(win); + } catch (e) { + console.error("Failed to find camera window", e); + } + } + + if (!win || !interacting) return; + + // Initialize data + if (!originalCameraBounds() || cachedScaleFactor() === null) { + try { + const pos = await win.outerPosition(); + const size = await win.outerSize(); + const factor = await win.scaleFactor(); + setOriginalCameraBounds({ position: pos, size }); + setCachedScaleFactor(factor); + } catch (e) { + console.error("Failed to init camera bounds", e); + } + return; + } + + const original = originalCameraBounds(); + const scaleFactor = cachedScaleFactor() ?? 1; + + if (!original) return; + + const originalLogicalSize = original.size.toLogical(scaleFactor); + + const padding = 16; + const selectionMinDim = Math.min(bounds.width, bounds.height); + const targetMaxDim = Math.max( + 150, + Math.min( + Math.max(originalLogicalSize.width, originalLogicalSize.height), + selectionMinDim * 0.5, + ), + ); + + const originalMaxDim = Math.max( + originalLogicalSize.width, + originalLogicalSize.height, + ); + const scale = targetMaxDim / originalMaxDim; + + const newWidth = Math.round(originalLogicalSize.width * scale); + const newHeight = Math.round(originalLogicalSize.height * scale); + + if ( + bounds.width > newWidth + padding * 2 && + bounds.height > newHeight + padding * 2 + ) { + const newX = Math.round( + bounds.x + bounds.width - newWidth - padding, + ); + const newY = Math.round( + bounds.y + bounds.height - newHeight - padding, + ); + + setTargetState({ + x: newX * scaleFactor, + y: newY * scaleFactor, + width: newWidth * scaleFactor, + height: newHeight * scaleFactor, + }); + } + }); + + async function revertCamera() { + const original = originalCameraBounds(); + const win = cameraWindow(); + if (original && win) { + await win.setPosition(original.position); + await win.setSize(original.size); + setOriginalCameraBounds(null); + setTargetState(null); + lastApplied = null; + } + } + + onCleanup(() => { + revertCamera(); }); async function showCropOptionsMenu(e: UIEvent) { @@ -416,6 +585,7 @@ function Inner() { cropperRef?.reset(); setAspect(null); setPendingAreaTarget(null); + revertCamera(); }, }, await PredefinedMenuItem.new({ @@ -503,10 +673,10 @@ function Inner() { createEffect(() => { if (isInteracting()) return; - if (!committedIsValid()) return; + if (!isValid()) return; const screenId = displayId(); if (!screenId) return; - const bounds = committedCrop(); + const bounds = crop(); setPendingAreaTarget({ variant: "area", screen: screenId, @@ -518,7 +688,7 @@ function Inner() { }); return ( -
+
setOriginalCameraBounds(null)} />
@@ -587,6 +758,7 @@ function RecordingControls(props: { setToggleModeSelect?: (value: boolean) => void; showBackground?: boolean; disabled?: boolean; + onRecordingStart?: () => void; }) { const auth = authStore.createQuery(); const { setOptions, rawOptions } = useRecordingOptions(); @@ -724,6 +896,8 @@ function RecordingControls(props: { ); } + props.onRecordingStart?.(); + commands.startRecording({ capture_target: props.target, mode: rawOptions.mode, diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 05762f6987..80ada5de5a 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -277,6 +277,9 @@ async openTargetSelectOverlays(focusedTarget: ScreenCaptureTarget | null) : Prom async closeTargetSelectOverlays() : Promise { return await TAURI_INVOKE("close_target_select_overlays"); }, +async updateCameraOverlayBounds(x: number, y: number, width: number, height: number) : Promise { + return await TAURI_INVOKE("update_camera_overlay_bounds", { x, y, width, height }); +}, async displayInformation(displayId: string) : Promise { return await TAURI_INVOKE("display_information", { displayId }); }, From 05f39bb1938034db4427a4ef7fe40acbf954fac7 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 21:46:20 +0000 Subject: [PATCH 24/34] Improve countdown animation and fix recording logic --- .../src/routes/in-progress-recording.tsx | 58 +++++++++++++++++-- packages/ui-solid/src/auto-imports.d.ts | 34 ----------- 2 files changed, 53 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/routes/in-progress-recording.tsx b/apps/desktop/src/routes/in-progress-recording.tsx index b4cbd007ad..a735ab17a9 100644 --- a/apps/desktop/src/routes/in-progress-recording.tsx +++ b/apps/desktop/src/routes/in-progress-recording.tsx @@ -18,10 +18,12 @@ import { createEffect, createMemo, createSignal, + For, onCleanup, Show, } from "solid-js"; import { createStore, produce } from "solid-js/store"; +import { TransitionGroup } from "solid-transition-group"; import { authStore } from "~/store"; import { createTauriEventListener } from "~/utils/createEventListener"; import { @@ -191,8 +193,9 @@ export default function () { }); createEffect(() => { - if (state().variant === "initializing") { - const recording = currentRecording.data as CurrentRecording; + const s = state(); + if (s.variant === "initializing" || s.variant === "countdown") { + const recording = currentRecording.data as CurrentRecording | undefined; if (recording?.status === "recording") { setDisconnectedInputs({ microphone: false, camera: false }); setRecordingFailure(null); @@ -486,7 +489,7 @@ export default function () { for (const { pause, resume } of pauseResumes) { if (pause && resume) t -= resume - pause; } - return t; + return Math.max(0, t); }; const isMaxRecordingLimitEnabled = () => { @@ -560,10 +563,55 @@ export default function () { > - + + { + const a = el.animate( + [ + { + opacity: 0, + transform: "translateY(-100%)", + }, + { opacity: 1, transform: "translateY(0)" }, + ], + { + duration: 300, + easing: "cubic-bezier(0.16, 1, 0.3, 1)", + }, + ); + a.finished.then(done); + }} + onExit={(el, done) => { + const a = el.animate( + [ + { opacity: 1, transform: "translateY(0)" }, + { + opacity: 0, + transform: "translateY(100%)", + }, + ], + { + duration: 300, + easing: "cubic-bezier(0.16, 1, 0.3, 1)", + }, + ); + a.finished.then(done); + }} + > + + {(num) => ( + + {num} + + )} + + +
+ } > Date: Tue, 18 Nov 2025 21:52:38 +0000 Subject: [PATCH 25/34] Add checked_duration_since for timestamp types --- crates/recording/src/output_pipeline/core.rs | 14 ++++++++++---- crates/timestamp/src/lib.rs | 11 +++++++++++ crates/timestamp/src/macos.rs | 9 +++++++++ crates/timestamp/src/win.rs | 19 +++++++++++++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/crates/recording/src/output_pipeline/core.rs b/crates/recording/src/output_pipeline/core.rs index 1c5ff7b8bc..56ec9e95ab 100644 --- a/crates/recording/src/output_pipeline/core.rs +++ b/crates/recording/src/output_pipeline/core.rs @@ -464,10 +464,12 @@ fn spawn_video_encoder, TVideo: V let _ = first_tx.send(timestamp); } + let duration = timestamp.checked_duration_since(timestamps).unwrap_or(Duration::ZERO); + muxer .lock() .await - .send_video_frame(frame, timestamp.duration_since(timestamps)) + .send_video_frame(frame, duration) .map_err(|e| anyhow!("Error queueing video frame: {e}"))?; } @@ -476,12 +478,16 @@ fn spawn_video_encoder, TVideo: V }) .await; + muxer.lock().await.stop(); + + if let Some(Err(e)) = res { + return Err(e); + } + if res.is_none() { info!("mux-video cancelled"); } - muxer.lock().await.stop(); - Ok(()) }); } @@ -512,7 +518,7 @@ impl PreparedAudioSources { let _ = first_tx.send(frame.timestamp); } - let timestamp = frame.timestamp.duration_since(timestamps); + let timestamp = frame.timestamp.checked_duration_since(timestamps).unwrap_or(Duration::ZERO); if let Err(e) = muxer.lock().await.send_audio_frame(frame, timestamp) { error!("Audio encoder: {e}"); } diff --git a/crates/timestamp/src/lib.rs b/crates/timestamp/src/lib.rs index c5f3ffb7cd..aafa3dd3e3 100644 --- a/crates/timestamp/src/lib.rs +++ b/crates/timestamp/src/lib.rs @@ -32,6 +32,17 @@ impl Timestamp { } } + pub fn checked_duration_since(&self, start: Timestamps) -> Option { + match self { + Self::Instant(instant) => instant.checked_duration_since(start.instant), + Self::SystemTime(time) => time.duration_since(start.system_time).ok(), + #[cfg(windows)] + Self::PerformanceCounter(counter) => counter.checked_duration_since(start.performance_counter), + #[cfg(target_os = "macos")] + Self::MachAbsoluteTime(time) => time.checked_duration_since(start.mach_absolute_time), + } + } + pub fn from_cpal(instant: cpal::StreamInstant) -> Self { #[cfg(windows)] { diff --git a/crates/timestamp/src/macos.rs b/crates/timestamp/src/macos.rs index a316d7533f..b726533501 100644 --- a/crates/timestamp/src/macos.rs +++ b/crates/timestamp/src/macos.rs @@ -30,6 +30,15 @@ impl MachAbsoluteTimestamp { Duration::from_nanos((diff as f64 * freq) as u64) } + pub fn checked_duration_since(&self, other: Self) -> Option { + let info = TimeBaseInfo::new(); + let freq = info.numer as f64 / info.denom as f64; + + let diff = self.0.checked_sub(other.0)?; + + Some(Duration::from_nanos((diff as f64 * freq) as u64)) + } + pub fn from_cpal(instant: cpal::StreamInstant) -> Self { use cpal::host::coreaudio::StreamInstantExt; diff --git a/crates/timestamp/src/win.rs b/crates/timestamp/src/win.rs index ee2edc6dd3..d3efe2d444 100644 --- a/crates/timestamp/src/win.rs +++ b/crates/timestamp/src/win.rs @@ -46,6 +46,25 @@ impl PerformanceCounterTimestamp { } } + pub fn checked_duration_since(&self, other: Self) -> Option { + let freq = perf_freq() as i128; + debug_assert!(freq > 0); + + let diff = self.0 as i128 - other.0 as i128; + + if diff < 0 { + None + } else { + let diff = diff as u128; + let freq = freq as u128; + + let secs = diff / freq; + let nanos = ((diff % freq) * 1_000_000_000u128) / freq; + + Some(Duration::new(secs as u64, nanos as u32)) + } + } + pub fn now() -> Self { let mut value = 0; unsafe { QueryPerformanceCounter(&mut value).unwrap() }; From a0aeffaaf83fcfe13a36936103079486c7bd4108 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 23:38:32 +0000 Subject: [PATCH 26/34] Refactor duration calculation for readability --- crates/recording/src/output_pipeline/core.rs | 9 +++++++-- crates/timestamp/src/lib.rs | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/recording/src/output_pipeline/core.rs b/crates/recording/src/output_pipeline/core.rs index 56ec9e95ab..a3d79c8a67 100644 --- a/crates/recording/src/output_pipeline/core.rs +++ b/crates/recording/src/output_pipeline/core.rs @@ -464,7 +464,9 @@ fn spawn_video_encoder, TVideo: V let _ = first_tx.send(timestamp); } - let duration = timestamp.checked_duration_since(timestamps).unwrap_or(Duration::ZERO); + let duration = timestamp + .checked_duration_since(timestamps) + .unwrap_or(Duration::ZERO); muxer .lock() @@ -518,7 +520,10 @@ impl PreparedAudioSources { let _ = first_tx.send(frame.timestamp); } - let timestamp = frame.timestamp.checked_duration_since(timestamps).unwrap_or(Duration::ZERO); + let timestamp = frame + .timestamp + .checked_duration_since(timestamps) + .unwrap_or(Duration::ZERO); if let Err(e) = muxer.lock().await.send_audio_frame(frame, timestamp) { error!("Audio encoder: {e}"); } diff --git a/crates/timestamp/src/lib.rs b/crates/timestamp/src/lib.rs index aafa3dd3e3..47b4b19013 100644 --- a/crates/timestamp/src/lib.rs +++ b/crates/timestamp/src/lib.rs @@ -37,7 +37,9 @@ impl Timestamp { Self::Instant(instant) => instant.checked_duration_since(start.instant), Self::SystemTime(time) => time.duration_since(start.system_time).ok(), #[cfg(windows)] - Self::PerformanceCounter(counter) => counter.checked_duration_since(start.performance_counter), + Self::PerformanceCounter(counter) => { + counter.checked_duration_since(start.performance_counter) + } #[cfg(target_os = "macos")] Self::MachAbsoluteTime(time) => time.checked_duration_since(start.mach_absolute_time), } From 51e2f6e48f97b455551d2971cc6aba19deb014f7 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 23:38:43 +0000 Subject: [PATCH 27/34] misc packages --- Cargo.lock | 44 ++++++++++++++++++++++--- packages/ui-solid/src/auto-imports.d.ts | 6 ++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c530687aed..0a90eb0f29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1233,6 +1233,7 @@ dependencies = [ "specta", "specta-typescript", "swift-rs", + "sysinfo", "tauri", "tauri-build", "tauri-nspanel", @@ -4122,7 +4123,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.0", "system-configuration 0.6.1", "tokio", "tower-service", @@ -4809,7 +4810,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.53.3", ] [[package]] @@ -5464,6 +5465,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -5805,6 +5815,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-kit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-io-surface" version = "0.3.1" @@ -6826,7 +6846,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls 0.23.31", - "socket2 0.5.10", + "socket2 0.6.0", "thiserror 2.0.16", "tokio", "tracing", @@ -6863,7 +6883,7 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.0", "tracing", "windows-sys 0.60.2", ] @@ -8799,6 +8819,20 @@ dependencies = [ "libc", ] +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -11088,7 +11122,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.0", ] [[package]] diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index 25871e867e..b22b089717 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -67,13 +67,16 @@ declare global { const IconLucideBug: typeof import('~icons/lucide/bug.jsx')['default'] const IconLucideCheck: typeof import('~icons/lucide/check.jsx')['default'] const IconLucideClock: typeof import('~icons/lucide/clock.jsx')['default'] + const IconLucideEdit: typeof import('~icons/lucide/edit.jsx')['default'] const IconLucideEyeOff: typeof import('~icons/lucide/eye-off.jsx')['default'] const IconLucideFastForward: typeof import('~icons/lucide/fast-forward.jsx')['default'] const IconLucideFolder: typeof import('~icons/lucide/folder.jsx')['default'] + const IconLucideGift: typeof import('~icons/lucide/gift.jsx')['default'] const IconLucideHardDrive: typeof import('~icons/lucide/hard-drive.jsx')['default'] const IconLucideLayout: typeof import('~icons/lucide/layout.jsx')['default'] const IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle.jsx')['default'] const IconLucideMaximize: typeof import('~icons/lucide/maximize.jsx')['default'] + const IconLucideMessageSquarePlus: typeof import('~icons/lucide/message-square-plus.jsx')['default'] const IconLucideMicOff: typeof import('~icons/lucide/mic-off.jsx')['default'] const IconLucideMonitor: typeof import('~icons/lucide/monitor.jsx')['default'] const IconLucidePlus: typeof import('~icons/lucide/plus.jsx')['default'] @@ -82,8 +85,11 @@ declare global { const IconLucideRotateCcw: typeof import('~icons/lucide/rotate-ccw.jsx')['default'] const IconLucideSearch: typeof import('~icons/lucide/search.jsx')['default'] const IconLucideSquarePlay: typeof import('~icons/lucide/square-play.jsx')['default'] + const IconLucideUnplug: typeof import('~icons/lucide/unplug.jsx')['default'] const IconLucideVideo: typeof import('~icons/lucide/video.jsx')['default'] const IconLucideVolume2: typeof import('~icons/lucide/volume2.jsx')['default'] const IconLucideX: typeof import('~icons/lucide/x.jsx')['default'] const IconPhMonitorBold: typeof import('~icons/ph/monitor-bold.jsx')['default'] + const IconPhRecordFill: typeof import('~icons/ph/record-fill.jsx')['default'] + const IconPhWarningBold: typeof import('~icons/ph/warning-bold.jsx')['default'] } From 6db1d2dca13f5f2648274c044b40b1fe0063f5f8 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 18 Nov 2025 23:41:03 +0000 Subject: [PATCH 28/34] Remove unused setCamera mutation in camera page --- apps/desktop/src/routes/camera.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index 3d9d2dc9b5..0953b05eaf 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -447,8 +447,6 @@ function LegacyCameraPreviewPage(props: { disconnected: Accessor }) { let cameraCanvasRef: HTMLCanvasElement | undefined; - const setCamera = createCameraMutation(); - createEffect( on( () => rawOptions.cameraLabel, From ff78eba0dcebb6a0b21445d6646ba8c3460f3d9f Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:19:09 +0000 Subject: [PATCH 29/34] Clarify and emphasize no code comments policy --- AGENTS.md | 2 ++ CLAUDE.md | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 7a1dd5542b..f09db99711 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ - Rust: `rustfmt` + workspace clippy lints. - Naming: files kebab‑case (`user-menu.tsx`); components PascalCase; Rust modules snake_case, crates kebab‑case. - 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.). ## Testing - TS/JS: Vitest where present (e.g., desktop). Name tests `*.test.ts(x)` near sources. @@ -37,6 +38,7 @@ - Database flow: always `db:generate` → `db:push` before relying on new schema. - Keep secrets out of VCS; configure via `.env` from `pnpm env-setup`. - macOS note: desktop permissions (screen/mic) apply to the terminal running `pnpm dev:desktop`. +- **CRITICAL: NO CODE COMMENTS**: Never add any form of comments (`//`, `/* */`, `///`, `//!`, `#`, etc.) to generated or edited code. Code must be self-explanatory. ## Effect Usage - Next.js API routes in `apps/web/app/api/*` are built with `@effect/platform`'s `HttpApi` builder; copy the existing class/group/endpoint pattern instead of ad-hoc handlers. diff --git a/CLAUDE.md b/CLAUDE.md index d13e14a888..ee2f0b29f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -360,7 +360,12 @@ Minimize `useEffect` usage: compute during render, handle logic in event handler - Windowing/permissions are handled in Rust; keep UI logic in Solid and avoid mixing IPC with rendering logic. ## Conventions -- No code comments: Never add inline, block, or docstring comments in any language. Code must be self-explanatory through naming, types, and structure. Use docs/READMEs for explanations when necessary. +- **CRITICAL: NO CODE COMMENTS**: Never add any form of comments to code. This includes: + - Single-line comments: `//` (JavaScript/TypeScript/Rust), `#` (Python/Shell) + - Multi-line comments: `/* */` (JavaScript/TypeScript), `/* */` (Rust) + - Documentation comments: `///`, `//!` (Rust), `/** */` (JSDoc) + - Any other comment syntax in any language + - Code must be self-explanatory through naming, types, and structure. Use docs/READMEs for explanations when necessary. - Directory naming: lower-case-dashed - Components: PascalCase; hooks: camelCase starting with `use` - Strict TypeScript; avoid `any`; leverage shared types From 756d29640781d1aaa99b55b2b39bbb8555cde458 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:19:29 +0000 Subject: [PATCH 30/34] Improve camera window resizing and positioning logic --- apps/desktop/src-tauri/src/camera.rs | 84 +++++-- apps/desktop/src/routes/camera.tsx | 314 +++++++++++++++++++-------- 2 files changed, 289 insertions(+), 109 deletions(-) diff --git a/apps/desktop/src-tauri/src/camera.rs b/apps/desktop/src-tauri/src/camera.rs index ee03d7680a..dc2581139a 100644 --- a/apps/desktop/src-tauri/src/camera.rs +++ b/apps/desktop/src-tauri/src/camera.rs @@ -186,8 +186,8 @@ impl InitializedCameraPreview { 1.0 }; - let size = - resize_window(&window, default_state, aspect).context("Error resizing Tauri window")?; + let size = resize_window(&window, default_state, aspect, true) + .context("Error resizing Tauri window")?; let (tx, rx) = oneshot::channel(); window @@ -577,7 +577,7 @@ impl Renderer { self.sync_ratio_uniform_and_resize_window_to_it(&window, &state, aspect_ratio); self.update_state_uniforms(&state); - if let Ok((width, height)) = resize_window(&window, &state, aspect_ratio) + if let Ok((width, height)) = resize_window(&window, &state, aspect_ratio, false) .map_err(|err| error!("Error resizing camera preview window: {err}")) { self.reconfigure_gpu_surface(width, height); @@ -662,7 +662,7 @@ impl Renderer { bytemuck::cast_slice(&[camera_uniforms]), ); - if let Ok((width, height)) = resize_window(window, state, aspect_ratio) + if let Ok((width, height)) = resize_window(window, state, aspect_ratio, false) .map_err(|err| error!("Error resizing camera preview window: {err}")) { self.reconfigure_gpu_surface(width, height); @@ -677,6 +677,7 @@ fn resize_window( window: &WebviewWindow, state: &CameraPreviewState, aspect: f32, + should_move: bool, ) -> tauri::Result<(u32, u32)> { trace!("CameraPreview/resize_window"); @@ -692,25 +693,68 @@ fn resize_window( base } + TOOLBAR_HEIGHT; - let (monitor_size, monitor_offset, monitor_scale_factor): ( - PhysicalSize, - LogicalPosition, - _, - ) = if let Some(monitor) = window.current_monitor()? { - let size = monitor.position().to_logical(monitor.scale_factor()); - (*monitor.size(), size, monitor.scale_factor()) - } else { - (PhysicalSize::new(640, 360), LogicalPosition::new(0, 0), 1.0) - }; + if should_move { + let (monitor_size, monitor_offset, monitor_scale_factor): ( + PhysicalSize, + LogicalPosition, + _, + ) = if let Some(monitor) = window.current_monitor()? { + let size = monitor.position().to_logical(monitor.scale_factor()); + (*monitor.size(), size, monitor.scale_factor()) + } else { + (PhysicalSize::new(640, 360), LogicalPosition::new(0, 0), 1.0) + }; + + let x = (monitor_size.width as f64 / monitor_scale_factor - window_width as f64 - 100.0) + as u32 + + monitor_offset.x; + let y = (monitor_size.height as f64 / monitor_scale_factor - window_height as f64 - 100.0) + as u32 + + monitor_offset.y; + + window.set_position(LogicalPosition::new(x, y))?; + } else if let Some(monitor) = window.current_monitor()? { + // Ensure the window stays within the monitor bounds when resizing + let scale_factor = monitor.scale_factor(); + let monitor_pos = monitor.position().to_logical::(scale_factor); + let monitor_size = monitor.size().to_logical::(scale_factor); + + let current_pos = window + .outer_position() + .map(|p| p.to_logical::(scale_factor)) + .unwrap_or(monitor_pos); + + let mut new_x = current_pos.x; + let mut new_y = current_pos.y; + let new_width = window_width as f64; + let new_height = window_height as f64; + + // Check right edge + if new_x + new_width > monitor_pos.x + monitor_size.width { + new_x = monitor_pos.x + monitor_size.width - new_width; + } + + // Check bottom edge + if new_y + new_height > monitor_pos.y + monitor_size.height { + new_y = monitor_pos.y + monitor_size.height - new_height; + } + + // Check left edge + if new_x < monitor_pos.x { + new_x = monitor_pos.x; + } - let x = (monitor_size.width as f64 / monitor_scale_factor - window_width as f64 - 100.0) as u32 - + monitor_offset.x; - let y = (monitor_size.height as f64 / monitor_scale_factor - window_height as f64 - 100.0) - as u32 - + monitor_offset.y; + // Check top edge + if new_y < monitor_pos.y { + new_y = monitor_pos.y; + } + + if (new_x - current_pos.x).abs() > 1.0 || (new_y - current_pos.y).abs() > 1.0 { + window.set_position(LogicalPosition::new(new_x, new_y))?; + } + } window.set_size(LogicalSize::new(window_width, window_height))?; - window.set_position(LogicalPosition::new(x, y))?; Ok((window_width as u32, window_height as u32)) } diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index 0953b05eaf..6170e7b570 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -24,7 +24,7 @@ import { createStore } from "solid-js/store"; import { generalSettingsStore } from "~/store"; import { createTauriEventListener } from "~/utils/createEventListener"; import { createCameraMutation } from "~/utils/queries"; -import { createImageDataWS, createLazySignal } from "~/utils/socket"; +import { createLazySignal } from "~/utils/socket"; import { commands, events } from "~/utils/tauri"; import { RecordingOptionsProvider, @@ -293,6 +293,8 @@ function LegacyCameraPreviewPage(props: { disconnected: Accessor }) { corner: "", }); + const [hasPositioned, setHasPositioned] = createSignal(false); + const [latestFrame, setLatestFrame] = createLazySignal<{ width: number; data: ImageData; @@ -322,28 +324,107 @@ function LegacyCameraPreviewPage(props: { disconnected: Accessor }) { ctx?.putImageData(imageData.data, 0, 0); } - const { cameraWsPort } = (window as any).__CAP__; - const [ws, isConnected] = createImageDataWS( - `ws://localhost:${cameraWsPort}`, - imageDataHandler, - ); + const { cameraWsPort } = window.__CAP__; + const [isConnected, setIsConnected] = createSignal(false); + let ws: WebSocket | undefined; + + const createSocket = () => { + const socket = new WebSocket(`ws://localhost:${cameraWsPort}`); + socket.binaryType = "arraybuffer"; + + socket.addEventListener("open", () => { + console.log("WebSocket connected"); + setIsConnected(true); + }); + + socket.addEventListener("close", () => { + console.log("WebSocket disconnected"); + setIsConnected(false); + }); + + socket.addEventListener("error", (error) => { + console.error("WebSocket error:", error); + setIsConnected(false); + }); + + socket.onmessage = (event) => { + const buffer = event.data as ArrayBuffer; + const clamped = new Uint8ClampedArray(buffer); + if (clamped.length < 12) { + console.error("Received frame too small to contain metadata"); + return; + } + + const metadataOffset = clamped.length - 12; + const meta = new DataView(buffer, metadataOffset, 12); + const strideBytes = meta.getUint32(0, true); + const height = meta.getUint32(4, true); + const width = meta.getUint32(8, true); + + if (!width || !height) { + console.error("Received invalid frame dimensions", { width, height }); + return; + } + + const source = clamped.subarray(0, metadataOffset); + const expectedRowBytes = width * 4; + const expectedLength = expectedRowBytes * height; + const availableLength = strideBytes * height; + + if ( + strideBytes === 0 || + strideBytes < expectedRowBytes || + source.length < availableLength + ) { + console.error("Received invalid frame stride", { + strideBytes, + expectedRowBytes, + height, + sourceLength: source.length, + }); + return; + } + + let pixels: Uint8ClampedArray; + + if (strideBytes === expectedRowBytes) { + pixels = source.subarray(0, expectedLength); + } else { + pixels = new Uint8ClampedArray(expectedLength); + for (let row = 0; row < height; row += 1) { + const srcStart = row * strideBytes; + const destStart = row * expectedRowBytes; + pixels.set( + source.subarray(srcStart, srcStart + expectedRowBytes), + destStart, + ); + } + } + + const imageData = new ImageData( + new Uint8ClampedArray(pixels), + width, + height, + ); + imageDataHandler({ width, data: imageData }); + }; + + return socket; + }; + + ws = createSocket(); const reconnectInterval = setInterval(() => { - if (!isConnected()) { + if (!ws || ws.readyState !== WebSocket.OPEN) { console.log("Attempting to reconnect..."); - ws.close(); - - const newWs = createImageDataWS( - `ws://localhost:${cameraWsPort}`, - imageDataHandler, - ); - Object.assign(ws, newWs[0]); + if (ws) ws.close(); + ws = createSocket(); } }, 5000); onCleanup(() => { clearInterval(reconnectInterval); - ws.close(); + ws?.close(); }); const scale = () => { @@ -433,13 +514,56 @@ function LegacyCameraPreviewPage(props: { disconnected: Accessor }) { const height = monitor.size.height / scalingFactor - totalHeight - 100; const currentWindow = getCurrentWindow(); - currentWindow.setSize(new LogicalSize(windowWidth, totalHeight)); - currentWindow.setPosition( - new LogicalPosition( - width + monitor.position.toLogical(scalingFactor).x, - height + monitor.position.toLogical(scalingFactor).y, - ), - ); + + if (!hasPositioned()) { + currentWindow.setPosition( + new LogicalPosition( + width + monitor.position.toLogical(scalingFactor).x, + height + monitor.position.toLogical(scalingFactor).y, + ), + ); + setHasPositioned(true); + } else { + const outerPos = await currentWindow.outerPosition(); + const logicalPos = outerPos.toLogical(scalingFactor); + const monitorLogicalPos = monitor.position.toLogical(scalingFactor); + const monitorLogicalSize = monitor.size.toLogical(scalingFactor); + + let newX = logicalPos.x; + let newY = logicalPos.y; + + // Right edge + if ( + newX + windowWidth > + monitorLogicalPos.x + monitorLogicalSize.width + ) { + newX = monitorLogicalPos.x + monitorLogicalSize.width - windowWidth; + } + // Bottom edge + if ( + newY + totalHeight > + monitorLogicalPos.y + monitorLogicalSize.height + ) { + newY = monitorLogicalPos.y + monitorLogicalSize.height - totalHeight; + } + // Left edge + if (newX < monitorLogicalPos.x) { + newX = monitorLogicalPos.x; + } + // Top edge + if (newY < monitorLogicalPos.y) { + newY = monitorLogicalPos.y; + } + + if ( + Math.abs(newX - logicalPos.x) > 1 || + Math.abs(newY - logicalPos.y) > 1 + ) { + await currentWindow.setPosition(new LogicalPosition(newX, newY)); + } + } + + await currentWindow.setSize(new LogicalSize(windowWidth, totalHeight)); return { width, height, size: base, windowWidth, windowHeight }; }, @@ -541,73 +665,12 @@ function LegacyCameraPreviewPage(props: { disconnected: Accessor }) { data-tauri-drag-region > }> - - {(latestFrame) => { - const style = () => { - const aspectRatio = - latestFrame().data.width / latestFrame().data.height; - - // Use state.size directly for immediate feedback - const base = state.size; - - // Replicate window size logic synchronously for the canvas - const winWidth = - state.shape === "full" - ? aspectRatio >= 1 - ? base * aspectRatio - : base - : base; - const winHeight = - state.shape === "full" - ? aspectRatio >= 1 - ? base - : base / aspectRatio - : base; - - if (state.shape === "full") { - return { - width: `${winWidth}px`, - height: `${winHeight}px`, - transform: state.mirrored ? "scaleX(-1)" : "scaleX(1)", - }; - } - - const size = (() => { - if (aspectRatio > 1) - return { - width: base * aspectRatio, - height: base, - }; - else - return { - width: base, - height: base * aspectRatio, - }; - })(); - - const left = aspectRatio > 1 ? (size.width - base) / 2 : 0; - const top = aspectRatio > 1 ? 0 : (base - size.height) / 2; - - return { - width: `${size.width}px`, - height: `${size.height}px`, - left: `-${left}px`, - top: `-${top}px`, - transform: state.mirrored ? "scaleX(-1)" : "scaleX(1)", - }; - }; - - return ( - - ); - }} + +
@@ -615,6 +678,79 @@ function LegacyCameraPreviewPage(props: { disconnected: Accessor }) { ); } +function Canvas(props: { + latestFrame: Accessor<{ width: number; data: ImageData } | null | undefined>; + state: CameraWindowState; + ref: HTMLCanvasElement | undefined; +}) { + const style = () => { + const frame = props.latestFrame(); + if (!frame) return {}; + + const aspectRatio = frame.data.width / frame.data.height; + + // Use state.size directly for immediate feedback + const base = props.state.size; + + // Replicate window size logic synchronously for the canvas + const winWidth = + props.state.shape === "full" + ? aspectRatio >= 1 + ? base * aspectRatio + : base + : base; + const winHeight = + props.state.shape === "full" + ? aspectRatio >= 1 + ? base + : base / aspectRatio + : base; + + if (props.state.shape === "full") { + return { + width: `${winWidth}px`, + height: `${winHeight}px`, + transform: props.state.mirrored ? "scaleX(-1)" : "scaleX(1)", + }; + } + + const size = (() => { + if (aspectRatio > 1) + return { + width: base * aspectRatio, + height: base, + }; + else + return { + width: base, + height: base * aspectRatio, + }; + })(); + + const left = aspectRatio > 1 ? (size.width - base) / 2 : 0; + const top = aspectRatio > 1 ? 0 : (base - size.height) / 2; + + return { + width: `${size.width}px`, + height: `${size.height}px`, + left: `-${left}px`, + top: `-${top}px`, + transform: props.state.mirrored ? "scaleX(-1)" : "scaleX(1)", + }; + }; + + return ( + + ); +} + function CameraLoadingState() { return (
From 0510be8e8880338bff342b2217f2baf1df1acabc Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:19:44 +0000 Subject: [PATCH 31/34] Add __CAP__ property to Window interface --- apps/desktop/src/global.d.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 1b9dd66727..5764facb4b 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -1885,5 +1885,8 @@ declare var FLAGS: Flags; declare global { interface Window { FLAGS: Flags; + __CAP__: { + cameraWsPort: number; + }; } } From 695bb66a2dffe15190cb1e12284ff315d57e4400 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:19:52 +0000 Subject: [PATCH 32/34] Fix requestAnimationFrame cleanup in overlay --- apps/desktop/src/routes/target-select-overlay.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 1d1f9a17fd..65c3f76f86 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -444,6 +444,7 @@ function Inner() { onMount(() => { let processing = false; + let raf: number; const loop = async () => { const target = targetState(); @@ -471,9 +472,9 @@ function Inner() { processing = false; } } - requestAnimationFrame(loop); + raf = requestAnimationFrame(loop); }; - const raf = requestAnimationFrame(loop); + raf = requestAnimationFrame(loop); onCleanup(() => cancelAnimationFrame(raf)); }); From ef920b23b07bf286dfddebd304c562147994d169 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:20:04 +0000 Subject: [PATCH 33/34] gen'd files --- Cargo.lock | 34 ------------------------- packages/ui-solid/src/auto-imports.d.ts | 1 + 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a90eb0f29..0d759a4c6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1233,7 +1233,6 @@ dependencies = [ "specta", "specta-typescript", "swift-rs", - "sysinfo", "tauri", "tauri-build", "tauri-nspanel", @@ -5465,15 +5464,6 @@ dependencies = [ "zbus", ] -[[package]] -name = "ntapi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" -dependencies = [ - "winapi", -] - [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -5815,16 +5805,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "objc2-io-kit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" -dependencies = [ - "libc", - "objc2-core-foundation", -] - [[package]] name = "objc2-io-surface" version = "0.3.1" @@ -8819,20 +8799,6 @@ dependencies = [ "libc", ] -[[package]] -name = "sysinfo" -version = "0.37.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" -dependencies = [ - "libc", - "memchr", - "ntapi", - "objc2-core-foundation", - "objc2-io-kit", - "windows 0.61.3", -] - [[package]] name = "system-configuration" version = "0.5.1" diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index b22b089717..7bb78817bb 100644 --- a/packages/ui-solid/src/auto-imports.d.ts +++ b/packages/ui-solid/src/auto-imports.d.ts @@ -67,6 +67,7 @@ declare global { const IconLucideBug: typeof import('~icons/lucide/bug.jsx')['default'] const IconLucideCheck: typeof import('~icons/lucide/check.jsx')['default'] const IconLucideClock: typeof import('~icons/lucide/clock.jsx')['default'] + const IconLucideDatabase: typeof import('~icons/lucide/database.jsx')['default'] const IconLucideEdit: typeof import('~icons/lucide/edit.jsx')['default'] const IconLucideEyeOff: typeof import('~icons/lucide/eye-off.jsx')['default'] const IconLucideFastForward: typeof import('~icons/lucide/fast-forward.jsx')['default'] From 2f7504349867fdb96316501ce69dfdcecfd2e3a3 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:30:49 +0000 Subject: [PATCH 34/34] clippy bits --- .../src/routes/(window-chrome)/settings/screenshots.tsx | 3 ++- apps/desktop/src/routes/(window-chrome)/setup.tsx | 1 + apps/desktop/src/routes/capture-area.tsx | 1 + apps/desktop/src/routes/recordings-overlay.tsx | 3 +++ crates/recording/examples/recording-cli.rs | 5 ++--- crates/recording/src/sources/camera.rs | 2 +- 6 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/routes/(window-chrome)/settings/screenshots.tsx b/apps/desktop/src/routes/(window-chrome)/settings/screenshots.tsx index 880df5e866..cb788f88ef 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/screenshots.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/screenshots.tsx @@ -1,8 +1,9 @@ import { createQuery } from "@tanstack/solid-query"; import { convertFileSrc } from "@tauri-apps/api/core"; import { createSignal, For, Show } from "solid-js"; - import { commands, events } from "~/utils/tauri"; +import IconLucideEye from "~icons/lucide/eye"; +import IconLucideFolder from "~icons/lucide/folder"; type MediaEntry = { path: string; diff --git a/apps/desktop/src/routes/(window-chrome)/setup.tsx b/apps/desktop/src/routes/(window-chrome)/setup.tsx index e13737eb9e..1b8fd5449b 100644 --- a/apps/desktop/src/routes/(window-chrome)/setup.tsx +++ b/apps/desktop/src/routes/(window-chrome)/setup.tsx @@ -21,6 +21,7 @@ import { type OSPermission, type OSPermissionStatus, } from "~/utils/tauri"; +import IconLucideVolumeX from "~icons/lucide/volume-x"; function isPermitted(status?: OSPermissionStatus): boolean { return status === "granted" || status === "notNeeded"; diff --git a/apps/desktop/src/routes/capture-area.tsx b/apps/desktop/src/routes/capture-area.tsx index 43037488d0..280ad95e2b 100644 --- a/apps/desktop/src/routes/capture-area.tsx +++ b/apps/desktop/src/routes/capture-area.tsx @@ -23,6 +23,7 @@ import SelectionHint from "~/components/selection-hint"; import { createOptionsQuery } from "~/utils/queries"; import type { DisplayId } from "~/utils/tauri"; import { emitTo } from "~/utils/tauriSpectaHack"; +import IconLucideExpand from "~icons/lucide/expand"; const MIN_SIZE = { width: 150, height: 150 }; diff --git a/apps/desktop/src/routes/recordings-overlay.tsx b/apps/desktop/src/routes/recordings-overlay.tsx index fd20488659..32a905c4a6 100644 --- a/apps/desktop/src/routes/recordings-overlay.tsx +++ b/apps/desktop/src/routes/recordings-overlay.tsx @@ -33,7 +33,10 @@ import { type UploadProgress, type UploadResult, } from "~/utils/tauri"; +import IconCapEditor from "~icons/cap/editor"; +import IconCapUpload from "~icons/cap/upload"; import IconLucideClock from "~icons/lucide/clock"; +import IconLucideEye from "~icons/lucide/eye"; import { FPS, OUTPUT_SIZE } from "./editor/context"; type MediaEntry = { diff --git a/crates/recording/examples/recording-cli.rs b/crates/recording/examples/recording-cli.rs index e2a9343b58..fa122661c4 100644 --- a/crates/recording/examples/recording-cli.rs +++ b/crates/recording/examples/recording-cli.rs @@ -1,7 +1,6 @@ -use cap_recording::{feeds::*, screen_capture::ScreenCaptureTarget, *}; -use kameo::Actor as _; +use cap_recording::{screen_capture::ScreenCaptureTarget, *}; use scap_targets::Display; -use std::{sync::Arc, time::Duration}; +use std::time::Duration; use tracing::*; #[tokio::main] diff --git a/crates/recording/src/sources/camera.rs b/crates/recording/src/sources/camera.rs index 30ba67392d..42c305f5f2 100644 --- a/crates/recording/src/sources/camera.rs +++ b/crates/recording/src/sources/camera.rs @@ -34,7 +34,7 @@ impl VideoSource for Camera { async fn setup( feed_lock: Self::Config, - mut video_tx: mpsc::Sender, + video_tx: mpsc::Sender, _: &mut SetupCtx, ) -> anyhow::Result where