Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ backend/training/runs/
backend/training/__pycache__/
backend/calibration_frames/videos/
*.mp4
!frontend/web/public/Bounce_Animated.mp4
*.mov

# Node / frontend artifacts
Expand Down Expand Up @@ -76,7 +77,7 @@ docs/upgrade-plan*.md
docs/superpowers/

# Audit (responsive screenshots, session state) — local-only
frontend/web/scripts/audit/storageState.json
frontend/web/scripts/audit/storageState*.json
frontend/web/scripts/audit/screenshots/
frontend/web/scripts/audit/sign-in-failed.png

Expand All @@ -86,3 +87,5 @@ catboost_info/
legacy/
media/
.codegraph/
frontend/web/scripts/audit/functional/REPORT.md
frontend/web/scripts/audit/functional/report.json
141 changes: 84 additions & 57 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
import os
import tempfile
import time
import uuid
import modal
import requests
from fastapi import Request, HTTPException
from fastapi.responses import JSONResponse

app = modal.App("tennis-modal")

Expand Down Expand Up @@ -66,27 +68,40 @@ async def process_video(request: Request):
):
raise HTTPException(status_code=401, detail="Unauthorized")

payload = await request.json()
file_key = payload["file_key"]
match_id = payload["match_id"]

from supabase import create_client

supabase = create_client(
os.environ["SUPABASE_URL"],
os.environ["SUPABASE_SERVICE_ROLE_KEY"],
)

def _stage(stage: str, progress: float) -> None:
try:
payload = await request.json()
if not isinstance(payload, dict):
raise ValueError("Expected an object")
match_id = payload.get("match_id")
file_key = payload.get("file_key")
if not isinstance(match_id, str):
raise ValueError("Missing recording ID")
uuid.UUID(match_id)
if not isinstance(file_key, str) or not file_key.startswith(f"{match_id}/"):
raise ValueError("Storage key does not belong to the recording")
if any(part in ("", ".", "..") for part in file_key.split("/")):
raise ValueError("Invalid storage key")
except (ValueError, TypeError):
return JSONResponse({"error": "Invalid recording ID or storage key."}, status_code=400)

supabase = None

def _stage(stage: str, progress: float, *, failure: str | None = None) -> None:
"""Write an early-stage breadcrumb to Supabase so the UI shows what's
happening during the 15-90s window between the trigger-process webhook
and run_pipeline's heartbeat. Without this the page sat on
'Calibrating the court · STARTING' (derived stage from progress=0)
for the entire Modal cold-start + download + import phase.
Defensive on column-missing so an un-migrated environment still
runs to completion."""
nonlocal supabase
payload: dict = {"progress": round(float(progress), 3), "processing_stage": stage}
if failure:
payload = {**payload, "status": "failed", "error": failure}
try:
if supabase is None:
from supabase import create_client
supabase = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_SERVICE_ROLE_KEY"])
supabase.table("matches").update(payload).eq("id", match_id).execute()
print(f"[Stage] {stage} ({progress:.3f})", flush=True)
except Exception as e:
Expand All @@ -97,55 +112,67 @@ def _stage(stage: str, progress: float) -> None:
except Exception as e2:
print(f"[Stage] {stage} write failed: {e2}", flush=True)

_stage("Downloading recording", 0.008)
try:
from supabase import create_client

# Download video — preserve original filename so _resolve_camera_id can detect court number.
# Supabase storage occasionally returns 504 Gateway Timeout under load; retry with a
# fresh signed URL on each attempt (the URL itself is fine, but a fresh one is cheap insurance).
original_name = os.path.basename(file_key)
video_path = os.path.join(tempfile.gettempdir(), original_name)
supabase = create_client(
os.environ["SUPABASE_URL"],
os.environ["SUPABASE_SERVICE_ROLE_KEY"],
)

download_attempts = 4
last_exc = None
for attempt in range(1, download_attempts + 1):
try:
signed = supabase.storage.from_("raw-videos").create_signed_url(
file_key,
expires_in=3600,
)
signed_url = signed["signedUrl"]

# (connect, read) timeouts. Most stalls are read-side; 300s read covers
# multi-GB clips at typical CDN throughput while still failing fast on dead links.
with requests.get(signed_url, stream=True, timeout=(15, 300)) as r:
r.raise_for_status()
with open(video_path, "wb") as f:
for chunk in r.iter_content(64 * 1024):
if chunk:
f.write(chunk)
break # success
except (requests.exceptions.HTTPError,
requests.exceptions.ConnectionError,
requests.exceptions.ChunkedEncodingError,
requests.exceptions.Timeout) as exc:
last_exc = exc
status = getattr(getattr(exc, "response", None), "status_code", None)
# 4xx (other than 408/429) are real errors — don't retry
if status is not None and 400 <= status < 500 and status not in (408, 429):
raise
if attempt == download_attempts:
raise
backoff = min(60, 2 ** attempt) # 2s, 4s, 8s, 16s (cap 60s)
print(f"[download] attempt {attempt}/{download_attempts} failed ({type(exc).__name__}: {exc}); retrying in {backoff}s")
time.sleep(backoff)
else:
# Should be unreachable — loop either breaks on success or raises on final attempt.
if last_exc is not None:
raise last_exc
_stage("Downloading recording", 0.008)

