Add BetterCam engine, UI polish, and bug fixes

- Add BetterCam capture engine (DXGI Desktop Duplication, priority 4)
- Fix missing picture_stream_id in get_device endpoint
- Fix template delete validation to check streams instead of devices
- Add description field to capture engine template UI
- Default template name changed to "Default" with descriptive text
- Display picker highlights selected display instead of primary
- Fix modals closing when dragging text selection outside dialog
- Rename "Engine Configuration" to "Configuration", hide when empty
- Rename "Run Test" to "Run" across all test buttons
- Always reserve space for vertical scrollbar
- Redesign Stream Settings info panel with pill-style props
- Fix processed stream showing internal ID instead of stream name
- Update en/ru locale files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 23:28:35 +03:00
parent 9ae93497a6
commit ebec1bd16e
13 changed files with 417 additions and 100 deletions

View File

@@ -389,6 +389,7 @@ async def get_device(
),
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
capture_template_id=device.capture_template_id,
picture_stream_id=device.picture_stream_id,
created_at=device.created_at,
updated_at=device.updated_at,
)
@@ -918,25 +919,25 @@ async def delete_template(
template_id: str,
_auth: AuthRequired,
template_store: TemplateStore = Depends(get_template_store),
device_store: DeviceStore = Depends(get_device_store),
stream_store: PictureStreamStore = Depends(get_picture_stream_store),
):
"""Delete a template.
Validates that no devices are currently using this template before deletion.
Validates that no streams are currently using this template before deletion.
"""
try:
# Check if any devices are using this template
devices_using_template = []
for device in device_store.get_all_devices():
if device.capture_template_id == template_id:
devices_using_template.append(device.name)
# Check if any streams are using this template
streams_using_template = []
for stream in stream_store.get_all_streams():
if stream.capture_template_id == template_id:
streams_using_template.append(stream.name)
if devices_using_template:
device_list = ", ".join(devices_using_template)
if streams_using_template:
stream_list = ", ".join(streams_using_template)
raise HTTPException(
status_code=409,
detail=f"Cannot delete template: it is currently assigned to the following device(s): {device_list}. "
f"Please reassign these devices to a different template before deleting."
detail=f"Cannot delete template: it is used by the following stream(s): {stream_list}. "
f"Please reassign these streams to a different template before deleting."
)
# Proceed with deletion
@@ -1036,6 +1037,10 @@ async def test_template(
screen_capture = engine.capture_display(test_request.display_index)
capture_elapsed = time.perf_counter() - capture_start
# Skip if no new frame (screen unchanged)
if screen_capture is None:
continue
total_capture_time += capture_elapsed
frame_count += 1
last_frame = screen_capture
@@ -1354,6 +1359,9 @@ async def test_pp_template(
screen_capture = engine.capture_display(display_index)
capture_elapsed = time.perf_counter() - capture_start
if screen_capture is None:
continue
total_capture_time += capture_elapsed
frame_count += 1
last_frame = screen_capture
@@ -1739,6 +1747,9 @@ async def test_picture_stream(
screen_capture = engine.capture_display(display_index)
capture_elapsed = time.perf_counter() - capture_start
if screen_capture is None:
continue
total_capture_time += capture_elapsed
frame_count += 1
last_frame = screen_capture

View File

@@ -8,11 +8,13 @@ from wled_controller.core.capture_engines.base import (
from wled_controller.core.capture_engines.factory import EngineRegistry
from wled_controller.core.capture_engines.mss_engine import MSSEngine
from wled_controller.core.capture_engines.dxcam_engine import DXcamEngine
from wled_controller.core.capture_engines.bettercam_engine import BetterCamEngine
from wled_controller.core.capture_engines.wgc_engine import WGCEngine
# Auto-register available engines
EngineRegistry.register(MSSEngine)
EngineRegistry.register(DXcamEngine)
EngineRegistry.register(BetterCamEngine)
EngineRegistry.register(WGCEngine)
__all__ = [
@@ -22,5 +24,6 @@ __all__ = [
"EngineRegistry",
"MSSEngine",
"DXcamEngine",
"BetterCamEngine",
"WGCEngine",
]

View File

@@ -2,7 +2,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
import numpy as np
@@ -84,14 +84,15 @@ class CaptureEngine(ABC):
pass
@abstractmethod
def capture_display(self, display_index: int) -> ScreenCapture:
def capture_display(self, display_index: int) -> Optional[ScreenCapture]:
"""Capture the specified display.
Args:
display_index: Index of display to capture (0-based)
Returns:
ScreenCapture object with image data as numpy array (RGB format)
ScreenCapture object with image data as numpy array (RGB format),
or None if no new frame is available (screen unchanged).
Raises:
ValueError: If display_index is invalid

View File

@@ -0,0 +1,236 @@
"""BetterCam-based screen capture engine (Windows only, DXGI Desktop Duplication)."""
import sys
from typing import Any, Dict, List, Optional
import numpy as np
from wled_controller.core.capture_engines.base import (
CaptureEngine,
DisplayInfo,
ScreenCapture,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class BetterCamEngine(CaptureEngine):
"""BetterCam-based screen capture engine.
Uses the bettercam library (a high-performance fork of DXCam) which leverages
DXGI Desktop Duplication API for ultra-fast screen capture on Windows.
Offers better performance than DXCam with multi-GPU support.
Requires: Windows 8.1+
"""
ENGINE_TYPE = "bettercam"
ENGINE_PRIORITY = 4
def __init__(self, config: Dict[str, Any]):
"""Initialize BetterCam engine."""
super().__init__(config)
self._camera = None
self._bettercam = None
self._current_output = None
def initialize(self) -> None:
"""Initialize BetterCam capture.
Raises:
RuntimeError: If bettercam not installed or initialization fails
"""
try:
import bettercam
self._bettercam = bettercam
except ImportError:
raise RuntimeError(
"BetterCam not installed. Install with: pip install bettercam"
)
self._initialized = True
logger.info("BetterCam engine initialized")
def _ensure_camera(self, display_index: int) -> None:
"""Ensure camera is created for the requested display.
Creates or recreates the BetterCam camera if needed.
"""
if self._camera and self._current_output == display_index:
return
# Stop and release existing camera
if self._camera:
try:
if self._camera.is_capturing:
self._camera.stop()
except Exception:
pass
try:
self._camera.release()
except Exception:
pass
self._camera = None
# Clear global camera cache to avoid stale DXGI state
try:
self._bettercam.__factory.clean_up()
except Exception:
pass
self._camera = self._bettercam.create(
output_idx=display_index,
output_color="RGB",
)
if not self._camera:
raise RuntimeError(f"Failed to create BetterCam camera for display {display_index}")
self._current_output = display_index
logger.info(f"BetterCam camera created (output={display_index})")
def cleanup(self) -> None:
"""Cleanup BetterCam resources."""
if self._camera:
try:
if self._camera.is_capturing:
self._camera.stop()
except Exception:
pass
try:
self._camera.release()
except Exception as e:
logger.error(f"Error releasing BetterCam camera: {e}")
self._camera = None
# Clear global cache so next create() gets fresh DXGI state
if self._bettercam:
try:
self._bettercam.__factory.clean_up()
except Exception:
pass
self._current_output = None
self._initialized = False
logger.info("BetterCam engine cleaned up")
def get_available_displays(self) -> List[DisplayInfo]:
"""Get list of available displays using BetterCam.
Returns:
List of DisplayInfo objects
Raises:
RuntimeError: If not initialized or detection fails
"""
if not self._initialized:
raise RuntimeError("Engine not initialized")
try:
displays = []
output_idx = self._current_output or 0
if self._camera and hasattr(self._camera, "width") and hasattr(self._camera, "height"):
display_info = DisplayInfo(
index=output_idx,
name=f"BetterCam Display {output_idx}",
width=self._camera.width,
height=self._camera.height,
x=0,
y=0,
is_primary=(output_idx == 0),
refresh_rate=60,
)
displays.append(display_info)
else:
display_info = DisplayInfo(
index=output_idx,
name=f"BetterCam Display {output_idx}",
width=1920,
height=1080,
x=0,
y=0,
is_primary=(output_idx == 0),
refresh_rate=60,
)
displays.append(display_info)
logger.debug(f"BetterCam detected {len(displays)} display(s)")
return displays
except Exception as e:
logger.error(f"Failed to detect displays with BetterCam: {e}")
raise RuntimeError(f"Failed to detect displays: {e}")
def capture_display(self, display_index: int) -> Optional[ScreenCapture]:
"""Capture display using BetterCam.
Args:
display_index: Index of display to capture (0-based).
Returns:
ScreenCapture object with image data, or None if screen unchanged.
Raises:
RuntimeError: If capture fails
"""
# Auto-initialize if not already initialized
if not self._initialized:
self.initialize()
# Ensure camera is ready for the requested display
self._ensure_camera(display_index)
try:
# grab() uses AcquireNextFrame with timeout=0 (non-blocking).
# Returns None if screen content hasn't changed since last grab.
frame = self._camera.grab()
if frame is None:
return None
logger.debug(
f"BetterCam captured display {display_index}: "
f"{frame.shape[1]}x{frame.shape[0]}"
)
return ScreenCapture(
image=frame,
width=frame.shape[1],
height=frame.shape[0],
display_index=display_index,
)
except ValueError:
raise
except Exception as e:
logger.error(f"Failed to capture display {display_index} with BetterCam: {e}")
raise RuntimeError(f"Screen capture failed: {e}")
@classmethod
def is_available(cls) -> bool:
"""Check if BetterCam is available.
BetterCam requires Windows 8.1+ and the bettercam package.
Returns:
True if bettercam is available on this system
"""
if sys.platform != "win32":
return False
try:
import bettercam
return True
except ImportError:
return False
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
"""Get default BetterCam configuration.
Returns:
Default config dict with BetterCam options
"""
return {}

View File

@@ -1,8 +1,7 @@
"""DXcam-based screen capture engine (Windows only, DXGI Desktop Duplication)."""
import sys
import time
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
import numpy as np
@@ -63,8 +62,13 @@ class DXcamEngine(CaptureEngine):
if self._camera and self._current_output == display_index:
return
# Release existing camera
# Stop and release existing camera
if self._camera:
try:
if self._camera.is_capturing:
self._camera.stop()
except Exception:
pass
try:
self._camera.release()
except Exception:
@@ -91,6 +95,11 @@ class DXcamEngine(CaptureEngine):
def cleanup(self) -> None:
"""Cleanup DXcam resources."""
if self._camera:
try:
if self._camera.is_capturing:
self._camera.stop()
except Exception:
pass
try:
self._camera.release()
except Exception as e:
@@ -165,14 +174,14 @@ class DXcamEngine(CaptureEngine):
logger.error(f"Failed to detect displays with DXcam: {e}")
raise RuntimeError(f"Failed to detect displays: {e}")
def capture_display(self, display_index: int) -> ScreenCapture:
def capture_display(self, display_index: int) -> Optional[ScreenCapture]:
"""Capture display using DXcam.
Args:
display_index: Index of display to capture (0-based).
Returns:
ScreenCapture object with image data
ScreenCapture object with image data, or None if screen unchanged.
Raises:
RuntimeError: If capture fails
@@ -185,21 +194,12 @@ class DXcamEngine(CaptureEngine):
self._ensure_camera(display_index)
try:
# Grab frame from DXcam (one-shot mode, no start() needed).
# First grab after create() often returns None as DXGI Desktop
# Duplication needs a frame change to capture. Retry a few times.
frame = None
for attempt in range(5):
frame = self._camera.grab()
if frame is not None:
break
time.sleep(0.05)
# grab() uses AcquireNextFrame with timeout=0 (non-blocking).
# Returns None if screen content hasn't changed since last grab.
frame = self._camera.grab()
if frame is None:
raise RuntimeError(
"Failed to capture frame after retries. "
"The screen may not have changed or the display is unavailable."
)
return None
# DXcam returns numpy array directly in configured color format
logger.debug(

View File

@@ -555,6 +555,11 @@ class ProcessorManager:
display_index
)
# Skip processing if no new frame (screen unchanged)
if capture is None:
await asyncio.sleep(frame_time)
continue
# Apply postprocessing filters to the full captured image
if filter_objects:
capture.image = await asyncio.to_thread(_apply_filters, capture.image)

View File

@@ -2,6 +2,25 @@ const API_BASE = '/api/v1';
let refreshInterval = null;
let apiKey = null;
// Backdrop click helper: only closes modal if both mousedown and mouseup were on the backdrop itself.
// Prevents accidental close when user drags text selection outside the dialog.
function setupBackdropClose(modal, closeFn) {
// Guard against duplicate listeners when called on every modal open
if (modal._backdropCloseSetup) {
modal._backdropCloseFn = closeFn;
return;
}
modal._backdropCloseFn = closeFn;
let mouseDownTarget = null;
modal.addEventListener('mousedown', (e) => { mouseDownTarget = e.target; });
modal.addEventListener('mouseup', (e) => {
if (mouseDownTarget === modal && e.target === modal && modal._backdropCloseFn) modal._backdropCloseFn();
mouseDownTarget = null;
});
modal.onclick = null;
modal._backdropCloseSetup = true;
}
// Track logged errors to avoid console spam
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
@@ -90,9 +109,11 @@ document.addEventListener('keydown', (e) => {
// Display picker lightbox
let _displayPickerCallback = null;
let _displayPickerSelectedIndex = null;
function openDisplayPicker(callback) {
function openDisplayPicker(callback, selectedIndex) {
_displayPickerCallback = callback;
_displayPickerSelectedIndex = (selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null;
const lightbox = document.getElementById('display-picker-lightbox');
const canvas = document.getElementById('display-picker-canvas');
@@ -157,8 +178,9 @@ function renderDisplayPickerLayout(displays) {
const widthPct = (display.width / totalWidth) * 100;
const heightPct = (display.height / totalHeight) * 100;
const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex;
return `
<div class="layout-display layout-display-pickable ${display.is_primary ? 'primary' : 'secondary'}"
<div class="layout-display layout-display-pickable${isSelected ? ' selected' : ''}"
style="left: ${leftPct}%; top: ${topPct}%; width: ${widthPct}%; height: ${heightPct}%;"
onclick="selectDisplay(${display.index})"
title="${t('displays.picker.click_to_select')}">
@@ -169,7 +191,6 @@ function renderDisplayPickerLayout(displays) {
<small>${display.width}×${display.height}</small>
<small>${display.refresh_rate}Hz</small>
</div>
${display.is_primary ? '<div class="primary-indicator">★</div>' : ''}
</div>
`;
}).join('');
@@ -2450,13 +2471,7 @@ async function showAddTemplateModal() {
// Show modal
const modal = document.getElementById('template-modal');
modal.style.display = 'flex';
// Add backdrop click handler to close modal
modal.onclick = function(event) {
if (event.target === modal) {
closeTemplateModal();
}
};
setupBackdropClose(modal, closeTemplateModal);
}
// Edit template
@@ -2473,6 +2488,7 @@ async function editTemplate(templateId) {
document.getElementById('template-modal-title').textContent = t('templates.edit');
document.getElementById('template-id').value = templateId;
document.getElementById('template-name').value = template.name;
document.getElementById('template-description').value = template.description || '';
// Load available engines
await loadAvailableEngines();
@@ -2494,13 +2510,7 @@ async function editTemplate(templateId) {
// Show modal
const modal = document.getElementById('template-modal');
modal.style.display = 'flex';
// Add backdrop click handler to close modal
modal.onclick = function(event) {
if (event.target === modal) {
closeTemplateModal();
}
};
setupBackdropClose(modal, closeTemplateModal);
} catch (error) {
console.error('Error loading template:', error);
showToast(t('templates.error.load') + ': ' + error.message, 'error');
@@ -2662,12 +2672,7 @@ async function showTestTemplateModal(templateId) {
const modal = document.getElementById('test-template-modal');
modal.style.display = 'flex';
// Add backdrop click handler to close modal
modal.onclick = function(event) {
if (event.target === modal) {
closeTestTemplateModal();
}
};
setupBackdropClose(modal, closeTestTemplateModal);
}
// Close test template modal
@@ -2738,7 +2743,8 @@ async function onEngineChange() {
const defaultConfig = engine.default_config || {};
if (Object.keys(defaultConfig).length === 0) {
configFields.innerHTML = `<p class="text-muted">${t('templates.config.none')}</p>`;
configSection.style.display = 'none';
return;
} else {
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
@@ -2917,12 +2923,14 @@ async function saveTemplate() {
return;
}
const description = document.getElementById('template-description').value.trim();
const engineConfig = collectEngineConfig();
const payload = {
name,
engine_type: engineType,
engine_config: engineConfig
engine_config: engineConfig,
description: description || null
};
try {
@@ -3105,6 +3113,7 @@ function renderPictureStreamsList(streams) {
${engineIcon} ${escapeHtml(template.name)}
</div>
</div>
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('templates.engine')}">⚙️ ${template.engine_type.toUpperCase()}</span>
${configEntries.length > 0 ? `<span class="stream-card-prop" title="${t('templates.config.show')}">🔧 ${configEntries.length}</span>` : ''}
@@ -3271,7 +3280,7 @@ async function showAddStreamModal(presetType) {
const modal = document.getElementById('stream-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); };
setupBackdropClose(modal, closeStreamModal);
}
async function editStream(streamId) {
@@ -3324,7 +3333,7 @@ async function editStream(streamId) {
const modal = document.getElementById('stream-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); };
setupBackdropClose(modal, closeStreamModal);
} catch (error) {
console.error('Error loading stream:', error);
showToast(t('streams.error.load') + ': ' + error.message, 'error');
@@ -3550,7 +3559,7 @@ async function showTestStreamModal(streamId) {
const modal = document.getElementById('test-stream-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closeTestStreamModal(); };
setupBackdropClose(modal, closeTestStreamModal);
}
function closeTestStreamModal() {
@@ -3636,7 +3645,7 @@ async function showTestPPTemplateModal(templateId) {
const modal = document.getElementById('test-pp-template-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closeTestPPTemplateModal(); };
setupBackdropClose(modal, closeTestPPTemplateModal);
}
function closeTestPPTemplateModal() {
@@ -3888,7 +3897,7 @@ async function showAddPPTemplateModal() {
const modal = document.getElementById('pp-template-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); };
setupBackdropClose(modal, closePPTemplateModal);
}
async function editPPTemplate(templateId) {
@@ -3917,7 +3926,7 @@ async function editPPTemplate(templateId) {
const modal = document.getElementById('pp-template-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); };
setupBackdropClose(modal, closePPTemplateModal);
} catch (error) {
console.error('Error loading PP template:', error);
showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
@@ -4066,7 +4075,7 @@ async function showStreamSelector(deviceId) {
const modal = document.getElementById('stream-selector-modal');
modal.style.display = 'flex';
lockBody();
modal.onclick = (e) => { if (e.target === modal) closeStreamSelectorModal(); };
setupBackdropClose(modal, closeStreamSelectorModal);
} catch (error) {
console.error('Failed to load stream settings:', error);
showToast('Failed to load stream settings', 'error');
@@ -4088,17 +4097,67 @@ async function updateStreamSelectorInfo(streamId) {
}
const stream = await response.json();
let infoHtml = `<div class="stream-info-type"><strong>${t('streams.type')}</strong> ${stream.stream_type === 'raw' ? t('streams.type.raw') : t('streams.type.processed')}</div>`;
const typeIcon = stream.stream_type === 'raw' ? '🖥️' : stream.stream_type === 'static_image' ? '🖼️' : '🎨';
const typeName = stream.stream_type === 'raw' ? t('streams.type.raw') : stream.stream_type === 'static_image' ? t('streams.type.static_image') : t('streams.type.processed');
let propsHtml = '';
if (stream.stream_type === 'raw') {
infoHtml += `<div><strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}</div>`;
infoHtml += `<div><strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}</div>`;
} else {
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
infoHtml += `<div><strong>${t('streams.source')}</strong> ${sourceStream ? escapeHtml(sourceStream.name) : stream.source_stream_id}</div>`;
let capTmplName = '';
if (stream.capture_template_id) {
if (!_cachedCaptureTemplates || _cachedCaptureTemplates.length === 0) {
try {
const ctResp = await fetchWithAuth('/capture-templates');
if (ctResp.ok) { const d = await ctResp.json(); _cachedCaptureTemplates = d.templates || []; }
} catch {}
}
if (_cachedCaptureTemplates) {
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
}
}
propsHtml = `
<span class="stream-card-prop" title="${t('streams.display')}">🖥️ ${stream.display_index ?? 0}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">⚡ ${stream.target_fps ?? 30}</span>
${capTmplName ? `<span class="stream-card-prop" title="${t('streams.capture_template')}">${capTmplName}</span>` : ''}
`;
} else if (stream.stream_type === 'processed') {
if ((!_cachedStreams || _cachedStreams.length === 0) && stream.source_stream_id) {
try {
const streamsResp = await fetchWithAuth('/picture-streams');
if (streamsResp.ok) { const d = await streamsResp.json(); _cachedStreams = d.streams || []; }
} catch {}
}
const sourceStream = _cachedStreams ? _cachedStreams.find(s => s.id === stream.source_stream_id) : null;
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
let ppTmplName = '';
if (stream.postprocessing_template_id) {
if (!_cachedPPTemplates || _cachedPPTemplates.length === 0) {
try {
const ppResp = await fetchWithAuth('/postprocessing-templates');
if (ppResp.ok) { const d = await ppResp.json(); _cachedPPTemplates = d.templates || []; }
} catch {}
}
if (_cachedPPTemplates) {
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
}
}
propsHtml = `
<span class="stream-card-prop" title="${t('streams.source')}">📺 ${sourceName}</span>
${ppTmplName ? `<span class="stream-card-prop" title="${t('streams.pp_template')}">🎨 ${ppTmplName}</span>` : ''}
`;
} else if (stream.stream_type === 'static_image') {
const src = stream.image_source || '';
propsHtml = `<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">🌐 ${escapeHtml(src)}</span>`;
}
infoPanel.innerHTML = infoHtml;
infoPanel.innerHTML = `
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('streams.type')}">${typeIcon} ${typeName}</span>
${propsHtml}
</div>
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}
`;
infoPanel.style.display = '';
} catch {
infoPanel.style.display = 'none';

View File

@@ -238,11 +238,10 @@
<div class="form-group">
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Stream:</label>
<select id="stream-selector-stream"></select>
<div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
<small class="input-hint" data-i18n="device.stream_selector.hint">Select a stream that defines what this device captures and processes</small>
</div>
<div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
<div class="form-group">
<label for="stream-selector-border-width" data-i18n="device.stream_settings.border_width">Border Width (px):</label>
<input type="number" id="stream-selector-border-width" min="1" max="100" value="10">
@@ -375,6 +374,11 @@
<input type="text" id="template-name" data-i18n-placeholder="templates.name.placeholder" placeholder="My Custom Template" required>
</div>
<div class="form-group">
<label for="template-description" data-i18n="templates.description.label">Description (optional):</label>
<input type="text" id="template-description" data-i18n-placeholder="templates.description.placeholder" placeholder="Describe this template..." maxlength="500">
</div>
<div class="form-group">
<label for="template-engine" data-i18n="templates.engine">Capture Engine:</label>
<select id="template-engine" onchange="onEngineChange()" required>
@@ -384,7 +388,7 @@
</div>
<div id="engine-config-section" style="display: none;">
<h3 data-i18n="templates.config">Engine Configuration</h3>
<h3 data-i18n="templates.config">Configuration</h3>
<div id="engine-config-fields"></div>
</div>
@@ -409,7 +413,7 @@
<div class="form-group">
<label data-i18n="templates.test.display">Display:</label>
<input type="hidden" id="test-template-display" value="">
<button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openDisplayPicker(onTestDisplaySelected)">
<button type="button" class="btn btn-display-picker" id="test-display-picker-btn" onclick="openDisplayPicker(onTestDisplaySelected, document.getElementById('test-template-display').value)">
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button>
</div>
@@ -423,7 +427,7 @@
</div>
<button type="button" class="btn btn-primary" onclick="runTemplateTest()" style="margin-top: 16px;">
<span data-i18n="templates.test.run">🧪 Run Test</span>
<span data-i18n="templates.test.run">🧪 Run</span>
</button>
</div>
@@ -447,7 +451,7 @@
</div>
<button type="button" class="btn btn-primary" onclick="runStreamTest()" style="margin-top: 16px;">
<span data-i18n="streams.test.run">🧪 Run Test</span>
<span data-i18n="streams.test.run">🧪 Run</span>
</button>
</div>
@@ -475,7 +479,7 @@
</div>
<button type="button" class="btn btn-primary" onclick="runPPTemplateTest()" style="margin-top: 16px;">
<span data-i18n="streams.test.run">🧪 Run Test</span>
<span data-i18n="streams.test.run">🧪 Run</span>
</button>
</div>
@@ -504,7 +508,7 @@
<div class="form-group">
<label data-i18n="streams.display">Display:</label>
<input type="hidden" id="stream-display-index" value="">
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected)">
<button type="button" class="btn btn-display-picker" id="stream-display-picker-btn" onclick="openDisplayPicker(onStreamDisplaySelected, document.getElementById('stream-display-index').value)">
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
</button>
</div>

View File

@@ -49,7 +49,7 @@
"templates.engine.select": "Select an engine...",
"templates.engine.unavailable": "Unavailable",
"templates.engine.unavailable.hint": "This engine is not available on your system",
"templates.config": "Engine Configuration",
"templates.config": "Configuration",
"templates.config.show": "Show configuration",
"templates.config.none": "No additional configuration",
"templates.config.default": "Default",
@@ -67,7 +67,7 @@
"templates.test.display.select": "Select display...",
"templates.test.duration": "Capture Duration (s):",
"templates.test.border_width": "Border Width (px):",
"templates.test.run": "\uD83E\uDDEA Run Test",
"templates.test.run": "\uD83E\uDDEA Run",
"templates.test.running": "Running test...",
"templates.test.results.preview": "Full Capture Preview",
"templates.test.results.borders": "Border Extraction",
@@ -229,7 +229,7 @@
"streams.error.required": "Please fill in all required fields",
"streams.error.delete": "Failed to delete stream",
"streams.test.title": "Test Stream",
"streams.test.run": "🧪 Run Test",
"streams.test.run": "🧪 Run",
"streams.test.running": "Testing stream...",
"streams.test.duration": "Capture Duration (s):",
"streams.test.error.failed": "Stream test failed",

View File

@@ -49,7 +49,7 @@
"templates.engine.select": "Выберите движок...",
"templates.engine.unavailable": "Недоступен",
"templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе",
"templates.config": "Конфигурация Движка",
"templates.config": "Конфигурация",
"templates.config.show": "Показать конфигурацию",
"templates.config.none": "Нет дополнительных настроек",
"templates.config.default": "По умолчанию",
@@ -67,7 +67,7 @@
"templates.test.display.select": "Выберите дисплей...",
"templates.test.duration": "Длительность Захвата (с):",
"templates.test.border_width": "Ширина Границы (px):",
"templates.test.run": "\uD83E\uDDEA Запустить Тест",
"templates.test.run": "\uD83E\uDDEA Запустить",
"templates.test.running": "Выполняется тест...",
"templates.test.results.preview": "Полный Предпросмотр Захвата",
"templates.test.results.borders": "Извлечение Границ",
@@ -229,7 +229,7 @@
"streams.error.required": "Пожалуйста, заполните все обязательные поля",
"streams.error.delete": "Не удалось удалить поток",
"streams.test.title": "Тест Потока",
"streams.test.run": "🧪 Запустить Тест",
"streams.test.run": "🧪 Запустить",
"streams.test.running": "Тестирование потока...",
"streams.test.duration": "Длительность Захвата (с):",
"streams.test.error.failed": "Тест потока не удался",

View File

@@ -37,6 +37,7 @@ body {
html {
background: var(--bg-color);
overflow-y: scroll;
}
body {
@@ -2159,23 +2160,11 @@ input:-webkit-autofill:focus {
/* Stream info panel in stream selector modal */
.stream-info-panel {
background: var(--bg-secondary, #2a2a2a);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px 16px;
margin-top: 12px;
padding: 4px 0 0 0;
font-size: 14px;
line-height: 1.6;
}
.stream-info-panel div {
margin-bottom: 4px;
}
.stream-info-panel strong {
margin-right: 6px;
}
/* Stream sub-tabs */
.stream-tab-bar {
display: flex;
@@ -2359,6 +2348,8 @@ input:-webkit-autofill:focus {
.layout-display-pickable {
cursor: pointer !important;
border: 2px solid var(--border-color) !important;
background: linear-gradient(135deg, rgba(128, 128, 128, 0.08), rgba(128, 128, 128, 0.03)) !important;
}
.layout-display-pickable:hover {
@@ -2366,6 +2357,12 @@ input:-webkit-autofill:focus {
border-color: var(--primary-color) !important;
}
.layout-display-pickable.selected {
border-color: var(--primary-color) !important;
box-shadow: 0 0 16px rgba(76, 175, 80, 0.5);
background: rgba(76, 175, 80, 0.12) !important;
}
/* Display picker button in forms */
.btn-display-picker {
width: 100%;

View File

@@ -49,12 +49,12 @@ class TemplateStore:
template = CaptureTemplate(
id=template_id,
name=best_engine.upper(),
name="Default",
engine_type=best_engine,
engine_config=default_config,
created_at=now,
updated_at=now,
description=f"Auto-created {best_engine.upper()} template",
description=f"Default capture template using {best_engine.upper()} engine",
)
self._templates[template_id] = template