Files
vf-cuda-grid/controller/cuda_grid_controller/dispatch.py
T
gx 96e6048b64 controller: Phase 4b end-to-end working — wire format fix + FrigateBridge auto-overlay
Fixed pipeline (live verified с ffmpeg-vf-cuda-grid:phase4b-icon):

zmq_client.send_command — wrap args в single-quotes
  FFmpeg's zmq filter parses через av_get_token (libavfilter/f_zmq.c) — берёт
  ОДИН token как arg. Без quotes filter получает только id, parse fail.

dispatch._serialize_overlay_to_zmq — translation pydantic → filter wire:
  color "#RRGGBB"     → r=N g=N b=N
  opacity 0..1.0      → opacity 0..255
  border_only + width → thickness (0=filled, иначе width)
  dim_factor 0..1.0   → amount 0..255
  text/icon_name      → URL-encoded (%20 для space)

frigate_bridge — Phase 4b auto-rendering:
  motion ON       → add RectOverlay (orange border весь cell)
  motion OFF      → remove
  event new/update → add RectOverlay (bbox) + TextOverlay (label + score%)
  event end       → remove оба
  Bbox px → normalized через configurable camera_width/height (default 1920x1080).
  Опциональные flags motion_indicator/bbox_overlay в mapping.
  Constructor принимает dispatcher для dispatch.handle("overlay.add"/"overlay.remove").

mqtt_loop._handle_message — await frigate_bridge.handle_message (теперь async).
__main__.py — передаёт dispatcher в FrigateBridge constructor.

Verified end-to-end через test pipeline:
  pydantic Overlay → _serialize_overlay_to_zmq → send_command quoted →
  ZMQ → filter parse_overlay_args → CUDA kernel render
4× add_overlay (rect border, text, dim, rect filled) — все 4 visible в output frame.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 19:19:47 +01:00

