From 1c0b0cbf4de2ec1a91aa15d4a088233d1a82f393 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 00:17:40 +0000 Subject: [PATCH 01/26] feat: add keyboard event types and word grouping algorithm to cap-project Co-authored-by: Richie McIlroy --- crates/project/src/keyboard.rs | 426 +++++++++++++++++++++++++++++++++ crates/project/src/lib.rs | 2 + 2 files changed, 428 insertions(+) create mode 100644 crates/project/src/keyboard.rs diff --git a/crates/project/src/keyboard.rs b/crates/project/src/keyboard.rs new file mode 100644 index 0000000000..88c79e2db9 --- /dev/null +++ b/crates/project/src/keyboard.rs @@ -0,0 +1,426 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::fs::File; +use std::path::Path; + +#[derive(Serialize, Deserialize, Clone, Type, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct KeyPressEvent { + pub key: String, + pub key_code: String, + pub time_ms: f64, + pub down: bool, +} + +impl PartialOrd for KeyPressEvent { + fn partial_cmp(&self, other: &Self) -> Option { + self.time_ms.partial_cmp(&other.time_ms) + } +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, Type)] +#[serde(rename_all = "camelCase")] +pub struct KeyboardEvents { + pub presses: Vec, +} + +impl KeyboardEvents { + pub fn load_from_file(path: &Path) -> Result { + let file = + File::open(path).map_err(|e| format!("Failed to open keyboard events file: {e}"))?; + serde_json::from_reader(file) + .map_err(|e| format!("Failed to parse keyboard events: {e}")) + } +} + +const MODIFIER_KEYS: &[&str] = &[ + "LShift", + "RShift", + "LControl", + "RControl", + "LAlt", + "RAlt", + "LMeta", + "RMeta", + "Meta", + "Command", +]; + +const SPECIAL_KEY_SYMBOLS: &[(&str, &str)] = &[ + ("Enter", "⏎"), + ("Return", "⏎"), + ("Tab", "⇥"), + ("Backspace", "⌫"), + ("Delete", "⌦"), + ("Escape", "⎋"), + ("Space", "␣"), + ("Up", "↑"), + ("Down", "↓"), + ("Left", "←"), + ("Right", "→"), + ("Home", "⇱"), + ("End", "⇲"), + ("PageUp", "⇞"), + ("PageDown", "⇟"), +]; + +fn is_modifier_key(key: &str) -> bool { + MODIFIER_KEYS.iter().any(|&m| key == m) +} + +fn special_key_symbol(key: &str) -> Option<&'static str> { + SPECIAL_KEY_SYMBOLS + .iter() + .find(|&&(k, _)| k == key) + .map(|&(_, symbol)| symbol) +} + +fn display_char_for_key(key: &str) -> Option { + if key.len() == 1 { + return Some(key.to_string()); + } + + if let Some(symbol) = special_key_symbol(key) { + return Some(symbol.to_string()); + } + + if is_modifier_key(key) { + return None; + } + + None +} + +fn modifier_prefix(active_modifiers: &[String]) -> String { + let mut parts = Vec::new(); + + let has = |names: &[&str]| active_modifiers.iter().any(|m| names.contains(&m.as_str())); + + if has(&["LMeta", "RMeta", "Meta", "Command"]) { + parts.push("⌘"); + } + if has(&["LControl", "RControl"]) { + parts.push("⌃"); + } + if has(&["LAlt", "RAlt"]) { + parts.push("⌥"); + } + if has(&["LShift", "RShift"]) { + parts.push("⇧"); + } + + if parts.is_empty() { + String::new() + } else { + parts.join("") + } +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct KeyPressDisplay { + pub key: String, + pub time_offset: f64, +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct KeyboardTrackSegment { + pub id: String, + pub start: f64, + pub end: f64, + pub display_text: String, + #[serde(default)] + pub keys: Vec, + #[serde(default)] + pub fade_duration_override: Option, + #[serde(default)] + pub position_override: Option, + #[serde(default)] + pub color_override: Option, + #[serde(default)] + pub background_color_override: Option, + #[serde(default)] + pub font_size_override: Option, +} + +pub fn group_key_events( + events: &KeyboardEvents, + grouping_threshold_ms: f64, + linger_duration_ms: f64, + show_modifiers: bool, + show_special_keys: bool, +) -> Vec { + let mut segments: Vec = Vec::new(); + + let down_events: Vec<&KeyPressEvent> = + events.presses.iter().filter(|e| e.down).collect(); + + if down_events.is_empty() { + return segments; + } + + let active_modifiers_at = |time_ms: f64| -> Vec { + let mut active = Vec::new(); + for event in &events.presses { + if event.time_ms > time_ms { + break; + } + if is_modifier_key(&event.key) { + if event.down { + if !active.contains(&event.key) { + active.push(event.key.clone()); + } + } else { + active.retain(|k| k != &event.key); + } + } + } + active + }; + + let mut current_group_start: Option = None; + let mut current_display = String::new(); + let mut current_keys: Vec = Vec::new(); + let mut last_key_time: f64 = 0.0; + let mut segment_counter: u64 = 0; + + for event in &down_events { + let is_modifier = is_modifier_key(&event.key); + + if is_modifier && !show_modifiers { + continue; + } + + let is_special = special_key_symbol(&event.key).is_some() && event.key != "Space"; + + if is_special && !show_special_keys && !is_modifier { + continue; + } + + let should_start_new_group = current_group_start.is_none() + || (event.time_ms - last_key_time) > grouping_threshold_ms + || is_modifier; + + if should_start_new_group && current_group_start.is_some() { + let start = current_group_start.unwrap(); + segment_counter += 1; + segments.push(KeyboardTrackSegment { + id: format!("kb-{segment_counter}"), + start: start / 1000.0, + end: (last_key_time + linger_duration_ms) / 1000.0, + display_text: current_display.clone(), + keys: current_keys.clone(), + fade_duration_override: None, + position_override: None, + color_override: None, + background_color_override: None, + font_size_override: None, + }); + current_display.clear(); + current_keys.clear(); + current_group_start = None; + } + + if is_modifier { + let modifiers = active_modifiers_at(event.time_ms); + let prefix = modifier_prefix(&modifiers); + if !prefix.is_empty() { + current_group_start = Some(event.time_ms); + current_display = prefix; + current_keys.push(KeyPressDisplay { + key: event.key.clone(), + time_offset: 0.0, + }); + last_key_time = event.time_ms; + } + continue; + } + + if event.key == "Backspace" && !current_display.is_empty() { + current_display.pop(); + last_key_time = event.time_ms; + continue; + } + + let active_mods = active_modifiers_at(event.time_ms); + let has_command_mod = active_mods + .iter() + .any(|m| matches!(m.as_str(), "LMeta" | "RMeta" | "Meta" | "Command" | "LControl" | "RControl")); + + if has_command_mod && show_modifiers { + let prefix = modifier_prefix(&active_mods); + let key_display = display_char_for_key(&event.key) + .unwrap_or_else(|| event.key.clone()); + let combo = format!("{prefix}{key_display}"); + + segment_counter += 1; + segments.push(KeyboardTrackSegment { + id: format!("kb-{segment_counter}"), + start: event.time_ms / 1000.0, + end: (event.time_ms + linger_duration_ms) / 1000.0, + display_text: combo, + keys: vec![KeyPressDisplay { + key: event.key.clone(), + time_offset: 0.0, + }], + fade_duration_override: None, + position_override: None, + color_override: None, + background_color_override: None, + font_size_override: None, + }); + + current_display.clear(); + current_keys.clear(); + current_group_start = None; + last_key_time = event.time_ms; + continue; + } + + if let Some(display_char) = display_char_for_key(&event.key) { + if current_group_start.is_none() { + current_group_start = Some(event.time_ms); + } + + let offset = event.time_ms - current_group_start.unwrap(); + current_display.push_str(&display_char); + current_keys.push(KeyPressDisplay { + key: event.key.clone(), + time_offset: offset, + }); + last_key_time = event.time_ms; + } + } + + if current_group_start.is_some() && !current_display.is_empty() { + let start = current_group_start.unwrap(); + segment_counter += 1; + segments.push(KeyboardTrackSegment { + id: format!("kb-{segment_counter}"), + start: start / 1000.0, + end: (last_key_time + linger_duration_ms) / 1000.0, + display_text: current_display, + keys: current_keys, + fade_duration_override: None, + position_override: None, + color_override: None, + background_color_override: None, + font_size_override: None, + }); + } + + segments +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key_down(key: &str, time_ms: f64) -> KeyPressEvent { + KeyPressEvent { + key: key.to_string(), + key_code: key.to_string(), + time_ms, + down: true, + } + } + + fn key_up(key: &str, time_ms: f64) -> KeyPressEvent { + KeyPressEvent { + key: key.to_string(), + key_code: key.to_string(), + time_ms, + down: false, + } + } + + #[test] + fn groups_rapid_typing_into_word() { + let events = KeyboardEvents { + presses: vec![ + key_down("h", 100.0), + key_up("h", 150.0), + key_down("e", 200.0), + key_up("e", 250.0), + key_down("l", 300.0), + key_up("l", 350.0), + key_down("l", 400.0), + key_up("l", 450.0), + key_down("o", 500.0), + key_up("o", 550.0), + ], + }; + + let segments = group_key_events(&events, 300.0, 500.0, true, true); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].display_text, "hello"); + assert_eq!(segments[0].keys.len(), 5); + } + + #[test] + fn splits_on_long_pause() { + let events = KeyboardEvents { + presses: vec![ + key_down("h", 100.0), + key_up("h", 150.0), + key_down("i", 200.0), + key_up("i", 250.0), + key_down("b", 1000.0), + key_up("b", 1050.0), + key_down("y", 1100.0), + key_up("y", 1150.0), + key_down("e", 1200.0), + key_up("e", 1250.0), + ], + }; + + let segments = group_key_events(&events, 300.0, 500.0, true, true); + assert_eq!(segments.len(), 2); + assert_eq!(segments[0].display_text, "hi"); + assert_eq!(segments[1].display_text, "bye"); + } + + #[test] + fn backspace_removes_last_char() { + let events = KeyboardEvents { + presses: vec![ + key_down("h", 100.0), + key_up("h", 150.0), + key_down("e", 200.0), + key_up("e", 250.0), + key_down("Backspace", 300.0), + key_up("Backspace", 350.0), + key_down("a", 400.0), + key_up("a", 450.0), + ], + }; + + let segments = group_key_events(&events, 300.0, 500.0, true, true); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].display_text, "ha"); + } + + #[test] + fn empty_events_returns_empty() { + let events = KeyboardEvents { + presses: vec![], + }; + let segments = group_key_events(&events, 300.0, 500.0, true, true); + assert!(segments.is_empty()); + } + + #[test] + fn special_keys_show_symbols() { + let events = KeyboardEvents { + presses: vec![ + key_down("Enter", 100.0), + key_up("Enter", 150.0), + ], + }; + + let segments = group_key_events(&events, 300.0, 500.0, true, true); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].display_text, "⏎"); + } +} diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index dd239ba255..ec10fd3706 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1,9 +1,11 @@ mod configuration; pub mod cursor; +pub mod keyboard; mod meta; pub use configuration::*; pub use cursor::*; +pub use keyboard::*; pub use meta::*; use serde::{Deserialize, Serialize}; From 86640c2fac1067f333a6327f8f7567718abbce91 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 00:18:32 +0000 Subject: [PATCH 02/26] feat: add keyboard field to MultipleSegment recording metadata Co-authored-by: Richie McIlroy --- crates/project/src/meta.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index 05248b5df4..4217516ab2 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -10,7 +10,7 @@ use std::{ use tracing::{debug, info, warn}; use crate::{ - CaptionsData, CursorEvents, CursorImage, ProjectConfiguration, XY, + CaptionsData, CursorEvents, CursorImage, KeyboardEvents, ProjectConfiguration, XY, cursor::SHORT_CURSOR_SHAPE_DEBOUNCE_MS, }; @@ -361,6 +361,9 @@ pub struct MultipleSegment { #[serde(default, skip_serializing_if = "Option::is_none")] #[specta(type = Option)] pub cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[specta(type = Option)] + pub keyboard: Option, } impl MultipleSegment { @@ -395,6 +398,22 @@ impl MultipleSegment { data } + pub fn keyboard_events(&self, meta: &RecordingMeta) -> KeyboardEvents { + let Some(keyboard_path) = &self.keyboard else { + return KeyboardEvents::default(); + }; + + let full_path = meta.path(keyboard_path); + + match KeyboardEvents::load_from_file(&full_path) { + Ok(data) => data, + Err(e) => { + eprintln!("Failed to load keyboard data: {e}"); + KeyboardEvents::default() + } + } + } + pub fn latest_start_time(&self) -> Option { let mut value = self.display.start_time?; From 03019a1538a4933754e27899ece1f084d37507ee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 00:20:10 +0000 Subject: [PATCH 03/26] feat: add caption/keyboard track segments, keyboard settings, and backwards-compatible caption migration Co-authored-by: Richie McIlroy --- crates/project/src/configuration.rs | 72 +++++++++++++++++++++++++++++ crates/project/src/meta.rs | 32 +++++++++++++ 2 files changed, 104 insertions(+) diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index f2f49fbea0..2a5ab46f53 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -788,6 +788,33 @@ pub struct TimelineConfiguration { pub mask_segments: Vec, #[serde(default)] pub text_segments: Vec, + #[serde(default)] + pub caption_segments: Vec, + #[serde(default)] + pub keyboard_segments: Vec, +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CaptionTrackSegment { + pub id: String, + pub start: f64, + pub end: f64, + pub text: String, + #[serde(default)] + pub words: Vec, + #[serde(default)] + pub fade_duration_override: Option, + #[serde(default)] + pub linger_duration_override: Option, + #[serde(default)] + pub position_override: Option, + #[serde(default)] + pub color_override: Option, + #[serde(default)] + pub background_color_override: Option, + #[serde(default)] + pub font_size_override: Option, } impl TimelineConfiguration { @@ -934,6 +961,50 @@ pub struct CaptionsData { pub settings: CaptionSettings, } +#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase", default)] +pub struct KeyboardSettings { + pub enabled: bool, + pub font: String, + pub size: u32, + pub color: String, + pub background_color: String, + pub background_opacity: u32, + pub position: String, + pub font_weight: u32, + pub fade_duration: f32, + pub linger_duration: f32, + pub grouping_threshold_ms: f64, + pub show_modifiers: bool, + pub show_special_keys: bool, +} + +impl Default for KeyboardSettings { + fn default() -> Self { + Self { + enabled: false, + font: "System Sans-Serif".to_string(), + size: 28, + color: "#FFFFFF".to_string(), + background_color: "#000000".to_string(), + background_opacity: 85, + position: "above-captions".to_string(), + font_weight: 500, + fade_duration: 0.15, + linger_duration: 0.8, + grouping_threshold_ms: 300.0, + show_modifiers: true, + show_special_keys: true, + } + } +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct KeyboardData { + pub settings: KeyboardSettings, +} + #[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, Default)] pub struct ClipOffsets { #[serde(default)] @@ -1083,6 +1154,7 @@ pub struct ProjectConfiguration { pub hotkeys: HotkeysConfiguration, pub timeline: Option, pub captions: Option, + pub keyboard: Option, pub clips: Vec, pub annotations: Vec, #[serde(skip_serializing)] diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index 4217516ab2..f75fccdca1 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -168,6 +168,38 @@ impl RecordingMeta { debug!("No captions.json found"); } + if let Some(ref captions) = config.captions { + let timeline_has_captions = config + .timeline + .as_ref() + .map(|t| !t.caption_segments.is_empty()) + .unwrap_or(false); + + if !timeline_has_captions && !captions.segments.is_empty() { + let caption_track_segments: Vec = captions + .segments + .iter() + .map(|seg| crate::CaptionTrackSegment { + id: seg.id.clone(), + start: seg.start as f64, + end: seg.end as f64, + text: seg.text.clone(), + words: seg.words.clone(), + fade_duration_override: None, + linger_duration_override: None, + position_override: None, + color_override: None, + background_color_override: None, + font_size_override: None, + }) + .collect(); + + if let Some(ref mut timeline) = config.timeline { + timeline.caption_segments = caption_track_segments; + } + } + } + config } From 73b5d07eaa073c6e1c6f60d525b530718052ac96 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 00:24:58 +0000 Subject: [PATCH 04/26] feat: record keyboard presses alongside cursor in studio recording Co-authored-by: Richie McIlroy --- crates/recording/src/cursor.rs | 166 ++++++++++++++++++++++- crates/recording/src/studio_recording.rs | 24 ++++ 2 files changed, 188 insertions(+), 2 deletions(-) diff --git a/crates/recording/src/cursor.rs b/crates/recording/src/cursor.rs index 796093d035..18b4fd3053 100644 --- a/crates/recording/src/cursor.rs +++ b/crates/recording/src/cursor.rs @@ -1,6 +1,8 @@ use cap_cursor_capture::CursorCropBounds; use cap_cursor_info::CursorShape; -use cap_project::{CursorClickEvent, CursorEvents, CursorMoveEvent, XY}; +use cap_project::{ + CursorClickEvent, CursorEvents, CursorMoveEvent, KeyPressEvent, KeyboardEvents, XY, +}; use cap_timestamp::Timestamps; use futures::{FutureExt, future::Shared}; use std::{ @@ -23,11 +25,11 @@ pub type Cursors = HashMap; #[derive(Clone)] pub struct CursorActorResponse { - // pub cursor_images: HashMap>, pub cursors: Cursors, pub next_cursor_id: u32, pub moves: Vec, pub clicks: Vec, + pub keyboard_presses: Vec, } pub struct CursorActor { @@ -59,6 +61,128 @@ fn flush_cursor_data(output_path: &Path, moves: &[CursorMoveEvent], clicks: &[Cu } } +fn flush_keyboard_data(output_path: &Path, presses: &[KeyPressEvent]) { + let events = KeyboardEvents { + presses: presses.to_vec(), + }; + if let Ok(json) = serde_json::to_string_pretty(&events) + && let Err(e) = std::fs::write(output_path, json) + { + tracing::error!( + "Failed to write keyboard data to {}: {}", + output_path.display(), + e + ); + } +} + +fn keycode_to_string(key: &device_query::Keycode) -> (String, String) { + use device_query::Keycode; + let (display, code) = match key { + Keycode::Key0 => ("0", "Key0"), + Keycode::Key1 => ("1", "Key1"), + Keycode::Key2 => ("2", "Key2"), + Keycode::Key3 => ("3", "Key3"), + Keycode::Key4 => ("4", "Key4"), + Keycode::Key5 => ("5", "Key5"), + Keycode::Key6 => ("6", "Key6"), + Keycode::Key7 => ("7", "Key7"), + Keycode::Key8 => ("8", "Key8"), + Keycode::Key9 => ("9", "Key9"), + Keycode::A => ("a", "A"), + Keycode::B => ("b", "B"), + Keycode::C => ("c", "C"), + Keycode::D => ("d", "D"), + Keycode::E => ("e", "E"), + Keycode::F => ("f", "F"), + Keycode::G => ("g", "G"), + Keycode::H => ("h", "H"), + Keycode::I => ("i", "I"), + Keycode::J => ("j", "J"), + Keycode::K => ("k", "K"), + Keycode::L => ("l", "L"), + Keycode::M => ("m", "M"), + Keycode::N => ("n", "N"), + Keycode::O => ("o", "O"), + Keycode::P => ("p", "P"), + Keycode::Q => ("q", "Q"), + Keycode::R => ("r", "R"), + Keycode::S => ("s", "S"), + Keycode::T => ("t", "T"), + Keycode::U => ("u", "U"), + Keycode::V => ("v", "V"), + Keycode::W => ("w", "W"), + Keycode::X => ("x", "X"), + Keycode::Y => ("y", "Y"), + Keycode::Z => ("z", "Z"), + Keycode::F1 => ("F1", "F1"), + Keycode::F2 => ("F2", "F2"), + Keycode::F3 => ("F3", "F3"), + Keycode::F4 => ("F4", "F4"), + Keycode::F5 => ("F5", "F5"), + Keycode::F6 => ("F6", "F6"), + Keycode::F7 => ("F7", "F7"), + Keycode::F8 => ("F8", "F8"), + Keycode::F9 => ("F9", "F9"), + Keycode::F10 => ("F10", "F10"), + Keycode::F11 => ("F11", "F11"), + Keycode::F12 => ("F12", "F12"), + Keycode::Escape => ("Escape", "Escape"), + Keycode::Space => (" ", "Space"), + Keycode::LControl => ("LControl", "LControl"), + Keycode::RControl => ("RControl", "RControl"), + Keycode::LShift => ("LShift", "LShift"), + Keycode::RShift => ("RShift", "RShift"), + Keycode::LAlt => ("LAlt", "LAlt"), + Keycode::RAlt => ("RAlt", "RAlt"), + Keycode::Meta => ("Meta", "Meta"), + Keycode::Enter => ("Enter", "Enter"), + Keycode::Up => ("Up", "Up"), + Keycode::Down => ("Down", "Down"), + Keycode::Left => ("Left", "Left"), + Keycode::Right => ("Right", "Right"), + Keycode::Backspace => ("Backspace", "Backspace"), + Keycode::CapsLock => ("CapsLock", "CapsLock"), + Keycode::Tab => ("Tab", "Tab"), + Keycode::Home => ("Home", "Home"), + Keycode::End => ("End", "End"), + Keycode::PageUp => ("PageUp", "PageUp"), + Keycode::PageDown => ("PageDown", "PageDown"), + Keycode::Insert => ("Insert", "Insert"), + Keycode::Delete => ("Delete", "Delete"), + Keycode::Numpad0 => ("0", "Numpad0"), + Keycode::Numpad1 => ("1", "Numpad1"), + Keycode::Numpad2 => ("2", "Numpad2"), + Keycode::Numpad3 => ("3", "Numpad3"), + Keycode::Numpad4 => ("4", "Numpad4"), + Keycode::Numpad5 => ("5", "Numpad5"), + Keycode::Numpad6 => ("6", "Numpad6"), + Keycode::Numpad7 => ("7", "Numpad7"), + Keycode::Numpad8 => ("8", "Numpad8"), + Keycode::Numpad9 => ("9", "Numpad9"), + Keycode::NumpadSubtract => ("-", "NumpadSubtract"), + Keycode::NumpadAdd => ("+", "NumpadAdd"), + Keycode::NumpadDivide => ("/", "NumpadDivide"), + Keycode::NumpadMultiply => ("*", "NumpadMultiply"), + Keycode::Grave => ("`", "Grave"), + Keycode::Minus => ("-", "Minus"), + Keycode::Equal => ("=", "Equal"), + Keycode::LeftBracket => ("[", "LeftBracket"), + Keycode::RightBracket => ("]", "RightBracket"), + Keycode::BackSlash => ("\\", "BackSlash"), + Keycode::Semicolon => (";", "Semicolon"), + Keycode::Apostrophe => ("'", "Apostrophe"), + Keycode::Comma => (",", "Comma"), + Keycode::Dot => (".", "Dot"), + Keycode::Slash => ("/", "Slash"), + _ => { + let s = format!("{key:?}"); + return (s.clone(), s); + } + }; + (display.to_string(), code.to_string()) +} + #[tracing::instrument(name = "cursor", skip_all)] pub fn spawn_cursor_recorder( crop_bounds: CursorCropBounds, @@ -68,6 +192,7 @@ pub fn spawn_cursor_recorder( next_cursor_id: u32, start_time: Timestamps, output_path: Option, + keyboard_output_path: Option, ) -> CursorActor { use cap_utils::spawn_actor; use device_query::{DeviceQuery, DeviceState}; @@ -83,6 +208,7 @@ pub fn spawn_cursor_recorder( spawn_actor(async move { let device_state = DeviceState::new(); let mut last_mouse_state = device_state.get_mouse(); + let mut last_keys: Vec = device_state.get_keys(); let mut last_position = cap_cursor_capture::RawCursorPosition::get(); @@ -93,6 +219,7 @@ pub fn spawn_cursor_recorder( next_cursor_id, moves: vec![], clicks: vec![], + keyboard_presses: vec![], }; let mut last_flush = Instant::now(); @@ -203,10 +330,41 @@ pub fn spawn_cursor_recorder( last_mouse_state = mouse_state; + let current_keys = device_state.get_keys(); + + for key in ¤t_keys { + if !last_keys.contains(key) { + let (display, code) = keycode_to_string(key); + response.keyboard_presses.push(KeyPressEvent { + key: display, + key_code: code, + time_ms: elapsed, + down: true, + }); + } + } + + for key in &last_keys { + if !current_keys.contains(key) { + let (display, code) = keycode_to_string(key); + response.keyboard_presses.push(KeyPressEvent { + key: display, + key_code: code, + time_ms: elapsed, + down: false, + }); + } + } + + last_keys = current_keys; + if let Some(ref path) = output_path && last_flush.elapsed() >= flush_interval { flush_cursor_data(path, &response.moves, &response.clicks); + if let Some(ref kb_path) = keyboard_output_path { + flush_keyboard_data(kb_path, &response.keyboard_presses); + } last_flush = Instant::now(); } } @@ -217,6 +375,10 @@ pub fn spawn_cursor_recorder( flush_cursor_data(path, &response.moves, &response.clicks); } + if let Some(ref kb_path) = keyboard_output_path { + flush_keyboard_data(kb_path, &response.keyboard_presses); + } + let _ = tx.send(response); }); diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 4d575f6782..de526ac24a 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -100,6 +100,15 @@ impl Actor { })?, )?; + if !res.keyboard_presses.is_empty() { + std::fs::write( + &cursor.keyboard_output_path, + serde_json::to_string_pretty(&cap_project::KeyboardEvents { + presses: res.keyboard_presses, + })?, + )?; + } + (res.cursors, res.next_cursor_id) } else { (Default::default(), 0) @@ -419,6 +428,7 @@ impl Pipeline { struct CursorPipeline { output_path: PathBuf, + keyboard_output_path: PathBuf, actor: CursorActor, } @@ -790,6 +800,12 @@ async fn stop_recording( .cursor .as_ref() .map(|cursor| make_relative(&cursor.output_path)), + keyboard: s + .pipeline + .cursor + .as_ref() + .filter(|cursor| cursor.keyboard_output_path.exists()) + .map(|cursor| make_relative(&cursor.keyboard_output_path)), } }) .collect::>() @@ -1226,11 +1242,17 @@ async fn create_segment_pipeline( .ok_or(CreateSegmentPipelineError::NoBounds)?; let cursor_output_path = dir.join("cursor.json"); + let keyboard_output_path = dir.join("keyboard.json"); let incremental_output = if fragmented { Some(cursor_output_path.clone()) } else { None }; + let keyboard_incremental_output = if fragmented { + Some(keyboard_output_path.clone()) + } else { + None + }; let cursor_display = cursor_display.ok_or(CreateSegmentPipelineError::NoDisplay)?; @@ -1242,10 +1264,12 @@ async fn create_segment_pipeline( next_cursors_id, start_time, incremental_output, + keyboard_incremental_output, ); Ok::<_, CreateSegmentPipelineError>(CursorPipeline { output_path: cursor_output_path, + keyboard_output_path, actor: cursor, }) }) From 0f6dadbd5a609d0b44bdef42627992228d0e4a10 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 00:28:31 +0000 Subject: [PATCH 05/26] feat: add keyboard events to RenderSegment, SegmentMedia, and export pipelines Co-authored-by: Richie McIlroy --- apps/desktop/src-tauri/src/export.rs | 1 + crates/editor/src/editor_instance.rs | 5 +++++ crates/export/src/gif.rs | 1 + crates/export/src/mp4.rs | 1 + crates/rendering/src/lib.rs | 1 + crates/rendering/src/main.rs | 4 +++- 6 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index 0eba1e546a..3ba63d6268 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/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 4879fadc26..e305921f12 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -581,6 +581,7 @@ pub struct SegmentMedia { pub audio: Option>, pub system_audio: Option>, pub cursor: Arc, + pub keyboard: Arc, pub decoders: RecordingSegmentDecoders, } @@ -638,6 +639,7 @@ pub async fn create_segments( audio, system_audio: None, cursor, + keyboard: Arc::new(Default::default()), decoders, }]) } @@ -680,10 +682,13 @@ pub async fn create_segments( .await .map_err(|e| format!("MultipleSegments {i} / {e}"))?; + let keyboard = Arc::new(s.keyboard_events(recording_meta)); + segments.push(SegmentMedia { audio, system_audio, cursor, + keyboard, decoders, }); } diff --git a/crates/export/src/gif.rs b/crates/export/src/gif.rs index f25dc52531..54e38507a6 100644 --- a/crates/export/src/gif.rs +++ b/crates/export/src/gif.rs @@ -122,6 +122,7 @@ impl GifExportSettings { .iter() .map(|s| RenderSegment { cursor: s.cursor.clone(), + keyboard: s.keyboard.clone(), decoders: s.decoders.clone(), }) .collect(), diff --git a/crates/export/src/mp4.rs b/crates/export/src/mp4.rs index 672eb1915c..d5b93c732a 100644 --- a/crates/export/src/mp4.rs +++ b/crates/export/src/mp4.rs @@ -323,6 +323,7 @@ impl Mp4ExportSettings { .iter() .map(|s| RenderSegment { cursor: s.cursor.clone(), + keyboard: s.keyboard.clone(), decoders: s.decoders.clone(), }) .collect(), diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 5690609fec..c74753409f 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -288,6 +288,7 @@ pub enum RenderingError { pub struct RenderSegment { pub cursor: Arc, + pub keyboard: Arc, pub decoders: RecordingSegmentDecoders, } diff --git a/crates/rendering/src/main.rs b/crates/rendering/src/main.rs index efbbc39ddf..e9a85d109b 100644 --- a/crates/rendering/src/main.rs +++ b/crates/rendering/src/main.rs @@ -108,6 +108,7 @@ async fn main() -> Result<()> { vec![RenderSegment { cursor: Arc::new(Default::default()), + keyboard: Arc::new(Default::default()), decoders, }] } @@ -130,8 +131,9 @@ async fn main() -> Result<()> { })?; let cursor = Arc::new(s.cursor_events(&recording_meta)); + let keyboard = Arc::new(s.keyboard_events(&recording_meta)); - segments.push(RenderSegment { cursor, decoders }); + segments.push(RenderSegment { cursor, keyboard, decoders }); } segments } From 5b121ee86837b67a1949e9a11208336ddfb21ad5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 00:31:30 +0000 Subject: [PATCH 06/26] feat: add keyboard overlay rendering layer with fade and character buildup Co-authored-by: Richie McIlroy --- crates/rendering/src/layers/keyboard.rs | 595 ++++++++++++++++++++++++ crates/rendering/src/layers/mod.rs | 2 + crates/rendering/src/lib.rs | 23 +- 3 files changed, 619 insertions(+), 1 deletion(-) create mode 100644 crates/rendering/src/layers/keyboard.rs diff --git a/crates/rendering/src/layers/keyboard.rs b/crates/rendering/src/layers/keyboard.rs new file mode 100644 index 0000000000..8e1e38517a --- /dev/null +++ b/crates/rendering/src/layers/keyboard.rs @@ -0,0 +1,595 @@ +use bytemuck::{Pod, Zeroable}; +use cap_project::XY; +use glyphon::{ + Attrs, Buffer, Cache, Color, Family, FontSystem, Metrics, Resolution, Shaping, SwashCache, + TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, Weight, +}; +use log::warn; +use wgpu::{Device, Queue, include_wgsl, util::DeviceExt}; + +use crate::{DecodedSegmentFrames, ProjectUniforms, RenderVideoConstants, parse_color_component}; + +#[repr(C)] +#[derive(Copy, Clone, Pod, Zeroable, Debug)] +struct KeyboardBackgroundUniforms { + rect: [f32; 4], + color: [f32; 4], + radius: f32, + _padding: [f32; 3], + _padding2: [f32; 4], +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum KeyboardPosition { + AboveCaptions, + TopLeft, + TopCenter, + TopRight, + BottomLeft, + BottomCenter, + BottomRight, +} + +impl KeyboardPosition { + fn from_str(s: &str) -> Self { + match s { + "top-left" => Self::TopLeft, + "top-center" | "top" => Self::TopCenter, + "top-right" => Self::TopRight, + "bottom-left" => Self::BottomLeft, + "bottom-right" => Self::BottomRight, + "bottom-center" | "bottom" => Self::BottomCenter, + _ => Self::AboveCaptions, + } + } + + fn y_factor(&self) -> f32 { + match self { + Self::TopLeft | Self::TopCenter | Self::TopRight => 0.08, + Self::AboveCaptions => 0.75, + Self::BottomLeft | Self::BottomCenter | Self::BottomRight => 0.85, + } + } +} + +const BOUNCE_OFFSET_PIXELS: f32 = 6.0; + +pub struct KeyboardLayer { + font_system: FontSystem, + swash_cache: SwashCache, + text_atlas: TextAtlas, + text_renderer: TextRenderer, + text_buffer: Buffer, + viewport: Viewport, + background_pipeline: wgpu::RenderPipeline, + background_bind_group: wgpu::BindGroup, + background_uniform_buffer: wgpu::Buffer, + background_scissor: Option<[u32; 4]>, + output_size: (u32, u32), + has_content: bool, +} + +impl KeyboardLayer { + pub fn new(device: &Device, queue: &Queue) -> Self { + let font_system = FontSystem::new(); + let swash_cache = SwashCache::new(); + let cache = Cache::new(device); + let viewport = Viewport::new(device, &cache); + let mut text_atlas = TextAtlas::new(device, queue, &cache, wgpu::TextureFormat::Rgba8Unorm); + let text_renderer = TextRenderer::new( + &mut text_atlas, + device, + wgpu::MultisampleState::default(), + None, + ); + + let metrics = Metrics::new(28.0, 28.0 * 1.2); + let text_buffer = Buffer::new_empty(metrics); + + let background_uniforms = KeyboardBackgroundUniforms { + rect: [0.0; 4], + color: [0.0; 4], + radius: 0.0, + _padding: [0.0; 3], + _padding2: [0.0; 4], + }; + + let background_uniform_buffer = + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Keyboard Background Uniform Buffer"), + contents: bytemuck::bytes_of(&background_uniforms), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + let background_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Keyboard Background Bind Group Layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + + let background_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Keyboard Background Bind Group"), + layout: &background_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: background_uniform_buffer.as_entire_binding(), + }], + }); + + let background_shader = + device.create_shader_module(include_wgsl!("../shaders/caption_bg.wgsl")); + + let background_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Keyboard Background Pipeline Layout"), + bind_group_layouts: &[&background_bind_group_layout], + push_constant_ranges: &[], + }); + + let background_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Keyboard Background Pipeline"), + layout: Some(&background_pipeline_layout), + vertex: wgpu::VertexState { + module: &background_shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &background_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + + Self { + font_system, + swash_cache, + text_atlas, + text_renderer, + text_buffer, + viewport, + background_pipeline, + background_bind_group, + background_uniform_buffer, + background_scissor: None, + output_size: (0, 0), + has_content: false, + } + } + + pub fn prepare( + &mut self, + uniforms: &ProjectUniforms, + _segment_frames: &DecodedSegmentFrames, + output_size: XY, + constants: &RenderVideoConstants, + ) { + self.has_content = false; + self.background_scissor = None; + self.output_size = (output_size.x, output_size.y); + + let Some(keyboard_data) = &uniforms.project.keyboard else { + return; + }; + + if !keyboard_data.settings.enabled { + return; + } + + let timeline = match &uniforms.project.timeline { + Some(t) => t, + None => return, + }; + + if timeline.keyboard_segments.is_empty() { + return; + } + + let current_time = uniforms.frame_number as f64 / uniforms.frame_rate as f64; + let settings = &keyboard_data.settings; + let fade_duration = settings.fade_duration as f64; + let linger_duration = settings.linger_duration as f64; + + let active_segment = find_active_keyboard_segment( + current_time, + &timeline.keyboard_segments, + linger_duration, + fade_duration, + ); + + let Some(active) = active_segment else { + return; + }; + + let visible_text = build_visible_text( + &active.segment, + current_time, + ); + + if visible_text.is_empty() { + return; + } + + let fade_opacity = calculate_fade( + current_time, + active.segment.start, + active.segment.end, + fade_duration, + linger_duration, + ); + + if fade_opacity <= 0.0 { + return; + } + + let bounce_offset = calculate_keyboard_bounce( + current_time, + active.segment.start, + active.segment.end, + fade_duration, + ); + + let (width, height) = (output_size.x, output_size.y); + let device = &constants.device; + let queue = &constants.queue; + + let position = active + .segment + .position_override + .as_deref() + .map(KeyboardPosition::from_str) + .unwrap_or_else(|| KeyboardPosition::from_str(&settings.position)); + + let margin = width as f32 * 0.05; + + let color_hex = active + .segment + .color_override + .as_deref() + .unwrap_or(&settings.color); + let text_color = [ + parse_color_component(color_hex, 0), + parse_color_component(color_hex, 1), + parse_color_component(color_hex, 2), + ]; + + let bg_color_hex = active + .segment + .background_color_override + .as_deref() + .unwrap_or(&settings.background_color); + let background_color_rgb = [ + parse_color_component(bg_color_hex, 0), + parse_color_component(bg_color_hex, 1), + parse_color_component(bg_color_hex, 2), + ]; + + let background_alpha = ((settings.background_opacity as f32 / 100.0) * fade_opacity as f32) + .clamp(0.0, 1.0); + + let font_size_base = active + .segment + .font_size_override + .unwrap_or(settings.size) as f32; + let font_size = font_size_base * (height as f32 / 1080.0); + let metrics = Metrics::new(font_size, font_size * 1.2); + + let mut updated_buffer = Buffer::new(&mut self.font_system, metrics); + let wrap_width = (width as f32 - margin * 2.0).max(font_size); + updated_buffer.set_size(&mut self.font_system, Some(wrap_width), None); + + let font_family = match settings.font.as_str() { + "System Serif" => Family::Serif, + "System Monospace" => Family::Monospace, + _ => Family::SansSerif, + }; + + let weight = if settings.font_weight >= 700 { + Weight::BOLD + } else if settings.font_weight >= 500 { + Weight::MEDIUM + } else { + Weight::NORMAL + }; + + let text_alpha = (fade_opacity as f32).clamp(0.0, 1.0); + let color = Color::rgba( + (text_color[0] * 255.0) as u8, + (text_color[1] * 255.0) as u8, + (text_color[2] * 255.0) as u8, + (text_alpha * 255.0) as u8, + ); + + let attrs = Attrs::new().family(font_family).weight(weight).color(color); + updated_buffer.set_text( + &mut self.font_system, + &visible_text, + &attrs, + Shaping::Advanced, + ); + + let mut layout_width: f32 = 0.0; + let mut layout_height: f32 = 0.0; + for run in glyphon::cosmic_text::LayoutRunIter::new(&updated_buffer) { + layout_width = layout_width.max(run.line_w); + layout_height = layout_height.max(run.line_top + run.line_height); + } + + if layout_height == 0.0 { + layout_height = font_size * 1.2; + layout_width = layout_width.max(font_size); + } + + let available_width = (width as f32 - margin * 2.0).max(1.0); + let padding = font_size * 0.45; + let corner_radius = font_size * 0.5; + let text_width = layout_width.min(available_width); + let text_height = layout_height; + let box_width = (text_width + padding * 2.0).min(available_width).max(1.0); + let box_height = (text_height + padding * 2.0).min(height as f32).max(1.0); + + let background_left = ((width as f32 - box_width) / 2.0).max(0.0); + + let center_y = height as f32 * position.y_factor(); + let base_background_top = + (center_y - box_height / 2.0).clamp(0.0, (height as f32 - box_height).max(0.0)); + let background_top = (base_background_top + bounce_offset as f32) + .clamp(0.0, (height as f32 - box_height).max(0.0)); + + let text_left = background_left + padding; + let text_top = background_top + padding; + + let bounds = TextBounds { + left: (text_left - 2.0).floor() as i32, + top: (text_top - 2.0).floor() as i32, + right: (text_left + text_width + 2.0).ceil() as i32, + bottom: (text_top + text_height + 2.0).ceil() as i32, + }; + + self.text_buffer = updated_buffer; + self.viewport.update(queue, Resolution { width, height }); + + let text_areas = vec![TextArea { + buffer: &self.text_buffer, + left: text_left, + top: text_top, + scale: 1.0, + bounds, + default_color: color, + custom_glyphs: &[], + }]; + + match self.text_renderer.prepare( + device, + queue, + &mut self.font_system, + &mut self.text_atlas, + &self.viewport, + text_areas, + &mut self.swash_cache, + ) { + Ok(_) => {} + Err(e) => warn!("Error preparing keyboard text: {e:?}"), + } + + let rect = KeyboardBackgroundUniforms { + rect: [ + background_left.max(0.0), + background_top.max(0.0), + box_width, + box_height, + ], + color: [ + background_color_rgb[0], + background_color_rgb[1], + background_color_rgb[2], + background_alpha, + ], + radius: corner_radius.min(box_width / 2.0).min(box_height / 2.0), + _padding: [0.0; 3], + _padding2: [0.0; 4], + }; + + queue.write_buffer( + &self.background_uniform_buffer, + 0, + bytemuck::bytes_of(&rect), + ); + + let scissor_padding = 4.0; + let scissor_x = (background_left - scissor_padding).max(0.0).floor() as u32; + let scissor_y = (background_top - scissor_padding).max(0.0).floor() as u32; + let max_width = width.saturating_sub(scissor_x); + let max_height = height.saturating_sub(scissor_y); + + if max_width == 0 || max_height == 0 { + self.has_content = false; + return; + } + + let scissor_width = (box_width + scissor_padding * 2.0) + .ceil() + .max(1.0) + .min(max_width as f32) as u32; + let scissor_height = (box_height + scissor_padding * 2.0) + .ceil() + .max(1.0) + .min(max_height as f32) as u32; + + if scissor_width == 0 || scissor_height == 0 { + self.has_content = false; + return; + } + + self.background_scissor = Some([scissor_x, scissor_y, scissor_width, scissor_height]); + self.has_content = true; + } + + pub fn has_content(&self) -> bool { + self.has_content + } + + pub fn render<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) { + if !self.has_content { + return; + } + + if let Some([x, y, width, height]) = self.background_scissor { + pass.set_scissor_rect(x, y, width, height); + pass.set_pipeline(&self.background_pipeline); + pass.set_bind_group(0, &self.background_bind_group, &[]); + pass.draw(0..6, 0..1); + pass.set_scissor_rect(x, y, width, height); + } else if self.output_size.0 > 0 && self.output_size.1 > 0 { + pass.set_scissor_rect(0, 0, self.output_size.0, self.output_size.1); + } + + match self + .text_renderer + .render(&self.text_atlas, &self.viewport, pass) + { + Ok(_) => {} + Err(e) => warn!("Error rendering keyboard text: {e:?}"), + } + + if self.output_size.0 > 0 && self.output_size.1 > 0 { + pass.set_scissor_rect(0, 0, self.output_size.0, self.output_size.1); + } + } +} + +struct ActiveKeyboardSegment<'a> { + segment: &'a cap_project::KeyboardTrackSegment, +} + +fn find_active_keyboard_segment<'a>( + time: f64, + segments: &'a [cap_project::KeyboardTrackSegment], + linger_duration: f64, + fade_duration: f64, +) -> Option> { + let extended_end = linger_duration + fade_duration; + + for segment in segments { + if time >= segment.start && time < segment.end { + return Some(ActiveKeyboardSegment { segment }); + } + } + + for segment in segments { + if time >= segment.end && time < segment.end + extended_end { + return Some(ActiveKeyboardSegment { segment }); + } + } + + None +} + +fn build_visible_text( + segment: &cap_project::KeyboardTrackSegment, + current_time: f64, +) -> String { + if segment.keys.is_empty() { + return segment.display_text.clone(); + } + + let time_offset_from_start = (current_time - segment.start) * 1000.0; + + let mut visible = String::new(); + let chars: Vec = segment.display_text.chars().collect(); + + for (i, key) in segment.keys.iter().enumerate() { + if time_offset_from_start >= key.time_offset { + if i < chars.len() { + visible.push(chars[i]); + } + } + } + + if visible.is_empty() && !segment.display_text.is_empty() { + visible.push(chars[0]); + } + + visible +} + +fn calculate_fade( + current_time: f64, + start: f64, + end: f64, + fade_duration: f64, + linger_duration: f64, +) -> f32 { + if fade_duration <= 0.0 { + if current_time >= start && current_time < end + linger_duration { + return 1.0; + } + return 0.0; + } + + let time_from_start = current_time - start; + let time_to_end = end - current_time; + + let fade_in = (time_from_start / fade_duration).min(1.0) as f32; + + let effective_time_to_end = time_to_end + linger_duration; + let fade_out = if effective_time_to_end > linger_duration { + 1.0 + } else if effective_time_to_end > 0.0 { + (effective_time_to_end / fade_duration).min(1.0) as f32 + } else { + 0.0 + }; + + fade_in.min(fade_out).max(0.0) +} + +fn calculate_keyboard_bounce( + current_time: f64, + start: f64, + end: f64, + fade_duration: f64, +) -> f64 { + if fade_duration <= 0.0 { + return 0.0; + } + + let time_from_start = current_time - start; + let time_to_end = end - current_time; + + let fade_in_progress = (time_from_start / fade_duration).clamp(0.0, 1.0); + let fade_out_progress = (time_to_end / fade_duration).clamp(0.0, 1.0); + + if fade_in_progress < 1.0 { + let ease = 1.0 - fade_in_progress; + -(ease * ease) * BOUNCE_OFFSET_PIXELS as f64 + } else if fade_out_progress < 1.0 { + let ease = 1.0 - fade_out_progress; + (ease * ease) * BOUNCE_OFFSET_PIXELS as f64 + } else { + 0.0 + } +} diff --git a/crates/rendering/src/layers/mod.rs b/crates/rendering/src/layers/mod.rs index 536fbb3bf6..7e727b27dc 100644 --- a/crates/rendering/src/layers/mod.rs +++ b/crates/rendering/src/layers/mod.rs @@ -4,6 +4,7 @@ mod camera; mod captions; mod cursor; mod display; +mod keyboard; mod mask; mod text; @@ -13,5 +14,6 @@ pub use camera::*; pub use captions::*; pub use cursor::*; pub use display::*; +pub use keyboard::*; pub use mask::*; pub use text::*; diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index c74753409f..2a72587cde 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -12,7 +12,7 @@ use futures::FutureExt; use futures::future::OptionFuture; use layers::{ Background, BackgroundLayer, BlurLayer, CameraLayer, CaptionsLayer, CursorLayer, DisplayLayer, - MaskLayer, TextLayer, + KeyboardLayer, MaskLayer, TextLayer, }; use specta::Type; use spring_mass_damper::SpringMassDamperSimulationConfig; @@ -2414,6 +2414,7 @@ pub struct RendererLayers { mask: MaskLayer, text: TextLayer, captions: CaptionsLayer, + keyboard: KeyboardLayer, } impl RendererLayers { @@ -2436,6 +2437,7 @@ impl RendererLayers { mask: MaskLayer::new(device), text: TextLayer::new(device, queue), captions: CaptionsLayer::new(device, queue), + keyboard: KeyboardLayer::new(device, queue), } } @@ -2537,6 +2539,13 @@ impl RendererLayers { constants, ); + self.keyboard.prepare( + uniforms, + segment_frames, + XY::new(uniforms.output_size.0, uniforms.output_size.1), + constants, + ); + Ok(()) } @@ -2618,6 +2627,13 @@ impl RendererLayers { constants, ); + self.keyboard.prepare( + uniforms, + segment_frames, + XY::new(uniforms.output_size.0, uniforms.output_size.1), + constants, + ); + Ok(()) } @@ -2705,6 +2721,11 @@ impl RendererLayers { self.text.render(&mut pass); } + if self.keyboard.has_content() { + let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); + self.keyboard.render(&mut pass); + } + if self.captions.has_content() { let mut pass = render_pass!(session.current_texture_view(), wgpu::LoadOp::Load); self.captions.render(&mut pass); From 17e932789e5cb7688ff03bf6b9c9b01e1701bdbb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 00:33:54 +0000 Subject: [PATCH 07/26] feat: add caption and keyboard track types to editor context and timeline Co-authored-by: Richie McIlroy --- .../src/routes/editor/Timeline/index.tsx | 6 + apps/desktop/src/routes/editor/context.ts | 136 +++++++++++++++++- 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/routes/editor/Timeline/index.tsx b/apps/desktop/src/routes/editor/Timeline/index.tsx index 8aacea955f..c028688078 100644 --- a/apps/desktop/src/routes/editor/Timeline/index.tsx +++ b/apps/desktop/src/routes/editor/Timeline/index.tsx @@ -160,6 +160,8 @@ export function Timeline() { sceneSegments: [], maskSegments: [], textSegments: [], + captionSegments: [], + keyboardSegments: [], }); resume(); } @@ -201,11 +203,15 @@ export function Timeline() { sceneSegments: [], maskSegments: [], textSegments: [], + captionSegments: [], + keyboardSegments: [], }; project.timeline.sceneSegments ??= []; project.timeline.maskSegments ??= []; project.timeline.textSegments ??= []; project.timeline.zoomSegments ??= []; + project.timeline.captionSegments ??= []; + project.timeline.keyboardSegments ??= []; }), ); } diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index 7e43e5abcf..d41ae420d5 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -86,7 +86,7 @@ export const getPreviewResolution = ( return { x: width, y: height }; }; -export type TimelineTrackType = "clip" | "text" | "zoom" | "scene" | "mask"; +export type TimelineTrackType = "clip" | "text" | "zoom" | "scene" | "mask" | "caption" | "keyboard"; export const MAX_ZOOM_IN = 3; const PROJECT_SAVE_DEBOUNCE_MS = 250; @@ -104,6 +104,33 @@ export type CornerRoundingType = "rounded" | "squircle"; type WithCornerStyle = T & { roundingType: CornerRoundingType }; +type CaptionTrackSegment = { + id: string; + start: number; + end: number; + text: string; + words?: Array<{ text: string; start: number; end: number }>; + fadeDurationOverride?: number | null; + lingerDurationOverride?: number | null; + positionOverride?: string | null; + colorOverride?: string | null; + backgroundColorOverride?: string | null; + fontSizeOverride?: number | null; +}; + +type KeyboardTrackSegment = { + id: string; + start: number; + end: number; + displayText: string; + keys?: Array<{ key: string; timeOffset: number }>; + fadeDurationOverride?: number | null; + positionOverride?: string | null; + colorOverride?: string | null; + backgroundColorOverride?: string | null; + fontSizeOverride?: number | null; +}; + type EditorTimelineConfiguration = Omit< TimelineConfiguration, "sceneSegments" | "maskSegments" @@ -111,6 +138,8 @@ type EditorTimelineConfiguration = Omit< sceneSegments?: SceneSegment[]; maskSegments: MaskSegment[]; textSegments: TextSegment[]; + captionSegments: CaptionTrackSegment[]; + keyboardSegments: KeyboardTrackSegment[]; }; export type EditorProjectConfiguration = Omit< @@ -155,6 +184,18 @@ export function normalizeProject( textSegments?: TextSegment[]; } ).textSegments ?? [], + captionSegments: + ( + config.timeline as TimelineConfiguration & { + captionSegments?: CaptionTrackSegment[]; + } + ).captionSegments ?? [], + keyboardSegments: + ( + config.timeline as TimelineConfiguration & { + keyboardSegments?: KeyboardTrackSegment[]; + } + ).keyboardSegments ?? [], } : undefined; @@ -179,6 +220,8 @@ export function serializeProjectConfiguration( ...project.timeline, maskSegments: project.timeline.maskSegments ?? [], textSegments: project.timeline.textSegments ?? [], + captionSegments: project.timeline.captionSegments ?? [], + keyboardSegments: project.timeline.keyboardSegments ?? [], } : project.timeline; @@ -417,6 +460,86 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( setEditorState("timeline", "selection", null); }); }, + splitCaptionSegment: (index: number, time: number) => { + setProject( + "timeline", + "captionSegments", + produce((segments) => { + const segment = segments?.[index]; + if (!segment) return; + + const duration = segment.end - segment.start; + const remaining = duration - time; + if (time < 0.5 || remaining < 0.5) return; + + segments.splice(index + 1, 0, { + ...segment, + id: `caption-${Date.now()}`, + start: segment.start + time, + end: segment.end, + }); + segments[index].end = segment.start + time; + }), + ); + }, + deleteCaptionSegments: (segmentIndices: number[]) => { + batch(() => { + setProject( + "timeline", + "captionSegments", + produce((segments) => { + if (!segments) return; + const sorted = [...new Set(segmentIndices)] + .filter( + (i) => Number.isInteger(i) && i >= 0 && i < segments.length, + ) + .sort((a, b) => b - a); + for (const i of sorted) segments.splice(i, 1); + }), + ); + setEditorState("timeline", "selection", null); + }); + }, + splitKeyboardSegment: (index: number, time: number) => { + setProject( + "timeline", + "keyboardSegments", + produce((segments) => { + const segment = segments?.[index]; + if (!segment) return; + + const duration = segment.end - segment.start; + const remaining = duration - time; + if (time < 0.5 || remaining < 0.5) return; + + segments.splice(index + 1, 0, { + ...segment, + id: `kb-${Date.now()}`, + start: segment.start + time, + end: segment.end, + }); + segments[index].end = segment.start + time; + }), + ); + }, + deleteKeyboardSegments: (segmentIndices: number[]) => { + batch(() => { + setProject( + "timeline", + "keyboardSegments", + produce((segments) => { + if (!segments) return; + const sorted = [...new Set(segmentIndices)] + .filter( + (i) => Number.isInteger(i) && i >= 0 && i < segments.length, + ) + .sort((a, b) => b - a); + for (const i of sorted) segments.splice(i, 1); + }), + ); + setEditorState("timeline", "selection", null); + }); + }, setClipSegmentTimescale: (index: number, timescale: number) => { setProject( produce((project) => { @@ -633,6 +756,11 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( (project.timeline?.maskSegments?.length ?? 0) > 0; const initialTextTrackEnabled = (project.timeline?.textSegments?.length ?? 0) > 0; + const initialCaptionTrackEnabled = + (project.timeline?.captionSegments?.length ?? 0) > 0 || + (project.captions?.segments?.length ?? 0) > 0; + const initialKeyboardTrackEnabled = + (project.timeline?.keyboardSegments?.length ?? 0) > 0; const [editorState, setEditorState] = createStore({ previewTime: null as number | null, @@ -652,7 +780,9 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( | { type: "clip"; indices: number[] } | { type: "scene"; indices: number[] } | { type: "mask"; indices: number[] } - | { type: "text"; indices: number[] }, + | { type: "text"; indices: number[] } + | { type: "caption"; indices: number[] } + | { type: "keyboard"; indices: number[] }, transform: { // visible seconds zoom: zoomOutLimit(), @@ -695,6 +825,8 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( scene: true, mask: initialMaskTrackEnabled, text: initialTextTrackEnabled, + caption: initialCaptionTrackEnabled, + keyboard: initialKeyboardTrackEnabled, }, hoveredTrack: null as null | TimelineTrackType, }, From 78763c23b8fa5ffc4270fcaddec1b7c63c8fc176 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 00:36:48 +0000 Subject: [PATCH 08/26] feat: add CaptionsTrack and KeyboardTrack timeline components with full drag/resize/split support Co-authored-by: Richie McIlroy --- .../routes/editor/Timeline/CaptionsTrack.tsx | 280 ++++++++++++++++++ .../routes/editor/Timeline/KeyboardTrack.tsx | 280 ++++++++++++++++++ .../src/routes/editor/Timeline/index.tsx | 72 ++++- 3 files changed, 628 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/routes/editor/Timeline/CaptionsTrack.tsx create mode 100644 apps/desktop/src/routes/editor/Timeline/KeyboardTrack.tsx diff --git a/apps/desktop/src/routes/editor/Timeline/CaptionsTrack.tsx b/apps/desktop/src/routes/editor/Timeline/CaptionsTrack.tsx new file mode 100644 index 0000000000..f950260bb0 --- /dev/null +++ b/apps/desktop/src/routes/editor/Timeline/CaptionsTrack.tsx @@ -0,0 +1,280 @@ +import { createEventListenerMap } from "@solid-primitives/event-listener"; +import { cx } from "cva"; +import { createMemo, createRoot, For } from "solid-js"; +import { produce } from "solid-js/store"; + +import { useEditorContext } from "../context"; +import { useTimelineContext } from "./context"; +import { SegmentContent, SegmentHandle, SegmentRoot, TrackRoot } from "./Track"; + +export type CaptionSegmentDragState = + | { type: "idle" } + | { type: "movePending" } + | { type: "moving" }; + +const MIN_SEGMENT_SECS = 0.5; +const MIN_SEGMENT_PIXELS = 40; + +export function CaptionsTrack(props: { + onDragStateChanged: (v: CaptionSegmentDragState) => void; + handleUpdatePlayhead: (e: MouseEvent) => void; +}) { + const { + project, + setProject, + editorState, + setEditorState, + totalDuration, + projectHistory, + projectActions, + } = useEditorContext(); + const { secsPerPixel, timelineBounds } = useTimelineContext(); + + const minDuration = () => + Math.max(MIN_SEGMENT_SECS, secsPerPixel() * MIN_SEGMENT_PIXELS); + + const captionSegments = () => project.timeline?.captionSegments ?? []; + + const neighborBounds = (index: number) => { + const segments = captionSegments(); + return { + prevEnd: segments[index - 1]?.end ?? 0, + nextStart: segments[index + 1]?.start ?? totalDuration(), + }; + }; + + function createMouseDownDrag( + segmentIndex: () => number, + setup: () => T, + update: (e: MouseEvent, value: T, initialMouseX: number) => void, + ) { + return (downEvent: MouseEvent) => { + if (editorState.timeline.interactMode !== "seek") return; + downEvent.stopPropagation(); + const initial = setup(); + let moved = false; + let initialMouseX: number | null = null; + + const resumeHistory = projectHistory.pause(); + props.onDragStateChanged({ type: "movePending" }); + + function finish(e: MouseEvent) { + resumeHistory(); + if (!moved) { + e.stopPropagation(); + const index = segmentIndex(); + const isMultiSelect = e.ctrlKey || e.metaKey; + + if (isMultiSelect) { + const currentSelection = editorState.timeline.selection; + if (currentSelection?.type === "caption") { + const base = currentSelection.indices; + const exists = base.includes(index); + const next = exists + ? base.filter((i) => i !== index) + : [...base, index]; + setEditorState( + "timeline", + "selection", + next.length > 0 + ? { type: "caption", indices: next } + : null, + ); + } else { + setEditorState("timeline", "selection", { + type: "caption", + indices: [index], + }); + } + } else { + setEditorState("timeline", "selection", { + type: "caption", + indices: [index], + }); + } + props.handleUpdatePlayhead(e); + } + props.onDragStateChanged({ type: "idle" }); + } + + function handleUpdate(event: MouseEvent) { + if (Math.abs(event.clientX - downEvent.clientX) > 2) { + if (!moved) { + moved = true; + initialMouseX = event.clientX; + props.onDragStateChanged({ type: "moving" }); + } + } + if (initialMouseX === null) return; + update(event, initial, initialMouseX); + } + + createRoot((dispose) => { + createEventListenerMap(window, { + mousemove: (e) => handleUpdate(e), + mouseup: (e) => { + handleUpdate(e); + finish(e); + dispose(); + }, + }); + }); + }; + } + + return ( + + setEditorState("timeline", "hoveredTrack", "caption") + } + onMouseLeave={() => setEditorState("timeline", "hoveredTrack", null)} + > + +
No captions
+
+ Generate captions in the sidebar +
+ + } + > + {(segment, i) => { + const isSelected = createMemo(() => { + const selection = editorState.timeline.selection; + if (!selection || selection.type !== "caption") return false; + return selection.indices.includes(i()); + }); + + const segmentWidth = () => segment.end - segment.start; + + return ( + { + e.stopPropagation(); + if (editorState.timeline.interactMode === "split") { + const rect = e.currentTarget.getBoundingClientRect(); + const fraction = (e.clientX - rect.left) / rect.width; + const splitTime = fraction * segmentWidth(); + projectActions.splitCaptionSegment(i(), splitTime); + } + }} + > + { + const bounds = neighborBounds(i()); + const start = segment.start; + const minValue = bounds.prevEnd; + const maxValue = Math.max( + minValue, + Math.min( + segment.end - minDuration(), + bounds.nextStart - minDuration(), + ), + ); + return { start, minValue, maxValue }; + }, + (e, value, initialMouseX) => { + const delta = (e.clientX - initialMouseX) * secsPerPixel(); + const next = Math.max( + value.minValue, + Math.min(value.maxValue, value.start + delta), + ); + setProject( + "timeline", + "captionSegments", + i(), + "start", + next, + ); + }, + )} + /> + { + const original = { ...segment }; + const bounds = neighborBounds(i()); + const minDelta = bounds.prevEnd - original.start; + const maxDelta = bounds.nextStart - original.end; + return { original, minDelta, maxDelta }; + }, + (e, value, initialMouseX) => { + const delta = (e.clientX - initialMouseX) * secsPerPixel(); + const lowerBound = Math.min( + value.minDelta, + value.maxDelta, + ); + const upperBound = Math.max( + value.minDelta, + value.maxDelta, + ); + const clampedDelta = Math.min( + upperBound, + Math.max(lowerBound, delta), + ); + setProject("timeline", "captionSegments", i(), { + ...value.original, + start: value.original.start + clampedDelta, + end: value.original.end + clampedDelta, + }); + }, + )} + > +
+
+ + {segment.text || "Caption"} + +
+
+
+ { + const bounds = neighborBounds(i()); + const end = segment.end; + const minValue = segment.start + minDuration(); + const maxValue = Math.max(minValue, bounds.nextStart); + return { end, minValue, maxValue }; + }, + (e, value, initialMouseX) => { + const delta = (e.clientX - initialMouseX) * secsPerPixel(); + const next = Math.max( + value.minValue, + Math.min(value.maxValue, value.end + delta), + ); + setProject( + "timeline", + "captionSegments", + i(), + "end", + next, + ); + }, + )} + /> +
+ ); + }} +
+
+ ); +} diff --git a/apps/desktop/src/routes/editor/Timeline/KeyboardTrack.tsx b/apps/desktop/src/routes/editor/Timeline/KeyboardTrack.tsx new file mode 100644 index 0000000000..0fc543dd9e --- /dev/null +++ b/apps/desktop/src/routes/editor/Timeline/KeyboardTrack.tsx @@ -0,0 +1,280 @@ +import { createEventListenerMap } from "@solid-primitives/event-listener"; +import { cx } from "cva"; +import { createMemo, createRoot, For } from "solid-js"; +import { produce } from "solid-js/store"; + +import { useEditorContext } from "../context"; +import { useTimelineContext } from "./context"; +import { SegmentContent, SegmentHandle, SegmentRoot, TrackRoot } from "./Track"; + +export type KeyboardSegmentDragState = + | { type: "idle" } + | { type: "movePending" } + | { type: "moving" }; + +const MIN_SEGMENT_SECS = 0.3; +const MIN_SEGMENT_PIXELS = 30; + +export function KeyboardTrack(props: { + onDragStateChanged: (v: KeyboardSegmentDragState) => void; + handleUpdatePlayhead: (e: MouseEvent) => void; +}) { + const { + project, + setProject, + editorState, + setEditorState, + totalDuration, + projectHistory, + projectActions, + } = useEditorContext(); + const { secsPerPixel, timelineBounds } = useTimelineContext(); + + const minDuration = () => + Math.max(MIN_SEGMENT_SECS, secsPerPixel() * MIN_SEGMENT_PIXELS); + + const keyboardSegments = () => project.timeline?.keyboardSegments ?? []; + + const neighborBounds = (index: number) => { + const segments = keyboardSegments(); + return { + prevEnd: segments[index - 1]?.end ?? 0, + nextStart: segments[index + 1]?.start ?? totalDuration(), + }; + }; + + function createMouseDownDrag( + segmentIndex: () => number, + setup: () => T, + update: (e: MouseEvent, value: T, initialMouseX: number) => void, + ) { + return (downEvent: MouseEvent) => { + if (editorState.timeline.interactMode !== "seek") return; + downEvent.stopPropagation(); + const initial = setup(); + let moved = false; + let initialMouseX: number | null = null; + + const resumeHistory = projectHistory.pause(); + props.onDragStateChanged({ type: "movePending" }); + + function finish(e: MouseEvent) { + resumeHistory(); + if (!moved) { + e.stopPropagation(); + const index = segmentIndex(); + const isMultiSelect = e.ctrlKey || e.metaKey; + + if (isMultiSelect) { + const currentSelection = editorState.timeline.selection; + if (currentSelection?.type === "keyboard") { + const base = currentSelection.indices; + const exists = base.includes(index); + const next = exists + ? base.filter((i) => i !== index) + : [...base, index]; + setEditorState( + "timeline", + "selection", + next.length > 0 + ? { type: "keyboard", indices: next } + : null, + ); + } else { + setEditorState("timeline", "selection", { + type: "keyboard", + indices: [index], + }); + } + } else { + setEditorState("timeline", "selection", { + type: "keyboard", + indices: [index], + }); + } + props.handleUpdatePlayhead(e); + } + props.onDragStateChanged({ type: "idle" }); + } + + function handleUpdate(event: MouseEvent) { + if (Math.abs(event.clientX - downEvent.clientX) > 2) { + if (!moved) { + moved = true; + initialMouseX = event.clientX; + props.onDragStateChanged({ type: "moving" }); + } + } + if (initialMouseX === null) return; + update(event, initial, initialMouseX); + } + + createRoot((dispose) => { + createEventListenerMap(window, { + mousemove: (e) => handleUpdate(e), + mouseup: (e) => { + handleUpdate(e); + finish(e); + dispose(); + }, + }); + }); + }; + } + + return ( + + setEditorState("timeline", "hoveredTrack", "keyboard") + } + onMouseLeave={() => setEditorState("timeline", "hoveredTrack", null)} + > + +
No keyboard events
+
+ Record keyboard presses or generate from recording +
+ + } + > + {(segment, i) => { + const isSelected = createMemo(() => { + const selection = editorState.timeline.selection; + if (!selection || selection.type !== "keyboard") return false; + return selection.indices.includes(i()); + }); + + const segmentWidth = () => segment.end - segment.start; + + return ( + { + e.stopPropagation(); + if (editorState.timeline.interactMode === "split") { + const rect = e.currentTarget.getBoundingClientRect(); + const fraction = (e.clientX - rect.left) / rect.width; + const splitTime = fraction * segmentWidth(); + projectActions.splitKeyboardSegment(i(), splitTime); + } + }} + > + { + const bounds = neighborBounds(i()); + const start = segment.start; + const minValue = bounds.prevEnd; + const maxValue = Math.max( + minValue, + Math.min( + segment.end - minDuration(), + bounds.nextStart - minDuration(), + ), + ); + return { start, minValue, maxValue }; + }, + (e, value, initialMouseX) => { + const delta = (e.clientX - initialMouseX) * secsPerPixel(); + const next = Math.max( + value.minValue, + Math.min(value.maxValue, value.start + delta), + ); + setProject( + "timeline", + "keyboardSegments", + i(), + "start", + next, + ); + }, + )} + /> + { + const original = { ...segment }; + const bounds = neighborBounds(i()); + const minDelta = bounds.prevEnd - original.start; + const maxDelta = bounds.nextStart - original.end; + return { original, minDelta, maxDelta }; + }, + (e, value, initialMouseX) => { + const delta = (e.clientX - initialMouseX) * secsPerPixel(); + const lowerBound = Math.min( + value.minDelta, + value.maxDelta, + ); + const upperBound = Math.max( + value.minDelta, + value.maxDelta, + ); + const clampedDelta = Math.min( + upperBound, + Math.max(lowerBound, delta), + ); + setProject("timeline", "keyboardSegments", i(), { + ...value.original, + start: value.original.start + clampedDelta, + end: value.original.end + clampedDelta, + }); + }, + )} + > +
+
+ + {segment.displayText || "⌨"} + +
+
+
+ { + const bounds = neighborBounds(i()); + const end = segment.end; + const minValue = segment.start + minDuration(); + const maxValue = Math.max(minValue, bounds.nextStart); + return { end, minValue, maxValue }; + }, + (e, value, initialMouseX) => { + const delta = (e.clientX - initialMouseX) * secsPerPixel(); + const next = Math.max( + value.minValue, + Math.min(value.maxValue, value.end + delta), + ); + setProject( + "timeline", + "keyboardSegments", + i(), + "end", + next, + ); + }, + )} + /> +
+ ); + }} +
+
+ ); +} diff --git a/apps/desktop/src/routes/editor/Timeline/index.tsx b/apps/desktop/src/routes/editor/Timeline/index.tsx index c028688078..9df302a754 100644 --- a/apps/desktop/src/routes/editor/Timeline/index.tsx +++ b/apps/desktop/src/routes/editor/Timeline/index.tsx @@ -18,8 +18,10 @@ import Tooltip from "~/components/Tooltip"; import { commands } from "~/utils/tauri"; import { FPS, type TimelineTrackType, useEditorContext } from "../context"; import { formatTime } from "../utils"; +import { type CaptionSegmentDragState, CaptionsTrack } from "./CaptionsTrack"; import { ClipTrack } from "./ClipTrack"; import { TimelineContextProvider, useTimelineContext } from "./context"; +import { type KeyboardSegmentDragState, KeyboardTrack } from "./KeyboardTrack"; import { type MaskSegmentDragState, MaskTrack } from "./MaskTrack"; import { type SceneSegmentDragState, SceneTrack } from "./SceneTrack"; import { type TextSegmentDragState, TextTrack } from "./TextTrack"; @@ -36,6 +38,8 @@ const trackIcons: Record = { mask: , zoom: , scene: , + caption: , + keyboard: , }; type TrackDefinition = { @@ -52,6 +56,18 @@ const trackDefinitions: TrackDefinition[] = [ icon: trackIcons.clip, locked: true, }, + { + type: "caption", + label: "Captions", + icon: trackIcons.caption, + locked: false, + }, + { + type: "keyboard", + label: "Keyboard", + icon: trackIcons.keyboard, + locked: false, + }, { type: "text", label: "Text", @@ -112,7 +128,11 @@ export function Timeline() { ? trackState().mask : definition.type === "text" ? trackState().text - : true, + : definition.type === "caption" + ? trackState().caption + : definition.type === "keyboard" + ? trackState().keyboard + : true, available: definition.type === "scene" ? sceneAvailable() : true, })); const sceneTrackVisible = () => trackState().scene && sceneAvailable(); @@ -120,6 +140,8 @@ export function Timeline() { 2 + (trackState().text ? 1 : 0) + (trackState().mask ? 1 : 0) + + (trackState().caption ? 1 : 0) + + (trackState().keyboard ? 1 : 0) + (sceneTrackVisible() ? 1 : 0); const trackHeight = () => (visibleTrackCount() > 2 ? "3rem" : "3.25rem"); @@ -142,6 +164,22 @@ export function Timeline() { if (!next && editorState.timeline.selection?.type === "mask") { setEditorState("timeline", "selection", null); } + return; + } + + if (type === "caption") { + setEditorState("timeline", "tracks", "caption", next); + if (!next && editorState.timeline.selection?.type === "caption") { + setEditorState("timeline", "selection", null); + } + return; + } + + if (type === "keyboard") { + setEditorState("timeline", "tracks", "keyboard", next); + if (!next && editorState.timeline.selection?.type === "keyboard") { + setEditorState("timeline", "selection", null); + } } } @@ -220,6 +258,8 @@ export function Timeline() { let sceneSegmentDragState = { type: "idle" } as SceneSegmentDragState; let maskSegmentDragState = { type: "idle" } as MaskSegmentDragState; let textSegmentDragState = { type: "idle" } as TextSegmentDragState; + let captionSegmentDragState = { type: "idle" } as CaptionSegmentDragState; + let keyboardSegmentDragState = { type: "idle" } as KeyboardSegmentDragState; let pendingZoomDelta = 0; let pendingZoomOrigin: number | null = null; @@ -278,7 +318,9 @@ export function Timeline() { zoomSegmentDragState.type !== "moving" && sceneSegmentDragState.type !== "moving" && maskSegmentDragState.type !== "moving" && - textSegmentDragState.type !== "moving" + textSegmentDragState.type !== "moving" && + captionSegmentDragState.type !== "moving" && + keyboardSegmentDragState.type !== "moving" ) { // Guard against missing bounds and clamp computed time to [0, totalDuration()] if (left == null) return; @@ -332,15 +374,17 @@ export function Timeline() { projectActions.deleteMaskSegments(selection.indices); } else if (selection.type === "text") { projectActions.deleteTextSegments(selection.indices); + } else if (selection.type === "caption") { + projectActions.deleteCaptionSegments(selection.indices); + } else if (selection.type === "keyboard") { + projectActions.deleteKeyboardSegments(selection.indices); } else if (selection.type === "clip") { - // Delete all selected clips in reverse order [...selection.indices] .sort((a, b) => b - a) .forEach((idx) => { projectActions.deleteClipSegment(idx); }); } else if (selection.type === "scene") { - // Delete all selected scenes in reverse order [...selection.indices] .sort((a, b) => b - a) .forEach((idx) => { @@ -541,6 +585,26 @@ export function Timeline() { handleUpdatePlayhead={handleUpdatePlayhead} /> + + + { + captionSegmentDragState = v; + }} + handleUpdatePlayhead={handleUpdatePlayhead} + /> + + + + + { + keyboardSegmentDragState = v; + }} + handleUpdatePlayhead={handleUpdatePlayhead} + /> + + Date: Wed, 18 Feb 2026 00:39:24 +0000 Subject: [PATCH 09/26] feat: add KeyboardTab sidebar, per-segment caption overrides, and keyboard settings store Co-authored-by: Richie McIlroy --- .../desktop/src/routes/editor/CaptionsTab.tsx | 84 ++++++ .../src/routes/editor/ConfigSidebar.tsx | 15 +- .../desktop/src/routes/editor/KeyboardTab.tsx | 267 ++++++++++++++++++ apps/desktop/src/store/keyboard.ts | 31 ++ 4 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/routes/editor/KeyboardTab.tsx create mode 100644 apps/desktop/src/store/keyboard.ts diff --git a/apps/desktop/src/routes/editor/CaptionsTab.tsx b/apps/desktop/src/routes/editor/CaptionsTab.tsx index 356bfa493b..fb758545e7 100644 --- a/apps/desktop/src/routes/editor/CaptionsTab.tsx +++ b/apps/desktop/src/routes/editor/CaptionsTab.tsx @@ -890,6 +890,90 @@ export function CaptionsTab() { + + {(() => { + const selectedIndex = () => + editorState.timeline.selection?.type === "caption" + ? editorState.timeline.selection.indices[0] + : -1; + const selectedSegment = () => + project.timeline?.captionSegments?.[selectedIndex()]; + + return ( + } + > + + {(seg) => ( +
+ + + setProject( + "timeline", + "captionSegments", + selectedIndex(), + "start", + Number.parseFloat(e.target.value), + ) + } + /> + + + + setProject( + "timeline", + "captionSegments", + selectedIndex(), + "end", + Number.parseFloat(e.target.value), + ) + } + /> + + + + setProject( + "timeline", + "captionSegments", + selectedIndex(), + "fadeDurationOverride", + v[0] / 100, + ) + } + minValue={0} + maxValue={50} + step={1} + /> + +
+ )} +
+
+ ); + })()} +
+ }>
diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 8765313716..f803ac991a 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -65,6 +65,7 @@ import IconLucideTimer from "~icons/lucide/timer"; import IconLucideType from "~icons/lucide/type"; import IconLucideWind from "~icons/lucide/wind"; import { CaptionsTab } from "./CaptionsTab"; +import { KeyboardTab } from "./KeyboardTab"; import { type CornerRoundingType, useEditorContext } from "./context"; import { evaluateMask, type MaskKind, type MaskSegment } from "./masks"; import { @@ -370,7 +371,8 @@ export function ConfigSidebar() { | "audio" | "cursor" | "hotkeys" - | "captions", + | "captions" + | "keyboard", }); let scrollRef!: HTMLDivElement; @@ -403,7 +405,10 @@ export function ConfigSidebar() { id: "captions" as const, icon: IconCapMessageBubble, }, - // { id: "hotkeys" as const, icon: IconCapHotkeys }, + { + id: "keyboard" as const, + icon: IconLucideKeyboard, + }, ].filter(Boolean)} > {(item) => ( @@ -819,6 +824,12 @@ export function ConfigSidebar() { > + + +
( + key: K, + ): NonNullable => { + const settings = project?.keyboard?.settings; + if (settings && key in settings) { + return (settings as Record)[key as string] as NonNullable< + KeyboardSettings[K] + >; + } + return defaultKeyboardSettings[key] as NonNullable; + }; + + const updateSetting = ( + key: K, + value: KeyboardSettings[K], + ) => { + if (!project?.keyboard) { + setProject("keyboard", { + settings: { ...defaultKeyboardSettings, [key]: value }, + }); + return; + } + setProject("keyboard", "settings", key as string, value); + }; + + const hasKeyboardSegments = createMemo( + () => (project.timeline?.keyboardSegments?.length ?? 0) > 0, + ); + + const selectedSegment = () => { + const selection = editorState.timeline.selection; + if (selection?.type !== "keyboard" || selection.indices.length !== 1) + return null; + return project.timeline?.keyboardSegments?.[selection.indices[0]] ?? null; + }; + + const selectedIndex = () => { + const selection = editorState.timeline.selection; + if (selection?.type !== "keyboard" || selection.indices.length !== 1) + return -1; + return selection.indices[0]; + }; + + return ( + }> +
+ + updateSetting("enabled", checked)} + /> + + +
+ }> +
+ + updateSetting("size", v[0])} + minValue={12} + maxValue={72} + step={1} + /> + + + + updateSetting("fontWeight", v[0])} + minValue={100} + maxValue={900} + step={100} + /> + + + + updateSetting("backgroundOpacity", v[0])} + minValue={0} + maxValue={100} + step={1} + /> + +
+
+ + }> +
+ + updateSetting("fadeDuration", v[0] / 100)} + minValue={0} + maxValue={50} + step={1} + /> + + {(getSetting("fadeDuration") * 1000).toFixed(0)}ms + + + + + updateSetting("lingerDuration", v[0] / 100)} + minValue={0} + maxValue={300} + step={5} + /> + + {(getSetting("lingerDuration") * 1000).toFixed(0)}ms + + + + + updateSetting("groupingThresholdMs", v[0])} + minValue={50} + maxValue={1000} + step={10} + /> + + {getSetting("groupingThresholdMs").toFixed(0)}ms + + + + + + updateSetting("showModifiers", checked) + } + /> + + + + + updateSetting("showSpecialKeys", checked) + } + /> + +
+
+ + + {(seg) => ( + } + > +
+ + + setProject( + "timeline", + "keyboardSegments", + selectedIndex(), + "start", + Number.parseFloat(e.target.value), + ) + } + /> + + + + setProject( + "timeline", + "keyboardSegments", + selectedIndex(), + "end", + Number.parseFloat(e.target.value), + ) + } + /> + + + + setProject( + "timeline", + "keyboardSegments", + selectedIndex(), + "displayText", + e.target.value, + ) + } + /> + + + + setProject( + "timeline", + "keyboardSegments", + selectedIndex(), + "fadeDurationOverride", + v[0] / 100, + ) + } + minValue={0} + maxValue={50} + step={1} + /> + +
+
+ )} +
+ + +
+

