Add Picture Streams architecture with postprocessing templates and stream test UI

Introduce Picture Stream abstraction that separates the capture pipeline into
composable layers: raw streams (display + capture engine + FPS) and processed
streams (source stream + postprocessing template). Devices reference a picture
stream instead of managing individual capture settings.

- Add PictureStream and PostprocessingTemplate data models and stores
- Add CRUD API endpoints for picture streams and postprocessing templates
- Add stream chain resolution in ProcessorManager for start_processing
- Add picture stream test endpoint with postprocessing preview support
- Add Stream Settings modal with border_width and interpolation_mode controls
- Add stream test modal with capture preview and performance metrics
- Add full frontend: Picture Streams tab, Processing Templates tab, stream
  selector on device cards, test buttons on stream cards
- Add localization keys for all new features (en, ru)
- Migrate existing devices to picture streams on startup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 00:00:30 +03:00
parent 3db7ba4b0e
commit 493f14fba9
23 changed files with 2773 additions and 200 deletions

View File

@@ -39,6 +39,7 @@ class CaptureEngine(ABC):
"""
ENGINE_TYPE: str = "base" # Override in subclasses
ENGINE_PRIORITY: int = 0 # Higher = preferred. Override in subclasses.
def __init__(self, config: Dict[str, Any]):
"""Initialize engine with configuration.

View File

@@ -27,6 +27,7 @@ class DXcamEngine(CaptureEngine):
"""
ENGINE_TYPE = "dxcam"
ENGINE_PRIORITY = 3
def __init__(self, config: Dict[str, Any]):
"""Initialize DXcam engine."""

View File

@@ -1,6 +1,6 @@
"""Engine registry and factory for screen capture engines."""
from typing import Any, Dict, List, Type
from typing import Any, Dict, List, Optional, Type
from wled_controller.core.capture_engines.base import CaptureEngine
from wled_controller.utils import get_logger
@@ -85,6 +85,26 @@ class EngineRegistry:
return available
@classmethod
def get_best_available_engine(cls) -> Optional[str]:
"""Get the highest-priority available engine type.
Returns:
Engine type string, or None if no engines are available.
"""
best_type = None
best_priority = -1
for engine_type, engine_class in cls._engines.items():
try:
if engine_class.is_available() and engine_class.ENGINE_PRIORITY > best_priority:
best_priority = engine_class.ENGINE_PRIORITY
best_type = engine_type
except Exception as e:
logger.error(
f"Error checking availability for engine '{engine_type}': {e}"
)
return best_type
@classmethod
def get_all_engines(cls) -> Dict[str, Type[CaptureEngine]]:
"""Get all registered engines (available or not).

View File

@@ -26,6 +26,7 @@ class MSSEngine(CaptureEngine):
"""
ENGINE_TYPE = "mss"
ENGINE_PRIORITY = 1
def __init__(self, config: Dict[str, Any]):
"""Initialize MSS engine.

View File

@@ -34,6 +34,7 @@ class WGCEngine(CaptureEngine):
"""
ENGINE_TYPE = "wgc"
ENGINE_PRIORITY = 2
def __init__(self, config: Dict[str, Any]):
"""Initialize WGC engine.

View File

