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()