From 3db7ba4b0edc166ef0f60120c5505738324cd1ef Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 10 Feb 2026 17:26:38 +0300 Subject: [PATCH] Fix DXcam engine and improve UI: loading spinners, template card gap DXcam engine overhaul: - Remove all user-facing config (device_idx, output_idx, output_color) since these are auto-resolved or hardcoded to RGB - Use one-shot grab() mode with retry for reliability - Lazily create camera per display via _ensure_camera() - Clear dxcam global factory cache to prevent stale DXGI state UI improvements: - Replace "Loading..." text with CSS spinner animations - Fix template card header gap on default cards (scope padding-right to cards with remove button only via :has selector) - Add auto-restart server rule to CLAUDE.md Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 10 ++ .../core/capture_engines/dxcam_engine.py | 122 +++++++++--------- server/src/wled_controller/static/index.html | 6 +- server/src/wled_controller/static/style.css | 27 +++- 4 files changed, 99 insertions(+), 66 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7853042..ab1606f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,16 @@ ✅ Claude: [now creates the commit] ``` +## IMPORTANT: Auto-Restart Server on Code Changes + +**Whenever server-side Python code is modified** (any file under `/server/src/`), **automatically restart the server** so the changes take effect immediately. Do NOT wait for the user to ask for a restart. + +### Restart procedure + +1. Stop the running Python process: `powershell -Command "Get-Process -Name python -ErrorAction SilentlyContinue | Stop-Process -Force"` +2. Start the server: `powershell -Command "Set-Location 'c:\Users\Alexei\Documents\wled-screen-controller\server'; python -m wled_controller.main"` (run in background) +3. Wait 3 seconds and check startup logs to confirm it's running + ## Project Structure This is a monorepo containing: 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 7ab2927..2025ef4 100644 --- a/server/src/wled_controller/core/capture_engines/dxcam_engine.py +++ b/server/src/wled_controller/core/capture_engines/dxcam_engine.py @@ -1,6 +1,7 @@ """DXcam-based screen capture engine (Windows only, DXGI Desktop Duplication).""" import sys +import time from typing import Any, Dict, List import numpy as np @@ -28,18 +29,11 @@ class DXcamEngine(CaptureEngine): 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) - """ + """Initialize DXcam engine.""" super().__init__(config) self._camera = None self._dxcam = None + self._current_output = None def initialize(self) -> None: """Initialize DXcam capture. @@ -55,45 +49,61 @@ class DXcamEngine(CaptureEngine): "DXcam not installed. Install with: pip install dxcam" ) + self._initialized = True + logger.info("DXcam engine initialized") + + def _ensure_camera(self, display_index: int) -> None: + """Ensure camera is created for the requested display. + + Creates or recreates the DXcam camera if needed. + DXcam caches cameras globally per (device, output). We clear the + cache before creating to avoid stale DXGI state from prior requests. + """ + if self._camera and self._current_output == display_index: + return + + # Release existing camera + if self._camera: + try: + self._camera.release() + except Exception: + pass + self._camera = None + + # Clear dxcam's global camera cache to avoid stale DXGI state 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._dxcam.__factory.clean_up() + except Exception: + pass - self._camera = self._dxcam.create( - device_idx=device_idx, - output_idx=output_idx, - output_color=output_color, - max_buffer_len=max_buffer_len, - ) + self._camera = self._dxcam.create( + output_idx=display_index, + output_color="RGB", + ) - if not self._camera: - raise RuntimeError("Failed to create DXcam camera instance") + if not self._camera: + raise RuntimeError(f"Failed to create DXcam camera for display {display_index}") - # 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}") + self._current_output = display_index + logger.info(f"DXcam camera created (output={display_index})") 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 + # Clear dxcam's global cache so next create() gets fresh DXGI state + if self._dxcam: + try: + self._dxcam.__factory.clean_up() + except Exception: + pass + + self._current_output = None self._initialized = False logger.info("DXcam engine cleaned up") @@ -118,9 +128,7 @@ class DXcamEngine(CaptureEngine): # 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 + output_idx = self._current_output or 0 # DXcam camera has basic output info if self._camera and hasattr(self._camera, "width") and hasattr(self._camera, "height"): @@ -160,41 +168,36 @@ class DXcamEngine(CaptureEngine): """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 + display_index: Index of display to capture (0-based). Returns: ScreenCapture object with image data Raises: - ValueError: If display_index doesn't match configured output RuntimeError: If capture fails """ # Auto-initialize if not already initialized if not self._initialized: self.initialize() - # 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." - ) + # Ensure camera is ready for the requested display + self._ensure_camera(display_index) try: - # Grab frame from DXcam - frame = self._camera.grab() + # 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) 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." + "Failed to capture frame after retries. " + "The screen may not have changed or the display is unavailable." ) # DXcam returns numpy array directly in configured color format @@ -243,9 +246,4 @@ class DXcamEngine(CaptureEngine): 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 - } + return {} diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 563e9ad..1b10473 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -49,14 +49,14 @@ This controller sends pixel color data and controls brightness per device.

-
Loading devices...
+
-
Loading layout...
+
@@ -69,7 +69,7 @@

-
Loading templates...
+
diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 817b55c..07180e1 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -165,7 +165,8 @@ section { gap: 20px; } -.devices-grid > .loading { +.devices-grid > .loading, +.devices-grid > .loading-spinner { grid-column: 1 / -1; } @@ -735,6 +736,27 @@ input:-webkit-autofill:focus { color: #999; } +.loading-spinner { + display: flex; + justify-content: center; + align-items: center; + padding: 40px; +} + +.loading-spinner::after { + content: ''; + width: 28px; + height: 28px; + border: 3px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + /* Full-page overlay spinner */ .overlay-spinner { position: fixed; @@ -1815,6 +1837,9 @@ input:-webkit-autofill:focus { justify-content: space-between; align-items: center; margin-bottom: 12px; +} + +.template-card:has(.card-remove-btn) .template-card-header { padding-right: 24px; }