diff --git a/Cargo.lock b/Cargo.lock index 9e437b7672..0c4ff52c2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -714,6 +714,26 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa4734ba28c4eb6dcd44213f969e007bd2a17f966151656d1c7676f8526bedd" +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + [[package]] name = "bindgen" version = "0.69.5" @@ -1506,6 +1526,7 @@ dependencies = [ name = "cap-project" version = "0.1.0" dependencies = [ + "bincode", "cap-cursor-info", "either", "log", @@ -10628,6 +10649,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + [[package]] name = "ureq" version = "3.1.0" @@ -10774,6 +10801,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + [[package]] name = "vswhom" version = "0.1.0" diff --git a/apps/desktop/src-tauri/src/captions.rs b/apps/desktop/src-tauri/src/captions.rs index a31c56c7cd..ed00f54506 100644 --- a/apps/desktop/src-tauri/src/captions.rs +++ b/apps/desktop/src-tauri/src/captions.rs @@ -22,7 +22,7 @@ use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextPar pub use cap_project::{CaptionSegment, CaptionSettings, CaptionWord}; -use crate::http_client; +use crate::{general_settings::GeneralSettingsStore, http_client}; #[derive(Debug, Serialize, Deserialize, Type, Clone)] pub struct CaptionData { @@ -529,6 +529,7 @@ fn process_with_whisper( audio_path: &PathBuf, context: Arc, language: &str, + transcription_hints: &[String], ) -> Result { log::info!("=== WHISPER TRANSCRIPTION START ==="); log::info!("Processing audio file: {audio_path:?}"); @@ -544,6 +545,10 @@ fn process_with_whisper( params.set_language(Some(if language == "auto" { "auto" } else { language })); params.set_max_len(i32::MAX); + if let Some(initial_prompt) = build_initial_prompt(transcription_hints) { + params.set_initial_prompt(&initial_prompt); + } + log::info!("Whisper params - translate: false, token_timestamps: true, max_len: MAX"); let mut audio_file = File::open(audio_path) @@ -783,10 +788,32 @@ fn process_with_whisper( }) } +fn build_initial_prompt(transcription_hints: &[String]) -> Option { + let mut normalized = Vec::new(); + + for hint in transcription_hints { + let value = hint.replace('\0', "").trim().to_string(); + if value.is_empty() || normalized.contains(&value) { + continue; + } + normalized.push(value); + } + + if normalized.is_empty() { + None + } else { + Some(format!( + "Preferred spellings, names, and capitalization for this transcript: {}", + normalized.join("; ") + )) + } +} + #[tauri::command] #[specta::specta] #[instrument] pub async fn transcribe_audio( + app: AppHandle, video_path: String, model_path: String, language: String, @@ -843,11 +870,18 @@ pub async fn transcribe_audio( } }; + let transcription_hints = GeneralSettingsStore::get(&app) + .ok() + .flatten() + .map(|settings| settings.transcription_hints) + .unwrap_or_default(); + log::info!("Starting Whisper transcription in blocking task..."); - let whisper_result = - tokio::task::spawn_blocking(move || process_with_whisper(&audio_path, context, &language)) - .await - .map_err(|e| format!("Whisper task panicked: {e}"))?; + let whisper_result = tokio::task::spawn_blocking(move || { + process_with_whisper(&audio_path, context, &language, &transcription_hints) + }) + .await + .map_err(|e| format!("Whisper task panicked: {e}"))?; match whisper_result { Ok(captions) => { diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index 40993c1db3..fa10225fe3 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -293,6 +293,7 @@ pub async fn generate_export_preview( .iter() .map(|s| RenderSegment { cursor: s.cursor.clone(), + keyboard: s.keyboard.clone(), decoders: s.decoders.clone(), }) .collect(); diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 546b4960a8..feeb386635 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -121,8 +121,10 @@ pub struct GeneralSettingsStore { skip_serializing_if = "no" )] pub enable_native_camera_preview: bool, - #[serde(default)] + #[serde(default = "default_true")] pub auto_zoom_on_clicks: bool, + #[serde(default = "default_true")] + pub capture_keyboard_events: bool, #[serde(default)] pub post_deletion_behaviour: PostDeletionBehaviour, #[serde(default = "default_excluded_windows")] @@ -137,6 +139,8 @@ pub struct GeneralSettingsStore { pub crash_recovery_recording: bool, #[serde(default = "default_max_fps")] pub max_fps: u32, + #[serde(default = "default_transcription_hints")] + pub transcription_hints: Vec, #[serde(default)] pub editor_preview_quality: EditorPreviewQuality, #[serde(default)] @@ -167,6 +171,15 @@ fn default_max_fps() -> u32 { 60 } +fn default_transcription_hints() -> Vec { + vec![ + "Cap".to_string(), + "TypeScript".to_string(), + "My Brand Name".to_string(), + "mywebsite.com".to_string(), + ] +} + fn default_server_url() -> String { std::option_env!("VITE_SERVER_URL") .unwrap_or("https://cap.so") @@ -202,7 +215,8 @@ impl Default for GeneralSettingsStore { server_url: default_server_url(), recording_countdown: Some(3), enable_native_camera_preview: default_enable_native_camera_preview(), - auto_zoom_on_clicks: false, + auto_zoom_on_clicks: true, + capture_keyboard_events: true, post_deletion_behaviour: PostDeletionBehaviour::DoNothing, excluded_windows: default_excluded_windows(), delete_instant_recordings_after_upload: false, @@ -210,6 +224,7 @@ impl Default for GeneralSettingsStore { default_project_name_template: None, crash_recovery_recording: true, max_fps: 60, + transcription_hints: default_transcription_hints(), editor_preview_quality: EditorPreviewQuality::Half, main_window_position: None, camera_window_position: None, diff --git a/apps/desktop/src-tauri/src/import.rs b/apps/desktop/src-tauri/src/import.rs index c37b701bb9..2af5408a85 100644 --- a/apps/desktop/src-tauri/src/import.rs +++ b/apps/desktop/src-tauri/src/import.rs @@ -506,6 +506,7 @@ pub async fn start_video_import(app: AppHandle, source_path: PathBuf) -> Result< mic: None, system_audio: None, cursor: None, + keyboard: None, }], cursors: Cursors::default(), status: Some(StudioRecordingStatus::InProgress), @@ -599,6 +600,7 @@ pub async fn start_video_import(app: AppHandle, source_path: PathBuf) -> Result< mic: None, system_audio, cursor: None, + keyboard: None, }], cursors: Cursors::default(), status: Some(StudioRecordingStatus::Complete), diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 09916751a5..a2138e14c0 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2123,6 +2123,51 @@ async fn generate_zoom_segments_from_clicks( Ok(zoom_segments) } +#[tauri::command] +#[specta::specta] +#[instrument(skip(editor_instance))] +async fn generate_keyboard_segments( + editor_instance: WindowEditorInstance, + grouping_threshold_ms: f64, + linger_duration_ms: f64, + show_modifiers: bool, + show_special_keys: bool, +) -> Result, String> { + let meta = editor_instance.meta(); + + let RecordingMetaInner::Studio(studio_meta) = &meta.inner else { + return Ok(vec![]); + }; + + let segments = match studio_meta.as_ref() { + StudioRecordingMeta::MultipleSegments { inner, .. } => &inner.segments, + _ => return Ok(vec![]), + }; + + let mut all_events = cap_project::KeyboardEvents { presses: vec![] }; + + for segment in segments { + let events = segment.keyboard_events(meta); + all_events.presses.extend(events.presses); + } + + all_events.presses.sort_by(|a, b| { + a.time_ms + .partial_cmp(&b.time_ms) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let grouped = cap_project::group_key_events( + &all_events, + grouping_threshold_ms, + linger_duration_ms, + show_modifiers, + show_special_keys, + ); + + Ok(grouped) +} + #[tauri::command] #[specta::specta] #[instrument] @@ -3105,6 +3150,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { set_project_config, update_project_config_in_memory, generate_zoom_segments_from_clicks, + generate_keyboard_segments, permissions::open_permission_settings, permissions::do_permissions_check, permissions::request_permission, @@ -3673,13 +3719,14 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { id, CapWindowId::TargetSelectOverlay { .. } | CapWindowId::Main - | CapWindowId::Camera ) { let _ = window.show(); } } + restore_camera_window(app); + #[cfg(target_os = "windows")] if !has_open_editor_window(app) { reopen_main_window(app); @@ -3694,12 +3741,12 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { id, CapWindowId::TargetSelectOverlay { .. } | CapWindowId::Main - | CapWindowId::Camera ) { let _ = window.show(); } } + restore_camera_window(app); return; } CapWindowId::TargetSelectOverlay { display_id } => { @@ -3901,9 +3948,25 @@ fn restore_main_windows_if_no_editors(app: &AppHandle) { if let Some(main) = CapWindowId::Main.get(app) { let _ = main.show(); } - if let Some(camera) = CapWindowId::Camera.get(app) { - let _ = camera.show(); - } + + restore_camera_window(app); + } +} + +fn restore_camera_window(app: &AppHandle) { + let should_restore_camera = app + .state::>() + .try_read() + .map(|state| state.selected_camera_id.is_some()) + .unwrap_or(false); + + if should_restore_camera { + let app = app.clone(); + tokio::spawn(async move { + let operation_lock = app.state::(); + let _operation_guard = operation_lock.lock().await; + let _ = ShowCapWindow::Camera { centered: false }.show(&app).await; + }); } } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index d65bce27fc..7b763d382a 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -878,6 +878,12 @@ pub async fn start_recording( .map(|s| s.custom_cursor_capture) .unwrap_or_default(), ) + .with_keyboard_capture( + general_settings + .as_ref() + .map(|s| s.capture_keyboard_events) + .unwrap_or(true), + ) .with_fragmented( general_settings .as_ref() @@ -2370,6 +2376,8 @@ fn project_config_from_recording( scene_segments: Vec::new(), mask_segments: Vec::new(), text_segments: Vec::new(), + caption_segments: Vec::new(), + keyboard_segments: Vec::new(), }); config @@ -2391,7 +2399,7 @@ pub fn needs_fragment_remux(recording_dir: &Path, meta: &StudioRecordingMeta) -> } pub fn remux_fragmented_recording(recording_dir: &Path) -> Result<(), String> { - let incomplete_recording = RecoveryManager::find_incomplete_single(recording_dir); + let incomplete_recording = RecoveryManager::inspect_recording(recording_dir); if let Some(recording) = incomplete_recording { RecoveryManager::recover(&recording) diff --git a/apps/desktop/src-tauri/src/recovery.rs b/apps/desktop/src-tauri/src/recovery.rs index 84e1d979db..381a31ae24 100644 --- a/apps/desktop/src-tauri/src/recovery.rs +++ b/apps/desktop/src-tauri/src/recovery.rs @@ -76,21 +76,13 @@ pub async fn find_incomplete_recordings( #[tauri::command] #[specta::specta] -pub async fn recover_recording(app: AppHandle, project_path: String) -> Result { - let recordings_dir = app - .path() - .app_data_dir() - .map_err(|e| e.to_string())? - .join("recordings"); - +pub async fn recover_recording(_app: AppHandle, project_path: String) -> Result { let path = PathBuf::from(&project_path); - let incomplete_list = RecoveryManager::find_incomplete(&recordings_dir); - - let recording = incomplete_list - .into_iter() - .find(|r| r.project_path == path) - .ok_or_else(|| "Recording not found in incomplete list".to_string())?; + let recording = tokio::task::spawn_blocking(move || RecoveryManager::inspect_recording(&path)) + .await + .map_err(|e| format!("Recovery scan task failed: {e}"))? + .ok_or_else(|| "No recoverable segments found".to_string())?; if recording.recoverable_segments.is_empty() { return Err("No recoverable segments found".to_string()); diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index d04e8039e4..5e0b2beed9 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -95,6 +95,31 @@ fn hide_recording_windows(app: &AppHandle) { } } +async fn ensure_camera_input_active(app_state: &mut App) { + if let Some(id) = app_state.selected_camera_id.clone() + && !app_state.camera_in_use + { + match app_state + .camera_feed + .ask(feeds::camera::SetInput { id }) + .await + { + Ok(ready_future) => { + if let Err(err) = ready_future.await { + error!("Camera failed to initialize: {err}"); + return; + } + } + Err(err) => { + error!("Failed to send SetInput to camera feed: {err}"); + return; + } + } + + app_state.camera_in_use = true; + } +} + async fn cleanup_camera_window( app: &AppHandle, window: Option<&WebviewWindow>, @@ -517,7 +542,7 @@ impl CapWindowId { Self::Main => (330.0, 395.0), Self::Editor { .. } => (1275.0, 800.0), Self::ScreenshotEditor { .. } => (800.0, 600.0), - Self::Settings => (700.0, 540.0), + Self::Settings => (800.0, 580.0), Self::Camera => (200.0, 200.0), Self::Upgrade => (950.0, 850.0), Self::ModeSelect => (580.0, 340.0), @@ -693,6 +718,8 @@ impl ShowCapWindow { None }; + ensure_camera_input_active(&mut app_state).await; + if enable_native_camera_preview { let camera_feed = app_state.camera_feed.clone(); if let Err(err) = app_state @@ -773,6 +800,8 @@ impl ShowCapWindow { None }; + ensure_camera_input_active(&mut app_state).await; + if enable_native_camera_preview && !app_state.camera_preview.is_initialized() { let camera_feed = app_state.camera_feed.clone(); if let Err(err) = app_state @@ -1244,19 +1273,19 @@ impl ShowCapWindow { app, format!("/settings/{}", page.clone().unwrap_or_default()), ) - .inner_size(600.0, 465.0) - .min_inner_size(600.0, 465.0) + .inner_size(800.0, 580.0) + .min_inner_size(800.0, 580.0) .resizable(true) .maximized(false) .build()?; - let (pos_x, pos_y) = cursor_monitor.center_position(600.0, 465.0); + let (pos_x, pos_y) = cursor_monitor.center_position(800.0, 580.0); let _ = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y)); #[cfg(windows)] { use tauri::LogicalSize; - if let Err(e) = window.set_size(LogicalSize::new(600.0, 465.0)) { + if let Err(e) = window.set_size(LogicalSize::new(800.0, 580.0)) { warn!("Failed to set Settings window size on Windows: {}", e); } if let Err(e) = window.set_position(tauri::LogicalPosition::new(pos_x, pos_y)) { @@ -1576,21 +1605,7 @@ impl ShowCapWindow { .set_position(tauri::LogicalPosition::new(camera_pos_x, camera_pos_y)); } - if let Some(id) = state.selected_camera_id.clone() - && !state.camera_in_use - { - match state.camera_feed.ask(feeds::camera::SetInput { id }).await { - Ok(ready_future) => { - if let Err(err) = ready_future.await { - error!("Camera failed to initialize: {err}"); - } - } - Err(err) => { - error!("Failed to send SetInput to camera feed: {err}"); - } - } - state.camera_in_use = true; - } + ensure_camera_input_active(&mut state).await; #[cfg(target_os = "macos")] { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 8e8eaf4327..5fef765e3e 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -28,6 +28,9 @@ const SettingsGeneralPage = lazy( const SettingsRecordingsPage = lazy( () => import("./routes/(window-chrome)/settings/recordings"), ); +const SettingsTranscriptionPage = lazy( + () => import("./routes/(window-chrome)/settings/transcription"), +); const SettingsScreenshotsPage = lazy( () => import("./routes/(window-chrome)/settings/screenshots"), ); @@ -137,17 +140,7 @@ function Inner() { if (location.pathname !== "/camera") currentWindow.show(); }); - return ( - { - console.log("Root suspense fallback showing"); - }) as any - } - > - {props.children} - - ); + return {props.children}; }} > @@ -157,6 +150,10 @@ function Inner() { + diff --git a/apps/desktop/src/entry-client.tsx b/apps/desktop/src/entry-client.tsx index 801872019c..a44f61bdc5 100644 --- a/apps/desktop/src/entry-client.tsx +++ b/apps/desktop/src/entry-client.tsx @@ -10,7 +10,10 @@ async function initApp() { console.error("Failed to get OS type:", error); } - mount(() => , document.getElementById("app")!); + const app = document.getElementById("app"); + if (!app) throw new Error("App root element not found"); + + mount(() => , app); } initApp(); diff --git a/apps/desktop/src/routes/(window-chrome).tsx b/apps/desktop/src/routes/(window-chrome).tsx index 49b6b3bc70..64b53baf0f 100644 --- a/apps/desktop/src/routes/(window-chrome).tsx +++ b/apps/desktop/src/routes/(window-chrome).tsx @@ -20,8 +20,10 @@ export default function (props: RouteSectionProps) { onMount(async () => { console.log("window chrome mounted"); unlistenResize = await initializeTitlebar(); - const capContext = (window as any).__CAP__; - const hasInitialTargetMode = capContext?.initialTargetMode != null; + const { __CAP__ } = window as typeof window & { + __CAP__?: { initialTargetMode?: unknown }; + }; + const hasInitialTargetMode = __CAP__?.initialTargetMode != null; if (location.pathname === "/" && !hasInitialTargetMode) getCurrentWindow().show(); }); @@ -50,25 +52,9 @@ export default function (props: RouteSectionProps) { enterClass="opacity-0" exitToClass="opacity-0" > */} - { - console.log("Outer window chrome suspense fallback"); - return ; - }) as any - } - > + }> - {/* prevents flicker idk */} - { - console.log("Inner window chrome suspense fallback"); - }) as any - } - > - {props.children} - + {props.children} {/* */} @@ -78,7 +64,11 @@ export default function (props: RouteSectionProps) { } function Header() { - const ctx = useWindowChromeContext()!; + const ctx = useWindowChromeContext(); + if (!ctx) + throw new Error( + "useWindowChrome must be used within a WindowChromeContext", + ); const isWindows = ostype() === "windows"; const isMacOS = ostype() === "macos"; diff --git a/apps/desktop/src/routes/(window-chrome)/settings.tsx b/apps/desktop/src/routes/(window-chrome)/settings.tsx index 718124dd05..2bddbee209 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings.tsx @@ -1,18 +1,15 @@ import { Button } from "@cap/ui-solid"; import { A, type RouteSectionProps } from "@solidjs/router"; import { getVersion } from "@tauri-apps/api/app"; -import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window"; import * as shell from "@tauri-apps/plugin-shell"; import "@total-typescript/ts-reset/filter-boolean"; -import { createResource, For, onMount, Show, Suspense } from "solid-js"; +import { createResource, For, Show, Suspense } from "solid-js"; import { CapErrorBoundary } from "~/components/CapErrorBoundary"; import { SignInButton } from "~/components/SignInButton"; import { authStore } from "~/store"; import { trackEvent } from "~/utils/analytics"; -const WINDOW_SIZE = { width: 700, height: 540 } as const; - export default function Settings(props: RouteSectionProps) { const auth = authStore.createQuery(); const [version] = createResource(() => getVersion()); @@ -24,14 +21,6 @@ export default function Settings(props: RouteSectionProps) { } }; - onMount(() => { - const currentWindow = getCurrentWindow(); - - currentWindow.setSize( - new LogicalSize(WINDOW_SIZE.width, WINDOW_SIZE.height), - ); - }); - return (
@@ -58,6 +47,11 @@ export default function Settings(props: RouteSectionProps) { name: "Screenshots", icon: IconLucideImage, }, + { + href: "transcription", + name: "Transcription", + icon: IconCapCaptions, + }, { href: "integrations", name: "Integrations", diff --git a/apps/desktop/src/routes/(window-chrome)/settings/Setting.tsx b/apps/desktop/src/routes/(window-chrome)/settings/Setting.tsx index 9f182f4a66..019b79829d 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/Setting.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/Setting.tsx @@ -1,10 +1,11 @@ +import type { JSX } from "solid-js"; import { Toggle } from "~/components/Toggle"; export function SettingItem(props: { pro?: boolean; label: string; description?: string; - children: any; + children: JSX.Element; }) { return (
diff --git a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx index 3d1bd22fc7..6a241c8c50 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/experimental.tsx @@ -4,30 +4,30 @@ import { createResource, Show } from "solid-js"; import { createStore } from "solid-js/store"; import { generalSettingsStore } from "~/store"; -import { commands, type GeneralSettingsStore } from "~/utils/tauri"; +import { + deriveGeneralSettings, + type GeneralSettingsStore, +} from "~/utils/general-settings"; +import { commands } from "~/utils/tauri"; import { ToggleSettingItem } from "./Setting"; export default function ExperimentalSettings() { const [store] = createResource(() => generalSettingsStore.get()); + const osType = type(); return ( - {(store) => } + {(store) => } ); } -function Inner(props: { initialStore: GeneralSettingsStore | null }) { +function Inner(props: { + initialStore: GeneralSettingsStore | null; + osType: ReturnType; +}) { const [settings, setSettings] = createStore( - props.initialStore ?? { - uploadIndividualFiles: false, - hideDockIcon: false, - autoCreateShareableLink: false, - enableNotifications: true, - enableNativeCameraPreview: false, - autoZoomOnClicks: false, - custom_cursor_capture2: true, - }, + deriveGeneralSettings(props.initialStore), ); const handleChange = async ( @@ -61,18 +61,17 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { expected.

-
-

Recording Features

-
- - handleChange("custom_cursor_capture2", value) - } - /> - {type() !== "windows" && ( + + No experimental features are currently available on this platform. +

+ } + > +
+

Preview

+
- )} - { - handleChange("autoZoomOnClicks", value); - setTimeout( - () => window.scrollTo({ top: 0, behavior: "instant" }), - 5, - ); - }} - /> +
-
+
); diff --git a/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx b/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx index dde7ede637..8e59e5af91 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/feedback.tsx @@ -1,16 +1,23 @@ import { Button } from "@cap/ui-solid"; import { action, useAction, useSubmission } from "@solidjs/router"; import { getVersion } from "@tauri-apps/api/app"; -import { type as ostype } from "@tauri-apps/plugin-os"; +import { type OsType, type as ostype } from "@tauri-apps/plugin-os"; +import * as shell from "@tauri-apps/plugin-shell"; import { createResource, createSignal, For, Show } from "solid-js"; import toast from "solid-toast"; import { commands, type SystemDiagnostics } from "~/utils/tauri"; import { apiClient, protectedHeaders } from "~/utils/web-api"; +const getFeedbackOs = (): Extract => { + const os = ostype(); + if (os === "macos" || os === "windows") return os; + throw new Error(`Unsupported OS for feedback submission: ${os}`); +}; + const sendFeedbackAction = action(async (feedback: string) => { const response = await apiClient.desktop.submitFeedback({ - body: { feedback, os: ostype() as any, version: await getVersion() }, + body: { feedback, os: getFeedbackOs(), version: await getVersion() }, headers: await protectedHeaders(), }); @@ -109,7 +116,7 @@ export default function FeedbackTab() { Cap Discord community.

+ + + {saveState() === "saving" + ? "Saving..." + : saveState() === "saved" + ? "Saved" + : ""} + +
+ + +
+ setPendingHint(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + addHint(); + }} + placeholder="Add a term" + spellcheck={false} + autocapitalize="off" + autocomplete="off" + autocorrect="off" + class="flex-1 px-3 py-2 bg-gray-1 border border-gray-3 rounded-md text-gray-12 placeholder:text-gray-10 focus:outline-none focus:ring-1 focus:ring-gray-8 hover:border-gray-6" + /> + +
+ +

+ These hints are applied when you generate captions in the editor. +

+ + + 0}> +
+
+

Active hints

+ + {hints().length} {hints().length === 1 ? "item" : "items"} + +
+
+ + {(hint) => ( + + )} + +
+
+
+ + + + ); +} diff --git a/apps/desktop/src/routes/(window-chrome)/update.tsx b/apps/desktop/src/routes/(window-chrome)/update.tsx index 41cee01e50..b6f58badda 100644 --- a/apps/desktop/src/routes/(window-chrome)/update.tsx +++ b/apps/desktop/src/routes/(window-chrome)/update.tsx @@ -2,7 +2,7 @@ import { Button } from "@cap/ui-solid"; import { useNavigate } from "@solidjs/router"; import { getCurrentWindow, UserAttentionType } from "@tauri-apps/api/window"; import { relaunch } from "@tauri-apps/plugin-process"; -import { check, type Update } from "@tauri-apps/plugin-updater"; +import { check } from "@tauri-apps/plugin-updater"; import { createResource, createSignal, Match, Show, Switch } from "solid-js"; export default function () { diff --git a/apps/desktop/src/routes/(window-chrome)/upgrade.tsx b/apps/desktop/src/routes/(window-chrome)/upgrade.tsx index 148c850d67..a48a27d00e 100644 --- a/apps/desktop/src/routes/(window-chrome)/upgrade.tsx +++ b/apps/desktop/src/routes/(window-chrome)/upgrade.tsx @@ -65,7 +65,7 @@ export default function Page() { "message" in resp.body ) throw resp.body.message; - throw new Error((resp.body as any).toString()); + throw new Error(String(resp.body)); } }, onSuccess: async () => { @@ -552,7 +552,7 @@ const ActivateLicenseDialog = ({ open, onOpenChange }: Props) => { return { ...resp.body, licenseKey: vars.licenseKey }; if (typeof resp.body === "object" && resp.body && "message" in resp.body) throw resp.body.message; - throw new Error((resp.body as any).toString()); + throw new Error(String(resp.body)); }, onSuccess: async (value) => { await generalSettingsStore.set({ diff --git a/apps/desktop/src/routes/camera.tsx b/apps/desktop/src/routes/camera.tsx index 3342c97cd9..7745e6e762 100644 --- a/apps/desktop/src/routes/camera.tsx +++ b/apps/desktop/src/routes/camera.tsx @@ -1052,7 +1052,7 @@ function Canvas(props: { style={style()} width={props.latestFrame()?.data.width} height={props.latestFrame()?.data.height} - ref={props.ref!} + ref={props.ref} /> ); } diff --git a/apps/desktop/src/routes/editor/CaptionsTab.tsx b/apps/desktop/src/routes/editor/CaptionsTab.tsx index 79c7726d69..17ea6a7620 100644 --- a/apps/desktop/src/routes/editor/CaptionsTab.tsx +++ b/apps/desktop/src/routes/editor/CaptionsTab.tsx @@ -1,6 +1,5 @@ import { Button } from "@cap/ui-solid"; import { Select as KSelect } from "@kobalte/core/select"; -import { createWritableMemo } from "@solid-primitives/memo"; import { appLocalDataDir, join } from "@tauri-apps/api/path"; import { exists } from "@tauri-apps/plugin-fs"; import { cx } from "cva"; @@ -13,6 +12,7 @@ import { onMount, Show, } from "solid-js"; +import { produce } from "solid-js/store"; import toast from "solid-toast"; import { Toggle } from "~/components/Toggle"; import { defaultCaptionSettings } from "~/store/captions"; @@ -22,9 +22,22 @@ import IconCapChevronDown from "~icons/cap/chevron-down"; import IconCapCircleCheck from "~icons/cap/circle-check"; import IconLucideCheck from "~icons/lucide/check"; import IconLucideDownload from "~icons/lucide/download"; -import { getColorPreviewBorderColor } from "./color-utils"; +import { + CAPTION_MODEL_FOLDER, + createCaptionTrackSegments, + DEFAULT_CAPTION_MODEL, + getCaptionGenerationErrorMessage, + syncCaptionWordsWithText, + transcribeEditorCaptions, +} from "./captions"; import { useEditorContext } from "./context"; -import { TextInput } from "./TextInput"; +import { + CAPTION_POSITION_OPTIONS, + FONT_OPTIONS, + getTextWeightLabel, + HexColorInput, + TEXT_WEIGHT_OPTIONS, +} from "./text-style"; import { Field, Input, @@ -97,79 +110,47 @@ const LANGUAGE_OPTIONS: LanguageOption[] = [ { code: "ta", label: "Tamil" }, ]; -interface PositionOption { - value: string; - label: string; -} - -const POSITION_OPTIONS: PositionOption[] = [ - { value: "top-left", label: "Top Left" }, - { value: "top-center", label: "Top Center" }, - { value: "top-right", label: "Top Right" }, - { value: "bottom-left", label: "Bottom Left" }, - { value: "bottom-center", label: "Bottom Center" }, - { value: "bottom-right", label: "Bottom Right" }, -]; - -const DEFAULT_MODEL = "small"; -const MODEL_FOLDER = "transcription_models"; +export function CaptionsTab() { + const { project, setProject, editorInstance, editorState, setEditorState } = + useEditorContext(); -const fontOptions = [ - { value: "System Sans-Serif", label: "System Sans-Serif" }, - { value: "System Serif", label: "System Serif" }, - { value: "System Monospace", label: "System Monospace" }, -]; + const selectedCaptionIndex = () => + editorState.timeline.selection?.type === "caption" && + editorState.timeline.selection.indices.length === 1 + ? editorState.timeline.selection.indices[0] + : -1; -function RgbInput(props: { value: string; onChange: (value: string) => void }) { - const [text, setText] = createWritableMemo(() => props.value); - let prevColor = props.value; - let colorInput!: HTMLInputElement; + const selectedCaptionSegment = () => + project.timeline?.captionSegments?.[selectedCaptionIndex()]; - return ( -
-
- ); -} + const updateSelectedCaption = ( + update: ( + segment: NonNullable>, + ) => void, + ) => { + const index = selectedCaptionIndex(); + if (index < 0) return; -export function CaptionsTab() { - const { project, setProject, editorInstance, editorState, setEditorState } = - useEditorContext(); + setProject( + produce((currentProject: typeof project) => { + const timelineSegment = + currentProject.timeline?.captionSegments?.[index]; + if (!timelineSegment) return; + + update(timelineSegment); + + const captionSegment = currentProject.captions?.segments?.[index]; + if (!captionSegment) return; + + captionSegment.start = timelineSegment.start; + captionSegment.end = timelineSegment.end; + captionSegment.text = timelineSegment.text; + captionSegment.words = timelineSegment.words?.map((word) => ({ + ...word, + })); + }), + ); + }; const getSetting = ( key: K, @@ -186,7 +167,7 @@ export function CaptionsTab() { setProject("captions", "settings", key, value); }; - const [selectedModel, setSelectedModel] = createSignal(DEFAULT_MODEL); + const [selectedModel, setSelectedModel] = createSignal(DEFAULT_CAPTION_MODEL); const [selectedLanguage, setSelectedLanguage] = createSignal("auto"); const [downloadedModels, setDownloadedModels] = createSignal([]); @@ -221,7 +202,7 @@ export function CaptionsTab() { onMount(async () => { try { const appDataDirPath = await appLocalDataDir(); - const modelsPath = await join(appDataDirPath, MODEL_FOLDER); + const modelsPath = await join(appDataDirPath, CAPTION_MODEL_FOLDER); if (!(await exists(modelsPath))) { await commands.createDir(modelsPath, true); @@ -316,7 +297,7 @@ export function CaptionsTab() { const checkModelExists = async (modelName: string) => { const appDataDirPath = await appLocalDataDir(); - const modelsPath = await join(appDataDirPath, MODEL_FOLDER); + const modelsPath = await join(appDataDirPath, CAPTION_MODEL_FOLDER); const path = await join(modelsPath, `${modelName}.bin`); return await commands.checkModelExists(path); }; @@ -329,7 +310,7 @@ export function CaptionsTab() { setDownloadingModel(modelToDownload); const appDataDirPath = await appLocalDataDir(); - const modelsPath = await join(appDataDirPath, MODEL_FOLDER); + const modelsPath = await join(appDataDirPath, CAPTION_MODEL_FOLDER); const modelPath = await join(modelsPath, `${modelToDownload}.bin`); try { @@ -365,23 +346,23 @@ export function CaptionsTab() { setIsGenerating(true); try { - const videoPath = editorInstance.path; - const lang = selectedLanguage(); - const currentModelPath = await join( - await appLocalDataDir(), - MODEL_FOLDER, - `${selectedModel()}.bin`, - ); - - const result = await commands.transcribeAudio( - videoPath, - currentModelPath, - lang, + const result = await transcribeEditorCaptions( + editorInstance.path, + selectedModel(), + selectedLanguage(), ); if (result && result.segments.length > 0) { setProject("captions", "segments", result.segments); updateCaptionSetting("enabled", true); + + setProject( + "timeline", + "captionSegments", + createCaptionTrackSegments(result.segments), + ); + setEditorState("timeline", "tracks", "caption", true); + toast.success("Captions generated successfully!"); } else { toast.error( @@ -390,75 +371,21 @@ export function CaptionsTab() { } } catch (error) { console.error("Error generating captions:", error); - let errorMessage = "Unknown error occurred"; - - if (error instanceof Error) { - errorMessage = error.message; - } else if (typeof error === "string") { - errorMessage = error; - } - - if (errorMessage.includes("No audio stream found")) { - errorMessage = "No audio found in the video file"; - } else if (errorMessage.includes("Model file not found")) { - errorMessage = "Caption model not found. Please download it first"; - } else if (errorMessage.includes("Failed to load Whisper model")) { - errorMessage = - "Failed to load the caption model. Try downloading it again"; - } - + const errorMessage = getCaptionGenerationErrorMessage(error); toast.error(`Failed to generate captions: ${errorMessage}`); } finally { setIsGenerating(false); } }; - const deleteSegment = (id: string) => { - if (!project?.captions?.segments) return; - - setProject( - "captions", - "segments", - project.captions.segments.filter((segment) => segment.id !== id), - ); - }; - - const updateSegment = ( - id: string, - updates: Partial<{ start: number; end: number; text: string }>, - ) => { - if (!project?.captions?.segments) return; - - setProject( - "captions", - "segments", - project.captions.segments.map((segment) => - segment.id === id ? { ...segment, ...updates } : segment, - ), - ); - }; - - const addSegment = (time: number) => { - if (!project?.captions) return; - - const id = `segment-${Date.now()}`; - setProject("captions", "segments", [ - ...project.captions.segments, - { - id, - start: time, - end: time + 2, - text: "New caption", - }, - ]); - }; - const hasCaptions = createMemo( - () => (project.captions?.segments?.length ?? 0) > 0, + () => + (project.timeline?.captionSegments?.length ?? 0) > 0 || + (project.captions?.segments?.length ?? 0) > 0, ); return ( - }> + } badge="Beta">
@@ -626,7 +553,7 @@ export function CaptionsTab() {
Font Family - options={fontOptions.map((f) => f.value)} + options={FONT_OPTIONS.map((f) => f.value)} value={getSetting("font")} onChange={(value) => { if (value === null) return; @@ -640,7 +567,7 @@ export function CaptionsTab() { > { - fontOptions.find( + FONT_OPTIONS.find( (f) => f.value === props.item.rawValue, )?.label } @@ -651,7 +578,7 @@ export function CaptionsTab() { > {(state) => - fontOptions.find( + FONT_OPTIONS.find( (f) => f.value === state.selectedOption(), )?.label } @@ -708,8 +635,8 @@ export function CaptionsTab() {
- Font Color - Text Color + updateCaptionSetting("color", value)} /> @@ -721,7 +648,7 @@ export function CaptionsTab() {
Background Color - updateCaptionSetting("backgroundColor", value) @@ -747,7 +674,7 @@ export function CaptionsTab() { }> - options={POSITION_OPTIONS.map((p) => p.value)} + options={CAPTION_POSITION_OPTIONS.map((p) => p.value)} value={getSetting("position")} onChange={(value) => { if (value === null) return; @@ -761,7 +688,7 @@ export function CaptionsTab() { > { - POSITION_OPTIONS.find( + CAPTION_POSITION_OPTIONS.find( (p) => p.value === props.item.rawValue, )?.label } @@ -774,7 +701,7 @@ export function CaptionsTab() { {(state) => ( { - POSITION_OPTIONS.find( + CAPTION_POSITION_OPTIONS.find( (p) => p.value === state.selectedOption(), )?.label } @@ -802,7 +729,7 @@ export function CaptionsTab() {
Highlight Color - updateCaptionSetting("highlightColor", value) @@ -830,11 +757,7 @@ export function CaptionsTab() { }> class="truncate"> - {(state) => { - const selected = state.selectedOption(); - if (selected) return selected.label; - const weight = getSetting("fontWeight"); - const option = [ - { label: "Normal", value: 400 }, - { label: "Medium", value: 500 }, - { label: "Bold", value: 700 }, - ].find((o) => o.value === weight); - return option ? option.label : "Bold"; - }} + {(state) => + state.selectedOption()?.label ?? + getTextWeightLabel(getSetting("fontWeight")) + } @@ -908,117 +824,91 @@ export function CaptionsTab() {
- - }> -
-
- -
- -
- - {(segment) => ( -
-
-
-
- - - updateSegment(segment.id, { - start: parseFloat(e.target.value), - }) - } - /> -
-
- - - updateSegment(segment.id, { - end: parseFloat(e.target.value), - }) - } - /> -
-
- -
- -
-