File size: 52,405 Bytes
592de64
 
 
 
 
 
 
 
 
a071eaf
 
592de64
 
 
 
 
 
 
 
f514c45
592de64
 
 
 
d689491
ef7986f
 
592de64
6809da8
592de64
 
9d7b599
6809da8
d1e2705
a66365f
 
592de64
 
 
 
 
 
 
 
 
 
 
 
 
 
d689491
6f6bf2c
d689491
 
592de64
 
 
 
 
 
d1ceac4
592de64
 
ce4b4fb
d1ceac4
 
592de64
 
 
 
 
 
 
 
a1b8590
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592de64
 
ce4b4fb
 
 
 
 
 
 
592de64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ce4b4fb
592de64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dffa291
 
99cf4c0
 
 
 
 
 
 
 
 
592de64
 
 
 
 
 
 
 
 
 
 
 
d1ceac4
592de64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ef7986f
 
 
 
 
 
 
 
 
 
 
 
 
592de64
 
 
 
 
 
 
 
 
 
 
 
 
d689491
 
592de64
 
 
 
 
 
d689491
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a071eaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d86ab1e
a071eaf
 
 
 
 
 
 
 
 
 
d1e2705
a071eaf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592de64
 
 
 
 
 
 
2ebe600
a1b8590
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592de64
 
 
a071eaf
 
 
d1e2705
 
 
 
 
a071eaf
592de64
 
d689491
592de64
 
d1e2705
 
 
 
592de64
 
61f39da
592de64
2ebe600
592de64
 
2ebe600
592de64
 
c553d42
 
 
 
592de64
 
61f39da
592de64
 
 
 
 
61f39da
592de64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ef7986f
592de64
 
 
ef7986f
592de64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d689491
 
 
 
 
 
 
 
 
6f6bf2c
 
d689491
 
 
 
592de64
 
 
 
 
0a19e69
 
592de64
61f39da
 
592de64
 
61f39da
 
 
592de64
61f39da
592de64
 
 
d689491
 
592de64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d689491
592de64
 
d689491
592de64
d689491
592de64
61f39da
 
d689491
 
 
592de64
d689491
 
 
 
2ebe600
592de64
61f39da
592de64
 
 
 
 
61f39da
592de64
 
61f39da
 
592de64
 
 
e7269f1
592de64
e7269f1
592de64
 
 
 
 
 
e7269f1
61f39da
592de64
 
 
 
e7269f1
592de64
61f39da
592de64
 
 
 
 
 
 
e7269f1
 
61f39da
e7269f1
 
 
 
592de64
 
 
 
e7269f1
592de64
 
 
 
 
61f39da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6809da8
 
 
 
 
61f39da
 
6809da8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61f39da
 
a66365f
 
592de64
 
 
 
 
692e287
 
 
 
 
d1ceac4
0a19e69
d1ceac4
692e287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d1ceac4
692e287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d1ceac4
692e287
 
 
 
 
 
 
 
 
d1ceac4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d1e2705
 
 
 
 
 
 
 
d1ceac4
 
 
 
 
 
 
 
d1e2705
 
d1ceac4
 
 
 
 
 
 
 
 
 
692e287
6809da8
d1ceac4
692e287
 
 
 
 
6809da8
d1ceac4
6809da8
 
 
 
692e287
 
 
592de64
 
e130b68
592de64
 
 
2ebe600
 
d689491
2ebe600
82b44e7
2ebe600
 
 
 
 
 
 
592de64
d1e2705
592de64
 
692e287
 
592de64
 
 
 
 
 
 
 
 
eb5c1fd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ebe600
 
 
 
692e287
d1e2705
 
 
 
 
 
592de64
 
d1e2705
 
 
592de64
 
 
eb5c1fd
592de64
 
df39d85
 
 
 
 
 
 
 
 
 
 
 
592de64
 
 
 
61f39da
692e287
592de64
 
 
 
 
a66365f
592de64
 
a66365f
 
 
d689491
 
 
 
a66365f
d689491
 
9d7b599
 
 
 
 
d689491
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
import hmac
import hashlib
import importlib.util
import json
import logging
import math
import os
import re
import shutil
import subprocess
import threading
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from functools import lru_cache
from pathlib import Path
from urllib.request import urlretrieve
from uuid import UUID, uuid5
from zipfile import ZipFile

import httpx
import numpy as np
import cv2
from email.message import Message
from email.utils import collapse_rfc2231_value
from dotenv import load_dotenv
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
from huggingface_hub.errors import RemoteEntryNotFoundError
from storage_listing import session_files
from playback_timing import action_times, frame_times
from video_stream import video_response
from starlette.concurrency import run_in_threadpool

load_dotenv(Path(__file__).with_name(".env"))
jobs = {}
executor = ThreadPoolExecutor(max_workers=1)
logger = logging.getLogger("sporalize")
POSE_MODEL_FILE = "Weights/rtmpose-x-halpe26.onnx"
POSE_MODEL_REPO_ID = ""
POSE_MODEL_HUB_FILE = "rtmpose-x-halpe26.onnx"
POSE_MODEL_REPO_TYPE = "model"
YOLO_MODEL_FILE = "Weights/yolo11x.pt"
YOLO_MODEL_REPO_ID = "Ultralytics/YOLO11"
YOLO_MODEL_HUB_FILE = "yolo11x.pt"
YOLO_MODEL_REPO_TYPE = "model"
YOLO_IMGSZ = 896
MAX_VIDEO_SIZE_BYTES = int(os.environ.get("MAX_VIDEO_SIZE_MB", "2048")) * 1024 * 1024
MAX_ACTIVE_ANALYSES = 5
MAX_ACTIONS_SIZE_BYTES = 32 * 1024 * 1024
MAX_CALIBRATION_SIZE_BYTES = 256 * 1024 * 1024


def hub():
    return HfApi(token=os.environ["HF_TOKEN"])


def backend(path, user_id, method="GET", body=None, is_admin=False):
    for attempt in range(3):
        try:
            response = httpx.request(method, f'{os.environ["BACKEND_INTERNAL_URL"].rstrip("/")}/internal/analysis{path}',
                json=body, timeout=60, headers={"x-sporalize-proxy-secret": os.environ["ANALYSIS_PROXY_SECRET"], "x-sporalize-user-id": user_id,
                    **({"x-sporalize-admin": "true"} if is_admin else {})})
            response.raise_for_status()
            return response.json() if response.content else None
        except (httpx.TransportError, httpx.HTTPStatusError) as error:
            if attempt == 2 or isinstance(error, httpx.HTTPStatusError) and error.response.status_code < 500:
                raise
            time.sleep(2 ** attempt)


