diff --git a/server/app/grid.py b/server/app/grid.py index da2f1fa..0b3bf01 100644 --- a/server/app/grid.py +++ b/server/app/grid.py @@ -30,6 +30,7 @@ MIN_FOOTPRINT: dict[str, tuple[int, int]] = { "calendar": (3, 2), "whiteboard": (2, 2), "tasks": (2, 2), + "static": (1, 1), } Rect = tuple[int, int, int, int] # (x, y, w, h) diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py index 590b45f..8624146 100644 --- a/server/app/image_pipeline.py +++ b/server/app/image_pipeline.py @@ -220,6 +220,13 @@ DISPLAY_MODE_LABELS = { DEFAULT_DISPLAY_MODE = "crop_faces" LETTERBOX_BG = (255, 255, 255) +# Static-image widget only offers a subset of DISPLAY_MODES -- no face +# detection for an uploaded image, so "crop_faces" (which silently falls +# back to crop_fill anyway, see compose_into) would just be a confusing +# duplicate entry in that dialog's dropdown. +STATIC_DISPLAY_MODES = ["crop_fill", "stretch_fill", "letterbox"] +DEFAULT_STATIC_DISPLAY_MODE = "crop_fill" + def _placement_transform( img_width: int, img_height: int, target_w: int, target_h: int, diff --git a/server/app/image_upload.py b/server/app/image_upload.py new file mode 100644 index 0000000..4e1f054 --- /dev/null +++ b/server/app/image_upload.py @@ -0,0 +1,54 @@ +"""Decodes an arbitrary uploaded file (PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF/...) +into a plain RGB PIL image, for the static-image widget (see routers/ +api_widgets.py's api_widget_static_upload, app/widgets/static_image.py). +The result is stored (as PNG bytes) rather than the original upload, so +render() never needs to re-run PDF/GIF decoding on every panel refresh -- +this module only runs once, at upload time. + +PDF decoding uses pypdfium2 (Google's PDFium bindings -- BSD-3-Clause/ +Apache-2.0, no copyleft exposure) rather than a GPL/AGPL alternative +like PyMuPDF, per CLAUDE.md's copyleft-dependency convention (a check +that only applies to copyleft/unclear licenses -- this one's plainly +permissive, so no explicit flag was needed here).""" + +from __future__ import annotations + +import io + +import pypdfium2 as pdfium +from fastapi import HTTPException +from PIL import Image, UnidentifiedImageError + +MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # generous for a single image/PDF page; stops an accidental huge upload +# ~144 DPI off a PDF's 72-DPI native unit -- comfortably above the panel's +# own 800x480, without ballooning render time/memory on a poster-sized page. +PDF_RENDER_SCALE = 2.0 + + +def decode_upload(data: bytes) -> Image.Image: + """Raises HTTPException(400) for anything that isn't a recognizable + image or PDF. Detects PDF by magic bytes, not the client-supplied + filename/content-type (neither is trustworthy). A PDF renders only + its first page -- there's no "which page" concept for a single-image + widget.""" + if len(data) > MAX_UPLOAD_BYTES: + raise HTTPException(400, f"File is too large (max {MAX_UPLOAD_BYTES // (1024 * 1024)}MB)") + if data.startswith(b"%PDF-"): + return _decode_pdf(data) + try: + img = Image.open(io.BytesIO(data)) + img.load() + except UnidentifiedImageError: + raise HTTPException(400, "Not a recognizable image or PDF file") from None + return img.convert("RGB") + + +def _decode_pdf(data: bytes) -> Image.Image: + try: + pdf = pdfium.PdfDocument(data) + if len(pdf) == 0: + raise HTTPException(400, "PDF has no pages") + bitmap = pdf[0].render(scale=PDF_RENDER_SCALE) + except pdfium.PdfiumError as e: + raise HTTPException(400, f"Could not read PDF: {e}") from e + return bitmap.to_pil().convert("RGB") diff --git a/server/app/migration.py b/server/app/migration.py index 653165c..ba95b6b 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -500,6 +500,17 @@ def _migration_19(conn) -> None: conn.execute(text("ALTER TABLE task_widget_configs ADD COLUMN name TEXT NOT NULL DEFAULT ''")) +def _migration_20(conn) -> None: + """New widget type: a static image widget shows whatever single + image (or a PDF's first page) the user last uploaded (see + app/image_upload.py, routers/api_widgets.py's api_widget_static_ + upload) -- no live upstream to poll, unlike every other widget type. + Brand new table with no existing data to carry forward, so this is + just create_all's usual "creates the one new table; existing ones + untouched" shape (see _migration_2).""" + Base.metadata.create_all(bind=conn) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -520,6 +531,7 @@ MIGRATIONS = [ (17, _migration_17), (18, _migration_18), (19, _migration_19), + (20, _migration_20), ] diff --git a/server/app/models.py b/server/app/models.py index 975e0d1..d10fb97 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -553,6 +553,27 @@ class WhiteboardWidgetConfig(Base): cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) +class StaticWidgetConfig(Base): + """One static-image widget's uploaded content + display settings -- + unlike every other widget type, this one has no live upstream to + poll (Immich/CalDAV/WebDAV): the "source" is whatever the user last + uploaded (see routers/api_widgets.py's api_widget_static_upload, + app/image_upload.py), decoded once at upload time into plain RGB PNG + bytes so app/widgets/static_image.py's render() never re-runs + PDF/GIF decoding on every panel refresh.""" + + __tablename__ = "static_widget_configs" + + widget_id: Mapped[int] = mapped_column(ForeignKey("widgets.id", ondelete="CASCADE"), primary_key=True) + image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) + original_filename: Mapped[str] = mapped_column(String, default="") + uploaded_at: Mapped[float] = mapped_column(Float, default=0.0) + # Same DISPLAY_MODES vocabulary as PhotoWidgetConfig.display_mode, + # minus crop_faces -- no face detection for an uploaded image (see + # image_pipeline.STATIC_DISPLAY_MODES). + display_mode: Mapped[str] = mapped_column(String, default="crop_fill") + + # widget_type -> its per-type extension table, keyed by widget_id. Used # by db.widget_locked() to resolve the right config row without importing # app/widgets/'s heavier render/action registry just for this lookup. @@ -561,6 +582,7 @@ WIDGET_CONFIG_MODELS: dict[str, type] = { "calendar": CalendarWidgetConfig, "whiteboard": WhiteboardWidgetConfig, "tasks": TaskWidgetConfig, + "static": StaticWidgetConfig, } diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index 9186381..2277f1b 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -19,7 +19,7 @@ import io import time import httpx -from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile from fastapi.responses import Response from PIL import Image from pydantic import BaseModel @@ -29,13 +29,21 @@ from sqlalchemy.orm import Session from .. import calendar_render, grid, photo_queue, quiet_hours, weather, webdav_client from ..auth import require_frame_control, require_frame_view, require_user_api from ..db import frame_locked, get_db, widget_locked -from ..image_pipeline import DEFAULT_DISPLAY_MODE, DISPLAY_MODES, render_preview_png +from ..image_pipeline import ( + DEFAULT_DISPLAY_MODE, + DEFAULT_STATIC_DISPLAY_MODE, + DISPLAY_MODES, + STATIC_DISPLAY_MODES, + render_preview_png, +) +from ..image_upload import decode_upload from ..models import ( CalendarWidgetConfig, Frame, FrameCalendar, FrameTaskList, PhotoWidgetConfig, + StaticWidgetConfig, TaskWidgetConfig, WhiteboardWidgetConfig, WIDGET_CONFIG_MODELS, @@ -245,7 +253,9 @@ def api_widgets_clear(frame: Frame = Depends(require_frame_control), db: Session def api_widget_config_save( frame_widget: tuple[Frame, Widget] = Depends(require_widget_control), db: Session = Depends(get_db), - # photos + # photos (display_mode is also reused by the static branch below -- + # each widget's own dialog only ever posts its own fields, so the two + # Form(None) uses of the same name never collide) album_id: str | None = Form(None), order: str | None = Form(None), display_mode: str | None = Form(None), @@ -333,6 +343,10 @@ def api_widget_config_save( if tasks_show_completed is not None and tasks_show_completed != tcfg.show_completed: tcfg.show_completed = tasks_show_completed tcfg.checked_at = 0.0 # pick up the change promptly + elif widget.widget_type == "static": + with widget_locked(db, frame.id, widget.id) as (_, _, scfg): + if display_mode is not None: + scfg.display_mode = display_mode if display_mode in STATIC_DISPLAY_MODES else DEFAULT_STATIC_DISPLAY_MODE with frame_locked(db, frame.id) as cfg: cfg.stats_config_saves += 1 return {"status": "saved"} @@ -787,6 +801,57 @@ def api_widget_weather_city_remove( return {"status": "saved"} +# --- Static image: upload/preview ----------------------------------------- + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/static-upload") +async def api_widget_static_upload( + file: UploadFile = File(...), + frame_widget: tuple[Frame, Widget] = Depends(require_widget_control), + db: Session = Depends(get_db), +): + """Decodes an uploaded PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF (see + app/image_upload.py) into plain RGB PNG bytes and stores it as this + widget's whole content -- a widget-wide setting like a photos + widget's album, hence require_widget_control (the frame's "take + control" gate) rather than the calendar/tasks owner-adds/anyone-mutes + split, since there's only ever one image and no per-person data.""" + frame, widget = frame_widget + _require_widget_type(widget, "static") + data = await file.read() + if not data: + raise HTTPException(400, "No file uploaded") + image = decode_upload(data) + buf = io.BytesIO() + image.save(buf, format="PNG") + with widget_locked(db, frame.id, widget.id) as (_, _, scfg): + scfg.image = buf.getvalue() + scfg.original_filename = (file.filename or "")[:255] + scfg.uploaded_at = time.time() + return {"status": "saved", "filename": scfg.original_filename} + + +@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/static") +def api_widget_preview_static( + frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db) +): + """The uploaded image run through this frame's actual saved + rendering pipeline (display mode, palette, color/contrast/dithering) + -- "how it will look on the frame", same convention as the photos/ + whiteboard preview endpoints.""" + frame, widget = frame_widget + _require_widget_type(widget, "static") + scfg = db.get(StaticWidgetConfig, widget.id) + if not scfg.image: + raise HTTPException(400, "No image uploaded to this widget yet") + source = Image.open(io.BytesIO(scfg.image)).convert("RGB") + png = render_preview_png( + source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb, + display_mode=scfg.display_mode, color_boost=frame.color_boost, + contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength, + ) + return Response(content=png, media_type="image/png") + + # --- Whiteboard: source/preview ------------------------------------------ class WhiteboardSourceRequest(BaseModel): diff --git a/server/app/routers/frame_pages.py b/server/app/routers/frame_pages.py index 94acefe..de6658e 100644 --- a/server/app/routers/frame_pages.py +++ b/server/app/routers/frame_pages.py @@ -24,6 +24,7 @@ from ..image_pipeline import ( DEFAULT_PALETTE_RGB, DISPLAY_MODE_LABELS, PALETTE_LABELS, + STATIC_DISPLAY_MODES, palette_to_hex, ) from ..models import ( @@ -32,6 +33,7 @@ from ..models import ( FrameCalendar, FrameTaskList, PhotoWidgetConfig, + StaticWidgetConfig, TaskWidgetConfig, User, UserFrame, @@ -236,6 +238,13 @@ def widget_dialog(frame_id: int, widget_id: int, request: Request, db: Session = "palette_to_hex": palette_to_hex, }) + if widget.widget_type == "static": + static_cfg = db.get(StaticWidgetConfig, widget.id) + return templates.TemplateResponse("_widget_dialog_static.html", { + "request": request, "frame": frame, "widget": widget, "static_cfg": static_cfg, + "display_mode_labels": {k: v for k, v in DISPLAY_MODE_LABELS.items() if k in STATIC_DISPLAY_MODES}, + }) + if widget.widget_type == "whiteboard": whiteboard_cfg = db.get(WhiteboardWidgetConfig, widget.id) viewer_has_webdav_creds = bool( diff --git a/server/app/static/common.js b/server/app/static/common.js index dbccb26..9f5403b 100644 --- a/server/app/static/common.js +++ b/server/app/static/common.js @@ -60,7 +60,10 @@ // Shared display names for widget_type, everywhere one shows up in the // UI (Layout canvas, Add-a-widget buttons, button-assignment dropdowns). -const WIDGET_LABELS = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks' }; +const WIDGET_LABELS = { + photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard (alpha)', tasks: 'Tasks', + static: 'Static image', +}; function showStatus(ok, message) { // While a is open, its own .dialog-result container gets the diff --git a/server/app/static/frame_layout.js b/server/app/static/frame_layout.js index ccf5788..02cc907 100644 --- a/server/app/static/frame_layout.js +++ b/server/app/static/frame_layout.js @@ -286,11 +286,11 @@ window.addEventListener('resize', () => { // icon was clicked). const DIALOG_INIT = { photos: initPhotosDialog, calendar: initCalendarDialog, whiteboard: initWhiteboardDialog, - tasks: initTasksDialog, + tasks: initTasksDialog, static: initStaticDialog, }; const DIALOG_CLOSE = { photos: closePhotosDialog, calendar: closeCalendarDialog, whiteboard: closeWhiteboardDialog, - tasks: closeTasksDialog, + tasks: closeTasksDialog, static: closeStaticDialog, }; let openDialogWidgetType = null; diff --git a/server/app/static/widget_dialog_static.js b/server/app/static/widget_dialog_static.js new file mode 100644 index 0000000..9885b13 --- /dev/null +++ b/server/app/static/widget_dialog_static.js @@ -0,0 +1,62 @@ +// Static image widget dialog: upload, display-mode setting, and the +// rendered preview. Not a page-load script -- frame_layout.js fetches +// this widget's dialog HTML fragment, injects it into the shared +// , points window.FRAME_API at this specific widget +// (/api/frames/{id}/widgets/{widget_id}), then calls initStaticDialog(). + +function loadStaticPreview() { + document.getElementById('static-preview').src = `${window.FRAME_API}/preview/static?_=${Date.now()}`; +} + +function initStaticDialog() { + document.getElementById('static-upload').addEventListener('click', async () => { + const input = document.getElementById('static-file'); + if (!input.files.length) { + showStatus(false, 'Pick a file first.'); + return; + } + const form = new FormData(); + form.append('file', input.files[0]); + try { + const resp = await fetch(`${window.FRAME_API}/static-upload`, { method: 'POST', body: form }); + if (!resp.ok) throw new Error(await apiError(resp)); + const result = await resp.json(); + const currentFileEl = document.getElementById('static-current-file'); + currentFileEl.textContent = 'Currently showing: '; + const nameEl = document.createElement('strong'); + nameEl.textContent = result.filename; + currentFileEl.appendChild(nameEl); + input.value = ''; + showStatus(true, 'Uploaded.'); + loadStaticPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); + + document.getElementById('static-config-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const body = new URLSearchParams({ + display_mode: document.getElementById('display_mode').value, + }); + try { + const resp = await fetch(`${window.FRAME_API}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + if (!resp.ok) throw new Error(await apiError(resp)); + showStatus(true, 'Saved.'); + loadStaticPreview(); + } catch (e) { + showStatus(false, e.message); + } + }); + + document.getElementById('static-preview-refresh').addEventListener('click', loadStaticPreview); + loadStaticPreview(); +} + +function closeStaticDialog() { + // Nothing to tear down -- no poll interval, unlike the photos dialog. +} diff --git a/server/app/templates/_widget_dialog_static.html b/server/app/templates/_widget_dialog_static.html new file mode 100644 index 0000000..d85f4a9 --- /dev/null +++ b/server/app/templates/_widget_dialog_static.html @@ -0,0 +1,44 @@ +

