Bl4ckSpaces commited on
Commit
7051a38
Β·
verified Β·
1 Parent(s): 74ba326

Update app_relay2.py

Browse files
Files changed (1) hide show
  1. app_relay2.py +210 -458
app_relay2.py CHANGED
@@ -1,10 +1,11 @@
1
  # ═══════════════════════════════════════════════════════════════════════════════
2
- # πŸ§… SODA-GEN ANONYMOUS RELAY V1.4 β€” HF SPACE CPU (BACKEND ONLY)
3
  #
4
- # V1.4 FIX: Non-blocking startup (was causing 503 on HF Space)
5
- # - on_startup: lightweight only, no Tor network calls
6
- # - Tor IP check runs in background thread
7
- # - Health endpoint always responds instantly
 
8
  # ═══════════════════════════════════════════════════════════════════════════════
9
 
10
  import json
@@ -52,37 +53,25 @@ UPLOAD_DIR.mkdir(exist_ok=True)
52
  TASKS: Dict[str, Dict[str, Any]] = {}
53
 
54
  _last_tor_ip = {"ip": "unknown", "time": 0}
55
- _tor_ready = False # Set True once Tor is confirmed working
56
 
57
  # ═══════════════════════════════════════════════════════════════════════════════
58
- # πŸ§… TOR ENGINE
59
  # ═══════════════════════════════════════════════════════════════════════════════
60
 
61
- def ensure_tor_running():
62
- """Ensure Tor daemon is active (no network calls)"""
63
  try:
64
- result = subprocess.run(
65
- ["service", "tor", "status"],
66
- capture_output=True, text=True, timeout=5
67
- )
68
- if "running" not in result.stdout.lower():
69
- subprocess.run(["service", "tor", "start"], capture_output=True, timeout=10)
70
- time.sleep(3)
71
- print("πŸ§… Tor started")
72
  except:
73
- try:
74
- subprocess.run(["service", "tor", "start"], capture_output=True, timeout=10)
75
- time.sleep(3)
76
- except Exception as e:
77
- print(f"⚠️ Cannot start Tor: {e}")
78
 
79
 
80
  def get_tor_ip() -> str:
81
- """Get current Tor exit node IP (cached for 10s)"""
82
  now = time.time()
83
  if now - _last_tor_ip["time"] < 10 and _last_tor_ip["ip"] != "unknown":
84
  return _last_tor_ip["ip"]
85
-
86
  try:
87
  r = httpx.get("https://api.ipify.org", proxy=TOR_PROXY, timeout=15)
88
  ip = r.text.strip()
@@ -95,61 +84,37 @@ def get_tor_ip() -> str:
95
 
96
 
97
  def rotate_tor_circuit() -> str:
98
- """Request new Tor circuit β†’ new exit node β†’ new IP"""
99
  try:
100
  with Controller.from_port(port=9051) as controller:
101
  controller.authenticate()
102
  controller.signal(Signal.NEWNYM)
103
  time.sleep(5)
104
  _last_tor_ip["ip"] = "unknown"
105
- new_ip = get_tor_ip()
106
- print(f"πŸ”„ Tor rotated β†’ {new_ip}")
107
- return new_ip
108
  except Exception as e:
109
- print(f"⚠️ Tor rotation method 1 failed: {e}, trying restart...")
110
- try:
111
- subprocess.run(["service", "tor", "restart"], capture_output=True, timeout=15)
112
- time.sleep(8)
113
- with Controller.from_port(port=9051) as controller:
114
- controller.authenticate()
115
- controller.signal(Signal.NEWNYM)
116
- time.sleep(5)
117
- _last_tor_ip["ip"] = "unknown"
118
- new_ip = get_tor_ip()
119
- print(f"πŸ”„ Tor restarted & rotated β†’ {new_ip}")
120
- return new_ip
121
- except Exception as e2:
122
- print(f"❌ Tor rotation failed entirely: {e2}")
123
- return "rotation_failed"
124
-
125
-
126
- def _background_tor_init():
127
  """
128
- Run in background thread AFTER server is ready.
129
- This prevents blocking the startup event.
130
  """
131
  global _tor_ready
132
- print("πŸ§… Background Tor init starting...")
133
-
134
- # Wait extra time for Tor to build circuits
135
- time.sleep(10)
136
-
137
- ensure_tor_running()
138
-
139
- # Try to get IP (might take time)
140
- ip = get_tor_ip()
141
- if ip != "unknown":
142
- _tor_ready = True
143
- print(f"πŸ§… Tor ready! IP: {ip}")
144
- else:
145
- # Retry once after short delay
146
- time.sleep(5)
147
- ip = get_tor_ip()
148
- if ip != "unknown":
149
  _tor_ready = True
150
- print(f"πŸ§… Tor ready (retry)! IP: {ip}")
151
- else:
152
- print("⚠️ Tor init incomplete β€” will retry on first request")
 
 
 
 
 
 
 
153
 
154
 
155
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -169,31 +134,21 @@ class GradioClientWithProxy:
169
  for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy",
170
  "ALL_PROXY", "all_proxy"]:
171
  self._old_env[key] = os.environ.get(key)
172
-
173
  os.environ["HTTP_PROXY"] = self.proxy
174
  os.environ["HTTPS_PROXY"] = self.proxy
175
  os.environ["http_proxy"] = self.proxy
176
  os.environ["https_proxy"] = self.proxy
177
  os.environ["ALL_PROXY"] = self.proxy
178
  os.environ["all_proxy"] = self.proxy
179
-
180
- self.client = Client(
181
- self.base_url,
182
- headers={"User-Agent": "SodaGen-Anonymous/1.4"}
183
- )
184
 
