Files
vf-cuda-grid/controller/cuda_grid_controller/zmq_client.py
T
gx d807cd2c23 controller: Phase 5b+5c+6 — multi-audio + intercom ducking + dynamic overlays
5b — audio source switching:
  AudioSourceCfg list + audio_filter_target в InstanceCfg
  CommandDispatcher._audio_set → ZMQ astreamselect@as map <index>
  REST: GET /audio/{inst}, POST /audio/{inst}/set
  MQTT: cuda_grid/cmd/<inst>/audio/set <source_name>

5c — intercom ducking:
  music_volume_target / intercom_volume_target / music_ducked_volume в InstanceCfg
  CommandDispatcher._intercom_set → 2× ZMQ volume@music/@intercom commands
  REST: POST /intercom/{inst}/start (music↓ + intercom↑) + /end (restore)
  MQTT: cuda_grid/cmd/<inst>/intercom/start|end

6 — dynamic overlays (charts/chats):
  dynamic_overlays.py: ChartCfg/ChatCfg + DynamicRenderer
  PIL rendering: line chart + scrolling text list
  Async loops пишут PNG в icon_dir + invalidate filter cache via reload_icon ZMQ
  MQTT subscriptions для real data (charts: numeric topic, chats: text topic)
  Demo: chart sine wave если data_topic=null
  Wired в __main__.py + mqtt_loop dispatch

+ ZMQ client asyncio.Lock — REQ socket strict send/recv pattern требует
  serialize requests (overlay/audio/intercom concurrent ломали "Operation
  cannot be accomplished in current state")
+ Pillow в Dockerfile (для PIL render)

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

87 lines
3.4 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.
"""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 asyncio
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
# REQ socket требует strict send→recv→send→recv pattern. Без lock'а
# concurrent send_command (overlay + audio) ломает state в "Operation
# cannot be accomplished in current state". Serialize requests.
self._lock = asyncio.Lock()
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)
async with self._lock:
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)
self._sock.close(linger=0)
self._sock = None
raise TimeoutError(f"zmq command timeout: {cmd_str}")
except Exception as e:
# Любая другая ошибка тоже ломает REQ state — сбрасываем socket
log.warning("zmq.error", endpoint=self.endpoint, error=str(e))
if self._sock is not None:
self._sock.close(linger=0)
self._sock = None
raise
async def close(self) -> None:
if self._sock is not None:
self._sock.close(linger=0)
self._sock = None