270 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Command dispatch — между MQTT/HTTP командами и ZMQ выходом.
Action kinds:
layout.set — set_layout <name>
overlay.add — add overlay (JSON payload)
overlay.remove — remove overlay by id
overlay.clear — remove all overlays
"""
from __future__ import annotations
import json
import structlog
from .config import Config, InstanceCfg
from .layouts import PREDEFINED_LAYOUTS
from .overlays import Overlay
from .state import ControllerState
from .zmq_client import FFmpegZmqClient
log = structlog.get_logger()
def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
"""'#FF8800' → (255, 136, 0). Fallback (255, 255, 255) при невалидном input."""
s = hex_color.lstrip("#")
if len(s) != 6:
return (255, 255, 255)
try:
return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16))
except ValueError:
return (255, 255, 255)
def _serialize_overlay_to_zmq(overlay: Overlay) -> str:
"""Сериализовать overlay в строку для FFmpeg process_command.
Wire format: `<id> <type> <key>=<val>...`
Filter parser (libavfilter/vf_cuda_grid.c parse_overlay_args) ожидает
plain fields — поэтому translate:
color "#RRGGBB" → r=N g=N b=N
opacity 0..1.0 → opacity 0..255
border_only+width → thickness (0=filled, иначе border_width)
dim_factor 0..1 → amount 0..255
String values URL-encoded (%20 для space) — filter inline decode'ит.
"""
from urllib.parse import quote
r, g, b = 255, 255, 255 # default
parts: list[str] = [overlay.id, overlay.type]
# Common base fields
if overlay.cell is not None:
parts.append(f"cell={overlay.cell}")
parts.append(f"z_order={overlay.z_order}")
parts.append(f"opacity={int(round(overlay.opacity * 255))}")
parts.append(f"visible={1 if overlay.visible else 0}")
t = overlay.type
if t == "rect":
r, g, b = _hex_to_rgb(overlay.color)
thickness = overlay.border_width if overlay.border_only else 0
parts += [
f"x={overlay.x}", f"y={overlay.y}", f"w={overlay.w}", f"h={overlay.h}",
f"r={r}", f"g={g}", f"b={b}", f"thickness={thickness}",
]
elif t == "text":
r, g, b = _hex_to_rgb(overlay.color)
parts += [
f"x={overlay.x}", f"y={overlay.y}",
f"text={quote(overlay.text, safe='')}",
f"font_size={overlay.font_size}",
f"r={r}", f"g={g}", f"b={b}",
]
elif t == "icon":
parts += [
f"x={overlay.x}", f"y={overlay.y}",
f"icon_name={quote(overlay.name, safe='')}",
]
elif t == "dim":
# filter: amount = насколько затемнить (0..255). Маппится из dim_factor.
amount = int(round(overlay.dim_factor * 255))
parts += [
f"x={overlay.x}", f"y={overlay.y}", f"w={overlay.w}", f"h={overlay.h}",
f"amount={amount}",
]
elif t in ("image", "graph", "chat"):
# Phase 5+ — filter пока не support'ит. Просто отправляем что есть, filter
# skip-логирует (parse_overlay_args знает только rect/text/icon/dim).
pass
return " ".join(parts)
class CommandDispatcher:
def __init__(self, cfg: Config, state: ControllerState) -> None:
self.cfg = cfg
self.state = state
self._zmq_clients: dict[str, FFmpegZmqClient] = {}
self.on_state_change = None # type: ignore[var-annotated]
self.on_event = None # type: ignore[var-annotated]
def _client(self, inst: InstanceCfg) -> FFmpegZmqClient:
c = self._zmq_clients.get(inst.name)
if c is None:
c = FFmpegZmqClient(inst.zmq_endpoint)
self._zmq_clients[inst.name] = c
return c
def _find_instance(self, name: str) -> InstanceCfg | None:
return next((i for i in self.cfg.instances if i.name == name), None)
async def handle(self, instance_name: str, kind: str, payload: str) -> None:
inst = self._find_instance(instance_name)
if inst is None:
log.warning("dispatch.unknown_instance", instance=instance_name)
return
match kind:
case "layout.set":
await self._set_layout(inst, payload.strip())
case "overlay.add":
await self._overlay_add(inst, payload)
case "overlay.remove":
await self._overlay_remove(inst, payload.strip())
case "overlay.clear":
await self._overlay_clear(inst)
case _:
log.warning(
"dispatch.unknown_kind", instance=instance_name, kind=kind
)
# ─── Layout ────────────────────────────────────────────────────
async def _set_layout(self, inst: InstanceCfg, layout: str) -> None:
if layout not in PREDEFINED_LAYOUTS:
log.warning(
"dispatch.unknown_layout",
instance=inst.name,
layout=layout,
available=PREDEFINED_LAYOUTS,
)
return
old = await self.state.get_layout(inst.name)
client = self._client(inst)
try:
reply = await client.send_command(
inst.filter_target, "layout", layout
)
log.info(
"dispatch.layout_set",
instance=inst.name,
layout=layout,
ffmpeg_reply=reply,
)
except (TimeoutError, Exception) as e:
log.error("dispatch.zmq_fail", instance=inst.name, error=str(e))
return
await self.state.set_layout(inst.name, layout)
if self.on_state_change:
await self.on_state_change(inst.name, "layout", layout)
if self.on_event:
await self.on_event(
inst.name,
"layout_switched",
{"from": old, "to": layout, "reason": "mqtt"},
)
# ─── Overlays ──────────────────────────────────────────────────
async def _overlay_add(self, inst: InstanceCfg, payload: str) -> None:
"""Payload = JSON совместимый с Overlay discriminated union."""
from pydantic import TypeAdapter
try:
overlay: Overlay = TypeAdapter(Overlay).validate_json(payload)
except Exception as e:
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),
)
await self.state.add_overlay(inst.name, overlay)
if self.on_state_change:
count = len(await self.state.get_overlays(inst.name))
await self.on_state_change(inst.name, "overlays_count", str(count))
if self.on_event:
await self.on_event(
inst.name,
"overlay_added",
{"id": overlay.id, "type": overlay.type},
)
async def _overlay_remove(self, inst: InstanceCfg, overlay_id: str) -> None:
existed = await self.state.remove_overlay(inst.name, overlay_id)
if not existed:
log.warning(
"dispatch.overlay_unknown",
instance=inst.name,
id=overlay_id,
)
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))
log.info("dispatch.overlay_removed", instance=inst.name, id=overlay_id)
if self.on_state_change:
count = len(await self.state.get_overlays(inst.name))
await self.on_state_change(inst.name, "overlays_count", str(count))
if self.on_event:
await self.on_event(
inst.name, "overlay_removed", {"id": overlay_id}
)
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))
log.info("dispatch.overlays_cleared", instance=inst.name, count=n)
if self.on_state_change:
await self.on_state_change(inst.name, "overlays_count", "0")
if self.on_event:
await self.on_event(inst.name, "overlays_cleared", {"count": n})
# ─── Cleanup ───────────────────────────────────────────────────
async def close(self) -> None:
for c in self._zmq_clients.values():
await c.close()