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:
@@ -17,6 +17,12 @@ server/**/__pycache__/
|
||||
server/.venv/
|
||||
server/*.egg-info/
|
||||
server/data/
|
||||
# render-service/ (whiteboard mode's Node sidecar) -- installed fresh
|
||||
# inside the Docker image, never committed. No package-lock.json exists
|
||||
# yet either (no Node/npm available in this project's dev environment to
|
||||
# generate one -- see render-service/README.md); if one's added later, do
|
||||
# NOT ignore it, lockfiles belong in git.
|
||||
server/render-service/node_modules/
|
||||
# Real deploy config, copied from docker-compose.yml.example -- holds the
|
||||
# Immich API key, must never be committed.
|
||||
server/docker-compose.yml
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
.venv/
|
||||
*.egg-info/
|
||||
data/
|
||||
render-service/node_modules/
|
||||
.git/
|
||||
+19
-2
@@ -6,14 +6,31 @@ WORKDIR /app
|
||||
# database backing the web UI's "Timezone" setting (used by "Quiet hours")
|
||||
# would have no named zones to resolve without this -- ZoneInfo() would
|
||||
# raise for anything other than "UTC".
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
|
||||
#
|
||||
# Node.js + fonts: whiteboard frame mode's render-service/ (own README
|
||||
# there) runs as a second process in this same container rather than a
|
||||
# separate compose service -- it's a lightweight, stateless, localhost-
|
||||
# only sidecar with nothing worth independently scaling or restarting.
|
||||
# NodeSource's setup script is used instead of Debian bookworm's own
|
||||
# apt Node package, which is both older than jsdom's minimum (20.19+)
|
||||
# and inconsistently available. fonts-dejavu-core gives the sidecar's
|
||||
# SVG rasterizer something to render whiteboard text with.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tzdata curl ca-certificates gnupg fontconfig fonts-dejavu-core \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY render-service ./render-service
|
||||
RUN cd render-service && npm install --omit=dev
|
||||
|
||||
COPY app ./app
|
||||
COPY start.sh .
|
||||
RUN chmod +x start.sh
|
||||
|
||||
EXPOSE 8420
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8420"]
|
||||
CMD ["./start.sh"]
|
||||
|
||||
@@ -224,6 +224,29 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
explicit, informed call by the project owner, not a default -- anyone
|
||||
redistributing this project (vs. just self-hosting it) should
|
||||
re-evaluate that tradeoff for their own situation before doing so.
|
||||
- Whiteboard frame mode (`app/webdav_client.py`, `app/whiteboard.py`)
|
||||
fetches a Nextcloud Whiteboard (or any WebDAV server's) `.whiteboard`
|
||||
file -- which turns out to be Excalidraw scene JSON (elements/appState/
|
||||
files), not an image -- and renders it via `render-service/`, a small
|
||||
Node.js sidecar using Excalidraw's own real export code
|
||||
(`@excalidraw/utils`'s `exportToSvg`) plus `@resvg/resvg-js` (a native
|
||||
Rust SVG rasterizer, no headless browser) to turn that into a PNG. That
|
||||
sidecar runs as a **second process inside this same container**
|
||||
(`Dockerfile` installs Node, `start.sh` launches it in the background
|
||||
before `exec`-ing uvicorn), reachable only at `127.0.0.1:3001` from the
|
||||
Python process -- not a second docker-compose service, since it's
|
||||
lightweight, stateless, and has nothing worth independently scaling or
|
||||
restarting. License check (after getting burned once already in this
|
||||
same file, on the CalDAV dependency below, into checking transitive
|
||||
deps and not just top-level ones): Excalidraw, `@excalidraw/utils`,
|
||||
every one of its own runtime dependencies, `@resvg/resvg-js`
|
||||
(MPL-2.0 -- weak/file-level copyleft, doesn't extend to code that just
|
||||
calls into it), `jsdom`, and `express` are all MIT/Apache-2.0/Zlib/
|
||||
MPL-2.0 -- no repeat of the AGPL surprise. **Not runtime-tested against
|
||||
a real `npm install`/`docker build`** -- this project's dev environment
|
||||
has no Node.js/npm, only network access to the npm registry API (used
|
||||
to verify the above and pick real, current dependency versions). See
|
||||
`render-service/README.md` for exactly what is and isn't verified.
|
||||
- Calendar event titles can contain emoji, which `ImageFont.load_default()`
|
||||
(used for every other bit of text this project renders) has no glyphs
|
||||
for -- PIL/FreeType substitute a visible ".notdef" tofu box rather than
|
||||
|
||||
@@ -199,6 +199,25 @@ def _migration_13(conn) -> None:
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN calendar_week_start_offset INTEGER NOT NULL DEFAULT 0"))
|
||||
|
||||
|
||||
def _migration_14(conn) -> None:
|
||||
"""Whiteboard frame mode: generic WebDAV credentials per user
|
||||
(webdav_username/password, plus webdav_reuse_caldav_creds as a
|
||||
convenience when it's the same Nextcloud account as an already-
|
||||
configured CalDAV one -- see models.py's User docstring), and the
|
||||
frame-level whiteboard source (whiteboard_user_id/url) + rendered-
|
||||
PNG cache (see webdav_client.py, whiteboard.py,
|
||||
routers/device.py's RENDERERS["whiteboard"]). Every new column has a
|
||||
behavior-preserving default -- no existing frame's render changes
|
||||
until its mode is actually switched to "whiteboard"."""
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_username TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_password TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN webdav_reuse_caldav_creds INTEGER NOT NULL DEFAULT 0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_url TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_checked_at REAL NOT NULL DEFAULT 0.0"))
|
||||
conn.execute(text("ALTER TABLE frames ADD COLUMN whiteboard_cached_image BLOB"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -213,6 +232,7 @@ MIGRATIONS = [
|
||||
(11, _migration_11),
|
||||
(12, _migration_12),
|
||||
(13, _migration_13),
|
||||
(14, _migration_14),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+37
-3
@@ -19,7 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy import JSON, Boolean, Float, ForeignKey, Index, Integer, LargeBinary, String
|
||||
from sqlalchemy.ext.mutable import MutableList
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
@@ -68,6 +68,18 @@ class User(Base):
|
||||
# add, without hitting the CalDAV server on every page load.
|
||||
calendar_caldav_calendars: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
calendar_caldav_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
# WebDAV credentials for whiteboard frame mode (see webdav_client.py,
|
||||
# whiteboard.py) -- generic WebDAV, not Nextcloud-specific, but
|
||||
# webdav_reuse_caldav_creds is a convenience for the common case
|
||||
# where it IS the same Nextcloud account as calendar_caldav_*: skip
|
||||
# re-entering the same username/password, since Nextcloud's CalDAV
|
||||
# and general-file-WebDAV both sit under the one account. Doesn't
|
||||
# try to be clever and derive the reuse automatically -- an explicit
|
||||
# opt-in, same as everywhere else in this project defaults features
|
||||
# off rather than silently inferring them.
|
||||
webdav_username: Mapped[str] = mapped_column(String, default="")
|
||||
webdav_password: Mapped[str] = mapped_column(String, default="")
|
||||
webdav_reuse_caldav_creds: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
|
||||
__table_args__ = (
|
||||
@@ -102,8 +114,9 @@ class Frame(Base):
|
||||
# the migrated legacy frame until its device first reports an id.
|
||||
device_id: Mapped[str | None] = mapped_column(String, unique=True, nullable=True)
|
||||
name: Mapped[str] = mapped_column(String, default="")
|
||||
# Renderer dispatch seam for future calendar/canva modes -- only
|
||||
# "photos" is registered today (see routers/device.py RENDERERS).
|
||||
# Renderer dispatch seam -- "photos", "calendar", or "whiteboard"
|
||||
# (see routers/device.py RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS
|
||||
# and routers/common.py FRAME_MODES).
|
||||
mode: Mapped[str] = mapped_column(String, default="photos")
|
||||
# Whose Immich library this frame pulls from; NULL = unclaimed.
|
||||
owner_user_id: Mapped[int | None] = mapped_column(
|
||||
@@ -238,6 +251,27 @@ class Frame(Base):
|
||||
# by due date -- see caldav_client.fetch_tasks.
|
||||
calendar_tasks_cached: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
|
||||
# -- whiteboard mode (see webdav_client.py, whiteboard.py,
|
||||
# routers/device.py's RENDERERS["whiteboard"]) -- a frame-wide
|
||||
# setting like calendar mode's own frame_calendars source, not
|
||||
# personal data, but still owner-gated the same way: only
|
||||
# whiteboard_user_id may point the frame at their own account (see
|
||||
# routers/api_frames.py's api_whiteboard_source), since it's their
|
||||
# credentials being used to fetch it. --
|
||||
whiteboard_user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
# The specific .whiteboard file's WebDAV URL -- pasted directly, same
|
||||
# idiom as calendar_ics_url, not discovered/browsed (unlike CalDAV's
|
||||
# account-has-several-calendars case, a WebDAV account doesn't need
|
||||
# a picker step here since the user already knows which one file).
|
||||
whiteboard_url: Mapped[str] = mapped_column(String, default="")
|
||||
whiteboard_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
# Cached rendered PNG bytes (see whiteboard.fetch_and_render) --
|
||||
# BLOB rather than the JSON columns the rest of this cache-pattern
|
||||
# family uses, since this is binary image data, not JSON-shaped.
|
||||
whiteboard_cached_image: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
|
||||
# -- state --
|
||||
current_asset_id: Mapped[str] = mapped_column(String, default="")
|
||||
current_asset_set_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import caldav_client, calendar_feed, quiet_hours, weather
|
||||
from .. import caldav_client, calendar_feed, quiet_hours, weather, whiteboard
|
||||
from ..db import frame_locked
|
||||
from ..image_pipeline import render_frame
|
||||
from ..immich_client import ImmichClient
|
||||
@@ -24,7 +24,7 @@ from ..models import BatteryLog, Frame, FrameCalendar, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FRAME_MODES = ("photos", "calendar")
|
||||
FRAME_MODES = ("photos", "calendar", "whiteboard")
|
||||
|
||||
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
|
||||
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
|
||||
@@ -542,3 +542,50 @@ def get_or_refresh_tasks(db: Session, frame: Frame) -> list[dict]:
|
||||
locked.calendar_tasks_cached = tasks
|
||||
locked.calendar_tasks_checked_at = now
|
||||
return tasks
|
||||
|
||||
|
||||
def webdav_creds_for(user: User) -> tuple[str, str] | None:
|
||||
"""(username, password) for `user`'s WebDAV access -- their own
|
||||
dedicated webdav_username/password, or (if they opted in)
|
||||
calendar_caldav_username/password reused from their CalDAV account
|
||||
(see models.py's User docstring on webdav_reuse_caldav_creds). None
|
||||
if neither is actually set up."""
|
||||
if user.webdav_reuse_caldav_creds:
|
||||
if user.calendar_caldav_username:
|
||||
return user.calendar_caldav_username, user.calendar_caldav_password
|
||||
return None
|
||||
if user.webdav_username:
|
||||
return user.webdav_username, user.webdav_password
|
||||
return None
|
||||
|
||||
|
||||
def get_or_refresh_whiteboard(db: Session, frame: Frame) -> bytes | None:
|
||||
"""Frame-level throttled render cache (calendar_feed.CHECK_INTERVAL_S)
|
||||
-- None if no whiteboard source is configured, credentials are
|
||||
missing (e.g. the owning user unlinked their WebDAV/CalDAV account),
|
||||
or the most recent fetch/render failed and nothing was ever cached
|
||||
yet. A failure after a previous success keeps showing the last
|
||||
good render rather than going blank for one bad refresh cycle, same
|
||||
reasoning as get_or_refresh_weather/get_or_refresh_tasks."""
|
||||
if not frame.whiteboard_url or not frame.whiteboard_user_id:
|
||||
return None
|
||||
now = time.time()
|
||||
if (frame.whiteboard_cached_image is not None
|
||||
and now - frame.whiteboard_checked_at < calendar_feed.CHECK_INTERVAL_S):
|
||||
return frame.whiteboard_cached_image
|
||||
|
||||
user = db.get(User, frame.whiteboard_user_id)
|
||||
creds = webdav_creds_for(user) if user else None
|
||||
if creds is None:
|
||||
return frame.whiteboard_cached_image
|
||||
|
||||
try:
|
||||
png = whiteboard.fetch_and_render(frame.whiteboard_url, creds[0], creds[1])
|
||||
except whiteboard.WhiteboardRenderError as e:
|
||||
logger.warning("Could not refresh whiteboard for frame %d: %s", frame.id, e)
|
||||
return frame.whiteboard_cached_image
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.whiteboard_cached_image = png
|
||||
locked.whiteboard_checked_at = now
|
||||
return png
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -152,6 +152,34 @@ def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(g
|
||||
)
|
||||
|
||||
|
||||
def _whiteboard_source_info(db: Session, frame: Frame) -> dict | None:
|
||||
"""Whose account this frame's whiteboard currently fetches with, for
|
||||
showing "using <name>'s account" to everyone linked, not just
|
||||
whoever set it. None if no source is configured."""
|
||||
if not frame.whiteboard_user_id or not frame.whiteboard_url:
|
||||
return None
|
||||
user = db.get(User, frame.whiteboard_user_id)
|
||||
if user is None:
|
||||
return None
|
||||
return {"user_id": user.id, "display_name": user.display_name or user.username, "url": frame.whiteboard_url}
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}/whiteboard", response_class=HTMLResponse)
|
||||
def frame_whiteboard_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
viewer = current_user(request, db)
|
||||
frame = db.get(Frame, frame_id)
|
||||
viewer_has_webdav_creds = False
|
||||
if viewer is not None and frame is not None and can_view_frame(db, viewer, frame):
|
||||
viewer_has_webdav_creds = bool(
|
||||
viewer.webdav_username or (viewer.webdav_reuse_caldav_creds and viewer.calendar_caldav_username)
|
||||
)
|
||||
return _frame_page(
|
||||
request, db, frame_id, "frame_whiteboard.html", "whiteboard",
|
||||
whiteboard_source=_whiteboard_source_info(db, frame) if frame is not None else None,
|
||||
viewer_has_webdav_creds=viewer_has_webdav_creds,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}/stats", response_class=HTMLResponse)
|
||||
def frame_stats_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
return _frame_page(request, db, frame_id, "frame_stats.html", "stats")
|
||||
@@ -428,6 +428,9 @@ def settings_submit(
|
||||
calendar_caldav_url: str = Form(""),
|
||||
calendar_caldav_username: str = Form(""),
|
||||
calendar_caldav_password: str = Form(""),
|
||||
webdav_username: str = Form(""),
|
||||
webdav_password: str = Form(""),
|
||||
webdav_reuse_caldav_creds: bool = Form(False),
|
||||
current_password: str = Form(""),
|
||||
new_password: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -473,6 +476,11 @@ def settings_submit(
|
||||
if calendar_caldav_password.strip():
|
||||
user.calendar_caldav_password = calendar_caldav_password.strip()
|
||||
|
||||
user.webdav_reuse_caldav_creds = webdav_reuse_caldav_creds
|
||||
user.webdav_username = webdav_username.strip()
|
||||
if webdav_password.strip():
|
||||
user.webdav_password = webdav_password.strip()
|
||||
|
||||
if new_password:
|
||||
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
||||
error = "Current password is wrong -- password not changed."
|
||||
|
||||
@@ -71,9 +71,12 @@
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
previous = mode;
|
||||
showStatus(true, mode === 'calendar' ? 'Switched to Calendar mode.' : 'Switched to Photos mode.');
|
||||
var modeLabels = { photos: 'Photos', calendar: 'Calendar', whiteboard: 'Whiteboard' };
|
||||
showStatus(true, `Switched to ${modeLabels[mode] || mode} mode.`);
|
||||
var calTab = document.querySelector('.tabs a[href$="/calendar"]');
|
||||
if (calTab) calTab.classList.toggle('tab-disabled', mode !== 'calendar');
|
||||
var wbTab = document.querySelector('.tabs a[href$="/whiteboard"]');
|
||||
if (wbTab) wbTab.classList.toggle('tab-disabled', mode !== 'whiteboard');
|
||||
} catch (e) {
|
||||
sel.value = previous;
|
||||
showStatus(false, e.message);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Whiteboard tab: source URL (owner-gated, see api_frames.py's
|
||||
// api_whiteboard_source), preview, and take control. window.FRAME_API is
|
||||
// set by the template.
|
||||
|
||||
const whiteboardForm = document.getElementById('whiteboard-source-form');
|
||||
if (whiteboardForm) {
|
||||
whiteboardForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const url = document.getElementById('whiteboard-url-input').value.trim();
|
||||
if (!url) return;
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Saved. Reload to see the updated source.');
|
||||
loadWhiteboardPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const whiteboardClearBtn = document.getElementById('whiteboard-source-clear');
|
||||
if (whiteboardClearBtn) {
|
||||
whiteboardClearBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/whiteboard-source`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: null }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'Cleared. Reload to see the change.');
|
||||
loadWhiteboardPreview();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function loadWhiteboardPreview() {
|
||||
document.getElementById('whiteboard-preview').src = `${window.FRAME_API}/preview/whiteboard?_=${Date.now()}`;
|
||||
}
|
||||
document.getElementById('whiteboard-preview-refresh').addEventListener('click', loadWhiteboardPreview);
|
||||
loadWhiteboardPreview();
|
||||
|
||||
async function takeControl() {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/take-control`, { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, 'You have control now.');
|
||||
loadControl();
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadControl() {
|
||||
const banner = document.getElementById('control-banner');
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/queue`);
|
||||
if (!resp.ok) return; // unconfigured frame: control still works via 409s
|
||||
const data = await resp.json();
|
||||
if (data.control && !data.control.you) {
|
||||
banner.style.display = 'flex';
|
||||
document.getElementById('control-holder').textContent = data.control.controller
|
||||
? `${data.control.controller} currently has control of this frame.`
|
||||
: 'Nobody has control of this frame yet.';
|
||||
} else {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
} catch (e) { /* banner is best-effort */ }
|
||||
}
|
||||
|
||||
document.getElementById('take-control').addEventListener('click', takeControl);
|
||||
loadControl();
|
||||
@@ -3,6 +3,19 @@
|
||||
// the frame's already-saved Immich creds) -- so this only works after
|
||||
// the CalDAV URL/username/password have been saved once.
|
||||
|
||||
// Hides the dedicated WebDAV username/password fields while "reuse my
|
||||
// CalDAV creds" is checked -- they'd be ignored server-side anyway (see
|
||||
// routers/common.py's webdav_creds_for), no reason to leave them visibly
|
||||
// editable and implying they still do something.
|
||||
const reuseCaldavCreds = document.getElementById('webdav_reuse_caldav_creds');
|
||||
if (reuseCaldavCreds) {
|
||||
const updateWebdavFieldVisibility = () => {
|
||||
document.getElementById('webdav-creds-fields').style.display = reuseCaldavCreds.checked ? 'none' : '';
|
||||
};
|
||||
reuseCaldavCreds.addEventListener('change', updateWebdavFieldVisibility);
|
||||
updateWebdavFieldVisibility();
|
||||
}
|
||||
|
||||
const discoverBtn = document.getElementById('caldav-discover');
|
||||
if (discoverBtn) {
|
||||
discoverBtn.addEventListener('click', async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<select id="frame-mode-select" class="frame-mode-select" title="Frame mode" aria-label="Frame mode">
|
||||
<option value="photos" {% if frame.mode == "photos" %}selected{% endif %}>Photos</option>
|
||||
<option value="calendar" {% if frame.mode == "calendar" %}selected{% endif %}>Calendar</option>
|
||||
<option value="whiteboard" {% if frame.mode == "whiteboard" %}selected{% endif %}>Whiteboard</option>
|
||||
</select>
|
||||
|
||||
@@ -3,5 +3,7 @@
|
||||
<a href="/frames/{{ frame.id }}/config" class="{% if active_tab == 'config' %}active{% endif %}">Configuration</a>
|
||||
<a href="/frames/{{ frame.id }}/calendar"
|
||||
class="{% if active_tab == 'calendar' %}active{% endif %} {% if frame.mode != 'calendar' %}tab-disabled{% endif %}">Calendar</a>
|
||||
<a href="/frames/{{ frame.id }}/whiteboard"
|
||||
class="{% if active_tab == 'whiteboard' %}active{% endif %} {% if frame.mode != 'whiteboard' %}tab-disabled{% endif %}">Whiteboard</a>
|
||||
<a href="/frames/{{ frame.id }}/stats" class="{% if active_tab == 'stats' %}active{% endif %}">Stats</a>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
{% extends "app_base.html" %}
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Whiteboard{% endblock %}
|
||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||
|
||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="control-banner" class="control-banner" style="display: none;">
|
||||
<span id="control-holder"></span>
|
||||
<button type="button" id="take-control" class="btn-inline">Take control</button>
|
||||
</div>
|
||||
|
||||
{% if frame.mode != 'whiteboard' %}
|
||||
<div class="info-box">This frame is currently in <strong>{{ frame.mode|capitalize }}</strong> mode --
|
||||
settings below take effect once you switch it to <strong>Whiteboard</strong> mode
|
||||
using the selector at the top of the page.</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="layout">
|
||||
<div class="main-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Whiteboard source</h2>
|
||||
<p class="sub">Renders a Nextcloud Whiteboard (or any Excalidraw
|
||||
scene) fetched over WebDAV -- credentials set up in
|
||||
<a href="/settings">Settings</a>.</p>
|
||||
|
||||
{% if whiteboard_source %}
|
||||
<p class="sub" style="margin-top: 10px;">
|
||||
Currently showing <strong>{{ whiteboard_source.url }}</strong>
|
||||
using <strong>{{ whiteboard_source.display_name }}</strong>'s
|
||||
WebDAV account.
|
||||
<button type="button" class="btn-inline secondary" id="whiteboard-source-clear">Clear</button>
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="sub" style="margin-top: 10px;">No whiteboard configured yet.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if viewer_has_webdav_creds %}
|
||||
<form id="whiteboard-source-form" style="margin-top: 16px;">
|
||||
<label>{{ "Change to one of your own files" if whiteboard_source else "Use one of your own files" }}
|
||||
<input type="text" id="whiteboard-url-input"
|
||||
placeholder="https://cloud.example.com/remote.php/dav/files/you/Boards/family.whiteboard"
|
||||
value="{{ whiteboard_source.url if whiteboard_source and whiteboard_source.user_id == user.id else '' }}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 4px;">The direct WebDAV URL
|
||||
to the specific file -- in Nextcloud's Files app, this is
|
||||
the file's path under
|
||||
<code>remote.php/dav/files/<your-username>/</code>.</p>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="sub" style="margin-top: 10px;">Set up WebDAV credentials
|
||||
in <a href="/settings">Settings</a> first to point this frame at
|
||||
one of your own files.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="side-col">
|
||||
<section class="card">
|
||||
<h2 class="card-title">Preview</h2>
|
||||
<p class="sub">How this frame's whiteboard currently renders.</p>
|
||||
<img class="preview-img" id="whiteboard-preview" alt="Whiteboard preview">
|
||||
<button type="button" class="secondary" id="whiteboard-preview-refresh">Refresh preview</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>window.FRAME_API = {{ ("/api/frames/" ~ frame.id) | tojson }};</script>
|
||||
<script src="/static/device_status_bar.js"></script>
|
||||
<script src="/static/frame_header.js"></script>
|
||||
<script src="/static/frame_whiteboard.js"></script>
|
||||
{% endblock %}
|
||||
@@ -71,6 +71,32 @@
|
||||
you're linked to from that frame's Calendar tab, so a frame only
|
||||
shows calendars people have actually chosen to share with it.</p>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Whiteboard (WebDAV)</h2>
|
||||
<p class="sub">Credentials for whiteboard frame mode -- fetching a
|
||||
specific file (e.g. a Nextcloud Whiteboard board) over WebDAV.
|
||||
Any WebDAV server works, not just Nextcloud.</p>
|
||||
<div class="checkbox-row">
|
||||
<input type="checkbox" id="webdav_reuse_caldav_creds" name="webdav_reuse_caldav_creds"
|
||||
value="true" {% if user.webdav_reuse_caldav_creds %}checked{% endif %}>
|
||||
<label for="webdav_reuse_caldav_creds" style="margin: 0; font-weight: normal;">
|
||||
Reuse my CalDAV username/password above (only works if it's the
|
||||
same account -- e.g. Nextcloud's CalDAV and its regular file
|
||||
storage share one login)</label>
|
||||
</div>
|
||||
<div id="webdav-creds-fields">
|
||||
<label>WebDAV username
|
||||
<input type="text" id="webdav_username" name="webdav_username" autocomplete="off"
|
||||
value="{{ user.webdav_username }}">
|
||||
</label>
|
||||
<label>WebDAV password
|
||||
<input type="password" id="webdav_password" name="webdav_password" autocomplete="off"
|
||||
placeholder="{% if user.webdav_password %}(unchanged -- enter a new password to replace){% else %}app password or account password{% endif %}">
|
||||
</label>
|
||||
</div>
|
||||
<p class="sub" style="margin-top: 8px;">Doesn't show up anywhere by
|
||||
itself -- point a specific frame's Whiteboard tab at a file URL
|
||||
using this account.</p>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
|
||||
<label>Current password
|
||||
<input type="password" name="current_password" autocomplete="current-password">
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Plain authenticated WebDAV file fetch -- whiteboard frame mode's way
|
||||
of pulling one specific file (a Nextcloud Whiteboard .whiteboard, or any
|
||||
other WebDAV server's file, this isn't Nextcloud-specific) out of a
|
||||
user's account. Deliberately just "GET this URL with Basic auth", the
|
||||
same shape as calendar_feed.py's plain ICS fetch -- no discovery, no
|
||||
account-wide browsing, since the caller already has (or pastes) the
|
||||
exact file URL, unlike caldav_client.py's calendar-account discovery
|
||||
flow which exists because a CalDAV account can hold several calendars
|
||||
worth picking between.
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||
philosophy as calendar_feed.py/caldav_client.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
FETCH_MAX_BYTES = 10 * 1024 * 1024 # a whiteboard scene is KB, not MB -- sanity cap, not an expected size
|
||||
|
||||
|
||||
class WebDavError(Exception):
|
||||
"""Fetch failed -- network, auth, a missing file, or an oversized
|
||||
response. Raised loudly; callers decide what to do."""
|
||||
|
||||
|
||||
def fetch_file(url: str, username: str, password: str) -> bytes:
|
||||
"""The raw bytes of one WebDAV file, HTTP Basic auth. That's the
|
||||
whole protocol surface whiteboard mode needs -- Basic auth over
|
||||
plain HTTP GET is what WebDAV file access boils down to once you
|
||||
already have the exact URL, no PROPFIND/discovery involved."""
|
||||
try:
|
||||
with httpx.stream("GET", url, auth=(username, password), timeout=HTTP_TIMEOUT_S,
|
||||
follow_redirects=True) as resp:
|
||||
resp.raise_for_status()
|
||||
chunks = []
|
||||
total = 0
|
||||
for chunk in resp.iter_bytes():
|
||||
total += len(chunk)
|
||||
if total > FETCH_MAX_BYTES:
|
||||
raise WebDavError(f"File exceeds {FETCH_MAX_BYTES} bytes")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
except httpx.HTTPError as e:
|
||||
raise WebDavError(str(e)) from e
|
||||
@@ -0,0 +1,75 @@
|
||||
"""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
|
||||
@@ -0,0 +1,33 @@
|
||||
# whiteboard-render
|
||||
|
||||
Local sidecar for whiteboard frame mode -- see `server.js`'s own header
|
||||
comment for the full "why Node, why not a headless browser" reasoning.
|
||||
Not an independently deployed service: it runs as a second process
|
||||
inside the main server's container (`../Dockerfile` installs Node,
|
||||
`../start.sh` launches this in the background before `exec`-ing
|
||||
uvicorn), reachable only at `127.0.0.1:3001` from the Python process in
|
||||
that same container.
|
||||
|
||||
**Not runtime-tested against a real `npm install` during development**
|
||||
-- this environment had no Node.js/npm available, only the npm registry
|
||||
API (used to verify the dependency versions/licenses in `package.json`
|
||||
actually exist and resolve). The code is written carefully against each
|
||||
library's documented API (`@excalidraw/utils`'s `exportToSvg`,
|
||||
`@resvg/resvg-js`'s `Resvg` class), but the first real build
|
||||
(`docker compose build`) is the first time this has actually executed
|
||||
end to end. If something's off, `docker compose logs` will show it --
|
||||
most likely candidates are `exportToSvg`'s actual return type (string vs.
|
||||
DOM element -- handled defensively, see server.js) or a font-rendering
|
||||
quirk, not a wrong API shape.
|
||||
|
||||
## Local development (if you have Node 20.19+/22.13+ installed)
|
||||
|
||||
```sh
|
||||
cd render-service
|
||||
npm install
|
||||
npm start # listens on 127.0.0.1:3001
|
||||
curl -X POST http://127.0.0.1:3001/render \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"elements": [], "width": 800}' \
|
||||
-o /tmp/test.png # an empty scene -- just checks the service comes up and returns a valid PNG
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "espresso-frame-whiteboard-render",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Local sidecar: renders Excalidraw scene JSON (the format Nextcloud Whiteboard's .whiteboard files use) to PNG for whiteboard frame mode. Runs as a second process inside the main server's container (see ../Dockerfile and ../start.sh), talked to over 127.0.0.1 only -- not an independent deployment, not reachable from outside the container.",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@excalidraw/utils": "0.1.3-test32",
|
||||
"@resvg/resvg-js": "2.6.2",
|
||||
"express": "^5.2.1",
|
||||
"jsdom": "^29.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Local render sidecar for whiteboard frame mode: turns Excalidraw scene
|
||||
// JSON (the format Nextcloud Whiteboard's .whiteboard files use --
|
||||
// {"elements", "appState", "files"}, see app/webdav_client.py) into a
|
||||
// PNG, using the real Excalidraw export code rather than a hand-rolled
|
||||
// reimplementation of its element types/styling/fonts. That's the whole
|
||||
// reason this exists as Node rather than more Python: @excalidraw/utils
|
||||
// IS the renderer real whiteboards are drawn with, so this reproduces
|
||||
// whatever a user actually sees in their whiteboard exactly, and never
|
||||
// drifts out of sync with new element types as Excalidraw adds them.
|
||||
//
|
||||
// Runs as a second process inside the main Python server's own
|
||||
// container (see ../Dockerfile installing Node, and ../start.sh
|
||||
// launching this in the background before exec'ing uvicorn) -- not a
|
||||
// separate deployment, no independent scaling/restart needs, so one
|
||||
// container is simpler than a second docker-compose service. Bound to
|
||||
// 127.0.0.1 only: reachable from the Python process in the same
|
||||
// container, never from outside it, so there's no auth on top of that
|
||||
// -- the network boundary IS the access control here.
|
||||
//
|
||||
// No headless browser (Puppeteer/Playwright) -- jsdom provides just
|
||||
// enough of a browser-like global environment for @excalidraw/utils'
|
||||
// internal DOM calls (e.g. text measurement) to work, and
|
||||
// @resvg/resvg-js (a native Rust SVG rasterizer, no browser process)
|
||||
// turns the resulting SVG into the actual PNG.
|
||||
|
||||
const { JSDOM } = require('jsdom');
|
||||
|
||||
// @excalidraw/utils touches `window`/`document` globals even though
|
||||
// exportToSvg's own return value doesn't depend on a live page -- these
|
||||
// have to exist before the package is required, not just before it's
|
||||
// called.
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||
global.window = dom.window;
|
||||
global.document = dom.window.document;
|
||||
global.navigator = dom.window.navigator;
|
||||
|
||||
const express = require('express');
|
||||
const { exportToSvg } = require('@excalidraw/utils');
|
||||
const { Resvg } = require('@resvg/resvg-js');
|
||||
|
||||
const PORT = process.env.RENDER_SERVICE_PORT || 3001;
|
||||
const HOST = '127.0.0.1';
|
||||
// A whiteboard scene is normally tiny (KB, not MB) -- this is a sanity
|
||||
// cap against something going wrong upstream, not a real expected size.
|
||||
const MAX_BODY_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: MAX_BODY_BYTES }));
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.post('/render', async (req, res) => {
|
||||
const { elements, appState, files, width, height } = req.body || {};
|
||||
if (!Array.isArray(elements)) {
|
||||
res.status(400).json({ error: 'elements must be an array (a parsed .whiteboard/Excalidraw scene)' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const svg = await exportToSvg({
|
||||
elements,
|
||||
appState: appState || {},
|
||||
files: files || {},
|
||||
exportPadding: 20,
|
||||
});
|
||||
// Depending on the installed version, exportToSvg resolves to either
|
||||
// an SVGSVGElement (needs serializing) or already a string -- handle
|
||||
// both rather than assume, since this isn't runtime-tested against a
|
||||
// live install in this environment (no Node available to verify
|
||||
// during development, see the server README's whiteboard mode notes).
|
||||
const svgString = typeof svg === 'string' ? svg : svg.outerHTML;
|
||||
|
||||
const targetWidth = Number(width) || undefined;
|
||||
const resvg = new Resvg(svgString, {
|
||||
fitTo: targetWidth ? { mode: 'width', value: targetWidth } : { mode: 'original' },
|
||||
background: 'rgba(255, 255, 255, 1)',
|
||||
font: {
|
||||
// No bundled Excalidraw font assets (Virgil/Cascadia) in v1 --
|
||||
// text renders in whatever fonts fontconfig finds in the image
|
||||
// (see ../Dockerfile's fonts-dejavu-core), not pixel-identical
|
||||
// to the browser editor's handwriting-style font. Good enough
|
||||
// for "what does the board say", not a design-fidelity tool.
|
||||
loadSystemFonts: true,
|
||||
},
|
||||
});
|
||||
const pngBuffer = resvg.render().asPng();
|
||||
res.set('Content-Type', 'image/png');
|
||||
res.send(pngBuffer);
|
||||
} catch (err) {
|
||||
console.error('Whiteboard render failed:', err);
|
||||
res.status(500).json({ error: String((err && err.message) || err) });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`whiteboard-render listening on ${HOST}:${PORT}`);
|
||||
});
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Starts render-service/ (whiteboard frame mode's Excalidraw-to-PNG
|
||||
# sidecar, see its own README) in the background, bound to 127.0.0.1 --
|
||||
# reachable from this container's Python process, never from outside it.
|
||||
# Then execs uvicorn as the foreground/PID 1 process so it receives
|
||||
# Docker's stop signal directly. The backgrounded Node process has no
|
||||
# state worth flushing on shutdown -- fine for it to just die with the
|
||||
# container.
|
||||
node ./render-service/server.js &
|
||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8420
|
||||
Reference in New Issue
Block a user