Claude 5e2094f2ad
feat(media): local inference sidecar for Media Studio
FastAPI service in infra/media-worker implementing every
MediaInferenceProvider operation behind HTTP API v1, plus capabilities.
Bearer auth, MEDIA_ROOT realpath enforcement, strict request schemas,
cancellation on disconnect, GPU/CPU gates with 503 overflow, input
size/duration limits, lazy SHA256-pinned model downloads, fail-closed
redaction with atomic output, offline pytest suite with fake backends,
CPU/CUDA/ROCm Dockerfile and a license review of every model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:19:47 +00:00

154 lines
6.5 KiB
Python

"""HTTP API v1 of the Media Studio sidecar.
GET /healthz is open; everything else needs the bearer token. POST
/v1/<operation> takes the TypeScript request object (minus `signal`) and
answers with the TypeScript result object.
"""
from __future__ import annotations
import logging
import threading
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import anyio
from fastapi import Depends, FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, ValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from . import VERSION, encoders, schemas
from .auth import require_token
from .config import Settings
from .errors import Cancelled, MediaError, error_response
from .models import OPERATIONS, Registry
from .ops import audio, embeddings, faces, inpaint, redact, saliency, scenes, segment, synthesize, transcribe
from .ops.common import Ctx, jsonable
from .ops.devices import effective_accelerator
from .runtime import Gate, run_job
log = logging.getLogger("media-worker")
MAX_BODY_BYTES = 64 * 1024 * 1024
# Status for a request whose client went away; nobody reads it.
CLIENT_CLOSED = 499
@dataclass(frozen=True)
class Route:
schema: type[BaseModel]
run: Callable[[Ctx, Any, threading.Event], dict[str, Any]]
gpu: bool
ROUTES: dict[str, Route] = {
"detectScenes": Route(schemas.PathRequest, scenes.run, False),
"transcribe": Route(schemas.TranscribeRequest, transcribe.run, True),
"detectFaces": Route(schemas.DetectFacesRequest, faces.detect_run, True),
"trackFaces": Route(schemas.TrackFacesRequest, faces.track_run, True),
"faceLandmarks": Route(schemas.TimesRequest, faces.landmarks_run, True),
"embedFrames": Route(schemas.TimesRequest, embeddings.embed_run, True),
"classifyContent": Route(schemas.ClassifyContentRequest, embeddings.classify_run, True),
"segmentPrompted": Route(schemas.SegmentPromptedRequest, segment.run, True),
"inpaintVideo": Route(schemas.InpaintVideoRequest, inpaint.run, True),
"redact": Route(schemas.RedactRequest, redact.run, False),
"synthesizeIdentity": Route(schemas.SynthesizeIdentityRequest, synthesize.run, True),
"audioFeatures": Route(schemas.PathRequest, audio.run, False),
"saliency": Route(schemas.TimesRequest, saliency.run, False),
}
assert set(ROUTES) == set(OPERATIONS)
def _operation_of(request: Request) -> str | None:
name = request.url.path.rsplit("/", 1)[-1]
return name if name in ROUTES else None
def _validation_message(error: ValidationError) -> str:
first = error.errors()[0] if error.errors() else {}
where = ".".join(str(part) for part in first.get("loc", ()))
return (
f"{where}: {first.get('msg', 'invalid request')}"
if where
else str(first.get("msg", "invalid request"))
)
def create_app(settings: Settings | None = None, registry: Registry | None = None) -> FastAPI:
settings = settings or Settings.from_env()
registry = registry or Registry(settings)
app = FastAPI(title="media-worker", version=VERSION, docs_url=None, redoc_url=None, openapi_url=None)
app.state.settings = settings
app.state.ctx = Ctx(settings, registry)
app.state.gpu_gate = Gate(settings.gpu_concurrency, settings.queue_size)
app.state.cpu_gate = Gate(settings.cpu_concurrency, settings.queue_size)
@app.exception_handler(MediaError)
async def media_error(request: Request, error: MediaError) -> JSONResponse:
return error_response(error.code, error.message, error.operation or _operation_of(request))
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, error: RequestValidationError) -> JSONResponse:
return error_response("bad_request", "invalid request", _operation_of(request))
@app.exception_handler(StarletteHTTPException)
async def http_error(request: Request, error: StarletteHTTPException) -> JSONResponse:
code = "bad_request" if error.status_code < 500 else "internal"
return error_response(code, str(error.detail), _operation_of(request), status=error.status_code)
@app.exception_handler(Exception)
async def unexpected(request: Request, error: Exception) -> JSONResponse:
log.exception("unhandled error")
return error_response("internal", "internal error", _operation_of(request))
@app.get("/healthz")
async def healthz() -> dict[str, Any]:
return {"ok": True, "version": VERSION}
@app.get("/v1/capabilities", dependencies=[Depends(require_token)])
async def capabilities() -> dict[str, Any]:
hardware = await anyio.to_thread.run_sync(encoders.hardware_encoders)
return {
"configured": True,
"operations": registry.operation_capabilities(),
"accelerator": effective_accelerator(settings),
"hardwareEncoders": list(hardware),
}
@app.post("/v1/{operation}", dependencies=[Depends(require_token)])
async def operate(operation: str, request: Request) -> Response:
route = ROUTES.get(operation)
if route is None:
return error_response("bad_request", "unknown operation", None, status=404)
raw = bytearray()
async for chunk in request.stream():
raw.extend(chunk)
if len(raw) > MAX_BODY_BYTES:
raise MediaError("too_large", "request body too large", operation)
try:
body = route.schema.model_validate_json(bytes(raw))
except ValidationError as error:
raise MediaError("bad_request", _validation_message(error), operation) from error
ctx: Ctx = request.app.state.ctx
gate: Gate = request.app.state.gpu_gate if route.gpu else request.app.state.cpu_gate
try:
result = await run_job(
request, gate, lambda cancel: route.run(ctx, body, cancel), settings.max_seconds
)
except MediaError as error:
error.operation = operation
raise
except Cancelled as error:
if error.reason == "client disconnected":
return Response(status_code=CLIENT_CLOSED)
raise MediaError("internal", error.reason, operation) from error
except Exception as error:
log.exception("%s failed", operation)
raise MediaError("internal", "internal error", operation) from error
return JSONResponse(jsonable(result))
return app