Spaces:
Running
Running
Harden analysis uploads and add video previews
Browse files- .env.example +2 -0
- app.py +74 -6
- src/server.ts +5 -2
.env.example
CHANGED
|
@@ -10,5 +10,7 @@ SUPABASE_SERVICE_ROLE_KEY=your-service-role-or-secret-key
|
|
| 10 |
CORE_REPO_ID=your-account/sporalize-core
|
| 11 |
STORAGE_REPO_ID=your-account/sporalize-application-storage
|
| 12 |
HF_TOKEN=
|
|
|
|
|
|
|
| 13 |
# CORE_REVISION=optional-pinned-revision
|
| 14 |
# Internal Node/AI URLs are set automatically by start.py.
|
|
|
|
| 10 |
CORE_REPO_ID=your-account/sporalize-core
|
| 11 |
STORAGE_REPO_ID=your-account/sporalize-application-storage
|
| 12 |
HF_TOKEN=
|
| 13 |
+
# Per-video upload ceiling; raise only if the Space has enough disk and memory.
|
| 14 |
+
MAX_VIDEO_SIZE_MB=2048
|
| 15 |
# CORE_REVISION=optional-pinned-revision
|
| 16 |
# Internal Node/AI URLs are set automatically by start.py.
|
app.py
CHANGED
|
@@ -20,6 +20,7 @@ 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
|
|
@@ -38,6 +39,9 @@ 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():
|
|
@@ -176,12 +180,37 @@ async def download_drive_file(link, destination):
|
|
| 176 |
with destination.open("wb") as output:
|
| 177 |
async for chunk in response.aiter_bytes(1024 * 1024):
|
| 178 |
total += len(chunk)
|
|
|
|
|
|
|
| 179 |
output.write(chunk)
|
| 180 |
if total == 0:
|
| 181 |
raise ValueError("Google Drive returned an empty file")
|
| 182 |
return file_id, total
|
| 183 |
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
def run_job(client_id, work, cameras):
|
| 186 |
job = jobs[client_id]
|
| 187 |
session_id = job["sessionId"]
|
|
@@ -204,7 +233,7 @@ def run_job(client_id, work, cameras):
|
|
| 204 |
job.update(progress=95, phase="Saving")
|
| 205 |
hub().upload_folder(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
|
| 206 |
folder_path=str(work), path_in_repo=f"sessions/{session_id}",
|
| 207 |
-
allow_patterns=["results.json", "videos/*"])
|
| 208 |
saved = True
|
| 209 |
completion = {"status": "completed", "path": f"sessions/{session_id}", "actions": [
|
| 210 |
{"index": index, **{key: json_value(report[key]) for key in ("action", "start_frame", "end_frame", "fps", "error") if key in report}}
|
|
@@ -267,6 +296,21 @@ async def analyze(request: Request, playerId: str = Form(...), clientId: UUID =
|
|
| 267 |
if videos and videoDriveLinks or len(videos or videoDriveLinks) < 2 or len(videos or videoDriveLinks) != len(videoOrders) or len(set(videoOrders)) != len(videoOrders):
|
| 268 |
raise HTTPException(422, "Provide at least two videos with unique camera orders")
|
| 269 |
client_id, user_id = str(clientId), request.state.user_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
if client_id in jobs:
|
| 271 |
raise HTTPException(409, "Analysis already exists")
|
| 272 |
reference = playerId.strip()
|
|
@@ -281,7 +325,8 @@ async def analyze(request: Request, playerId: str = Form(...), clientId: UUID =
|
|
| 281 |
try:
|
| 282 |
(work / "videos").mkdir()
|
| 283 |
cameras = {}
|
| 284 |
-
uploads = [(actionsJson, work / "actions.json"
|
|
|
|
| 285 |
video_names = []
|
| 286 |
sources = list(zip(videoOrders, videos)) if videos else list(zip(videoOrders, videoDriveLinks))
|
| 287 |
for order, source in sources:
|
|
@@ -297,13 +342,21 @@ async def analyze(request: Request, playerId: str = Form(...), clientId: UUID =
|
|
| 297 |
video_names.append(target.name)
|
| 298 |
cameras[order] = str(target)
|
| 299 |
if video is not None:
|
| 300 |
-
uploads.append((video, target))
|
| 301 |
else:
|
| 302 |
await download_drive_file(source, target)
|
| 303 |
-
for upload, target in uploads:
|
| 304 |
with target.open("wb") as output:
|
|
|
|
| 305 |
while chunk := await upload.read(1024 * 1024):
|
|
|
|
|
|
|
|
|
|
| 306 |
output.write(chunk)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
jobs[client_id].update(status="queued", phase="Queued")
|
| 308 |
jobs[client_id]["videoNames"] = video_names
|
| 309 |
executor.submit(run_job, client_id, work, cameras)
|
|
@@ -367,8 +420,10 @@ def results(session_id: UUID, request: Request):
|
|
| 367 |
filename=f'{row["path"]}/results.json', token=os.environ["HF_TOKEN"])
|
| 368 |
reports = json.loads(Path(path).read_text(encoding="utf-8"))
|
| 369 |
prefix = f'{row["path"]}/videos/camera_'
|
|
|
|
| 370 |
camera_files = sorted({name[len(prefix):] for name in hub().list_repo_files(
|
| 371 |
-
repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset")
|
|
|
|
| 372 |
fps_values = [float(report.get("fps") or 30) for report in reports if report.get("fps")]
|
| 373 |
actions, failed = [], []
|
| 374 |
for index, report in enumerate(reports):
|
|
@@ -381,7 +436,10 @@ def results(session_id: UUID, request: Request):
|
|
| 381 |
continue
|
| 382 |
analytics = report.get("analytics") or {}
|
| 383 |
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]
|
| 384 |
-
clips = [{"cameraId": int(name.split("_", 1)[1].split(".", 1)[0]),
|
|
|
|
|
|
|
|
|
|
| 385 |
digest = hashlib.sha256(f"{session_id}:{index}".encode()).hexdigest()
|
| 386 |
action_id = f"{digest[:8]}-{digest[8:12]}-5{digest[13:16]}-8{digest[17:20]}-{digest[20:32]}"
|
| 387 |
actions.append({"id": action_id, "label": label,
|
|
@@ -411,3 +469,13 @@ def video(session_id: UUID, filename: str, request: Request):
|
|
| 411 |
path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
|
| 412 |
filename=f'{row["path"]}/videos/{filename}', token=os.environ["HF_TOKEN"])
|
| 413 |
return FileResponse(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
import httpx
|
| 22 |
import numpy as np
|
| 23 |
+
import cv2
|
| 24 |
from dotenv import load_dotenv
|
| 25 |
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
| 26 |
from fastapi.responses import FileResponse, JSONResponse
|
|
|
|
| 39 |
YOLO_MODEL_HUB_FILE = "yolo11x.pt"
|
| 40 |
YOLO_MODEL_REPO_TYPE = "model"
|
| 41 |
YOLO_IMGSZ = 896
|
| 42 |
+
MAX_VIDEO_SIZE_BYTES = int(os.environ.get("MAX_VIDEO_SIZE_MB", "2048")) * 1024 * 1024
|
| 43 |
+
MAX_ACTIONS_SIZE_BYTES = 32 * 1024 * 1024
|
| 44 |
+
MAX_CALIBRATION_SIZE_BYTES = 256 * 1024 * 1024
|
| 45 |
|
| 46 |
|
| 47 |
def hub():
|
|
|
|
| 180 |
with destination.open("wb") as output:
|
| 181 |
async for chunk in response.aiter_bytes(1024 * 1024):
|
| 182 |
total += len(chunk)
|
| 183 |
+
if total > MAX_VIDEO_SIZE_BYTES:
|
| 184 |
+
raise HTTPException(413, "Video exceeds the configured per-file size limit")
|
| 185 |
output.write(chunk)
|
| 186 |
if total == 0:
|
| 187 |
raise ValueError("Google Drive returned an empty file")
|
| 188 |
return file_id, total
|
| 189 |
|
| 190 |
|
| 191 |
+
def create_video_poster(source, destination):
|
| 192 |
+
capture = cv2.VideoCapture(str(source))
|
| 193 |
+
try:
|
| 194 |
+
capture.set(cv2.CAP_PROP_POS_MSEC, 500)
|
| 195 |
+
ok, frame = capture.read()
|
| 196 |
+
if not ok:
|
| 197 |
+
capture.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
| 198 |
+
ok, frame = capture.read()
|
| 199 |
+
if not ok:
|
| 200 |
+
logger.warning("Could not decode preview frame from %s", source.name)
|
| 201 |
+
return False
|
| 202 |
+
height, width = frame.shape[:2]
|
| 203 |
+
if width > 640:
|
| 204 |
+
frame = cv2.resize(frame, (640, round(height * 640 / width)), interpolation=cv2.INTER_AREA)
|
| 205 |
+
ok, encoded = cv2.imencode(".webp", frame, [cv2.IMWRITE_WEBP_QUALITY, 76])
|
| 206 |
+
if not ok:
|
| 207 |
+
return False
|
| 208 |
+
destination.write_bytes(encoded.tobytes())
|
| 209 |
+
return True
|
| 210 |
+
finally:
|
| 211 |
+
capture.release()
|
| 212 |
+
|
| 213 |
+
|
| 214 |
def run_job(client_id, work, cameras):
|
| 215 |
job = jobs[client_id]
|
| 216 |
session_id = job["sessionId"]
|
|
|
|
| 233 |
job.update(progress=95, phase="Saving")
|
| 234 |
hub().upload_folder(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
|
| 235 |
folder_path=str(work), path_in_repo=f"sessions/{session_id}",
|
| 236 |
+
allow_patterns=["results.json", "videos/*", "posters/*.webp"])
|
| 237 |
saved = True
|
| 238 |
completion = {"status": "completed", "path": f"sessions/{session_id}", "actions": [
|
| 239 |
{"index": index, **{key: json_value(report[key]) for key in ("action", "start_frame", "end_frame", "fps", "error") if key in report}}
|
|
|
|
| 296 |
if videos and videoDriveLinks or len(videos or videoDriveLinks) < 2 or len(videos or videoDriveLinks) != len(videoOrders) or len(set(videoOrders)) != len(videoOrders):
|
| 297 |
raise HTTPException(422, "Provide at least two videos with unique camera orders")
|
| 298 |
client_id, user_id = str(clientId), request.state.user_id
|
| 299 |
+
if any(video.size is not None and video.size > MAX_VIDEO_SIZE_BYTES for video in videos):
|
| 300 |
+
raise HTTPException(413, f"Each video must be smaller than {MAX_VIDEO_SIZE_BYTES // (1024 * 1024)} MB")
|
| 301 |
+
if actionsJson.size is not None and actionsJson.size > MAX_ACTIONS_SIZE_BYTES:
|
| 302 |
+
raise HTTPException(413, "The actions file must be 32 MB or smaller")
|
| 303 |
+
if calibration.size is not None and calibration.size > MAX_CALIBRATION_SIZE_BYTES:
|
| 304 |
+
raise HTTPException(413, "The calibration file must be 256 MB or smaller")
|
| 305 |
+
active_jobs = [job for job in jobs.values() if job["status"] in {"uploading", "queued", "running"}]
|
| 306 |
+
if any(job["userId"] == user_id for job in active_jobs):
|
| 307 |
+
raise HTTPException(429, "Finish or cancel your active analysis before starting another")
|
| 308 |
+
if len(active_jobs) >= 3:
|
| 309 |
+
raise HTTPException(429, "The analysis queue is full. Try again shortly")
|
| 310 |
+
cutoff = int(time.time() * 1000) - 24 * 60 * 60 * 1000
|
| 311 |
+
for key, job in list(jobs.items()):
|
| 312 |
+
if job["status"] in {"completed", "failed", "cancelled", "sync_failed"} and job["updatedAt"] < cutoff:
|
| 313 |
+
jobs.pop(key, None)
|
| 314 |
if client_id in jobs:
|
| 315 |
raise HTTPException(409, "Analysis already exists")
|
| 316 |
reference = playerId.strip()
|
|
|
|
| 325 |
try:
|
| 326 |
(work / "videos").mkdir()
|
| 327 |
cameras = {}
|
| 328 |
+
uploads = [(actionsJson, work / "actions.json", MAX_ACTIONS_SIZE_BYTES),
|
| 329 |
+
(calibration, work / "calibration.npz", MAX_CALIBRATION_SIZE_BYTES)]
|
| 330 |
video_names = []
|
| 331 |
sources = list(zip(videoOrders, videos)) if videos else list(zip(videoOrders, videoDriveLinks))
|
| 332 |
for order, source in sources:
|
|
|
|
| 342 |
video_names.append(target.name)
|
| 343 |
cameras[order] = str(target)
|
| 344 |
if video is not None:
|
| 345 |
+
uploads.append((video, target, MAX_VIDEO_SIZE_BYTES))
|
| 346 |
else:
|
| 347 |
await download_drive_file(source, target)
|
| 348 |
+
for upload, target, size_limit in uploads:
|
| 349 |
with target.open("wb") as output:
|
| 350 |
+
total = 0
|
| 351 |
while chunk := await upload.read(1024 * 1024):
|
| 352 |
+
total += len(chunk)
|
| 353 |
+
if total > size_limit:
|
| 354 |
+
raise HTTPException(413, f"{target.name} exceeds the allowed size")
|
| 355 |
output.write(chunk)
|
| 356 |
+
poster_dir = work / "posters"
|
| 357 |
+
poster_dir.mkdir()
|
| 358 |
+
for order, source in cameras.items():
|
| 359 |
+
create_video_poster(source, poster_dir / f"camera_{order}.webp")
|
| 360 |
jobs[client_id].update(status="queued", phase="Queued")
|
| 361 |
jobs[client_id]["videoNames"] = video_names
|
| 362 |
executor.submit(run_job, client_id, work, cameras)
|
|
|
|
| 420 |
filename=f'{row["path"]}/results.json', token=os.environ["HF_TOKEN"])
|
| 421 |
reports = json.loads(Path(path).read_text(encoding="utf-8"))
|
| 422 |
prefix = f'{row["path"]}/videos/camera_'
|
| 423 |
+
video_extensions = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"}
|
| 424 |
camera_files = sorted({name[len(prefix):] for name in hub().list_repo_files(
|
| 425 |
+
repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset")
|
| 426 |
+
if name.startswith(prefix) and Path(name).suffix.lower() in video_extensions})
|
| 427 |
fps_values = [float(report.get("fps") or 30) for report in reports if report.get("fps")]
|
| 428 |
actions, failed = [], []
|
| 429 |
for index, report in enumerate(reports):
|
|
|
|
| 436 |
continue
|
| 437 |
analytics = report.get("analytics") or {}
|
| 438 |
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]
|
| 439 |
+
clips = [{"cameraId": int(name.split("_", 1)[1].split(".", 1)[0]),
|
| 440 |
+
"url": f"/storage/{session_id}/videos/{name}",
|
| 441 |
+
"poster": f"/storage/{session_id}/posters/camera_{int(name.split('_', 1)[1].split('.', 1)[0])}.webp"}
|
| 442 |
+
for name in camera_files]
|
| 443 |
digest = hashlib.sha256(f"{session_id}:{index}".encode()).hexdigest()
|
| 444 |
action_id = f"{digest[:8]}-{digest[8:12]}-5{digest[13:16]}-8{digest[17:20]}-{digest[20:32]}"
|
| 445 |
actions.append({"id": action_id, "label": label,
|
|
|
|
| 469 |
path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
|
| 470 |
filename=f'{row["path"]}/videos/{filename}', token=os.environ["HF_TOKEN"])
|
| 471 |
return FileResponse(path)
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
@app.api_route("/storage/{session_id}/posters/{filename}", methods=["GET", "HEAD"])
|
| 475 |
+
def video_poster(session_id: UUID, filename: str, request: Request):
|
| 476 |
+
row = session_row(session_id, request.state.user_id)
|
| 477 |
+
if Path(filename).name != filename or "\\" in filename or not filename.endswith(".webp"):
|
| 478 |
+
raise HTTPException(404, "Preview not found")
|
| 479 |
+
path = hf_hub_download(repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset",
|
| 480 |
+
filename=f'{row["path"]}/posters/{filename}', token=os.environ["HF_TOKEN"])
|
| 481 |
+
return FileResponse(path, media_type="image/webp", headers={"Cache-Control": "private, max-age=86400"})
|
src/server.ts
CHANGED
|
@@ -46,6 +46,8 @@ app.use((req, res, next) => {
|
|
| 46 |
});
|
| 47 |
|
| 48 |
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 25, standardHeaders: "draft-8", legacyHeaders: false });
|
|
|
|
|
|
|
| 49 |
const signupSchema = z.object({ fname: z.string().trim().min(1).max(100), lname: z.string().trim().min(1).max(100), email: z.string().trim().toLowerCase().email().max(255), password: z.string().min(8).max(128), academy: z.string().trim().max(150).optional() });
|
| 50 |
const loginSchema = z.object({ email: z.string().trim().toLowerCase().email(), password: z.string().min(1).max(128) });
|
| 51 |
const profileRoles = ["football_player", "coach", "club_manager", "academy_manager", "scout", "performance_analyst", "sports_director", "other"] as const;
|
|
@@ -489,9 +491,10 @@ const analysisProxy = (req: Request, res: Response, next: NextFunction) => {
|
|
| 489 |
// gate. Auth and subscription status endpoints remain available without a plan.
|
| 490 |
app.use("/api", (req, res, next) => {
|
| 491 |
if (req.originalUrl.startsWith("/api/auth/") || req.originalUrl === "/api/subscriptions" || req.originalUrl === "/api/subscription/access" || req.originalUrl === "/api/public-config") return next();
|
| 492 |
-
|
|
|
|
| 493 |
});
|
| 494 |
-
app.use("/storage", analysisProxy);
|
| 495 |
|
| 496 |
app.use((_req, res) => res.status(404).json({ error: "Route not found." }));
|
| 497 |
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
|
|
|
| 46 |
});
|
| 47 |
|
| 48 |
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 25, standardHeaders: "draft-8", legacyHeaders: false });
|
| 49 |
+
const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 3000, standardHeaders: "draft-8", legacyHeaders: false });
|
| 50 |
+
const analysisUploadLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 4, standardHeaders: "draft-8", legacyHeaders: false });
|
| 51 |
const signupSchema = z.object({ fname: z.string().trim().min(1).max(100), lname: z.string().trim().min(1).max(100), email: z.string().trim().toLowerCase().email().max(255), password: z.string().min(8).max(128), academy: z.string().trim().max(150).optional() });
|
| 52 |
const loginSchema = z.object({ email: z.string().trim().toLowerCase().email(), password: z.string().min(1).max(128) });
|
| 53 |
const profileRoles = ["football_player", "coach", "club_manager", "academy_manager", "scout", "performance_analyst", "sports_director", "other"] as const;
|
|
|
|
| 491 |
// gate. Auth and subscription status endpoints remain available without a plan.
|
| 492 |
app.use("/api", (req, res, next) => {
|
| 493 |
if (req.originalUrl.startsWith("/api/auth/") || req.originalUrl === "/api/subscriptions" || req.originalUrl === "/api/subscription/access" || req.originalUrl === "/api/public-config") return next();
|
| 494 |
+
const limiter = req.method === "POST" && req.path === "/analyze" ? analysisUploadLimiter : apiLimiter;
|
| 495 |
+
return limiter(req, res, (error?: unknown) => error ? next(error) : analysisProxy(req, res, next));
|
| 496 |
});
|
| 497 |
+
app.use("/storage", apiLimiter, analysisProxy);
|
| 498 |
|
| 499 |
app.use((_req, res) => res.status(404).json({ error: "Route not found." }));
|
| 500 |
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
|