"""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") extra_http_headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers для каждого request — auth tokens, custom UA. " "Пример Grafana service account: " '{"Authorization": "Bearer glsa_xxxxxxxx"}') cookies: list[dict] = Field(default_factory=list, description="Session cookies через Playwright context.add_cookies. " "Каждый dict: {name, value, url}|{name, value, domain, path}. " "Пример: [{\"name\":\"session_id\",\"value\":\"abc\",\"url\":\"https://...\"}]") 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() # Full chromium (не headless-shell) — нужен для `--default-background-color` # transparency support. headless-shell incompatible с remote debugging при # этом флаге. channel="chromium" → use full browser binary (~170MB, # установлен через `playwright install chromium`). self._browser = await self._playwright.chromium.launch( channel="chromium", headless=True, args=[ "--default-background-color=00000000", # hex alpha=00 = full transparent "--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}, ) if cfg.extra_http_headers: await page.set_extra_http_headers(cfg.extra_http_headers) if cfg.cookies: try: await page.context.add_cookies(cfg.cookies) except Exception as e: log.warning("browser_overlays.cookies_invalid", id=cfg.id, error=str(e)) # 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") # Resize к target w_px×h_px — overlay поместится exactly в configured slot. # Без resize element скриншот возвращается в native dims (e.g. 790×258 для # Grafana panel) → overflow / gap в slot. LANCZOS = high-quality downscale. if img.size != (cfg.w_px, cfg.h_px): img = img.resize((cfg.w_px, cfg.h_px), Image.LANCZOS) 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())