Records rather than streams: each device records a clip WITH AUDIO and uploads
it; the normal offline pipeline takes over. Fits the design almost for free —
MediaRecorder's webm/mp4 are already ingestible, and audio-based sync means
devices need no clock sync and needn't start together.
- capture.py: POST /api/capture/upload (streamed, sanitized name, size cap,
partial cleanup), heartbeat/devices registry, GET /capture page.
- static/capture.html: standalone page (no build step) — device picker, live
preview, record->upload, 1fps snapshot heartbeat, 'who's shooting what'
monitor, and secure-context detection that tells you to use
(getUserMedia needs HTTPS — the #1 gotcha for phones).
- OPT-IN via FESTIVAL4D_CAPTURE=1; manifest reports has_capture and the viewer
only then shows a Capture link. Public deploy leaves it off (open upload
endpoint would be unsafe) — noted in DEPLOY.md.
- Adds python-multipart dep. Suite 103 -> 119.
Also fixes test_manifest_shape, which was already RED on main: f12b6e0 added
has_splat to the manifest without updating the exact-set lock. Updated to the
true set (now also has_capture); the lock is what caught both drifts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4.9 KiB
Deploying Festival 4D to digalot.fyi/festifun
Target: dealgod@100.94.195.115 (tailscale), served under https://digalot.fyi/festifun by the
existing web server that already terminates TLS for the domain.
The app runs as two pieces behind that web server:
- Static SPA — the built frontend (
frontend/dist), served under/festifun/. - API backend — uvicorn on
127.0.0.1:8000(localhost-only), reached via/festifun/apiand/festifun/media.
This topology was validated locally end-to-end against a proxy that mirrors the nginx config in
this directory (static + prefix-stripping API/media proxy with Range) — the app loads, plays
video, and renders the 3D scene correctly under the /festifun prefix.
Deploy mode: fully open
Per the owner's decision, all features are public and unauthenticated — playback, 3D, and the
mutating endpoints (annotate, correct event type, add/delete anchors). Be aware this means anyone
who finds the URL can edit or delete the project's annotations/anchors/events. If that becomes a
problem, add HTTP basic-auth on the /festifun/ location, or run the backend read-only.
AI classification & API keys — deliberately OFF on the public box
POST /api/events/detect runs ffmpeg and, if a classifier key is present, a paid AI call. To
avoid an unauthenticated public endpoint that spends real credits, do not put GEMINI_API_KEY
or the OpenRouter creds on the server. Instead bake the moment labels in before deploy:
# locally, with keys loaded (set -a; . ./.env; set +a):
python -m festival4d events # writes AI labels into data/project.db
Then ship that DB. On the server, detect still works but degrades to candidate-only (no spend),
exactly as designed. If you want live classification on the public site, set the key in the
systemd unit — but add rate-limiting first (nginx limit_req), or it's a bill-run-up vector.
One-time server setup
# on dealgod@100.94.195.115
sudo mkdir -p /var/www/festifun
sudo chown dealgod:dealgod /var/www/festifun
# Python venv for the backend
python3 -m venv /var/www/festifun/.venv
Build + push (run locally)
# 1. Build the frontend for the /festifun prefix + same-origin API
cd frontend
FESTIVAL4D_BASE=/festifun/ VITE_API_BASE=/festifun npm run build
# 2. Generate the project to ship (synthetic demo, or your real footage pipeline first),
# with labels baked in if you loaded keys:
cd ..
python -m festival4d synthetic # or: ingest -> sync -> reconstruct -> events
# 3. Push build + backend + data to the server
rsync -az --delete frontend/dist/ dealgod@100.94.195.115:/var/www/festifun/dist/
rsync -az --delete backend/ dealgod@100.94.195.115:/var/www/festifun/backend/
rsync -az pyproject.toml dealgod@100.94.195.115:/var/www/festifun/
rsync -az --delete data/ dealgod@100.94.195.115:/var/www/festifun/data/
Install backend deps + service (on the server)
cd /var/www/festifun
.venv/bin/pip install -e ".[dev]" # or a runtime-only extra if defined
sudo cp backend/../deploy/festifun-api.service /etc/systemd/system/ # adjust path to the repo copy
sudo systemctl daemon-reload
sudo systemctl enable --now festifun-api
curl -s localhost:8000/api/health # -> {"status":"ok"}
Wire up the web server
Append the blocks from deploy/festifun.nginx.conf into the existing server { } for
digalot.fyi, adjusting alias paths to /var/www/festifun/dist/. Then:
sudo nginx -t && sudo systemctl reload nginx
Visit https://digalot.fyi/festifun/.
Using Caddy instead of nginx? The equivalent is a
handle_path /festifun/*block:file_serverfor the SPA,reverse_proxy 127.0.0.1:8000for/festifun/api/*and/festifun/media/*(Caddy strips the matched prefix withhandle_path). Ask and I'll write the Caddyfile once I can see which server is actually running there.
Updating later
Re-run the build + rsync steps, then sudo systemctl restart festifun-api (only needed if the
backend or data changed; a frontend-only change just needs the dist rsync).
Notes / caveats
- CORS is irrelevant in this topology — everything is same-origin under
/festifun. The dev-only wide CORS inconfig.pystays as-is; it doesn't affect the hosted site. - Single project. The app assumes one project at a time (SQLite at
data/project.db). - The backend binds
127.0.0.1only; nginx is the sole public entry point.
Live capture on the public box: leave it OFF
FESTIVAL4D_CAPTURE is not set in deploy/festifun-api.service, so /capture and
/api/capture/* are never mounted publicly — an open upload endpoint on a public host would let
anyone write files to the server. Capture is for your local/tailnet machine (see the README's
Capture section: FESTIVAL4D_CAPTURE=1 + tailscale serve). Record locally, run the pipeline,
then deploy the resulting project.