From c0fdd5b8ee81fb73845161168403d7529fffed5e Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Mon, 26 Oct 2026 01:33:12 +0000 Subject: [PATCH 1/2] fix(desktop-gpui): keep caption text when applying presets --- apps/desktop-gpui/src/editor_window.rs | 5 +- apps/desktop-gpui/src/presets.rs | 147 +++++++++++++++++++++++-- 2 files changed, 138 insertions(+), 14 deletions(-) diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index e89dee55090..cc1dfdef867 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -7264,10 +7264,7 @@ impl EditorWindow { /// The built-in Default row: Cap's own look, with this project's timeline /// and clips kept. fn apply_default_preset(&mut self, window: &mut Window, cx: &mut Context) { - let mut next = crate::presets::default_project_config(); - next.timeline = self.project.timeline.clone(); - next.overlay_order = self.project.overlay_order.clone(); - next.clips = self.project.clips.clone(); + let next = crate::presets::default_project_config(&self.project); self.apply_project_preset(next, window, cx); } diff --git a/apps/desktop-gpui/src/presets.rs b/apps/desktop-gpui/src/presets.rs index 27c8f3b67b7..45139733165 100644 --- a/apps/desktop-gpui/src/presets.rs +++ b/apps/desktop-gpui/src/presets.rs @@ -9,7 +9,7 @@ //! *applied*, so a preset written by a newer Tauri build with fields this //! build has never heard of survives every rename/reorder untouched. -use cap_project::ProjectConfiguration; +use cap_project::{CaptionsData, ProjectConfiguration}; use serde_json::{Map, Value, json}; use crate::store::{set_store_setting, store_section}; @@ -124,7 +124,7 @@ impl PresetsStore { /// presentation defaults the recorder applies (`recording.rs` /// `apply_screen_recording_presentation_defaults`): 10% padding, 7.5% /// rounding and the default screen-movement spring. -pub fn default_project_config() -> ProjectConfiguration { +pub fn default_project_config(current: &ProjectConfiguration) -> ProjectConfiguration { let mut config = ProjectConfiguration::default(); if config.background.padding <= f64::EPSILON { config.background.padding = 10.0; @@ -133,36 +133,57 @@ pub fn default_project_config() -> ProjectConfiguration { config.background.rounding = 7.5; } config.screen_movement_spring = cap_project::ScreenMovementSpring::default(); - config + merge_preset(config, current) } -/// What a preset stores: the whole project config with `timeline: null` and -/// `clips: []` -- presets style a project, they never carry its cuts -/// (`createPresets.ts:31-35`). +/// Presets store styling without recording cuts, caption text or layer order. pub fn preset_config(project: &ProjectConfiguration) -> Value { let mut value = serde_json::to_value(project).unwrap_or(Value::Null); if let Some(map) = value.as_object_mut() { map.insert("timeline".into(), Value::Null); map.insert("clips".into(), Value::Array(Vec::new())); map.remove("overlayOrder"); + if let Some(captions) = map.get_mut("captions").and_then(Value::as_object_mut) { + captions.insert("segments".into(), Value::Array(Vec::new())); + captions.remove("sourceTimed"); + } } value } /// `applyPreset`'s merge (`PresetsDropdown.tsx`): the preset's config with the -/// *current* timeline and clips kept in place. `None` when the stored JSON no -/// longer deserializes. +/// *current* timeline, clips and caption source kept in place. `None` when the +/// stored JSON no longer deserializes. pub fn apply_preset( config: &Value, current: &ProjectConfiguration, ) -> Option { - let mut next: ProjectConfiguration = serde_json::from_value(config.clone()).ok()?; + let next = serde_json::from_value(config.clone()).ok()?; + Some(merge_preset(next, current)) +} + +fn merge_preset( + mut next: ProjectConfiguration, + current: &ProjectConfiguration, +) -> ProjectConfiguration { + let settings = next.captions.take().map(|captions| captions.settings); + next.captions = match (current.captions.clone(), settings) { + (Some(mut captions), settings) => { + captions.settings = settings.unwrap_or_default(); + Some(captions) + } + (None, Some(settings)) => Some(CaptionsData { + settings, + ..Default::default() + }), + (None, None) => None, + }; next.timeline = current.timeline.clone(); next.text_size_version = current.text_size_version; next.text_anim_version = current.text_anim_version; next.overlay_order = current.overlay_order.clone(); next.clips = current.clips.clone(); - Some(next) + next } #[cfg(test)] @@ -328,6 +349,112 @@ mod tests { ); } + fn captioned_project(text: &str, source_timed: bool) -> ProjectConfiguration { + serde_json::from_value(json!({ + "timeline": { + "segments": [{ "start": 0.0, "end": 10.0, "timescale": 1.0 }], + "zoomSegments": [], + "captionSegments": [{ + "id": "spoken", "start": 2.0, "end": 3.0, "text": text, + "words": [{ "text": text, "start": 2.0, "end": 3.0 }], + "colorOverride": "#123456", "positionOverride": "top-center" + }] + }, + "captions": { + "segments": [{ + "id": "spoken", "start": 2.0, "end": 3.0, "text": text, + "words": [{ "text": text, "start": 2.0, "end": 3.0 }] + }], + "settings": { "enabled": true, "size": 32 }, + "sourceTimed": source_timed + } + })) + .unwrap() + } + + #[test] + fn saving_a_preset_keeps_caption_settings_without_the_transcript() { + let source = captioned_project("Other recording", true); + let preset = preset_config(&source); + let captions = &preset["captions"]; + assert_eq!(captions["segments"], json!([])); + assert_eq!(captions["settings"]["size"], 32); + assert!(captions.get("sourceTimed").is_none()); + assert!( + !serde_json::to_string(&preset) + .unwrap() + .contains("Other recording") + ); + } + + #[test] + fn applying_a_preset_keeps_source_captions_and_timeline_overrides() { + let preset = serde_json::to_value(captioned_project("Other recording", false)).unwrap(); + for source_timed in [false, true] { + let current = captioned_project("Current recording", source_timed); + let mut preset = preset.clone(); + preset["captions"]["settings"]["size"] = json!(72); + let mut applied = apply_preset(&preset, ¤t).unwrap(); + let captions = applied.captions.as_ref().unwrap(); + assert_eq!(captions.segments[0].text, "Current recording"); + assert_eq!(captions.source_timed, source_timed); + assert_eq!(captions.settings.size, 72); + assert!(captions.settings.enabled); + cap_project::synchronize_captions(&mut applied, &[10.0]); + let track = &applied.timeline.as_ref().unwrap().caption_segments[0]; + assert_eq!(track.text, "Current recording"); + assert_eq!((track.start, track.end), (2.0, 3.0)); + assert_eq!(track.color_override.as_deref(), Some("#123456")); + assert_eq!(track.position_override.as_deref(), Some("top-center")); + let directory = tempfile::tempdir().unwrap(); + applied.write(directory.path()).unwrap(); + let mut reopened = ProjectConfiguration::load(directory.path()).unwrap(); + cap_project::synchronize_captions(&mut reopened, &[10.0]); + assert_eq!( + reopened.captions.as_ref().unwrap().segments[0].text, + "Current recording" + ); + assert_eq!( + reopened.timeline.as_ref().unwrap().caption_segments[0].text, + "Current recording" + ); + } + } + + #[test] + fn applying_default_caption_settings_keeps_the_current_transcript() { + let current = captioned_project("Current recording", true); + let preset = preset_config(&ProjectConfiguration::default()); + for applied in [ + apply_preset(&preset, ¤t).unwrap(), + default_project_config(¤t), + ] { + let captions = applied.captions.as_ref().unwrap(); + assert_eq!(captions.segments[0].text, "Current recording"); + assert!(captions.source_timed); + assert_eq!( + captions.settings.size, + cap_project::CaptionSettings::default().size + ); + assert!(!captions.settings.enabled); + } + } + + #[test] + fn applying_a_preset_to_an_uncaptioned_project_keeps_only_its_settings() { + let source = captioned_project("Other recording", true); + let preset = serde_json::to_value(&source).unwrap(); + let current = ProjectConfiguration::default(); + let applied = apply_preset(&preset, ¤t).unwrap(); + let captions = applied.captions.as_ref().unwrap(); + assert!(captions.segments.is_empty()); + assert_eq!(captions.settings.size, 32); + assert!(captions.settings.enabled); + let preset = preset_config(¤t); + assert!(apply_preset(&preset, ¤t).unwrap().captions.is_none()); + assert!(default_project_config(¤t).captions.is_none()); + } + #[test] fn malformed_entries_are_skipped_on_load() { let mut section = Map::new(); From df1e9e2ae5dbb7f05da3e8f37a879a879fa2f5e3 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Mon, 26 Oct 2026 05:47:58 +0000 Subject: [PATCH 2/2] fix(presets): preserve project content in CLI automations --- apps/cli/src/automation.rs | 102 +++++++++++++++++++++++++--- apps/desktop-gpui/src/presets.rs | 30 +------- crates/project/src/configuration.rs | 21 ++++++ 3 files changed, 115 insertions(+), 38 deletions(-) diff --git a/apps/cli/src/automation.rs b/apps/cli/src/automation.rs index 37e50753df4..bd998934635 100644 --- a/apps/cli/src/automation.rs +++ b/apps/cli/src/automation.rs @@ -366,20 +366,11 @@ impl AutomationHost for CliAutomationHost { .ok_or_else(|| format!("Preset '{name}' not found"))?; let config_value = preset.get("config").ok_or("Preset has no config")?; - let mut config: cap_project::ProjectConfiguration = + let config: cap_project::ProjectConfiguration = serde_json::from_value(config_value.clone()) .map_err(|e| format!("Failed to parse preset config: {e}"))?; - if config.timeline.is_none() { - config.timeline = cap_project::ProjectConfiguration::load(project_path) - .ok() - .and_then(|c| c.timeline); - } - - config - .write(project_path) - .map_err(|e| format!("Failed to write project config: {e}"))?; - Ok(()) + apply_project_preset(project_path, config) } async fn delete_local_files(&self, ctx: &TriggerContext) -> Result<(), String> { @@ -402,6 +393,21 @@ fn map_compression( } } +fn apply_project_preset( + project_path: &Path, + preset: cap_project::ProjectConfiguration, +) -> Result<(), String> { + let current = match cap_project::ProjectConfiguration::load(project_path) { + Ok(current) => current, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Default::default(), + Err(error) => return Err(format!("Failed to load project config: {error}")), + }; + current + .with_preset(preset) + .write(project_path) + .map_err(|error| format!("Failed to write project config: {error}")) +} + fn context_env(ctx: &TriggerContext) -> Vec<(&'static str, String)> { let mut env = Vec::new(); if let Some(p) = &ctx.project_path { @@ -576,6 +582,80 @@ mod tests { use super::*; use cap_automation::{Action, AutomationRule, MatchMode}; + #[test] + fn cli_presets_keep_caption_source_and_edits_after_reopening() { + let current: cap_project::ProjectConfiguration = + serde_json::from_value(serde_json::json!({ + "timeline": { + "segments": [{ "start": 0.0, "end": 10.0, "timescale": 1.0 }], + "zoomSegments": [], + "captionSegments": [{ "id": "spoken", "start": 2.0, "end": 3.0, + "text": "Current recording", "colorOverride": "#123456" }] + }, + "captions": { + "segments": [{ "id": "spoken", "start": 2.0, "end": 3.0, + "text": "Current recording", + "words": [{ "text": "Current recording", "start": 2.0, "end": 3.0 }] }], + "settings": { "enabled": true, "size": 32 }, + "sourceTimed": true + }, + "clips": [{ "index": 0, "offsets": { "mic": -0.125 } }] + })) + .unwrap(); + let mut legacy = current.clone(); + let captions = legacy.captions.as_mut().unwrap(); + captions.segments[0].text = "Other recording".into(); + captions.segments[0].words[0].text = "Other recording".into(); + captions.settings.size = 72; + legacy.timeline.as_mut().unwrap().segments[0].end = 5.0; + legacy.clips.clear(); + let mut styling = legacy.clone(); + styling.captions.as_mut().unwrap().segments.clear(); + styling.timeline = None; + + for preset in [legacy, styling, Default::default()] { + let temp = tempfile::tempdir().unwrap(); + current.write(temp.path()).unwrap(); + let expected_size = preset.captions.as_ref().map_or_else( + || cap_project::CaptionSettings::default().size, + |captions| captions.settings.size, + ); + apply_project_preset(temp.path(), preset).unwrap(); + let mut reopened = cap_project::ProjectConfiguration::load(temp.path()).unwrap(); + let captions = reopened.captions.as_ref().unwrap(); + assert_eq!(captions.segments[0].text, "Current recording"); + assert!(captions.source_timed); + assert_eq!(captions.settings.size, expected_size); + assert_eq!(reopened.clips.len(), 1); + assert_eq!(reopened.clips[0].offsets.mic, -0.125); + assert_eq!(reopened.timeline.as_ref().unwrap().segments[0].end, 10.0); + cap_project::synchronize_captions(&mut reopened, &[10.0]); + let track = &reopened.timeline.as_ref().unwrap().caption_segments[0]; + assert_eq!(track.text, "Current recording"); + assert_eq!(track.color_override.as_deref(), Some("#123456")); + } + } + + #[test] + fn cli_presets_apply_to_projects_without_a_saved_config() { + let temp = tempfile::tempdir().unwrap(); + let mut preset = cap_project::ProjectConfiguration::default(); + preset.background.padding = 24.0; + apply_project_preset(temp.path(), preset).unwrap(); + let reopened = cap_project::ProjectConfiguration::load(temp.path()).unwrap(); + assert_eq!(reopened.background.padding, 24.0); + } + + #[test] + fn cli_presets_leave_unreadable_configs_untouched() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("project-config.json"); + std::fs::write(&path, b"incomplete configuration").unwrap(); + let error = apply_project_preset(temp.path(), Default::default()).unwrap_err(); + assert!(error.starts_with("Failed to load project config:")); + assert_eq!(std::fs::read(path).unwrap(), b"incomplete configuration"); + } + #[tokio::test] async fn cli_upload_cleanup_retains_media_without_a_verified_receipt() { for trigger in [Trigger::UploadCompleted, Trigger::InstantRecordingFinished] { diff --git a/apps/desktop-gpui/src/presets.rs b/apps/desktop-gpui/src/presets.rs index 45139733165..a2f746e47ad 100644 --- a/apps/desktop-gpui/src/presets.rs +++ b/apps/desktop-gpui/src/presets.rs @@ -9,7 +9,7 @@ //! *applied*, so a preset written by a newer Tauri build with fields this //! build has never heard of survives every rename/reorder untouched. -use cap_project::{CaptionsData, ProjectConfiguration}; +use cap_project::ProjectConfiguration; use serde_json::{Map, Value, json}; use crate::store::{set_store_setting, store_section}; @@ -133,7 +133,7 @@ pub fn default_project_config(current: &ProjectConfiguration) -> ProjectConfigur config.background.rounding = 7.5; } config.screen_movement_spring = cap_project::ScreenMovementSpring::default(); - merge_preset(config, current) + current.with_preset(config) } /// Presets store styling without recording cuts, caption text or layer order. @@ -159,31 +159,7 @@ pub fn apply_preset( current: &ProjectConfiguration, ) -> Option { let next = serde_json::from_value(config.clone()).ok()?; - Some(merge_preset(next, current)) -} - -fn merge_preset( - mut next: ProjectConfiguration, - current: &ProjectConfiguration, -) -> ProjectConfiguration { - let settings = next.captions.take().map(|captions| captions.settings); - next.captions = match (current.captions.clone(), settings) { - (Some(mut captions), settings) => { - captions.settings = settings.unwrap_or_default(); - Some(captions) - } - (None, Some(settings)) => Some(CaptionsData { - settings, - ..Default::default() - }), - (None, None) => None, - }; - next.timeline = current.timeline.clone(); - next.text_size_version = current.text_size_version; - next.text_anim_version = current.text_anim_version; - next.overlay_order = current.overlay_order.clone(); - next.clips = current.clips.clone(); - next + Some(current.with_preset(next)) } #[cfg(test)] diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index edda76a60be..bf75871e9ae 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -2812,6 +2812,27 @@ impl ProjectStyleCache { } impl ProjectConfiguration { + pub fn with_preset(&self, mut preset: Self) -> Self { + let settings = preset.captions.take().map(|captions| captions.settings); + preset.captions = match (self.captions.clone(), settings) { + (Some(mut captions), settings) => { + captions.settings = settings.unwrap_or_default(); + Some(captions) + } + (None, Some(settings)) => Some(CaptionsData { + settings, + ..Default::default() + }), + (None, None) => None, + }; + preset.timeline = self.timeline.clone(); + preset.text_size_version = self.text_size_version; + preset.text_anim_version = self.text_anim_version; + preset.overlay_order = self.overlay_order.clone(); + preset.clips = self.clips.clone(); + preset + } + pub fn resolved_overlay_order(&self, available: &[OverlayTrack]) -> Vec { let mut ordered = Vec::with_capacity(available.len()); for track in available {