File size: 72,381 Bytes
5278313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
# Video Analysis API v4.2
# Whisper transcription + emotion2vec voice emotion + HSEmotion facial emotions

import os
import base64
import math
import tempfile
import subprocess
import shutil
import threading
from concurrent.futures import ThreadPoolExecutor
from fastapi import FastAPI, UploadFile, File, Query
from fastapi.middleware.cors import CORSMiddleware
import torch
import service_auth
import numpy as np

print("=" * 50, flush=True)
print(f"[STARTUP] CUDA available: {torch.cuda.is_available()}", flush=True)
if torch.cuda.is_available():
    print(f"[STARTUP] GPU: {torch.cuda.get_device_name(0)}", flush=True)
print("=" * 50, flush=True)

# No interactive docs: the API is for the EchoDepth app only (service_auth.py)
app = FastAPI(title="EchoDepth Video Analysis API", docs_url=None, redoc_url=None, openapi_url=None)
# Pass check first, CORS added after so it wraps it: a refusal still reaches the browser readably
app.middleware("http")(service_auth.require_pass)
app.add_middleware(CORSMiddleware, allow_origins=service_auth.ALLOWED_ORIGINS, allow_credentials=False, allow_methods=["GET", "POST"], allow_headers=["*"])
print(f"[STARTUP] Service passes: {'enforced' if service_auth.enforcing() else 'OFF - set VIDEO_SERVICE_SECRET'}", flush=True)

# Lazy load models. Loads take the lock, so the start-up warm-up thread and a first request
# arriving meanwhile never load the same model twice.
_LOAD = threading.RLock()
# One analysis at a time. Requests are answered from a thread pool now (the endpoints are plain
# def), so /health and the job polls stay responsive during an analysis; this keeps two films from
# sharing the GPU and from mixing their per-request entries in MODEL_ERRORS.
_ANALYSE = threading.Lock()
# Entries in MODEL_ERRORS that describe one request rather than a model that failed to load.
# Cleared at the start of each analysis: before, one failed CEI pass was reported on every
# analysis after it until the service restarted.
_PER_REQUEST_ERRORS = ("cei", "stills", "proxy", "decode")


def _loaded_once(fn):
    """Run a model getter under the load lock."""
    def wrapper(*args, **kwargs):
        with _LOAD:
            return fn(*args, **kwargs)
    wrapper.__name__ = fn.__name__
    wrapper.__doc__ = fn.__doc__
    return wrapper


whisper_model = None
emotion2vec_model = None
face_emotion_model = None

# Why a model did not load, keyed by analyser. An optional model that fails to import used to be
# swallowed here and the request still answered 200 OK, so a service missing funasr returned a
# complete-looking analysis with the voice channel silently absent — and downstream, congruence
# defaulted rather than reporting itself unmeasured. Every response now says what actually ran.
MODEL_ERRORS: dict = {}

# emotion2vec labels (9 classes)
EMOTION2VEC_LABELS = ['angry', 'disgust', 'fearful', 'happy', 'neutral', 'other', 'sad', 'surprised', 'unknown']

# Normalize emotion labels from emotion2vec (handles Chinese/mixed labels)
EMOTION_LABEL_MAP = {
    '中立/neutral': 'neutral', '中立': 'neutral', 'neutral': 'neutral', 'neutra': 'neutral',
    '开心/happy': 'happy', '开心': 'happy', 'happy': 'happy',
    '生气/angry': 'angry', '生气': 'angry', 'angry': 'angry',
    '难过/sad': 'sad', '难过': 'sad', 'sad': 'sad',
    '恐惧/fear': 'fear', '恐惧': 'fear', 'fear': 'fear', 'fearful': 'fear',
    '惊讶/surprise': 'surprise', '惊讶': 'surprise', 'surprise': 'surprise', 'surprised': 'surprise',
    '厌恶/disgust': 'disgust', '厌恶': 'disgust', 'disgust': 'disgust',
    '其他/other': 'other', '其他': 'other', 'other': 'other',
    '<unk>': 'unknown', 'unknown': 'unknown', 'unk': 'unknown',
}

def normalize_emotion_label(label):
    """Normalize emotion label to clean English.



    emotion2vec_plus_large emits '吃惊/surprised', '恐惧/fearful' and '厌恶/disgusted', none of which

    were in the map, so three of its nine classes reached the app untranslated. Anything the map

    misses now falls back to the English half after the slash, mapped again.

    """
    label_lower = label.lower().strip()
    if label_lower in EMOTION_LABEL_MAP:
        return EMOTION_LABEL_MAP[label_lower]
    english = label_lower.split('/')[-1].strip()
    return EMOTION_LABEL_MAP.get(english, {'disgusted': 'disgust'}.get(english, english))

whisper_fast_model = None


@_loaded_once
def get_whisper_fast():
    """Whisper small, for short spoken answers people correct before sending (Reflections).



    Several times quicker than medium on this CPU. Leave cpu_threads at its default: os.cpu_count()

    reports the host's cores, not the Space's two, and oversubscribing them made it six times slower.

    The brand-name misses that ruled small out for

    ads do not matter here: every transcript is shown to its speaker to correct first.

    """
    global whisper_fast_model
    if whisper_fast_model is None:
        from faster_whisper import WhisperModel
        device = "cuda" if torch.cuda.is_available() else "cpu"
        compute = "float16" if device == "cuda" else "int8"
        print(f"[whisper] Loading small (fast profile) on {device}", flush=True)
        whisper_fast_model = WhisperModel("small", device=device, compute_type=compute)
    return whisper_fast_model


@_loaded_once
def get_whisper():
    global whisper_model
    if whisper_model is None:
        from faster_whisper import WhisperModel
        device = "cuda" if torch.cuda.is_available() else "cpu"
        compute = "float16" if device == "cuda" else "int8"
        # "medium" everywhere. "small" dropped a sung brand name at the end of a 30-second film
        # ("making smart moves with..." for "with Go.Compare") whatever it was prompted with;
        # medium with the brand as a hint hears it. The memorability words channel was fitted to
        # "small" transcripts of the training ads (2026-09-21); a medium re-run is the next cut.
        model_size = "medium"
        print(f"[whisper] Loading {model_size} on {device}", flush=True)
        whisper_model = WhisperModel(model_size, device=device, compute_type=compute)
    return whisper_model

@_loaded_once
def get_emotion2vec():
    """Load emotion2vec model for voice emotion recognition"""
    global emotion2vec_model
    if emotion2vec_model is None:
        try:
            from funasr import AutoModel
            print("[voice] Loading emotion2vec model...", flush=True)
            # Use HuggingFace model name (emotion2vec/) not ModelScope (iic/)
            emotion2vec_model = AutoModel(
                model="emotion2vec/emotion2vec_plus_large",
                hub="hf",
                device="cuda" if torch.cuda.is_available() else "cpu"
            )
            print("[voice] emotion2vec loaded successfully", flush=True)
        except Exception as e:
            print(f"[voice] Failed to load emotion2vec: {e}", flush=True)
            MODEL_ERRORS["voice_emotion"] = f"{type(e).__name__}: {e}"
            emotion2vec_model = "failed"
    return emotion2vec_model if emotion2vec_model != "failed" else None

