diff --git a/server/pyproject.toml b/server/pyproject.toml index fa8b258..8438bb1 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -51,6 +51,7 @@ dev = [ # High-performance screen capture engines (Windows only) perf = [ "dxcam>=0.0.5; sys_platform == 'win32'", + "bettercam>=1.0.0; sys_platform == 'win32'", "windows-capture>=1.5.0; sys_platform == 'win32'", ] diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py index eaa706e..467c275 100644 --- a/server/src/wled_controller/api/routes.py +++ b/server/src/wled_controller/api/routes.py @@ -389,6 +389,7 @@ async def get_device( ), calibration=CalibrationSchema(**calibration_to_dict(device.calibration)), capture_template_id=device.capture_template_id, + picture_stream_id=device.picture_stream_id, created_at=device.created_at, updated_at=device.updated_at, ) @@ -918,25 +919,25 @@ async def delete_template( template_id: str, _auth: AuthRequired, template_store: TemplateStore = Depends(get_template_store), - device_store: DeviceStore = Depends(get_device_store), + stream_store: PictureStreamStore = Depends(get_picture_stream_store), ): """Delete a template. - Validates that no devices are currently using this template before deletion. + Validates that no streams are currently using this template before deletion. """ try: - # Check if any devices are using this template - devices_using_template = [] - for device in device_store.get_all_devices(): - if device.capture_template_id == template_id: - devices_using_template.append(device.name) + # Check if any streams are using this template + streams_using_template = [] + for stream in stream_store.get_all_streams(): + if stream.capture_template_id == template_id: + streams_using_template.append(stream.name) - if devices_using_template: - device_list = ", ".join(devices_using_template) + if streams_using_template: + stream_list = ", ".join(streams_using_template) raise HTTPException( status_code=409, - detail=f"Cannot delete template: it is currently assigned to the following device(s): {device_list}. " - f"Please reassign these devices to a different template before deleting." + detail=f"Cannot delete template: it is used by the following stream(s): {stream_list}. " + f"Please reassign these streams to a different template before deleting." ) # Proceed with deletion @@ -1036,6 +1037,10 @@ async def test_template( screen_capture = engine.capture_display(test_request.display_index) capture_elapsed = time.perf_counter() - capture_start + # Skip if no new frame (screen unchanged) + if screen_capture is None: + continue + total_capture_time += capture_elapsed frame_count += 1 last_frame = screen_capture @@ -1354,6 +1359,9 @@ async def test_pp_template( screen_capture = engine.capture_display(display_index) capture_elapsed = time.perf_counter() - capture_start + if screen_capture is None: + continue + total_capture_time += capture_elapsed frame_count += 1 last_frame = screen_capture @@ -1739,6 +1747,9 @@ async def test_picture_stream( screen_capture = engine.capture_display(display_index) capture_elapsed = time.perf_counter() - capture_start + if screen_capture is None: + continue + total_capture_time += capture_elapsed frame_count += 1 last_frame = screen_capture diff --git a/server/src/wled_controller/core/capture_engines/__init__.py b/server/src/wled_controller/core/capture_engines/__init__.py index aa6b892..fb84edf 100644 --- a/server/src/wled_controller/core/capture_engines/__init__.py +++ b/server/src/wled_controller/core/capture_engines/__init__.py @@ -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", ] diff --git a/server/src/wled_controller/core/capture_engines/base.py b/server/src/wled_controller/core/capture_engines/base.py index c4f3ce8..5cdae99 100644 --- a/server/src/wled_controller/core/capture_engines/base.py +++ b/server/src/wled_controller/core/capture_engines/base.py @@ -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 diff --git a/server/src/wled_controller/core/capture_engines/bettercam_engine.py b/server/src/wled_controller/core/capture_engines/bettercam_engine.py new file mode 100644 index 0000000..5a4f11e --- /dev/null +++ b/server/src/wled_controller/core/capture_engines/bettercam_engine.py @@ -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 {} diff --git a/server/src/wled_controller/core/capture_engines/dxcam_engine.py b/server/src/wled_controller/core/capture_engines/dxcam_engine.py index a40bd50..514949d 100644 --- a/server/src/wled_controller/core/capture_engines/dxcam_engine.py +++ b/server/src/wled_controller/core/capture_engines/dxcam_engine.py @@ -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( diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index 4a32448..28c8345 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -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) diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index afb94e1..72de7a2 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -2,6 +2,25 @@ const API_BASE = '/api/v1'; let refreshInterval = null; let apiKey = null; +// Backdrop click helper: only closes modal if both mousedown and mouseup were on the backdrop itself. +// Prevents accidental close when user drags text selection outside the dialog. +function setupBackdropClose(modal, closeFn) { + // Guard against duplicate listeners when called on every modal open + if (modal._backdropCloseSetup) { + modal._backdropCloseFn = closeFn; + return; + } + modal._backdropCloseFn = closeFn; + let mouseDownTarget = null; + modal.addEventListener('mousedown', (e) => { mouseDownTarget = e.target; }); + modal.addEventListener('mouseup', (e) => { + if (mouseDownTarget === modal && e.target === modal && modal._backdropCloseFn) modal._backdropCloseFn(); + mouseDownTarget = null; + }); + modal.onclick = null; + modal._backdropCloseSetup = true; +} + // Track logged errors to avoid console spam const loggedErrors = new Map(); // deviceId -> { errorCount, lastError } @@ -90,9 +109,11 @@ document.addEventListener('keydown', (e) => { // Display picker lightbox let _displayPickerCallback = null; +let _displayPickerSelectedIndex = null; -function openDisplayPicker(callback) { +function openDisplayPicker(callback, selectedIndex) { _displayPickerCallback = callback; + _displayPickerSelectedIndex = (selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null; const lightbox = document.getElementById('display-picker-lightbox'); const canvas = document.getElementById('display-picker-canvas'); @@ -157,8 +178,9 @@ function renderDisplayPickerLayout(displays) { const widthPct = (display.width / totalWidth) * 100; const heightPct = (display.height / totalHeight) * 100; + const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex; return ` -
${t('templates.config.none')}
`; + configSection.style.display = 'none'; + return; } else { Object.entries(defaultConfig).forEach(([key, value]) => { const fieldType = typeof value === 'number' ? 'number' : 'text'; @@ -2917,12 +2923,14 @@ async function saveTemplate() { return; } + const description = document.getElementById('template-description').value.trim(); const engineConfig = collectEngineConfig(); const payload = { name, engine_type: engineType, - engine_config: engineConfig + engine_config: engineConfig, + description: description || null }; try { @@ -3105,6 +3113,7 @@ function renderPictureStreamsList(streams) { ${engineIcon} ${escapeHtml(template.name)} + ${template.description ? `