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:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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 == {}
|
||||
Reference in New Issue
Block a user