From 8ee64be419714fe679bb7b11f043f4bf2e91d11c Mon Sep 17 00:00:00 2001 From: Quentin Date: Sun, 9 Aug 2026 20:39:09 +0200 Subject: [PATCH] fix(server): initialize COM for Windows camera discovery --- lelab/server.py | 39 +++++++++++++++++++++++++++++++-------- tests/test_server.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/lelab/server.py b/lelab/server.py index e7fbc792..16e17f69 100644 --- a/lelab/server.py +++ b/lelab/server.py @@ -1004,6 +1004,28 @@ def _generic_cv2_cameras(backend) -> list[dict[str, Any]]: return cameras +@contextlib.contextmanager +def _windows_com_initialized(): + """Initialize COM for the current Windows worker thread when available.""" + try: + import comtypes + except ImportError: + yield + return + + try: + comtypes.CoInitialize() + except OSError as e: + logger.warning("Windows COM initialization failed: %s", e) + yield + return + + try: + yield + finally: + comtypes.CoUninitialize() + + def _windows_cameras() -> list[dict[str, Any]]: """Enumerate Windows cameras with their real DirectShow names. @@ -1013,16 +1035,17 @@ def _windows_cameras() -> list[dict[str, Any]]: frontend match each index to the browser's ``MediaDeviceInfo.label`` for the live preview. Falls back to generic names if pygrabber is unavailable. """ - try: - from pygrabber.dshow_graph import FilterGraph + with _windows_com_initialized(): + try: + from pygrabber.dshow_graph import FilterGraph - names = FilterGraph().get_input_devices() - except Exception as e: # ImportError, or a COM/DirectShow failure - logger.warning("pygrabber unavailable; using generic camera names: %s", e) - import cv2 + names = FilterGraph().get_input_devices() + except Exception as e: # ImportError, or a COM/DirectShow failure + logger.warning("pygrabber unavailable; using generic camera names: %s", e) + import cv2 - return _generic_cv2_cameras(cv2.CAP_DSHOW) - return [{"index": i, "name": name, "available": True} for i, name in enumerate(names)] + return _generic_cv2_cameras(cv2.CAP_DSHOW) + return [{"index": i, "name": name, "available": True} for i, name in enumerate(names)] def _v4l2_camera_name(index: int) -> str | None: diff --git a/tests/test_server.py b/tests/test_server.py index 8b10033f..5f84e364 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -160,6 +160,44 @@ def _install_fake_pygrabber(monkeypatch: pytest.MonkeyPatch, filter_graph_cls) - monkeypatch.setitem(sys.modules, "pygrabber.dshow_graph", module) +def _install_fake_comtypes( + monkeypatch: pytest.MonkeyPatch, + co_initialize, + co_uninitialize, +) -> None: + import sys + import types + + module = types.ModuleType("comtypes") + module.CoInitialize = co_initialize + module.CoUninitialize = co_uninitialize + monkeypatch.setitem(sys.modules, "comtypes", module) + + +def test_windows_cameras_initializes_com_in_worker_thread(monkeypatch: pytest.MonkeyPatch) -> None: + """DirectShow is called only while COM is initialized for this thread.""" + from lelab import server + + events = [] + + class _FakeGraph: + def get_input_devices(self) -> list[str]: + events.append("enumerate") + return ["USB webcam"] + + _install_fake_comtypes( + monkeypatch, + lambda: events.append("initialize"), + lambda: events.append("uninitialize"), + ) + _install_fake_pygrabber(monkeypatch, _FakeGraph) + + assert server._windows_cameras() == [ + {"index": 0, "name": "USB webcam", "available": True}, + ] + assert events == ["initialize", "enumerate", "uninitialize"] + + def test_windows_cameras_uses_real_directshow_names(monkeypatch: pytest.MonkeyPatch) -> None: """The Windows path returns pygrabber's real device names in index order so the frontend can match each camera to its browser deviceId (issues #12/#16).