Spaces:
Running on T4
Running on T4
Add analyze_video_async: background analysis with callback to AdVisor, single GPU lock
Browse files
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()
|