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.
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
||||||
|
})();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user