From 19ddaf2ddee4e73e67a07ed0e01e8733b8215930 Mon Sep 17 00:00:00 2001 From: gx Date: Thu, 21 May 2026 23:09:50 +0100 Subject: [PATCH] =?UTF-8?q?controller:=20browser-rendered=20overlays=20?= =?UTF-8?q?=E2=80=94=20Grafana/chat/=D0=BB=D1=8E=D0=B1=D0=BE=D0=B9=20HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- controller/Dockerfile | 9 +- controller/cuda_grid_controller/__main__.py | 14 ++ .../cuda_grid_controller/browser_overlays.py | 237 ++++++++++++++++++ 3 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 controller/cuda_grid_controller/browser_overlays.py diff --git a/controller/Dockerfile b/controller/Dockerfile index 7f57df2..f5ae3e3 100644 --- a/controller/Dockerfile +++ b/controller/Dockerfile @@ -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 diff --git a/controller/cuda_grid_controller/__main__.py b/controller/cuda_grid_controller/__main__.py index 67d9fbe..051a8a4 100644 --- a/controller/cuda_grid_controller/__main__.py +++ b/controller/cuda_grid_controller/__main__.py @@ -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() diff --git a/controller/cuda_grid_controller/browser_overlays.py b/controller/cuda_grid_controller/browser_overlays.py new file mode 100644 index 0000000..ca4ff3f --- /dev/null +++ b/controller/cuda_grid_controller/browser_overlays.py @@ -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 "") + 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())