JakgritB commited on
Commit
e72b932
·
1 Parent(s): 102f4d2

Deploy safe hackathon demo with proxy

Browse files
Dockerfile CHANGED
@@ -34,6 +34,7 @@ COPY frontend/ /app/frontend/
34
  ENV NEXT_PUBLIC_API_URL=""
35
  ENV NEXT_PUBLIC_DEMO_ENABLED="true"
36
  ENV NEXT_PUBLIC_DEMO_ONLY="true"
 
37
 
38
  RUN cd /app/frontend && npm run build
39
 
 
34
  ENV NEXT_PUBLIC_API_URL=""
35
  ENV NEXT_PUBLIC_DEMO_ENABLED="true"
36
  ENV NEXT_PUBLIC_DEMO_ONLY="true"
37
+ ENV REMOTE_BACKEND_URL="http://129.212.178.101:8080"
38
 
39
  RUN cd /app/frontend && npm run build
40
 
README.md CHANGED
@@ -1,3 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
1
  # ElevenClip AI ✂️
2
 
3
  > **AMD Developer Hackathon 2026 — Track 3: Vision & Multimodal AI**
@@ -233,6 +244,15 @@ For a self-contained HuggingFace GPU Space, leave `NEXT_PUBLIC_API_URL=""` so ng
233
 
234
  For the public HuggingFace Space, set `NEXT_PUBLIC_DEMO_ONLY=true`. Visitors can open the UI and run the simulated demo without touching AMD GPU credits. Judges can enter the access code to run real generation against the protected AMD GPU Cloud backend.
235
 
 
 
 
 
 
 
 
 
 
236
  ---
237
 
238
  ## Hackathon Compliance
 
1
+ ---
2
+ title: ElevenClip AI
3
+ emoji: ✂️
4
+ colorFrom: red
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
  # ElevenClip AI ✂️
13
 
14
  > **AMD Developer Hackathon 2026 — Track 3: Vision & Multimodal AI**
 
244
 
245
  For the public HuggingFace Space, set `NEXT_PUBLIC_DEMO_ONLY=true`. Visitors can open the UI and run the simulated demo without touching AMD GPU credits. Judges can enter the access code to run real generation against the protected AMD GPU Cloud backend.
246
 
247
+ The current Docker setup keeps `NEXT_PUBLIC_API_URL=""` so the browser calls the HF Space on the same origin, then FastAPI forwards real judge requests to `REMOTE_BACKEND_URL`. This avoids browser mixed-content blocking from an HTTPS Space calling an HTTP AMD Cloud IP directly.
248
+
249
+ ```env
250
+ # HF Space / Docker runtime
251
+ NEXT_PUBLIC_API_URL=
252
+ NEXT_PUBLIC_DEMO_ONLY=true
253
+ REMOTE_BACKEND_URL=http://129.212.178.101:8080
254
+ ```
255
+
256
  ---
257
 
258
  ## Hackathon Compliance
backend/main.py CHANGED
@@ -17,11 +17,12 @@ import uuid
17
  from pathlib import Path
18
  from typing import Optional
19
 
20
- from fastapi import FastAPI, UploadFile, File, Form, Header, WebSocket, WebSocketDisconnect, HTTPException
21
  from fastapi.middleware.cors import CORSMiddleware
22
  from fastapi.staticfiles import StaticFiles
23
  from pydantic import BaseModel
24
  from loguru import logger
 
25
 
26
  from src.gpu.rocm_utils import get_device, log_gpu_status
27
  from src.gpu.vllm_manager import ensure_vllm_running, vllm_stop, vllm_status
@@ -50,8 +51,10 @@ WORK_DIR.mkdir(parents=True, exist_ok=True)
50
  DEMO_ACCESS_CODE = os.getenv("DEMO_ACCESS_CODE", "").strip()
51
  MAX_CONCURRENT_JOBS = int(os.getenv("MAX_CONCURRENT_JOBS", "1"))
52
  MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "500"))
 
53
 
54
- app.mount("/downloads", StaticFiles(directory=str(WORK_DIR)), name="downloads")
 
55
 
56
  # In-memory session store + WebSocket registry
57
  sessions: dict[str, dict] = {}
@@ -66,6 +69,15 @@ def _require_access(x_demo_key: Optional[str]) -> None:
66
  raise HTTPException(403, "Access code required for generation")
67
 
68
 
 
 
 
 
 
 
 
 
 
69
  # ─── Startup ──────────────────────────────────────────────────────────────
70
 
71
  @app.on_event("startup")
