[FEAT] frame: birdnet.local default, with image-url and bird-weather flavors

This commit is contained in:
Twarner491
2026-06-21 18:39:55 -07:00
parent 10548dbca7
commit 77dda2bc94
5 changed files with 236 additions and 110 deletions
+20 -23
View File
@@ -21,6 +21,13 @@ A [Pimoroni Inky Impression 13.3"](https://amzn.to/4xlAWr3) (Spectra 6) mirrorin
CAD + 3d print files can be found in [`hardware/`](hardware/).
### Kits
I offer the frame and the bird mic as separate electronics kits. I put up a store for some of my open-source projects and will soon be able to offer kits cheaper than buying all the components individually, once I start buying in bulk.
- [Frame kit](https://theodore.net/store/avian-visitors/)
- [Bird mic kit](https://theodore.net/store/avian-mic/)
---
## 1. Flash the SD card
@@ -39,32 +46,22 @@ Then install in Pi and power up.
```bash
ssh <your-username>@birdpic.local
git clone https://github.com/Twarner491/AvianVisitors
cd AvianVisitors/frame && ./install.sh
cd AvianVisitors/frame
```
Enables SPI + I2C, installs the deps and a 15-minute systemd timer, writes `~/.birdframe/config.toml`, and reboots once to bring SPI up.
---
## 3. Point it at the collage
After it reboots and comes back, set your image source in `~/.birdframe/config.toml`. The Pi is too small to run a browser, so it fetches a ready-made PNG that the aggregator Worker renders at `/frame.png`, gated by a key:
```toml
base_url = "https://bird.onethreenine.net"
image_url = "https://bird.onethreenine.net/frame.png?k=YOUR_FRAME_KEY"
```
No Worker? Set `shoot = true` to screenshot on any capable host instead. Full options are in [`config.example.toml`](config.example.toml).
---
## No bird mic? BirdWeather mode
To run the frame on its own, with no mic and no website, install with `--bird-weather` and your ZIP code instead of step 3:
Pick how the frame gets its birds:
```bash
cd AvianVisitors/frame && ./install.sh --bird-weather --zip 94107
# Pair with your bird mic on the same network (birdnet.local). The default.
./install.sh
# No microphone: draw the collage from BirdWeather for any ZIP code.
./install.sh --bird-weather --zip 94107
# Bird mic hosted at a public URL: point the frame straight at it.
./install.sh --image-url https://bird.onethreenine.net/frame.png?k=YOUR_FRAME_KEY
```
It pulls the top recently-heard birds near that ZIP code from [BirdWeather](https://app.birdweather.com) and renders the collage on the Pi itself, refreshing every few hours. Cutouts load from this repo's illustrations on GitHub, so there is no image set to copy over; add illustrations for any local birds it is missing the same way you would for the site. ZIP codes with no station nearby fall back to the closest ones, and an optional `EBIRD_API_KEY` covers the most remote spots.
Each one enables SPI + I2C, installs the deps and a systemd timer, writes `~/.birdframe/config.toml`, and reboots once to bring SPI up. Full options live in [`config.example.toml`](config.example.toml).
BirdWeather mode renders on the Pi from this repo's illustrations on GitHub, so there is no image set to copy over. ZIP codes with no station nearby fall back to the closest ones, and an optional `EBIRD_API_KEY` covers the most remote spots.
+37 -40
View File
@@ -1,55 +1,52 @@
# AvianVisitors e-ink frame. Copy to config.toml and edit.
# AvianVisitors e-ink frame. install.sh writes this for you; edit to taste.
# config.toml is gitignored. Nothing here is secret unless your whole site is
# behind basic-auth (see the bottom).
# behind basic-auth (see the bottom). This file is the full reference.
# The collage to mirror. The Pi reads the recent API here to decide when a
# refresh is worth it, so point it at the public site (a Pi Zero W cannot reach
# a LAN BirdNET-Pi by default anyway).
base_url = "https://bird.onethreenine.net"
# base_url = "http://birdnet.local" # same-LAN BirdNET-Pi instead
# --- Where the collage comes from -------------------------------------------
# Default: mirror the BirdNET-Pi on your network. This Pi screenshots it.
base_url = "http://birdnet.local"
shoot = true
hours = 24 # window shown on the frame
# Where the screenshot comes from. birdpic (a Pi Zero W) has no browser, so it
# fetches a ready-made PNG: the aggregator Worker renders the collage via
# Cloudflare Browser Rendering at /frame.png, gated by its FRAME_KEY secret.
# Put that key after k=.
shoot = false
image_url = "https://bird.onethreenine.net/frame.png?k=YOUR_FRAME_KEY"
# On a capable host (Pi 4/5, laptop) shoot inline instead of fetching:
# shoot = true
# Or fetch a ready-made PNG instead of rendering here (lighter, but needs
# something that serves a frame image, e.g. a public Cloudflare Worker):
# shoot = false
# image_url = "https://bird.onethreenine.net/frame.png?k=YOUR_FRAME_KEY"
# Or read a PNG a sibling process drops next to us:
# image = "~/.birdframe/frame.png"
# Panel.
rotate = 90 # 90 or 270 if the frame hangs the other way up
saturation = 0.6 # Inky colour saturation, 0 to 1
mat = 0.0 # extra shrink of the content inside the A5 opening (0 = none)
# panel = "el133uf1" # force the 13.3" driver if auto-detect ever fails
hours = 24 # window shown on the frame
# Refresh cadence. The panel refreshes only when the species set or a count
# bracket changes, plus a daily heal. Quiet hours are off so nocturnal
# detections still show; set quiet_start/quiet_end to a window to mute overnight.
quiet_start = 0 # 0/0 = always refresh on change; e.g. 22 and 6 mutes 22:00-06:00
quiet_end = 0
heal_hours = 24 # force a refresh at least this often
# Paths.
state = "~/.birdframe/state.json"
cache = "~/.birdframe"
timeout = 45
# Titles and look. Used only when shooting inline (shoot = true). With the
# Worker endpoint these are baked in, so leave them out.
# shoot_title = "onethreenine birds"
# shoot_subtitle = "heard today"
# --- Titles and look (used when this Pi renders, i.e. shoot = true) ----------
shoot_title = "Avian Visitors"
shoot_subtitle = "Heard Today"
# shoot_headline_px = 42
# shoot_eyebrow_px = 18
# shoot_lowercase = false
# shoot_mat = 0.04
# shoot_small_floor = 0.04
# shoot_count_exp = 0.65 # tile size vs call count; higher = the loudest birds dominate more
# Only if your WHOLE site is behind basic-auth. The AvianVisitors default
# leaves the collage and detection API public, so usually leave these out.
# --- Panel ------------------------------------------------------------------
rotate = 90 # 90 or 270 if the frame hangs the other way up
saturation = 0.6 # Inky colour saturation, 0 to 1
mat = 0.0 # extra shrink of the content inside the A5 opening (0 = none)
# panel = "el133uf1" # force the 13.3" driver if auto-detect ever fails
# --- Refresh cadence --------------------------------------------------------
# The panel refreshes only when the species set or a count bracket changes,
# plus a daily heal. Quiet hours are off so nocturnal detections still show;
# set quiet_start/quiet_end to a window to mute overnight.
quiet_start = 0 # 0/0 = always refresh on change; e.g. 22 and 6 mutes 22:00-06:00
quiet_end = 0
heal_hours = 24 # force a refresh at least this often
# --- Paths ------------------------------------------------------------------
state = "~/.birdframe/state.json"
cache = "~/.birdframe"
timeout = 45
# --- Only if your WHOLE site is behind basic-auth ---------------------------
# The AvianVisitors default leaves the collage and detection API public, so
# usually leave these out.
# basic_user = "monalisa"
# basic_pass = "..."
+45 -13
View File
@@ -43,10 +43,10 @@ DEFAULTS = {
"hours": 24,
"image": "", # local PNG written by the shooter
"image_url": "", # or a published screenshot URL
"shoot": False, # or capture inline (needs a browser; not a Zero W)
"shoot": False, # or capture inline (needs a browser; the Zero 2 W handles it)
"shoot_title": None, "shoot_subtitle": None,
"shoot_headline_px": 42, "shoot_eyebrow_px": 18, "shoot_lowercase": False,
"shoot_mat": 0.04, "shoot_small_floor": 0.04,
"shoot_mat": 0.04, "shoot_small_floor": 0.04, "shoot_count_exp": 0.65,
"mat": 0.0, # extra global shrink of the content inside the A5 opening
"rotate": 90, # 90 or 270 if the frame hangs the other way up
"saturation": 0.6,
@@ -144,6 +144,11 @@ def _scale_w(img, target_w):
return img.resize((max(1, round(img.width * s)), max(1, round(img.height * s))), Image.LANCZOS)
def _scale_h(img, target_h):
s = target_h / img.height
return img.resize((max(1, round(img.width * s)), max(1, round(img.height * s))), Image.LANCZOS)
def _centroid_x(img, paper):
"""Horizontal centre of ink weight (what the eye reads as centred)."""
m = ImageChops.difference(img, Image.new("RGB", img.size, paper)).convert("L")
@@ -155,10 +160,10 @@ def _centroid_x(img, paper):
# Content layout inside the A5 opening: the title and collage are sized
# independently (as fractions of the opening width), so tuning one leaves the
# other untouched. gap is a fraction of the opening height.
TITLE_FRAC, COLLAGE_FRAC, GAP_FRAC = 0.55, 0.66, 0.1
TITLE_H_FRAC, COLLAGE_FRAC, GAP_FRAC = 0.065, 0.66, 0.1
def mat_and_center(img, mat):
def mat_and_center(img, mat, empty=False):
"""Crop the title and collage, size each to a fraction of the A5 opening,
stack with a gap, and centre on the panel."""
img = img.convert("RGB")
@@ -174,7 +179,7 @@ def mat_and_center(img, mat):
for y in range(top, bot):
if levels[y] <= 2:
run += 1
if run >= 30: # first empty band of 30px splits title from collage
if run >= 60: # split below the headline; a 60px band clears the ~30px eyebrow/headline gap so the title stays whole
cy = y
while cy < bot and levels[cy] <= 2:
cy += 1
@@ -185,20 +190,45 @@ def mat_and_center(img, mat):
tb = _region_bbox(img, paper, top, split[0]) if split else None
cb = _region_bbox(img, paper, split[1], bot + 1) if split else None
box_w, box_h = A5_W * (1 - mat), A5_H * (1 - mat)
# No birds: the content under the title is just the one-line empty-state
# note. Render a calm title card (a modest title with the small note
# below) rather than blowing the lone title up to fill the opening.
if empty and tb and cb:
title = _scale_h(img.crop(tb), box_h * TITLE_H_FRAC)
note = _scale_w(img.crop(cb), box_w * 0.30)
gap = round(box_h * 0.05)
cw = max(title.width, note.width)
comp = Image.new("RGB", (cw, title.height + gap + note.height), paper)
comp.paste(title, ((cw - title.width) // 2, 0))
comp.paste(note, ((cw - note.width) // 2, title.height + gap))
canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper)
canvas.paste(comp, ((PANEL_W - comp.width) // 2, (PANEL_H - comp.height) // 2))
return canvas
if not (tb and cb):
return _place(img.crop(full), paper, mat)
title = _scale_w(img.crop(tb), box_w * TITLE_FRAC)
collage = _scale_w(img.crop(cb), box_w * COLLAGE_FRAC)
title = _scale_h(img.crop(tb), box_h * TITLE_H_FRAC)
gap = round(box_h * GAP_FRAC)
# Size the collage to fill the room left under the fixed-size title,
# binding on whichever of width or remaining height runs out first, so the
# title stays a consistent size whether the collage is tall or compact
# instead of ballooning when the collage happens to be short.
coll = img.crop(cb)
cs = min(box_w * COLLAGE_FRAC / coll.width, (box_h - title.height - gap) / coll.height)
collage = coll.resize((max(1, round(coll.width * cs)), max(1, round(coll.height * cs))), Image.LANCZOS)
ccx = _centroid_x(collage, paper) # centre the collage by ink weight, not bbox
half = max(ccx, collage.width - ccx)
# A wildly off-centre collage can push the centroid-mirrored width (2*half)
# past the A5 opening; shrink only the collage, never the fixed-size title,
# so nothing spills under the physical mat.
if 2 * half > box_w:
s = box_w / (2 * half)
collage = collage.resize((max(1, round(collage.width * s)), max(1, round(collage.height * s))), Image.LANCZOS)
ccx = round(ccx * s)
half = max(ccx, collage.width - ccx)
cw = round(max(title.width, 2 * half))
comp = Image.new("RGB", (cw, title.height + gap + collage.height), paper)
comp.paste(title, ((cw - title.width) // 2, 0))
comp.paste(collage, (round(cw / 2 - ccx), title.height + gap))
fit = min(box_w / comp.width, box_h / comp.height, 1.0) # shrink to the opening, never enlarge
if fit < 1.0:
comp = comp.resize((max(1, round(comp.width * fit)), max(1, round(comp.height * fit))), Image.LANCZOS)
canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper)
canvas.paste(comp, ((PANEL_W - comp.width) // 2, (PANEL_H - comp.height) // 2))
return canvas
@@ -276,7 +306,7 @@ def obtain_image(cfg):
shoot(cfg["base_url"], out, title=cfg["shoot_title"], subtitle=cfg["shoot_subtitle"],
headline_px=cfg["shoot_headline_px"], eyebrow_px=cfg["shoot_eyebrow_px"],
lowercase=cfg["shoot_lowercase"], mat=cfg["shoot_mat"],
small_floor=cfg["shoot_small_floor"], timeout_ms=cfg["timeout"] * 1000,
small_floor=cfg["shoot_small_floor"], count_exp=cfg["shoot_count_exp"], timeout_ms=cfg["timeout"] * 1000,
user=cfg["basic_user"], password=cfg["basic_pass"])
return Image.open(out).convert("RGB")
src = cfg["image_url"] or cfg["image"]
@@ -289,9 +319,11 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
now = time.time()
state = load_state(cfg["state"])
sig = None
species = None
if use_signature:
try:
sig = signature(fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], _auth(cfg)))
species = fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], _auth(cfg))
sig = signature(species)
except Exception as e:
print(f"signature fetch failed: {e}", file=sys.stderr) # treat as no change
heal_due = now - state.get("last_refresh", 0) >= cfg["heal_hours"] * 3600
@@ -310,7 +342,7 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
except Exception as e:
print(f"could not get image: {e}", file=sys.stderr) # keep last panel image
return
img = mat_and_center(img, cfg["mat"])
img = mat_and_center(img, cfg["mat"], empty=(species == []))
if preview:
out = quantize_spectra6(img)
if mat_box:
+117 -26
View File
@@ -2,30 +2,69 @@
# Install the AvianVisitors e-ink frame (display side) on a Raspberry Pi.
# Enables SPI + I2C, installs deps, makes a venv, installs the systemd timer.
#
# Default mode mirrors a local bird mic (set the image source in the config).
# Pass --bird-weather --zip <ZIP> to instead run the frame standalone from
# BirdWeather data for that ZIP: it renders on the Pi and pulls cutouts from the
# repo's raw GitHub URLs, so no mic and no local illustration folder are needed.
# Three ways to feed the frame, pick one:
# ./install.sh mirror the BirdNET-Pi on your network
# (birdnet.local), rendered on this Pi
# ./install.sh --image-url <URL> fetch a ready-made frame PNG instead
# (e.g. a public Cloudflare Worker)
# ./install.sh --bird-weather --zip <ZIP> standalone from BirdWeather, no mic
set -euo pipefail
cd "$(dirname "$0")"
FRAME="$(pwd)"
BIRD_WEATHER=0
MODE=local # local | image | birdweather
ZIP=""
IMAGE_URL=""
while [ $# -gt 0 ]; do
case "$1" in
--bird-weather) BIRD_WEATHER=1 ;;
--zip) ZIP="${2:-}"; shift ;;
--zip=*) ZIP="${1#*=}" ;;
--bird-weather) MODE=birdweather; shift ;;
--zip) [ $# -ge 2 ] || { echo "--zip needs a value, e.g. --zip 94107" >&2; exit 1; }
ZIP="$2"; shift 2 ;;
--zip=*) ZIP="${1#*=}"; shift ;;
--image-url) [ $# -ge 2 ] || { echo "--image-url needs a URL, e.g. --image-url https://bird.example/frame.png" >&2; exit 1; }
MODE=image; IMAGE_URL="$2"; shift 2 ;;
--image-url=*) MODE=image; IMAGE_URL="${1#*=}"; shift ;;
*) echo "unknown argument: $1" >&2; exit 1 ;;
esac
shift
done
if [ "$BIRD_WEATHER" = 1 ] && [ -z "$ZIP" ]; then
echo "--bird-weather needs --zip <ZIP code>, e.g. install.sh --bird-weather --zip 94107" >&2
if [ -n "$ZIP" ] && [ "$MODE" != birdweather ]; then
echo "--zip only applies with --bird-weather" >&2
exit 1
fi
# Validate inputs up front: a bad value would otherwise land in a config file or
# a systemd unit verbatim. These checks also reject a flag passed as a value
# (e.g. "--zip --image-url"), which would fail the format below.
if [ "$MODE" = birdweather ]; then
if [ -z "$ZIP" ]; then
echo "--bird-weather needs --zip <ZIP code>, e.g. install.sh --bird-weather --zip 94107" >&2
exit 1
fi
if ! printf '%s' "$ZIP" | LC_ALL=C grep -qE '^[A-Za-z0-9][A-Za-z0-9 -]{1,9}$'; then
echo "--zip should look like a postal code, e.g. 94107 or SW1A 1AA" >&2
exit 1
fi
fi
if [ "$MODE" = image ]; then
if [ -z "$IMAGE_URL" ]; then
echo "--image-url needs a URL, e.g. install.sh --image-url https://bird.example/frame.png" >&2
exit 1
fi
case "$IMAGE_URL" in
http://*|https://*) ;;
*) echo "--image-url must start with http:// or https://" >&2; exit 1 ;;
esac
if printf '%s' "$IMAGE_URL" | LC_ALL=C grep -q '[^A-Za-z0-9._~:/?#@!$&()*+,;=%-]'; then
echo "--image-url has characters that are not allowed in a URL" >&2
exit 1
fi
fi
# local + birdweather render on the Pi (need a browser); image only fetches.
NEEDS_BROWSER=1
if [ "$MODE" = image ]; then NEEDS_BROWSER=0; fi
CONFIG_TXT=/boot/firmware/config.txt
[ -f "$CONFIG_TXT" ] || CONFIG_TXT=/boot/config.txt
@@ -42,19 +81,58 @@ echo "3/5 Creating venv and installing Python deps..."
python3 -m venv .venv
.venv/bin/pip install -q --upgrade pip
.venv/bin/pip install -q -r requirements-frame.txt
if [ "$BIRD_WEATHER" = 1 ]; then
echo " BirdWeather mode: installing Playwright + Chromium for on-Pi rendering (a few minutes)..."
if [ "$NEEDS_BROWSER" = 1 ]; then
echo " Installing Playwright + Chromium so the Pi can render the collage (a few minutes)..."
.venv/bin/pip install -q playwright
sudo .venv/bin/playwright install-deps chromium
.venv/bin/playwright install chromium
fi
echo "4/5 Setting up config..."
echo "4/5 Writing config..."
mkdir -p "$HOME/.birdframe"
[ -f "$HOME/.birdframe/config.toml" ] || cp config.example.toml "$HOME/.birdframe/config.toml"
CONFIG="$HOME/.birdframe/config.toml"
if [ -f "$CONFIG" ]; then
EXISTING="$(sed -n 's/^# birdframe-mode: //p' "$CONFIG" | head -1)"
if [ -n "$EXISTING" ] && [ "$EXISTING" != "$MODE" ]; then
echo " $CONFIG is set up for '$EXISTING' mode, not '$MODE'." >&2
echo " To switch, remove it and re-run: rm $CONFIG" >&2
exit 1
fi
echo " $CONFIG already exists, leaving it untouched."
elif [ "$MODE" = local ]; then
cat > "$CONFIG" <<'CFG'
# birdframe-mode: local
# AvianVisitors frame, local mode: mirrors the BirdNET-Pi on your network.
# This Pi screenshots birdnet.local itself, so there is nothing else to set up.
base_url = "http://birdnet.local"
shoot = true
shoot_title = "Avian Visitors"
shoot_subtitle = "Heard Today"
rotate = 90 # flip to 270 if the frame hangs the other way up
saturation = 0.6
timeout = 45
# If your BirdNET-Pi is behind basic-auth, uncomment and set these:
# basic_user = "..."
# basic_pass = "..."
CFG
elif [ "$MODE" = image ]; then
BASE="$(printf '%s' "$IMAGE_URL" | sed -E 's#^(https?://[^/]+).*#\1#')"
# printf, not a heredoc: the URL is written literally, never shell-expanded.
{
printf '%s\n' '# birdframe-mode: image'
printf '%s\n' '# AvianVisitors frame, image mode: fetches a ready-made frame PNG.'
printf 'base_url = "%s"\n' "$BASE"
printf 'image_url = "%s"\n' "$IMAGE_URL"
printf '%s\n' 'shoot = false'
printf '%s\n' 'rotate = 90 # flip to 270 if the frame hangs the other way up'
printf '%s\n' 'saturation = 0.6'
} > "$CONFIG"
else
{ printf '%s\n' '# birdframe-mode: birdweather'; cat config.example.toml; } > "$CONFIG"
fi
echo "5/5 Installing systemd service + timer..."
if [ "$BIRD_WEATHER" = 1 ]; then
if [ "$MODE" = birdweather ]; then
PY="$FRAME/.venv/bin/python"
PNG="$HOME/.birdframe/frame.png"
sudo tee /etc/systemd/system/birdframe.service >/dev/null <<SERVICE
@@ -77,6 +155,7 @@ SERVICE
sed 's|OnUnitActiveSec=.*|OnUnitActiveSec=6h|' systemd/birdframe.timer \
| sudo tee /etc/systemd/system/birdframe.timer >/dev/null
else
# local + image both run display.py against the config; only the config differs.
sed "s|/home/monalisa/AvianVisitors/frame|$FRAME|g; s|/home/monalisa|$HOME|g; s|User=monalisa|User=$USER|" \
systemd/birdframe.service | sudo tee /etc/systemd/system/birdframe.service >/dev/null
sudo cp systemd/birdframe.timer /etc/systemd/system/birdframe.timer
@@ -84,21 +163,33 @@ fi
sudo systemctl daemon-reload
sudo systemctl enable --now birdframe.timer # --now starts it immediately, not only on the next boot
if [ "$BIRD_WEATHER" = 1 ]; then
cat <<DONE
case "$MODE" in
local)
cat <<DONE
Installed. The frame mirrors birdnet.local on your network and refreshes every
15 min, only when the birds change. Until the mic has heard its first bird it
shows a plain title card. If the panel hangs upside down, set rotate = 270 in
~/.birdframe/config.toml.
DONE
;;
image)
cat <<DONE
Installed. The frame fetches its image from
$IMAGE_URL
and refreshes every 15 min, only when the birds change.
DONE
;;
birdweather)
cat <<DONE
Installed in BirdWeather mode for ZIP $ZIP. The frame renders the top birds
near you on the Pi and refreshes every 6 hours. Cutouts come from the repo on
GitHub, so add illustrations there for any local birds it is missing.
DONE
else
cat <<DONE
Installed. Set your image source in ~/.birdframe/config.toml (your /frame.png
key, or shoot = true); the panel fills itself in and refreshes every 15 min,
only when the birds change.
DONE
fi
;;
esac
# SPI only takes effect on a reboot, so do it for the user. Skip if SPI is
# already up (e.g. a re-run) so we don't bounce a working frame.
+17 -8
View File
@@ -184,14 +184,19 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
resp = page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
if resp is None or not resp.ok:
raise RuntimeError(f"site returned {resp.status if resp else 'no response'}")
page.wait_for_selector(".gtile", timeout=timeout_ms) # no collage -> fatal, keep the last frame
try:
page.wait_for_function(
"() => { const t=[...document.querySelectorAll('.gtile img')];"
" return t.length>0 && t.every(i=>i.complete && i.naturalWidth>0); }",
timeout=timeout_ms)
except PWTimeout:
print("some illustrations did not finish loading; capturing anyway", file=sys.stderr)
# Wait for the collage, or for the empty-state element the page shows
# when the mic has heard nothing yet, so a birdless frame renders a
# clean title card fast instead of hanging until the timeout. A page
# with neither still times out here and stays fatal (keep last frame).
page.wait_for_selector(".gtile, .empty", state="attached", timeout=timeout_ms)
if page.query_selector(".gtile") is not None:
try:
page.wait_for_function(
"() => { const t=[...document.querySelectorAll('.gtile img')];"
" return t.length>0 && t.every(i=>i.complete && i.naturalWidth>0); }",
timeout=timeout_ms)
except PWTimeout:
print("some illustrations did not finish loading; capturing anyway", file=sys.stderr)
if misses:
raise RuntimeError(f"apt.js tunables not found ({len(misses)}); refusing to ship a half-tuned frame")
@@ -199,6 +204,10 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
page.evaluate("t=>{const e=document.querySelector('.static-head .pre'); if(e)e.textContent=t;}", title)
if subtitle is not None:
page.evaluate("s=>{const e=document.querySelector('.static-head h1'); if(e)e.textContent=s;}", subtitle)
# Soften the empty-state line for a fresh frame whose mic hasn't
# heard a bird yet, and darken it so it survives the e-ink dither and
# the matting step's ink detection (a no-op once there are birds).
page.evaluate("() => { const e = document.querySelector('.empty'); if (e) { e.textContent = 'listening for birds…'; e.style.color = '#555'; } }")
page.wait_for_timeout(250)
# clip is CSS px; device_scale_factor scales the PNG to vw*dsf by vh*dsf = 1200x1600
page.screenshot(path=out, clip={"x": 0, "y": 0, "width": vw, "height": vh})