festifun/backend/festival4d/capture.py
m3ultra 36bacf67f0 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>
2026-07-17 15:43:51 +10:00

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"])
]