import base64 import io import tempfile import uuid from pathlib import Path from gradio import Server from fastapi import Request from fastapi.responses import HTMLResponse, JSONResponse, FileResponse from commentary import STYLES, generate_commentary # eager-loads Qwen on import from events import events_to_frames, pick_key_events from faces import annotate_event_frame, cluster_faces, frame_at, scan_video from ov_models import download_models from video import get_duration, normalize_video MAX_SECONDS = 60.0 # keep the demo snappy; matches the UI copy HERE = Path(__file__).parent SESSIONS = {} TIERS = ["HEADLINER", "RISING", "DIVA", "WILDCARD", "BREAKOUT", "CAMEO"] VIBE_TO_STYLE = { "football": "Football (Premier League hype)", "diva": "Diva Hour", "wildlife": "Nature documentary", "boxing": "Boxing announcer", } SUPPORTED_VIDEO = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"} try: download_models() except Exception as e: print(f"[warn] OV model download failed at boot: {e}") def _crop_b64(pil, size=160): img = pil.copy(); img.thumbnail((size, size)) buf = io.BytesIO(); img.save(buf, format="PNG") return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() def _build_ui() -> str: """Inline CSS + JS into Marquee.html so the page is fully self-contained.""" doc = (HERE / "Marquee.html").read_text() css = (HERE / "marquee.css").read_text() js = (HERE / "marquee.js").read_text() doc = doc.replace('', f"") doc = doc.replace('', f"") return doc app = Server() _UI_HTML = _build_ui() @app.get("/", response_class=HTMLResponse) async def root(): return HTMLResponse(_UI_HTML) @app.post("/api/scan") async def api_scan(request: Request): """CPU-only (OpenVINO + ffmpeg). Plain route + multipart upload via fetch.""" try: form = await request.form() upload = form.get("file") if upload is None: return JSONResponse({"ok": False, "error": "no file"}, status_code=400) suffix = Path(upload.filename).suffix.lower() if suffix not in SUPPORTED_VIDEO: return JSONResponse({"ok": False, "error": f"Unsupported format '{suffix}'. Supported: {', '.join(sorted(SUPPORTED_VIDEO))}"}) tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) tmp.write(await upload.read()); tmp.flush(); tmp.close() dur = get_duration(tmp.name) if dur > MAX_SECONDS + 1: return JSONResponse({"ok": False, "error": "Clip longer than 60 seconds"}) norm_path, w, h = normalize_video(tmp.name) embeds, crops, motion, meta = scan_video(norm_path, MAX_SECONDS) identities = cluster_faces(embeds, crops) sid = uuid.uuid4().hex SESSIONS[sid] = {"norm": norm_path, "identities": identities, "motion": motion, "meta": meta} stars = [{"idx": i, "tier": TIERS[i % len(TIERS)], "crop": _crop_b64(idn["crop"])} for i, idn in enumerate(identities)] return JSONResponse({"ok": True, "session_id": sid, "video_url": f"/video/{sid}", "duration": get_duration(norm_path), "w": w, "h": h, "portrait": h > w, "stars": stars}) except Exception as e: import traceback; traceback.print_exc() return JSONResponse({"ok": False, "error": str(e)}, status_code=500) @app.get("/video/{sid}") async def serve_video(sid: str): """Stream the normalized video file.""" sess = SESSIONS.get(sid) if not sess: return JSONResponse({"error": "session not found"}, status_code=404) return FileResponse(sess["norm"], media_type="video/mp4", headers={"Accept-Ranges": "bytes"}) @app.api(name="generate") def generate(session_id: str, names: list, vibe: str) -> dict: """GPU path. Exposed via @app.api() so ZeroGPU detects the @spaces.GPU function and gradio queues the calls. Called from the frontend through the Gradio JS client. """ sess = SESSIONS.get(session_id) if not sess: return {"ok": False, "error": "Session expired -- re-cast"} name_map = {i: str(n).strip() for i, n in enumerate(names) if str(n).strip()} style = VIBE_TO_STYLE.get(vibe, next(iter(STYLES))) fps = sess["meta"]["fps"] event_times = pick_key_events(sess["motion"], fps) event_idxs = events_to_frames(event_times, fps) frames = [] for ts, fidx in zip(event_times, event_idxs): bgr = frame_at(sess["norm"], fidx) if bgr is None: continue frames.append((ts, annotate_event_frame(bgr, sess["identities"], name_map))) script = generate_commentary(frames, list(name_map.values()), style) return {"ok": True, "script": [{"t": round(float(l["time"]), 2), "text": l["text"]} for l in script]} # Custom "/" already takes priority over Gradio's default page; no gradio UI # components, so SSR is pointless -> off. On Spaces, launch() binds 0.0.0.0:7860. app.launch(ssr_mode=False, show_error=True)