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.
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;
}