_stage("Loading models", 0.012)
# Download video — preserve original filename so _resolve_camera_id can detect court number.
# Supabase storage occasionally returns 504 Gateway Timeout under load; retry with a
# fresh signed URL on each attempt (the URL itself is fine, but a fresh one is cheap insurance).
original_name = os.path.basename(file_key)
video_path = os.path.join(tempfile.gettempdir(), original_name)

download_attempts = 4
last_exc = None
for attempt in range(1, download_attempts + 1):
try:
signed = supabase.storage.from_("raw-videos").create_signed_url(
file_key,
expires_in=3600,
)
signed_url = signed["signedUrl"]

# (connect, read) timeouts. Most stalls are read-side; 300s read covers
# multi-GB clips at typical CDN throughput while still failing fast on dead links.
with requests.get(signed_url, stream=True, timeout=(15, 300)) as r:
r.raise_for_status()
with open(video_path, "wb") as f:
for chunk in r.iter_content(64 * 1024):
if chunk:
f.write(chunk)
break # success
except (requests.exceptions.HTTPError,
requests.exceptions.ConnectionError,
requests.exceptions.ChunkedEncodingError,
requests.exceptions.Timeout) as exc:
last_exc = exc
status = getattr(getattr(exc, "response", None), "status_code", None)
# 4xx (other than 408/429) are real errors — don't retry
if status is not None and 400 <= status < 500 and status not in (408, 429):
raise
if attempt == download_attempts:
raise
backoff = min(60, 2 ** attempt) # 2s, 4s, 8s, 16s (cap 60s)
print(f"[download] attempt {attempt}/{download_attempts} failed ({type(exc).__name__}: {exc}); retrying in {backoff}s")
time.sleep(backoff)
else:
# Should be unreachable — loop either breaks on success or raises on final attempt.
if last_exc is not None:
raise last_exc

_stage("Loading models", 0.012)

from backend.pipeline.run import run_pipeline
except Exception:
_stage("Processing could not start", 0, failure="Processing could not start. Press Reprocess to try again.")
raise

from backend.pipeline.run import run_pipeline
result = run_pipeline(video_path, match_id)

return result
Expand Down
6 changes: 4 additions & 2 deletions backend/pipeline/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from backend.vision.heatmaps import generate_minimap_heatmaps, generate_player_shot_dot_map
from backend.vision.postprocess import detect_shot_frames

from backend.pipeline.storage import upload_processed_video, upload_heatmap_png, get_supabase, make_streamable_mp4, upload_results_parallel
from backend.pipeline.storage import upload_processed_video, upload_heatmap_png, get_supabase, make_streamable_mp4, upload_results_parallel, ProcessedVideoUploadError, PROCESSED_VIDEO_UPLOAD_ERROR
from backend.pipeline.config import PipelineConfig
from backend.pipeline.rallies import (
RALLY_GAP_SECONDS,
Expand Down Expand Up @@ -2561,6 +2561,8 @@ def update_progress(value: float, stage: "str | None" = None):
local_shot_map_path=local_shot_map_path,
)
results_path = upload_results.get("results_path")
if not results_path:
raise ProcessedVideoUploadError(PROCESSED_VIDEO_UPLOAD_ERROR)
bounce_heatmap_path = upload_results.get("bounce_heatmap_path")
player_heatmap_path = upload_results.get("player_heatmap_path")
player_shot_map_path = upload_results.get("player_shot_map_path")
Expand Down Expand Up @@ -2697,7 +2699,7 @@ def update_progress(value: float, stage: "str | None" = None):
logging.exception("Pipeline failed for match %s", match_id)
supabase.table("matches").update({
"status": "failed",
"error": "Processing failed. Please try again.",
"error": PROCESSED_VIDEO_UPLOAD_ERROR if isinstance(e, ProcessedVideoUploadError) else "Processing failed. Please try again.",
"progress": 0
}).eq("id", match_id).execute()
raise
Expand Down
15 changes: 14 additions & 1 deletion backend/pipeline/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
_UPLOAD_BUDGET_SEC = 600.0 # overall wall-clock per artifact across retries (< the 1800s job cap)
_FFMPEG_BUDGET_SEC = 900 # overall encode budget shared across codec attempts (< the 1800s job cap)

PROCESSED_VIDEO_UPLOAD_ERROR = "The analyzed video could not be saved. Press Reprocess to try again."


class ProcessedVideoUploadError(RuntimeError):
"""The required video artifact could not be stored."""


def make_streamable_mp4(input_path: str, source_audio_path: str | None = None) -> str:
"""
Re-encode and remux to a browser-streamable MP4 (moov atom first).
Expand Down Expand Up @@ -321,7 +328,13 @@ def upload_results_parallel(
# We deliberately do NOT sweep optional uploads on failure: heatmap keys are
# deterministic (<match_id>/*.png) with x-upsert, so on a reprocess they may
# belong to a prior successful run whose row still references them.
results = {"results_path": video_future.result()}
try:
results = {"results_path": video_future.result()}
except Exception as e:
# Coach-readable terminal error; run_pipeline writes it to the row.
raise ProcessedVideoUploadError(PROCESSED_VIDEO_UPLOAD_ERROR) from e
if not results["results_path"]:
raise ProcessedVideoUploadError(PROCESSED_VIDEO_UPLOAD_ERROR)
for key, future in heatmap_futures.items():
try:
results[key] = future.result()
Expand Down
Loading