@@ -88,10 +88,11 @@ class ProcessorState:
led_count: int
settings: ProcessingSettings
calibration: CalibrationConfig
capture_template_id: str = "tpl_mss_default" # NEW: template ID for capture engine
capture_template_id: str = ""
picture_stream_id: str = ""
wled_client: Optional[WLEDClient] = None
pixel_mapper: Optional[PixelMapper] = None
capture_engine: Optional[CaptureEngine] = None # NEW: initialized capture engine
capture_engine: Optional[CaptureEngine] = None
is_running: bool = False
task: Optional[asyncio.Task] = None
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
@@ -100,16 +101,34 @@ class ProcessorState:
test_mode_active: bool = False
test_mode_edges: Dict[str, Tuple[int, int, int]] = field(default_factory=dict)
health_task: Optional[asyncio.Task] = None
# Resolved stream values (populated at start_processing time)
resolved_display_index: Optional[int] = None
resolved_target_fps: Optional[int] = None
resolved_engine_type: Optional[str] = None
resolved_engine_config: Optional[dict] = None
resolved_gamma: Optional[float] = None
resolved_saturation: Optional[float] = None
resolved_brightness: Optional[float] = None
resolved_smoothing: Optional[float] = None
class ProcessorManager:
"""Manages screen processing for multiple WLED devices."""
def __init__(self):
"""Initialize processor manager."""
def __init__(self, picture_stream_store=None, capture_template_store=None, pp_template_store=None):
"""Initialize processor manager.
Args:
picture_stream_store: PictureStreamStore instance (for stream resolution)
capture_template_store: TemplateStore instance (for engine lookup)
pp_template_store: PostprocessingTemplateStore instance (for PP settings)
"""
self._processors: Dict[str, ProcessorState] = {}
self._health_monitoring_active = False
self._http_client: Optional[httpx.AsyncClient] = None
self._picture_stream_store = picture_stream_store
self._capture_template_store = capture_template_store
self._pp_template_store = pp_template_store
logger.info("Processor manager initialized")
async def _get_http_client(self) -> httpx.AsyncClient:
@@ -125,7 +144,8 @@ class ProcessorManager:
led_count: int,
settings: Optional[ProcessingSettings] = None,
calibration: Optional[CalibrationConfig] = None,
capture_template_id: str = "tpl_mss_default",
capture_template_id: str = "",
picture_stream_id: str = "",
):
"""Add a device for processing.
@@ -135,7 +155,8 @@ class ProcessorManager:
led_count: Number of LEDs
settings: Processing settings (uses defaults if None)
calibration: Calibration config (creates default if None)
capture_template_id: Template ID for screen capture engine
capture_template_id: Legacy template ID for screen capture engine
picture_stream_id: Picture stream ID (preferred over capture_template_id)
"""
if device_id in self._processors:
raise ValueError(f"Device {device_id} already exists")
@@ -153,6 +174,7 @@ class ProcessorManager:
settings=settings,
calibration=calibration,
capture_template_id=capture_template_id,
picture_stream_id=picture_stream_id,
)
self._processors[device_id] = state
@@ -245,9 +267,81 @@ class ProcessorManager:
logger.info(f"Updated calibration for device {device_id}")
def _resolve_stream_settings(self, state: ProcessorState):
"""Resolve picture stream chain to populate resolved_* fields on state.
If device has a picture_stream_id and stores are available, resolves the
stream chain to get display_index, fps, engine type/config, and PP settings.
Otherwise falls back to legacy device settings.
"""
if state.picture_stream_id and self._picture_stream_store:
try:
chain = self._picture_stream_store.resolve_stream_chain(state.picture_stream_id)
raw_stream = chain["raw_stream"]
pp_template_ids = chain["postprocessing_template_ids"]
state.resolved_display_index = raw_stream.display_index
state.resolved_target_fps = raw_stream.target_fps
# Resolve capture engine from raw stream's capture template
if raw_stream.capture_template_id and self._capture_template_store:
try:
tpl = self._capture_template_store.get_template(raw_stream.capture_template_id)
state.resolved_engine_type = tpl.engine_type
state.resolved_engine_config = tpl.engine_config
except ValueError:
logger.warning(f"Capture template {raw_stream.capture_template_id} not found, using MSS fallback")
state.resolved_engine_type = "mss"
state.resolved_engine_config = {}
# Resolve postprocessing: use first PP template in chain
if pp_template_ids and self._pp_template_store:
try:
pp = self._pp_template_store.get_template(pp_template_ids[0])
state.resolved_gamma = pp.gamma
state.resolved_saturation = pp.saturation
state.resolved_brightness = pp.brightness
state.resolved_smoothing = pp.smoothing
except ValueError:
logger.warning(f"PP template {pp_template_ids[0]} not found, using defaults")
logger.info(
f"Resolved stream chain for {state.device_id}: "
f"display={state.resolved_display_index}, fps={state.resolved_target_fps}, "
f"engine={state.resolved_engine_type}, pp_templates={len(pp_template_ids)}"
)
return
except ValueError as e:
logger.warning(f"Failed to resolve stream {state.picture_stream_id}: {e}, falling back to legacy settings")
# Fallback: use legacy device settings
state.resolved_display_index = state.settings.display_index
state.resolved_target_fps = state.settings.fps
state.resolved_gamma = state.settings.gamma
state.resolved_saturation = state.settings.saturation
state.resolved_brightness = state.settings.brightness
state.resolved_smoothing = state.settings.smoothing
# Resolve engine from legacy capture_template_id
if state.capture_template_id and self._capture_template_store:
try:
tpl = self._capture_template_store.get_template(state.capture_template_id)
state.resolved_engine_type = tpl.engine_type
state.resolved_engine_config = tpl.engine_config
except ValueError:
logger.warning(f"Capture template {state.capture_template_id} not found, using MSS fallback")
state.resolved_engine_type = "mss"
state.resolved_engine_config = {}
else:
state.resolved_engine_type = "mss"
state.resolved_engine_config = {}
async def start_processing(self, device_id: str):
"""Start screen processing for a device.
Resolves the picture stream chain (if assigned) to determine capture engine,
display, FPS, and postprocessing settings. Falls back to legacy device settings.
Args:
device_id: Device identifier
@@ -263,9 +357,11 @@ class ProcessorManager:
if state.is_running:
raise RuntimeError(f"Processing already running for device {device_id}")
# Resolve stream settings
self._resolve_stream_settings(state)
# Connect to WLED device
try:
# Enable DDP for large LED counts (>500 LEDs)
use_ddp = state.led_count > 500
state.wled_client = WLEDClient(state.device_url, use_ddp=use_ddp)
await state.wled_client.connect()
@@ -276,17 +372,16 @@ class ProcessorManager:
logger.error(f"Failed to connect to WLED device {device_id}: {e}")
raise RuntimeError(f"Failed to connect to WLED device: {e}")
# Initialize capture engine
# Phase 2: Use MSS engine for all devices (template integration in Phase 5)
# Initialize capture engine from resolved settings
try:
# For now, always use MSS engine (Phase 5 will load from template)
engine = EngineRegistry.create_engine("mss", {})
engine_type = state.resolved_engine_type or "mss"
engine_config = state.resolved_engine_config or {}
engine = EngineRegistry.create_engine(engine_type, engine_config)
engine.initialize()
state.capture_engine = engine
logger.debug(f"Initialized capture engine for device {device_id}: mss")
logger.info(f"Initialized capture engine for device {device_id}: {engine_type}")
except Exception as e:
logger.error(f"Failed to initialize capture engine for device {device_id}: {e}")
# Cleanup WLED client before raising
if state.wled_client:
await state.wled_client.disconnect()
raise RuntimeError(f"Failed to initialize capture engine: {e}")
@@ -352,18 +447,31 @@ class ProcessorManager:
async def _processing_loop(self, device_id: str):
"""Main processing loop for a device.
Args:
device_id: Device identifier
Uses resolved_* fields from stream resolution for display, FPS,
and postprocessing. Falls back to device settings for LED projection
parameters (border_width, interpolation_mode) and WLED brightness.
"""
state = self._processors[device_id]
settings = state.settings
# Use resolved values (populated by _resolve_stream_settings)
display_index = state.resolved_display_index or settings.display_index
target_fps = state.resolved_target_fps or settings.fps
gamma = state.resolved_gamma if state.resolved_gamma is not None else settings.gamma
saturation = state.resolved_saturation if state.resolved_saturation is not None else settings.saturation
pp_brightness = state.resolved_brightness if state.resolved_brightness is not None else settings.brightness
smoothing = state.resolved_smoothing if state.resolved_smoothing is not None else settings.smoothing
# These always come from device settings (LED projection)
border_width = settings.border_width
wled_brightness = settings.brightness # WLED hardware brightness
logger.info(
f"Processing loop started for {device_id} "
f"(display={settings.display_index}, fps={settings.fps})"
f"(display={display_index}, fps={target_fps})"
)
frame_time = 1.0 / settings.fps
frame_time = 1.0 / target_fps
fps_samples = []
try:
@@ -376,39 +484,38 @@ class ProcessorManager:
continue
try:
# Run blocking operations in thread pool to avoid blocking event loop
# Capture screen using engine (blocking I/O)
# Capture screen using engine
capture = await asyncio.to_thread(
state.capture_engine.capture_display,
settings.display_index
display_index
)
# Extract border pixels (CPU-intensive)
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, settings.border_width)
# Extract border pixels
border_pixels = await asyncio.to_thread(extract_border_pixels, capture, border_width)
# Map to LED colors (CPU-intensive)
# Map to LED colors
led_colors = await asyncio.to_thread(state.pixel_mapper.map_border_to_leds, border_pixels)
# Apply color correction (CPU-intensive)
# Apply color correction from postprocessing
led_colors = await asyncio.to_thread(
apply_color_correction,
led_colors,
gamma=settings.gamma,
saturation=settings.saturation,
brightness=settings.brightness,
gamma=gamma,
saturation=saturation,
brightness=pp_brightness,
)
# Apply smoothing (CPU-intensive)
if state.previous_colors and settings.smoothing > 0:
# Apply smoothing from postprocessing
if state.previous_colors and smoothing > 0:
led_colors = await asyncio.to_thread(
smooth_colors,
led_colors,
state.previous_colors,
settings.smoothing,
smoothing,
)
# Send to WLED with brightness
brightness_value = int(settings.brightness * 255)
# Send to WLED with device brightness
brightness_value = int(wled_brightness * 255)
await state.wled_client.send_pixels(led_colors, brightness=brightness_value)
# Update metrics
@@ -468,8 +575,8 @@ class ProcessorManager:
"device_id": device_id,
"processing": state.is_running,
"fps_actual": metrics.fps_actual if state.is_running else None,
"fps_target": state.settings.fps,
"display_index": state.settings.display_index,
"fps_target": state.resolved_target_fps or state.settings.fps,
"display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index,
"last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
"wled_online": h.online,