def registered_colab_worker():
    secret = os.environ.get("COLAB_WORKER_SECRET", "")
    if len(secret) < 32:
        return None
    try:
        response = httpx.get(
            f'{os.environ["BACKEND_INTERNAL_URL"].rstrip("/")}/internal/colab-worker/current',
            timeout=3,
            headers={"x-sporalize-proxy-secret": os.environ["ANALYSIS_PROXY_SECRET"]},
        )
        response.raise_for_status()
        registration = response.json()
        if not registration.get("active") or not registration.get("url"):
            return None
        base_url = registration["url"].rstrip("/")
        health = httpx.get(
            f"{base_url}/worker/health", timeout=5,
            headers={"x-sporalize-colab-secret": secret},
        )
        health.raise_for_status()
        status = health.json()
        return base_url if status.get("ready") and status.get("cuda") else None
    except (httpx.HTTPError, ValueError, KeyError) as error:
        logger.info("Colab GPU worker is not available: %s", error)
        return None


def run_pipeline_on_colab(worker_url, client_id, work, cameras, job):
    """Use HF as a temporary handoff so large videos never cross the tunnel."""
    secret = os.environ["COLAB_WORKER_SECRET"]
    headers = {"x-sporalize-colab-secret": secret}
    input_path = f"colab-staging/{client_id}"
    result_path = f"colab-results/{client_id}/reports.json"
    job.update(status="running", progress=5, phase="Preparing analysis", stage="analysis")

    try:
        hub().upload_folder(
            repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset", folder_path=str(work),
            path_in_repo=input_path, allow_patterns=["actions.json", "calibration.npz", "videos/*"],
            commit_message=f"Stage analysis inputs {client_id}", token=os.environ["HF_TOKEN"],
        )
        video_files = [{"order": order, "filename": Path(path).name} for order, path in cameras.items()]
        response = httpx.post(
            f"{worker_url}/worker/jobs",
            json={"jobId": client_id, "sessionId": job["sessionId"], "inputPath": input_path, "videos": video_files},
            headers=headers, timeout=20,
        )
        response.raise_for_status()

        failures = 0
        while True:
            if job.get("cancel"):
                try:
                    httpx.post(f"{worker_url}/worker/jobs/{client_id}/cancel", headers=headers, timeout=5)
                except httpx.HTTPError:
                    pass
                raise InterruptedError("Processing cancelled by user")

            try:
                status_response = httpx.get(f"{worker_url}/worker/jobs/{client_id}", headers=headers, timeout=10)
                status_response.raise_for_status()
                remote = status_response.json()
                failures = 0
            except (httpx.HTTPError, ValueError) as error:
                failures += 1
                if failures >= 4:
                    logger.warning("Colab worker connection was lost; using the primary analysis server: %s", error)
                    return None
                time.sleep(3)
                continue

            status = remote.get("status")
            if status == "completed":
                try:
                    downloaded = hf_hub_download(
                        repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset", filename=result_path,
                        token=os.environ["HF_TOKEN"], force_download=True,
                    )
                    reports = json.loads(Path(downloaded).read_text(encoding="utf-8"))
                    return reports if isinstance(reports, list) else None
                except Exception as error:
                    logger.warning("Colab result could not be read; using the primary analysis server: %s", error)
                    return None
            if status in {"failed", "missing"}:
                logger.warning("Colab analysis failed; using the primary analysis server: %s", remote.get("error", status))
                return None
            if status == "cancelled":
                raise InterruptedError("Processing cancelled by user")

            try:
                percentage = min(100.0, max(0.0, float(remote.get("progress", 0))))
            except (TypeError, ValueError):
                percentage = 0.0
            job.update(
                status="running", stage="analysis", progress=5 + 0.85 * percentage,
                phase=remote.get("phase") or "Analyzing session",
                step=remote.get("step", 0), total=remote.get("total", 0),
                action=remote.get("action", 0), actionsTotal=remote.get("actionsTotal", 0),
                updatedAt=int(time.time() * 1000),
            )
            time.sleep(2)
    except InterruptedError:
        raise
    except Exception as error:
        logger.warning("Colab handoff failed; using the primary analysis server: %s", error)
        return None
    finally:
        for remote_path in (input_path, f"colab-results/{client_id}"):
            try:
                hub().delete_folder(
                    repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset", path_in_repo=remote_path,
                    commit_message=f"Clean up analysis handoff {client_id}", token=os.environ["HF_TOKEN"],
                )
            except Exception as error:
                logger.info("Temporary Colab handoff cleanup skipped for %s: %s", remote_path, error)


@lru_cache(maxsize=1)
def runtime():
    root = Path(__file__).resolve().parent.parent / "Core"
    if not (root / "pipeline.py").is_file():
        if not os.environ.get("CORE_REPO_ID"):
            raise RuntimeError("CORE_REPO_ID is required when Core is not included locally")
        root = Path(snapshot_download(repo_id=os.environ["CORE_REPO_ID"], repo_type="dataset",
            revision=os.environ.get("CORE_REVISION"), token=os.environ["HF_TOKEN"],
            allow_patterns=["pipeline.py", "Weights/*"]))
    spec = importlib.util.spec_from_file_location("sporalize_core", root / "pipeline.py")
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    pose_file = POSE_MODEL_FILE
    yolo_file = YOLO_MODEL_FILE
    pose = (root / pose_file).resolve()
    yolo = (root / yolo_file).resolve()
    if root.resolve() not in pose.parents or root.resolve() not in yolo.parents:
        raise RuntimeError("Model files must be inside the Core repository")
    if not pose.is_file():
        cache = Path(os.environ.get("HF_HOME", str(Path.home() / ".cache/huggingface"))) / "sporalize"
        cache.mkdir(parents=True, exist_ok=True)
        if not (cache / Path(pose_file).name).is_file():
            if POSE_MODEL_REPO_ID:
                downloaded = hf_hub_download(POSE_MODEL_REPO_ID, POSE_MODEL_HUB_FILE,
                    repo_type=POSE_MODEL_REPO_TYPE, token=os.environ["HF_TOKEN"])
                shutil.copyfile(downloaded, cache / Path(pose_file).name)
            elif pose_file == "Weights/rtmpose-x-halpe26.onnx":
                with tempfile.TemporaryDirectory() as temporary:
                    archive = Path(temporary) / "pose.zip"
                    urlretrieve("https://download.openmmlab.com/mmpose/v1/projects/rtmposev1/onnx_sdk/rtmpose-x_simcc-body7_pt-body7-halpe26_700e-384x288-7fb6e239_20230606.zip", archive)
                    with ZipFile(archive) as bundle:
                        member = next(name for name in bundle.namelist() if name.endswith("end2end.onnx"))
                        with bundle.open(member) as source, (cache / Path(pose_file).name).open("wb") as target:
                            shutil.copyfileobj(source, target)
            else:
                raise RuntimeError("Configured pose model is missing from Core and no pose model repository was configured")
        pose = cache / Path(pose_file).name
    if not yolo.is_file():
        yolo = Path(hf_hub_download(YOLO_MODEL_REPO_ID, YOLO_MODEL_HUB_FILE,
            repo_type=YOLO_MODEL_REPO_TYPE, token=os.environ["HF_TOKEN"]))
    return module.run_pipeline, {"POSE_PATH": str(pose), "YOLO_PATH": str(yolo)}


