Skip to content
Merged
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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,13 @@ 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 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.
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
109 changes: 57 additions & 52 deletions src/capture_method/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -39,6 +40,8 @@


if TYPE_CHECKING:
from collections.abc import Iterable

from AutoSplit import AutoSplit


Expand Down Expand Up @@ -162,70 +165,72 @@ 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() -> Iterable[tuple[int, str]]:
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()))
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 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


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()))
71 changes: 71 additions & 0 deletions src/log_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

from __future__ import annotations

import codecs
import io
import os
import re
import sys
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -123,12 +130,76 @@ 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)
# 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(
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))

Expand Down
9 changes: 9 additions & 0 deletions src/menu_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
9 changes: 6 additions & 3 deletions src/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,18 @@ 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
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.
Expand Down
Loading
Loading