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
+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(