Spaces:
Running
Running
Download app.py from Shoraky/Sporalize-api: direct link, hf CLI and curl.
- Browser
- Download file 52.4 kB
-
https://huggingface.co/spaces/Shoraky/Sporalize-api/resolve/main/app.py
- Command line
-
hf download hf://spaces/Shoraky/Sporalize-api/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Shoraky/Sporalize-api/resolve/main/app.py
52.4 kB
| 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) | |
| 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)} | |
| 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) | |
| 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) | |
| 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() | |
| def health(): | |
| return {"ok": True} | |
| 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} | |
| 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 | |
| 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"}} | |
| 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) | |
| 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"} | |
| ]} | |
| 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, | |
| ) | |
| 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} | |
| 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} | |
| 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} | |
| 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} | |
| 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"]) | |
| 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"}) | |