@@ -182,6 +194,14 @@ async def health():
182
  @app.post("/api/video-info")
183
  async def video_info(req: VideoInfoRequest, x_demo_key: Optional[str] = Header(None, alias="X-Demo-Key")):
184
  _require_access(x_demo_key)
 
 
 
 
 
 
 
 
185
  try:
186
  return get_video_info(req.url)
187
  except Exception as e:
@@ -196,6 +216,27 @@ async def process(
196
  ):
197
  """Main pipeline endpoint. Returns session_id immediately; progress via WebSocket."""
198
  _require_access(x_demo_key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  if len(active_jobs) >= MAX_CONCURRENT_JOBS:
200
  raise HTTPException(429, "GPU is busy. Please try again in a few minutes.")
201
 
@@ -383,6 +424,11 @@ async def _run_pipeline(
383
 
384
  @app.get("/api/clips/{session_id}")
385
  async def get_clips(session_id: str):
 
 
 
 
 
386
  session = sessions.get(session_id)
387
  if not session:
388
  raise HTTPException(404, "Session not found")
@@ -391,6 +437,14 @@ async def get_clips(session_id: str):
391
 
392
  @app.patch("/api/clips/{session_id}/{clip_index}/subtitles")
393
  async def patch_subtitle(session_id: str, clip_index: int, patch: SubtitlePatch):
 
 
 
 
 
 
 
 
394
  clip = _get_clip_or_404(session_id, clip_index)
395
  if not clip.get("ass_path"):
396
  raise HTTPException(404, "No subtitle file for this clip")
@@ -400,6 +454,14 @@ async def patch_subtitle(session_id: str, clip_index: int, patch: SubtitlePatch)
400
 
401
  @app.patch("/api/clips/{session_id}/{clip_index}/style")
402
  async def patch_global_style(session_id: str, clip_index: int, patch: GlobalStylePatch):
 
 
 
 
 
 
 
 
403
  clip = _get_clip_or_404(session_id, clip_index)
404
  if not clip.get("ass_path"):
405
  raise HTTPException(404, "No subtitle file for this clip")
@@ -409,6 +471,11 @@ async def patch_global_style(session_id: str, clip_index: int, patch: GlobalStyl
409
 
410
  @app.post("/api/clips/{session_id}/{clip_index}/render")
411
  async def render_clip(session_id: str, clip_index: int):
 
 
 
 
 
412
  clip = _get_clip_or_404(session_id, clip_index)
413
 
414
  clip_path = Path(clip["clip_path"])
@@ -439,14 +506,34 @@ def _get_clip_or_404(session_id: str, clip_index: int) -> dict:
439
 
440
  # ─── vLLM management endpoints ────────────────────────────────────────────────
441
 
 
 
 
 
 
 
 
 
442
  @app.get("/api/vllm/status")
443
  async def get_vllm_status():
 
 
 
 
444
  return vllm_status()
445
 
446
 
447
  @app.post("/api/vllm/stop")
448
  async def stop_vllm(x_demo_key: Optional[str] = Header(None, alias="X-Demo-Key")):
449
  _require_access(x_demo_key)
 
 
 
 
 
 
 
 
450
  loop = asyncio.get_running_loop()
451
  await loop.run_in_executor(None, vllm_stop)
452
  return {"ok": True, "message": "vLLM stopped — will restart automatically on next job"}
@@ -455,6 +542,14 @@ async def stop_vllm(x_demo_key: Optional[str] = Header(None, alias="X-Demo-Key")
455
  @app.post("/api/vllm/start")
456
  async def start_vllm(x_demo_key: Optional[str] = Header(None, alias="X-Demo-Key")):
457
  _require_access(x_demo_key)
 
 
 
 
 
 
 
 
458
  loop = asyncio.get_running_loop()
459
  await loop.run_in_executor(None, ensure_vllm_running)
460
  return {"ok": True, "status": vllm_status()}
 
17
  from pathlib import Path
18
  from typing import Optional
19
 
20
+ from fastapi import FastAPI, UploadFile, File, Form, Header, Response, WebSocket, WebSocketDisconnect, HTTPException
21
  from fastapi.middleware.cors import CORSMiddleware
22
  from fastapi.staticfiles import StaticFiles
23
  from pydantic import BaseModel
24
  from loguru import logger
25
+ import httpx
26
 
27
  from src.gpu.rocm_utils import get_device, log_gpu_status
28
  from src.gpu.vllm_manager import ensure_vllm_running, vllm_stop, vllm_status
 
51
  DEMO_ACCESS_CODE = os.getenv("DEMO_ACCESS_CODE", "").strip()
52
  MAX_CONCURRENT_JOBS = int(os.getenv("MAX_CONCURRENT_JOBS", "1"))
53
  MAX_UPLOAD_MB = int(os.getenv("MAX_UPLOAD_MB", "500"))
54
+ REMOTE_BACKEND_URL = os.getenv("REMOTE_BACKEND_URL", "").rstrip("/")
55
 
56
+ if not REMOTE_BACKEND_URL:
57
+ app.mount("/downloads", StaticFiles(directory=str(WORK_DIR)), name="downloads")
58
 
59
  # In-memory session store + WebSocket registry
60
  sessions: dict[str, dict] = {}
 
69
  raise HTTPException(403, "Access code required for generation")
70
 
71
 
72
+ def _demo_headers(x_demo_key: Optional[str]) -> dict[str, str]:
73
+ return {"X-Demo-Key": x_demo_key.strip()} if x_demo_key and x_demo_key.strip() else {}
74
+
75
+
76
+ def _proxy_response(resp: httpx.Response) -> Response:
77
+ content_type = resp.headers.get("content-type", "application/octet-stream")
78
+ return Response(content=resp.content, status_code=resp.status_code, media_type=content_type)
79
+
80
+
81
  # ─── Startup ──────────────────────────────────────────────────────────────
82
 
83
  @app.on_event("startup")
 
194
  @app.post("/api/video-info")
195
  async def video_info(req: VideoInfoRequest, x_demo_key: Optional[str] = Header(None, alias="X-Demo-Key")):
196
  _require_access(x_demo_key)
197
+ if REMOTE_BACKEND_URL:
198
+ async with httpx.AsyncClient(timeout=120.0) as client:
199
+ resp = await client.post(
200
+ f"{REMOTE_BACKEND_URL}/api/video-info",
201
+ json=req.model_dump(),
202
+ headers=_demo_headers(x_demo_key),
203
+ )
204
+ return _proxy_response(resp)
205
  try:
206
  return get_video_info(req.url)
207
  except Exception as e:
 
216
  ):
217
  """Main pipeline endpoint. Returns session_id immediately; progress via WebSocket."""
218
  _require_access(x_demo_key)
219
+ if REMOTE_BACKEND_URL:
220
+ file_bytes: Optional[bytes] = None
221
+ file_name: Optional[str] = None
222
+ file_type = "application/octet-stream"
223
+ if file:
224
+ file_bytes = await file.read()
225
+ file_name = file.filename or "upload.mp4"
226
+ file_type = file.content_type or file_type
227
+ if len(file_bytes) > MAX_UPLOAD_MB * 1024 * 1024:
228
+ raise HTTPException(413, f"File too large. Max upload size is {MAX_UPLOAD_MB} MB.")
229
+
230
+ files = {"file": (file_name, file_bytes, file_type)} if file_bytes and file_name else None
231
+ async with httpx.AsyncClient(timeout=900.0) as client:
232
+ resp = await client.post(
233
+ f"{REMOTE_BACKEND_URL}/api/process",
234
+ data={"settings_json": settings_json},
235
+ files=files,
236
+ headers=_demo_headers(x_demo_key),
237
+ )
238
+ return _proxy_response(resp)
239
+
240
  if len(active_jobs) >= MAX_CONCURRENT_JOBS:
241
  raise HTTPException(429, "GPU is busy. Please try again in a few minutes.")
242
 
 
424
 
425
  @app.get("/api/clips/{session_id}")
426
  async def get_clips(session_id: str):
427
+ if REMOTE_BACKEND_URL:
428
+ async with httpx.AsyncClient(timeout=120.0) as client:
429
+ resp = await client.get(f"{REMOTE_BACKEND_URL}/api/clips/{session_id}")
430
+ return _proxy_response(resp)
431
+
432
  session = sessions.get(session_id)
433
  if not session:
434
  raise HTTPException(404, "Session not found")
 
437
 
438
  @app.patch("/api/clips/{session_id}/{clip_index}/subtitles")
439
  async def patch_subtitle(session_id: str, clip_index: int, patch: SubtitlePatch):
440
+ if REMOTE_BACKEND_URL:
441
+ async with httpx.AsyncClient(timeout=120.0) as client:
442
+ resp = await client.patch(
443
+ f"{REMOTE_BACKEND_URL}/api/clips/{session_id}/{clip_index}/subtitles",
444
+ json=patch.model_dump(),
445
+ )
446
+ return _proxy_response(resp)
447
+
448
  clip = _get_clip_or_404(session_id, clip_index)
449
  if not clip.get("ass_path"):
450
  raise HTTPException(404, "No subtitle file for this clip")
 
454
 
455
  @app.patch("/api/clips/{session_id}/{clip_index}/style")
456
  async def patch_global_style(session_id: str, clip_index: int, patch: GlobalStylePatch):
457
+ if REMOTE_BACKEND_URL:
458
+ async with httpx.AsyncClient(timeout=120.0) as client:
459
+ resp = await client.patch(
460
+ f"{REMOTE_BACKEND_URL}/api/clips/{session_id}/{clip_index}/style",
461
+ json=patch.model_dump(),
462
+ )
463
+ return _proxy_response(resp)
464
+
465
  clip = _get_clip_or_404(session_id, clip_index)
466
  if not clip.get("ass_path"):
467
  raise HTTPException(404, "No subtitle file for this clip")
 
471
 
472
  @app.post("/api/clips/{session_id}/{clip_index}/render")
473
  async def render_clip(session_id: str, clip_index: int):
474
+ if REMOTE_BACKEND_URL:
475
+ async with httpx.AsyncClient(timeout=600.0) as client:
476
+ resp = await client.post(f"{REMOTE_BACKEND_URL}/api/clips/{session_id}/{clip_index}/render")
477
+ return _proxy_response(resp)
478
+
479
  clip = _get_clip_or_404(session_id, clip_index)
480
 
481
  clip_path = Path(clip["clip_path"])
 
506
 
507
  # ─── vLLM management endpoints ────────────────────────────────────────────────
508
 
509
+ if REMOTE_BACKEND_URL:
510
+ @app.get("/downloads/{file_path:path}")
511
+ async def proxy_download(file_path: str):
512
+ async with httpx.AsyncClient(timeout=600.0) as client:
513
+ resp = await client.get(f"{REMOTE_BACKEND_URL}/downloads/{file_path}")
514
+ return _proxy_response(resp)
515
+
516
+
517
  @app.get("/api/vllm/status")
518
  async def get_vllm_status():
519
+ if REMOTE_BACKEND_URL:
520
+ async with httpx.AsyncClient(timeout=30.0) as client:
521
+ resp = await client.get(f"{REMOTE_BACKEND_URL}/api/vllm/status")
522
+ return _proxy_response(resp)
523
  return vllm_status()
524
 
525
 
526
  @app.post("/api/vllm/stop")
527
  async def stop_vllm(x_demo_key: Optional[str] = Header(None, alias="X-Demo-Key")):
528
  _require_access(x_demo_key)
529
+ if REMOTE_BACKEND_URL:
530
+ async with httpx.AsyncClient(timeout=120.0) as client:
531
+ resp = await client.post(
532
+ f"{REMOTE_BACKEND_URL}/api/vllm/stop",
533
+ headers=_demo_headers(x_demo_key),
534
+ )
535
+ return _proxy_response(resp)
536
+
537
  loop = asyncio.get_running_loop()
538
  await loop.run_in_executor(None, vllm_stop)
539
  return {"ok": True, "message": "vLLM stopped — will restart automatically on next job"}
 
542
  @app.post("/api/vllm/start")
543
  async def start_vllm(x_demo_key: Optional[str] = Header(None, alias="X-Demo-Key")):
544
  _require_access(x_demo_key)
545
+ if REMOTE_BACKEND_URL:
546
+ async with httpx.AsyncClient(timeout=240.0) as client:
547
+ resp = await client.post(
548
+ f"{REMOTE_BACKEND_URL}/api/vllm/start",
549
+ headers=_demo_headers(x_demo_key),
550
+ )
551
+ return _proxy_response(resp)
552
+
553
  loop = asyncio.get_running_loop()
554
  await loop.run_in_executor(None, ensure_vllm_running)
555
  return {"ok": True, "status": vllm_status()}
docker-compose.yml CHANGED
@@ -14,6 +14,7 @@ services:
14
  - VLLM_MODEL=Qwen/Qwen2.5-VL-7B-Instruct
15
  - VLLM_PORT=8000
16
  - VLLM_DOCKER_CONTAINER=
 
17
  - NEXT_PUBLIC_API_URL=http://localhost:7860
18
  volumes:
19
  - /tmp/elevnclip:/tmp/elevnclip
 
14
  - VLLM_MODEL=Qwen/Qwen2.5-VL-7B-Instruct
15
  - VLLM_PORT=8000
16
  - VLLM_DOCKER_CONTAINER=
17
+ - REMOTE_BACKEND_URL=
18
  - NEXT_PUBLIC_API_URL=http://localhost:7860
19
  volumes:
20
  - /tmp/elevnclip:/tmp/elevnclip
frontend/app/page.tsx CHANGED
@@ -5,7 +5,7 @@ import VideoUpload from "@/components/VideoUpload";
5
  import ClipSettings from "@/components/ClipSettings";
6
  import SubtitleDesigner from "@/components/SubtitleDesigner";
7
  import GenerationProgress from "@/components/GenerationProgress";
8
- import { startProcessing, connectProgressWS, type StyleConfig, type ProcessSettings } from "@/lib/api";
9
  import { Scissors, Check, ChevronLeft, ArrowRight, Zap, Sparkles, PlayCircle } from "lucide-react";
10
 
11
  const LANGS = [
@@ -206,11 +206,9 @@ export default function HomePage() {
206
  wsRef.current = ws;
207
 
208
  // HTTP polling fallback — kicks in if WS doesn't deliver messages
209
- const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
210
  const poll = setInterval(async () => {
211
  try {
212
- const res = await fetch(`${API_BASE}/api/clips/${sessionId}`);
213
- const data = await res.json();
214
  const lp = data.last_progress;
215
  if (!wsAlive && lp) setProgress(lp);
216
  if (data.status === "done") {
@@ -248,7 +246,6 @@ export default function HomePage() {
248
  const sessionId = await startProcessing(settings, undefined, accessCode);
249
  localStorage.setItem("elevnclip_session", sessionId);
250
 
251
- const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
252
  const ws = connectProgressWS(sessionId, (data) => {
253
  setProgress(data);
254
  if (data.stage === "done") { ws.close(); router.push(`/editor?session=${sessionId}`); }
@@ -258,8 +255,7 @@ export default function HomePage() {
258
 
259
  const poll = setInterval(async () => {
260
  try {
261
- const res = await fetch(`${API_BASE}/api/clips/${sessionId}`);
262
- const data = await res.json();
263
  if (data.last_progress) setProgress(data.last_progress);
264
  if (data.status === "done") { clearInterval(poll); router.push(`/editor?session=${sessionId}`); }
265
  if (data.status === "error") clearInterval(poll);
 
5
  import ClipSettings from "@/components/ClipSettings";
6
  import SubtitleDesigner from "@/components/SubtitleDesigner";
7
  import GenerationProgress from "@/components/GenerationProgress";
8
+ import { startProcessing, connectProgressWS, getClips, type StyleConfig, type ProcessSettings } from "@/lib/api";
9
  import { Scissors, Check, ChevronLeft, ArrowRight, Zap, Sparkles, PlayCircle } from "lucide-react";
10
 
11
  const LANGS = [
 
206
  wsRef.current = ws;
207
 
208
  // HTTP polling fallback — kicks in if WS doesn't deliver messages
 
209
  const poll = setInterval(async () => {
210
  try {
211
+ const data = await getClips(sessionId);
 
212
  const lp = data.last_progress;
213
  if (!wsAlive && lp) setProgress(lp);
214
  if (data.status === "done") {
 
246
  const sessionId = await startProcessing(settings, undefined, accessCode);
247
  localStorage.setItem("elevnclip_session", sessionId);
248
 
 
249
  const ws = connectProgressWS(sessionId, (data) => {
250
  setProgress(data);
251
  if (data.stage === "done") { ws.close(); router.push(`/editor?session=${sessionId}`); }
 
255
 
256
  const poll = setInterval(async () => {
257
  try {
258
+ const data = await getClips(sessionId);
 
259
  if (data.last_progress) setProgress(data.last_progress);
260
  if (data.status === "done") { clearInterval(poll); router.push(`/editor?session=${sessionId}`); }
261
  if (data.status === "error") clearInterval(poll);
frontend/lib/api.ts CHANGED
@@ -66,6 +66,7 @@ export interface SessionResult {
66
  status: "starting" | "done" | "error";
67
  clips: ClipResult[];
68
  error?: string;
 
69
  }
70
 
71
  export async function getVideoInfo(url: string, accessCode?: string) {
 
66
  status: "starting" | "done" | "error";
67
  clips: ClipResult[];
68
  error?: string;
69
+ last_progress?: { stage: string; pct: number; message: string };
70
  }
71
 
72
  export async function getVideoInfo(url: string, accessCode?: string) {