[FEAT] frame: --bird-weather draws the collage from a ZIP code, no mic (#12)
This commit is contained in:
+98
-17
@@ -19,13 +19,23 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
|
||||
from playwright.sync_api import TimeoutError as PWTimeout
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
# --bird-weather pulls cutouts straight from the repo's raw GitHub URLs, so the
|
||||
# Pi never bundles the illustration set and picks up new birds with no redeploy.
|
||||
RAW_ILLUSTRATIONS = ("https://raw.githubusercontent.com/Twarner491/AvianVisitors/"
|
||||
"avian-visitors/avian/assets/illustrations/")
|
||||
|
||||
# Hide the controls and the other views, freeze animations. Titles + collage
|
||||
# stay. Injected before first paint.
|
||||
HIDE_CSS = """
|
||||
@@ -60,19 +70,26 @@ def _safe_continue(route):
|
||||
pass
|
||||
|
||||
|
||||
def _make_api_handler(floor_frac, window_hours, auth):
|
||||
def _make_api_handler(floor_frac, window_hours, auth, species=None):
|
||||
"""Re-window action=recent (to preview busy days) and floor the rarest
|
||||
counts so the packer draws them a little larger."""
|
||||
counts so the packer draws them a little larger. With `species` set
|
||||
(--bird-weather), serve that list for recent and an empty body for the
|
||||
other views, which have no backend in that mode."""
|
||||
def handler(route):
|
||||
req = route.request
|
||||
if "action=recent" not in req.url:
|
||||
if species is not None:
|
||||
return route.fulfill(status=200, content_type="application/json", body="{}")
|
||||
return route.continue_()
|
||||
try:
|
||||
url = re.sub(r"hours=\d+", f"hours={int(window_hours)}", req.url) if window_hours else req.url
|
||||
kw = {"url": url}
|
||||
if auth:
|
||||
kw["headers"] = {**req.headers, "authorization": auth}
|
||||
data = route.fetch(**kw).json()
|
||||
if species is not None:
|
||||
data = {"hours": int(window_hours or 24), "species": species, "as_of": ""}
|
||||
else:
|
||||
url = re.sub(r"hours=\d+", f"hours={int(window_hours)}", req.url) if window_hours else req.url
|
||||
kw = {"url": url}
|
||||
if auth:
|
||||
kw["headers"] = {**req.headers, "authorization": auth}
|
||||
data = route.fetch(**kw).json()
|
||||
sp = data.get("species", [])
|
||||
if sp and floor_frac > 0:
|
||||
floor = max((s.get("n") or 1) for s in sp) * floor_frac
|
||||
@@ -86,6 +103,37 @@ def _make_api_handler(floor_frac, window_hours, auth):
|
||||
return handler
|
||||
|
||||
|
||||
def _serve_frontend(directory):
|
||||
"""Serve the static collage frontend on a free localhost port (daemon thread)."""
|
||||
class Quiet(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
def make(*args, **kwargs):
|
||||
return Quiet(*args, directory=directory, **kwargs)
|
||||
|
||||
httpd = socketserver.TCPServer(("127.0.0.1", 0), make)
|
||||
httpd.daemon_threads = True
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
return httpd, httpd.server_address[1]
|
||||
|
||||
|
||||
def _make_cutout_handler(base):
|
||||
"""Redirect each cutout.php lookup to the bird's raw illustration on GitHub.
|
||||
Trusts species_for_zip to pre-filter to drawable slugs, so a redirect only
|
||||
lands on a missing file if the repo is mid-update."""
|
||||
def handler(route):
|
||||
try:
|
||||
params = urllib.parse.parse_qs(urllib.parse.urlparse(route.request.url).query)
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", (params.get("sci") or [""])[0].lower()).strip("-")
|
||||
if (params.get("pose") or ["1"])[0] == "2":
|
||||
slug += "-2"
|
||||
route.fulfill(status=302, headers={"location": base + slug + ".png"})
|
||||
except Exception:
|
||||
_safe_continue(route)
|
||||
return handler
|
||||
|
||||
|
||||
def _make_js_handler(xbias, ybias, count_exp, pad, auth, misses):
|
||||
"""Rewrite the collage tunables inside the page's apt.js at capture time."""
|
||||
def handler(route):
|
||||
@@ -110,20 +158,22 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
|
||||
headline_px=42, eyebrow_px=18, lowercase=False,
|
||||
mat=0.04, collage_vh=52, cluster_xbias=1.0, cluster_ybias=1.2,
|
||||
count_exp=0.4, cluster_pad=1, small_floor=0.04, window_hours=None,
|
||||
timeout_ms=45000, user=None, password=None):
|
||||
timeout_ms=45000, user=None, password=None, species=None, cutout_base=None):
|
||||
pad_side, pad_top, pad_bottom = int(vw * mat), int(vh * mat * 0.92), int(vh * mat)
|
||||
auth = "Basic " + base64.b64encode(f"{user}:{password or ''}".encode()).decode() if user else None
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=["--force-color-profile=srgb"])
|
||||
browser = p.chromium.launch(args=["--force-color-profile=srgb", "--disable-dev-shm-usage"])
|
||||
try:
|
||||
ctx_kw = {"viewport": {"width": vw, "height": vh}, "device_scale_factor": dsf}
|
||||
if user:
|
||||
ctx_kw["http_credentials"] = {"username": user, "password": password or ""}
|
||||
page = browser.new_context(**ctx_kw).new_page()
|
||||
misses = []
|
||||
page.route("**/birdnet-api.php**", _make_api_handler(small_floor, window_hours, auth))
|
||||
page.route("**/birdnet-api.php**", _make_api_handler(small_floor, window_hours, auth, species))
|
||||
page.route("**/apt.js*", _make_js_handler(cluster_xbias, cluster_ybias, count_exp, cluster_pad, auth, misses))
|
||||
if cutout_base:
|
||||
page.route("**/cutout.php*", _make_cutout_handler(cutout_base))
|
||||
|
||||
css = HIDE_CSS + _frame_css(headline_px, eyebrow_px, lowercase, pad_top, pad_side, pad_bottom, collage_vh)
|
||||
page.add_init_script(
|
||||
@@ -164,16 +214,24 @@ def main():
|
||||
ap.add_argument("--title")
|
||||
ap.add_argument("--subtitle")
|
||||
ap.add_argument("--lowercase", action="store_true")
|
||||
ap.add_argument("--headline-px", type=int, default=42)
|
||||
ap.add_argument("--eyebrow-px", type=int, default=18)
|
||||
ap.add_argument("--headline-px", type=int, default=None,
|
||||
help="headline font px; default 42 for the mic, 39 for --bird-weather")
|
||||
ap.add_argument("--eyebrow-px", type=int, default=None,
|
||||
help="eyebrow font px; default 18 for the mic, 17 for --bird-weather")
|
||||
ap.add_argument("--mat", type=float, default=0.04)
|
||||
ap.add_argument("--collage-vh", type=float, default=52)
|
||||
ap.add_argument("--cluster-xbias", type=float, default=1.0)
|
||||
ap.add_argument("--cluster-ybias", type=float, default=1.2)
|
||||
ap.add_argument("--count-exp", type=float, default=0.4)
|
||||
ap.add_argument("--count-exp", type=float, default=None,
|
||||
help="count-to-size exponent; default 0.4 for the mic, 1.0 for --bird-weather")
|
||||
ap.add_argument("--cluster-pad", type=int, default=1)
|
||||
ap.add_argument("--small-floor", type=float, default=0.04)
|
||||
ap.add_argument("--window-hours", type=int)
|
||||
ap.add_argument("--bird-weather", action="store_true",
|
||||
help="render from BirdWeather data for --zip instead of a local mic")
|
||||
ap.add_argument("--zip", help="ZIP / postal code, required with --bird-weather")
|
||||
ap.add_argument("--bw-days", type=int, default=7, help="--bird-weather lookback window in days")
|
||||
ap.add_argument("--bw-country", default="us", help="--bird-weather geocoder country code")
|
||||
ap.add_argument("--width", type=int, default=600)
|
||||
ap.add_argument("--height", type=int, default=800)
|
||||
ap.add_argument("--dsf", type=int, default=2)
|
||||
@@ -181,13 +239,36 @@ def main():
|
||||
ap.add_argument("--password")
|
||||
ap.add_argument("--timeout", type=int, default=45000)
|
||||
a = ap.parse_args()
|
||||
url, title, subtitle, species, cutout_base = a.url, a.title, a.subtitle, None, None
|
||||
if a.bird_weather:
|
||||
if not a.zip:
|
||||
print("--bird-weather needs --zip", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import birdweather
|
||||
species = birdweather.species_for_zip(a.zip, country=a.bw_country, days=a.bw_days)
|
||||
if not species:
|
||||
print(f"no drawable birds near {a.zip}; nothing to render", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
front = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "avian", "frontend")
|
||||
_httpd, port = _serve_frontend(front)
|
||||
url = f"http://127.0.0.1:{port}/"
|
||||
cutout_base = RAW_ILLUSTRATIONS
|
||||
title = title or "Avian Visitors"
|
||||
subtitle = subtitle or "Heard Today"
|
||||
# BirdWeather's 7-day counts are flatter than a mic's, so they need a steeper
|
||||
# exponent to get the same hero-bird hierarchy and collage shape.
|
||||
count_exp = a.count_exp if a.count_exp is not None else (1.0 if a.bird_weather else 0.4)
|
||||
headline_px = a.headline_px if a.headline_px is not None else (39 if a.bird_weather else 42)
|
||||
eyebrow_px = a.eyebrow_px if a.eyebrow_px is not None else (17 if a.bird_weather else 18)
|
||||
try:
|
||||
shoot(a.url, a.out, title=a.title, subtitle=a.subtitle, vw=a.width, vh=a.height, dsf=a.dsf,
|
||||
headline_px=a.headline_px, eyebrow_px=a.eyebrow_px, lowercase=a.lowercase,
|
||||
shoot(url, a.out, title=title, subtitle=subtitle, vw=a.width, vh=a.height, dsf=a.dsf,
|
||||
headline_px=headline_px, eyebrow_px=eyebrow_px, lowercase=a.lowercase,
|
||||
mat=a.mat, collage_vh=a.collage_vh, cluster_xbias=a.cluster_xbias,
|
||||
cluster_ybias=a.cluster_ybias, count_exp=a.count_exp, cluster_pad=a.cluster_pad,
|
||||
cluster_ybias=a.cluster_ybias, count_exp=count_exp, cluster_pad=a.cluster_pad,
|
||||
small_floor=a.small_floor,
|
||||
window_hours=a.window_hours, timeout_ms=a.timeout, user=a.user, password=a.password)
|
||||
window_hours=a.window_hours, timeout_ms=a.timeout, user=a.user, password=a.password,
|
||||
species=species, cutout_base=cutout_base)
|
||||
except Exception as e:
|
||||
print(f"shoot failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
Reference in New Issue
Block a user