"""Experimental "modern" weather widget render style: Jinja2 + a persistent headless Chromium browser (Playwright) instead of the hand- drawn PIL primitives in weather_render.py -- see docs/widgets.md and the `html-widget-render` branch's PR description for the design rationale (gradients/shadows/soft shading that PIL can't easily do, at the cost of a real browser-process dependency). Two things this module owns that nothing else in the codebase needed before: 1. A **persistent** background browser process. Widget rendering already happens concurrently across a fresh `ThreadPoolExecutor` per frame request (routers/device.py's _render_widgets) -- Playwright's sync API is thread-affine (an object must be used from the thread that created it), so a single browser object can't be handed across those ad-hoc worker threads, and relaunching a full Chromium process on every widget render would be real, avoidable latency. Fix: one background thread runs its own persistent asyncio event loop hosting one long-lived `Browser`, lazily started on first use (see start()) -- not eagerly at server startup, so a deployment that never enables the weather widget's "modern" style never launches Chromium at all and never needs Playwright's browser binaries installed. main.py's lifespan only wires up the *shutdown* half (stop()), so a clean server restart doesn't leave an orphaned Chromium process behind if this was ever actually used. render_html_to_image() is a plain sync function any worker thread can call, bridging in via `asyncio.run_coroutine_threadsafe` (the standard safe cross-thread entry point into a *running* loop on another thread). 2. **Per-region ordered (Bayer) dithering against the palette**, done here rather than in the shared image_pipeline.py pipeline. render_panel's whole-canvas single Floyd-Steinberg pass exists because Floyd-Steinberg's error diffusion can't be split across independently-quantized regions without a visible seam at the boundary -- but that reasoning doesn't apply to ordered dithering, which has no cross-pixel error term (each pixel's dither decision only depends on its own position + color). So this module dithers its own rendered widget to *already-exact* palette colors before returning it; the later shared Floyd-Steinberg pass sees zero quantization error there and leaves it untouched -- the same "pre-commit to exact palette colors" trick image_pipeline.draw_text and the hand-drawn weather icons already rely on, just reached a different way. Floyd-Steinberg keeps working exactly as before for photos and every other (classic-rendered) widget region. """ from __future__ import annotations import asyncio import io import threading from datetime import date from pathlib import Path import numpy as np from jinja2 import Environment, FileSystemLoader, select_autoescape from PIL import Image from . import panel_style from .image_pipeline import DEFAULT_PALETTE_RGB _TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html" _FONT_DIR = Path(__file__).resolve().parent / "fonts" _jinja_env = Environment( loader=FileSystemLoader(str(_TEMPLATE_DIR)), autoescape=select_autoescape(["html", "jinja"]), ) CATEGORY_EMOJI = { "clear": "☀️", "partly_cloudy": "⛅", "cloudy": "☁️", "fog": "\U0001f32b️", "rain": "\U0001f327️", "snow": "❄️", "thunderstorm": "⛈️", } # ACCENT_START/END: a fixed blue gradient pair for the "modern" style's # card header -- deliberately not routed through panel_style.theme_color # (unlike every classic-rendered widget's chrome), since the whole point # of this style is the gradient look ordered_dither below then commits # to exact palette colors anyway; which literal hex this starts from # doesn't matter to the end result the way it would for a flat PIL fill. ACCENT_START = "#1c4fd6" ACCENT_END = "#6fa8ff" # --- Persistent background browser ------------------------------------- _loop: asyncio.AbstractEventLoop | None = None _loop_thread: threading.Thread | None = None _browser = None _playwright_cm = None _start_lock = threading.Lock() async def _launch_browser() -> None: global _browser, _playwright_cm from playwright.async_api import async_playwright _playwright_cm = async_playwright() playwright = await _playwright_cm.__aenter__() _browser = await playwright.chromium.launch() async def _close_browser() -> None: global _browser, _playwright_cm if _browser is not None: await _browser.close() _browser = None if _playwright_cm is not None: await _playwright_cm.__aexit__(None, None, None) _playwright_cm = None def start() -> None: """Launches the background event loop + persistent Chromium browser, if not already running. Called lazily by render_html_to_image on first use (not from main.py's lifespan -- see module docstring for why this must stay opt-in) -- exposed directly too, for tests that want to control startup explicitly. Idempotent -- a second call while already started is a no-op.""" global _loop, _loop_thread if _loop is not None: return ready = threading.Event() def _run() -> None: global _loop loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) _loop = loop ready.set() loop.run_forever() _loop_thread = threading.Thread(target=_run, daemon=True, name="html-render-loop") _loop_thread.start() ready.wait() asyncio.run_coroutine_threadsafe(_launch_browser(), _loop).result() def stop() -> None: """Closes the browser and stops the background loop -- called from main.py's lifespan shutdown so a server restart never leaves an orphaned Chromium process behind. No-op if start() was never called (the common case: most deployments never enable "modern" style).""" global _loop, _loop_thread if _loop is None: return asyncio.run_coroutine_threadsafe(_close_browser(), _loop).result() _loop.call_soon_threadsafe(_loop.stop) _loop_thread.join(timeout=5) _loop = None _loop_thread = None async def _screenshot(html: str, target_w: int, target_h: int) -> bytes: page = await _browser.new_page(viewport={"width": target_w, "height": target_h}, device_scale_factor=1) try: await page.set_content(html, wait_until="networkidle") return await page.screenshot() finally: await page.close() def render_html_to_image(html: str, target_w: int, target_h: int) -> Image.Image: """Renders `html` (already sized to target_w x target_h via its own