From c2802e520a08dbf0c939ec8b5653b29dfd886427 Mon Sep 17 00:00:00 2001 From: ninbura Date: Sat, 11 Jul 2026 00:27:50 -0600 Subject: [PATCH] avdevice/avfoundation: add device selection by USB serial and unique ID A capture device could previously be selected only by index or by name. Both are unreliable when multiple audio/video devices share a name or across reboots. AVFoundation reorders the device indices, and the USB video uniqueID embeds the macOS locationID, which can be reassigned on reboot or replug and can then resolve to a different physical device. Add -video_device_id and -audio_device_id, which take a prefixed identifier: uid: or serial:. The USB serial number is the only identifier that stays pegged to a given physical unit. For video it is resolved to the device's current locationID via IOKit. For audio it is matched against the uniqueID, which already embeds it. The unique ID covers devices that have no serial, such as virtual camera/audio devices. -list_devices additionally prints each device's uniqueID and USB serial so the values can be discovered. IOKit is detected in configure and used only when available. Signed-off-by: Gabriel Balaich --- Changelog | 1 + configure | 4 +- doc/indevs.texi | 16 ++- libavdevice/avfoundation.m | 273 ++++++++++++++++++++++++++++++++++--- 4 files changed, 271 insertions(+), 23 deletions(-) diff --git a/Changelog b/Changelog index ce57599aa2..38f1e10263 100644 --- a/Changelog +++ b/Changelog @@ -9,6 +9,7 @@ version : - MVR demuxer - latticepal filter - DVD-Audio LPCM decoder and demuxing support +- AVFoundation input device selection by unique ID and USB serial number version 9.0: diff --git a/configure b/configure index 61a948d14d..8ac272a155 100755 --- a/configure +++ b/configure @@ -2688,6 +2688,7 @@ HAVE_LIST=" ffnvcodec_cuarray gzip ioctl_posix + iokit libdrm_getfb2 makeinfo_html opencl_d3d11 @@ -4014,7 +4015,7 @@ android_camera_indev_deps="android camera2ndk mediandk pthreads" alsa_indev_deps="alsa" alsa_outdev_deps="alsa" avfoundation_indev_deps="avfoundation corevideo coremedia pthreads AVCaptureSession" -avfoundation_indev_suggest="coregraphics applicationservices" +avfoundation_indev_suggest="coregraphics applicationservices iokit" avfoundation_indev_extralibs="-framework Foundation" audiotoolbox_outdev_deps="audiotoolbox pthreads AudioObjectPropertyAddress" audiotoolbox_outdev_extralibs="-framework AudioToolbox -framework CoreAudio" @@ -7108,6 +7109,7 @@ enabled avfoundation && { disable coregraphics applicationservices check_lib coregraphics CoreGraphics/CoreGraphics.h CGGetActiveDisplayList "-framework CoreGraphics" || check_lib applicationservices ApplicationServices/ApplicationServices.h CGGetActiveDisplayList "-framework ApplicationServices" + check_lib iokit IOKit/IOKitLib.h IOServiceGetMatchingServices "-framework IOKit" check_objc_class AVFoundation/AVFoundation.h AVCaptureSession } diff --git a/doc/indevs.texi b/doc/indevs.texi index 7c3c7114a4..31dfc098bb 100644 --- a/doc/indevs.texi +++ b/doc/indevs.texi @@ -150,8 +150,8 @@ AVFoundation supports the following options: @table @option @item -list_devices -If set to true, a list of all available input devices is given showing all -device names and indices. +If set to true, a list of all available input devices is given showing their +index, name, unique ID, and USB serial number (when available). @item -video_device_index Specify the video device by its index. Overrides anything given in the input filename. @@ -159,6 +159,18 @@ Specify the video device by its index. Overrides anything given in the input fil @item -audio_device_index Specify the audio device by its index. Overrides anything given in the input filename. +@item -video_device_id +Specify the video device by a prefixed identifier, as shown by +@option{-list_devices}: @code{uid:} selects by AVFoundation unique ID, +@code{serial:} by USB serial number. The unique ID of a UVC device +may not be stable across reboots. Only UVC devices expose a serial number. +Overrides anything given in the input filename. + +@item -audio_device_id +Specify the audio device by a prefixed identifier. See +@option{-video_device_id}; for audio, the serial is only available for +class-compliant USB devices. + @item -pixel_format Request the video device to use a specific pixel format. If the specified format is not supported, a list of available formats is given diff --git a/libavdevice/avfoundation.m b/libavdevice/avfoundation.m index 6f52989fc9..88b814ecf1 100644 --- a/libavdevice/avfoundation.m +++ b/libavdevice/avfoundation.m @@ -25,7 +25,19 @@ * @author Thilo Borgmann */ +#include "config.h" + #import +#if HAVE_IOKIT +# import + /* kIOMainPortDefault replaced kIOMasterPortDefault in the macOS 12 SDK. */ +# if defined(__MAC_OS_X_VERSION_MAX_ALLOWED) && __MAC_OS_X_VERSION_MAX_ALLOWED >= 120000 +# define AVF_IO_MAIN_PORT_DEFAULT kIOMainPortDefault +# else +# define AVF_IO_MAIN_PORT_DEFAULT kIOMasterPortDefault +# endif +#endif + #include #include "libavutil/channel_layout.h" @@ -113,6 +125,8 @@ typedef struct char *url; char *video_filename; char *audio_filename; + char *video_device_id; + char *audio_device_id; int num_video_devices; @@ -479,7 +493,7 @@ static int add_video_device(AVFormatContext *s, AVCaptureDevice *video_device) NSDictionary *capture_dict; dispatch_queue_t queue; - if (ctx->video_device_index < ctx->num_video_devices) { + if (!ctx->video_is_screen) { capture_input = (AVCaptureInput*) [[[AVCaptureDeviceInput alloc] initWithDevice:video_device error:&error] autorelease]; } else { capture_input = (AVCaptureInput*) video_device; @@ -852,6 +866,221 @@ static NSArray* getDevicesWithMediaType(AVMediaType mediaType) { #endif } +#if HAVE_IOKIT +static int avf_io_get_string(io_service_t service, CFStringRef key, char *buf, size_t size) +{ + CFTypeRef ref = IORegistryEntryCreateCFProperty(service, key, kCFAllocatorDefault, 0); + int ok = ref && CFGetTypeID(ref) == CFStringGetTypeID() && CFStringGetCString(ref, buf, size, kCFStringEncodingUTF8); + if (ref) + CFRelease(ref); + return ok; +} + +static int avf_io_get_uint32(io_service_t service, CFStringRef key, uint32_t *out) +{ + CFTypeRef ref = IORegistryEntryCreateCFProperty(service, key, kCFAllocatorDefault, 0); + int ok = ref && CFGetTypeID(ref) == CFNumberGetTypeID() && CFNumberGetValue(ref, kCFNumberSInt32Type, out); + if (ref) + CFRelease(ref); + return ok; +} +#endif + +#if HAVE_IOKIT +static int64_t avf_usb_location_for_serial(const char *serial) +{ + int64_t location = -1; + io_iterator_t iterator = 0; + io_service_t service; + + if (IOServiceGetMatchingServices(AVF_IO_MAIN_PORT_DEFAULT, + IOServiceMatching("IOUSBHostDevice"), &iterator) != KERN_SUCCESS) + return -1; + + while (location < 0 && (service = IOIteratorNext(iterator))) { + char found[512]; + uint32_t loc; + if (avf_io_get_string(service, CFSTR("USB Serial Number"), found, sizeof(found)) && + !strcmp(found, serial) && + avf_io_get_uint32(service, CFSTR("locationID"), &loc)) + location = loc; + IOObjectRelease(service); + } + IOObjectRelease(iterator); + + return location; +} +#else +static int64_t avf_usb_location_for_serial(const char *serial) +{ + return -1; +} +#endif + +#if HAVE_IOKIT +static NSString *avf_usb_serial_for_location(uint32_t location) +{ + NSString *serial = nil; + io_iterator_t iterator = 0; + io_service_t service; + + if (IOServiceGetMatchingServices(AVF_IO_MAIN_PORT_DEFAULT, + IOServiceMatching("IOUSBHostDevice"), &iterator) != KERN_SUCCESS) + return nil; + + while (!serial && (service = IOIteratorNext(iterator))) { + char found[512]; + uint32_t loc; + if (avf_io_get_uint32(service, CFSTR("locationID"), &loc) && loc == location && + avf_io_get_string(service, CFSTR("USB Serial Number"), found, sizeof(found))) + serial = [NSString stringWithUTF8String:found]; + IOObjectRelease(service); + } + IOObjectRelease(iterator); + + return serial; +} +#else +static NSString *avf_usb_serial_for_location(uint32_t location) +{ + return nil; +} +#endif + +// USB video uniqueID = locationID<<32 | VID<<16 | PID; match on the locationID. +static AVCaptureDevice *avf_video_device_with_serial(const char *serial, + NSArray *devices, NSArray *devices_muxed, int *is_muxed) +{ + int64_t location = avf_usb_location_for_serial(serial); + NSArray *lists[2] = { devices, devices_muxed }; + + if (location < 0) + return nil; + + for (int i = 0; i < 2; i++) { + for (AVCaptureDevice *device in lists[i]) { + NSString *uid = [device uniqueID]; + if ([uid hasPrefix:@"0x"] && + (uint32_t)(strtoull([uid UTF8String], NULL, 16) >> 32) == (uint32_t)location) { + *is_muxed = (i == 1); + return device; + } + } + } + + return nil; +} + +// CoreAudio USB-audio UID: AppleUSBAudioEngine:manufacturer:device:serial:interfaces +#if HAVE_IOKIT +static NSString *avf_audio_serial_for_uid(NSString *uid) +{ + if (![uid hasPrefix:@"AppleUSBAudioEngine:"]) + return nil; + NSArray *fields = [uid componentsSeparatedByString:@":"]; + if (fields.count < 5) + return nil; + NSString *serial = fields[fields.count - 2]; + if (serial.length && avf_usb_location_for_serial([serial UTF8String]) >= 0) + return serial; + return nil; +} +#else +static NSString *avf_audio_serial_for_uid(NSString *uid) +{ + return nil; +} +#endif + +static AVCaptureDevice *avf_audio_device_with_serial(const char *serial, NSArray *devices) +{ + NSString *want = [NSString stringWithUTF8String:serial]; + + for (AVCaptureDevice *device in devices) + if ([avf_audio_serial_for_uid([device uniqueID]) isEqualToString:want]) + return device; + + return nil; +} + +static AVCaptureDevice *avf_device_with_uid(const char *uid, + NSArray *devices, NSArray *devices_muxed, int *is_muxed) +{ + NSString *want = [NSString stringWithUTF8String:uid]; + NSArray *lists[2] = { devices, devices_muxed }; + + for (int i = 0; i < 2; i++) { + for (AVCaptureDevice *device in lists[i]) { + if ([[device uniqueID] isEqualToString:want]) { + if (is_muxed) + *is_muxed = (i == 1); + return device; + } + } + } + + return nil; +} + +// Best-effort serial string for -list_devices, or nil. +static NSString *avf_device_listing_serial(AVCaptureDevice *device) +{ + NSString *uid = [device uniqueID]; + + if ([uid hasPrefix:@"0x"]) { + unsigned long long value = strtoull([uid UTF8String], NULL, 16); + NSString *serial = avf_usb_serial_for_location((uint32_t)(value >> 32)); + return serial.length ? serial : nil; + } + return avf_audio_serial_for_uid(uid); +} + +static void avf_log_device_entry(AVFContext *ctx, int index, AVCaptureDevice *device) +{ + NSString *serial = avf_device_listing_serial(device); + + if (serial) + av_log(ctx, AV_LOG_INFO, "[%d] %s [uid:%s] [serial:%s]\n", index, + [[device localizedName] UTF8String], [[device uniqueID] UTF8String], + [serial UTF8String]); + else + av_log(ctx, AV_LOG_INFO, "[%d] %s [uid:%s]\n", index, + [[device localizedName] UTF8String], [[device uniqueID] UTF8String]); +} + +// Returns 1 if a device id was set (*device = match, or nil after logging on miss), else 0. +static int avf_device_from_id(AVFContext *ctx, AVMediaType media_type, + NSArray *devices, NSArray *devices_muxed, + const char *id, AVCaptureDevice **device, int *is_muxed) +{ + BOOL is_audio = [media_type isEqualToString:AVMediaTypeAudio]; + const char *kind = is_audio ? "Audio" : "Video"; + const char *value = NULL; + + if (!id) + return 0; + + if (av_strstart(id, "serial:", &value)) { + if (is_audio) + *device = avf_audio_device_with_serial(value, devices); + else + *device = avf_video_device_with_serial(value, devices, devices_muxed, is_muxed); + if (!*device) + av_log(ctx, AV_LOG_ERROR, + "%s capture device with serial number '%s' not found\n", kind, value); + } else if (av_strstart(id, "uid:", &value)) { + *device = avf_device_with_uid(value, devices, devices_muxed, is_muxed); + if (!*device) + av_log(ctx, AV_LOG_ERROR, + "%s capture device with unique ID '%s' not found\n", kind, value); + } else { + av_log(ctx, AV_LOG_ERROR, + "Invalid %s device id '%s': expected 'uid:' or 'serial:'\n", + is_audio ? "audio" : "video", id); + } + return 1; +} + static int avf_read_header(AVFormatContext *s) { int ret = 0; @@ -863,6 +1092,7 @@ static int avf_read_header(AVFormatContext *s) // Find capture device NSArray *devices = getDevicesWithMediaType(AVMediaTypeVideo); NSArray *devices_muxed = getDevicesWithMediaType(AVMediaTypeMuxed); + NSArray *audio_devices = getDevicesWithMediaType(AVMediaTypeAudio); ctx->num_video_devices = [devices count] + [devices_muxed count]; @@ -879,14 +1109,12 @@ static int avf_read_header(AVFormatContext *s) int index = 0; av_log(ctx, AV_LOG_INFO, "AVFoundation video devices:\n"); for (AVCaptureDevice *device in devices) { - const char *name = [[device localizedName] UTF8String]; - index = [devices indexOfObject:device]; - av_log(ctx, AV_LOG_INFO, "[%d] %s\n", index, name); + index = [devices indexOfObject:device]; + avf_log_device_entry(ctx, index, device); } for (AVCaptureDevice *device in devices_muxed) { - const char *name = [[device localizedName] UTF8String]; - index = [devices count] + [devices_muxed indexOfObject:device]; - av_log(ctx, AV_LOG_INFO, "[%d] %s\n", index, name); + index = [devices count] + [devices_muxed indexOfObject:device]; + avf_log_device_entry(ctx, index, device); } #if !TARGET_OS_IPHONE && __MAC_OS_X_VERSION_MIN_REQUIRED >= 1070 if (num_screens > 0) { @@ -901,9 +1129,8 @@ static int avf_read_header(AVFormatContext *s) av_log(ctx, AV_LOG_INFO, "AVFoundation audio devices:\n"); devices = getDevicesWithMediaType(AVMediaTypeAudio); for (AVCaptureDevice *device in devices) { - const char *name = [[device localizedName] UTF8String]; - int index = [devices indexOfObject:device]; - av_log(ctx, AV_LOG_INFO, "[%d] %s\n", index, name); + int index = [devices indexOfObject:device]; + avf_log_device_entry(ctx, index, device); } goto fail; } @@ -921,7 +1148,11 @@ static int avf_read_header(AVFormatContext *s) sscanf(ctx->audio_filename, "%d", &ctx->audio_device_index); } - if (ctx->video_device_index >= 0) { + if (avf_device_from_id(ctx, AVMediaTypeVideo, devices, devices_muxed, + ctx->video_device_id, &video_device, &ctx->video_is_muxed)) { + if (!video_device) + goto fail; + } else if (ctx->video_device_index >= 0) { if (ctx->video_device_index < ctx->num_video_devices) { if (ctx->video_device_index < [devices count]) { video_device = [devices objectAtIndex:ctx->video_device_index]; @@ -1022,23 +1253,23 @@ static int avf_read_header(AVFormatContext *s) } // get audio device - if (ctx->audio_device_index >= 0) { - NSArray *devices = getDevicesWithMediaType(AVMediaTypeAudio); - - if (ctx->audio_device_index >= [devices count]) { + if (avf_device_from_id(ctx, AVMediaTypeAudio, audio_devices, nil, + ctx->audio_device_id, &audio_device, NULL)) { + if (!audio_device) + goto fail; + } else if (ctx->audio_device_index >= 0) { + if (ctx->audio_device_index >= [audio_devices count]) { av_log(ctx, AV_LOG_ERROR, "Invalid audio device index\n"); goto fail; } - audio_device = [devices objectAtIndex:ctx->audio_device_index]; + audio_device = [audio_devices objectAtIndex:ctx->audio_device_index]; } else if (ctx->audio_filename && strncmp(ctx->audio_filename, "none", 4)) { if (!strncmp(ctx->audio_filename, "default", 7)) { audio_device = [AVCaptureDevice defaultDeviceWithMediaType:AVMediaTypeAudio]; } else { - NSArray *devices = getDevicesWithMediaType(AVMediaTypeAudio); - - for (AVCaptureDevice *device in devices) { + for (AVCaptureDevice *device in audio_devices) { if (!strncmp(ctx->audio_filename, [[device localizedName] UTF8String], strlen(ctx->audio_filename))) { audio_device = device; break; @@ -1059,7 +1290,7 @@ static int avf_read_header(AVFormatContext *s) } if (video_device) { - if (ctx->video_device_index < ctx->num_video_devices) { + if (!ctx->video_is_screen) { av_log(s, AV_LOG_DEBUG, "'%s' opened\n", [[video_device localizedName] UTF8String]); } else { av_log(s, AV_LOG_DEBUG, "'%s' opened\n", [[video_device description] UTF8String]); @@ -1316,6 +1547,8 @@ static const AVOption options[] = { { "list_devices", "list available devices", offsetof(AVFContext, list_devices), AV_OPT_TYPE_BOOL, {.i64=0}, 0, 1, AV_OPT_FLAG_DECODING_PARAM }, { "video_device_index", "select video device by index for devices with same name (starts at 0)", offsetof(AVFContext, video_device_index), AV_OPT_TYPE_INT, {.i64 = -1}, -1, INT_MAX, AV_OPT_FLAG_DECODING_PARAM }, { "audio_device_index", "select audio device by index for devices with same name (starts at 0)", offsetof(AVFContext, audio_device_index), AV_OPT_TYPE_INT, {.i64 = -1}, -1, INT_MAX, AV_OPT_FLAG_DECODING_PARAM }, + { "video_device_id", "select video device by prefixed id (uid: or serial:)", offsetof(AVFContext, video_device_id), AV_OPT_TYPE_STRING, {.str = NULL}, 0, 0, AV_OPT_FLAG_DECODING_PARAM }, + { "audio_device_id", "select audio device by prefixed id (uid: or serial:)", offsetof(AVFContext, audio_device_id), AV_OPT_TYPE_STRING, {.str = NULL}, 0, 0, AV_OPT_FLAG_DECODING_PARAM }, { "pixel_format", "set pixel format", offsetof(AVFContext, pixel_format), AV_OPT_TYPE_PIXEL_FMT, {.i64 = AV_PIX_FMT_YUV420P}, 0, INT_MAX, AV_OPT_FLAG_DECODING_PARAM}, { "framerate", "set frame rate", offsetof(AVFContext, framerate), AV_OPT_TYPE_VIDEO_RATE, {.str = "ntsc"}, 0, INT_MAX, AV_OPT_FLAG_DECODING_PARAM }, { "video_size", "set video size", offsetof(AVFContext, width), AV_OPT_TYPE_IMAGE_SIZE, {.str = NULL}, 0, 0, AV_OPT_FLAG_DECODING_PARAM },