Add BetterCam engine, UI polish, and bug fixes

- Add BetterCam capture engine (DXGI Desktop Duplication, priority 4)
- Fix missing picture_stream_id in get_device endpoint
- Fix template delete validation to check streams instead of devices
- Add description field to capture engine template UI
- Default template name changed to "Default" with descriptive text
- Display picker highlights selected display instead of primary
- Fix modals closing when dragging text selection outside dialog
- Rename "Engine Configuration" to "Configuration", hide when empty
- Rename "Run Test" to "Run" across all test buttons
- Always reserve space for vertical scrollbar
- Redesign Stream Settings info panel with pill-style props
- Fix processed stream showing internal ID instead of stream name
- Update en/ru locale files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 23:28:35 +03:00
parent 9ae93497a6
commit ebec1bd16e
13 changed files with 417 additions and 100 deletions

View File

@@ -8,11 +8,13 @@ from wled_controller.core.capture_engines.base import (
from wled_controller.core.capture_engines.factory import EngineRegistry
from wled_controller.core.capture_engines.mss_engine import MSSEngine
from wled_controller.core.capture_engines.dxcam_engine import DXcamEngine
from wled_controller.core.capture_engines.bettercam_engine import BetterCamEngine
from wled_controller.core.capture_engines.wgc_engine import WGCEngine
# Auto-register available engines
EngineRegistry.register(MSSEngine)
EngineRegistry.register(DXcamEngine)
EngineRegistry.register(BetterCamEngine)
EngineRegistry.register(WGCEngine)
__all__ = [
@@ -22,5 +24,6 @@ __all__ = [
"EngineRegistry",
"MSSEngine",
"DXcamEngine",
"BetterCamEngine",
"WGCEngine",
]

View File

@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
import numpy as np
@@ -84,14 +84,15 @@ class CaptureEngine(ABC):
pass
@abstractmethod
def capture_display(self, display_index: int) -> ScreenCapture:
def capture_display(self, display_index: int) -> Optional[ScreenCapture]:
"""Capture the specified display.
Args:
display_index: Index of display to capture (0-based)
Returns:
ScreenCapture object with image data as numpy array (RGB format)
ScreenCapture object with image data as numpy array (RGB format),
or None if no new frame is available (screen unchanged).
Raises:
ValueError: If display_index is invalid

View File

@@ -0,0 +1,236 @@
"""BetterCam-based screen capture engine (Windows only, DXGI Desktop Duplication)."""
import sys
from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.capture_engines.base import (
CaptureEngine,
DisplayInfo,
ScreenCapture,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class BetterCamEngine(CaptureEngine):
"""BetterCam-based screen capture engine.
Uses the bettercam library (a high-performance fork of DXCam) which leverages
DXGI Desktop Duplication API for ultra-fast screen capture on Windows.
Offers better performance than DXCam with multi-GPU support.
Requires: Windows 8.1+
"""
ENGINE_TYPE = "bettercam"
ENGINE_PRIORITY = 4
def __init__(self, config: Dict[str, Any]):
"""Initialize BetterCam engine."""
super().__init__(config)
self._camera = None
self._bettercam = None
self._current_output = None
def initialize(self) -> None:
"""Initialize BetterCam capture.
Raises:
RuntimeError: If bettercam not installed or initialization fails
"""
try:
import bettercam
self._bettercam = bettercam
except ImportError:
raise RuntimeError(
"BetterCam not installed. Install with: pip install bettercam"
)
self._initialized = True
logger.info("BetterCam engine initialized")
def _ensure_camera(self, display_index: int) -> None:
"""Ensure camera is created for the requested display.
Creates or recreates the BetterCam camera if needed.
"""
if self._camera and self._current_output == display_index:
return
# Stop and release existing camera
if self._camera:
try:
if self._camera.is_capturing:
self._camera.stop()
except Exception:
pass
try:
self._camera.release()
except Exception:
pass
self._camera = None
# Clear global camera cache to avoid stale DXGI state
try:
self._bettercam.__factory.clean_up()
except Exception:
pass
self._camera = self._bettercam.create(
output_idx=display_index,
output_color="RGB",
)
if not self._camera:
raise RuntimeError(f"Failed to create BetterCam camera for display {display_index}")
self._current_output = display_index
logger.info(f"BetterCam camera created (output={display_index})")
def cleanup(self) -> None:
"""Cleanup BetterCam resources."""
if self._camera:
try:
if self._camera.is_capturing:
self._camera.stop()
except Exception:
pass
try:
self._camera.release()
except Exception as e:
logger.error(f"Error releasing BetterCam camera: {e}")
self._camera = None
# Clear global cache so next create() gets fresh DXGI state
if self._bettercam:
try:
self._bettercam.__factory.clean_up()
except Exception:
pass
self._current_output = None
self._initialized = False
logger.info("BetterCam engine cleaned up")
def get_available_displays(self) -> List[DisplayInfo]:
"""Get list of available displays using BetterCam.
Returns:
List of DisplayInfo objects
Raises:
RuntimeError: If not initialized or detection fails
"""
if not self._initialized:
raise RuntimeError("Engine not initialized")
try:
displays = []
output_idx = self._current_output or 0
if self._camera and hasattr(self._camera, "width") and hasattr(self._camera, "height"):
display_info = DisplayInfo(
index=output_idx,
name=f"BetterCam Display {output_idx}",
width=self._camera.width,
height=self._camera.height,
x=0,
y=0,
is_primary=(output_idx == 0),
refresh_rate=60,
)
displays.append(display_info)
else:
display_info = DisplayInfo(
index=output_idx,
name=f"BetterCam Display {output_idx}",
width=1920,
height=1080,
x=0,
y=0,
is_primary=(output_idx == 0),
refresh_rate=60,
)
displays.append(display_info)
logger.debug(f"BetterCam detected {len(displays)} display(s)")
return displays
except Exception as e:
logger.error(f"Failed to detect displays with BetterCam: {e}")
raise RuntimeError(f"Failed to detect displays: {e}")
def capture_display(self, display_index: int) -> Optional[ScreenCapture]:
"""Capture display using BetterCam.
Args:
display_index: Index of display to capture (0-based).
Returns:
ScreenCapture object with image data, or None if screen unchanged.
Raises:
RuntimeError: If capture fails
"""
# Auto-initialize if not already initialized
if not self._initialized:
self.initialize()
# Ensure camera is ready for the requested display
self._ensure_camera(display_index)
try:
# grab() uses AcquireNextFrame with timeout=0 (non-blocking).
# Returns None if screen content hasn't changed since last grab.
frame = self._camera.grab()
if frame is None:
return None
logger.debug(
f"BetterCam captured display {display_index}: "
f"{frame.shape[1]}x{frame.shape[0]}"
)
return ScreenCapture(
image=frame,
width=frame.shape[1],
height=frame.shape[0],
display_index=display_index,
)
except ValueError:
raise
except Exception as e:
logger.error(f"Failed to capture display {display_index} with BetterCam: {e}")
raise RuntimeError(f"Screen capture failed: {e}")
@classmethod
def is_available(cls) -> bool:
"""Check if BetterCam is available.
BetterCam requires Windows 8.1+ and the bettercam package.
Returns:
True if bettercam is available on this system
"""
if sys.platform != "win32":
return False
try:
import bettercam
return True
except ImportError:
return False
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
"""Get default BetterCam configuration.
Returns:
Default config dict with BetterCam options
"""
return {}

View File

@@ -1,8 +1,7 @@
"""DXcam-based screen capture engine (Windows only, DXGI Desktop Duplication)."""
import sys
import time
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
import numpy as np
@@ -63,8 +62,13 @@ class DXcamEngine(CaptureEngine):
if self._camera and self._current_output == display_index:
return
# Release existing camera
# Stop and release existing camera
if self._camera:
try:
if self._camera.is_capturing:
self._camera.stop()
except Exception:
pass
try:
self._camera.release()
except Exception:
@@ -91,6 +95,11 @@ class DXcamEngine(CaptureEngine):
def cleanup(self) -> None:
"""Cleanup DXcam resources."""
if self._camera:
try:
if self._camera.is_capturing:
self._camera.stop()
except Exception:
pass
try:
self._camera.release()
except Exception as e:
@@ -165,14 +174,14 @@ class DXcamEngine(CaptureEngine):
logger.error(f"Failed to detect displays with DXcam: {e}")
raise RuntimeError(f"Failed to detect displays: {e}")
def capture_display(self, display_index: int) -> ScreenCapture:
def capture_display(self, display_index: int) -> Optional[ScreenCapture]:
"""Capture display using DXcam.
Args:
display_index: Index of display to capture (0-based).
Returns:
ScreenCapture object with image data
ScreenCapture object with image data, or None if screen unchanged.
Raises:
RuntimeError: If capture fails
@@ -185,21 +194,12 @@ class DXcamEngine(CaptureEngine):
self._ensure_camera(display_index)
try:
# Grab frame from DXcam (one-shot mode, no start() needed).
# First grab after create() often returns None as DXGI Desktop
# Duplication needs a frame change to capture. Retry a few times.
frame = None
for attempt in range(5):
frame = self._camera.grab()
if frame is not None:
break
time.sleep(0.05)
# grab() uses AcquireNextFrame with timeout=0 (non-blocking).
# Returns None if screen content hasn't changed since last grab.
frame = self._camera.grab()
if frame is None:
raise RuntimeError(
"Failed to capture frame after retries. "
"The screen may not have changed or the display is unavailable."
)
return None
# DXcam returns numpy array directly in configured color format
logger.debug(

View File

@@ -555,6 +555,11 @@ class ProcessorManager:
display_index
)
# Skip processing if no new frame (screen unchanged)
if capture is None:
await asyncio.sleep(frame_time)
continue
# Apply postprocessing filters to the full captured image
if filter_objects:
capture.image = await asyncio.to_thread(_apply_filters, capture.image)