diff --git a/server/src/wled_controller/core/capture_engines/__init__.py b/server/src/wled_controller/core/capture_engines/__init__.py new file mode 100644 index 0000000..aa6b892 --- /dev/null +++ b/server/src/wled_controller/core/capture_engines/__init__.py @@ -0,0 +1,26 @@ +"""Screen capture engine abstraction layer.""" + +from wled_controller.core.capture_engines.base import ( + CaptureEngine, + DisplayInfo, + ScreenCapture, +) +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.wgc_engine import WGCEngine + +# Auto-register available engines +EngineRegistry.register(MSSEngine) +EngineRegistry.register(DXcamEngine) +EngineRegistry.register(WGCEngine) + +__all__ = [ + "CaptureEngine", + "DisplayInfo", + "ScreenCapture", + "EngineRegistry", + "MSSEngine", + "DXcamEngine", + "WGCEngine", +] diff --git a/server/src/wled_controller/core/capture_engines/base.py b/server/src/wled_controller/core/capture_engines/base.py new file mode 100644 index 0000000..8827735 --- /dev/null +++ b/server/src/wled_controller/core/capture_engines/base.py @@ -0,0 +1,141 @@ +"""Base classes for screen capture engines.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Dict, List + +import numpy as np + + +@dataclass +class DisplayInfo: + """Information about a display/monitor.""" + + index: int + name: str + width: int + height: int + x: int + y: int + is_primary: bool + refresh_rate: int + + +@dataclass +class ScreenCapture: + """Captured screen image data.""" + + image: np.ndarray + width: int + height: int + display_index: int + + +class CaptureEngine(ABC): + """Abstract base class for screen capture engines. + + All screen capture engines must implement this interface to be + compatible with the WLED Screen Controller system. + """ + + ENGINE_TYPE: str = "base" # Override in subclasses + + def __init__(self, config: Dict[str, Any]): + """Initialize engine with configuration. + + Args: + config: Engine-specific configuration dict + """ + self.config = config + self._initialized = False + + @abstractmethod + def initialize(self) -> None: + """Initialize the capture engine. + + This method should prepare any resources needed for screen capture + (e.g., creating capture objects, allocating buffers). + + Raises: + RuntimeError: If initialization fails + """ + pass + + @abstractmethod + def cleanup(self) -> None: + """Cleanup engine resources. + + This method should release any resources allocated during + initialization or capture operations. + """ + pass + + @abstractmethod + def get_available_displays(self) -> List[DisplayInfo]: + """Get list of available displays. + + Returns: + List of DisplayInfo objects describing available displays + + Raises: + RuntimeError: If unable to detect displays + """ + pass + + @abstractmethod + def capture_display(self, display_index: int) -> 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) + + Raises: + ValueError: If display_index is invalid + RuntimeError: If capture fails + """ + pass + + @classmethod + @abstractmethod + def is_available(cls) -> bool: + """Check if this engine is available on current system. + + Returns: + True if engine can be used on this platform + + Examples: + >>> MSSEngine.is_available() + True # MSS is available on all platforms + >>> DXcamEngine.is_available() + True # On Windows 8.1+ + False # On Linux/macOS + """ + pass + + @classmethod + @abstractmethod + def get_default_config(cls) -> Dict[str, Any]: + """Get default configuration for this engine. + + Returns: + Default config dict with engine-specific options + + Examples: + >>> MSSEngine.get_default_config() + {} + >>> DXcamEngine.get_default_config() + {'device_idx': 0, 'output_color': 'RGB', 'max_buffer_len': 64} + """ + pass + + def __enter__(self): + """Context manager entry - initialize engine.""" + self.initialize() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit - cleanup engine.""" + self.cleanup() diff --git a/server/src/wled_controller/core/capture_engines/dxcam_engine.py b/server/src/wled_controller/core/capture_engines/dxcam_engine.py new file mode 100644 index 0000000..4748925 --- /dev/null +++ b/server/src/wled_controller/core/capture_engines/dxcam_engine.py @@ -0,0 +1,251 @@ +"""DXcam-based screen capture engine (Windows only, DXGI Desktop Duplication).""" + +import sys +from typing import Any, Dict, List + +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 DXcamEngine(CaptureEngine): + """DXcam-based screen capture engine. + + Uses the dxcam library which leverages DXGI Desktop Duplication API for + ultra-fast screen capture on Windows. Offers significantly better performance + than MSS and eliminates cursor flickering. + + Requires: Windows 8.1+ + """ + + ENGINE_TYPE = "dxcam" + + def __init__(self, config: Dict[str, Any]): + """Initialize DXcam engine. + + Args: + config: Engine configuration + - device_idx (int): GPU index (default: 0) + - output_idx (int|None): Monitor index (default: None = primary) + - output_color (str): Color format "RGB" or "BGR" (default: "RGB") + - max_buffer_len (int): Frame buffer size (default: 64) + """ + super().__init__(config) + self._camera = None + self._dxcam = None + + def initialize(self) -> None: + """Initialize DXcam capture. + + Raises: + RuntimeError: If DXcam not installed or initialization fails + """ + try: + import dxcam + self._dxcam = dxcam + except ImportError: + raise RuntimeError( + "DXcam not installed. Install with: pip install dxcam" + ) + + try: + device_idx = self.config.get("device_idx", 0) + output_idx = self.config.get("output_idx", None) + output_color = self.config.get("output_color", "RGB") + max_buffer_len = self.config.get("max_buffer_len", 64) + + self._camera = self._dxcam.create( + device_idx=device_idx, + output_idx=output_idx, + output_color=output_color, + max_buffer_len=max_buffer_len, + ) + + if not self._camera: + raise RuntimeError("Failed to create DXcam camera instance") + + # Start the camera to begin capturing + self._camera.start() + + self._initialized = True + logger.info( + f"DXcam engine initialized (device={device_idx}, " + f"output={output_idx}, color={output_color})" + ) + + except Exception as e: + raise RuntimeError(f"Failed to initialize DXcam: {e}") + + def cleanup(self) -> None: + """Cleanup DXcam resources.""" + if self._camera: + try: + # Stop capturing before releasing + self._camera.stop() + self._camera.release() + except Exception as e: + logger.error(f"Error releasing DXcam camera: {e}") + self._camera = None + + self._initialized = False + logger.info("DXcam engine cleaned up") + + def get_available_displays(self) -> List[DisplayInfo]: + """Get list of available displays using DXcam. + + Note: DXcam provides limited display enumeration. This method + returns basic information for the configured output. + + Returns: + List of DisplayInfo objects + + Raises: + RuntimeError: If not initialized or detection fails + """ + if not self._initialized: + raise RuntimeError("Engine not initialized") + + try: + displays = [] + + # Get output information from DXcam + # Note: DXcam doesn't provide comprehensive display enumeration + # We report the single configured output + output_idx = self.config.get("output_idx", 0) + if output_idx is None: + output_idx = 0 + + # DXcam camera has basic output info + if self._camera and hasattr(self._camera, "width") and hasattr(self._camera, "height"): + display_info = DisplayInfo( + index=output_idx, + name=f"DXcam Display {output_idx}", + width=self._camera.width, + height=self._camera.height, + x=0, # DXcam doesn't provide position info + y=0, + is_primary=(output_idx == 0), + refresh_rate=60, # DXcam doesn't report refresh rate + ) + displays.append(display_info) + else: + # Fallback if camera doesn't have dimensions + display_info = DisplayInfo( + index=output_idx, + name=f"DXcam Display {output_idx}", + width=1920, # Reasonable default + height=1080, + x=0, + y=0, + is_primary=(output_idx == 0), + refresh_rate=60, + ) + displays.append(display_info) + + logger.debug(f"DXcam detected {len(displays)} display(s)") + return displays + + except Exception as e: + 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: + """Capture display using DXcam. + + Args: + display_index: Index of display to capture (0-based) + Note: DXcam is configured for a specific output, so this + should match the configured output_idx + + Returns: + ScreenCapture object with image data + + Raises: + RuntimeError: If not initialized + ValueError: If display_index doesn't match configured output + RuntimeError: If capture fails + """ + if not self._initialized: + raise RuntimeError("Engine not initialized") + + # DXcam is configured for a specific output + configured_output = self.config.get("output_idx", 0) + if configured_output is None: + configured_output = 0 + + if display_index != configured_output: + raise ValueError( + f"DXcam engine is configured for output {configured_output}, " + f"cannot capture display {display_index}. Create a new template " + f"with output_idx={display_index} to capture this display." + ) + + try: + # Grab frame from DXcam + frame = self._camera.grab() + + if frame is None: + raise RuntimeError( + "Failed to capture frame (no new data). This can happen if " + "the screen hasn't changed or if there's a timeout." + ) + + # DXcam returns numpy array directly in configured color format + logger.debug( + f"DXcam 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 DXcam: {e}") + raise RuntimeError(f"Screen capture failed: {e}") + + @classmethod + def is_available(cls) -> bool: + """Check if DXcam is available. + + DXcam requires Windows 8.1+ and the dxcam package. + + Returns: + True if dxcam is available on this system + """ + # Check platform + if sys.platform != "win32": + return False + + # Check if dxcam is installed + try: + import dxcam + return True + except ImportError: + return False + + @classmethod + def get_default_config(cls) -> Dict[str, Any]: + """Get default DXcam configuration. + + Returns: + Default config dict with DXcam options + """ + return { + "device_idx": 0, # Primary GPU + "output_idx": None, # Primary monitor (None = auto-select) + "output_color": "RGB", # RGB color format + "max_buffer_len": 64, # Frame buffer size + } diff --git a/server/src/wled_controller/core/capture_engines/factory.py b/server/src/wled_controller/core/capture_engines/factory.py new file mode 100644 index 0000000..9cb063e --- /dev/null +++ b/server/src/wled_controller/core/capture_engines/factory.py @@ -0,0 +1,134 @@ +"""Engine registry and factory for screen capture engines.""" + +from typing import Any, Dict, List, Type + +from wled_controller.core.capture_engines.base import CaptureEngine +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + + +class EngineRegistry: + """Registry for available capture engines. + + This class maintains a registry of all capture engine implementations + and provides factory methods for creating engine instances. + """ + + _engines: Dict[str, Type[CaptureEngine]] = {} + + @classmethod + def register(cls, engine_class: Type[CaptureEngine]): + """Register a capture engine. + + Args: + engine_class: Engine class to register (must inherit from CaptureEngine) + + Raises: + ValueError: If engine_class is not a subclass of CaptureEngine + ValueError: If an engine with the same ENGINE_TYPE is already registered + """ + if not issubclass(engine_class, CaptureEngine): + raise ValueError(f"{engine_class} must be a subclass of CaptureEngine") + + engine_type = engine_class.ENGINE_TYPE + if engine_type == "base": + raise ValueError("Cannot register base engine type") + + if engine_type in cls._engines: + logger.warning(f"Engine '{engine_type}' already registered, overwriting") + + cls._engines[engine_type] = engine_class + logger.info(f"Registered capture engine: {engine_type}") + + @classmethod + def get_engine(cls, engine_type: str) -> Type[CaptureEngine]: + """Get engine class by type. + + Args: + engine_type: Engine type identifier (e.g., "mss", "dxcam") + + Returns: + Engine class + + Raises: + ValueError: If engine type not found + """ + if engine_type not in cls._engines: + available = ", ".join(cls._engines.keys()) or "none" + raise ValueError( + f"Unknown engine type: '{engine_type}'. Available engines: {available}" + ) + return cls._engines[engine_type] + + @classmethod + def get_available_engines(cls) -> List[str]: + """Get list of available engine types on this system. + + Returns: + List of engine type identifiers that are available on the current platform + + Examples: + >>> EngineRegistry.get_available_engines() + ['mss'] # On Linux + ['mss', 'dxcam', 'wgc'] # On Windows 10+ + """ + available = [] + for engine_type, engine_class in cls._engines.items(): + try: + if engine_class.is_available(): + available.append(engine_type) + except Exception as e: + logger.error( + f"Error checking availability for engine '{engine_type}': {e}" + ) + + return available + + @classmethod + def get_all_engines(cls) -> Dict[str, Type[CaptureEngine]]: + """Get all registered engines (available or not). + + Returns: + Dictionary mapping engine type to engine class + """ + return cls._engines.copy() + + @classmethod + def create_engine(cls, engine_type: str, config: Dict[str, Any]) -> CaptureEngine: + """Create engine instance with configuration. + + Args: + engine_type: Engine type identifier + config: Engine-specific configuration + + Returns: + Initialized engine instance + + Raises: + ValueError: If engine type not found or not available + RuntimeError: If engine initialization fails + """ + engine_class = cls.get_engine(engine_type) + + if not engine_class.is_available(): + raise ValueError( + f"Engine '{engine_type}' is not available on this system" + ) + + try: + engine = engine_class(config) + logger.debug(f"Created engine instance: {engine_type}") + return engine + except Exception as e: + logger.error(f"Failed to create engine '{engine_type}': {e}") + raise RuntimeError(f"Failed to create engine '{engine_type}': {e}") + + @classmethod + def clear_registry(cls): + """Clear all registered engines. + + This is primarily useful for testing. + """ + cls._engines.clear() + logger.debug("Cleared engine registry") diff --git a/server/src/wled_controller/core/capture_engines/mss_engine.py b/server/src/wled_controller/core/capture_engines/mss_engine.py new file mode 100644 index 0000000..dc8fc28 --- /dev/null +++ b/server/src/wled_controller/core/capture_engines/mss_engine.py @@ -0,0 +1,185 @@ +"""MSS-based screen capture engine (cross-platform).""" + +from typing import Any, Dict, List + +import mss +import numpy as np +from PIL import Image + +from wled_controller.core.capture_engines.base import ( + CaptureEngine, + DisplayInfo, + ScreenCapture, +) +from wled_controller.utils import get_logger, get_monitor_names, get_monitor_refresh_rates + +logger = get_logger(__name__) + + +class MSSEngine(CaptureEngine): + """MSS-based screen capture engine. + + Uses the mss library for cross-platform screen capture support. + Works on Windows, macOS, and Linux. + + Note: May experience cursor flickering on some systems. + """ + + ENGINE_TYPE = "mss" + + def __init__(self, config: Dict[str, Any]): + """Initialize MSS engine. + + Args: + config: Engine configuration (currently unused for MSS) + """ + super().__init__(config) + self._sct = None + + def initialize(self) -> None: + """Initialize MSS capture context. + + Raises: + RuntimeError: If MSS initialization fails + """ + try: + self._sct = mss.mss() + self._initialized = True + logger.info("MSS engine initialized") + except Exception as e: + raise RuntimeError(f"Failed to initialize MSS: {e}") + + def cleanup(self) -> None: + """Cleanup MSS resources.""" + if self._sct: + self._sct.close() + self._sct = None + self._initialized = False + logger.info("MSS engine cleaned up") + + def get_available_displays(self) -> List[DisplayInfo]: + """Get list of available displays using MSS. + + Returns: + List of DisplayInfo objects for each available monitor + + Raises: + RuntimeError: If not initialized or display detection fails + """ + if not self._initialized: + raise RuntimeError("Engine not initialized") + + try: + # Get friendly monitor names (Windows only, falls back to generic names) + monitor_names = get_monitor_names() + + # Get monitor refresh rates (Windows only, falls back to 60Hz) + refresh_rates = get_monitor_refresh_rates() + + displays = [] + + # Skip the first monitor (combined virtual screen on multi-monitor setups) + for idx, monitor in enumerate(self._sct.monitors[1:], start=0): + # Use friendly name from WMI if available, otherwise generic name + friendly_name = monitor_names.get(idx, f"Display {idx}") + + # Use detected refresh rate or default to 60Hz + refresh_rate = refresh_rates.get(idx, 60) + + display_info = DisplayInfo( + index=idx, + name=friendly_name, + width=monitor["width"], + height=monitor["height"], + x=monitor["left"], + y=monitor["top"], + is_primary=(idx == 0), + refresh_rate=refresh_rate, + ) + displays.append(display_info) + + logger.debug(f"MSS detected {len(displays)} display(s)") + return displays + + except Exception as e: + logger.error(f"Failed to detect displays with MSS: {e}") + raise RuntimeError(f"Failed to detect displays: {e}") + + def capture_display(self, display_index: int) -> ScreenCapture: + """Capture display using MSS. + + Args: + display_index: Index of display to capture (0-based) + + Returns: + ScreenCapture object with image data + + Raises: + RuntimeError: If not initialized + ValueError: If display_index is invalid + RuntimeError: If capture fails + """ + if not self._initialized: + raise RuntimeError("Engine not initialized") + + try: + # mss monitors[0] is the combined screen, monitors[1+] are individual displays + monitor_index = display_index + 1 + + if monitor_index >= len(self._sct.monitors): + raise ValueError( + f"Invalid display index {display_index}. " + f"Available displays: 0-{len(self._sct.monitors) - 2}" + ) + + monitor = self._sct.monitors[monitor_index] + + # Capture screenshot + screenshot = self._sct.grab(monitor) + + # Convert to numpy array (RGB) + img = Image.frombytes("RGB", screenshot.size, screenshot.rgb) + img_array = np.array(img) + + logger.debug( + f"MSS captured display {display_index}: {monitor['width']}x{monitor['height']}" + ) + + return ScreenCapture( + image=img_array, + width=monitor["width"], + height=monitor["height"], + display_index=display_index, + ) + + except ValueError: + raise + except Exception as e: + logger.error(f"Failed to capture display {display_index} with MSS: {e}") + raise RuntimeError(f"Screen capture failed: {e}") + + @classmethod + def is_available(cls) -> bool: + """Check if MSS is available. + + MSS is cross-platform and should always be available. + + Returns: + True if mss library is available + """ + try: + import mss + return True + except ImportError: + return False + + @classmethod + def get_default_config(cls) -> Dict[str, Any]: + """Get default MSS configuration. + + MSS has no configurable options. + + Returns: + Empty dict (MSS has no configuration) + """ + return {} diff --git a/server/src/wled_controller/core/capture_engines/wgc_engine.py b/server/src/wled_controller/core/capture_engines/wgc_engine.py new file mode 100644 index 0000000..4c1895d --- /dev/null +++ b/server/src/wled_controller/core/capture_engines/wgc_engine.py @@ -0,0 +1,348 @@ +"""Windows Graphics Capture engine (Windows 10+, WGC API).""" + +import gc +import sys +import time +import threading +from typing import Any, Dict, List + +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 WGCEngine(CaptureEngine): + """Windows Graphics Capture engine. + + Uses the windows-capture library which leverages Windows.Graphics.Capture API. + This is Microsoft's recommended modern screen capture API for Windows 10+. + + Features: + - Cross-GPU support (works regardless of GPU routing) + - Hardware cursor exclusion (no cursor flickering) + - GPU-accelerated with direct texture sharing + - Modern, future-proof API + + Requires: Windows 10 1803+ + """ + + ENGINE_TYPE = "wgc" + + def __init__(self, config: Dict[str, Any]): + """Initialize WGC engine. + + Args: + config: Engine configuration + - monitor_index (int): Monitor index (default: 0) + - capture_cursor (bool): Include cursor in capture (default: False) + - draw_border (bool): Draw border around capture (default: False) + """ + super().__init__(config) + self._wgc = None + self._capture_instance = None + self._capture_control = None + self._latest_frame = None + self._frame_lock = threading.Lock() + self._frame_event = threading.Event() + self._closed_event = threading.Event() # Signals when capture session is closed + + def initialize(self) -> None: + """Initialize WGC capture. + + Raises: + RuntimeError: If windows-capture not installed or initialization fails + """ + try: + import windows_capture + self._wgc = windows_capture + except ImportError: + raise RuntimeError( + "windows-capture not installed. Install with: pip install windows-capture" + ) + + # Clear events for fresh initialization + self._frame_event.clear() + self._closed_event.clear() + + try: + monitor_index = self.config.get("monitor_index", 0) + capture_cursor = self.config.get("capture_cursor", False) + # Note: draw_border is not supported by WGC API on most platforms + + # WGC uses 1-based monitor indexing (1, 2, 3...) while we use 0-based (0, 1, 2...) + wgc_monitor_index = monitor_index + 1 + + # Create capture instance + # Note: draw_border parameter not supported on all platforms + self._capture_instance = self._wgc.WindowsCapture( + cursor_capture=capture_cursor, + monitor_index=wgc_monitor_index, + ) + + # Define event handlers as local functions first + def on_frame_arrived(frame, capture_control): + """Called when a new frame is captured.""" + try: + logger.debug("WGC frame callback triggered") + + # Store capture_control reference for cleanup + if self._capture_control is None: + self._capture_control = capture_control + + # Get frame buffer as numpy array + frame_buffer = frame.frame_buffer + width = frame.width + height = frame.height + + # Reshape to image dimensions (height, width, channels) + # WGC provides BGRA format + frame_array = frame_buffer.reshape((height, width, 4)) + + # Convert BGRA to RGB + frame_rgb = frame_array[:, :, [2, 1, 0]] # Take BGR channels + + # Store the latest frame + with self._frame_lock: + self._latest_frame = frame_rgb.copy() + self._frame_event.set() + except Exception as e: + logger.error(f"Error processing WGC frame: {e}", exc_info=True) + + def on_closed(): + """Called when capture session is closed.""" + logger.debug("WGC capture session closed callback triggered") + # Signal that the capture session has fully closed and resources are released + self._closed_event.set() + + # Set handlers directly as attributes + self._capture_instance.frame_handler = on_frame_arrived + self._capture_instance.closed_handler = on_closed + + # Start capture using free-threaded mode (non-blocking) + logger.debug("Starting WGC capture (free-threaded mode)...") + self._capture_instance.start_free_threaded() + + # Wait for first frame to arrive (with timeout) + logger.debug("Waiting for first WGC frame...") + frame_received = self._frame_event.wait(timeout=5.0) + + if not frame_received or self._latest_frame is None: + raise RuntimeError( + "WGC capture started but no frames received within 5 seconds. " + "This may indicate the capture session failed to start or " + "the display is not actively updating." + ) + + self._initialized = True + logger.info( + f"WGC engine initialized (monitor={monitor_index}, " + f"cursor={capture_cursor})" + ) + + except Exception as e: + logger.error(f"Failed to initialize WGC: {e}", exc_info=True) + raise RuntimeError(f"Failed to initialize WGC: {e}") + + def cleanup(self) -> None: + """Cleanup WGC resources.""" + # For free-threaded captures, cleanup is tricky: + # 1. Stop capture via capture_control if available + # 2. Explicitly delete the capture instance (releases COM objects) + # 3. Wait for resources to be freed (give Windows time to cleanup) + # 4. Force garbage collection multiple times to ensure COM cleanup + + if self._capture_control: + try: + logger.debug("Stopping WGC capture session via capture_control...") + self._capture_control.stop() + except Exception as e: + logger.error(f"Error stopping WGC capture_control: {e}") + finally: + self._capture_control = None + + # Explicitly delete the capture instance BEFORE waiting + # This is critical for releasing COM objects and GPU resources + if self._capture_instance: + try: + logger.debug("Explicitly deleting WGC capture instance...") + instance = self._capture_instance + self._capture_instance = None + del instance + logger.debug("WGC capture instance deleted") + except Exception as e: + logger.error(f"Error releasing WGC capture instance: {e}") + self._capture_instance = None + + # Force garbage collection multiple times to ensure COM cleanup + # COM objects may require multiple GC passes to fully release + logger.debug("Forcing garbage collection for COM cleanup...") + for i in range(3): + gc.collect() + time.sleep(0.05) # Small delay between GC passes + + logger.debug("Waiting for WGC resources to be fully released...") + # Give Windows time to clean up the capture session and remove border + time.sleep(0.3) + + # Final GC pass + gc.collect() + logger.debug("Final garbage collection completed") + + with self._frame_lock: + self._latest_frame = None + self._frame_event.clear() + self._closed_event.clear() + + self._initialized = False + logger.info("WGC engine cleaned up") + + def get_available_displays(self) -> List[DisplayInfo]: + """Get list of available displays using MSS. + + Note: WGC doesn't provide a direct API for enumerating monitors, + so we use MSS for display detection. + + Returns: + List of DisplayInfo objects + + Raises: + RuntimeError: If detection fails + """ + try: + import mss + + with mss.mss() as sct: + displays = [] + # Skip monitor 0 (all monitors combined) + for i, monitor in enumerate(sct.monitors[1:], start=0): + displays.append( + DisplayInfo( + index=i, + name=f"Monitor {i+1}", + width=monitor["width"], + height=monitor["height"], + x=monitor["left"], + y=monitor["top"], + is_primary=(i == 0), + refresh_rate=60, + ) + ) + + logger.debug(f"WGC detected {len(displays)} display(s)") + return displays + + except Exception as e: + logger.error(f"Failed to detect displays: {e}") + raise RuntimeError(f"Failed to detect displays: {e}") + + def capture_display(self, display_index: int) -> ScreenCapture: + """Capture display using WGC. + + Args: + display_index: Index of display to capture (0-based) + + Returns: + ScreenCapture object with image data + + Raises: + RuntimeError: If not initialized + ValueError: If display_index doesn't match configured monitor + RuntimeError: If capture fails or no frame available + """ + if not self._initialized: + raise RuntimeError("Engine not initialized") + + # WGC is configured for a specific monitor + configured_monitor = self.config.get("monitor_index", 0) + + if display_index != configured_monitor: + raise ValueError( + f"WGC engine is configured for monitor {configured_monitor}, " + f"cannot capture display {display_index}. Create a new template " + f"with monitor_index={display_index} to capture this display." + ) + + try: + # Get the latest frame + with self._frame_lock: + if self._latest_frame is None: + raise RuntimeError( + "No frame available yet. The capture may not have started or " + "the screen hasn't updated. Wait a moment and try again." + ) + frame = self._latest_frame.copy() + + logger.debug( + f"WGC 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 WGC: {e}") + raise RuntimeError(f"Screen capture failed: {e}") + + @classmethod + def is_available(cls) -> bool: + """Check if WGC is available. + + WGC requires Windows 10 1803+ and the windows-capture package. + + Returns: + True if windows-capture is available on this system + """ + # Check platform + if sys.platform != "win32": + return False + + # Check Windows version (Windows 10 1803 = version 10.0.17134) + try: + import platform + version = platform.version() + # Parse version string like "10.0.19045" + parts = version.split(".") + if len(parts) >= 3: + major = int(parts[0]) + minor = int(parts[1]) + build = int(parts[2]) + # Check for Windows 10 1803+ (build 17134+) + if major < 10 or (major == 10 and minor == 0 and build < 17134): + return False + except Exception: + # If we can't parse version, assume it might work + pass + + # Check if windows-capture is installed + try: + import windows_capture + return True + except ImportError: + return False + + @classmethod + def get_default_config(cls) -> Dict[str, Any]: + """Get default WGC configuration. + + Returns: + Default config dict with WGC options + """ + return { + "monitor_index": 0, # Primary monitor + "capture_cursor": False, # Exclude cursor (hardware exclusion) + "draw_border": False, # Don't draw border around capture + } diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index a63de31..fa16c8d 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -164,12 +164,12 @@ document.addEventListener('DOMContentLoaded', async () => { // Show content now that translations are loaded document.body.style.visibility = 'visible'; - // Restore active tab - initTabs(); - // Load API key from localStorage apiKey = localStorage.getItem('wled_api_key'); + // Restore active tab (after API key is loaded) + initTabs(); + // Setup form handler document.getElementById('add-device-form').addEventListener('submit', handleAddDevice); @@ -206,6 +206,31 @@ function getHeaders() { return headers; } +// Fetch wrapper that automatically includes auth headers +async function fetchWithAuth(url, options = {}) { + // Build full URL if relative path provided + const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`; + + // Merge auth headers with any custom headers + const headers = options.headers + ? { ...getHeaders(), ...options.headers } + : getHeaders(); + + // Make request with merged options + return fetch(fullUrl, { + ...options, + headers + }); +} + +// Escape HTML to prevent XSS +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + // Handle 401 errors by showing login modal function handle401Error() { // Clear invalid API key @@ -319,6 +344,9 @@ function switchTab(name) { if (name === 'displays' && _cachedDisplays) { requestAnimationFrame(() => renderDisplayLayout(_cachedDisplays)); } + if (name === 'templates') { + loadCaptureTemplates(); + } } function initTabs() { @@ -709,10 +737,11 @@ async function removeDevice(deviceId) { async function showSettings(deviceId) { try { - // Fetch device data and displays in parallel - const [deviceResponse, displaysResponse] = await Promise.all([ + // Fetch device data, displays, and templates in parallel + const [deviceResponse, displaysResponse, templatesResponse] = await Promise.all([ fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }), fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }), + fetchWithAuth('/capture-templates'), ]); if (deviceResponse.status === 401) { @@ -747,6 +776,27 @@ async function showSettings(deviceId) { } displaySelect.value = String(device.settings.display_index ?? 0); + // Populate capture template select + const templateSelect = document.getElementById('settings-capture-template'); + templateSelect.innerHTML = ''; + if (templatesResponse.ok) { + const templatesData = await templatesResponse.json(); + (templatesData.templates || []).forEach(t => { + const opt = document.createElement('option'); + opt.value = t.id; + const engineIcon = getEngineIcon(t.engine_type); + opt.textContent = `${engineIcon} ${t.name}`; + templateSelect.appendChild(opt); + }); + } + if (templateSelect.options.length === 0) { + const opt = document.createElement('option'); + opt.value = 'tpl_mss_default'; + opt.textContent = 'MSS (Default)'; + templateSelect.appendChild(opt); + } + templateSelect.value = device.capture_template_id || 'tpl_mss_default'; + // Populate other fields document.getElementById('settings-device-id').value = device.id; document.getElementById('settings-device-name').value = device.name; @@ -759,6 +809,7 @@ async function showSettings(deviceId) { url: device.url, display_index: String(device.settings.display_index ?? 0), state_check_interval: String(device.settings.state_check_interval || 30), + capture_template_id: device.capture_template_id || 'tpl_mss_default', }; // Show modal @@ -782,7 +833,8 @@ function isSettingsDirty() { document.getElementById('settings-device-name').value !== settingsInitialValues.name || document.getElementById('settings-device-url').value !== settingsInitialValues.url || document.getElementById('settings-display-index').value !== settingsInitialValues.display_index || - document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval + document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval || + document.getElementById('settings-capture-template').value !== settingsInitialValues.capture_template_id ); } @@ -809,6 +861,7 @@ async function saveDeviceSettings() { const url = document.getElementById('settings-device-url').value.trim(); const display_index = parseInt(document.getElementById('settings-display-index').value) || 0; const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30; + const capture_template_id = document.getElementById('settings-capture-template').value; const error = document.getElementById('settings-error'); // Validation @@ -819,11 +872,11 @@ async function saveDeviceSettings() { } try { - // Update device info (name, url) + // Update device info (name, url, capture_template_id) const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, { method: 'PUT', headers: getHeaders(), - body: JSON.stringify({ name, url }) + body: JSON.stringify({ name, url, capture_template_id }) }); if (deviceResponse.status === 401) { @@ -2126,3 +2179,513 @@ function handleTutorialKey(e) { else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); } } + + +// =========================== +// Capture Templates Functions +// =========================== + +let availableEngines = []; +let currentEditingTemplateId = null; + +// Load and render capture templates +async function loadCaptureTemplates() { + try { + const response = await fetchWithAuth('/capture-templates'); + if (!response.ok) { + throw new Error(`Failed to load templates: ${response.status}`); + } + + const data = await response.json(); + renderTemplatesList(data.templates || []); + } catch (error) { + console.error('Error loading capture templates:', error); + document.getElementById('templates-list').innerHTML = ` +
${t('templates.error.load')}: ${error.message}
+ `; + } +} + +// Render templates list +function renderTemplatesList(templates) { + const container = document.getElementById('templates-list'); + + if (templates.length === 0) { + container.innerHTML = `
+
+
+
${t('templates.add')}
+
`; + return; + } + + container.innerHTML = templates.map(template => { + const engineIcon = getEngineIcon(template.engine_type); + const defaultBadge = template.is_default + ? `${t('templates.default')}` + : ''; + + return ` +
+
+
+ ${engineIcon} ${escapeHtml(template.name)} +
+ ${defaultBadge} +
+
+ ${t('templates.engine')} ${template.engine_type.toUpperCase()} +
+ ${Object.keys(template.engine_config).length > 0 ? ` +
+ ${t('templates.config.show')} +
${JSON.stringify(template.engine_config, null, 2)}
+
+ ` : ` +
${t('templates.config.none')}
+ `} +
+ + ${!template.is_default ? ` + + + ` : ''} +
+
+ `; + }).join('') + `
+
+
+
${t('templates.add')}
+
`; +} + +// Get engine icon +function getEngineIcon(engineType) { + return '๐Ÿ–ฅ๏ธ'; +} + +// Show add template modal +async function showAddTemplateModal() { + currentEditingTemplateId = null; + document.getElementById('template-modal-title').textContent = t('templates.add'); + document.getElementById('template-form').reset(); + document.getElementById('template-id').value = ''; + document.getElementById('engine-config-section').style.display = 'none'; + document.getElementById('template-error').style.display = 'none'; + + // Load available engines + await loadAvailableEngines(); + + document.getElementById('template-modal').style.display = 'flex'; +} + +// Edit template +async function editTemplate(templateId) { + try { + const response = await fetchWithAuth(`/capture-templates/${templateId}`); + if (!response.ok) { + throw new Error(`Failed to load template: ${response.status}`); + } + + const template = await response.json(); + + currentEditingTemplateId = templateId; + document.getElementById('template-modal-title').textContent = t('templates.edit'); + document.getElementById('template-id').value = templateId; + document.getElementById('template-name').value = template.name; + + // Load available engines + await loadAvailableEngines(); + + // Set engine and load config + document.getElementById('template-engine').value = template.engine_type; + await onEngineChange(); + + // Populate engine config fields + populateEngineConfig(template.engine_config); + + // Load displays for test + await loadDisplaysForTest(); + + document.getElementById('template-test-results').style.display = 'none'; + document.getElementById('template-error').style.display = 'none'; + + document.getElementById('template-modal').style.display = 'flex'; + } catch (error) { + console.error('Error loading template:', error); + showToast(t('templates.error.load') + ': ' + error.message, 'error'); + } +} + +// Close template modal +function closeTemplateModal() { + document.getElementById('template-modal').style.display = 'none'; + currentEditingTemplateId = null; +} + +// Update capture duration and save to localStorage +function updateCaptureDuration(value) { + document.getElementById('test-template-duration-value').textContent = value; + localStorage.setItem('capture_duration', value); +} + +// Restore capture duration from localStorage +function restoreCaptureDuration() { + const savedDuration = localStorage.getItem('capture_duration'); + if (savedDuration) { + const durationInput = document.getElementById('test-template-duration'); + const durationValue = document.getElementById('test-template-duration-value'); + durationInput.value = savedDuration; + durationValue.textContent = savedDuration; + } +} + +// Show test template modal +async function showTestTemplateModal(templateId) { + const templates = await fetchWithAuth('/capture-templates').then(r => r.json()); + const template = templates.templates.find(t => t.id === templateId); + + if (!template) { + showToast(t('templates.error.load'), 'error'); + return; + } + + // Store current template for testing + window.currentTestingTemplate = template; + + // Load displays + await loadDisplaysForTest(); + + // Restore last used capture duration + restoreCaptureDuration(); + + // Reset results + document.getElementById('test-template-results').style.display = 'none'; + + // Show modal + document.getElementById('test-template-modal').style.display = 'flex'; +} + +// Close test template modal +function closeTestTemplateModal() { + document.getElementById('test-template-modal').style.display = 'none'; + window.currentTestingTemplate = null; +} + +// Load available engines +async function loadAvailableEngines() { + try { + const response = await fetchWithAuth('/capture-engines'); + if (!response.ok) { + throw new Error(`Failed to load engines: ${response.status}`); + } + + const data = await response.json(); + availableEngines = data.engines || []; + + const select = document.getElementById('template-engine'); + select.innerHTML = ``; + + availableEngines.forEach(engine => { + const option = document.createElement('option'); + option.value = engine.type; + option.textContent = `${getEngineIcon(engine.type)} ${engine.name}`; + if (!engine.available) { + option.disabled = true; + option.textContent += ` (${t('templates.engine.unavailable')})`; + } + select.appendChild(option); + }); + } catch (error) { + console.error('Error loading engines:', error); + showToast(t('templates.error.engines') + ': ' + error.message, 'error'); + } +} + +// Handle engine selection change +async function onEngineChange() { + const engineType = document.getElementById('template-engine').value; + const configSection = document.getElementById('engine-config-section'); + const configFields = document.getElementById('engine-config-fields'); + + if (!engineType) { + configSection.style.display = 'none'; + return; + } + + const engine = availableEngines.find(e => e.type === engineType); + if (!engine) { + configSection.style.display = 'none'; + return; + } + + // Show availability hint + const hint = document.getElementById('engine-availability-hint'); + if (!engine.available) { + hint.textContent = t('templates.engine.unavailable.hint'); + hint.style.display = 'block'; + hint.style.color = 'var(--error-color)'; + } else { + hint.style.display = 'none'; + } + + // Render config fields based on default_config + configFields.innerHTML = ''; + const defaultConfig = engine.default_config || {}; + + if (Object.keys(defaultConfig).length === 0) { + configFields.innerHTML = `

