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 587de53b43d..acfce1bbf38 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,7 @@ 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, @@ -135,7 +135,7 @@ 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/import.rs b/apps/desktop/src-tauri/src/import.rs index 9ef0e4f8279..b09e964c110 100644 --- a/apps/desktop/src-tauri/src/import.rs +++ b/apps/desktop/src-tauri/src/import.rs @@ -315,7 +315,7 @@ fn full_timeline_for_segments( .iter() .enumerate() .map(|(index, segment)| { - let duration = get_video_duration_secs(&segment.display.path.to_path(project_path))?; + let duration = get_video_duration_secs(&segment.display.as_ref().map(|d| d.path.clone()).unwrap_or_default().to_path(project_path))?; Ok(TimelineSegment { recording_clip: index as u32, timescale: 1.0, @@ -347,7 +347,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, @@ -593,7 +596,7 @@ 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); }; @@ -870,7 +873,10 @@ 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 }; @@ -921,15 +927,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 @@ -1405,12 +1417,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, @@ -1422,6 +1434,7 @@ pub async fn start_video_import(app: AppHandle, source_path: PathBuf) -> Result< }, })), upload: None, + audio_only: false, }; initial_meta @@ -1498,14 +1511,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, @@ -1518,6 +1531,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() { @@ -1674,12 +1688,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, @@ -1965,7 +1979,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, @@ -1978,6 +1992,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 78914f8ce2c..10fa521c202 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2226,6 +2226,7 @@ enum CurrentRecordingTarget { bounds: LogicalBounds, }, Camera, + Audio, } #[derive(Serialize, Type)] @@ -2278,6 +2279,7 @@ async fn get_current_recording( bounds: *bounds, }, ScreenCaptureTarget::CameraOnly => CurrentRecordingTarget::Camera, + ScreenCaptureTarget::AudioOnly => CurrentRecordingTarget::Audio, }; Ok(JsonValue::new(&Some(CurrentRecording { @@ -2906,12 +2908,12 @@ 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 ec215570cfc..9c77a16c8a1 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1194,7 +1194,10 @@ pub async fn start_recording( } let mut inputs = inputs; - if matches!(inputs.capture_target, ScreenCaptureTarget::CameraOnly) { + if matches!( + inputs.capture_target, + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly + ) { inputs.capture_system_audio = false; { @@ -1287,8 +1290,10 @@ pub async fn start_recording( RecordingMode::Instant => { match AuthStore::get(&app).ok().flatten() { Some(_) => { - 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" @@ -1361,6 +1366,7 @@ pub async fn start_recording( }, sharing: None, upload: None, + audio_only: matches!(inputs.capture_target, ScreenCaptureTarget::AudioOnly), }; meta.save_for_project() @@ -1468,7 +1474,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?), }; @@ -2445,7 +2451,7 @@ pub async fn take_screenshot( }; let segment = cap_project::SingleSegment { - display: video_meta, + display: Some(video_meta), camera: None, audio: None, cursor: None, @@ -2460,6 +2466,7 @@ pub async fn take_screenshot( cap_project::StudioRecordingMeta::SingleSegment { segment }, )), upload: None, + audio_only: false, }; meta.save_for_project() @@ -2784,10 +2791,10 @@ async fn handle_recording_finish( let display_output_path = match &updated_studio_meta { StudioRecordingMeta::SingleSegment { segment } => { - segment.display.path.to_path(&recording_dir) + segment.display.as_ref().map(|d| d.path.clone()).unwrap_or_default().to_path(&recording_dir) } StudioRecordingMeta::MultipleSegments { inner, .. } => { - inner.segments[0].display.path.to_path(&recording_dir) + inner.segments[0].display.as_ref().map(|d| d.path.clone()).unwrap_or_default().to_path(&recording_dir) } }; @@ -3028,10 +3035,10 @@ async fn finalize_studio_recording( let display_output_path = match &updated_studio_meta { StudioRecordingMeta::SingleSegment { segment } => { - segment.display.path.to_path(&recording_dir) + segment.display.as_ref().map(|d| d.path.clone()).unwrap_or_default().to_path(&recording_dir) } StudioRecordingMeta::MultipleSegments { inner, .. } => { - inner.segments[0].display.path.to_path(&recording_dir) + inner.segments[0].display.as_ref().map(|d| d.path.clone()).unwrap_or_default().to_path(&recording_dir) } }; @@ -3159,6 +3166,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) @@ -3357,7 +3365,9 @@ 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; } @@ -3410,7 +3420,7 @@ 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, 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..7db9e703929 100644 --- a/apps/desktop/src-tauri/src/recovery.rs +++ b/apps/desktop/src-tauri/src/recovery.rs @@ -128,11 +128,13 @@ pub async fn recover_recording(app: AppHandle, project_path: String) -> Result { - segment.display.path.to_path(&recovered.project_path) + segment.display.as_ref().map(|d| d.path.clone()).unwrap_or_default().to_path(&recovered.project_path) } StudioRecordingMeta::MultipleSegments { inner, .. } => inner.segments[0] .display - .path + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default() .to_path(&recovered.project_path), }; diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index 4118f50a34e..fdcf48d4f57 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -228,7 +228,7 @@ impl ScreenshotEditorInstances { device_id: None, }; let segment = SingleSegment { - display: video_meta.clone(), + display: Some(video_meta.clone()), camera: None, audio: None, cursor: None, @@ -241,6 +241,7 @@ impl ScreenshotEditorInstances { sharing: None, inner: RecordingMetaInner::Studio(Box::new(studio_meta.clone())), upload: None, + audio_only: false, } }; @@ -1252,7 +1253,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, @@ -1265,6 +1266,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 b0c28af0b10..b449ab9baf1 100644 --- a/crates/editor/examples/editor-playback-benchmark.rs +++ b/crates/editor/examples/editor-playback-benchmark.rs @@ -315,7 +315,7 @@ 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, @@ -332,7 +332,7 @@ 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 e7b9c0d0144..7c485c8f61b 100644 --- a/crates/editor/examples/playback-pipeline-benchmark.rs +++ b/crates/editor/examples/playback-pipeline-benchmark.rs @@ -265,7 +265,7 @@ 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, @@ -282,7 +282,7 @@ 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, @@ -332,19 +332,27 @@ async fn run_decode_only_benchmark( let mut timings = PipelineTimings::default(); let display_path = match meta { + 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 } => { - recording_meta.path(&segment.display.path) + segment.display.as_ref().map(|d| d.fps).unwrap_or(0) } StudioRecordingMeta::MultipleSegments { inner } => { - recording_meta.path(&inner.segments[0].display.path) + inner.segments[0].display.as_ref().map(|d| d.fps).unwrap_or(0) } }; - let display_fps = match meta { - StudioRecordingMeta::SingleSegment { segment } => segment.display.fps, - StudioRecordingMeta::MultipleSegments { inner } => inner.segments[0].display.fps, - }; - let decoder = match spawn_decoder( "benchmark-screen", display_path, diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 146bdcf9465..a2dfe486fd0 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -807,7 +807,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)) + .unwrap_or_default(), camera: s.camera.as_ref().map(|c| recording_meta.path(&c.path)), }, 0, @@ -855,7 +859,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)) + .unwrap_or_default(), 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 33360361ec9..9589142ab56 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -94,6 +94,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)] @@ -254,7 +256,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); } @@ -265,7 +269,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); } @@ -354,19 +360,29 @@ 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), } } } @@ -374,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")] @@ -474,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")] @@ -523,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() { @@ -551,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..972415f1cd6 100644 --- a/crates/recording/examples/playback-test-runner.rs +++ b/crates/recording/examples/playback-test-runner.rs @@ -349,10 +349,10 @@ async fn test_playback( let display_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()) } StudioRecordingMeta::MultipleSegments { inner } => { - recording_meta.path(&inner.segments[segment_index].display.path) + recording_meta.path(&inner.segments[segment_index].display.as_ref().map(|d| d.path.clone()).unwrap_or_default()) } }; @@ -461,14 +461,14 @@ 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 +547,20 @@ 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 +754,9 @@ 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,9 +786,9 @@ 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::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.path) + meta.path(&inner.segments[segment_idx].display.as_ref().map(|d| d.path.clone()).unwrap_or_default()) } }; diff --git a/crates/recording/examples/real-device-test-runner.rs b/crates/recording/examples/real-device-test-runner.rs index 615c1357940..6f0cc9f3320 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,7 @@ 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 +1015,7 @@ 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 +1178,7 @@ 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 +1220,7 @@ 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 +1304,7 @@ 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 +1321,7 @@ 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 +1455,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 aa7847aa261..d8dc41a76b5 100644 --- a/crates/recording/src/capture_pipeline.rs +++ b/crates/recording/src/capture_pipeline.rs @@ -446,7 +446,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 7f96464e6e7..53bff8f59d8 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, }) @@ -548,11 +548,43 @@ 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 output_path = content_dir.join("output.mp4"); + let mut builder = OutputPipeline::builder(output_path.clone()) + .with_timestamps(timestamps); + + if let Some(mic_feed) = inputs.mic_feed.clone() { + builder = builder.with_audio_source::(mic_feed); + } + + let audio_pipeline = builder + .build::( + output_pipeline::DashSegmentedAudioMuxerConfig { + shared_pause_state: None, + segment_tx: None, + ..Default::default() + }, + ) + .await + .context("audio-only pipeline setup")?; + + ( + Pipeline { + video: audio_pipeline, + audio: None, + video_info: None, + segments_dir: content_dir.clone(), + segment_rx: None, + }, + None, ) } _ => { diff --git a/crates/recording/src/recovery.rs b/crates/recording/src/recovery.rs index d257a9d259e..39f8a878f1f 100644 --- a/crates/recording/src/recovery.rs +++ b/crates/recording/src/recovery.rs @@ -1186,19 +1186,23 @@ 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 { + display: 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.device_id.clone()), - }, + 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 { path: RelativePathBuf::from(format!("{segment_base}/camera.mp4")), @@ -1307,12 +1311,16 @@ impl RecoveryManager { .iter() .enumerate() .filter_map(|(i, segment)| { - let display_path = recording.project_path.join(segment.display.path.as_str()); + let display_path = segment + .display + .as_ref() + .map(|d| recording.project_path.join(d.path.as_str())) + .unwrap_or_default(); let duration = get_media_duration(&display_path) .map(|d| d.as_secs_f64()) .unwrap_or_else(|| { - let fps = segment.display.fps as f64; + let fps = f64::from(segment.display.as_ref().map(|d| d.fps).unwrap_or(0)); if fps > 0.0 { recording.estimated_duration.as_secs_f64() / recording.recoverable_segments.len() as f64 diff --git a/crates/recording/src/screenshot.rs b/crates/recording/src/screenshot.rs index a468967a76f..7d8f8e0f9d1 100644 --- a/crates/recording/src/screenshot.rs +++ b/crates/recording/src/screenshot.rs @@ -320,7 +320,7 @@ fn try_fast_capture(target: &ScreenCaptureTarget) -> Option { } unsafe { core_graphics::image::CGImage::from_ptr(image) } } - ScreenCaptureTarget::CameraOnly => { + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => { return None; } }; @@ -884,7 +884,7 @@ pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result { + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => { return Err(anyhow!("Camera-only not supported for screenshots")); } }; @@ -902,7 +902,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; @@ -920,7 +920,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 ef7bd644fbd..235fba4f6fb 100644 --- a/crates/recording/src/sources/screen_capture/mod.rs +++ b/crates/recording/src/sources/screen_capture/mod.rs @@ -63,6 +63,7 @@ pub enum ScreenCaptureTarget { bounds: LogicalBounds, }, CameraOnly, + AudioOnly, } impl ScreenCaptureTarget { @@ -72,6 +73,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, } } @@ -171,6 +173,7 @@ impl ScreenCaptureTarget { } } Self::CameraOnly => None, + Self::AudioOnly => None, } } @@ -189,6 +192,7 @@ impl ScreenCaptureTarget { )) } Self::CameraOnly => None, + Self::AudioOnly => None, } } @@ -198,6 +202,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()), } } @@ -207,6 +212,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 f8b975efb63..3f7db413f2a 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,11 @@ 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; + if let Some(done) = screen_done { + let _ = done.await; + } if let Some(token) = mic_cancel.as_ref() { token.cancel(); } @@ -971,23 +974,33 @@ 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 = @@ -1028,7 +1041,7 @@ async fn stop_recording( 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(|| { @@ -1125,7 +1138,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 { @@ -1396,6 +1413,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 { let camera_feed = base_inputs.camera_feed.clone().ok_or_else(|| { anyhow!( @@ -1427,7 +1449,9 @@ async fn create_segment_pipeline( .await .context("camera-only screen pipeline setup")?; - (screen, None, None) + (Some(screen), None, None) + } else if audio_only { + (None, None, None) } else { let capture_target = base_inputs.capture_target.clone(); @@ -1490,11 +1514,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 { @@ -1525,7 +1549,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 { @@ -1603,7 +1627,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) @@ -1687,6 +1711,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: false, }; if let Err(err) = recording_meta.save_for_project() { @@ -1716,6 +1741,7 @@ fn write_in_progress_meta(recording_dir: &Path) -> anyhow::Result<()> { }, })), upload: None, + audio_only: false, }; meta.save_for_project() @@ -2029,12 +2055,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, @@ -2105,7 +2131,7 @@ mod tests { let mut pipeline = Pipeline { start_time: timestamps, - screen, + screen: Some(screen), microphone: Some(microphone), camera: None, system_audio: None, @@ -2147,7 +2173,7 @@ 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 39bfcb69a70..07865dc6569 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -271,10 +271,14 @@ 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).unwrap_or(0) } + StudioRecordingMeta::MultipleSegments { inner, .. } => inner.segments[segment_i] + .display + .as_ref() + .map(|d| d.fps) + .unwrap_or(0), }; let camera_fps = match &meta { @@ -293,7 +297,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..1031d38eb93 100644 --- a/crates/rendering/src/main.rs +++ b/crates/rendering/src/main.rs @@ -94,7 +94,11 @@ async fn main() -> Result<()> { &recording_meta, &studio_meta, SegmentVideoPaths { - display: recording_meta.path(&segment.display.path), + display: segment + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .unwrap_or_default(), camera: segment .camera .as_ref() @@ -120,7 +124,11 @@ async fn main() -> Result<()> { &recording_meta, &studio_meta, SegmentVideoPaths { - display: recording_meta.path(&s.display.path), + display: s + .display + .as_ref() + .map(|d| recording_meta.path(&d.path)) + .unwrap_or_default(), 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}"))?,