diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 6a803b6a5..404b819d9 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -40,6 +40,7 @@ Features: - Use zero-copy for Packet init from buffer data by :gh-user:`WyattBlue` in (:pr:`2199`). - Expose AVIndexEntry by :gh-user:`Queuecumber` in (:pr:`2136`). - Preserving hardware memory during cuvid decoding, exporting/importing via dlpack by :gh-user:`WyattBlue` in (:pr:`2155`). +- Add enumerate_input_devices and enumerate_output_devices API by :gh-user:`WyattBlue` in (:pr:`2174`). Fixes: diff --git a/av/__init__.py b/av/__init__.py index cbc3c8a2f..9d8148082 100644 --- a/av/__init__.py +++ b/av/__init__.py @@ -18,6 +18,7 @@ from av.codec.codec import Codec, codecs_available from av.codec.context import CodecContext from av.container import open +from av.device import DeviceInfo, enumerate_input_devices, enumerate_output_devices from av.format import ContainerFormat, formats_available from av.packet import Packet from av.error import * # noqa: F403; This is limited to exception types. @@ -44,6 +45,9 @@ "codecs_available", "CodecContext", "open", + "DeviceInfo", + "enumerate_input_devices", + "enumerate_output_devices", "ContainerFormat", "formats_available", "Packet", diff --git a/av/_core.pxd b/av/_core.pxd index 49f665b6e..43d1e716d 100644 --- a/av/_core.pxd +++ b/av/_core.pxd @@ -1,9 +1,3 @@ -cdef extern from "libavdevice/avdevice.h" nogil: - cdef int avdevice_version() - cdef char* avdevice_configuration() - cdef char* avdevice_license() - void avdevice_register_all() - cdef extern from "libswscale/swscale.h" nogil: cdef int swscale_version() cdef char* swscale_configuration() diff --git a/av/_core.py b/av/_core.py index d9ad01074..6ad38df5b 100644 --- a/av/_core.py +++ b/av/_core.py @@ -1,7 +1,7 @@ import cython import cython.cimports.libav as lib -avdevice_register_all() +lib.avdevice_register_all() # Exports. time_base = lib.AV_TIME_BASE @@ -41,9 +41,9 @@ def decode_version(v): license=lib.avformat_license(), ), "libavdevice": dict( - version=decode_version(avdevice_version()), - configuration=avdevice_configuration(), - license=avdevice_license(), + version=decode_version(lib.avdevice_version()), + configuration=lib.avdevice_configuration(), + license=lib.avdevice_license(), ), "libavfilter": dict( version=decode_version(lib.avfilter_version()), diff --git a/av/device.py b/av/device.py new file mode 100644 index 000000000..7be7e68a6 --- /dev/null +++ b/av/device.py @@ -0,0 +1,187 @@ +import re + +import cython +import cython.cimports.libav as lib +from cython.cimports.av.error import err_check + + +class DeviceInfo: + """Information about an input or output device. + + :param str name: The device identifier, for use as the first argument to :func:`av.open`. + :param str description: Human-readable description of the device. + :param bool is_default: Whether this is the default device. + :param list media_types: Media types this device provides, e.g. ``["video"]``, ``["audio"]``, + or ``["video", "audio"]``. + + """ + + name: str + description: str + is_default: bool + media_types: list[str] + + def __init__( + self, + name: str, + description: str, + is_default: bool, + media_types: list[str], + ) -> None: + self.name = name + self.description = description + self.is_default = is_default + self.media_types = media_types + + def __repr__(self) -> str: + default = " (default)" if self.is_default else "" + return f"" + + +@cython.cfunc +def _build_device_list(device_list: cython.pointer[lib.AVDeviceInfoList]) -> list: + devices: list = [] + i: cython.int + j: cython.int + device_info: cython.pointer[lib.AVDeviceInfo] + mt: lib.AVMediaType + s: cython.p_const_char + + for i in range(device_list.nb_devices): + device_info = device_list.devices[i] + + media_types: list = [] + for j in range(device_info.nb_media_types): + mt = device_info.media_types[j] + s = lib.av_get_media_type_string(mt) + if s: + media_types.append(s.decode()) + + devices.append( + DeviceInfo( + name=device_info.device_name.decode() + if device_info.device_name + else "", + description=device_info.device_description.decode() + if device_info.device_description + else "", + is_default=(i == device_list.default_device), + media_types=media_types, + ) + ) + + return devices + + +def _enumerate_via_log_fallback(format_name: str) -> list[DeviceInfo]: + """Fallback for formats (e.g. avfoundation) that log devices instead of + implementing get_device_list. Opens the format with list_devices=1 and + parses the INFO log output.""" + from av import logging as avlogging + + fmt: cython.pointer[cython.const[lib.AVInputFormat]] = lib.av_find_input_format( + format_name + ) + + opts: cython.pointer[lib.AVDictionary] = cython.NULL + lib.av_dict_set(cython.address(opts), b"list_devices", b"1", 0) + + ctx: cython.pointer[lib.AVFormatContext] = cython.NULL + + # Temporarily enable INFO logging so Capture receives device list messages. + old_level = avlogging.get_level() + avlogging.set_level(avlogging.INFO) + devices: list[DeviceInfo] = [] + try: + with avlogging.Capture() as logs: + lib.avformat_open_input(cython.address(ctx), b"", fmt, cython.address(opts)) + if ctx: + lib.avformat_close_input(cython.address(ctx)) + + current_media_type = "video" + for _level, _name, message in logs: + message = message.strip() + if "video devices" in message.lower(): + current_media_type = "video" + elif "audio devices" in message.lower(): + current_media_type = "audio" + else: + m = re.match(r"\[(\d+)\] (.+)", message) + if m: + devices.append( + DeviceInfo( + name=m.group(1), + description=m.group(2), + is_default=False, + media_types=[current_media_type], + ) + ) + finally: + avlogging.set_level(old_level) + lib.av_dict_free(cython.address(opts)) + + return devices + + +def enumerate_input_devices(format_name: str) -> list[DeviceInfo]: + """List the available input devices for a given format. + + :param str format_name: The format name, e.g. ``"avfoundation"``, ``"dshow"``, ``"v4l2"``. + :rtype: list[DeviceInfo] + :raises ValueError: If *format_name* is not a known input format. + :raises av.FFmpegError: If the device does not support enumeration. + + Example:: + + for device in av.enumerate_input_devices("avfoundation"): + print(device.name, device.description) + + """ + fmt: cython.pointer[cython.const[lib.AVInputFormat]] = lib.av_find_input_format( + format_name + ) + if not fmt: + raise ValueError(f"no such input format: {format_name!r}") + + device_list: cython.pointer[lib.AVDeviceInfoList] = cython.NULL + try: + err_check( + lib.avdevice_list_input_sources( + fmt, cython.NULL, cython.NULL, cython.address(device_list) + ) + ) + return _build_device_list(device_list) + except NotImplementedError: + # Format doesn't implement get_device_list (e.g. avfoundation). + # Fall back to opening with list_devices=1 and parsing the log output. + return _enumerate_via_log_fallback(format_name) + finally: + lib.avdevice_free_list_devices(cython.address(device_list)) + + +def enumerate_output_devices(format_name: str) -> list[DeviceInfo]: + """List the available output devices for a given format. + + :param str format_name: The format name, e.g. ``"audiotoolbox"``. + :rtype: list[DeviceInfo] + :raises ValueError: If *format_name* is not a known output format. + :raises av.FFmpegError: If the device does not support enumeration. + + """ + fmt: cython.pointer[cython.const[lib.AVOutputFormat]] = lib.av_guess_format( + format_name, cython.NULL, cython.NULL + ) + if not fmt: + raise ValueError(f"no such output format: {format_name!r}") + + device_list: cython.pointer[lib.AVDeviceInfoList] = cython.NULL + err_check( + lib.avdevice_list_output_sinks( + fmt, cython.NULL, cython.NULL, cython.address(device_list) + ) + ) + + try: + return _build_device_list(device_list) + finally: + lib.avdevice_free_list_devices(cython.address(device_list)) diff --git a/av/device.pyi b/av/device.pyi new file mode 100644 index 000000000..33c556994 --- /dev/null +++ b/av/device.pyi @@ -0,0 +1,19 @@ +__all__ = ("DeviceInfo", "enumerate_input_devices", "enumerate_output_devices") + +class DeviceInfo: + name: str + description: str + is_default: bool + media_types: list[str] + + def __init__( + self, + name: str, + description: str, + is_default: bool, + media_types: list[str], + ) -> None: ... + def __repr__(self) -> str: ... + +def enumerate_input_devices(format_name: str) -> list[DeviceInfo]: ... +def enumerate_output_devices(format_name: str) -> list[DeviceInfo]: ... diff --git a/examples/basics/record_facecam.py b/examples/basics/record_facecam.py index 0bba6b36a..e554d25d2 100644 --- a/examples/basics/record_facecam.py +++ b/examples/basics/record_facecam.py @@ -5,9 +5,9 @@ """ This is written for MacOS. Other platforms will need to init `input_` differently. -You may need to change the file "0". Use this command to list all devices: +You may need to change the file "0". Use this API to list all devices: - ffmpeg -f avfoundation -list_devices true -i "" + av.enumerate_input_devices("avfoundation") """ diff --git a/examples/basics/record_screen.py b/examples/basics/record_screen.py index 14fdfc428..76b50a7e2 100644 --- a/examples/basics/record_screen.py +++ b/examples/basics/record_screen.py @@ -4,9 +4,9 @@ """ This is written for MacOS. Other platforms will need a different file, format pair. -You may need to change the file "1". Use this command to list all devices: +You may need to change the file "1". Use this API to list all devices: - ffmpeg -f avfoundation -list_devices true -i "" + av.enumerate_input_devices("avfoundation") """ diff --git a/include/avdevice.pxd b/include/avdevice.pxd new file mode 100644 index 000000000..9631337fa --- /dev/null +++ b/include/avdevice.pxd @@ -0,0 +1,30 @@ +cdef extern from "libavdevice/avdevice.h" nogil: + cdef int avdevice_version() + cdef char* avdevice_configuration() + cdef char* avdevice_license() + void avdevice_register_all() + + cdef struct AVDeviceInfo: + char *device_name + char *device_description + int nb_media_types + AVMediaType *media_types + + cdef struct AVDeviceInfoList: + AVDeviceInfo **devices + int nb_devices + int default_device + + int avdevice_list_input_sources( + const AVInputFormat *device, + const char *device_name, + AVDictionary *device_options, + AVDeviceInfoList **device_list, + ) + int avdevice_list_output_sinks( + const AVOutputFormat *device, + const char *device_name, + AVDictionary *device_options, + AVDeviceInfoList **device_list, + ) + void avdevice_free_list_devices(AVDeviceInfoList **device_list) diff --git a/include/libav.pxd b/include/libav.pxd index 9157a8b33..333815d80 100644 --- a/include/libav.pxd +++ b/include/libav.pxd @@ -2,3 +2,4 @@ include "avutil.pxd" include "avcodec.pxd" include "avformat.pxd" include "avfilter.pxd" +include "avdevice.pxd" diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 000000000..4fffaf697 --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,66 @@ +import sys + +import pytest + +import av.error +from av.device import DeviceInfo, enumerate_input_devices, enumerate_output_devices + + +def test_device_info_attributes() -> None: + d = DeviceInfo("0", "FaceTime HD Camera", True, ["video"]) + assert d.name == "0" + assert d.description == "FaceTime HD Camera" + assert d.is_default is True + assert d.media_types == ["video"] + + +def test_device_info_repr_default() -> None: + d = DeviceInfo("0", "FaceTime HD Camera", True, ["video"]) + assert repr(d) == "" + + +def test_device_info_repr_non_default() -> None: + d = DeviceInfo("1", "Built-in Microphone", False, ["audio"]) + assert repr(d) == "" + + +def test_enumerate_input_devices_unknown_format() -> None: + with pytest.raises(ValueError, match="no such input format"): + enumerate_input_devices("not_a_real_format_xyz") + + +def test_enumerate_output_devices_unknown_format() -> None: + with pytest.raises(ValueError, match="no such output format"): + enumerate_output_devices("not_a_real_format_xyz") + + +def _assert_valid_device_list(devices: list[DeviceInfo]) -> None: + assert isinstance(devices, list) + for device in devices: + assert isinstance(device, DeviceInfo) + assert isinstance(device.name, str) + assert isinstance(device.description, str) + assert isinstance(device.is_default, bool) + assert isinstance(device.media_types, list) + assert all(isinstance(mt, str) for mt in device.media_types) + + +@pytest.mark.skipif(sys.platform != "darwin", reason="avfoundation is macOS only") +def test_enumerate_input_devices_avfoundation() -> None: + _assert_valid_device_list(enumerate_input_devices("avfoundation")) + + +@pytest.mark.skipif(sys.platform != "linux", reason="v4l2 is Linux only") +def test_enumerate_input_devices_v4l2() -> None: + try: + _assert_valid_device_list(enumerate_input_devices("video4linux2")) + except av.error.OSError: + pytest.skip("v4l2 device enumeration not available") + + +@pytest.mark.skipif(sys.platform != "win32", reason="dshow is Windows only") +def test_enumerate_input_devices_dshow() -> None: + try: + _assert_valid_device_list(enumerate_input_devices("dshow")) + except av.error.OSError: + pytest.skip("dshow device enumeration not available")