${t('templates.config.none')}

`; + } else { + Object.entries(defaultConfig).forEach(([key, value]) => { + const fieldType = typeof value === 'number' ? 'number' : 'text'; + const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value; + + const fieldHtml = ` +
+ + ${typeof value === 'boolean' ? ` + + ` : ` + + `} + ${t('templates.config.default')}: ${JSON.stringify(value)} +
+ `; + configFields.innerHTML += fieldHtml; + }); + } + + configSection.style.display = 'block'; +} + +// Populate engine config fields with values +function populateEngineConfig(config) { + Object.entries(config).forEach(([key, value]) => { + const field = document.getElementById(`config-${key}`); + if (field) { + if (field.tagName === 'SELECT') { + field.value = value.toString(); + } else { + field.value = value; + } + } + }); +} + +// Collect engine config from form +function collectEngineConfig() { + const config = {}; + const fields = document.querySelectorAll('[data-config-key]'); + + fields.forEach(field => { + const key = field.dataset.configKey; + let value = field.value; + + // Type conversion + if (field.type === 'number') { + value = parseFloat(value); + } else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) { + value = value === 'true'; + } + + config[key] = value; + }); + + return config; +} + +// Load displays for test selector +async function loadDisplaysForTest() { + try { + const response = await fetchWithAuth('/config/displays'); + if (!response.ok) { + throw new Error(`Failed to load displays: ${response.status}`); + } + + const displaysData = await response.json(); + const select = document.getElementById('test-template-display'); + select.innerHTML = ``; + + (displaysData.displays || []).forEach(display => { + const option = document.createElement('option'); + option.value = display.index; + option.textContent = `Display ${display.index} (${display.width}x${display.height})`; + if (display.is_primary) { + option.textContent += ' - Primary'; + } + select.appendChild(option); + }); + } catch (error) { + console.error('Error loading displays:', error); + } +} + +// Run template test +async function runTemplateTest() { + if (!window.currentTestingTemplate) { + showToast(t('templates.test.error.no_engine'), 'error'); + return; + } + + const displayIndex = document.getElementById('test-template-display').value; + const captureDuration = parseFloat(document.getElementById('test-template-duration').value); + + if (displayIndex === '') { + showToast(t('templates.test.error.no_display'), 'error'); + return; + } + + const template = window.currentTestingTemplate; + const resultsDiv = document.getElementById('test-template-results'); + + // Show loading state without destroying the structure + const loadingDiv = document.createElement('div'); + loadingDiv.className = 'loading'; + loadingDiv.textContent = t('templates.test.running'); + loadingDiv.style.position = 'absolute'; + loadingDiv.style.inset = '0'; + loadingDiv.style.background = 'var(--bg-primary)'; + loadingDiv.style.display = 'flex'; + loadingDiv.style.alignItems = 'center'; + loadingDiv.style.justifyContent = 'center'; + loadingDiv.style.zIndex = '10'; + loadingDiv.id = 'test-loading-overlay'; + + // Remove old loading overlay if exists + const oldLoading = document.getElementById('test-loading-overlay'); + if (oldLoading) oldLoading.remove(); + + resultsDiv.style.display = 'block'; + resultsDiv.style.position = 'relative'; + resultsDiv.appendChild(loadingDiv); + + try { + const response = await fetchWithAuth('/capture-templates/test', { + method: 'POST', + body: JSON.stringify({ + engine_type: template.engine_type, + engine_config: template.engine_config, + display_index: parseInt(displayIndex), + capture_duration: captureDuration + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Test failed'); + } + + const result = await response.json(); + displayTestResults(result); + } catch (error) { + console.error('Error running test:', error); + // Remove loading overlay + const loadingOverlay = document.getElementById('test-loading-overlay'); + if (loadingOverlay) loadingOverlay.remove(); + // Show short error in snack, details are in console + showToast(t('templates.test.error.failed'), 'error'); + } +} + +// Display test results +function displayTestResults(result) { + const resultsDiv = document.getElementById('test-template-results'); + + // Remove loading overlay + const loadingOverlay = document.getElementById('test-loading-overlay'); + if (loadingOverlay) loadingOverlay.remove(); + + // Full capture preview + const previewImg = document.getElementById('test-template-preview-image'); + previewImg.innerHTML = `Capture preview`; + + // Performance stats + document.getElementById('test-template-actual-duration').textContent = `${result.performance.capture_duration_s.toFixed(2)}s`; + document.getElementById('test-template-frame-count').textContent = result.performance.frame_count; + document.getElementById('test-template-actual-fps').textContent = `${result.performance.actual_fps.toFixed(1)} FPS`; + document.getElementById('test-template-avg-capture-time').textContent = `${result.performance.avg_capture_time_ms.toFixed(1)}ms`; + + // Show results + resultsDiv.style.display = 'block'; +} + +// Save template +async function saveTemplate() { + const templateId = document.getElementById('template-id').value; + const name = document.getElementById('template-name').value.trim(); + const engineType = document.getElementById('template-engine').value; + + if (!name || !engineType) { + showToast(t('templates.error.required'), 'error'); + return; + } + + const engineConfig = collectEngineConfig(); + + const payload = { + name, + engine_type: engineType, + engine_config: engineConfig + }; + + try { + let response; + if (templateId) { + // Update existing template + response = await fetchWithAuth(`/capture-templates/${templateId}`, { + method: 'PUT', + body: JSON.stringify(payload) + }); + } else { + // Create new template + response = await fetchWithAuth('/capture-templates', { + method: 'POST', + body: JSON.stringify(payload) + }); + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Failed to save template'); + } + + showToast(templateId ? t('templates.updated') : t('templates.created'), 'success'); + closeTemplateModal(); + await loadCaptureTemplates(); + } catch (error) { + console.error('Error saving template:', error); + document.getElementById('template-error').textContent = error.message; + document.getElementById('template-error').style.display = 'block'; + } +} + +// Delete template +async function deleteTemplate(templateId) { + const confirmed = await showConfirm(t('templates.delete.confirm')); + if (!confirmed) return; + + try { + const response = await fetchWithAuth(`/capture-templates/${templateId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || error.message || 'Failed to delete template'); + } + + showToast(t('templates.deleted'), 'success'); + await loadCaptureTemplates(); + } catch (error) { + console.error('Error deleting template:', error); + showToast(t('templates.error.delete') + ': ' + error.message, 'error'); + } +} diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index ac06a11..e86f043 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -36,6 +36,7 @@
+
@@ -60,6 +61,17 @@
+ +
+

+ + Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance. + +

+
+
Loading templates...
+
+