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 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 17:26:38 +03:00
parent 74d87fd0ab
commit 3db7ba4b0e
4 changed files with 99 additions and 66 deletions

View File

@@ -66,6 +66,16 @@
✅ Claude: [now creates the commit] ✅ 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 ## Project Structure
This is a monorepo containing: This is a monorepo containing:

View File

@@ -1,6 +1,7 @@
"""DXcam-based screen capture engine (Windows only, DXGI Desktop Duplication).""" """DXcam-based screen capture engine (Windows only, DXGI Desktop Duplication)."""
import sys import sys
import time
from typing import Any, Dict, List from typing import Any, Dict, List
import numpy as np import numpy as np
@@ -28,18 +29,11 @@ class DXcamEngine(CaptureEngine):
ENGINE_TYPE = "dxcam" ENGINE_TYPE = "dxcam"
def __init__(self, config: Dict[str, Any]): def __init__(self, config: Dict[str, Any]):
"""Initialize DXcam engine. """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) super().__init__(config)
self._camera = None self._camera = None
self._dxcam = None self._dxcam = None
self._current_output = None
def initialize(self) -> None: def initialize(self) -> None:
"""Initialize DXcam capture. """Initialize DXcam capture.
@@ -55,45 +49,61 @@ class DXcamEngine(CaptureEngine):
"DXcam not installed. Install with: pip install dxcam" "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: try:
device_idx = self.config.get("device_idx", 0) self._dxcam.__factory.clean_up()
output_idx = self.config.get("output_idx", None) except Exception:
output_color = self.config.get("output_color", "RGB") pass
max_buffer_len = self.config.get("max_buffer_len", 64)
self._camera = self._dxcam.create( self._camera = self._dxcam.create(
device_idx=device_idx, output_idx=display_index,
output_idx=output_idx, output_color="RGB",
output_color=output_color, )
max_buffer_len=max_buffer_len,
)
if not self._camera: if not self._camera:
raise RuntimeError("Failed to create DXcam camera instance") raise RuntimeError(f"Failed to create DXcam camera for display {display_index}")
# Start the camera to begin capturing self._current_output = display_index
self._camera.start() logger.info(f"DXcam camera created (output={display_index})")
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: def cleanup(self) -> None:
"""Cleanup DXcam resources.""" """Cleanup DXcam resources."""
if self._camera: if self._camera:
try: try:
# Stop capturing before releasing
self._camera.stop()
self._camera.release() self._camera.release()
except Exception as e: except Exception as e:
logger.error(f"Error releasing DXcam camera: {e}") logger.error(f"Error releasing DXcam camera: {e}")
self._camera = None 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 self._initialized = False
logger.info("DXcam engine cleaned up") logger.info("DXcam engine cleaned up")
@@ -118,9 +128,7 @@ class DXcamEngine(CaptureEngine):
# Get output information from DXcam # Get output information from DXcam
# Note: DXcam doesn't provide comprehensive display enumeration # Note: DXcam doesn't provide comprehensive display enumeration
# We report the single configured output # We report the single configured output
output_idx = self.config.get("output_idx", 0) output_idx = self._current_output or 0
if output_idx is None:
output_idx = 0
# DXcam camera has basic output info # DXcam camera has basic output info
if self._camera and hasattr(self._camera, "width") and hasattr(self._camera, "height"): if self._camera and hasattr(self._camera, "width") and hasattr(self._camera, "height"):
@@ -160,41 +168,36 @@ class DXcamEngine(CaptureEngine):
"""Capture display using DXcam. """Capture display using DXcam.
Args: Args:
display_index: Index of display to capture (0-based) 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: Returns:
ScreenCapture object with image data ScreenCapture object with image data
Raises: Raises:
ValueError: If display_index doesn't match configured output
RuntimeError: If capture fails RuntimeError: If capture fails
""" """
# Auto-initialize if not already initialized # Auto-initialize if not already initialized
if not self._initialized: if not self._initialized:
self.initialize() self.initialize()
# DXcam is configured for a specific output # Ensure camera is ready for the requested display
configured_output = self.config.get("output_idx", 0) self._ensure_camera(display_index)
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: try:
# Grab frame from DXcam # Grab frame from DXcam (one-shot mode, no start() needed).
frame = self._camera.grab() # 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: if frame is None:
raise RuntimeError( raise RuntimeError(
"Failed to capture frame (no new data). This can happen if " "Failed to capture frame after retries. "
"the screen hasn't changed or if there's a timeout." "The screen may not have changed or the display is unavailable."
) )
# DXcam returns numpy array directly in configured color format # DXcam returns numpy array directly in configured color format
@@ -243,9 +246,4 @@ class DXcamEngine(CaptureEngine):
Returns: Returns:
Default config dict with DXcam options Default config dict with DXcam options
""" """
return { 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
}

View File

@@ -49,14 +49,14 @@
<span data-i18n="devices.wled_note2">This controller sends pixel color data and controls brightness per device.</span> <span data-i18n="devices.wled_note2">This controller sends pixel color data and controls brightness per device.</span>
</p> </p>
<div id="devices-list" class="devices-grid"> <div id="devices-list" class="devices-grid">
<div class="loading" data-i18n="devices.loading">Loading devices...</div> <div class="loading-spinner"></div>
</div> </div>
</div> </div>
<div class="tab-panel" id="tab-displays"> <div class="tab-panel" id="tab-displays">
<div class="display-layout-preview"> <div class="display-layout-preview">
<div id="display-layout-canvas" class="display-layout-canvas"> <div id="display-layout-canvas" class="display-layout-canvas">
<div class="loading" data-i18n="displays.loading">Loading layout...</div> <div class="loading-spinner"></div>
</div> </div>
</div> </div>
<div id="displays-list" style="display: none;"></div> <div id="displays-list" style="display: none;"></div>
@@ -69,7 +69,7 @@
</span> </span>
</p> </p>
<div id="templates-list" class="templates-grid"> <div id="templates-list" class="templates-grid">
<div class="loading" data-i18n="templates.loading">Loading templates...</div> <div class="loading-spinner"></div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -165,7 +165,8 @@ section {
gap: 20px; gap: 20px;
} }
.devices-grid > .loading { .devices-grid > .loading,
.devices-grid > .loading-spinner {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -735,6 +736,27 @@ input:-webkit-autofill:focus {
color: #999; 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 */ /* Full-page overlay spinner */
.overlay-spinner { .overlay-spinner {
position: fixed; position: fixed;
@@ -1815,6 +1837,9 @@ input:-webkit-autofill:focus {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: 12px;
}
.template-card:has(.card-remove-btn) .template-card-header {
padding-right: 24px; padding-right: 24px;
} }