185
  def submit(self, **kwargs):
186
  return self.client.submit(**kwargs)
187
 
188
- def predict(self, **kwargs):
189
- return self.client.predict(**kwargs)
190
-
191
  def _restore_env(self):
192
  for key, val in self._old_env.items():
193
- if val is None:
194
- os.environ.pop(key, None)
195
- else:
196
- os.environ[key] = val
197
 
198
  def __del__(self):
199
  self._restore_env()
@@ -203,266 +158,154 @@ class GradioClientWithProxy:
203
  # πŸ“Š ERROR DETECTION
204
  # ═══════════════════════════════════════════════════════════════════════════════
205
 
206
- def is_quota_error(error_msg: str) -> bool:
207
- msg = error_msg.lower()
208
- return any(kw in msg for kw in [
209
- "exceeded", "quota", "rate limit", "too many requests",
210
- "daily limit", "usage limit"
211
- ])
212
-
213
- def is_tor_blocked(error_msg: str) -> bool:
214
- msg = error_msg.lower()
215
- return any(kw in msg for kw in [
216
- "403", "forbidden", "blocked", "banned", "denied",
217
- "access denied", "ip blocked"
218
- ])
219
-
220
- def is_gpu_cold_error(error_msg: str) -> bool:
221
- msg = error_msg.lower()
222
- return any(kw in msg for kw in [
223
- "no gpu", "gpu was available", "available after",
224
- "no gpu was", "gpu is not available", "not ready",
225
- ])
226
-
227
- def parse_retry_time(error_msg: str) -> Optional[int]:
228
- m = re.search(r'(?:try again in|retry in)\s+(.+?)(?:\.|$)', error_msg, re.IGNORECASE)
229
  if m:
230
- retry_str = m.group(1).strip()
231
  try:
 
232
  days = 0
233
- day_match = re.search(r'(\d+)\s+day', retry_str)
234
- if day_match:
235
- days = int(day_match.group(1))
236
- time_match = re.search(r'(\d+):(\d+):(\d+)', retry_str)
237
- if time_match:
238
- h, mi, s = int(time_match.group(1)), int(time_match.group(2)), int(time_match.group(3))
239
- return (days * 86400) + (h * 3600) + (mi * 60) + s
240
- except:
241
- pass
242
  return None
243
 
244
 
245
  # ════════════════════════════���══════════════════════════════════════════════════
246
- # 🎬 VIDEO POST-PROCESSING
247
  # ═══════════════════════════════════════════════════════════════════════════════
248
 
249
- def get_video_info(video_path: str) -> dict:
250
  try:
251
- result = subprocess.run(
252
- ['ffprobe', '-v', 'quiet', '-print_format', 'json',
253
- '-show_format', '-show_streams', str(video_path)],
254
- capture_output=True, text=True, timeout=10
255
- )
256
- info = json.loads(result.stdout)
257
- duration = float(info.get('format', {}).get('duration', 0))
258
- size = int(info.get('format', {}).get('size', 0))
259
- vs = next((s for s in info.get('streams', []) if s.get('codec_type') == 'video'), {})
260
- width = int(vs.get('width', 768))
261
- height = int(vs.get('height', 512))
262
- return {'duration': duration, 'size': size, 'width': width, 'height': height}
263
  except:
264
- file_size = os.path.getsize(video_path) if os.path.exists(video_path) else 0
265
- return {'duration': 0, 'size': file_size, 'width': 768, 'height': 512}
266
 
267
-
268
- def generate_thumbnail(video_path: str, output_path: str) -> bool:
269
  try:
270
- subprocess.run(
271
- ['ffmpeg', '-y', '-i', str(video_path),
272
- '-vframes', '1', '-q:v', '3', '-vf', 'scale=640:-1', str(output_path)],
273
- capture_output=True, timeout=15
274
- )
275
- return os.path.exists(output_path)
276
- except:
277
- return False
278
 
279
-
280
- def resize_image_for_video(image_path, width=768, height=512):
281
  try:
282
- if not image_path or not os.path.exists(image_path):
283
- return None
284
- img = Image.open(image_path).convert("RGB").resize(
285
- (int(width), int(height)), Image.LANCZOS
286
- )
287
- img.save(image_path)
288
- return image_path
289
- except:
290
- return image_path
291
 
292
 
293
  # ═══════════════════════════════════════════════════════════════════════════════
294
  # πŸš€ GENERATION TASK
295
  # ═══════════════════════════════════════════════════════════════════════════════
296
 
297
- def run_generation_task(
298
- task_id: str,
299
- prompt: str,
300
- img1_path: Optional[str],
301
- img2_path: Optional[str],
302
- duration: float,
303
- steps: float,
304
- frame_mult: int,
305
- ):
306
- global _tor_ready
307
  task = TASKS[task_id]
308
-
309
  try:
310
  task["status"] = "processing"
311
- task["log"] += f"🎬 [ANONYMOUS MODE] Prompt: {prompt[:80]}...\n"
312
- task["log"] += f"⏱️ Duration: {duration}s | Steps: {steps} | FPS Mult: {frame_mult}\n"
313
 
314
- # Ensure Tor is running (may not be ready yet on first call)
315
- ensure_tor_running()
316
-
317
- # Get Tor IP (might fail first time, that's ok)
318
- current_ip = get_tor_ip()
319
- task["log"] += f"πŸ§… Tor IP: {current_ip}\n"
320
 
321
- if current_ip == "unknown":
322
- task["log"] += f"⚠️ Tor not ready yet, waiting 15s for circuits...\n"
323
  time.sleep(15)
