From 9c8a87e90d3d32fda0de1d0d47e0912ee757a7c6 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Fri, 24 Jul 2026 15:55:59 -0400 Subject: [PATCH] Add a live preview thumbnail next to the frame name Small header thumbnail showing exactly what the frame is currently displaying -- same widget compositor /frame/image uses, handed back as a plain PNG (image_pipeline.render_panel/render_placeholder gain an as_png option) instead of packed native-panel bytes. New session-authed GET /api/frames/{id}/preview exposes it; click-to-refresh plus a slow 60s poll on the frame header so it doesn't hammer Immich/calendar sources just for a header thumbnail. --- server/app/image_pipeline.py | 19 +++++- server/app/routers/api_frames.py | 18 ++++++ server/app/routers/device.py | 31 +++++++--- server/app/static/frame_header.js | 17 ++++++ server/app/static/theme.css | 15 +++++ server/app/templates/_frame_name_edit.html | 2 + server/tests/test_frame_preview.py | 71 ++++++++++++++++++++++ 7 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 server/tests/test_frame_preview.py diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py index 96c18ba..590b45f 100644 --- a/server/app/image_pipeline.py +++ b/server/app/image_pipeline.py @@ -383,7 +383,7 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None, def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], orientation: str = "landscape", palette_rgb: list | None = None, color_boost: float = 1.0, contrast_boost: float = 1.0, - dither_strength: float = 1.0, manage: dict | None = None) -> bytes: + dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False) -> bytes: """The widget system's compositor -- generalizes render_frame's tail (paste, enhance once, overlay once, quantize once, pack once) from "compose one photo" to "paste N already-rendered regions, then run @@ -410,7 +410,12 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o each region separately before pasting, is what keeps a 6-color e-ink panel's dithering pattern consistent across a widget boundary instead of showing a visible seam where two independently-dithered - regions meet.""" + regions meet. + + as_png=True returns a normal browser-viewable PNG in logical (upright) + orientation instead of packed native-panel bytes, same convention as + render_preview_png -- used for the web UI's live "how it's displaying" + thumbnail.""" logical_w, logical_h = logical_render_size(orientation) canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG) for (x, y, w, h), region_img in regions: @@ -419,6 +424,10 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o fitted = _enhance(canvas, color_boost, contrast_boost) fitted = _apply_manage_overlay(fitted, manage) quantized = _quantize(fitted, palette_rgb, dither_strength) + if as_png: + buf = io.BytesIO() + quantized.convert("RGB").save(buf, format="PNG") + return buf.getvalue() return _transpose_and_pack(quantized, orientation) @@ -442,7 +451,7 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None, def render_placeholder(lines: list[str], qr_url: str | None = None, orientation: str = "landscape", palette_rgb: list | None = None, - manage: dict | None = None) -> bytes: + manage: dict | None = None, as_png: bool = False) -> bytes: """A readable full-panel message (plus an optional QR code) in the same packed format as render_frame -- what /frame/image serves for a frame that isn't claimed or configured yet, so a fresh device shows @@ -513,4 +522,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None, img = _apply_manage_overlay(img, manage) quantized = _quantize(img, palette_rgb, dither_strength=1.0) + if as_png: + buf = io.BytesIO() + quantized.convert("RGB").save(buf, format="PNG") + return buf.getvalue() return _transpose_and_pack(quantized, orientation) diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py index 163e11b..c1eb464 100644 --- a/server/app/routers/api_frames.py +++ b/server/app/routers/api_frames.py @@ -21,6 +21,7 @@ import time import httpx from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import Response from sqlalchemy import select from sqlalchemy.orm import Session @@ -31,6 +32,7 @@ from ..image_pipeline import PALETTE_LABELS, hex_to_rgb from ..firmware import firmware_path, parse_app_version from ..models import BatteryLog, Frame, Widget from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url +from .device import render_frame_preview_png logger = logging.getLogger(__name__) @@ -231,6 +233,22 @@ def api_status( } +@router.get("/api/frames/{frame_id}/preview") +def api_frame_preview( + request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db) +): + """A small PNG of exactly what the frame is currently displaying -- + the same widget compositor /frame/image uses (see routers/device.py's + render_frame_preview_png), just handed back upright and unpacked for + the dashboard header's live thumbnail instead of the device's packed + native format. Not cached: cheap enough for an on-demand header image, + and each widget's own render is already idempotent between a device's + real wakes (see photo_queue.get_current, calendar widget's browse + reset), so an extra read here doesn't skip or duplicate anything.""" + png = render_frame_preview_png(db, frame, request) + return Response(content=png, media_type="image/png") + + @router.get("/api/frames/{frame_id}/battery-log") def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): rows = db.execute( diff --git a/server/app/routers/device.py b/server/app/routers/device.py index 73bd160..66d3246 100644 --- a/server/app/routers/device.py +++ b/server/app/routers/device.py @@ -45,7 +45,8 @@ logger = logging.getLogger(__name__) router = APIRouter() -def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes: +def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None, + as_png: bool = False) -> bytes: """What an unclaimed or widget-less frame displays instead of real content -- instructions with a QR, rendered at 200 so the device treats it as a perfectly normal image and never error-loops. The @@ -61,6 +62,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage, + as_png=as_png, ) if frame.owner_user_id is None: return render_placeholder( @@ -68,6 +70,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage, + as_png=as_png, ) return render_placeholder( ["Almost there!", "Add a widget for this frame at", base], @@ -75,10 +78,12 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage, + as_png=as_png, ) -def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool) -> bytes: +def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool, + as_png: bool = False) -> bytes: """The widget-system compositor: renders every widget on this frame into its own region (see app/grid.py for grid-cell -> pixel math) and hands the results to image_pipeline.render_panel for the single @@ -102,12 +107,12 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa return render_panel( regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb, color_boost=frame.color_boost, contrast_boost=frame.contrast_boost, - dither_strength=frame.dither_strength, manage=manage, + dither_strength=frame.dither_strength, manage=manage, as_png=as_png, ) def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None, - is_normal_wake: bool) -> bytes: + is_normal_wake: bool, as_png: bool = False) -> bytes: """The top-level "what does this frame show right now" entry point. An unclaimed frame or one with no widgets yet gets the setup placeholder (needs `request` for its QR URLs -- only available on the @@ -124,11 +129,23 @@ def _render_frame_content(db: Session, frame: Frame, request: Request | None, ma if not has_widgets: if request is None: return render_placeholder( - ["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage + ["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb, + manage=manage, as_png=as_png, ) - return _setup_placeholder(frame, request, manage=manage) + return _setup_placeholder(frame, request, manage=manage, as_png=as_png) - return _render_widgets(db, frame, manage, is_normal_wake) + return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png) + + +def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes: + """The web UI's live "how it's displaying" thumbnail (see + routers/api_frames.py's /preview endpoint) -- same compositor + /frame/image uses, just handed back as a small upright PNG instead of + packed native-panel bytes. Exported from here (rather than + duplicated) since this module already owns the full widget- + compositing pipeline; nothing about the /frame/* paths themselves + changes.""" + return _render_frame_content(db, frame, request, manage=None, is_normal_wake=True, as_png=True) def _run_button_actions(db: Session, frame: Frame, button: str) -> None: diff --git a/server/app/static/frame_header.js b/server/app/static/frame_header.js index e8dc2de..eeab908 100644 --- a/server/app/static/frame_header.js +++ b/server/app/static/frame_header.js @@ -57,3 +57,20 @@ if (e.key === 'Escape') closeEdit(); }); })(); + +// Live "how it's displaying" thumbnail. A real composite render (same +// pipeline /frame/image uses), not a cached snapshot, so it's on a slow +// poll and also click-to-refresh rather than something tighter like the +// 10s device-status poll -- no need to hit Immich/calendar/whiteboard +// sources that often just for a header thumbnail. +(function () { + var thumb = document.getElementById('frame-preview-thumb'); + if (!thumb || !window.FRAME_BASE_API) return; + + function refresh() { + thumb.src = `${window.FRAME_BASE_API}/preview?t=${Date.now()}`; + } + thumb.addEventListener('click', refresh); + refresh(); + setInterval(refresh, 60000); +})(); diff --git a/server/app/static/theme.css b/server/app/static/theme.css index 641b4dc..e3800b7 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -619,6 +619,21 @@ code { } .frame-name-edit button { margin-top: 0; } +.frame-preview-thumb { + height: 44px; + width: auto; + max-width: 130px; + object-fit: contain; + vertical-align: middle; + margin-left: 12px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface-alt); + cursor: pointer; + transition: opacity .12s ease; +} +.frame-preview-thumb:hover { opacity: 0.8; } + .control-banner { display: flex; align-items: center; diff --git a/server/app/templates/_frame_name_edit.html b/server/app/templates/_frame_name_edit.html index 473227d..feaac6d 100644 --- a/server/app/templates/_frame_name_edit.html +++ b/server/app/templates/_frame_name_edit.html @@ -7,3 +7,5 @@ +Live preview of what the frame is displaying + diff --git a/server/tests/test_frame_preview.py b/server/tests/test_frame_preview.py new file mode 100644 index 0000000..27d20dd --- /dev/null +++ b/server/tests/test_frame_preview.py @@ -0,0 +1,71 @@ +"""GET /api/frames/{id}/preview -- the dashboard header's live "how it's +displaying" thumbnail (see routers/device.py's render_frame_preview_png). +Same compositor as /frame/image, just PNG instead of packed bytes, and +gated by require_frame_view instead of device auth -- so the interesting +things to check are that it's a real PNG of the right logical size and +that view-only auth (not device auth) actually applies.""" + +from __future__ import annotations + +import io + +from PIL import Image + +from app.image_pipeline import logical_render_size +from app.models import Frame + +from .conftest import link_user, login, make_user + + +def test_preview_returns_a_png_at_logical_size(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + + resp = client.get("/api/frames/1/preview") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "image/png" + + img = Image.open(io.BytesIO(resp.content)) + assert img.size == logical_render_size(frame.orientation) + + +def test_preview_reflects_orientation(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + frame = db_session.get(Frame, 1) + frame.orientation = "portrait" + db_session.commit() + + resp = client.get("/api/frames/1/preview") + assert resp.status_code == 200 + img = Image.open(io.BytesIO(resp.content)) + assert img.size == logical_render_size("portrait") + assert img.width < img.height + + +def test_preview_visible_to_linked_user(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + bob = make_user(db_session, "bob") + frame = db_session.get(Frame, 1) + link_user(db_session, bob, frame) + + client.cookies.clear() + login(client, "bob") + resp = client.get("/api/frames/1/preview") + assert resp.status_code == 200 + + +def test_preview_hidden_from_unrelated_user(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + make_user(db_session, "mallory") + + client.cookies.clear() + login(client, "mallory") + resp = client.get("/api/frames/1/preview") + assert resp.status_code == 404 + + +def test_preview_requires_login(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + client.cookies.clear() + resp = client.get("/api/frames/1/preview") + assert resp.status_code in (401, 403)