Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 91 additions & 11 deletions apps/cli/src/automation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand All @@ -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 {
Expand Down Expand Up @@ -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] {
Expand Down
5 changes: 1 addition & 4 deletions apps/desktop-gpui/src/editor_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7227,10 +7227,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<Self>) {
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);
}

Expand Down
131 changes: 117 additions & 14 deletions apps/desktop-gpui/src/presets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -133,36 +133,33 @@ pub fn default_project_config() -> ProjectConfiguration {
config.background.rounding = 7.5;
}
config.screen_movement_spring = cap_project::ScreenMovementSpring::default();
config
current.with_preset(config)
}

/// 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<ProjectConfiguration> {
let mut next: ProjectConfiguration = serde_json::from_value(config.clone()).ok()?;
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)
let next = serde_json::from_value(config.clone()).ok()?;
Some(current.with_preset(next))
}

#[cfg(test)]
Expand Down Expand Up @@ -328,6 +325,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, &current).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, &current).unwrap(),
default_project_config(&current),
] {
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, &current).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(&current);
assert!(apply_preset(&preset, &current).unwrap().captions.is_none());
assert!(default_project_config(&current).captions.is_none());
}

#[test]
fn malformed_entries_are_skipped_on_load() {
let mut section = Map::new();
Expand Down
21 changes: 21 additions & 0 deletions crates/project/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2825,6 +2825,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<OverlayTrack> {
let mut ordered = Vec::with_capacity(available.len());
for track in available {
Expand Down
Loading