Shoraky commited on
Commit
a1b8590
·
1 Parent(s): 35962e4

Add optional Colab GPU analysis worker

Browse files
Files changed (6) hide show
  1. .env.example +2 -0
  2. README.md +11 -0
  3. app.py +135 -12
  4. src/colab-worker.ts +61 -0
  5. src/config.ts +5 -0
  6. src/server.ts +2 -0
.env.example CHANGED
@@ -4,6 +4,8 @@ NODE_ENV=development
4
  FRONTEND_URL=http://localhost:8080
5
  BACKEND_URL=http://localhost:4000
6
  ANALYSIS_PROXY_SECRET=replace-with-a-random-secret-of-at-least-32-characters
 
 
7
  SUPABASE_URL=https://your-project.supabase.co
8
  SUPABASE_ANON_KEY=your-publishable-or-anon-key
9
  SUPABASE_SERVICE_ROLE_KEY=your-service-role-or-secret-key
 
4
  FRONTEND_URL=http://localhost:8080
5
  BACKEND_URL=http://localhost:4000
6
  ANALYSIS_PROXY_SECRET=replace-with-a-random-secret-of-at-least-32-characters
7
+ # Optional: use the same random secret in the Colab notebook to register its worker.
8
+ # COLAB_WORKER_SECRET=replace-with-a-different-random-secret-of-at-least-32-characters
9
  SUPABASE_URL=https://your-project.supabase.co
10
  SUPABASE_ANON_KEY=your-publishable-or-anon-key
11
  SUPABASE_SERVICE_ROLE_KEY=your-service-role-or-secret-key
README.md CHANGED
@@ -37,6 +37,17 @@ Set these Space variables/secrets from `.env.example`:
37
 
38
  Store tokens, the service-role key, and proxy secret as Space secrets. `HF_TOKEN` requires read access to Core and write access to the private storage dataset. Use a dedicated storage dataset for this application. `start.py` sets `ANALYSIS_API_URL` and `BACKEND_INTERNAL_URL` automatically; do not configure a separate public AI URL.
39
 
 
 
 
 
 
 
 
 
 
 
 
40
  For a local Docker check, build with `docker build -t sporalize-backend .`, then run `docker run --rm --env-file .env -e PORT=7860 -p 7860:7860 sporalize-backend`. The `.env` NODE_ENV setting controls whether production HTTPS checks apply.
41
 
42
  Node health: `GET /health`. AI health is internal at `http://127.0.0.1:8000/healthz` and becomes available after model initialization. Node health alone does not indicate model readiness. Video uploads go through Node directly to Python without Vercel. Validate hosting timeouts and representative large uploads before release.
 
37
 
38
  Store tokens, the service-role key, and proxy secret as Space secrets. `HF_TOKEN` requires read access to Core and write access to the private storage dataset. Use a dedicated storage dataset for this application. `start.py` sets `ANALYSIS_API_URL` and `BACKEND_INTERNAL_URL` automatically; do not configure a separate public AI URL.
39
 
