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.
76 lines
2.9 KiB
Python
76 lines
2.9 KiB
Python
"""Whiteboard frame mode: fetches a Nextcloud Whiteboard (or any other
|
|
WebDAV server's) .whiteboard file and renders it via the local
|
|
render-service sidecar (server/render-service/, own README there) --
|
|
Excalidraw's real export code, not a hand-rolled reimplementation of its
|
|
element types/styling/fonts.
|
|
|
|
Deliberately renders at a fixed generous width, not the panel's exact
|
|
target size -- the resulting PNG then runs through
|
|
image_pipeline.compose_into exactly like a photo would (crop/letterbox
|
|
per the frame's own display_mode setting), so this module doesn't need
|
|
to know anything about panel dimensions/orientation, and whiteboard mode
|
|
reuses the same fit logic photos mode already has instead of a second
|
|
parallel implementation of it.
|
|
|
|
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
|
philosophy as calendar_feed.py/weather.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import httpx
|
|
|
|
from . import webdav_client
|
|
|
|
RENDER_SERVICE_URL = "http://127.0.0.1:3001/render"
|
|
# Rendering (not just fetching) can take a moment for a busy board --
|
|
# more generous than a typical fetch timeout.
|
|
HTTP_TIMEOUT_S = 30.0
|
|
# Fixed render width regardless of the target frame's orientation/size --
|
|
# see module docstring. Comfortably above this panel's 800px long edge
|
|
# so downstream cropping isn't working from an upscaled source.
|
|
RENDER_WIDTH = 1600
|
|
|
|
CHECK_INTERVAL_S = 20 * 60 # same cadence as calendar_feed's merge-fetch throttle
|
|
|
|
|
|
class WhiteboardRenderError(Exception):
|
|
"""Fetch or render failed -- network, auth, an invalid/non-JSON
|
|
file, or the render sidecar itself erroring. Raised loudly; callers
|
|
decide what to do."""
|
|
|
|
|
|
def fetch_and_render(url: str, username: str, password: str) -> bytes:
|
|
"""Fetches the .whiteboard file at `url` and renders it to a PNG via
|
|
the local render-service sidecar. Returns raw PNG bytes at
|
|
RENDER_WIDTH wide, natural aspect ratio."""
|
|
try:
|
|
raw = webdav_client.fetch_file(url, username, password)
|
|
except webdav_client.WebDavError as e:
|
|
raise WhiteboardRenderError(f"Could not fetch whiteboard file: {e}") from e
|
|
|
|
try:
|
|
scene = json.loads(raw)
|
|
except ValueError as e:
|
|
raise WhiteboardRenderError(f"Not a valid whiteboard file (not JSON): {e}") from e
|
|
if not isinstance(scene.get("elements"), list):
|
|
raise WhiteboardRenderError('Not a valid whiteboard file (no "elements" array)')
|
|
|
|
try:
|
|
resp = httpx.post(
|
|
RENDER_SERVICE_URL,
|
|
json={
|
|
"elements": scene.get("elements", []),
|
|
"appState": scene.get("appState", {}),
|
|
"files": scene.get("files", {}),
|
|
"width": RENDER_WIDTH,
|
|
},
|
|
timeout=HTTP_TIMEOUT_S,
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.content
|
|
except httpx.HTTPError as e:
|
|
raise WhiteboardRenderError(f"Render sidecar failed: {e}") from e
|