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>
This commit is contained in:
@@ -22,30 +22,75 @@ from .zmq_client import FFmpegZmqClient
|
||||
log = structlog.get_logger()
|
||||
|
||||
|
||||
def _serialize_overlay_to_zmq(overlay: Overlay) -> str:
|
||||
"""Сериализовать overlay в одну строку для FFmpeg process_command.
|
||||
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)
|
||||
|
||||
Формат: `<id> <type> <key>=<val> <key>=<val> ...`
|
||||
String values URL-encoded (spaces → %20), filter-side decode'ит inline
|
||||
в parse_overlay_args. Nested values (style и т.п.) skip'аются — Phase 4b
|
||||
их не поддерживает.
|
||||
|
||||
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
|
||||
|
||||
parts = [overlay.id, overlay.type]
|
||||
data = overlay.model_dump()
|
||||
for key, value in data.items():
|
||||
if key in {"id", "type"}:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (dict, list)):
|
||||
continue # Phase 4b skipped — Phase 5 для nested style
|
||||
if isinstance(value, bool):
|
||||
value = 1 if value else 0
|
||||
if isinstance(value, str):
|
||||
value = quote(value, safe="") # encode spaces + всё кроме alnum
|
||||
parts.append(f"{key}={value}")
|
||||
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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user