324
- current_ip = get_tor_ip()
325
- task["log"] += f"πŸ§… Tor IP (retry): {current_ip}\n"
326
 
327
- img1_resized = resize_image_for_video(img1_path) if img1_path else None
328
- img2_resized = resize_image_for_video(img2_path) if img2_path else None
 
 
329
 
330
- safe_img1 = handle_file(img1_resized) if img1_resized else None
331
- safe_img2 = handle_file(img2_resized) if img2_resized else None
 
332
 
333
- tor_retry = 0
334
- success = False
335
- final_video = None
336
-
337
- while tor_retry <= MAX_TOR_RETRIES:
338
- current_ip = get_tor_ip()
339
- task["log"] += f"πŸ§… Tor IP: {current_ip} (attempt {tor_retry + 1}/{MAX_TOR_RETRIES + 1})\n"
340
-
341
  try:
342
- client_wrapper = GradioClientWithProxy(TARGET_URL, proxy=TOR_PROXY)
343
-
344
- task["log"] += f"πŸš€ Submitting to HF Space (anonymous via Tor)...\n"
345
  task["progress"] = 20
346
-
347
- job = client_wrapper.submit(
348
- input_image=safe_img1,
349
- last_image=safe_img2,
350
- prompt=prompt,
351
- steps=float(steps),
352
- negative_prompt=WAN_NEG,
353
  duration_seconds=float(duration),
354
- guidance_scale=1.0,
355
- guidance_scale_2=1.0,
356
- seed=float(random.randint(0, 999999)),
357
- randomize_seed=True,
358
- quality=6.0,
359
- scheduler="UniPCMultistep",
360
- flow_shift=3.0,
361
  frame_multiplier=int(frame_mult),
362
- safe_mode=False,
363
- video_component=True,
364
  api_name="/generate_video",
365
  )
366
-
367
- task["log"] += f"⏳ Waiting for result (timeout: {PREDICT_TIMEOUT}s)...\n"
368
  task["progress"] = 50
369
-
370
  result = job.result(timeout=PREDICT_TIMEOUT)
371
-
372
- task["log"] += f"πŸ“₯ Processing result...\n"
373
  task["progress"] = 80
374
-
375
- if isinstance(result, (list, tuple)):
376
- final_video = result[0]
377
- else:
378
- final_video = result
379
-
380
- if final_video:
381
- success = True
382
- task["log"] += f"βœ… Generation SUCCESS via Tor IP: {current_ip}\n"
383
  break
384
  else:
385
- task["log"] += f"⚠️ No video in result: {str(result)[:200]}\n"
386
-
387
  except Exception as e:
388
- err_msg = str(e)
389
-
390
- if is_quota_error(err_msg):
391
- task["log"] += f"⚠️ QUOTA EXCEEDED on IP {current_ip}\n"
392
- task["log"] += f"πŸ”„ Rotating Tor circuit...\n"
393
- new_ip = rotate_tor_circuit()
394
- task["log"] += f" πŸ§… New IP: {new_ip}\n"
395
- tor_retry += 1
396
- continue
397
-
398
- elif is_tor_blocked(err_msg):
399
- task["log"] += f"⚠️ Tor IP BLOCKED: {current_ip}\n"
400
- new_ip = rotate_tor_circuit()
401
- task["log"] += f" πŸ§… New IP: {new_ip}\n"
402
- tor_retry += 1
403
- continue
404
-
405
- elif is_gpu_cold_error(err_msg):
406
- task["log"] += f"⏳ GPU cold start. Waiting {GPU_RETRY_DELAY}s...\n"
407
  time.sleep(GPU_RETRY_DELAY)
408
  continue
409
-
410
- elif "timeout" in err_msg.lower() or "timed out" in err_msg.lower():
411
- task["log"] += f"⏰ Timeout! Retrying with new IP...\n"
412
  time.sleep(GPU_RETRY_DELAY)
413
- new_ip = rotate_tor_circuit()
414
- task["log"] += f" πŸ§… New IP: {new_ip}\n"
415
- tor_retry += 1
416
- continue
417
-
418
  else:
419
- task["log"] += f"⚠️ Error: {err_msg[:200]}\n"
420
- if tor_retry < MAX_TOR_RETRIES:
421
- new_ip = rotate_tor_circuit()
422
- task["log"] += f" πŸ§… New IP: {new_ip}\n"
423
- tor_retry += 1
424
- continue
425
 
426
- # POST-PROCESSING
427
- if success and final_video:
428
- out_filename = f"{task_id}.mp4"
429
- out_path = OUTPUT_DIR / out_filename
430
-
431
- if isinstance(final_video, dict) and "video" in final_video:
432
- src = final_video["video"]
433
- elif isinstance(final_video, str):
434
- src = final_video
435
- else:
436
- src = str(final_video)
437
-
438
- task["log"] += f"πŸ’Ύ Saving video...\n"
439
-
440
- try:
441
- shutil.copy2(src, str(out_path))
442
-
443
- thumb_filename = f"{task_id}.jpg"
444
- thumb_path = OUTPUT_DIR / thumb_filename
445
- has_thumb = generate_thumbnail(str(out_path), str(thumb_path))
446
-
447
- video_info = get_video_info(str(out_path))
448
-
449
- task["video_path"] = out_filename
450
- task["thumbnail_path"] = thumb_filename if has_thumb else None
451
- task["video_info"] = video_info
452
- task["status"] = "complete"
453
- task["progress"] = 100
454
- task["log"] += (
455
- f"βœ… SUCCESS: {video_info['width']}x{video_info['height']}, "
456
- f"{video_info['duration']:.1f}s, {video_info['size'] // 1024}KB\n"
457
- )
458
-
459
- except Exception as e:
460
- task["status"] = "error"
461
- task["log"] += f"❌ Save failed: {str(e)[:150]}\n"
462
  else:
