diff --git a/avian/frontend/apt.js b/avian/frontend/apt.js
index 63c83b4..5acf486 100644
--- a/avian/frontend/apt.js
+++ b/avian/frontend/apt.js
@@ -413,7 +413,12 @@
function renderCollage(items, animate) {
collage.innerHTML = '';
if (!items.length) {
- collage.innerHTML = '
no birds heard in this window.
';
+ // No birds heard yet: show an empty nest where the collage would be, with
+ // the status line beneath it. The frame (shoot.py) overrides the .empty
+ // text for the e-ink panel; the nest illustration is shared by both.
+ collage.innerHTML = '' +
+ '

' +
+ '
no birds heard in this window.
';
return;
}
var W = collage.clientWidth, H = collage.clientHeight;
diff --git a/avian/frontend/nest.webp b/avian/frontend/nest.webp
new file mode 100644
index 0000000..efc4c34
Binary files /dev/null and b/avian/frontend/nest.webp differ
diff --git a/avian/frontend/styles.css b/avian/frontend/styles.css
index 6071205..ae494fa 100644
--- a/avian/frontend/styles.css
+++ b/avian/frontend/styles.css
@@ -591,6 +591,18 @@
text-transform: uppercase;
}
+ /* No-birds state: an empty-nest illustration sits where the collage would be,
+ with the status line beneath it. Fills the collage box so the frame's
+ matting treats it like any other collage. */
+ .empty-nest {
+ height: 100%;
+ display: flex; flex-direction: column;
+ align-items: center; justify-content: center;
+ gap: 28px;
+ }
+ .empty-nest .nest-img { width: 78%; max-width: 470px; height: auto; }
+ .empty-nest .empty { padding: 0; margin: 0; }
+
/* Page header is now shared at .stage > .static-head - see top of CSS. */
/* Stats grid: charts stack on the LEFT, text sections on the RIGHT.
Each chart is a full-bleed card; each text section is a list with
diff --git a/frame/display.py b/frame/display.py
index 9a4cd99..a4e9b4d 100644
--- a/frame/display.py
+++ b/frame/display.py
@@ -177,7 +177,7 @@ def _centroid_x(img, paper):
TITLE_H_FRAC, COLLAGE_FRAC, GAP_FRAC = 0.065, 0.66, 0.1
-def mat_and_center(img, mat, empty=False):
+def mat_and_center(img, mat):
"""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")
@@ -204,22 +204,6 @@ def mat_and_center(img, mat, empty=False):
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 yet: hold the title where a full collage would put it and float
- # the one-line note where the birds would be, so a birdless frame reads like
- # a full one with the collage removed, not a title card drifted to the middle.
- 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 * GAP_FRAC)
- region = box_w * COLLAGE_FRAC # stand in for a usual-size collage
- cw = max(title.width, note.width)
- comp = Image.new("RGB", (cw, round(title.height + gap + region)), paper)
- comp.paste(title, ((cw - title.width) // 2, 0))
- ny = title.height + gap + max(0, (round(region) - note.height) // 2)
- comp.paste(note, ((cw - note.width) // 2, ny))
- 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_h(img.crop(tb), box_h * TITLE_H_FRAC)
@@ -367,7 +351,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"], empty=(species == []))
+ img = mat_and_center(img, cfg["mat"])
if preview:
out = quantize_spectra6(img)
if mat_box:
diff --git a/frame/shoot.py b/frame/shoot.py
index 6785504..3a76138 100644
--- a/frame/shoot.py
+++ b/frame/shoot.py
@@ -167,7 +167,7 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
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, species=None, cutout_base=None,
- cutout_local=None):
+ cutout_local=None, empty_text="listening for birds…"):
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
@@ -206,6 +206,16 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
timeout=timeout_ms)
except PWTimeout:
print("some illustrations did not finish loading; capturing anyway", file=sys.stderr)
+ elif page.query_selector(".nest-img") is not None:
+ # Birdless empty state: wait for the nest illustration to load so
+ # the frame never captures a blank collage area.
+ try:
+ page.wait_for_function(
+ "() => { const n=document.querySelector('.nest-img');"
+ " return n && n.complete && n.naturalWidth>0; }",
+ timeout=timeout_ms)
+ except PWTimeout:
+ print("nest illustration 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")
@@ -213,10 +223,11 @@ 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'; } }")
+ # Set the empty-state line for a birdless frame (the mic hasn't heard
+ # anything yet, or BirdWeather has no recent detections nearby) 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("(t) => { const e = document.querySelector('.empty'); if (e) { e.textContent = t; e.style.color = '#555'; } }", empty_text)
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})
@@ -241,7 +252,9 @@ def shoot_birdweather(out, species, *, title=None, subtitle=None, timeout_ms=450
cutout_local = os.path.join(here, "..", "avian", "assets", "illustrations")
# BirdWeather's flat 7-day counts need a steeper exponent for the same hero
# hierarchy; the slightly smaller titles match the mic frame's optical weight.
- for k, v in (("count_exp", 1.0), ("headline_px", 39), ("eyebrow_px", 17)):
+ # A birdless BirdWeather frame says "no recent detections nearby", not "listening".
+ for k, v in (("count_exp", 1.0), ("headline_px", 39), ("eyebrow_px", 17),
+ ("empty_text", "no recent detections nearby")):
look.setdefault(k, v)
return shoot(f"http://127.0.0.1:{port}/", out,
title=title or "Avian Visitors", subtitle=subtitle or "Heard Today",