Add a live preview thumbnail next to the frame name
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 1m59s
Build and push server image / deploy (push) Successful in 48s

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.
This commit is contained in:
2026-07-24 15:55:59 -04:00
parent 82f60ed428
commit 9c8a87e90d
7 changed files with 163 additions and 10 deletions
+16 -3
View File
@@ -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", 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, 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 """The widget system's compositor -- generalizes render_frame's tail
(paste, enhance once, overlay once, quantize once, pack once) from (paste, enhance once, overlay once, quantize once, pack once) from
"compose one photo" to "paste N already-rendered regions, then run "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 each region separately before pasting, is what keeps a 6-color
e-ink panel's dithering pattern consistent across a widget boundary e-ink panel's dithering pattern consistent across a widget boundary
instead of showing a visible seam where two independently-dithered 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) logical_w, logical_h = logical_render_size(orientation)
canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG) canvas = Image.new("RGB", (logical_w, logical_h), LETTERBOX_BG)
for (x, y, w, h), region_img in regions: 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 = _enhance(canvas, color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage) fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength) 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) 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, def render_placeholder(lines: list[str], qr_url: str | None = None,
orientation: str = "landscape", palette_rgb: list | 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 """A readable full-panel message (plus an optional QR code) in the
same packed format as render_frame -- what /frame/image serves for a 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 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) img = _apply_manage_overlay(img, manage)
quantized = _quantize(img, palette_rgb, dither_strength=1.0) 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) return _transpose_and_pack(quantized, orientation)
+18
View File
@@ -21,6 +21,7 @@ import time
import httpx import httpx
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session 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 ..firmware import firmware_path, parse_app_version
from ..models import BatteryLog, Frame, Widget from ..models import BatteryLog, Frame, Widget
from .common import OVERDUE_FACTOR, battery_estimate_s, immich_client_for, immich_creds, valid_http_url 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__) 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") @router.get("/api/frames/{frame_id}/battery-log")
def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)): def api_battery_log(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
rows = db.execute( rows = db.execute(
+24 -7
View File
@@ -45,7 +45,8 @@ logger = logging.getLogger(__name__)
router = APIRouter() 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 """What an unclaimed or widget-less frame displays instead of real
content -- instructions with a QR, rendered at 200 so the device content -- instructions with a QR, rendered at 200 so the device
treats it as a perfectly normal image and never error-loops. The 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, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, palette_rgb=frame.palette_rgb,
manage=manage, manage=manage,
as_png=as_png,
) )
if frame.owner_user_id is None: if frame.owner_user_id is None:
return render_placeholder( return render_placeholder(
@@ -68,6 +70,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
orientation=frame.orientation, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, palette_rgb=frame.palette_rgb,
manage=manage, manage=manage,
as_png=as_png,
) )
return render_placeholder( return render_placeholder(
["Almost there!", "Add a widget for this frame at", base], ["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, orientation=frame.orientation,
palette_rgb=frame.palette_rgb, palette_rgb=frame.palette_rgb,
manage=manage, 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 """The widget-system compositor: renders every widget on this frame
into its own region (see app/grid.py for grid-cell -> pixel math) and 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 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( return render_panel(
regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb, regions, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost, 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, 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. """The top-level "what does this frame show right now" entry point.
An unclaimed frame or one with no widgets yet gets the setup An unclaimed frame or one with no widgets yet gets the setup
placeholder (needs `request` for its QR URLs -- only available on the 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 not has_widgets:
if request is None: if request is None:
return render_placeholder( 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: def _run_button_actions(db: Session, frame: Frame, button: str) -> None:
+17
View File
@@ -57,3 +57,20 @@
if (e.key === 'Escape') closeEdit(); 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);
})();
+15
View File
@@ -619,6 +619,21 @@ code {
} }
.frame-name-edit button { margin-top: 0; } .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 { .control-banner {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -7,3 +7,5 @@
<button type="button" class="btn-inline" id="frame-name-save">Save</button> <button type="button" class="btn-inline" id="frame-name-save">Save</button>
<button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button> <button type="button" class="btn-inline secondary" id="frame-name-cancel">Cancel</button>
</span> </span>
<img id="frame-preview-thumb" class="frame-preview-thumb" alt="Live preview of what the frame is displaying" title="What the frame is currently displaying -- click to refresh">
+71
View File
@@ -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)