diff --git a/server/pyproject.toml b/server/pyproject.toml index fa8b258..8438bb1 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -51,6 +51,7 @@ dev = [ # High-performance screen capture engines (Windows only) perf = [ "dxcam>=0.0.5; sys_platform == 'win32'", + "bettercam>=1.0.0; sys_platform == 'win32'", "windows-capture>=1.5.0; sys_platform == 'win32'", ] diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py index eaa706e..467c275 100644 --- a/server/src/wled_controller/api/routes.py +++ b/server/src/wled_controller/api/routes.py @@ -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 diff --git a/server/src/wled_controller/core/capture_engines/__init__.py b/server/src/wled_controller/core/capture_engines/__init__.py index aa6b892..fb84edf 100644 --- a/server/src/wled_controller/core/capture_engines/__init__.py +++ b/server/src/wled_controller/core/capture_engines/__init__.py @@ -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", ] diff --git a/server/src/wled_controller/core/capture_engines/base.py b/server/src/wled_controller/core/capture_engines/base.py index c4f3ce8..5cdae99 100644 --- a/server/src/wled_controller/core/capture_engines/base.py +++ b/server/src/wled_controller/core/capture_engines/base.py @@ -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 diff --git a/server/src/wled_controller/core/capture_engines/bettercam_engine.py b/server/src/wled_controller/core/capture_engines/bettercam_engine.py new file mode 100644 index 0000000..5a4f11e --- /dev/null +++ b/server/src/wled_controller/core/capture_engines/bettercam_engine.py @@ -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 {} diff --git a/server/src/wled_controller/core/capture_engines/dxcam_engine.py b/server/src/wled_controller/core/capture_engines/dxcam_engine.py index a40bd50..514949d 100644 --- a/server/src/wled_controller/core/capture_engines/dxcam_engine.py +++ b/server/src/wled_controller/core/capture_engines/dxcam_engine.py @@ -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( diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index 4a32448..28c8345 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -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) diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index afb94e1..72de7a2 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -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 ` -
@@ -169,7 +191,6 @@ function renderDisplayPickerLayout(displays) { ${display.width}×${display.height} ${display.refresh_rate}Hz
- ${display.is_primary ? '
' : ''} `; }).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 = `

${t('templates.config.none')}

`; + 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)} + ${template.description ? `
${escapeHtml(template.description)}
` : ''}
⚙️ ${template.engine_type.toUpperCase()} ${configEntries.length > 0 ? `🔧 ${configEntries.length}` : ''} @@ -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 = `
${t('streams.type')} ${stream.stream_type === 'raw' ? t('streams.type.raw') : t('streams.type.processed')}
`; + 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 += `
${t('streams.display')} ${stream.display_index ?? 0}
`; - infoHtml += `
${t('streams.target_fps')} ${stream.target_fps ?? 30}
`; - } else { - const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id); - infoHtml += `
${t('streams.source')} ${sourceStream ? escapeHtml(sourceStream.name) : stream.source_stream_id}
`; + 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 = ` + 🖥️ ${stream.display_index ?? 0} + ⚡ ${stream.target_fps ?? 30} + ${capTmplName ? `${capTmplName}` : ''} + `; + } 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 = ` + 📺 ${sourceName} + ${ppTmplName ? `🎨 ${ppTmplName}` : ''} + `; + } else if (stream.stream_type === 'static_image') { + const src = stream.image_source || ''; + propsHtml = `🌐 ${escapeHtml(src)}`; } - infoPanel.innerHTML = infoHtml; + infoPanel.innerHTML = ` +
+ ${typeIcon} ${typeName} + ${propsHtml} +
+ ${stream.description ? `
${escapeHtml(stream.description)}
` : ''} + `; infoPanel.style.display = ''; } catch { infoPanel.style.display = 'none'; diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 0b5f434..eb85ed8 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -238,11 +238,10 @@
+ Select a stream that defines what this device captures and processes
- -
@@ -375,6 +374,11 @@
+
+ + +
+
-
@@ -423,7 +427,7 @@
@@ -447,7 +451,7 @@ @@ -475,7 +479,7 @@ @@ -504,7 +508,7 @@
-
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index dd66531..faaf2ab 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index e1d23e4..189b035 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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": "Тест потока не удался", diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index f2108b3..2d377c6 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -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%; diff --git a/server/src/wled_controller/storage/template_store.py b/server/src/wled_controller/storage/template_store.py index e7a52f5..054ea0f 100644 --- a/server/src/wled_controller/storage/template_store.py +++ b/server/src/wled_controller/storage/template_store.py @@ -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