Add camera/webcam capture engine with engine-aware display picker
- New CameraEngine using OpenCV VideoCapture for webcam capture - HAS_OWN_DISPLAYS class attribute on CaptureEngine base to distinguish engines with their own device lists from desktop monitor engines - Display picker renders device list for cameras/scrcpy, spatial layout for desktop monitors - Engine-aware display label formatting (camera name vs monitor index) - Stream modal properly loads engine-specific displays on template change, edit, and clone - Camera backend config rendered as dropdown (auto/dshow/msmf/v4l2) - Remove offline label from device cards (healthcheck indicator suffices) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
11
TODO.md
11
TODO.md
@@ -20,9 +20,7 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
|||||||
- [ ] `P1` **Rename `picture-targets` to `output-targets`** — Rename API endpoints and internal references for clarity
|
- [ ] `P1` **Rename `picture-targets` to `output-targets`** — Rename API endpoints and internal references for clarity
|
||||||
- Complexity: low — mechanical rename across routes, schemas, store, frontend fetch calls; no logic changes, but many files touched (~20+), needs care with stored JSON migration
|
- Complexity: low — mechanical rename across routes, schemas, store, frontend fetch calls; no logic changes, but many files touched (~20+), needs care with stored JSON migration
|
||||||
- Impact: low-medium — improves API clarity for future integrations (OpenRGB, Art-Net)
|
- Impact: low-medium — improves API clarity for future integrations (OpenRGB, Art-Net)
|
||||||
- [ ] `P2` **OpenRGB** — Control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
|
- [x] `P2` **OpenRGB** — Control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets
|
||||||
- Complexity: medium — new device type + client (OpenRGB SDK uses a TCP socket protocol); new target processor subclass; device discovery via OpenRGB server
|
|
||||||
- Impact: high — extends ambient lighting beyond WLED to entire PC ecosystem
|
|
||||||
- [ ] `P2` **Art-Net / sACN (E1.31)** — Stage/theatrical lighting protocols, DMX controllers
|
- [ ] `P2` **Art-Net / sACN (E1.31)** — Stage/theatrical lighting protocols, DMX controllers
|
||||||
- Complexity: medium — UDP-based protocols with well-documented specs; similar architecture to DDP client; needs DMX universe/channel mapping UI
|
- Complexity: medium — UDP-based protocols with well-documented specs; similar architecture to DDP client; needs DMX universe/channel mapping UI
|
||||||
- Impact: medium — opens stage/theatrical use case, niche but differentiating
|
- Impact: medium — opens stage/theatrical use case, niche but differentiating
|
||||||
@@ -53,12 +51,13 @@ Priority: `P1` quick win · `P2` moderate · `P3` large effort
|
|||||||
- [ ] `P3` **SCRCPY capture engine** — Implement SCRCPY-based screen capture for Android devices
|
- [ ] `P3` **SCRCPY capture engine** — Implement SCRCPY-based screen capture for Android devices
|
||||||
- Complexity: large — external dependency on scrcpy binary; need to manage subprocess lifecycle, parse video stream (ffmpeg/AV pipe), handle device connect/disconnect
|
- Complexity: large — external dependency on scrcpy binary; need to manage subprocess lifecycle, parse video stream (ffmpeg/AV pipe), handle device connect/disconnect
|
||||||
- Impact: medium — enables phone screen mirroring to ambient lighting; appeals to mobile gaming use case
|
- Impact: medium — enables phone screen mirroring to ambient lighting; appeals to mobile gaming use case
|
||||||
- [ ] `P3` **Camera / webcam** — Border-sampling from camera feed for video calls or room-reactive lighting
|
- [x] `P3` **Camera / webcam** — Border-sampling from camera feed for video calls or room-reactive lighting
|
||||||
- Complexity: medium — OpenCV `VideoCapture` is straightforward; needs new capture source type + calibration for camera field of view; FPS/resolution config
|
|
||||||
- Impact: low-medium — room-reactive lighting is novel but limited practical appeal
|
|
||||||
|
|
||||||
## UX
|
## UX
|
||||||
|
|
||||||
|
- [ ] `P2` **Tags / groups for cards** — Assign tags to devices, targets, and sources; filter and group cards by tag
|
||||||
|
- Complexity: medium — new `tags: List[str]` field on all card entities; tag CRUD API; filter bar UI per section; tag badge rendering on cards; persistence migration
|
||||||
|
- Impact: medium-high — essential for setups with many devices/targets; enables quick filtering (e.g. "bedroom", "desk", "gaming")
|
||||||
- [ ] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest
|
- [ ] `P3` **PWA / mobile layout** — Mobile-first layout + "Add to Home Screen" manifest
|
||||||
- Complexity: medium-large — responsive CSS overhaul for all tabs; service worker for offline caching; manifest.json; touch-friendly controls (larger tap targets, swipe gestures)
|
- Complexity: medium-large — responsive CSS overhaul for all tabs; service worker for offline caching; manifest.json; touch-friendly controls (larger tap targets, swipe gestures)
|
||||||
- Impact: high — phone control is a top user request; current desktop-first layout is unusable on mobile
|
- Impact: high — phone control is a top user request; current desktop-first layout is unusable on mobile
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ dev = [
|
|||||||
"black>=24.0.0",
|
"black>=24.0.0",
|
||||||
"ruff>=0.6.0",
|
"ruff>=0.6.0",
|
||||||
]
|
]
|
||||||
|
camera = [
|
||||||
|
"opencv-python-headless>=4.8.0",
|
||||||
|
]
|
||||||
# High-performance screen capture engines (Windows only)
|
# High-performance screen capture engines (Windows only)
|
||||||
perf = [
|
perf = [
|
||||||
"dxcam>=0.0.5; sys_platform == 'win32'",
|
"dxcam>=0.0.5; sys_platform == 'win32'",
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ async def list_engines(_auth: AuthRequired):
|
|||||||
name=engine_type.upper(),
|
name=engine_type.upper(),
|
||||||
default_config=engine_class.get_default_config(),
|
default_config=engine_class.get_default_config(),
|
||||||
available=(engine_type in available_set),
|
available=(engine_type in available_set),
|
||||||
|
has_own_displays=getattr(engine_class, 'HAS_OWN_DISPLAYS', False),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class EngineInfo(BaseModel):
|
|||||||
name: str = Field(description="Human-readable engine name")
|
name: str = Field(description="Human-readable engine name")
|
||||||
default_config: Dict = Field(description="Default configuration for this engine")
|
default_config: Dict = Field(description="Default configuration for this engine")
|
||||||
available: bool = Field(description="Whether engine is available on this system")
|
available: bool = Field(description="Whether engine is available on this system")
|
||||||
|
has_own_displays: bool = Field(default=False, description="Engine has its own device list (not desktop monitors)")
|
||||||
|
|
||||||
|
|
||||||
class EngineListResponse(BaseModel):
|
class EngineListResponse(BaseModel):
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from wled_controller.core.capture_engines.dxcam_engine import DXcamEngine, DXcam
|
|||||||
from wled_controller.core.capture_engines.bettercam_engine import BetterCamEngine, BetterCamCaptureStream
|
from wled_controller.core.capture_engines.bettercam_engine import BetterCamEngine, BetterCamCaptureStream
|
||||||
from wled_controller.core.capture_engines.wgc_engine import WGCEngine, WGCCaptureStream
|
from wled_controller.core.capture_engines.wgc_engine import WGCEngine, WGCCaptureStream
|
||||||
from wled_controller.core.capture_engines.scrcpy_engine import ScrcpyEngine, ScrcpyCaptureStream
|
from wled_controller.core.capture_engines.scrcpy_engine import ScrcpyEngine, ScrcpyCaptureStream
|
||||||
|
from wled_controller.core.capture_engines.camera_engine import CameraEngine, CameraCaptureStream
|
||||||
|
|
||||||
# Auto-register available engines
|
# Auto-register available engines
|
||||||
EngineRegistry.register(MSSEngine)
|
EngineRegistry.register(MSSEngine)
|
||||||
@@ -19,6 +20,7 @@ EngineRegistry.register(DXcamEngine)
|
|||||||
EngineRegistry.register(BetterCamEngine)
|
EngineRegistry.register(BetterCamEngine)
|
||||||
EngineRegistry.register(WGCEngine)
|
EngineRegistry.register(WGCEngine)
|
||||||
EngineRegistry.register(ScrcpyEngine)
|
EngineRegistry.register(ScrcpyEngine)
|
||||||
|
EngineRegistry.register(CameraEngine)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CaptureEngine",
|
"CaptureEngine",
|
||||||
@@ -36,4 +38,6 @@ __all__ = [
|
|||||||
"WGCCaptureStream",
|
"WGCCaptureStream",
|
||||||
"ScrcpyEngine",
|
"ScrcpyEngine",
|
||||||
"ScrcpyCaptureStream",
|
"ScrcpyCaptureStream",
|
||||||
|
"CameraEngine",
|
||||||
|
"CameraCaptureStream",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class CaptureEngine(ABC):
|
|||||||
|
|
||||||
ENGINE_TYPE: str = "base"
|
ENGINE_TYPE: str = "base"
|
||||||
ENGINE_PRIORITY: int = 0
|
ENGINE_PRIORITY: int = 0
|
||||||
|
HAS_OWN_DISPLAYS: bool = False # True for engines with non-desktop device lists
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
|||||||
276
server/src/wled_controller/core/capture_engines/camera_engine.py
Normal file
276
server/src/wled_controller/core/capture_engines/camera_engine.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"""Camera/webcam capture engine using OpenCV.
|
||||||
|
|
||||||
|
Captures frames from USB/integrated webcams via ``cv2.VideoCapture``.
|
||||||
|
Each camera is exposed as a "display" through the standard
|
||||||
|
``CaptureEngine`` / ``CaptureStream`` architecture.
|
||||||
|
|
||||||
|
Prerequisites (optional dependency):
|
||||||
|
pip install opencv-python-headless>=4.8.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from wled_controller.core.capture_engines.base import (
|
||||||
|
CaptureEngine,
|
||||||
|
CaptureStream,
|
||||||
|
DisplayInfo,
|
||||||
|
ScreenCapture,
|
||||||
|
)
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_MAX_CAMERA_INDEX = 10 # probe indices 0..9
|
||||||
|
_CV2_BACKENDS = {
|
||||||
|
"auto": None,
|
||||||
|
"dshow": 700, # cv2.CAP_DSHOW
|
||||||
|
"msmf": 1400, # cv2.CAP_MSMF
|
||||||
|
"v4l2": 200, # cv2.CAP_V4L2
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_backend():
|
||||||
|
"""Return the best default backend for the current platform."""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return "dshow" # faster open than MSMF on Windows
|
||||||
|
return "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def _cv2_backend_id(backend_name: str) -> Optional[int]:
|
||||||
|
"""Convert a backend name string to cv2 API preference constant."""
|
||||||
|
return _CV2_BACKENDS.get(backend_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_camera_friendly_names() -> Dict[int, str]:
|
||||||
|
"""Get friendly names for cameras from OS.
|
||||||
|
|
||||||
|
On Windows, queries WMI for PnP camera devices.
|
||||||
|
Returns a dict mapping sequential index → friendly name.
|
||||||
|
"""
|
||||||
|
if platform.system() != "Windows":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import wmi
|
||||||
|
c = wmi.WMI()
|
||||||
|
cameras = c.query(
|
||||||
|
"SELECT Name FROM Win32_PnPEntity WHERE PNPClass = 'Camera'"
|
||||||
|
)
|
||||||
|
return {i: cam.Name for i, cam in enumerate(cameras)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"WMI camera enumeration failed: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]:
|
||||||
|
"""Probe camera indices and return metadata for each available camera.
|
||||||
|
|
||||||
|
Returns a list of dicts: {cv2_index, name, width, height, fps}.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
except ImportError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
backend_id = _cv2_backend_id(backend_name)
|
||||||
|
friendly_names = _get_camera_friendly_names()
|
||||||
|
|
||||||
|
cameras: List[Dict[str, Any]] = []
|
||||||
|
sequential_idx = 0
|
||||||
|
|
||||||
|
for i in range(_MAX_CAMERA_INDEX):
|
||||||
|
if backend_id is not None:
|
||||||
|
cap = cv2.VideoCapture(i, backend_id)
|
||||||
|
else:
|
||||||
|
cap = cv2.VideoCapture(i)
|
||||||
|
|
||||||
|
if not cap.isOpened():
|
||||||
|
cap.release()
|
||||||
|
continue
|
||||||
|
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||||
|
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
|
||||||
|
|
||||||
|
name = friendly_names.get(sequential_idx, f"Camera {sequential_idx}")
|
||||||
|
cap.release()
|
||||||
|
|
||||||
|
cameras.append({
|
||||||
|
"cv2_index": i,
|
||||||
|
"name": name,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"fps": fps,
|
||||||
|
})
|
||||||
|
sequential_idx += 1
|
||||||
|
|
||||||
|
return cameras
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CaptureStream
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class CameraCaptureStream(CaptureStream):
|
||||||
|
"""OpenCV-based webcam capture stream.
|
||||||
|
|
||||||
|
Synchronous capture like MSSEngine — ``capture_frame()`` grabs
|
||||||
|
one frame per call. OpenCV's internal buffering keeps latency low.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, display_index: int, config: Dict[str, Any]):
|
||||||
|
super().__init__(display_index, config)
|
||||||
|
self._cap = None
|
||||||
|
|
||||||
|
def initialize(self) -> None:
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
backend_name = self.config.get("camera_backend", _get_default_backend())
|
||||||
|
|
||||||
|
# Enumerate to resolve display_index → actual cv2 camera index
|
||||||
|
cameras = _enumerate_cameras(backend_name)
|
||||||
|
if not cameras:
|
||||||
|
raise RuntimeError(
|
||||||
|
"No cameras found. Ensure a webcam is connected."
|
||||||
|
)
|
||||||
|
if self.display_index >= len(cameras):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Camera index {self.display_index} out of range "
|
||||||
|
f"(found {len(cameras)} camera(s))"
|
||||||
|
)
|
||||||
|
|
||||||
|
camera = cameras[self.display_index]
|
||||||
|
cv2_index = camera["cv2_index"]
|
||||||
|
|
||||||
|
# Open the camera
|
||||||
|
backend_id = _cv2_backend_id(backend_name)
|
||||||
|
if backend_id is not None:
|
||||||
|
self._cap = cv2.VideoCapture(cv2_index, backend_id)
|
||||||
|
else:
|
||||||
|
self._cap = cv2.VideoCapture(cv2_index)
|
||||||
|
|
||||||
|
if not self._cap.isOpened():
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to open camera {self.display_index} "
|
||||||
|
f"(cv2 index {cv2_index})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply optional resolution override
|
||||||
|
res_w = self.config.get("resolution_width", 0)
|
||||||
|
res_h = self.config.get("resolution_height", 0)
|
||||||
|
if res_w > 0 and res_h > 0:
|
||||||
|
self._cap.set(cv2.CAP_PROP_FRAME_WIDTH, res_w)
|
||||||
|
self._cap.set(cv2.CAP_PROP_FRAME_HEIGHT, res_h)
|
||||||
|
|
||||||
|
# Test read
|
||||||
|
ret, frame = self._cap.read()
|
||||||
|
if not ret or frame is None:
|
||||||
|
self._cap.release()
|
||||||
|
self._cap = None
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Camera {self.display_index} opened but test read failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
self._initialized = True
|
||||||
|
logger.info(
|
||||||
|
f"Camera capture stream initialized "
|
||||||
|
f"(camera={camera['name']}, cv2_idx={cv2_index}, {w}x{h})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def capture_frame(self) -> Optional[ScreenCapture]:
|
||||||
|
if not self._initialized:
|
||||||
|
self.initialize()
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
ret, frame = self._cap.read()
|
||||||
|
if not ret or frame is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# OpenCV captures BGR; convert to RGB
|
||||||
|
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||||
|
h, w = rgb.shape[:2]
|
||||||
|
|
||||||
|
return ScreenCapture(
|
||||||
|
image=rgb,
|
||||||
|
width=w,
|
||||||
|
height=h,
|
||||||
|
display_index=self.display_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
if self._cap is not None:
|
||||||
|
self._cap.release()
|
||||||
|
self._cap = None
|
||||||
|
self._initialized = False
|
||||||
|
logger.info(f"Camera capture stream cleaned up (display={self.display_index})")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CaptureEngine
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class CameraEngine(CaptureEngine):
|
||||||
|
"""Camera/webcam capture engine using OpenCV.
|
||||||
|
|
||||||
|
Captures frames from USB or integrated webcams.
|
||||||
|
Each connected camera appears as a selectable "display".
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
pip install opencv-python-headless>=4.8.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
ENGINE_TYPE = "camera"
|
||||||
|
ENGINE_PRIORITY = 0
|
||||||
|
HAS_OWN_DISPLAYS = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_available(cls) -> bool:
|
||||||
|
try:
|
||||||
|
import cv2 # noqa: F401
|
||||||
|
return True
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_default_config(cls) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"camera_backend": _get_default_backend(),
|
||||||
|
"resolution_width": 0,
|
||||||
|
"resolution_height": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_available_displays(cls) -> List[DisplayInfo]:
|
||||||
|
backend = _get_default_backend()
|
||||||
|
cameras = _enumerate_cameras(backend)
|
||||||
|
|
||||||
|
displays = []
|
||||||
|
for idx, cam in enumerate(cameras):
|
||||||
|
displays.append(DisplayInfo(
|
||||||
|
index=idx,
|
||||||
|
name=cam["name"],
|
||||||
|
width=cam["width"],
|
||||||
|
height=cam["height"],
|
||||||
|
x=idx * 500,
|
||||||
|
y=0,
|
||||||
|
is_primary=(idx == 0),
|
||||||
|
refresh_rate=int(cam["fps"]),
|
||||||
|
))
|
||||||
|
|
||||||
|
logger.debug(f"Camera engine detected {len(displays)} camera(s)")
|
||||||
|
return displays
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_stream(
|
||||||
|
cls, display_index: int, config: Dict[str, Any]
|
||||||
|
) -> CameraCaptureStream:
|
||||||
|
return CameraCaptureStream(display_index, config)
|
||||||
@@ -304,6 +304,7 @@ class ScrcpyEngine(CaptureEngine):
|
|||||||
|
|
||||||
ENGINE_TYPE = "scrcpy"
|
ENGINE_TYPE = "scrcpy"
|
||||||
ENGINE_PRIORITY = 5
|
ENGINE_PRIORITY = 5
|
||||||
|
HAS_OWN_DISPLAYS = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_available(cls) -> bool:
|
def is_available(cls) -> bool:
|
||||||
|
|||||||
@@ -723,6 +723,36 @@
|
|||||||
background: rgba(76, 175, 80, 0.12) !important;
|
background: rgba(76, 175, 80, 0.12) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Device picker list (cameras, scrcpy) ─────────────────────── */
|
||||||
|
|
||||||
|
.device-picker-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-picker-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, rgba(128, 128, 128, 0.08), rgba(128, 128, 128, 0.03));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-picker-item .layout-index-label {
|
||||||
|
position: static;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-picker-item .layout-display-label {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Gradient editor ────────────────────────────────────────────── */
|
/* ── Gradient editor ────────────────────────────────────────────── */
|
||||||
|
|
||||||
.effect-palette-preview {
|
.effect-palette-preview {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function createDeviceCard(device) {
|
|||||||
healthClass = 'health-offline';
|
healthClass = 'health-offline';
|
||||||
healthTitle = t('device.health.offline');
|
healthTitle = t('device.health.offline');
|
||||||
if (state.device_error) healthTitle += `: ${state.device_error}`;
|
if (state.device_error) healthTitle += `: ${state.device_error}`;
|
||||||
healthLabel = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
|
healthLabel = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ledCount = state.device_led_count || device.led_count;
|
const ledCount = state.device_led_count || device.led_count;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import {
|
import {
|
||||||
_cachedDisplays, _displayPickerCallback, _displayPickerSelectedIndex,
|
_cachedDisplays, _displayPickerCallback, _displayPickerSelectedIndex,
|
||||||
set_displayPickerCallback, set_displayPickerSelectedIndex, displaysCache,
|
set_displayPickerCallback, set_displayPickerSelectedIndex, displaysCache,
|
||||||
|
availableEngines,
|
||||||
} from '../core/state.js';
|
} from '../core/state.js';
|
||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { fetchWithAuth } from '../core/api.js';
|
import { fetchWithAuth } from '../core/api.js';
|
||||||
@@ -14,6 +15,10 @@ import { showToast } from '../core/ui.js';
|
|||||||
/** Currently active engine type for the picker (null = desktop monitors). */
|
/** Currently active engine type for the picker (null = desktop monitors). */
|
||||||
let _pickerEngineType = null;
|
let _pickerEngineType = null;
|
||||||
|
|
||||||
|
/** Check if an engine type has its own device list (for inline onclick use). */
|
||||||
|
window._engineHasOwnDisplays = (engineType) =>
|
||||||
|
!!(engineType && availableEngines.find(e => e.type === engineType)?.has_own_displays);
|
||||||
|
|
||||||
export function openDisplayPicker(callback, selectedIndex, engineType = null) {
|
export function openDisplayPicker(callback, selectedIndex, engineType = null) {
|
||||||
set_displayPickerCallback(callback);
|
set_displayPickerCallback(callback);
|
||||||
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
|
set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null);
|
||||||
@@ -57,8 +62,10 @@ async function _fetchAndRenderEngineDisplays(engineType) {
|
|||||||
|
|
||||||
if (displays.length > 0) {
|
if (displays.length > 0) {
|
||||||
renderDisplayPickerLayout(displays, engineType);
|
renderDisplayPickerLayout(displays, engineType);
|
||||||
} else {
|
} else if (engineType === 'scrcpy') {
|
||||||
_renderEmptyAndroidPicker(canvas);
|
_renderEmptyAndroidPicker(canvas);
|
||||||
|
} else {
|
||||||
|
canvas.innerHTML = `<div class="loading">${t('displays.none')}</div>`;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching engine displays:', error);
|
console.error('Error fetching engine displays:', error);
|
||||||
@@ -143,6 +150,34 @@ export function renderDisplayPickerLayout(displays, engineType = null) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Engines with own displays (camera, scrcpy) → device list layout
|
||||||
|
if (engineType) {
|
||||||
|
const items = displays.map(display => {
|
||||||
|
const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex;
|
||||||
|
return `
|
||||||
|
<div class="device-picker-item layout-display-pickable${isSelected ? ' selected' : ''}"
|
||||||
|
onclick="selectDisplay(${display.index})"
|
||||||
|
title="${t('displays.picker.click_to_select')}">
|
||||||
|
<div class="layout-index-label">#${display.index}</div>
|
||||||
|
<div class="layout-display-label">
|
||||||
|
<strong>${display.name}</strong>
|
||||||
|
<small>${display.width}×${display.height} · ${display.refresh_rate} FPS</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
let html = `<div class="device-picker-list">${items}</div>`;
|
||||||
|
|
||||||
|
if (engineType === 'scrcpy') {
|
||||||
|
html += _buildAdbConnectHtml();
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.innerHTML = html;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop monitors → positioned rectangle layout
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
displays.forEach(display => {
|
displays.forEach(display => {
|
||||||
minX = Math.min(minX, display.x);
|
minX = Math.min(minX, display.x);
|
||||||
@@ -178,22 +213,18 @@ export function renderDisplayPickerLayout(displays, engineType = null) {
|
|||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
let html = `
|
canvas.innerHTML = `
|
||||||
<div class="layout-container" style="width: 100%; padding-bottom: ${aspect * 100}%; position: relative;">
|
<div class="layout-container" style="width: 100%; padding-bottom: ${aspect * 100}%; position: relative;">
|
||||||
${displayElements}
|
${displayElements}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Show ADB connect form below devices for scrcpy engine
|
|
||||||
if (engineType === 'scrcpy') {
|
|
||||||
html += _buildAdbConnectHtml();
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.innerHTML = html;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDisplayLabel(displayIndex, display) {
|
export function formatDisplayLabel(displayIndex, display, engineType = null) {
|
||||||
if (display) {
|
if (display) {
|
||||||
|
if (engineType && window._engineHasOwnDisplays(engineType)) {
|
||||||
|
return `${display.name} (${display.width}×${display.height})`;
|
||||||
|
}
|
||||||
return `#${display.index}: ${display.width}×${display.height}${display.is_primary ? ' (' + t('displays.badge.primary') + ')' : ''}`;
|
return `#${display.index}: ${display.width}×${display.height}${display.is_primary ? ' (' + t('displays.badge.primary') + ')' : ''}`;
|
||||||
}
|
}
|
||||||
return `Display ${displayIndex}`;
|
return `Display ${displayIndex}`;
|
||||||
|
|||||||
@@ -327,6 +327,11 @@ export async function onEngineChange() {
|
|||||||
configFields.innerHTML = '';
|
configFields.innerHTML = '';
|
||||||
const defaultConfig = engine.default_config || {};
|
const defaultConfig = engine.default_config || {};
|
||||||
|
|
||||||
|
// Known select options for specific config keys
|
||||||
|
const CONFIG_SELECT_OPTIONS = {
|
||||||
|
camera_backend: ['auto', 'dshow', 'msmf', 'v4l2'],
|
||||||
|
};
|
||||||
|
|
||||||
if (Object.keys(defaultConfig).length === 0) {
|
if (Object.keys(defaultConfig).length === 0) {
|
||||||
configSection.style.display = 'none';
|
configSection.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
@@ -335,6 +340,7 @@ export async function onEngineChange() {
|
|||||||
Object.entries(defaultConfig).forEach(([key, value]) => {
|
Object.entries(defaultConfig).forEach(([key, value]) => {
|
||||||
const fieldType = typeof value === 'number' ? 'number' : 'text';
|
const fieldType = typeof value === 'number' ? 'number' : 'text';
|
||||||
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
|
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
|
||||||
|
const selectOptions = CONFIG_SELECT_OPTIONS[key];
|
||||||
gridHtml += `
|
gridHtml += `
|
||||||
<label class="config-grid-label" for="config-${key}">${key}</label>
|
<label class="config-grid-label" for="config-${key}">${key}</label>
|
||||||
<div class="config-grid-value">
|
<div class="config-grid-value">
|
||||||
@@ -343,6 +349,10 @@ export async function onEngineChange() {
|
|||||||
<option value="true" ${value ? 'selected' : ''}>true</option>
|
<option value="true" ${value ? 'selected' : ''}>true</option>
|
||||||
<option value="false" ${!value ? 'selected' : ''}>false</option>
|
<option value="false" ${!value ? 'selected' : ''}>false</option>
|
||||||
</select>
|
</select>
|
||||||
|
` : selectOptions ? `
|
||||||
|
<select id="config-${key}" data-config-key="${key}">
|
||||||
|
${selectOptions.map(opt => `<option value="${opt}" ${opt === String(value) ? 'selected' : ''}>${opt}</option>`).join('')}
|
||||||
|
</select>
|
||||||
` : `
|
` : `
|
||||||
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
|
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
|
||||||
`}
|
`}
|
||||||
@@ -387,14 +397,15 @@ function collectEngineConfig() {
|
|||||||
|
|
||||||
async function loadDisplaysForTest() {
|
async function loadDisplaysForTest() {
|
||||||
try {
|
try {
|
||||||
// Use engine-specific display list when testing a scrcpy template
|
// Use engine-specific display list for engines with own devices (camera, scrcpy)
|
||||||
const engineType = window.currentTestingTemplate?.engine_type;
|
const engineType = window.currentTestingTemplate?.engine_type;
|
||||||
const url = engineType === 'scrcpy'
|
const engineHasOwnDisplays = availableEngines.find(e => e.type === engineType)?.has_own_displays || false;
|
||||||
? `/config/displays?engine_type=scrcpy`
|
const url = engineHasOwnDisplays
|
||||||
|
? `/config/displays?engine_type=${engineType}`
|
||||||
: '/config/displays';
|
: '/config/displays';
|
||||||
|
|
||||||
// Always refetch for scrcpy (devices may change); use cache for desktop
|
// Always refetch for engines with own displays (devices may change); use cache for desktop
|
||||||
if (!_cachedDisplays || engineType === 'scrcpy') {
|
if (!_cachedDisplays || engineHasOwnDisplays) {
|
||||||
const response = await fetchWithAuth(url);
|
const response = await fetchWithAuth(url);
|
||||||
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
|
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
|
||||||
const displaysData = await response.json();
|
const displaysData = await response.json();
|
||||||
@@ -1420,13 +1431,15 @@ export function onStreamTypeChange() {
|
|||||||
|
|
||||||
export function onStreamDisplaySelected(displayIndex, display) {
|
export function onStreamDisplaySelected(displayIndex, display) {
|
||||||
document.getElementById('stream-display-index').value = displayIndex;
|
document.getElementById('stream-display-index').value = displayIndex;
|
||||||
document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
|
const engineType = document.getElementById('stream-capture-template').selectedOptions[0]?.dataset?.engineType || null;
|
||||||
|
document.getElementById('stream-display-picker-label').textContent = formatDisplayLabel(displayIndex, display, engineType);
|
||||||
_autoGenerateStreamName();
|
_autoGenerateStreamName();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onTestDisplaySelected(displayIndex, display) {
|
export function onTestDisplaySelected(displayIndex, display) {
|
||||||
document.getElementById('test-template-display').value = displayIndex;
|
document.getElementById('test-template-display').value = displayIndex;
|
||||||
document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display);
|
const engineType = window.currentTestingTemplate?.engine_type || null;
|
||||||
|
document.getElementById('test-display-picker-label').textContent = formatDisplayLabel(displayIndex, display, engineType);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _autoGenerateStreamName() {
|
function _autoGenerateStreamName() {
|
||||||
@@ -1488,10 +1501,11 @@ export async function showAddStreamModal(presetType, cloneData = null) {
|
|||||||
document.getElementById('stream-name').value = (cloneData.name || '') + ' (Copy)';
|
document.getElementById('stream-name').value = (cloneData.name || '') + ' (Copy)';
|
||||||
document.getElementById('stream-description').value = cloneData.description || '';
|
document.getElementById('stream-description').value = cloneData.description || '';
|
||||||
if (streamType === 'raw') {
|
if (streamType === 'raw') {
|
||||||
|
document.getElementById('stream-capture-template').value = cloneData.capture_template_id || '';
|
||||||
|
await _onCaptureTemplateChanged();
|
||||||
const displayIdx = cloneData.display_index ?? 0;
|
const displayIdx = cloneData.display_index ?? 0;
|
||||||
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
|
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
|
||||||
onStreamDisplaySelected(displayIdx, display);
|
onStreamDisplaySelected(displayIdx, display);
|
||||||
document.getElementById('stream-capture-template').value = cloneData.capture_template_id || '';
|
|
||||||
const fps = cloneData.target_fps ?? 30;
|
const fps = cloneData.target_fps ?? 30;
|
||||||
document.getElementById('stream-target-fps').value = fps;
|
document.getElementById('stream-target-fps').value = fps;
|
||||||
document.getElementById('stream-target-fps-value').textContent = fps;
|
document.getElementById('stream-target-fps-value').textContent = fps;
|
||||||
@@ -1534,10 +1548,12 @@ export async function editStream(streamId) {
|
|||||||
await populateStreamModalDropdowns();
|
await populateStreamModalDropdowns();
|
||||||
|
|
||||||
if (stream.stream_type === 'raw') {
|
if (stream.stream_type === 'raw') {
|
||||||
|
document.getElementById('stream-capture-template').value = stream.capture_template_id || '';
|
||||||
|
// Ensure correct engine displays are loaded for this template
|
||||||
|
await _onCaptureTemplateChanged();
|
||||||
const displayIdx = stream.display_index ?? 0;
|
const displayIdx = stream.display_index ?? 0;
|
||||||
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
|
const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null;
|
||||||
onStreamDisplaySelected(displayIdx, display);
|
onStreamDisplaySelected(displayIdx, display);
|
||||||
document.getElementById('stream-capture-template').value = stream.capture_template_id || '';
|
|
||||||
const fps = stream.target_fps ?? 30;
|
const fps = stream.target_fps ?? 30;
|
||||||
document.getElementById('stream-target-fps').value = fps;
|
document.getElementById('stream-target-fps').value = fps;
|
||||||
document.getElementById('stream-target-fps-value').textContent = fps;
|
document.getElementById('stream-target-fps-value').textContent = fps;
|
||||||
@@ -1568,16 +1584,12 @@ async function populateStreamModalDropdowns() {
|
|||||||
fetchWithAuth('/postprocessing-templates'),
|
fetchWithAuth('/postprocessing-templates'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Cache desktop displays (used as default unless engine has own displays)
|
||||||
if (displaysRes.ok) {
|
if (displaysRes.ok) {
|
||||||
const displaysData = await displaysRes.json();
|
const displaysData = await displaysRes.json();
|
||||||
displaysCache.update(displaysData.displays || []);
|
displaysCache.update(displaysData.displays || []);
|
||||||
}
|
}
|
||||||
_streamModalDisplaysEngine = null; // desktop displays loaded
|
_streamModalDisplaysEngine = null;
|
||||||
|
|
||||||
if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) {
|
|
||||||
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
|
|
||||||
onStreamDisplaySelected(primary.index, primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
const templateSelect = document.getElementById('stream-capture-template');
|
const templateSelect = document.getElementById('stream-capture-template');
|
||||||
templateSelect.innerHTML = '';
|
templateSelect.innerHTML = '';
|
||||||
@@ -1588,6 +1600,7 @@ async function populateStreamModalDropdowns() {
|
|||||||
opt.value = tmpl.id;
|
opt.value = tmpl.id;
|
||||||
opt.dataset.name = tmpl.name;
|
opt.dataset.name = tmpl.name;
|
||||||
opt.dataset.engineType = tmpl.engine_type;
|
opt.dataset.engineType = tmpl.engine_type;
|
||||||
|
opt.dataset.hasOwnDisplays = availableEngines.find(e => e.type === tmpl.engine_type)?.has_own_displays ? '1' : '';
|
||||||
opt.textContent = `${tmpl.name} (${tmpl.engine_type})`;
|
opt.textContent = `${tmpl.name} (${tmpl.engine_type})`;
|
||||||
templateSelect.appendChild(opt);
|
templateSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
@@ -1596,6 +1609,15 @@ async function populateStreamModalDropdowns() {
|
|||||||
// When template changes, refresh displays if engine type switched
|
// When template changes, refresh displays if engine type switched
|
||||||
templateSelect.addEventListener('change', _onCaptureTemplateChanged);
|
templateSelect.addEventListener('change', _onCaptureTemplateChanged);
|
||||||
|
|
||||||
|
// Load displays for the selected engine (engine-specific or desktop)
|
||||||
|
const firstOpt = templateSelect.selectedOptions[0];
|
||||||
|
if (firstOpt?.dataset?.hasOwnDisplays === '1') {
|
||||||
|
await _refreshStreamDisplaysForEngine(firstOpt.dataset.engineType);
|
||||||
|
} else if (!document.getElementById('stream-display-index').value && _cachedDisplays && _cachedDisplays.length > 0) {
|
||||||
|
const primary = _cachedDisplays.find(d => d.is_primary) || _cachedDisplays[0];
|
||||||
|
onStreamDisplaySelected(primary.index, primary);
|
||||||
|
}
|
||||||
|
|
||||||
const sourceSelect = document.getElementById('stream-source');
|
const sourceSelect = document.getElementById('stream-source');
|
||||||
sourceSelect.innerHTML = '';
|
sourceSelect.innerHTML = '';
|
||||||
if (streamsRes.ok) {
|
if (streamsRes.ok) {
|
||||||
@@ -1626,19 +1648,13 @@ async function populateStreamModalDropdowns() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_autoGenerateStreamName();
|
_autoGenerateStreamName();
|
||||||
|
|
||||||
// If the first template is an scrcpy engine, reload displays immediately
|
|
||||||
const firstOpt = templateSelect.selectedOptions[0];
|
|
||||||
if (firstOpt?.dataset?.engineType === 'scrcpy') {
|
|
||||||
await _refreshStreamDisplaysForEngine('scrcpy');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _onCaptureTemplateChanged() {
|
async function _onCaptureTemplateChanged() {
|
||||||
const templateSelect = document.getElementById('stream-capture-template');
|
const templateSelect = document.getElementById('stream-capture-template');
|
||||||
const engineType = templateSelect.selectedOptions[0]?.dataset?.engineType || null;
|
const engineType = templateSelect.selectedOptions[0]?.dataset?.engineType || null;
|
||||||
const needsEngineDisplays = engineType === 'scrcpy';
|
const hasOwnDisplays = templateSelect.selectedOptions[0]?.dataset?.hasOwnDisplays === '1';
|
||||||
const currentEngine = needsEngineDisplays ? engineType : null;
|
const currentEngine = hasOwnDisplays ? engineType : null;
|
||||||
|
|
||||||
// Only refetch if the engine category actually changed
|
// Only refetch if the engine category actually changed
|
||||||
if (currentEngine !== _streamModalDisplaysEngine) {
|
if (currentEngine !== _streamModalDisplaysEngine) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="streams.display.hint">Which screen to capture</small>
|
<small class="input-hint" style="display:none" data-i18n="streams.display.hint">Which screen to capture</small>
|
||||||
<input type="hidden" id="stream-display-index" value="">
|
<input type="hidden" id="stream-display-index" value="">
|
||||||
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected, document.getElementById('stream-display-index').value, document.getElementById('stream-capture-template').selectedOptions[0]?.dataset?.engineType)">
|
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected, document.getElementById('stream-display-index').value, document.getElementById('stream-capture-template').selectedOptions[0]?.dataset?.hasOwnDisplays === '1' ? document.getElementById('stream-capture-template').selectedOptions[0].dataset.engineType : null)">
|
||||||
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="templates.test.display">Display:</label>
|
<label data-i18n="templates.test.display">Display:</label>
|
||||||
<input type="hidden" id="test-template-display" value="">
|
<input type="hidden" id="test-template-display" value="">
|
||||||
<button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openDisplayPicker(onTestDisplaySelected, document.getElementById('test-template-display').value, window.currentTestingTemplate?.engine_type)">
|
<button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openDisplayPicker(onTestDisplaySelected, document.getElementById('test-template-display').value, window._engineHasOwnDisplays?.(window.currentTestingTemplate?.engine_type) ? window.currentTestingTemplate.engine_type : null)">
|
||||||
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user