Add a static image widget (PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF upload)
Build and push server image / test (push) Successful in 26s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 59s

A new widget type showing a single user-uploaded image with no live
upstream to poll -- decoded once at upload time (PDF's first page via
pypdfium2, BSD-3-Clause/Apache-2.0, no copyleft exposure) into plain RGB
PNG bytes, then composed per a crop/stretch/letterbox display mode like
the photos widget.
This commit is contained in:
Thomas Faour
2026-07-25 08:39:09 +00:00
parent 4a2b1f3795
commit 35e80c6d1c
19 changed files with 618 additions and 8 deletions
+1
View File
@@ -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)
+7
View File
@@ -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,
+54
View File
@@ -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")
+12
View File
@@ -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),
]
+22
View File
@@ -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,
}
+68 -3
View File
@@ -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):
+9
View File
@@ -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(
+4 -1
View File
@@ -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 <dialog> is open, its own .dialog-result container gets the
+2 -2
View File
@@ -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;
+62
View File
@@ -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
// <dialog>, 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.
}
@@ -0,0 +1,44 @@
<h2 class="dialog-title">Static image widget</h2>
<section class="card">
<h2 class="card-title">Image</h2>
<p class="sub">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.</p>
<p class="sub" id="static-current-file">
{% if static_cfg and static_cfg.original_filename %}
Currently showing: <strong>{{ static_cfg.original_filename }}</strong>
{% else %}
No image uploaded yet.
{% endif %}
</p>
<input type="file" id="static-file" accept="image/*,application/pdf">
<button type="button" class="secondary" id="static-upload" style="margin-top: 8px;">Upload</button>
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Settings</h2>
<form id="static-config-form">
<label>Display mode
<select id="display_mode">
{% for mode, label in display_mode_labels.items() %}
<option value="{{ mode }}" {% if static_cfg and static_cfg.display_mode == mode %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<p class="sub" style="margin-top: 8px;">How the image's aspect ratio
is reconciled with this widget's box: <strong>Crop to fill</strong>
trims the excess; <strong>Stretch to fill</strong> fills the box
exactly without cropping (an image with a different aspect ratio
looks stretched); <strong>Shrink to fit</strong> shows the whole
image, letterboxed if needed.</p>
<button type="submit">Save</button>
</form>
</section>
<section class="card" style="margin-top: 20px;">
<h2 class="card-title">Preview</h2>
<p class="sub">How this widget currently renders.</p>
<img class="preview-img" id="static-preview" alt="Static image preview">
<button type="button" class="secondary" id="static-preview-refresh">Refresh now</button>
</section>
+1
View File
@@ -59,5 +59,6 @@
<script src="/static/widget_dialog_calendar.js"></script>
<script src="/static/widget_dialog_whiteboard.js"></script>
<script src="/static/widget_dialog_tasks.js"></script>
<script src="/static/widget_dialog_static.js"></script>
<script src="/static/frame_layout.js"></script>
{% endblock %}
+2 -1
View File
@@ -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,
}
+35
View File
@@ -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)
+1
View File
@@ -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
+88
View File
@@ -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<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[]/Count 0>>endobj\n"
b"xref\n0 1\n0000000000 65535 f \n"
b"trailer<</Size 3/Root 1 0 R>>\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
+1
View File
@@ -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) ---
@@ -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"
+74
View File
@@ -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 == {}