@asynccontextmanager
async def lifespan(app):
    for name in ("STORAGE_REPO_ID", "HF_TOKEN", "BACKEND_INTERNAL_URL"):
        if not os.environ.get(name):
            raise RuntimeError(f"{name} is required")
    if len(os.environ.get("ANALYSIS_PROXY_SECRET", "")) < 32:
        raise RuntimeError("ANALYSIS_PROXY_SECRET must contain at least 32 characters")
    if os.environ["STORAGE_REPO_ID"].lower() == "shoraky/sporalizelabs-storage":
        raise RuntimeError("Use a separate storage dataset for this application")
    if not 32 <= len(os.environ["ANALYSIS_PROXY_SECRET"]) <= 4096:
        raise RuntimeError("ANALYSIS_PROXY_SECRET length is invalid")
    runtime()
    yield


app = FastAPI(title="Sporalize AI", lifespan=lifespan)


@app.exception_handler(httpx.HTTPStatusError)
async def backend_error(request: Request, error: httpx.HTTPStatusError):
    status = error.response.status_code
    logger.error("Analysis backend request failed: path=%s status=%s actor_is_admin=%s response=%s",
        request.url.path, status, getattr(request.state, "is_admin", False), error.response.text[:500])
    try:
        backend_body = error.response.json()
    except ValueError:
        backend_body = {}
    detail = backend_body.get("error") or backend_body.get("detail") or "Backend request failed"
    payload = {"detail": detail}
    if backend_body.get("code"):
        payload["code"] = backend_body["code"]
    return JSONResponse(payload, status_code=status if status < 500 else 502)


@app.middleware("http")
async def authenticate(request: Request, call_next):
    if request.url.path != "/healthz":
        secret = os.environ.get("ANALYSIS_PROXY_SECRET", "")
        if len(secret) < 32 or not hmac.compare_digest(secret, request.headers.get("x-sporalize-proxy-secret", "")):
            return JSONResponse({"detail": "Unauthorized"}, status_code=403)
        try:
            request.state.user_id = str(UUID(request.headers.get("x-sporalize-user-id", "")))
        except ValueError:
            return JSONResponse({"detail": "Invalid user identity"}, status_code=403)
        request.state.is_admin = request.headers.get("x-sporalize-admin") == "true"
    return await call_next(request)


def json_value(value):
    if isinstance(value, np.ndarray):
        return json_value(value.tolist())
    if isinstance(value, np.generic):
        return json_value(value.item())
    if isinstance(value, dict):
        return {str(key): json_value(item) for key, item in value.items()}
    if isinstance(value, (list, tuple)):
        return [json_value(item) for item in value]
    if isinstance(value, float) and not math.isfinite(value):
        return None
    return value


def drive_file_id(link):
    value = link.strip()
    if re.fullmatch(r"[A-Za-z0-9_-]{10,}", value):
        return value
    if not any(host in value for host in ("drive.google.com", "drive.usercontent.google.com")):
        raise ValueError("Not a Google Drive file link")
    match = re.search(r"/file/d/([A-Za-z0-9_-]{10,})|/d/([A-Za-z0-9_-]{10,})|[?&]id=([A-Za-z0-9_-]{10,})", value)
    if not match:
        raise ValueError("Could not find a Google Drive file ID")
    return next(group for group in match.groups() if group)


def drive_file_name(content_disposition, file_id):
    message = Message()
    if content_disposition:
        message["content-disposition"] = content_disposition
    name = message.get_filename()
    if isinstance(name, tuple):
        name = collapse_rfc2231_value(name)
    name = (name or f"{file_id}.mp4").replace("\\", "/")
    name = Path(name).name
    name = "".join(character for character in name if character.isprintable()).strip()
    return name[:255] or f"{file_id}.mp4"


async def download_drive_file(link, destination):
    file_id = drive_file_id(link)
    url = f"https://drive.google.com/uc?export=download&confirm=t&id={file_id}"
    total = 0
    async with httpx.AsyncClient(follow_redirects=True, timeout=300) as client:
        async with client.stream("GET", url) as response:
            response.raise_for_status()
            content_type = response.headers.get("content-type", "").lower()
            if "text/html" in content_type:
                raise ValueError("Google Drive did not return a public video; check link access")
            with destination.open("wb") as output:
                async for chunk in response.aiter_bytes(1024 * 1024):
                    total += len(chunk)
                    if total > MAX_VIDEO_SIZE_BYTES:
                        raise HTTPException(413, "Video exceeds the configured per-file size limit")
                    output.write(chunk)
    if total == 0:
        raise ValueError("Google Drive returned an empty file")
    return file_id, total


def create_video_poster(source, destination):
    capture = cv2.VideoCapture(str(source))
    try:
        capture.set(cv2.CAP_PROP_POS_MSEC, 500)
        ok, frame = capture.read()
        if not ok:
            capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
            ok, frame = capture.read()
        if not ok:
            logger.warning("Could not decode preview frame from %s", source.name)
            return False
        height, width = frame.shape[:2]
        if width > 640:
            frame = cv2.resize(frame, (640, round(height * 640 / width)), interpolation=cv2.INTER_AREA)
        ok, encoded = cv2.imencode(".webp", frame, [cv2.IMWRITE_WEBP_QUALITY, 76])
        if not ok:
            return False
        destination.write_bytes(encoded.tobytes())
        return True
    finally:
        capture.release()


