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:
2026-03-01 12:46:28 +03:00
parent b9ec509f56
commit 8fe9c6489b
14 changed files with 405 additions and 42 deletions

11
TODO.md
View File

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

View File

@@ -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'",

View File

@@ -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),
) )
) )

View File

@@ -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):

View File

@@ -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",
] ]

View File

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

View 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)

View File

@@ -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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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