Spaces:
Running
Running
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"})
|