controller: browser-rendered overlays — Grafana/chat/любой HTML

MVP Phase 8 feature: headless Chromium snapshot URL → PNG → existing icon
overlay infrastructure. Use case — Grafana dashboard, web chat, любой
HTML widget с transparent background поверх video composite.

Архитектура:
  BrowserRenderer launches один shared Chromium instance, per-dashboard
  page. Loop:
    page.reload (для свежих данных — Grafana auto-refresh не triggers
                 в headless mode без user interaction)
    page.add_style_tag (re-inject transparent CSS — reload teryает styles)
    page.wait_for_selector (если selector задан)
    page.screenshot(omit_background=True) или locator(selector).screenshot
    save PNG к icon_dir
    dispatcher._reload_icon → filter re-reads atlas

Config (dynamic_overlays.dashboards в controller.yaml):
  id, target_instance, cell, url, x, y, w_px, h_px,
  refresh_sec (default 2.0s, min 0.5s — frequent reload bad см. stutter memory),
  inject_css (default — transparent background + zero margins),
  selector (optional CSS selector — экспортить только конкретный element),
  viewport_w/h (override page viewport, default = w_px/h_px;
                полезно при selector чтобы page layout не collapsed),
  wait_until, page_timeout_ms, opacity, z_order.

Dockerfile:
  + pip install playwright
  + playwright install --with-deps chromium (~170MB Chromium + ~150MB system libs)

Если dashboards не нужны — можно убрать оба install'а (playwright
import lazy, BrowserRenderer.start() graceful no-op без playwright).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gx
2026-05-21 23:09:50 +01:00
parent 48eb62bddc
commit 19ddaf2dde
3 changed files with 259 additions and 1 deletions
+8 -1
View File
@@ -28,7 +28,14 @@ RUN pip install --no-cache-dir \
typer \ typer \
sse-starlette \ sse-starlette \
pillow \ pillow \
httpx httpx \
playwright
# Playwright headless Chromium для browser-rendered overlays (Grafana, чаты).
# install-deps добавляет system libs (libnss3, libxkbcommon0, libgbm1...).
# Chromium ~170 MB + system deps ~150 MB. Можно отключить если dashboards
# не нужны: убрать `playwright` из pip и закомментировать ниже.
RUN playwright install --with-deps chromium
# Source code # Source code
COPY cuda_grid_controller ./cuda_grid_controller COPY cuda_grid_controller ./cuda_grid_controller
@@ -13,6 +13,7 @@ import uvicorn
from .config import Config from .config import Config
from .dispatch import CommandDispatcher from .dispatch import CommandDispatcher
from .browser_overlays import BrowserRenderer, DashboardCfg
from .dynamic_overlays import ChartCfg, ChatCfg, DynamicRenderer from .dynamic_overlays import ChartCfg, ChatCfg, DynamicRenderer
from .frigate_bridge import FrigateBridge, FrigateBridgeCfg from .frigate_bridge import FrigateBridge, FrigateBridgeCfg
from .http_api import create_app from .http_api import create_app
@@ -60,6 +61,7 @@ async def _run(cfg: Config) -> None:
# Dynamic overlays (charts/chats) — Phase 6 # Dynamic overlays (charts/chats) — Phase 6
dynamic_renderer: DynamicRenderer | None = None dynamic_renderer: DynamicRenderer | None = None
browser_renderer: BrowserRenderer | None = None
if cfg.dynamic_overlays: if cfg.dynamic_overlays:
try: try:
d = cfg.dynamic_overlays d = cfg.dynamic_overlays
@@ -72,6 +74,14 @@ async def _run(cfg: Config) -> None:
charts=charts, charts=charts,
chats=chats, chats=chats,
) )
dashboards = [DashboardCfg.model_validate(b)
for b in (d.get("dashboards") or [])]
if dashboards:
browser_renderer = BrowserRenderer(
icon_dir=Path(cfg.icon_dir),
dispatcher=dispatcher,
dashboards=dashboards,
)
except Exception as e: except Exception as e:
structlog.get_logger().warning("dynamic_overlays.config_invalid", error=str(e)) structlog.get_logger().warning("dynamic_overlays.config_invalid", error=str(e))
@@ -121,6 +131,8 @@ async def _run(cfg: Config) -> None:
# Start dynamic renderer задачи (если есть) # Start dynamic renderer задачи (если есть)
if dynamic_renderer: if dynamic_renderer:
await dynamic_renderer.start() await dynamic_renderer.start()
if browser_renderer:
await browser_renderer.start()
await snapshot_hist.start() await snapshot_hist.start()
if watchdog: if watchdog:
watchdog._publish_event = mqtt.publish_event watchdog._publish_event = mqtt.publish_event
@@ -136,6 +148,8 @@ async def _run(cfg: Config) -> None:
finally: finally:
if dynamic_renderer: if dynamic_renderer:
await dynamic_renderer.stop() await dynamic_renderer.stop()
if browser_renderer:
await browser_renderer.stop()
await snapshot_hist.stop() await snapshot_hist.stop()
if watchdog: if watchdog:
await watchdog.stop() await watchdog.stop()
@@ -0,0 +1,237 @@
"""Browser-rendered overlays — Playwright headless Chromium → PNG → overlay.
Use case: Grafana dashboard, web chat, любой HTML с прозрачным background
в композите поверх video. Reuses existing icon-overlay infrastructure
(reload_icon + IconOverlay).
Pattern: один shared browser instance, per-dashboard page, periodic
reload + screenshot → PNG в `${icon_dir}/${dashboard_id}.png` → ZMQ
`reload_icon` → existing icon overlay re-reads file.
Refresh rate cap: 0.5 Hz (2s) default — heavy dashboards (Grafana) +
PNG re-blit на каждый frame нагружают pipeline. Видели stutter при 5Hz
reload_icon (см. memory: cuda-grid-filter-stutter). Hard min = 0.5s.
"""
from __future__ import annotations
import asyncio
import io
from pathlib import Path
from typing import TYPE_CHECKING
import structlog
from PIL import Image
from pydantic import BaseModel, Field
from .overlays import IconOverlay
if TYPE_CHECKING:
from .dispatch import CommandDispatcher
log = structlog.get_logger()
# ─── Config ───────────────────────────────────────────────────────────
class DashboardCfg(BaseModel):
"""Browser-rendered overlay — Grafana panel, chat widget, HTML view."""
id: str = Field(description="Уникальное имя → PNG file name + overlay_id")
target_instance: str
cell: int | None = Field(default=None, description="None = absolute coords на output")
url: str = Field(description="URL для loading в headless browser. Page CSS должна иметь "
"transparent background для proper alpha composition.")
x: float = Field(ge=0.0, le=1.0, description="X в [0..1] of output canvas")
y: float = Field(ge=0.0, le=1.0)
w_px: int = Field(default=480, ge=64, le=1920,
description="Output PNG width. Если selector задан — element auto-scaled "
"по своим dimensions, w_px используется только для viewport если "
"viewport_w не задан явно.")
h_px: int = Field(default=270, ge=32, le=1080)
selector: str | None = Field(default=None,
description="CSS selector (e.g. '#dashboard-container', '.panel-content'). "
"Если задан — snapshot'ится только этот element, не вся page. "
"Удобно для Grafana single-panel или isolated widget. "
"Element ждётся через page.wait_for_selector до screenshot.")
viewport_w: int | None = Field(default=None, ge=64, le=3840,
description="Override page viewport width. Default = w_px. "
"Полезно при selector — set viewport больше чем element, чтобы "
"layout не collapse'нул (e.g. viewport 1920×1080, selector '#panel-3').")
viewport_h: int | None = Field(default=None, ge=32, le=2160,
description="Override viewport height. Default = h_px.")
refresh_sec: float = Field(default=2.0, ge=0.5, le=300.0,
description="Snapshot interval. Min 0.5s — частые reload_icon снижают video framerate.")
wait_until: str = Field(default="networkidle",
description="Playwright wait_until для navigation: load, domcontentloaded, networkidle")
inject_css: str = Field(
default="html, body { background: transparent !important; margin: 0 !important; }",
description="CSS injected после load — ensure transparency, remove default margins",
)
page_timeout_ms: int = Field(default=10000, ge=1000, le=60000,
description="Max time to wait for page load")
opacity: float = 1.0
z_order: int = 28
# ─── Renderer ─────────────────────────────────────────────────────────
class BrowserRenderer:
"""Управляет headless browser + per-dashboard pages."""
def __init__(
self,
icon_dir: Path,
dispatcher: "CommandDispatcher",
dashboards: list[DashboardCfg],
) -> None:
self.icon_dir = icon_dir
self.dispatcher = dispatcher
self.dashboards = dashboards
self._tasks: list[asyncio.Task] = []
self._playwright = None
self._browser = None
self._pages: dict[str, object] = {} # dashboard_id → playwright Page
async def start(self) -> None:
if not self.dashboards:
log.info("browser_overlays.disabled", reason="no dashboards configured")
return
# Import lazily — playwright optional dependency
try:
from playwright.async_api import async_playwright
except ImportError:
log.error("browser_overlays.no_playwright",
msg="playwright не установлен — dashboards disabled. "
"В Dockerfile: pip install playwright + playwright install chromium")
return
self.icon_dir.mkdir(parents=True, exist_ok=True)
self._playwright = await async_playwright().start()
# Transparent background через `--default-background-color=00000000`
# (требует hex с alpha=00 для full transparency).
self._browser = await self._playwright.chromium.launch(
headless=True,
args=[
"--default-background-color=00000000",
"--no-sandbox", # требуется в docker root context
"--disable-dev-shm-usage", # /dev/shm в Docker обычно мал
"--disable-gpu", # headless без GPU OK для PIL
],
)
for cfg in self.dashboards:
try:
vw = cfg.viewport_w or cfg.w_px
vh = cfg.viewport_h or cfg.h_px
page = await self._browser.new_page(
viewport={"width": vw, "height": vh},
)
# background_color в new_context не работает для transparent — used CLI arg
await page.goto(cfg.url, wait_until=cfg.wait_until,
timeout=cfg.page_timeout_ms)
if cfg.inject_css:
await page.add_style_tag(content=cfg.inject_css)
# Если selector — заранее проверим что он существует
if cfg.selector:
await page.wait_for_selector(cfg.selector,
timeout=cfg.page_timeout_ms)
self._pages[cfg.id] = page
log.info("browser_overlays.page_loaded", id=cfg.id, url=cfg.url,
viewport=f"{vw}×{vh}", selector=cfg.selector or "<full>")
except Exception as e:
log.error("browser_overlays.page_load_fail",
id=cfg.id, url=cfg.url, error=str(e))
continue
self._tasks.append(asyncio.create_task(self._render_loop(cfg)))
log.info("browser_overlays.started", dashboards=len(self._pages))
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()
if self._browser:
try:
await self._browser.close()
except Exception:
pass
if self._playwright:
try:
await self._playwright.stop()
except Exception:
pass
async def _render_loop(self, cfg: DashboardCfg) -> None:
page = self._pages.get(cfg.id)
if page is None:
return
registered = False
# Первый snapshot — сразу. Дальше — каждые refresh_sec.
while True:
try:
# screenshot omit_background=True → alpha-correct PNG
if cfg.selector:
locator = page.locator(cfg.selector)
screenshot_bytes = await locator.screenshot(
omit_background=True,
)
else:
screenshot_bytes = await page.screenshot(
omit_background=True,
full_page=False,
)
# Save через PIL для consistency с другими renderers
img = Image.open(io.BytesIO(screenshot_bytes))
if img.mode != "RGBA":
img = img.convert("RGBA")
path = self.icon_dir / f"{cfg.id}.png"
tmp = path.with_suffix(".png.tmp")
img.save(tmp, "PNG")
tmp.replace(path)
await self.dispatcher._reload_icon(cfg.target_instance, cfg.id)
if not registered:
await self._register_overlay(cfg)
registered = True
# Reload page для свежих данных (Grafana auto-refresh не triggers
# без user interaction в headless mode)
await asyncio.sleep(cfg.refresh_sec)
try:
await page.reload(wait_until=cfg.wait_until,
timeout=cfg.page_timeout_ms)
# Re-inject CSS после reload (новый DOM teryает добавленные styles)
if cfg.inject_css:
await page.add_style_tag(content=cfg.inject_css)
if cfg.selector:
await page.wait_for_selector(cfg.selector,
timeout=cfg.page_timeout_ms)
except Exception as e:
log.warning("browser_overlays.reload_fail",
id=cfg.id, error=str(e))
except asyncio.CancelledError:
raise
except Exception as e:
log.error("browser_overlays.loop_fail", id=cfg.id, error=str(e))
await asyncio.sleep(5)
async def _register_overlay(self, cfg: DashboardCfg) -> None:
ov = IconOverlay(
id=cfg.id,
cell=cfg.cell,
name=cfg.id,
x=cfg.x, y=cfg.y, size=0.1,
opacity=cfg.opacity, z_order=cfg.z_order,
)
await self.dispatcher.handle(cfg.target_instance, "overlay.add",
ov.model_dump_json())