Files
vf-cuda-grid/controller/cuda_grid_controller/dynamic_overlays.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

275 lines
10 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=2.0, ge=0.5, le=60.0)
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()
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:
registered = False
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 registered:
await self._register_overlay(cfg.id, cfg.target_instance, cfg.cell,
cfg.x, cfg.y, cfg.opacity, cfg.z_order)
registered = 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:
registered = False
last_signature: tuple[str, ...] = ()
while True:
try:
msgs = list(self._chat_messages[cfg.id])
sig = tuple(msgs)
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)
registered = True
await asyncio.sleep(0.5) # tight check для chat reactivity
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())