40
+ ## Optional Colab GPU worker
41
+
42
+ Analysis automatically uses Colab for inference when its worker lease is healthy. If no worker is registered, Colab is on CPU, or its connection is lost, the backend falls back to its existing local analysis pipeline. Colab returns analysis reports only; the backend still prepares playback videos, writes the normal `sessions/{session_id}` dataset folder, and updates Supabase.
43
+
44
+ 1. Generate a separate random secret with at least 32 characters. Add it to the Hugging Face Space as `COLAB_WORKER_SECRET`. Do not reuse `ANALYSIS_PROXY_SECRET`.
45
+ 2. Open [`Colab Server/worker.ipynb`](../Colab%20Server/worker.ipynb) in Colab and add these Colab Secrets: `HF_TOKEN`, `STORAGE_REPO_ID`, `CORE_REPO_ID`, `SPORALIZE_BACKEND_URL`, and `SPORALIZE_COLAB_WORKER_SECRET`. `HF_TOKEN` needs read access to Core and read/write access to the private storage dataset; `STORAGE_REPO_ID` and `CORE_REPO_ID` must match the backend values. Set `CORE_REVISION` too if the backend pins one. The backend URL is the public Space origin, such as `https://shoraky-sporalize-api.hf.space`; the Colab secret must match `COLAB_WORKER_SECRET` exactly.
46
+ 3. Select a GPU runtime and run the notebook. It starts the worker and Cloudflare quick tunnel, waits for model initialization, then registers the generated URL automatically. Keep the notebook cell running while analyses should use Colab.
47
+ 4. The notebook refreshes the worker lease every 20 seconds. The backend treats it as offline after 75 seconds without a heartbeat. Stopping or disconnecting Colab returns new jobs to the normal backend pipeline.
48
+
49
+ The worker accepts one analysis at a time. The backend stages inputs and receives reports through temporary folders in the private Hugging Face dataset; large files and report payloads do not cross the tunnel. A job already running on Colab can resume on the backend if the tunnel is lost; Colab does not write final session records or artifacts, so fallback does not create duplicate saved sessions. Temporary handoff folders are deleted when the job completes or falls back. The backend still retains its usual CPU processing requirements for fallback. No Supabase schema change is needed.
50
+
51
  For a local Docker check, build with `docker build -t sporalize-backend .`, then run `docker run --rm --env-file .env -e PORT=7860 -p 7860:7860 sporalize-backend`. The `.env` NODE_ENV setting controls whether production HTTPS checks apply.
52
 
53
  Node health: `GET /health`. AI health is internal at `http://127.0.0.1:8000/healthz` and becomes available after model initialization. Node health alone does not indicate model readiness. Video uploads go through Node directly to Python without Vercel. Validate hosting timeouts and representative large uploads before release.
app.py CHANGED
@@ -67,6 +67,123 @@ def backend(path, user_id, method="GET", body=None, is_admin=False):
67
  time.sleep(2 ** attempt)
68
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  @lru_cache(maxsize=1)
71
  def runtime():
72
  root = Path(__file__).resolve().parent.parent / "Core"
@@ -375,18 +492,24 @@ def run_job(client_id, work, cameras):
375
  if job.get("cancel"):
376
  raise InterruptedError()
377
  job.update(status="running", progress=5, phase="Analyzing", stage="analysis")
378
- pipeline, weights = runtime()
379
-
380
- def progress(action, total, frame, frames):
381
- action_number = action + 1
382
- frame_total = max(1, frames)
383
- job.update(progress=5 + 85 * (action + (frame + 1) / frame_total) / max(total, 1),
384
- phase=f"Analyzing action {action_number}/{total}", step=frame + 1,
385
- total=frame_total, action=action_number, actionsTotal=total,
386
- updatedAt=int(time.time() * 1000))
387
- return not job.get("cancel")
388
-
389
- reports = pipeline(cameras, {**weights, "CALIBRATION_PATH": str(work / "calibration.npz"), "ACTIONS_PATH": str(work / "actions.json")}, {"YOLO_IMGSZ": YOLO_IMGSZ}, progress)
 
 
 
 
 
 
390
  if job.get("cancel"):
391
  raise InterruptedError()
392
  (work / "results.json").write_text(json.dumps(json_value(reports), allow_nan=False), encoding="utf-8")
 
67
  time.sleep(2 ** attempt)
68
 
69
 
