Add composable filter templates, skip keepalive for serial devices
Filter Template meta-filter: reference existing PP templates inside others for composable, DRY filter chains. Filters are recursively expanded at pipeline build time with cycle detection. New `select` option type with dynamic choices populated by the API. Keepalive optimization: serial devices (Adalight, AmbiLED) don't need keepalive — they hold last frame indefinitely. Check `standby_required` capability at processor start, skip keepalive sends for serial targets, and hide keepalive metrics in the UI. Rename "Standby Interval" to "Keep Alive Interval" throughout the frontend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -226,18 +226,12 @@ class LiveStreamManager:
|
||||
source_stream_id = config.source_stream_id
|
||||
source_live = self.acquire(source_stream_id)
|
||||
|
||||
# Resolve postprocessing filters
|
||||
# Resolve postprocessing filters (recursively expanding filter_template refs)
|
||||
filters = []
|
||||
if config.postprocessing_template_id and self._pp_template_store:
|
||||
try:
|
||||
pp = self._pp_template_store.get_template(config.postprocessing_template_id)
|
||||
for fi in pp.filters:
|
||||
try:
|
||||
filters.append(
|
||||
FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
|
||||
filters = self._resolve_filters(pp.filters)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"PP template {config.postprocessing_template_id} not found, no filters applied"
|
||||
@@ -245,6 +239,46 @@ class LiveStreamManager:
|
||||
|
||||
return ProcessedLiveStream(source_live, filters), source_stream_id
|
||||
|
||||
def _resolve_filters(self, filter_instances, _visited=None):
|
||||
"""Recursively resolve filter instances, expanding filter_template refs.
|
||||
|
||||
Args:
|
||||
filter_instances: List of FilterInstance configs.
|
||||
_visited: Set of template IDs already in the expansion stack
|
||||
(prevents circular references).
|
||||
|
||||
Returns:
|
||||
List of instantiated PostprocessingFilter objects.
|
||||
"""
|
||||
if _visited is None:
|
||||
_visited = set()
|
||||
resolved = []
|
||||
for fi in filter_instances:
|
||||
if fi.filter_id == "filter_template":
|
||||
template_id = fi.options.get("template_id", "")
|
||||
if not template_id or not self._pp_template_store:
|
||||
continue
|
||||
if template_id in _visited:
|
||||
logger.warning(
|
||||
f"Circular filter template reference detected: {template_id}, skipping"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
pp = self._pp_template_store.get_template(template_id)
|
||||
_visited.add(template_id)
|
||||
resolved.extend(self._resolve_filters(pp.filters, _visited))
|
||||
_visited.discard(template_id)
|
||||
except ValueError:
|
||||
logger.warning(f"Referenced filter template '{template_id}' not found, skipping")
|
||||
else:
|
||||
try:
|
||||
resolved.append(
|
||||
FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
|
||||
return resolved
|
||||
|
||||
def _create_static_image_live_stream(self, config) -> StaticImageLiveStream:
|
||||
"""Create a StaticImageLiveStream from a StaticImagePictureSource config."""
|
||||
image = self._load_static_image(config.image_source)
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from wled_controller.core.devices.led_client import LEDClient, create_led_client
|
||||
from wled_controller.core.devices.led_client import LEDClient, create_led_client, get_device_capabilities
|
||||
from wled_controller.core.capture.screen_capture import get_available_displays
|
||||
from wled_controller.core.processing.target_processor import (
|
||||
DeviceInfo,
|
||||
@@ -57,6 +57,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._color_strip_stream = None
|
||||
self._device_state_before: Optional[dict] = None
|
||||
self._overlay_active = False
|
||||
self._needs_keepalive = True # resolved at start from device capabilities
|
||||
|
||||
# Resolved stream metadata (set once stream is acquired)
|
||||
self._resolved_display_index: Optional[int] = None
|
||||
@@ -95,6 +96,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
f"device ({device_info.led_count} LEDs)"
|
||||
)
|
||||
self._device_state_before = await self._led_client.snapshot_device_state()
|
||||
self._needs_keepalive = "standby_required" in get_device_capabilities(device_info.device_type)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}")
|
||||
raise RuntimeError(f"Failed to connect to LED device: {e}")
|
||||
@@ -290,6 +292,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
"timing_total_ms": total_ms,
|
||||
"display_index": self._resolved_display_index,
|
||||
"overlay_active": self._overlay_active,
|
||||
"needs_keepalive": self._needs_keepalive,
|
||||
"last_update": metrics.last_update,
|
||||
"errors": [metrics.last_error] if metrics.last_error else [],
|
||||
}
|
||||
@@ -467,8 +470,8 @@ class WledTargetProcessor(TargetProcessor):
|
||||
continue
|
||||
|
||||
if colors is prev_colors:
|
||||
# Same frame — send keepalive if interval elapsed
|
||||
if prev_colors is not None and (loop_start - last_send_time) >= standby_interval:
|
||||
# Same frame — send keepalive if interval elapsed (only for devices that need it)
|
||||
if self._needs_keepalive and prev_colors is not None and (loop_start - last_send_time) >= standby_interval:
|
||||
if not self._is_running or self._led_client is None:
|
||||
break
|
||||
kc = prev_colors
|
||||
|
||||
Reference in New Issue
Block a user