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

View File

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

View File

@@ -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 = `<span class="health-latency offline">${t('device.health.offline')}</span>`;
healthLabel = '';
}
const ledCount = state.device_led_count || device.led_count;

View File

@@ -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 = `<div class="loading">${t('displays.none')}</div>`;
}
} 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 `
<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;
displays.forEach(display => {
minX = Math.min(minX, display.x);
@@ -178,22 +213,18 @@ export function renderDisplayPickerLayout(displays, engineType = null) {
`;
}).join('');
let html = `
canvas.innerHTML = `
<div class="layout-container" style="width: 100%; padding-bottom: ${aspect * 100}%; position: relative;">
${displayElements}
</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 (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}`;

View File

@@ -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 += `
<label class="config-grid-label" for="config-${key}">${key}</label>
<div class="config-grid-value">
@@ -343,6 +349,10 @@ export async function onEngineChange() {
<option value="true" ${value ? 'selected' : ''}>true</option>
<option value="false" ${!value ? 'selected' : ''}>false</option>
</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}">
`}
@@ -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) {