Live capture: USB webcams, laptop cams, and phones -> data/raw

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>
This commit is contained in:
m3ultra 2026-07-17 15:43:51 +10:00
parent f12b6e0847
commit 36bacf67f0
10 changed files with 661 additions and 1 deletions

View File

@ -67,6 +67,54 @@ moment markers on the timeline.
--- ---
## Capture: record straight from webcams & phones
Don't have footage yet? Record it with whatever cameras you have — USB webcams, the laptop cam,
and any phone with a browser. **No app install.** Cameras *record* clips that feed the normal
pipeline (this isn't live streaming — see `docs/ideas.md` for that).
This works because of two properties of the design: browser recordings (`.webm`, or `.mp4` on
iOS) are already ingestible formats, and **sync is audio-based** — so the devices need **no clock
sync and don't even have to start together**. They only have to record the same moment and hear
the same sound.
```bash
# capture is OPT-IN (it accepts uploads) — enable it explicitly:
FESTIVAL4D_CAPTURE=1 uv run python -m festival4d serve
```
Then open **`/capture`** on each device (a 📹 Capture link also appears in the viewer): name the
device, pick a camera + mic, hit record, hit stop — the clip uploads into `data/raw/`. The page
shows a live "who's shooting what" monitor of every connected camera. When you're done:
```bash
uv run python -m festival4d ingest && uv run python -m festival4d sync # then reconstruct / events
```
### Getting a phone connected (the HTTPS bit)
Browsers **only grant camera access on a secure origin**. A phone opening `http://<lan-ip>:8000`
gets *no camera*, silently. The easiest fix if you use Tailscale — it issues a real cert, so
there's nothing to trust manually:
```bash
tailscale serve --bg 8000 # prints https://<machine>.<tailnet>.ts.net
```
Join the phone to the tailnet and open `https://<machine>.<tailnet>.ts.net/capture`. (Without
Tailscale: any HTTPS reverse proxy works; `localhost` is also treated as secure, which is why the
laptop's own cameras work with no setup.)
### Capture tips
- **Every clip needs audio**, and all cameras must hear the same sound — that's the only thing
aligning them. Testing indoors? Play music.
- **USB bandwidth is the real limit** on multiple webcams: two 1080p cams on one controller often
fails. Drop to 720p or use separate ports/hubs.
- Follow the *Shooting tips* below for reconstruction-friendly angles.
- Capture routes stay **unmounted** unless `FESTIVAL4D_CAPTURE=1` — a public deployment must
never expose an open upload endpoint.
## Real-footage workflow ## Real-footage workflow
1. **Shoot / gather** 24 videos of the same performance from different positions (see 1. **Shoot / gather** 24 videos of the same performance from different positions (see

View File

@ -52,6 +52,14 @@ config.RAW_DIR.mkdir(parents=True, exist_ok=True)
# HTTP Range-capable static serving of source videos (spec M3, pitfall #3). # HTTP Range-capable static serving of source videos (spec M3, pitfall #3).
app.mount("/media", StaticFiles(directory=config.RAW_DIR), name="media") app.mount("/media", StaticFiles(directory=config.RAW_DIR), name="media")
# Live capture (webcams / phones -> data/raw). OPT-IN: these routes accept file uploads, so they
# stay unmounted unless FESTIVAL4D_CAPTURE=1 — a public deployment must never expose them.
from festival4d import capture # noqa: E402 (import after app/config are ready)
if capture.capture_enabled():
app.include_router(capture.router)
log.info("capture routes ENABLED (/capture, /api/capture/*) — FESTIVAL4D_CAPTURE is set")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Request models # Request models
@ -145,6 +153,7 @@ def manifest() -> dict:
return { return {
"videos": [_video_dict(v) for v in videos], "videos": [_video_dict(v) for v in videos],
"t_global_max": t_global_max, "t_global_max": t_global_max,
"has_capture": capture.capture_enabled(),
"has_poses": db.has_poses(), "has_poses": db.has_poses(),
"has_splat": config.SPLAT_PLY.exists(), "has_splat": config.SPLAT_PLY.exists(),
} }

View File

@ -0,0 +1,166 @@
"""Live capture — USB webcams, laptop cams, and phones -> ``data/raw`` (Phase 5).
**Records, it doesn't stream.** Each device records a clip *with audio* and uploads it; the normal
offline pipeline (``ingest -> sync -> reconstruct -> events``) takes it from there. This fits the
app's design almost for free:
- Browser ``MediaRecorder`` emits WebM (Safari/iOS: MP4) both are already in ingest's
``_VIDEO_EXTS``, so no format work is needed.
- Sync is **audio-based** (GCC-PHAT), so capture devices need **no clock sync and no
synchronized start** they only have to record the same moment and hear the same sound.
Every recording MUST carry an audio track or ``sync`` has nothing to align.
A lightweight device registry powers the "who's shooting what" monitor: each device posts a
heartbeat with its status and a small JPEG snapshot (~1 fps), so the capture page can show every
camera without a WebRTC stack.
**Disabled by default.** These routes accept file uploads; they must never be exposed on a public
deployment. Set ``FESTIVAL4D_CAPTURE=1`` to mount them (see :func:`capture_enabled`).
"""
from __future__ import annotations
import logging
import os
import re
import time
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, UploadFile
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from festival4d import config
log = logging.getLogger("festival4d.capture")
router = APIRouter()
STATIC_DIR = Path(__file__).resolve().parent / "static"
# Containers a browser MediaRecorder can produce, intersected with what ingest accepts.
ALLOWED_EXTS = {".webm", ".mp4", ".mov", ".mkv"}
MAX_UPLOAD_BYTES = int(os.environ.get("FESTIVAL4D_MAX_UPLOAD_MB", "2048")) * 1024 * 1024
MAX_SNAPSHOT_CHARS = 300_000 # data-URL cap for a heartbeat thumbnail
DEVICE_STALE_S = 15.0 # drop devices that stop heartbeating
def capture_enabled() -> bool:
"""Capture routes are opt-in — they accept uploads, so never on by default."""
return os.environ.get("FESTIVAL4D_CAPTURE", "").strip().lower() in {"1", "true", "yes", "on"}
# ---------------------------------------------------------------------------
# Device registry (in-memory, ephemeral — it only powers the live monitor)
# ---------------------------------------------------------------------------
_devices: dict[str, dict] = {}
class Heartbeat(BaseModel):
device_id: str = Field(min_length=1, max_length=64)
name: str = Field(default="camera", max_length=64)
status: str = Field(default="idle", max_length=16) # idle | recording
elapsed_s: float = 0.0
snapshot: str | None = None # data:image/jpeg;base64,...
def _prune() -> None:
now = time.monotonic()
for did in [d for d, v in _devices.items() if now - v["_seen"] > DEVICE_STALE_S]:
_devices.pop(did, None)
def _slug(name: str) -> str:
"""Filesystem-safe slug from a user-supplied device name."""
s = re.sub(r"[^a-zA-Z0-9]+", "-", name).strip("-").lower()
return (s or "cam")[:40]
def _safe_raw_path(device_name: str, filename: str) -> Path:
"""Build a sanitized destination inside RAW_DIR. Never trusts the client filename."""
ext = Path(filename or "").suffix.lower()
if ext not in ALLOWED_EXTS:
raise HTTPException(
status_code=400,
detail=f"unsupported container '{ext or '?'}' (allowed: {sorted(ALLOWED_EXTS)})",
)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
dest = (config.RAW_DIR / f"{_slug(device_name)}-{stamp}{ext}").resolve()
# Defence in depth: the slug can't traverse, but verify containment anyway.
if not str(dest).startswith(str(config.RAW_DIR.resolve()) + os.sep):
raise HTTPException(status_code=400, detail="invalid destination path")
return dest
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/capture", response_class=HTMLResponse)
def capture_page() -> str:
"""The standalone capture page (open this on a phone over HTTPS — see README)."""
page = STATIC_DIR / "capture.html"
if not page.is_file():
raise HTTPException(status_code=500, detail="capture.html missing from the install")
return page.read_text(encoding="utf-8")
@router.post("/api/capture/upload")
async def upload(file: UploadFile, device_name: str = Form("camera")) -> dict:
"""Stream an uploaded clip into ``data/raw`` under a sanitized name."""
dest = _safe_raw_path(device_name, file.filename or "")
config.RAW_DIR.mkdir(parents=True, exist_ok=True)
written = 0
try:
with open(dest, "wb") as out:
while chunk := await file.read(1 << 20): # 1 MiB
written += len(chunk)
if written > MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"upload exceeds {MAX_UPLOAD_BYTES // (1024*1024)} MB",
)
out.write(chunk)
except Exception:
dest.unlink(missing_ok=True) # never leave a partial file for ingest to trip on
raise
if written == 0:
dest.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail="empty upload")
log.info("capture: saved %s (%.1f MB) from %r", dest.name, written / 1e6, device_name)
return {"filename": dest.name, "bytes": written, "next": "run `python -m festival4d ingest`"}
@router.post("/api/capture/heartbeat")
def heartbeat(hb: Heartbeat) -> dict:
"""Register/refresh a capture device and its latest thumbnail (powers the monitor)."""
snap = hb.snapshot
if snap and len(snap) > MAX_SNAPSHOT_CHARS:
snap = None # oversized thumbnail: keep the device, drop the image
entry = _devices.setdefault(hb.device_id, {})
entry.update(
{
"device_id": hb.device_id,
"name": hb.name,
"status": hb.status,
"elapsed_s": hb.elapsed_s,
"_seen": time.monotonic(),
}
)
if snap:
entry["snapshot"] = snap
_prune()
return {"ok": True, "devices": len(_devices)}
@router.get("/api/capture/devices")
def devices() -> list[dict]:
"""Live capture devices (stale ones drop off after ~15 s)."""
_prune()
return [
{k: v for k, v in d.items() if not k.startswith("_")}
for d in sorted(_devices.values(), key=lambda d: d["name"])
]

View File

@ -0,0 +1,276 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Festival 4D — Capture</title>
<style>
:root {
color-scheme: dark;
--bg: #0b0d12; --panel: #141824; --panel-2: #1c2233; --border: #232838;
--text: #e6e8ee; --dim: #8a92a6; --ok: #59d499; --bad: #ff6b6b; --rec: #ff3b6b;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text); min-height: 100vh;
font: 15px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: env(safe-area-inset-top) 0.8rem calc(env(safe-area-inset-bottom) + 0.8rem);
}
h1 { font-size: 1.1rem; margin: 0.8rem 0 0.2rem; }
.sub { color: var(--dim); font-size: 0.8rem; margin-bottom: 0.8rem; }
.card {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 0.7rem; margin-bottom: 0.7rem;
}
label { display: block; color: var(--dim); font-size: 0.76rem; margin-bottom: 0.2rem; }
select, input[type="text"] {
width: 100%; background: var(--panel-2); color: var(--text); font-size: 1rem;
border: 1px solid var(--border); border-radius: 7px; padding: 0.5rem;
margin-bottom: 0.55rem;
}
#preview {
width: 100%; aspect-ratio: 16/9; background: #000; border-radius: 8px;
object-fit: contain; display: block;
}
.row { display: flex; gap: 0.5rem; align-items: center; }
button {
flex: 1 1 auto; font-size: 1rem; font-weight: 600; padding: 0.75rem 0.6rem;
border-radius: 9px; border: 1px solid var(--border); background: var(--panel-2);
color: var(--text); cursor: pointer;
}
button:disabled { opacity: 0.45; cursor: default; }
#rec.recording { background: var(--rec); border-color: var(--rec); color: #fff; }
.pill {
display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.75rem;
color: var(--dim);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); }
.dot.on { background: var(--rec); animation: pulse 1s infinite; }
@keyframes pulse { 50% { opacity: 0.25; } }
.msg { font-size: 0.82rem; margin-top: 0.5rem; line-height: 1.4; }
.err { color: var(--bad); }
.ok { color: var(--ok); }
code {
background: var(--panel-2); padding: 0.1rem 0.3rem; border-radius: 4px;
font-size: 0.85em; word-break: break-all;
}
/* monitor */
#mon { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 0.5rem; }
.mcell { background: var(--panel-2); border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
.mcell img { width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; background: #000; }
.mcell .cap { padding: 0.25rem 0.35rem; font-size: 0.68rem; display: flex; align-items: center; gap: 0.25rem; }
.mcell .nm { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mcell.rec { border-color: var(--rec); }
#gate { display: none; }
</style>
</head>
<body>
<h1>Festival 4D — Capture</h1>
<div class="sub">Record this camera. Clips upload to the project and sync by audio.</div>
<!-- Shown when getUserMedia is unavailable (insecure origin) -->
<div class="card" id="gate">
<div class="msg err"><b>Camera blocked — this page isn't a secure context.</b></div>
<div class="msg">
Browsers only grant camera access over <b>HTTPS</b> (or localhost). On the machine running
the backend:
<div style="margin:0.4rem 0"><code>tailscale serve --bg 8000</code></div>
then open the printed <code>https://&lt;machine&gt;.&lt;tailnet&gt;.ts.net/capture</code>
URL on this device (join the tailnet first).
</div>
</div>
<div id="app">
<div class="card">
<label for="name">Device name (used in the filename)</label>
<input type="text" id="name" placeholder="e.g. phone-left / logitech-c920" />
<label for="cam">Camera</label>
<select id="cam"></select>
<label for="mic">Microphone <span style="color:var(--bad)">— required for sync</span></label>
<select id="mic"></select>
<video id="preview" playsinline muted autoplay></video>
<div class="row" style="margin-top:0.55rem">
<span class="pill"><span class="dot" id="recdot"></span><span id="status">idle</span></span>
</div>
</div>
<div class="row">
<button id="rec">● Record</button>
</div>
<div class="msg" id="msg"></div>
<div class="card" style="margin-top:0.8rem">
<label style="margin-bottom:0.4rem">Who's shooting what — live monitor</label>
<div id="mon"></div>
<div class="msg" id="monempty" style="color:var(--dim)">no other cameras yet</div>
</div>
<div class="msg" style="color:var(--dim)">
All cameras must hear the <b>same sound</b> — that's how clips get aligned. No clock sync
needed; you don't even have to start together. When done, run
<code>python -m festival4d ingest &amp;&amp; python -m festival4d sync</code>.
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const deviceId = (localStorage.f4dDevId ||= "dev-" + Math.random().toString(36).slice(2, 9));
let stream = null, recorder = null, chunks = [], startedAt = 0, timer = null;
const secure = window.isSecureContext && navigator.mediaDevices?.getUserMedia;
if (!secure) {
$("gate").style.display = "block";
$("app").style.display = "none";
}
function msg(html, cls = "") { $("msg").className = "msg " + cls; $("msg").innerHTML = html; }
async function listDevices() {
const devs = await navigator.mediaDevices.enumerateDevices();
const fill = (sel, kind, fallback) => {
const cur = sel.value;
sel.innerHTML = devs
.filter((d) => d.kind === kind)
.map((d, i) => `<option value="${d.deviceId}">${d.label || `${fallback} ${i + 1}`}</option>`)
.join("");
if (cur) sel.value = cur;
};
fill($("cam"), "videoinput", "Camera");
fill($("mic"), "audioinput", "Microphone");
}
async function start(camId, micId) {
stream?.getTracks().forEach((t) => t.stop());
// Audio is NOT optional: the pipeline aligns clips by sound.
stream = await navigator.mediaDevices.getUserMedia({
video: camId ? { deviceId: { exact: camId } } : true,
audio: micId ? { deviceId: { exact: micId } } : true,
});
$("preview").srcObject = stream;
await listDevices(); // labels only populate after permission is granted
}
function pickMime() {
const want = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
"video/mp4", // Safari / iOS
];
return want.find((m) => MediaRecorder.isTypeSupported?.(m)) || "";
}
$("rec").addEventListener("click", () => (recorder ? stop() : record()));
function record() {
if (!stream) return msg("no camera stream yet", "err");
if (stream.getAudioTracks().length === 0)
return msg("no audio track — clips without sound can't be synced.", "err");
const mime = pickMime();
chunks = [];
recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
recorder.onstop = upload;
recorder.start(1000);
startedAt = Date.now();
$("rec").textContent = "■ Stop";
$("rec").classList.add("recording");
$("recdot").classList.add("on");
timer = setInterval(tick, 250);
msg("recording — point at the stage and keep some static structure in frame.");
}
function stop() {
clearInterval(timer);
recorder?.stop();
recorder = null;
$("rec").textContent = "● Record";
$("rec").classList.remove("recording");
$("recdot").classList.remove("on");
tick();
}
function elapsed() { return startedAt ? (Date.now() - startedAt) / 1000 : 0; }
function tick() {
$("status").textContent = recorder ? `recording ${elapsed().toFixed(0)}s` : "idle";
}
async function upload() {
const type = chunks[0]?.type || "video/webm";
const blob = new Blob(chunks, { type });
const ext = type.includes("mp4") ? "mp4" : "webm";
const name = ($("name").value || "camera").trim();
const fd = new FormData();
fd.append("device_name", name);
fd.append("file", blob, `clip.${ext}`);
msg(`uploading ${(blob.size / 1e6).toFixed(1)} MB…`);
try {
const r = await fetch("api/capture/upload", { method: "POST", body: fd });
const j = await r.json();
if (!r.ok) throw new Error(j.detail || `HTTP ${r.status}`);
msg(`saved <code>${j.filename}</code> (${(j.bytes / 1e6).toFixed(1)} MB). Record again or run the pipeline.`, "ok");
} catch (e) {
msg(`upload failed: ${e.message}`, "err");
}
startedAt = 0;
tick();
}
// --- heartbeat + monitor -------------------------------------------------------------
const snapCanvas = document.createElement("canvas");
function snapshot() {
const v = $("preview");
if (!v.videoWidth) return null;
snapCanvas.width = 160;
snapCanvas.height = Math.round((160 * v.videoHeight) / v.videoWidth);
snapCanvas.getContext("2d").drawImage(v, 0, 0, snapCanvas.width, snapCanvas.height);
return snapCanvas.toDataURL("image/jpeg", 0.4);
}
async function beat() {
try {
await fetch("api/capture/heartbeat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_id: deviceId,
name: ($("name").value || "camera").trim(),
status: recorder ? "recording" : "idle",
elapsed_s: elapsed(),
snapshot: snapshot(),
}),
});
const devs = await (await fetch("api/capture/devices")).json();
renderMonitor(devs.filter((d) => d.device_id !== deviceId));
} catch { /* offline: monitor just goes stale */ }
}
function renderMonitor(devs) {
$("monempty").style.display = devs.length ? "none" : "block";
$("mon").innerHTML = devs
.map(
(d) => `<div class="mcell ${d.status === "recording" ? "rec" : ""}">
${d.snapshot ? `<img src="${d.snapshot}" alt="">` : `<img alt="">`}
<div class="cap"><span class="dot ${d.status === "recording" ? "on" : ""}"></span>
<span class="nm">${d.name}</span>
<span>${d.status === "recording" ? d.elapsed_s.toFixed(0) + "s" : ""}</span></div>
</div>`
)
.join("");
}
// --- boot ----------------------------------------------------------------------------
if (secure) {
$("name").value = localStorage.f4dName || "";
$("name").addEventListener("change", () => (localStorage.f4dName = $("name").value));
$("cam").addEventListener("change", () => start($("cam").value, $("mic").value).catch((e) => msg(e.message, "err")));
$("mic").addEventListener("change", () => start($("cam").value, $("mic").value).catch((e) => msg(e.message, "err")));
start().then(() => msg("ready — name this device, then record.")).catch((e) =>
msg(`camera error: ${e.message}`, "err")
);
setInterval(beat, 2000);
navigator.mediaDevices.addEventListener?.("devicechange", listDevices);
}
</script>
</body>
</html>

