96e6048b64
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>
74 lines
2.8 KiB
Python
74 lines
2.8 KiB
Python
"""ZMQ клиент к FFmpeg's `zmq` filter.
|
|
|
|
FFmpeg zmq filter принимает строки формата:
|
|
`target command [args]`
|
|
|
|
Например для cuda_grid в filter graph:
|
|
`cuda_grid@livingroom_tv set_layout quad`
|
|
|
|
См. https://ffmpeg.org/ffmpeg-filters.html#zmq_002c-azmq
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import structlog
|
|
import zmq
|
|
import zmq.asyncio
|
|
|
|
log = structlog.get_logger()
|
|
|
|
|
|
class FFmpegZmqClient:
|
|
"""REQ-socket к FFmpeg zmq filter. Один client = один FFmpeg pipeline."""
|
|
|
|
def __init__(self, endpoint: str, request_timeout_ms: int = 2000) -> None:
|
|
self.endpoint = endpoint
|
|
self.request_timeout_ms = request_timeout_ms
|
|
self._ctx = zmq.asyncio.Context.instance()
|
|
self._sock: zmq.asyncio.Socket | None = None
|
|
|
|
async def connect(self) -> None:
|
|
if self._sock is not None:
|
|
return
|
|
self._sock = self._ctx.socket(zmq.REQ)
|
|
self._sock.setsockopt(zmq.LINGER, 0)
|
|
self._sock.setsockopt(zmq.RCVTIMEO, self.request_timeout_ms)
|
|
self._sock.setsockopt(zmq.SNDTIMEO, self.request_timeout_ms)
|
|
self._sock.connect(self.endpoint)
|
|
log.info("zmq.connected", endpoint=self.endpoint)
|
|
|
|
async def send_command(self, target: str, command: str, args: str | None = None) -> str:
|
|
"""Отправить команду filter'у. Возвращает ответ от ffmpeg ('0 Success' / error string).
|
|
|
|
FFmpeg's zmq filter parses через `av_get_token` (libavfilter/f_zmq.c) — берёт
|
|
ОДИН token как arg. Если arg содержит пробелы (наш case для add_overlay),
|
|
нужно обернуть в single-quotes. av_get_token honours quoting + escape '\\''.
|
|
"""
|
|
if self._sock is None:
|
|
await self.connect()
|
|
assert self._sock is not None
|
|
|
|
cmd_str = f"{target} {command}"
|
|
if args:
|
|
# Escape embedded single-quotes (rare для наших args, но safe).
|
|
escaped = args.replace("'", r"\'")
|
|
cmd_str = f"{cmd_str} '{escaped}'"
|
|
|
|
log.debug("zmq.send", endpoint=self.endpoint, cmd=cmd_str)
|
|
try:
|
|
await self._sock.send_string(cmd_str)
|
|
reply = await self._sock.recv_string()
|
|
log.debug("zmq.reply", reply=reply)
|
|
return reply
|
|
except zmq.error.Again:
|
|
log.warning("zmq.timeout", endpoint=self.endpoint, cmd=cmd_str)
|
|
# Reset REQ socket state — после timeout REQ нельзя re-use
|
|
self._sock.close(linger=0)
|
|
self._sock = None
|
|
raise TimeoutError(f"zmq command timeout: {cmd_str}")
|
|
|
|
async def close(self) -> None:
|
|
if self._sock is not None:
|
|
self._sock.close(linger=0)
|
|
self._sock = None
|