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:
@@ -51,6 +51,7 @@ dev = [
|
|||||||
# 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'",
|
||||||
|
"bettercam>=1.0.0; sys_platform == 'win32'",
|
||||||
"windows-capture>=1.5.0; sys_platform == 'win32'",
|
"windows-capture>=1.5.0; sys_platform == 'win32'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ async def get_device(
|
|||||||
),
|
),
|
||||||
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
calibration=CalibrationSchema(**calibration_to_dict(device.calibration)),
|
||||||
capture_template_id=device.capture_template_id,
|
capture_template_id=device.capture_template_id,
|
||||||
|
picture_stream_id=device.picture_stream_id,
|
||||||
created_at=device.created_at,
|
created_at=device.created_at,
|
||||||
updated_at=device.updated_at,
|
updated_at=device.updated_at,
|
||||||
)
|
)
|
||||||
@@ -918,25 +919,25 @@ async def delete_template(
|
|||||||
template_id: str,
|
template_id: str,
|
||||||
_auth: AuthRequired,
|
_auth: AuthRequired,
|
||||||
template_store: TemplateStore = Depends(get_template_store),
|
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.
|
"""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:
|
try:
|
||||||
# Check if any devices are using this template
|
# Check if any streams are using this template
|
||||||
devices_using_template = []
|
streams_using_template = []
|
||||||
for device in device_store.get_all_devices():
|
for stream in stream_store.get_all_streams():
|
||||||
if device.capture_template_id == template_id:
|
if stream.capture_template_id == template_id:
|
||||||
devices_using_template.append(device.name)
|
streams_using_template.append(stream.name)
|
||||||
|
|
||||||
if devices_using_template:
|
if streams_using_template:
|
||||||
device_list = ", ".join(devices_using_template)
|
stream_list = ", ".join(streams_using_template)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail=f"Cannot delete template: it is currently assigned to the following device(s): {device_list}. "
|
detail=f"Cannot delete template: it is used by the following stream(s): {stream_list}. "
|
||||||
f"Please reassign these devices to a different template before deleting."
|
f"Please reassign these streams to a different template before deleting."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Proceed with deletion
|
# Proceed with deletion
|
||||||
@@ -1036,6 +1037,10 @@ async def test_template(
|
|||||||
screen_capture = engine.capture_display(test_request.display_index)
|
screen_capture = engine.capture_display(test_request.display_index)
|
||||||
capture_elapsed = time.perf_counter() - capture_start
|
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
|
total_capture_time += capture_elapsed
|
||||||
frame_count += 1
|
frame_count += 1
|
||||||
last_frame = screen_capture
|
last_frame = screen_capture
|
||||||
@@ -1354,6 +1359,9 @@ async def test_pp_template(
|
|||||||
screen_capture = engine.capture_display(display_index)
|
screen_capture = engine.capture_display(display_index)
|
||||||
capture_elapsed = time.perf_counter() - capture_start
|
capture_elapsed = time.perf_counter() - capture_start
|
||||||
|
|
||||||
|
if screen_capture is None:
|
||||||
|
continue
|
||||||
|
|
||||||
total_capture_time += capture_elapsed
|
total_capture_time += capture_elapsed
|
||||||
frame_count += 1
|
frame_count += 1
|
||||||
last_frame = screen_capture
|
last_frame = screen_capture
|
||||||
@@ -1739,6 +1747,9 @@ async def test_picture_stream(
|
|||||||
screen_capture = engine.capture_display(display_index)
|
screen_capture = engine.capture_display(display_index)
|
||||||
capture_elapsed = time.perf_counter() - capture_start
|
capture_elapsed = time.perf_counter() - capture_start
|
||||||
|
|
||||||
|
if screen_capture is None:
|
||||||
|
continue
|
||||||
|
|
||||||
total_capture_time += capture_elapsed
|
total_capture_time += capture_elapsed
|
||||||
frame_count += 1
|
frame_count += 1
|
||||||
last_frame = screen_capture
|
last_frame = screen_capture
|
||||||
|
|||||||
@@ -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.factory import EngineRegistry
|
||||||
from wled_controller.core.capture_engines.mss_engine import MSSEngine
|
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.dxcam_engine import DXcamEngine
|
||||||
|
from wled_controller.core.capture_engines.bettercam_engine import BetterCamEngine
|
||||||
from wled_controller.core.capture_engines.wgc_engine import WGCEngine
|
from wled_controller.core.capture_engines.wgc_engine import WGCEngine
|
||||||
|
|
||||||
# Auto-register available engines
|
# Auto-register available engines
|
||||||
EngineRegistry.register(MSSEngine)
|
EngineRegistry.register(MSSEngine)
|
||||||
EngineRegistry.register(DXcamEngine)
|
EngineRegistry.register(DXcamEngine)
|
||||||
|
EngineRegistry.register(BetterCamEngine)
|
||||||
EngineRegistry.register(WGCEngine)
|
EngineRegistry.register(WGCEngine)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -22,5 +24,6 @@ __all__ = [
|
|||||||
"EngineRegistry",
|
"EngineRegistry",
|
||||||
"MSSEngine",
|
"MSSEngine",
|
||||||
"DXcamEngine",
|
"DXcamEngine",
|
||||||
|
"BetterCamEngine",
|
||||||
"WGCEngine",
|
"WGCEngine",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -84,14 +84,15 @@ class CaptureEngine(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def capture_display(self, display_index: int) -> ScreenCapture:
|
def capture_display(self, display_index: int) -> Optional[ScreenCapture]:
|
||||||
"""Capture the specified display.
|
"""Capture the specified display.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
display_index: Index of display to capture (0-based)
|
display_index: Index of display to capture (0-based)
|
||||||
|
|
||||||
Returns:
|
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:
|
Raises:
|
||||||
ValueError: If display_index is invalid
|
ValueError: If display_index is invalid
|
||||||
|
|||||||
@@ -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 {}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"""DXcam-based screen capture engine (Windows only, DXGI Desktop Duplication)."""
|
"""DXcam-based screen capture engine (Windows only, DXGI Desktop Duplication)."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
from typing import Any, Dict, List, Optional
|
||||||
from typing import Any, Dict, List
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -63,8 +62,13 @@ class DXcamEngine(CaptureEngine):
|
|||||||
if self._camera and self._current_output == display_index:
|
if self._camera and self._current_output == display_index:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Release existing camera
|
# Stop and release existing camera
|
||||||
if self._camera:
|
if self._camera:
|
||||||
|
try:
|
||||||
|
if self._camera.is_capturing:
|
||||||
|
self._camera.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
self._camera.release()
|
self._camera.release()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -91,6 +95,11 @@ class DXcamEngine(CaptureEngine):
|
|||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
"""Cleanup DXcam resources."""
|
"""Cleanup DXcam resources."""
|
||||||
if self._camera:
|
if self._camera:
|
||||||
|
try:
|
||||||
|
if self._camera.is_capturing:
|
||||||
|
self._camera.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
self._camera.release()
|
self._camera.release()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -165,14 +174,14 @@ class DXcamEngine(CaptureEngine):
|
|||||||
logger.error(f"Failed to detect displays with DXcam: {e}")
|
logger.error(f"Failed to detect displays with DXcam: {e}")
|
||||||
raise RuntimeError(f"Failed to detect displays: {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.
|
"""Capture display using DXcam.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
display_index: Index of display to capture (0-based).
|
display_index: Index of display to capture (0-based).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ScreenCapture object with image data
|
ScreenCapture object with image data, or None if screen unchanged.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RuntimeError: If capture fails
|
RuntimeError: If capture fails
|
||||||
@@ -185,21 +194,12 @@ class DXcamEngine(CaptureEngine):
|
|||||||
self._ensure_camera(display_index)
|
self._ensure_camera(display_index)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Grab frame from DXcam (one-shot mode, no start() needed).
|
# grab() uses AcquireNextFrame with timeout=0 (non-blocking).
|
||||||
# First grab after create() often returns None as DXGI Desktop
|
# Returns None if screen content hasn't changed since last grab.
|
||||||
# Duplication needs a frame change to capture. Retry a few times.
|
frame = self._camera.grab()
|
||||||
frame = None
|
|
||||||
for attempt in range(5):
|
|
||||||
frame = self._camera.grab()
|
|
||||||
if frame is not None:
|
|
||||||
break
|
|
||||||
time.sleep(0.05)
|
|
||||||
|
|
||||||
if frame is None:
|
if frame is None:
|
||||||
raise RuntimeError(
|
return None
|
||||||
"Failed to capture frame after retries. "
|
|
||||||
"The screen may not have changed or the display is unavailable."
|
|
||||||
)
|
|
||||||
|
|
||||||
# DXcam returns numpy array directly in configured color format
|
# DXcam returns numpy array directly in configured color format
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -555,6 +555,11 @@ class ProcessorManager:
|
|||||||
display_index
|
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
|
# Apply postprocessing filters to the full captured image
|
||||||
if filter_objects:
|
if filter_objects:
|
||||||
capture.image = await asyncio.to_thread(_apply_filters, capture.image)
|
capture.image = await asyncio.to_thread(_apply_filters, capture.image)
|
||||||
|
|||||||
@@ -2,6 +2,25 @@ const API_BASE = '/api/v1';
|
|||||||
let refreshInterval = null;
|
let refreshInterval = null;
|
||||||
let apiKey = 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
|
// Track logged errors to avoid console spam
|
||||||
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
|
const loggedErrors = new Map(); // deviceId -> { errorCount, lastError }
|
||||||
|
|
||||||
@@ -90,9 +109,11 @@ document.addEventListener('keydown', (e) => {
|
|||||||
|
|
||||||
// Display picker lightbox
|
// Display picker lightbox
|
||||||
let _displayPickerCallback = null;
|
let _displayPickerCallback = null;
|
||||||
|
let _displayPickerSelectedIndex = null;
|
||||||
|
|
||||||
function openDisplayPicker(callback) {
|
function openDisplayPicker(callback, selectedIndex) {
|
||||||
_displayPickerCallback = callback;
|
_displayPickerCallback = callback;
|
||||||
|
_displayPickerSelectedIndex = (selectedIndex !== undefined && selectedIndex !== null && selectedIndex !== '') ? Number(selectedIndex) : null;
|
||||||
const lightbox = document.getElementById('display-picker-lightbox');
|
const lightbox = document.getElementById('display-picker-lightbox');
|
||||||
const canvas = document.getElementById('display-picker-canvas');
|
const canvas = document.getElementById('display-picker-canvas');
|
||||||
|
|
||||||
@@ -157,8 +178,9 @@ function renderDisplayPickerLayout(displays) {
|
|||||||
const widthPct = (display.width / totalWidth) * 100;
|
const widthPct = (display.width / totalWidth) * 100;
|
||||||
const heightPct = (display.height / totalHeight) * 100;
|
const heightPct = (display.height / totalHeight) * 100;
|
||||||
|
|
||||||
|
const isSelected = _displayPickerSelectedIndex !== null && display.index === _displayPickerSelectedIndex;
|
||||||
return `
|
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}%;"
|
style="left: ${leftPct}%; top: ${topPct}%; width: ${widthPct}%; height: ${heightPct}%;"
|
||||||
onclick="selectDisplay(${display.index})"
|
onclick="selectDisplay(${display.index})"
|
||||||
title="${t('displays.picker.click_to_select')}">
|
title="${t('displays.picker.click_to_select')}">
|
||||||
@@ -169,7 +191,6 @@ function renderDisplayPickerLayout(displays) {
|
|||||||
<small>${display.width}×${display.height}</small>
|
<small>${display.width}×${display.height}</small>
|
||||||
<small>${display.refresh_rate}Hz</small>
|
<small>${display.refresh_rate}Hz</small>
|
||||||
</div>
|
</div>
|
||||||
${display.is_primary ? '<div class="primary-indicator">★</div>' : ''}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -2450,13 +2471,7 @@ async function showAddTemplateModal() {
|
|||||||
// Show modal
|
// Show modal
|
||||||
const modal = document.getElementById('template-modal');
|
const modal = document.getElementById('template-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
|
setupBackdropClose(modal, closeTemplateModal);
|
||||||
// Add backdrop click handler to close modal
|
|
||||||
modal.onclick = function(event) {
|
|
||||||
if (event.target === modal) {
|
|
||||||
closeTemplateModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit template
|
// Edit template
|
||||||
@@ -2473,6 +2488,7 @@ async function editTemplate(templateId) {
|
|||||||
document.getElementById('template-modal-title').textContent = t('templates.edit');
|
document.getElementById('template-modal-title').textContent = t('templates.edit');
|
||||||
document.getElementById('template-id').value = templateId;
|
document.getElementById('template-id').value = templateId;
|
||||||
document.getElementById('template-name').value = template.name;
|
document.getElementById('template-name').value = template.name;
|
||||||
|
document.getElementById('template-description').value = template.description || '';
|
||||||
|
|
||||||
// Load available engines
|
// Load available engines
|
||||||
await loadAvailableEngines();
|
await loadAvailableEngines();
|
||||||
@@ -2494,13 +2510,7 @@ async function editTemplate(templateId) {
|
|||||||
// Show modal
|
// Show modal
|
||||||
const modal = document.getElementById('template-modal');
|
const modal = document.getElementById('template-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
|
setupBackdropClose(modal, closeTemplateModal);
|
||||||
// Add backdrop click handler to close modal
|
|
||||||
modal.onclick = function(event) {
|
|
||||||
if (event.target === modal) {
|
|
||||||
closeTemplateModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading template:', error);
|
console.error('Error loading template:', error);
|
||||||
showToast(t('templates.error.load') + ': ' + error.message, 'error');
|
showToast(t('templates.error.load') + ': ' + error.message, 'error');
|
||||||
@@ -2662,12 +2672,7 @@ async function showTestTemplateModal(templateId) {
|
|||||||
const modal = document.getElementById('test-template-modal');
|
const modal = document.getElementById('test-template-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
// Add backdrop click handler to close modal
|
setupBackdropClose(modal, closeTestTemplateModal);
|
||||||
modal.onclick = function(event) {
|
|
||||||
if (event.target === modal) {
|
|
||||||
closeTestTemplateModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close test template modal
|
// Close test template modal
|
||||||
@@ -2738,7 +2743,8 @@ async function onEngineChange() {
|
|||||||
const defaultConfig = engine.default_config || {};
|
const defaultConfig = engine.default_config || {};
|
||||||
|
|
||||||
if (Object.keys(defaultConfig).length === 0) {
|
if (Object.keys(defaultConfig).length === 0) {
|
||||||
configFields.innerHTML = `<p class="text-muted">${t('templates.config.none')}</p>`;
|
configSection.style.display = 'none';
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
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';
|
||||||
@@ -2917,12 +2923,14 @@ async function saveTemplate() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const description = document.getElementById('template-description').value.trim();
|
||||||
const engineConfig = collectEngineConfig();
|
const engineConfig = collectEngineConfig();
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name,
|
name,
|
||||||
engine_type: engineType,
|
engine_type: engineType,
|
||||||
engine_config: engineConfig
|
engine_config: engineConfig,
|
||||||
|
description: description || null
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3105,6 +3113,7 @@ function renderPictureStreamsList(streams) {
|
|||||||
${engineIcon} ${escapeHtml(template.name)}
|
${engineIcon} ${escapeHtml(template.name)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${template.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(template.description)}</div>` : ''}
|
||||||
<div class="stream-card-props">
|
<div class="stream-card-props">
|
||||||
<span class="stream-card-prop" title="${t('templates.engine')}">⚙️ ${template.engine_type.toUpperCase()}</span>
|
<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>` : ''}
|
${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');
|
const modal = document.getElementById('stream-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
lockBody();
|
lockBody();
|
||||||
modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); };
|
setupBackdropClose(modal, closeStreamModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editStream(streamId) {
|
async function editStream(streamId) {
|
||||||
@@ -3324,7 +3333,7 @@ async function editStream(streamId) {
|
|||||||
const modal = document.getElementById('stream-modal');
|
const modal = document.getElementById('stream-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
lockBody();
|
lockBody();
|
||||||
modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); };
|
setupBackdropClose(modal, closeStreamModal);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading stream:', error);
|
console.error('Error loading stream:', error);
|
||||||
showToast(t('streams.error.load') + ': ' + error.message, 'error');
|
showToast(t('streams.error.load') + ': ' + error.message, 'error');
|
||||||
@@ -3550,7 +3559,7 @@ async function showTestStreamModal(streamId) {
|
|||||||
const modal = document.getElementById('test-stream-modal');
|
const modal = document.getElementById('test-stream-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
lockBody();
|
lockBody();
|
||||||
modal.onclick = (e) => { if (e.target === modal) closeTestStreamModal(); };
|
setupBackdropClose(modal, closeTestStreamModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTestStreamModal() {
|
function closeTestStreamModal() {
|
||||||
@@ -3636,7 +3645,7 @@ async function showTestPPTemplateModal(templateId) {
|
|||||||
const modal = document.getElementById('test-pp-template-modal');
|
const modal = document.getElementById('test-pp-template-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
lockBody();
|
lockBody();
|
||||||
modal.onclick = (e) => { if (e.target === modal) closeTestPPTemplateModal(); };
|
setupBackdropClose(modal, closeTestPPTemplateModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTestPPTemplateModal() {
|
function closeTestPPTemplateModal() {
|
||||||
@@ -3888,7 +3897,7 @@ async function showAddPPTemplateModal() {
|
|||||||
const modal = document.getElementById('pp-template-modal');
|
const modal = document.getElementById('pp-template-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
lockBody();
|
lockBody();
|
||||||
modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); };
|
setupBackdropClose(modal, closePPTemplateModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editPPTemplate(templateId) {
|
async function editPPTemplate(templateId) {
|
||||||
@@ -3917,7 +3926,7 @@ async function editPPTemplate(templateId) {
|
|||||||
const modal = document.getElementById('pp-template-modal');
|
const modal = document.getElementById('pp-template-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
lockBody();
|
lockBody();
|
||||||
modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); };
|
setupBackdropClose(modal, closePPTemplateModal);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading PP template:', error);
|
console.error('Error loading PP template:', error);
|
||||||
showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
|
showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
|
||||||
@@ -4066,7 +4075,7 @@ async function showStreamSelector(deviceId) {
|
|||||||
const modal = document.getElementById('stream-selector-modal');
|
const modal = document.getElementById('stream-selector-modal');
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
lockBody();
|
lockBody();
|
||||||
modal.onclick = (e) => { if (e.target === modal) closeStreamSelectorModal(); };
|
setupBackdropClose(modal, closeStreamSelectorModal);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load stream settings:', error);
|
console.error('Failed to load stream settings:', error);
|
||||||
showToast('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();
|
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') {
|
if (stream.stream_type === 'raw') {
|
||||||
infoHtml += `<div><strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}</div>`;
|
let capTmplName = '';
|
||||||
infoHtml += `<div><strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}</div>`;
|
if (stream.capture_template_id) {
|
||||||
} else {
|
if (!_cachedCaptureTemplates || _cachedCaptureTemplates.length === 0) {
|
||||||
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
try {
|
||||||
infoHtml += `<div><strong>${t('streams.source')}</strong> ${sourceStream ? escapeHtml(sourceStream.name) : stream.source_stream_id}</div>`;
|
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 = '';
|
infoPanel.style.display = '';
|
||||||
} catch {
|
} catch {
|
||||||
infoPanel.style.display = 'none';
|
infoPanel.style.display = 'none';
|
||||||
|
|||||||
@@ -238,11 +238,10 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Stream:</label>
|
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Stream:</label>
|
||||||
<select id="stream-selector-stream"></select>
|
<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>
|
<small class="input-hint" data-i18n="device.stream_selector.hint">Select a stream that defines what this device captures and processes</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="stream-selector-border-width" data-i18n="device.stream_settings.border_width">Border Width (px):</label>
|
<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">
|
<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>
|
<input type="text" id="template-name" data-i18n-placeholder="templates.name.placeholder" placeholder="My Custom Template" required>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="template-engine" data-i18n="templates.engine">Capture Engine:</label>
|
<label for="template-engine" data-i18n="templates.engine">Capture Engine:</label>
|
||||||
<select id="template-engine" onchange="onEngineChange()" required>
|
<select id="template-engine" onchange="onEngineChange()" required>
|
||||||
@@ -384,7 +388,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="engine-config-section" style="display: none;">
|
<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 id="engine-config-fields"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -409,7 +413,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)">
|
<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>
|
<span id="test-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,7 +427,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-primary" onclick="runTemplateTest()" style="margin-top: 16px;">
|
<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>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -447,7 +451,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-primary" onclick="runStreamTest()" style="margin-top: 16px;">
|
<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>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -475,7 +479,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-primary" onclick="runPPTemplateTest()" style="margin-top: 16px;">
|
<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>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -504,7 +508,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label data-i18n="streams.display">Display:</label>
|
<label data-i18n="streams.display">Display:</label>
|
||||||
<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)">
|
<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>
|
<span id="stream-display-picker-label" data-i18n="displays.picker.select">Select display...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
"templates.engine.select": "Select an engine...",
|
"templates.engine.select": "Select an engine...",
|
||||||
"templates.engine.unavailable": "Unavailable",
|
"templates.engine.unavailable": "Unavailable",
|
||||||
"templates.engine.unavailable.hint": "This engine is not available on your system",
|
"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.show": "Show configuration",
|
||||||
"templates.config.none": "No additional configuration",
|
"templates.config.none": "No additional configuration",
|
||||||
"templates.config.default": "Default",
|
"templates.config.default": "Default",
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"templates.test.display.select": "Select display...",
|
"templates.test.display.select": "Select display...",
|
||||||
"templates.test.duration": "Capture Duration (s):",
|
"templates.test.duration": "Capture Duration (s):",
|
||||||
"templates.test.border_width": "Border Width (px):",
|
"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.running": "Running test...",
|
||||||
"templates.test.results.preview": "Full Capture Preview",
|
"templates.test.results.preview": "Full Capture Preview",
|
||||||
"templates.test.results.borders": "Border Extraction",
|
"templates.test.results.borders": "Border Extraction",
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
"streams.error.required": "Please fill in all required fields",
|
"streams.error.required": "Please fill in all required fields",
|
||||||
"streams.error.delete": "Failed to delete stream",
|
"streams.error.delete": "Failed to delete stream",
|
||||||
"streams.test.title": "Test Stream",
|
"streams.test.title": "Test Stream",
|
||||||
"streams.test.run": "🧪 Run Test",
|
"streams.test.run": "🧪 Run",
|
||||||
"streams.test.running": "Testing stream...",
|
"streams.test.running": "Testing stream...",
|
||||||
"streams.test.duration": "Capture Duration (s):",
|
"streams.test.duration": "Capture Duration (s):",
|
||||||
"streams.test.error.failed": "Stream test failed",
|
"streams.test.error.failed": "Stream test failed",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
"templates.engine.select": "Выберите движок...",
|
"templates.engine.select": "Выберите движок...",
|
||||||
"templates.engine.unavailable": "Недоступен",
|
"templates.engine.unavailable": "Недоступен",
|
||||||
"templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе",
|
"templates.engine.unavailable.hint": "Этот движок недоступен в вашей системе",
|
||||||
"templates.config": "Конфигурация Движка",
|
"templates.config": "Конфигурация",
|
||||||
"templates.config.show": "Показать конфигурацию",
|
"templates.config.show": "Показать конфигурацию",
|
||||||
"templates.config.none": "Нет дополнительных настроек",
|
"templates.config.none": "Нет дополнительных настроек",
|
||||||
"templates.config.default": "По умолчанию",
|
"templates.config.default": "По умолчанию",
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"templates.test.display.select": "Выберите дисплей...",
|
"templates.test.display.select": "Выберите дисплей...",
|
||||||
"templates.test.duration": "Длительность Захвата (с):",
|
"templates.test.duration": "Длительность Захвата (с):",
|
||||||
"templates.test.border_width": "Ширина Границы (px):",
|
"templates.test.border_width": "Ширина Границы (px):",
|
||||||
"templates.test.run": "\uD83E\uDDEA Запустить Тест",
|
"templates.test.run": "\uD83E\uDDEA Запустить",
|
||||||
"templates.test.running": "Выполняется тест...",
|
"templates.test.running": "Выполняется тест...",
|
||||||
"templates.test.results.preview": "Полный Предпросмотр Захвата",
|
"templates.test.results.preview": "Полный Предпросмотр Захвата",
|
||||||
"templates.test.results.borders": "Извлечение Границ",
|
"templates.test.results.borders": "Извлечение Границ",
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
"streams.error.required": "Пожалуйста, заполните все обязательные поля",
|
"streams.error.required": "Пожалуйста, заполните все обязательные поля",
|
||||||
"streams.error.delete": "Не удалось удалить поток",
|
"streams.error.delete": "Не удалось удалить поток",
|
||||||
"streams.test.title": "Тест Потока",
|
"streams.test.title": "Тест Потока",
|
||||||
"streams.test.run": "🧪 Запустить Тест",
|
"streams.test.run": "🧪 Запустить",
|
||||||
"streams.test.running": "Тестирование потока...",
|
"streams.test.running": "Тестирование потока...",
|
||||||
"streams.test.duration": "Длительность Захвата (с):",
|
"streams.test.duration": "Длительность Захвата (с):",
|
||||||
"streams.test.error.failed": "Тест потока не удался",
|
"streams.test.error.failed": "Тест потока не удался",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ body {
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
background: var(--bg-color);
|
background: var(--bg-color);
|
||||||
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -2159,23 +2160,11 @@ input:-webkit-autofill:focus {
|
|||||||
|
|
||||||
/* Stream info panel in stream selector modal */
|
/* Stream info panel in stream selector modal */
|
||||||
.stream-info-panel {
|
.stream-info-panel {
|
||||||
background: var(--bg-secondary, #2a2a2a);
|
padding: 4px 0 0 0;
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
margin-top: 12px;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream-info-panel div {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stream-info-panel strong {
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stream sub-tabs */
|
/* Stream sub-tabs */
|
||||||
.stream-tab-bar {
|
.stream-tab-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2359,6 +2348,8 @@ input:-webkit-autofill:focus {
|
|||||||
|
|
||||||
.layout-display-pickable {
|
.layout-display-pickable {
|
||||||
cursor: pointer !important;
|
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 {
|
.layout-display-pickable:hover {
|
||||||
@@ -2366,6 +2357,12 @@ input:-webkit-autofill:focus {
|
|||||||
border-color: var(--primary-color) !important;
|
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 */
|
/* Display picker button in forms */
|
||||||
.btn-display-picker {
|
.btn-display-picker {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -49,12 +49,12 @@ class TemplateStore:
|
|||||||
|
|
||||||
template = CaptureTemplate(
|
template = CaptureTemplate(
|
||||||
id=template_id,
|
id=template_id,
|
||||||
name=best_engine.upper(),
|
name="Default",
|
||||||
engine_type=best_engine,
|
engine_type=best_engine,
|
||||||
engine_config=default_config,
|
engine_config=default_config,
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_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
|
self._templates[template_id] = template
|
||||||
|
|||||||
Reference in New Issue
Block a user