diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py
index 219ae84..b991511 100644
--- a/server/app/image_pipeline.py
+++ b/server/app/image_pipeline.py
@@ -496,9 +496,16 @@ def render_frame(source: Image.Image, faces: list[dict] | None = None,
return _transpose_and_pack(quantized, orientation)
+def _png_bytes(img: Image.Image) -> bytes:
+ buf = io.BytesIO()
+ img.convert("RGB").save(buf, format="PNG")
+ return buf.getvalue()
+
+
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, as_png: bool = False) -> bytes:
+ dither_strength: float = 1.0, manage: dict | None = None, as_png: bool = False,
+ capture_snapshot: bool = False) -> bytes | tuple[bytes, 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
@@ -530,7 +537,14 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
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."""
+ thumbnail.
+
+ capture_snapshot=True (only meaningful alongside as_png=False) returns
+ (packed_bytes, png_bytes) instead of just packed_bytes -- both derived
+ from the same already-quantized canvas, so a device-facing render can
+ also persist a browser-viewable copy (see routers/device.py's
+ _record_last_displayed) without re-running composition/quantization a
+ second time."""
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:
@@ -540,10 +554,11 @@ def render_panel(regions: list[tuple[tuple[int, int, int, int], Image.Image]], o
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)
+ return _png_bytes(quantized)
+ packed = _transpose_and_pack(quantized, orientation)
+ if capture_snapshot:
+ return packed, _png_bytes(quantized)
+ return packed
def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
@@ -559,14 +574,13 @@ def render_preview_png(source: Image.Image, faces: list[dict] | None = None,
fitted = _enhance(_compose(source, faces, orientation, display_mode), color_boost, contrast_boost)
fitted = _apply_manage_overlay(fitted, manage)
quantized = _quantize(fitted, palette_rgb, dither_strength)
- buf = io.BytesIO()
- quantized.convert("RGB").save(buf, format="PNG")
- return buf.getvalue()
+ return _png_bytes(quantized)
def render_placeholder(lines: list[str], qr_url: str | None = None,
orientation: str = "landscape", palette_rgb: list | None = None,
- manage: dict | None = None, as_png: bool = False) -> bytes:
+ manage: dict | None = None, as_png: bool = False,
+ capture_snapshot: bool = False) -> bytes | tuple[bytes, 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
@@ -574,7 +588,8 @@ def render_placeholder(lines: list[str], qr_url: str | None = None,
`manage`, same as render_frame's -- lets the manage button still work
(at minimum, the scan-to-manage QR) on a frame that isn't configured
- yet."""
+ yet. `capture_snapshot`, same as render_panel's -- (packed, png)
+ instead of just packed."""
margin = 24
logical_w, logical_h = logical_render_size(orientation)
img = Image.new("RGB", (logical_w, logical_h), (255, 255, 255))
@@ -638,7 +653,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)
+ return _png_bytes(quantized)
+ packed = _transpose_and_pack(quantized, orientation)
+ if capture_snapshot:
+ return packed, _png_bytes(quantized)
+ return packed
diff --git a/server/app/migration.py b/server/app/migration.py
index f0f6a74..566a1f2 100644
--- a/server/app/migration.py
+++ b/server/app/migration.py
@@ -774,6 +774,30 @@ def _migration_29(conn) -> None:
conn.execute(text("ALTER TABLE frames ADD COLUMN last_cycled_layout_id INTEGER"))
+def _migration_30(conn) -> None:
+ """"Now displaying" (models.Frame.last_displayed_image/
+ last_displayed_at) -- the web UI's header preview pair needs a frozen
+ record of exactly what the last device-facing render actually sent,
+ separate from the always-live "up next" re-render (see
+ routers/device.py's _record_last_displayed, api_frames.py's
+ /now-displaying endpoint). NULL/0.0 for every existing frame until
+ its next real device fetch -- no behavior change to what's served,
+ only a new thing recorded alongside it.
+
+ Guarded per-column, same reasoning as migration 26/27/29's own
+ comments: frames is a table test_migrations.py's pre-widget-system
+ replay tests leave un-dropped, so it keeps the fresh-install
+ create_all() copy -- which already has these columns -- when those
+ tests replay migrations 17+ from schema_version 16. Without the
+ guard, replaying this migration there re-adds a column that's already
+ there and SQLite raises "duplicate column name"."""
+ existing = {c["name"] for c in inspect(conn).get_columns("frames")}
+ if "last_displayed_image" not in existing:
+ conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_image BLOB"))
+ if "last_displayed_at" not in existing:
+ conn.execute(text("ALTER TABLE frames ADD COLUMN last_displayed_at REAL NOT NULL DEFAULT 0.0"))
+
+
MIGRATIONS = [
(1, _migration_1),
(2, _migration_2),
@@ -804,6 +828,7 @@ MIGRATIONS = [
(27, _migration_27),
(28, _migration_28),
(29, _migration_29),
+ (30, _migration_30),
]
diff --git a/server/app/models.py b/server/app/models.py
index e0e57f0..56a836f 100644
--- a/server/app/models.py
+++ b/server/app/models.py
@@ -328,6 +328,16 @@ class Frame(Base):
# starts over from the first one, same as an unset value.
last_cycled_layout_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ # -- "now displaying" (see routers/device.py's _record_last_displayed,
+ # api_frames.py's /now-displaying endpoint) -- exactly what the last
+ # device-facing render (/frame/image, /frame/advance, /frame/back, or
+ # a global hold action) actually sent, as an upright PNG, so the web
+ # UI's header preview can show it frozen alongside a live "up next"
+ # re-render instead of conflating the two. NULL until a real device
+ # has fetched at least once.
+ last_displayed_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, default=None)
+ last_displayed_at: Mapped[float] = mapped_column(Float, default=0.0)
+
# -- stats (flattened from the old nested FrameStats) --
stats_first_seen: Mapped[float] = mapped_column(Float, default=0.0)
stats_device_wakes: Mapped[int] = mapped_column(Integer, default=0)
diff --git a/server/app/routers/api_frames.py b/server/app/routers/api_frames.py
index 396dbad..b50226a 100644
--- a/server/app/routers/api_frames.py
+++ b/server/app/routers/api_frames.py
@@ -271,6 +271,25 @@ def api_frame_preview(
return Response(content=png, media_type="image/png")
+@router.get("/api/frames/{frame_id}/now-displaying")
+def api_frame_now_displaying(frame: Frame = Depends(require_frame_view)):
+ """Exactly what was last actually sent to this frame's device (see
+ routers/device.py's _record_last_displayed) -- the frozen "now
+ displaying" half of the header preview pair, as opposed to /preview's
+ always-live "up next" re-render. 404 (not a placeholder image) until
+ the device has fetched at least once, so the web UI can show its own
+ empty state instead of a broken image. X-Displayed-At carries the
+ capture time (unix seconds) for a "N ago" label -- a header, not the
+ body, since the body is the raw PNG bytes."""
+ if frame.last_displayed_image is None:
+ raise HTTPException(404, "This frame hasn't displayed anything yet")
+ return Response(
+ content=frame.last_displayed_image,
+ media_type="image/png",
+ headers={"X-Displayed-At": str(frame.last_displayed_at)},
+ )
+
+
@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 4df1389..9a044a7 100644
--- a/server/app/routers/device.py
+++ b/server/app/routers/device.py
@@ -43,7 +43,7 @@ router = APIRouter()
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None,
- as_png: bool = False) -> bytes:
+ as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, 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
@@ -60,6 +60,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
palette_rgb=frame.palette_rgb,
manage=manage,
as_png=as_png,
+ capture_snapshot=capture_snapshot,
)
if frame.owner_user_id is None:
return render_placeholder(
@@ -68,6 +69,7 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
palette_rgb=frame.palette_rgb,
manage=manage,
as_png=as_png,
+ capture_snapshot=capture_snapshot,
)
return render_placeholder(
["Almost there!", "Add a widget for this frame at", base],
@@ -76,11 +78,12 @@ def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = Non
palette_rgb=frame.palette_rgb,
manage=manage,
as_png=as_png,
+ capture_snapshot=capture_snapshot,
)
def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wake: bool,
- as_png: bool = False) -> bytes:
+ as_png: bool = False, capture_snapshot: bool = False) -> bytes | tuple[bytes, bytes]:
"""The widget-system compositor: renders every widget on this frame
into its own region (see app/grid.py for grid-cell -> pixel math),
draws that widget's own optional border directly onto its region
@@ -112,11 +115,13 @@ def _render_widgets(db: Session, frame: Frame, manage: dict | None, is_normal_wa
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, as_png=as_png,
+ capture_snapshot=capture_snapshot,
)
def _render_frame_content(db: Session, frame: Frame, request: Request | None, manage: dict | None,
- is_normal_wake: bool, as_png: bool = False) -> bytes:
+ is_normal_wake: bool, as_png: bool = False,
+ capture_snapshot: bool = False) -> bytes | tuple[bytes, 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
@@ -134,11 +139,11 @@ def _render_frame_content(db: Session, frame: Frame, request: Request | None, ma
if request is None:
return render_placeholder(
["Almost there!"], orientation=frame.orientation, palette_rgb=frame.palette_rgb,
- manage=manage, as_png=as_png,
+ manage=manage, as_png=as_png, capture_snapshot=capture_snapshot,
)
- return _setup_placeholder(frame, request, manage=manage, as_png=as_png)
+ return _setup_placeholder(frame, request, manage=manage, as_png=as_png, capture_snapshot=capture_snapshot)
- return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png)
+ return _render_widgets(db, frame, manage, is_normal_wake, as_png=as_png, capture_snapshot=capture_snapshot)
def render_frame_preview_png(db: Session, frame: Frame, request: Request) -> bytes:
@@ -245,6 +250,17 @@ def _manage_flag(request: Request) -> bool:
return request.query_params.get("manage") == "1"
+def _record_last_displayed(db: Session, frame: Frame, png_snapshot: bytes) -> None:
+ """Persists exactly what a device-facing render just sent (upright
+ PNG, manage overlay included if present -- whatever's actually on the
+ panel) as this frame's "now displaying" snapshot, the frozen half of
+ the web UI's header preview pair (see api_frames.py's /now-displaying
+ endpoint and its always-live "up next" counterpart, /preview)."""
+ with frame_locked(db, frame.id) as locked:
+ locked.last_displayed_image = png_snapshot
+ locked.last_displayed_at = time.time()
+
+
@router.get("/frame/image")
def frame_image(
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
@@ -262,9 +278,14 @@ def frame_image(
?manage=1 (the manage button) composites the manage overlay onto
whatever this would have returned anyway -- see build_manage_content.
This is also the "normal wake" that resets any calendar widget's
- browse position back to today (see app/widgets/calendar.py)."""
+ browse position back to today (see app/widgets/calendar.py).
+
+ Also records what's returned as this frame's "now displaying"
+ snapshot (see _record_last_displayed) -- every other device-facing
+ render endpoint below does the same."""
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
- content = _render_frame_content(db, frame, request, manage, is_normal_wake=True)
+ content, snapshot = _render_frame_content(db, frame, request, manage, is_normal_wake=True, capture_snapshot=True)
+ _record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream")
@@ -277,7 +298,10 @@ def frame_advance(request: Request, frame: Frame = Depends(require_device), db:
device's next-photo button."""
_run_button_actions(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
- content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
+ content, snapshot = _render_frame_content(
+ db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
+ )
+ _record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream")
@@ -288,7 +312,10 @@ def frame_back(request: Request, frame: Frame = Depends(require_device), db: Ses
with nothing to go back to. Used by the device's back-photo button."""
_run_button_actions(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
- content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
+ content, snapshot = _render_frame_content(
+ db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
+ )
+ _record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream")
@@ -303,7 +330,10 @@ def frame_global_next(request: Request, frame: Frame = Depends(require_device),
firmware/main/next_button.c for the short/long split."""
_run_global_action(db, frame, "next")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
- content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
+ content, snapshot = _render_frame_content(
+ db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
+ )
+ _record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream")
@@ -312,7 +342,10 @@ def frame_global_back(request: Request, frame: Frame = Depends(require_device),
"""The mirror of /frame/global-next, for a held BACK button."""
_run_global_action(db, frame, "back")
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
- content = _render_frame_content(db, frame, request=None, manage=manage, is_normal_wake=False)
+ content, snapshot = _render_frame_content(
+ db, frame, request=None, manage=manage, is_normal_wake=False, capture_snapshot=True
+ )
+ _record_last_displayed(db, frame, snapshot)
return Response(content=content, media_type="application/octet-stream")
diff --git a/server/app/static/frame_header.js b/server/app/static/frame_header.js
index e269aea..b8e35b3 100644
--- a/server/app/static/frame_header.js
+++ b/server/app/static/frame_header.js
@@ -58,48 +58,31 @@
});
})();
-// 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 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. Click enlarges it in a dialog (which also fetches
-// a fresh render); clicking the enlarged image refreshes it again.
+// Now-displaying / up-next header preview pair. "Up next" is a real
+// composite render (same pipeline /frame/image uses), not a cached
+// snapshot, so it's on a slow poll 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, and it
+// shows layout edits live as they're made. "Now displaying" is the
+// opposite: exactly the bytes last actually sent to the device (see
+// routers/device.py's _record_last_displayed), frozen until the
+// device's next real wake even while the layout is being edited live --
+// that contrast is the point of showing both side by side.
(function () {
- var thumb = document.getElementById('frame-preview-thumb');
- var dialog = document.getElementById('frame-preview-dialog');
- var bigImg = document.getElementById('frame-preview-dialog-img');
- var closeBtn = document.getElementById('frame-preview-dialog-close');
- if (!thumb || !window.FRAME_BASE_API) return;
+ var nextThumb = document.getElementById('frame-preview-thumb');
+ var nextDialog = document.getElementById('frame-preview-dialog');
+ var nextBigImg = document.getElementById('frame-preview-dialog-img');
+ var nextCloseBtn = document.getElementById('frame-preview-dialog-close');
+ var nowThumb = document.getElementById('frame-preview-now-thumb');
+ var nowDialog = document.getElementById('frame-preview-now-dialog');
+ var nowBigImg = document.getElementById('frame-preview-now-dialog-img');
+ var nowCloseBtn = document.getElementById('frame-preview-now-dialog-close');
+ if (!nextThumb || !window.FRAME_BASE_API) return;
- function previewUrl() {
- return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
- }
- function refreshThumb() {
- thumb.src = previewUrl();
- }
- // Opening the dialog (or clicking the big image inside it) fetches a
- // fresh render and keeps the header thumb in sync, so this single path
- // covers both "enlarge" and the old click-to-refresh behavior.
- function refreshBig() {
- var url = previewUrl();
- bigImg.src = url;
- thumb.src = url;
- }
-
- thumb.addEventListener('click', function () {
- if (!dialog) { refreshThumb(); return; }
- refreshBig();
- dialog.showModal();
- });
- refreshThumb();
- setInterval(refreshThumb, 60000);
-
- if (dialog && bigImg && closeBtn) {
- bigImg.addEventListener('click', refreshBig);
- closeBtn.addEventListener('click', function () { dialog.close(); });
- // Same backdrop-click-to-close trick as #widget-dialog: a click that
- // lands on the dialog element itself (not its content box) means the
- // backdrop was hit.
+ // Same backdrop-click-to-close trick as #widget-dialog: a click that
+ // lands on the dialog element itself (not its content box) means the
+ // backdrop was hit.
+ function closeOnBackdropClick(dialog) {
dialog.addEventListener('click', function (e) {
if (e.target !== dialog) return;
var rect = dialog.getBoundingClientRect();
@@ -107,4 +90,80 @@
if (!inside) dialog.close();
});
}
+
+ function nextUrl() {
+ return `${window.FRAME_BASE_API}/preview?t=${Date.now()}`;
+ }
+ function refreshNext() {
+ nextThumb.src = nextUrl();
+ }
+ // Opening the dialog (or clicking the big image inside it) fetches a
+ // fresh render and keeps the header thumb in sync, so this single path
+ // covers both "enlarge" and the old click-to-refresh behavior.
+ function refreshNextBig() {
+ var url = nextUrl();
+ nextBigImg.src = url;
+ nextThumb.src = url;
+ }
+
+ nextThumb.addEventListener('click', function () {
+ if (!nextDialog) { refreshNext(); return; }
+ refreshNextBig();
+ nextDialog.showModal();
+ });
+ refreshNext();
+ setInterval(refreshNext, 60000);
+
+ if (nextDialog && nextBigImg && nextCloseBtn) {
+ nextBigImg.addEventListener('click', refreshNextBig);
+ nextCloseBtn.addEventListener('click', function () { nextDialog.close(); });
+ closeOnBackdropClick(nextDialog);
+ }
+
+ // "Now displaying" fetches rather than sets .src directly: it needs to
+ // tell a 404 (device hasn't fetched yet) apart from a real image to
+ // show its own empty state instead of a broken-image icon, and reads
+ // the capture time off X-Displayed-At for the "N ago" tooltip.
+ if (nowThumb) {
+ var nowObjectUrl = null;
+ function refreshNow() {
+ fetch(`${window.FRAME_BASE_API}/now-displaying?t=${Date.now()}`)
+ .then(function (resp) {
+ if (!resp.ok) {
+ nowThumb.classList.add('frame-preview-thumb-empty');
+ nowThumb.removeAttribute('src');
+ nowThumb.title = "Now displaying -- hasn't shown anything yet";
+ return null;
+ }
+ var displayedAt = resp.headers.get('X-Displayed-At');
+ nowThumb.title = displayedAt
+ ? `Now displaying -- ${formatDuration(Math.max(0, Date.now() / 1000 - parseFloat(displayedAt)))} ago -- click to enlarge`
+ : 'Now displaying -- click to enlarge';
+ return resp.blob();
+ })
+ .then(function (blob) {
+ if (!blob) return;
+ nowThumb.classList.remove('frame-preview-thumb-empty');
+ var url = URL.createObjectURL(blob);
+ var old = nowObjectUrl;
+ nowObjectUrl = url;
+ nowThumb.src = url;
+ if (old) URL.revokeObjectURL(old);
+ })
+ .catch(function () { /* transient failure -- leave the last-known thumb showing */ });
+ }
+
+ nowThumb.addEventListener('click', function () {
+ if (nowThumb.classList.contains('frame-preview-thumb-empty') || !nowDialog) return;
+ nowBigImg.src = nowThumb.src;
+ nowDialog.showModal();
+ });
+ refreshNow();
+ setInterval(refreshNow, 60000);
+
+ if (nowDialog && nowCloseBtn) {
+ nowCloseBtn.addEventListener('click', function () { nowDialog.close(); });
+ closeOnBackdropClick(nowDialog);
+ }
+ }
})();
diff --git a/server/app/static/theme.css b/server/app/static/theme.css
index b37af6b..4cf08bd 100644
--- a/server/app/static/theme.css
+++ b/server/app/static/theme.css
@@ -719,13 +719,24 @@ code {
}
.frame-name-edit button { margin-top: 0; }
+.frame-preview-pair {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-left: 12px;
+ vertical-align: middle;
+}
+.frame-preview-arrow {
+ color: var(--text-muted);
+ font-size: 16px;
+ line-height: 1;
+}
.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);
@@ -733,6 +744,13 @@ code {
transition: opacity .12s ease;
}
.frame-preview-thumb:hover { opacity: 0.8; }
+.frame-preview-thumb-empty {
+ opacity: 0.3;
+ cursor: default;
+ width: 60px;
+ font-size: 0; /* no src yet -- suppresses the browser's fallback alt-text render */
+}
+.frame-preview-thumb-empty:hover { opacity: 0.3; }
.frame-preview-dialog {
position: fixed;
diff --git a/server/app/templates/_frame_name_edit.html b/server/app/templates/_frame_name_edit.html
index 9d63799..55ca3e4 100644
--- a/server/app/templates/_frame_name_edit.html
+++ b/server/app/templates/_frame_name_edit.html
@@ -7,10 +7,19 @@
-
+
+
+ →
+
+
+
+
diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py
index fe4ed8a..adf81c4 100644
--- a/server/tests/test_migrations.py
+++ b/server/tests/test_migrations.py
@@ -92,6 +92,7 @@ def test_expected_columns_exist_on_current_schema():
button_action_indexes = {idx["name"] for idx in inspector.get_indexes("frame_button_actions")}
assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28
assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns
+ assert {"last_displayed_image", "last_displayed_at"} <= frame_columns # migration 30
# --- widget system backfill (migration 16 + _ensure_widgets_backfilled) ---
@@ -331,6 +332,25 @@ def test_migration_29_adds_hold_action_columns_to_an_existing_database(db_sessio
assert frame.last_cycled_layout_id is None
+def test_migration_30_adds_now_displaying_columns_to_an_existing_database(db_session):
+ """Exercises _migration_30's real guarded ALTER path (frames isn't
+ dropped/recreated by the pre-widget-system replay tests, so its
+ columns must be added defensively, same reasoning as migration
+ 26/27/29's own comments)."""
+ with db_module.engine.begin() as conn:
+ conn.execute(text("UPDATE schema_version SET version = 29"))
+
+ run_migrations()
+
+ with db_module.engine.connect() as conn:
+ version = conn.execute(text("SELECT version FROM schema_version")).scalar()
+ assert version == MIGRATIONS[-1][0]
+
+ frame = db_session.get(Frame, 1)
+ assert frame.last_displayed_image is None
+ assert frame.last_displayed_at == 0.0
+
+
def test_migration_17_and_18_extract_tasks_into_a_standalone_multi_list_widget(db_session):
"""Exercises _migration_17 and _migration_18's actual data-extraction
SQL back to back (the real "existing widget-system database
diff --git a/server/tests/test_now_displaying.py b/server/tests/test_now_displaying.py
new file mode 100644
index 0000000..e3e6026
--- /dev/null
+++ b/server/tests/test_now_displaying.py
@@ -0,0 +1,94 @@
+"""GET /api/frames/{id}/now-displaying -- the frozen half of the header
+preview pair (see routers/device.py's _record_last_displayed). Distinct
+from /preview (test_frame_preview.py): that one always live-renders,
+this one serves back exactly whatever bytes a device-facing endpoint
+last actually sent, recorded as a side effect of /frame/image,
+/frame/advance, and /frame/back."""
+
+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_now_displaying_404s_before_any_device_fetch(client, db_session):
+ client.post("/setup", data={"username": "alice", "password": "hunter22"})
+
+ resp = client.get("/api/frames/1/now-displaying")
+ assert resp.status_code == 404
+
+
+def test_frame_image_records_now_displaying(client, db_session):
+ client.post("/setup", data={"username": "alice", "password": "hunter22"})
+ frame = db_session.get(Frame, 1)
+
+ resp = client.get("/frame/image")
+ assert resp.status_code == 200
+
+ resp = client.get("/api/frames/1/now-displaying")
+ assert resp.status_code == 200
+ assert resp.headers["content-type"] == "image/png"
+ assert "X-Displayed-At" in resp.headers
+ assert float(resp.headers["X-Displayed-At"]) > 0
+
+ img = Image.open(io.BytesIO(resp.content))
+ assert img.size == logical_render_size(frame.orientation)
+
+
+def test_advance_and_back_also_update_now_displaying(client, db_session):
+ client.post("/setup", data={"username": "alice", "password": "hunter22"})
+ db_session.get(Frame, 1)
+
+ client.get("/frame/image")
+ first = client.get("/api/frames/1/now-displaying")
+ assert first.status_code == 200
+
+ resp = client.post("/frame/advance")
+ assert resp.status_code == 200
+ after_advance = client.get("/api/frames/1/now-displaying")
+ assert after_advance.status_code == 200
+
+ resp = client.post("/frame/back")
+ assert resp.status_code == 200
+ after_back = client.get("/api/frames/1/now-displaying")
+ assert after_back.status_code == 200
+ assert float(after_back.headers["X-Displayed-At"]) >= float(first.headers["X-Displayed-At"])
+
+
+def test_now_displaying_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.get("/frame/image")
+
+ client.cookies.clear()
+ login(client, "bob")
+ resp = client.get("/api/frames/1/now-displaying")
+ assert resp.status_code == 200
+
+
+def test_now_displaying_hidden_from_unrelated_user(client, db_session):
+ client.post("/setup", data={"username": "alice", "password": "hunter22"})
+ make_user(db_session, "mallory")
+ client.get("/frame/image")
+
+ client.cookies.clear()
+ login(client, "mallory")
+ resp = client.get("/api/frames/1/now-displaying")
+ assert resp.status_code == 404
+
+
+def test_now_displaying_requires_login(client, db_session):
+ client.post("/setup", data={"username": "alice", "password": "hunter22"})
+ client.get("/frame/image")
+
+ client.cookies.clear()
+ resp = client.get("/api/frames/1/now-displaying")
+ assert resp.status_code in (401, 403)