Sporalize-api / app.py
Shoraky's picture
Stream session video ranges directly from storage
a66365f
Raw History Blame Contribute Delete
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)
@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"})