Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions lelab/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down
38 changes: 38 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down