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)