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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user