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

@@ -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 {}

View File

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

View File

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