From aa4a382c1b64bd3056ef627cc19923fbf0f9dcab Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Tue, 28 Jul 2026 00:41:11 +0000 Subject: [PATCH] Add "now displaying" / "up next" preview pair to the frame header The server now records exactly what was last sent to the device on every device-facing render (/frame/image, /frame/advance, /frame/back, and the global hold actions), persisted as Frame.last_displayed_image/ _at and served back via GET /api/frames/{id}/now-displaying. The header thumbnail is split into that frozen "now displaying" snapshot and the existing live "up next" re-render, with an arrow between them -- so editing a layout shows the change immediately on the right while the left stays exactly what's actually on the panel until the device's next real wake. --- server/app/image_pipeline.py | 46 ++++--- server/app/migration.py | 25 ++++ server/app/models.py | 10 ++ server/app/routers/api_frames.py | 19 +++ server/app/routers/device.py | 57 +++++++-- server/app/static/frame_header.js | 139 +++++++++++++++------ server/app/static/theme.css | 20 ++- server/app/templates/_frame_name_edit.html | 13 +- server/tests/test_migrations.py | 20 +++ server/tests/test_now_displaying.py | 94 ++++++++++++++ 10 files changed, 373 insertions(+), 70 deletions(-) create mode 100644 server/tests/test_now_displaying.py 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 @@ -Live preview of what the frame is displaying + + What the frame is currently displaying + + Live preview of what the frame will show next + + + + + What the frame is currently displaying + - Live preview of what the frame is displaying + Live preview of what the frame will show next 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)