Refactor face detection and tracking in faces.py; optimize performance by reducing redundant detections and using a dictionary for track lookups. Update OpenVINO model usage and remove unused models. Enhance logging for better visibility.
afa7690 | import base64 | |
| import io | |
| import logging | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import time | |
| import uuid | |
| from pathlib import Path | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s %(levelname)s %(message)s", | |
| datefmt="%H:%M:%S", | |
| ) | |
| log = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # Install chatterbox-tts with --no-deps BEFORE any other import. | |
| # --------------------------------------------------------------------------- | |
| try: | |
| import chatterbox # noqa: F401 | |
| except ImportError: | |
| log.info("[boot] Installing chatterbox-tts --no-deps …") | |
| subprocess.run( | |
| [sys.executable, "-m", "pip", "install", "--no-deps", "--quiet", "chatterbox-tts"], | |
| check=True, | |
| ) | |
| log.info("[boot] chatterbox-tts installed.") | |
| from gradio import Server | |
| from fastapi import Request, BackgroundTasks | |
| from fastapi.responses import HTMLResponse, JSONResponse, FileResponse | |
| from commentary import STYLES, VIBE_TO_STYLE, generate_commentary | |
| from events import events_to_frames, pick_key_events | |
| from faces import annotate_event_frame, frame_at, scan_video, write_names_to_jsonl | |
| from ov_models import download_models | |
| from tts import generate_script_audio | |
| from video import get_duration, normalize_video | |
| MAX_SECONDS = 60.0 | |
| HERE = Path(__file__).parent | |
| SESSIONS: dict[str, dict] = {} | |
| TIERS = ["HEADLINER", "RISING", "DIVA", "WILDCARD", "BREAKOUT", "CAMEO"] | |
| SUPPORTED_VIDEO = {".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"} | |
| log.info("[boot] Downloading OpenVINO models if needed…") | |
| try: | |
| download_models() | |
| log.info("[boot] OpenVINO models ready.") | |
| except Exception as e: | |
| log.warning(f"[boot] OV model download failed: {e}") | |
| def _crop_b64(pil, size=160): | |
| if pil is None: | |
| return None | |
| 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: | |
| doc = (HERE / "Marquee.html").read_text() | |
| css = (HERE / "marquee.css").read_text() | |
| js = (HERE / "marquee.js").read_text() | |
| doc = doc.replace('<link rel="stylesheet" href="marquee.css">', f"<style>{css}</style>") | |
| doc = doc.replace('<script src="marquee.js"></script>', f"<script type='module'>{js}</script>") | |
| return doc | |
| app = Server() | |
| _UI_HTML = _build_ui() | |
| log.info("[boot] UI built and ready.") | |
| async def root(): | |
| return HTMLResponse(_UI_HTML) | |
| async def api_scan(request: Request): | |
| t0 = time.time() | |
| log.info("[scan] Request received") | |
| 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() | |
| log.info(f"[scan] File: {upload.filename} ({suffix})") | |
| 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) | |
| data = await upload.read() | |
| tmp.write(data); tmp.flush(); tmp.close() | |
| log.info(f"[scan] Upload saved: {len(data)//1024}KB → {tmp.name}") | |
| dur = get_duration(tmp.name) | |
| log.info(f"[scan] Duration: {dur:.1f}s") | |
| if dur > MAX_SECONDS + 1: | |
| return JSONResponse({"ok": False, "error": "Clip longer than 60 seconds"}) | |
| log.info("[scan] Normalising video (ffmpeg)…") | |
| t1 = time.time() | |
| norm_path, w, h = normalize_video(tmp.name) | |
| log.info(f"[scan] Normalised in {time.time()-t1:.1f}s → {w}x{h}") | |
| log.info("[scan] Running person tracking scan…") | |
| tracks, motion, meta, jsonl_path = scan_video(norm_path, MAX_SECONDS) | |
| log.info(f"[scan] Tracking done — {len(tracks)} person(s) found") | |
| sid = uuid.uuid4().hex | |
| SESSIONS[sid] = { | |
| "norm": norm_path, | |
| "tracks": tracks, | |
| "jsonl_path": jsonl_path, | |
| "motion": motion, | |
| "meta": meta, | |
| } | |
| log.info(f"[scan] Session created: {sid}") | |
| stars = [ | |
| {"idx": trk["id"], "tier": TIERS[i % len(TIERS)], | |
| "crop": _crop_b64(trk["crop"])} | |
| for i, trk in enumerate(tracks) | |
| ] | |
| log.info(f"[scan] Completed in {time.time()-t0:.1f}s total") | |
| 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() | |
| log.error(f"[scan] FAILED: {e}") | |
| return JSONResponse({"ok": False, "error": str(e)}, status_code=500) | |
| async def serve_video(sid: str): | |
| log.info(f"[video] Serving video for session {sid}") | |
| sess = SESSIONS.get(sid) | |
| if not sess: | |
| log.warning(f"[video] Session not found: {sid}") | |
| return JSONResponse({"error": "session not found"}, status_code=404) | |
| return FileResponse(sess["norm"], media_type="video/mp4", | |
| headers={"Accept-Ranges": "bytes"}) | |
| async def api_tts(request: Request): | |
| t0 = time.time() | |
| log.info("[tts] Request received") | |
| try: | |
| body = await request.json() | |
| sid = body.get("session_id", "") | |
| vibe = body.get("vibe", "football") | |
| script = body.get("script", []) | |
| voice_model = body.get("voice_model", "chatterbox") | |
| log.info(f"[tts] sid={sid} vibe={vibe} voice={voice_model} lines={len(script)}") | |
| if not script: | |
| return JSONResponse({"ok": False, "error": "empty script"}, status_code=400) | |
| if sid and sid not in SESSIONS: | |
| return JSONResponse({"ok": False, "error": "session not found"}, status_code=404) | |
| log.info(f"[tts] Generating {len(script)} lines with {voice_model}…") | |
| lines = generate_script_audio(script, vibe, voice_model=voice_model) | |
| audio_count = sum(1 for l in lines if l.get("audio_b64")) | |
| log.info(f"[tts] Done in {time.time()-t0:.1f}s — " | |
| f"{audio_count}/{len(lines)} lines have audio") | |
| return JSONResponse({"ok": True, "lines": lines}) | |
| except Exception as e: | |
| import traceback; traceback.print_exc() | |
| log.error(f"[tts] FAILED: {e}") | |
| return JSONResponse({"ok": False, "error": str(e)}, status_code=500) | |
| async def api_export(request: Request, background_tasks: BackgroundTasks): | |
| """Burn video + TTS audio + soft subtitle captions into one MP4. | |
| FIX: tmpdir is cleaned up via BackgroundTasks AFTER the response is | |
| fully streamed — prevents the race condition where FileResponse tries | |
| to read a file that Python GC already deleted. | |
| """ | |
| import wave as _wave | |
| t0 = time.time() | |
| log.info("[export] Request received") | |
| tmpdir = None | |
| try: | |
| body = await request.json() | |
| sid = body.get("session_id", "") | |
| vibe = body.get("vibe", "") | |
| lines = body.get("lines", []) | |
| log.info(f"[export] sid={sid} vibe={vibe} lines={len(lines)}") | |
| sess = SESSIONS.get(sid) | |
| if not sess: | |
| return JSONResponse({"ok": False, "error": "session not found"}, status_code=404) | |
| if not lines: | |
| return JSONResponse({"ok": False, "error": "no lines"}, status_code=400) | |
| video_path = sess["norm"] | |
| tmpdir = tempfile.mkdtemp(prefix="mq_export_") | |
| log.info(f"[export] Working dir: {tmpdir}") | |
| # 1. Write TTS WAV files | |
| log.info("[export] Writing TTS WAV files…") | |
| audio_clips = [] | |
| for i, line in enumerate(lines): | |
| b64 = line.get("audio_b64") | |
| if not b64: | |
| continue | |
| wav_bytes = base64.b64decode(b64) | |
| wav_path = os.path.join(tmpdir, f"tts_{i:03d}.wav") | |
| with open(wav_path, "wb") as f: | |
| f.write(wav_bytes) | |
| with _wave.open(io.BytesIO(wav_bytes)) as wf: | |
| dur = wf.getnframes() / wf.getframerate() | |
| audio_clips.append({"path": wav_path, "t": float(line["t"]), "duration": dur}) | |
| log.info(f"[export] {len(audio_clips)} audio clips written") | |
| # 2. Build SRT subtitle file | |
| log.info("[export] Building subtitle file…") | |
| total_dur = sess["meta"]["n_frames"] / sess["meta"]["fps"] | |
| vibe_label = { | |
| "football": "COCO", "diva": "COCO", "wildlife": "COCO", | |
| "boxing": "COCO", "masterchef": "COCO", | |
| }.get(vibe, "COCO") | |
| def _srt_time(s): | |
| h=int(s//3600); m=int((s%3600)//60); sec=s%60 | |
| ms=int((sec%1)*1000); sec=int(sec) | |
| return f"{h:02d}:{m:02d}:{sec:02d},{ms:03d}" | |
| srt_rows = [] | |
| for i, line in enumerate(lines): | |
| t_start = float(line["t"]) | |
| t_end = float(lines[i+1]["t"]) if i+1<len(lines) else min(t_start+4, total_dur) | |
| srt_rows += [ | |
| str(i+1), | |
| f"{_srt_time(t_start)} --> {_srt_time(t_end)}", | |
| f"{vibe_label} {line['text']}", | |
| "", | |
| ] | |
| srt_path = os.path.join(tmpdir, "subs.srt") | |
| with open(srt_path, "w", encoding="utf-8") as f: | |
| f.write("\n".join(srt_rows)) | |
| log.info(f"[export] SRT written: {len(lines)} cues") | |
| # 3. Build ffmpeg command | |
| out_path = os.path.join(tmpdir, "broadcast.mp4") | |
| n = len(audio_clips) | |
| cmd = ["ffmpeg", "-y", "-i", video_path, "-i", srt_path] | |
| for clip in audio_clips: | |
| cmd += ["-i", clip["path"]] | |
| if n > 0: | |
| parts, delayed = [], [] | |
| for idx, clip in enumerate(audio_clips): | |
| delay_ms = int(clip["t"] * 1000) | |
| lbl = f"d{idx}" | |
| cmd_idx = idx + 2 # 0=video, 1=srt, 2..=wavs | |
| parts.append(f"[{cmd_idx}:a]adelay={delay_ms}|{delay_ms},apad[{lbl}]") | |
| delayed.append(f"[{lbl}]") | |
| parts.append(f"{''.join(delayed)}amix=inputs={n}:normalize=0[commentary]") | |
| parts.append("[0:a]volume=0.25[orig];[orig][commentary]amix=inputs=2:normalize=0[mixed]") | |
| cmd += [ | |
| "-filter_complex", ";".join(parts), | |
| "-map", "0:v", | |
| "-map", "[mixed]", | |
| "-map", "1:s", | |
| "-c:v", "copy", # stream-copy video — no re-encode | |
| "-c:a", "aac", "-b:a", "128k", | |
| "-c:s", "mov_text", | |
| "-movflags", "+faststart", | |
| out_path, | |
| ] | |
| else: | |
| cmd += [ | |
| "-map", "0:v", "-map", "0:a", "-map", "1:s", | |
| "-c:v", "copy", "-c:a", "copy", | |
| "-c:s", "mov_text", | |
| "-movflags", "+faststart", | |
| out_path, | |
| ] | |
| log.info(f"[export] Running ffmpeg (n_audio={n})…") | |
| log.info(f"[export] CMD: {' '.join(cmd)}") | |
| t1 = time.time() | |
| res = subprocess.run(cmd, capture_output=True) | |
| log.info(f"[export] ffmpeg finished in {time.time()-t1:.1f}s, " | |
| f"returncode={res.returncode}") | |
| if res.returncode != 0: | |
| err = res.stderr.decode(errors="replace")[-1200:] | |
| log.error(f"[export] ffmpeg STDERR:\n{err}") | |
| return JSONResponse({"ok": False, "error": f"ffmpeg: {err}"}, status_code=500) | |
| out_size_mb = os.path.getsize(out_path) / 1024 / 1024 | |
| log.info(f"[export] Output: {out_path} ({out_size_mb:.1f}MB)") | |
| log.info(f"[export] Total time: {time.time()-t0:.1f}s") | |
| vibe_slug = vibe or "broadcast" | |
| fname = f"marquee_{vibe_slug}.mp4" | |
| # KEY FIX: schedule tmpdir cleanup as a background task so it runs | |
| # AFTER the file has been fully streamed to the client. | |
| # Previously tmpdir could be cleaned up mid-stream causing download to hang. | |
| def _cleanup(d: str): | |
| try: | |
| shutil.rmtree(d, ignore_errors=True) | |
| log.info(f"[export] Cleaned up tmpdir: {d}") | |
| except Exception as ex: | |
| log.warning(f"[export] Cleanup failed: {ex}") | |
| background_tasks.add_task(_cleanup, tmpdir) | |
| return FileResponse( | |
| out_path, | |
| media_type="video/mp4", | |
| filename=fname, | |
| headers={"Content-Disposition": f'attachment; filename="{fname}"'}, | |
| ) | |
| except Exception as e: | |
| import traceback; traceback.print_exc() | |
| log.error(f"[export] FAILED: {e}") | |
| if tmpdir: | |
| shutil.rmtree(tmpdir, ignore_errors=True) | |
| return JSONResponse({"ok": False, "error": str(e)}, status_code=500) | |
| def generate(session_id: str, names: list, vibe: str) -> dict: | |
| t0 = time.time() | |
| log.info(f"[generate] session={session_id} vibe={vibe} names={names}") | |
| sess = SESSIONS.get(session_id) | |
| if not sess: | |
| log.error(f"[generate] Session not found: {session_id}") | |
| return {"ok": False, "error": "Session expired — re-cast"} | |
| tracks = sess.get("tracks", []) | |
| id_to_name = {} | |
| for i, trk in enumerate(tracks): | |
| raw = str(names[i]).strip() if i < len(names) else "" | |
| if raw: | |
| id_to_name[trk["id"]] = raw | |
| log.info(f"[generate] Named tracks: {id_to_name}") | |
| if id_to_name: | |
| write_names_to_jsonl(sess["jsonl_path"], id_to_name) | |
| 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) | |
| log.info(f"[generate] Key events: {event_times}") | |
| log.info("[generate] Annotating keyframes…") | |
| frames = [] | |
| for ts, fidx in zip(event_times, event_idxs): | |
| bgr = frame_at(sess["norm"], fidx) | |
| if bgr is None: | |
| log.warning(f"[generate] frame_at({fidx}) returned None, skipping") | |
| continue | |
| frames.append((ts, annotate_event_frame(bgr, sess["jsonl_path"], ts))) | |
| log.info(f"[generate] {len(frames)} annotated frames ready for VLM") | |
| log.info(f"[generate] Calling Qwen VLM (style={style})…") | |
| t1 = time.time() | |
| roster = list(id_to_name.values()) | |
| script = generate_commentary(frames, roster, style, vibe=vibe) | |
| log.info(f"[generate] VLM done in {time.time()-t1:.1f}s — {len(script)} lines") | |
| log.info(f"[generate] Total: {time.time()-t0:.1f}s") | |
| return {"ok": True, | |
| "script": [{"t": round(float(l["time"]),2), "text": l["text"]} | |
| for l in script]} | |
| app.launch(ssr_mode=False, show_error=True) |