From f8cd75a92a3c3852ceb945fabc93edcc5b46e13e Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Mon, 1 Jun 2026 22:47:07 +0530 Subject: [PATCH 01/17] feat(recording): add AudioOnly capture target and recording pipeline foundation --- .../desktop-display-transport-benchmark.rs | 4 +- apps/desktop/src-tauri/src/import.rs | 55 +++++++---- apps/desktop/src-tauri/src/lib.rs | 5 +- apps/desktop/src-tauri/src/recording.rs | 30 +++--- .../src-tauri/src/recording_telemetry.rs | 1 + apps/desktop/src-tauri/src/recovery.rs | 6 +- .../src-tauri/src/screenshot_editor.rs | 6 +- .../examples/editor-playback-benchmark.rs | 4 +- .../examples/playback-pipeline-benchmark.rs | 26 +++-- crates/editor/src/editor_instance.rs | 24 ++++- crates/project/src/meta.rs | 42 +++++--- .../examples/playback-test-runner.rs | 24 ++--- .../examples/real-device-test-runner.rs | 17 ++-- crates/recording/src/capture_pipeline.rs | 2 +- crates/recording/src/instant_recording.rs | 46 +++++++-- crates/recording/src/recovery.rs | 20 ++-- crates/recording/src/screenshot.rs | 8 +- .../src/sources/screen_capture/mod.rs | 6 ++ crates/recording/src/studio_recording.rs | 96 ++++++++++--------- crates/recording/tests/recovery.rs | 5 +- crates/rendering/src/lib.rs | 12 ++- crates/rendering/src/main.rs | 12 ++- crates/rendering/src/project_recordings.rs | 17 +++- 23 files changed, 308 insertions(+), 160 deletions(-) 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 074eda6785a..476b800b5e8 100644 --- a/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs +++ b/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs @@ -117,7 +117,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, @@ -134,7 +134,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 361fbd3e6a4..11f1ff2c7cc 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 @@ -1497,14 +1510,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, @@ -1517,6 +1530,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() { @@ -1672,12 +1686,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, @@ -1963,7 +1977,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, @@ -1976,6 +1990,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 9eda46443f8..e9145c49dfc 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2246,6 +2246,7 @@ async fn get_current_recording( bounds: *bounds, }, ScreenCaptureTarget::CameraOnly => CurrentRecordingTarget::Camera, + ScreenCaptureTarget::AudioOnly => CurrentRecordingTarget::Camera, }; Ok(JsonValue::new(&Some(CurrentRecording { @@ -2874,12 +2875,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 e1986690466..a5205dd4ba0 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -850,7 +850,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; { @@ -943,8 +946,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" @@ -1017,6 +1022,7 @@ pub async fn start_recording( }, sharing: None, upload: None, + audio_only: matches!(inputs.capture_target, ScreenCaptureTarget::AudioOnly), }; meta.save_for_project() @@ -1124,7 +1130,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?), }; @@ -2143,7 +2149,7 @@ pub async fn take_screenshot( }; let segment = cap_project::SingleSegment { - display: video_meta, + display: Some(video_meta), camera: None, audio: None, cursor: None, @@ -2158,6 +2164,7 @@ pub async fn take_screenshot( cap_project::StudioRecordingMeta::SingleSegment { segment }, )), upload: None, + audio_only: false, }; meta.save_for_project() @@ -2482,10 +2489,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) } }; @@ -2725,10 +2732,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) } }; @@ -2855,6 +2862,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) @@ -3041,7 +3049,7 @@ 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 display_path = segment.display.as_ref().map(|d| d.path.clone()).unwrap_or_default().to_path(recording_dir); if display_path.is_dir() { return true; } @@ -3094,7 +3102,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 aff8c3e8508..7f033912381 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 feb13ff0cbe..b2d254fe4d5 100644 --- a/crates/editor/examples/playback-pipeline-benchmark.rs +++ b/crates/editor/examples/playback-pipeline-benchmark.rs @@ -108,7 +108,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, @@ -125,7 +125,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, @@ -174,19 +174,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, display_fps, 0.0, false).await { Ok(d) => d, diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 38ec303dfad..bfe34a7e8dc 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -125,7 +125,11 @@ 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); + 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(v) => v.duration, Err(e) => { @@ -155,7 +159,11 @@ impl EditorInstance { .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(); tracing::debug!("Attempting to get duration for segment {}: {:?}", i, display_path); let duration = match Video::new(&display_path, 0.0) { Ok(v) => { @@ -669,7 +677,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, @@ -716,7 +728,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 cd6cc418624..06ceb144143 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -75,6 +75,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)] @@ -235,7 +237,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); } @@ -246,7 +250,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); } @@ -335,19 +341,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), } } } @@ -355,7 +371,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")] @@ -455,7 +472,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")] @@ -504,7 +522,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() { @@ -532,7 +550,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 e1a91e28d13..0467b9e24fa 100644 --- a/crates/recording/examples/real-device-test-runner.rs +++ b/crates/recording/examples/real-device-test-runner.rs @@ -607,7 +607,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); @@ -741,7 +741,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; @@ -970,7 +970,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() { @@ -1007,7 +1007,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() { @@ -1170,7 +1170,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()) @@ -1212,7 +1212,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()) @@ -1296,7 +1296,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(); @@ -1313,7 +1313,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(); @@ -1447,6 +1447,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 bc28ab07d05..d5d49ed1cbd 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 37afb1a090f..a6b1951dab2 100644 --- a/crates/recording/src/recovery.rs +++ b/crates/recording/src/recovery.rs @@ -1169,19 +1169,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")), @@ -1288,12 +1292,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 131333eb5da..ee618fce220 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -353,7 +353,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, @@ -365,7 +365,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, @@ -510,7 +510,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(); @@ -524,7 +524,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(), @@ -558,10 +558,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({ @@ -590,10 +592,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(); } @@ -957,24 +960,16 @@ 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 - } else { - raw_display_start - } - } else { - raw_display_start - }; + let raw_display_start = s + .pipeline + .screen + .as_ref() + .map(|sc| to_start_time(sc.first_timestamp)); + let display_start_time = raw_display_start.unwrap_or_else(|| { + mic_start_time + .or(camera_start_time) + .unwrap_or(s.start) + }); let diagnostics = (!s.pipeline.track_failures.is_empty()).then(|| SegmentFailureDiagnostics { @@ -986,11 +981,9 @@ async fn stop_recording( SegmentOutput { meta: MultipleSegment { - display: VideoMeta { - path: make_relative(&s.pipeline.screen.path), - fps: s - .pipeline - .screen + display: s.pipeline.screen.as_ref().map(|sc| VideoMeta { + path: make_relative(&sc.path), + fps: sc .video_info .map(|v| v.fps()) .unwrap_or_else(|| { @@ -1002,7 +995,7 @@ async fn stop_recording( }), 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(|| { @@ -1071,7 +1064,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 { @@ -1328,6 +1325,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!( @@ -1359,7 +1361,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(); @@ -1422,11 +1426,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 { @@ -1457,7 +1461,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 { @@ -1535,7 +1539,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) @@ -1619,6 +1623,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() { @@ -1648,6 +1653,7 @@ fn write_in_progress_meta(recording_dir: &Path) -> anyhow::Result<()> { }, })), upload: None, + audio_only: false, }; meta.save_for_project() @@ -1960,12 +1966,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, @@ -2036,7 +2042,7 @@ mod tests { let mut pipeline = Pipeline { start_time: timestamps, - screen, + screen: Some(screen), microphone: Some(microphone), camera: None, system_audio: None, @@ -2078,7 +2084,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 e8110a88d3a..4fd973ef8b3 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..8080a906646 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() + .map(|d| { + Video::new(d.path.to_path(recording_path), 0.0) + .expect("Failed to read display video") + }) + .expect("SingleSegment missing display"); 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,12 @@ impl ProjectRecordingsMeta { }; Ok::<_, String>(SegmentRecordings { - display: load_video(&s.display).map_err(|e| format!("video / {e}"))?, + display: s + .display + .as_ref() + .map(|d| load_video(d).map_err(|e| format!("video / {e}"))) + .transpose()? + .expect("MultipleSegment missing display"), camera: Option::map(s.camera.as_ref(), load_video) .transpose() .map_err(|e| format!("camera / {e}"))?, From ab6bfe38a0ea344126fb76b285d45dc6429cfa88 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Mon, 1 Jun 2026 23:36:44 +0530 Subject: [PATCH 02/17] fix(recording): return Err for audio-only in ProjectRecordingsMeta, add Audio target variant --- apps/desktop/src-tauri/src/lib.rs | 3 ++- crates/rendering/src/project_recordings.rs | 13 ++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index e9145c49dfc..c30d9bce003 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2194,6 +2194,7 @@ enum CurrentRecordingTarget { bounds: LogicalBounds, }, Camera, + Audio, } #[derive(Serialize, Type)] @@ -2246,7 +2247,7 @@ async fn get_current_recording( bounds: *bounds, }, ScreenCaptureTarget::CameraOnly => CurrentRecordingTarget::Camera, - ScreenCaptureTarget::AudioOnly => CurrentRecordingTarget::Camera, + ScreenCaptureTarget::AudioOnly => CurrentRecordingTarget::Audio, }; Ok(JsonValue::new(&Some(CurrentRecording { diff --git a/crates/rendering/src/project_recordings.rs b/crates/rendering/src/project_recordings.rs index 8080a906646..92e0e81e35f 100644 --- a/crates/rendering/src/project_recordings.rs +++ b/crates/rendering/src/project_recordings.rs @@ -128,11 +128,11 @@ impl ProjectRecordingsMeta { let display = s .display .as_ref() - .map(|d| { + .ok_or_else(|| "SingleSegment missing display".to_string()) + .and_then(|d| { Video::new(d.path.to_path(recording_path), 0.0) - .expect("Failed to read display video") - }) - .expect("SingleSegment missing display"); + .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") @@ -204,9 +204,8 @@ impl ProjectRecordingsMeta { display: s .display .as_ref() - .map(|d| load_video(d).map_err(|e| format!("video / {e}"))) - .transpose()? - .expect("MultipleSegment missing display"), + .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}"))?, From 07506b3b22f0fdcd1fd8db131dfee05227797240 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Sun, 7 Jun 2026 09:56:38 +0530 Subject: [PATCH 03/17] fix(recording): restore CROSS_TRACK_SNAP_SECS AV sync for screen recordings --- crates/recording/src/studio_recording.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index ee618fce220..576fde9d685 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -965,11 +965,29 @@ async fn stop_recording( .screen .as_ref() .map(|sc| to_start_time(sc.first_timestamp)); - let display_start_time = raw_display_start.unwrap_or_else(|| { + 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 + } + } else { mic_start_time .or(camera_start_time) .unwrap_or(s.start) - }); + }; let diagnostics = (!s.pipeline.track_failures.is_empty()).then(|| SegmentFailureDiagnostics { From 64507dff5fc5b3b3dd33c395b99b1d1f463187b7 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Sun, 7 Jun 2026 10:35:32 +0530 Subject: [PATCH 04/17] fix(recording): skip audio-only segments in needs_fragment_remux check --- apps/desktop/src-tauri/src/recording.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index a5205dd4ba0..75c28e07175 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -3049,7 +3049,9 @@ pub fn needs_fragment_remux(recording_dir: &Path, meta: &StudioRecordingMeta) -> }; for segment in &inner.segments { - let display_path = segment.display.as_ref().map(|d| d.path.clone()).unwrap_or_default().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; } From b04c68d83eded1dac86376fa778bdca2ae8731e1 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Thu, 18 Jun 2026 21:35:08 +0530 Subject: [PATCH 05/17] fix(audio-only): address review comments and fix compilation errors --- apps/cli/src/project.rs | 14 ++- apps/cli/src/record.rs | 1 + .../desktop-display-transport-benchmark.rs | 12 ++- apps/desktop/src-tauri/src/import.rs | 16 +++- apps/desktop/src-tauri/src/lib.rs | 19 +++- apps/desktop/src-tauri/src/recording.rs | 62 ++++++++----- apps/desktop/src-tauri/src/recovery.rs | 9 +- .../examples/editor-playback-benchmark.rs | 12 ++- .../examples/playback-pipeline-benchmark.rs | 20 +++-- crates/editor/src/editor_instance.rs | 35 ++++---- crates/project/src/meta.rs | 8 +- .../examples/playback-test-runner.rs | 89 +++++++++++++++---- .../examples/real-device-test-runner.rs | 48 ++++++++-- crates/recording/src/instant_recording.rs | 8 +- crates/recording/src/studio_recording.rs | 57 +++++++----- 15 files changed, 293 insertions(+), 117 deletions(-) diff --git a/apps/cli/src/project.rs b/apps/cli/src/project.rs index 0081eae079a..9232ac155d5 100644 --- a/apps/cli/src/project.rs +++ b/apps/cli/src/project.rs @@ -123,10 +123,9 @@ fn studio_checks(meta: &RecordingMeta, studio: &StudioRecordingMeta) -> Vec { - checks.push(required_check( - "displayVideo", - meta.path(&segment.display.path), - )); + if let Some(display) = &segment.display { + checks.push(required_check("displayVideo", meta.path(&display.path))); + } if let Some(camera) = &segment.camera { checks.push(required_check("camera", meta.path(&camera.path))); } @@ -139,10 +138,9 @@ fn studio_checks(meta: &RecordingMeta, studio: &StudioRecordingMeta) -> Vec { for segment in &inner.segments { - checks.push(required_check( - "displayVideo", - meta.path(&segment.display.path), - )); + if let Some(display) = &segment.display { + checks.push(required_check("displayVideo", meta.path(&display.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 2ae52a02a81..2cf015c82e4 100644 --- a/apps/cli/src/record.rs +++ b/apps/cli/src/record.rs @@ -889,6 +889,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 dcc9cb89b75..9001c45d90c 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 = segment.display.as_ref().map(|d| recording_meta.path(&d.path)).unwrap_or_default(); + 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 +139,11 @@ async fn load_recording( .iter() .enumerate() .filter_map(|(i, segment)| { - let display_path = segment.display.as_ref().map(|d| recording_meta.path(&d.path)).unwrap_or_default(); + 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 b09e964c110..e5853af4243 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.as_ref().map(|d| d.path.clone()).unwrap_or_default().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, @@ -596,7 +597,11 @@ fn copy_keyboard_path( return Ok(Some(target_relative_path)); }; - let Some(display_dir) = source_segment.display.as_ref().and_then(|d| d.path.parent()) else { + let Some(display_dir) = source_segment + .display + .as_ref() + .and_then(|d| d.path.parent()) + else { return Ok(None); }; @@ -875,7 +880,10 @@ fn source_timeline_segments_for_import( } else { let duration = get_source_video_duration_secs( source_meta, - source_segment.display.as_ref().ok_or("Missing display video")?, + source_segment + .display + .as_ref() + .ok_or("Missing display video")?, )?; duration_cache.insert(source_index, duration); duration @@ -1531,7 +1539,7 @@ pub async fn start_video_import(app: AppHandle, source_path: PathBuf) -> Result< }, )), upload: None, - audio_only: false, + audio_only: false, }; if let Err(e) = meta.save_for_project() { diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 01f3ee063be..c2978638ea9 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -2918,12 +2918,27 @@ async fn get_video_metadata(path: PathBuf) -> Result { - vec![recording_meta.path(&segment.display.as_ref().map(|d| d.path.clone()).unwrap_or_default())] + 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.as_ref().map(|d| d.path.clone()).unwrap_or_default())) + .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 901b92f5d48..f301601314f 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -2830,22 +2830,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.as_ref().map(|d| d.path.clone()).unwrap_or_default().to_path(&recording_dir) - } - StudioRecordingMeta::MultipleSegments { inner, .. } => { - inner.segments[0].display.as_ref().map(|d| d.path.clone()).unwrap_or_default().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, @@ -3074,12 +3084,18 @@ async fn finalize_studio_recording( .clone(); let display_output_path = match &updated_studio_meta { - StudioRecordingMeta::SingleSegment { segment } => { - segment.display.as_ref().map(|d| d.path.clone()).unwrap_or_default().to_path(&recording_dir) - } - StudioRecordingMeta::MultipleSegments { inner, .. } => { - inner.segments[0].display.as_ref().map(|d| d.path.clone()).unwrap_or_default().to_path(&recording_dir) - } + StudioRecordingMeta::SingleSegment { segment } => segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default() + .to_path(&recording_dir), + StudioRecordingMeta::MultipleSegments { inner, .. } => inner.segments[0] + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default() + .to_path(&recording_dir), }; let display_screenshot = screenshots_dir.join("display.jpg"); @@ -3405,7 +3421,11 @@ pub fn needs_fragment_remux(recording_dir: &Path, meta: &StudioRecordingMeta) -> }; for segment in &inner.segments { - let Some(display_path) = segment.display.as_ref().map(|d| d.path.to_path(recording_dir)) else { + let Some(display_path) = segment + .display + .as_ref() + .map(|d| d.path.to_path(recording_dir)) + else { continue; }; if display_path.is_dir() { @@ -3460,7 +3480,9 @@ pub fn remux_fragmented_recording_with_trigger( inner .segments .iter() - .filter_map(|seg| seg.display.as_ref().and_then(|d| d.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/recovery.rs b/apps/desktop/src-tauri/src/recovery.rs index 7db9e703929..4bb44c9df4e 100644 --- a/apps/desktop/src-tauri/src/recovery.rs +++ b/apps/desktop/src-tauri/src/recovery.rs @@ -127,9 +127,12 @@ pub async fn recover_recording(app: AppHandle, project_path: String) -> Result { - segment.display.as_ref().map(|d| d.path.clone()).unwrap_or_default().to_path(&recovered.project_path) - } + StudioRecordingMeta::SingleSegment { segment } => segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default() + .to_path(&recovered.project_path), StudioRecordingMeta::MultipleSegments { inner, .. } => inner.segments[0] .display .as_ref() diff --git a/crates/editor/examples/editor-playback-benchmark.rs b/crates/editor/examples/editor-playback-benchmark.rs index b449ab9baf1..389a0ebe32d 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 = segment.display.as_ref().map(|d| recording_meta.path(&d.path)).unwrap_or_default(); + 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 +336,11 @@ async fn load_recording( .iter() .enumerate() .filter_map(|(i, segment)| { - let display_path = segment.display.as_ref().map(|d| recording_meta.path(&d.path)).unwrap_or_default(); + 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 7c485c8f61b..ad159bcfd4c 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 = segment.display.as_ref().map(|d| recording_meta.path(&d.path)).unwrap_or_default(); + 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 +286,11 @@ async fn load_recording( .iter() .enumerate() .filter_map(|(i, segment)| { - let display_path = segment.display.as_ref().map(|d| recording_meta.path(&d.path)).unwrap_or_default(); + 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, @@ -348,9 +356,11 @@ async fn run_decode_only_benchmark( 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) - } + StudioRecordingMeta::MultipleSegments { inner } => inner.segments[0] + .display + .as_ref() + .map(|d| d.fps) + .unwrap_or(0), }; let decoder = match spawn_decoder( diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index a2dfe486fd0..3517791f0ae 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -139,21 +139,25 @@ 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, - }], - _ => { - 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, + }], + _ => { + warn!( + "Failed to determine display duration for {}, leaving timeline unset", + display_path.display() + ); + Vec::new() + } } + } else { + Vec::new() } } StudioRecordingMeta::MultipleSegments { inner } => inner @@ -161,7 +165,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, diff --git a/crates/project/src/meta.rs b/crates/project/src/meta.rs index 7b9d30a933c..0b438a9e3bc 100644 --- a/crates/project/src/meta.rs +++ b/crates/project/src/meta.rs @@ -364,9 +364,7 @@ impl StudioRecordingMeta { pub fn min_fps(&self) -> u32 { match self { - Self::SingleSegment { segment } => { - segment.display.as_ref().map(|d| d.fps).unwrap_or(0) - } + Self::SingleSegment { segment } => segment.display.as_ref().map(|d| d.fps).unwrap_or(0), Self::MultipleSegments { inner, .. } => inner .segments .iter() @@ -378,9 +376,7 @@ impl StudioRecordingMeta { pub fn max_fps(&self) -> u32 { match self { - Self::SingleSegment { segment } => { - segment.display.as_ref().map(|d| d.fps).unwrap_or(0) - } + Self::SingleSegment { segment } => segment.display.as_ref().map(|d| d.fps).unwrap_or(0), Self::MultipleSegments { inner, .. } => inner .segments .iter() diff --git a/crates/recording/examples/playback-test-runner.rs b/crates/recording/examples/playback-test-runner.rs index 972415f1cd6..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.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()) - } + 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.as_ref().map(|d| d.path.clone()).unwrap_or_default()), + 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.as_ref().map(|d| d.path.clone()).unwrap_or_default()), + 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,7 +566,13 @@ 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.as_ref().map(|d| d.path.clone()).unwrap_or_default()), + recording_meta.path( + &segment + .display + .as_ref() + .map(|d| d.path.clone()) + .unwrap_or_default(), + ), segment .camera .as_ref() @@ -558,7 +583,12 @@ async fn test_camera_sync( StudioRecordingMeta::MultipleSegments { inner } => { let seg = &inner.segments[segment_index]; ( - recording_meta.path(&seg.display.as_ref().map(|d| d.path.clone()).unwrap_or_default()), + 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.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.as_ref().map(|d| d.path.clone()).unwrap_or_default()).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.as_ref().map(|d| d.path.clone()).unwrap_or_default()).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.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()) - } + 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 6f0cc9f3320..21c5c174dd7 100644 --- a/crates/recording/examples/real-device-test-runner.rs +++ b/crates/recording/examples/real-device-test-runner.rs @@ -978,7 +978,13 @@ async fn analyze_frame_rate( match studio_meta.as_ref() { StudioRecordingMeta::SingleSegment { segment } => { - let display_path = meta.path(&segment.display.as_ref().map(|d| d.path.clone()).unwrap_or_default()); + 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.as_ref().map(|d| d.path.clone()).unwrap_or_default()); + 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.as_ref().map(|d| d.path.clone()).unwrap_or_default()); + 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.as_ref().map(|d| d.path.clone()).unwrap_or_default()); + 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.as_ref().map(|d| d.path.clone()).unwrap_or_default()); + 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.as_ref().map(|d| d.path.clone()).unwrap_or_default()); + 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(); diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index ad1e0baa412..164330e8394 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -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,11 +599,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), ) } } diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 3c5f3c5864f..8acecdc44e2 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -608,9 +608,10 @@ impl Pipeline { let screen_done = self.screen.as_ref().map(|s| s.done_fut()); tokio::spawn(async move { - if let Some(done) = screen_done { - let _ = done.await; - } + let Some(done) = screen_done else { + return; + }; + let _ = done.await; if let Some(token) = mic_cancel.as_ref() { token.cancel(); } @@ -998,9 +999,7 @@ async fn stop_recording( raw_display } } else { - mic_start_time - .or(camera_start_time) - .unwrap_or(s.start) + mic_start_time.or(camera_start_time).unwrap_or(s.start) }; let diagnostics = @@ -1011,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 @@ -1028,16 +1028,20 @@ 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, @@ -1482,7 +1486,7 @@ async fn create_segment_pipeline( .await .context("camera-only screen pipeline setup")?; - (screen, None, None) + (Some(screen), None, None) } } else { let capture_target = base_inputs.capture_target.clone(); @@ -2237,7 +2241,12 @@ mod tests { .expect("display success should still allow the recording to stop cleanly"); assert_eq!( - finished.screen.as_ref().map(|s| s.video_frame_count).unwrap_or(0), 1, + finished + .screen + .as_ref() + .map(|s| s.video_frame_count) + .unwrap_or(0), + 1, "display output should be preserved" ); assert!( From b22097e957c9d5b4ab2b4f208e6d4dde0bdeb5ae Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Thu, 18 Jun 2026 22:18:26 +0530 Subject: [PATCH 06/17] fix(audio-only): skip timeline generation when no display segments --- apps/desktop/src-tauri/src/recording.rs | 30 +++++++++++++------------ 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index f301601314f..d2024e45729 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -3325,21 +3325,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 } From 801e689586d3918b87c0ae440baedecb1ec403c3 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Thu, 18 Jun 2026 22:29:36 +0530 Subject: [PATCH 07/17] fix(audio-only): preserve audio_only flag in meta writes, skip camera window for AudioOnly --- apps/desktop/src-tauri/src/recording.rs | 5 +---- crates/recording/src/studio_recording.rs | 12 ++++++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index d2024e45729..311c80d27e8 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -1232,10 +1232,7 @@ pub async fn start_recording( } let mut inputs = inputs; - if matches!( - inputs.capture_target, - ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly - ) { + if matches!(inputs.capture_target, ScreenCaptureTarget::CameraOnly) { inputs.capture_system_audio = false; { diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 8acecdc44e2..249b3b71d7a 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -1772,6 +1772,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(), @@ -1779,7 +1783,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, + audio_only, }; if let Err(err) = recording_meta.save_for_project() { @@ -1795,6 +1799,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()), @@ -1809,7 +1817,7 @@ fn write_in_progress_meta(recording_dir: &Path) -> anyhow::Result<()> { }, })), upload: None, - audio_only: false, + audio_only, }; meta.save_for_project() From d7f2fb08e2d8f391de810237e684f8a1b7689867 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Thu, 18 Jun 2026 22:44:30 +0530 Subject: [PATCH 08/17] fix(audio-only): gate display checks on audio_only flag, fail fast on missing display in renderer --- apps/cli/src/project.rs | 16 ++++++++++++---- crates/rendering/src/lib.rs | 10 ++++++---- crates/rendering/src/main.rs | 22 ++++++++++++---------- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/apps/cli/src/project.rs b/apps/cli/src/project.rs index 9232ac155d5..f35cb65e75d 100644 --- a/apps/cli/src/project.rs +++ b/apps/cli/src/project.rs @@ -123,8 +123,12 @@ fn studio_checks(meta: &RecordingMeta, studio: &StudioRecordingMeta) -> Vec { - if let Some(display) = &segment.display { - checks.push(required_check("displayVideo", meta.path(&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))); @@ -138,8 +142,12 @@ fn studio_checks(meta: &RecordingMeta, studio: &StudioRecordingMeta) -> Vec { for segment in &inner.segments { - if let Some(display) = &segment.display { - checks.push(required_check("displayVideo", meta.path(&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/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 702a1ac44ed..0da9aee822e 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -271,14 +271,16 @@ impl RecordingSegmentDecoders { }; let screen_fps = match &meta { - StudioRecordingMeta::SingleSegment { segment } => { - segment.display.as_ref().map(|d| d.fps).unwrap_or(0) - } + 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) - .unwrap_or(0), + .ok_or_else(|| "Display metadata missing".to_string())?, }; let camera_fps = match &meta { diff --git a/crates/rendering/src/main.rs b/crates/rendering/src/main.rs index 1031d38eb93..5d51461a265 100644 --- a/crates/rendering/src/main.rs +++ b/crates/rendering/src/main.rs @@ -90,15 +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: segment - .display - .as_ref() - .map(|d| recording_meta.path(&d.path)) - .unwrap_or_default(), + display, camera: segment .camera .as_ref() @@ -120,15 +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: s - .display - .as_ref() - .map(|d| recording_meta.path(&d.path)) - .unwrap_or_default(), + display, camera: s.camera.as_ref().map(|c| recording_meta.path(&c.path)), }, i, From 863fe1f26396b5ff580a6825f043981935d4f4af Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Thu, 18 Jun 2026 22:56:40 +0530 Subject: [PATCH 09/17] fix(audio-only): add pipeline branches for AudioOnly in studio/instant modes, fix recovery and finalize --- apps/desktop/src-tauri/src/recording.rs | 37 ++++++++++++----------- crates/recording/src/instant_recording.rs | 28 +++++++++++++++++ crates/recording/src/recovery.rs | 2 +- crates/recording/src/studio_recording.rs | 2 ++ 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 311c80d27e8..3bb88d20e83 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -3084,26 +3084,29 @@ async fn finalize_studio_recording( StudioRecordingMeta::SingleSegment { segment } => segment .display .as_ref() - .map(|d| d.path.clone()) - .unwrap_or_default() - .to_path(&recording_dir), - StudioRecordingMeta::MultipleSegments { inner, .. } => inner.segments[0] - .display - .as_ref() - .map(|d| d.path.clone()) - .unwrap_or_default() - .to_path(&recording_dir), + .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, diff --git a/crates/recording/src/instant_recording.rs b/crates/recording/src/instant_recording.rs index 164330e8394..cf235c349ca 100644 --- a/crates/recording/src/instant_recording.rs +++ b/crates/recording/src/instant_recording.rs @@ -607,6 +607,34 @@ pub async fn spawn_instant_recording_actor( ) } } + 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 cdb3548dfb9..325ba25a520 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 diff --git a/crates/recording/src/studio_recording.rs b/crates/recording/src/studio_recording.rs index 249b3b71d7a..fe157680226 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -1488,6 +1488,8 @@ async fn create_segment_pipeline( (Some(screen), None, None) } + } else if audio_only { + (None, None, None) } else { let capture_target = base_inputs.capture_target.clone(); From c9159323ca9a170718d748dead43e302d3bb0b56 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Fri, 19 Jun 2026 19:58:16 +0530 Subject: [PATCH 10/17] fix(audio-only): wire up recovery display gating, mic guard, and remaining AudioOnly call sites --- apps/desktop/src-tauri/src/recording.rs | 36 ++++++++++++++-- crates/recording/src/recovery.rs | 52 +++++++++++++----------- crates/recording/src/studio_recording.rs | 9 +++- 3 files changed, 70 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 3bb88d20e83..f5582424261 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -105,7 +105,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; } @@ -1334,7 +1337,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" @@ -3382,7 +3388,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; } @@ -3819,6 +3828,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/crates/recording/src/recovery.rs b/crates/recording/src/recovery.rs index 325ba25a520..6b5b42789ca 100644 --- a/crates/recording/src/recovery.rs +++ b/crates/recording/src/recovery.rs @@ -1276,14 +1276,18 @@ impl RecoveryManager { }; MultipleSegment { - 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.as_ref()) - .and_then(|d| d.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 { path: RelativePathBuf::from(format!("{segment_base}/camera.mp4")), @@ -1392,23 +1396,25 @@ impl RecoveryManager { .iter() .enumerate() .filter_map(|(i, segment)| { - 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 = f64::from(segment.display.as_ref().map(|d| d.fps).unwrap_or(0)); - 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/studio_recording.rs b/crates/recording/src/studio_recording.rs index fe157680226..81d2d29ef62 100644 --- a/crates/recording/src/studio_recording.rs +++ b/crates/recording/src/studio_recording.rs @@ -1489,6 +1489,13 @@ async fn create_segment_pipeline( (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(); @@ -1620,7 +1627,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 { From c90d08dafb9230b58febc9655526a904dd86bf78 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Sun, 21 Jun 2026 17:37:50 +0530 Subject: [PATCH 11/17] fix(audio-only): fix Linux compile gap, gate recovery screenshot and editor decode on display presence --- apps/desktop/src-tauri/src/recovery.rs | 31 +++++++++---------- crates/editor/src/editor_instance.rs | 6 ++-- crates/recording/src/capture_pipeline.rs | 7 +++++ .../src/sources/screen_capture/mod.rs | 4 ++- 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/apps/desktop/src-tauri/src/recovery.rs b/apps/desktop/src-tauri/src/recovery.rs index 4bb44c9df4e..2f421f7f5d6 100644 --- a/apps/desktop/src-tauri/src/recovery.rs +++ b/apps/desktop/src-tauri/src/recovery.rs @@ -130,27 +130,26 @@ pub async fn recover_recording(app: AppHandle, project_path: String) -> Result segment .display .as_ref() - .map(|d| d.path.clone()) - .unwrap_or_default() - .to_path(&recovered.project_path), + .map(|d| d.path.to_path(&recovered.project_path)), StudioRecordingMeta::MultipleSegments { inner, .. } => inner.segments[0] .display .as_ref() - .map(|d| d.path.clone()) - .unwrap_or_default() - .to_path(&recovered.project_path), + .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/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 3eda8c43396..bf178c3e159 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -816,7 +816,7 @@ pub async fn create_segments( .display .as_ref() .map(|d| recording_meta.path(&d.path)) - .unwrap_or_default(), + .ok_or("SingleSegment / missing display metadata")?, camera: s.camera.as_ref().map(|c| recording_meta.path(&c.path)), }, 0, @@ -868,7 +868,9 @@ pub async fn create_segments( .display .as_ref() .map(|d| recording_meta.path(&d.path)) - .unwrap_or_default(), + .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/recording/src/capture_pipeline.rs b/crates/recording/src/capture_pipeline.rs index 797332a9453..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"))?; diff --git a/crates/recording/src/sources/screen_capture/mod.rs b/crates/recording/src/sources/screen_capture/mod.rs index 0e8e2a2f841..edffd866a15 100644 --- a/crates/recording/src/sources/screen_capture/mod.rs +++ b/crates/recording/src/sources/screen_capture/mod.rs @@ -85,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, } } } From 73607537ff941fde6272e319720e208deb16cf2a Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Sun, 21 Jun 2026 17:43:00 +0530 Subject: [PATCH 12/17] fix(audio-only): repair botched main-merge conflict resolution and update new main-side call sites for AudioOnly/optional display --- apps/cli/src/automation.rs | 2 +- apps/desktop/src-tauri/src/automation.rs | 2 +- apps/desktop/src-tauri/src/clip_thumbnails.rs | 12 +++++-- .../src-tauri/src/screenshot_editor.rs | 3 +- .../examples/playback-pipeline-benchmark.rs | 11 ++++--- crates/editor/src/editor_instance.rs | 32 ++++++++++--------- 6 files changed, 38 insertions(+), 24 deletions(-) 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/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/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index 9894bc38f31..b93e9aea25e 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -793,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, @@ -806,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 { diff --git a/crates/editor/examples/playback-pipeline-benchmark.rs b/crates/editor/examples/playback-pipeline-benchmark.rs index 2ec14eaa949..348d67b3236 100644 --- a/crates/editor/examples/playback-pipeline-benchmark.rs +++ b/crates/editor/examples/playback-pipeline-benchmark.rs @@ -658,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 bf178c3e159..83043e519cf 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -139,21 +139,23 @@ 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() From 23b11d9585f94b927b009774f6f6cb2a78d59558 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Mon, 22 Jun 2026 23:00:36 +0530 Subject: [PATCH 13/17] fix(audio-only): fix Windows clippy exhaustiveness gaps for AudioOnly and cargo fmt --- apps/desktop/src-tauri/src/recovery.rs | 3 +-- crates/recording/src/screenshot.rs | 18 +++++++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src-tauri/src/recovery.rs b/apps/desktop/src-tauri/src/recovery.rs index 2f421f7f5d6..7d66f925d94 100644 --- a/apps/desktop/src-tauri/src/recovery.rs +++ b/apps/desktop/src-tauri/src/recovery.rs @@ -144,8 +144,7 @@ pub async fn recover_recording(app: AppHandle, project_path: String) -> Result anyhow::Result Err(unsupported_error()), + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => { + Err(unsupported_error()) + } } } @@ -777,7 +779,7 @@ fn try_fast_capture(target: &ScreenCaptureTarget) -> Option { let display = scap_targets::Display::from_id(&screen)?; display.raw_handle().try_as_capture_item().ok()? } - ScreenCaptureTarget::CameraOnly => { + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => { return None; } }; @@ -1006,8 +1008,10 @@ pub async fn capture_screenshot(target: ScreenCaptureTarget) -> anyhow::Result { - return Err(anyhow!("Camera-only not supported for screenshots")); + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => { + return Err(anyhow!( + "Camera-only/Audio-only not supported for screenshots" + )); } }; @@ -1302,8 +1306,8 @@ fn linux_capture_geometry( bounds.size().height().max(1.0) as u32, )) } - ScreenCaptureTarget::CameraOnly => { - Err(anyhow!("Camera-only not supported for screenshots")) - } + ScreenCaptureTarget::CameraOnly | ScreenCaptureTarget::AudioOnly => Err(anyhow!( + "Camera-only/Audio-only not supported for screenshots" + )), } } From 7fe1931249ee9245bfbd10a1774a9a404e099041 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Mon, 22 Jun 2026 23:19:09 +0530 Subject: [PATCH 14/17] fix(audio-only): return explicit error for audio-only recordings in editor instead of empty display path --- crates/editor/src/editor_instance.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 83043e519cf..7a32353de8b 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -158,6 +158,12 @@ impl EditorInstance { } } } else { + if recording_meta.audio_only { + return Err( + "Audio-only recordings aren't supported in the editor yet" + .to_string(), + ); + } Vec::new() } } @@ -818,7 +824,14 @@ pub async fn create_segments( .display .as_ref() .map(|d| recording_meta.path(&d.path)) - .ok_or("SingleSegment / missing display metadata")?, + .ok_or_else(|| { + if recording_meta.audio_only { + "Audio-only recordings aren't supported in the editor yet" + .to_string() + } else { + "SingleSegment / missing display metadata".to_string() + } + })?, camera: s.camera.as_ref().map(|c| recording_meta.path(&c.path)), }, 0, @@ -871,7 +884,12 @@ pub async fn create_segments( .as_ref() .map(|d| recording_meta.path(&d.path)) .ok_or_else(|| { - format!("MultipleSegments {i} / missing display metadata") + if recording_meta.audio_only { + "Audio-only recordings aren't supported in the editor yet" + .to_string() + } else { + format!("MultipleSegments {i} / missing display metadata") + } })?, camera: s.camera.as_ref().map(|c| recording_meta.path(&c.path)), }, From 0abc8d37465bddcb7e1141e28d6cb3ab48abc479 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Tue, 23 Jun 2026 14:45:26 +0530 Subject: [PATCH 15/17] fix(audio-only): report clear missing-display check in CLI validator and fail audio-only recordings unconditionally in editor --- apps/cli/src/project.rs | 30 ++++++++++++++++++---------- crates/editor/src/editor_instance.rs | 10 ++++------ 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/project.rs b/apps/cli/src/project.rs index f35cb65e75d..5b2007d51f9 100644 --- a/apps/cli/src/project.rs +++ b/apps/cli/src/project.rs @@ -124,11 +124,16 @@ fn studio_checks(meta: &RecordingMeta, studio: &StudioRecordingMeta) -> Vec { 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(display) = segment.display.as_ref() { + checks.push(required_check("displayVideo", meta.path(&display.path))); + } else { + checks.push(FileCheck { + role: "displayVideo", + path: PathBuf::from(""), + exists: false, + required: true, + }); + } } if let Some(camera) = &segment.camera { checks.push(required_check("camera", meta.path(&camera.path))); @@ -143,11 +148,16 @@ fn studio_checks(meta: &RecordingMeta, studio: &StudioRecordingMeta) -> Vec { for segment in &inner.segments { 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(display) = segment.display.as_ref() { + checks.push(required_check("displayVideo", meta.path(&display.path))); + } else { + checks.push(FileCheck { + role: "displayVideo", + path: PathBuf::from(""), + exists: false, + required: true, + }); + } } if let Some(camera) = &segment.camera { checks.push(required_check("camera", meta.path(&camera.path))); diff --git a/crates/editor/src/editor_instance.rs b/crates/editor/src/editor_instance.rs index 7a32353de8b..da8ef01e47e 100644 --- a/crates/editor/src/editor_instance.rs +++ b/crates/editor/src/editor_instance.rs @@ -122,6 +122,10 @@ impl EditorInstance { return Err("Cannot edit non-studio recordings".to_string()); }; + if recording_meta.audio_only { + return Err("Audio-only recordings aren't supported in the editor yet".to_string()); + } + let segment_count = match meta.as_ref() { StudioRecordingMeta::SingleSegment { .. } => 1, StudioRecordingMeta::MultipleSegments { inner } => inner.segments.len(), @@ -158,12 +162,6 @@ impl EditorInstance { } } } else { - if recording_meta.audio_only { - return Err( - "Audio-only recordings aren't supported in the editor yet" - .to_string(), - ); - } Vec::new() } } From cd870dd71977cbbf44c811093d782dbd76ddfa46 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Thu, 25 Jun 2026 20:37:44 +0530 Subject: [PATCH 16/17] fix(audio-only): avoid constructing empty display path in transport benchmark example --- .../desktop-display-transport-benchmark.rs | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) 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 5cc3839f914..d43aec61ec3 100644 --- a/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs +++ b/apps/desktop/src-tauri/examples/desktop-display-transport-benchmark.rs @@ -118,15 +118,12 @@ async fn load_recording( if project.timeline.is_none() { let timeline_segments = match meta.as_ref() { StudioRecordingMeta::SingleSegment { segment } => { - let display_path = segment + let duration = 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, - }; + .and_then(|d| Video::new(&recording_meta.path(&d.path), 0.0).ok()) + .map(|video| video.duration) + .unwrap_or(5.0); vec![TimelineSegment { recording_clip: 0, start: 0.0, @@ -140,15 +137,12 @@ async fn load_recording( .iter() .enumerate() .filter_map(|(i, segment)| { - let display_path = segment + let duration = 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, - }; + .and_then(|d| Video::new(&recording_meta.path(&d.path), 0.0).ok()) + .map(|video| video.duration) + .unwrap_or(5.0); (duration > 0.0).then_some(TimelineSegment { recording_clip: i as u32, start: 0.0, From 889637b97d2751bbed135988271644135418bce8 Mon Sep 17 00:00:00 2001 From: ManthanNimodiya Date: Thu, 25 Jun 2026 21:43:02 +0530 Subject: [PATCH 17/17] fix(audio-only): guard export preview and exporter build against audio-only recordings --- apps/desktop/src-tauri/src/export.rs | 4 ++++ crates/export/src/lib.rs | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index e01cadd0240..1d6321e21a8 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -1474,6 +1474,10 @@ async fn generate_export_preview_inner( return Err("Cannot preview non-studio recordings".to_string()); }; + if recording_meta.audio_only { + return Err("Audio-only recordings don't have a visual preview".to_string()); + } + let project_config = export_project_config(recording_meta.project_config(), settings.cursor_only); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 7ee8f19528b..571767f46de 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -44,6 +44,8 @@ pub enum ExporterBuildError { MetaLoad(#[source] Box), #[error("Recording is not a studio recording")] NotStudioRecording, + #[error("Audio-only recordings aren't supported in the export pipeline yet")] + AudioOnly, #[error("Failed to load recordings meta: {0}")] RecordingsMeta(String), #[error("Failed to setup renderer: {0}")] @@ -93,6 +95,10 @@ impl ExporterBuilder { .studio_meta() .ok_or(Error::NotStudioRecording)?; + if recording_meta.audio_only { + return Err(Error::AudioOnly); + } + let recordings = Arc::new( ProjectRecordingsMeta::new(&recording_meta.project_path, studio_meta) .map_err(Error::RecordingsMeta)?,