diff --git a/controller/cuda_grid_controller/config.py b/controller/cuda_grid_controller/config.py index 4fe60ff..a39c339 100644 --- a/controller/cuda_grid_controller/config.py +++ b/controller/cuda_grid_controller/config.py @@ -67,7 +67,14 @@ class InstanceCfg(BaseModel): default_layout: str = "quad" filter_target: str = Field( default="Parsed_cuda_grid_0", - description="Filter target name в FFmpeg filter graph (для process_command)", + description="Default ZMQ target для process_command (используется reload_icon и др.)", + ) + overlay_filter_targets: list[str] = Field( + default_factory=list, + description="Список cuda_grid instances в pipeline (e.g. quad/single/mpp). " + "Overlay add/remove/clear broadcast ко всем — иначе при layout switch " + "overlays исчезают (каждый cuda_grid имеет own overlay state). " + "Если empty — fallback к [filter_target].", ) output_rtsp_url: str | None = Field( default=None, diff --git a/controller/cuda_grid_controller/dispatch.py b/controller/cuda_grid_controller/dispatch.py index 81d386f..f52df83 100644 --- a/controller/cuda_grid_controller/dispatch.py +++ b/controller/cuda_grid_controller/dispatch.py @@ -211,15 +211,12 @@ class CommandDispatcher: log.warning("dispatch.mpp_main_fail", instance=instance, error=str(e)) async def _reload_icon(self, instance: str, icon_name: str) -> None: - """Invalidate cached icon atlas в filter — used by DynamicRenderer.""" + """Invalidate cached icon atlas во всех cuda_grid instances — каждый + имеет свой кеш.""" inst = self._find_instance(instance) if inst is None: return - client = self._client(inst) - try: - await client.send_command(inst.filter_target, "reload_icon", icon_name) - except (TimeoutError, Exception) as e: - log.warning("dispatch.reload_icon_fail", instance=instance, icon=icon_name, error=str(e)) + await self._overlay_broadcast(inst, "reload_icon", icon_name) async def _intercom_set(self, inst: InstanceCfg, active: bool) -> None: """Ducking pattern: при intercom ON → music volume ↓ + intercom ↑. @@ -286,6 +283,24 @@ class CommandDispatcher: # ─── Overlays ────────────────────────────────────────────────── + def _overlay_targets(self, inst: InstanceCfg) -> list[str]: + """Если overlay_filter_targets настроен — broadcast'им ко всем + (нужно когда несколько cuda_grid instances в pipeline, например + layout switching). Иначе fallback к default filter_target.""" + return inst.overlay_filter_targets or [inst.filter_target] + + async def _overlay_broadcast(self, inst: InstanceCfg, cmd: str, arg: str) -> str | None: + """Send command к каждому overlay target. Return last reply (если успешный).""" + client = self._client(inst) + last_reply = None + for target in self._overlay_targets(inst): + try: + last_reply = await client.send_command(target, cmd, arg) + except (TimeoutError, Exception) as e: + log.warning("dispatch.overlay_broadcast_fail", + instance=inst.name, target=target, error=str(e)) + return last_reply + async def _overlay_add(self, inst: InstanceCfg, payload: str) -> None: """Payload = JSON совместимый с Overlay discriminated union.""" from pydantic import TypeAdapter @@ -296,29 +311,12 @@ class CommandDispatcher: log.warning("dispatch.overlay_parse_fail", error=str(e), payload=payload[:200]) return - # ZMQ send → filter (Phase 4b will actually render) - client = self._client(inst) - try: - zmq_arg = _serialize_overlay_to_zmq(overlay) - reply = await client.send_command( - inst.filter_target, "add_overlay", zmq_arg - ) - log.info( - "dispatch.overlay_add", - instance=inst.name, - id=overlay.id, - type=overlay.type, - ffmpeg_reply=reply, - ) - except (TimeoutError, Exception) as e: - # Filter side might not support yet — log warn but persist - # в state (controller behaves correctly даже если filter ignore'ит). - log.warning( - "dispatch.overlay_zmq_fail", - instance=inst.name, - id=overlay.id, - error=str(e), - ) + # Broadcast к ВСЕМ cuda_grid instances (если несколько в pipeline для + # layout switching) — каждый имеет свой overlay state. + zmq_arg = _serialize_overlay_to_zmq(overlay) + reply = await self._overlay_broadcast(inst, "add_overlay", zmq_arg) + log.info("dispatch.overlay_add", instance=inst.name, + id=overlay.id, type=overlay.type, ffmpeg_reply=reply) await self.state.add_overlay(inst.name, overlay) @@ -342,14 +340,7 @@ class CommandDispatcher: ) return - client = self._client(inst) - try: - await client.send_command( - inst.filter_target, "remove_overlay", overlay_id - ) - except (TimeoutError, Exception) as e: - log.warning("dispatch.overlay_remove_zmq_fail", error=str(e)) - + await self._overlay_broadcast(inst, "remove_overlay", overlay_id) log.info("dispatch.overlay_removed", instance=inst.name, id=overlay_id) if self.on_state_change: @@ -362,12 +353,7 @@ class CommandDispatcher: async def _overlay_clear(self, inst: InstanceCfg) -> None: n = await self.state.clear_overlays(inst.name) - client = self._client(inst) - try: - await client.send_command(inst.filter_target, "clear_overlays", "") - except (TimeoutError, Exception) as e: - log.warning("dispatch.overlay_clear_zmq_fail", error=str(e)) - + await self._overlay_broadcast(inst, "clear_overlays", "") log.info("dispatch.overlays_cleared", instance=inst.name, count=n) if self.on_state_change: diff --git a/controller/cuda_grid_controller/frigate_bridge.py b/controller/cuda_grid_controller/frigate_bridge.py index 77ab32b..61d2753 100644 --- a/controller/cuda_grid_controller/frigate_bridge.py +++ b/controller/cuda_grid_controller/frigate_bridge.py @@ -15,6 +15,7 @@ Bbox coords из Frigate event = absolute pixel в camera resolution. from __future__ import annotations +import asyncio import json from typing import TYPE_CHECKING @@ -77,7 +78,10 @@ class FrigateBridgeCfg(BaseModel): description="Auto-focus: dim non-active cells когда motion на одной") auto_layout: bool = Field(default=False, description="Auto-switch layout based on active motion + priorities. " - "0 active → quad, 1+ active → single c main=highest priority active") + "0 active → quad, 1 active → single, 2+ → mpp с highest-priority main") + auto_hysteresis_sec: float = Field(default=3.0, ge=0.0, le=60.0, + description="Debounce — apply layout change только если state стабилен N sec. " + "0 = немедленно. Защищает от short motion blips дёргающих layout") class FrigateBridge: @@ -98,6 +102,7 @@ class FrigateBridge: self._cell_states: dict[str, set[str]] = {} # ":" → set of active cameras с motion self._focus_dims: dict[str, set[int]] = {} # instance → set of cells which сейчас затемнены self._auto_state: dict[str, tuple[str, int]] = {} # instance → (current_layout, current_main_cam_index) + self._auto_pending: dict[str, asyncio.Task] = {} # instance → debounce task # Active per camera (для auto-layout decision) self._cam_active: dict[str, bool] = {m.frigate_camera: False for m in cfg.mappings} @@ -202,38 +207,57 @@ class FrigateBridge: self._cam_active[cam] = is_active await self._update_auto_layout(mapping.target_instance) - async def _update_auto_layout(self, instance: str) -> None: - """Auto-select layout + main_cam based на active cameras + priority.""" - if not self.cfg.auto_layout or not self.dispatcher: - return - # Active cameras для этого instance, отсортированы по priority desc + def _compute_target(self, instance: str) -> tuple[str, int, list[str]]: + """Returns (layout, main_cam_index, [active cam names sorted by priority]).""" active = [ m for m in self.cfg.mappings if m.target_instance == instance and self._cam_active.get(m.frigate_camera, False) ] active.sort(key=lambda m: -m.priority) - # Auto-layout logic v2: - # 0 active → quad (overview) - # 1 active → single, main_cam = тот один - # 2+ active → main_plus_preview, main_cam = highest priority active if not active: - target_layout = "quad" - target_main_cam = 0 - elif len(active) == 1: - target_layout = "single" - target_main_cam = active[0].main_cam_index - else: - target_layout = "main_plus_preview" - target_main_cam = active[0].main_cam_index # highest priority + return ("quad", 0, []) + if len(active) == 1: + return ("single", active[0].main_cam_index, [m.frigate_camera for m in active]) + return ("main_plus_preview", active[0].main_cam_index, [m.frigate_camera for m in active]) + async def _update_auto_layout(self, instance: str) -> None: + """Schedule debounced auto-layout update. Cancel any pending.""" + if not self.cfg.auto_layout or not self.dispatcher: + return + + hyst = self.cfg.auto_hysteresis_sec + + # Cancel pending — мы получили новый state event, timer reset'ится + pending = self._auto_pending.pop(instance, None) + if pending and not pending.done(): + pending.cancel() + + if hyst <= 0: + await self._apply_auto_layout(instance) + return + + # Schedule debounced apply + self._auto_pending[instance] = asyncio.create_task( + self._debounced_apply(instance, hyst) + ) + + async def _debounced_apply(self, instance: str, delay: float) -> None: + try: + await asyncio.sleep(delay) + await self._apply_auto_layout(instance) + except asyncio.CancelledError: + log.debug("auto_layout.debounce_cancel", instance=instance) + raise + + async def _apply_auto_layout(self, instance: str) -> None: + target_layout, target_main_cam, active_names = self._compute_target(instance) prev = self._auto_state.get(instance, (None, None)) if prev == (target_layout, target_main_cam): return log.info("auto_layout.change", instance=instance, from_state=prev, to_layout=target_layout, to_main=target_main_cam, - active=[m.frigate_camera for m in active]) - # Set both main_cam streamselects (single и mpp независимые) + active=active_names) await self.dispatcher.set_main_cam(instance, target_main_cam) await self.dispatcher.set_mpp_main(instance, target_main_cam) await self.dispatcher.handle(instance, "layout.set", target_layout)