diff --git a/server/src/wled_controller/api/routes/templates.py b/server/src/wled_controller/api/routes/templates.py index f29aea6..914f47c 100644 --- a/server/src/wled_controller/api/routes/templates.py +++ b/server/src/wled_controller/api/routes/templates.py @@ -12,6 +12,7 @@ from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( get_device_store, get_picture_source_store, + get_pp_template_store, get_processor_manager, get_template_store, ) @@ -385,26 +386,44 @@ async def test_template( # ===== FILTER TYPE ENDPOINTS ===== @router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"]) -async def list_filter_types(_auth: AuthRequired): +async def list_filter_types( + _auth: AuthRequired, + pp_store=Depends(get_pp_template_store), +): """List all available postprocessing filter types and their options schemas.""" all_filters = FilterRegistry.get_all() + + # Pre-build template choices for the filter_template filter + template_choices = None + if pp_store: + try: + templates = pp_store.get_all_templates() + template_choices = [{"value": t.id, "label": t.name} for t in templates] + except Exception: + template_choices = [] + responses = [] for filter_id, filter_cls in all_filters.items(): schema = filter_cls.get_options_schema() + opt_schemas = [] + for opt in schema: + choices = opt.choices + # Enrich filter_template choices with current template list + if filter_id == "filter_template" and opt.key == "template_id" and template_choices is not None: + choices = template_choices + opt_schemas.append(FilterOptionDefSchema( + key=opt.key, + label=opt.label, + type=opt.option_type, + default=opt.default, + min_value=opt.min_value, + max_value=opt.max_value, + step=opt.step, + choices=choices, + )) responses.append(FilterTypeResponse( filter_id=filter_cls.filter_id, filter_name=filter_cls.filter_name, - options_schema=[ - FilterOptionDefSchema( - key=opt.key, - label=opt.label, - type=opt.option_type, - default=opt.default, - min_value=opt.min_value, - max_value=opt.max_value, - step=opt.step, - ) - for opt in schema - ], + options_schema=opt_schemas, )) return FilterTypeListResponse(filters=responses, count=len(responses)) diff --git a/server/src/wled_controller/api/schemas/filters.py b/server/src/wled_controller/api/schemas/filters.py index 507b0d7..bab5045 100644 --- a/server/src/wled_controller/api/schemas/filters.py +++ b/server/src/wled_controller/api/schemas/filters.py @@ -1,6 +1,6 @@ """Filter-related schemas.""" -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field @@ -17,11 +17,12 @@ class FilterOptionDefSchema(BaseModel): key: str = Field(description="Option key") label: str = Field(description="Display label") - type: str = Field(description="Option type (float or int)") + type: str = Field(description="Option type (float, int, bool, or select)") default: Any = Field(description="Default value") min_value: Any = Field(description="Minimum value") max_value: Any = Field(description="Maximum value") step: Any = Field(description="Step increment") + choices: Optional[List[Dict[str, str]]] = Field(default=None, description="Available choices for select type") class FilterTypeResponse(BaseModel): diff --git a/server/src/wled_controller/core/filters/__init__.py b/server/src/wled_controller/core/filters/__init__.py index 43e6d43..bdef2fe 100644 --- a/server/src/wled_controller/core/filters/__init__.py +++ b/server/src/wled_controller/core/filters/__init__.py @@ -19,6 +19,7 @@ import wled_controller.core.filters.auto_crop # noqa: F401 import wled_controller.core.filters.flip # noqa: F401 import wled_controller.core.filters.color_correction # noqa: F401 import wled_controller.core.filters.frame_interpolation # noqa: F401 +import wled_controller.core.filters.filter_template # noqa: F401 __all__ = [ "FilterOptionDef", diff --git a/server/src/wled_controller/core/filters/base.py b/server/src/wled_controller/core/filters/base.py index 6db9f68..2d33010 100644 --- a/server/src/wled_controller/core/filters/base.py +++ b/server/src/wled_controller/core/filters/base.py @@ -13,14 +13,15 @@ class FilterOptionDef: key: str label: str - option_type: str # "float" | "int" + option_type: str # "float" | "int" | "bool" | "select" default: Any min_value: Any max_value: Any step: Any + choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}] def to_dict(self) -> dict: - return { + d = { "key": self.key, "label": self.label, "type": self.option_type, @@ -29,6 +30,9 @@ class FilterOptionDef: "max_value": self.max_value, "step": self.step, } + if self.choices is not None: + d["choices"] = self.choices + return d class PostprocessingFilter(ABC): @@ -80,10 +84,12 @@ class PostprocessingFilter(ABC): val = int(raw) elif opt_def.option_type == "bool": val = bool(raw) if not isinstance(raw, bool) else raw + elif opt_def.option_type == "select": + val = str(raw) if raw is not None else opt_def.default else: val = raw - # Clamp to range (skip for bools) - if opt_def.option_type != "bool": + # Clamp to range (skip for bools and selects) + if opt_def.option_type not in ("bool", "select"): if opt_def.min_value is not None and val < opt_def.min_value: val = opt_def.min_value if opt_def.max_value is not None and val > opt_def.max_value: diff --git a/server/src/wled_controller/core/filters/filter_template.py b/server/src/wled_controller/core/filters/filter_template.py new file mode 100644 index 0000000..4ae0e73 --- /dev/null +++ b/server/src/wled_controller/core/filters/filter_template.py @@ -0,0 +1,41 @@ +"""Filter Template meta-filter — references another postprocessing template. + +This filter exists in the registry for UI discovery only. It is never +instantiated at runtime: ``LiveStreamManager`` expands it into the +referenced template's filters when building the processing pipeline. +""" + +from typing import List, Optional + +import numpy as np + +from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter +from wled_controller.core.filters.image_pool import ImagePool +from wled_controller.core.filters.registry import FilterRegistry + + +@FilterRegistry.register +class FilterTemplateFilter(PostprocessingFilter): + """Include another filter template's chain at this position.""" + + filter_id = "filter_template" + filter_name = "Filter Template" + + @classmethod + def get_options_schema(cls) -> List[FilterOptionDef]: + return [ + FilterOptionDef( + key="template_id", + label="Template", + option_type="select", + default="", + min_value=None, + max_value=None, + step=None, + choices=[], # populated dynamically by GET /api/v1/filters + ), + ] + + def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: + # Never called — expanded at pipeline build time by LiveStreamManager. + return None diff --git a/server/src/wled_controller/core/processing/live_stream_manager.py b/server/src/wled_controller/core/processing/live_stream_manager.py index 79eac39..773ea30 100644 --- a/server/src/wled_controller/core/processing/live_stream_manager.py +++ b/server/src/wled_controller/core/processing/live_stream_manager.py @@ -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) diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 42e77bd..ad193af 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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 diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index c2d1022..5b51c3a 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -1175,6 +1175,22 @@ export function renderModalFilterList() { onchange="updateFilterOption(${index}, '${opt.key}', this.checked)"> `; + } else if (opt.type === 'select' && Array.isArray(opt.choices)) { + // Exclude the template being edited from filter_template choices (prevent self-reference) + const editingId = document.getElementById('pp-template-id')?.value || ''; + const filteredChoices = (fi.filter_id === 'filter_template' && opt.key === 'template_id' && editingId) + ? opt.choices.filter(c => c.value !== editingId) + : opt.choices; + const options = filteredChoices.map(c => + `` + ).join(''); + html += `