diff --git a/docs/widgets.md b/docs/widgets.md index 17c8b28..5c59235 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -269,6 +269,25 @@ modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like side -- the calendar widget's embedded strip, as a standalone widget's whole content instead of a strip above an agenda day. +**Render style** (`WeatherWidgetConfig.render_style`, `"classic"` default +| `"modern"`, experimental): `current`/`daily` only -- `hourly`/ +`multi_city` always render classic regardless of this setting. `modern` +draws the widget as an HTML/CSS card (Jinja2 templates under +`app/templates/widget_html/`) through a persistent headless Chromium +browser (`app/html_render.py`, Playwright) instead of `app/weather_render. +py`'s hand-drawn PIL primitives -- gradients/shadows/soft icon shading +PIL can't easily do. Its own `ordered_dither` (Bayer/ordered, not Floyd- +Steinberg) commits the rendered widget to exact palette colors *before* +compositing, so it's safe to mix with photo/other classic-rendered +widgets on the same frame without a Floyd-Steinberg seam at the boundary +(see that module's docstring for why ordered dithering doesn't have this +problem and Floyd-Steinberg does) -- no `Frame`-level dithering setting +was needed. Playwright/Chromium is a real, heavyweight runtime dependency +imported lazily only when a weather widget actually uses this style, and +its Docker packaging has a known likely-blocking image-size problem not +yet resolved (see `server/Dockerfile`'s own comment) -- treat this style +as unshipped/local-only until that's sorted out. + `current`/`hourly`/`daily` share one configured location (`city_label`/`city_latitude`/`city_longitude`, set via `POST .../ weather-location`, geocoded through `weather.geocode_city`); `multi_city` diff --git a/server/Dockerfile b/server/Dockerfile index 6dd26d0..43307b1 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -36,25 +36,62 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ tzdata fontconfig fonts-dejavu-core nodejs npm \ && rm -rf /var/lib/apt/lists/* +# EXPERIMENTAL, likely unmergeable as-is -- see below. System libs a +# headless Chromium needs (app/html_render.py, the weather widget's +# opt-in "modern" render style), trimmed from Playwright's own full +# `install-deps chromium` list to just what a headless (no Xvfb), +# Latin-text-plus-emoji use case needs: dropped xvfb (only needed for a +# *headed* browser) and the CJK/Cyrillic/Thai locale font packages +# (fonts-ipafont-gothic, fonts-wqy-zenhei, fonts-tlwg-loma-otf, +# xfonts-cyrillic, xfonts-scalable, fonts-freefont-ttf, fonts-unifont) -- +# fonts-noto-color-emoji is the one that actually matters here (real +# color emoji in the weather icons, vs. WeasyPrint/Pango's monochrome +# fallback glyphs in this feature's original spike). +RUN apt-get update && apt-get install -y --no-install-recommends \ + libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 \ + libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 \ + libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 \ + libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 \ + fonts-noto-color-emoji libfontconfig1 libfreetype6 fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . # Split across several layers rather than one `pip install -r # requirements.txt` -- same Cloudflare single-blob/layer payload-size # limit as render-service's npm installs below. The single combined # layer was measured at ~113MB unpacked, over the limit on its own. -# Isolating the three largest packages gets every layer's unpacked size -# well clear of 100MB (sqlalchemy ~15MB, pillow ~19MB, pypdfium2 ~8MB, -# the remaining `-r requirements.txt` layer ~71MB). Each package -# version here still comes from requirements.txt (`pip install -r` for -# everything that doesn't need its own layer skips these three, since -# pip sees them already satisfied); the explicit versions below just -# control *when* each installs -- same "single source of truth, just -# splitting *when* it installs" tradeoff as the npm section's --no-save -# comment below. +# Isolating the largest packages gets every layer's unpacked size well +# clear of 100MB (sqlalchemy ~15MB, pillow ~19MB, pypdfium2 ~8MB, the +# remaining `-r requirements.txt` layer ~71MB). Each package version here +# still comes from requirements.txt (`pip install -r` for everything that +# doesn't need its own layer skips these, since pip sees them already +# satisfied); the explicit versions below just control *when* each +# installs -- same "single source of truth, just splitting *when* it +# installs" tradeoff as the npm section's --no-save comment below. RUN pip install --no-cache-dir sqlalchemy==2.0.51 RUN pip install --no-cache-dir pillow==12.3.0 RUN pip install --no-cache-dir pypdfium2==5.12.1 +RUN pip install --no-cache-dir playwright==1.61.0 RUN pip install --no-cache-dir -r requirements.txt +# KNOWN LIKELY BLOCKER, not resolved by pulling this into its own layer: +# `playwright install chromium-headless-shell` unpacks to ~262MB, and its +# single `chrome-headless-shell` binary alone (measured: 181MB) is one +# file -- unlike the pip/npm splits above (independently-installable +# smaller packages moved into their own layers), a single 181MB file +# can't be divided across multiple <100MB Docker layers by any ordinary +# COPY/RUN restructuring; the whole file lands in whichever layer's diff +# contains it. This almost certainly exceeds the same Cloudflare single- +# blob/layer limit that forced the pip/npm splits elsewhere in this file +# (see their comments) -- an actual push to this project's registry +# hasn't been attempted (would require pushing to `main`, which triggers +# deploy) to confirm, but there is no reason to expect a single 181MB +# blob to fit where combined ~113MB of many small wheels didn't. Needs a +# real resolution (a registry without this limit, hosting the browser +# binary outside the image, etc.) before this branch can actually ship -- +# tracked as open, not silently assumed away. +RUN playwright install chromium-headless-shell + # render-service/'s dependencies installed as several separate layers # rather than one `npm install` covering all of them -- a from-scratch # push of this image once hit Cloudflare's payload-size limit on a diff --git a/server/app/html_render.py b/server/app/html_render.py new file mode 100644 index 0000000..7c5f9e3 --- /dev/null +++ b/server/app/html_render.py @@ -0,0 +1,327 @@ +"""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 + +
+