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:
@@ -12,12 +12,14 @@ see manage_overlay.py and common.build_manage_content)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, RedirectResponse, Response
|
||||
from PIL import Image
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -26,7 +28,7 @@ from .. import calendar_render, mail, photo_queue, quiet_hours
|
||||
from ..auth import get_server_settings, require_device
|
||||
from ..db import frame_locked, get_db
|
||||
from ..firmware import firmware_path
|
||||
from ..image_pipeline import render_placeholder
|
||||
from ..image_pipeline import render_frame, render_placeholder
|
||||
from ..models import BatteryLog, Frame
|
||||
from .common import (
|
||||
BATTERY_HISTORY_MAX,
|
||||
@@ -37,6 +39,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,
|
||||
@@ -204,17 +207,64 @@ def _back_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes
|
||||
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
|
||||
|
||||
# --- whiteboard mode ---
|
||||
|
||||
def _render_whiteboard_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
|
||||
is_normal_wake: bool) -> bytes:
|
||||
"""Fetches (throttled, see get_or_refresh_whiteboard) and renders the
|
||||
frame's configured .whiteboard file. The rendered PNG is treated
|
||||
exactly like a photo from here on -- run through the same
|
||||
render_frame composition/quantization pipeline as photos mode,
|
||||
letterboxed (never cropped: unlike a photo, losing part of a
|
||||
whiteboard to a crop loses actual content, not just some background)
|
||||
-- rather than a second parallel image pipeline just for this mode."""
|
||||
png_bytes = get_or_refresh_whiteboard(db, frame)
|
||||
if png_bytes is None:
|
||||
return render_placeholder(
|
||||
["This frame's whiteboard isn't set up yet",
|
||||
"Add a WebDAV/Nextcloud whiteboard file URL on",
|
||||
"this frame's Whiteboard tab."],
|
||||
orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
|
||||
)
|
||||
|
||||
source = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||
return render_frame(
|
||||
source, faces=None, orientation=frame.orientation, palette_rgb=frame.palette_rgb,
|
||||
display_mode="letterbox", manage=manage,
|
||||
)
|
||||
|
||||
|
||||
def _advance_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
"""NEXT in whiteboard mode: there's no "next" concept for a single
|
||||
static board, so this instead forces an immediate re-fetch/re-render
|
||||
bypassing the throttle -- a "check now" button for "someone just
|
||||
updated the board, show it right away" rather than waiting out
|
||||
calendar_feed.CHECK_INTERVAL_S."""
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.whiteboard_checked_at = 0.0
|
||||
return _render_whiteboard_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
|
||||
|
||||
def _back_whiteboard_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
"""Same "check now" behavior as _advance_whiteboard_mode -- there's
|
||||
no separate "back" concept for a single static board either."""
|
||||
return _advance_whiteboard_mode(db, frame, manage)
|
||||
|
||||
|
||||
RENDERERS = {
|
||||
"photos": _render_photos_mode,
|
||||
"calendar": _render_calendar_mode,
|
||||
"whiteboard": _render_whiteboard_mode,
|
||||
}
|
||||
ADVANCE_RENDERERS = {
|
||||
"photos": _advance_photos_mode,
|
||||
"calendar": _advance_calendar_mode,
|
||||
"whiteboard": _advance_whiteboard_mode,
|
||||
}
|
||||
BACK_RENDERERS = {
|
||||
"photos": _back_photos_mode,
|
||||
"calendar": _back_calendar_mode,
|
||||
"whiteboard": _back_whiteboard_mode,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user