def probe_video(path):
    result = subprocess.run(
        ["ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json", str(path)],
        check=True, capture_output=True, text=True, timeout=60,
    )
    metadata = json.loads(result.stdout)
    video = next((stream for stream in metadata.get("streams", []) if stream.get("codec_type") == "video"), None)
    if not video:
        raise RuntimeError(f"No video stream found in {path.name}")
    def numeric(value):
        try:
            parsed = float(value)
            return parsed if math.isfinite(parsed) else 0
        except (TypeError, ValueError):
            return 0

    duration = numeric(video.get("duration")) or numeric(metadata.get("format", {}).get("duration"))
    fps_text = video.get("avg_frame_rate", "0/1")
    try:
        numerator, denominator = fps_text.split("/", 1)
        denominator_value = float(denominator)
        fps = float(numerator) / denominator_value if denominator_value > 0 else 0
    except (ValueError, ZeroDivisionError):
        fps = 0
    try:
        total_frames = int(video.get("nb_frames"))
    except (TypeError, ValueError):
        total_frames = round(duration * fps)
    compatible = (
        Path(path).suffix.lower() == ".mp4"
        and video.get("codec_name") == "h264"
        and video.get("pix_fmt") == "yuv420p"
        and duration > 0
    )
    return {"duration": duration, "frames": total_frames, "compatible": compatible}


def finalize_videos(work, cameras, job):
    video_dir = work / "videos"
    inputs = []
    for order in cameras:
        matches = [path for path in video_dir.glob(f"camera_{order}.*") if path.is_file()]
        if len(matches) != 1:
            raise RuntimeError(f"Expected one source video for camera {order}")
        metadata = probe_video(matches[0])
        inputs.append((order, matches[0], metadata))

    total_duration = sum(metadata["duration"] for _, _, metadata in inputs)
    if total_duration <= 0:
        raise RuntimeError("Could not determine video duration for finalization")
    completed_duration = 0.0

    def report(order, message, local_progress, frames_done=0, frames_total=0):
        weighted = (completed_duration + inputs_by_order[order]["duration"] * local_progress) / total_duration
        job.update(
            status="running", stage="finalizing", progress=90 + 8 * weighted,
            phase=message, videoProgress=round(local_progress * 100),
            videoFramesProcessed=frames_done, videoFramesTotal=frames_total,
            updatedAt=int(time.time() * 1000),
        )

    inputs_by_order = {order: metadata for order, _, metadata in inputs}
    for index, (order, source, metadata) in enumerate(inputs, start=1):
        if job.get("cancel"):
            raise InterruptedError()
        frames_total = metadata["frames"]
        if metadata["compatible"]:
            report(order, f"Video {index}/{len(inputs)} already browser-ready", 1, 0, 0)
            completed_duration += metadata["duration"]
            continue

        target = video_dir / f"camera_{order}.mp4"
        temporary = video_dir / f"camera_{order}.finalizing.mp4"
        temporary.unlink(missing_ok=True)
        command = [
            "ffmpeg", "-hide_banner", "-loglevel", "error", "-nostats", "-y", "-i", str(source),
            "-map", "0:v:0", "-an", "-sn", "-dn", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
            "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p",
            "-fps_mode", "passthrough", "-movflags", "+faststart", "-progress", "pipe:1", str(temporary),
        ]
        process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1)
        stderr_lines = []
        stderr_reader = threading.Thread(target=lambda: stderr_lines.extend(process.stderr), daemon=True)
        stderr_reader.start()
        last_frames = 0
        try:
            for line in process.stdout:
                if job.get("cancel"):
                    process.terminate()
                    raise InterruptedError()
                key, separator, value = line.strip().partition("=")
                if not separator:
                    continue
                if key == "frame":
                    try:
                        last_frames = int(value)
                    except ValueError:
                        pass
                elif key == "out_time_us":
                    try:
                        local_progress = min(0.99, max(0, int(value) / 1_000_000 / metadata["duration"]))
                        report(order, f"Preparing video {index}/{len(inputs)}", local_progress, last_frames, frames_total)
                    except ValueError:
                        pass
            return_code = process.wait()
            stderr_reader.join(timeout=5)
            if return_code != 0:
                raise RuntimeError("Video conversion failed: " + "".join(stderr_lines)[-1000:])
            if not temporary.is_file() or temporary.stat().st_size == 0:
                raise RuntimeError("Video conversion produced an empty file")
            verified = probe_video(temporary)
            if not verified["compatible"]:
                raise RuntimeError("Converted video failed browser compatibility verification")
            os.replace(temporary, target)
            if source != target:
                source.unlink()
        except Exception:
            if process.poll() is None:
                process.kill()
                process.wait()
            temporary.unlink(missing_ok=True)
            raise
        report(order, f"Video {index}/{len(inputs)} verified", 1, frames_total, frames_total)
        completed_duration += metadata["duration"]


