@@ -135,6 +135,23 @@ pub struct DeviceSnapshot {
135135}
136136
137137impl DeviceSnapshot {
138+ pub fn update_camera_formats (
139+ & mut self ,
140+ queried : & CameraOption ,
141+ formats : Vec < CameraFormat > ,
142+ ) -> bool {
143+ let Some ( camera) = self
144+ . cameras
145+ . iter_mut ( )
146+ . find ( |camera| camera. same_device ( queried) )
147+ else {
148+ return false ;
149+ } ;
150+ camera. best_format = formats. first ( ) . copied ( ) ;
151+ camera. formats = formats;
152+ true
153+ }
154+
138155 /// Enumerate everything. This blocks — AVFoundation camera discovery and the
139156 /// window-server queries are both slow enough to drop frames — so callers
140157 /// should run it on the background executor, never inside `render`.
@@ -179,8 +196,8 @@ pub enum InputSnapshot {
179196}
180197
181198impl InputSnapshot {
182- pub fn cameras ( ) -> Self {
183- Self :: Cameras ( list_cameras ( ) )
199+ pub fn cameras ( previous : & [ CameraOption ] ) -> Self {
200+ Self :: Cameras ( list_cameras_with_previous ( previous ) )
184201 }
185202
186203 pub fn microphones ( ) -> Self {
@@ -189,7 +206,20 @@ impl InputSnapshot {
189206
190207 pub fn install ( self , snapshot : & mut DeviceSnapshot ) -> bool {
191208 match self {
192- Self :: Cameras ( cameras) if snapshot. cameras != cameras => {
209+ Self :: Cameras ( mut cameras) => {
210+ for camera in & mut cameras {
211+ if camera. formats . is_empty ( )
212+ && let Some ( current) = snapshot. cameras . iter ( ) . find ( |current| {
213+ current. same_device ( camera) && !current. formats . is_empty ( )
214+ } )
215+ {
216+ camera. best_format = current. best_format ;
217+ camera. formats = current. formats . clone ( ) ;
218+ }
219+ }
220+ if snapshot. cameras == cameras {
221+ return false ;
222+ }
193223 snapshot. cameras = cameras;
194224 }
195225 Self :: Microphones ( microphones) if snapshot. microphones != microphones => {
@@ -251,6 +281,149 @@ mod input_enumeration_tests {
251281 assert ! ( InputSnapshot :: Microphones ( Vec :: new( ) ) . install( & mut snapshot) ) ;
252282 assert_eq ! ( snapshot. cameras, vec![ camera] ) ;
253283 }
284+ fn camera_fixture ( id : & str ) -> CameraOption {
285+ CameraOption {
286+ device_id : id. into ( ) ,
287+ model_id : None ,
288+ label : "Camera" . into ( ) ,
289+ best_format : None ,
290+ formats : Vec :: new ( ) ,
291+ }
292+ }
293+
294+ fn formats_fixture ( ) -> Vec < CameraFormat > {
295+ vec ! [ CameraFormat {
296+ width: 1920 ,
297+ height: 1080 ,
298+ frame_rate: 30.0 ,
299+ } ]
300+ }
301+
302+ #[ test]
303+ fn completed_query_updates_matching_camera_capabilities ( ) {
304+ let queried = camera_fixture ( "camera-a" ) ;
305+ let mut snapshot = DeviceSnapshot {
306+ cameras : vec ! [ queried. clone( ) ] ,
307+ ..Default :: default ( )
308+ } ;
309+ assert ! ( snapshot. update_camera_formats( & queried, formats_fixture( ) ) ) ;
310+ assert_eq ! ( snapshot. cameras[ 0 ] . formats, formats_fixture( ) ) ;
311+ assert_eq ! (
312+ snapshot. cameras[ 0 ] . best_format,
313+ formats_fixture( ) . first( ) . copied( )
314+ ) ;
315+ }
316+
317+ #[ test]
318+ fn completed_query_cannot_overwrite_replacement_camera_capabilities ( ) {
319+ let queried = camera_fixture ( "camera-a" ) ;
320+ let mut changed_id = queried. clone ( ) ;
321+ changed_id. device_id = "camera-b" . into ( ) ;
322+ let mut changed_model = queried. clone ( ) ;
323+ changed_model. model_id =
324+ Some ( cap_camera:: ModelID :: try_from ( "046d:08e5" . to_string ( ) ) . unwrap ( ) ) ;
325+ let mut changed_label = queried. clone ( ) ;
326+ changed_label. label = "Replacement camera" . into ( ) ;
327+ for replacement in [ changed_id, changed_model, changed_label] {
328+ let mut snapshot = DeviceSnapshot {
329+ cameras : vec ! [ replacement. clone( ) ] ,
330+ ..Default :: default ( )
331+ } ;
332+ assert ! ( !snapshot. update_camera_formats( & queried, formats_fixture( ) ) ) ;
333+ assert_eq ! ( snapshot. cameras, vec![ replacement] ) ;
334+ }
335+ }
336+
337+ #[ test]
338+ fn completed_query_cannot_restore_disconnected_camera ( ) {
339+ let mut snapshot = DeviceSnapshot :: default ( ) ;
340+ assert ! ( !snapshot. update_camera_formats( & camera_fixture( "camera-a" ) , formats_fixture( ) ) ) ;
341+ assert ! ( snapshot. cameras. is_empty( ) ) ;
342+ }
343+
344+ #[ test]
345+ fn repeated_camera_refresh_does_not_reopen_unchanged_devices ( ) {
346+ let mut previous = vec ! [ camera_fixture( "camera-a" ) . with_formats( & [ ] , formats_fixture) ] ;
347+ for _ in 0 ..1000 {
348+ let refreshed = camera_fixture ( "camera-a" ) . with_formats ( & previous, || {
349+ panic ! ( "unchanged camera must not reopen its driver" )
350+ } ) ;
351+ assert_eq ! ( refreshed, previous[ 0 ] ) ;
352+ previous = vec ! [ refreshed] ;
353+ }
354+ }
355+
356+ #[ test]
357+ fn new_camera_does_not_inherit_another_devices_formats ( ) {
358+ let previous = vec ! [ camera_fixture( "camera-a" ) . with_formats( & [ ] , formats_fixture) ] ;
359+ let mut probes = 0 ;
360+ let refreshed = camera_fixture ( "camera-b" ) . with_formats ( & previous, || {
361+ probes += 1 ;
362+ Vec :: new ( )
363+ } ) ;
364+ assert_eq ! ( probes, 1 ) ;
365+ assert ! ( refreshed. formats. is_empty( ) ) ;
366+ assert ! ( refreshed. best_format. is_none( ) ) ;
367+ }
368+
369+ #[ test]
370+ fn reconnect_refreshes_formats_after_camera_disappears ( ) {
371+ let connected = camera_fixture ( "camera-a" ) . with_formats ( & [ ] , formats_fixture) ;
372+ let after_disconnect = Vec :: new ( ) ;
373+ let changed_format = CameraFormat {
374+ width : 1280 ,
375+ height : 720 ,
376+ frame_rate : 60.0 ,
377+ } ;
378+ let reconnected =
379+ camera_fixture ( "camera-a" ) . with_formats ( & after_disconnect, || vec ! [ changed_format] ) ;
380+ assert_ne ! ( reconnected. formats, connected. formats) ;
381+ assert_eq ! ( reconnected. best_format, Some ( changed_format) ) ;
382+ }
383+
384+ #[ test]
385+ fn renamed_camera_refreshes_capabilities ( ) {
386+ let previous = vec ! [ camera_fixture( "camera-a" ) . with_formats( & [ ] , formats_fixture) ] ;
387+ let mut camera = camera_fixture ( "camera-a" ) ;
388+ camera. label = "Reconfigured virtual camera" . into ( ) ;
389+ let mut probes = 0 ;
390+ camera. with_formats ( & previous, || {
391+ probes += 1 ;
392+ Vec :: new ( )
393+ } ) ;
394+ assert_eq ! ( probes, 1 ) ;
395+ }
396+ #[ test]
397+ fn failed_probe_is_not_retried_by_background_refresh ( ) {
398+ let previous = vec ! [ camera_fixture( "camera-a" ) . with_formats( & [ ] , Vec :: new) ] ;
399+ let refreshed = camera_fixture ( "camera-a" ) . with_formats ( & previous, || {
400+ panic ! ( "background refresh must not retry a native probe" )
401+ } ) ;
402+ assert ! ( refreshed. formats. is_empty( ) ) ;
403+ }
404+
405+ #[ test]
406+ fn late_refresh_does_not_erase_successful_explicit_retry ( ) {
407+ let refreshed = vec ! [ camera_fixture( "camera-a" ) ] ;
408+ let mut snapshot = DeviceSnapshot {
409+ cameras : vec ! [ camera_fixture( "camera-a" ) . with_formats( & [ ] , formats_fixture) ] ,
410+ ..Default :: default ( )
411+ } ;
412+ assert ! ( !InputSnapshot :: Cameras ( refreshed) . install( & mut snapshot) ) ;
413+ assert_eq ! ( snapshot. cameras[ 0 ] . formats, formats_fixture( ) ) ;
414+ }
415+
416+ #[ test]
417+ fn changed_device_does_not_inherit_previous_capabilities ( ) {
418+ let mut changed = camera_fixture ( "camera-a" ) ;
419+ changed. label = "Changed virtual camera" . into ( ) ;
420+ let mut snapshot = DeviceSnapshot {
421+ cameras : vec ! [ camera_fixture( "camera-a" ) . with_formats( & [ ] , formats_fixture) ] ,
422+ ..Default :: default ( )
423+ } ;
424+ assert ! ( InputSnapshot :: Cameras ( vec![ changed] ) . install( & mut snapshot) ) ;
425+ assert ! ( snapshot. cameras[ 0 ] . formats. is_empty( ) ) ;
426+ }
254427}
255428
256429/// Just the capture targets.
@@ -279,44 +452,107 @@ impl TargetSnapshot {
279452}
280453
281454fn list_cameras ( ) -> Vec < CameraOption > {
455+ list_cameras_with_previous ( & [ ] )
456+ }
457+
458+ fn list_cameras_with_previous ( previous : & [ CameraOption ] ) -> Vec < CameraOption > {
282459 cap_camera:: list_cameras ( )
283460 . map ( |camera| {
284- let mut formats = camera
285- . formats ( )
286- . unwrap_or_default ( )
287- . into_iter ( )
288- . map ( |format| CameraFormat {
289- width : format. width ( ) ,
290- height : format. height ( ) ,
291- frame_rate : format. frame_rate ( ) ,
292- } )
293- . collect :: < Vec < _ > > ( ) ;
294- let mut seen = std:: collections:: HashSet :: new ( ) ;
295- formats. retain ( |format| {
296- seen. insert ( (
297- format. width ,
298- format. height ,
299- format. frame_rate . round ( ) as u32 ,
300- ) )
301- } ) ;
302- formats. sort_by ( |a, b| {
303- ( b. width * b. height )
304- . cmp ( & ( a. width * a. height ) )
305- . then ( b. frame_rate . total_cmp ( & a. frame_rate ) )
306- } ) ;
307- let best_format = formats. first ( ) . copied ( ) ;
308-
309461 CameraOption {
310462 device_id : camera. device_id ( ) . to_string ( ) ,
311463 model_id : camera. model_id ( ) . cloned ( ) ,
312464 label : camera. display_name ( ) . to_string ( ) ,
313- best_format,
314- formats,
465+ best_format : None ,
466+ formats : Vec :: new ( ) ,
315467 }
468+ . with_formats ( previous, || {
469+ camera
470+ . formats ( )
471+ . unwrap_or_default ( )
472+ . into_iter ( )
473+ . map ( |format| CameraFormat {
474+ width : format. width ( ) ,
475+ height : format. height ( ) ,
476+ frame_rate : format. frame_rate ( ) ,
477+ } )
478+ . collect ( )
479+ } )
316480 } )
317481 . collect ( )
318482}
319483
484+ impl CameraOption {
485+ fn same_device ( & self , other : & Self ) -> bool {
486+ self . device_id == other. device_id
487+ && self . model_id == other. model_id
488+ && self . label == other. label
489+ }
490+
491+ fn with_formats (
492+ mut self ,
493+ previous : & [ Self ] ,
494+ load_formats : impl FnOnce ( ) -> Vec < CameraFormat > ,
495+ ) -> Self {
496+ // Windows format probing opens native camera sources; polling an unchanged
497+ // device must reuse metadata rather than repeatedly activating its driver.
498+ if let Some ( previous) = previous. iter ( ) . find ( |previous| previous. same_device ( & self ) ) {
499+ self . best_format = previous. best_format ;
500+ self . formats = previous. formats . clone ( ) ;
501+ return self ;
502+ }
503+
504+ let mut formats = load_formats ( ) ;
505+ let mut seen = std:: collections:: HashSet :: new ( ) ;
506+ formats. retain ( |format| {
507+ seen. insert ( (
508+ format. width ,
509+ format. height ,
510+ format. frame_rate . round ( ) as u32 ,
511+ ) )
512+ } ) ;
513+ formats. sort_by ( |a, b| {
514+ ( u64:: from ( b. width ) * u64:: from ( b. height ) )
515+ . cmp ( & ( u64:: from ( a. width ) * u64:: from ( a. height ) ) )
516+ . then ( b. frame_rate . total_cmp ( & a. frame_rate ) )
517+ } ) ;
518+ self . best_format = formats. first ( ) . copied ( ) ;
519+ self . formats = formats;
520+ self
521+ }
522+ }
523+
524+ pub fn camera_formats ( device_id : & str ) -> Result < Vec < CameraFormat > , String > {
525+ let camera = cap_camera:: list_cameras ( )
526+ . find ( |camera| camera. device_id ( ) == device_id)
527+ . ok_or_else ( || "Camera is no longer available. Reconnect it and try again." . to_string ( ) ) ?;
528+ let formats = camera. formats ( ) . ok_or_else ( || {
529+ "Could not read camera formats. Check camera access and try again." . to_string ( )
530+ } ) ?;
531+ if formats. is_empty ( ) {
532+ return Err (
533+ "No camera formats are available. Check camera access and try again." . to_string ( ) ,
534+ ) ;
535+ }
536+ Ok ( CameraOption {
537+ device_id : camera. device_id ( ) . to_string ( ) ,
538+ model_id : camera. model_id ( ) . cloned ( ) ,
539+ label : camera. display_name ( ) . to_string ( ) ,
540+ best_format : None ,
541+ formats : Vec :: new ( ) ,
542+ }
543+ . with_formats ( & [ ] , || {
544+ formats
545+ . into_iter ( )
546+ . map ( |format| CameraFormat {
547+ width : format. width ( ) ,
548+ height : format. height ( ) ,
549+ frame_rate : format. frame_rate ( ) ,
550+ } )
551+ . collect ( )
552+ } )
553+ . formats )
554+ }
555+
320556/// Mirrors `MicrophoneFeed::list_with_settings`: the default input device is
321557/// inserted first so it heads the list, then every other input device is
322558/// appended, deduped by name.
0 commit comments