Files
vf-cuda-grid/controller/cuda_grid_controller/dynamic_overlays.py
T
gx 6081e33e5a controller: PipelineMonitor — auto-restore overlay state после pipeline restart
Pipeline filter state (overlays, layout, cell_map, audio) живёт в RAM
ffmpeg process. При recreate container (compose up, OOM, NVENC crash,
config change) state lost — controller'у нужно re-push.

Раньше user'у приходилось вручную:
  curl POST /layout/.../set
  docker restart cuda-grid-controller  # для browser/dynamic re-register

Теперь автоматизировано:
  PipelineMonitor polls ZMQ каждые 3 sec (no-op set_layout).
  On timeout/error → mark instance lost.
  First success after lost → trigger restore:
    1. set_layout к state.active_layout
    2. set_audio_output_enabled к state.audio_output_enabled
    3. re-push все overlays из state.overlays
    4. browser/dynamic/frigate hooks: mark_all_unregistered() —
       их loops автоматически re-add на next iteration

Verified test: docker restart cuda-grid-pipeline → 10 sec downtime →
monitor logs lost+restored+restore_done с count=6 overlays.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 07:35:27 +01:00

281 lines
11 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.
"""Dynamic overlay renderer (Phase 6).
Controller рендерит chart/chat overlays через PIL → PNG в shared volume,
затем посылает filter команду `reload_icon` чтобы invalidate cache. Filter
re-reads PNG file при следующем render.
Файлы кладутся в `${icon_dir}/${overlay_id}.png` где icon_dir = mount к
volume общий с pipeline (filter ищет тут).
"""
from __future__ import annotations
import asyncio
import math
import time
from collections import deque
from pathlib import Path
from typing import TYPE_CHECKING
import structlog
from PIL import Image, ImageDraw, ImageFont
from pydantic import BaseModel, Field
from .overlays import IconOverlay
if TYPE_CHECKING:
from .dispatch import CommandDispatcher
log = structlog.get_logger()
# ─── Config ───────────────────────────────────────────────────────────
class ChartCfg(BaseModel):
"""Live chart overlay."""
id: str = Field(description="Уникальное имя — становится PNG file name + overlay_id")
target_instance: str
cell: int | None = None # None = absolute
x: float = Field(ge=0.0, le=1.0)
y: float = Field(ge=0.0, le=1.0)
w_px: int = Field(default=320, ge=64, le=1920)
h_px: int = Field(default=120, ge=32, le=1080)
title: str = ""
data_topic: str | None = Field(
default=None,
description="MQTT topic с numeric payload. Если None — fake sine wave для demo.",
)
refresh_sec: float = Field(default=0.5, ge=0.05, le=60.0,
description="0.05 = 20 Hz max; 0.5 default = 2 Hz")
max_points: int = Field(default=60, ge=10, le=600)
line_color: tuple[int, int, int] = (0, 255, 128)
bg_color: tuple[int, int, int, int] = (0, 0, 0, 180)
opacity: float = 1.0
z_order: int = 25
class ChatCfg(BaseModel):
"""Scrolling text notifications."""
id: str
target_instance: str
cell: int | None = None
x: float = Field(ge=0.0, le=1.0)
y: float = Field(ge=0.0, le=1.0)
w_px: int = Field(default=400, ge=64, le=1920)
h_px: int = Field(default=200, ge=32, le=1080)
source_topic: str | None = Field(default=None, description="MQTT topic — каждое сообщение = новая строка")
max_messages: int = Field(default=10, ge=1, le=50)
font_size: int = Field(default=18, ge=10, le=64)
text_color: tuple[int, int, int] = (255, 255, 255)
bg_color: tuple[int, int, int, int] = (0, 0, 0, 150)
opacity: float = 1.0
z_order: int = 26
# ─── Renderers ────────────────────────────────────────────────────────
class _FontCache:
_font: ImageFont.FreeTypeFont | None = None
@classmethod
def get(cls, size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
try:
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size=size)
except OSError:
return ImageFont.load_default()
def render_chart(cfg: ChartCfg, data: list[float]) -> Image.Image:
"""Line chart с title. Returns RGBA Image."""
img = Image.new("RGBA", (cfg.w_px, cfg.h_px), cfg.bg_color)
draw = ImageDraw.Draw(img)
pad = 8
# Title
if cfg.title:
font = _FontCache.get(14)
draw.text((pad, 2), cfg.title, fill=(255, 255, 255), font=font)
chart_top = 20
else:
chart_top = pad
chart_bottom = cfg.h_px - pad
chart_left = pad
chart_right = cfg.w_px - pad
chart_h = chart_bottom - chart_top
chart_w = chart_right - chart_left
# Border
draw.rectangle([chart_left, chart_top, chart_right, chart_bottom],
outline=(128, 128, 128, 200), width=1)
if len(data) >= 2:
lo, hi = min(data), max(data)
span = (hi - lo) or 1.0
points: list[tuple[float, float]] = []
for i, v in enumerate(data):
px = chart_left + (i / (len(data) - 1)) * chart_w
py = chart_bottom - ((v - lo) / span) * chart_h
points.append((px, py))
draw.line(points, fill=cfg.line_color, width=2)
# Last value label
font = _FontCache.get(12)
last = data[-1]
draw.text((chart_right - 50, chart_top + 2), f"{last:.1f}",
fill=cfg.line_color, font=font)
return img
def render_chat(cfg: ChatCfg, messages: list[str]) -> Image.Image:
"""Vertical list — last N messages at bottom."""
img = Image.new("RGBA", (cfg.w_px, cfg.h_px), cfg.bg_color)
draw = ImageDraw.Draw(img)
font = _FontCache.get(cfg.font_size)
pad = 6
line_h = cfg.font_size + 4
visible = messages[-cfg.max_messages:]
y = cfg.h_px - pad - line_h
for msg in reversed(visible):
if y < pad:
break
draw.text((pad, y), msg[:80], fill=cfg.text_color, font=font)
y -= line_h
return img
# ─── Runner ──────────────────────────────────────────────────────────
class DynamicRenderer:
"""Управляет рендерингом charts/chats — пишет PNG + посылает reload_icon."""
def __init__(
self,
icon_dir: Path,
dispatcher: "CommandDispatcher",
charts: list[ChartCfg],
chats: list[ChatCfg],
) -> None:
self.icon_dir = icon_dir
self.dispatcher = dispatcher
self.charts = charts
self.chats = chats
# State
self._chart_data: dict[str, deque[float]] = {
c.id: deque(maxlen=c.max_points) for c in charts
}
self._chat_messages: dict[str, deque[str]] = {
c.id: deque(maxlen=c.max_messages) for c in chats
}
self._tasks: list[asyncio.Task] = []
self._start_time = time.time()
self._registered: dict[str, bool] = {} # overlay_id → registered
def mark_all_unregistered(self) -> None:
"""Pipeline restart hook — clear registered flags чтобы loops
re-add overlays на next iteration."""
self._registered.clear()
def topics_to_subscribe(self) -> list[str]:
topics = []
for c in self.charts:
if c.data_topic:
topics.append(c.data_topic)
for c in self.chats:
if c.source_topic:
topics.append(c.source_topic)
return topics
def handle_message(self, topic: str, payload: str) -> None:
"""Update buffer when MQTT data arrives. Caller — mqtt_loop."""
for c in self.charts:
if c.data_topic == topic:
try:
self._chart_data[c.id].append(float(payload.strip()))
except ValueError:
log.warning("dynamic.bad_chart_value", id=c.id, payload=payload[:50])
return
for c in self.chats:
if c.source_topic == topic:
self._chat_messages[c.id].append(payload.strip())
return
async def start(self) -> None:
"""Spawn render tasks per overlay."""
self.icon_dir.mkdir(parents=True, exist_ok=True)
for cfg in self.charts:
self._tasks.append(asyncio.create_task(self._chart_loop(cfg)))
for cfg in self.chats:
self._tasks.append(asyncio.create_task(self._chat_loop(cfg)))
log.info("dynamic.started", charts=len(self.charts), chats=len(self.chats))
async def stop(self) -> None:
for t in self._tasks:
t.cancel()
for t in self._tasks:
try:
await t
except asyncio.CancelledError:
pass
self._tasks.clear()
async def _chart_loop(self, cfg: ChartCfg) -> None:
while True:
try:
buf = self._chart_data[cfg.id]
# Demo data если нет MQTT topic
if cfg.data_topic is None:
t = time.time() - self._start_time
buf.append(20.0 + 10.0 * math.sin(t / 5))
await self._render_and_publish(cfg, lambda: render_chart(cfg, list(buf)))
if not self._registered.get(cfg.id, False):
await self._register_overlay(cfg.id, cfg.target_instance, cfg.cell,
cfg.x, cfg.y, cfg.opacity, cfg.z_order)
self._registered[cfg.id] = True
await asyncio.sleep(cfg.refresh_sec)
except asyncio.CancelledError:
raise
except Exception as e:
log.error("dynamic.chart_loop_fail", id=cfg.id, error=str(e))
await asyncio.sleep(5)
async def _chat_loop(self, cfg: ChatCfg) -> None:
last_signature: tuple[str, ...] = ()
while True:
try:
msgs = list(self._chat_messages[cfg.id])
sig = tuple(msgs)
registered = self._registered.get(cfg.id, False)
if sig != last_signature or not registered:
await self._render_and_publish(cfg, lambda: render_chat(cfg, msgs))
last_signature = sig
if not registered:
await self._register_overlay(cfg.id, cfg.target_instance, cfg.cell,
cfg.x, cfg.y, cfg.opacity, cfg.z_order)
self._registered[cfg.id] = True
await asyncio.sleep(0.1) # tight check для chat reactivity (10 Hz)
except asyncio.CancelledError:
raise
except Exception as e:
log.error("dynamic.chat_loop_fail", id=cfg.id, error=str(e))
await asyncio.sleep(5)
async def _render_and_publish(self, cfg, render_fn) -> None:
path = self.icon_dir / f"{cfg.id}.png"
tmp = path.with_suffix(".png.tmp")
img = render_fn()
img.save(tmp, "PNG")
tmp.replace(path)
# Tell filter to invalidate cached atlas — next render re-reads file
await self.dispatcher._reload_icon(cfg.target_instance, cfg.id)
async def _register_overlay(self, ov_id, instance, cell, x, y, opacity, z_order) -> None:
"""Initial register — add IconOverlay referencing icon_name=<id>."""
ov = IconOverlay(
id=ov_id, cell=cell, name=ov_id, # icon name = same as overlay id
x=x, y=y, size=0.1, # size игнорируется filter'ом — use native PNG dims
opacity=opacity, z_order=z_order,
)
await self.dispatcher.handle(instance, "overlay.add", ov.model_dump_json())