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]
|
✅ 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:
|
||||||
|
|||||||
@@ -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:
|
try:
|
||||||
device_idx = self.config.get("device_idx", 0)
|
self._camera.release()
|
||||||
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 = 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(
|
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).
|
||||||
|
# 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()
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user