controller: overlay broadcast ко всем cuda_grid + auto_hysteresis_sec

Issue: при layout switch overlays исчезали — каждый cuda_grid instance
(quad/single/mpp) имеет свой overlay state. add_overlay шёл только в один
target (cuda_grid@cg) → quad имел overlays, single/mpp без.

Fix:
  InstanceCfg.overlay_filter_targets: list[str] (e.g. [cuda_grid@cg,
    cuda_grid@cg_s, cuda_grid@cg_m]) — fallback к [filter_target] если empty.
  Dispatcher._overlay_broadcast(cmd, arg) — sends к каждому target.
  _overlay_add/remove/clear + _reload_icon now use broadcast.

Auto-layout debounce/hysteresis:
  FrigateBridgeCfg.auto_hysteresis_sec (default 3.0)
  _update_auto_layout schedules debounced apply через asyncio.Task;
  каждый новый state event cancels pending timer (reset). Apply только
  когда state стабилен N sec — short motion blips не дёргают layout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gx
2026-05-21 06:54:04 +01:00
parent 9080004d48
commit 48b24a04dd
3 changed files with 81 additions and 64 deletions
+8 -1
View File
@@ -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,
+29 -43
View File
@@ -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:
@@ -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]] = {} # "<inst>:<cell>" → 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)