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