463
  task["status"] = "error"
464
- task["log"] += f"❌ Failed after {tor_retry + 1} Tor IP rotations\n"
465
-
466
  except Exception as e:
467
  task["status"] = "error"
468
  task["log"] += f"❌ FATAL: {str(e)[:150]}\n"
@@ -472,62 +315,38 @@ def run_generation_task(
472
  # ⚑ VIDEO STREAMING
473
  # ═══════════════════════════════════════════════════════════════════════════════
474
 
475
- def stream_video(video_path: str, request: Request):
476
- file_size = os.path.getsize(video_path)
477
- range_header = request.headers.get('range')
478
-
479
- if range_header:
480
- range_match = range_header.replace('bytes=', '').split('-')
481
- start = int(range_match[0]) if range_match[0] else 0
482
- end = int(range_match[1]) if len(range_match) > 1 and range_match[1] else file_size - 1
483
- start = min(start, file_size - 1)
484
- end = min(end, file_size - 1)
485
- content_length = end - start + 1
486
-
487
- def iterfile():
488
- with open(video_path, 'rb') as f:
489
- f.seek(start)
490
- remaining = content_length
491
- while remaining > 0:
492
- chunk = f.read(min(1024 * 1024, remaining))
493
- if not chunk: break
494
- remaining -= len(chunk)
495
- yield chunk
496
-
497
- return StreamingResponse(
498
- iterfile(), status_code=206, media_type='video/mp4',
499
- headers={
500
- 'Content-Range': f'bytes {start}-{end}/{file_size}',
501
- 'Accept-Ranges': 'bytes',
502
- 'Content-Length': str(content_length),
503
- 'Cache-Control': 'public, max-age=86400',
504
- 'Content-Disposition': 'inline',
505
- }
506
- )
507
  else:
508
- def iterfile():
509
- with open(video_path, 'rb') as f:
510
  while True:
511
- chunk = f.read(1024 * 1024)
512
- if not chunk: break
513
- yield chunk
514
-
515
- return StreamingResponse(
516
- iterfile(), status_code=200, media_type='video/mp4',
517
- headers={
518
- 'Accept-Ranges': 'bytes',
519
- 'Content-Length': str(file_size),
520
- 'Cache-Control': 'public, max-age=86400',
521
- 'Content-Disposition': 'inline',
522
- }
523
- )
524
 
525
 
526
  # ═══════════════════════════════════════════════════════════════════════════════
527
- # 🌐 FASTAPI APPLICATION
528
  # ═══════════════════════════════════════════════════════════════════════════════
529
 
530
- app = FastAPI(title="SODA-GEN Anonymous Relay V1.4")
531
 
532
  app.add_middleware(
533
  CORSMiddleware,
@@ -538,47 +357,20 @@ app.add_middleware(
538
  )
539
 
540
 
541
- # ═══════════════════════════════════════════════════════════
542
- # πŸ”§ V1.4 FIX: STARTUP IS LIGHTWEIGHT β€” NO BLOCKING CALLS
543
- # ═══════════════════════════════════════════════════════════
544
-
545
- @app.on_event("startup")
546
- async def on_startup():
547
- """
548
- V1.4: Startup event is FAST and NON-BLOCKING.
549
- Only prints basic info. Tor IP check runs in background thread.
550
- This ensures HF Space health check gets instant response β†’ no 503.
551
- """
552
- print("=" * 60)
553
- print("πŸ§… SODA-GEN Anonymous Relay V1.4 β€” HF Space CPU")
554
- print(f" πŸ“‘ Target: {TARGET_URL}")
555
- print(f" πŸ§… Proxy: {TOR_PROXY}")
556
- print(f" ⏱️ Timeout: {PREDICT_TIMEOUT}s")
557
- print(f" 🎬 ffmpeg: {'βœ…' if shutil.which('ffmpeg') else '❌'}")
558
- print(f" πŸ§… Tor init: running in background thread...")
559
- print("=" * 60)
560
-
561
- # Start Tor initialization in BACKGROUND thread
562
- # This does NOT block the startup event
563
- t = threading.Thread(target=_background_tor_init, daemon=True)
564
- t.start()
565
 
566
 
567
  @app.get("/api/health")
568
- async def health_check():
569
- """
570
- Lightweight health check β€” responds INSTANTLY.
571
- Tor IP is optional (shows cached or 'initializing').
572
- HF Space uses this to determine if space is alive.
573
- """
574
  return JSONResponse({
575
- "status": "ok",
576
- "mode": "anonymous",
577
- "version": "1.4",
578
- "tor_ip": _last_tor_ip["ip"],
579
- "tor_ready": _tor_ready,
580
- "proxy_scheme": "socks5://",
581
- "ffmpeg": shutil.which("ffmpeg") is not None,
582
  })
583
 
584
 
@@ -592,103 +384,63 @@ async def api_generate(
592
  img_start: Optional[UploadFile] = File(None),
593
  img_end: Optional[UploadFile] = File(None),
594
  ):
595
- task_id = uuid.uuid4().hex[:12]
596
- task_dir = UPLOAD_DIR / task_id
597
- task_dir.mkdir(exist_ok=True)
598
-
599
- img1_path = None
600
- img2_path = None
601
-
602
  if img_start and img_start.filename:
603
- img1_path = str(task_dir / "start.png")
604
- with open(img1_path, "wb") as f:
605
- f.write(await img_start.read())
606
-
607
  if img_end and img_end.filename:
608
- img2_path = str(task_dir / "end.png")
609
- with open(img2_path, "wb") as f:
610
- f.write(await img_end.read())
 
 
611
 
612
- TASKS[task_id] = {
613
- "status": "queued",
614
- "progress": 0,
615
- "log": "",
616
- "video_path": None,
617
- "thumbnail_path": None,
618
- "video_info": None,
619
- }
620
 
621
- background_tasks.add_task(
622
- run_generation_task,
623
- task_id=task_id, prompt=prompt,
624
- img1_path=img1_path, img2_path=img2_path,
625
- duration=duration, steps=steps, frame_mult=frame_mult,
626
- )
 
 
 
 
627
 
628
- return JSONResponse({"task_id": task_id})
629
 
 
 
 
 
 
630
 
631
- @app.get("/api/task/{task_id}")
632
- async def api_task_status(task_id: str):
633
- task = TASKS.get(task_id)
634
- if not task:
635
- return JSONResponse({"error": "Task not found"}, status_code=404)
636
-
637
- resp = {
638
- "status": task["status"],
639
- "progress": task["progress"],
640
- "log": task["log"],
641
- }
642
- if task["status"] == "complete" and task.get("video_path"):
643
- resp["video_url"] = f"/api/video/{task['video_path']}"
644
- if task.get("thumbnail_path"):
645
- resp["thumbnail_url"] = f"/api/thumbnail/{task['thumbnail_path']}"
646
- if task.get("video_info"):
647
- resp["video_info"] = task["video_info"]
648
-
649
- return JSONResponse(resp)
650
-
651
-
652
- @app.get("/api/video/{filename}")
653
- async def serve_video(filename: str, request: Request):
654
- video_path = OUTPUT_DIR / filename
655
- if not video_path.exists():
656
- return JSONResponse({"error": "Video not found"}, status_code=404)
657
- return stream_video(str(video_path), request)
658
-
659
-
660
- @app.get("/api/thumbnail/{filename}")
661
- async def serve_thumbnail(filename: str):
662
- thumb_path = OUTPUT_DIR / filename
663
- if not thumb_path.exists():
664
- return JSONResponse({"error": "Thumbnail not found"}, status_code=404)
665
- return FileResponse(
666
- str(thumb_path), media_type="image/jpeg",
667
- headers={"Cache-Control": "public, max-age=86400"}
668
- )
669
 
670
 
671
  @app.get("/api/stats")
672
- async def api_stats():
673
  return JSONResponse({
674
- "mode": "anonymous",
675
- "tor_ip": _last_tor_ip["ip"],
676
- "tor_ready": _tor_ready,
677
- "quota_per_ip": 120,
678
- "total_tokens": "infinity (Tor rotation)",
679
- "active_tokens": "∞",
680
- "cooldown_tokens": 0,
681
- "version": "1.4",
682
  })
683
 
684
 
685
  @app.get("/api/tor-ip")
686
- async def api_tor_ip():
687
  _last_tor_ip["ip"] = "unknown"
688
  return JSONResponse({"ip": get_tor_ip()})
689
 
690
 
691
  @app.post("/api/tor-rotate")
692
- async def api_tor_rotate():
693
- new_ip = rotate_tor_circuit()
694
- return JSONResponse({"new_ip": new_ip, "success": new_ip != "rotation_failed"})
 
1
  # ═══════════════════════════════════════════════════════════════════════════════
2
+ # πŸ§… SODA-GEN ANONYMOUS RELAY V1.5 β€” HF SPACE CPU (BACKEND ONLY)
3
  #
4
+ # V1.5: ULTRA-MINIMAL startup β€” ZERO blocking calls
5
+ # - Root endpoint `/` added (HF Space health check)
6
+ # - Tor runs as background process (not service)
7
+ # - All Tor init deferred to background thread
8
+ # - First generate request triggers lazy Tor check
9
  # ═══════════════════════════════════════════════════════════════════════════════
10
 
11
  import json
 
53
  TASKS: Dict[str, Dict[str, Any]] = {}
54
 
55
  _last_tor_ip = {"ip": "unknown", "time": 0}
56
+ _tor_ready = False
57
 
58
  # ═══════════════════════════════════════════════════════════════════════════════
59
+ # πŸ§… TOR ENGINE (Lazy β€” only runs when needed)
60
  # ═══════════════════════════════════════════════════════════════════════════════
61
 
62
+ def check_tor_alive() -> bool:
63
+ """Quick check if Tor SOCKS proxy is responding (non-blocking, 3s timeout)"""
64
  try:
65
+ r = httpx.get("https://api.ipify.org", proxy=TOR_PROXY, timeout=3)
66
+ return r.status_code == 200
 
 
 
 
 
 
67
  except:
68
+ return False
 
 
 
 
69
 
70
 
71
  def get_tor_ip() -> str:
 
72
  now = time.time()
73
  if now - _last_tor_ip["time"] < 10 and _last_tor_ip["ip"] != "unknown":
74
  return _last_tor_ip["ip"]
 
75
  try:
76
  r = httpx.get("https://api.ipify.org", proxy=TOR_PROXY, timeout=15)
77
  ip = r.text.strip()
 
84
 
85
 
86
  def rotate_tor_circuit() -> str:
 
87
  try:
88
  with Controller.from_port(port=9051) as controller:
89
  controller.authenticate()
90
  controller.signal(Signal.NEWNYM)
91
  time.sleep(5)
92
  _last_tor_ip["ip"] = "unknown"
93
+ return get_tor_ip()
 
 
94
  except Exception as e:
95
+ print(f"⚠️ Tor rotation failed: {e}")
96
+ return "rotation_failed"
97
+
98
+
99
+ def _lazy_tor_init():
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  """
101
+ Background thread: wait for Tor to be ready, then get IP.
102
+ Called once on first generate request or after startup.
103
  """
104
  global _tor_ready
105
+ for attempt in range(6):
106
+ if check_tor_alive():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  _tor_ready = True
108
+ ip = get_tor_ip()
109
+ print(f"πŸ§… Tor ready! IP: {ip} (attempt {attempt+1})")
110
+ return
111
+ print(f"⏳ Tor not ready, waiting 5s... (attempt {attempt+1}/6)")
112
+ time.sleep(5)
113
+ print("⚠️ Tor init incomplete β€” will retry on requests")
114
+
115
+
116
+ # Start lazy init in background (non-blocking)
117
+ threading.Thread(target=_lazy_tor_init, daemon=True).start()
118
 
119
 
120
  # ═══════════════════════════════════════════════════════════════════════════════
 
134
  for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy",
135
  "ALL_PROXY", "all_proxy"]:
136
  self._old_env[key] = os.environ.get(key)
 
137
  os.environ["HTTP_PROXY"] = self.proxy
138
  os.environ["HTTPS_PROXY"] = self.proxy
139
  os.environ["http_proxy"] = self.proxy
140
  os.environ["https_proxy"] = self.proxy
141
  os.environ["ALL_PROXY"] = self.proxy
142
  os.environ["all_proxy"] = self.proxy
143
+ self.client = Client(self.base_url, headers={"User-Agent": "SodaGen/1.5"})
 
 
 
 
144
 
145
  def submit(self, **kwargs):
146
  return self.client.submit(**kwargs)
147
 
 
 
 
148
  def _restore_env(self):
149
  for key, val in self._old_env.items():
150
+ if val is None: os.environ.pop(key, None)
151
+ else: os.environ[key] = val
 
 
152
 
153
  def __del__(self):
154
  self._restore_env()
 
158
  # πŸ“Š ERROR DETECTION
159
  # ═══════════════════════════════════════════════════════════════════════════════
160
 
161
+ def is_quota_error(msg: str) -> bool:
162
+ return any(kw in msg.lower() for kw in ["exceeded","quota","rate limit","too many requests","daily limit","usage limit"])
163
+
164
+ def is_tor_blocked(msg: str) -> bool:
165
+ return any(kw in msg.lower() for kw in ["403","forbidden","blocked","banned","denied","access denied","ip blocked"])
166
+
167
+ def is_gpu_cold(msg: str) -> bool:
168
+ return any(kw in msg.lower() for kw in ["no gpu","gpu was available","available after","not ready"])
169
+
170
+ def parse_retry_time(msg: str) -> Optional[int]:
171
+ m = re.search(r'(?:try again in|retry in)\s+(.+?)(?:\.|$)', msg, re.IGNORECASE)
 
 
 
 
 
 
 
 
 
 
 
 
172
  if m:
 
173
  try:
174
+ s = m.group(1).strip()
175
  days = 0
176
+ d = re.search(r'(\d+)\s+day', s)
177
+ if d: days = int(d.group(1))
178
+ t = re.search(r'(\d+):(\d+):(\d+)', s)
179
+ if t: return (days*86400)+(int(t.group(1))*3600)+(int(t.group(2))*60)+int(t.group(3))
180
+ except: pass
 
 
 
 
181
  return None
182
 
183
 
184
  # ════════════════════════════���══════════════════════════════════════════════════
185
+ # 🎬 POST-PROCESSING
186
  # ═══════════════════════════════════════════════════════════════════════════════
187
 
188
+ def get_video_info(path: str) -> dict:
189
  try:
190
+ r = subprocess.run(['ffprobe','-v','quiet','-print_format','json','-show_format','-show_streams',path], capture_output=True, text=True, timeout=10)
191
+ info = json.loads(r.stdout)
192
+ d = float(info.get('format',{}).get('duration',0))
193
+ s = int(info.get('format',{}).get('size',0))
194
+ vs = next((x for x in info.get('streams',[]) if x.get('codec_type')=='video'), {})
195
+ return {'duration':d,'size':s,'width':int(vs.get('width',768)),'height':int(vs.get('height',512))}
 
 
 
 
 
 
196
  except:
197
+ sz = os.path.getsize(path) if os.path.exists(path) else 0
198
+ return {'duration':0,'size':sz,'width':768,'height':512}
199
 
200
+ def gen_thumb(vpath, opath):
 
201
  try:
202
+ subprocess.run(['ffmpeg','-y','-i',vpath,'-vframes','1','-q:v','3','-vf','scale=640:-1',opath], capture_output=True, timeout=15)
203
+ return os.path.exists(opath)
204
+ except: return False
 
 
 
 
 
205
 
206
+ def resize_img(path, w=768, h=512):
 
207
  try:
208
+ if not path or not os.path.exists(path): return None
209
+ Image.open(path).convert("RGB").resize((int(w),int(h)), Image.LANCZOS).save(path)
210
+ return path
211
+ except: return path
 
 
 
 
 
212
 
213
 
214
  # ═══════════════════════════════════════════════════════════════════════════════
215
  # πŸš€ GENERATION TASK
216
  # ═══════════════════════════════════════════════════════════════════════════════
217
 
218
+ def run_generation_task(task_id, prompt, img1_path, img2_path, duration, steps, frame_mult):
 
 
 
 
 
 
 
 
 
219
  task = TASKS[task_id]
 
220
  try:
221
  task["status"] = "processing"
222
+ task["log"] += f"🎬 [ANONYMOUS] {prompt[:80]}...\n"
223
+ task["log"] += f"⏱️ {duration}s | Steps: {steps} | FPS: {frame_mult}\n"
224
 
225
+ ip = get_tor_ip()
226
+ task["log"] += f"πŸ§… Tor IP: {ip}\n"
 
 
 
 
227
 
228
+ if ip == "unknown":
229
+ task["log"] += "⚠️ Tor not ready, waiting 15s...\n"
230
  time.sleep(15)
231
+ ip = get_tor_ip()
232
+ task["log"] += f"πŸ§… Tor IP retry: {ip}\n"
233
 
234
+ i1 = resize_img(img1_path) if img1_path else None
235
+ i2 = resize_img(img2_path) if img2_path else None
236
+ f1 = handle_file(i1) if i1 else None
237
+ f2 = handle_file(i2) if i2 else None
238
 
239
+ ok = False
240
+ vid = None
241
+ retry = 0
242
 
243
+ while retry <= MAX_TOR_RETRIES:
244
+ cip = get_tor_ip()
245
+ task["log"] += f"πŸ§… IP: {cip} ({retry+1}/{MAX_TOR_RETRIES+1})\n"
 
 
 
 
 
246
  try:
247
+ cw = GradioClientWithProxy(TARGET_URL, proxy=TOR_PROXY)
248
+ task["log"] += "πŸš€ Submitting...\n"
 
249
  task["progress"] = 20
250
+ job = cw.submit(
251
+ input_image=f1, last_image=f2, prompt=prompt,
252
+ steps=float(steps), negative_prompt=WAN_NEG,
 
 
 
 
253
  duration_seconds=float(duration),
254
+ guidance_scale=1.0, guidance_scale_2=1.0,
255
+ seed=float(random.randint(0,999999)),
256
+ randomize_seed=True, quality=6.0,
257
+ scheduler="UniPCMultistep", flow_shift=3.0,
 
 
 
258
  frame_multiplier=int(frame_mult),
259
+ safe_mode=False, video_component=True,
 
260
  api_name="/generate_video",
261
  )
262
+ task["log"] += f"⏳ Waiting ({PREDICT_TIMEOUT}s)...\n"
 
263
  task["progress"] = 50
 
264
  result = job.result(timeout=PREDICT_TIMEOUT)
 
 
265
  task["progress"] = 80
266
+ vid = result[0] if isinstance(result,(list,tuple)) else result
267
+ if vid:
268
+ ok = True
269
+ task["log"] += f"βœ… SUCCESS via {cip}\n"
 
 
 
 
 
270
  break
271
  else:
272
+ task["log"] += f"⚠️ Empty result\n"
 
273
  except Exception as e:
274
+ em = str(e)
275
+ if is_quota_error(em) or is_tor_blocked(em):
276
+ task["log"] += f"⚠️ {'Quota' if is_quota_error(em) else 'Blocked'}. Rotating...\n"
277
+ nip = rotate_tor_circuit()
278
+ task["log"] += f"πŸ§… New: {nip}\n"
279
+ elif is_gpu_cold(em):
280
+ task["log"] += f"⏳ GPU cold, wait {GPU_RETRY_DELAY}s...\n"
 
 
 
 
 
 
 
 
 
 
 
 
281
  time.sleep(GPU_RETRY_DELAY)
282
  continue
283
+ elif "timeout" in em.lower():
284
+ task["log"] += "⏰ Timeout, retry...\n"
 
285
  time.sleep(GPU_RETRY_DELAY)
286
+ nip = rotate_tor_circuit()
287
+ task["log"] += f"πŸ§… New: {nip}\n"
 
 
 
288
  else:
289
+ task["log"] += f"⚠️ {em[:150]}\n"
290
+ if retry < MAX_TOR_RETRIES:
291
+ nip = rotate_tor_circuit()
292
+ task["log"] += f"πŸ§… New: {nip}\n"
293
+ retry += 1
 
294
 
295
+ if ok and vid:
296
+ outf = f"{task_id}.mp4"
297
+ outp = OUTPUT_DIR / outf
298
+ src = vid["video"] if isinstance(vid,dict) and "video" in vid else str(vid)
299
+ shutil.copy2(src, str(outp))
300
+ tf = f"{task_id}.jpg"
301
+ tp = OUTPUT_DIR / tf
302
+ ht = gen_thumb(str(outp), str(tp))
303
+ vi = get_video_info(str(outp))
304
+ task.update(video_path=outf, thumbnail_path=tf if ht else None, video_info=vi, status="complete", progress=100)
305
+ task["log"] += f"βœ… {vi['width']}x{vi['height']}, {vi['duration']:.1f}s, {vi['size']//1024}KB\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  else:
307
  task["status"] = "error"
308
+ task["log"] += f"❌ Failed after {retry+1} rotations\n"
 
309
  except Exception as e:
310
  task["status"] = "error"
311
  task["log"] += f"❌ FATAL: {str(e)[:150]}\n"
 
315
  # ⚑ VIDEO STREAMING
316
  # ═══════════════════════════════════════════════════════════════════════════════
317
 
318
+ def stream_video(vpath, req):
319
+ fsz = os.path.getsize(vpath)
320
+ rh = req.headers.get('range')
321
+ if rh:
322
+ m = rh.replace('bytes=','').split('-')
323
+ s = int(m[0]) if m[0] else 0
324
+ e = int(m[1]) if len(m)>1 and m[1] else fsz-1
325
+ s,e = min(s,fsz-1),min(e,fsz-1)
326
+ cl = e-s+1
327
+ def it():
328
+ with open(vpath,'rb') as f:
329
+ f.seek(s); r=cl
330
+ while r>0:
331
+ c=f.read(min(1048576,r))
332
+ if not c: break
333
+ r-=len(c); yield c
334
+ return StreamingResponse(it(),status_code=206,media_type='video/mp4',headers={'Content-Range':f'bytes {s}-{e}/{fsz}','Accept-Ranges':'bytes','Content-Length':str(cl),'Cache-Control':'public, max-age=86400'})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
335
  else:
336
+ def it():
337
+ with open(vpath,'rb') as f:
338
  while True:
339
+ c=f.read(1048576)
340
+ if not c: break
341
+ yield c
342
+ return StreamingResponse(it(),status_code=200,media_type='video/mp4',headers={'Accept-Ranges':'bytes','Content-Length':str(fsz),'Cache-Control':'public, max-age=86400'})
 
 
 
 
 
 
 
 
 
343
 
344
 
345
  # ═══════════════════════════════════════════════════════════════════════════════
346
+ # 🌐 FASTAPI β€” ZERO BLOCKING STARTUP
347
  # ═══════════════════════════════════════════════════════════════════════════════
348
 
349
+ app = FastAPI(title="SODA-GEN Relay V1.5")
350
 
351
  app.add_middleware(
352
  CORSMiddleware,
 
357
  )
358
 
359
 
360
+ # ══════════════════════════════════════════════════════
361
+ # πŸ”‘ ROOT ENDPOINT β€” HF Space health check butuh ini!
362
+ # ══════════════════════════════════════════════════════
363
+ @app.get("/")
364
+ async def root():
365
+ """HF Space checks this to determine if space is alive"""
366
+ return {"status": "ok", "service": "SODA-GEN Relay V1.5", "tor_ready": _tor_ready}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
 
368
 
369
  @app.get("/api/health")
370
+ async def health():
 
 
 
 
 
371
  return JSONResponse({
372
+ "status": "ok", "mode": "anonymous", "version": "1.5",
373
+ "tor_ip": _last_tor_ip["ip"], "tor_ready": _tor_ready,
 
 
 
 
 
374
  })
375
 
376
 
 
384
  img_start: Optional[UploadFile] = File(None),
385
  img_end: Optional[UploadFile] = File(None),
386
  ):