70
+ def registered_colab_worker():
71
+ secret = os.environ.get("COLAB_WORKER_SECRET", "")
72
+ if len(secret) < 32:
73
+ return None
74
+ try:
75
+ response = httpx.get(
76
+ f'{os.environ["BACKEND_INTERNAL_URL"].rstrip("/")}/internal/colab-worker/current',
77
+ timeout=3,
78
+ headers={"x-sporalize-proxy-secret": os.environ["ANALYSIS_PROXY_SECRET"]},
79
+ )
80
+ response.raise_for_status()
81
+ registration = response.json()
82
+ if not registration.get("active") or not registration.get("url"):
83
+ return None
84
+ base_url = registration["url"].rstrip("/")
85
+ health = httpx.get(
86
+ f"{base_url}/worker/health", timeout=5,
87
+ headers={"x-sporalize-colab-secret": secret},
88
+ )
89
+ health.raise_for_status()
90
+ status = health.json()
91
+ return base_url if status.get("ready") and status.get("cuda") else None
92
+ except (httpx.HTTPError, ValueError, KeyError) as error:
93
+ logger.info("Colab GPU worker is not available: %s", error)
94
+ return None
95
+
96
+
97
+ def run_pipeline_on_colab(worker_url, client_id, work, cameras, job):
98
+ """Use HF as a temporary handoff so large videos never cross the tunnel."""
99
+ secret = os.environ["COLAB_WORKER_SECRET"]
100
+ headers = {"x-sporalize-colab-secret": secret}
101
+ input_path = f"colab-staging/{client_id}"
102
+ result_path = f"colab-results/{client_id}/reports.json"
103
+ job.update(status="running", progress=5, phase="Preparing analysis", stage="analysis")
104
+
105
+ try:
106
+ hub().upload_folder(
107
+ repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset", folder_path=str(work),
108
+ path_in_repo=input_path, allow_patterns=["actions.json", "calibration.npz", "videos/*"],
109
+ commit_message=f"Stage analysis inputs {client_id}", token=os.environ["HF_TOKEN"],
110
+ )
111
+ video_files = [{"order": order, "filename": Path(path).name} for order, path in cameras.items()]
112
+ response = httpx.post(
113
+ f"{worker_url}/worker/jobs",
114
+ json={"jobId": client_id, "sessionId": job["sessionId"], "inputPath": input_path, "videos": video_files},
115
+ headers=headers, timeout=20,
116
+ )
117
+ response.raise_for_status()
118
+
119
+ failures = 0
120
+ while True:
121
+ if job.get("cancel"):
122
+ try:
123
+ httpx.post(f"{worker_url}/worker/jobs/{client_id}/cancel", headers=headers, timeout=5)
124
+ except httpx.HTTPError:
125
+ pass
126
+ raise InterruptedError("Processing cancelled by user")
127
+
128
+ try:
129
+ status_response = httpx.get(f"{worker_url}/worker/jobs/{client_id}", headers=headers, timeout=10)
130
+ status_response.raise_for_status()
131
+ remote = status_response.json()
132
+ failures = 0
133
+ except (httpx.HTTPError, ValueError) as error:
134
+ failures += 1
135
+ if failures >= 4:
136
+ logger.warning("Colab worker connection was lost; using the primary analysis server: %s", error)
137
+ return None
138
+ time.sleep(3)
139
+ continue
140
+
141
+ status = remote.get("status")
142
+ if status == "completed":
143
+ try:
144
+ downloaded = hf_hub_download(
145
+ repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset", filename=result_path,
146
+ token=os.environ["HF_TOKEN"], force_download=True,
147
+ )
148
+ reports = json.loads(Path(downloaded).read_text(encoding="utf-8"))
149
+ return reports if isinstance(reports, list) else None
150
+ except Exception as error:
151
+ logger.warning("Colab result could not be read; using the primary analysis server: %s", error)
152
+ return None
153
+ if status in {"failed", "missing"}:
154
+ logger.warning("Colab analysis failed; using the primary analysis server: %s", remote.get("error", status))
155
+ return None
156
+ if status == "cancelled":
157
+ raise InterruptedError("Processing cancelled by user")
158
+
159
+ try:
160
+ percentage = min(100.0, max(0.0, float(remote.get("progress", 0))))
161
+ except (TypeError, ValueError):
162
+ percentage = 0.0
163
+ job.update(
164
+ status="running", stage="analysis", progress=5 + 0.85 * percentage,
165
+ phase=remote.get("phase") or "Analyzing session",
166
+ step=remote.get("step", 0), total=remote.get("total", 0),
167
+ action=remote.get("action", 0), actionsTotal=remote.get("actionsTotal", 0),
168
+ updatedAt=int(time.time() * 1000),
169
+ )
170
+ time.sleep(2)
171
+ except InterruptedError:
172
+ raise
173
+ except Exception as error:
174
+ logger.warning("Colab handoff failed; using the primary analysis server: %s", error)
175
+ return None
176
+ finally:
177
+ for remote_path in (input_path, f"colab-results/{client_id}"):
178
+ try:
179
+ hub().delete_folder(
180
+ repo_id=os.environ["STORAGE_REPO_ID"], repo_type="dataset", path_in_repo=remote_path,
181
+ commit_message=f"Clean up analysis handoff {client_id}", token=os.environ["HF_TOKEN"],
182
+ )
183
+ except Exception as error:
184
+ logger.info("Temporary Colab handoff cleanup skipped for %s: %s", remote_path, error)
185
+
186
+
187
  @lru_cache(maxsize=1)
