Spaces:
Running on T4
Running on T4
File size: 47,547 Bytes
7d1d2c9 77c97fe 7d1d2c9 2c73e57 7d1d2c9 a8ae6d3 7d1d2c9 2c73e57 7d1d2c9 a8ae6d3 7d1d2c9 c99eb91 6e48ea5 7d1d2c9 c99eb91 7d1d2c9 c99eb91 2c73e57 6e48ea5 1459d2a ed00044 1459d2a 6e48ea5 1459d2a 6e48ea5 1459d2a 6e48ea5 1459d2a 6a629c2 1459d2a 6e48ea5 1459d2a 6e48ea5 1459d2a ed00044 76266ba ed00044 76266ba ed00044 6e48ea5 1459d2a 6e48ea5 7d1d2c9 2c73e57 7d1d2c9 2c73e57 7d1d2c9 2c73e57 7d1d2c9 2c73e57 7d1d2c9 2c73e57 7d1d2c9 2c73e57 7d1d2c9 c99eb91 f606156 c99eb91 7d1d2c9 c99eb91 7d1d2c9 c99eb91 7d1d2c9 6e48ea5 25c26a7 6e48ea5 c99eb91 7d1d2c9 c64d80c 7d1d2c9 c64d80c c99eb91 7d1d2c9 c99eb91 7d1d2c9 23e3186 7d1d2c9 23e3186 7d1d2c9 c99eb91 23e3186 a5fa6d8 13b1120 a5fa6d8 7d1d2c9 13b1120 23e3186 7d1d2c9 13b1120 a5fa6d8 13b1120 a5fa6d8 13b1120 a5fa6d8 13b1120 a5fa6d8 13b1120 a5fa6d8 13b1120 23e3186 b90c03f 13b1120 23e3186 13b1120 23e3186 b90c03f 13b1120 23e3186 13b1120 b90c03f 13b1120 b90c03f 13b1120 b90c03f 13b1120 b90c03f 13b1120 a5fa6d8 b90c03f 13b1120 b90c03f 13b1120 23e3186 13b1120 a5fa6d8 23e3186 13b1120 23e3186 13b1120 a5fa6d8 23e3186 13b1120 23e3186 7d1d2c9 c99eb91 b2bde86 1459d2a c66c3e7 f606156 c66c3e7 1459d2a f606156 c66c3e7 f606156 1459d2a c66c3e7 a8ae6d3 a5fa6d8 a8ae6d3 2c73e57 a5fa6d8 2c73e57 dc48354 2c73e57 c99eb91 2c73e57 c66c3e7 77c97fe 6e48ea5 77c97fe 6e48ea5 77c97fe 2c73e57 c99eb91 2c73e57 c99eb91 2c73e57 23e3186 a5fa6d8 2c73e57 23e3186 6e48ea5 23e3186 2c73e57 c99eb91 2c73e57 7d1d2c9 c99eb91 a8ae6d3 7d1d2c9 2c73e57 7d1d2c9 2c73e57 a5fa6d8 7d1d2c9 a5fa6d8 a8ae6d3 7d1d2c9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 | """
TRIBE v2 Neural Engagement Analyzer β HuggingFace Space
Runs on T4 GPU with full WhisperX transcription pipeline.
Falls back to audio-only mode if WhisperX fails.
"""
import json
import os
import subprocess
import sys
import tempfile
import threading
import time
import traceback
from pathlib import Path
from urllib.parse import urlparse
# Disable hf_transfer β uvx sandboxes don't have it
os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "0"
# Persistent storage, when the Space has it: the WhisperX environment (uvx),
# the whisper large-v3 weights and every HF model then survive a restart
# instead of being downloaded again on each cold start.
if os.path.isdir("/data") and os.access("/data", os.W_OK):
os.environ.setdefault("UV_CACHE_DIR", "/data/uv-cache")
os.environ.setdefault("HF_HOME", "/data/hf-home")
for _d in (os.environ["UV_CACHE_DIR"], os.environ["HF_HOME"]):
os.makedirs(_d, exist_ok=True)
print("Persistent /data found: uv and HF caches live there")
import gradio as gr
import numpy as np
import pandas as pd
import requests
# Set HF_TOKEN
hf_token = os.environ.get("HF_TOKEN", "")
if hf_token:
os.environ["HUGGING_FACE_HUB_TOKEN"] = hf_token
print("HF token configured")
import torch
CUDA = torch.cuda.is_available()
print(f"torch {torch.__version__} | cuda={CUDA}" + (f" | {torch.cuda.get_device_name(0)} | {torch.cuda.get_device_properties(0).total_memory / 2**30:.1f} GiB" if CUDA else ""))
if CUDA:
torch.backends.cudnn.benchmark = True
# Frames are cached per video as uint8 arrays; at 16 fps a 60 s ad is ~960
# frames of 320x568, about half a GiB, well within the Space's RAM.
MAX_CACHED_FRAMES = 2400
# Clips per VJEPA2 forward. Each clip's fp16 hidden states are ~0.9 GiB (40
# layers x 8192 tokens x 1408); two fit comfortably next to the fp32 weights
# and the fp16 Llama on a 16 GiB T4. Raise on a bigger GPU.
CLIP_BATCH = int(os.environ.get("TRIBE_CLIP_BATCH", "2"))
def _patch_video_pipeline() -> None:
"""Two fixes to neuralset's HuggingFaceVideo path, measured on the T4.
1. Frame decoding. For every 4 s clip the extractor asked moviepy for 64
frames by timestamp, and each get_frame() seeks and decodes through
ffmpeg on the CPU: ~13 s per clip, the whole cost of "Encoding video".
We decode each video once, sequentially, into memory and serve frames
by index. Same frames, same PIL output, no per-frame seeks.
2. The VJEPA2 (ViT-Giant) forward ran in fp32. Under fp16 autocast the T4's
tensor cores take it; hidden states are cast back to fp32 so nothing
downstream sees a dtype change.
"""
try:
from neuralset.extractors import video as ns_video
except Exception as e: # pragma: no cover
print(f"[perf] could not import neuralset.extractors.video: {e}")
return
import PIL.Image
from tqdm import tqdm
frame_cache: dict[int, tuple[np.ndarray, float]] = {}
original_read = ns_video._VideoImage._read
def _read_cached(self):
video = self.video
key = id(video)
entry = frame_cache.get(key)
if entry is None:
n_est = int(round(float(video.duration) * float(video.fps))) + 1
if n_est > MAX_CACHED_FRAMES:
return original_read(self)
t0 = time.time()
frames = np.stack([f.astype("uint8", copy=False) for f in video.iter_frames(dtype="uint8")])
frame_cache.clear() # one video at a time under the analysis lock
entry = (frames, float(video.fps))
frame_cache[key] = entry
print(f"[perf] decoded {len(frames)} frames @ {video.fps:.1f} fps once in {time.time() - t0:.1f}s ({frames.nbytes / 2**20:.0f} MiB)")
frames, fps = entry
idx = min(len(frames) - 1, max(0, int(round(float(self.time) * fps))))
return PIL.Image.fromarray(frames[idx])
ns_video._VideoImage._read = _read_cached
if CUDA:
original_predict = ns_video._HFVideoModel.predict
def _predict_fp16(self, images, audio=None):
if getattr(self.model, "device", torch.device("cpu")).type != "cuda":
return original_predict(self, images, audio)
with torch.autocast("cuda", dtype=torch.float16):
pred = original_predict(self, images, audio)
hs = getattr(pred, "hidden_states", None)
if hs is not None:
pred.hidden_states = tuple(h.float() for h in hs)
return pred
ns_video._HFVideoModel.predict = _predict_fp16
# Image-model path (DINOv2 for image_feature), same idea.
try:
from neuralset.extractors import image as ns_image
original_hidden = ns_image.HuggingFaceImage._get_hidden_states
def _fp16_hidden_states(self, images):
if CUDA and str(self.device).startswith("cuda"):
with torch.autocast("cuda", dtype=torch.float16):
states = original_hidden(self, images)
return [st.float() for st in states]
return original_hidden(self, images)
ns_image.HuggingFaceImage._get_hidden_states = _fp16_hidden_states
except Exception as e: # pragma: no cover
print(f"[perf] image extractor patch skipped: {e}")
# 3. Several clips per GPU forward. neuralset encodes one 4 s clip per
# forward, so the T4 idles while the next clip's 64 frames are gathered
# and preprocessed on the CPU. We gather CLIP_BATCH clips, run one
# forward, and aggregate per clip exactly as the original did (token
# aggregation in fp32 on one clip at a time, so peak memory stays that
# of a single clip's hidden states).
def _forward_clips(model, clips):
"""clips: list of (num_frames, H, W, 3) uint8 arrays -> list of (L, tokens, F) fp16 tensors."""
kwargs = {"return_tensors": "pt", "videos": [list(c) for c in clips]}
inputs = model.processor(**kwargs)
ns_video._fix_pixel_values(inputs)
inputs = inputs.to(model.model.device)
with torch.inference_mode():
if CUDA and model.model.device.type == "cuda":
with torch.autocast("cuda", dtype=torch.float16):
pred = model.model(**inputs)
else:
pred = model.model(**inputs)
states = pred.hidden_states # tuple of L tensors, each (B, tokens, F)
out = []
for b in range(len(clips)):
out.append(torch.stack([st[b] for st in states])) # (L, tokens, F)
return out
# The loader asks for the same video's features more than once per job
# (one call per segment it builds). exca's disk cache used to make the
# repeats free; this memo does the same in memory, for the current video.
feature_memo: dict[tuple, "ns_video.nsbase.TimedArray"] = {}
def _get_data_batched(self, events):
if "vjepa2" not in self.image.model_name:
yield from _get_data_original(self, events)
return
pending = []
for event in events:
key = (str(event.filepath), round(float(getattr(event, "start", 0.0) or 0.0), 3), round(float(event.duration or 0.0), 3))
hit = feature_memo.get(key)
if hit is not None:
print(f"[perf] video features served from memo for {key[0].rsplit('/', 1)[-1]} ({key[1]}s+{key[2]}s)")
yield hit
else:
pending.append((key, event))
if not pending:
return
for key, ta in zip([k for k, _ in pending], _encode_events(self, [e for _, e in pending])):
if any(k[0] != key[0] for k in feature_memo):
feature_memo.clear() # a new video: drop the previous one's features
feature_memo[key] = ta
yield ta
def _encode_events(self, events):
model = ns_video._HFVideoModel(
model_name=self.image.model_name, pretrained=self.image.pretrained,
layer_type=self.layer_type, num_frames=self.num_frames,
)
if model.model.device.type == "cpu":
model.model.to(self.image.device)
freq = events[0].frequency if self.frequency == "native" else self.frequency
T = 1 / freq if self.clip_duration is None else self.clip_duration
subtimes = [k / model.num_frames * T for k in reversed(range(model.num_frames))]
for event in events:
video = event.read()
freq = self.frequency if self.frequency != "native" else event.frequency
expect_frames = ns_video.nsbase.Frequency(freq).to_ind(event.duration)
times = np.linspace(0, video.duration, expect_frames + 1)[1:]
output = None
t_start = time.time()
for start in tqdm(range(0, len(times), CLIP_BATCH), desc=f"Encoding video (x{CLIP_BATCH})"):
chunk = times[start:start + CLIP_BATCH]
clips = []
for t in chunk:
ims = [ns_video._VideoImage(video=video, time=max(0, t - t2)) for t2 in subtimes]
pil_imgs = [i.read() for i in ims]
if pil_imgs and self.max_imsize is not None:
factor = max(pil_imgs[0].size) / self.max_imsize
if factor > 1:
size = tuple(int(sz / factor) for sz in pil_imgs[0].size)
pil_imgs = [pi.resize(size) for pi in pil_imgs]
clips.append(np.array([np.array(pi) for pi in pil_imgs]))
for k, t_embd in zip(range(start, start + len(chunk)), _forward_clips(model, clips)):
embd = self.image._aggregate_tokens(t_embd.float()).cpu().numpy()
if not self.image.cache_all_layers and self.image.cache_n_layers is None:
embd = self.image._aggregate_layers(embd)
if output is None:
output = np.zeros((len(times),) + embd.shape)
output[k] = embd
del clips
video.close()
print(f"[perf] encoded {len(times)} clips in {time.time() - t_start:.0f}s ({(time.time() - t_start) / max(1, len(times)):.2f}s/clip, batch {CLIP_BATCH})")
output = output.transpose(list(range(1, output.ndim)) + [0])
yield ns_video.nsbase.TimedArray(
data=output.astype(np.float32), frequency=freq,
start=ns_video.nsbase._UNSET_START, duration=event.duration,
)
# exca's infra.apply wraps _get_data with a per-event feature cache. That
# cache is built for re-running studies over the same stimuli; every ad here
# is analysed once, so the wrapper buys nothing and the method is replaced
# outright. The wrapped original is kept for any non-VJEPA2 model.
_get_data_original = ns_video.HuggingFaceVideo._get_data
ns_video.HuggingFaceVideo._get_data = _get_data_batched
patched_loop = True
print(f"[perf] video pipeline patched: cached frame decoding, fp16 autocast={'on' if CUDA else 'off'}, clip batching={'x%d' % CLIP_BATCH if patched_loop else 'off'}")
def _patch_whisperx_language() -> None:
"""Let WhisperX detect the language instead of the library's hard-coded English.
Ads are short and speech starts early, so Whisper's detection on the first
30 s is reliable here; forcing English turns Italian or Hebrew copy into
garbage words, and garbage words become garbage text features.
Set TRIBE_WHISPER_LANGUAGE=english (or another supported name) to force one.
"""
try:
from tribev2 import eventstransforms as et
except Exception as e: # pragma: no cover
print(f"[whisperx] could not import tribev2.eventstransforms: {e}")
return
forced = os.environ.get("TRIBE_WHISPER_LANGUAGE", "auto").strip().lower()
original = et.ExtractWordsFromAudio._get_transcript_from_audio
def _transcribe(wav_filename, language):
import json as _json
import subprocess as _sp
import tempfile as _tf
if forced != "auto":
return original(wav_filename, forced)
device = "cuda" if torch.cuda.is_available() else "cpu"
with _tf.TemporaryDirectory() as output_dir:
cmd = [
"uvx", "whisperx", str(wav_filename),
"--model", "large-v3",
"--device", device,
"--compute_type", "float16" if device == "cuda" else "int8",
"--batch_size", "16",
"--output_dir", output_dir,
"--output_format", "json",
]
env = {k: v for k, v in os.environ.items() if k != "MPLBACKEND"}
t0 = time.time()
result = _sp.run(cmd, capture_output=True, text=True, env=env)
if result.returncode != 0:
raise RuntimeError(f"whisperx failed:\n{result.stderr[-3000:]}")
json_path = Path(output_dir) / f"{Path(wav_filename).stem}.json"
transcript = _json.loads(json_path.read_text())
detected = transcript.get("language", "?")
n_words = sum(len(seg.get("words", [])) for seg in transcript.get("segments", []))
print(f"[whisperx] language={detected} words={n_words} in {time.time() - t0:.0f}s")
words = []
for i, segment in enumerate(transcript.get("segments", [])):
sentence = segment.get("text", "").replace('"', "")
for word in segment.get("words", []):
if "start" not in word or "end" not in word:
continue
words.append({
"text": word["word"].replace('"', ""),
"start": word["start"],
"duration": word["end"] - word["start"],
"sequence_id": i,
"sentence": sentence,
})
return pd.DataFrame(words)
et.ExtractWordsFromAudio._get_transcript_from_audio = staticmethod(_transcribe)
print(f"[whisperx] language mode: {forced}")
def _prewarm_whisperx() -> None:
"""Build the uvx whisperx environment once at startup so the first job does not pay for it."""
def run():
try:
t0 = time.time()
r = subprocess.run(["uvx", "whisperx", "--help"], capture_output=True, text=True, timeout=1800)
print(f"[whisperx] prewarm {'ok' if r.returncode == 0 else 'FAILED'} in {time.time() - t0:.0f}s" + ("" if r.returncode == 0 else f": {r.stderr[-800:]}"))
except Exception as e:
print(f"[whisperx] prewarm error: {e}")
threading.Thread(target=run, daemon=True, name="whisperx-prewarm").start()
_patch_video_pipeline()
_patch_whisperx_language()
_prewarm_whisperx()
FUNCTIONAL_REGIONS = {
"visual_processing": {
"description": "Visual cortex",
"labels": ["G_cuneus", "S_calcarine", "G_occipital_sup", "G_occipital_middle", "Pole_occipital", "G_and_S_occipital_inf", "G_oc-temp_lat-fusifor"],
},
"attention": {
"description": "Prefrontal and parietal",
"labels": ["G_front_sup", "G_front_middle", "S_front_sup", "G_parietal_sup", "S_intrapariet_and_P_trans", "G_precuneus"],
},
"emotional_response": {
"description": "Limbic and temporal",
"labels": ["G_cingul-Post-dorsal", "G_temp_sup-Lateral", "S_temporal_sup", "G_and_S_cingul-Ant"],
},
"language_processing": {
"description": "Broca and Wernicke",
"labels": ["G_front_inf-Opercular", "G_front_inf-Triangul", "G_temp_sup-G_T_transv", "G_temporal_middle", "G_pariet_inf-Angular"],
},
"motor_planning": {
"description": "Motor and premotor",
"labels": ["G_precentral", "S_precentral-sup-part", "S_central"],
},
}
WEIGHTS = {"attention": 0.30, "visual_processing": 0.25, "emotional_response": 0.25, "language_processing": 0.15, "motor_planning": 0.05}
model = None
label_map = None
def build_events_without_whisperx(video_path: str) -> pd.DataFrame:
"""
Build events dataframe from video WITHOUT using WhisperX.
Extracts audio and creates Video+Audio events only.
TRIBE v2 can still predict brain responses from video and audio
features even without word-level transcription.
"""
from neuralset.events.transforms import ExtractAudioFromVideo, ChunkEvents
from neuralset.events.utils import standardize_events
# Create initial video event
event = {
"type": "Video",
"filepath": str(video_path),
"start": 0,
"timeline": "default",
"subject": "default",
}
events = pd.DataFrame([event])
events = standardize_events(events)
# Extract audio from video
transform_audio = ExtractAudioFromVideo()
events = transform_audio(events)
# Chunk long clips β keep short for faster processing
transform_chunk_audio = ChunkEvents(event_type_to_chunk="Audio", max_duration=15, min_duration=5)
transform_chunk_video = ChunkEvents(event_type_to_chunk="Video", max_duration=15, min_duration=5)
events = transform_chunk_audio(events)
events = transform_chunk_video(events)
events = standardize_events(events)
print(f"Built events: {len(events)} rows (audio_only mode, no WhisperX)")
return events
def load_model():
global model, label_map
if model is not None:
return
from tribev2 import TribeModel
from nilearn.datasets import fetch_atlas_surf_destrieux
print("Loading TRIBE v2 model...")
# Text features come from Llama-3.2-3B. In fp32 that is ~12.8 GiB and cannot
# share a 16 GiB T4 with VJEPA2 (~4.4 GiB); "accelerate" loads it in fp16
# with device_map=auto (~6.4 GiB, spilling to CPU rather than OOM-ing).
# data.num_workers: the training config forks 20 DataLoader workers. Here
# one video is analysed at a time and its features need the GPU, so the
# extractors must run in this process: a forked worker cannot initialise
# CUDA and the job dies inside the DataLoader. Zero workers, no fork.
config_update = {"data.num_workers": 0}
if CUDA:
config_update["data.text_feature.device"] = "accelerate"
model = TribeModel.from_pretrained("facebook/tribev2", cache_folder="./cache", config_update=config_update)
print("Loading Destrieux atlas...")
atlas = fetch_atlas_surf_destrieux()
label_map = {}
labels = atlas.labels
for i, label_entry in enumerate(labels):
if isinstance(label_entry, (tuple, list)) and len(label_entry) == 2:
idx, name = label_entry
elif isinstance(label_entry, bytes):
idx, name = i, label_entry.decode("utf-8")
elif isinstance(label_entry, str):
idx, name = i, label_entry
else:
idx, name = i, str(label_entry)
if name == "Unknown" or idx == 0:
continue
left_verts = np.where(np.array(atlas.map_left) == idx)[0]
right_verts = np.where(np.array(atlas.map_right) == idx)[0] + len(atlas.map_left)
if len(left_verts) > 0 or len(right_verts) > 0:
label_map[name] = np.concatenate([left_verts, right_verts])
print(f"Loaded {len(label_map)} brain regions. Model ready.")
def compute_regions(preds):
region_activations = {}
for rname, rinfo in FUNCTIONAL_REGIONS.items():
verts = []
for lbl in rinfo["labels"]:
if lbl in label_map:
verts.append(label_map[lbl])
if not verts:
region_activations[rname] = np.zeros(preds.shape[0])
continue
all_v = np.concatenate(verts)
all_v = all_v[all_v < preds.shape[1]]
region_activations[rname] = np.mean(preds[:, all_v], axis=1)
return region_activations
def compute_engagement(region_activations):
"""
Compute per-second engagement from region activations.
Uses z-score normalization instead of min-max to avoid the
"everything peaks at the last second" artifact.
"""
n = len(next(iter(region_activations.values())))
composite = np.zeros(n)
for region, weight in WEIGHTS.items():
if region in region_activations:
vals = region_activations[region]
mean, std = vals.mean(), vals.std()
if std > 0:
# Z-score: how many SDs above/below mean activation
z = (vals - mean) / std
# Sigmoid to [0, 1] range β z=0 maps to 0.5
normalized = 1 / (1 + np.exp(-z))
else:
normalized = np.full(n, 0.5)
composite += weight * normalized
return composite
def compute_score(engagement):
"""
Score 1-10 based on variability and peak strength.
Good ads have high peaks and dynamic variation (not flat).
"""
if len(engagement) == 0:
return 5
peak = float(np.max(engagement))
valley = float(np.min(engagement))
mean = float(np.mean(engagement))
dynamic_range = peak - valley # 0 to ~1
# Weight: 60% peak strength, 40% dynamic range
raw = 0.6 * peak + 0.4 * dynamic_range
# Scale to 1-10 (0.3 β 1, 0.8 β 10)
score = 1 + 9 * max(0, min(1, (raw - 0.3) / 0.5))
return max(1, min(10, round(score)))
def find_peak_region_seconds(region_activations, hrf_offset):
"""
Find per-region peak seconds, excluding the very last second
(which is an artifact of the model's attention accumulation).
"""
summary = {}
for rname, rinfo in FUNCTIONAL_REGIONS.items():
if rname not in region_activations:
continue
vals = region_activations[rname]
# Exclude last 2 time points (attention accumulation artifact)
search_range = vals[:-2] if len(vals) > 3 else vals
peak_idx = int(np.argmax(search_range))
mean_val = float(np.mean(vals))
peak_val = float(np.max(search_range))
# Relative strength: how much above mean is the peak
rel_strength = (peak_val - mean_val) / (mean_val + 1e-8)
summary[rname] = {
"description": rinfo["description"],
"mean": round(mean_val, 4),
"peak": round(peak_val, 4),
"peak_second": peak_idx + hrf_offset,
"relative_strength": round(rel_strength, 2),
}
return summary
def generate_insights(engagement, region_activations, hrf_offset=0, ad_context=None):
"""Generate data-specific creative insights from brain activation patterns."""
# Ad context for tailored language
ctx = ad_context or {}
offer = ctx.get("offer", "key message") # "the app", "the service", "the shoe", etc.
cta_label = ctx.get("cta", "CTA")
brand = ctx.get("brand", "the brand")
insights = []
n = len(engagement)
if n < 3:
return insights
mean_eng = float(np.mean(engagement))
std_eng = float(np.std(engagement))
peak_idx = int(np.argmax(engagement))
low_idx = int(np.argmin(engagement))
peak_val = float(engagement[peak_idx])
low_val = float(engagement[low_idx])
# ββ Compute per-region stats ββ
region_stats = {}
for rname in ["visual_processing", "attention", "emotional_response", "language_processing", "motor_planning"]:
if rname in region_activations:
vals = region_activations[rname]
safe = vals[:-2] if len(vals) > 3 else vals
region_stats[rname] = {
"mean": float(np.mean(safe)),
"std": float(np.std(safe)),
"peak_idx": int(np.argmax(safe)),
"peak_val": float(np.max(safe)),
}
sorted_regions = sorted(region_stats.items(), key=lambda x: x[1]["mean"], reverse=True) if region_stats else []
top_region = sorted_regions[0][0] if sorted_regions else None
bottom_region = sorted_regions[-1][0] if sorted_regions else None
# ββ 1. Engagement Shape Analysis ββ
# Split into thirds and identify the shape
third = max(1, n // 3)
start_eng = float(np.mean(engagement[:third]))
mid_eng = float(np.mean(engagement[third:2*third]))
end_eng = float(np.mean(engagement[2*third:]))
if start_eng > mid_eng and start_eng > end_eng:
shape = "front-loaded"
elif end_eng > mid_eng and end_eng > start_eng:
shape = "back-loaded"
elif mid_eng < start_eng and mid_eng < end_eng:
shape = "u-shape"
elif mid_eng > start_eng and mid_eng > end_eng:
shape = "peak-middle"
else:
shape = "flat"
shape_insights = {
"front-loaded": {
"title": "Front-Loaded Engagement",
"severity": "medium",
"message": f"Brain engagement is strongest in the first {third}s, then declines. Viewers get hooked early but lose interest before {offer} is reinforced.",
"action": f"Move {offer} earlier β viewers are most receptive in the opening seconds. Or add a second hook in the final third to re-engage before the {cta_label}."
},
"back-loaded": {
"title": "Slow Build β Late Peak",
"severity": "medium",
"message": f"Engagement builds toward the end, peaking after {2*third}s. The opening is the weakest section β viewers in a feed may scroll past before {offer} lands.",
"action": f"Tease the payoff upfront: show a flash of the climax in the first 2 seconds to buy time. Or restructure to reveal {offer} immediately."
},
"u-shape": {
"title": "Mid-Section Drop",
"severity": "medium",
"message": f"Strong opening and closing, but engagement dips in the middle ({third}s-{2*third}s). Attention drops between the hook and the {cta_label}.",
"action": f"Add a pattern interrupt between {third}s-{2*third}s: scene change, new speaker, text overlay, or tempo shift. Bridge the gap between hook and {cta_label}."
},
"peak-middle": {
"title": "Center-Weighted Engagement",
"severity": "positive",
"message": f"The core of the ad ({third}s-{2*third}s) drives the strongest response β this is where {offer} resonates most.",
"action": f"The structure works. Ensure the {cta_label} appears during or immediately after this peak, not after engagement fades."
},
"flat": {
"title": "Flat Response β Low Contrast",
"severity": "medium",
"message": f"Brain engagement is uniform throughout β no strong peaks or valleys. Flat response correlates with lower recall for {brand}.",
"action": "Create contrast: add a surprising moment, an emotional beat, a visual disruption, or a change in pacing. Memorable ads need at least one peak."
},
}
insights.append({"type": "shape", **shape_insights[shape]})
# ββ 2. Hook Verdict ββ
hook_3s = float(np.mean(engagement[:min(3, n)]))
hook_vs_avg = (hook_3s - mean_eng) / (mean_eng + 1e-8) * 100
if hook_vs_avg < -15:
# Find what's WEAK in the hook
hook_regions = {r: float(np.mean(region_activations[r][:min(3, n)])) for r in region_stats}
weakest_hook_region = min(hook_regions, key=hook_regions.get) if hook_regions else None
weakness_label = {
"visual_processing": "visuals aren't grabbing attention",
"attention": "nothing demands focus",
"emotional_response": "no emotional hook",
"language_processing": "the copy/audio doesn't land immediately",
"motor_planning": "no impulse to engage",
}.get(weakest_hook_region, "the opening lacks stimulus")
insights.append({
"type": "hook", "severity": "high",
"title": f"Weak Hook β {abs(hook_vs_avg):.0f}% Below Average",
"message": f"The first 3 seconds underperform the rest of the ad. Specifically, {weakness_label} in the opening.",
"action": "Restructure the first 2 seconds: lead with motion, a face, bold text, or a sound that demands attention. The hook determines 80% of view-through rate."
})
elif hook_vs_avg > 15:
# Find what's STRONG in the hook
hook_regions = {r: float(np.mean(region_activations[r][:min(3, n)])) for r in region_stats}
strongest_hook_region = max(hook_regions, key=hook_regions.get) if hook_regions else None
strength_label = {
"visual_processing": "strong visual opening",
"attention": "immediately grabs focus",
"emotional_response": "emotional hook",
"language_processing": "compelling opening line/audio",
"motor_planning": "action-triggering opener",
}.get(strongest_hook_region, "effective opening")
insights.append({
"type": "hook", "severity": "positive",
"title": f"Strong Hook β {hook_vs_avg:.0f}% Above Average",
"message": f"The opening outperforms the rest of the ad, driven by {strength_label}. This hook pattern is worth replicating.",
"action": "Document this hook structure β it works. Test variations of the same opening pattern across other creatives."
})
# ββ 3. Engagement Drops β find the worst dip ββ
if n >= 6:
# Find the biggest drop (3-second rolling window)
window = 3
rolling = [float(np.mean(engagement[max(0,i-1):i+window-1])) for i in range(n - window + 1)]
worst_window_idx = int(np.argmin(rolling))
worst_window_val = rolling[worst_window_idx]
drop_pct = (mean_eng - worst_window_val) / (mean_eng + 1e-8) * 100
if drop_pct > 15:
drop_start = worst_window_idx + hrf_offset
drop_end = worst_window_idx + window + hrf_offset
# What region drops the most here?
drop_regions = {}
for rname in region_stats:
region_at_drop = float(np.mean(region_activations[rname][worst_window_idx:worst_window_idx+window]))
region_overall = region_stats[rname]["mean"]
drop_regions[rname] = (region_overall - region_at_drop) / (region_overall + 1e-8)
biggest_drop_region = max(drop_regions, key=drop_regions.get) if drop_regions else None
drop_label = {
"visual_processing": "visual interest drops β the scene may be static or repetitive",
"attention": "cognitive focus fades β nothing new to process",
"emotional_response": "emotional connection weakens",
"language_processing": "the message loses clarity or relevance",
"motor_planning": "action intent drops β the ad feels passive",
}.get(biggest_drop_region, "engagement drops")
insights.append({
"type": "drop", "severity": "high",
"title": f"Attention Drop at {drop_start}s-{drop_end}s ({drop_pct:.0f}% below avg)",
"message": f"The weakest moment in the ad. Specifically, {drop_label} during this window.",
"action": f"Review what happens at {drop_start}s-{drop_end}s in the video. Add a scene change, new audio cue, or text overlay to re-engage."
})
# ββ 4. Peak Moment β what makes it work ββ
peak_s = peak_idx + hrf_offset
# Find what region is strongest at the peak
peak_regions = {r: float(region_activations[r][peak_idx]) for r in region_stats}
peak_driver = max(peak_regions, key=peak_regions.get) if peak_regions else None
peak_label = {
"visual_processing": "a strong visual moment",
"attention": "high cognitive focus",
"emotional_response": "an emotional beat",
"language_processing": "a compelling message or audio cue",
"motor_planning": "an action trigger",
}.get(peak_driver, "a peak in brain activity")
insights.append({
"type": "peak", "severity": "positive",
"title": f"Peak at {peak_s}s β Driven by {peak_label.title()}",
"message": f"The strongest brain response occurs at {peak_s}s, primarily driven by {peak_label}. This is the most impactful moment in the ad.",
"action": f"Align {offer} with this moment. If the {cta_label} isn't near {peak_s}s, consider moving it closer."
})
# ββ 5. CTA Window ββ
if n >= 5:
last_3 = float(np.mean(engagement[-3:]))
cta_vs_avg = (last_3 - mean_eng) / (mean_eng + 1e-8) * 100
if cta_vs_avg < -15:
insights.append({
"type": "cta", "severity": "high",
"title": f"{cta_label} Lands on Low Engagement ({cta_vs_avg:.0f}%)",
"message": f"The final 3 seconds are {abs(cta_vs_avg):.0f}% below average. Viewers' attention has faded when the {cta_label} appears.",
"action": f"Option A: Move the {cta_label} to the peak moment ({peak_s}s). Option B: Add a re-engagement spike before the {cta_label} β a bold visual, urgency text, or direct question."
})
elif cta_vs_avg > 10:
insights.append({
"type": "cta", "severity": "positive",
"title": f"Strong {cta_label} Window",
"message": f"Engagement is {cta_vs_avg:.0f}% above average at the end. Viewers are attentive when the {cta_label} appears.",
"action": f"Make sure the {cta_label} is visually prominent and uses action language. This audience is primed to act."
})
# ββ 6. Region Dominance (only if there's a clear leader) ββ
if len(sorted_regions) >= 2:
top_name, top_stats = sorted_regions[0]
second_name, second_stats = sorted_regions[1]
dominance = (top_stats["mean"] - second_stats["mean"]) / (second_stats["mean"] + 1e-8) * 100
if dominance > 10:
labels = {
"visual_processing": ("Visually-Driven", "imagery and motion", "Test different visual treatments β color, pacing, composition β since the audience responds primarily to what they see."),
"attention": ("Cognitively Demanding", "focused thinking", "Balance information density with breathing room. Too much cognitive load causes fatigue and drop-off."),
"emotional_response": ("Emotion-Led", "feelings over logic", "Use emotion-aligned CTAs ('Don't miss out', 'Join the movement') β they outperform rational CTAs for this type of creative."),
"language_processing": ("Copy-Driven", "words and audio", "The copy is doing the heavy lifting. A/B test different scripts, voiceover styles, or text overlays."),
"motor_planning": ("Action-Primed", "impulse to act", "This is ideal for performance ads. Place the CTA at the moment of highest motor activation for maximum conversion."),
}
if top_name in labels:
label, driver, action = labels[top_name]
insights.append({
"type": "dominance", "severity": "positive",
"title": f"{label} Creative ({dominance:.0f}% stronger than #{2})",
"message": f"This ad's engagement is primarily driven by {driver}. The {labels.get(top_name, ('','',''))[0].lower()} signal is {dominance:.0f}% stronger than the next closest region.",
"action": action,
})
return insights
MAX_DURATION = 60 # Analyze up to 60s of video
# VJEPA2 consumes 64 frames per 4 s clip: 16 fps is its native rate. At the old
# 2 fps every frame the model saw was one of 8 duplicated eight times, a domain
# shift from the real video it was trained on. Decoding is cached, so the extra
# frames cost almost nothing.
TRIM_FPS = 16
def trim_video(input_path: str, max_seconds: int = MAX_DURATION) -> str:
"""Trim video to max_seconds and reduce FPS for faster encoding."""
output_path = input_path.replace(".mp4", "_trimmed.mp4")
try:
result = subprocess.run(
["ffmpeg", "-y", "-i", input_path, "-t", str(max_seconds),
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "28",
"-vf", f"fps={TRIM_FPS},scale='min(320,iw)':-2", # native VJEPA2 rate, 320px width
"-c:a", "aac", "-b:a", "64k", "-ar", "16000", output_path],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0 and Path(output_path).exists():
size = Path(output_path).stat().st_size
print(f"Trimmed to {max_seconds}s @{TRIM_FPS}fps 320px: {size} bytes")
return output_path
except Exception as e:
print(f"Trim failed ({e}), using original")
return input_path
# One analysis at a time on the GPU, whether it came from the UI/sync API or
# from the async queue below. Threads waiting here are the queue.
ANALYSIS_LOCK = threading.Lock()
def analyze_video(video_url: str, filename: str = "video.mp4", ad_context_json: str = "{}") -> str:
"""Main analysis β uses full TRIBE v2 pipeline with WhisperX on GPU. Serialized."""
with ANALYSIS_LOCK:
return _analyze_video_locked(video_url, filename, ad_context_json)
def _analyze_video_locked(video_url: str, filename: str = "video.mp4", ad_context_json: str = "{}") -> str:
try:
# Parse ad context for tailored insights
try:
ad_context = json.loads(ad_context_json) if ad_context_json else {}
except Exception:
ad_context = {}
load_model()
start_time = time.time()
# HRF offset: TRIBE v2 internally accounts for ~5s hemodynamic delay,
# but we report seconds as video time (0-based) so users can match
# insights to actual moments in their video.
hrf_offset = 0
# Download video
print(f"Downloading: {video_url[:100]}")
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
resp = requests.get(video_url, timeout=120)
resp.raise_for_status()
f.write(resp.content)
video_path = f.name
print(f"Downloaded {len(resp.content)} bytes")
# Trim to MAX_DURATION seconds and downscale for faster encoding
video_path = trim_video(video_path)
# Build events β try full pipeline (WhisperX + audio + video), fallback to audio-only
try:
print("Building events with full pipeline (WhisperX)...")
# Keyword argument: the library accepts exactly one of text/audio/video.
# Passing three positionals set all three and it refused, which is why
# every analysis before 2026-09-24 silently fell back to audio-only.
df = model.get_events_dataframe(video_path=video_path)
n_words = int((df["type"] == "Word").sum()) if "type" in df else 0
print(f"Events: {len(df)} rows, {n_words} words (full pipeline with transcription)")
except Exception as whisperx_err:
print(f"WhisperX pipeline failed, falling back to audio-only: {type(whisperx_err).__name__}: {whisperx_err}")
traceback.print_exc()
df = build_events_without_whisperx(video_path)
print(f"Events: {len(df)} rows (audio-only fallback)")
# Run brain prediction
print("Running brain prediction...")
preds, segments = model.predict(events=df)
print(f"Predictions: {preds.shape}")
# Compute engagement
region_activations = compute_regions(preds)
engagement = compute_engagement(region_activations)
mean_eng = float(np.mean(engagement))
overall_score = compute_score(engagement)
# Build timeline (exclude last 2 points β attention accumulation artifact)
valid_len = max(1, len(engagement) - 2)
timeline = [
{
"second": t + hrf_offset,
"engagement": round(float(engagement[t]), 3),
"regions": {r: round(float(region_activations[r][t]), 4)
for r in FUNCTIONAL_REGIONS if r in region_activations},
}
for t in range(valid_len)
]
# Region summary with artifact-corrected peaks
region_summary = find_peak_region_seconds(region_activations, hrf_offset)
# Find peak/lowest in the valid range (excluding tail artifact)
valid_engagement = engagement[:valid_len]
peak_idx = int(np.argmax(valid_engagement))
low_idx = int(np.argmin(valid_engagement))
insights = generate_insights(valid_engagement, region_activations, hrf_offset, ad_context)
elapsed = time.time() - start_time
report = {
"pipeline": "full" if ("type" in df and (df["type"] == "Word").any()) else "audio_only",
"filename": filename,
"duration_seconds": valid_len + hrf_offset,
"analyzed_seconds": valid_len,
"overall_score": overall_score,
"mean_engagement": round(mean_eng, 3),
"peak": {
"value": round(float(valid_engagement[peak_idx]), 3),
"second": peak_idx + hrf_offset,
},
"lowest": {
"value": round(float(valid_engagement[low_idx]), 3),
"second": low_idx + hrf_offset,
},
"region_summary": region_summary,
"timeline": timeline,
"insights": insights,
"inference_time_seconds": round(elapsed, 1),
"model": "facebook/tribev2",
}
Path(video_path).unlink(missing_ok=True)
print(f"Analysis complete: score={overall_score}, {len(engagement)} timesteps, {elapsed:.1f}s")
return json.dumps(report)
except Exception as e:
error_msg = f"Error: {type(e).__name__}: {str(e)}"
print(error_msg)
traceback.print_exc()
return json.dumps({"error": error_msg, "traceback": traceback.format_exc()})
# ---------------------------------------------------------------------------
# Async API for AdVisor: accept a job, return at once, analyse in the
# background, POST the report to the caller's callback URL.
#
# Only hosts listed in ADVISOR_CALLBACK_HOSTS (comma-separated, default: the
# AdVisor Supabase project) may receive callbacks, so this public endpoint
# cannot be pointed at arbitrary servers. The caller supplies job_ref and
# job_token; both are echoed back and the caller verifies the token, so no
# shared secret has to live in this Space.
# ---------------------------------------------------------------------------
ALLOWED_CALLBACK_HOSTS = {
h.strip().lower()
for h in os.environ.get("ADVISOR_CALLBACK_HOSTS", "emyiuzsgvnnzgtqwmttb.supabase.co").split(",")
if h.strip()
}
CALLBACK_RETRIES = 4
def _post_callback(callback_url: str, payload: dict) -> bool:
delay = 5
for attempt in range(1, CALLBACK_RETRIES + 1):
try:
resp = requests.post(
callback_url,
json=payload,
headers={"Content-Type": "application/json", "X-Job-Token": payload.get("job_token", "")},
timeout=60,
)
if resp.ok:
print(f"[async] callback delivered for {payload.get('job_ref')} ({resp.status_code})")
return True
print(f"[async] callback HTTP {resp.status_code} for {payload.get('job_ref')}: {resp.text[:200]}")
if 400 <= resp.status_code < 500 and resp.status_code != 429:
return False # our payload is wrong; retrying will not help
except Exception as e: # network
print(f"[async] callback attempt {attempt} failed: {e}")
time.sleep(delay)
delay = min(delay * 2, 60)
return False
def _run_async_job(video_url: str, filename: str, ad_context_json: str, callback_url: str, job_ref: str, job_token: str):
started = time.time()
try:
raw = analyze_video(video_url, filename, ad_context_json)
report = json.loads(raw)
if isinstance(report, dict) and report.get("error"):
payload = {"job_ref": job_ref, "job_token": job_token, "status": "failed", "error": str(report.get("error"))[:1000]}
else:
payload = {"job_ref": job_ref, "job_token": job_token, "status": "complete", "report": report}
except Exception as e: # never let a job die silently
payload = {"job_ref": job_ref, "job_token": job_token, "status": "failed", "error": f"{type(e).__name__}: {e}"[:1000]}
payload["elapsed_seconds"] = round(time.time() - started, 1)
_post_callback(callback_url, payload)
def analyze_video_async(video_url: str, filename: str, ad_context_json: str, callback_url: str, job_ref: str, job_token: str) -> str:
"""Queue an analysis and return immediately. The report arrives at callback_url."""
try:
host = (urlparse(callback_url).hostname or "").lower()
if urlparse(callback_url).scheme != "https" or host not in ALLOWED_CALLBACK_HOSTS:
return json.dumps({"accepted": False, "error": f"callback host not allowed: {host or '(none)'}"})
if not video_url or not job_ref or not job_token:
return json.dumps({"accepted": False, "error": "video_url, job_ref and job_token are required"})
t = threading.Thread(
target=_run_async_job,
args=(video_url, filename or "video.mp4", ad_context_json or "{}", callback_url, job_ref, job_token),
daemon=True,
name=f"tribe-async-{job_ref[:12]}",
)
t.start()
waiting = sum(1 for th in threading.enumerate() if th.name.startswith("tribe-async-"))
print(f"[async] accepted job {job_ref} ({waiting} in flight)")
return json.dumps({"accepted": True, "job_ref": job_ref, "in_flight": waiting})
except Exception as e:
return json.dumps({"accepted": False, "error": f"{type(e).__name__}: {e}"})
app = gr.Blocks(title="TRIBE v2 Neural Engagement Analyzer")
with app:
gr.Markdown("# TRIBE v2 Neural Engagement Analyzer")
gr.Markdown("Predict brain response to video content, second by second.")
with gr.Row():
video_url_input = gr.Textbox(label="Video URL", placeholder="https://example.com/video.mp4")
filename_input = gr.Textbox(label="Filename", value="video.mp4")
ad_context_input = gr.Textbox(label="Ad Context (JSON)", value="{}", visible=False)
analyze_btn = gr.Button("Analyze", variant="primary")
output = gr.JSON(label="Neural Engagement Report")
analyze_btn.click(fn=analyze_video, inputs=[video_url_input, filename_input, ad_context_input], outputs=output)
# Hidden controls back the async API (gradio_api/call/analyze_video_async).
with gr.Row(visible=False):
async_video_url = gr.Textbox(label="video_url")
async_filename = gr.Textbox(label="filename", value="video.mp4")
async_ad_context = gr.Textbox(label="ad_context_json", value="{}")
async_callback_url = gr.Textbox(label="callback_url")
async_job_ref = gr.Textbox(label="job_ref")
async_job_token = gr.Textbox(label="job_token")
async_btn = gr.Button("Queue")
async_out = gr.Textbox(label="ack")
async_btn.click(
fn=analyze_video_async,
inputs=[async_video_url, async_filename, async_ad_context, async_callback_url, async_job_ref, async_job_token],
outputs=async_out,
api_name="analyze_video_async",
)
app.queue()
if __name__ == "__main__":
app.launch()
|