Shoraky commited on
Commit
d689491
·
1 Parent(s): b3f8039

Harden analysis uploads and add video previews

Browse files
Files changed (3) hide show
  1. .env.example +2 -0
  2. app.py +74 -6
  3. 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"), (calibration, work / "calibration.npz")]
 
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") if name.startswith(prefix)})
 
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]), "url": f"/storage/{session_id}/videos/{name}"} for name in camera_files]
 
 
 
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
- return analysisProxy(req, res, next);
 
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) => {