Files
espresso_frame/server/app/html_render.py
T
tfaour 8ea1c53ec3
Build and push server image / test (push) Successful in 39s
Build and push server image / build-and-push (push) Failing after 2m34s
Build and push server image / deploy (push) Has been skipped
Add experimental HTML/CSS "modern" render style for weather widget
The weather widget's icons/layout are hand-drawn PIL primitives -- clean
under quantization but flat, no gradients/shadows. Adds an opt-in
render_style="modern" (current/daily modes only) that instead renders a
Jinja2 template through a persistent headless-Chromium browser
(app/html_render.py), following the approach of Tesserae, an open-source
e-ink dashboard targeting this same panel family.

Key design points:
- The Chromium dependency (Playwright) is lazily imported only when a
  weather widget actually uses "modern" style, and the background browser
  itself only launches on first use -- every other widget type, and this
  one's own classic/hourly/multi_city paths, never pay for it.
- No Frame-level dithering setting needed: html_render dithers its own
  rendered widget to exact palette colors (Bayer/ordered, not
  Floyd-Steinberg) before compositing, so the shared whole-canvas
  Floyd-Steinberg pass sees zero quantization error there and leaves it
  untouched -- same trick draw_text/hand-drawn icons already use. Floyd-
  Steinberg keeps working unchanged for photos and every other widget.
- A "Load calibrated Spectra 6 preset" button in Advanced configuration
  offers a community-measured palette (data ported from
  paperlesspaper/epdoptimize, Apache 2.0) as an alternative starting
  point to the existing idealized DEFAULT_PALETTE_RGB -- fills the
  existing palette table, doesn't save by itself.

Known open risk, not resolved here: a headless Chromium binary is far
larger than the ~100MB single-layer limit that already forced this
project's pip/npm installs into split layers, and (unlike those) is a
single ~180MB file that can't be split across layers by ordinary
Dockerfile restructuring. Flagged prominently in server/Dockerfile and
docs/widgets.md -- treat this render style as experimental/local-only
until that's resolved.
2026-07-30 22:18:43 +00:00