def run_job(client_id, work, cameras):
    job = jobs[client_id]
    session_id = job["sessionId"]
    saved = False
    try:
        if job.get("cancel"):
            raise InterruptedError()
        job.update(status="running", progress=5, phase="Analyzing", stage="analysis")
        worker_url = registered_colab_worker()
        reports = run_pipeline_on_colab(worker_url, client_id, work, cameras, job) if worker_url else None
        if reports is None:
            if job.get("cancel"):
                raise InterruptedError()
            job.update(status="running", progress=5, phase="Analyzing session", stage="analysis")
            pipeline, weights = runtime()

            def progress(action, total, frame, frames):
                action_number = action + 1
                frame_total = max(1, frames)
                job.update(progress=5 + 85 * (action + (frame + 1) / frame_total) / max(total, 1),
                           phase=f"Analyzing action {action_number}/{total}", step=frame + 1,
                           total=frame_total, action=action_number, actionsTotal=total,
                           updatedAt=int(time.time() * 1000))
                return not job.get("cancel")

            reports = pipeline(cameras, {**weights, "CALIBRATION_PATH": str(work / "calibration.npz"), "ACTIONS_PATH": str(work / "actions.json")}, {"YOLO_IMGSZ": YOLO_IMGSZ}, progress)
        if job.get("cancel"):
            raise InterruptedError()
        (work / "results.json").write_text(json.dumps(json_value(reports), allow_nan=False), encoding="utf-8")
        finalize_videos(work, cameras, job)
        if job.get("cancel"):
            raise InterruptedError()
        try:
            playback_boundaries = frame_times(work / "videos" / f"camera_{min(cameras)}.mp4")
        except (OSError, ValueError, subprocess.CalledProcessError):
            logger.exception("Unable to index final video timestamps for session %s", session_id)
            playback_boundaries = None
        job.update(progress=98, phase="Saving results", stage="saving", videoProgress=100)
        hub().upload_folder(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
            folder_path=str(work), path_in_repo=f"sessions/{session_id}",
            allow_patterns=["results.json", "videos/*", "posters/*.webp"])
        saved = True
        completion = {"status": "completed", "path": f"sessions/{session_id}", "actions": [
            {"index": index, **{key: json_value(report[key]) for key in ("action", "start_frame", "end_frame", "fps", "error") if key in report},
             **(action_times(playback_boundaries, int(report["start_frame"]), int(report["end_frame"]))
                if playback_boundaries and not report.get("error")
                and int(report["end_frame"]) + 1 < len(playback_boundaries) else {})}
            for index, report in enumerate(reports)]}
        job["completion"] = completion
        backend(f"/{session_id}", job.get("ownerUserId") or job["userId"], "PUT", completion, is_admin=job.get("actorIsAdmin", False))
        job.pop("completion", None)
        job.update(status="completed", phase="Completed", stage="done", progress=100, resultUrl=f"/api/sessions/{session_id}")
    except Exception as error:
        status = "cancelled" if isinstance(error, InterruptedError) else "failed"
        job.update(status=status, phase="Sync failed" if saved else status.capitalize(), stage="done" if status in {"cancelled", "failed"} else "saving", error="Files saved; retry backend sync" if saved else "Analysis cancelled" if status == "cancelled" else "Analysis or saving failed")
        if saved:
            job["status"] = "failed"
        if status == "cancelled":
            logger.info("Session %s cancelled", session_id)
        else:
            logger.exception("Session %s %s", session_id, status)
        try:
            if not saved:
                backend(f"/{session_id}", job.get("ownerUserId") or job["userId"], "PUT", {"status": status}, is_admin=job.get("actorIsAdmin", False))
        except Exception:
            logger.exception("Could not update session %s", session_id)
    finally:
        job["updatedAt"] = int(time.time() * 1000)
        shutil.rmtree(work, ignore_errors=True)
        job["done"].set()


@app.get("/healthz")
def health():
    return {"ok": True}


@app.post("/api/drive/probe")
def probe_drive(payload: dict):
    links = payload.get("links") or ([payload["link"]] if payload.get("link") else [])
    if not isinstance(links, list) or not 1 <= len(links) <= 16:
        raise HTTPException(400, "Provide between 1 and 16 Drive links")
    results = []
    for link in links:
        result = {"link": str(link)}
        try:
            file_id = drive_file_id(str(link))
            with httpx.stream("GET", f"https://drive.google.com/uc?export=download&confirm=t&id={file_id}", follow_redirects=True, timeout=30) as response:
                content_type = response.headers.get("content-type", "")
                content_disposition = response.headers.get("content-disposition", "")
                size = int(response.headers.get("content-length", "0") or 0)
                if not response.is_success or "text/html" in content_type.lower():
                    raise ValueError("File is not publicly downloadable")
            result.update(ok=True, fileId=file_id, fileName=drive_file_name(content_disposition, file_id), sizeBytes=size or None, contentType=content_type)
        except Exception as error:
            result.update(ok=False, code="unavailable", error=str(error))
        results.append(result)
    return {"results": results}


