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:
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user