Server: Frame.panel_type (new column + migration) is auto-derived from the device's reported board (X-Frame-Board), never user-set -- the panel is a property of the hardware, not a picker in the UI. image_pipeline's packing/render pipeline is parameterized by panel geometry instead of hardcoded 800x480 globals, with the real confirmed 13.3in geometry (1600x1200) registered alongside the original 7.3in panel. Existing 7.3in frames are unaffected (column default + board mapping both resolve to the original panel). Board identifiers are also renamed (devkit/xiao -> devkit_esp32c6/ xiao_esp32c6, plus new "ee02") since the EE02 board also carries a XIAO module -- "xiao" alone stopped disambiguating hardware. The server keeps accepting the legacy bare names indefinitely for already-flashed devices. Firmware: scaffolds a third build target (ee02, ESP32-S3 -- a real chip-target change, not just a same-chip Kconfig variant like xiao) and a new epd13in3e driver component skeleton. The actual panel init/LUT/ refresh register sequence isn't ported from vendor demo code yet (none was available), so that component deliberately fails to compile (#error) rather than risk sending unverified register values to real hardware -- devkit/xiao are unaffected and build identically to before. CI's ee02 build step is continue-on-error for the same reason.
1432 lines
65 KiB
Python
1432 lines
65 KiB
Python
"""Everything scoped to one specific widget rather than "the frame":
|
|
placement CRUD (backing the Layout tab's canvas, static/frame_layout.js)
|
|
plus every setting/action that used to assume a frame had at most one
|
|
widget of a given type -- photo queue, calendar inclusion/color, tasks
|
|
inclusion/color, whiteboard source, and their preview endpoints. Split out of
|
|
api_frames.py (which keeps frame-wide settings: orientation, quiet
|
|
hours, palette, firmware, stats) once a frame could hold more than one
|
|
widget of the same type, at which point "the frame's calendar settings"
|
|
stopped meaning anything unambiguous.
|
|
|
|
Placement mutations re-validate bounds/minimum footprint/no-overlap
|
|
server-side regardless of what the client already checked -- the
|
|
client's own checks are UX, not the source of truth (this project's
|
|
usual posture, e.g. api_frames.py's own field clamps)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import time
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
|
from fastapi.responses import Response
|
|
from PIL import Image
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .. import calendar_render, grid, panel_style, photo_queue, quiet_hours, weather, weather_render, 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 (
|
|
BORDER_STYLES,
|
|
DEFAULT_DISPLAY_MODE,
|
|
DEFAULT_STATIC_DISPLAY_MODE,
|
|
DISPLAY_MODES,
|
|
hex_to_rgb,
|
|
logical_render_size,
|
|
MAX_BORDER_THICKNESS,
|
|
MIN_BORDER_THICKNESS,
|
|
PALETTE_LABELS,
|
|
STATIC_DISPLAY_MODES,
|
|
panel_size,
|
|
render_preview_png,
|
|
compose_into,
|
|
_enhance,
|
|
_png_bytes,
|
|
_quantize,
|
|
)
|
|
from ..image_upload import decode_upload
|
|
from ..models import (
|
|
CalendarWidgetConfig,
|
|
Frame,
|
|
FrameButtonAction,
|
|
FrameCalendar,
|
|
FrameTaskList,
|
|
PhotoWidgetConfig,
|
|
StaticWidgetConfig,
|
|
TaskWidgetConfig,
|
|
TextWidgetConfig,
|
|
WeatherWidgetConfig,
|
|
WhiteboardWidgetConfig,
|
|
WIDGET_CONFIG_MODELS,
|
|
Widget,
|
|
)
|
|
from ..text_content import has_text, parse_rich_text
|
|
from ..widgets import WIDGET_TYPES, default_button_actions
|
|
from ..widgets import battery as battery_widget
|
|
from ..widgets import text as text_widget
|
|
from .common import (
|
|
calendar_sources_for_widget,
|
|
fetch_source_and_faces,
|
|
get_or_refresh_calendar_events_for_widget,
|
|
get_or_refresh_tasks_for_widget,
|
|
get_or_refresh_weather_for_widget,
|
|
get_or_refresh_weather_widget_data,
|
|
get_or_refresh_whiteboard_for_widget,
|
|
immich_client_for,
|
|
immich_creds,
|
|
list_assets,
|
|
task_sources_for_widget,
|
|
valid_http_url,
|
|
webdav_creds_for,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
MIN_QUEUE_TARGET_LEN = 5
|
|
MAX_QUEUE_TARGET_LEN = 5000
|
|
MIN_TEXT_FONT_SIZE = 10
|
|
MAX_TEXT_FONT_SIZE = 96
|
|
CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE_LABELS
|
|
MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks
|
|
|
|
|
|
def _widget_dict(w: Widget, locked: bool = False) -> dict:
|
|
return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h,
|
|
"sort_order": w.sort_order, "border_style": w.border_style,
|
|
"border_thickness": w.border_thickness, "border_color_index": w.border_color_index,
|
|
"font_scale": w.font_scale, "locked": locked}
|
|
|
|
|
|
def require_widget_view(
|
|
widget_id: int, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)
|
|
) -> tuple[Frame, Widget]:
|
|
"""View-only widget dependency -- same 404-not-403 posture as
|
|
require_frame_view for a widget id that doesn't belong to this
|
|
frame (or doesn't exist at all)."""
|
|
widget = db.get(Widget, widget_id)
|
|
if widget is None or widget.frame_id != frame.id:
|
|
raise HTTPException(404, "No such widget")
|
|
return frame, widget
|
|
|
|
|
|
def require_widget_control(
|
|
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
|
) -> tuple[Frame, Widget]:
|
|
"""Same as require_widget_view, but behind the frame's "take control"
|
|
soft lock -- for endpoints that mutate the widget's own settings."""
|
|
widget = db.get(Widget, widget_id)
|
|
if widget is None or widget.frame_id != frame.id:
|
|
raise HTTPException(404, "No such widget")
|
|
return frame, widget
|
|
|
|
|
|
def _require_widget_type(widget: Widget, expected: str) -> None:
|
|
if widget.widget_type != expected:
|
|
raise HTTPException(400, f"This widget is a {widget.widget_type} widget, not {expected}")
|
|
|
|
|
|
def _photo_config_or_400(db: Session, frame: Frame, widget: Widget) -> PhotoWidgetConfig:
|
|
"""Same 400 shape routers/common.py's photo_widget_config_or_404 uses
|
|
for a frame with no configured photo widget at all, here for a widget
|
|
we already know is a photos widget -- Immich creds are frame/owner-
|
|
level, album_id is this widget's own."""
|
|
url, key = immich_creds(frame)
|
|
if not url or not key:
|
|
raise HTTPException(400, "Immich URL/API key not configured yet")
|
|
pcfg = db.get(PhotoWidgetConfig, widget.id)
|
|
if not pcfg.album_id:
|
|
raise HTTPException(400, "No album configured yet")
|
|
return pcfg
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets")
|
|
def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
|
user = require_user_api(request, db)
|
|
cols, rows = grid.grid_dims(frame.orientation)
|
|
widgets = db.scalars(
|
|
select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order)
|
|
).all()
|
|
# Layout canvas needs to know which photo widgets are locked (to draw
|
|
# the lock badge) -- a per-type config field, not on Widget itself,
|
|
# so it's a separate lookup rather than something _widget_dict can
|
|
# read straight off the row it's given.
|
|
photo_widget_ids = [w.id for w in widgets if w.widget_type == "photos"]
|
|
locked_by_widget_id = dict(db.execute(
|
|
select(PhotoWidgetConfig.widget_id, PhotoWidgetConfig.locked)
|
|
.where(PhotoWidgetConfig.widget_id.in_(photo_widget_ids))
|
|
).all()) if photo_widget_ids else {}
|
|
return {
|
|
"orientation": frame.orientation,
|
|
"grid": {"cols": cols, "rows": rows},
|
|
"widget_types": list(WIDGET_TYPES.keys()),
|
|
"min_footprint": grid.MIN_FOOTPRINT,
|
|
"widgets": [_widget_dict(w, locked_by_widget_id.get(w.id, False)) for w in widgets],
|
|
"control": {
|
|
"controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None,
|
|
"you": frame.controlled_by_user_id == user.id,
|
|
},
|
|
}
|
|
|
|
|
|
def _other_rects(db: Session, frame_id: int, exclude_widget_id: int | None) -> list[grid.Rect]:
|
|
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame_id)).all()
|
|
return [(w.x, w.y, w.w, w.h) for w in widgets if w.id != exclude_widget_id]
|
|
|
|
|
|
def _validate_placement(db: Session, frame: Frame, widget_type: str, rect: grid.Rect,
|
|
exclude_widget_id: int | None = None) -> None:
|
|
if not grid.in_bounds(frame.orientation, rect):
|
|
raise HTTPException(400, "Placement is out of bounds for this frame's grid")
|
|
if not grid.meets_minimum(widget_type, rect):
|
|
min_w, min_h = grid.MIN_FOOTPRINT.get(widget_type, (1, 1))
|
|
raise HTTPException(400, f"A {widget_type} widget needs at least {min_w}x{min_h} grid cells")
|
|
for other_rect in _other_rects(db, frame.id, exclude_widget_id):
|
|
if grid.overlaps(rect, other_rect):
|
|
raise HTTPException(400, "Overlaps another widget")
|
|
|
|
|
|
class WidgetCreateRequest(BaseModel):
|
|
widget_type: str
|
|
x: int | None = None
|
|
y: int | None = None
|
|
w: int | None = None
|
|
h: int | None = None
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets")
|
|
def api_widget_create(
|
|
body: WidgetCreateRequest, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
|
):
|
|
if body.widget_type not in WIDGET_TYPES:
|
|
raise HTTPException(400, f"Unknown widget type: {body.widget_type}")
|
|
min_w, min_h = grid.MIN_FOOTPRINT.get(body.widget_type, (1, 1))
|
|
w, h = body.w or min_w, body.h or min_h
|
|
|
|
if body.x is None or body.y is None:
|
|
rect = grid.find_open_rect(frame.orientation, _other_rects(db, frame.id, None), w, h)
|
|
if rect is None:
|
|
raise HTTPException(400, "No open space left for a widget this size")
|
|
else:
|
|
rect = (body.x, body.y, w, h)
|
|
_validate_placement(db, frame, body.widget_type, rect)
|
|
|
|
with frame_locked(db, frame.id):
|
|
max_sort = db.scalar(select(func.max(Widget.sort_order)).where(Widget.frame_id == frame.id))
|
|
x, y, w, h = rect
|
|
widget = Widget(frame_id=frame.id, widget_type=body.widget_type, x=x, y=y, w=w, h=h,
|
|
sort_order=(max_sort or 0) + 1, created_at=time.time())
|
|
db.add(widget)
|
|
db.flush()
|
|
db.add(WIDGET_CONFIG_MODELS[body.widget_type](widget_id=widget.id))
|
|
db.add_all(default_button_actions(frame.id, widget.id, body.widget_type))
|
|
db.commit()
|
|
return _widget_dict(widget)
|
|
|
|
|
|
class WidgetPlacementRequest(BaseModel):
|
|
x: int
|
|
y: int
|
|
w: int
|
|
h: int
|
|
|
|
|
|
@router.patch("/api/frames/{frame_id}/widgets/{widget_id}")
|
|
def api_widget_move(
|
|
widget_id: int, body: WidgetPlacementRequest,
|
|
frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db),
|
|
):
|
|
widget = db.get(Widget, widget_id)
|
|
if widget is None or widget.frame_id != frame.id:
|
|
raise HTTPException(404, "No such widget")
|
|
rect = (body.x, body.y, body.w, body.h)
|
|
_validate_placement(db, frame, widget.widget_type, rect, exclude_widget_id=widget.id)
|
|
with frame_locked(db, frame.id):
|
|
widget.x, widget.y, widget.w, widget.h = body.x, body.y, body.w, body.h
|
|
db.commit()
|
|
return _widget_dict(widget)
|
|
|
|
|
|
class WidgetBorderRequest(BaseModel):
|
|
border_style: str
|
|
border_thickness: int
|
|
border_color_index: int
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/border")
|
|
def api_widget_border(
|
|
body: WidgetBorderRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Sets this widget's optional border -- a shared Widget-level
|
|
property (see models.Widget), not a per-type config field, since
|
|
every widget type can have one regardless of widget_type. Its own
|
|
endpoint (not folded into api_widget_config_save) for the same
|
|
reason: that endpoint's per-type dispatch is keyed on a config row
|
|
via widget_locked, and border fields live on Widget itself, not any
|
|
per-type config table."""
|
|
frame, widget = frame_widget
|
|
if body.border_style not in BORDER_STYLES:
|
|
raise HTTPException(400, f"border_style must be one of {BORDER_STYLES}")
|
|
if not (0 <= body.border_color_index < len(PALETTE_LABELS)):
|
|
raise HTTPException(400, "border_color_index must be 0-5 (a panel palette color)")
|
|
thickness = max(MIN_BORDER_THICKNESS, min(MAX_BORDER_THICKNESS, body.border_thickness))
|
|
with frame_locked(db, frame.id):
|
|
widget.border_style = body.border_style
|
|
widget.border_thickness = thickness
|
|
widget.border_color_index = body.border_color_index
|
|
db.commit()
|
|
return _widget_dict(widget)
|
|
|
|
|
|
class WidgetFontScaleRequest(BaseModel):
|
|
font_scale: float
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/font-scale")
|
|
def api_widget_font_scale(
|
|
body: WidgetFontScaleRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Sets this widget's text-size multiplier (the Layout dialog's "Text
|
|
size" picker) -- a shared Widget-level property (see models.Widget.
|
|
font_scale), not a per-type config field, since any widget type with
|
|
body text can use it. Its own endpoint for the same reason
|
|
api_widget_border has one: api_widget_config_save's per-type dispatch
|
|
edits a config row via widget_locked, and font_scale lives on Widget
|
|
itself, not any per-type config table."""
|
|
frame, widget = frame_widget
|
|
if body.font_scale not in panel_style.FONT_SCALE_CHOICES:
|
|
raise HTTPException(400, f"font_scale must be one of {panel_style.FONT_SCALE_CHOICES}")
|
|
with frame_locked(db, frame.id):
|
|
widget.font_scale = body.font_scale
|
|
db.commit()
|
|
return _widget_dict(widget)
|
|
|
|
|
|
@router.delete("/api/frames/{frame_id}/widgets/{widget_id}")
|
|
def api_widget_delete(
|
|
widget_id: int, frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)
|
|
):
|
|
"""Cascades to the widget's own config row and any FrameButtonAction
|
|
bindings that pointed at it (both ondelete="CASCADE" FKs, see
|
|
models.py) -- nothing left pointing at a widget id that no longer
|
|
exists."""
|
|
widget = db.get(Widget, widget_id)
|
|
if widget is None or widget.frame_id != frame.id:
|
|
raise HTTPException(404, "No such widget")
|
|
with frame_locked(db, frame.id):
|
|
db.delete(widget)
|
|
db.commit()
|
|
return {"status": "deleted"}
|
|
|
|
|
|
@router.delete("/api/frames/{frame_id}/widgets")
|
|
def api_widgets_clear(frame: Frame = Depends(require_frame_control), db: Session = Depends(get_db)):
|
|
"""The Layout tab's "Clear all" button -- same per-widget cascade as
|
|
api_widget_delete, just every widget on this frame in one locked
|
|
transaction instead of one request per widget."""
|
|
with frame_locked(db, frame.id):
|
|
widgets = db.scalars(select(Widget).where(Widget.frame_id == frame.id)).all()
|
|
for widget in widgets:
|
|
db.delete(widget)
|
|
db.commit()
|
|
return {"status": "cleared", "count": len(widgets)}
|
|
|
|
|
|
# --- Per-widget-type config save (the gear-icon dialog's Save button) --------
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/config")
|
|
def api_widget_config_save(
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
# 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),
|
|
static_render_style: str | None = Form(None),
|
|
whiteboard_render_style: str | None = Form(None),
|
|
queue_target_len: int | None = Form(None),
|
|
# calendar
|
|
calendar_view: str | None = Form(None),
|
|
calendar_render_style: str | None = Form(None),
|
|
calendar_week_start: int | None = Form(None),
|
|
calendar_week_days: int | None = Form(None),
|
|
calendar_week_layout: str | None = Form(None),
|
|
calendar_week_start_offset: int | None = Form(None),
|
|
calendar_weather_enabled: bool | None = Form(None),
|
|
calendar_weather_units: str | None = Form(None),
|
|
# tasks
|
|
tasks_name: str | None = Form(None),
|
|
tasks_show_completed: bool | None = Form(None),
|
|
tasks_render_style: str | None = Form(None),
|
|
# text
|
|
text_html: str | None = Form(None),
|
|
text_font_size: int | None = Form(None),
|
|
text_font_family: str | None = Form(None),
|
|
text_render_style: str | None = Form(None),
|
|
text_align: str | None = Form(None),
|
|
text_background_color: str | None = Form(None),
|
|
# weather
|
|
weather_mode: str | None = Form(None),
|
|
weather_provider: str | None = Form(None),
|
|
weather_units: str | None = Form(None),
|
|
weather_hourly_interval_hours: int | None = Form(None),
|
|
weather_daily_days: int | None = Form(None),
|
|
weather_render_style: str | None = Form(None),
|
|
# battery
|
|
battery_mode: str | None = Form(None),
|
|
battery_render_style: str | None = Form(None),
|
|
):
|
|
"""Every field optional -- same partial-update, form-urlencoded
|
|
convention as the old frame-level api_config_save, now scoped to one
|
|
widget instead of "the frame's widget of this type". Fields that
|
|
don't apply to this widget's own widget_type are simply ignored,
|
|
same posture as an unrecognized form field always had here."""
|
|
frame, widget = frame_widget
|
|
if widget.widget_type == "photos":
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, pcfg):
|
|
if album_id is not None and album_id != pcfg.album_id:
|
|
# A newly selected album starts clean -- the old current
|
|
# photo and queue don't mean anything in the new album's
|
|
# context.
|
|
pcfg.current_asset_id = ""
|
|
pcfg.current_asset_set_at = 0.0
|
|
pcfg.queue = []
|
|
pcfg.queue_cursor = 0
|
|
pcfg.history = []
|
|
pcfg.excluded_asset_ids = []
|
|
pcfg.album_id = album_id
|
|
if order is not None:
|
|
pcfg.order = order if order in ("sequential", "shuffle") else "sequential"
|
|
if display_mode is not None:
|
|
pcfg.display_mode = display_mode if display_mode in DISPLAY_MODES else DEFAULT_DISPLAY_MODE
|
|
if queue_target_len is not None:
|
|
pcfg.queue_target_len = max(MIN_QUEUE_TARGET_LEN, min(MAX_QUEUE_TARGET_LEN, queue_target_len))
|
|
elif widget.widget_type == "calendar":
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, ccfg):
|
|
if calendar_view is not None:
|
|
new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
|
if new_view != ccfg.view:
|
|
# A stale offset means something different in a
|
|
# different view's units (days vs. weeks vs. months).
|
|
ccfg.browse_offset = 0
|
|
ccfg.view = new_view
|
|
if calendar_week_start is not None:
|
|
ccfg.week_start = max(0, min(6, calendar_week_start))
|
|
if calendar_week_days is not None:
|
|
new_days = max(2, min(10, calendar_week_days))
|
|
if new_days != ccfg.week_days:
|
|
# A stale offset counts a different-sized page under
|
|
# the old day count.
|
|
ccfg.browse_offset = 0
|
|
ccfg.week_days = new_days
|
|
if calendar_week_layout is not None:
|
|
ccfg.week_layout = (
|
|
calendar_week_layout if calendar_week_layout in ("horizontal", "vertical") else "horizontal"
|
|
)
|
|
if calendar_week_start_offset is not None:
|
|
new_offset = max(-30, min(30, calendar_week_start_offset))
|
|
if new_offset != ccfg.week_start_offset:
|
|
ccfg.browse_offset = 0
|
|
ccfg.week_start_offset = new_offset
|
|
if calendar_weather_enabled is not None:
|
|
ccfg.weather_enabled = calendar_weather_enabled
|
|
if calendar_weather_units is not None and calendar_weather_units in ("fahrenheit", "celsius"):
|
|
if calendar_weather_units != ccfg.weather_units:
|
|
# Cached forecasts are in the old unit -- force a
|
|
# refetch rather than showing stale numbers under a
|
|
# new unit label.
|
|
ccfg.weather_checked_at = 0.0
|
|
ccfg.weather_units = calendar_weather_units
|
|
if calendar_render_style is not None and calendar_render_style in ("classic", "modern"):
|
|
ccfg.render_style = calendar_render_style
|
|
elif widget.widget_type == "tasks":
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, tcfg):
|
|
if tasks_name is not None:
|
|
# Truncated, not rejected -- MAX_TASKS_NAME_LEN is a
|
|
# sane on-panel-header length, not a validation rule the
|
|
# user needs an error for.
|
|
tcfg.name = tasks_name.strip()[:MAX_TASKS_NAME_LEN]
|
|
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
|
|
if tasks_render_style is not None and tasks_render_style in ("classic", "modern"):
|
|
tcfg.render_style = tasks_render_style
|
|
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
|
|
if static_render_style is not None and static_render_style in ("classic", "modern"):
|
|
scfg.render_style = static_render_style
|
|
elif widget.widget_type == "whiteboard":
|
|
# user_id/url go through the dedicated /whiteboard-source endpoint
|
|
# (owner-only, JSON body) -- render_style is the one setting this
|
|
# widget type takes through the shared /config form, same as
|
|
# every other widget type's own render_style.
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, wbcfg):
|
|
if whiteboard_render_style is not None and whiteboard_render_style in ("classic", "modern"):
|
|
wbcfg.render_style = whiteboard_render_style
|
|
elif widget.widget_type == "text":
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, xcfg):
|
|
if text_html is not None:
|
|
# The one save path that touches app/text_content.py --
|
|
# see its module docstring for why parsing (not storing
|
|
# raw HTML) is the actual sanitization boundary here.
|
|
xcfg.content = parse_rich_text(text_html)
|
|
if text_font_size is not None:
|
|
xcfg.font_size = max(MIN_TEXT_FONT_SIZE, min(MAX_TEXT_FONT_SIZE, text_font_size))
|
|
if text_font_family is not None:
|
|
xcfg.font_family = (
|
|
text_font_family if text_font_family in text_widget.FONT_FAMILIES
|
|
else text_widget.DEFAULT_FONT_FAMILY
|
|
)
|
|
if text_align is not None:
|
|
xcfg.align = text_align if text_align in ("left", "center", "right") else "left"
|
|
if text_background_color is not None:
|
|
xcfg.background_color = (
|
|
text_background_color if hex_to_rgb(text_background_color) else "#ffffff"
|
|
)
|
|
if text_render_style is not None and text_render_style in ("classic", "modern"):
|
|
xcfg.render_style = text_render_style
|
|
elif widget.widget_type == "weather":
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, wcfg):
|
|
if weather_mode is not None and weather_mode in ("current", "hourly", "daily", "multi_city"):
|
|
if weather_mode != wcfg.mode:
|
|
# A stale cache is a different shape under a
|
|
# different mode (a single-temp dict vs. an hourly
|
|
# list vs. a daily dict vs. a city list) -- clear it
|
|
# outright (not just force a refetch attempt) so a
|
|
# get_or_refresh_weather_widget_data call that happens
|
|
# to fail on the very first fetch under the new mode
|
|
# doesn't fall back to the old mode's incompatible
|
|
# cached shape.
|
|
wcfg.checked_at = 0.0
|
|
wcfg.cached = None
|
|
wcfg.mode = weather_mode
|
|
if weather_provider is not None and weather_provider in weather.PROVIDERS:
|
|
if weather_provider != wcfg.provider:
|
|
wcfg.checked_at = 0.0
|
|
wcfg.provider = weather_provider
|
|
if weather_units is not None and weather_units in ("fahrenheit", "celsius"):
|
|
if weather_units != wcfg.units:
|
|
# Cached temps are in the old unit -- force a refetch
|
|
# rather than showing stale numbers under a new unit
|
|
# label (same idiom as calendar_weather_units above).
|
|
wcfg.checked_at = 0.0
|
|
wcfg.units = weather_units
|
|
if weather_hourly_interval_hours is not None:
|
|
wcfg.hourly_interval_hours = max(1, min(24, weather_hourly_interval_hours))
|
|
if weather_daily_days is not None:
|
|
wcfg.daily_days = max(1, min(14, weather_daily_days))
|
|
if weather_render_style is not None and weather_render_style in ("classic", "modern"):
|
|
wcfg.render_style = weather_render_style
|
|
elif widget.widget_type == "battery":
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, bcfg):
|
|
if battery_mode is not None:
|
|
bcfg.mode = battery_mode if battery_mode in ("compact", "detailed") else "detailed"
|
|
if battery_render_style is not None and battery_render_style in ("classic", "modern"):
|
|
bcfg.render_style = battery_render_style
|
|
with frame_locked(db, frame.id) as cfg:
|
|
cfg.stats_config_saves += 1
|
|
return {"status": "saved"}
|
|
|
|
|
|
class WidgetButtonActionsRequest(BaseModel):
|
|
next_button_action: str
|
|
back_button_action: str
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/button-actions")
|
|
def api_widget_button_actions(
|
|
body: WidgetButtonActionsRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Sets this widget's NEXT/BACK button bindings (models.FrameButtonAction)
|
|
-- its own endpoint, not folded into api_widget_config_save, same
|
|
reasoning as api_widget_border above: these rows live in their own
|
|
table, not this widget's per-type config table. Replaces the old
|
|
frame-level "Button assignments" card (routers/api_frames.py's
|
|
api_buttons_get/api_buttons_save, now removed) -- each widget's own
|
|
dialog edits its own binding directly, prefilled at widget-creation
|
|
time with a sane default (see widgets.default_button_actions).
|
|
|
|
An empty string clears the binding for that button. Unlike
|
|
api_widget_config_save's silent-ignore-unrecognized-value posture,
|
|
a value outside this widget type's own ACTIONS is a 400 -- this
|
|
request body is specifically about button actions, so a bad value
|
|
here is a real client bug worth surfacing, not a stray field to
|
|
shrug off."""
|
|
frame, widget = frame_widget
|
|
valid_actions = set(WIDGET_TYPES[widget.widget_type].ACTIONS)
|
|
for value in (body.next_button_action, body.back_button_action):
|
|
if value != "" and value not in valid_actions:
|
|
raise HTTPException(400, f"{widget.widget_type} widgets don't support the {value!r} action")
|
|
with frame_locked(db, frame.id):
|
|
for button, value in (("next", body.next_button_action), ("back", body.back_button_action)):
|
|
existing = db.scalars(
|
|
select(FrameButtonAction).where(
|
|
FrameButtonAction.widget_id == widget.id, FrameButtonAction.button == button
|
|
)
|
|
).first()
|
|
if value == "":
|
|
if existing is not None:
|
|
db.delete(existing)
|
|
elif existing is not None:
|
|
existing.action = value
|
|
else:
|
|
db.add(FrameButtonAction(frame_id=frame.id, button=button, widget_id=widget.id, action=value))
|
|
db.commit()
|
|
return {"status": "saved"}
|
|
|
|
|
|
# --- Photos: queue/thumbnail/preview ------------------------------------
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/queue")
|
|
def api_widget_queue(
|
|
request: Request, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "photos")
|
|
user = require_user_api(request, db)
|
|
pcfg = _photo_config_or_400(db, frame, widget)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, pcfg.album_id)
|
|
|
|
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_pcfg):
|
|
photo_queue.get_current(locked_pcfg, assets, locked_frame,
|
|
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
|
|
photo_queue.sync_queue_length(locked_pcfg, assets)
|
|
current_asset_id = locked_pcfg.current_asset_id
|
|
queue = list(locked_pcfg.queue)
|
|
locked = locked_pcfg.locked
|
|
controller_id = locked_frame.controlled_by_user_id
|
|
controller = (
|
|
(locked_frame.controlled_by.display_name or locked_frame.controlled_by.username)
|
|
if locked_frame.controlled_by else None
|
|
)
|
|
|
|
def entry(asset_id: str) -> dict:
|
|
return {"id": asset_id, "thumbnail_url": f"/api/frames/{frame.id}/widgets/{widget.id}/thumbnail/{asset_id}"}
|
|
|
|
return {
|
|
"current": entry(current_asset_id) if current_asset_id else None,
|
|
"upcoming": [entry(asset_id) for asset_id in queue],
|
|
"locked": locked,
|
|
"control": {"controller": controller, "you": controller_id == user.id},
|
|
}
|
|
|
|
|
|
class QueueReorderRequest(BaseModel):
|
|
queue: list[str]
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/reorder")
|
|
def api_widget_queue_reorder(
|
|
body: QueueReorderRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Applies the client's requested order, tolerating drift between the
|
|
browser's last-fetched snapshot and the server's current queue (e.g.
|
|
a top-up/trim landed in between) instead of hard-rejecting: any ID
|
|
the client sent that's no longer actually queued is dropped, and any
|
|
ID the server has that the client didn't know about is appended
|
|
rather than lost."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "photos")
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
|
current_set = set(cfg.queue)
|
|
reordered = [asset_id for asset_id in body.queue if asset_id in current_set]
|
|
reordered += [asset_id for asset_id in cfg.queue if asset_id not in set(reordered)]
|
|
cfg.queue = reordered
|
|
return {"status": "saved"}
|
|
|
|
|
|
class QueuePromoteRequest(BaseModel):
|
|
asset_id: str
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/promote")
|
|
def api_widget_queue_promote(
|
|
body: QueuePromoteRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Moves a single photo to the front of the queue -- "Show next"."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "photos")
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
|
if body.asset_id not in cfg.queue:
|
|
raise HTTPException(400, "That photo is no longer in the upcoming queue")
|
|
cfg.queue = [body.asset_id] + [asset_id for asset_id in cfg.queue if asset_id != body.asset_id]
|
|
return {"status": "saved"}
|
|
|
|
|
|
class QueueRemoveRequest(BaseModel):
|
|
asset_id: str
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/queue/remove")
|
|
def api_widget_queue_remove(
|
|
body: QueueRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Permanently removes a photo from this widget's rotation. Does NOT
|
|
touch Immich or the album itself; see photo_queue.remove_from_rotation()."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "photos")
|
|
pcfg = _photo_config_or_400(db, frame, widget)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, pcfg.album_id)
|
|
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_pcfg):
|
|
photo_queue.remove_from_rotation(locked_pcfg, assets, body.asset_id, locked_frame)
|
|
return {"status": "removed"}
|
|
|
|
|
|
class QueueLockRequest(BaseModel):
|
|
locked: bool
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/lock")
|
|
def api_widget_queue_lock(
|
|
body: QueueLockRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Freezes/unfreezes current_asset_id (models.PhotoWidgetConfig.locked)
|
|
-- while locked, neither the timer-elapsed auto-advance
|
|
(photo_queue.get_current) nor the advance/back button actions
|
|
(app/widgets/photos.py) change which photo is showing."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "photos")
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
|
cfg.locked = body.locked
|
|
return {"status": "saved", "locked": body.locked}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}")
|
|
def api_widget_thumbnail(
|
|
asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Scoped to what this widget is actually showing/queuing -- a user
|
|
merely linked to view this frame shouldn't be able to pull thumbnails
|
|
for arbitrary asset ids in the owner's Immich library, only this
|
|
widget's own curated album. Same rule manage.manage_thumbnail
|
|
already enforces."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "photos")
|
|
pcfg = db.get(PhotoWidgetConfig, widget.id)
|
|
if asset_id != pcfg.current_asset_id and asset_id not in pcfg.queue:
|
|
raise HTTPException(404, "Not on this frame")
|
|
client = immich_client_for(frame)
|
|
try:
|
|
content, content_type = client.download_asset_thumbnail(asset_id)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not download thumbnail from Immich: {e}") from e
|
|
return Response(content=content, media_type=content_type)
|
|
|
|
|
|
def _current_asset_id(db: Session, frame: Frame, widget: Widget) -> tuple[str, PhotoWidgetConfig]:
|
|
"""Same idempotent get_current() dance the queue endpoint uses --
|
|
picks a current photo if none is set yet, otherwise just reads it,
|
|
never advances early."""
|
|
pcfg = _photo_config_or_400(db, frame, widget)
|
|
client = immich_client_for(frame)
|
|
assets = list_assets(client, pcfg.album_id)
|
|
with widget_locked(db, frame.id, widget.id) as (locked_frame, _, locked_pcfg):
|
|
photo_queue.get_current(locked_pcfg, assets, locked_frame,
|
|
in_quiet_hours=quiet_hours.in_quiet_hours(locked_frame))
|
|
asset_id = locked_pcfg.current_asset_id
|
|
if not asset_id:
|
|
raise HTTPException(404, "No current photo")
|
|
return asset_id, pcfg
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/original")
|
|
def api_widget_preview_original(
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
|
):
|
|
"""The Immich preview image behind the currently-displayed photo,
|
|
unprocessed -- the "now displaying" side of the dialog's before/after
|
|
comparison."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "photos")
|
|
asset_id, _ = _current_asset_id(db, frame, widget)
|
|
client = immich_client_for(frame)
|
|
try:
|
|
jpeg_bytes = client.download_asset_preview(asset_id)
|
|
except httpx.HTTPError as e:
|
|
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
|
return Response(content=jpeg_bytes, media_type="image/jpeg")
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/rendered")
|
|
def api_widget_preview_rendered(
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
|
):
|
|
"""The same photo run through this frame's actual saved rendering
|
|
pipeline (display mode, palette, color/contrast/dithering) and
|
|
exported as a PNG -- the "how it will look on the frame" side of the
|
|
comparison. Not a live preview of unsaved slider values; reflects
|
|
whatever's currently saved. display_mode comes from this widget's own
|
|
config (palette/color/contrast/dither stay frame-level -- one
|
|
physical panel, one set of those)."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "photos")
|
|
asset_id, pcfg = _current_asset_id(db, frame, widget)
|
|
client = immich_client_for(frame)
|
|
source, faces = fetch_source_and_faces(client, pcfg.display_mode, asset_id)
|
|
png = render_preview_png(
|
|
source, faces=faces, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
|
display_mode=pcfg.display_mode, color_boost=frame.color_boost,
|
|
contrast_boost=frame.contrast_boost, dither_strength=frame.dither_strength,
|
|
panel_type=frame.panel_type,
|
|
)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
# --- Calendar: inclusion/color/weather/preview ---------------------------
|
|
|
|
class CalendarSelectRequest(BaseModel):
|
|
user_id: int
|
|
calendar_key: str
|
|
calendar_label: str = ""
|
|
included: bool
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/calendar-select")
|
|
def api_widget_calendar_select(
|
|
body: CalendarSelectRequest, request: Request,
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), # view access only -- NOT control
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Include/exclude one calendar (calendar_key "ics" or
|
|
"caldav:<href>", see FrameCalendar) on this calendar widget.
|
|
Deliberately not require_widget_control: adding your own calendar, or
|
|
muting anyone's (including your own), is each viewer's own call, not
|
|
something a frame's controller manages on someone else's behalf. The
|
|
one-sided permission split lives here: turning a calendar ON requires
|
|
being its owner (nobody can add someone else's calendar to a shared
|
|
frame for them); turning one OFF only requires being linked to the
|
|
frame at all, so anyone sharing the display can mute a calendar
|
|
they'd rather not see there even if they don't own it."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "calendar")
|
|
user = require_user_api(request, db)
|
|
if body.included and body.user_id != user.id:
|
|
raise HTTPException(403, "Only a calendar's owner can add it to a frame")
|
|
row = db.execute(
|
|
select(FrameCalendar).where(
|
|
FrameCalendar.widget_id == widget.id,
|
|
FrameCalendar.user_id == body.user_id,
|
|
FrameCalendar.calendar_key == body.calendar_key,
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
if not body.included:
|
|
raise HTTPException(404, "Not currently included on this widget")
|
|
row = FrameCalendar(widget_id=widget.id, user_id=body.user_id, calendar_key=body.calendar_key)
|
|
db.add(row)
|
|
row.included = body.included
|
|
if body.calendar_label:
|
|
row.calendar_label = body.calendar_label
|
|
# Force this widget's merged cache to pick up the change promptly
|
|
# rather than waiting out the throttle.
|
|
db.get(CalendarWidgetConfig, widget.id).checked_at = 0.0
|
|
db.commit()
|
|
return {"status": "saved", "included": row.included}
|
|
|
|
|
|
class CalendarColorRequest(BaseModel):
|
|
calendar_key: str
|
|
color_index: int | None # None clears the pin, reverting to auto-cycle
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/calendar-color")
|
|
def api_widget_calendar_color(
|
|
body: CalendarColorRequest, request: Request,
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Pins a specific panel color to one of your own included calendars
|
|
(models.FrameCalendar.color_index) -- always owner-only, unlike
|
|
calendar-select's included=False, since recoloring someone else's
|
|
calendar isn't the same kind of "I'd rather not see this" veto as
|
|
muting it. None clears the pin, reverting calendar_render.py to its
|
|
old auto-cycle-by-owner-name behavior for this calendar."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "calendar")
|
|
user = require_user_api(request, db)
|
|
if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE:
|
|
raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)")
|
|
row = db.execute(
|
|
select(FrameCalendar).where(
|
|
FrameCalendar.widget_id == widget.id,
|
|
FrameCalendar.user_id == user.id,
|
|
FrameCalendar.calendar_key == body.calendar_key,
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
raise HTTPException(404, "Not included on this widget")
|
|
row.color_index = body.color_index
|
|
db.get(CalendarWidgetConfig, widget.id).checked_at = 0.0
|
|
db.commit()
|
|
return {"status": "saved", "color_index": row.color_index}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/calendar")
|
|
def api_widget_preview_calendar(
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
|
):
|
|
"""The same merged, cached event set a live device render would use --
|
|
not a live preview of an unsaved calendar_view choice, same "reflects
|
|
what's currently saved" convention as preview/rendered."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "calendar")
|
|
if not calendar_sources_for_widget(db, widget):
|
|
raise HTTPException(400, "No calendars included on this widget yet")
|
|
ccfg = db.get(CalendarWidgetConfig, widget.id)
|
|
events, summary = get_or_refresh_calendar_events_for_widget(db, frame, widget)
|
|
weather_cities = get_or_refresh_weather_for_widget(db, frame, widget) if ccfg.weather_enabled else None
|
|
|
|
target_w, target_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
|
|
if ccfg.render_style == "modern":
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from .. import calendar_html_render
|
|
|
|
tz = ZoneInfo(frame.timezone) if frame.timezone else ZoneInfo("UTC")
|
|
img = calendar_html_render.build(
|
|
events, ccfg.view, ccfg.browse_offset, target_w, target_h, tz, ccfg.week_start, frame.palette_rgb,
|
|
weather_cities, ccfg.weather_units, ccfg.week_days, ccfg.week_layout, ccfg.week_start_offset,
|
|
frame.theme, widget.font_scale,
|
|
)
|
|
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
|
png = _png_bytes(quantized)
|
|
else:
|
|
native_w, native_h = panel_size(frame.panel_type)
|
|
png = calendar_render.render_calendar_preview_png(
|
|
events, view=ccfg.view, browse_offset=ccfg.browse_offset, orientation=frame.orientation,
|
|
palette_rgb=frame.palette_rgb, timezone=frame.timezone, fetch_summary=summary,
|
|
week_start=ccfg.week_start,
|
|
weather_cities=weather_cities, weather_units=ccfg.weather_units,
|
|
week_days=ccfg.week_days, week_layout=ccfg.week_layout,
|
|
week_start_offset=ccfg.week_start_offset, font_scale=widget.font_scale,
|
|
panel_w=native_w, panel_h=native_h,
|
|
)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
# --- Tasks: inclusion/color/preview ---------------------------------------
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/tasks")
|
|
def api_widget_preview_tasks(
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
|
):
|
|
"""The same cached merged task list a live device render would use,
|
|
same "reflects what's currently saved" convention as the other
|
|
preview endpoints."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "tasks")
|
|
if not task_sources_for_widget(db, widget):
|
|
raise HTTPException(400, "No task lists included on this widget yet")
|
|
tcfg = db.get(TaskWidgetConfig, widget.id)
|
|
tasks = get_or_refresh_tasks_for_widget(db, frame, widget)
|
|
title = tcfg.name or "Tasks"
|
|
if tcfg.render_style == "modern":
|
|
from .. import html_render
|
|
|
|
target_w, target_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
|
|
img = html_render.build_tasks(tasks, target_w, target_h, frame.palette_rgb, title, frame.theme,
|
|
widget.font_scale)
|
|
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
|
png = _png_bytes(quantized)
|
|
else:
|
|
native_w, native_h = panel_size(frame.panel_type)
|
|
png = calendar_render.render_tasks_preview_png(tasks, orientation=frame.orientation,
|
|
palette_rgb=frame.palette_rgb, title=title,
|
|
font_scale=widget.font_scale,
|
|
panel_w=native_w, panel_h=native_h)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
class TaskListSelectRequest(BaseModel):
|
|
user_id: int
|
|
calendar_key: str
|
|
calendar_label: str = ""
|
|
included: bool
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/task-list-select")
|
|
def api_widget_task_list_select(
|
|
body: TaskListSelectRequest, request: Request,
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), # view access only -- NOT control
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Include/exclude one CalDAV task list (calendar_key "caldav:<href>",
|
|
see FrameTaskList) on this tasks widget -- same one-sided permission
|
|
split as api_widget_calendar_select: turning a list ON requires being
|
|
its owner (nobody can add someone else's task list to a shared frame
|
|
for them), turning one OFF only requires being linked to the frame at
|
|
all, so anyone sharing the display can mute a list they'd rather not
|
|
see there even if they don't own it. Deliberately not
|
|
require_widget_control for the same reason."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "tasks")
|
|
user = require_user_api(request, db)
|
|
if body.included and body.user_id != user.id:
|
|
raise HTTPException(403, "Only a task list's owner can add it to a frame")
|
|
row = db.execute(
|
|
select(FrameTaskList).where(
|
|
FrameTaskList.widget_id == widget.id,
|
|
FrameTaskList.user_id == body.user_id,
|
|
FrameTaskList.calendar_key == body.calendar_key,
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
if not body.included:
|
|
raise HTTPException(404, "Not currently included on this widget")
|
|
row = FrameTaskList(widget_id=widget.id, user_id=body.user_id, calendar_key=body.calendar_key)
|
|
db.add(row)
|
|
row.included = body.included
|
|
if body.calendar_label:
|
|
row.calendar_label = body.calendar_label
|
|
# Force this widget's merged cache to pick up the change promptly
|
|
# rather than waiting out the throttle.
|
|
db.get(TaskWidgetConfig, widget.id).checked_at = 0.0
|
|
db.commit()
|
|
return {"status": "saved", "included": row.included}
|
|
|
|
|
|
class TaskListColorRequest(BaseModel):
|
|
calendar_key: str
|
|
color_index: int | None # None clears the pin, reverting to auto-cycle
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/task-list-color")
|
|
def api_widget_task_list_color(
|
|
body: TaskListColorRequest, request: Request,
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Pins a specific panel color to one of your own included task
|
|
lists (models.FrameTaskList.color_index) -- always owner-only, same
|
|
as api_widget_calendar_color. None clears the pin, reverting
|
|
calendar_render.py to its auto-cycle-by-owner-name behavior for this
|
|
list."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "tasks")
|
|
user = require_user_api(request, db)
|
|
if body.color_index is not None and body.color_index not in CALENDAR_COLOR_INDEX_RANGE:
|
|
raise HTTPException(400, "color_index must be 2-5 (the panel's non-black/white colors)")
|
|
row = db.execute(
|
|
select(FrameTaskList).where(
|
|
FrameTaskList.widget_id == widget.id,
|
|
FrameTaskList.user_id == user.id,
|
|
FrameTaskList.calendar_key == body.calendar_key,
|
|
)
|
|
).scalar_one_or_none()
|
|
if row is None:
|
|
raise HTTPException(404, "Not included on this widget")
|
|
row.color_index = body.color_index
|
|
db.get(TaskWidgetConfig, widget.id).checked_at = 0.0
|
|
db.commit()
|
|
return {"status": "saved", "color_index": row.color_index}
|
|
|
|
|
|
class WeatherCityAddRequest(BaseModel):
|
|
name: str
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-cities/add")
|
|
def api_widget_weather_city_add(
|
|
body: WeatherCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Geocodes a free-text city name (e.g. "Portland, OR") and adds it to
|
|
this widget's weather strip -- a widget-wide display setting (like
|
|
calendar_view), not personal data, so this is gated the same way as
|
|
the config-save endpoint rather than the calendar-select owner/mute
|
|
split."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "calendar")
|
|
try:
|
|
city = weather.geocode_city(body.name)
|
|
except weather.WeatherFetchError as e:
|
|
raise HTTPException(400, str(e)) from e
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
|
cities = list(cfg.weather_cities or [])
|
|
if any(c["label"] == city["label"] for c in cities):
|
|
raise HTTPException(400, f"{city['label']} is already on this widget's list")
|
|
cities.append(city)
|
|
cfg.weather_cities = cities
|
|
cfg.weather_checked_at = 0.0 # pick up the new city promptly
|
|
return {"status": "saved", "city": city}
|
|
|
|
|
|
class WeatherCityRemoveRequest(BaseModel):
|
|
label: str
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-cities/remove")
|
|
def api_widget_weather_city_remove(
|
|
body: WeatherCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "calendar")
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
|
cities = [c for c in (cfg.weather_cities or []) if c["label"] != body.label]
|
|
cfg.weather_cities = cities
|
|
cached = [c for c in (cfg.weather_cached or []) if c["label"] != body.label]
|
|
cfg.weather_cached = cached
|
|
return {"status": "saved"}
|
|
|
|
|
|
# --- Weather widget: location/cities/preview -------------------------------
|
|
#
|
|
# Endpoint names here are "weather-location"/"weather-widget-cities" (not
|
|
# "weather-cities") specifically to avoid colliding with the calendar
|
|
# widget's own /weather-cities/add|remove route *patterns* above -- both
|
|
# are registered against the same {widget_id}-parameterized path shape,
|
|
# so a literal name clash there would silently shadow one of them
|
|
# regardless of each handler's own _require_widget_type check.
|
|
|
|
class WeatherLocationRequest(BaseModel):
|
|
name: str | None # None clears the location; else a free-text city name to geocode
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-location")
|
|
def api_widget_weather_location(
|
|
body: WeatherLocationRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Sets (or clears) this weather widget's single configured location
|
|
-- the current/hourly/daily modes' one city. A widget-wide display
|
|
setting like calendar_view/weather_units, not personal data, hence
|
|
require_widget_control rather than the calendar/tasks owner-adds/
|
|
anyone-mutes split (there's only ever one location and no per-person
|
|
ownership of it)."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "weather")
|
|
if body.name is None:
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
|
cfg.city_label = None
|
|
cfg.city_latitude = None
|
|
cfg.city_longitude = None
|
|
cfg.cached = None
|
|
cfg.checked_at = 0.0
|
|
return {"status": "saved", "city": None}
|
|
try:
|
|
city = weather.geocode_city(body.name)
|
|
except weather.WeatherFetchError as e:
|
|
raise HTTPException(400, str(e)) from e
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
|
cfg.city_label = city["label"]
|
|
cfg.city_latitude = city["latitude"]
|
|
cfg.city_longitude = city["longitude"]
|
|
cfg.cached = None
|
|
cfg.checked_at = 0.0 # pick up the new location promptly
|
|
return {"status": "saved", "city": city}
|
|
|
|
|
|
class WeatherWidgetCityAddRequest(BaseModel):
|
|
name: str
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/add")
|
|
def api_widget_weather_widget_city_add(
|
|
body: WeatherWidgetCityAddRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""multi_city mode's city list -- same shape/gating as the calendar
|
|
widget's own weather-cities/add above, just scoped to this widget's
|
|
own WeatherWidgetConfig.cities."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "weather")
|
|
try:
|
|
city = weather.geocode_city(body.name)
|
|
except weather.WeatherFetchError as e:
|
|
raise HTTPException(400, str(e)) from e
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
|
cities = list(cfg.cities or [])
|
|
if any(c["label"] == city["label"] for c in cities):
|
|
raise HTTPException(400, f"{city['label']} is already on this widget's list")
|
|
cities.append(city)
|
|
cfg.cities = cities
|
|
cfg.checked_at = 0.0 # pick up the new city promptly
|
|
return {"status": "saved", "city": city}
|
|
|
|
|
|
class WeatherWidgetCityRemoveRequest(BaseModel):
|
|
label: str
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/weather-widget-cities/remove")
|
|
def api_widget_weather_widget_city_remove(
|
|
body: WeatherWidgetCityRemoveRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "weather")
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
|
cities = [c for c in (cfg.cities or []) if c["label"] != body.label]
|
|
cfg.cities = cities
|
|
if cfg.cached:
|
|
cfg.cached = [c for c in cfg.cached if c.get("label") != body.label]
|
|
return {"status": "saved"}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/weather")
|
|
def api_widget_preview_weather(
|
|
force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""The same throttled fetch cache a live device render would use, run
|
|
through the panel composition/quantization pipeline -- "how it will
|
|
look on the frame", same convention as the other preview endpoints.
|
|
force=True (the "Refresh now" button) bypasses the fetch throttle."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "weather")
|
|
wcfg = db.get(WeatherWidgetConfig, widget.id)
|
|
data = get_or_refresh_weather_widget_data(db, frame, widget, force=force)
|
|
if data is None:
|
|
if wcfg.mode == "multi_city":
|
|
raise HTTPException(400, "No cities added to this widget yet")
|
|
raise HTTPException(400, "No location set on this widget yet")
|
|
if wcfg.render_style == "modern" and wcfg.mode in ("current", "daily"):
|
|
# Same local-import reasoning as widgets/weather.py's render().
|
|
from .. import html_render
|
|
|
|
native_w, native_h = panel_size(frame.panel_type)
|
|
png = html_render.render_weather_preview_png(
|
|
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
|
city_label=wcfg.city_label or "", theme_name=frame.theme, panel_w=native_w, panel_h=native_h,
|
|
)
|
|
else:
|
|
native_w, native_h = panel_size(frame.panel_type)
|
|
png = weather_render.render_weather_preview_png(
|
|
wcfg.mode, data, orientation=frame.orientation, palette_rgb=frame.palette_rgb, units=wcfg.units,
|
|
city_label=wcfg.city_label or "", interval_hours=wcfg.hourly_interval_hours,
|
|
panel_w=native_w, panel_h=native_h,
|
|
)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
# --- 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")
|
|
if scfg.render_style == "modern":
|
|
from .. import html_render
|
|
|
|
target_w, target_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
|
|
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
|
|
display_mode=scfg.display_mode)
|
|
fitted = _enhance(composed, frame.color_boost, frame.contrast_boost)
|
|
img = html_render.build_framed_image(fitted, target_w, target_h, frame.palette_rgb, frame.theme, "static")
|
|
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
|
png = _png_bytes(quantized)
|
|
else:
|
|
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,
|
|
panel_type=frame.panel_type,
|
|
)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
# --- Text: preview ----------------------------------------------------------
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/text")
|
|
def api_widget_preview_text(
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
|
):
|
|
"""The saved rich text run through the same word-wrap/shrink-to-fit
|
|
layout and quantize pass a live device render would use -- same
|
|
"reflects what's currently saved" convention as the other preview
|
|
endpoints."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "text")
|
|
xcfg = db.get(TextWidgetConfig, widget.id)
|
|
if not has_text(xcfg.content):
|
|
raise HTTPException(400, "No text authored on this widget yet")
|
|
native_w, native_h = panel_size(frame.panel_type)
|
|
png = text_widget.render_preview_png(xcfg, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
|
theme_name=frame.theme, panel_w=native_w, panel_h=native_h)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
# --- Battery: preview --------------------------------------------------------
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/battery")
|
|
def api_widget_preview_battery(
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), db: Session = Depends(get_db)
|
|
):
|
|
"""Unlike every other preview endpoint, there's no "not configured
|
|
yet" 400 case -- the content is frame-level state (battery_percent)
|
|
that either exists or doesn't, and render() already degrades to a
|
|
"No reports yet" placeholder either way, same as a live device
|
|
render would."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "battery")
|
|
png = battery_widget.render_preview_png(
|
|
db, frame, widget, orientation=frame.orientation, palette_rgb=frame.palette_rgb
|
|
)
|
|
return Response(content=png, media_type="image/png")
|
|
|
|
|
|
# --- Whiteboard: source/preview ------------------------------------------
|
|
|
|
class WhiteboardSourceRequest(BaseModel):
|
|
url: str | None # None clears the source
|
|
|
|
|
|
@router.post("/api/frames/{frame_id}/widgets/{widget_id}/whiteboard-source")
|
|
def api_widget_whiteboard_source(
|
|
body: WhiteboardSourceRequest, request: Request,
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Points this widget at one of the calling user's own WebDAV (or
|
|
reused-CalDAV, see User.webdav_reuse_caldav_creds) credentials --
|
|
same owner-controls-their-own-data permission split as
|
|
api_widget_task_list_select: only the account owner can set the
|
|
widget to use it, but anyone linked to the frame can clear it, same
|
|
as muting a shared calendar."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "whiteboard")
|
|
user = require_user_api(request, db)
|
|
with widget_locked(db, frame.id, widget.id) as (_, _, cfg):
|
|
if body.url is None:
|
|
cfg.user_id = None
|
|
cfg.url = ""
|
|
cfg.cached_image = None
|
|
else:
|
|
stripped = body.url.strip()
|
|
if not valid_http_url(stripped):
|
|
raise HTTPException(400, "Whiteboard URL must be a plain http:// or https:// URL")
|
|
cfg.user_id = user.id
|
|
cfg.url = stripped
|
|
cfg.checked_at = 0.0 # pick up the change promptly
|
|
return {"status": "saved", "url": body.url}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/whiteboard-browse")
|
|
def api_widget_whiteboard_browse(
|
|
request: Request, url: str | None = None,
|
|
frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""One level of a WebDAV directory listing, using the calling user's
|
|
own credentials (never this widget's saved user_id -- this is "help
|
|
me find a file in MY account", same person as whoever would go on to
|
|
Save it, before that's even happened) -- powers the file picker in
|
|
the whiteboard dialog as an alternative to pasting a URL. Nested
|
|
under this widget's own path purely so the dialog's JS can keep using
|
|
one shared window.FRAME_API base for every call it makes -- the
|
|
lookup itself doesn't touch this (or any) widget's own state. `url`
|
|
omitted/None starts from the user's webdav_base_url (see models.py's
|
|
User docstring); passing back a previous response's `entries[].url`
|
|
(for a folder) descends into it."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "whiteboard")
|
|
user = require_user_api(request, db)
|
|
creds = webdav_creds_for(user)
|
|
if creds is None:
|
|
raise HTTPException(400, "Set up WebDAV credentials in Settings first")
|
|
target = url or user.webdav_base_url
|
|
if not target:
|
|
raise HTTPException(400, "Set a WebDAV browse root in Settings first, or paste the file URL directly")
|
|
if not valid_http_url(target):
|
|
raise HTTPException(400, "Browse URL must be a plain http:// or https:// URL")
|
|
try:
|
|
entries = webdav_client.list_directory(target, creds[0], creds[1])
|
|
except webdav_client.WebDavError as e:
|
|
raise HTTPException(502, f"Could not browse: {e}")
|
|
base = user.webdav_base_url or target
|
|
parent_url = webdav_client.parent_directory_url(base, target)
|
|
return {"current_url": target, "parent_url": parent_url, "entries": entries}
|
|
|
|
|
|
@router.get("/api/frames/{frame_id}/widgets/{widget_id}/preview/whiteboard")
|
|
def api_widget_preview_whiteboard(
|
|
force: bool = False, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""The same throttled fetch/render cache a live device request would
|
|
use, run through the same panel composition/quantization pipeline --
|
|
"how it will look on the frame" (dithered, letterboxed), not just the
|
|
raw Excalidraw export, same convention as the other preview
|
|
endpoints. force=True (the "Refresh now" button, as opposed to just
|
|
reopening the dialog) bypasses the fetch throttle."""
|
|
frame, widget = frame_widget
|
|
_require_widget_type(widget, "whiteboard")
|
|
wcfg = db.get(WhiteboardWidgetConfig, widget.id)
|
|
png_bytes = get_or_refresh_whiteboard_for_widget(db, frame, widget, force=force)
|
|
if png_bytes is None:
|
|
if not wcfg.url:
|
|
raise HTTPException(400, "No whiteboard configured on this widget yet")
|
|
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
|
|
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
|
if wcfg.render_style == "modern":
|
|
from .. import html_render
|
|
|
|
target_w, target_h = logical_render_size(frame.orientation, *panel_size(frame.panel_type))
|
|
composed = compose_into(source, faces=None, target_w=target_w, target_h=target_h,
|
|
display_mode="letterbox")
|
|
img = html_render.build_framed_image(composed, target_w, target_h, frame.palette_rgb, frame.theme,
|
|
"whiteboard")
|
|
quantized = _quantize(img, frame.palette_rgb, dither_strength=1.0)
|
|
png = _png_bytes(quantized)
|
|
else:
|
|
png = render_preview_png(
|
|
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
|
display_mode="letterbox", panel_type=frame.panel_type,
|
|
)
|
|
return Response(content=png, media_type="image/png")
|