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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user