No keyboard events recorded.

+

+ Keyboard presses are automatically recorded during studio mode + recording. +

+
+
+
+
+
+ ); +} diff --git a/apps/desktop/src/store/keyboard.ts b/apps/desktop/src/store/keyboard.ts new file mode 100644 index 0000000000..24310d2a1b --- /dev/null +++ b/apps/desktop/src/store/keyboard.ts @@ -0,0 +1,31 @@ +export type KeyboardSettings = { + enabled: boolean; + font: string; + size: number; + color: string; + backgroundColor: string; + backgroundOpacity: number; + position: string; + fontWeight: number; + fadeDuration: number; + lingerDuration: number; + groupingThresholdMs: number; + showModifiers: boolean; + showSpecialKeys: boolean; +}; + +export const defaultKeyboardSettings: KeyboardSettings = { + enabled: false, + font: "System Sans-Serif", + size: 28, + color: "#FFFFFF", + backgroundColor: "#000000", + backgroundOpacity: 85, + position: "above-captions", + fontWeight: 500, + fadeDuration: 0.15, + lingerDuration: 0.8, + groupingThresholdMs: 300, + showModifiers: true, + showSpecialKeys: true, +}; From 5a43fb1838a74ca5ec73dfa66ac1f86a4273e571 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 00:40:50 +0000 Subject: [PATCH 10/26] feat: add generate_keyboard_segments Tauri command for keyboard track generation Co-authored-by: Richie McIlroy --- apps/desktop/src-tauri/src/lib.rs | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 38fed85f50..c4edde30ce 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1971,6 +1971,49 @@ 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] @@ -2960,6 +3003,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, From 83348f2683f3d8cf1a2a6fb43354813a8b4076ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 00:41:48 +0000 Subject: [PATCH 11/26] chore: format Rust code with cargo fmt Co-authored-by: Richie McIlroy --- apps/desktop/src-tauri/src/lib.rs | 8 ++++-- crates/project/src/keyboard.rs | 38 ++++++++----------------- crates/rendering/src/layers/keyboard.rs | 26 ++++------------- crates/rendering/src/main.rs | 6 +++- 4 files changed, 28 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index c4edde30ce..6ee79cd62a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1999,9 +1999,11 @@ async fn generate_keyboard_segments( 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)); + 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, diff --git a/crates/project/src/keyboard.rs b/crates/project/src/keyboard.rs index 88c79e2db9..bfa8125131 100644 --- a/crates/project/src/keyboard.rs +++ b/crates/project/src/keyboard.rs @@ -28,22 +28,12 @@ impl KeyboardEvents { pub fn load_from_file(path: &Path) -> Result { let file = File::open(path).map_err(|e| format!("Failed to open keyboard events file: {e}"))?; - serde_json::from_reader(file) - .map_err(|e| format!("Failed to parse keyboard events: {e}")) + serde_json::from_reader(file).map_err(|e| format!("Failed to parse keyboard events: {e}")) } } const MODIFIER_KEYS: &[&str] = &[ - "LShift", - "RShift", - "LControl", - "RControl", - "LAlt", - "RAlt", - "LMeta", - "RMeta", - "Meta", - "Command", + "LShift", "RShift", "LControl", "RControl", "LAlt", "RAlt", "LMeta", "RMeta", "Meta", "Command", ]; const SPECIAL_KEY_SYMBOLS: &[(&str, &str)] = &[ @@ -153,8 +143,7 @@ pub fn group_key_events( ) -> Vec { let mut segments: Vec = Vec::new(); - let down_events: Vec<&KeyPressEvent> = - events.presses.iter().filter(|e| e.down).collect(); + let down_events: Vec<&KeyPressEvent> = events.presses.iter().filter(|e| e.down).collect(); if down_events.is_empty() { return segments; @@ -244,14 +233,16 @@ pub fn group_key_events( } let active_mods = active_modifiers_at(event.time_ms); - let has_command_mod = active_mods - .iter() - .any(|m| matches!(m.as_str(), "LMeta" | "RMeta" | "Meta" | "Command" | "LControl" | "RControl")); + let has_command_mod = active_mods.iter().any(|m| { + matches!( + m.as_str(), + "LMeta" | "RMeta" | "Meta" | "Command" | "LControl" | "RControl" + ) + }); if has_command_mod && show_modifiers { let prefix = modifier_prefix(&active_mods); - let key_display = display_char_for_key(&event.key) - .unwrap_or_else(|| event.key.clone()); + let key_display = display_char_for_key(&event.key).unwrap_or_else(|| event.key.clone()); let combo = format!("{prefix}{key_display}"); segment_counter += 1; @@ -403,9 +394,7 @@ mod tests { #[test] fn empty_events_returns_empty() { - let events = KeyboardEvents { - presses: vec![], - }; + let events = KeyboardEvents { presses: vec![] }; let segments = group_key_events(&events, 300.0, 500.0, true, true); assert!(segments.is_empty()); } @@ -413,10 +402,7 @@ mod tests { #[test] fn special_keys_show_symbols() { let events = KeyboardEvents { - presses: vec![ - key_down("Enter", 100.0), - key_up("Enter", 150.0), - ], + presses: vec![key_down("Enter", 100.0), key_up("Enter", 150.0)], }; let segments = group_key_events(&events, 300.0, 500.0, true, true); diff --git a/crates/rendering/src/layers/keyboard.rs b/crates/rendering/src/layers/keyboard.rs index 8e1e38517a..81ca2ec940 100644 --- a/crates/rendering/src/layers/keyboard.rs +++ b/crates/rendering/src/layers/keyboard.rs @@ -224,10 +224,7 @@ impl KeyboardLayer { return; }; - let visible_text = build_visible_text( - &active.segment, - current_time, - ); + let visible_text = build_visible_text(&active.segment, current_time); if visible_text.is_empty() { return; @@ -287,13 +284,10 @@ impl KeyboardLayer { parse_color_component(bg_color_hex, 2), ]; - let background_alpha = ((settings.background_opacity as f32 / 100.0) * fade_opacity as f32) - .clamp(0.0, 1.0); + let background_alpha = + ((settings.background_opacity as f32 / 100.0) * fade_opacity as f32).clamp(0.0, 1.0); - let font_size_base = active - .segment - .font_size_override - .unwrap_or(settings.size) as f32; + let font_size_base = active.segment.font_size_override.unwrap_or(settings.size) as f32; let font_size = font_size_base * (height as f32 / 1080.0); let metrics = Metrics::new(font_size, font_size * 1.2); @@ -508,10 +502,7 @@ fn find_active_keyboard_segment<'a>( None } -fn build_visible_text( - segment: &cap_project::KeyboardTrackSegment, - current_time: f64, -) -> String { +fn build_visible_text(segment: &cap_project::KeyboardTrackSegment, current_time: f64) -> String { if segment.keys.is_empty() { return segment.display_text.clone(); } @@ -567,12 +558,7 @@ fn calculate_fade( fade_in.min(fade_out).max(0.0) } -fn calculate_keyboard_bounce( - current_time: f64, - start: f64, - end: f64, - fade_duration: f64, -) -> f64 { +fn calculate_keyboard_bounce(current_time: f64, start: f64, end: f64, fade_duration: f64) -> f64 { if fade_duration <= 0.0 { return 0.0; } diff --git a/crates/rendering/src/main.rs b/crates/rendering/src/main.rs index e9a85d109b..b4d0d9bdcc 100644 --- a/crates/rendering/src/main.rs +++ b/crates/rendering/src/main.rs @@ -133,7 +133,11 @@ async fn main() -> Result<()> { let cursor = Arc::new(s.cursor_events(&recording_meta)); let keyboard = Arc::new(s.keyboard_events(&recording_meta)); - segments.push(RenderSegment { cursor, keyboard, decoders }); + segments.push(RenderSegment { + cursor, + keyboard, + decoders, + }); } segments } From 783d88768b82c2a2ffb6bc281192e1e04ce20d08 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Feb 2026 00:44:38 +0000 Subject: [PATCH 12/26] fix: adjust caption and keyboard segments when clip timescale changes Co-authored-by: Richie McIlroy --- apps/desktop/src/routes/editor/context.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/desktop/src/routes/editor/context.ts b/apps/desktop/src/routes/editor/context.ts index d41ae420d5..f4c263b0c9 100644 --- a/apps/desktop/src/routes/editor/context.ts +++ b/apps/desktop/src/routes/editor/context.ts @@ -583,6 +583,16 @@ export const [EditorContextProvider, useEditorContext] = createContextProvider( textSegment.end += diff(textSegment.end); } + for (const captionSegment of timeline.captionSegments) { + captionSegment.start += diff(captionSegment.start); + captionSegment.end += diff(captionSegment.end); + } + + for (const keyboardSegment of timeline.keyboardSegments) { + keyboardSegment.start += diff(keyboardSegment.start); + keyboardSegment.end += diff(keyboardSegment.end); + } + segment.timescale = timescale; }), ); From b59adc86075846c6af9405af01de33b7941eb5bf Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:23:24 +0000 Subject: [PATCH 13/26] fix(recording): update Meta keycode to LMeta --- crates/recording/src/cursor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/recording/src/cursor.rs b/crates/recording/src/cursor.rs index 18b4fd3053..a0d3972c85 100644 --- a/crates/recording/src/cursor.rs +++ b/crates/recording/src/cursor.rs @@ -135,7 +135,7 @@ fn keycode_to_string(key: &device_query::Keycode) -> (String, String) { Keycode::RShift => ("RShift", "RShift"), Keycode::LAlt => ("LAlt", "LAlt"), Keycode::RAlt => ("RAlt", "RAlt"), - Keycode::Meta => ("Meta", "Meta"), + Keycode::LMeta => ("Meta", "Meta"), Keycode::Enter => ("Enter", "Enter"), Keycode::Up => ("Up", "Up"), Keycode::Down => ("Down", "Down"), From 5d85c7ac71aa02317b565488d0841f788957e1c6 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:23:27 +0000 Subject: [PATCH 14/26] feat(project): add keyboard path fallback resolution for segments --- crates/project/src/meta.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index f75fccdca1..c3eea46fca 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -431,11 +431,18 @@ impl MultipleSegment { } pub fn keyboard_events(&self, meta: &RecordingMeta) -> KeyboardEvents { - let Some(keyboard_path) = &self.keyboard else { + let keyboard_path = self.keyboard.clone().or_else(|| { + let display_dir = self.display.path.parent()?; + let fallback = display_dir.join("keyboard.json"); + let full = meta.path(&fallback); + full.exists().then_some(fallback) + }); + + let Some(keyboard_path) = keyboard_path else { return KeyboardEvents::default(); }; - let full_path = meta.path(keyboard_path); + let full_path = meta.path(&keyboard_path); match KeyboardEvents::load_from_file(&full_path) { Ok(data) => data, From 6349804e79016ce67564d4ca7f960600a71f4f81 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:23:30 +0000 Subject: [PATCH 15/26] feat(project): add keyboard and caption segment fields to structs --- apps/desktop/src-tauri/src/import.rs | 2 ++ apps/desktop/src-tauri/src/recording.rs | 2 ++ crates/editor/src/editor_instance.rs | 2 ++ crates/recording/src/recovery.rs | 3 +++ 4 files changed, 9 insertions(+) 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/recording.rs b/apps/desktop/src-tauri/src/recording.rs index d65bce27fc..247e58e4d8 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -2370,6 +2370,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 diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 895a6bee2f..c6788679d2 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -200,6 +200,8 @@ impl EditorInstance { scene_segments: Vec::new(), mask_segments: Vec::new(), text_segments: Vec::new(), + caption_segments: Vec::new(), + keyboard_segments: Vec::new(), }); if let Err(e) = project.write(&recording_meta.project_path) { diff --git a/crates/recording/src/recovery.rs b/crates/recording/src/recovery.rs index 08371848c0..fa99ba3cea 100644 --- a/crates/recording/src/recovery.rs +++ b/crates/recording/src/recovery.rs @@ -884,6 +884,7 @@ impl RecoveryManager { } else { None }, + keyboard: None, } }) .collect(); @@ -952,6 +953,8 @@ impl RecoveryManager { scene_segments: Vec::new(), mask_segments: Vec::new(), text_segments: Vec::new(), + caption_segments: Vec::new(), + keyboard_segments: Vec::new(), }); config From 9dedbbf999b78c2e56d10bcfe21263b90f60cb7a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:23:33 +0000 Subject: [PATCH 16/26] feat(rendering): add recording_time field to ProjectUniforms --- crates/rendering/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index f8d7fd2495..5a9fd26858 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -1071,6 +1071,7 @@ pub struct ProjectUniforms { pub cursor_size: f32, pub frame_rate: u32, pub frame_number: u32, + pub recording_time: f64, display: CompositeVideoFrameUniforms, camera: Option, camera_only: Option, @@ -2187,6 +2188,7 @@ impl ProjectUniforms { interpolated_cursor, frame_rate: fps, frame_number, + recording_time: current_recording_time as f64, prev_cursor: prev_interpolated_cursor, display_parent_motion_px: display_motion_parent, motion_blur_amount: cursor_motion_blur, From 6f5a42c4c0cf4b7602651dc823409c30a1ea88d9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:23:35 +0000 Subject: [PATCH 17/26] refactor(rendering): simplify caption layer to use timeline segments --- crates/rendering/src/layers/captions.rs | 312 +++++++++--------------- 1 file changed, 109 insertions(+), 203 deletions(-) diff --git a/crates/rendering/src/layers/captions.rs b/crates/rendering/src/layers/captions.rs index 01c9b30e53..88df848f86 100644 --- a/crates/rendering/src/layers/captions.rs +++ b/crates/rendering/src/layers/captions.rs @@ -6,9 +6,9 @@ use glyphon::{ TextArea, TextAtlas, TextBounds, TextRenderer, Viewport, Weight, }; use log::warn; -use wgpu::{Device, Queue, include_wgsl, util::DeviceExt}; +use wgpu::{include_wgsl, util::DeviceExt, Device, Queue}; -use crate::{DecodedSegmentFrames, ProjectUniforms, RenderVideoConstants, parse_color_component}; +use crate::{parse_color_component, DecodedSegmentFrames, ProjectUniforms, RenderVideoConstants}; #[derive(Debug, Clone)] pub struct CaptionWord { @@ -17,15 +17,6 @@ pub struct CaptionWord { pub end: f32, } -#[derive(Debug, Clone)] -pub struct CaptionSegment { - pub _id: String, - pub start: f32, - pub end: f32, - pub text: String, - pub words: Vec, -} - #[repr(C)] #[derive(Copy, Clone, Pod, Zeroable, Debug)] pub struct CaptionSettings { @@ -107,7 +98,6 @@ impl CaptionPosition { const BASE_TEXT_OPACITY: f32 = 0.8; const MAX_WORDS_PER_LINE: usize = 6; const BOUNCE_OFFSET_PIXELS: f32 = 8.0; -const CLOSE_TRANSITION_BOUNCE_DURATION: f32 = 0.12; fn wrap_text_by_words(text: &str, max_words: usize) -> String { let words: Vec<&str> = text.split_whitespace().collect(); @@ -129,50 +119,6 @@ fn wrap_text_by_words(text: &str, max_words: usize) -> String { result } -fn calculate_bounce_offset( - _fade_opacity: f32, - time_from_start: f32, - time_to_end: f32, - fade_duration: f32, - skip_fade_in: bool, - skip_fade_out: bool, -) -> f32 { - if skip_fade_in && time_from_start < CLOSE_TRANSITION_BOUNCE_DURATION { - let progress = (time_from_start / CLOSE_TRANSITION_BOUNCE_DURATION).clamp(0.0, 1.0); - let ease = 1.0 - progress; - let bounce = ease * ease; - return -bounce * BOUNCE_OFFSET_PIXELS; - } - - if skip_fade_out && time_to_end < 0.0 { - let time_past_end = -time_to_end; - if time_past_end < CLOSE_TRANSITION_BOUNCE_DURATION { - let progress = (time_past_end / CLOSE_TRANSITION_BOUNCE_DURATION).clamp(0.0, 1.0); - let bounce = progress * progress; - return bounce * BOUNCE_OFFSET_PIXELS; - } - } - - if fade_duration <= 0.0 { - return 0.0; - } - - let fade_in_progress = (time_from_start / fade_duration).clamp(0.0, 1.0); - let fade_out_progress = (time_to_end / fade_duration).clamp(0.0, 1.0); - - if fade_in_progress < 1.0 && !skip_fade_in { - let ease = 1.0 - fade_in_progress; - let bounce = ease * ease; - -bounce * BOUNCE_OFFSET_PIXELS - } else if fade_out_progress < 1.0 && !skip_fade_out { - let ease = 1.0 - fade_out_progress; - let bounce = ease * ease; - bounce * BOUNCE_OFFSET_PIXELS - } else { - 0.0 - } -} - fn ease_out_cubic(t: f32) -> f32 { let t = t.clamp(0.0, 1.0); 1.0 - (1.0 - t).powi(3) @@ -385,43 +331,6 @@ impl CaptionsLayer { self.current_segment_end = end; } - fn calculate_fade_opacity( - &self, - current_time: f32, - fade_duration: f32, - linger_duration: f32, - skip_fade_in: bool, - skip_fade_out: bool, - ) -> (f32, f32, f32) { - let time_from_start = current_time - self.current_segment_start; - let time_to_end = self.current_segment_end - current_time; - - if fade_duration <= 0.0 { - return (1.0, time_from_start, time_to_end); - } - - let fade_in = if skip_fade_in { - 1.0 - } else { - (time_from_start / fade_duration).min(1.0) - }; - - let fade_out = if skip_fade_out { - 1.0 - } else { - let effective_time_to_end = time_to_end + linger_duration; - if effective_time_to_end > linger_duration { - 1.0 - } else if effective_time_to_end > 0.0 { - (effective_time_to_end / fade_duration).min(1.0) - } else { - 0.0 - } - }; - - (fade_in.min(fade_out).max(0.0), time_from_start, time_to_end) - } - pub fn prepare( &mut self, uniforms: &ProjectUniforms, @@ -443,53 +352,70 @@ impl CaptionsLayer { return; } - let current_time = uniforms.frame_number as f32 / uniforms.frame_rate as f32; - let fade_duration = caption_data.settings.fade_duration; - let linger_duration = caption_data.settings.linger_duration; + let timeline = match &uniforms.project.timeline { + Some(t) => t, + None => { + self.current_text = None; + return; + } + }; + + if timeline.caption_segments.is_empty() { + self.current_text = None; + return; + } + + let current_time = uniforms.frame_number as f64 / uniforms.frame_rate as f64; + let default_fade = caption_data.settings.fade_duration; let word_transition_duration = caption_data.settings.word_transition_duration; - let Some(caption_result) = find_caption_at_time_project( - current_time, - &caption_data.segments, - linger_duration, - fade_duration, - ) else { + let Some(active) = + find_active_caption_segment(current_time, &timeline.caption_segments, default_fade) + else { self.current_text = None; return; }; - let current_caption = caption_result.segment; - let skip_fade_in = caption_result.skip_fade_in; - let skip_fade_out = caption_result.skip_fade_out; + let segment_fade = active + .segment + .fade_duration_override + .unwrap_or(default_fade) as f64; self.update_caption( - Some(current_caption.text.clone()), - current_caption.start, - current_caption.end, + Some(active.segment.text.clone()), + active.segment.start as f32, + active.segment.end as f32, ); let raw_caption_text = self.current_text.clone().unwrap_or_default(); let caption_text = wrap_text_by_words(&raw_caption_text, MAX_WORDS_PER_LINE); - let caption_words = current_caption.words.clone(); - let (fade_opacity, time_from_start, time_to_end) = self.calculate_fade_opacity( + let caption_words: Vec = active + .segment + .words + .iter() + .map(|w| CaptionWord { + text: w.text.clone(), + start: w.start, + end: w.end, + }) + .collect(); + + let fade_opacity = calculate_caption_fade( current_time, - fade_duration, - linger_duration, - skip_fade_in, - skip_fade_out, + active.segment.start, + active.segment.end, + segment_fade, ); if fade_opacity <= 0.0 { self.current_text = None; return; } - let bounce_offset = calculate_bounce_offset( - fade_opacity, - time_from_start, - time_to_end, - fade_duration, - skip_fade_in, - skip_fade_out, + let bounce_offset = calculate_caption_bounce( + current_time, + active.segment.start, + active.segment.end, + segment_fade, ); let (width, height) = (output_size.x, output_size.y); @@ -580,7 +506,7 @@ impl CaptionsLayer { } let word_highlight = calculate_word_highlight( - current_time, + current_time as f32, word, idx, &caption_words, @@ -682,8 +608,8 @@ impl CaptionsLayer { let center_y = height as f32 * position.y_factor(); let base_background_top = (center_y - box_height / 2.0).clamp(0.0, (height as f32 - box_height).max(0.0)); - let background_top = - (base_background_top + bounce_offset).clamp(0.0, (height as f32 - box_height).max(0.0)); + let background_top = (base_background_top + bounce_offset as f32) + .clamp(0.0, (height as f32 - box_height).max(0.0)); let text_left = background_left + padding; let text_top = background_top + padding; @@ -853,94 +779,74 @@ impl CaptionsLayer { } } -#[allow(dead_code)] -pub fn find_caption_at_time(time: f32, segments: &[CaptionSegment]) -> Option<&CaptionSegment> { - segments - .iter() - .find(|segment| time >= segment.start && time < segment.end) -} - -pub struct CaptionAtTime { - pub segment: CaptionSegment, - pub skip_fade_in: bool, - pub skip_fade_out: bool, +struct ActiveCaptionSegment<'a> { + segment: &'a cap_project::CaptionTrackSegment, } -const CLOSE_TRANSITION_THRESHOLD: f32 = 0.4; +fn find_active_caption_segment<'a>( + time: f64, + segments: &'a [cap_project::CaptionTrackSegment], + default_fade_duration: f32, +) -> Option> { + for segment in segments { + if time >= segment.start && time < segment.end { + return Some(ActiveCaptionSegment { segment }); + } + } -fn convert_project_segment(segment: &cap_project::CaptionSegment) -> CaptionSegment { - CaptionSegment { - _id: segment.id.clone(), - start: segment.start, - end: segment.end, - text: segment.text.clone(), - words: segment - .words - .iter() - .map(|w| CaptionWord { - text: w.text.clone(), - start: w.start, - end: w.end, - }) - .collect(), + for segment in segments { + let fade = segment + .fade_duration_override + .unwrap_or(default_fade_duration) as f64; + if time >= segment.end && time < segment.end + fade { + return Some(ActiveCaptionSegment { segment }); + } } -} -pub fn find_caption_at_time_project( - time: f32, - segments: &[cap_project::CaptionSegment], - linger_duration: f32, - fade_duration: f32, -) -> Option { - let extended_end = linger_duration + fade_duration; + None +} - for (idx, segment) in segments.iter().enumerate() { - if time >= segment.start && time < segment.end { - let prev_segment = if idx > 0 { - Some(&segments[idx - 1]) - } else { - None - }; - let next_segment = segments.get(idx + 1); - - let skip_fade_in = prev_segment - .map(|prev| segment.start - prev.end < CLOSE_TRANSITION_THRESHOLD) - .unwrap_or(false); - let skip_fade_out = next_segment - .map(|next| next.start - segment.end < CLOSE_TRANSITION_THRESHOLD) - .unwrap_or(false); - - return Some(CaptionAtTime { - segment: convert_project_segment(segment), - skip_fade_in, - skip_fade_out, - }); +fn calculate_caption_fade(current_time: f64, start: f64, end: f64, fade_duration: f64) -> f32 { + if fade_duration <= 0.0 { + if current_time >= start && current_time < end { + return 1.0; } + return 0.0; } - for (idx, segment) in segments.iter().enumerate() { - if time >= segment.end && time < segment.end + extended_end { - let prev_segment = if idx > 0 { - Some(&segments[idx - 1]) - } else { - None - }; - let next_segment = segments.get(idx + 1); - - let skip_fade_in = prev_segment - .map(|prev| segment.start - prev.end < CLOSE_TRANSITION_THRESHOLD) - .unwrap_or(false); - let skip_fade_out = next_segment - .map(|next| next.start - segment.end < CLOSE_TRANSITION_THRESHOLD) - .unwrap_or(false); - - return Some(CaptionAtTime { - segment: convert_project_segment(segment), - skip_fade_in, - skip_fade_out, - }); - } + let time_from_start = current_time - start; + let time_to_end = end - current_time; + + let fade_in = (time_from_start / fade_duration).clamp(0.0, 1.0) as f32; + + let fade_out = if time_to_end >= 0.0 { + 1.0 + } else { + let past_end = -time_to_end; + (1.0 - past_end / fade_duration).clamp(0.0, 1.0) as f32 + }; + + fade_in.min(fade_out) +} + +fn calculate_caption_bounce(current_time: f64, start: f64, end: f64, fade_duration: f64) -> f64 { + if fade_duration <= 0.0 { + return 0.0; } - None + let time_from_start = current_time - start; + let time_to_end = end - current_time; + + let fade_in_progress = (time_from_start / fade_duration).clamp(0.0, 1.0); + let fade_out_progress = (time_to_end / fade_duration).clamp(0.0, 1.0); + + if fade_in_progress < 1.0 { + let ease = 1.0 - fade_in_progress; + -(ease * ease) * BOUNCE_OFFSET_PIXELS as f64 + } else if fade_out_progress < 1.0 { + let ease = 1.0 - fade_out_progress; + (ease * ease) * BOUNCE_OFFSET_PIXELS as f64 + } else { + 0.0 + } } From d59897dbd9ce6d54965ccafaf1b2496de116db92 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:23:41 +0000 Subject: [PATCH 18/26] refactor(rendering): simplify keyboard layer fade with per-segment overrides --- crates/rendering/src/layers/keyboard.rs | 53 ++++++++++--------------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/crates/rendering/src/layers/keyboard.rs b/crates/rendering/src/layers/keyboard.rs index 81ca2ec940..c55e0aee6c 100644 --- a/crates/rendering/src/layers/keyboard.rs +++ b/crates/rendering/src/layers/keyboard.rs @@ -210,20 +210,22 @@ impl KeyboardLayer { let current_time = uniforms.frame_number as f64 / uniforms.frame_rate as f64; let settings = &keyboard_data.settings; - let fade_duration = settings.fade_duration as f64; - let linger_duration = settings.linger_duration as f64; let active_segment = find_active_keyboard_segment( current_time, &timeline.keyboard_segments, - linger_duration, - fade_duration, + settings.fade_duration, ); let Some(active) = active_segment else { return; }; + let segment_fade = active + .segment + .fade_duration_override + .unwrap_or(settings.fade_duration) as f64; + let visible_text = build_visible_text(&active.segment, current_time); if visible_text.is_empty() { @@ -234,8 +236,7 @@ impl KeyboardLayer { current_time, active.segment.start, active.segment.end, - fade_duration, - linger_duration, + segment_fade, ); if fade_opacity <= 0.0 { @@ -246,7 +247,7 @@ impl KeyboardLayer { current_time, active.segment.start, active.segment.end, - fade_duration, + segment_fade, ); let (width, height) = (output_size.x, output_size.y); @@ -482,11 +483,8 @@ struct ActiveKeyboardSegment<'a> { fn find_active_keyboard_segment<'a>( time: f64, segments: &'a [cap_project::KeyboardTrackSegment], - linger_duration: f64, - fade_duration: f64, + default_fade_duration: f32, ) -> Option> { - let extended_end = linger_duration + fade_duration; - for segment in segments { if time >= segment.start && time < segment.end { return Some(ActiveKeyboardSegment { segment }); @@ -494,7 +492,10 @@ fn find_active_keyboard_segment<'a>( } for segment in segments { - if time >= segment.end && time < segment.end + extended_end { + let fade = segment + .fade_duration_override + .unwrap_or(default_fade_duration) as f64; + if time >= segment.end && time < segment.end + fade { return Some(ActiveKeyboardSegment { segment }); } } @@ -513,10 +514,8 @@ fn build_visible_text(segment: &cap_project::KeyboardTrackSegment, current_time: let chars: Vec = segment.display_text.chars().collect(); for (i, key) in segment.keys.iter().enumerate() { - if time_offset_from_start >= key.time_offset { - if i < chars.len() { - visible.push(chars[i]); - } + if time_offset_from_start >= key.time_offset && i < chars.len() { + visible.push(chars[i]); } } @@ -527,15 +526,9 @@ fn build_visible_text(segment: &cap_project::KeyboardTrackSegment, current_time: visible } -fn calculate_fade( - current_time: f64, - start: f64, - end: f64, - fade_duration: f64, - linger_duration: f64, -) -> f32 { +fn calculate_fade(current_time: f64, start: f64, end: f64, fade_duration: f64) -> f32 { if fade_duration <= 0.0 { - if current_time >= start && current_time < end + linger_duration { + if current_time >= start && current_time < end { return 1.0; } return 0.0; @@ -544,18 +537,16 @@ fn calculate_fade( let time_from_start = current_time - start; let time_to_end = end - current_time; - let fade_in = (time_from_start / fade_duration).min(1.0) as f32; + let fade_in = (time_from_start / fade_duration).clamp(0.0, 1.0) as f32; - let effective_time_to_end = time_to_end + linger_duration; - let fade_out = if effective_time_to_end > linger_duration { + let fade_out = if time_to_end >= 0.0 { 1.0 - } else if effective_time_to_end > 0.0 { - (effective_time_to_end / fade_duration).min(1.0) as f32 } else { - 0.0 + let past_end = -time_to_end; + (1.0 - past_end / fade_duration).clamp(0.0, 1.0) as f32 }; - fade_in.min(fade_out).max(0.0) + fade_in.min(fade_out) } fn calculate_keyboard_bounce(current_time: f64, start: f64, end: f64, fade_duration: f64) -> f64 { From 13ea9d5eafba534abb9e650f63bfa419bb4b5b10 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:23:43 +0000 Subject: [PATCH 19/26] chore: update auto-generated tauri bindings --- apps/desktop/src/utils/tauri.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index d32d6b2710..a1c730b688 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -155,6 +155,9 @@ async updateProjectConfigInMemory(config: ProjectConfiguration, frameNumber: num async generateZoomSegmentsFromClicks() : Promise { return await TAURI_INVOKE("generate_zoom_segments_from_clicks"); }, +async generateKeyboardSegments(groupingThresholdMs: number, lingerDurationMs: number, showModifiers: boolean, showSpecialKeys: boolean) : Promise { + return await TAURI_INVOKE("generate_keyboard_segments", { groupingThresholdMs, lingerDurationMs, showModifiers, showSpecialKeys }); +}, async openPermissionSettings(permission: OSPermission) : Promise { await TAURI_INVOKE("open_permission_settings", { permission }); }, @@ -403,7 +406,6 @@ videoImportProgress: "video-import-progress" /** user-defined types **/ -export type AllGpusInfo = { gpus: GpuInfoDiag[]; primaryGpuIndex: number | null; isMultiGpuSystem: boolean; hasDiscreteGpu: boolean } export type Annotation = { id: string; type: AnnotationType; x: number; y: number; width: number; height: number; strokeColor: string; strokeWidth: number; fillColor: string; opacity: number; rotation: number; text: string | null; maskType?: MaskType | null; maskLevel?: number | null } export type AnnotationType = "arrow" | "circle" | "rectangle" | "text" | "mask" export type AppTheme = "system" | "light" | "dark" @@ -430,6 +432,7 @@ export type CameraYPosition = "top" | "bottom" export type CaptionData = { segments: CaptionSegment[]; settings: CaptionSettings | null } export type CaptionSegment = { id: string; start: number; end: number; text: string; words?: CaptionWord[] } export type CaptionSettings = { enabled: boolean; font: string; size: number; color: string; backgroundColor: string; backgroundOpacity: number; position: string; italic: boolean; fontWeight: number; outline: boolean; outlineColor: string; exportWithSubtitles: boolean; highlightColor: string; fadeDuration: number; lingerDuration: number; wordTransitionDuration: number; activeWordHighlight: boolean } +export type CaptionTrackSegment = { id: string; start: number; end: number; text: string; words?: CaptionWord[]; fadeDurationOverride?: number | null; lingerDurationOverride?: number | null; positionOverride?: string | null; colorOverride?: string | null; backgroundColorOverride?: string | null; fontSizeOverride?: number | null } export type CaptionWord = { text: string; start: number; end: number } export type CaptionsData = { segments: CaptionSegment[]; settings: CaptionSettings } export type CaptureDisplay = { id: DisplayId; name: string; refresh_rate: number } @@ -477,7 +480,6 @@ quality: number | null; */ fast: boolean | null } export type GlideDirection = "none" | "left" | "right" | "up" | "down" -export type GpuInfoDiag = { vendor: string; description: string; dedicatedVideoMemoryMb: number; adapterIndex: number; isSoftwareAdapter: boolean; isBasicRenderDriver: boolean; supportsHardwareEncoding: boolean } export type HapticPattern = "alignment" | "levelChange" | "generic" export type HapticPerformanceTime = "default" | "now" | "drawCompleted" export type Hotkey = { code: string; meta: boolean; ctrl: boolean; alt: boolean; shift: boolean } @@ -488,9 +490,14 @@ export type ImportStage = "Probing" | "Converting" | "Finalizing" | "Complete" | export type IncompleteRecordingInfo = { projectPath: string; prettyName: string; segmentCount: number; estimatedDurationSecs: number } export type InstantRecordingMeta = { recording: boolean } | { error: string } | { fps: number; sample_rate: number | null } export type JsonValue = [T] +export type KeyPressDisplay = { key: string; timeOffset: number } +export type KeyboardData = { settings: KeyboardSettings } +export type KeyboardSettings = { enabled: boolean; font: string; size: number; color: string; backgroundColor: string; backgroundOpacity: number; position: string; fontWeight: number; fadeDuration: number; lingerDuration: number; groupingThresholdMs: number; showModifiers: boolean; showSpecialKeys: boolean } +export type KeyboardTrackSegment = { id: string; start: number; end: number; displayText: string; keys?: KeyPressDisplay[]; fadeDurationOverride?: number | null; positionOverride?: string | null; colorOverride?: string | null; backgroundColorOverride?: string | null; fontSizeOverride?: number | null } export type LogicalBounds = { position: LogicalPosition; size: LogicalSize } export type LogicalPosition = { x: number; y: number } export type LogicalSize = { width: number; height: number } +export type MacOSVersionInfo = { major: number; minor: number; patch: number; displayName: string; buildNumber: string; isAppleSilicon: boolean } export type MainWindowRecordingStartBehaviour = "close" | "minimise" export type MaskKeyframes = { position?: MaskVectorKeyframe[]; size?: MaskVectorKeyframe[]; intensity?: MaskScalarKeyframe[] } export type MaskKind = "sensitive" | "highlight" @@ -501,7 +508,7 @@ export type MaskVectorKeyframe = { time: number; x: number; y: number } export type MicrophoneInfo = { name: string; sampleRate: number; channels: number } export type ModelIDType = string export type Mp4ExportSettings = { fps: number; resolution_base: XY; compression: ExportCompression; custom_bpp: number | null; force_ffmpeg_decoder?: boolean } -export type MultipleSegment = { display: VideoMeta; camera?: VideoMeta | null; mic?: AudioMeta | null; system_audio?: AudioMeta | null; cursor?: string | null } +export type MultipleSegment = { display: VideoMeta; camera?: VideoMeta | null; mic?: AudioMeta | null; system_audio?: AudioMeta | null; cursor?: string | null; keyboard?: string | null } export type MultipleSegments = { segments: MultipleSegment[]; cursors: Cursors; status?: StudioRecordingStatus | null } export type NewNotification = { title: string; body: string; is_error: boolean } export type NewScreenshotAdded = { path: string } @@ -518,7 +525,7 @@ export type PostDeletionBehaviour = "doNothing" | "reopenRecordingWindow" export type PostStudioRecordingBehaviour = "openEditor" | "showOverlay" export type Preset = { name: string; config: ProjectConfiguration } export type PresetsStore = { presets: Preset[]; default: number | null } -export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background: BackgroundConfiguration; camera: Camera; audio: AudioConfiguration; cursor: CursorConfiguration; hotkeys: HotkeysConfiguration; timeline: TimelineConfiguration | null; captions: CaptionsData | null; clips: ClipConfiguration[]; annotations: Annotation[]; screenMotionBlur?: number; screenMovementSpring?: ScreenMovementSpring } +export type ProjectConfiguration = { aspectRatio: AspectRatio | null; background: BackgroundConfiguration; camera: Camera; audio: AudioConfiguration; cursor: CursorConfiguration; hotkeys: HotkeysConfiguration; timeline: TimelineConfiguration | null; captions: CaptionsData | null; keyboard: KeyboardData | null; clips: ClipConfiguration[]; annotations: Annotation[]; screenMotionBlur?: number; screenMovementSpring?: ScreenMovementSpring } export type ProjectRecordingsMeta = { segments: SegmentRecordings[] } export type RecordingAction = "Started" | "InvalidAuthentication" | "UpgradeRequired" export type RecordingDeleted = { path: string } @@ -534,7 +541,6 @@ export type RecordingStatus = "pending" | "recording" export type RecordingStopped = null export type RecordingTargetMode = "display" | "window" | "area" | "camera" export type RenderFrameEvent = { frame_number: number; fps: number; resolution_base: XY } -export type RenderingStatus = { isUsingSoftwareRendering: boolean; isUsingBasicRenderDriver: boolean; hardwareEncodingAvailable: boolean; warningMessage: string | null } export type RequestOpenRecordingPicker = { target_mode: RecordingTargetMode | null } export type RequestOpenSettings = { page: string } export type RequestScreenCapturePrewarm = { force?: boolean } @@ -557,10 +563,10 @@ export type StartRecordingInputs = { capture_target: ScreenCaptureTarget; captur export type StereoMode = "stereo" | "monoL" | "monoR" export type StudioRecordingMeta = { segment: SingleSegment } | { inner: MultipleSegments } export type StudioRecordingStatus = { status: "InProgress" } | { status: "NeedsRemux" } | { status: "Failed"; error: string } | { status: "Complete" } -export type SystemDiagnostics = { windowsVersion: WindowsVersionInfo | null; gpuInfo: GpuInfoDiag | null; allGpus: AllGpusInfo | null; renderingStatus: RenderingStatus; availableEncoders: string[]; graphicsCaptureSupported: boolean; d3D11VideoProcessorAvailable: boolean } +export type SystemDiagnostics = { macosVersion: MacOSVersionInfo | null; availableEncoders: string[]; screenCaptureSupported: boolean; metalSupported: boolean; gpuName: string | null } export type TargetUnderCursor = { display_id: DisplayId | null; window: WindowUnderCursor | null } export type TextSegment = { start: number; end: number; enabled?: boolean; content?: string; center?: XY; size?: XY; fontFamily?: string; fontSize?: number; fontWeight?: number; italic?: boolean; color?: string; fadeDuration?: number } -export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[]; maskSegments?: MaskSegment[]; textSegments?: TextSegment[] } +export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[]; maskSegments?: MaskSegment[]; textSegments?: TextSegment[]; captionSegments?: CaptionTrackSegment[]; keyboardSegments?: KeyboardTrackSegment[] } export type TimelineSegment = { recordingSegment?: number; timescale: number; start: number; end: number } export type UploadMeta = { state: "MultipartUpload"; video_id: string; file_path: string; pre_created_video: VideoUploadInfo; recording_dir: string } | { state: "SinglePartUpload"; video_id: string; recording_dir: string; file_path: string; screenshot_path: string } | { state: "Failed"; error: string } | { state: "Complete" } export type UploadMode = { Initial: { pre_created_video: VideoUploadInfo | null } } | "Reupload" @@ -576,7 +582,6 @@ export type WindowExclusion = { bundleIdentifier?: string | null; ownerName?: st export type WindowId = string export type WindowPosition = { x: number; y: number; displayId?: DisplayId | null } export type WindowUnderCursor = { id: WindowId; app_name: string; bounds: LogicalBounds } -export type WindowsVersionInfo = { major: number; minor: number; build: number; displayName: string; meetsRequirements: boolean; isWindows11: boolean } export type XY = { x: T; y: T } export type ZoomMode = "auto" | { manual: { x: number; y: number } } export type ZoomSegment = { start: number; end: number; amount: number; mode: ZoomMode; glideDirection?: GlideDirection; glideSpeed?: number; instantAnimation?: boolean; edgeSnapRatio?: number } From 41b04c4e35aa6e6baaa9cdb41fa2d4f576169d74 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:23:46 +0000 Subject: [PATCH 20/26] chore: update auto-generated icon imports --- packages/ui-solid/src/auto-imports.d.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts index db7988e6a4..4bf0349443 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 IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left.jsx')['default'] const IconLucideBell: typeof import('~icons/lucide/bell.jsx')['default'] const IconLucideBoxSelect: typeof import('~icons/lucide/box-select.jsx')['default'] + const IconLucideCaptions: typeof import('~icons/lucide/captions.jsx')['default'] const IconLucideCheck: typeof import('~icons/lucide/check.jsx')['default'] const IconLucideCircleOff: typeof import('~icons/lucide/circle-off.jsx')['default'] const IconLucideClapperboard: typeof import('~icons/lucide/clapperboard.jsx')['default'] @@ -80,6 +81,7 @@ declare global { const IconLucideHardDrive: typeof import('~icons/lucide/hard-drive.jsx')['default'] const IconLucideImage: typeof import('~icons/lucide/image.jsx')['default'] const IconLucideInfo: typeof import('~icons/lucide/info.jsx')['default'] + const IconLucideKeyboard: typeof import('~icons/lucide/keyboard.jsx')['default'] const IconLucideLayout: typeof import('~icons/lucide/layout.jsx')['default'] const IconLucideLoader2: typeof import('~icons/lucide/loader2.jsx')['default'] const IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle.jsx')['default'] @@ -97,6 +99,7 @@ declare global { const IconLucideSearch: typeof import('~icons/lucide/search.jsx')['default'] const IconLucideSparkles: typeof import('~icons/lucide/sparkles.jsx')['default'] const IconLucideSquarePlay: typeof import('~icons/lucide/square-play.jsx')['default'] + const IconLucideSubtitles: typeof import('~icons/lucide/subtitles.jsx')['default'] const IconLucideType: typeof import('~icons/lucide/type.jsx')['default'] const IconLucideUnplug: typeof import('~icons/lucide/unplug.jsx')['default'] const IconLucideVideo: typeof import('~icons/lucide/video.jsx')['default'] From d27b706c9593ac31bf776291ab2ea8a3a0d3a168 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:23:49 +0000 Subject: [PATCH 21/26] feat(editor): add badge prop to Field component --- apps/desktop/src/routes/editor/ui.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/desktop/src/routes/editor/ui.tsx b/apps/desktop/src/routes/editor/ui.tsx index 8601aa0d7e..932de6427c 100644 --- a/apps/desktop/src/routes/editor/ui.tsx +++ b/apps/desktop/src/routes/editor/ui.tsx @@ -27,6 +27,7 @@ export function Field( name: string; icon?: JSX.Element; value?: JSX.Element; + badge?: string; class?: string; disabled?: boolean; }>, @@ -39,6 +40,11 @@ export function Field( > {props.icon} {props.name} + {props.badge && ( + + {props.badge} + + )} {props.value &&
{props.value}
} {props.children} From 2f78e95396cf27d0abf798a54c68cf6e5e08189a Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 19 Feb 2026 00:23:51 +0000 Subject: [PATCH 22/26] feat(editor): migrate CaptionsTab to timeline-based caption segments --- .../desktop/src/routes/editor/CaptionsTab.tsx | 196 ++++-------------- 1 file changed, 44 insertions(+), 152 deletions(-) diff --git a/apps/desktop/src/routes/editor/CaptionsTab.tsx b/apps/desktop/src/routes/editor/CaptionsTab.tsx index fb758545e7..c7a97d68dd 100644 --- a/apps/desktop/src/routes/editor/CaptionsTab.tsx +++ b/apps/desktop/src/routes/editor/CaptionsTab.tsx @@ -364,6 +364,31 @@ export function CaptionsTab() { if (result && result.segments.length > 0) { setProject("captions", "segments", result.segments); updateCaptionSetting("enabled", true); + + const trackSegments = result.segments.map( + (seg: { + id: string; + start: number; + end: number; + text: string; + words?: Array<{ text: string; start: number; end: number }>; + }) => ({ + id: seg.id, + start: seg.start, + end: seg.end, + text: seg.text, + words: seg.words ?? [], + fadeDurationOverride: null, + lingerDurationOverride: null, + positionOverride: null, + colorOverride: null, + backgroundColorOverride: null, + fontSizeOverride: null, + }), + ); + setProject("timeline", "captionSegments", trackSegments); + setEditorState("timeline", "tracks", "caption", true); + toast.success("Captions generated successfully!"); } else { toast.error( @@ -395,52 +420,14 @@ export function CaptionsTab() { } }; - 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">
@@ -946,6 +933,21 @@ export function CaptionsTab() { } /> + + + setProject( + "timeline", + "captionSegments", + selectedIndex(), + "text", + e.target.value, + ) + } + /> + - - - }> -
-
- -
- -
- - {(segment) => ( -
-
-
-
- - - updateSegment(segment.id, { - start: parseFloat(e.target.value), - }) - } - /> -
-
- - - updateSegment(segment.id, { - end: parseFloat(e.target.value), - }) - } - /> -
-
- -
- -
-