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:
10
CLAUDE.md
10
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:
|
||||
|
||||
@@ -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:
|
||||
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._camera.release()
|
||||
except Exception:
|
||||
pass
|
||||
self._camera = None
|
||||
|
||||
# Clear dxcam's global camera cache to avoid stale DXGI state
|
||||
try:
|
||||
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,
|
||||
output_idx=display_index,
|
||||
output_color="RGB",
|
||||
)
|
||||
|
||||
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._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
|
||||
# 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 {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user