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 \
|
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())
|
||||||
Reference in New Issue
Block a user