View File

@ -32,8 +32,11 @@ def client():
def test_manifest_shape(client): def test_manifest_shape(client):
data = client.get("/api/manifest").json() data = client.get("/api/manifest").json()
assert set(data) == {"videos", "t_global_max", "has_poses"} # Exact-set lock: additive manifest fields are fine, but must be added here consciously
# (this assertion has already caught two silent drifts — has_splat and has_capture).
assert set(data) == {"videos", "t_global_max", "has_poses", "has_splat", "has_capture"}
assert data["has_poses"] is True assert data["has_poses"] is True
assert data["has_capture"] is False # capture routes are opt-in (FESTIVAL4D_CAPTURE)
assert len(data["videos"]) == 3 assert len(data["videos"]) == 3
v = data["videos"][0] v = data["videos"][0]
assert set(v) == { assert set(v) == {

View File

@ -0,0 +1,137 @@
"""Live capture tests (Phase 5) — upload safety, the env gate, and the device monitor.
The router is exercised directly on a throwaway app: ``capture_enabled()`` is read at
``api`` import time, so mounting it here is both simpler and keeps the gate test honest
(the real app must NOT expose these routes unless FESTIVAL4D_CAPTURE is set).
"""
from __future__ import annotations
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from festival4d import capture, config
@pytest.fixture()
def client():
app = FastAPI()
app.include_router(capture.router)
config.RAW_DIR.mkdir(parents=True, exist_ok=True)
with TestClient(app) as c:
yield c
def _upload(client, name: str, filename: str, data: bytes = b"fake-video-bytes"):
return client.post(
"/api/capture/upload",
data={"device_name": name},
files={"file": (filename, data, "video/webm")},
)
# --- the gate ---------------------------------------------------------------------------------
def test_capture_disabled_by_default():
"""FESTIVAL4D_CAPTURE is unset in tests => the real app exposes no upload surface."""
assert capture.capture_enabled() is False
from festival4d import api
paths = {r.path for r in api.app.routes}
assert "/capture" not in paths
assert not any(p.startswith("/api/capture") for p in paths)
@pytest.mark.parametrize("val,expected", [("1", True), ("true", True), ("on", True),
("0", False), ("", False), ("no", False)])
def test_capture_enabled_parsing(monkeypatch, val, expected):
monkeypatch.setenv("FESTIVAL4D_CAPTURE", val)
assert capture.capture_enabled() is expected
# --- upload -----------------------------------------------------------------------------------
def test_upload_saves_sanitized_file(client):
r = _upload(client, "Phone Left!", "clip.webm")
assert r.status_code == 200, r.text
body = r.json()
saved = config.RAW_DIR / body["filename"]
assert saved.is_file() and saved.read_bytes() == b"fake-video-bytes"
assert body["filename"].startswith("phone-left-") # slugified, timestamped
assert body["filename"].endswith(".webm")
assert body["bytes"] == len(b"fake-video-bytes")
saved.unlink()
def test_upload_name_cannot_traverse(client):
r = _upload(client, "../../etc/passwd", "clip.webm")
assert r.status_code == 200
saved = config.RAW_DIR / r.json()["filename"]
# The slug strips separators entirely; the file lands inside RAW_DIR, nowhere else.
assert saved.resolve().parent == config.RAW_DIR.resolve()
assert ".." not in r.json()["filename"]
saved.unlink()
def test_upload_rejects_bad_container(client):
r = _upload(client, "cam", "payload.exe")
assert r.status_code == 400
assert "unsupported container" in r.json()["detail"]
def test_upload_rejects_empty(client):
r = _upload(client, "cam", "clip.webm", data=b"")
assert r.status_code == 400
assert "empty" in r.json()["detail"]
def test_upload_rejects_oversize(client, monkeypatch):
monkeypatch.setattr(capture, "MAX_UPLOAD_BYTES", 8)
r = _upload(client, "cam", "clip.webm", data=b"x" * 64)
assert r.status_code == 413
# The partial file must not be left behind for ingest to trip on.
assert not any(p.name.startswith("cam-") for p in config.RAW_DIR.glob("cam-*.webm"))
# --- monitor ----------------------------------------------------------------------------------
def test_heartbeat_and_devices_monitor(client):
capture._devices.clear()
client.post("/api/capture/heartbeat", json={
"device_id": "d1", "name": "phone-left", "status": "recording",
"elapsed_s": 3.5, "snapshot": "data:image/jpeg;base64,AAAA",
})
client.post("/api/capture/heartbeat", json={"device_id": "d2", "name": "laptop"})
devs = client.get("/api/capture/devices").json()
assert [d["name"] for d in devs] == ["laptop", "phone-left"] # sorted by name
d1 = next(d for d in devs if d["device_id"] == "d1")
assert d1["status"] == "recording" and d1["snapshot"].startswith("data:image/jpeg")
assert "_seen" not in d1 # internals not leaked
capture._devices.clear()
def test_oversized_snapshot_dropped_but_device_kept(client):
capture._devices.clear()
client.post("/api/capture/heartbeat", json={
"device_id": "big", "name": "cam", "snapshot": "data:image/jpeg;base64," + "A" * 400_000,
})
devs = client.get("/api/capture/devices").json()
assert len(devs) == 1 and "snapshot" not in devs[0]
capture._devices.clear()
def test_stale_devices_pruned(client, monkeypatch):
capture._devices.clear()
client.post("/api/capture/heartbeat", json={"device_id": "old", "name": "cam"})
assert len(client.get("/api/capture/devices").json()) == 1
monkeypatch.setattr(capture, "DEVICE_STALE_S", -1.0) # everything is instantly stale
assert client.get("/api/capture/devices").json() == []
capture._devices.clear()
def test_capture_page_served(client):
r = client.get("/capture")
assert r.status_code == 200
assert "Festival 4D — Capture" in r.text
assert "getUserMedia" in r.text # the page really is the capture app

View File

@ -106,3 +106,13 @@ backend or data changed; a frontend-only change just needs the `dist` rsync).
dev-only wide CORS in `config.py` stays as-is; it doesn't affect the hosted site. dev-only wide CORS in `config.py` stays as-is; it doesn't affect the hosted site.
- **Single project.** The app assumes one project at a time (SQLite at `data/project.db`). - **Single project.** The app assumes one project at a time (SQLite at `data/project.db`).
- The backend binds `127.0.0.1` only; nginx is the sole public entry point. - The backend binds `127.0.0.1` only; 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.

View File

@ -211,6 +211,10 @@
<div id="topbar"> <div id="topbar">
<h1>Festival 4D</h1> <h1>Festival 4D</h1>
<span class="sub">synchronized multi-cam replay · 3D viewer · x-ray overlays</span> <span class="sub">synchronized multi-cam replay · 3D viewer · x-ray overlays</span>
<span class="spacer"></span>
<!-- shown only when the backend has capture enabled (FESTIVAL4D_CAPTURE=1) -->
<a id="capture-link" class="btn" style="display:none; text-decoration:none"
title="Record from webcams / phones into this project" target="_blank">📹 Capture</a>
</div> </div>
<div id="stage"> <div id="stage">

View File

@ -42,6 +42,12 @@ async function boot() {
state.videos = manifest.videos; state.videos = manifest.videos;
state.tGlobalMax = manifest.t_global_max; state.tGlobalMax = manifest.t_global_max;
state.hasPoses = manifest.has_poses; state.hasPoses = manifest.has_poses;
// Capture is opt-in on the backend; only surface the link when it's actually mounted.
if (manifest.has_capture) {
const link = el("capture-link");
link.href = `${API_BASE}/capture`;
link.style.display = "";
}
state.hasSplat = !!manifest.has_splat; state.hasSplat = !!manifest.has_splat;
for (const v of state.videos) state.enabled[v.id] = true; for (const v of state.videos) state.enabled[v.id] = true;
state.audioSourceId = referenceVideoId(); state.audioSourceId = referenceVideoId();

View File

@ -14,6 +14,7 @@ dependencies = [
"sqlalchemy>=2.0", "sqlalchemy>=2.0",
"pydantic>=2.6", "pydantic>=2.6",
"opencv-python-headless>=4.9", "opencv-python-headless>=4.9",
"python-multipart>=0.0.9", # multipart uploads for live capture (backend/capture.py)
# classifier providers (lane D) — imported lazily, never at module load # classifier providers (lane D) — imported lazily, never at module load
"google-genai>=0.3", "google-genai>=0.3",
"anthropic>=0.40", "anthropic>=0.40",