From b54b68f5142f4a0e2b90149553b4374e4f357a8a Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 18 Oct 2026 02:10:35 -0400 Subject: [PATCH 1/9] get_all_video_capture_devices cleanups --- src/capture_method/__init__.py | 71 +++++++++++++++++----------------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index e267f4f1..69733d7d 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -14,6 +14,7 @@ if sys.platform == "win32": import winerror + from pygrabber.dshow_ids import DeviceCategories from capture_method.BitBltCaptureMethod import BitBltCaptureMethod from capture_method.DesktopDuplicationCaptureMethod import ( @@ -162,19 +163,24 @@ class CameraInfo: name: str occupied: bool backend: str - resolution: tuple[int, int] + resolution: tuple[int, int] | None -def get_input_devices(): +def _get_input_devices(): if sys.platform == "win32": try: - from pygrabber.dshow_graph import FilterGraph # noqa: PLC0415 + from pygrabber.dshow_graph import SystemDeviceEnum # noqa: PLC0415 except OSError as exception: # wine can choke on D3D Device Enumeration if missing directshow if exception.winerror != winerror.TYPE_E_CANTLOADLIBRARY: raise return list[tuple[int, str]]() - return list(enumerate(FilterGraph().get_input_devices())) + # Not using FilterGraph().get_input_devices(), which does the same thing, + # but also eagerly builds a graph and a WMV profile manager we don't need. + # Some wine versions raise E_NOTIMPL on the latter. + return list( + enumerate(SystemDeviceEnum().get_available_filters(DeviceCategories.VideoInputDevice)) + ) cameras: list[tuple[int, str]] = [] if sys.platform == "linux": @@ -197,35 +203,30 @@ def get_input_devices(): return cameras -def get_all_video_capture_devices(): - named_video_inputs = get_input_devices() - - def get_camera_info(index: int, device_name: str): - backend = "" - # Probing freezes some devices (like GV-USB2 and AverMedia) if already in use. - # See Toufool/AutoSplit#169 - # FIXME: Maybe offer the option to the user to obtain more info about their devices? - # Off by default. With a tooltip to explain the risk. - # video_capture = cv2.VideoCapture(index) - # video_capture.setExceptionMode(True) - # try: - # # https://docs.opencv.org/3.4/d4/d15/group__videoio__flags__base.html#ga023786be1ee68a9105bf2e48c700294d - # backend = video_capture.getBackendName() # STS_ASSERT - # video_capture.grab() # STS_ERROR - # except cv2.error as error: - # return ( - # CameraInfo(index, device_name, True, backend) - # if error.code in (cv2.Error.STS_ERROR, cv2.Error.STS_ASSERT) - # else None - # ) - # finally: - # video_capture.release() - - resolution = get_input_device_resolution(index) - return ( - CameraInfo(index, device_name, False, backend, resolution) - if resolution is not None - else None - ) +def _get_camera_info(index: int, device_name: str): + backend = "" + # Probing freezes some devices (like GV-USB2 and AverMedia) if already in use. + # See Toufool/AutoSplit#169 + # So don't try to grab a frame to test a device! Code below would break + # # FIXME: Maybe offer the option to the user to obtain more info about their devices? + # Off by default. With a tooltip to explain the risk. + # video_capture = cv2.VideoCapture(index) + # video_capture.setExceptionMode(True) + # try: + # # https://docs.opencv.org/3.4/d4/d15/group__videoio__flags__base.html#ga023786be1ee68a9105bf2e48c700294d + # backend = video_capture.getBackendName() # STS_ASSERT + # video_capture.grab() # STS_ERROR + # except cv2.error as error: + # return ( + # CameraInfo(index, device_name, True, backend) + # if error.code in (cv2.Error.STS_ERROR, cv2.Error.STS_ASSERT) + # else None + # ) + # finally: + # video_capture.release() + + return CameraInfo(index, device_name, False, backend, get_input_device_resolution(index)) - return list(filter(None, starmap(get_camera_info, named_video_inputs))) + +def get_all_video_capture_devices(): + return list(starmap(_get_camera_info, _get_input_devices())) From 2accd7bc8f837bf58c621c47450f27dfcd5f0975 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 18 Oct 2026 02:28:23 -0400 Subject: [PATCH 2/9] Allow more dev-convenience typing references across OSes --- pyproject.toml | 6 ++++++ uv.lock | 31 +++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fb5a9766..a5bd0224 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,12 @@ dev = [ "types-pyinstaller >=6.21.0.20260616", # types to check for Splash screen support "types-python-xlib; sys_platform == 'linux'", "types-pywin32 >=306.0.0.20240130; sys_platform == 'win32'", + # + # OS-specific, but installable on other OSes for convenient typing references + "pillow", + "pygrabber", + "python-xlib", + "typed-D3DShot[numpy]", ] [tool.uv] diff --git a/uv.lock b/uv.lock index f37a9d08..b4c51a61 100644 --- a/uv.lock +++ b/uv.lock @@ -1,12 +1,12 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.14" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'linux'", - "python_full_version < '3.15' and sys_platform == 'linux'", "python_full_version >= '3.15' and platform_machine == 'ARM64' and sys_platform == 'win32'", - "python_full_version < '3.15' and platform_machine == 'ARM64' and sys_platform == 'win32'", "python_full_version >= '3.15' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version < '3.15' and sys_platform == 'linux'", + "python_full_version < '3.15' and platform_machine == 'ARM64' and sys_platform == 'win32'", "python_full_version < '3.15' and platform_machine != 'ARM64' and sys_platform == 'win32'", ] supported-markers = [ @@ -112,8 +112,12 @@ dev = [ { name = "beslogic-ruff-config" }, { name = "dprint-py" }, { name = "mypy" }, + { name = "pillow" }, + { name = "pygrabber" }, { name = "pyright", extra = ["nodejs"] }, + { name = "python-xlib" }, { name = "ruff" }, + { name = "typed-d3dshot", extra = ["numpy"] }, { name = "types-keyboard" }, { name = "types-pyautogui" }, { name = "types-pyinstaller" }, @@ -159,8 +163,12 @@ dev = [ { name = "beslogic-ruff-config", git = "https://github.com/Beslogic/Beslogic-Ruff-Config?rev=39c27278054fc428b54205581a0e23caca7ad77a" }, { name = "dprint-py", specifier = ">=0.50.0.0" }, { name = "mypy", specifier = ">=2.2" }, + { name = "pillow" }, + { name = "pygrabber" }, { name = "pyright", extras = ["nodejs"], specifier = ">=1.1.400" }, + { name = "python-xlib" }, { name = "ruff" }, + { name = "typed-d3dshot", extras = ["numpy"] }, { name = "types-keyboard" }, { name = "types-pyautogui" }, { name = "types-pyinstaller", specifier = ">=6.21.0.20260616" }, @@ -435,6 +443,9 @@ dependencies = [ { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/cc/295e9a4e783ca71ba1b8fbd34e51bc603eba4611afcfc7de1b09b2d6ed8d/opencv-contrib-python-headless-4.11.0.86.tar.gz", hash = "sha256:839319098a73264c580c97cb1ca835f7fce3d30e4fa9fa6d4d0618fff551be0b", size = 150579288, upload-time = "2025-01-16T13:54:11.763Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/c1/c7600136283a2d4d3327968bdd895ba917a033d5a5498b6c7ffcd78c772c/opencv_contrib_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:2671a828e5c8ec9d237dd8506a9e0268487d37e07625725f1a6de5fa973ea7fa", size = 46095689, upload-time = "2025-01-16T13:53:12.934Z" }, +] [[package]] name = "opencv-contrib-python-headless" @@ -442,8 +453,8 @@ version = "5.0.0.93" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'linux'", - "python_full_version < '3.15' and sys_platform == 'linux'", "python_full_version >= '3.15' and platform_machine != 'ARM64' and sys_platform == 'win32'", + "python_full_version < '3.15' and sys_platform == 'linux'", "python_full_version < '3.15' and platform_machine != 'ARM64' and sys_platform == 'win32'", ] dependencies = [ @@ -499,10 +510,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, @@ -510,10 +527,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, ] [[package]] From e51065de9ca11d96fbd0fd8abfbc398b1f556829 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 18 Oct 2026 02:29:26 -0400 Subject: [PATCH 3/9] Linux: Don't fail device enumeration if 1 device was unplugged mid-enumeration --- src/capture_method/__init__.py | 44 ++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/capture_method/__init__.py b/src/capture_method/__init__.py index 69733d7d..9bc4b2a0 100644 --- a/src/capture_method/__init__.py +++ b/src/capture_method/__init__.py @@ -40,6 +40,8 @@ if TYPE_CHECKING: + from collections.abc import Iterable + from AutoSplit import AutoSplit @@ -166,7 +168,7 @@ class CameraInfo: resolution: tuple[int, int] | None -def _get_input_devices(): +def _get_input_devices() -> Iterable[tuple[int, str]]: if sys.platform == "win32": try: from pygrabber.dshow_graph import SystemDeviceEnum # noqa: PLC0415 @@ -174,32 +176,34 @@ def _get_input_devices(): # wine can choke on D3D Device Enumeration if missing directshow if exception.winerror != winerror.TYPE_E_CANTLOADLIBRARY: raise - return list[tuple[int, str]]() + return () # Not using FilterGraph().get_input_devices(), which does the same thing, # but also eagerly builds a graph and a WMV profile manager we don't need. # Some wine versions raise E_NOTIMPL on the latter. - return list( - enumerate(SystemDeviceEnum().get_available_filters(DeviceCategories.VideoInputDevice)) + return enumerate( + SystemDeviceEnum().get_available_filters(DeviceCategories.VideoInputDevice) ) cameras: list[tuple[int, str]] = [] if sys.platform == "linux": - try: - # Iterating over /sys/class/video4linux will lead to duplicates, see - # https://askubuntu.com/questions/1123601/four-dev-video-entries-but-just-one-camera/1191209#1191209 - # To properly filter and, we need to get every video devices, - # ask for capabilities using VIDIOC_QUERYCAP ioctl and check for V4L2_CAP_VIDEO_CAPTURE - for video_device in Path("/dev").glob("video*"): - with video_device.open("rb") as file: - cap = v4l2_capability() - fcntl.ioctl(file, VIDIOC_QUERYCAP, cap) - if cap.device_caps & V4L2_CAP_VIDEO_CAPTURE: - cameras.append(( - int(video_device.name.removeprefix("video")), - cap.card.decode("utf-8"), - )) - except FileNotFoundError: - pass + # Iterating over /sys/class/video4linux will lead to duplicates, see + # https://askubuntu.com/questions/1123601/four-dev-video-entries-but-just-one-camera/1191209#1191209 + # To properly filter and, we need to get every video devices, + # ask for capabilities using VIDIOC_QUERYCAP ioctl and check for V4L2_CAP_VIDEO_CAPTURE + for video_device in Path("/dev").glob("video*"): + # TOCTOU: Device may have been unplugged since globbing + try: + file = video_device.open("rb") + except FileNotFoundError: + continue + with file: + cap = v4l2_capability() + fcntl.ioctl(file, VIDIOC_QUERYCAP, cap) + if cap.device_caps & V4L2_CAP_VIDEO_CAPTURE: + cameras.append(( + int(video_device.name.removeprefix("video")), + cap.card.decode("utf-8"), + )) return cameras From 685607b13e1c25804c771acad5459a8e13ee57d2 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 18 Oct 2026 02:56:19 -0400 Subject: [PATCH 4/9] Windows: Display expected camera resolution --- src/menu_bar.py | 9 +++++++++ src/utils.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/menu_bar.py b/src/menu_bar.py index 2b873475..73f127a8 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -298,10 +298,19 @@ def __set_all_capture_devices(self): if len(self.__video_capture_devices) > 0: for i in range(self.capture_device_combobox.count()): self.capture_device_combobox.removeItem(i) + + def resolution(device: CameraInfo): + if sys.platform != "win32": + return "" + if not device.resolution: + return " (Res. unknown)" + return f" ({device.resolution[0]}x{device.resolution[1]})" + self.capture_device_combobox.addItems([ f"* {device.name}" + (f" [{device.backend}]" if device.backend else "") + (" (occupied)" if device.occupied else "") + + resolution(device) for device in self.__video_capture_devices ]) self.__enable_capture_device_if_its_selected_method() diff --git a/src/utils.py b/src/utils.py index 5bbd64f6..f80ef465 100644 --- a/src/utils.py +++ b/src/utils.py @@ -175,7 +175,7 @@ def get_window_bounds(hwnd: int) -> tuple[int, int, int, int]: # different helper modules and a methods submodule def get_input_device_resolution(index: int) -> tuple[int, int] | None: if sys.platform != "win32": - return (0, 0) + return None try: from pygrabber.dshow_graph import FilterGraph # noqa: PLC0415 except OSError as exception: From 4d7b2c2c48ec7703dc5b69fbd0e629e047400a5b Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 18 Oct 2026 03:03:08 -0400 Subject: [PATCH 5/9] Fix get_input_device_resolution through wine --- src/utils.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/utils.py b/src/utils.py index f80ef465..84cbda32 100644 --- a/src/utils.py +++ b/src/utils.py @@ -177,13 +177,16 @@ def get_input_device_resolution(index: int) -> tuple[int, int] | None: if sys.platform != "win32": return None try: - from pygrabber.dshow_graph import FilterGraph # noqa: PLC0415 + from pygrabber import dshow_graph # noqa: PLC0415 except OSError as exception: # wine can choke on D3D Device Enumeration if missing directshow if exception.winerror != winerror.TYPE_E_CANTLOADLIBRARY: raise return None - filter_graph = FilterGraph() + # FilterGraph eagerly builds a WMV profile manager, only used for recording, which we never do. + # Some wine versions raise E_NOTIMPL on it (IWMProfileManager2.SetSystemProfileVersion) + dshow_graph.WmProfileManager = lambda: None + filter_graph = dshow_graph.FilterGraph() try: filter_graph.add_video_input_device(index) # This can happen with virtual cameras throwing errors. From 92981a29cc3a8b957b1bf3e1083d28caf32f43c3 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 18 Oct 2026 03:38:07 -0400 Subject: [PATCH 6/9] Capture native stderr in logs --- src/log_capture.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/log_capture.py b/src/log_capture.py index 728d644c..8bb2a00d 100644 --- a/src/log_capture.py +++ b/src/log_capture.py @@ -10,6 +10,8 @@ from __future__ import annotations +import codecs +import io import os import re import sys @@ -38,6 +40,11 @@ """SGR (color/style) escape sequences. Python 3.13+ colourizes tracebacks; the real console renders them, but the Qt log widget shows the raw codes, so they're stripped from the in-app copy only.""" +STDERR_FILENO = 2 +"""The C runtime's stderr file descriptor, which native libraries write to directly.""" +NATIVE_READ_SIZE = 2**12 +"""Max bytes read from the native stderr pipe at once. Partial lines are buffered across reads.""" + LogLine = tuple[str, str, bool] """A logged line: `(timestamp, text, is_stderr)`. `is_stderr` marks warnings/errors.""" @@ -123,12 +130,72 @@ def __getattr__(self, name: str): return getattr(real, name) +def _forward_native_stderr(read_fd: int, original_fd: int | None, encoding: str): + """ + Reader loop for `_capture_native_stderr`. Forwards raw bytes to the original stderr (if any) + and emits each completed line. Native output has no notion of a single `write`, so unlike + `_TeeStream`, every line is its own entry. + """ + decoder = codecs.getincrementaldecoder(encoding)(errors="replace") + partial = "" + while chunk := os.read(read_fd, NATIVE_READ_SIZE): + if original_fd is not None: + try: + os.write(original_fd, chunk) + except OSError: + original_fd = None + partial += decoder.decode(chunk) + *lines, partial = partial.split("\n") + for line in lines: + LOG_EMITTER.emit_line(line.rstrip("\r"), is_stderr=True) + + +def _capture_native_stderr(): + """ + Native libraries (ie: OpenCV's `[ WARN:...]` messages) write straight to the stderr file + descriptor, bypassing `sys.stderr`. Point that descriptor at a pipe drained by a reader thread. + + Python's own `sys.stderr` is re-opened on a duplicate of the original descriptor, so it keeps + reaching the console directly and isn't captured twice (once by `_TeeStream`, once here). + """ + try: + original_fd = os.dup(STDERR_FILENO) + except OSError: + # Frozen `--noconsole` builds on Windows have no valid stderr descriptor to forward to + original_fd = None + + encoding = getattr(sys.stderr, "encoding", None) or "utf-8" + if sys.stderr is not None: + sys.stderr.flush() + read_fd, write_fd = os.pipe() + os.dup2(write_fd, STDERR_FILENO) + os.close(write_fd) + + if sys.stderr is not None and original_fd is not None: + sys.stderr = io.TextIOWrapper( + io.FileIO(original_fd, "w", closefd=False), + encoding=sys.stderr.encoding, + errors=sys.stderr.errors, + line_buffering=True, + write_through=True, + ) + + threading.Thread( + target=_forward_native_stderr, + args=(read_fd, original_fd, encoding), + name="NativeStderrCapture", + daemon=True, + ).start() + + def install(): """ Wrap `sys.stdout` and `sys.stderr` so console output is mirrored to `LOG_EMITTER`. + Also captures native writes to the stderr file descriptor. Call once, as early as possible, so startup output is captured. """ + _capture_native_stderr() sys.stdout = cast("TextIO", _TeeStream(sys.stdout, LOG_EMITTER, is_stderr=False)) sys.stderr = cast("TextIO", _TeeStream(sys.stderr, LOG_EMITTER, is_stderr=True)) From c7d7e1e79cfddd32909e259b69bb20d7b42302c0 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 18 Oct 2026 03:42:19 -0400 Subject: [PATCH 7/9] TODO for faulthandler --- src/log_capture.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/log_capture.py b/src/log_capture.py index 8bb2a00d..ba94372e 100644 --- a/src/log_capture.py +++ b/src/log_capture.py @@ -170,6 +170,10 @@ def _capture_native_stderr(): read_fd, write_fd = os.pipe() os.dup2(write_fd, STDERR_FILENO) os.close(write_fd) + # TODO: On a hard crash (segfault, abort, Python fatal error), the last things native code and + # the interpreter wrote to the stderr descriptor are stuck in the pipe and die with the process. + # Add a crash hook that dumps a log file (ie: `faulthandler.enable(file=...)` on a real file, + # not the stderr descriptor) so users still have something to report. if sys.stderr is not None and original_fd is not None: sys.stderr = io.TextIOWrapper( From 7dd29b87c3b4e149af0bdac7f7937ca5da9d09f5 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 18 Oct 2026 03:47:57 -0400 Subject: [PATCH 8/9] Update readme --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6d6ec719..8d1ef57e 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,10 @@ Linux users, note that LiveSplit Desktop, with additional components, is only av ### Wine Limitations -- Only the BitBlt Capture method is supported. Wine [does not support `CreateDirect3D11DeviceFromDXGIDevice`](https://bugs.winehq.org/show_bug.cgi?id=52487) -- No Video Capture Device (ie Webcam, OBS Virtual Cam). Wine [does not support DirectShow Device Enumeration](https://gitlab.winehq.org/wine/wine/-/wikis/Hardware#restrictions:~:text=camera) +- For Window targetting, only the BitBlt Capture method is supported. Wine [does not support `CreateDirect3D11DeviceFromDXGIDevice`](https://bugs.winehq.org/show_bug.cgi?id=52487) +- Video Capture Devices (ie Webcam, OBS Virtual Cam) require extra setup, as Wine's builtin `qedit.dll` [lacks the DirectShow type library](https://gitlab.winehq.org/wine/wine/-/tree/master/dlls/qedit) (unlike [`quartz.dll`'s `control_tlb.idl`](https://gitlab.winehq.org/wine/wine/-/blob/master/dlls/quartz/control_tlb.idl)) needed to list devices. Without it, no device will be listed. + - Install native `quartz` and `qedit` (ie: `winetricks quartz qedit`, or the dependencies of the same name in Bottles). + - Keep `qcap` builtin (ie: `WINEDLLOVERRIDES=qcap=b`), only [Wine's implementation](https://gitlab.winehq.org/wine/wine/-/blob/master/dlls/qcap/v4l.c) can capture from Linux devices. - Requires Wine 10.1+ / Steam Proton 11+ (due to [added `crealf` implementation](https://gitlab.winehq.org/wine/wine/-/releases/wine-10.1#:~:text=crealf)) - Wine 10.1 to 11.2 must use the `WineCompat` version of the Windows build (same build but without UPX compression) as Wine hadn't [stubbed `PssQuerySnapshot`](https://gitlab.winehq.org/wine/wine/-/merge_requests/8779) yet. - Wine 11.3+ can use the regular Windows build. From d64fde478d07da4bc721b57be483f573399ada27 Mon Sep 17 00:00:00 2001 From: Avasam Date: Sun, 18 Oct 2026 04:56:52 -0400 Subject: [PATCH 9/9] Better readme update --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8d1ef57e..5c3ef03a 100644 --- a/README.md +++ b/README.md @@ -83,9 +83,12 @@ Linux users, note that LiveSplit Desktop, with additional components, is only av ### Wine Limitations - For Window targetting, only the BitBlt Capture method is supported. Wine [does not support `CreateDirect3D11DeviceFromDXGIDevice`](https://bugs.winehq.org/show_bug.cgi?id=52487) -- Video Capture Devices (ie Webcam, OBS Virtual Cam) require extra setup, as Wine's builtin `qedit.dll` [lacks the DirectShow type library](https://gitlab.winehq.org/wine/wine/-/tree/master/dlls/qedit) (unlike [`quartz.dll`'s `control_tlb.idl`](https://gitlab.winehq.org/wine/wine/-/blob/master/dlls/quartz/control_tlb.idl)) needed to list devices. Without it, no device will be listed. - - Install native `quartz` and `qedit` (ie: `winetricks quartz qedit`, or the dependencies of the same name in Bottles). - - Keep `qcap` builtin (ie: `WINEDLLOVERRIDES=qcap=b`), only [Wine's implementation](https://gitlab.winehq.org/wine/wine/-/blob/master/dlls/qcap/v4l.c) can capture from Linux devices. +- Video Capture Devices (ie Webcam, OBS Virtual Cam) require extra setup to list devices: + - Install native `qedit` (ie: `winetricks qedit`, already part of Essentials in Bottles). + - Wine's builtin `qedit.dll` [lacks the DirectShow type library](https://gitlab.winehq.org/wine/wine/-/tree/master/dlls/qedit) (unlike [`quartz.dll`'s `control_tlb.idl`](https://gitlab.winehq.org/wine/wine/-/blob/master/dlls/quartz/control_tlb.idl)) + - Keep `qcap` and `quartz` builtin. If any of those were installed as native, override them: `WINEDLLOVERRIDES="quartz,qcap=b"` + - Only [Wine's implementation](https://gitlab.winehq.org/wine/wine/-/blob/master/dlls/qcap/v4l.c) of `qcap` can capture from Linux devices. + - Native `quartz` crashes on recent Wine versions (tested 11.19 and 11.20). - Requires Wine 10.1+ / Steam Proton 11+ (due to [added `crealf` implementation](https://gitlab.winehq.org/wine/wine/-/releases/wine-10.1#:~:text=crealf)) - Wine 10.1 to 11.2 must use the `WineCompat` version of the Windows build (same build but without UPX compression) as Wine hadn't [stubbed `PssQuerySnapshot`](https://gitlab.winehq.org/wine/wine/-/merge_requests/8779) yet. - Wine 11.3+ can use the regular Windows build.