From 8fe9c6489b00cff5b3d62cbf9d49579eb59f14d9 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 1 Mar 2026 12:46:28 +0300 Subject: [PATCH] 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 --- TODO.md | 11 +- server/pyproject.toml | 3 + .../wled_controller/api/routes/templates.py | 1 + .../wled_controller/api/schemas/templates.py | 1 + .../core/capture_engines/__init__.py | 4 + .../core/capture_engines/base.py | 1 + .../core/capture_engines/camera_engine.py | 276 ++++++++++++++++++ .../core/capture_engines/scrcpy_engine.py | 1 + .../src/wled_controller/static/css/modal.css | 30 ++ .../static/js/features/devices.js | 2 +- .../static/js/features/displays.js | 51 +++- .../static/js/features/streams.js | 62 ++-- .../templates/modals/stream.html | 2 +- .../templates/modals/test-template.html | 2 +- 14 files changed, 405 insertions(+), 42 deletions(-) create mode 100644 server/src/wled_controller/core/capture_engines/camera_engine.py diff --git a/TODO.md b/TODO.md index 6c240dc..a1826a6 100644 --- a/TODO.md +++ b/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 - 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) -- [ ] `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 +- [x] `P2` **OpenRGB** — Control PC peripherals (keyboard, mouse, RAM, fans) as ambient targets - [ ] `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 - 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 - 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 -- [ ] `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 +- [x] `P3` **Camera / webcam** — Border-sampling from camera feed for video calls or room-reactive lighting ## 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 - 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 diff --git a/server/pyproject.toml b/server/pyproject.toml index 1d19f3f..82fdbad 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -57,6 +57,9 @@ dev = [ "black>=24.0.0", "ruff>=0.6.0", ] +camera = [ + "opencv-python-headless>=4.8.0", +] # High-performance screen capture engines (Windows only) perf = [ "dxcam>=0.0.5; sys_platform == 'win32'", diff --git a/server/src/wled_controller/api/routes/templates.py b/server/src/wled_controller/api/routes/templates.py index 160191e..fd51010 100644 --- a/server/src/wled_controller/api/routes/templates.py +++ b/server/src/wled_controller/api/routes/templates.py @@ -228,6 +228,7 @@ async def list_engines(_auth: AuthRequired): name=engine_type.upper(), default_config=engine_class.get_default_config(), available=(engine_type in available_set), + has_own_displays=getattr(engine_class, 'HAS_OWN_DISPLAYS', False), ) ) diff --git a/server/src/wled_controller/api/schemas/templates.py b/server/src/wled_controller/api/schemas/templates.py index 2777598..c0d04ca 100644 --- a/server/src/wled_controller/api/schemas/templates.py +++ b/server/src/wled_controller/api/schemas/templates.py @@ -50,6 +50,7 @@ class EngineInfo(BaseModel): name: str = Field(description="Human-readable engine name") default_config: Dict = Field(description="Default configuration for this engine") 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): diff --git a/server/src/wled_controller/core/capture_engines/__init__.py b/server/src/wled_controller/core/capture_engines/__init__.py index 03cea13..a914332 100644 --- a/server/src/wled_controller/core/capture_engines/__init__.py +++ b/server/src/wled_controller/core/capture_engines/__init__.py @@ -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.wgc_engine import WGCEngine, WGCCaptureStream 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 EngineRegistry.register(MSSEngine) @@ -19,6 +20,7 @@ EngineRegistry.register(DXcamEngine) EngineRegistry.register(BetterCamEngine) EngineRegistry.register(WGCEngine) EngineRegistry.register(ScrcpyEngine) +EngineRegistry.register(CameraEngine) __all__ = [ "CaptureEngine", @@ -36,4 +38,6 @@ __all__ = [ "WGCCaptureStream", "ScrcpyEngine", "ScrcpyCaptureStream", + "CameraEngine", + "CameraCaptureStream", ] diff --git a/server/src/wled_controller/core/capture_engines/base.py b/server/src/wled_controller/core/capture_engines/base.py index 7a2934d..7f634dc 100644 --- a/server/src/wled_controller/core/capture_engines/base.py +++ b/server/src/wled_controller/core/capture_engines/base.py @@ -103,6 +103,7 @@ class CaptureEngine(ABC): ENGINE_TYPE: str = "base" ENGINE_PRIORITY: int = 0 + HAS_OWN_DISPLAYS: bool = False # True for engines with non-desktop device lists @classmethod @abstractmethod diff --git a/server/src/wled_controller/core/capture_engines/camera_engine.py b/server/src/wled_controller/core/capture_engines/camera_engine.py new file mode 100644 index 0000000..56582ef --- /dev/null +++ b/server/src/wled_controller/core/capture_engines/camera_engine.py @@ -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) diff --git a/server/src/wled_controller/core/capture_engines/scrcpy_engine.py b/server/src/wled_controller/core/capture_engines/scrcpy_engine.py index 2b7f46b..3ba0d98 100644 --- a/server/src/wled_controller/core/capture_engines/scrcpy_engine.py +++ b/server/src/wled_controller/core/capture_engines/scrcpy_engine.py @@ -304,6 +304,7 @@ class ScrcpyEngine(CaptureEngine): ENGINE_TYPE = "scrcpy" ENGINE_PRIORITY = 5 + HAS_OWN_DISPLAYS = True @classmethod def is_available(cls) -> bool: diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 8b4509a..f132f76 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -723,6 +723,36 @@ 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 ────────────────────────────────────────────── */ .effect-palette-preview { diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index e6d9e68..d43d285 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -73,7 +73,7 @@ export function createDeviceCard(device) { healthClass = 'health-offline'; healthTitle = t('device.health.offline'); if (state.device_error) healthTitle += `: ${state.device_error}`; - healthLabel = `${t('device.health.offline')}`; + healthLabel = ''; } const ledCount = state.device_led_count || device.led_count; diff --git a/server/src/wled_controller/static/js/features/displays.js b/server/src/wled_controller/static/js/features/displays.js index 6268d69..4fbcada 100644 --- a/server/src/wled_controller/static/js/features/displays.js +++ b/server/src/wled_controller/static/js/features/displays.js @@ -6,6 +6,7 @@ import { _cachedDisplays, _displayPickerCallback, _displayPickerSelectedIndex, set_displayPickerCallback, set_displayPickerSelectedIndex, displaysCache, + availableEngines, } from '../core/state.js'; import { t } from '../core/i18n.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). */ 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) { set_displayPickerCallback(callback); set_displayPickerSelectedIndex((selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null); @@ -57,8 +62,10 @@ async function _fetchAndRenderEngineDisplays(engineType) { if (displays.length > 0) { renderDisplayPickerLayout(displays, engineType); - } else { + } else if (engineType === 'scrcpy') { _renderEmptyAndroidPicker(canvas); + } else { + canvas.innerHTML = `
${t('displays.none')}
`; } } catch (error) { console.error('Error fetching engine displays:', error); @@ -143,6 +150,34 @@ export function renderDisplayPickerLayout(displays, engineType = null) { 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 ` +
+
#${display.index}
+
+ ${display.name} + ${display.width}×${display.height} · ${display.refresh_rate} FPS +
+
+ `; + }).join(''); + + let html = `
${items}
`; + + if (engineType === 'scrcpy') { + html += _buildAdbConnectHtml(); + } + + canvas.innerHTML = html; + return; + } + + // Desktop monitors → positioned rectangle layout let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; displays.forEach(display => { minX = Math.min(minX, display.x); @@ -178,22 +213,18 @@ export function renderDisplayPickerLayout(displays, engineType = null) { `; }).join(''); - let html = ` + canvas.innerHTML = `
${displayElements}
`; - - // 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 (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 ${displayIndex}`; diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index ba03c6b..8e8e8fd 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -327,6 +327,11 @@ export async function onEngineChange() { configFields.innerHTML = ''; 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) { configSection.style.display = 'none'; return; @@ -335,6 +340,7 @@ export async function onEngineChange() { Object.entries(defaultConfig).forEach(([key, value]) => { const fieldType = typeof value === 'number' ? 'number' : 'text'; const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value; + const selectOptions = CONFIG_SELECT_OPTIONS[key]; gridHtml += `
@@ -343,6 +349,10 @@ export async function onEngineChange() { + ` : selectOptions ? ` + ` : ` `} @@ -387,14 +397,15 @@ function collectEngineConfig() { async function loadDisplaysForTest() { 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 url = engineType === 'scrcpy' - ? `/config/displays?engine_type=scrcpy` + const engineHasOwnDisplays = availableEngines.find(e => e.type === engineType)?.has_own_displays || false; + const url = engineHasOwnDisplays + ? `/config/displays?engine_type=${engineType}` : '/config/displays'; - // Always refetch for scrcpy (devices may change); use cache for desktop - if (!_cachedDisplays || engineType === 'scrcpy') { + // Always refetch for engines with own displays (devices may change); use cache for desktop + if (!_cachedDisplays || engineHasOwnDisplays) { const response = await fetchWithAuth(url); if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`); const displaysData = await response.json(); @@ -1420,13 +1431,15 @@ export function onStreamTypeChange() { export function onStreamDisplaySelected(displayIndex, display) { 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(); } export function onTestDisplaySelected(displayIndex, display) { 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() { @@ -1488,10 +1501,11 @@ export async function showAddStreamModal(presetType, cloneData = null) { document.getElementById('stream-name').value = (cloneData.name || '') + ' (Copy)'; document.getElementById('stream-description').value = cloneData.description || ''; if (streamType === 'raw') { + document.getElementById('stream-capture-template').value = cloneData.capture_template_id || ''; + await _onCaptureTemplateChanged(); const displayIdx = cloneData.display_index ?? 0; const display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null; onStreamDisplaySelected(displayIdx, display); - document.getElementById('stream-capture-template').value = cloneData.capture_template_id || ''; const fps = cloneData.target_fps ?? 30; document.getElementById('stream-target-fps').value = fps; document.getElementById('stream-target-fps-value').textContent = fps; @@ -1534,10 +1548,12 @@ export async function editStream(streamId) { await populateStreamModalDropdowns(); 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 display = _cachedDisplays ? _cachedDisplays.find(d => d.index === displayIdx) : null; onStreamDisplaySelected(displayIdx, display); - document.getElementById('stream-capture-template').value = stream.capture_template_id || ''; const fps = stream.target_fps ?? 30; document.getElementById('stream-target-fps').value = fps; document.getElementById('stream-target-fps-value').textContent = fps; @@ -1568,16 +1584,12 @@ async function populateStreamModalDropdowns() { fetchWithAuth('/postprocessing-templates'), ]); + // Cache desktop displays (used as default unless engine has own displays) if (displaysRes.ok) { const displaysData = await displaysRes.json(); displaysCache.update(displaysData.displays || []); } - _streamModalDisplaysEngine = null; // desktop displays loaded - - 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); - } + _streamModalDisplaysEngine = null; const templateSelect = document.getElementById('stream-capture-template'); templateSelect.innerHTML = ''; @@ -1588,6 +1600,7 @@ async function populateStreamModalDropdowns() { opt.value = tmpl.id; opt.dataset.name = tmpl.name; 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})`; templateSelect.appendChild(opt); }); @@ -1596,6 +1609,15 @@ async function populateStreamModalDropdowns() { // When template changes, refresh displays if engine type switched 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'); sourceSelect.innerHTML = ''; if (streamsRes.ok) { @@ -1626,19 +1648,13 @@ async function populateStreamModalDropdowns() { } _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() { const templateSelect = document.getElementById('stream-capture-template'); const engineType = templateSelect.selectedOptions[0]?.dataset?.engineType || null; - const needsEngineDisplays = engineType === 'scrcpy'; - const currentEngine = needsEngineDisplays ? engineType : null; + const hasOwnDisplays = templateSelect.selectedOptions[0]?.dataset?.hasOwnDisplays === '1'; + const currentEngine = hasOwnDisplays ? engineType : null; // Only refetch if the engine category actually changed if (currentEngine !== _streamModalDisplaysEngine) { diff --git a/server/src/wled_controller/templates/modals/stream.html b/server/src/wled_controller/templates/modals/stream.html index c8d9677..95c162d 100644 --- a/server/src/wled_controller/templates/modals/stream.html +++ b/server/src/wled_controller/templates/modals/stream.html @@ -24,7 +24,7 @@
- diff --git a/server/src/wled_controller/templates/modals/test-template.html b/server/src/wled_controller/templates/modals/test-template.html index 3ed9352..41d8360 100644 --- a/server/src/wled_controller/templates/modals/test-template.html +++ b/server/src/wled_controller/templates/modals/test-template.html @@ -9,7 +9,7 @@
-