188
  def runtime():
189
  root = Path(__file__).resolve().parent.parent / "Core"
 
492
  if job.get("cancel"):
493
  raise InterruptedError()
494
  job.update(status="running", progress=5, phase="Analyzing", stage="analysis")
495
+ worker_url = registered_colab_worker()
496
+ reports = run_pipeline_on_colab(worker_url, client_id, work, cameras, job) if worker_url else None
497
+ if reports is None:
498
+ if job.get("cancel"):
499
+ raise InterruptedError()
500
+ job.update(status="running", progress=5, phase="Analyzing session", stage="analysis")
501
+ pipeline, weights = runtime()
502
+
503
+ def progress(action, total, frame, frames):
504
+ action_number = action + 1
505
+ frame_total = max(1, frames)
506
+ job.update(progress=5 + 85 * (action + (frame + 1) / frame_total) / max(total, 1),
507
+ phase=f"Analyzing action {action_number}/{total}", step=frame + 1,
508
+ total=frame_total, action=action_number, actionsTotal=total,
509
+ updatedAt=int(time.time() * 1000))
510
+ return not job.get("cancel")
511
+
512
+ reports = pipeline(cameras, {**weights, "CALIBRATION_PATH": str(work / "calibration.npz"), "ACTIONS_PATH": str(work / "actions.json")}, {"YOLO_IMGSZ": YOLO_IMGSZ}, progress)
513
  if job.get("cancel"):
514
  raise InterruptedError()
515
  (work / "results.json").write_text(json.dumps(json_value(reports), allow_nan=False), encoding="utf-8")