Static image widget

+ +
+

Image

+

Upload a PNG, JPEG, GIF, BMP, WEBP, TIFF, or PDF (first + page only) -- it's decoded once on upload and shown as-is until you + upload something else.

+

+ {% if static_cfg and static_cfg.original_filename %} + Currently showing: {{ static_cfg.original_filename }} + {% else %} + No image uploaded yet. + {% endif %} +

+ + +
+ +
+

Settings

+
+ +

How the image's aspect ratio + is reconciled with this widget's box: Crop to fill + trims the excess; Stretch to fill fills the box + exactly without cropping (an image with a different aspect ratio + looks stretched); Shrink to fit shows the whole + image, letterboxed if needed.

+ +
+
+ +
+

Preview

+

How this widget currently renders.

+ Static image preview + +
diff --git a/server/app/templates/frame_layout.html b/server/app/templates/frame_layout.html index 2412066..5ee80bc 100644 --- a/server/app/templates/frame_layout.html +++ b/server/app/templates/frame_layout.html @@ -59,5 +59,6 @@ + {% endblock %} diff --git a/server/app/widgets/__init__.py b/server/app/widgets/__init__.py index c4674a9..6606442 100644 --- a/server/app/widgets/__init__.py +++ b/server/app/widgets/__init__.py @@ -36,11 +36,12 @@ Each module in this package exposes: from __future__ import annotations -from . import calendar, photos, tasks, whiteboard +from . import calendar, photos, static_image, tasks, whiteboard WIDGET_TYPES = { "photos": photos, "calendar": calendar, "whiteboard": whiteboard, "tasks": tasks, + "static": static_image, } diff --git a/server/app/widgets/static_image.py b/server/app/widgets/static_image.py new file mode 100644 index 0000000..502dbe3 --- /dev/null +++ b/server/app/widgets/static_image.py @@ -0,0 +1,35 @@ +"""Static image widget: shows whatever image the user last uploaded +(routers/api_widgets.py's api_widget_static_upload already decoded +PNG/JPEG/GIF/PDF/etc. into plain RGB PNG bytes at upload time, see +app/image_upload.py and models.StaticWidgetConfig) -- no live upstream +to fetch, so render() is just a decode + compose_into, the simplest of +every widget type's render(). + +No button actions -- there's nothing to advance/back/check for a fixed +uploaded image.""" + +from __future__ import annotations + +import io + +from PIL import Image +from sqlalchemy.orm import Session + +from ..image_pipeline import compose_into +from ..models import Frame, StaticWidgetConfig, Widget +from ._shared import placeholder_image + +ACTIONS: dict = {} +ACTION_LABELS: dict[str, str] = {} + + +def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int, + is_normal_wake: bool = True) -> Image.Image: + """is_normal_wake is unused -- see app/widgets/whiteboard.py's + identical note; every widget type's render() shares one call + signature regardless of which ones actually care.""" + cfg = db.get(StaticWidgetConfig, widget.id) + if not cfg.image: + return placeholder_image(target_w, target_h, ["Static image widget", "not configured yet"]) + source = Image.open(io.BytesIO(cfg.image)).convert("RGB") + return compose_into(source, faces=None, target_w=target_w, target_h=target_h, display_mode=cfg.display_mode) diff --git a/server/requirements.txt b/server/requirements.txt index 8f7b163..0b675fb 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -10,3 +10,4 @@ qrcode==8.2 icalendar==7.2.2 recurring-ical-events==3.8.2 caldav==3.2.1 +pypdfium2==5.12.1 diff --git a/server/tests/test_image_upload.py b/server/tests/test_image_upload.py new file mode 100644 index 0000000..d5c21e0 --- /dev/null +++ b/server/tests/test_image_upload.py @@ -0,0 +1,88 @@ +"""app.image_upload.decode_upload -- pure decoding logic, no HTTP, no DB. +See routers/api_widgets.py's api_widget_static_upload for the endpoint +that calls this, and tests/test_widget_config_and_queue_endpoints.py for +HTTP-level coverage of that endpoint.""" + +from __future__ import annotations + +import io + +import pytest +from fastapi import HTTPException +from PIL import Image + +from app import image_upload + + +def _png_bytes(size=(20, 10), color=(255, 0, 0)) -> bytes: + buf = io.BytesIO() + Image.new("RGB", size, color).save(buf, format="PNG") + return buf.getvalue() + + +def _pdf_bytes(size=(40, 30), color=(0, 255, 0)) -> bytes: + buf = io.BytesIO() + Image.new("RGB", size, color).save(buf, format="PDF") + return buf.getvalue() + + +def _gif_bytes(size=(15, 15)) -> bytes: + buf = io.BytesIO() + Image.new("RGB", size, (0, 0, 255)).save(buf, format="GIF") + return buf.getvalue() + + +def test_decodes_a_png(): + img = image_upload.decode_upload(_png_bytes(size=(20, 10))) + assert img.size == (20, 10) + assert img.mode == "RGB" + + +def test_decodes_a_jpeg(): + buf = io.BytesIO() + Image.new("RGB", (12, 8), (1, 2, 3)).save(buf, format="JPEG") + img = image_upload.decode_upload(buf.getvalue()) + assert img.size == (12, 8) + assert img.mode == "RGB" + + +def test_decodes_a_gif(): + img = image_upload.decode_upload(_gif_bytes(size=(15, 15))) + assert img.size == (15, 15) + assert img.mode == "RGB" + + +def test_decodes_a_pdfs_first_page(): + img = image_upload.decode_upload(_pdf_bytes(size=(40, 30))) + # Rendered at PDF_RENDER_SCALE (2.0), not the page's native point size. + assert img.size == (80, 60) + assert img.mode == "RGB" + + +def test_rejects_garbage_bytes(): + with pytest.raises(HTTPException) as exc_info: + image_upload.decode_upload(b"this is not an image or a pdf") + assert exc_info.value.status_code == 400 + + +def test_rejects_a_pdf_with_no_pages(): + # A syntactically-valid empty PDF (no /Page objects) -- pypdfium2 + # opens it fine but reports zero pages. + empty_pdf = ( + b"%PDF-1.4\n" + b"1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"xref\n0 1\n0000000000 65535 f \n" + b"trailer<>\n" + b"startxref\n0\n%%EOF" + ) + with pytest.raises(HTTPException) as exc_info: + image_upload.decode_upload(empty_pdf) + assert exc_info.value.status_code == 400 + + +def test_rejects_a_file_over_the_size_limit(monkeypatch): + monkeypatch.setattr(image_upload, "MAX_UPLOAD_BYTES", 10) + with pytest.raises(HTTPException) as exc_info: + image_upload.decode_upload(_png_bytes()) + assert exc_info.value.status_code == 400 diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index d52dbd7..41c7b9a 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -75,6 +75,7 @@ def test_expected_columns_exist_on_current_schema(): assert "whiteboard_cached_image" in frame_columns # migration 14 assert "calendar_week_start_offset" in frame_columns assert "name" in task_widget_columns # migration 19 + assert "static_widget_configs" in inspector.get_table_names() # migration 20 # --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- diff --git a/server/tests/test_widget_config_and_queue_endpoints.py b/server/tests/test_widget_config_and_queue_endpoints.py index bbe8afa..380d9ed 100644 --- a/server/tests/test_widget_config_and_queue_endpoints.py +++ b/server/tests/test_widget_config_and_queue_endpoints.py @@ -9,7 +9,18 @@ while the (unmodified, copied-over) dialog JS posts form-urlencoded data from __future__ import annotations -from app.models import CalendarWidgetConfig, Frame, PhotoWidgetConfig, TaskWidgetConfig, Widget +import io + +from PIL import Image + +from app.models import ( + CalendarWidgetConfig, + Frame, + PhotoWidgetConfig, + StaticWidgetConfig, + TaskWidgetConfig, + Widget, +) from .conftest import csrf_headers @@ -44,6 +55,24 @@ def _add_tasks_widget(db_session) -> Widget: return widget +def _add_static_widget(db_session) -> Widget: + import time + + widget = Widget(frame_id=1, widget_type="static", x=0, y=0, w=2, h=2, + sort_order=1, created_at=time.time()) + db_session.add(widget) + db_session.flush() + db_session.add(StaticWidgetConfig(widget_id=widget.id)) + db_session.commit() + return widget + + +def _png_bytes(size=(20, 10), color=(10, 20, 30)) -> bytes: + buf = io.BytesIO() + Image.new("RGB", size, color).save(buf, format="PNG") + return buf.getvalue() + + def _mock_immich(monkeypatch): monkeypatch.setattr("app.routers.api_widgets.immich_client_for", lambda frame: object()) monkeypatch.setattr("app.routers.api_widgets.list_assets", lambda client, album_id: _ASSETS) @@ -123,6 +152,38 @@ def test_config_save_truncates_an_overlong_tasks_name(client, db_session): assert len(cfg.name) == 40 +def test_config_save_updates_a_static_widget(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_static_widget(db_session) + + resp = client.post( + f"/api/frames/1/widgets/{widget.id}/config", + data={"display_mode": "letterbox"}, + headers=csrf_headers(client), + ) + assert resp.status_code == 200, resp.text + + cfg = db_session.get(StaticWidgetConfig, widget.id) + assert cfg.display_mode == "letterbox" + + +def test_config_save_rejects_an_unrecognized_static_display_mode(client, db_session): + """Falls back to the default rather than erroring -- same "truncate/ + clamp, don't reject" posture as the other config-save fields.""" + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_static_widget(db_session) + + resp = client.post( + f"/api/frames/1/widgets/{widget.id}/config", + data={"display_mode": "crop_faces"}, # valid for photos, not offered for static + headers=csrf_headers(client), + ) + assert resp.status_code == 200, resp.text + + cfg = db_session.get(StaticWidgetConfig, widget.id) + assert cfg.display_mode == "crop_fill" + + def test_config_save_only_partially_updates_provided_fields(client, db_session): """Fields not present in the POST are left untouched -- the whole point of the partial-update convention (each dialog's own form only @@ -200,3 +261,72 @@ def test_queue_400s_when_widget_is_not_photos(client, db_session): widget = _add_calendar_widget(db_session) resp = client.get(f"/api/frames/1/widgets/{widget.id}/queue") assert resp.status_code == 400 + + +# --- static image: upload/preview ------------------------------------------ + +def test_static_upload_decodes_and_stores_the_image(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_static_widget(db_session) + + resp = client.post( + f"/api/frames/1/widgets/{widget.id}/static-upload", + files={"file": ("photo.png", _png_bytes(size=(20, 10)), "image/png")}, + headers=csrf_headers(client), + ) + assert resp.status_code == 200, resp.text + assert resp.json()["filename"] == "photo.png" + + cfg = db_session.get(StaticWidgetConfig, widget.id) + assert cfg.image is not None + assert Image.open(io.BytesIO(cfg.image)).size == (20, 10) + assert cfg.original_filename == "photo.png" + assert cfg.uploaded_at > 0 + + +def test_static_upload_rejects_a_non_image_non_pdf_file(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_static_widget(db_session) + + resp = client.post( + f"/api/frames/1/widgets/{widget.id}/static-upload", + files={"file": ("notes.txt", b"just some text", "text/plain")}, + headers=csrf_headers(client), + ) + assert resp.status_code == 400 + + cfg = db_session.get(StaticWidgetConfig, widget.id) + assert cfg.image is None + + +def test_static_upload_400s_when_widget_is_not_static(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_calendar_widget(db_session) + + resp = client.post( + f"/api/frames/1/widgets/{widget.id}/static-upload", + files={"file": ("photo.png", _png_bytes(), "image/png")}, + headers=csrf_headers(client), + ) + assert resp.status_code == 400 + + +def test_preview_static_400s_before_anything_is_uploaded(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_static_widget(db_session) + resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/static") + assert resp.status_code == 400 + + +def test_preview_static_renders_after_upload(client, db_session): + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_static_widget(db_session) + client.post( + f"/api/frames/1/widgets/{widget.id}/static-upload", + files={"file": ("photo.png", _png_bytes(), "image/png")}, + headers=csrf_headers(client), + ) + + resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/static") + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"] == "image/png" diff --git a/server/tests/test_widgets_static.py b/server/tests/test_widgets_static.py new file mode 100644 index 0000000..672f786 --- /dev/null +++ b/server/tests/test_widgets_static.py @@ -0,0 +1,74 @@ +"""app.widgets.static_image -- unit-level, no HTTP: constructs Widget/ +StaticWidgetConfig rows directly. The decode-at-upload-time path (see +app/image_upload.py) is covered separately in test_image_upload.py and +the HTTP-level upload endpoint in +test_widget_config_and_queue_endpoints.py -- these tests only exercise +render(), which always starts from already-decoded PNG bytes.""" + +from __future__ import annotations + +import io +import time + +from PIL import Image + +from app import widgets +from app.models import Frame, StaticWidgetConfig, Widget + + +def _png_bytes(size=(40, 30), color=(200, 50, 50)) -> bytes: + buf = io.BytesIO() + Image.new("RGB", size, color).save(buf, format="PNG") + return buf.getvalue() + + +def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]: + frame = db_session.get(Frame, 1) + widget = Widget(frame_id=frame.id, widget_type="static", x=0, y=0, w=2, h=2, + sort_order=0, created_at=time.time()) + db_session.add(widget) + db_session.flush() + db_session.add(StaticWidgetConfig(widget_id=widget.id, **cfg_kwargs)) + db_session.commit() + return frame, widget + + +def test_render_shows_a_placeholder_when_no_image_uploaded(db_session): + frame, widget = _make_widget(db_session) + img = widgets.static_image.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + assert img.mode == "RGB" + + +def test_render_composes_the_uploaded_image(db_session): + frame, widget = _make_widget(db_session, image=_png_bytes()) + img = widgets.static_image.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + assert img.mode == "RGB" + + +def test_render_respects_display_mode_stretch_fill(db_session): + frame, widget = _make_widget(db_session, image=_png_bytes(), display_mode="stretch_fill") + img = widgets.static_image.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + + +def test_render_respects_display_mode_letterbox(db_session): + frame, widget = _make_widget(db_session, image=_png_bytes(size=(100, 10)), display_mode="letterbox") + img = widgets.static_image.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + + +def test_render_at_minimum_grid_footprint(db_session): + """grid.MIN_FOOTPRINT["static"] is (1, 1) cells -- on an 8x5 grid + against a full 800x480 panel that's a 100x96 box, the smallest a + static widget can actually be placed at.""" + frame, widget = _make_widget(db_session, image=_png_bytes()) + img = widgets.static_image.render(db_session, frame, widget, 100, 96) + assert img.size == (100, 96) + + +def test_no_button_actions(): + """A fixed uploaded image -- nothing to advance/back/check.""" + assert widgets.static_image.ACTIONS == {} + assert widgets.static_image.ACTION_LABELS == {}