MoburstAI commited on
Commit
a8ae6d3
·
verified ·
1 Parent(s): a5fa6d8

Add analyze_video_async: background analysis with callback to AdVisor, single GPU lock

Browse files
Files changed (1) hide show
  1. app.py +108 -1
app.py CHANGED
@@ -9,9 +9,11 @@ import os
9
  import subprocess
10
  import sys
11
  import tempfile
 
12
  import time
13
  import traceback
14
  from pathlib import Path
 
15
 
16
  # Disable hf_transfer — uvx sandboxes don't have it
17
  os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "0"
@@ -464,8 +466,18 @@ def trim_video(input_path: str, max_seconds: int = MAX_DURATION) -> str:
464
  return input_path
465
 
466
 
 
 
 
 
 
467
  def analyze_video(video_url: str, filename: str = "video.mp4", ad_context_json: str = "{}") -> str:
468
- """Main analysis — uses full TRIBE v2 pipeline with WhisperX on GPU."""
 
 
 
 
 
469
  try:
470
  # Parse ad context for tailored insights
471
  try:
@@ -567,6 +579,84 @@ def analyze_video(video_url: str, filename: str = "video.mp4", ad_context_json:
567
  return json.dumps({"error": error_msg, "traceback": traceback.format_exc()})
568
 
569
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
570
  app = gr.Blocks(title="TRIBE v2 Neural Engagement Analyzer")
571
  with app:
572
  gr.Markdown("# TRIBE v2 Neural Engagement Analyzer")
@@ -578,6 +668,23 @@ with app:
578
  analyze_btn = gr.Button("Analyze", variant="primary")
579
  output = gr.JSON(label="Neural Engagement Report")
580
  analyze_btn.click(fn=analyze_video, inputs=[video_url_input, filename_input, ad_context_input], outputs=output)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581
  app.queue()
582
  if __name__ == "__main__":
583
  app.launch()
 
9
  import subprocess
10
  import sys
11
  import tempfile
12
+ import threading
13
  import time
14
  import traceback
15
  from pathlib import Path
16
+ from urllib.parse import urlparse
17
 
18
  # Disable hf_transfer — uvx sandboxes don't have it
19
  os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "0"
 
466
  return input_path
467
 
468
 
469
+ # One analysis at a time on the GPU, whether it came from the UI/sync API or
470
+ # from the async queue below. Threads waiting here are the queue.
471
+ ANALYSIS_LOCK = threading.Lock()
472
+
473
+
474
  def analyze_video(video_url: str, filename: str = "video.mp4", ad_context_json: str = "{}") -> str:
475
+ """Main analysis — uses full TRIBE v2 pipeline with WhisperX on GPU. Serialized."""
476
+ with ANALYSIS_LOCK:
477
+ return _analyze_video_locked(video_url, filename, ad_context_json)
478
+
479
+
480
+ def _analyze_video_locked(video_url: str, filename: str = "video.mp4", ad_context_json: str = "{}") -> str:
481
  try:
482
  # Parse ad context for tailored insights
483
  try:
 
579
  return json.dumps({"error": error_msg, "traceback": traceback.format_exc()})
580
 
581
 
582
+ # ---------------------------------------------------------------------------
583
+ # Async API for AdVisor: accept a job, return at once, analyse in the
584
+ # background, POST the report to the caller's callback URL.
585
+ #
586
+ # Only hosts listed in ADVISOR_CALLBACK_HOSTS (comma-separated, default: the
587
+ # AdVisor Supabase project) may receive callbacks, so this public endpoint
588
+ # cannot be pointed at arbitrary servers. The caller supplies job_ref and
589
+ # job_token; both are echoed back and the caller verifies the token, so no
590
+ # shared secret has to live in this Space.
591
+ # ---------------------------------------------------------------------------
592
+ ALLOWED_CALLBACK_HOSTS = {
593
+ h.strip().lower()
594
+ for h in os.environ.get("ADVISOR_CALLBACK_HOSTS", "emyiuzsgvnnzgtqwmttb.supabase.co").split(",")
595
+ if h.strip()
596
+ }
597
+ CALLBACK_RETRIES = 4
598
+
599
+
600
+ def _post_callback(callback_url: str, payload: dict) -> bool:
601
+ delay = 5
602
+ for attempt in range(1, CALLBACK_RETRIES + 1):
603
+ try:
604
+ resp = requests.post(
605
+ callback_url,
606
+ json=payload,
607
+ headers={"Content-Type": "application/json", "X-Job-Token": payload.get("job_token", "")},
608
+ timeout=60,
609
+ )
610
+ if resp.ok:
611
+ print(f"[async] callback delivered for {payload.get('job_ref')} ({resp.status_code})")
612
+ return True
613
+ print(f"[async] callback HTTP {resp.status_code} for {payload.get('job_ref')}: {resp.text[:200]}")
614
+ if 400 <= resp.status_code < 500 and resp.status_code != 429:
615
+ return False # our payload is wrong; retrying will not help
616
+ except Exception as e: # network
617
+ print(f"[async] callback attempt {attempt} failed: {e}")
618
+ time.sleep(delay)
619
+ delay = min(delay * 2, 60)
620
+ return False
621
+
622
+
623
+ def _run_async_job(video_url: str, filename: str, ad_context_json: str, callback_url: str, job_ref: str, job_token: str):
624
+ started = time.time()
625
+ try:
626
+ raw = analyze_video(video_url, filename, ad_context_json)
627
+ report = json.loads(raw)
628
+ if isinstance(report, dict) and report.get("error"):
629
+ payload = {"job_ref": job_ref, "job_token": job_token, "status": "failed", "error": str(report.get("error"))[:1000]}
630
+ else:
631
+ payload = {"job_ref": job_ref, "job_token": job_token, "status": "complete", "report": report}
632
+ except Exception as e: # never let a job die silently
633
+ payload = {"job_ref": job_ref, "job_token": job_token, "status": "failed", "error": f"{type(e).__name__}: {e}"[:1000]}
634
+ payload["elapsed_seconds"] = round(time.time() - started, 1)
635
+ _post_callback(callback_url, payload)
636
+
637
+
638
+ def analyze_video_async(video_url: str, filename: str, ad_context_json: str, callback_url: str, job_ref: str, job_token: str) -> str:
639
+ """Queue an analysis and return immediately. The report arrives at callback_url."""
640
+ try:
641
+ host = (urlparse(callback_url).hostname or "").lower()
642
+ if urlparse(callback_url).scheme != "https" or host not in ALLOWED_CALLBACK_HOSTS:
643
+ return json.dumps({"accepted": False, "error": f"callback host not allowed: {host or '(none)'}"})
644
+ if not video_url or not job_ref or not job_token:
645
+ return json.dumps({"accepted": False, "error": "video_url, job_ref and job_token are required"})
646
+ t = threading.Thread(
647
+ target=_run_async_job,
648
+ args=(video_url, filename or "video.mp4", ad_context_json or "{}", callback_url, job_ref, job_token),
649
+ daemon=True,
650
+ name=f"tribe-async-{job_ref[:12]}",
651
+ )
652
+ t.start()
653
+ waiting = sum(1 for th in threading.enumerate() if th.name.startswith("tribe-async-"))
654
+ print(f"[async] accepted job {job_ref} ({waiting} in flight)")
655
+ return json.dumps({"accepted": True, "job_ref": job_ref, "in_flight": waiting})
656
+ except Exception as e:
657
+ return json.dumps({"accepted": False, "error": f"{type(e).__name__}: {e}"})
658
+
659
+
660
  app = gr.Blocks(title="TRIBE v2 Neural Engagement Analyzer")
661
  with app:
662
  gr.Markdown("# TRIBE v2 Neural Engagement Analyzer")
 
668
  analyze_btn = gr.Button("Analyze", variant="primary")
669
  output = gr.JSON(label="Neural Engagement Report")
670
  analyze_btn.click(fn=analyze_video, inputs=[video_url_input, filename_input, ad_context_input], outputs=output)
671
+
672
+ # Hidden controls back the async API (gradio_api/call/analyze_video_async).
673
+ with gr.Row(visible=False):
674
+ async_video_url = gr.Textbox(label="video_url")
675
+ async_filename = gr.Textbox(label="filename", value="video.mp4")
676
+ async_ad_context = gr.Textbox(label="ad_context_json", value="{}")
677
+ async_callback_url = gr.Textbox(label="callback_url")
678
+ async_job_ref = gr.Textbox(label="job_ref")
679
+ async_job_token = gr.Textbox(label="job_token")
680
+ async_btn = gr.Button("Queue")
681
+ async_out = gr.Textbox(label="ack")
682
+ async_btn.click(
683
+ fn=analyze_video_async,
684
+ inputs=[async_video_url, async_filename, async_ad_context, async_callback_url, async_job_ref, async_job_token],
685
+ outputs=async_out,
686
+ api_name="analyze_video_async",
687
+ )
688
  app.queue()
689
  if __name__ == "__main__":
690
  app.launch()