387
+ tid = uuid.uuid4().hex[:12]
388
+ td = UPLOAD_DIR / tid
389
+ td.mkdir(exist_ok=True)
390
+ i1 = i2 = None
 
 
 
391
  if img_start and img_start.filename:
392
+ i1 = str(td/"start.png")
393
+ with open(i1,"wb") as f: f.write(await img_start.read())
 
 
394
  if img_end and img_end.filename:
395
+ i2 = str(td/"end.png")
396
+ with open(i2,"wb") as f: f.write(await img_end.read())
397
+ TASKS[tid] = {"status":"queued","progress":0,"log":"","video_path":None,"thumbnail_path":None,"video_info":None}
398
+ background_tasks.add_task(run_generation_task, task_id=tid, prompt=prompt, img1_path=i1, img2_path=i2, duration=duration, steps=steps, frame_mult=frame_mult)
399
+ return JSONResponse({"task_id": tid})
400
 
 
 
 
 
 
 
 
 
401
 
402
+ @app.get("/api/task/{task_id}")
403
+ async def api_task(task_id: str):
404
+ t = TASKS.get(task_id)
405
+ if not t: return JSONResponse({"error":"Not found"}, status_code=404)
406
+ r = {"status":t["status"],"progress":t["progress"],"log":t["log"]}
407
+ if t["status"]=="complete" and t.get("video_path"):
408
+ r["video_url"] = f"/api/video/{t['video_path']}"
409
+ if t.get("thumbnail_path"): r["thumbnail_url"] = f"/api/thumbnail/{t['thumbnail_path']}"
410
+ if t.get("video_info"): r["video_info"] = t["video_info"]
411
+ return JSONResponse(r)
412
 
 
413
 
