Add a static image widget (PNG/JPEG/GIF/BMP/WEBP/TIFF/PDF upload)
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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user