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>
167 lines
6.4 KiB
Python
167 lines
6.4 KiB
Python
"""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"])
|
|
]
|