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 = '
' + + 'an empty nest' + + '

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",