diff --git a/apps/cli/src/automation.rs b/apps/cli/src/automation.rs index 8b3572d1c9f..3ceb0d00808 100644 --- a/apps/cli/src/automation.rs +++ b/apps/cli/src/automation.rs @@ -43,7 +43,7 @@ fn capture_target_kind(target: &ScreenCaptureTarget) -> Option Some(cap_automation::CaptureTargetKind::Window), ScreenCaptureTarget::Display { .. } => Some(cap_automation::CaptureTargetKind::Display), ScreenCaptureTarget::Area { .. } => Some(cap_automation::CaptureTargetKind::Area), - ScreenCaptureTarget::CameraOnly => None, + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => None, } } diff --git a/apps/cli/src/project.rs b/apps/cli/src/project.rs index 0081eae079a..f35cb65e75d 100644 --- a/apps/cli/src/project.rs +++ b/apps/cli/src/project.rs @@ -123,10 +123,13 @@ fn studio_checks(meta: &RecordingMeta, studio: &StudioRecordingMeta) -> Vec { - checks.push(required_check( - "displayVideo", - meta.path(&segment.display.path), - )); + if !meta.audio_only { + let path = segment + .display + .as_ref() + .map_or_else(PathBuf::new, |d| meta.path(&d.path)); + checks.push(required_check("displayVideo", path)); + } if let Some(camera) = &segment.camera { checks.push(required_check("camera", meta.path(&camera.path))); } @@ -139,10 +142,13 @@ fn studio_checks(meta: &RecordingMeta, studio: &StudioRecordingMeta) -> Vec { for segment in &inner.segments { - checks.push(required_check( - "displayVideo", - meta.path(&segment.display.path), - )); + if !meta.audio_only { + let path = segment + .display + .as_ref() + .map_or_else(PathBuf::new, |d| meta.path(&d.path)); + checks.push(required_check("displayVideo", path)); + } if let Some(camera) = &segment.camera { checks.push(required_check("camera", meta.path(&camera.path))); } diff --git a/apps/cli/src/record.rs b/apps/cli/src/record.rs index fff1ae7a53c..11bb9b03bb7 100644 --- a/apps/cli/src/record.rs +++ b/apps/cli/src/record.rs @@ -906,6 +906,7 @@ fn persist_instant_recording_meta( sharing: None, inner: RecordingMetaInner::Instant(meta), upload: None, + audio_only: false, } .save_for_project() .map_err(|e| format!("Failed to save instant recording meta: {e}"))?; diff --git a/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs b/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs index 4981b034029..5cc3839f914 100644 --- a/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs +++ b/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs @@ -118,7 +118,11 @@ async fn load_recording( if project.timeline.is_none() { let timeline_segments = match meta.as_ref() { StudioRecordingMeta::SingleSegment { segment } => { - let display_path = recording_meta.path(&segment.display.path); + let display_path = segment + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .unwrap_or_default(); let duration = match Video::new(&display_path, 0.0) { Ok(video) => video.duration, Err(_) => 5.0, @@ -136,7 +140,11 @@ async fn load_recording( .iter() .enumerate() .filter_map(|(i, segment)| { - let display_path = recording_meta.path(&segment.display.path); + let display_path = segment + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .unwrap_or_default(); let duration = match Video::new(&display_path, 0.0) { Ok(video) => video.duration, Err(_) => 5.0, diff --git a/apps/desktop/src-tauri/src/automation.rs b/apps/desktop/src-tauri/src/automation.rs index c669255f455..d672bf25d24 100644 --- a/apps/desktop/src-tauri/src/automation.rs +++ b/apps/desktop/src-tauri/src/automation.rs @@ -614,7 +614,7 @@ pub fn capture_target_kind(target: &ScreenCaptureTarget) -> Option Some(CaptureTargetKind::Window), ScreenCaptureTarget::Display { .. } => Some(CaptureTargetKind::Display), ScreenCaptureTarget::Area { .. } => Some(CaptureTargetKind::Area), - ScreenCaptureTarget::CameraOnly => None, + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => None, } } diff --git a/apps/desktop/src-tauri/src/clip_thumbnails.rs b/apps/desktop/src-tauri/src/clip_thumbnails.rs index bc898f7fc19..5eb07eb42a9 100644 --- a/apps/desktop/src-tauri/src/clip_thumbnails.rs +++ b/apps/desktop/src-tauri/src/clip_thumbnails.rs @@ -27,13 +27,21 @@ pub async fn get_clip_thumbnail( }; let display_path = match studio.as_ref() { - StudioRecordingMeta::SingleSegment { segment } => meta.path(&segment.display.path), + StudioRecordingMeta::SingleSegment { segment } => segment + .display + .as_ref() + .map(|d| meta.path(&d.path)) + .ok_or_else(|| "Recording has no display track".to_string())?, StudioRecordingMeta::MultipleSegments { inner } => { let segment = inner .segments .get(recording_segment as usize) .ok_or_else(|| format!("Recording segment {recording_segment} not found"))?; - meta.path(&segment.display.path) + segment + .display + .as_ref() + .map(|d| meta.path(&d.path)) + .ok_or_else(|| "Recording segment has no display track".to_string())? } }; diff --git a/apps/desktop/src-tauri/src/import.rs b/apps/desktop/src-tauri/src/import.rs index 1cb13bc49ec..3ed3f0dec6e 100644 --- a/apps/desktop/src-tauri/src/import.rs +++ b/apps/desktop/src-tauri/src/import.rs @@ -315,7 +315,8 @@ fn full_timeline_for_segments( .iter() .enumerate() .map(|(index, segment)| { - let duration = get_video_duration_secs(&segment.display.path.to_path(project_path))?; + let display = segment.display.as_ref().ok_or("Missing display video")?; + let duration = get_video_duration_secs(&display.path.to_path(project_path))?; Ok(TimelineSegment { recording_clip: index as u32, timescale: 1.0, @@ -348,7 +349,10 @@ fn full_timeline_for_source_segments( .iter() .enumerate() .map(|(index, segment)| { - let duration = get_source_video_duration_secs(source_meta, &segment.display)?; + let duration = get_source_video_duration_secs( + source_meta, + segment.display.as_ref().ok_or("Missing display video")?, + )?; Ok(TimelineSegment { recording_clip: index as u32, timescale: 1.0, @@ -595,7 +599,11 @@ fn copy_keyboard_path( return Ok(Some(target_relative_path)); }; - let Some(display_dir) = source_segment.display.path.parent() else { + let Some(display_dir) = source_segment + .display + .as_ref() + .and_then(|d| d.path.parent()) + else { return Ok(None); }; @@ -872,7 +880,13 @@ fn source_timeline_segments_for_import( let max_duration = if let Some(duration) = duration_cache.get(&source_index) { *duration } else { - let duration = get_source_video_duration_secs(source_meta, &source_segment.display)?; + let duration = get_source_video_duration_secs( + source_meta, + source_segment + .display + .as_ref() + .ok_or("Missing display video")?, + )?; duration_cache.insert(source_index, duration); duration }; @@ -924,15 +938,21 @@ fn copy_source_segment( target_relative_dir: &str, cursor_id_map: &HashMap, ) -> Result { - let display = copy_video_meta( - &source_meta.project_path, - target_project_path, - &source_segment.display, - target_relative_dir, - "display", - true, - )? - .ok_or_else(|| "Missing display video".to_string())?; + let display = source_segment + .display + .as_ref() + .map(|d| { + copy_video_meta( + &source_meta.project_path, + target_project_path, + d, + target_relative_dir, + "display", + true, + ) + }) + .transpose()? + .flatten(); let camera = source_segment .camera @@ -1408,12 +1428,12 @@ pub async fn start_video_import(app: AppHandle, source_path: PathBuf) -> Result< inner: RecordingMetaInner::Studio(Box::new(StudioRecordingMeta::MultipleSegments { inner: MultipleSegments { segments: vec![MultipleSegment { - display: VideoMeta { + display: Some(VideoMeta { path: RelativePathBuf::from("content/segments/segment-0/display.mp4"), fps: 30, start_time: Some(0.0), device_id: None, - }, + }), camera: None, mic: None, system_audio: None, @@ -1425,6 +1445,7 @@ pub async fn start_video_import(app: AppHandle, source_path: PathBuf) -> Result< }, })), upload: None, + audio_only: false, }; initial_meta @@ -1501,14 +1522,14 @@ pub async fn start_video_import(app: AppHandle, source_path: PathBuf) -> Result< StudioRecordingMeta::MultipleSegments { inner: MultipleSegments { segments: vec![MultipleSegment { - display: VideoMeta { + display: Some(VideoMeta { path: RelativePathBuf::from( "content/segments/segment-0/display.mp4", ), fps, start_time: Some(0.0), device_id: None, - }, + }), camera: None, mic: None, system_audio, @@ -1521,6 +1542,7 @@ pub async fn start_video_import(app: AppHandle, source_path: PathBuf) -> Result< }, )), upload: None, + audio_only: false, }; if let Err(e) = meta.save_for_project() { @@ -1677,12 +1699,12 @@ async fn append_mp4_to_editor_project( }; let imported_segment = MultipleSegment { - display: VideoMeta { + display: Some(VideoMeta { path: output_video_relative_path, fps, start_time: Some(0.0), device_id: None, - }, + }), camera: None, mic: None, system_audio, @@ -1971,7 +1993,7 @@ pub async fn start_image_import(app: AppHandle, source_path: PathBuf) -> Result< }; let segment = SingleSegment { - display: video_meta, + display: Some(video_meta), camera: None, audio: None, cursor: None, @@ -1984,6 +2006,7 @@ pub async fn start_image_import(app: AppHandle, source_path: PathBuf) -> Result< sharing: None, inner: RecordingMetaInner::Studio(Box::new(StudioRecordingMeta::SingleSegment { segment })), upload: None, + audio_only: false, }; meta.save_for_project() diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index f6158a1beca..b94d4aca41d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2240,6 +2240,7 @@ enum CurrentRecordingTarget { bounds: LogicalBounds, }, Camera, + Audio, } #[derive(Serialize, Type)] @@ -2292,6 +2293,7 @@ async fn get_current_recording( bounds: *bounds, }, ScreenCaptureTarget::CameraOnly => CurrentRecordingTarget::Camera, + ScreenCaptureTarget::AudioOnly => CurrentRecordingTarget::Audio, }; Ok(JsonValue::new(&Some(CurrentRecording { @@ -2930,12 +2932,27 @@ async fn get_video_metadata(path: PathBuf) -> Result { - vec![recording_meta.path(&segment.display.path)] + vec![ + recording_meta.path( + &segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ), + ] } StudioRecordingMeta::MultipleSegments { inner } => inner .segments .iter() - .map(|s| recording_meta.path(&s.display.path)) + .map(|s| { + recording_meta.path( + &s.display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ) + }) .collect(), } } diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index cd4afc70485..2f6c94cf9be 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -110,7 +110,10 @@ fn spawn_current_desktop_background_snapshot( recording_dir: PathBuf, capture_target: ScreenCaptureTarget, ) { - if matches!(capture_target, ScreenCaptureTarget::CameraOnly) { + if matches!( + capture_target, + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly + ) { return; } @@ -1484,7 +1487,10 @@ pub async fn start_recording( } else { cap_recording::FREE_INSTANT_MODE_MAX_RESOLUTION }; - let upload_mode = if matches!(inputs.capture_target, ScreenCaptureTarget::CameraOnly) { + let upload_mode = if matches!( + inputs.capture_target, + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly + ) { "desktopMP4" } else { "desktopSegments" @@ -1553,6 +1559,7 @@ pub async fn start_recording( }, sharing: None, upload: None, + audio_only: matches!(inputs.capture_target, ScreenCaptureTarget::AudioOnly), }; meta.save_for_project() @@ -1669,7 +1676,7 @@ pub async fn start_recording( #[cfg(target_os = "macos")] let mut shareable_content = match inputs.capture_target { - ScreenCaptureTarget::CameraOnly => None, + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => None, _ => Some(acquire_shareable_content_for_target(&inputs.capture_target).await?), }; @@ -2648,7 +2655,7 @@ pub async fn take_screenshot( }; let segment = cap_project::SingleSegment { - display: video_meta, + display: Some(video_meta), camera: None, audio: None, cursor: None, @@ -2663,6 +2670,7 @@ pub async fn take_screenshot( cap_project::StudioRecordingMeta::SingleSegment { segment }, )), upload: None, + audio_only: false, }; meta.save_for_project() @@ -3070,22 +3078,32 @@ async fn handle_recording_finish( let updated_studio_meta = recording.meta.clone(); let display_output_path = match &updated_studio_meta { - StudioRecordingMeta::SingleSegment { segment } => { - segment.display.path.to_path(&recording_dir) - } - StudioRecordingMeta::MultipleSegments { inner, .. } => { - inner.segments[0].display.path.to_path(&recording_dir) - } + StudioRecordingMeta::SingleSegment { segment } => segment + .display + .as_ref() + .map(|d| d.path.to_path(&recording_dir)), + StudioRecordingMeta::MultipleSegments { inner, .. } => inner + .segments + .first() + .and_then(|s| s.display.as_ref()) + .map(|d| d.path.to_path(&recording_dir)), }; + let has_display = display_output_path.is_some(); let display_screenshot = screenshots_dir.join("display.jpg"); - tokio::spawn(create_screenshot( - display_output_path, - display_screenshot.clone(), - None, - )); + if let Some(display_path) = display_output_path { + tokio::spawn(create_screenshot( + display_path, + display_screenshot.clone(), + None, + )); + } - let recordings = ProjectRecordingsMeta::new(&recording_dir, &updated_studio_meta)?; + let recordings = if has_display { + ProjectRecordingsMeta::new(&recording_dir, &updated_studio_meta)? + } else { + ProjectRecordingsMeta { segments: vec![] } + }; let config = project_config_from_recording( app, @@ -3315,23 +3333,32 @@ async fn finalize_studio_recording( .clone(); let display_output_path = match &updated_studio_meta { - StudioRecordingMeta::SingleSegment { segment } => { - segment.display.path.to_path(&recording_dir) - } - StudioRecordingMeta::MultipleSegments { inner, .. } => { - inner.segments[0].display.path.to_path(&recording_dir) - } + StudioRecordingMeta::SingleSegment { segment } => segment + .display + .as_ref() + .map(|d| d.path.to_path(&recording_dir)), + StudioRecordingMeta::MultipleSegments { inner, .. } => inner + .segments + .first() + .and_then(|s| s.display.as_ref()) + .map(|d| d.path.to_path(&recording_dir)), }; + let has_display = display_output_path.is_some(); - let display_screenshot = screenshots_dir.join("display.jpg"); - tokio::spawn(create_screenshot( - display_output_path, - display_screenshot, - None, - )); + if let Some(display_path) = display_output_path { + tokio::spawn(create_screenshot( + display_path, + screenshots_dir.join("display.jpg"), + None, + )); + } - let recordings = ProjectRecordingsMeta::new(&recording_dir, &updated_studio_meta) - .map_err(|e| format!("Failed to create project recordings meta: {e}"))?; + let recordings = if has_display { + ProjectRecordingsMeta::new(&recording_dir, &updated_studio_meta) + .map_err(|e| format!("Failed to create project recordings meta: {e}"))? + } else { + ProjectRecordingsMeta { segments: vec![] } + }; let config = project_config_from_recording( app, @@ -3447,6 +3474,7 @@ pub fn generate_zoom_segments_from_clicks( sharing: None, inner: RecordingMetaInner::Studio(Box::new(recording.meta.clone())), upload: None, + audio_only: false, }; generate_zoom_segments_for_project(&recording_meta, recordings) @@ -3550,21 +3578,23 @@ fn project_config_from_recording( }) .collect::>(); - let zoom_segments = if settings.auto_zoom_on_clicks { - generate_zoom_segments_from_clicks(completed_recording, recordings) - } else { - Vec::new() - }; + if !timeline_segments.is_empty() { + let zoom_segments = if settings.auto_zoom_on_clicks { + generate_zoom_segments_from_clicks(completed_recording, recordings) + } else { + Vec::new() + }; - config.timeline = Some(TimelineConfiguration { - segments: timeline_segments, - zoom_segments, - scene_segments: Vec::new(), - mask_segments: Vec::new(), - text_segments: Vec::new(), - caption_segments: Vec::new(), - keyboard_segments: Vec::new(), - }); + config.timeline = Some(TimelineConfiguration { + segments: timeline_segments, + zoom_segments, + scene_segments: Vec::new(), + mask_segments: Vec::new(), + text_segments: Vec::new(), + caption_segments: Vec::new(), + keyboard_segments: Vec::new(), + }); + } config } @@ -3605,7 +3635,10 @@ fn apply_screen_recording_presentation_defaults( ) { use cap_project::{BackgroundSource, ScreenMovementSpring}; - if matches!(capture_target, Some(ScreenCaptureTarget::CameraOnly)) { + if matches!( + capture_target, + Some(ScreenCaptureTarget::CameraOnly) | Some(ScreenCaptureTarget::AudioOnly) + ) { return; } @@ -3646,7 +3679,13 @@ pub fn needs_fragment_remux(recording_dir: &Path, meta: &StudioRecordingMeta) -> }; for segment in &inner.segments { - let display_path = segment.display.path.to_path(recording_dir); + let Some(display_path) = segment + .display + .as_ref() + .map(|d| d.path.to_path(recording_dir)) + else { + continue; + }; if display_path.is_dir() { return true; } @@ -3699,7 +3738,9 @@ pub fn remux_fragmented_recording_with_trigger( inner .segments .iter() - .filter_map(|seg| seg.display.start_time) + .filter_map(|seg| { + seg.display.as_ref().and_then(|d| d.start_time) + }) .fold(0.0_f64, |acc, v| acc.max(v)), ), StudioRecordingMeta::SingleSegment { .. } => None, @@ -4034,6 +4075,27 @@ mod tests { )); } + #[test] + fn skips_screen_presentation_defaults_for_audio_only_recordings() { + let mut config = ProjectConfiguration::default(); + + apply_screen_recording_presentation_defaults( + &mut config, + Some(&ScreenCaptureTarget::AudioOnly), + true, + Some("wallpaper.jpg".to_string()), + ); + + assert_eq!(config.background.padding, 0.0); + assert!(matches!( + config.background.source, + cap_project::BackgroundSource::Color { + value: [255, 255, 255], + alpha: 255, + } + )); + } + #[test] fn applies_screen_presentation_defaults_for_screen_recordings() { let mut config = ProjectConfiguration::default(); diff --git a/apps/desktop/src-tauri/src/recording_telemetry.rs b/apps/desktop/src-tauri/src/recording_telemetry.rs index b13bcccf89f..0fd3ea7ecfb 100644 --- a/apps/desktop/src-tauri/src/recording_telemetry.rs +++ b/apps/desktop/src-tauri/src/recording_telemetry.rs @@ -186,6 +186,7 @@ pub fn target_kind_label( ScreenCaptureTarget::Window { .. } => "window", ScreenCaptureTarget::Area { .. } => "area", ScreenCaptureTarget::CameraOnly => "camera_only", + ScreenCaptureTarget::AudioOnly => "audio_only", } } diff --git a/apps/desktop/src-tauri/src/recovery.rs b/apps/desktop/src-tauri/src/recovery.rs index b038e169494..2f421f7f5d6 100644 --- a/apps/desktop/src-tauri/src/recovery.rs +++ b/apps/desktop/src-tauri/src/recovery.rs @@ -127,25 +127,29 @@ pub async fn recover_recording(app: AppHandle, project_path: String) -> Result { - segment.display.path.to_path(&recovered.project_path) - } + StudioRecordingMeta::SingleSegment { segment } => segment + .display + .as_ref() + .map(|d| d.path.to_path(&recovered.project_path)), StudioRecordingMeta::MultipleSegments { inner, .. } => inner.segments[0] .display - .path - .to_path(&recovered.project_path), + .as_ref() + .map(|d| d.path.to_path(&recovered.project_path)), }; - let screenshots_dir = recovered.project_path.join("screenshots"); - std::fs::create_dir_all(&screenshots_dir) - .map_err(|e| format!("Failed to create screenshots directory: {e}"))?; - - let display_screenshot = screenshots_dir.join("display.jpg"); - tokio::spawn(async move { - if let Err(e) = create_screenshot(display_output_path, display_screenshot, None).await { - tracing::error!("Failed to create screenshot during recovery: {}", e); - } - }); + if let Some(display_output_path) = display_output_path { + let screenshots_dir = recovered.project_path.join("screenshots"); + std::fs::create_dir_all(&screenshots_dir) + .map_err(|e| format!("Failed to create screenshots directory: {e}"))?; + + let display_screenshot = screenshots_dir.join("display.jpg"); + tokio::spawn(async move { + if let Err(e) = create_screenshot(display_output_path, display_screenshot, None).await + { + tracing::error!("Failed to create screenshot during recovery: {}", e); + } + }); + } Ok(project_path) } diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index 5a1777a9b2b..b93e9aea25e 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -237,7 +237,7 @@ impl ScreenshotEditorInstances { device_id: None, }; let segment = SingleSegment { - display: video_meta.clone(), + display: Some(video_meta.clone()), camera: None, audio: None, cursor: None, @@ -250,6 +250,7 @@ impl ScreenshotEditorInstances { sharing: None, inner: RecordingMetaInner::Studio(Box::new(studio_meta.clone())), upload: None, + audio_only: false, } }; @@ -792,7 +793,7 @@ pub async fn prewarm_screenshot_renderer() { }; let studio_meta = StudioRecordingMeta::SingleSegment { segment: SingleSegment { - display: video_meta, + display: Some(video_meta), camera: None, audio: None, cursor: None, @@ -805,6 +806,7 @@ pub async fn prewarm_screenshot_renderer() { sharing: None, inner: RecordingMetaInner::Studio(Box::new(studio_meta.clone())), upload: None, + audio_only: false, }; let options = cap_rendering::RenderOptions { @@ -1476,7 +1478,7 @@ pub async fn render_screenshot_png(instance: &ScreenshotEditorInstance) -> Resul device_id: None, }; let segment = SingleSegment { - display: video_meta.clone(), + display: Some(video_meta.clone()), camera: None, audio: None, cursor: None, @@ -1489,6 +1491,7 @@ pub async fn render_screenshot_png(instance: &ScreenshotEditorInstance) -> Resul sharing: None, inner: RecordingMetaInner::Studio(Box::new(studio_meta)), upload: None, + audio_only: false, } }; diff --git a/crates/editor/examples/editor-playback-benchmark.rs b/crates/editor/examples/editor-playback-benchmark.rs index a2e196dbc24..cab667cccb3 100644 --- a/crates/editor/examples/editor-playback-benchmark.rs +++ b/crates/editor/examples/editor-playback-benchmark.rs @@ -315,7 +315,11 @@ async fn load_recording( if project.timeline.is_none() { let timeline_segments = match meta.as_ref() { StudioRecordingMeta::SingleSegment { segment } => { - let display_path = recording_meta.path(&segment.display.path); + let display_path = segment + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .unwrap_or_default(); let duration = match Video::new(&display_path, 0.0) { Ok(video) => video.duration, Err(_) => 5.0, @@ -333,7 +337,11 @@ async fn load_recording( .iter() .enumerate() .filter_map(|(i, segment)| { - let display_path = recording_meta.path(&segment.display.path); + let display_path = segment + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .unwrap_or_default(); let duration = match Video::new(&display_path, 0.0) { Ok(video) => video.duration, Err(_) => 5.0, diff --git a/crates/editor/examples/playback-pipeline-benchmark.rs b/crates/editor/examples/playback-pipeline-benchmark.rs index ccdaa2c2c45..348d67b3236 100644 --- a/crates/editor/examples/playback-pipeline-benchmark.rs +++ b/crates/editor/examples/playback-pipeline-benchmark.rs @@ -265,7 +265,11 @@ async fn load_recording( if project.timeline.is_none() { let timeline_segments = match meta.as_ref() { StudioRecordingMeta::SingleSegment { segment } => { - let display_path = recording_meta.path(&segment.display.path); + let display_path = segment + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .unwrap_or_default(); let duration = match cap_rendering::Video::new(&display_path, 0.0) { Ok(v) => v.duration, Err(_) => 5.0, @@ -283,7 +287,11 @@ async fn load_recording( .iter() .enumerate() .filter_map(|(i, segment)| { - let display_path = recording_meta.path(&segment.display.path); + let display_path = segment + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .unwrap_or_default(); let duration = match cap_rendering::Video::new(&display_path, 0.0) { Ok(v) => v.duration, Err(_) => 5.0, @@ -334,17 +342,27 @@ async fn run_decode_only_benchmark( let mut timings = PipelineTimings::default(); let display_path = match meta { - StudioRecordingMeta::SingleSegment { segment } => { - recording_meta.path(&segment.display.path) - } - StudioRecordingMeta::MultipleSegments { inner } => { - recording_meta.path(&inner.segments[0].display.path) - } + StudioRecordingMeta::SingleSegment { segment } => segment + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .unwrap_or_default(), + StudioRecordingMeta::MultipleSegments { inner } => inner.segments[0] + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .unwrap_or_default(), }; let display_fps = match meta { - StudioRecordingMeta::SingleSegment { segment } => segment.display.fps, - StudioRecordingMeta::MultipleSegments { inner } => inner.segments[0].display.fps, + StudioRecordingMeta::SingleSegment { segment } => { + segment.display.as_ref().map(|d| d.fps).unwrap_or(0) + } + StudioRecordingMeta::MultipleSegments { inner } => inner.segments[0] + .display + .as_ref() + .map(|d| d.fps) + .unwrap_or(0), }; let decoder = match spawn_decoder( @@ -640,13 +658,16 @@ async fn run_scrubbing_benchmark( } let display_paths: Vec = match meta { - StudioRecordingMeta::SingleSegment { segment } => { - vec![recording_meta.path(&segment.display.path)] - } + StudioRecordingMeta::SingleSegment { segment } => segment + .display + .as_ref() + .map(|d| vec![recording_meta.path(&d.path)]) + .unwrap_or_default(), StudioRecordingMeta::MultipleSegments { inner } => inner .segments .iter() - .map(|segment| recording_meta.path(&segment.display.path)) + .filter_map(|segment| segment.display.as_ref()) + .map(|display| recording_meta.path(&display.path)) .collect(), }; let keyframe_stats: Vec> = display_paths diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 68dbb7a2787..83043e519cf 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -139,22 +139,26 @@ impl EditorInstance { warn!("Project config has no timeline, creating one from recording segments"); let timeline_segments = match meta.as_ref() { StudioRecordingMeta::SingleSegment { segment } => { - let display_path = recording_meta.path(&segment.display.path); - match display_video_duration(&display_path) { - Some(duration) if duration > 0.0 => vec![TimelineSegment { - recording_clip: 0, - start: 0.0, - end: duration, - timescale: 1.0, - name: None, - }], - _ => { - warn!( - "Failed to determine display duration for {}, leaving timeline unset", - display_path.display() - ); - Vec::new() + if let Some(display) = segment.display.as_ref() { + let display_path = recording_meta.path(&display.path); + match display_video_duration(&display_path) { + Some(duration) if duration > 0.0 => vec![TimelineSegment { + recording_clip: 0, + start: 0.0, + end: duration, + timescale: 1.0, + name: None, + }], + _ => { + warn!( + "Failed to determine display duration for {}, leaving timeline unset", + display_path.display() + ); + Vec::new() + } } + } else { + Vec::new() } } StudioRecordingMeta::MultipleSegments { inner } => inner @@ -162,7 +166,8 @@ impl EditorInstance { .iter() .enumerate() .filter_map(|(i, segment)| { - let display_path = recording_meta.path(&segment.display.path); + let display = segment.display.as_ref()?; + let display_path = recording_meta.path(&display.path); tracing::debug!( "Attempting to get duration for segment {}: {:?}", i, @@ -809,7 +814,11 @@ pub async fn create_segments( recording_meta, meta, SegmentVideoPaths { - display: recording_meta.path(&s.display.path), + display: s + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .ok_or("SingleSegment / missing display metadata")?, camera: s.camera.as_ref().map(|c| recording_meta.path(&c.path)), }, 0, @@ -857,7 +866,13 @@ pub async fn create_segments( recording_meta, meta, SegmentVideoPaths { - display: recording_meta.path(&s.display.path), + display: s + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .ok_or_else(|| { + format!("MultipleSegments {i} / missing display metadata") + })?, camera: s.camera.as_ref().map(|c| recording_meta.path(&c.path)), }, i, diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index 35efde97b70..0b438a9e3bc 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -98,6 +98,8 @@ pub struct RecordingMeta { pub inner: RecordingMetaInner, #[serde(default, skip_serializing_if = "Option::is_none")] pub upload: Option, + #[serde(default)] + pub audio_only: bool, } #[derive(Deserialize, Serialize, Clone, Type, Debug)] @@ -258,7 +260,9 @@ impl RecordingMeta { match &mut self.inner { RecordingMetaInner::Studio(meta) => match meta.as_mut() { StudioRecordingMeta::SingleSegment { segment } => { - normalize_video(&mut segment.display); + if let Some(display) = &mut segment.display { + normalize_video(display); + } if let Some(camera) = &mut segment.camera { normalize_video(camera); } @@ -269,7 +273,9 @@ impl RecordingMeta { } StudioRecordingMeta::MultipleSegments { inner } => { for segment in &mut inner.segments { - normalize_video(&mut segment.display); + if let Some(display) = &mut segment.display { + normalize_video(display); + } if let Some(camera) = &mut segment.camera { normalize_video(camera); } @@ -358,19 +364,25 @@ impl StudioRecordingMeta { pub fn min_fps(&self) -> u32 { match self { - Self::SingleSegment { segment } => segment.display.fps, - Self::MultipleSegments { inner, .. } => { - inner.segments.iter().map(|s| s.display.fps).min().unwrap() - } + Self::SingleSegment { segment } => segment.display.as_ref().map(|d| d.fps).unwrap_or(0), + Self::MultipleSegments { inner, .. } => inner + .segments + .iter() + .filter_map(|s| s.display.as_ref().map(|d| d.fps)) + .min() + .unwrap_or(0), } } pub fn max_fps(&self) -> u32 { match self { - Self::SingleSegment { segment } => segment.display.fps, - Self::MultipleSegments { inner, .. } => { - inner.segments.iter().map(|s| s.display.fps).max().unwrap() - } + Self::SingleSegment { segment } => segment.display.as_ref().map(|d| d.fps).unwrap_or(0), + Self::MultipleSegments { inner, .. } => inner + .segments + .iter() + .filter_map(|s| s.display.as_ref().map(|d| d.fps)) + .max() + .unwrap_or(0), } } } @@ -378,7 +390,8 @@ impl StudioRecordingMeta { #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct SingleSegment { - pub display: VideoMeta, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub camera: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -478,7 +491,8 @@ impl MultipleSegments { #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct MultipleSegment { - pub display: VideoMeta, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub camera: Option, #[serde(default, skip_serializing_if = "Option::is_none", alias = "audio")] @@ -527,7 +541,7 @@ impl MultipleSegment { pub fn keyboard_events(&self, meta: &RecordingMeta) -> KeyboardEvents { let keyboard_path = self.keyboard.clone().or_else(|| { - let display_dir = self.display.path.parent()?; + let display_dir = self.display.as_ref()?.path.parent()?; let binary = display_dir.join(crate::KEYBOARD_EVENTS_FILE_NAME); let binary_full = meta.path(&binary); if binary_full.exists() { @@ -555,7 +569,7 @@ impl MultipleSegment { } pub fn latest_start_time(&self) -> Option { - let mut value = self.display.start_time?; + let mut value = self.display.as_ref()?.start_time?; if let Some(camera) = &self.camera { value = value.max(camera.start_time?); diff --git a/crates/recording/examples/playback-test-runner.rs b/crates/recording/examples/playback-test-runner.rs index 7acb9cccbf3..0d43fcc1ee7 100644 --- a/crates/recording/examples/playback-test-runner.rs +++ b/crates/recording/examples/playback-test-runner.rs @@ -348,12 +348,20 @@ async fn test_playback( }; let display_path = match meta { - StudioRecordingMeta::SingleSegment { segment } => { - recording_meta.path(&segment.display.path) - } - StudioRecordingMeta::MultipleSegments { inner } => { - recording_meta.path(&inner.segments[segment_index].display.path) - } + StudioRecordingMeta::SingleSegment { segment } => recording_meta.path( + &segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ), + StudioRecordingMeta::MultipleSegments { inner } => recording_meta.path( + &inner.segments[segment_index] + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ), }; let decoder = match spawn_decoder("display", display_path.clone(), fps, 0.0, false).await { @@ -461,14 +469,25 @@ async fn test_audio_sync( let (display_path, mic_path, system_audio_path) = match meta { StudioRecordingMeta::SingleSegment { segment } => ( - recording_meta.path(&segment.display.path), + recording_meta.path( + &segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ), segment.audio.as_ref().map(|a| recording_meta.path(&a.path)), None, ), StudioRecordingMeta::MultipleSegments { inner } => { let seg = &inner.segments[segment_index]; ( - recording_meta.path(&seg.display.path), + recording_meta.path( + &seg.display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ), seg.mic.as_ref().map(|m| recording_meta.path(&m.path)), seg.system_audio .as_ref() @@ -547,20 +566,31 @@ async fn test_camera_sync( let (display_path, camera_path, display_start_time, camera_start_time) = match meta { StudioRecordingMeta::SingleSegment { segment } => ( - recording_meta.path(&segment.display.path), + recording_meta.path( + &segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ), segment .camera .as_ref() .map(|c| recording_meta.path(&c.path)), - segment.display.start_time, + segment.display.as_ref().and_then(|d| d.start_time), segment.camera.as_ref().and_then(|c| c.start_time), ), StudioRecordingMeta::MultipleSegments { inner } => { let seg = &inner.segments[segment_index]; ( - recording_meta.path(&seg.display.path), + recording_meta.path( + &seg.display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ), seg.camera.as_ref().map(|c| recording_meta.path(&c.path)), - seg.display.start_time, + seg.display.as_ref().and_then(|d| d.start_time), seg.camera.as_ref().and_then(|c| c.start_time), ) } @@ -754,9 +784,26 @@ async fn run_tests_on_recording( }; let is_fragmented = match studio_meta.as_ref() { - StudioRecordingMeta::SingleSegment { segment } => meta.path(&segment.display.path).is_dir(), + StudioRecordingMeta::SingleSegment { segment } => meta + .path( + &segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ) + .is_dir(), StudioRecordingMeta::MultipleSegments { inner } => { - !inner.segments.is_empty() && meta.path(&inner.segments[0].display.path).is_dir() + !inner.segments.is_empty() + && meta + .path( + &inner.segments[0] + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ) + .is_dir() } }; @@ -786,10 +833,20 @@ async fn run_tests_on_recording( for segment_idx in 0..segment_count { if run_decoder { let display_path = match studio_meta.as_ref() { - StudioRecordingMeta::SingleSegment { segment } => meta.path(&segment.display.path), - StudioRecordingMeta::MultipleSegments { inner } => { - meta.path(&inner.segments[segment_idx].display.path) - } + StudioRecordingMeta::SingleSegment { segment } => meta.path( + &segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ), + StudioRecordingMeta::MultipleSegments { inner } => meta.path( + &inner.segments[segment_idx] + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ), }; if verbose { diff --git a/crates/recording/examples/real-device-test-runner.rs b/crates/recording/examples/real-device-test-runner.rs index 615c1357940..21c5c174dd7 100644 --- a/crates/recording/examples/real-device-test-runner.rs +++ b/crates/recording/examples/real-device-test-runner.rs @@ -615,7 +615,7 @@ fn validate_av_sync(meta: &RecordingMeta) -> AVSyncValidation { if let StudioRecordingMeta::MultipleSegments { inner } = studio_meta.as_ref() { for (idx, segment) in inner.segments.iter().enumerate() { - let display_start = segment.display.start_time; + let display_start = segment.display.as_ref().and_then(|d| d.start_time); let camera_start = segment.camera.as_ref().and_then(|c| c.start_time); let mic_start = segment.mic.as_ref().and_then(|m| m.start_time); let system_audio_start = segment.system_audio.as_ref().and_then(|s| s.start_time); @@ -749,7 +749,7 @@ fn validate_segment_timing(meta: &RecordingMeta) -> SegmentTimingValidation { if let StudioRecordingMeta::MultipleSegments { inner } = studio_meta.as_ref() { for (idx, segment) in inner.segments.iter().enumerate() { - if let Some(start_time) = segment.display.start_time + if let Some(start_time) = segment.display.as_ref().and_then(|d| d.start_time) && start_time.abs() > START_TIME_THRESHOLD { result.all_valid = false; @@ -978,7 +978,13 @@ async fn analyze_frame_rate( match studio_meta.as_ref() { StudioRecordingMeta::SingleSegment { segment } => { - let display_path = meta.path(&segment.display.path); + let display_path = meta.path( + &segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ); let expected_dur = expected_durations.first().copied().unwrap_or_default(); let file_path = if display_path.is_dir() { @@ -1015,7 +1021,13 @@ async fn analyze_frame_rate( } StudioRecordingMeta::MultipleSegments { inner } => { for (idx, segment) in inner.segments.iter().enumerate() { - let display_path = meta.path(&segment.display.path); + let display_path = meta.path( + &segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ); let expected_dur = expected_durations.get(idx).copied().unwrap_or_default(); let file_path = if display_path.is_dir() { @@ -1178,7 +1190,13 @@ async fn analyze_audio_timing( ..Default::default() }; - let display_path = meta.path(&segment.display.path); + let display_path = meta.path( + &segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ); let video_duration_secs = probe_media_duration(&display_path) .await .map(|d| d.as_secs_f64()) @@ -1220,7 +1238,13 @@ async fn analyze_audio_timing( ..Default::default() }; - let display_path = meta.path(&segment.display.path); + let display_path = meta.path( + &segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ); let video_duration_secs = probe_media_duration(&display_path) .await .map(|d| d.as_secs_f64()) @@ -1304,7 +1328,13 @@ async fn validate_duration( match &meta.inner { RecordingMetaInner::Studio(studio_meta) => match studio_meta.as_ref() { StudioRecordingMeta::SingleSegment { segment } => { - let display_path = meta.path(&segment.display.path); + let display_path = meta.path( + &segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ); let actual = probe_media_duration(&display_path) .await .unwrap_or_default(); @@ -1321,7 +1351,13 @@ async fn validate_duration( } StudioRecordingMeta::MultipleSegments { inner } => { for (idx, segment) in inner.segments.iter().enumerate() { - let display_path = meta.path(&segment.display.path); + let display_path = meta.path( + &segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ); let actual = probe_media_duration(&display_path) .await .unwrap_or_default(); @@ -1455,6 +1491,7 @@ async fn execute_recording( sharing: None, inner: RecordingMetaInner::Studio(Box::new(completed.meta)), upload: None, + audio_only: false, }; meta.save_for_project() .map_err(|e| anyhow::anyhow!("Failed to save recording metadata: {:?}", e))?; diff --git a/crates/recording/src/capture_pipeline.rs b/crates/recording/src/capture_pipeline.rs index d21c01d1167..93346cfc96f 100644 --- a/crates/recording/src/capture_pipeline.rs +++ b/crates/recording/src/capture_pipeline.rs @@ -424,6 +424,13 @@ pub fn target_to_display_and_crop( ) -> anyhow::Result<(scap_targets::Display, Option)> { use scap_targets::{bounds::*, *}; + if matches!( + target, + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly + ) { + return Err(anyhow!("Camera-only/Audio-only target has no display")); + } + let display = target .display() .ok_or_else(|| anyhow!("Display not found"))?; @@ -550,7 +557,7 @@ pub fn target_to_display_and_crop( )) } } - ScreenCaptureTarget::CameraOnly => { + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => { return Err(anyhow!("Camera-only target has no display")); } }; diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index 6ab8850c203..cf235c349ca 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -26,7 +26,7 @@ use tracing::*; struct Pipeline { video: OutputPipeline, audio: Option, - video_info: VideoInfo, + video_info: Option, segments_dir: PathBuf, segment_rx: Option>, @@ -113,7 +113,7 @@ pub struct Actor { recording_dir: PathBuf, output_dir: PathBuf, capture_target: ScreenCaptureTarget, - video_info: VideoInfo, + video_info: Option, state: ActorState, total_pause_duration: std::time::Duration, pause_started_at: Option, @@ -207,7 +207,7 @@ impl Message for Actor { Ok(CompletedRecording { project_path: self.recording_dir.clone(), meta: InstantRecordingMeta::Complete { - fps: self.video_info.fps(), + fps: self.video_info.map(|v| v.fps()).unwrap_or(0), sample_rate: None, }, display_source: self.capture_target.clone(), @@ -390,12 +390,12 @@ async fn create_pipeline( Ok(Pipeline { video, audio, - video_info: VideoInfo::from_raw_ffmpeg( + video_info: Some(VideoInfo::from_raw_ffmpeg( screen_info.pixel_format, output_resolution.0, output_resolution.1, screen_info.fps(), - ), + )), segments_dir, segment_rx, }) @@ -542,11 +542,11 @@ pub async fn spawn_instant_recording_actor( Pipeline { video: cam_pipeline, audio: None, - video_info, + video_info: Some(video_info), segments_dir: content_dir.clone(), segment_rx: None, }, - video_info, + Some(video_info), ) } @@ -599,14 +599,42 @@ pub async fn spawn_instant_recording_actor( Pipeline { video: cam_pipeline, audio: None, - video_info, + video_info: Some(video_info), segments_dir: content_dir.clone(), segment_rx: None, }, - video_info, + Some(video_info), ) } } + ScreenCaptureTarget::AudioOnly => { + let mic_feed = inputs.mic_feed.clone().ok_or_else(|| { + anyhow::anyhow!( + "Audio-only recording requires a microphone, but none is available. \ + Please select a microphone in the recording settings." + ) + })?; + + let output_path = content_dir.join("output.mp4"); + + let audio_pipeline = OutputPipeline::builder(output_path) + .with_timestamps(timestamps) + .with_audio_source::(mic_feed) + .build::(()) + .await + .context("audio-only pipeline setup")?; + + ( + Pipeline { + video: audio_pipeline, + audio: None, + video_info: None, + segments_dir: content_dir.clone(), + segment_rx: None, + }, + None, + ) + } _ => { #[cfg(windows)] let d3d_device = crate::capture_pipeline::create_d3d_device()?; diff --git a/crates/recording/src/recovery.rs b/crates/recording/src/recovery.rs index 2eba7e9145a..66eb3c542e0 100644 --- a/crates/recording/src/recovery.rs +++ b/crates/recording/src/recovery.rs @@ -223,7 +223,7 @@ impl RecoveryManager { display_init_segment = None; } - if display_fragments.is_empty() { + if display_fragments.is_empty() && !meta.audio_only { debug!( "No display fragments found for segment {} at {:?}", index, segment_path @@ -1267,18 +1267,26 @@ impl RecoveryManager { } }; - let display_start_time = original_segment.and_then(|s| s.display.start_time); + let display_start_time = original_segment + .and_then(|s| s.display.as_ref()) + .and_then(|d| d.start_time); let get_start_time_or_fallback = |original_time: Option| -> Option { start_time_or_display_fallback(original_time, display_start_time) }; MultipleSegment { - display: VideoMeta { - path: RelativePathBuf::from(format!("{segment_base}/display.mp4")), - fps, - start_time: display_start_time, - device_id: original_segment.and_then(|s| s.display.device_id.clone()), + display: if seg.display_fragments.is_empty() { + None + } else { + Some(VideoMeta { + path: RelativePathBuf::from(format!("{segment_base}/display.mp4")), + fps, + start_time: display_start_time, + device_id: original_segment + .and_then(|s| s.display.as_ref()) + .and_then(|d| d.device_id.clone()), + }) }, camera: if camera_path.exists() { Some(VideoMeta { @@ -1388,19 +1396,25 @@ impl RecoveryManager { .iter() .enumerate() .filter_map(|(i, segment)| { - let display_path = recording.project_path.join(segment.display.path.as_str()); - - let duration = get_media_duration(&display_path) - .map(|d| d.as_secs_f64()) - .unwrap_or_else(|| { - let fps = segment.display.fps as f64; - if fps > 0.0 { + let duration = if let Some(display) = segment.display.as_ref() { + let display_path = recording.project_path.join(display.path.as_str()); + get_media_duration(&display_path) + .map(|d| d.as_secs_f64()) + .unwrap_or_else(|| { recording.estimated_duration.as_secs_f64() / recording.recoverable_segments.len() as f64 - } else { - 5.0 - } - }); + }) + } else if let Some(mic) = segment.mic.as_ref() { + let mic_path = recording.project_path.join(mic.path.as_str()); + get_media_duration(&mic_path) + .map(|d| d.as_secs_f64()) + .unwrap_or_else(|| { + recording.estimated_duration.as_secs_f64() + / recording.recoverable_segments.len() as f64 + }) + } else { + 5.0 + }; if duration <= 0.0 { return None; diff --git a/crates/recording/src/screenshot.rs b/crates/recording/src/screenshot.rs index 362e1441c1c..2930fcb56e1 100644 --- a/crates/recording/src/screenshot.rs +++ b/crates/recording/src/screenshot.rs @@ -330,7 +330,7 @@ fn try_fast_capture(target: &ScreenCaptureTarget) -> Option { } unsafe { core_graphics::image::CGImage::from_ptr(image) } } - ScreenCaptureTarget::CameraOnly => { + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => { return None; } }; @@ -901,7 +901,7 @@ pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result { + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => { return Err(anyhow!("Camera-only not supported for screenshots")); } }; @@ -919,7 +919,7 @@ pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result { + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => { return Err(anyhow!("Camera-only not supported for screenshots")); } } as usize; @@ -937,7 +937,7 @@ pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result { + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => { return Err(anyhow!("Camera-only not supported for screenshots")); } } as usize; diff --git a/crates/recording/src/sources/screen_capture/mod.rs b/crates/recording/src/sources/screen_capture/mod.rs index 566431e246a..edffd866a15 100644 --- a/crates/recording/src/sources/screen_capture/mod.rs +++ b/crates/recording/src/sources/screen_capture/mod.rs @@ -68,6 +68,7 @@ pub enum ScreenCaptureTarget { bounds: LogicalBounds, }, CameraOnly, + AudioOnly, } #[cfg(target_os = "linux")] @@ -84,7 +85,9 @@ impl LinuxCaptureSource { match target { ScreenCaptureTarget::Window { .. } => Self::Window, ScreenCaptureTarget::Area { .. } => Self::Area, - ScreenCaptureTarget::Display { .. } | ScreenCaptureTarget::CameraOnly => Self::Display, + ScreenCaptureTarget::Display { .. } + | ScreenCaptureTarget::CameraOnly + | ScreenCaptureTarget::AudioOnly => Self::Display, } } } @@ -96,6 +99,7 @@ impl ScreenCaptureTarget { Self::Window { id } => Window::from_id(id).and_then(|w| w.display()), Self::Area { screen, .. } => Display::from_id(screen), Self::CameraOnly => None, + Self::AudioOnly => None, } } @@ -232,6 +236,7 @@ impl ScreenCaptureTarget { } } Self::CameraOnly => None, + Self::AudioOnly => None, } } @@ -250,6 +255,7 @@ impl ScreenCaptureTarget { )) } Self::CameraOnly => None, + Self::AudioOnly => None, } } @@ -259,6 +265,7 @@ impl ScreenCaptureTarget { Self::Window { id } => Window::from_id(id).and_then(|w| w.name()), Self::Area { screen, .. } => Display::from_id(screen).and_then(|d| d.name()), Self::CameraOnly => Some("Camera".to_string()), + Self::AudioOnly => Some("Audio".to_string()), } } @@ -268,6 +275,7 @@ impl ScreenCaptureTarget { ScreenCaptureTarget::Window { .. } => "Window", ScreenCaptureTarget::Area { .. } => "Area", ScreenCaptureTarget::CameraOnly => "Camera", + ScreenCaptureTarget::AudioOnly => "Audio", } } } diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 3789bcd0513..dc38e673944 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -355,7 +355,7 @@ pub struct ScreenPipelineOutput { struct Pipeline { pub start_time: Timestamps, // sources - pub screen: OutputPipeline, + pub screen: Option, pub microphone: Option, pub camera: Option, pub system_audio: Option, @@ -367,7 +367,7 @@ struct Pipeline { struct FinishedPipeline { pub start_time: Timestamps, // sources - pub screen: FinishedOutputPipeline, + pub screen: Option, pub microphone: Option, pub camera: Option, pub system_audio: Option, @@ -524,7 +524,7 @@ impl Pipeline { OptionFuture::from(self.system_audio.map(|s| s.stop())) ); - let screen = self.screen.stop().await; + let screen = OptionFuture::from(self.screen.map(|s| s.stop())).await; if let Some(cursor) = self.cursor.as_mut() { cursor.actor.stop(); @@ -538,7 +538,7 @@ impl Pipeline { Ok(FinishedPipeline { start_time: self.start_time, - screen: screen.context("display")?, + screen: screen.transpose().context("display")?, microphone: finalize_optional_track( RecordingTrackKind::Microphone, microphone.transpose(), @@ -572,10 +572,12 @@ impl Pipeline { >, >, >::new(); - futures.push(Box::pin({ - let done_fut = self.screen.done_fut(); - async move { (RecordingTrackKind::Display, true, done_fut.await) } - })); + if let Some(ref screen) = self.screen { + futures.push(Box::pin({ + let done_fut = screen.done_fut(); + async move { (RecordingTrackKind::Display, true, done_fut.await) } + })); + } if let Some(ref microphone) = self.microphone { futures.push(Box::pin({ @@ -604,10 +606,12 @@ impl Pipeline { let cam_cancel = self.camera.as_ref().map(|p| p.cancel_token()); let sys_cancel = self.system_audio.as_ref().map(|p| p.cancel_token()); - let screen_done = self.screen.done_fut(); + let screen_done = self.screen.as_ref().map(|s| s.done_fut()); tokio::spawn(async move { - // When screen (video) finishes, cancel the other pipelines - let _ = screen_done.await; + let Some(done) = screen_done else { + return; + }; + let _ = done.await; if let Some(token) = mic_cancel.as_ref() { token.cancel(); } @@ -971,23 +975,31 @@ async fn stop_recording( } }); - let raw_display_start = to_start_time(s.pipeline.screen.first_timestamp); - let display_start_time = if let Some(cam_start) = camera_start_time { - let sync_offset = raw_display_start - cam_start; - if sync_offset.abs() > CROSS_TRACK_SNAP_SECS { - cam_start - } else { - raw_display_start - } - } else if let Some(mic_start) = mic_start_time { - let sync_offset = raw_display_start - mic_start; - if sync_offset.abs() > CROSS_TRACK_SNAP_SECS { - mic_start + let raw_display_start = s + .pipeline + .screen + .as_ref() + .map(|sc| to_start_time(sc.first_timestamp)); + let display_start_time = if let Some(raw_display) = raw_display_start { + if let Some(cam_start) = camera_start_time { + let sync_offset = raw_display - cam_start; + if sync_offset.abs() > CROSS_TRACK_SNAP_SECS { + cam_start + } else { + raw_display + } + } else if let Some(mic_start) = mic_start_time { + let sync_offset = raw_display - mic_start; + if sync_offset.abs() > CROSS_TRACK_SNAP_SECS { + mic_start + } else { + raw_display + } } else { - raw_display_start + raw_display } } else { - raw_display_start + mic_start_time.or(camera_start_time).unwrap_or(s.start) }; let diagnostics = @@ -998,16 +1010,17 @@ async fn stop_recording( track_failures: s.pipeline.track_failures.clone(), }); - let display_fps = s - .pipeline - .screen - .video_info - .map(|v| v.fps()) + let screen = s.pipeline.screen.as_ref(); + + let display_fps = screen + .and_then(|sc| sc.video_info.map(|v| v.fps())) .unwrap_or_else(|| { - tracing::warn!( - "Screen video_info missing, using default fps: {}", - DEFAULT_FPS - ); + if screen.is_some() { + tracing::warn!( + "Screen video_info missing, using default fps: {}", + DEFAULT_FPS + ); + } DEFAULT_FPS }); // Use the encoded display-media duration (frame_count / fps), not the wall-clock @@ -1015,20 +1028,24 @@ async fn stop_recording( // recorder persists to project-config.json, so it is what un-edited recordings use; the // editor/export fallbacks only synthesize a timeline when none is present and read the // muxed container duration, which this closely (not bit-exactly) matches. - let display_media_duration = if display_fps > 0 { - s.pipeline.screen.video_frame_count as f64 / f64::from(display_fps) - } else { - 0.0 - }; + let display_media_duration = screen + .map(|sc| { + if display_fps > 0 { + sc.video_frame_count as f64 / f64::from(display_fps) + } else { + 0.0 + } + }) + .unwrap_or(0.0); SegmentOutput { meta: MultipleSegment { - display: VideoMeta { - path: make_relative(&s.pipeline.screen.path), + display: screen.map(|sc| VideoMeta { + path: make_relative(&sc.path), fps: display_fps, start_time: Some(display_start_time), device_id: None, - }, + }), camera: s.pipeline.camera.map(|camera| VideoMeta { path: make_relative(&camera.path), fps: camera.video_info.map(|v| v.fps()).unwrap_or_else(|| { @@ -1126,7 +1143,11 @@ async fn stop_recording( let needs_remux = if fragmented { segment_metas.iter().any(|seg| { - let display_path = seg.display.path.to_path(&recording_dir); + let display_path = seg + .display + .as_ref() + .map(|d| d.path.to_path(&recording_dir)) + .unwrap_or_default(); display_path.is_dir() }) } else { @@ -1387,6 +1408,11 @@ async fn create_segment_pipeline( screen_capture::ScreenCaptureTarget::CameraOnly ); + let audio_only = matches!( + base_inputs.capture_target, + screen_capture::ScreenCaptureTarget::AudioOnly + ); + let (screen, system_audio, cursor_display) = if camera_only { #[cfg(target_os = "linux")] { @@ -1461,8 +1487,17 @@ async fn create_segment_pipeline( .await .context("camera-only screen pipeline setup")?; - (screen, None, None) + (Some(screen), None, None) } + } else if audio_only { + base_inputs.mic_feed.clone().ok_or_else(|| { + anyhow!( + "Audio-only recording requires a microphone, but no microphone is currently \ + available. Please select a microphone in the recording settings before starting." + ) + })?; + + (None, None, None) } else { let capture_target = base_inputs.capture_target.clone(); @@ -1527,11 +1562,11 @@ async fn create_segment_pipeline( .await .context("screen pipeline setup")?; - (screen, system_audio, Some(display)) + (Some(screen), system_audio, Some(display)) }; #[cfg(target_os = "macos")] - let camera = if camera_only { + let camera = if camera_only || audio_only { None } else if let Some(camera_feed) = base_inputs.camera_feed { let pipeline = if segment_fragmented { @@ -1562,7 +1597,7 @@ async fn create_segment_pipeline( }; #[cfg(windows)] - let camera = if camera_only { + let camera = if camera_only || audio_only { None } else if let Some(camera_feed) = base_inputs.camera_feed { let pipeline = if segment_fragmented { @@ -1593,7 +1628,7 @@ async fn create_segment_pipeline( }; #[cfg(target_os = "linux")] - let camera = if camera_only { + let camera = if camera_only || audio_only { None } else if let Some(camera_feed) = base_inputs.camera_feed { let pipeline = if segment_fragmented { @@ -1670,7 +1705,7 @@ async fn create_segment_pipeline( None }; - let cursor = if camera_only { + let cursor = if camera_only || audio_only { None } else { (custom_cursor_capture || keyboard_capture) @@ -1747,6 +1782,10 @@ fn persist_final_recording_meta(recording_dir: &Path, studio_meta: &StudioRecord use chrono::Local; let pretty_name = Local::now().format("Cap %Y-%m-%d at %H.%M.%S").to_string(); + let audio_only = RecordingMeta::load_for_project(recording_dir) + .ok() + .map(|m| m.audio_only) + .unwrap_or(false); let recording_meta = RecordingMeta { platform: Some(Platform::default()), project_path: recording_dir.to_path_buf(), @@ -1754,6 +1793,7 @@ fn persist_final_recording_meta(recording_dir: &Path, studio_meta: &StudioRecord sharing: None, inner: RecordingMetaInner::Studio(Box::new(studio_meta.clone())), upload: None, + audio_only, }; if let Err(err) = recording_meta.save_for_project() { @@ -1769,6 +1809,10 @@ fn write_in_progress_meta(recording_dir: &Path) -> anyhow::Result<()> { use chrono::Local; let pretty_name = Local::now().format("Cap %Y-%m-%d at %H.%M.%S").to_string(); + let audio_only = RecordingMeta::load_for_project(recording_dir) + .ok() + .map(|m| m.audio_only) + .unwrap_or(false); let meta = RecordingMeta { platform: Some(Platform::default()), @@ -1783,6 +1827,7 @@ fn write_in_progress_meta(recording_dir: &Path) -> anyhow::Result<()> { }, })), upload: None, + audio_only, }; meta.save_for_project() @@ -2096,12 +2141,12 @@ mod tests { end: 1.0, pipeline: FinishedPipeline { start_time, - screen: test_finished_output_pipeline_at( + screen: Some(test_finished_output_pipeline_at( recording_dir.join("content/display.mp4"), Timestamp::Instant(start_time.instant() + Duration::from_millis(33)), Some(test_video_info()), 1, - ), + )), microphone: None, camera: None, system_audio: None, @@ -2172,7 +2217,7 @@ mod tests { let mut pipeline = Pipeline { start_time: timestamps, - screen, + screen: Some(screen), microphone: Some(microphone), camera: None, system_audio: None, @@ -2214,7 +2259,12 @@ mod tests { .expect("display success should still allow the recording to stop cleanly"); assert_eq!( - finished.screen.video_frame_count, 1, + finished + .screen + .as_ref() + .map(|s| s.video_frame_count) + .unwrap_or(0), + 1, "display output should be preserved" ); assert!( diff --git a/crates/recording/tests/recovery.rs b/crates/recording/tests/recovery.rs index dec50c9c741..5ce6fbb0166 100644 --- a/crates/recording/tests/recovery.rs +++ b/crates/recording/tests/recovery.rs @@ -118,12 +118,12 @@ impl TestRecording { inner: RecordingMetaInner::Studio(Box::new(StudioRecordingMeta::MultipleSegments { inner: MultipleSegments { segments: vec![MultipleSegment { - display: VideoMeta { + display: Some(VideoMeta { path: RelativePathBuf::from("content/segments/segment-0/display.mp4"), fps: 30, start_time: None, device_id: None, - }, + }), camera: None, mic: None, system_audio: None, @@ -134,6 +134,7 @@ impl TestRecording { status: Some(status), }, })), + audio_only: false, }; let meta_path = self.project_path.join("recording-meta.json"); diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index f762f6b2373..72d002a4e63 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -279,10 +279,16 @@ impl RecordingSegmentDecoders { }; let screen_fps = match &meta { - StudioRecordingMeta::SingleSegment { segment } => segment.display.fps, - StudioRecordingMeta::MultipleSegments { inner, .. } => { - inner.segments[segment_i].display.fps - } + StudioRecordingMeta::SingleSegment { segment } => segment + .display + .as_ref() + .map(|d| d.fps) + .ok_or_else(|| "Display metadata missing".to_string())?, + StudioRecordingMeta::MultipleSegments { inner, .. } => inner.segments[segment_i] + .display + .as_ref() + .map(|d| d.fps) + .ok_or_else(|| "Display metadata missing".to_string())?, }; let camera_fps = match &meta { @@ -301,7 +307,7 @@ impl RecordingSegmentDecoders { let segment = &inner.segments[segment_i]; latest_start_time - .zip(segment.display.start_time) + .zip(segment.display.as_ref().and_then(|d| d.start_time)) .map(|(latest_start_time, display_time)| latest_start_time - display_time) .unwrap_or(0.0) } diff --git a/crates/rendering/src/main.rs b/crates/rendering/src/main.rs index 0fef9a0943f..5d51461a265 100644 --- a/crates/rendering/src/main.rs +++ b/crates/rendering/src/main.rs @@ -90,11 +90,16 @@ async fn main() -> Result<()> { let render_segments: Vec = match &studio_meta { StudioRecordingMeta::SingleSegment { segment } => { + let display = segment + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .context("Missing display video")?; let decoders = RecordingSegmentDecoders::new( &recording_meta, &studio_meta, SegmentVideoPaths { - display: recording_meta.path(&segment.display.path), + display, camera: segment .camera .as_ref() @@ -116,11 +121,16 @@ async fn main() -> Result<()> { StudioRecordingMeta::MultipleSegments { inner, .. } => { let mut segments = Vec::new(); for (i, s) in inner.segments.iter().enumerate() { + let display = s + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .context("Missing display video")?; let decoders = RecordingSegmentDecoders::new( &recording_meta, &studio_meta, SegmentVideoPaths { - display: recording_meta.path(&s.display.path), + display, camera: s.camera.as_ref().map(|c| recording_meta.path(&c.path)), }, i, diff --git a/crates/rendering/src/project_recordings.rs b/crates/rendering/src/project_recordings.rs index e0b9985b3e1..92e0e81e35f 100644 --- a/crates/rendering/src/project_recordings.rs +++ b/crates/rendering/src/project_recordings.rs @@ -125,8 +125,14 @@ impl ProjectRecordingsMeta { pub fn new(recording_path: &PathBuf, meta: &StudioRecordingMeta) -> Result { let segments = match &meta { StudioRecordingMeta::SingleSegment { segment: s } => { - let display = Video::new(s.display.path.to_path(recording_path), 0.0) - .expect("Failed to read display video"); + let display = s + .display + .as_ref() + .ok_or_else(|| "SingleSegment missing display".to_string()) + .and_then(|d| { + Video::new(d.path.to_path(recording_path), 0.0) + .map_err(|e| format!("Failed to read display video: {e}")) + })?; let camera = s.camera.as_ref().map(|camera| { Video::new(camera.path.to_path(recording_path), 0.0) .expect("Failed to read camera video") @@ -195,7 +201,11 @@ impl ProjectRecordingsMeta { }; Ok::<_, String>(SegmentRecordings { - display: load_video(&s.display).map_err(|e| format!("video / {e}"))?, + display: s + .display + .as_ref() + .ok_or_else(|| "MultipleSegment missing display".to_string()) + .and_then(|d| load_video(d).map_err(|e| format!("video / {e}")))?, camera: Option::map(s.camera.as_ref(), load_video) .transpose() .map_err(|e| format!("camera / {e}"))?,