Add whiteboard frame mode (Nextcloud Whiteboard / Excalidraw over WebDAV)
Build and push server image / build-and-push (push) Failing after 1m10s
Build and push server image / build-and-push (push) Failing after 1m10s
New third mode alongside photos/calendar: fetches a .whiteboard file over plain WebDAV (Basic auth -- generic, not Nextcloud-specific) and renders it via a small Node.js sidecar using Excalidraw's own real export code (@excalidraw/utils + @resvg/resvg-js, no headless browser), since a .whiteboard file turns out to be Excalidraw scene JSON, not an image. The sidecar runs as a second process inside this same container (Dockerfile installs Node, start.sh backgrounds it before exec'ing uvicorn) rather than a separate docker-compose service -- lightweight, stateless, reachable only at 127.0.0.1 from the Python process, nothing worth independently scaling. The rendered PNG is treated exactly like a photo from there on -- composed/quantized through the existing image_pipeline (letterboxed, never cropped) rather than a second parallel rendering pipeline. WebDAV credentials support the common "it's actually the same Nextcloud account as my CalDAV" case (an explicit opt-in checkbox, not silently inferred) while still working with any WebDAV server generically. Frame-level source (URL + owning account) follows the same owner- controls-their-own-data permission split as calendar sources and the week view's task list: only the account owner can point a frame at it, anyone linked can clear it. Honest limitation: this environment has no Node.js/npm, so render-service/ is written carefully against each library's documented API (verified via the npm registry, including transitive dependency licenses after the CalDAV/AGPL surprise earlier this session) but has never actually been executed. First real docker build is the first true test -- see render-service/README.md.
This commit is contained in:
@@ -46,6 +46,7 @@ from .common import (
|
||||
get_or_refresh_calendar_events,
|
||||
get_or_refresh_tasks,
|
||||
get_or_refresh_weather,
|
||||
get_or_refresh_whiteboard,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
@@ -616,6 +617,64 @@ def api_tasks_source(
|
||||
return {"status": "saved", "calendar_key": body.calendar_key}
|
||||
|
||||
|
||||
class WhiteboardSourceRequest(BaseModel):
|
||||
url: str | None # None clears the source
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/whiteboard-source")
|
||||
def api_whiteboard_source(
|
||||
body: WhiteboardSourceRequest,
|
||||
request: Request,
|
||||
frame: Frame = Depends(require_frame_view),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Points this frame's whiteboard 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_tasks_source: only the account owner can set the frame to use
|
||||
it, but anyone linked to the frame can clear it, same as muting a
|
||||
shared calendar."""
|
||||
user = require_user_api(request, db)
|
||||
with frame_locked(db, frame.id) as cfg:
|
||||
if body.url is None:
|
||||
cfg.whiteboard_user_id = None
|
||||
cfg.whiteboard_url = ""
|
||||
cfg.whiteboard_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.whiteboard_user_id = user.id
|
||||
cfg.whiteboard_url = stripped
|
||||
cfg.whiteboard_checked_at = 0.0 # pick up the change promptly
|
||||
return {"status": "saved", "url": body.url}
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/preview/whiteboard")
|
||||
def api_preview_whiteboard(frame: Frame = Depends(require_frame_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
|
||||
(see routers/device.py's _render_whiteboard_mode) -- "how it will
|
||||
look on the frame" (dithered, letterboxed), not just the raw
|
||||
Excalidraw export, same convention as preview/rendered and
|
||||
preview/calendar."""
|
||||
png_bytes = get_or_refresh_whiteboard(db, frame)
|
||||
if png_bytes is None:
|
||||
if not frame.whiteboard_url:
|
||||
raise HTTPException(400, "No whiteboard configured on this frame yet")
|
||||
raise HTTPException(502, "Could not fetch/render the whiteboard yet -- check the URL and credentials")
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||
png = render_preview_png(
|
||||
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
display_mode="letterbox",
|
||||
)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
class WeatherCityAddRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
Reference in New Issue
Block a user