diff --git a/Cargo.lock b/Cargo.lock index 7074d9fcde..7071f00844 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2117,7 +2117,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.48.0", ] [[package]] @@ -5146,7 +5146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.53.3", + "windows-targets 0.48.5", ] [[package]] @@ -11836,7 +11836,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 80fac6831b..ed5a04b3f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ sentry = { version = "0.42.0", features = [ "anyhow", "backtrace", "debug-images", + "tracing", ] } tracing = "0.1.41" futures = "0.3.31" diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index f657e9980c..03eccc640b 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -57,6 +57,14 @@ impl ExportSettings { _ => false, } } + + fn format_label(&self) -> &'static str { + match self { + ExportSettings::Mp4(_) => "mp4", + ExportSettings::Gif(_) => "gif", + ExportSettings::Mov(_) => "mov", + } + } } fn export_project_config( @@ -169,6 +177,10 @@ pub async fn export_video( let result = do_export(&project_path, &settings, &progress, force_ffmpeg).await; + let format = settings.format_label(); + let fps = settings.fps(); + let cursor_only = settings.cursor_only(); + match result { Ok(path) => { info!("Exported to {} completed", path.display()); @@ -191,18 +203,50 @@ pub async fn export_video( Ok(path) } Err(retry_e) => { - sentry::capture_message(&retry_e, sentry::Level::Error); + capture_export_failure( + &retry_e, + format, + fps, + cursor_only, + true, + &project_path, + ); Err(retry_e) } } } Err(e) => { - sentry::capture_message(&e, sentry::Level::Error); + capture_export_failure(&e, format, fps, cursor_only, force_ffmpeg, &project_path); Err(e) } } } +fn capture_export_failure( + error: &str, + format: &'static str, + fps: u32, + cursor_only: bool, + force_ffmpeg: bool, + project_path: &Path, +) { + sentry::with_scope( + |scope| { + scope.set_tag("export.format", format); + scope.set_tag("export.fps", fps.to_string()); + scope.set_tag("export.cursor_only", cursor_only.to_string()); + scope.set_tag("export.force_ffmpeg", force_ffmpeg.to_string()); + scope.set_extra( + "project_path", + serde_json::Value::String(project_path.display().to_string()), + ); + }, + || { + sentry::capture_message(error, sentry::Level::Error); + }, + ); +} + #[derive(Debug, serde::Serialize, specta::Type)] pub struct ExportEstimates { pub duration_seconds: f64, diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 50ea5a9bf0..9d580fc5a5 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -170,6 +170,8 @@ pub struct GeneralSettingsStore { pub enable_telemetry: bool, #[serde(default)] pub out_of_process_muxer: bool, + #[serde(default)] + pub verbose_logging: bool, } fn default_enable_native_camera_preview() -> bool { @@ -254,6 +256,7 @@ impl Default for GeneralSettingsStore { has_completed_onboarding: false, enable_telemetry: true, out_of_process_muxer: false, + verbose_logging: false, } } } diff --git a/apps/desktop/src-tauri/src/gpu_context.rs b/apps/desktop/src-tauri/src/gpu_context.rs index 99a8de2643..c0985b8144 100644 --- a/apps/desktop/src-tauri/src/gpu_context.rs +++ b/apps/desktop/src-tauri/src/gpu_context.rs @@ -85,14 +85,23 @@ async fn init_gpu_inner() -> Option { tracing::warn!( "No hardware GPU adapter found, attempting software fallback for shared context" ); - let software_adapter = instance + let software_adapter = match instance .request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::LowPower, force_fallback_adapter: true, compatible_surface: None, }) .await - .ok()?; + { + Ok(a) => a, + Err(err) => { + tracing::error!( + error = %err, + "Failed to acquire any wgpu adapter (hardware and software fallback both unavailable)" + ); + return None; + } + }; let adapter_info = software_adapter.get_info(); @@ -105,14 +114,28 @@ async fn init_gpu_inner() -> Option { (software_adapter, true) }; - let (device, queue) = adapter + let adapter_info = adapter.get_info(); + + let (device, queue) = match adapter .request_device(&wgpu::DeviceDescriptor { label: Some("cap-shared-gpu-device"), required_features: wgpu::Features::empty(), ..Default::default() }) .await - .ok()?; + { + Ok(pair) => pair, + Err(err) => { + tracing::error!( + error = %err, + adapter_name = adapter_info.name, + adapter_backend = ?adapter_info.backend, + adapter_device_type = ?adapter_info.device_type, + "wgpu request_device failed for shared GPU context" + ); + return None; + } + }; Some(SharedGpuContext { device: Arc::new(device), diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 87d6d6edf4..2cde63955a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3509,9 +3509,89 @@ type FilteredRegistry = tracing_subscriber::layer::Layered< pub type DynLoggingLayer = Box + Send + Sync>; type LoggingHandle = tracing_subscriber::reload::Handle, FilteredRegistry>; +pub type LevelFilterHandle = tracing_subscriber::reload::Handle< + tracing_subscriber::filter::LevelFilter, + tracing_subscriber::layer::Layered< + tracing_subscriber::reload::Layer, FilteredRegistry>, + FilteredRegistry, + >, +>; + +pub struct VerboseLoggingHandle { + inner: LevelFilterHandle, + default_level: tracing_subscriber::filter::LevelFilter, +} + +impl VerboseLoggingHandle { + pub fn new( + inner: LevelFilterHandle, + default_level: tracing_subscriber::filter::LevelFilter, + ) -> Self { + Self { + inner, + default_level, + } + } + + pub fn set_verbose(&self, verbose: bool) -> Result<(), String> { + let level = if verbose { + tracing_subscriber::filter::LevelFilter::TRACE + } else { + self.default_level + }; + self.inner + .reload(level) + .map_err(|e| format!("Failed to reload log level: {e}")) + } +} + +#[tauri::command] +#[specta::specta] +async fn set_verbose_logging( + app: AppHandle, + handle: State<'_, Arc>, + enabled: bool, +) -> Result<(), String> { + handle.set_verbose(enabled)?; + + GeneralSettingsStore::update(&app, |s| { + s.verbose_logging = enabled; + })?; + + if enabled { + tracing::info!("Verbose logging enabled (TRACE level)"); + } else { + tracing::info!("Verbose logging disabled (returned to default level)"); + } + + Ok(()) +} + +#[tauri::command] +#[specta::specta] +async fn open_logs_folder(app: AppHandle) -> Result<(), String> { + let logs_dir = app + .state::>() + .read() + .await + .logs_dir + .clone(); + + let path_str = logs_dir + .to_str() + .ok_or_else(|| "Logs directory path is not valid UTF-8".to_string())?; + + app.opener() + .open_path(path_str, None::) + .map_err(|e| format!("Failed to open logs folder: {e}")) +} #[cfg_attr(mobile, tauri::mobile_entry_point)] -pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { +pub async fn run( + recording_logging_handle: LoggingHandle, + level_filter_handle: LevelFilterHandle, + logs_dir: PathBuf, +) { ffmpeg::init() .map_err(|e| { error!("Failed to initialize ffmpeg: {e}"); @@ -3528,6 +3608,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { set_camera_input, recording_settings::set_recording_mode, upload_logs, + open_logs_folder, + set_verbose_logging, get_system_diagnostics, recording::start_recording, recording::stop_recording, @@ -3805,6 +3887,30 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { specta_builder.mount_events(&app); hotkeys::init(&app); general_settings::init(&app); + + #[cfg(debug_assertions)] + let default_log_level = tracing_subscriber::filter::LevelFilter::TRACE; + #[cfg(not(debug_assertions))] + let default_log_level = tracing_subscriber::filter::LevelFilter::INFO; + + let verbose_logging_handle = Arc::new(VerboseLoggingHandle::new( + level_filter_handle, + default_log_level, + )); + + let verbose_logging_enabled = GeneralSettingsStore::get(&app) + .ok() + .flatten() + .map(|s| s.verbose_logging) + .unwrap_or(false); + + if verbose_logging_enabled + && let Err(err) = verbose_logging_handle.set_verbose(true) + { + warn!("Failed to enable verbose logging at startup: {err}"); + } + + app.manage(verbose_logging_handle); fake_window::init(&app); app.manage(target_select_overlay::WindowFocusManager::default()); app.manage(EditorWindowIds::default()); @@ -3877,6 +3983,27 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { }); } + if let Ok(Some(settings)) = GeneralSettingsStore::get(&app) { + sentry::configure_scope(|scope| { + scope.set_tag("os", std::env::consts::OS); + scope.set_tag("arch", std::env::consts::ARCH); + scope.set_tag("cap_version", env!("CARGO_PKG_VERSION")); + scope.set_tag("instance_id", settings.instance_id.to_string()); + scope.set_tag( + "cap_backend", + if settings.server_url == "https://cap.so" { + "cloud" + } else { + "self_hosted" + }, + ); + scope.set_tag( + "verbose_logging", + if settings.verbose_logging { "on" } else { "off" }, + ); + }); + } + { let (server_url, should_update) = if cfg!(debug_assertions) && let Ok(url) = std::env::var("VITE_SERVER_URL") diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 75dfffbdb9..95430067b2 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use cap_desktop_lib::DynLoggingLayer; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use tracing_subscriber::{filter::LevelFilter, layer::SubscriberExt, util::SubscriberInitExt}; fn main() { #[cfg(debug_assertions)] @@ -105,9 +105,23 @@ fn main() { }; #[cfg(debug_assertions)] - let level_filter = tracing_subscriber::filter::LevelFilter::TRACE; + let initial_level = LevelFilter::TRACE; #[cfg(not(debug_assertions))] - let level_filter = tracing_subscriber::filter::LevelFilter::INFO; + let initial_level = LevelFilter::INFO; + + let (level_filter, level_handle) = tracing_subscriber::reload::Layer::new(initial_level); + + let sentry_layer = sentry::integrations::tracing::layer().event_filter(|metadata| { + match *metadata.level() { + tracing::Level::ERROR => sentry::integrations::tracing::EventFilter::Event, + tracing::Level::WARN | tracing::Level::INFO => { + sentry::integrations::tracing::EventFilter::Breadcrumb + } + tracing::Level::DEBUG | tracing::Level::TRACE => { + sentry::integrations::tracing::EventFilter::Ignore + } + } + }); tracing_subscriber::registry() .with(tracing_subscriber::filter::filter_fn( @@ -116,6 +130,7 @@ fn main() { .with(reload_layer) .with(level_filter) .with(otel_layer) + .with(sentry_layer) .with( tracing_subscriber::fmt::layer() .with_ansi(true) @@ -141,5 +156,5 @@ fn main() { .enable_all() .build() .expect("Failed to build multi threaded tokio runtime") - .block_on(cap_desktop_lib::run(handle, logs_dir)); + .block_on(cap_desktop_lib::run(handle, level_handle, logs_dir)); } diff --git a/apps/desktop/src-tauri/src/posthog.rs b/apps/desktop/src-tauri/src/posthog.rs index 54872b2b38..7a89aa0fc3 100644 --- a/apps/desktop/src-tauri/src/posthog.rs +++ b/apps/desktop/src-tauri/src/posthog.rs @@ -20,6 +20,9 @@ pub enum PostHogEvent { MultipartUploadFailed { duration: Duration, error: String, + stage: &'static str, + retried_chunk_count: u32, + bytes_uploaded: u64, }, RecordingStarted { mode: &'static str, @@ -138,10 +141,19 @@ fn posthog_event(event: PostHogEvent, distinct_id: Option<&str>) -> posthog_rs:: set(&mut e, "size", size); e } - PostHogEvent::MultipartUploadFailed { duration, error } => { + PostHogEvent::MultipartUploadFailed { + duration, + error, + stage, + retried_chunk_count, + bytes_uploaded, + } => { let mut e = make_event("multipart_upload_failed", distinct_id); set(&mut e, "duration", duration.as_secs()); set(&mut e, "error", truncate_reason(error)); + set(&mut e, "stage", stage); + set(&mut e, "retried_chunk_count", retried_chunk_count); + set(&mut e, "bytes_uploaded_mb", bytes_uploaded / (1024 * 1024)); e } PostHogEvent::RecordingStarted { diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 251e332858..c1a89ac286 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -2917,23 +2917,27 @@ async fn emit_recording_started_telemetry(app: &AppHandle, state_mtx: &MutableSt use crate::posthog::{PostHogEvent, async_capture_event}; use crate::recording_telemetry::{mode_label, target_kind_label}; - let (mode, target_kind, has_camera, has_mic, has_system_audio) = { + let (mode, target_kind, has_camera, has_mic, has_system_audio, target_width, target_height) = { let state = state_mtx.read().await; let Some(recording) = state.current_recording() else { return; }; let inputs = recording.inputs(); - let target_kind = target_kind_label(recording.capture_target()); + let target = recording.capture_target(); + let target_kind = target_kind_label(target); let has_camera = match recording { InProgressRecording::Instant { camera_feed, .. } | InProgressRecording::Studio { camera_feed, .. } => camera_feed.is_some(), }; + let (target_width, target_height) = capture_target_dimensions(target); ( mode_label(inputs.mode), target_kind, has_camera, state.selected_mic_label.is_some(), inputs.capture_system_audio, + target_width, + target_height, ) }; @@ -2957,14 +2961,29 @@ async fn emit_recording_started_telemetry(app: &AppHandle, state_mtx: &MutableSt has_mic, has_system_audio, target_fps, - target_width: 0, - target_height: 0, + target_width, + target_height, fragmented, custom_cursor_capture, }, ); } +fn capture_target_dimensions(target: &ScreenCaptureTarget) -> (u32, u32) { + match target { + ScreenCaptureTarget::Display { .. } | ScreenCaptureTarget::Window { .. } => target + .display() + .and_then(|d| d.physical_size()) + .map(|s| (s.width().round() as u32, s.height().round() as u32)) + .unwrap_or((0, 0)), + ScreenCaptureTarget::Area { bounds, .. } => ( + bounds.size().width().round() as u32, + bounds.size().height().round() as u32, + ), + ScreenCaptureTarget::CameraOnly => (0, 0), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop/src-tauri/src/upload.rs b/apps/desktop/src-tauri/src/upload.rs index 7aa875d28b..9412987953 100644 --- a/apps/desktop/src-tauri/src/upload.rs +++ b/apps/desktop/src-tauri/src/upload.rs @@ -162,6 +162,9 @@ pub async fn upload_video( Err(err) => PostHogEvent::MultipartUploadFailed { duration: start.elapsed(), error: err.to_string(), + stage: "video_join", + retried_chunk_count: 0, + bytes_uploaded: 0, }, }, ); @@ -440,13 +443,18 @@ impl InstantMultipartUpload { .as_ref() .map(|v| Duration::from_secs(v.duration_in_secs as u64)) .unwrap_or_default(), - size: std::fs::metadata(file_path) + size: std::fs::metadata(&file_path) .map(|m| ((m.len() as f64) / 1_000_000.0) as u64) .unwrap_or_default(), }, Err(err) => PostHogEvent::MultipartUploadFailed { duration: start.elapsed(), error: err.to_string(), + stage: "instant_multipart", + retried_chunk_count: 0, + bytes_uploaded: std::fs::metadata(&file_path) + .map(|m| m.len()) + .unwrap_or_default(), }, }, ); @@ -775,6 +783,9 @@ impl SegmentUploader { Err(err) => PostHogEvent::MultipartUploadFailed { duration: start.elapsed(), error: err.to_string(), + stage: "studio_segment", + retried_chunk_count: 0, + bytes_uploaded: 0, }, }, ); diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index fbd86ba973..f6f5dd92b1 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -21,9 +21,11 @@ import { Show, } from "solid-js"; import { createStore, reconcile } from "solid-js/store"; +import toast from "solid-toast"; import themePreviewAuto from "~/assets/theme-previews/auto.jpg"; import themePreviewDark from "~/assets/theme-previews/dark.jpg"; import themePreviewLight from "~/assets/theme-previews/light.jpg"; +import { Toggle } from "~/components/Toggle"; import { Input } from "~/routes/editor/ui"; import { authStore, generalSettingsStore } from "~/store"; import { @@ -604,6 +606,108 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { value={settings.enableTelemetry !== false} onChange={(v) => handleChange("enableTelemetry", v)} /> + + { + setSettings("verboseLogging", value); + try { + await commands.setVerboseLogging(value); + } catch (error) { + console.error("Failed to set verbose logging", error); + setSettings("verboseLogging", !value); + toast.error("Failed to update verbose logging"); + } + }} + /> + + + ); +} + +function DiagnosticsCard(props: { + verboseLogging: boolean; + onVerboseLoggingChange: (value: boolean) => void; +}) { + const [revealing, setRevealing] = createSignal(false); + const [uploading, setUploading] = createSignal(false); + + const handleReveal = async () => { + setRevealing(true); + try { + await commands.openLogsFolder(); + } catch (error) { + console.error("Failed to open logs folder", error); + toast.error("Failed to open logs folder"); + } finally { + setRevealing(false); + } + }; + + const handleUpload = async () => { + setUploading(true); + try { + await commands.uploadLogs(); + toast.success("Logs uploaded successfully"); + } catch (error) { + console.error("Failed to upload logs", error); + toast.error("Failed to upload logs"); + } finally { + setUploading(false); + } + }; + + return ( +
+