414
+ @app.get("/api/video/{fn}")
415
+ async def vid(fn: str, request: Request):
416
+ p = OUTPUT_DIR/fn
417
+ if not p.exists(): return JSONResponse({"error":"Not found"}, status_code=404)
418
+ return stream_video(str(p), request)
419
 
420
+
421
+ @app.get("/api/thumbnail/{fn}")
422
+ async def thumb(fn: str):
423
+ p = OUTPUT_DIR/fn
424
+ if not p.exists(): return JSONResponse({"error":"Not found"}, status_code=404)
425
+ return FileResponse(str(p), media_type="image/jpeg", headers={"Cache-Control":"public, max-age=86400"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
426
 
427
 
428
  @app.get("/api/stats")
429
+ async def stats():
430
  return JSONResponse({
431
+ "mode":"anonymous","tor_ip":_last_tor_ip["ip"],"tor_ready":_tor_ready,
432
+ "quota_per_ip":120,"total_tokens":"infinity","active_tokens":"∞",
433
+ "cooldown_tokens":0,"version":"1.5",
 
 
 
 
 
434
  })
435
 
436
 
437
  @app.get("/api/tor-ip")
438
+ async def torip():
439
  _last_tor_ip["ip"] = "unknown"
440
  return JSONResponse({"ip": get_tor_ip()})
441
 
442
 
443
  @app.post("/api/tor-rotate")
444
+ async def torrot():
445
+ nip = rotate_tor_circuit()
446
+ return JSONResponse({"new_ip": nip, "success": nip != "rotation_failed"})