feat(api-routes): Introduce new video protection, recording management, and object detection endpoints in route handlers

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-20 00:40:15 -07:00
parent 707c48111a
commit 7bc33ef2c2
4 changed files with 100 additions and 1 deletions

View file

@ -24,7 +24,7 @@ from jobs.protect_job_store import ProtectJobStore
from pipeline.protection_processor import ProtectionProcessor
from pipeline.video_processor import VideoProcessor
from .routes import health, invisible_protect, media, process, recordings
from .routes import detect, health, invisible_protect, media, process, recordings
# ---------------------------------------------------------------------------
# Structured logging
@ -136,6 +136,7 @@ def create_app() -> FastAPI:
app.include_router(invisible_protect.router)
app.include_router(media.router)
app.include_router(recordings.router)
app.include_router(detect.router)
@app.get("/")
async def root():

View file

@ -0,0 +1,64 @@
"""Single-frame face detection route.
POST /detect/frame run SCRFD face detection on a single base64-encoded frame.
Used by the frontend video player to generate live adversary overlays during playback.
"""
from __future__ import annotations
import base64
import logging
import cv2
import numpy as np
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter(tags=["detect"])
class FrameDetectRequest(BaseModel):
frame_b64: str = Field(..., description="Base64-encoded image (JPEG or PNG, any size)")
class FrameDetectResponse(BaseModel):
bboxes: list[list[float]] = Field(
..., description="[[x1, y1, x2, y2], ...] in pixel coordinates of the input frame"
)
confidences: list[float]
face_count: int
@router.post("/detect/frame", response_model=FrameDetectResponse)
async def detect_frame(
body: FrameDetectRequest,
request: Request,
) -> FrameDetectResponse:
"""Run SCRFD face detection on a single base64-encoded frame.
Input frame can be any resolution the detector resizes internally.
Returns bounding boxes in pixel coordinates of the *input* frame.
"""
detector = request.state.detector
if not detector._initialized:
raise HTTPException(status_code=503, detail="Face detector not initialised")
try:
raw = base64.b64decode(body.frame_b64)
buf = np.frombuffer(raw, dtype=np.uint8)
frame = cv2.imdecode(buf, cv2.IMREAD_COLOR)
if frame is None:
raise ValueError("cv2.imdecode returned None")
except Exception as exc:
raise HTTPException(status_code=422, detail=f"Invalid frame_b64: {exc}") from exc
faces = await detector.detect(frame)
return FrameDetectResponse(
bboxes=[
[float(f.bbox[0]), float(f.bbox[1]), float(f.bbox[2]), float(f.bbox[3])]
for f in faces
],
confidences=[f.confidence for f in faces],
face_count=len(faces),
)

View file

@ -8,8 +8,10 @@ GET /protections — list available protection operation slugs
from __future__ import annotations
import uuid
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import FileResponse
from jobs.protect_job_store import ProtectJobStore
from models.protection_types import (
@ -78,6 +80,23 @@ async def get_protect_job(job_id: str, request: Request) -> ProtectJobStatusResp
)
@router.get("/protect-jobs/{job_id}/output")
async def stream_protect_job_output(job_id: str, request: Request) -> FileResponse:
"""Stream the protected output video for browser playback (supports range requests)."""
protect_job_store: ProtectJobStore = request.state.protect_job_store
record = await protect_job_store.get(job_id)
if record is None:
raise HTTPException(status_code=404, detail=f"Protect job not found: {job_id}")
if record.status != "done":
raise HTTPException(status_code=409, detail=f"Job not completed: status={record.status}")
if not record.output_path:
raise HTTPException(status_code=404, detail="No output path recorded for this job")
output = Path(record.output_path)
if not output.exists():
raise HTTPException(status_code=404, detail="Output file not found on disk")
return FileResponse(str(output), media_type="video/mp4", headers={"Accept-Ranges": "bytes"})
@router.get("/protections")
async def list_protections() -> dict:
"""List all available protection operation slugs."""

View file

@ -21,6 +21,7 @@ from pathlib import Path
import cv2
from fastapi import APIRouter, HTTPException, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
logger = logging.getLogger(__name__)
@ -184,6 +185,20 @@ async def list_recordings() -> list[RecordingItem]:
return items
@router.get("/recordings/{rec_id}/stream")
async def stream_recording(rec_id: str) -> FileResponse:
"""Stream a recording MP4 for browser playback (supports range requests)."""
try:
uuid.UUID(rec_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid recording ID — must be a UUID")
p = RECORDINGS_DIR / f"{rec_id}.mp4"
if not p.exists():
raise HTTPException(status_code=404, detail="Recording not found")
return FileResponse(str(p), media_type="video/mp4", headers={"Accept-Ranges": "bytes"})
@router.delete("/recordings/{rec_id}", status_code=204)
async def delete_recording(rec_id: str) -> None:
"""Delete a recording (MP4 + thumbnail) by UUID."""