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:
2026-02-23 01:48:23 +03:00
parent a4083764fb
commit 9e555cef2e
12 changed files with 178 additions and 51 deletions

View File

@@ -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)

View File

@@ -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