@_loaded_once
def get_face_emotion():
    """Load HSEmotion ONNX model for facial emotion recognition"""
    global face_emotion_model
    if face_emotion_model is None:
        try:
            import onnxruntime as ort
            from hsemotion_onnx.facial_emotions import HSEmotionRecognizer, get_model_path
            print("[face] Loading HSEmotion ONNX...", flush=True)
            face_emotion_model = HSEmotionRecognizer(model_name='enet_b0_8_best_afew')
            # The library opens its session on the CPU provider only. With onnxruntime-gpu in the
            # image, reopen it on CUDA; the model and its outputs are the same, only where it runs.
            # Checked against torch too: onnxruntime-gpu lists the CUDA provider on a machine with
            # no GPU (the CPU fallback Space runs this same image).
            if torch.cuda.is_available() and "CUDAExecutionProvider" in ort.get_available_providers():
                face_emotion_model.ort_session = ort.InferenceSession(
                    get_model_path('enet_b0_8_best_afew'), providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
            print(f"[face] HSEmotion ONNX loaded on {face_emotion_model.ort_session.get_providers()[0]}", flush=True)
        except Exception as e:
            import traceback
            print(f"[face] Failed to load HSEmotion ONNX: {e}", flush=True)
            print(f"[face] Traceback: {traceback.format_exc()}", flush=True)
            MODEL_ERRORS["face_emotion"] = f"{type(e).__name__}: {e}"
            face_emotion_model = "failed"
    return face_emotion_model if face_emotion_model != "failed" else None

ocr_reader = None

@_loaded_once
def get_ocr():
    """EasyOCR reader, loaded once. Optional like the emotion models: a service without it still

    answers, and says so, rather than pretending the film carried no on-screen copy."""
    global ocr_reader
    if ocr_reader is None:
        try:
            import easyocr
            print("[ocr] Loading EasyOCR...", flush=True)
            # On the GPU when there is one. It was pinned to the CPU when the Space had six
            # gigabytes of VRAM, where adding it beside Whisper, emotion2vec and HSEmotion killed
            # the service mid-request; the GPUs it runs on now have 24 GB or more, and OCR on full
            # frames was the slowest pass on the CPU.
            ocr_reader = easyocr.Reader(["en"], gpu=torch.cuda.is_available(), verbose=False)
            print(f"[ocr] EasyOCR loaded on {'cuda' if torch.cuda.is_available() else 'cpu'}", flush=True)
        except Exception as e:
            print(f"[ocr] Failed to load EasyOCR: {e}", flush=True)
            MODEL_ERRORS["screen_text"] = f"{type(e).__name__}: {e}"
            ocr_reader = "failed"
    return ocr_reader if ocr_reader != "failed" else None


class _TextPass:
    """Words on screen, at fps frames a second of the film. See analyze_screen_text."""

    def __init__(self, video_fps, fps=1.0, min_conf=0.4):
        self.reader = get_ocr()
        self.interval = max(1, int(video_fps / fps))
        self.video_fps = video_fps
        self.min_conf = min_conf
        self.out = []

    def wants(self, frame_num):
        return self.reader is not None and frame_num % self.interval == 0

    def add(self, frame, frame_num):
        t = frame_num / self.video_fps
        try:
            found = self.reader.readtext(frame)
        except Exception as e:
            print(f"[ocr] frame {t:.1f}s failed: {e}", flush=True)
            found = []
        words = [
            {"text": txt.strip(), "confidence": round(float(conf), 3)}
            for _box, txt, conf in found
            if conf >= self.min_conf and txt.strip()
        ]
        if words:
            self.out.append({"time": round(t, 2), "words": words,
                             "text": " ".join(w["text"] for w in words)})

    def finish(self):
        return self.out


def analyze_screen_text(video_path, fps=1.0, min_conf=0.4):
    """Read the words on screen, sampled through the film.



    An advert with no voiceover is not an advert with no message: the message is in the supers and

    the end frame. Reading only the audio channel and concluding "nothing was said" is how a

    perfectly clear piece of copy came back unscored.

    """
    return scan_frames(video_path, screen_text=True, faces=False, ocr_fps=fps, ocr_min_conf=min_conf)[0]


def brand_presence(text_timeline, transcript, duration, brand):
    """When the brand is actually present, and where.



    Ehrenberg-Bass: an advert nobody attributes is an advert that sells someone else's product.

    This is the measurable half of that — first appearance, share of the film it is on screen for,

    whether it is there in the opening seconds, and whether it closes the film.

    """
    if not brand or duration <= 0:
        return None
    # Comma-separated aliases, because a brand is rarely one string on screen: this campaign
    # carries WRU, Wales and Cymru, and matching only the legal name would have reported the
    # brand absent from a film that shows it four seconds in.
    aliases = [a.strip().lower() for a in brand.split(",") if a.strip()]
    if not aliases:
        return None
    tokens = []
    for a in aliases:
        parts = [t for t in a.replace("&", " ").split() if len(t) >= 3]
        tokens.extend(parts or [a])
    if not tokens:
        return None

    def _close(a, b, budget):
        """Levenshtein within a small budget — enough for one or two misread characters."""
        if abs(len(a) - len(b)) > budget:
            return False
        prev = list(range(len(b) + 1))
        for i, ca in enumerate(a, 1):
            cur = [i]
            for j, cb in enumerate(b, 1):
                cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
            if min(cur) > budget:
                return False
            prev = cur
        return prev[-1] <= budget

    def hit(text):
        low = (text or "").lower()
        if any(t in low for t in tokens):
            return True
        # Brand names are set in display type, which is where OCR slips: this campaign's CYMRU
        # came back as GYMRU and GIMRU. An exact match would have reported the brand absent from
        # frames it is plainly on, which is the opposite of the finding.
        words = [w.strip(".,:;!?/()-") for w in low.split()]
        for t in tokens:
            budget = 1 if len(t) <= 5 else 2
            if any(_close(t, w, budget) for w in words if abs(len(w) - len(t)) <= budget):
                return True
        return False

    frames = [f for f in text_timeline if hit(f.get("text"))]
    sampled = len(text_timeline) or 1
    first = min((f["time"] for f in frames), default=None)
    last = max((f["time"] for f in frames), default=None)

    return {
        "brand": brand,
        "on_screen_frames": len(frames),
        "sampled_frames": len(text_timeline),
        "share_of_frames": round(100 * len(frames) / sampled, 1) if text_timeline else None,
        "first_seen_s": first,
        "last_seen_s": last,
        # The two placements that carry attribution: the hook, and the end frame nobody skips past.
        "in_opening_3s": bool(first is not None and first <= 3),
        "in_closing_3s": bool(last is not None and last >= duration - 3),
        "spoken": hit(transcript),
    }


text_emotion_model = None

# GoEmotions label -> (valence, arousal, dominance), 0-1. Same table the Stage Read pipeline uses,
# so a line of ad copy and a line of a speech are scored on one scale.
TEXT_VAD = {
    "admiration": (0.80, 0.50, 0.55), "amusement": (0.80, 0.60, 0.60), "anger": (0.15, 0.85, 0.75),
    "annoyance": (0.25, 0.60, 0.55), "approval": (0.75, 0.40, 0.60), "caring": (0.75, 0.40, 0.55),
    "confusion": (0.35, 0.50, 0.30), "curiosity": (0.65, 0.55, 0.50), "desire": (0.70, 0.65, 0.55),
    "disappointment": (0.20, 0.40, 0.30), "disapproval": (0.25, 0.50, 0.55), "disgust": (0.15, 0.60, 0.55),
    "embarrassment": (0.20, 0.55, 0.20), "excitement": (0.85, 0.85, 0.65), "fear": (0.15, 0.80, 0.20),
    "gratitude": (0.85, 0.45, 0.50), "grief": (0.10, 0.45, 0.20), "joy": (0.90, 0.70, 0.65),
    "love": (0.90, 0.60, 0.60), "nervousness": (0.25, 0.75, 0.25), "optimism": (0.80, 0.55, 0.65),
    "pride": (0.80, 0.60, 0.75), "realization": (0.55, 0.45, 0.50), "relief": (0.75, 0.30, 0.55),
    "remorse": (0.15, 0.40, 0.20), "sadness": (0.10, 0.30, 0.20), "surprise": (0.60, 0.80, 0.40),
    "neutral": (0.50, 0.30, 0.50),
}


@_loaded_once
def get_text_emotion():
    global text_emotion_model
    if text_emotion_model is None:
        try:
            from transformers import pipeline as hf_pipeline
            print("[text] Loading GoEmotions...", flush=True)
            text_emotion_model = hf_pipeline("text-classification",
                                             model="SamLowe/roberta-base-go_emotions",
                                             top_k=None, device=0 if torch.cuda.is_available() else -1)
            print("[text] GoEmotions loaded", flush=True)
        except Exception as e:
            print(f"[text] Failed to load GoEmotions: {e}", flush=True)
            MODEL_ERRORS["copy_emotion"] = f"{type(e).__name__}: {e}"
            text_emotion_model = "failed"
    return text_emotion_model if text_emotion_model != "failed" else None


def _normalise_case(text):
    """Set-in-caps copy to sentence case before scoring.



    The model reads capitals as shouting: the identical sentence scores 95 in capitals and 13 in

    sentence case. End frames are nearly always set in caps and a rewritten line arrives in

    sentence case, so without this every original beats every alternative on typesetting alone —

    which is the opposite of the comparison the report claims to be making.

    """
    letters = [c for c in text if c.isalpha()]
    if letters and sum(c.isupper() for c in letters) / len(letters) > 0.7:
        lowered = text.lower()
        return lowered[:1].upper() + lowered[1:]
    return text


def score_lines(lines):
    """Emotional content of each line of copy.



    `feel` is 1 minus the neutral score: how much emotional work the words are doing at all. It is

    the number that matters for advertising, because the failure mode is not being disliked, it is

    being ignored — and a line that reads as pure neutral has nothing for anyone to react to.

    """
    model = get_text_emotion()
    if not model or not lines:
        return []
    out = []
    for line in lines:
        text = (line or "").strip()
        if len(text) < 3:
            continue
        try:
            scores = {d["label"]: float(d["score"]) for d in model(_normalise_case(text)[:500])[0]}
        except Exception as e:
            print(f"[text] scoring failed: {e}", flush=True)
            continue
        total = sum(scores.values()) or 1e-9
        vad = [sum(TEXT_VAD.get(k, (0.5, 0.3, 0.5))[i] * v for k, v in scores.items()) / total for i in range(3)]
        top = sorted(((k, v) for k, v in scores.items() if k != "neutral"), key=lambda kv: -kv[1])[:3]
        out.append({
            "text": text,
            "feel": round(1 - scores.get("neutral", 0.0), 3),
            "valence": round(vad[0], 3),
            "arousal": round(vad[1], 3),
            "dominance": round(vad[2], 3),
            "top": [{"emotion": k, "score": round(v, 3)} for k, v in top],
        })
    return out


YUNET_MODEL = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stage_read", "models", "face_detection_yunet_2023mar.onnx")
# Frames wider than this are downscaled before detection: YuNet is a small network and 1080p buys
# nothing over 960 wide for faces that are in shot at all, at four times the cost.
YUNET_MAX_WIDTH = 960


_MEMORABILITY = None
try:
    import memorability as memorability_mod
except Exception as e:  # the service still starts without it; the pass then reports why
    memorability_mod = None
    MODEL_ERRORS["memorability"] = f"{type(e).__name__}: {e}"

@_loaded_once
def get_memorability():
    """CLIP, PerceptCLIP-Memorability and the fitted LAMBDA model, loaded on first use (about 3 GB of weights)."""
    global _MEMORABILITY
    if _MEMORABILITY is None:
        if memorability_mod is None:
            raise RuntimeError(MODEL_ERRORS.get("memorability", "memorability module missing"))
        yunet = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stage_read", "models", "face_detection_yunet_2023mar.onnx")
        predictor = memorability_mod.Predictor()
        text_model = memorability_mod.TextModel() if predictor.needs_text() else None
        _MEMORABILITY = (memorability_mod.FrameModels(None, yunet), predictor, text_model)
    return _MEMORABILITY

def get_face_detector():
    """The detector behind the four-frames-a-second emotion pass.



    YuNet where its model is present (the Dockerfile fetches it for Stage Read), the Haar cascade

    otherwise. The cascade found no face on 43% of frames of a film that has a person in shot for

    most of it: it wants a frontal face of a decent size and loses profiles, small faces and motion

    blur, and the second-by-second chart came out as a comb. YuNet handles all three.



    This pass is not part of the Creative Effectiveness Index. The index's own two-a-second pass in

    cei_measures.py keeps the cascade, because the corpus was measured with it and must stay so.

    """
    import cv2
    if os.path.exists(YUNET_MODEL):
        try:
            det = cv2.FaceDetectorYN.create(YUNET_MODEL, "", (320, 320), score_threshold=0.6, nms_threshold=0.3, top_k=50)
            return ("yunet", det)
        except Exception as e:
            print(f"[face] YuNet unavailable ({e}); using the Haar cascade", flush=True)
    return ("haar", cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'))


def detect_faces(detector, frame, gray):
    """(x, y, w, h) boxes in the frame's own pixels, from whichever detector get_face_detector chose."""
    import cv2
    kind, det = detector
    if kind == "haar":
        return [tuple(int(v) for v in f) for f in det.detectMultiScale(gray, 1.1, 5, minSize=(60, 60))]
    h, w = frame.shape[:2]
    scale = min(1.0, YUNET_MAX_WIDTH / max(w, 1))
    small = cv2.resize(frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA) if scale < 1 else frame
    det.setInputSize((small.shape[1], small.shape[0]))
    _, faces = det.detect(small)
    out = []
    if faces is not None:
        for f in faces:
            x, y, fw, fh = (float(v) / scale for v in f[:4])
            if fw >= 24 and fh >= 24:
                out.append((max(0, int(x)), max(0, int(y)), int(fw), int(fh)))
    return out

def extract_audio(video_path, audio_path):
    """Returns None on success, or a message explaining the failure.



    The bare `except: return False` this replaced turned every cause — a corrupt upload, a missing

    ffmpeg, a process that could not start at all — into the same unactionable "Failed to extract

    audio" at the API boundary.

    """
    from ffmpeg_util import run_ffmpeg, FFmpegError
    try:
        run_ffmpeg(["-i", video_path, "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", audio_path])
        return None
    except FFmpegError as e:
        print(f"[audio] {e}", flush=True)
        return str(e)
    except Exception as e:
        print(f"[audio] {type(e).__name__}: {e}", flush=True)
        return f"{type(e).__name__}: {e}"

def transcribe(audio_path, brand=None):
    """

    With a brand, its name goes to Whisper as the initial prompt, which biases recognition toward

    the term; a brand name that is sung, or unusual, is otherwise the word most likely to be lost.

    """
    model = get_whisper()
    prompt = f"{brand.split(',')[0].strip()}." if brand and brand.strip() else None
    segments, info = model.transcribe(audio_path, beam_size=5, language="en", initial_prompt=prompt)
    return [{"start": s.start, "end": s.end, "text": s.text.strip(), "confidence": round(math.exp(s.avg_logprob), 4)} for s in segments], info.language

def _voice_result(result):
    """emotion2vec's output for one clip, as the response carries it."""
    if result and len(result) > 0:
        scores = result[0].get("scores", [])
        labels = result[0].get("labels", EMOTION2VEC_LABELS)
        if scores:
            all_emotions = {normalize_emotion_label(labels[i]): round(float(scores[i]), 4) for i in range(len(scores))}
            top_idx = int(np.argmax(scores))
            return {
                "emotion": normalize_emotion_label(labels[top_idx]),
                "confidence": round(float(scores[top_idx]), 4),
                "all_emotions": all_emotions,
            }
    return None


def _voice_clip(model, y, sr):
    """One clip through emotion2vec, from memory.



    The clip used to be written to a temporary WAV and read back. The source is 16-bit PCM, so the

    samples are exact multiples of 1/32768 and that round trip changed nothing; passing the array

    skips a file write and read per segment. A funasr that refuses arrays gets the file instead.

    """
    import soundfile as sf
    try:
        return _voice_result(model.generate(y, fs=sr, granularity="utterance", extract_embedding=False))
    except Exception as e:
        print(f"[voice] array input refused ({type(e).__name__}); using a temporary file", flush=True)
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
        sf.write(f.name, y, sr)
        temp_path = f.name
    try:
        return _voice_result(model.generate(temp_path, granularity="utterance", extract_embedding=False))
    finally:
        os.unlink(temp_path)


def _segment_samples(y, sr, start, end):
    """The samples librosa.load(offset=start, duration=end-start) returns, cut from the whole file."""
    i0 = int(start * sr)
    return y[i0:i0 + int((end - start) * sr)]


def analyze_voice_segments(audio_path, segments):
    """Voice emotion for each segment, reading the audio once.



    Each segment used to reload the file from its own offset and go through a temporary WAV. Same

    samples, same model, same output; one read instead of one per sentence.

    """
    import librosa
    model = get_emotion2vec()
    if not model or not segments:
        return [None] * len(segments)
    y_all, sr = librosa.load(audio_path, sr=16000)
    out = []
    for seg in segments:
        y = _segment_samples(y_all, sr, seg["start"], seg["end"])
        if len(y) < 1600:  # Too short
            out.append(None)
            continue
        try:
            out.append(_voice_clip(model, y, sr))
        except Exception as e:
            print(f"[voice] emotion2vec error: {e}", flush=True)
            out.append(None)
    return out


def analyze_voice_emotion(audio_path, start, end):
    """Voice emotion for one stretch of the audio (the transcription jobs read it one at a time)."""
    return analyze_voice_segments(audio_path, [{"start": start, "end": end}])[0]

# HSEmotion-ONNX labels (correct order: Anger, Contempt, Disgust, Fear, Happiness, Neutral, Sadness, Surprise)
FACE_EMOTION_LABELS = ['angry', 'contempt', 'disgust', 'fear', 'happy', 'neutral', 'sad', 'surprise']
# Face crops sent to HSEmotion in one call. One call per face was most of the cost of the pass on a
# GPU; the scores are the model's own either way (a batch is the same per-image forward pass).
FACE_BATCH = 32


class _FacePass:
    """Faces and their emotions, at fps frames a second of the film. See analyze_faces."""

    def __init__(self, video_fps, fps=0.5, use_emotions=True):
        self.detector = get_face_detector()
        self.emotion_model = get_face_emotion() if use_emotions else None
        self.interval = max(1, int(video_fps / fps))
        self.video_fps = video_fps
        self.timeline = []
        self.pending = []  # (timeline index, face index, crop) awaiting HSEmotion

    def wants(self, frame_num):
        return frame_num % self.interval == 0

    def add(self, frame, frame_num):
        import cv2
        time = frame_num / self.video_fps
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        faces = detect_faces(self.detector, frame, gray)

        emotions = {}
        # Every face's box and reading, in the frame's own pixels, so the player can draw the
        # measurement on the person it was taken from.
        per_face = [{"box": [int(x), int(y), int(w), int(h)], "emotions": {}} for (x, y, w, h) in faces]
        self.timeline.append({"time": time, "faces": len(faces), "emotions": emotions,
                              "size": [int(frame.shape[1]), int(frame.shape[0])], "per_face": per_face})
        if len(faces) > 0 and self.emotion_model:
            for fi, (x, y, w, h) in enumerate(faces):
                # Expand face region slightly for better detection
                pad = int(w * 0.1)
                x1 = max(0, x - pad)
                y1 = max(0, y - pad)
                x2 = min(frame.shape[1], x + w + pad)
                y2 = min(frame.shape[0], y + h + pad)
                crop = frame[y1:y2, x1:x2]
                if crop.size:
                    self.pending.append((len(self.timeline) - 1, fi, crop.copy()))
            if len(self.pending) >= FACE_BATCH:
                self._flush()
        elif len(faces) > 0:
            # Fallback: basic heuristic if HSEmotion not available
            x, y, w, h = faces[0]
            face_roi = gray[y:y+h, x:x+w]
            brightness = face_roi.mean() / 255
            emotions["neutral"] = round(0.5 + brightness * 0.2, 3)

    def _flush(self):
        if not self.pending:
            return
        batch, self.pending = self.pending, []
        try:
            _, scores = self.emotion_model.predict_multi_emotions([c for _, _, c in batch], logits=False)
        except Exception as e:
            print(f"[face] HSEmotion batch error ({e}); scoring faces one at a time", flush=True)
            scores = []
            for _, _, crop in batch:
                try:
                    _, sc = self.emotion_model.predict_emotions(crop, logits=False)
                    scores.append(sc[0] if len(sc.shape) > 1 else sc)
                except Exception as e2:
                    print(f"[face] HSEmotion error for face: {e2}", flush=True)
                    scores.append(None)
        for (ti, fi, _), sc in zip(batch, scores):
            if sc is None or len(sc) == 0:
                continue
            face_emo = {FACE_EMOTION_LABELS[i]: float(sc[i]) for i in range(min(len(sc), len(FACE_EMOTION_LABELS)))}
            self.timeline[ti]["per_face"][fi]["emotions"] = {k: round(v, 4) for k, v in face_emo.items()}
            self.timeline[ti].setdefault("_raw", []).append(face_emo)

    def finish(self):
        if self.emotion_model:
            self._flush()
        # Aggregate: average emotions across all faces in each frame
        for entry in self.timeline:
            raw = entry.pop("_raw", None)
            if raw:
                for label in FACE_EMOTION_LABELS:
                    entry["emotions"][label] = round(sum(f.get(label, 0) for f in raw) / len(raw), 4)
        return self.timeline


def scan_frames(video_path, screen_text=True, faces=True, face_fps=4, use_emotions=True, ocr_fps=1.0, ocr_min_conf=0.4):
    """The on-screen text pass and the face pass from one read of the film.



    Each pass used to open the file and decode every frame itself. They read the same frames they

    always did (each keeps its own sampling interval), from a single decode. Returns

    (text_timeline, face_timeline).

    """
    import cv2
    cap = cv2.VideoCapture(video_path)
    if not cap.isOpened():
        return [], []
    video_fps = cap.get(cv2.CAP_PROP_FPS) or 30
    text_pass = _TextPass(video_fps, ocr_fps, ocr_min_conf) if screen_text else None
    face_pass = _FacePass(video_fps, face_fps, use_emotions) if faces else None
    frame_num = 0
    try:
        while True:
            ret, frame = cap.read()
            if not ret:
                break
            if text_pass and text_pass.wants(frame_num):
                text_pass.add(frame, frame_num)
            if face_pass and face_pass.wants(frame_num):
                face_pass.add(frame, frame_num)
            frame_num += 1
    finally:
        cap.release()
    return (text_pass.finish() if text_pass else []), (face_pass.finish() if face_pass else [])


def analyze_faces(video_path, fps=0.5, use_emotions=True):
    """Analyze faces in video frames with optional HSEmotion"""
    return scan_frames(video_path, screen_text=False, faces=True, face_fps=fps, use_emotions=use_emotions)[1]

def video_duration(video_path):
    """Running time from the container."""
    import cv2
    cap = cv2.VideoCapture(str(video_path))
    try:
        fps = cap.get(cv2.CAP_PROP_FPS) or 0
        frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
        return round(frames / fps, 3) if fps > 0 and frames > 0 else None
    finally:
        cap.release()


def cut_stills(video_path, duration, width=320, max_frames=240):
    """One small JPEG per second of the film, as data URLs.



    The report puts a frame beside every moment it names, and it cuts those frames in the browser

    from the file it already has open. That fails outright on a master the browser cannot decode -

    ProRes, which is what an agency has to hand, or HEVC - and the moments print as "no still". This

    strip is the fallback: the service has the film decoded already, so it costs one seek per

    second. At 320 wide and quality 72 a frame is about 12 KB, so a 30-second film adds about

    500 KB to the response. Sampled mid-second (0.5, 1.5, ...) so a black slate at 0 is never the

    frame chosen. Longer than four minutes, the step widens so the strip stays under max_frames.

    """
    import cv2  # imported per function throughout this module, so a missing wheel fails one pass rather than the service
    if not duration or duration <= 0:
        return []
    step = max(1.0, float(math.ceil(duration / max_frames)))
    cap = cv2.VideoCapture(video_path)
    out = []
    try:
        t = 0.5
        while t < duration:
            cap.set(cv2.CAP_PROP_POS_MSEC, t * 1000.0)
            ok, frame = cap.read()
            if ok and frame is not None and frame.size:
                h, w = frame.shape[:2]
                scale = width / max(w, 1)
                small = cv2.resize(frame, (width, max(1, int(round(h * scale)))), interpolation=cv2.INTER_AREA)
                ok2, buf = cv2.imencode(".jpg", small, [cv2.IMWRITE_JPEG_QUALITY, 72])
                if ok2:
                    out.append({"time": round(t, 2),
                                "jpeg": "data:image/jpeg;base64," + base64.b64encode(buf.tobytes()).decode("ascii")})
            t += step
    finally:
        cap.release()
    return out


def decodable_copy(video_path):
    """The path the frame passes should read: the upload itself, unless OpenCV cannot decode it.



    OpenCV's Linux wheel ships an ffmpeg with no software AV1 decoder, and YouTube serves a third

    of its downloads as AV1. Every frame pass then returned nothing at all, quietly - "Your

    platform doesn't support hardware accelerated AV1 decoding" in the log, and a film reported as

    measured with no faces in it. Probed by reading one frame. On failure the system ffmpeg, which

    does decode AV1, writes an H.264 copy for the frame passes to read; audio still comes from the

    original, and the copy is the caller's to delete. Returns (path_to_read, copy_to_delete).

    """
    import cv2
    cap = cv2.VideoCapture(video_path)
    try:
        ok, frame = cap.read()
    finally:
        cap.release()
    if ok and frame is not None and frame.size:
        return video_path, None
    from ffmpeg_util import run_ffmpeg, FFmpegError
    copy = video_path + ".h264.mp4"
    print("[process] OpenCV could not decode a frame; re-encoding to H.264 for the frame passes", flush=True)
    try:
        # crf 16 is visually lossless for a face pass that downsamples anyway; veryfast because the
        # point is to get frames at all, not to archive them.
        encode_h264(["-i", video_path, "-an"], 16, ["-pix_fmt", "yuv420p", copy])
    except FFmpegError as e:
        print(f"[process] re-encode failed: {e}", flush=True)
        MODEL_ERRORS["decode"] = f"FFmpegError: {e}"
        return video_path, None
    return copy, copy


_NVENC = None


def nvenc_available():
    """Whether ffmpeg can encode H.264 on the GPU here: listed, and a one-frame test encode works.



    Checked once. The encoder needs the driver's video capability in the container

    (NVIDIA_DRIVER_CAPABILITIES includes video, set in the Dockerfile); without it ffmpeg lists

    h264_nvenc and then fails to open it, which is why this tries an encode rather than trusting the list.

    """
    global _NVENC
    if _NVENC is None:
        _NVENC = False
        if torch.cuda.is_available():
            try:
                from ffmpeg_util import run_ffmpeg
                run_ffmpeg(["-f", "lavfi", "-i", "color=c=black:s=256x256:d=0.1", "-frames:v", "1",
                            "-c:v", "h264_nvenc", "-f", "null", "-"], timeout=60)
                _NVENC = True
            except Exception as e:
                print(f"[encode] GPU H.264 encoder unavailable, using libx264: {e}", flush=True)
        print(f"[encode] H.264 encoder: {'h264_nvenc' if _NVENC else 'libx264'}", flush=True)
    return _NVENC


def h264_args(crf, preset="veryfast"):
    """Encoder arguments for an H.264 copy at about the quality of libx264 at this crf."""
    if nvenc_available():
        return ["-c:v", "h264_nvenc", "-preset", "p4", "-rc", "vbr", "-cq", str(crf), "-b:v", "0"]
    return ["-c:v", "libx264", "-preset", preset, "-crf", str(crf)]


def encode_h264(args_before, crf, args_after, timeout=None):
    """run_ffmpeg with an H.264 video encoder, on the GPU when it can; falls back to libx264 if the GPU encode fails."""
    from ffmpeg_util import run_ffmpeg
    try:
        return run_ffmpeg(args_before + h264_args(crf) + args_after, **({"timeout": timeout} if timeout else {}))
    except Exception:
        if not nvenc_available():
            raise
        print("[encode] GPU encode failed; retrying with libx264", flush=True)
        return run_ffmpeg(args_before + ["-c:v", "libx264", "-preset", "veryfast", "-crf", str(crf)] + args_after,
                          **({"timeout": timeout} if timeout else {}))


PLAYABLE_CODECS = {"h264", "vp8", "vp9", "av1"}
# A proxy bigger than this is left out of the response rather than making it unwieldy.
PROXY_MAX_BYTES = 40 * 1024 * 1024


def probe_codec(video_path):
    """codec_name and pix_fmt of the first video stream, from the system ffprobe; (None, None) if it cannot say."""
    try:
        from ffmpeg_util import ffmpeg_exe
        exe = shutil.which("ffprobe") or os.path.join(os.path.dirname(ffmpeg_exe()), "ffprobe")
        r = subprocess.run([exe, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,pix_fmt",
                            "-of", "csv=p=0", video_path], capture_output=True, text=True, timeout=60)
        parts = (r.stdout.strip().splitlines() or [""])[0].split(",")
        return (parts[0] or None, parts[1] if len(parts) > 1 and parts[1] else None)
    except Exception:
        return (None, None)


def make_proxy(video_path, force=False):
    """A browser-playable copy of a master the browser cannot decode, or None when the upload already is one.



    Agencies send ProRes. A browser plays its audio track over a black frame, the thumbnail comes out

    black, and the report cannot cut a still from it. This writes a 720p H.264 with AAC audio, the

    one format every browser plays, and hands it back base64 in the response so the app can play it,

    cut frames from it and store it as the playable asset beside the original. A master that is

    already H.264, VP8, VP9 or AV1 needs none of this and gets None.

    """
    from ffmpeg_util import run_ffmpeg
    codec, pix = probe_codec(video_path)
    if not force and codec in PLAYABLE_CODECS and (pix or "yuv420p") == "yuv420p":
        return None
    out = video_path + ".proxy.mp4"
    print(f"[proxy] {codec or 'unknown'} {pix or ''} is not browser-playable; writing a 720p H.264 proxy", flush=True)
    # Video and audio only, and no timecode: a ProRes master carries a timecode, and the mp4 muxer
    # writes it back as a data track that shows up as an unknown stream some browsers refuse the
    # whole file for. Mapping the streams is not enough, because the muxer takes it from metadata.
    encode_h264(["-i", video_path, "-map", "0:v:0", "-map", "0:a?", "-dn", "-sn", "-map_metadata", "-1",
                 "-write_tmcd", "0", "-vf", "scale=-2:min(720\\,ih)"], 23,
                ["-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", out],
                timeout=900)
    try:
        size = os.path.getsize(out)
        if size > PROXY_MAX_BYTES:
            MODEL_ERRORS["proxy"] = f"proxy is {size // 1_000_000} MB, over the {PROXY_MAX_BYTES // 1_000_000} MB the response carries"
            return None
        with open(out, "rb") as fh:
            data = base64.b64encode(fh.read()).decode("ascii")
    finally:
        if os.path.exists(out):
            os.unlink(out)
    print(f"[proxy] {size // 1000} KB", flush=True)
    return {"mp4_base64": data, "bytes": size, "source_codec": f"{codec or 'unknown'}{(' ' + pix) if pix else ''}"}


def process_video(video_path, emotions=True, vocal_emotion=True, screen_text=True, brand=None, cei=False, stills=False, proxy=False, memorability=False):
    with _ANALYSE:
        for key in _PER_REQUEST_ERRORS:
            MODEL_ERRORS.pop(key, None)
        if memorability_mod is not None:
            MODEL_ERRORS.pop("memorability", None)
        return _process_video(video_path, emotions, vocal_emotion, screen_text, brand, cei, stills, proxy, memorability)


def _process_video(video_path, emotions, vocal_emotion, screen_text, brand, cei, stills, proxy, memorability):
    audio_path = video_path + ".wav"

    audio_error = extract_audio(video_path, audio_path)
    if audio_error:
        return {"success": False, "error": f"Failed to extract audio. {audio_error}"}

    # See decodable_copy. Everything that reads frames reads frames_path; duration and audio come
    # from the upload itself.
    frames_path, frames_copy = decodable_copy(video_path)

    try:
        # From the container, read once. See below for why not from the transcript.
        container_duration = video_duration(video_path)

        # The passes run side by side rather than one after another. The sound (Whisper, then
        # emotion2vec per segment) needs nothing from the pictures; the text-and-faces read, the
        # Creative Effectiveness pass and memorability each read the film themselves. Only
        # memorability waits, for the transcript its words channel scores. Each pass is the same
        # code on the same input as before, so a film measures the same; it just takes the time of
        # the longest branch rather than the sum of all of them.
        def sound():
            print("[process] Transcribing...", flush=True)
            segs, lang = transcribe(audio_path, brand)
            voices = []
            if vocal_emotion and segs:
                print("[process] Analyzing voice emotions (emotion2vec)...", flush=True)
                voices = analyze_voice_segments(audio_path, segs)
            return segs, lang, voices

        def pictures():
            if screen_text:
                print("[process] Reading on-screen text...", flush=True)
            print(f"[process] Analyzing faces (emotions={emotions})...", flush=True)
            return scan_frames(frames_path, screen_text=screen_text, faces=True, face_fps=4, use_emotions=emotions)

        def effectiveness():
            # Creative Effectiveness Index inputs. A separate pass on purpose: it samples at 2fps
            # through DeepFace because the 138-video corpus the index compares against was scored
            # that way, and a measure taken with this service's 4fps HSEmotion pass would not sit
            # on the same scale as the thresholds.
            try:
                import cei_measures
                # DeepFace pulls in TensorFlow on first use. Imported under the load lock, as every
                # model is: two threads importing torch, transformers and TensorFlow at once is what
                # left "KeyError: 'torch.distributed.fsdp'" and dropped whole passes.
                with _LOAD:
                    cei_measures._deepface()
                print("[process] Measuring for the Creative Effectiveness Index...", flush=True)
                return cei_measures.measure(frames_path, audio_path)
            except Exception as e:
                import traceback
                print(f"[cei] failed: {e}", flush=True)
                print(traceback.format_exc(), flush=True)
                MODEL_ERRORS["cei"] = f"{type(e).__name__}: {e}"
                return None

        def recall(sound_future):
            # Memorability: predicted brand recall a day or more after viewing, from the pictures
            # and the cutting alone. A model fitted to LAMBDA, 2,183 real ads whose viewers were
            # asked 24 to 72 hours later which brands they remembered (see memorability.py). Its
            # own pass because it reads the film at 1fps through CLIP, which none of the others do.
            try:
                segs = sound_future.result()[0]
                print("[process] Reading memorability...", flush=True)
                models, predictor, text_model = get_memorability()
                out = memorability_mod.read_memorability(
                    frames_path, models, predictor, container_duration,
                    transcript=" ".join(seg.get("text", "") for seg in segs) if segs else "",
                    brand=(brand or "").split(",")[0].strip() or None, text_model=text_model)
                if out.get("error"):
                    MODEL_ERRORS["memorability"] = out["error"]
                return out
            except Exception as e:
                import traceback
                print(f"[memorability] failed: {e}", flush=True)
                print(traceback.format_exc(), flush=True)
                MODEL_ERRORS["memorability"] = f"{type(e).__name__}: {e}"
                return None

        def playable():
            # A playable copy for the browser; see make_proxy. Never fatal. Alongside the rest
            # because a ProRes master's re-encode is minutes on its own.
            try:
                return make_proxy(video_path)
            except Exception as e:
                print(f"[proxy] failed: {e}", flush=True)
                MODEL_ERRORS["proxy"] = f"{type(e).__name__}: {e}"
                return None

        with ThreadPoolExecutor(max_workers=5, thread_name_prefix="analyse") as pool:
            sound_f = pool.submit(sound)
            pictures_f = pool.submit(pictures)
            cei_f = pool.submit(effectiveness) if cei else None
            recall_f = pool.submit(recall, sound_f) if memorability else None
            proxy_f = pool.submit(playable) if proxy else None
            segments, language, voices = sound_f.result()
            text_timeline, face_timeline = pictures_f.result()
            cei_metrics = cei_f.result() if cei_f else None
            memorability_out = recall_f.result() if recall_f else None
            proxy_out = proxy_f.result() if proxy_f else None

        # A film with no speech used to return before any of the frame passes: a music-only
        # advert, which is most of the effectiveness corpus, came back as a 0-second film with
        # nothing measured and no error. Every pass tolerates an empty transcript, so a silent
        # film goes through like any other and its voice measures are simply absent.
        if not segments:
            print("[process] No speech; measured the pictures anyway", flush=True)

        # The emotional content of the copy, scored once per distinct line.
        copy_emotion = []
        if screen_text and text_timeline:
            seen, distinct = set(), []
            for f in text_timeline:
                t = (f.get("text") or "").strip()
                key = t.lower()
                if t and key not in seen:
                    seen.add(key)
                    distinct.append(t)
            print(f"[process] Scoring {len(distinct)} lines of on-screen copy...", flush=True)
            copy_emotion = score_lines(distinct)

        timeline = []
        for i, seg in enumerate(segments):
            entry = {
                "start_time": seg["start"],
                "end_time": seg["end"],
                "text": seg["text"],
            }
            # Voice emotion (emotion2vec)
            voice_emo = voices[i] if i < len(voices) else None
            if voice_emo:
                entry["voice_emotion"] = voice_emo

            # Find closest face frame
            seg_mid = (seg["start"] + seg["end"]) / 2
            closest = min(face_timeline, key=lambda f: abs(f["time"] - seg_mid), default=None)
            if closest and abs(closest["time"] - seg_mid) < 2:
                entry["faces_detected"] = closest["faces"]
                if closest["emotions"]:
                    entry["facial_emotions"] = closest["emotions"]
            timeline.append(entry)

        # From the container, not from the last speech segment. Deriving it from the transcript
        # made a 20-second advert report as 16 seconds whenever nobody spoke near the end, and
        # every percentage-of-runtime figure moved with it between runs on the same file.
        duration = container_duration or (max(t["end_time"] for t in timeline) if timeline else 0)
        transcript = " ".join(t["text"] for t in timeline)

        features_enabled = ["transcription"]
        if vocal_emotion:
            features_enabled.append("vocal_emotion")
        if emotions:
            features_enabled.append("facial_emotions")

        # Stills for the report's moment cards; see cut_stills. Never fatal: a film with no
        # pictures against its moments is still a measured film.
        stills_out = None
        if stills:
            try:
                print("[process] Cutting stills...", flush=True)
                stills_out = cut_stills(frames_path, duration)
                print(f"[process] {len(stills_out)} stills", flush=True)
            except Exception as e:
                print(f"[stills] failed: {e}", flush=True)
                MODEL_ERRORS["stills"] = f"{type(e).__name__}: {e}"

        return {
            "success": True,
            "timeline": timeline,
            # Facial emotion at the sampling rate, not collapsed onto speech segments. The timeline
            # above has one entry per thing that was said, so an advert with music and no voiceover
            # produced a single data point from sixty-odd measured frames — no curve, no peak, and
            # a report with nothing in it. The visual channel is the whole content of that advert.
            "face_timeline": face_timeline,
            "text_timeline": text_timeline,
            "screen_text_full": " ".join(f["text"] for f in text_timeline),
            "copy_emotion": copy_emotion,
            "cei": cei_metrics,
            "memorability": memorability_out,
            "stills": stills_out,
            "proxy": proxy_out,
            "brand_presence": brand_presence(text_timeline, transcript, duration, brand),
            "transcript_full": transcript,
            "duration": duration,
            "metadata": {
                "language": language,
                "segments": len(timeline),
                # What was asked for. `analysers` below is what actually ran — the two differ
                # whenever an optional model is missing, and only the second one is a fact.
                "features_enabled": features_enabled,
                "analysers": {
                    "transcription": {"ran": True},
                    "voice_emotion": {
                        "ran": vocal_emotion and "voice_emotion" not in MODEL_ERRORS,
                        "requested": bool(vocal_emotion),
                        "error": MODEL_ERRORS.get("voice_emotion"),
                    },
                    "face_emotion": {
                        "ran": emotions and "face_emotion" not in MODEL_ERRORS,
                        "requested": bool(emotions),
                        "error": MODEL_ERRORS.get("face_emotion"),
                    },
                    "screen_text": {
                        "ran": screen_text and "screen_text" not in MODEL_ERRORS,
                        "requested": bool(screen_text),
                        "error": MODEL_ERRORS.get("screen_text"),
                    },
                    "copy_emotion": {
                        "ran": bool(copy_emotion),
                        "requested": bool(screen_text),
                        "error": MODEL_ERRORS.get("copy_emotion"),
                    },
                    "cei": {
                        "ran": cei_metrics is not None,
                        "requested": bool(cei),
                        "error": MODEL_ERRORS.get("cei"),
                    },
                    "memorability": {
                        "ran": memorability_out is not None and "memorability" not in MODEL_ERRORS,
                        "requested": bool(memorability),
                        "error": MODEL_ERRORS.get("memorability"),
                    },
                },
                "degraded": sorted(MODEL_ERRORS.keys()),
            }
        }
    finally:
        if os.path.exists(audio_path):
            os.unlink(audio_path)
        if frames_copy and os.path.exists(frames_copy):
            os.unlink(frames_copy)

@app.get("/")
def root():
    return {"service": "EchoDepth Video Analysis", "version": "4.2-emotion2vec", "gpu": torch.cuda.is_available()}

@app.get("/health")
def health():
    return {"status": "ok", "gpu": torch.cuda.is_available(), "auth": "enforced" if service_auth.enforcing() else "off"}

@app.get("/models")
def models_status():
    """Check which models are loaded/available"""
    import traceback
    status = {
        "whisper": "not_loaded",
        "hsemotion": "not_loaded",
        "emotion2vec": "not_loaded",
        "errors": [],
        "gpu": torch.cuda.is_available(),
        "gpu_name": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None
    }

    # Check whisper
    try:
        model = get_whisper()
        status["whisper"] = "loaded" if model else "failed"
    except Exception as e:
        status["whisper"] = "error"
        status["errors"].append(f"whisper: {str(e)}")

    # Check HSEmotion
    try:
        model = get_face_emotion()
        status["hsemotion"] = "loaded" if model else "failed"
        if not model:
            status["errors"].append("hsemotion: model returned None (check logs)")
    except Exception as e:
        status["hsemotion"] = "error"
        status["errors"].append(f"hsemotion: {str(e)}\n{traceback.format_exc()}")

    # Check emotion2vec
    try:
        model = get_emotion2vec()
        status["emotion2vec"] = "loaded" if model else "failed"
    except Exception as e:
        status["emotion2vec"] = "error"
        status["errors"].append(f"emotion2vec: {str(e)}")

    # Where each pass runs: the check that the GPU is being used, not just present
    face = face_emotion_model if face_emotion_model not in (None, "failed") else None
    status["devices"] = {
        "whisper": "cuda" if torch.cuda.is_available() else "cpu",
        "face_emotion": face.ort_session.get_providers()[0] if face else None,
        "screen_text": ("cuda" if torch.cuda.is_available() else "cpu") if ocr_reader not in (None, "failed") else None,
        "h264_encoder": "h264_nvenc" if nvenc_available() else "libx264",
        "cei_deepface": "cpu (deliberately; see cei_measures._deepface)",
    }
    return status

@app.post("/score-text")
def score_text(payload: dict):
    """Score arbitrary lines of copy on the same scale as the copy read off the film.



    Exists so a rewritten call to action is measured by the model that judged the original, rather

    than against a second opinion that cannot be compared with the first.

    """
    lines = payload.get("lines") or []
    if not isinstance(lines, list):
        return {"success": False, "error": "lines must be an array of strings"}
    scored = score_lines([str(l) for l in lines[:40]])
    return {"success": True, "scored": scored, "error": MODEL_ERRORS.get("copy_emotion")}


@app.post("/memorability-words")
def memorability_words(payload: dict):
    """

    Score different words against the same pictures. `channels` is the per-channel prediction the

    film's own read returned; only the words channel is recomputed from `transcript`. For scoring a

    rewritten script against the film as shot.

    """
    transcript = (payload.get("transcript") or "").strip()
    channels = payload.get("channels") or {}
    if not channels:
        return {"success": False, "error": "channels from the film's memorability read are required"}
    try:
        _, predictor, text_model = get_memorability()
        if text_model is None or "words" not in predictor.channels:
            return {"success": False, "error": "this model has no words channel"}
        return {"success": True, **memorability_mod.score_words(predictor, text_model, transcript, payload.get("brand"), channels)}
    except Exception as e:
        return {"success": False, "error": f"{type(e).__name__}: {e}"}

@app.post("/proxy")
def proxy_only(file: UploadFile = File(...)):
    """A playable 720p H.264 copy of an upload, and nothing else.



    For attaching a video to an analysis that was saved without one: the film was measured, the

    stored copy failed or was never made, and re-running eleven minutes of analysis to get a

    player back is the wrong price. Always transcodes, so the result is small and plays anywhere

    whatever came in.

    """
    print(f"[API] Proxy only: {file.filename}", flush=True)
    with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp:
        shutil.copyfileobj(file.file, tmp)
        video_path = tmp.name
    try:
        MODEL_ERRORS.pop("proxy", None)
        out = make_proxy(video_path, force=True)
        return {"success": out is not None, "proxy": out, "error": MODEL_ERRORS.get("proxy")}
    except Exception as e:
        return {"success": False, "proxy": None, "error": f"{type(e).__name__}: {e}"}
    finally:
        if os.path.exists(video_path):
            os.unlink(video_path)


# A plain def, like the other upload routes: FastAPI runs it on its thread pool. As an async def it
# ran the whole analysis on the event loop, so /health, the transcription and Stage Read polls and
# every other request waited until the film was done.
@app.post("/analyze")
def analyze(

    file: UploadFile = File(...),

    emotions: bool = Query(True, description="Enable facial emotion detection (HSEmotion)"),

    vocal_emotion: bool = Query(True, description="Enable voice emotion detection (emotion2vec)"),

    screen_text: bool = Query(True, description="Read on-screen copy (EasyOCR)"),

    brand: str = Query(None, description="Brand name to look for on screen and in speech"),

    cei: bool = Query(False, description="Measure for the Creative Effectiveness Index (DeepFace, 2fps)"),

    stills: bool = Query(False, description="Return one 320px JPEG per second, for reports on a master the browser cannot decode"),

    proxy: bool = Query(False, description="Return a 720p H.264 copy when the upload is not browser-playable (ProRes, HEVC)"),

    memorability: bool = Query(False, description="Predict brand recall a day later from the pictures (CLIP at 1fps, model fitted to LAMBDA)")

):
    print(f"[API] Received: {file.filename} (emotions={emotions}, vocal_emotion={vocal_emotion}, screen_text={screen_text}, brand={brand}, cei={cei}, stills={stills}, proxy={proxy}, memorability={memorability})", flush=True)
    with tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") as tmp:
        shutil.copyfileobj(file.file, tmp)
        video_path = tmp.name
    try:
        return process_video(video_path, emotions=emotions, vocal_emotion=vocal_emotion, screen_text=screen_text, brand=brand, cei=cei, stills=stills, proxy=proxy, memorability=memorability)
    finally:
        if os.path.exists(video_path):
            os.unlink(video_path)

# ------------------------------------------------------------------------------------------------
# Transcription jobs: Voice Analysis, Practice and script audio.
#
# These used to go to AssemblyAI, which trains on customer audio unless opted out and keeps
# transcripts until they are deleted. They now run here, behind the app's signed pass: Whisper for
# the words, emotion2vec for the voice, GoEmotions for what the words mean.
#
# A job, not a request: on CPU hardware Whisper medium runs at around real time, so a few minutes
# of audio outlasts any sensible HTTP timeout. The caller posts the file and polls.
#
# RETENTION. The upload and its audio are deleted as soon as the job finishes, pass or fail. The
# result is held in memory for TRANSCRIBE_TTL_S so the caller can collect it, then dropped. Nothing
# is written anywhere else.
# ------------------------------------------------------------------------------------------------
import re as _re
import threading as _threading
import time as _time
import uuid as _uuid
from fastapi import HTTPException as _HTTPException

TRANSCRIBE_JOBS: dict = {}
TRANSCRIBE_TTL_S = 3600
_TRANSCRIBE_RUN = _threading.Lock()  # one at a time: the CPU is shared with /analyze
_JOB_ID = _re.compile(r"^[0-9a-f]{32}$")


def _text_sentiment(score):
    """AssemblyAI-style POSITIVE / NEGATIVE / NEUTRAL from GoEmotions, with a strength 0-1.



    Valence is the emotion-weighted pleasantness (0.5 is neither); feel is how much emotion the

    words carry at all. A line with little feeling is NEUTRAL whatever its lean.

    """
    if not score:
        return "NEUTRAL", 0.0
    valence, feel = score["valence"], score["feel"]
    lean = (valence - 0.5) * 2  # -1 .. 1
    if feel < 0.25 or abs(lean) < 0.15:
        return "NEUTRAL", round(1 - feel, 3)
    return ("POSITIVE" if lean > 0 else "NEGATIVE"), round(min(1.0, abs(lean) * 0.5 + feel * 0.5), 3)


def _sentences(audio_path):
    """Whisper's words regrouped into sentences, each with its own start and end.



    Whisper segments by pauses, so one segment can hold a compliment and a complaint, which then

    cancel out to NEUTRAL. The voice page charts sentiment sentence by sentence, as AssemblyAI

    returned it, so the words are split at sentence ends (or a long pause) instead.

    """
    model = get_whisper()
    # vad_filter skips the silent stretches of a recording rather than decoding them. Spoken audio
    # only: /analyze keeps it off, because a sung brand line is the word most likely to be lost and
    # the voice activity detector is built for speech.
    segments, info = model.transcribe(audio_path, beam_size=5, language="en", word_timestamps=True, vad_filter=True)
    out, words, confs = [], [], []

    def flush():
        if words:
            text = "".join(w.word for w in words).strip()
            if text:
                out.append({"start": words[0].start, "end": words[-1].end, "text": text,
                            "confidence": round(sum(confs) / len(confs), 4) if confs else None})
        words.clear(); confs.clear()

    for seg in segments:
        for w in (seg.words or []):
            if words and w.start - words[-1].end > 1.5:
                flush()
            words.append(w)
            confs.append(float(w.probability))
            if w.word.strip().endswith((".", "?", "!")):
                flush()
    flush()
    return out, info.language


def _sweep_transcribe_jobs():
    cutoff = _time.time() - TRANSCRIBE_TTL_S
    for job_id in [k for k, j in TRANSCRIBE_JOBS.items() if j.get("finished_at") and j["finished_at"] < cutoff]:
        TRANSCRIBE_JOBS.pop(job_id, None)


def _fast_segments(audio_path):
    """Whisper small, greedy, silence skipped, no word timings: the fast profile's words."""
    segments, info = get_whisper_fast().transcribe(
        audio_path, beam_size=1, language="en", vad_filter=True, condition_on_previous_text=False,
    )
    return [{"start": s.start, "end": s.end, "text": s.text.strip(), "confidence": None}
            for s in segments if s.text.strip()], info.language


def _run_transcription(job_id, src_path, vocal, fast=False):
    """fast: the Reflections profile. Whisper small and greedy, no per-sentence text sentiment,

    and one voice-emotion read over the whole answer instead of one per sentence."""
    job = TRANSCRIBE_JOBS[job_id]
    audio_path = src_path + ".wav"
    try:
        with _TRANSCRIBE_RUN:
            job["status"] = "running"
            err = extract_audio(src_path, audio_path)
            if err:
                raise RuntimeError(f"Could not read the audio: {err}")
            import soundfile as sf
            duration = float(sf.info(audio_path).duration)
            if fast:
                # The words are what the speaker waits for, so the job is done as soon as they are;
                # the voice read follows in the same job, and voice_pending says when it has landed.
                segments, language = _fast_segments(audio_path)
                out = [{
                    "start": round(seg["start"], 3), "end": round(seg["end"], 3), "text": seg["text"],
                    "sentiment": "NEUTRAL", "sentiment_confidence": 0.0, "asr_confidence": None,
                    "voice_emotion": None,
                } for seg in segments]
                result = {
                    "text": " ".join(s["text"] for s in out).strip(),
                    "language": language,
                    "duration": round(duration, 3),
                    "confidence": None,
                    "segments": out,
                    "engine": "whisper-small-fast+emotion2vec",
                    "voice_pending": bool(vocal and segments),
                }
                job.update(status="done", result=result)
                if result["voice_pending"]:
                    try:
                        voice = analyze_voice_emotion(audio_path, 0, duration)
                        for seg in out:
                            seg["voice_emotion"] = voice
                    finally:
                        result["voice_pending"] = False
                return
            segments, language = _sentences(audio_path)
            out = []
            for seg in segments:
                scored = score_lines([seg["text"]])
                sentiment, strength = _text_sentiment(scored[0] if scored else None)
                voice = analyze_voice_emotion(audio_path, seg["start"], seg["end"]) if vocal else None
                out.append({
                    "start": round(seg["start"], 3), "end": round(seg["end"], 3), "text": seg["text"],
                    "sentiment": sentiment, "sentiment_confidence": strength,
                    "asr_confidence": seg.get("confidence"),
                    "voice_emotion": voice,
                })
            confs = [s["asr_confidence"] for s in out if s["asr_confidence"] is not None]
            job.update(status="done", result={
                "text": " ".join(s["text"] for s in out).strip(),
                "language": language,
                "duration": round(duration, 3),
                "confidence": round(sum(confs) / len(confs), 4) if confs else None,
                "segments": out,
                "engine": "whisper-medium+emotion2vec+goemotions",
            })
    except Exception as e:
        print(f"[transcribe] job failed: {type(e).__name__}: {e}", flush=True)
        job.update(status="error", error=f"{e}")
    finally:
        job["finished_at"] = _time.time()
        for p in (src_path, audio_path):
            try:
                if os.path.exists(p):
                    os.unlink(p)
            except OSError:
                pass


def _warm_fast_profile():
    """Load the models as the Space starts, so the first job after a wake does not wait for them.



    The fast transcription profile first, because a speaker is waiting on it; then everything

    /analyze uses. The weights are in the image (prefetch_models.py), so this is loading, not

    downloading. In the background: the service answers meanwhile, and the load lock means a

    request that arrives first simply waits for the model it needs.

    """
    try:
        get_whisper_fast()
        get_emotion2vec()
        print("[whisper] fast profile ready", flush=True)
    except Exception as e:
        print(f"[whisper] fast profile warm-up failed: {e}", flush=True)
    for name, load in (("whisper medium", get_whisper), ("HSEmotion", get_face_emotion), ("EasyOCR", get_ocr),
                       ("GoEmotions", get_text_emotion), ("memorability", get_memorability)):
        try:
            load()
            print(f"[warm] {name} ready", flush=True)
        except Exception as e:
            print(f"[warm] {name} failed: {e}", flush=True)
    try:
        import cei_measures
        with _LOAD:
            cei_measures.warm()
        print("[warm] DeepFace ready", flush=True)
    except Exception as e:
        print(f"[warm] DeepFace failed: {e}", flush=True)
    nvenc_available()


if os.environ.get("ECHODEPTH_NO_WARMUP") != "1":
    _threading.Thread(target=_warm_fast_profile, daemon=True).start()


@app.post("/transcribe")
def transcribe_submit(file: UploadFile = File(...), vocal_emotion: bool = Query(True),

                            profile: str = Query("full", description="full, or fast for short answers the speaker corrects")):
    """Start a transcription job. Returns {job_id}; poll GET /transcribe/{job_id}."""
    _sweep_transcribe_jobs()
    suffix = os.path.splitext(file.filename or "")[1] or ".bin"
    with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
        shutil.copyfileobj(file.file, tmp)
        src_path = tmp.name
    job_id = _uuid.uuid4().hex
    TRANSCRIBE_JOBS[job_id] = {"status": "queued", "created_at": _time.time()}
    _threading.Thread(target=_run_transcription, args=(job_id, src_path, vocal_emotion, profile == "fast"), daemon=True).start()
    return {"job_id": job_id, "status": "queued"}


@app.get("/transcribe/{job_id}")
def transcribe_status(job_id: str):
    if not _JOB_ID.match(job_id or ""):
        raise _HTTPException(400, "bad job id")
    job = TRANSCRIBE_JOBS.get(job_id)
    if not job:
        raise _HTTPException(404, "unknown or expired job")
    out = {"status": job["status"]}
    if job["status"] == "done":
        out["result"] = job["result"]
    if job["status"] == "error":
        out["error"] = job.get("error")
    return out


# Stage Read (multi-speaker stage measurement) — see docs/stage-read-spec.md. Optional: the service
# still starts if its extra dependencies are missing.
try:
    from stage_read.api import router as stage_read_router
    app.include_router(stage_read_router)
    print("[STARTUP] Stage Read router mounted", flush=True)
except Exception as _e:
    print(f"[STARTUP] Stage Read unavailable: {_e}", flush=True)

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=7860)