Shoraky commited on
Commit
592de64
·
0 Parent(s):

inital commit at migration

Browse files
Files changed (7) hide show
  1. .dockerignore +5 -0
  2. .env.example +5 -0
  3. .gitignore +3 -0
  4. Dockerfile +11 -0
  5. README.md +21 -0
  6. app.py +409 -0
  7. requirements.txt +14 -0
.dockerignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ .env
2
+ .env.*
3
+ __pycache__/
4
+ .venv/
5
+ test_app.py
.env.example ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ CORE_REPO_ID=your-account/sporalize-core
2
+ STORAGE_REPO_ID=your-account/sporalize-application-storage
3
+ HF_TOKEN=
4
+ ANALYSIS_PROXY_SECRET=
5
+ BACKEND_URL=http://localhost:4000
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ .env
2
+ __pycache__/
3
+ .venv/
Dockerfile ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+ ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 HF_HOME=/home/appuser/.cache/huggingface
3
+ RUN apt-get update && apt-get install -y --no-install-recommends libglib2.0-0 libgl1 && rm -rf /var/lib/apt/lists/*
4
+ RUN useradd --create-home --uid 1000 appuser
5
+ WORKDIR /app
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+ COPY --chown=appuser:appuser app.py .
9
+ USER appuser
10
+ EXPOSE 7860
11
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Sporalize AI API
3
+ sdk: docker
4
+ app_port: 7860
5
+ ---
6
+
7
+ # Sporalize AI API
8
+
9
+ Deploy `AI/API` as a public Docker Space and `AI/Core` as a private dataset. The API loads `pipeline.py` and weights from Core at startup. The public service is accessible only through the Node backend using `ANALYSIS_PROXY_SECRET`; the health endpoint is public. Supabase credentials stay in Node.
10
+
11
+ Copy `.env.example` to `.env` for local use. Set `HF_TOKEN` with read access to Core and write access to a new private storage dataset. Set `BACKEND_URL` and the same `ANALYSIS_PROXY_SECRET` as Node. `CORE_REVISION` can pin the private Core dataset revision.
12
+
13
+ Model settings are constants near the top of `app.py`: `POSE_MODEL_FILE`, `POSE_MODEL_REPO_ID`, `YOLO_MODEL_FILE`, `YOLO_MODEL_REPO_ID`, and `YOLO_IMGSZ`. Change those values to select weights or detector input size. Video uploads do not have an application-level size limit.
14
+
15
+ The storage dataset contains only the pipeline's `results.json` and source videos under `sessions/<session UUID>/`. Calibration and action input files are temporary. `results.json` is the direct pipeline return after NumPy values are serialized and non-finite values become `null`.
16
+
17
+ `POST /api/analyze` accepts `playerId`, `clientId`, `videoOrders`, either uploaded `videos` or `videoDriveLinks`, `actionsJson`, `calibration`, and optional `sessionName`. Drive videos must be publicly downloadable. The service asks Node to authorize/create the session, saves results and videos to the HF dataset, then sends the path and action frame/FPS details to Node. Node owns the database and stores frame indices and FPS.
18
+
19
+ The authorized session endpoint adapts stored reports to the Results screen shape and provides private video URLs. Other endpoints include `/api/progress/{clientId}`, `/api/active-analyses`, `/api/cancel/{clientId}`, and `/api/retry-save/{clientId}`. All except `/healthz` require trusted proxy headers. Run one process because jobs and retry payloads are held in memory and do not survive a restart.
20
+
21
+ Apply `../../Database/migrations/20261016_node_analysis.sql` in Supabase. The older `20261016_analysis_persistence.sql` migration is not used.
app.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hmac
2
+ import hashlib
3
+ import importlib.util
4
+ import json
5
+ import logging
6
+ import math
7
+ import os
8
+ import re
9
+ import shutil
10
+ import tempfile
11
+ import time
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from contextlib import asynccontextmanager
14
+ from datetime import datetime, timezone
15
+ from functools import lru_cache
16
+ from pathlib import Path
17
+ from urllib.request import urlretrieve
18
+ from uuid import UUID, uuid5
19
+ from zipfile import ZipFile
20
+
21
+ import httpx
22
+ import numpy as np
23
+ from dotenv import load_dotenv
24
+ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
25
+ from fastapi.responses import FileResponse, JSONResponse
26
+ from huggingface_hub import HfApi, hf_hub_download, snapshot_download
27
+
28
+ load_dotenv(Path(__file__).with_name(".env"))
29
+ jobs = {}
30
+ executor = ThreadPoolExecutor(max_workers=1)
31
+ logger = logging.getLogger("sporalize")
32
+ POSE_MODEL_FILE = "Weights/rtmpose-x-halpe26.onnx"
33
+ POSE_MODEL_REPO_ID = ""
34
+ POSE_MODEL_HUB_FILE = "rtmpose-x-halpe26.onnx"
35
+ POSE_MODEL_REPO_TYPE = "model"
36
+ YOLO_MODEL_FILE = "Weights/yolo11x.pt"
37
+ YOLO_MODEL_REPO_ID = "Ultralytics/YOLO11"
38
+ YOLO_MODEL_HUB_FILE = "yolo11x.pt"
39
+ YOLO_MODEL_REPO_TYPE = "model"
40
+ YOLO_IMGSZ = 896
41
+
42
+
43
+ def hub():
44
+ return HfApi(token=os.environ["HF_TOKEN"])
45
+
46
+
47
+ def backend(path, user_id, method="GET", body=None):
48
+ for attempt in range(3):
49
+ try:
50
+ response = httpx.request(method, f'{os.environ["BACKEND_URL"].rstrip("/")}/internal/analysis{path}',
51
+ json=body, timeout=60, headers={"x-sporalize-proxy-secret": os.environ["ANALYSIS_PROXY_SECRET"], "x-sporalize-user-id": user_id})
52
+ response.raise_for_status()
53
+ return response.json() if response.content else None
54
+ except (httpx.TransportError, httpx.HTTPStatusError) as error:
55
+ if attempt == 2 or isinstance(error, httpx.HTTPStatusError) and error.response.status_code < 500:
56
+ raise
57
+ time.sleep(2 ** attempt)
58
+
59
+
60
+ @lru_cache(maxsize=1)
61
+ def runtime():
62
+ root = Path(snapshot_download(repo_id=os.environ["CORE_REPO_ID"], repo_type="dataset",
63
+ revision=os.environ.get("CORE_REVISION"), token=os.environ["HF_TOKEN"],
64
+ allow_patterns=["pipeline.py", "Weights/*"]))
65
+ spec = importlib.util.spec_from_file_location("sporalize_core", root / "pipeline.py")
66
+ module = importlib.util.module_from_spec(spec)
67
+ spec.loader.exec_module(module)
68
+ pose_file = POSE_MODEL_FILE
69
+ yolo_file = YOLO_MODEL_FILE
70
+ pose = (root / pose_file).resolve()
71
+ yolo = (root / yolo_file).resolve()
72
+ if root.resolve() not in pose.parents or root.resolve() not in yolo.parents:
73
+ raise RuntimeError("Model files must be inside the Core repository")
74
+ if not pose.is_file():
75
+ cache = Path(os.environ.get("HF_HOME", str(Path.home() / ".cache/huggingface"))) / "sporalize"
76
+ cache.mkdir(parents=True, exist_ok=True)
77
+ if not (cache / Path(pose_file).name).is_file():
78
+ if POSE_MODEL_REPO_ID:
79
+ downloaded = hf_hub_download(POSE_MODEL_REPO_ID, POSE_MODEL_HUB_FILE,
80
+ repo_type=POSE_MODEL_REPO_TYPE, token=os.environ["HF_TOKEN"])
81
+ shutil.copyfile(downloaded, cache / Path(pose_file).name)
82
+ elif pose_file == "Weights/rtmpose-x-halpe26.onnx":
83
+ with tempfile.TemporaryDirectory() as temporary:
84
+ archive = Path(temporary) / "pose.zip"
85
+ urlretrieve("https://download.openmmlab.com/mmpose/v1/projects/rtmposev1/onnx_sdk/rtmpose-x_simcc-body7_pt-body7-halpe26_700e-384x288-7fb6e239_20230606.zip", archive)
86
+ with ZipFile(archive) as bundle:
87
+ member = next(name for name in bundle.namelist() if name.endswith("end2end.onnx"))
88
+ with bundle.open(member) as source, (cache / Path(pose_file).name).open("wb") as target:
89
+ shutil.copyfileobj(source, target)
90
+ else:
91
+ raise RuntimeError("Configured pose model is missing from Core and no pose model repository was configured")
92
+ pose = cache / Path(pose_file).name
93
+ if not yolo.is_file():
94
+ yolo = Path(hf_hub_download(YOLO_MODEL_REPO_ID, YOLO_MODEL_HUB_FILE,
95
+ repo_type=YOLO_MODEL_REPO_TYPE, token=os.environ["HF_TOKEN"]))
96
+ return module.run_pipeline, {"POSE_PATH": str(pose), "YOLO_PATH": str(yolo)}
97
+
98
+
99
+ @asynccontextmanager
100
+ async def lifespan(app):
101
+ for name in ("CORE_REPO_ID", "STORAGE_REPO_ID", "HF_TOKEN", "BACKEND_URL"):
102
+ if not os.environ.get(name):
103
+ raise RuntimeError(f"{name} is required")
104
+ if len(os.environ.get("ANALYSIS_PROXY_SECRET", "")) < 32:
105
+ raise RuntimeError("ANALYSIS_PROXY_SECRET must contain at least 32 characters")
106
+ if os.environ["STORAGE_REPO_ID"].lower() == "shoraky/sporalizelabs-storage":
107
+ raise RuntimeError("Use a separate storage dataset for this application")
108
+ if not 32 <= len(os.environ["ANALYSIS_PROXY_SECRET"]) <= 4096:
109
+ raise RuntimeError("ANALYSIS_PROXY_SECRET length is invalid")
110
+ runtime()
111
+ yield
112
+
113
+
114
+ app = FastAPI(title="Sporalize AI", lifespan=lifespan)
115
+
116
+
117
+ @app.exception_handler(httpx.HTTPStatusError)
118
+ async def backend_error(request: Request, error: httpx.HTTPStatusError):
119
+ status = error.response.status_code
120
+ return JSONResponse({"detail": "Backend request failed"}, status_code=status if status < 500 else 502)
121
+
122
+
123
+ @app.middleware("http")
124
+ async def authenticate(request: Request, call_next):
125
+ if request.url.path != "/healthz":
126
+ secret = os.environ.get("ANALYSIS_PROXY_SECRET", "")
127
+ if len(secret) < 32 or not hmac.compare_digest(secret, request.headers.get("x-sporalize-proxy-secret", "")):
128
+ return JSONResponse({"detail": "Unauthorized"}, status_code=403)
129
+ try:
130
+ request.state.user_id = str(UUID(request.headers.get("x-sporalize-user-id", "")))
131
+ except ValueError:
132
+ return JSONResponse({"detail": "Invalid user identity"}, status_code=403)
133
+ return await call_next(request)
134
+
135
+
136
+ def json_value(value):
137
+ if isinstance(value, np.ndarray):
138
+ return json_value(value.tolist())
139
+ if isinstance(value, np.generic):
140
+ return json_value(value.item())
141
+ if isinstance(value, dict):
142
+ return {str(key): json_value(item) for key, item in value.items()}
143
+ if isinstance(value, (list, tuple)):
144
+ return [json_value(item) for item in value]
145
+ if isinstance(value, float) and not math.isfinite(value):
146
+ return None
147
+ return value
148
+
149
+
150
+ def drive_file_id(link):
151
+ value = link.strip()
152
+ if re.fullmatch(r"[A-Za-z0-9_-]{10,}", value):
153
+ return value
154
+ if not any(host in value for host in ("drive.google.com", "drive.usercontent.google.com")):
155
+ raise ValueError("Not a Google Drive file link")
156
+ match = re.search(r"/file/d/([A-Za-z0-9_-]{10,})|/d/([A-Za-z0-9_-]{10,})|[?&]id=([A-Za-z0-9_-]{10,})", value)
157
+ if not match:
158
+ raise ValueError("Could not find a Google Drive file ID")
159
+ return next(group for group in match.groups() if group)
160
+
161
+
162
+ async def download_drive_file(link, destination):
163
+ file_id = drive_file_id(link)
164
+ url = f"https://drive.google.com/uc?export=download&confirm=t&id={file_id}"
165
+ total = 0
166
+ async with httpx.AsyncClient(follow_redirects=True, timeout=300) as client:
167
+ async with client.stream("GET", url) as response:
168
+ response.raise_for_status()
169
+ content_type = response.headers.get("content-type", "").lower()
170
+ if "text/html" in content_type:
171
+ raise ValueError("Google Drive did not return a public video; check link access")
172
+ with destination.open("wb") as output:
173
+ async for chunk in response.aiter_bytes(1024 * 1024):
174
+ total += len(chunk)
175
+ output.write(chunk)
176
+ if total == 0:
177
+ raise ValueError("Google Drive returned an empty file")
178
+ return file_id, total
179
+
180
+
181
+ def run_job(client_id, work, cameras):
182
+ job = jobs[client_id]
183
+ session_id = job["sessionId"]
184
+ saved = False
185
+ try:
186
+ if job.get("cancel"):
187
+ raise InterruptedError()
188
+ job.update(status="running", progress=5, phase="Analyzing")
189
+ pipeline, weights = runtime()
190
+
191
+ def progress(action, total, frame, frames):
192
+ job.update(progress=5 + 85 * (action + (frame + 1) / max(frames, 1)) / max(total, 1),
193
+ updatedAt=int(time.time() * 1000))
194
+ return not job.get("cancel")
195
+
196
+ reports = pipeline(cameras, {**weights, "CALIBRATION_PATH": str(work / "calibration.npz"), "ACTIONS_PATH": str(work / "actions.json")}, {"YOLO_IMGSZ": YOLO_IMGSZ}, progress)
197
+ if job.get("cancel"):
198
+ raise InterruptedError()
199
+ (work / "results.json").write_text(json.dumps(json_value(reports), allow_nan=False), encoding="utf-8")
200
+ job.update(progress=95, phase="Saving")
201
+ hub().upload_folder(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
202
+ folder_path=str(work), path_in_repo=f"sessions/{session_id}",
203
+ allow_patterns=["results.json", "videos/*"])
204
+ saved = True
205
+ completion = {"status": "completed", "path": f"sessions/{session_id}", "actions": [
206
+ {"index": index, **{key: json_value(report[key]) for key in ("action", "start_frame", "end_frame", "fps", "error") if key in report}}
207
+ for index, report in enumerate(reports)]}
208
+ job["completion"] = completion
209
+ backend(f"/{session_id}", job["userId"], "PUT", completion)
210
+ job.pop("completion", None)
211
+ job.update(status="completed", phase="Completed", progress=100, resultUrl=f"/api/sessions/{session_id}")
212
+ except Exception as error:
213
+ status = "cancelled" if isinstance(error, InterruptedError) else "failed"
214
+ job.update(status=status, phase="Sync failed" if saved else status.capitalize(), error="Files saved; retry backend sync" if saved else "Analysis cancelled" if status == "cancelled" else "Analysis or saving failed")
215
+ if saved:
216
+ job["status"] = "failed"
217
+ logger.exception("Session %s %s", session_id, status)
218
+ try:
219
+ if not saved:
220
+ backend(f"/{session_id}", job["userId"], "PUT", {"status": status})
221
+ except Exception:
222
+ logger.exception("Could not update session %s", session_id)
223
+ finally:
224
+ job["updatedAt"] = int(time.time() * 1000)
225
+ shutil.rmtree(work, ignore_errors=True)
226
+
227
+
228
+ @app.get("/healthz")
229
+ def health():
230
+ return {"ok": True}
231
+
232
+
233
+ @app.post("/api/drive/probe")
234
+ def probe_drive(payload: dict):
235
+ links = payload.get("links") or ([payload["link"]] if payload.get("link") else [])
236
+ if not isinstance(links, list) or not 1 <= len(links) <= 16:
237
+ raise HTTPException(400, "Provide between 1 and 16 Drive links")
238
+ results = []
239
+ for link in links:
240
+ result = {"link": str(link)}
241
+ try:
242
+ file_id = drive_file_id(str(link))
243
+ with httpx.stream("GET", f"https://drive.google.com/uc?export=download&confirm=t&id={file_id}", follow_redirects=True, timeout=30) as response:
244
+ content_type = response.headers.get("content-type", "")
245
+ size = int(response.headers.get("content-length", "0") or 0)
246
+ if not response.is_success or "text/html" in content_type.lower():
247
+ raise ValueError("File is not publicly downloadable")
248
+ result.update(ok=True, fileId=file_id, fileName=f"{file_id}.mp4", sizeBytes=size or None, contentType=content_type)
249
+ except Exception as error:
250
+ result.update(ok=False, code="unavailable", error=str(error))
251
+ results.append(result)
252
+ return {"results": results}
253
+
254
+
255
+ @app.post("/api/analyze", status_code=202)
256
+ async def analyze(request: Request, playerId: str = Form(...), clientId: UUID = Form(...),
257
+ videoOrders: list[int] = Form(...), videos: list[UploadFile] = File(None),
258
+ videoDriveLinks: list[str] = Form(None),
259
+ actionsJson: UploadFile = File(...), calibration: UploadFile = File(...),
260
+ sessionName: str = Form("")):
261
+ videos = videos or []
262
+ videoDriveLinks = videoDriveLinks or []
263
+ if videos and videoDriveLinks or len(videos or videoDriveLinks) < 2 or len(videos or videoDriveLinks) != len(videoOrders) or len(set(videoOrders)) != len(videoOrders):
264
+ raise HTTPException(422, "Provide at least two videos with unique camera orders")
265
+ client_id, user_id = str(clientId), request.state.user_id
266
+ if client_id in jobs:
267
+ raise HTTPException(409, "Analysis already exists")
268
+ reference = playerId.strip()
269
+ if not reference or len(reference) > 100 or len(sessionName) > 150:
270
+ raise HTTPException(422, "Invalid player reference or session name")
271
+ session = backend("", user_id, "POST", {"clientId": client_id, "playerId": reference, "name": sessionName})
272
+ session_id, player_id = session["session_id"], session["player_id"]
273
+ now = int(time.time() * 1000)
274
+ jobs[client_id] = {"clientId": client_id, "sessionId": session_id, "playerId": player_id,
275
+ "userId": user_id, "status": "uploading", "phase": "Uploading", "progress": 0, "createdAt": now, "updatedAt": now}
276
+ work = Path(tempfile.mkdtemp(prefix="sporalize-"))
277
+ try:
278
+ (work / "videos").mkdir()
279
+ cameras = {}
280
+ uploads = [(actionsJson, work / "actions.json"), (calibration, work / "calibration.npz")]
281
+ video_names = []
282
+ sources = list(zip(videoOrders, videos)) if videos else list(zip(videoOrders, videoDriveLinks))
283
+ for order, source in sources:
284
+ if videos:
285
+ video = source
286
+ suffix = Path(video.filename or "video.mp4").suffix.lower()
287
+ else:
288
+ video = None
289
+ suffix = ".mp4"
290
+ if suffix not in {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"}:
291
+ raise HTTPException(422, "Unsupported video format")
292
+ target = work / "videos" / f"camera_{order}{suffix}"
293
+ video_names.append(target.name)
294
+ cameras[order] = str(target)
295
+ if video is not None:
296
+ uploads.append((video, target))
297
+ else:
298
+ await download_drive_file(source, target)
299
+ for upload, target in uploads:
300
+ with target.open("wb") as output:
301
+ while chunk := await upload.read(1024 * 1024):
302
+ output.write(chunk)
303
+ jobs[client_id].update(status="queued", phase="Queued")
304
+ jobs[client_id]["videoNames"] = video_names
305
+ executor.submit(run_job, client_id, work, cameras)
306
+ return {"accepted": True, "status": "queued", "clientId": client_id, "sessionId": session_id}
307
+ except Exception:
308
+ jobs[client_id].update(status="failed", phase="Failed")
309
+ shutil.rmtree(work)
310
+ try:
311
+ backend(f"/{session_id}", user_id, "PUT", {"status": "failed"})
312
+ except Exception:
313
+ logger.exception("Could not report failed upload %s", session_id)
314
+ raise
315
+
316
+
317
+ def owned_job(client_id, user_id):
318
+ job = jobs.get(client_id)
319
+ if not job or job["userId"] != user_id:
320
+ raise HTTPException(404, "Analysis not found")
321
+ return job
322
+
323
+
324
+ @app.get("/api/progress/{client_id}")
325
+ def progress(client_id: str, request: Request):
326
+ return {key: value for key, value in owned_job(client_id, request.state.user_id).copy().items() if key not in {"userId", "cancel", "completion"}}
327
+
328
+
329
+ @app.post("/api/retry-save/{client_id}")
330
+ def retry_save(client_id: str, request: Request):
331
+ job = owned_job(client_id, request.state.user_id)
332
+ if job.get("completion"):
333
+ backend(f'/{job["sessionId"]}', job["userId"], "PUT", job["completion"])
334
+ job.pop("completion", None)
335
+ job.update(status="completed", phase="Completed", progress=100, resultUrl=f'/api/sessions/{job["sessionId"]}')
336
+ return progress(client_id, request)
337
+
338
+
339
+ @app.get("/api/active-analyses")
340
+ def active(request: Request):
341
+ return {"jobs": [progress(key, request) for key, job in list(jobs.items()) if job["userId"] == request.state.user_id and job["status"] in {"uploading", "queued", "running"}]}
342
+
343
+
344
+ @app.post("/api/cancel/{client_id}")
345
+ def cancel(client_id: str, request: Request):
346
+ job = owned_job(client_id, request.state.user_id)
347
+ if job["status"] not in {"completed", "failed", "cancelled", "sync_failed"}:
348
+ job["cancel"] = True
349
+ return {"status": job["status"], "clientId": client_id}
350
+
351
+
352
+ def session_row(session_id, user_id):
353
+ row = backend(f"/{session_id}", user_id)
354
+ if row["path"] != f"sessions/{session_id}":
355
+ raise HTTPException(409, "Session uses a different storage layout")
356
+ return row
357
+
358
+
359
+ @app.get("/api/sessions/{session_id}")
360
+ def results(session_id: UUID, request: Request):
361
+ row = session_row(session_id, request.state.user_id)
362
+ path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
363
+ filename=f'{row["path"]}/results.json', token=os.environ["HF_TOKEN"])
364
+ reports = json.loads(Path(path).read_text(encoding="utf-8"))
365
+ prefix = f'{row["path"]}/videos/camera_'
366
+ camera_files = sorted({name[len(prefix):] for name in hub().list_repo_files(
367
+ repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset") if name.startswith(prefix)})
368
+ fps_values = [float(report.get("fps") or 30) for report in reports if report.get("fps")]
369
+ actions, failed = [], []
370
+ for index, report in enumerate(reports):
371
+ start = int(report.get("start_frame") or 0)
372
+ end = int(report.get("end_frame") or start)
373
+ action_fps = float(report.get("fps") or (fps_values[0] if fps_values else 30))
374
+ label = report.get("action", "Unknown")
375
+ if report.get("error"):
376
+ failed.append({"id": str(uuid5(session_id, str(index))), "label": label, "start": str(start), "end": str(end), "error": report["error"]})
377
+ continue
378
+ analytics = report.get("analytics") or {}
379
+ 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]
380
+ clips = [{"cameraId": int(name.split("_", 1)[1].split(".", 1)[0]), "url": f"/storage/{session_id}/videos/{name}"} for name in camera_files]
381
+ digest = hashlib.sha256(f"{session_id}:{index}".encode()).hexdigest()
382
+ action_id = f"{digest[:8]}-{digest[8:12]}-5{digest[13:16]}-8{digest[17:20]}-{digest[20:32]}"
383
+ actions.append({"id": action_id, "label": label,
384
+ "start": str(start), "end": str(end), "fps": action_fps,
385
+ "startFrame": start, "endFrame": end, "startSeconds": start / action_fps,
386
+ "endSeconds": (end + 1) / action_fps, "totalFrames": max(0, end - start + 1),
387
+ "preFrames": 0, "inFrame": max(0, (end - start) // 2), "postFrames": 0,
388
+ "cameraClips": clips, "sourceCameraClips": [], "preMetrics": [],
389
+ "preActionMetrics": [], "inActionMetrics": metrics, "postMetrics": [],
390
+ "postActionMetrics": [], "fullIntervalMetrics": [],
391
+ "skeleton": report.get("skeleton_history", []), "rawAnalytics": analytics})
392
+ created = row.get("created_at")
393
+ created_at = datetime.fromisoformat(created.replace("Z", "+00:00")) if created else datetime.now(timezone.utc)
394
+ if created_at.tzinfo is None:
395
+ created_at = created_at.replace(tzinfo=timezone.utc)
396
+ return {"id": str(session_id), "playerId": row["player_id"], "playerName": "",
397
+ "name": row.get("name", "Analysis session"),
398
+ "createdAt": int(created_at.timestamp() * 1000),
399
+ "targetSize": [0, 0], "cameraCount": len(camera_files), "actions": actions, "failedActions": failed}
400
+
401
+
402
+ @app.api_route("/storage/{session_id}/videos/{filename}", methods=["GET", "HEAD"])
403
+ def video(session_id: UUID, filename: str, request: Request):
404
+ row = session_row(session_id, request.state.user_id)
405
+ if Path(filename).name != filename or "\\" in filename:
406
+ raise HTTPException(404, "Video not found")
407
+ path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
408
+ filename=f'{row["path"]}/videos/{filename}', token=os.environ["HF_TOKEN"])
409
+ return FileResponse(path)
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ uvicorn>=0.30.0
3
+ python-multipart>=0.0.9
4
+ python-dotenv>=1.0.0
5
+ huggingface_hub>=0.34.0
6
+ httpx>=0.27.0
7
+ numpy
8
+ scipy
9
+ opencv-python-headless
10
+ onnxruntime
11
+ rtmlib>=0.0.14
12
+ torch
13
+ ultralytics
14
+ lap>=0.5.12