src/colab-worker.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { Router, type Request } from "express";
3
+ import { z } from "zod";
4
+
5
+ const registrationSchema = z.object({ active: z.boolean().default(true), url: z.string().url().optional() }).strict();
6
+ const HEARTBEAT_TTL_MS = 75_000;
7
+
8
+ let worker: { url: string; lastSeenAt: number } | null = null;
9
+
10
+ function secretMatches(expected: string | undefined, received: string | undefined) {
11
+ if (!expected || !received) return false;
12
+ const expectedBytes = Buffer.from(expected);
13
+ const receivedBytes = Buffer.from(received);
14
+ return expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes);
15
+ }
16
+
17
+ function validTunnelUrl(value: string) {
18
+ try {
19
+ const url = new URL(value);
20
+ return url.protocol === "https:" && url.hostname.endsWith(".trycloudflare.com") &&
21
+ !url.username && !url.password && url.pathname === "/" && !url.search && !url.hash;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ export function colabWorkerRouter(analysisSecret: string, workerSecret?: string) {
28
+ const router = Router();
29
+
30
+ // The Colab notebook refreshes this lease while its tunnel is alive.
31
+ router.post("/register", (req, res) => {
32
+ if (!secretMatches(workerSecret, req.header("x-sporalize-colab-secret"))) {
33
+ return res.status(401).json({ error: "Invalid worker registration secret." });
34
+ }
35
+ const parsed = registrationSchema.safeParse(req.body);
36
+ if (!parsed.success) return res.status(400).json({ error: "Invalid worker registration." });
37
+ if (!parsed.data.active) {
38
+ worker = null;
39
+ return res.json({ active: false });
40
+ }
41
+ if (!parsed.data.url || !validTunnelUrl(parsed.data.url)) {
42
+ return res.status(400).json({ error: "Worker URL must be an HTTPS Cloudflare quick tunnel URL." });
43
+ }
44
+ worker = { url: parsed.data.url.replace(/\/$/, ""), lastSeenAt: Date.now() };
45
+ return res.json({ active: true, expiresInSeconds: HEARTBEAT_TTL_MS / 1000 });
46
+ });
47
+
48
+ // Only the private Python service can ask Node for the current worker lease.
49
+ router.get("/current", (req: Request, res) => {
50
+ if (!secretMatches(analysisSecret, req.header("x-sporalize-proxy-secret"))) {
51
+ return res.status(401).json({ error: "Unauthorized." });
52
+ }
53
+ if (!worker || Date.now() - worker.lastSeenAt > HEARTBEAT_TTL_MS) {
54
+ worker = null;
55
+ return res.json({ active: false });
56
+ }
57
+ return res.json({ active: true, url: worker.url });
58
+ });
59
+
60
+ return router;
61
+ }
src/config.ts CHANGED
@@ -9,6 +9,7 @@ const envSchema = z.object({
9
  BACKEND_URL: z.string().url(),
10
  ANALYSIS_API_URL: z.string().url().default("http://127.0.0.1:8000"),
11
  ANALYSIS_PROXY_SECRET: z.string().min(32),
 
12
  SUPABASE_URL: z.string().url(),
13
  SUPABASE_ANON_KEY: z.string().min(1),
14
  SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
@@ -21,6 +22,10 @@ if (!parsed.success) {
21
  }
22
 
23
  export const env = parsed.data;
 
 
 
 
24
  export const isProduction = env.NODE_ENV === "production";
25
  // Production browsers reach auth through Cloudflare Pages, including OAuth.
26
  export const authPublicOrigin = new URL(isProduction ? env.FRONTEND_URL : env.BACKEND_URL).origin;
 
9
  BACKEND_URL: z.string().url(),
10
  ANALYSIS_API_URL: z.string().url().default("http://127.0.0.1:8000"),
11
  ANALYSIS_PROXY_SECRET: z.string().min(32),
12
+ COLAB_WORKER_SECRET: z.string().optional(),
13
  SUPABASE_URL: z.string().url(),
14
  SUPABASE_ANON_KEY: z.string().min(1),
15
  SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
 
22
  }
23
 
24
  export const env = parsed.data;
25
+ if (env.COLAB_WORKER_SECRET && env.COLAB_WORKER_SECRET.length < 32) {
26
+ console.error("COLAB_WORKER_SECRET must contain at least 32 characters when configured");
27
+ process.exit(1);
28
+ }
29
  export const isProduction = env.NODE_ENV === "production";
30
  // Production browsers reach auth through Cloudflare Pages, including OAuth.
31
  export const authPublicOrigin = new URL(isProduction ? env.FRONTEND_URL : env.BACKEND_URL).origin;
src/server.ts CHANGED
@@ -13,6 +13,7 @@ import { authenticatedUser, clearPasswordRecoveryCookie, clearSessionCookies, ha
13
  import { hashPassword, oauthClient, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
14
  import { registerProfileImageRoutes } from "./profile-images.js";
15
  import { registerAdminRoutes } from "./admin.js";
 
16
 
17
  const app = express();
18
  app.disable("x-powered-by");
@@ -25,6 +26,7 @@ app.use(cors({
25
  }));
26
  app.use("/internal/analysis", analysisRouter(supabaseAdmin, env.ANALYSIS_PROXY_SECRET));
27
  app.use(express.json({ limit: "32kb", type: ["application/json", "text/plain"] }));
 
28
  app.use(cookieParser());
29
  app.use((req, res, next) => {
30
  if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();
 
13
  import { hashPassword, oauthClient, supabaseAdmin, supabaseAuth, syncProfile, userHasPassword, userPayload } from "./supabase.js";
14
  import { registerProfileImageRoutes } from "./profile-images.js";
15
  import { registerAdminRoutes } from "./admin.js";
16
+ import { colabWorkerRouter } from "./colab-worker.js";
17
 
18
  const app = express();
19
  app.disable("x-powered-by");
 
26
  }));
27
  app.use("/internal/analysis", analysisRouter(supabaseAdmin, env.ANALYSIS_PROXY_SECRET));
28
  app.use(express.json({ limit: "32kb", type: ["application/json", "text/plain"] }));
29
+ app.use("/internal/colab-worker", colabWorkerRouter(env.ANALYSIS_PROXY_SECRET, env.COLAB_WORKER_SECRET));
30
  app.use(cookieParser());
31
  app.use((req, res, next) => {
32
  if (["GET", "HEAD", "OPTIONS"].includes(req.method)) return next();