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)
|
||||
Reference in New Issue
Block a user