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
+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 == {}