Improve WGC cleanup, add capture duration persistence, simplify test errors
Some checks failed
Validate / validate (push) Failing after 9s

- Add WGC engine with aggressive COM object cleanup (multiple GC passes)
- Persist capture test duration to localStorage
- Show only short error messages in snackbar (details in console)

Note: WGC cleanup still has issues with yellow border persistence - needs further investigation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 18:38:12 +03:00
parent 6c5608f5ea
commit e2508554cd
8 changed files with 1770 additions and 8 deletions

View File

@@ -0,0 +1,26 @@
"""Screen capture engine abstraction layer."""
from wled_controller.core.capture_engines.base import (
CaptureEngine,
DisplayInfo,
ScreenCapture,
)
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.wgc_engine import WGCEngine
# Auto-register available engines
EngineRegistry.register(MSSEngine)
EngineRegistry.register(DXcamEngine)
EngineRegistry.register(WGCEngine)
__all__ = [
"CaptureEngine",
"DisplayInfo",
"ScreenCapture",
"EngineRegistry",
"MSSEngine",
"DXcamEngine",
"WGCEngine",
]

View File

@@ -0,0 +1,141 @@
"""Base classes for screen capture engines."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Dict, List
import numpy as np
@dataclass
class DisplayInfo:
"""Information about a display/monitor."""
index: int
name: str
width: int
height: int
x: int
y: int
is_primary: bool
refresh_rate: int
@dataclass
class ScreenCapture:
"""Captured screen image data."""
image: np.ndarray
width: int
height: int
display_index: int
class CaptureEngine(ABC):
"""Abstract base class for screen capture engines.
All screen capture engines must implement this interface to be
compatible with the WLED Screen Controller system.
"""
ENGINE_TYPE: str = "base" # Override in subclasses
def __init__(self, config: Dict[str, Any]):
"""Initialize engine with configuration.
Args:
config: Engine-specific configuration dict
"""
self.config = config
self._initialized = False
@abstractmethod
def initialize(self) -> None:
"""Initialize the capture engine.
This method should prepare any resources needed for screen capture
(e.g., creating capture objects, allocating buffers).
Raises:
RuntimeError: If initialization fails
"""
pass
@abstractmethod
def cleanup(self) -> None:
"""Cleanup engine resources.
This method should release any resources allocated during
initialization or capture operations.
"""
pass
@abstractmethod
def get_available_displays(self) -> List[DisplayInfo]:
"""Get list of available displays.
Returns:
List of DisplayInfo objects describing available displays
Raises:
RuntimeError: If unable to detect displays
"""
pass
@abstractmethod
def capture_display(self, display_index: int) -> 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)
Raises:
ValueError: If display_index is invalid
RuntimeError: If capture fails
"""
pass
@classmethod
@abstractmethod
def is_available(cls) -> bool:
"""Check if this engine is available on current system.
Returns:
True if engine can be used on this platform
Examples:
>>> MSSEngine.is_available()
True # MSS is available on all platforms
>>> DXcamEngine.is_available()
True # On Windows 8.1+
False # On Linux/macOS
"""
pass
@classmethod
@abstractmethod
def get_default_config(cls) -> Dict[str, Any]:
"""Get default configuration for this engine.
Returns:
Default config dict with engine-specific options
Examples:
>>> MSSEngine.get_default_config()
{}
>>> DXcamEngine.get_default_config()
{'device_idx': 0, 'output_color': 'RGB', 'max_buffer_len': 64}
"""
pass
def __enter__(self):
"""Context manager entry - initialize engine."""
self.initialize()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit - cleanup engine."""
self.cleanup()

View File

@@ -0,0 +1,251 @@
"""DXcam-based screen capture engine (Windows only, DXGI Desktop Duplication)."""
import sys
from typing import Any, Dict, List
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 DXcamEngine(CaptureEngine):
"""DXcam-based screen capture engine.
Uses the dxcam library which leverages DXGI Desktop Duplication API for
ultra-fast screen capture on Windows. Offers significantly better performance
than MSS and eliminates cursor flickering.
Requires: Windows 8.1+
"""
ENGINE_TYPE = "dxcam"
def __init__(self, config: Dict[str, Any]):
"""Initialize DXcam engine.
Args:
config: Engine configuration
- device_idx (int): GPU index (default: 0)
- output_idx (int|None): Monitor index (default: None = primary)
- output_color (str): Color format "RGB" or "BGR" (default: "RGB")
- max_buffer_len (int): Frame buffer size (default: 64)
"""
super().__init__(config)
self._camera = None
self._dxcam = None
def initialize(self) -> None:
"""Initialize DXcam capture.
Raises:
RuntimeError: If DXcam not installed or initialization fails
"""
try:
import dxcam
self._dxcam = dxcam
except ImportError:
raise RuntimeError(
"DXcam not installed. Install with: pip install dxcam"
)
try:
device_idx = self.config.get("device_idx", 0)
output_idx = self.config.get("output_idx", None)
output_color = self.config.get("output_color", "RGB")
max_buffer_len = self.config.get("max_buffer_len", 64)
self._camera = self._dxcam.create(
device_idx=device_idx,
output_idx=output_idx,
output_color=output_color,
max_buffer_len=max_buffer_len,
)
if not self._camera:
raise RuntimeError("Failed to create DXcam camera instance")
# Start the camera to begin capturing
self._camera.start()
self._initialized = True
logger.info(
f"DXcam engine initialized (device={device_idx}, "
f"output={output_idx}, color={output_color})"
)
except Exception as e:
raise RuntimeError(f"Failed to initialize DXcam: {e}")
def cleanup(self) -> None:
"""Cleanup DXcam resources."""
if self._camera:
try:
# Stop capturing before releasing
self._camera.stop()
self._camera.release()
except Exception as e:
logger.error(f"Error releasing DXcam camera: {e}")
self._camera = None
self._initialized = False
logger.info("DXcam engine cleaned up")
def get_available_displays(self) -> List[DisplayInfo]:
"""Get list of available displays using DXcam.
Note: DXcam provides limited display enumeration. This method
returns basic information for the configured output.
Returns:
List of DisplayInfo objects
Raises:
RuntimeError: If not initialized or detection fails
"""
if not self._initialized:
raise RuntimeError("Engine not initialized")
try:
displays = []
# Get output information from DXcam
# Note: DXcam doesn't provide comprehensive display enumeration
# We report the single configured output
output_idx = self.config.get("output_idx", 0)
if output_idx is None:
output_idx = 0
# DXcam camera has basic output info
if self._camera and hasattr(self._camera, "width") and hasattr(self._camera, "height"):
display_info = DisplayInfo(
index=output_idx,
name=f"DXcam Display {output_idx}",
width=self._camera.width,
height=self._camera.height,
x=0, # DXcam doesn't provide position info
y=0,
is_primary=(output_idx == 0),
refresh_rate=60, # DXcam doesn't report refresh rate
)
displays.append(display_info)
else:
# Fallback if camera doesn't have dimensions
display_info = DisplayInfo(
index=output_idx,
name=f"DXcam Display {output_idx}",
width=1920, # Reasonable default
height=1080,
x=0,
y=0,
is_primary=(output_idx == 0),
refresh_rate=60,
)
displays.append(display_info)
logger.debug(f"DXcam detected {len(displays)} display(s)")
return displays
except Exception as e:
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:
"""Capture display using DXcam.
Args:
display_index: Index of display to capture (0-based)
Note: DXcam is configured for a specific output, so this
should match the configured output_idx
Returns:
ScreenCapture object with image data
Raises:
RuntimeError: If not initialized
ValueError: If display_index doesn't match configured output
RuntimeError: If capture fails
"""
if not self._initialized:
raise RuntimeError("Engine not initialized")
# DXcam is configured for a specific output
configured_output = self.config.get("output_idx", 0)
if configured_output is None:
configured_output = 0
if display_index != configured_output:
raise ValueError(
f"DXcam engine is configured for output {configured_output}, "
f"cannot capture display {display_index}. Create a new template "
f"with output_idx={display_index} to capture this display."
)
try:
# Grab frame from DXcam
frame = self._camera.grab()
if frame is None:
raise RuntimeError(
"Failed to capture frame (no new data). This can happen if "
"the screen hasn't changed or if there's a timeout."
)
# DXcam returns numpy array directly in configured color format
logger.debug(
f"DXcam 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 DXcam: {e}")
raise RuntimeError(f"Screen capture failed: {e}")
@classmethod
def is_available(cls) -> bool:
"""Check if DXcam is available.
DXcam requires Windows 8.1+ and the dxcam package.
Returns:
True if dxcam is available on this system
"""
# Check platform
if sys.platform != "win32":
return False
# Check if dxcam is installed
try:
import dxcam
return True
except ImportError:
return False
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
"""Get default DXcam configuration.
Returns:
Default config dict with DXcam options
"""
return {
"device_idx": 0, # Primary GPU
"output_idx": None, # Primary monitor (None = auto-select)
"output_color": "RGB", # RGB color format
"max_buffer_len": 64, # Frame buffer size
}

View File

@@ -0,0 +1,134 @@
"""Engine registry and factory for screen capture engines."""
from typing import Any, Dict, List, Type
from wled_controller.core.capture_engines.base import CaptureEngine
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class EngineRegistry:
"""Registry for available capture engines.
This class maintains a registry of all capture engine implementations
and provides factory methods for creating engine instances.
"""
_engines: Dict[str, Type[CaptureEngine]] = {}
@classmethod
def register(cls, engine_class: Type[CaptureEngine]):
"""Register a capture engine.
Args:
engine_class: Engine class to register (must inherit from CaptureEngine)
Raises:
ValueError: If engine_class is not a subclass of CaptureEngine
ValueError: If an engine with the same ENGINE_TYPE is already registered
"""
if not issubclass(engine_class, CaptureEngine):
raise ValueError(f"{engine_class} must be a subclass of CaptureEngine")
engine_type = engine_class.ENGINE_TYPE
if engine_type == "base":
raise ValueError("Cannot register base engine type")
if engine_type in cls._engines:
logger.warning(f"Engine '{engine_type}' already registered, overwriting")
cls._engines[engine_type] = engine_class
logger.info(f"Registered capture engine: {engine_type}")
@classmethod
def get_engine(cls, engine_type: str) -> Type[CaptureEngine]:
"""Get engine class by type.
Args:
engine_type: Engine type identifier (e.g., "mss", "dxcam")
Returns:
Engine class
Raises:
ValueError: If engine type not found
"""
if engine_type not in cls._engines:
available = ", ".join(cls._engines.keys()) or "none"
raise ValueError(
f"Unknown engine type: '{engine_type}'. Available engines: {available}"
)
return cls._engines[engine_type]
@classmethod
def get_available_engines(cls) -> List[str]:
"""Get list of available engine types on this system.
Returns:
List of engine type identifiers that are available on the current platform
Examples:
>>> EngineRegistry.get_available_engines()
['mss'] # On Linux
['mss', 'dxcam', 'wgc'] # On Windows 10+
"""
available = []
for engine_type, engine_class in cls._engines.items():
try:
if engine_class.is_available():
available.append(engine_type)
except Exception as e:
logger.error(
f"Error checking availability for engine '{engine_type}': {e}"
)
return available
@classmethod
def get_all_engines(cls) -> Dict[str, Type[CaptureEngine]]:
"""Get all registered engines (available or not).
Returns:
Dictionary mapping engine type to engine class
"""
return cls._engines.copy()
@classmethod
def create_engine(cls, engine_type: str, config: Dict[str, Any]) -> CaptureEngine:
"""Create engine instance with configuration.
Args:
engine_type: Engine type identifier
config: Engine-specific configuration
Returns:
Initialized engine instance
Raises:
ValueError: If engine type not found or not available
RuntimeError: If engine initialization fails
"""
engine_class = cls.get_engine(engine_type)
if not engine_class.is_available():
raise ValueError(
f"Engine '{engine_type}' is not available on this system"
)
try:
engine = engine_class(config)
logger.debug(f"Created engine instance: {engine_type}")
return engine
except Exception as e:
logger.error(f"Failed to create engine '{engine_type}': {e}")
raise RuntimeError(f"Failed to create engine '{engine_type}': {e}")
@classmethod
def clear_registry(cls):
"""Clear all registered engines.
This is primarily useful for testing.
"""
cls._engines.clear()
logger.debug("Cleared engine registry")

View File

@@ -0,0 +1,185 @@
"""MSS-based screen capture engine (cross-platform)."""
from typing import Any, Dict, List
import mss
import numpy as np
from PIL import Image
from wled_controller.core.capture_engines.base import (
CaptureEngine,
DisplayInfo,
ScreenCapture,
)
from wled_controller.utils import get_logger, get_monitor_names, get_monitor_refresh_rates
logger = get_logger(__name__)
class MSSEngine(CaptureEngine):
"""MSS-based screen capture engine.
Uses the mss library for cross-platform screen capture support.
Works on Windows, macOS, and Linux.
Note: May experience cursor flickering on some systems.
"""
ENGINE_TYPE = "mss"
def __init__(self, config: Dict[str, Any]):
"""Initialize MSS engine.
Args:
config: Engine configuration (currently unused for MSS)
"""
super().__init__(config)
self._sct = None
def initialize(self) -> None:
"""Initialize MSS capture context.
Raises:
RuntimeError: If MSS initialization fails
"""
try:
self._sct = mss.mss()
self._initialized = True
logger.info("MSS engine initialized")
except Exception as e:
raise RuntimeError(f"Failed to initialize MSS: {e}")
def cleanup(self) -> None:
"""Cleanup MSS resources."""
if self._sct:
self._sct.close()
self._sct = None
self._initialized = False
logger.info("MSS engine cleaned up")
def get_available_displays(self) -> List[DisplayInfo]:
"""Get list of available displays using MSS.
Returns:
List of DisplayInfo objects for each available monitor
Raises:
RuntimeError: If not initialized or display detection fails
"""
if not self._initialized:
raise RuntimeError("Engine not initialized")
try:
# Get friendly monitor names (Windows only, falls back to generic names)
monitor_names = get_monitor_names()
# Get monitor refresh rates (Windows only, falls back to 60Hz)
refresh_rates = get_monitor_refresh_rates()
displays = []
# Skip the first monitor (combined virtual screen on multi-monitor setups)
for idx, monitor in enumerate(self._sct.monitors[1:], start=0):
# Use friendly name from WMI if available, otherwise generic name
friendly_name = monitor_names.get(idx, f"Display {idx}")
# Use detected refresh rate or default to 60Hz
refresh_rate = refresh_rates.get(idx, 60)
display_info = DisplayInfo(
index=idx,
name=friendly_name,
width=monitor["width"],
height=monitor["height"],
x=monitor["left"],
y=monitor["top"],
is_primary=(idx == 0),
refresh_rate=refresh_rate,
)
displays.append(display_info)
logger.debug(f"MSS detected {len(displays)} display(s)")
return displays
except Exception as e:
logger.error(f"Failed to detect displays with MSS: {e}")
raise RuntimeError(f"Failed to detect displays: {e}")
def capture_display(self, display_index: int) -> ScreenCapture:
"""Capture display using MSS.
Args:
display_index: Index of display to capture (0-based)
Returns:
ScreenCapture object with image data
Raises:
RuntimeError: If not initialized
ValueError: If display_index is invalid
RuntimeError: If capture fails
"""
if not self._initialized:
raise RuntimeError("Engine not initialized")
try:
# mss monitors[0] is the combined screen, monitors[1+] are individual displays
monitor_index = display_index + 1
if monitor_index >= len(self._sct.monitors):
raise ValueError(
f"Invalid display index {display_index}. "
f"Available displays: 0-{len(self._sct.monitors) - 2}"
)
monitor = self._sct.monitors[monitor_index]
# Capture screenshot
screenshot = self._sct.grab(monitor)
# Convert to numpy array (RGB)
img = Image.frombytes("RGB", screenshot.size, screenshot.rgb)
img_array = np.array(img)
logger.debug(
f"MSS captured display {display_index}: {monitor['width']}x{monitor['height']}"
)
return ScreenCapture(
image=img_array,
width=monitor["width"],
height=monitor["height"],
display_index=display_index,
)
except ValueError:
raise
except Exception as e:
logger.error(f"Failed to capture display {display_index} with MSS: {e}")
raise RuntimeError(f"Screen capture failed: {e}")
@classmethod
def is_available(cls) -> bool:
"""Check if MSS is available.
MSS is cross-platform and should always be available.
Returns:
True if mss library is available
"""
try:
import mss
return True
except ImportError:
return False
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
"""Get default MSS configuration.
MSS has no configurable options.
Returns:
Empty dict (MSS has no configuration)
"""
return {}

View File

@@ -0,0 +1,348 @@
"""Windows Graphics Capture engine (Windows 10+, WGC API)."""
import gc
import sys
import time
import threading
from typing import Any, Dict, List
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 WGCEngine(CaptureEngine):
"""Windows Graphics Capture engine.
Uses the windows-capture library which leverages Windows.Graphics.Capture API.
This is Microsoft's recommended modern screen capture API for Windows 10+.
Features:
- Cross-GPU support (works regardless of GPU routing)
- Hardware cursor exclusion (no cursor flickering)
- GPU-accelerated with direct texture sharing
- Modern, future-proof API
Requires: Windows 10 1803+
"""
ENGINE_TYPE = "wgc"
def __init__(self, config: Dict[str, Any]):
"""Initialize WGC engine.
Args:
config: Engine configuration
- monitor_index (int): Monitor index (default: 0)
- capture_cursor (bool): Include cursor in capture (default: False)
- draw_border (bool): Draw border around capture (default: False)
"""
super().__init__(config)
self._wgc = None
self._capture_instance = None
self._capture_control = None
self._latest_frame = None
self._frame_lock = threading.Lock()
self._frame_event = threading.Event()
self._closed_event = threading.Event() # Signals when capture session is closed
def initialize(self) -> None:
"""Initialize WGC capture.
Raises:
RuntimeError: If windows-capture not installed or initialization fails
"""
try:
import windows_capture
self._wgc = windows_capture
except ImportError:
raise RuntimeError(
"windows-capture not installed. Install with: pip install windows-capture"
)
# Clear events for fresh initialization
self._frame_event.clear()
self._closed_event.clear()
try:
monitor_index = self.config.get("monitor_index", 0)
capture_cursor = self.config.get("capture_cursor", False)
# Note: draw_border is not supported by WGC API on most platforms
# WGC uses 1-based monitor indexing (1, 2, 3...) while we use 0-based (0, 1, 2...)
wgc_monitor_index = monitor_index + 1
# Create capture instance
# Note: draw_border parameter not supported on all platforms
self._capture_instance = self._wgc.WindowsCapture(
cursor_capture=capture_cursor,
monitor_index=wgc_monitor_index,
)
# Define event handlers as local functions first
def on_frame_arrived(frame, capture_control):
"""Called when a new frame is captured."""
try:
logger.debug("WGC frame callback triggered")
# Store capture_control reference for cleanup
if self._capture_control is None:
self._capture_control = capture_control
# Get frame buffer as numpy array
frame_buffer = frame.frame_buffer
width = frame.width
height = frame.height
# Reshape to image dimensions (height, width, channels)
# WGC provides BGRA format
frame_array = frame_buffer.reshape((height, width, 4))
# Convert BGRA to RGB
frame_rgb = frame_array[:, :, [2, 1, 0]] # Take BGR channels
# Store the latest frame
with self._frame_lock:
self._latest_frame = frame_rgb.copy()
self._frame_event.set()
except Exception as e:
logger.error(f"Error processing WGC frame: {e}", exc_info=True)
def on_closed():
"""Called when capture session is closed."""
logger.debug("WGC capture session closed callback triggered")
# Signal that the capture session has fully closed and resources are released
self._closed_event.set()
# Set handlers directly as attributes
self._capture_instance.frame_handler = on_frame_arrived
self._capture_instance.closed_handler = on_closed
# Start capture using free-threaded mode (non-blocking)
logger.debug("Starting WGC capture (free-threaded mode)...")
self._capture_instance.start_free_threaded()
# Wait for first frame to arrive (with timeout)
logger.debug("Waiting for first WGC frame...")
frame_received = self._frame_event.wait(timeout=5.0)
if not frame_received or self._latest_frame is None:
raise RuntimeError(
"WGC capture started but no frames received within 5 seconds. "
"This may indicate the capture session failed to start or "
"the display is not actively updating."
)
self._initialized = True
logger.info(
f"WGC engine initialized (monitor={monitor_index}, "
f"cursor={capture_cursor})"
)
except Exception as e:
logger.error(f"Failed to initialize WGC: {e}", exc_info=True)
raise RuntimeError(f"Failed to initialize WGC: {e}")
def cleanup(self) -> None:
"""Cleanup WGC resources."""
# For free-threaded captures, cleanup is tricky:
# 1. Stop capture via capture_control if available
# 2. Explicitly delete the capture instance (releases COM objects)
# 3. Wait for resources to be freed (give Windows time to cleanup)
# 4. Force garbage collection multiple times to ensure COM cleanup
if self._capture_control:
try:
logger.debug("Stopping WGC capture session via capture_control...")
self._capture_control.stop()
except Exception as e:
logger.error(f"Error stopping WGC capture_control: {e}")
finally:
self._capture_control = None
# Explicitly delete the capture instance BEFORE waiting
# This is critical for releasing COM objects and GPU resources
if self._capture_instance:
try:
logger.debug("Explicitly deleting WGC capture instance...")
instance = self._capture_instance
self._capture_instance = None
del instance
logger.debug("WGC capture instance deleted")
except Exception as e:
logger.error(f"Error releasing WGC capture instance: {e}")
self._capture_instance = None
# Force garbage collection multiple times to ensure COM cleanup
# COM objects may require multiple GC passes to fully release
logger.debug("Forcing garbage collection for COM cleanup...")
for i in range(3):
gc.collect()
time.sleep(0.05) # Small delay between GC passes
logger.debug("Waiting for WGC resources to be fully released...")
# Give Windows time to clean up the capture session and remove border
time.sleep(0.3)
# Final GC pass
gc.collect()
logger.debug("Final garbage collection completed")
with self._frame_lock:
self._latest_frame = None
self._frame_event.clear()
self._closed_event.clear()
self._initialized = False
logger.info("WGC engine cleaned up")
def get_available_displays(self) -> List[DisplayInfo]:
"""Get list of available displays using MSS.
Note: WGC doesn't provide a direct API for enumerating monitors,
so we use MSS for display detection.
Returns:
List of DisplayInfo objects
Raises:
RuntimeError: If detection fails
"""
try:
import mss
with mss.mss() as sct:
displays = []
# Skip monitor 0 (all monitors combined)
for i, monitor in enumerate(sct.monitors[1:], start=0):
displays.append(
DisplayInfo(
index=i,
name=f"Monitor {i+1}",
width=monitor["width"],
height=monitor["height"],
x=monitor["left"],
y=monitor["top"],
is_primary=(i == 0),
refresh_rate=60,
)
)
logger.debug(f"WGC detected {len(displays)} display(s)")
return displays
except Exception as e:
logger.error(f"Failed to detect displays: {e}")
raise RuntimeError(f"Failed to detect displays: {e}")
def capture_display(self, display_index: int) -> ScreenCapture:
"""Capture display using WGC.
Args:
display_index: Index of display to capture (0-based)
Returns:
ScreenCapture object with image data
Raises:
RuntimeError: If not initialized
ValueError: If display_index doesn't match configured monitor
RuntimeError: If capture fails or no frame available
"""
if not self._initialized:
raise RuntimeError("Engine not initialized")
# WGC is configured for a specific monitor
configured_monitor = self.config.get("monitor_index", 0)
if display_index != configured_monitor:
raise ValueError(
f"WGC engine is configured for monitor {configured_monitor}, "
f"cannot capture display {display_index}. Create a new template "
f"with monitor_index={display_index} to capture this display."
)
try:
# Get the latest frame
with self._frame_lock:
if self._latest_frame is None:
raise RuntimeError(
"No frame available yet. The capture may not have started or "
"the screen hasn't updated. Wait a moment and try again."
)
frame = self._latest_frame.copy()
logger.debug(
f"WGC 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 WGC: {e}")
raise RuntimeError(f"Screen capture failed: {e}")
@classmethod
def is_available(cls) -> bool:
"""Check if WGC is available.
WGC requires Windows 10 1803+ and the windows-capture package.
Returns:
True if windows-capture is available on this system
"""
# Check platform
if sys.platform != "win32":
return False
# Check Windows version (Windows 10 1803 = version 10.0.17134)
try:
import platform
version = platform.version()
# Parse version string like "10.0.19045"
parts = version.split(".")
if len(parts) >= 3:
major = int(parts[0])
minor = int(parts[1])
build = int(parts[2])
# Check for Windows 10 1803+ (build 17134+)
if major < 10 or (major == 10 and minor == 0 and build < 17134):
return False
except Exception:
# If we can't parse version, assume it might work
pass
# Check if windows-capture is installed
try:
import windows_capture
return True
except ImportError:
return False
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
"""Get default WGC configuration.
Returns:
Default config dict with WGC options
"""
return {
"monitor_index": 0, # Primary monitor
"capture_cursor": False, # Exclude cursor (hardware exclusion)
"draw_border": False, # Don't draw border around capture
}

View File

@@ -164,12 +164,12 @@ document.addEventListener('DOMContentLoaded', async () => {
// Show content now that translations are loaded
document.body.style.visibility = 'visible';
// Restore active tab
initTabs();
// Load API key from localStorage
apiKey = localStorage.getItem('wled_api_key');
// Restore active tab (after API key is loaded)
initTabs();
// Setup form handler
document.getElementById('add-device-form').addEventListener('submit', handleAddDevice);
@@ -206,6 +206,31 @@ function getHeaders() {
return headers;
}
// Fetch wrapper that automatically includes auth headers
async function fetchWithAuth(url, options = {}) {
// Build full URL if relative path provided
const fullUrl = url.startsWith('http') ? url : `${API_BASE}${url}`;
// Merge auth headers with any custom headers
const headers = options.headers
? { ...getHeaders(), ...options.headers }
: getHeaders();
// Make request with merged options
return fetch(fullUrl, {
...options,
headers
});
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Handle 401 errors by showing login modal
function handle401Error() {
// Clear invalid API key
@@ -319,6 +344,9 @@ function switchTab(name) {
if (name === 'displays' && _cachedDisplays) {
requestAnimationFrame(() => renderDisplayLayout(_cachedDisplays));
}
if (name === 'templates') {
loadCaptureTemplates();
}
}
function initTabs() {
@@ -709,10 +737,11 @@ async function removeDevice(deviceId) {
async function showSettings(deviceId) {
try {
// Fetch device data and displays in parallel
const [deviceResponse, displaysResponse] = await Promise.all([
// Fetch device data, displays, and templates in parallel
const [deviceResponse, displaysResponse, templatesResponse] = await Promise.all([
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
fetchWithAuth('/capture-templates'),
]);
if (deviceResponse.status === 401) {
@@ -747,6 +776,27 @@ async function showSettings(deviceId) {
}
displaySelect.value = String(device.settings.display_index ?? 0);
// Populate capture template select
const templateSelect = document.getElementById('settings-capture-template');
templateSelect.innerHTML = '';
if (templatesResponse.ok) {
const templatesData = await templatesResponse.json();
(templatesData.templates || []).forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
const engineIcon = getEngineIcon(t.engine_type);
opt.textContent = `${engineIcon} ${t.name}`;
templateSelect.appendChild(opt);
});
}
if (templateSelect.options.length === 0) {
const opt = document.createElement('option');
opt.value = 'tpl_mss_default';
opt.textContent = 'MSS (Default)';
templateSelect.appendChild(opt);
}
templateSelect.value = device.capture_template_id || 'tpl_mss_default';
// Populate other fields
document.getElementById('settings-device-id').value = device.id;
document.getElementById('settings-device-name').value = device.name;
@@ -759,6 +809,7 @@ async function showSettings(deviceId) {
url: device.url,
display_index: String(device.settings.display_index ?? 0),
state_check_interval: String(device.settings.state_check_interval || 30),
capture_template_id: device.capture_template_id || 'tpl_mss_default',
};
// Show modal
@@ -782,7 +833,8 @@ function isSettingsDirty() {
document.getElementById('settings-device-name').value !== settingsInitialValues.name ||
document.getElementById('settings-device-url').value !== settingsInitialValues.url ||
document.getElementById('settings-display-index').value !== settingsInitialValues.display_index ||
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval
document.getElementById('settings-health-interval').value !== settingsInitialValues.state_check_interval ||
document.getElementById('settings-capture-template').value !== settingsInitialValues.capture_template_id
);
}
@@ -809,6 +861,7 @@ async function saveDeviceSettings() {
const url = document.getElementById('settings-device-url').value.trim();
const display_index = parseInt(document.getElementById('settings-display-index').value) || 0;
const state_check_interval = parseInt(document.getElementById('settings-health-interval').value) || 30;
const capture_template_id = document.getElementById('settings-capture-template').value;
const error = document.getElementById('settings-error');
// Validation
@@ -819,11 +872,11 @@ async function saveDeviceSettings() {
}
try {
// Update device info (name, url)
// Update device info (name, url, capture_template_id)
const deviceResponse = await fetch(`${API_BASE}/devices/${deviceId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify({ name, url })
body: JSON.stringify({ name, url, capture_template_id })
});
if (deviceResponse.status === 401) {
@@ -2126,3 +2179,513 @@ function handleTutorialKey(e) {
else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); tutorialNext(); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); tutorialPrev(); }
}
// ===========================
// Capture Templates Functions
// ===========================
let availableEngines = [];
let currentEditingTemplateId = null;
// Load and render capture templates
async function loadCaptureTemplates() {
try {
const response = await fetchWithAuth('/capture-templates');
if (!response.ok) {
throw new Error(`Failed to load templates: ${response.status}`);
}
const data = await response.json();
renderTemplatesList(data.templates || []);
} catch (error) {
console.error('Error loading capture templates:', error);
document.getElementById('templates-list').innerHTML = `
<div class="error-message">${t('templates.error.load')}: ${error.message}</div>
`;
}
}
// Render templates list
function renderTemplatesList(templates) {
const container = document.getElementById('templates-list');
if (templates.length === 0) {
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('templates.add')}</div>
</div>`;
return;
}
container.innerHTML = templates.map(template => {
const engineIcon = getEngineIcon(template.engine_type);
const defaultBadge = template.is_default
? `<span class="badge badge-default">${t('templates.default')}</span>`
: '';
return `
<div class="template-card" data-template-id="${template.id}">
<div class="template-card-header">
<div class="template-name">
${engineIcon} ${escapeHtml(template.name)}
</div>
${defaultBadge}
</div>
<div class="template-config">
<strong>${t('templates.engine')}</strong> ${template.engine_type.toUpperCase()}
</div>
${Object.keys(template.engine_config).length > 0 ? `
<details class="template-config-details">
<summary>${t('templates.config.show')}</summary>
<pre>${JSON.stringify(template.engine_config, null, 2)}</pre>
</details>
` : `
<div class="template-no-config">${t('templates.config.none')}</div>
`}
<div class="template-card-actions">
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">
🧪
</button>
${!template.is_default ? `
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">
✏️
</button>
<button class="btn btn-icon btn-danger" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">
🗑️
</button>
` : ''}
</div>
</div>
`;
}).join('') + `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
<div class="add-template-icon">+</div>
<div class="add-template-label">${t('templates.add')}</div>
</div>`;
}
// Get engine icon
function getEngineIcon(engineType) {
return '🖥️';
}
// Show add template modal
async function showAddTemplateModal() {
currentEditingTemplateId = null;
document.getElementById('template-modal-title').textContent = t('templates.add');
document.getElementById('template-form').reset();
document.getElementById('template-id').value = '';
document.getElementById('engine-config-section').style.display = 'none';
document.getElementById('template-error').style.display = 'none';
// Load available engines
await loadAvailableEngines();
document.getElementById('template-modal').style.display = 'flex';
}
// Edit template
async function editTemplate(templateId) {
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
if (!response.ok) {
throw new Error(`Failed to load template: ${response.status}`);
}
const template = await response.json();
currentEditingTemplateId = templateId;
document.getElementById('template-modal-title').textContent = t('templates.edit');
document.getElementById('template-id').value = templateId;
document.getElementById('template-name').value = template.name;
// Load available engines
await loadAvailableEngines();
// Set engine and load config
document.getElementById('template-engine').value = template.engine_type;
await onEngineChange();
// Populate engine config fields
populateEngineConfig(template.engine_config);
// Load displays for test
await loadDisplaysForTest();
document.getElementById('template-test-results').style.display = 'none';
document.getElementById('template-error').style.display = 'none';
document.getElementById('template-modal').style.display = 'flex';
} catch (error) {
console.error('Error loading template:', error);
showToast(t('templates.error.load') + ': ' + error.message, 'error');
}
}
// Close template modal
function closeTemplateModal() {
document.getElementById('template-modal').style.display = 'none';
currentEditingTemplateId = null;
}
// Update capture duration and save to localStorage
function updateCaptureDuration(value) {
document.getElementById('test-template-duration-value').textContent = value;
localStorage.setItem('capture_duration', value);
}
// Restore capture duration from localStorage
function restoreCaptureDuration() {
const savedDuration = localStorage.getItem('capture_duration');
if (savedDuration) {
const durationInput = document.getElementById('test-template-duration');
const durationValue = document.getElementById('test-template-duration-value');
durationInput.value = savedDuration;
durationValue.textContent = savedDuration;
}
}
// Show test template modal
async function showTestTemplateModal(templateId) {
const templates = await fetchWithAuth('/capture-templates').then(r => r.json());
const template = templates.templates.find(t => t.id === templateId);
if (!template) {
showToast(t('templates.error.load'), 'error');
return;
}
// Store current template for testing
window.currentTestingTemplate = template;
// Load displays
await loadDisplaysForTest();
// Restore last used capture duration
restoreCaptureDuration();
// Reset results
document.getElementById('test-template-results').style.display = 'none';
// Show modal
document.getElementById('test-template-modal').style.display = 'flex';
}
// Close test template modal
function closeTestTemplateModal() {
document.getElementById('test-template-modal').style.display = 'none';
window.currentTestingTemplate = null;
}
// Load available engines
async function loadAvailableEngines() {
try {
const response = await fetchWithAuth('/capture-engines');
if (!response.ok) {
throw new Error(`Failed to load engines: ${response.status}`);
}
const data = await response.json();
availableEngines = data.engines || [];
const select = document.getElementById('template-engine');
select.innerHTML = `<option value="">${t('templates.engine.select')}</option>`;
availableEngines.forEach(engine => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = `${getEngineIcon(engine.type)} ${engine.name}`;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('templates.engine.unavailable')})`;
}
select.appendChild(option);
});
} catch (error) {
console.error('Error loading engines:', error);
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
}
}
// Handle engine selection change
async function onEngineChange() {
const engineType = document.getElementById('template-engine').value;
const configSection = document.getElementById('engine-config-section');
const configFields = document.getElementById('engine-config-fields');
if (!engineType) {
configSection.style.display = 'none';
return;
}
const engine = availableEngines.find(e => e.type === engineType);
if (!engine) {
configSection.style.display = 'none';
return;
}
// Show availability hint
const hint = document.getElementById('engine-availability-hint');
if (!engine.available) {
hint.textContent = t('templates.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
// Render config fields based on default_config
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
if (Object.keys(defaultConfig).length === 0) {
configFields.innerHTML = `<p class="text-muted">${t('templates.config.none')}</p>`;
} else {
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
const fieldHtml = `
<div class="form-group">
<label for="config-${key}">${key}:</label>
${typeof value === 'boolean' ? `
<select id="config-${key}" data-config-key="${key}">
<option value="true" ${value ? 'selected' : ''}>true</option>
<option value="false" ${!value ? 'selected' : ''}>false</option>
</select>
` : `
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
`}
<small class="form-hint">${t('templates.config.default')}: ${JSON.stringify(value)}</small>
</div>
`;
configFields.innerHTML += fieldHtml;
});
}
configSection.style.display = 'block';
}
// Populate engine config fields with values
function populateEngineConfig(config) {
Object.entries(config).forEach(([key, value]) => {
const field = document.getElementById(`config-${key}`);
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
// Collect engine config from form
function collectEngineConfig() {
const config = {};
const fields = document.querySelectorAll('[data-config-key]');
fields.forEach(field => {
const key = field.dataset.configKey;
let value = field.value;
// Type conversion
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
// Load displays for test selector
async function loadDisplaysForTest() {
try {
const response = await fetchWithAuth('/config/displays');
if (!response.ok) {
throw new Error(`Failed to load displays: ${response.status}`);
}
const displaysData = await response.json();
const select = document.getElementById('test-template-display');
select.innerHTML = `<option value="">${t('templates.test.display.select')}</option>`;
(displaysData.displays || []).forEach(display => {
const option = document.createElement('option');
option.value = display.index;
option.textContent = `Display ${display.index} (${display.width}x${display.height})`;
if (display.is_primary) {
option.textContent += ' - Primary';
}
select.appendChild(option);
});
} catch (error) {
console.error('Error loading displays:', error);
}
}
// Run template test
async function runTemplateTest() {
if (!window.currentTestingTemplate) {
showToast(t('templates.test.error.no_engine'), 'error');
return;
}
const displayIndex = document.getElementById('test-template-display').value;
const captureDuration = parseFloat(document.getElementById('test-template-duration').value);
if (displayIndex === '') {
showToast(t('templates.test.error.no_display'), 'error');
return;
}
const template = window.currentTestingTemplate;
const resultsDiv = document.getElementById('test-template-results');
// Show loading state without destroying the structure
const loadingDiv = document.createElement('div');
loadingDiv.className = 'loading';
loadingDiv.textContent = t('templates.test.running');
loadingDiv.style.position = 'absolute';
loadingDiv.style.inset = '0';
loadingDiv.style.background = 'var(--bg-primary)';
loadingDiv.style.display = 'flex';
loadingDiv.style.alignItems = 'center';
loadingDiv.style.justifyContent = 'center';
loadingDiv.style.zIndex = '10';
loadingDiv.id = 'test-loading-overlay';
// Remove old loading overlay if exists
const oldLoading = document.getElementById('test-loading-overlay');
if (oldLoading) oldLoading.remove();
resultsDiv.style.display = 'block';
resultsDiv.style.position = 'relative';
resultsDiv.appendChild(loadingDiv);
try {
const response = await fetchWithAuth('/capture-templates/test', {
method: 'POST',
body: JSON.stringify({
engine_type: template.engine_type,
engine_config: template.engine_config,
display_index: parseInt(displayIndex),
capture_duration: captureDuration
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Test failed');
}
const result = await response.json();
displayTestResults(result);
} catch (error) {
console.error('Error running test:', error);
// Remove loading overlay
const loadingOverlay = document.getElementById('test-loading-overlay');
if (loadingOverlay) loadingOverlay.remove();
// Show short error in snack, details are in console
showToast(t('templates.test.error.failed'), 'error');
}
}
// Display test results
function displayTestResults(result) {
const resultsDiv = document.getElementById('test-template-results');
// Remove loading overlay
const loadingOverlay = document.getElementById('test-loading-overlay');
if (loadingOverlay) loadingOverlay.remove();
// Full capture preview
const previewImg = document.getElementById('test-template-preview-image');
previewImg.innerHTML = `<img src="${result.full_capture.image}" alt="Capture preview" style="max-width: 100%; border-radius: 4px;">`;
// Performance stats
document.getElementById('test-template-actual-duration').textContent = `${result.performance.capture_duration_s.toFixed(2)}s`;
document.getElementById('test-template-frame-count').textContent = result.performance.frame_count;
document.getElementById('test-template-actual-fps').textContent = `${result.performance.actual_fps.toFixed(1)} FPS`;
document.getElementById('test-template-avg-capture-time').textContent = `${result.performance.avg_capture_time_ms.toFixed(1)}ms`;
// Show results
resultsDiv.style.display = 'block';
}
// Save template
async function saveTemplate() {
const templateId = document.getElementById('template-id').value;
const name = document.getElementById('template-name').value.trim();
const engineType = document.getElementById('template-engine').value;
if (!name || !engineType) {
showToast(t('templates.error.required'), 'error');
return;
}
const engineConfig = collectEngineConfig();
const payload = {
name,
engine_type: engineType,
engine_config: engineConfig
};
try {
let response;
if (templateId) {
// Update existing template
response = await fetchWithAuth(`/capture-templates/${templateId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
} else {
// Create new template
response = await fetchWithAuth('/capture-templates', {
method: 'POST',
body: JSON.stringify(payload)
});
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
closeTemplateModal();
await loadCaptureTemplates();
} catch (error) {
console.error('Error saving template:', error);
document.getElementById('template-error').textContent = error.message;
document.getElementById('template-error').style.display = 'block';
}
}
// Delete template
async function deleteTemplate(templateId) {
const confirmed = await showConfirm(t('templates.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`, {
method: 'DELETE'
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('templates.deleted'), 'success');
await loadCaptureTemplates();
} catch (error) {
console.error('Error deleting template:', error);
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
}
}

View File

@@ -36,6 +36,7 @@
<div class="tab-bar">
<button class="tab-btn active" data-tab="devices" onclick="switchTab('devices')"><span data-i18n="devices.title">💡 Devices</span></button>
<button class="tab-btn" data-tab="displays" onclick="switchTab('displays')"><span data-i18n="displays.layout">🖥️ Displays</span></button>
<button class="tab-btn" data-tab="templates" onclick="switchTab('templates')"><span data-i18n="templates.title">🎯 Capture Templates</span></button>
</div>
<div class="tab-panel active" id="tab-devices">
@@ -60,6 +61,17 @@
</div>
<div id="displays-list" style="display: none;"></div>
</div>
<div class="tab-panel" id="tab-templates">
<p class="section-tip">
<span data-i18n="templates.description">
Capture templates define how the screen is captured. Each template uses a specific capture engine (MSS, DXcam, WGC) with custom settings. Assign templates to devices for optimal performance.
</span>
</p>
<div id="templates-list" class="templates-grid">
<div class="loading" data-i18n="templates.loading">Loading templates...</div>
</div>
</div>
</div>
<footer class="app-footer">
@@ -222,6 +234,12 @@
<small class="input-hint" data-i18n="settings.display_index.hint">Which screen to capture for this device</small>
</div>
<div class="form-group">
<label for="settings-capture-template" data-i18n="settings.capture_template">Capture Template:</label>
<select id="settings-capture-template"></select>
<small class="input-hint" data-i18n="settings.capture_template.hint">Screen capture engine and configuration for this device</small>
</div>
<div class="form-group">
<label for="settings-health-interval" data-i18n="settings.health_interval">Health Check Interval (s):</label>
<input type="number" id="settings-health-interval" min="5" max="600" value="30">
@@ -320,6 +338,102 @@
</div>
</div>
<!-- Template Modal -->
<div id="template-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="template-modal-title" data-i18n="templates.add">Add Capture Template</h2>
<button class="modal-close-btn" onclick="closeTemplateModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<input type="hidden" id="template-id">
<form id="template-form">
<div class="form-group">
<label for="template-name" data-i18n="templates.name">Template Name:</label>
<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-engine" data-i18n="templates.engine">Capture Engine:</label>
<select id="template-engine" onchange="onEngineChange()" required>
<option value="" data-i18n="templates.engine.select">Select an engine...</option>
</select>
<small id="engine-availability-hint" class="form-hint" style="display: none;"></small>
</div>
<div id="engine-config-section" style="display: none;">
<h3 data-i18n="templates.config">Engine Configuration</h3>
<div id="engine-config-fields"></div>
</div>
<div id="template-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeTemplateModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveTemplate()" title="Save">&#x2713;</button>
</div>
</div>
</div>
<!-- Test Template Modal -->
<div id="test-template-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 data-i18n="templates.test.title">Test Capture Template</h2>
<button class="modal-close-btn" onclick="closeTestTemplateModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="test-template-display" data-i18n="templates.test.display">Display:</label>
<select id="test-template-display">
<option value="" data-i18n="templates.test.display.select">Select display...</option>
</select>
</div>
<div class="form-group">
<label for="test-template-duration">
<span data-i18n="templates.test.duration">Capture Duration (s):</span>
<span id="test-template-duration-value">5</span>
</label>
<input type="range" id="test-template-duration" min="1" max="10" step="1" value="5" oninput="updateCaptureDuration(this.value)" />
</div>
<button type="button" class="btn btn-primary" onclick="runTemplateTest()" style="margin-top: 16px;">
<span data-i18n="templates.test.run">🧪 Run Test</span>
</button>
<div id="test-template-results" style="display: none; margin-top: 16px;">
<div class="test-results-container">
<div class="test-preview-section">
<div id="test-template-preview-image" class="test-preview-image"></div>
</div>
<div class="test-performance-section">
<div class="test-performance-stats">
<div class="stat-item">
<span data-i18n="templates.test.results.duration">Duration:</span>
<strong id="test-template-actual-duration">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.frame_count">Frames:</span>
<strong id="test-template-frame-count">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.actual_fps">Actual FPS:</span>
<strong id="test-template-actual-fps">-</strong>
</div>
<div class="stat-item">
<span data-i18n="templates.test.results.avg_capture_time">Avg Capture:</span>
<strong id="test-template-avg-capture-time">-</strong>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Device Tutorial Overlay (viewport-level) -->
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed">