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:
@@ -28,7 +28,14 @@ RUN pip install --no-cache-dir \
|
||||
typer \
|
||||
sse-starlette \
|
||||
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
|
||||
COPY cuda_grid_controller ./cuda_grid_controller
|
||||
|
||||
@@ -13,6 +13,7 @@ import uvicorn
|
||||
|
||||
from .config import Config
|
||||
from .dispatch import CommandDispatcher
|
||||
from .browser_overlays import BrowserRenderer, DashboardCfg
|
||||
from .dynamic_overlays import ChartCfg, ChatCfg, DynamicRenderer
|
||||
from .frigate_bridge import FrigateBridge, FrigateBridgeCfg
|
||||
from .http_api import create_app
|
||||
@@ -60,6 +61,7 @@ async def _run(cfg: Config) -> None:
|
||||
|
||||
# Dynamic overlays (charts/chats) — Phase 6
|
||||
dynamic_renderer: DynamicRenderer | None = None
|
||||
browser_renderer: BrowserRenderer | None = None
|
||||
if cfg.dynamic_overlays:
|
||||
try:
|
||||
d = cfg.dynamic_overlays
|
||||
@@ -72,6 +74,14 @@ async def _run(cfg: Config) -> None:
|
||||
charts=charts,
|
||||
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:
|
||||
structlog.get_logger().warning("dynamic_overlays.config_invalid", error=str(e))
|
||||
|
||||
@@ -121,6 +131,8 @@ async def _run(cfg: Config) -> None:
|
||||
# Start dynamic renderer задачи (если есть)
|
||||
if dynamic_renderer:
|
||||
await dynamic_renderer.start()
|
||||
if browser_renderer:
|
||||
await browser_renderer.start()
|
||||
await snapshot_hist.start()
|
||||
if watchdog:
|
||||
watchdog._publish_event = mqtt.publish_event
|
||||
@@ -136,6 +148,8 @@ async def _run(cfg: Config) -> None:
|
||||
finally:
|
||||
if dynamic_renderer:
|
||||
await dynamic_renderer.stop()
|
||||
if browser_renderer:
|
||||
await browser_renderer.stop()
|
||||
await snapshot_hist.stop()
|
||||
if watchdog:
|
||||
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())
|
||||
Reference in New Issue
Block a user