diff --git a/apps/desktop-gpui/src/devices.rs b/apps/desktop-gpui/src/devices.rs index 6335d10e9e..8b7be80e82 100644 --- a/apps/desktop-gpui/src/devices.rs +++ b/apps/desktop-gpui/src/devices.rs @@ -135,6 +135,23 @@ pub struct DeviceSnapshot { } impl DeviceSnapshot { + pub fn update_camera_formats( + &mut self, + queried: &CameraOption, + formats: Vec, + ) -> bool { + let Some(camera) = self + .cameras + .iter_mut() + .find(|camera| camera.same_device(queried)) + else { + return false; + }; + camera.best_format = formats.first().copied(); + camera.formats = formats; + true + } + /// Enumerate everything. This blocks — AVFoundation camera discovery and the /// window-server queries are both slow enough to drop frames — so callers /// should run it on the background executor, never inside `render`. @@ -179,8 +196,8 @@ pub enum InputSnapshot { } impl InputSnapshot { - pub fn cameras() -> Self { - Self::Cameras(list_cameras()) + pub fn cameras(previous: &[CameraOption]) -> Self { + Self::Cameras(list_cameras_with_previous(previous)) } pub fn microphones() -> Self { @@ -189,7 +206,20 @@ impl InputSnapshot { pub fn install(self, snapshot: &mut DeviceSnapshot) -> bool { match self { - Self::Cameras(cameras) if snapshot.cameras != cameras => { + Self::Cameras(mut cameras) => { + for camera in &mut cameras { + if camera.formats.is_empty() + && let Some(current) = snapshot.cameras.iter().find(|current| { + current.same_device(camera) && !current.formats.is_empty() + }) + { + camera.best_format = current.best_format; + camera.formats = current.formats.clone(); + } + } + if snapshot.cameras == cameras { + return false; + } snapshot.cameras = cameras; } Self::Microphones(microphones) if snapshot.microphones != microphones => { @@ -251,6 +281,149 @@ mod input_enumeration_tests { assert!(InputSnapshot::Microphones(Vec::new()).install(&mut snapshot)); assert_eq!(snapshot.cameras, vec![camera]); } + fn camera_fixture(id: &str) -> CameraOption { + CameraOption { + device_id: id.into(), + model_id: None, + label: "Camera".into(), + best_format: None, + formats: Vec::new(), + } + } + + fn formats_fixture() -> Vec { + vec![CameraFormat { + width: 1920, + height: 1080, + frame_rate: 30.0, + }] + } + + #[test] + fn completed_query_updates_matching_camera_capabilities() { + let queried = camera_fixture("camera-a"); + let mut snapshot = DeviceSnapshot { + cameras: vec![queried.clone()], + ..Default::default() + }; + assert!(snapshot.update_camera_formats(&queried, formats_fixture())); + assert_eq!(snapshot.cameras[0].formats, formats_fixture()); + assert_eq!( + snapshot.cameras[0].best_format, + formats_fixture().first().copied() + ); + } + + #[test] + fn completed_query_cannot_overwrite_replacement_camera_capabilities() { + let queried = camera_fixture("camera-a"); + let mut changed_id = queried.clone(); + changed_id.device_id = "camera-b".into(); + let mut changed_model = queried.clone(); + changed_model.model_id = + Some(cap_camera::ModelID::try_from("046d:08e5".to_string()).unwrap()); + let mut changed_label = queried.clone(); + changed_label.label = "Replacement camera".into(); + for replacement in [changed_id, changed_model, changed_label] { + let mut snapshot = DeviceSnapshot { + cameras: vec![replacement.clone()], + ..Default::default() + }; + assert!(!snapshot.update_camera_formats(&queried, formats_fixture())); + assert_eq!(snapshot.cameras, vec![replacement]); + } + } + + #[test] + fn completed_query_cannot_restore_disconnected_camera() { + let mut snapshot = DeviceSnapshot::default(); + assert!(!snapshot.update_camera_formats(&camera_fixture("camera-a"), formats_fixture())); + assert!(snapshot.cameras.is_empty()); + } + + #[test] + fn repeated_camera_refresh_does_not_reopen_unchanged_devices() { + let mut previous = vec![camera_fixture("camera-a").with_formats(&[], formats_fixture)]; + for _ in 0..1000 { + let refreshed = camera_fixture("camera-a").with_formats(&previous, || { + panic!("unchanged camera must not reopen its driver") + }); + assert_eq!(refreshed, previous[0]); + previous = vec![refreshed]; + } + } + + #[test] + fn new_camera_does_not_inherit_another_devices_formats() { + let previous = vec![camera_fixture("camera-a").with_formats(&[], formats_fixture)]; + let mut probes = 0; + let refreshed = camera_fixture("camera-b").with_formats(&previous, || { + probes += 1; + Vec::new() + }); + assert_eq!(probes, 1); + assert!(refreshed.formats.is_empty()); + assert!(refreshed.best_format.is_none()); + } + + #[test] + fn reconnect_refreshes_formats_after_camera_disappears() { + let connected = camera_fixture("camera-a").with_formats(&[], formats_fixture); + let after_disconnect = Vec::new(); + let changed_format = CameraFormat { + width: 1280, + height: 720, + frame_rate: 60.0, + }; + let reconnected = + camera_fixture("camera-a").with_formats(&after_disconnect, || vec![changed_format]); + assert_ne!(reconnected.formats, connected.formats); + assert_eq!(reconnected.best_format, Some(changed_format)); + } + + #[test] + fn renamed_camera_refreshes_capabilities() { + let previous = vec![camera_fixture("camera-a").with_formats(&[], formats_fixture)]; + let mut camera = camera_fixture("camera-a"); + camera.label = "Reconfigured virtual camera".into(); + let mut probes = 0; + camera.with_formats(&previous, || { + probes += 1; + Vec::new() + }); + assert_eq!(probes, 1); + } + #[test] + fn failed_probe_is_not_retried_by_background_refresh() { + let previous = vec![camera_fixture("camera-a").with_formats(&[], Vec::new)]; + let refreshed = camera_fixture("camera-a").with_formats(&previous, || { + panic!("background refresh must not retry a native probe") + }); + assert!(refreshed.formats.is_empty()); + } + + #[test] + fn late_refresh_does_not_erase_successful_explicit_retry() { + let refreshed = vec![camera_fixture("camera-a")]; + let mut snapshot = DeviceSnapshot { + cameras: vec![camera_fixture("camera-a").with_formats(&[], formats_fixture)], + ..Default::default() + }; + assert!(!InputSnapshot::Cameras(refreshed).install(&mut snapshot)); + assert_eq!(snapshot.cameras[0].formats, formats_fixture()); + } + + #[test] + fn changed_device_does_not_inherit_previous_capabilities() { + let mut changed = camera_fixture("camera-a"); + changed.label = "Changed virtual camera".into(); + let mut snapshot = DeviceSnapshot { + cameras: vec![camera_fixture("camera-a").with_formats(&[], formats_fixture)], + ..Default::default() + }; + assert!(InputSnapshot::Cameras(vec![changed]).install(&mut snapshot)); + assert!(snapshot.cameras[0].formats.is_empty()); + } } /// Just the capture targets. @@ -279,44 +452,107 @@ impl TargetSnapshot { } fn list_cameras() -> Vec { + list_cameras_with_previous(&[]) +} + +fn list_cameras_with_previous(previous: &[CameraOption]) -> Vec { cap_camera::list_cameras() .map(|camera| { - let mut formats = camera - .formats() - .unwrap_or_default() - .into_iter() - .map(|format| CameraFormat { - width: format.width(), - height: format.height(), - frame_rate: format.frame_rate(), - }) - .collect::>(); - let mut seen = std::collections::HashSet::new(); - formats.retain(|format| { - seen.insert(( - format.width, - format.height, - format.frame_rate.round() as u32, - )) - }); - formats.sort_by(|a, b| { - (b.width * b.height) - .cmp(&(a.width * a.height)) - .then(b.frame_rate.total_cmp(&a.frame_rate)) - }); - let best_format = formats.first().copied(); - CameraOption { device_id: camera.device_id().to_string(), model_id: camera.model_id().cloned(), label: camera.display_name().to_string(), - best_format, - formats, + best_format: None, + formats: Vec::new(), } + .with_formats(previous, || { + camera + .formats() + .unwrap_or_default() + .into_iter() + .map(|format| CameraFormat { + width: format.width(), + height: format.height(), + frame_rate: format.frame_rate(), + }) + .collect() + }) }) .collect() } +impl CameraOption { + fn same_device(&self, other: &Self) -> bool { + self.device_id == other.device_id + && self.model_id == other.model_id + && self.label == other.label + } + + fn with_formats( + mut self, + previous: &[Self], + load_formats: impl FnOnce() -> Vec, + ) -> Self { + // Windows format probing opens native camera sources; polling an unchanged + // device must reuse metadata rather than repeatedly activating its driver. + if let Some(previous) = previous.iter().find(|previous| previous.same_device(&self)) { + self.best_format = previous.best_format; + self.formats = previous.formats.clone(); + return self; + } + + let mut formats = load_formats(); + let mut seen = std::collections::HashSet::new(); + formats.retain(|format| { + seen.insert(( + format.width, + format.height, + format.frame_rate.round() as u32, + )) + }); + formats.sort_by(|a, b| { + (u64::from(b.width) * u64::from(b.height)) + .cmp(&(u64::from(a.width) * u64::from(a.height))) + .then(b.frame_rate.total_cmp(&a.frame_rate)) + }); + self.best_format = formats.first().copied(); + self.formats = formats; + self + } +} + +pub fn camera_formats(device_id: &str) -> Result, String> { + let camera = cap_camera::list_cameras() + .find(|camera| camera.device_id() == device_id) + .ok_or_else(|| "Camera is no longer available. Reconnect it and try again.".to_string())?; + let formats = camera.formats().ok_or_else(|| { + "Could not read camera formats. Check camera access and try again.".to_string() + })?; + if formats.is_empty() { + return Err( + "No camera formats are available. Check camera access and try again.".to_string(), + ); + } + Ok(CameraOption { + device_id: camera.device_id().to_string(), + model_id: camera.model_id().cloned(), + label: camera.display_name().to_string(), + best_format: None, + formats: Vec::new(), + } + .with_formats(&[], || { + formats + .into_iter() + .map(|format| CameraFormat { + width: format.width(), + height: format.height(), + frame_rate: format.frame_rate(), + }) + .collect() + }) + .formats) +} + /// Mirrors `MicrophoneFeed::list_with_settings`: the default input device is /// inserted first so it heads the list, then every other input device is /// appended, deduped by name. diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index bf12873621..132d3f5137 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -347,6 +347,60 @@ pub enum DeviceMenu { Microphone, } +fn camera_format_choices( + formats: Result, String>, +) -> (Vec, Option) { + let mut choices = vec![DeviceFormat::Camera(Default::default())]; + match formats { + Ok(formats) => { + choices.extend( + formats + .into_iter() + .map(|format| DeviceFormat::Camera(format.settings())), + ); + (choices, None) + } + Err(error) => (choices, Some(error)), + } +} + +#[cfg(test)] +mod camera_format_choice_tests { + use super::*; + + #[test] + fn failed_probe_keeps_automatic_available() { + let (choices, notice) = camera_format_choices(Err("Camera access denied".into())); + assert_eq!(choices, vec![DeviceFormat::Camera(Default::default())]); + assert_eq!(notice.as_deref(), Some("Camera access denied")); + } + + #[test] + fn empty_formats_keep_automatic_available() { + let (choices, notice) = camera_format_choices(Ok(Vec::new())); + assert_eq!(choices, vec![DeviceFormat::Camera(Default::default())]); + assert!(notice.is_none()); + } + + #[test] + fn successful_retry_adds_current_formats_and_clears_error() { + let format = devices::CameraFormat { + width: 1280, + height: 720, + frame_rate: 60.0, + }; + let (choices, notice) = camera_format_choices(Ok(vec![format])); + assert_eq!( + choices, + vec![ + DeviceFormat::Camera(Default::default()), + DeviceFormat::Camera(format.settings()) + ] + ); + assert!(notice.is_none()); + } +} + fn input_refresh_is_current( current_generation: u64, current_menu: Option, @@ -1312,10 +1366,18 @@ impl MainWindow { ), !this.device_restore_suspended && this.target_prewarm_allowed(window, cx), ) + .map(|allowed| { + let cameras = if allowed && menu == DeviceMenu::Camera { + this.devices.cameras.clone() + } else { + Vec::new() + }; + (allowed, cameras) + }) }) else { return; }; - let Some(allowed) = allowed else { + let Some((allowed, previous_cameras)) = allowed else { return; }; if allowed && let Some(permit) = gate.try_enter() { @@ -1324,7 +1386,9 @@ impl MainWindow { .spawn(async move { let _permit = permit; match menu { - DeviceMenu::Camera => devices::InputSnapshot::cameras(), + DeviceMenu::Camera => { + devices::InputSnapshot::cameras(&previous_cameras) + } DeviceMenu::Microphone => devices::InputSnapshot::microphones(), } }) @@ -4936,6 +5000,10 @@ impl MainWindow { }); self.device_format_target = Some(target.clone()); self.device_format_notice = None; + let should_query = match &target { + DeviceFormatTarget::Camera(camera) => camera.formats.is_empty(), + DeviceFormatTarget::Microphone(_) => true, + }; self.device_formats = match &target { DeviceFormatTarget::Camera(camera) => Some(Ok(std::iter::once(DeviceFormat::Camera( Default::default(), @@ -4947,18 +5015,29 @@ impl MainWindow { .map(|format| DeviceFormat::Camera(format.settings())), ) .collect())), - DeviceFormatTarget::Microphone(_) => None, + _ => None, }; - if let DeviceFormatTarget::Microphone(name) = target { + if should_query { cx.spawn(async move |this, cx| { - let formats = cx + let (formats, camera_formats, notice) = cx .background_executor() .spawn(async move { - devices::microphone_formats(&name).map(|formats| { - std::iter::once(DeviceFormat::Microphone(Default::default())) - .chain(formats.into_iter().map(DeviceFormat::Microphone)) - .collect() - }) + match target { + DeviceFormatTarget::Camera(camera) => { + let formats = devices::camera_formats(&camera.device_id); + let metadata = formats.as_ref().ok().cloned(); + let (choices, notice) = camera_format_choices(formats); + (Ok(choices), metadata, notice) + } + DeviceFormatTarget::Microphone(name) => { + let formats = devices::microphone_formats(&name).map(|formats| { + std::iter::once(DeviceFormat::Microphone(Default::default())) + .chain(formats.into_iter().map(DeviceFormat::Microphone)) + .collect() + }); + (formats, None, None) + } + } }) .await; this.update(cx, |this, cx| { @@ -4967,6 +5046,17 @@ impl MainWindow { { return; } + if let Some(formats) = camera_formats + && let Some(DeviceFormatTarget::Camera(camera)) = &this.device_format_target + && !this.devices.update_camera_formats(camera, formats) + { + this.device_formats = Some(Err( + "Camera changed or disconnected. Reopen its format settings.".into(), + )); + cx.notify(); + return; + } + this.device_format_notice = notice; this.device_formats = Some(formats); cx.notify(); }) diff --git a/crates/camera-windows/examples/enumeration_leak.rs b/crates/camera-windows/examples/enumeration_leak.rs index 0822ffbd7e..cff1fd42e8 100644 --- a/crates/camera-windows/examples/enumeration_leak.rs +++ b/crates/camera-windows/examples/enumeration_leak.rs @@ -2,14 +2,15 @@ //! watching threads, handles and private bytes of this process from outside //! (Process Explorer, or `Get-Process -Id ` in a loop). //! -//! Usage: enumeration_leak.exe [mf|ds|both] [iterations] [sleep_ms] +//! Usage: enumeration_leak.exe [mf|ds|both|mf-formats|ds-formats|formats] [iterations] [sleep_ms] //! //! `mf` and `ds` isolate the Media Foundation and DirectShow halves of //! `get_devices()`; whichever mode grows is the leaking half. The printed //! per-enumeration wall time also rises as leaked threads and handles //! accumulate. -fn main() { +#[cfg(windows)] +fn main() -> Result<(), Box> { let mode = std::env::args().nth(1).unwrap_or_else(|| "both".into()); let iterations: usize = std::env::args() .nth(2) @@ -25,30 +26,77 @@ fn main() { std::process::id() ); - let _ = cap_camera_directshow::initialize_directshow(); - let _ = cap_camera_mediafoundation::initialize_mediafoundation(); + if !matches!( + mode.as_str(), + "mf" | "ds" | "both" | "mf-formats" | "ds-formats" | "formats" + ) { + return Err(format!("Unknown enumeration mode: {mode}").into()); + } + cap_camera_directshow::initialize_directshow()?; + cap_camera_mediafoundation::initialize_mediafoundation()?; for i in 1..=iterations { let start = std::time::Instant::now(); + let mut format_count = 0; let devices = match mode.as_str() { - "mf" => cap_camera_mediafoundation::DeviceSourcesIterator::new() - .map(|devices| devices.count()) - .unwrap_or(0), - "ds" => cap_camera_directshow::VideoInputDeviceIterator::new() - .map(|devices| devices.count()) - .unwrap_or(0), - _ => cap_camera_windows::get_devices() - .map(|devices| devices.len()) - .unwrap_or(0), + "mf" => cap_camera_mediafoundation::DeviceSourcesIterator::new()?.count(), + "ds" => cap_camera_directshow::VideoInputDeviceIterator::new()?.count(), + "mf-formats" => { + let mut count = 0; + for device in cap_camera_mediafoundation::DeviceSourcesIterator::new()? { + let formats = device.formats().map(Iterator::count); + device.shutdown(); + format_count += formats?; + count += 1; + } + count + } + "ds-formats" => { + let mut count = 0; + for device in cap_camera_directshow::VideoInputDeviceIterator::new()? { + format_count += device + .media_types() + .ok_or("Could not read DirectShow formats")? + .count(); + count += 1; + } + count + } + "formats" => { + let devices = cap_camera_windows::get_devices()?; + let count = devices.len(); + for device in devices { + format_count += device.into_formats().len(); + } + count + } + _ => cap_camera_windows::get_devices()?.len(), }; + if devices == 0 { + return Err( + "No cameras found; this run cannot validate camera resource stability".into(), + ); + } + if mode.ends_with("formats") && format_count == 0 { + return Err( + "No camera formats found; format-probe validation requires a usable camera".into(), + ); + } println!( - "{i}: {devices} device(s) in {}ms", + "{i}: {devices} device(s), {format_count} format(s) in {}ms", start.elapsed().as_millis() ); std::thread::sleep(std::time::Duration::from_millis(sleep_ms)); } println!("done"); + Ok(()) +} + +#[cfg(not(windows))] +fn main() { + eprintln!("Camera enumeration leak validation requires Windows"); + std::process::exit(1); } diff --git a/crates/camera-windows/src/lib.rs b/crates/camera-windows/src/lib.rs index 2fd12c5779..b1004ae2d6 100644 --- a/crates/camera-windows/src/lib.rs +++ b/crates/camera-windows/src/lib.rs @@ -399,6 +399,16 @@ impl VideoDeviceInfo { Ok(res) } + pub fn into_formats(self) -> Vec { + let formats = self.formats(); + // Format-only callers own a temporary device. Shut it down after the + // reader is dropped without racing a live capture engine's teardown. + if let VideoDeviceInfoInner::MediaFoundation { device, .. } = &self.inner { + device.shutdown(); + } + formats + } + pub fn formats(&self) -> Vec { match &self.inner { VideoDeviceInfoInner::MediaFoundation { @@ -436,7 +446,6 @@ impl Debug for VideoDeviceInfo { f.debug_struct("DeviceInfo") .field("id", &self.id) .field("name", &self.name) - .field("format_count", &self.formats().len()) .field("inner", &self.inner) .finish() } diff --git a/crates/camera/src/windows.rs b/crates/camera/src/windows.rs index 795850a3f6..a99d3d7b81 100644 --- a/crates/camera/src/windows.rs +++ b/crates/camera/src/windows.rs @@ -29,7 +29,7 @@ impl CameraInfo { let mut ret = vec![]; - for format in device.formats() { + for format in device.into_formats() { // Read before `inner` is moved out of `format`. let pixel_format = format!("{:?}", format.pixel_format()); ret.push(Format {