Spaces:
Sleeping
Sleeping
| """ | |
| SURPRISE backend — FastAPI server. | |
| Endpoints: | |
| GET / → service info | |
| GET /health → health check | |
| POST /analyze → analyze a video for AI-generated content | |
| The model backend is selected via env var MODEL_BACKEND (default: "mock"). | |
| Set MODEL_BACKEND=lewm and provide CHECKPOINT_PATH when you have a trained model. | |
| """ | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| import logging | |
| import os | |
| import tempfile | |
| import time | |
| from fastapi import FastAPI, File, HTTPException, UploadFile | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse | |
| from config import settings | |
| from inference import get_inference_engine | |
| from video_utils import extract_frames, validate_video | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| ) | |
| log = logging.getLogger("surprise") | |
| async def lifespan(app: FastAPI): | |
| log.info("Starting SURPRISE backend") | |
| log.info(f"Backend: {settings.model_backend}") | |
| app.state.engine = get_inference_engine() | |
| log.info(f"Engine ready: {app.state.engine.__class__.__name__}") | |
| yield | |
| log.info("Shutting down") | |
| app = FastAPI( | |
| title="SURPRISE", | |
| description="Deepfake detection via latent surprise (LeWorldModel)", | |
| version="0.1.0", | |
| lifespan=lifespan, | |
| ) | |
| # CORS — allows the Vercel frontend to call this backend | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=settings.allowed_origins, | |
| allow_origin_regex=settings.allowed_origin_regex, | |
| allow_methods=["GET", "POST"], | |
| allow_headers=["*"], | |
| ) | |
| def root(): | |
| return { | |
| "service": "surprise", | |
| "version": "0.1.0", | |
| "status": "ok", | |
| "backend": settings.model_backend, | |
| "endpoints": ["/health", "/analyze"], | |
| } | |
| def health(): | |
| return {"status": "healthy", "backend": settings.model_backend} | |
| async def analyze(video: UploadFile = File(...)): | |
| """ | |
| Analyze an uploaded video for AI-generated content. | |
| Request: multipart/form-data with field `video` (a video file). | |
| Response shape (JSON): | |
| { | |
| "verdict": "real" | "fake" | "uncertain", | |
| "label": "AUTHENTIC" | "AI-GENERATED" | "INCONCLUSIVE", | |
| "confidence": float [0,100], | |
| "surprise_score": float, | |
| "straightness": float, | |
| "per_frame_surprise": [float, ...], | |
| "flagged_frames": [int, ...], | |
| "flag_reason": str, | |
| "frame_count": int, | |
| "duration_seconds": float, | |
| "processing_time_ms": int, | |
| "backend": "mock" | "lewm" | |
| } | |
| """ | |
| t0 = time.time() | |
| # Validate content type OR file extension (some clients omit content-type) | |
| video_exts = {".mp4", ".webm", ".mov", ".mkv", ".avi", ".m4v"} | |
| ext = Path(video.filename or "").suffix.lower() | |
| is_video_mime = video.content_type and video.content_type.startswith("video/") | |
| is_video_ext = ext in video_exts | |
| if not (is_video_mime or is_video_ext): | |
| raise HTTPException( | |
| 400, | |
| f"File must be a video. Got content-type={video.content_type!r}, ext={ext!r}", | |
| ) | |
| # Read & size check | |
| contents = await video.read() | |
| size_mb = len(contents) / (1024 * 1024) | |
| if size_mb > settings.max_file_size_mb: | |
| raise HTTPException( | |
| 413, | |
| f"File too large: {size_mb:.1f}MB (max {settings.max_file_size_mb}MB)", | |
| ) | |
| log.info(f"Received: {video.filename} ({size_mb:.2f}MB, {video.content_type})") | |
| # Save to temp & process | |
| suffix = Path(video.filename or "vid.mp4").suffix or ".mp4" | |
| with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: | |
| tmp.write(contents) | |
| tmp_path = tmp.name | |
| try: | |
| meta = validate_video(tmp_path) | |
| if meta["duration"] > settings.max_duration_seconds: | |
| raise HTTPException( | |
| 413, | |
| f"Video too long: {meta['duration']:.1f}s " | |
| f"(max {settings.max_duration_seconds}s)", | |
| ) | |
| frames = extract_frames( | |
| tmp_path, | |
| target_size=settings.input_resolution, | |
| max_frames=settings.max_frames, | |
| target_fps=settings.target_fps, | |
| ) | |
| log.info( | |
| f"Extracted {len(frames)} frames " | |
| f"({meta['fps']:.1f}fps src, {meta['duration']:.1f}s)" | |
| ) | |
| result = app.state.engine.analyze(frames) | |
| elapsed_ms = int((time.time() - t0) * 1000) | |
| return JSONResponse( | |
| { | |
| **result, | |
| "frame_count": len(frames), | |
| "duration_seconds": round(meta["duration"], 2), | |
| "processing_time_ms": elapsed_ms, | |
| } | |
| ) | |
| except HTTPException: | |
| raise | |
| except ValueError as e: | |
| raise HTTPException(400, str(e)) | |
| finally: | |
| try: | |
| os.unlink(tmp_path) | |
| except OSError: | |
| pass | |
| async def general_exception_handler(request, exc): | |
| log.exception("Unhandled exception") | |
| return JSONResponse( | |
| status_code=500, | |
| content={"error": "internal_error", "message": str(exc)}, | |
| ) | |
| if __name__ == "__main__": | |
| import uvicorn | |
| port = int(os.getenv("PORT", "8000")) | |
| uvicorn.run("server:app", host="0.0.0.0", port=port, reload=True) | |