@app.post("/api/analyze", status_code=202)
async def analyze(request: Request, playerId: str = Form(...), clientId: UUID = Form(...),
                  videoOrders: list[int] = Form(...), videos: list[UploadFile] = File(None),
                  videoDriveLinks: list[str] = Form(None),
                  actionsJson: UploadFile = File(...), calibration: UploadFile = File(...),
                  sessionName: str = Form("")):
    videos = videos or []
    videoDriveLinks = videoDriveLinks or []
    if videos and videoDriveLinks or len(videos or videoDriveLinks) < 2 or len(videos or videoDriveLinks) != len(videoOrders) or len(set(videoOrders)) != len(videoOrders):
        raise HTTPException(422, "Provide at least two videos with unique camera orders")
    client_id, user_id = str(clientId), request.state.user_id
    if any(video.size is not None and video.size > MAX_VIDEO_SIZE_BYTES for video in videos):
        raise HTTPException(413, f"Each video must be smaller than {MAX_VIDEO_SIZE_BYTES // (1024 * 1024)} MB")
    if actionsJson.size is not None and actionsJson.size > MAX_ACTIONS_SIZE_BYTES:
        raise HTTPException(413, "The actions file must be 32 MB or smaller")
    if calibration.size is not None and calibration.size > MAX_CALIBRATION_SIZE_BYTES:
        raise HTTPException(413, "The calibration file must be 256 MB or smaller")
    active_jobs = [job for job in jobs.values() if job["status"] in {"uploading", "queued", "running"}]
    if any(job["userId"] == user_id for job in active_jobs):
        raise HTTPException(429, "Finish or cancel your active analysis before starting another")
    if len(active_jobs) >= MAX_ACTIVE_ANALYSES:
        raise HTTPException(429, f"The analysis queue is full (maximum {MAX_ACTIVE_ANALYSES} active jobs). Try again when a slot opens.")
    cutoff = int(time.time() * 1000) - 24 * 60 * 60 * 1000
    for key, job in list(jobs.items()):
        if job["status"] in {"completed", "failed", "cancelled", "sync_failed"} and job["updatedAt"] < cutoff:
            jobs.pop(key, None)
    if client_id in jobs:
        raise HTTPException(409, "Analysis already exists")
    reference = playerId.strip()
    if not reference or len(reference) > 100 or len(sessionName) > 150:
        raise HTTPException(422, "Invalid player reference or session name")
    session = backend("", user_id, "POST", {"clientId": client_id, "playerId": reference, "name": sessionName},
        is_admin=request.state.is_admin)
    session_id, player_id = session["session_id"], session["player_id"]
    owner_user_id = session.get("user_id") if "user_id" in session else user_id
    actor_is_admin = bool(session.get("actor_is_admin", False))
    now = int(time.time() * 1000)
    jobs[client_id] = {"clientId": client_id, "sessionId": session_id, "playerId": player_id,
        "userId": user_id, "status": "uploading", "phase": "Uploading", "stage": "upload", "progress": 0, "createdAt": now, "updatedAt": now,
        "ownerUserId": owner_user_id, "actorIsAdmin": actor_is_admin,
        "done": threading.Event(), "workPath": None, "future": None}
    work = Path(tempfile.mkdtemp(prefix="sporalize-"))
    jobs[client_id]["workPath"] = str(work)
    try:
        (work / "videos").mkdir()
        cameras = {}
        uploads = [(actionsJson, work / "actions.json", MAX_ACTIONS_SIZE_BYTES),
                   (calibration, work / "calibration.npz", MAX_CALIBRATION_SIZE_BYTES)]
        video_names = []
        sources = list(zip(videoOrders, videos)) if videos else list(zip(videoOrders, videoDriveLinks))
        for order, source in sources:
            if videos:
                video = source
                suffix = Path(video.filename or "video.mp4").suffix.lower()
            else:
                video = None
                suffix = ".mp4"
            if suffix not in {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"}:
                raise HTTPException(422, "Unsupported video format")
            target = work / "videos" / f"camera_{order}{suffix}"
            video_names.append(target.name)
            cameras[order] = str(target)
            if video is not None:
                uploads.append((video, target, MAX_VIDEO_SIZE_BYTES))
            else:
                await download_drive_file(source, target)
        for upload, target, size_limit in uploads:
            with target.open("wb") as output:
                total = 0
                while chunk := await upload.read(1024 * 1024):
                    if jobs[client_id].get("cancel"):
                        raise InterruptedError("Analysis cancelled during upload")
                    total += len(chunk)
                    if total > size_limit:
                        raise HTTPException(413, f"{target.name} exceeds the allowed size")
                    output.write(chunk)
        poster_dir = work / "posters"
        poster_dir.mkdir()
        for order, source in cameras.items():
            create_video_poster(source, poster_dir / f"camera_{order}.webp")
        jobs[client_id].update(status="queued", phase="Queued", stage="analysis")
        jobs[client_id]["videoNames"] = video_names
        jobs[client_id]["future"] = executor.submit(run_job, client_id, work, cameras)
        return {"accepted": True, "status": "queued", "clientId": client_id, "sessionId": session_id}
    except Exception:
        jobs[client_id].update(status="failed", phase="Failed")
        shutil.rmtree(work)
        try:
            backend(f"/{session_id}", owner_user_id or user_id, "PUT", {"status": "failed"}, is_admin=actor_is_admin)
        except Exception:
            logger.exception("Could not report failed upload %s", session_id)
        finally:
            jobs[client_id]["done"].set()
        raise


def owned_job(client_id, user_id, is_admin=False):
    job = jobs.get(client_id)
    if not job or (job["userId"] != user_id and not is_admin):
        raise HTTPException(404, "Analysis not found")
    return job


@app.get("/api/progress/{client_id}")
def progress(client_id: str, request: Request):
    job = owned_job(client_id, request.state.user_id, request.state.is_admin)
    return {key: value for key, value in job.copy().items() if key not in {"userId", "cancel", "completion", "done", "workPath", "future"}}


@app.post("/api/retry-save/{client_id}")
def retry_save(client_id: str, request: Request):
    job = owned_job(client_id, request.state.user_id, request.state.is_admin)
    if job.get("completion"):
        backend(f'/{job["sessionId"]}', job.get("ownerUserId") or job["userId"], "PUT", job["completion"], is_admin=job.get("actorIsAdmin", False))
        job.pop("completion", None)
        job.update(status="completed", phase="Completed", progress=100, resultUrl=f'/api/sessions/{job["sessionId"]}')
    return progress(client_id, request)


@app.get("/api/active-analyses")
def active(request: Request):
    is_admin = request.state.is_admin
    return {"jobs": [
        {key: value for key, value in job.copy().items() if key not in {"userId", "cancel", "completion", "done", "workPath", "future"}}
        for job in list(jobs.values())
        if (is_admin or job["userId"] == request.state.user_id)
        and job["status"] in {"uploading", "queued", "running"}
    ]}


@app.post("/api/cancel/{client_id}")
def cancel(client_id: str, request: Request):
    job = owned_job(client_id, request.state.user_id, request.state.is_admin)
    if job["status"] not in {"completed", "failed", "cancelled", "sync_failed"}:
        job["cancel"] = True
    return {"status": job["status"], "clientId": client_id}


def cancel_session_job(session_id: UUID):
    matching = [(client_id, job) for client_id, job in list(jobs.items()) if job.get("sessionId") == str(session_id)]
    for client_id, job in matching:
        if job.get("status") in {"uploading", "queued", "running"}:
            job["cancel"] = True
            future = job.get("future")
            if future is not None:
                try:
                    future.result(timeout=300)
                except TimeoutError as error:
                    raise HTTPException(504, "The analysis did not stop in time; deletion was not started") from error
            else:
                job["done"].wait(timeout=300)
                if not job["done"].is_set():
                    raise HTTPException(504, "The analysis upload did not stop in time; deletion was not started")
    return [(client_id, job.get("workPath")) for client_id, job in matching]


def cleanup_deleted_session_files(session_id, storage_path, job_paths):
    for _, work_path in job_paths:
        if work_path:
            shutil.rmtree(work_path, ignore_errors=True)
    folders = ([storage_path] if storage_path else []) + [
        path for client_id, _ in job_paths
        for path in (f"colab-staging/{client_id}", f"colab-results/{client_id}")
    ]
    api = hub()
    repo_id = os.environ["STORAGE_REPO_ID"]
    failed_folders = []
    for folder in folders:
        for attempt in range(3):
            try:
                api.delete_folder(repo_id=repo_id, repo_type="dataset", path_in_repo=folder,
                    commit_message=f"Delete session {session_id} files")
                break
            except RemoteEntryNotFoundError:
                break
            except Exception:
                if attempt == 2:
                    logger.exception("Unable to delete session storage folder %s", folder)
                    failed_folders.append(folder)
                else:
                    time.sleep(2 ** attempt)
    if failed_folders:
        logger.error("Background storage cleanup incomplete for session %s: %s", session_id, failed_folders)
    else:
        logger.info("Finished background storage cleanup for session %s", session_id)


def session_row(session_id, user_id, is_admin=False, playback_times=True):
    row = backend(f"/{session_id}" + ("" if playback_times else "?playbackTimes=false"), user_id, is_admin=is_admin)
    if row["path"] != f"sessions/{session_id}":
        raise HTTPException(409, "Session uses a different storage layout")
    return row


def result_action_id(session_id, index):
    digest = hashlib.sha256(f"{session_id}:{index}".encode()).hexdigest()
    return f"{digest[:8]}-{digest[8:12]}-5{digest[13:16]}-8{digest[17:20]}-{digest[20:32]}"


def owned_session_row(session_id, user_id, is_admin=False):
    row = backend(f"/{session_id}", user_id, is_admin=is_admin)
    if row.get("user_id") != user_id and not is_admin:
        raise HTTPException(404, "Session not found")
    return row


def upload_session_results(path_in_repo, payload, commit_message):
    from io import BytesIO

    hub().upload_file(
        path_or_fileobj=BytesIO(payload),
        path_in_repo=path_in_repo,
        repo_id=os.environ["STORAGE_REPO_ID"],
        repo_type="dataset",
        commit_message=commit_message,
    )


@app.delete("/api/sessions/{session_id}/actions/{action_id}")
def delete_action(session_id: UUID, action_id: UUID, request: Request):
    row = owned_session_row(session_id, request.state.user_id, request.state.is_admin)
    if row.get("path") != f"sessions/{session_id}":
        raise HTTPException(409, "Session results are not available")

    result_path = f'{row["path"]}/results.json'
    downloaded_path = hf_hub_download(
        repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
        filename=result_path, token=os.environ["HF_TOKEN"],
    )
    original_payload = Path(downloaded_path).read_bytes()
    reports = json.loads(original_payload.decode("utf-8"))
    matching_index = next((
        index for index, report in enumerate(reports)
        if not report.get("error") and not report.get("_deleted")
        and result_action_id(session_id, index) == str(action_id)
    ), None)
    if matching_index is None:
        raise HTTPException(404, "Action not found")

    # Keep a tombstone so action IDs based on pipeline report indexes remain
    # stable for every action that follows the deleted one.
    reports[matching_index]["_deleted"] = True
    updated_reports = reports
    updated_payload = json.dumps(json_value(updated_reports), allow_nan=False).encode("utf-8")
    upload_session_results(result_path, updated_payload, f"Delete action {action_id}")
    try:
        backend(f"/{session_id}/actions/{action_id}", request.state.user_id, "DELETE", is_admin=request.state.is_admin)
    except Exception:
        try:
            upload_session_results(result_path, original_payload, f"Restore action {action_id} after delete failure")
        except Exception:
            logger.exception("Could not restore session results after action deletion failed: session=%s action=%s", session_id, action_id)
        raise
    return {"ok": True}


@app.patch("/api/sessions/{session_id}/actions/{action_id}")
def update_action(session_id: UUID, action_id: UUID, payload: dict, request: Request):
    if not request.state.is_admin:
        raise HTTPException(403, "Administrator access is required")
    row = owned_session_row(session_id, request.state.user_id, True)
    if row.get("path") != f"sessions/{session_id}":
        raise HTTPException(409, "Session results are not available")

    start_frame = payload.get("startFrame")
    end_frame = payload.get("endFrame")
    action_type = payload.get("type")
    if not isinstance(start_frame, int) or isinstance(start_frame, bool) or start_frame < 0:
        raise HTTPException(400, "Start frame must be a non-negative integer")
    if not isinstance(end_frame, int) or isinstance(end_frame, bool) or end_frame < start_frame:
        raise HTTPException(400, "End frame must be an integer greater than or equal to the start frame")
    if not isinstance(action_type, str) or not action_type.strip() or len(action_type.strip()) > 50:
        raise HTTPException(400, "Action type is invalid")

    result_path = f'{row["path"]}/results.json'
    downloaded_path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
        filename=result_path, token=os.environ["HF_TOKEN"])
    original_payload = Path(downloaded_path).read_bytes()
    reports = json.loads(original_payload.decode("utf-8"))
    matching_index = next((index for index, report in enumerate(reports)
        if not report.get("error") and not report.get("_deleted")
        and result_action_id(session_id, index) == str(action_id)), None)
    if matching_index is None:
        raise HTTPException(404, "Action not found")
    video_dir = f'{row["path"]}/videos/'
    video_files = sorted(file for file in session_files(hub(), os.environ["STORAGE_REPO_ID"], video_dir.rstrip("/"))
                         if re.search(r"/camera_\d+\.mp4$", file))
    if not video_files:
        raise HTTPException(409, "Session video is unavailable")
    video_path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
                                 filename=video_files[0], token=os.environ["HF_TOKEN"])
    playback_times = action_times(frame_times(video_path), start_frame, end_frame)
    reports[matching_index]["start_frame"] = start_frame
    reports[matching_index]["end_frame"] = end_frame
    reports[matching_index]["action"] = action_type.strip()
    updated_payload = json.dumps(json_value(reports), allow_nan=False).encode("utf-8")
    upload_session_results(result_path, updated_payload, f"Update action {action_id}")
    try:
        backend(f"/{session_id}/actions/{action_id}", request.state.user_id, "PATCH", {
            "startFrame": start_frame, "endFrame": end_frame, "type": action_type.strip(),
            "startTimeSeconds": playback_times["start_time_seconds"],
            "endTimeSeconds": playback_times["end_time_seconds"],
        }, is_admin=True)
    except Exception:
        try:
            upload_session_results(result_path, original_payload, f"Restore action {action_id} after update failure")
        except Exception:
            logger.exception("Could not restore session results after action update failed: session=%s action=%s", session_id, action_id)
        raise
    return {"ok": True}


@app.delete("/api/sessions/{session_id}")
def delete_session(session_id: UUID, request: Request, background_tasks: BackgroundTasks):
    row = owned_session_row(session_id, request.state.user_id, request.state.is_admin)
    expected_path = f"sessions/{session_id}"
    storage_path = row.get("path")
    if storage_path is not None and storage_path != expected_path:
        raise HTTPException(409, "Session uses a different storage layout")

    job_paths = cancel_session_job(session_id)
    backend(f"/{session_id}", request.state.user_id, "DELETE", is_admin=request.state.is_admin)
    for client_id, _ in job_paths:
        jobs.pop(client_id, None)
    # The database deletion makes the session disappear before Hugging Face finishes deleting its files.
    background_tasks.add_task(cleanup_deleted_session_files, session_id, storage_path, job_paths)
    return {"ok": True}


@app.get("/api/sessions/{session_id}")
def results(session_id: UUID, request: Request):
    row = session_row(session_id, request.state.user_id, request.state.is_admin)
    path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
        filename=f'{row["path"]}/results.json', token=os.environ["HF_TOKEN"])
    reports = json.loads(Path(path).read_text(encoding="utf-8"))
    video_dir = f'{row["path"]}/videos/'
    prefix = f'{video_dir}camera_'
    video_extensions = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"}
    camera_files = []
    for repo_file in session_files(hub(), os.environ["STORAGE_REPO_ID"], video_dir.rstrip("/")):
        if not repo_file.startswith(prefix):
            continue
        filename = repo_file[len(video_dir):]
        camera_id_text = Path(filename).stem.removeprefix("camera_")
        if Path(filename).suffix.lower() in video_extensions and camera_id_text.isdigit():
            camera_files.append((int(camera_id_text), filename))
    camera_files.sort()
    fps_values = [float(report.get("fps") or 30) for report in reports if report.get("fps")]
    saved_times = {item["action_id"]: item for item in row.get("action_times", [])}
    actions, failed = [], []
    for index, report in enumerate(reports):
        if report.get("_deleted"):
            continue
        start = int(report.get("start_frame") or 0)
        end = int(report.get("end_frame") or start)
        action_fps = float(report.get("fps") or (fps_values[0] if fps_values else 30))
        label = report.get("action", "Unknown")
        if report.get("error"):
            failed.append({"id": str(uuid5(session_id, str(index))), "label": label, "start": str(start), "end": str(end), "error": report["error"]})
            continue
        analytics = report.get("analytics") or {}
        metrics = [{"name": key.replace("_", " ").title(), "value": value, "unit": ""} for key, value in analytics.items() if isinstance(value, (int, float, str, bool)) or value is None]
        full_interval = report.get("full_interval") or {}
        telemetry_names = {
            "head_angle": ("Head Angle", "°"),
            "trunk_lean": ("Trunk Lean", "°"),
            "left_knee_angle": ("Left Knee Angle", "°"),
            "right_knee_angle": ("Right Knee Angle", "°"),
            "active_ball_dist": ("Active Foot to Ball Distance", "cm"),
            "ball_speed": ("Ball Speed", "cm/s"),
            "active_heel_speed": ("Active Heel Speed", "cm/s"),
            "feet_distance": ("Feet Distance", "cm"),
            "left_heel_height": ("Left Heel Height", "cm"),
            "right_heel_height": ("Right Heel Height", "cm"),
        }
        full_interval_metrics = []
        metric_frames = {}
        for frame_text, frame_data in full_interval.items():
            if not isinstance(frame_data, dict):
                continue
            try:
                frame_number = int(frame_text) - start
            except (TypeError, ValueError):
                continue
            if frame_number < 0 or frame_number >= max(0, end - start + 1):
                continue
            for key, (metric_name, unit) in telemetry_names.items():
                value = frame_data.get(key)
                if isinstance(value, (int, float)) and math.isfinite(value):
                    metric_frames.setdefault(key, []).append({"frame": frame_number, "value": value})
        for key, values in metric_frames.items():
            if values:
                full_interval_metrics.append({"name": telemetry_names[key][0], "unit": telemetry_names[key][1], "values": values})
        clips = [{"cameraId": camera_id,
            "url": f"/storage/{session_id}/videos/{filename}",
            "poster": f"/storage/{session_id}/posters/camera_{camera_id}.webp"}
            for camera_id, filename in camera_files]
        action_id = result_action_id(session_id, index)
        playback = saved_times.get(action_id) or {}
        start_seconds = playback.get("start_time_seconds")
        end_seconds = playback.get("end_time_seconds")
        has_playback_times = (isinstance(start_seconds, (int, float)) and isinstance(end_seconds, (int, float))
                              and math.isfinite(start_seconds) and math.isfinite(end_seconds)
                              and start_seconds >= 0 and end_seconds > start_seconds)
        actions.append({"id": action_id, "label": label,
            "start": str(start), "end": str(end), "fps": action_fps,
            "startFrame": start, "endFrame": end, "startSeconds": start_seconds if has_playback_times else start / action_fps,
            "endSeconds": end_seconds if has_playback_times else (end + 1) / action_fps,
            "hasPlaybackTimes": has_playback_times, "totalFrames": max(0, end - start + 1),
            "preFrames": 0, "inFrame": max(0, (end - start) // 2), "postFrames": 0,
            "cameraClips": clips, "sourceCameraClips": [], "preMetrics": [],
            "preActionMetrics": [], "inActionMetrics": metrics, "postMetrics": [],
            "postActionMetrics": [], "fullIntervalMetrics": full_interval_metrics,
            "skeleton": report.get("skeleton_history", []), "rawAnalytics": analytics})
    created = row.get("created_at")
    if created:
        created_iso = created.replace("Z", "+00:00")
        # Python 3.10's fromisoformat requires a six-digit fractional second,
        # while the API may return a shorter fraction such as `.89`.
        created_iso = re.sub(
            r"(?<=\.)\d+(?=(?:[+-]\d{2}:\d{2})?$)",
            lambda match: match.group(0)[:6].ljust(6, "0"),
            created_iso,
        )
        created_at = datetime.fromisoformat(created_iso)
    else:
        created_at = datetime.now(timezone.utc)
    if created_at.tzinfo is None:
        created_at = created_at.replace(tzinfo=timezone.utc)
    return {"id": str(session_id), "playerId": row["player_id"], "playerName": "",
        "name": row.get("name", "Analysis session"),
        "byAdmin": bool(row.get("by_admin", False)),
        "isOwner": row.get("user_id") == request.state.user_id,
        "createdAt": int(created_at.timestamp() * 1000),
        "targetSize": [0, 0], "cameraCount": len(camera_files), "actions": actions, "failedActions": failed}


@app.api_route("/storage/{session_id}/videos/{filename}", methods=["GET", "HEAD"])
async def video(session_id: UUID, filename: str, request: Request):
    if Path(filename).name != filename or "\\" in filename:
        raise HTTPException(404, "Video not found")
    row = await run_in_threadpool(session_row, session_id, request.state.user_id, request.state.is_admin, False)
    return await video_response(request, os.environ["STORAGE_REPO_ID"],
                                f'{row["path"]}/videos/{filename}', os.environ["HF_TOKEN"])


@app.api_route("/storage/{session_id}/posters/{filename}", methods=["GET", "HEAD"])
def video_poster(session_id: UUID, filename: str, request: Request):
    row = session_row(session_id, request.state.user_id, request.state.is_admin, playback_times=False)
    if Path(filename).name != filename or "\\" in filename or not filename.endswith(".webp"):
        raise HTTPException(404, "Preview not found")
    try:
        path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
            filename=f'{row["path"]}/posters/{filename}', token=os.environ["HF_TOKEN"])
    except RemoteEntryNotFoundError as error:
        raise HTTPException(404, "Preview not found") from error
    return FileResponse(path, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})