Diagnostics

+
+
+
+

Verbose logging

+

+ Capture detailed trace-level logs covering recording, exporting, + uploading, and rendering pipelines. Use this when asked by support + so we can pinpoint and reproduce the exact issue you're seeing. + Logs are written locally and never sent anywhere until you press{" "} + Upload logs. May slightly increase CPU usage and + disk space while enabled. +

+
+ props.onVerboseLoggingChange(v)} + /> +
+ +
+
+

Logs folder

+

+ Open the folder containing Cap's local log files, or send a + diagnostic bundle so we can investigate any issues you've run + into. +

+
+
+ + +
+
); diff --git a/apps/desktop/src/utils/general-settings.ts b/apps/desktop/src/utils/general-settings.ts index 4479462cc3..352f7458f9 100644 --- a/apps/desktop/src/utils/general-settings.ts +++ b/apps/desktop/src/utils/general-settings.ts @@ -5,6 +5,7 @@ export type GeneralSettingsStore = TauriGeneralSettingsStore & { transcriptionHints?: string[]; enableTelemetry?: boolean; outOfProcessMuxer?: boolean; + verboseLogging?: boolean; }; export const DEFAULT_TRANSCRIPTION_HINTS = [ @@ -30,6 +31,7 @@ export function createDefaultGeneralSettings(): GeneralSettingsStore { maxFps: 60, transcriptionHints: [...DEFAULT_TRANSCRIPTION_HINTS], enableTelemetry: true, + verboseLogging: false, }; } diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index c1f1153e32..4bf7261715 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -17,6 +17,12 @@ async setRecordingMode(mode: RecordingMode) : Promise { async uploadLogs() : Promise { return await TAURI_INVOKE("upload_logs"); }, +async openLogsFolder() : Promise { + return await TAURI_INVOKE("open_logs_folder"); +}, +async setVerboseLogging(enabled: boolean) : Promise { + return await TAURI_INVOKE("set_verbose_logging", { enabled }); +}, async getSystemDiagnostics() : Promise { return await TAURI_INVOKE("get_system_diagnostics"); }, @@ -491,7 +497,7 @@ export type ExportSettings = ({ format: "Mp4" } & Mp4ExportSettings) | ({ format export type FileType = "recording" | "screenshot" export type Flags = { captions: boolean } export type FramesRendered = { renderedCount: number; totalFrames: number; type: "FramesRendered" } -export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; captureKeyboardEvents?: boolean; postDeletionBehaviour?: PostDeletionBehaviour; excludedWindows?: WindowExclusion[]; deleteInstantRecordingsAfterUpload?: boolean; instantModeMaxResolution?: number; defaultProjectNameTemplate?: string | null; crashRecoveryRecording?: boolean; maxFps?: number; transcriptionHints?: string[]; editorPreviewQuality?: EditorPreviewQuality; studioRecordingQuality?: StudioRecordingQuality; mainWindowPosition?: WindowPosition | null; cameraWindowPosition?: WindowPosition | null; cameraWindowPositionsByMonitorName?: { [key in string]: WindowPosition }; hasCompletedOnboarding?: boolean; enableTelemetry?: boolean; outOfProcessMuxer?: boolean } +export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; captureKeyboardEvents?: boolean; postDeletionBehaviour?: PostDeletionBehaviour; excludedWindows?: WindowExclusion[]; deleteInstantRecordingsAfterUpload?: boolean; instantModeMaxResolution?: number; defaultProjectNameTemplate?: string | null; crashRecoveryRecording?: boolean; maxFps?: number; transcriptionHints?: string[]; editorPreviewQuality?: EditorPreviewQuality; studioRecordingQuality?: StudioRecordingQuality; mainWindowPosition?: WindowPosition | null; cameraWindowPosition?: WindowPosition | null; cameraWindowPositionsByMonitorName?: { [key in string]: WindowPosition }; hasCompletedOnboarding?: boolean; enableTelemetry?: boolean; outOfProcessMuxer?: boolean; verboseLogging?: boolean } export type GifExportSettings = { fps: number; resolution_base: XY; quality: GifQuality | null } export type GifQuality = { /**