328 lines
14 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
<style>) through the persistent headless Chromium browser and
returns an RGB image of exactly that size. Safe to call from any
thread -- bridges into the dedicated background asyncio loop via
run_coroutine_threadsafe. Lazily calls start() on first use (see its
docstring) -- the first "modern" style render on a freshly-started
server pays Chromium's launch latency; every render after that reuses
the same persistent browser."""
if _loop is None:
with _start_lock:
if _loop is None:
start()
future = asyncio.run_coroutine_threadsafe(_screenshot(html, target_w, target_h), _loop)
png_bytes = future.result()
return Image.open(io.BytesIO(png_bytes)).convert("RGB")
# --- Ordered (Bayer 8x8) dithering against an arbitrary palette ---------
_BAYER8 = (
np.array(
[
[0, 32, 8, 40, 2, 34, 10, 42],
[48, 16, 56, 24, 50, 18, 58, 26],
[12, 44, 4, 36, 14, 46, 6, 38],
[60, 28, 52, 20, 62, 30, 54, 22],
[3, 35, 11, 43, 1, 33, 9, 41],
[51, 19, 59, 27, 49, 17, 57, 25],
[15, 47, 7, 39, 13, 45, 5, 37],
[63, 31, 55, 23, 61, 29, 53, 21],
],
dtype=np.float32,
)
/ 64.0
- 0.5
)
def ordered_dither(img: Image.Image, palette_rgb: list | None, amplitude: float = 48.0) -> Image.Image:
"""Bayer-ordered dither of `img` against `palette_rgb` (falls back to
DEFAULT_PALETTE_RGB) -- every output pixel is one of the palette's
exact colors, spatially patterned rather than error-diffused, so it's
safe to run per-region before compositing (see module docstring for
why that's not true of Floyd-Steinberg). `amplitude` is the Bayer
bias's full swing in 0-255 RGB units before nearest-palette-color
matching -- 48 was the value this render style was tuned against in
the exploratory spike behind this feature; not exposed as a per-frame
setting (unlike dither_strength) since there's only one consumer of
it today."""
palette = np.array(palette_rgb or DEFAULT_PALETTE_RGB, dtype=np.float32)
arr = np.asarray(img.convert("RGB"), dtype=np.float32)
h, w, _ = arr.shape
tile = np.tile(_BAYER8, (h // 8 + 1, w // 8 + 1))[:h, :w]
biased = np.clip(arr + tile[:, :, None] * amplitude, 0, 255)
diffs = biased[:, :, None, :] - palette[None, None, :, :]
dists = np.einsum("hwkc,hwkc->hwk", diffs, diffs)
idx = np.argmin(dists, axis=2)
return Image.fromarray(palette[idx].astype(np.uint8), "RGB")
# --- Weather "modern" style ----------------------------------------------
def _day_label(day_date: date) -> str:
delta = (day_date - date.today()).days
if delta == 0:
return "Today"
if delta == 1:
return "Tomorrow"
return day_date.strftime("%a")
def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_current --
same call signature, so app/widgets/weather.py can dispatch to
either interchangeably. Returns an already-palette-exact RGB image
(see ordered_dither)."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
if not entry:
return img
unit_suffix = "F" if units == "fahrenheit" else "C"
icon_size = max(28, min(target_w, target_h) // 3)
template = _jinja_env.get_template("weather_current.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
font_dir=str(_FONT_DIR), emoji=CATEGORY_EMOJI.get(entry["category"], ""),
temp=round(entry["temp"]), unit_suffix=unit_suffix, city_label=city_label,
icon_size=icon_size, temp_size=max(24, min(target_w, target_h) // 3),
label_size=max(12, icon_size // 3),
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)
def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
"""HTML/CSS-rendered analogue of weather_render.build_daily -- same
call signature. Returns an already-palette-exact RGB image (see
ordered_dither)."""
img = Image.new("RGB", (target_w, target_h), (255, 255, 255))
days = list(daily.items())
if not days:
return img
unit_suffix = "F" if units == "fahrenheit" else "C"
header_h = max(28, min(target_w, target_h) // 8) if city_label else 0
col_w = max(1, target_w // len(days))
icon_size = max(16, min(col_w // 2, 36))
day_entries = [
{
"label": _day_label(date.fromisoformat(day_str)),
"emoji": CATEGORY_EMOJI.get(d["category"], ""),
"high": round(d["high"]),
"low": round(d["low"]),
}
for day_str, d in days
]
template = _jinja_env.get_template("weather_daily.html.jinja")
html = template.render(
w=target_w, h=target_h, gutter=panel_style.GUTTER, radius=panel_style.CARD_RADIUS,
font_dir=str(_FONT_DIR), city_label=city_label, header_h=header_h,
title_size=max(14, header_h - 12), accent_start=ACCENT_START, accent_end=ACCENT_END,
days=day_entries, icon_size=icon_size, label_size=max(12, icon_size // 2),
unit_suffix=unit_suffix,
)
rendered = render_html_to_image(html, target_w, target_h)
return ordered_dither(rendered, palette_rgb)
SUPPORTED_MODES = ("current", "daily")
def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
units: str = "fahrenheit", city_label: str = "") -> Image.Image:
"""Dispatches to build_current/build_daily -- mirrors weather_render.
build()'s signature (minus interval_hours, which no modern-style mode
uses) so app/widgets/weather.py and the weather preview endpoint can
call either module identically. Only call this for mode in
SUPPORTED_MODES -- callers are expected to have already fallen back to
weather_render.build() for hourly/multi_city (see weather.py)."""
if mode == "current":
return build_current(data, target_w, target_h, palette_rgb, units, city_label)
return build_daily(data, target_w, target_h, palette_rgb, units, city_label)
def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
units: str = "fahrenheit", city_label: str = "") -> bytes:
"""Modern-style analogue of weather_render.render_weather_preview_png
-- same browser-viewable-PNG convention every other widget's preview
endpoint uses. build()'s output is already palette-exact (see
ordered_dither), so the final _quantize pass here is a no-op on it,
same reasoning as the module docstring's compositing story."""
from .image_pipeline import _quantize, _png_bytes, logical_render_size
target_w, target_h = logical_render_size(orientation)
img = build(mode, data, target_w, target_h, palette_rgb, units, city_label)
quantized = _quantize(img, palette_rgb, dither_strength=1.0)
return _png_bytes(quantized)