Bl4ckSpaces commited on
Commit
3a64582
·
verified ·
1 Parent(s): a3a2967

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +650 -0
app.py ADDED
@@ -0,0 +1,650 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ═══════════════════════════════════════════════════════════════════════════════
2
+ # 🧅 SODA-GEN ANONYMOUS RELAY V1.2 — HF SPACE CPU (BACKEND ONLY)
3
+ #
4
+ # MODE: Anonymous + Tor IP Rotation
5
+ # QUOTA: 120s per Tor exit node (auto-rotate on exhaustion)
6
+ # TARGET: https://r3gm-wan2-2-fp8da-aoti-preview-2.hf.space
7
+ # PLATFORM: Hugging Face Spaces (Docker / CPU Basic)
8
+ #
9
+ # INI BACKEND ONLY — Relay 1 (HF Space) tetap full stack dengan UI
10
+ # V1.2: Adapted for HF Space (no ngrok, Docker-native Tor)
11
+ # ═══════════════════════════════════════════════════════════════════════════════
12
+
13
+ import json
14
+ import os
15
+ import random
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import time
20
+ import uuid
21
+ from pathlib import Path
22
+ from typing import Dict, Any, Optional
23
+
24
+ import httpx
25
+ from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks, Request
26
+ from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
27
+ from fastapi.middleware.cors import CORSMiddleware
28
+ from gradio_client import Client, handle_file
29
+ from PIL import Image
30
+ from stem import Signal
31
+ from stem.control import Controller
32
+ import threading
33
+
34
+ # ═══════════════════════════════════════════════════════════════════════════════
35
+ # ⚙️ CONFIGURATION
36
+ # ═══════════════════════════════════════════════════════════════════════════════
37
+
38
+ TARGET_URL = "https://r3gm-wan2-2-fp8da-aoti-preview-2.hf.space"
39
+ TOR_PROXY = "socks5h://127.0.0.1:9050"
40
+ PREDICT_TIMEOUT = 300 # 5 minutes for full generation
41
+ MAX_TOR_RETRIES = 5 # Max circuit rotations per request
42
+ GPU_RETRY_DELAY = 15 # Wait before GPU cold retry
43
+
44
+ WAN_NEG = (
45
+ "色调艳丽, 过曝, 静态, 细节模糊不清, 字幕, 风格, 作品, 画作, 画面, 静止, "
46
+ "整体发灰, 最差质量, 低质量, JPEG压缩残留, 丑陋的, 残缺的, 多余手指, "
47
+ "画不好手部, 畸形, 毁容"
48
+ )
49
+
50
+ OUTPUT_DIR = Path("outputs")
51
+ OUTPUT_DIR.mkdir(exist_ok=True)
52
+ UPLOAD_DIR = Path("uploads")
53
+ UPLOAD_DIR.mkdir(exist_ok=True)
54
+
55
+ TASKS: Dict[str, Dict[str, Any]] = {}
56
+
57
+ # Track last known Tor IP for caching
58
+ _last_tor_ip = {"ip": "unknown", "time": 0}
59
+
60
+ # ═══════════════════════════════════════════════════════════════════════════════
61
+ # 🧅 TOR ENGINE
62
+ # ═══════════════════════════════════════════════════════════════════════════════
63
+
64
+ def ensure_tor_running():
65
+ """Ensure Tor daemon is active"""
66
+ try:
67
+ result = subprocess.run(
68
+ ["service", "tor", "status"],
69
+ capture_output=True, text=True, timeout=5
70
+ )
71
+ if "running" not in result.stdout.lower():
72
+ subprocess.run(["service", "tor", "start"], capture_output=True, timeout=10)
73
+ time.sleep(3)
74
+ print("🧅 Tor started")
75
+ except:
76
+ subprocess.run(["service", "tor", "start"], capture_output=True, timeout=10)
77
+ time.sleep(3)
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()
89
+ _last_tor_ip["ip"] = ip
90
+ _last_tor_ip["time"] = now
91
+ return ip
92
+ except Exception as e:
93
+ print(f"⚠️ get_tor_ip failed: {e}")
94
+ return "unknown"
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" # Force refresh
105
+ new_ip = get_tor_ip()
106
+ print(f"🔄 Tor rotated → {new_ip}")
107
+ return new_ip
108
+ except Exception as e:
109
+ # Fallback: restart Tor entirely
110
+ print(f"⚠️ Tor rotation method 1 failed: {e}, trying restart...")
111
+ try:
112
+ subprocess.run(["service", "tor", "restart"], capture_output=True, timeout=15)
113
+ time.sleep(8)
114
+ with Controller.from_port(port=9051) as controller:
115
+ controller.authenticate()
116
+ controller.signal(Signal.NEWNYM)
117
+ time.sleep(5)
118
+ _last_tor_ip["ip"] = "unknown"
119
+ new_ip = get_tor_ip()
120
+ print(f"🔄 Tor restarted & rotated → {new_ip}")
121
+ return new_ip
122
+ except Exception as e2:
123
+ print(f"❌ Tor rotation failed entirely: {e2}")
124
+ return "rotation_failed"
125
+
126
+
127
+ # ═══════════════════════════════════════════════════════════════════════════════
128
+ # 📡 GRADIO CLIENT WITH PROXY
129
+ # ═══════════════════════════════════════════════════════════════════════════════
130
+
131
+ class GradioClientWithProxy:
132
+ """
133
+ Wrapper untuk gradio_client yang route traffic via Tor proxy.
134
+
135
+ gradio_client internal menggunakan httpx, yang respect env vars.
136
+ Kita set HTTP_PROXY/HTTPS_PROXY sebelum Client() dibuat.
137
+ """
138
+
139
+ def __init__(self, base_url: str, proxy: str = None):
140
+ self.base_url = base_url
141
+ self.proxy = proxy
142
+ self.client = None
143
+ self._old_env = {}
144
+ self._setup_client()
145
+
146
+ def _setup_client(self):
147
+ """Setup gradio_client dengan proxy"""
148
+ if self.proxy:
149
+ # Save old values
150
+ for key in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"]:
151
+ self._old_env[key] = os.environ.get(key)
152
+ # Set proxy
153
+ os.environ["HTTP_PROXY"] = self.proxy
154
+ os.environ["HTTPS_PROXY"] = self.proxy
155
+ os.environ["http_proxy"] = self.proxy
156
+ os.environ["https_proxy"] = self.proxy
157
+
158
+ self.client = Client(
159
+ self.base_url,
160
+ headers={"User-Agent": "SodaGen-Anonymous/1.2"}
161
+ )
162
+
163
+ def submit(self, **kwargs):
164
+ return self.client.submit(**kwargs)
165
+
166
+ def predict(self, **kwargs):
167
+ return self.client.predict(**kwargs)
168
+
169
+ def _restore_env(self):
170
+ """Restore original env vars"""
171
+ for key, val in self._old_env.items():
172
+ if val is None:
173
+ os.environ.pop(key, None)
174
+ else:
175
+ os.environ[key] = val
176
+
177
+ def __del__(self):
178
+ self._restore_env()
179
+
180
+
181
+ # ═══════════════════════════════════════════════════════════════════════════════
182
+ # 📊 ERROR DETECTION
183
+ # ═══════════════════════════════════════════════════════════════════════════════
184
+
185
+ def is_quota_error(error_msg: str) -> bool:
186
+ msg = error_msg.lower()
187
+ return any(kw in msg for kw in [
188
+ "exceeded", "quota", "rate limit", "too many requests",
189
+ "daily limit", "usage limit"
190
+ ])
191
+
192
+ def is_tor_blocked(error_msg: str) -> bool:
193
+ msg = error_msg.lower()
194
+ return any(kw in msg for kw in [
195
+ "403", "forbidden", "blocked", "banned", "denied",
196
+ "access denied", "ip blocked"
197
+ ])
198
+
199
+ def is_gpu_cold_error(error_msg: str) -> bool:
200
+ msg = error_msg.lower()
201
+ return any(kw in msg for kw in [
202
+ "no gpu", "gpu was available", "available after",
203
+ "no gpu was", "gpu is not available", "not ready",
204
+ ])
205
+
206
+ def parse_retry_time(error_msg: str) -> Optional[int]:
207
+ m = re.search(r'(?:try again in|retry in)\s+(.+?)(?:\.|$)', error_msg, re.IGNORECASE)
208
+ if m:
209
+ retry_str = m.group(1).strip()
210
+ try:
211
+ days = 0
212
+ day_match = re.search(r'(\d+)\s+day', retry_str)
213
+ if day_match:
214
+ days = int(day_match.group(1))
215
+ time_match = re.search(r'(\d+):(\d+):(\d+)', retry_str)
216
+ if time_match:
217
+ h, mi, s = int(time_match.group(1)), int(time_match.group(2)), int(time_match.group(3))
218
+ return (days * 86400) + (h * 3600) + (mi * 60) + s
219
+ except:
220
+ pass
221
+ return None
222
+
223
+
224
+ # ═══════════════════════════════════════════════════════════════════════════════
225
+ # 🎬 VIDEO POST-PROCESSING
226
+ # ═══════════════════════════════════════════════════════════════════════════════
227
+
228
+ def get_video_info(video_path: str) -> dict:
229
+ try:
230
+ result = subprocess.run(
231
+ ['ffprobe', '-v', 'quiet', '-print_format', 'json',
232
+ '-show_format', '-show_streams', str(video_path)],
233
+ capture_output=True, text=True, timeout=10
234
+ )
235
+ info = json.loads(result.stdout)
236
+ duration = float(info.get('format', {}).get('duration', 0))
237
+ size = int(info.get('format', {}).get('size', 0))
238
+ vs = next((s for s in info.get('streams', []) if s.get('codec_type') == 'video'), {})
239
+ width = int(vs.get('width', 768))
240
+ height = int(vs.get('height', 512))
241
+ return {'duration': duration, 'size': size, 'width': width, 'height': height}
242
+ except:
243
+ file_size = os.path.getsize(video_path) if os.path.exists(video_path) else 0
244
+ return {'duration': 0, 'size': file_size, 'width': 768, 'height': 512}
245
+
246
+
247
+ def generate_thumbnail(video_path: str, output_path: str) -> bool:
248
+ try:
249
+ subprocess.run(
250
+ ['ffmpeg', '-y', '-i', str(video_path),
251
+ '-vframes', '1', '-q:v', '3', '-vf', 'scale=640:-1', str(output_path)],
252
+ capture_output=True, timeout=15
253
+ )
254
+ return os.path.exists(output_path)
255
+ except:
256
+ return False
257
+
258
+
259
+ def resize_image_for_video(image_path, width=768, height=512):
260
+ try:
261
+ if not image_path or not os.path.exists(image_path):
262
+ return None
263
+ img = Image.open(image_path).convert("RGB").resize(
264
+ (int(width), int(height)), Image.LANCZOS
265
+ )
266
+ img.save(image_path)
267
+ return image_path
268
+ except:
269
+ return image_path
270
+
271
+
272
+ # ═══════════════════════════════════════════════════════════════════════════════
273
+ # 🚀 GENERATION TASK (ANONYMOUS MODE)
274
+ # ═══════════════════════════════════════════════════════════════════════════════
275
+
276
+ def run_generation_task(
277
+ task_id: str,
278
+ prompt: str,
279
+ img1_path: Optional[str],
280
+ img2_path: Optional[str],
281
+ duration: float,
282
+ steps: float,
283
+ frame_mult: int,
284
+ ):
285
+ task = TASKS[task_id]
286
+
287
+ try:
288
+ task["status"] = "processing"
289
+ task["log"] += f"🎬 [ANONYMOUS MODE] Prompt: {prompt[:80]}...\n"
290
+ task["log"] += f"⏱️ Duration: {duration}s | Steps: {steps} | FPS Mult: {frame_mult}\n"
291
+ task["log"] += f"⏱️ Timeout: {PREDICT_TIMEOUT}s | Max Tor retries: {MAX_TOR_RETRIES}\n"
292
+
293
+ ensure_tor_running()
294
+
295
+ img1_resized = resize_image_for_video(img1_path) if img1_path else None
296
+ img2_resized = resize_image_for_video(img2_path) if img2_path else None
297
+
298
+ safe_img1 = handle_file(img1_resized) if img1_resized else None
299
+ safe_img2 = handle_file(img2_resized) if img2_resized else None
300
+
301
+ tor_retry = 0
302
+ success = False
303
+ final_video = None
304
+
305
+ while tor_retry <= MAX_TOR_RETRIES:
306
+ current_ip = get_tor_ip()
307
+ task["log"] += f"🧅 Tor IP: {current_ip} (attempt {tor_retry + 1}/{MAX_TOR_RETRIES + 1})\n"
308
+
309
+ try:
310
+ client_wrapper = GradioClientWithProxy(TARGET_URL, proxy=TOR_PROXY)
311
+
312
+ task["log"] += f"🚀 Submitting to HF Space (anonymous via Tor)...\n"
313
+ task["progress"] = 20
314
+
315
+ job = client_wrapper.submit(
316
+ input_image=safe_img1,
317
+ last_image=safe_img2,
318
+ prompt=prompt,
319
+ steps=float(steps),
320
+ negative_prompt=WAN_NEG,
321
+ duration_seconds=float(duration),
322
+ guidance_scale=1.0,
323
+ guidance_scale_2=1.0,
324
+ seed=float(random.randint(0, 999999)),
325
+ randomize_seed=True,
326
+ quality=6.0,
327
+ scheduler="UniPCMultistep",
328
+ flow_shift=3.0,
329
+ frame_multiplier=int(frame_mult),
330
+ safe_mode=False,
331
+ video_component=True,
332
+ api_name="/generate_video",
333
+ )
334
+
335
+ task["log"] += f"⏳ Waiting for result (timeout: {PREDICT_TIMEOUT}s)...\n"
336
+ task["progress"] = 50
337
+
338
+ result = job.result(timeout=PREDICT_TIMEOUT)
339
+
340
+ task["log"] += f"📥 Processing result...\n"
341
+ task["progress"] = 80
342
+
343
+ if isinstance(result, (list, tuple)):
344
+ final_video = result[0]
345
+ else:
346
+ final_video = result
347
+
348
+ if final_video:
349
+ success = True
350
+ task["log"] += f"✅ Generation SUCCESS via Tor IP: {current_ip}\n"
351
+ break
352
+ else:
353
+ task["log"] += f"⚠️ No video in result: {str(result)[:200]}\n"
354
+
355
+ except Exception as e:
356
+ err_msg = str(e)
357
+
358
+ if is_quota_error(err_msg):
359
+ task["log"] += f"⚠️ QUOTA EXCEEDED on IP {current_ip}\n"
360
+ retry_time = parse_retry_time(err_msg)
361
+ if retry_time:
362
+ task["log"] += f" ⏰ Retry in: {retry_time}s\n"
363
+ task["log"] += f"🔄 Rotating Tor circuit...\n"
364
+ new_ip = rotate_tor_circuit()
365
+ task["log"] += f" 🧅 New IP: {new_ip}\n"
366
+ tor_retry += 1
367
+ continue
368
+
369
+ elif is_tor_blocked(err_msg):
370
+ task["log"] += f"⚠️ Tor IP BLOCKED: {current_ip}\n"
371
+ new_ip = rotate_tor_circuit()
372
+ task["log"] += f" 🧅 New IP: {new_ip}\n"
373
+ tor_retry += 1
374
+ continue
375
+
376
+ elif is_gpu_cold_error(err_msg):
377
+ task["log"] += f"⏳ GPU cold start. Waiting {GPU_RETRY_DELAY}s...\n"
378
+ time.sleep(GPU_RETRY_DELAY)
379
+ continue
380
+
381
+ elif "timeout" in err_msg.lower() or "timed out" in err_msg.lower():
382
+ task["log"] += f"⏰ Timeout! Retrying with new IP...\n"
383
+ time.sleep(GPU_RETRY_DELAY)
384
+ new_ip = rotate_tor_circuit()
385
+ task["log"] += f" 🧅 New IP: {new_ip}\n"
386
+ tor_retry += 1
387
+ continue
388
+
389
+ else:
390
+ task["log"] += f"⚠️ Error: {err_msg[:200]}\n"
391
+ if tor_retry < MAX_TOR_RETRIES:
392
+ new_ip = rotate_tor_circuit()
393
+ task["log"] += f" 🧅 New IP: {new_ip}\n"
394
+ tor_retry += 1
395
+ continue
396
+
397
+ # POST-PROCESSING
398
+ if success and final_video:
399
+ out_filename = f"{task_id}.mp4"
400
+ out_path = OUTPUT_DIR / out_filename
401
+
402
+ if isinstance(final_video, dict) and "video" in final_video:
403
+ src = final_video["video"]
404
+ elif isinstance(final_video, str):
405
+ src = final_video
406
+ else:
407
+ src = str(final_video)
408
+
409
+ task["log"] += f"💾 Saving video...\n"
410
+
411
+ try:
412
+ shutil.copy2(src, str(out_path))
413
+
414
+ thumb_filename = f"{task_id}.jpg"
415
+ thumb_path = OUTPUT_DIR / thumb_filename
416
+ has_thumb = generate_thumbnail(str(out_path), str(thumb_path))
417
+
418
+ video_info = get_video_info(str(out_path))
419
+
420
+ task["video_path"] = out_filename
421
+ task["thumbnail_path"] = thumb_filename if has_thumb else None
422
+ task["video_info"] = video_info
423
+ task["status"] = "complete"
424
+ task["progress"] = 100
425
+ task["log"] += (
426
+ f"✅ SUCCESS: {video_info['width']}x{video_info['height']}, "
427
+ f"{video_info['duration']:.1f}s, {video_info['size'] // 1024}KB\n"
428
+ )
429
+
430
+ except Exception as e:
431
+ task["status"] = "error"
432
+ task["log"] += f"❌ Save failed: {str(e)[:150]}\n"
433
+ else:
434
+ task["status"] = "error"
435
+ task["log"] += f"❌ Failed after {tor_retry + 1} Tor IP rotations\n"
436
+
437
+ except Exception as e:
438
+ task["status"] = "error"
439
+ task["log"] += f"❌ FATAL: {str(e)[:150]}\n"
440
+
441
+
442
+ # ═══════════════════════════════════════════════════════════════════════════════
443
+ # ⚡ VIDEO STREAMING
444
+ # ═══════════════════════════════════════════════════════════════════════════════
445
+
446
+ def stream_video(video_path: str, request: Request):
447
+ file_size = os.path.getsize(video_path)
448
+ range_header = request.headers.get('range')
449
+
450
+ if range_header:
451
+ range_match = range_header.replace('bytes=', '').split('-')
452
+ start = int(range_match[0]) if range_match[0] else 0
453
+ end = int(range_match[1]) if len(range_match) > 1 and range_match[1] else file_size - 1
454
+ start = min(start, file_size - 1)
455
+ end = min(end, file_size - 1)
456
+ content_length = end - start + 1
457
+
458
+ def iterfile():
459
+ with open(video_path, 'rb') as f:
460
+ f.seek(start)
461
+ remaining = content_length
462
+ while remaining > 0:
463
+ chunk = f.read(min(1024 * 1024, remaining))
464
+ if not chunk: break
465
+ remaining -= len(chunk)
466
+ yield chunk
467
+
468
+ return StreamingResponse(
469
+ iterfile(), status_code=206, media_type='video/mp4',
470
+ headers={
471
+ 'Content-Range': f'bytes {start}-{end}/{file_size}',
472
+ 'Accept-Ranges': 'bytes',
473
+ 'Content-Length': str(content_length),
474
+ 'Cache-Control': 'public, max-age=86400',
475
+ 'Content-Disposition': 'inline',
476
+ }
477
+ )
478
+ else:
479
+ def iterfile():
480
+ with open(video_path, 'rb') as f:
481
+ while True:
482
+ chunk = f.read(1024 * 1024)
483
+ if not chunk: break
484
+ yield chunk
485
+
486
+ return StreamingResponse(
487
+ iterfile(), status_code=200, media_type='video/mp4',
488
+ headers={
489
+ 'Accept-Ranges': 'bytes',
490
+ 'Content-Length': str(file_size),
491
+ 'Cache-Control': 'public, max-age=86400',
492
+ 'Content-Disposition': 'inline',
493
+ }
494
+ )
495
+
496
+
497
+ # ═══════════════════════════════════════════════════════════════════════════════
498
+ # 🌐 FASTAPI APPLICATION (BACKEND ONLY — NO UI)
499
+ # ═══════════════════════════════════════════════════════════════════════════════
500
+
501
+ app = FastAPI(title="SODA-GEN Anonymous Relay V1.2")
502
+
503
+ app.add_middleware(
504
+ CORSMiddleware,
505
+ allow_origins=["*"],
506
+ allow_credentials=True,
507
+ allow_methods=["*"],
508
+ allow_headers=["*"],
509
+ )
510
+
511
+
512
+ @app.get("/api/health")
513
+ async def health_check():
514
+ ip = get_tor_ip()
515
+ tor_ok = ip != "unknown" and ip != "rotation_failed"
516
+ return JSONResponse({
517
+ "status": "ok",
518
+ "mode": "anonymous",
519
+ "version": "1.2",
520
+ "tor_ip": ip,
521
+ "tor_active": tor_ok,
522
+ "ffmpeg": shutil.which("ffmpeg") is not None,
523
+ })
524
+
525
+
526
+ @app.post("/api/generate")
527
+ async def api_generate(
528
+ background_tasks: BackgroundTasks,
529
+ prompt: str = Form(...),
530
+ duration: float = Form(5.0),
531
+ steps: float = Form(6.0),
532
+ frame_mult: int = Form(16),
533
+ img_start: Optional[UploadFile] = File(None),
534
+ img_end: Optional[UploadFile] = File(None),
535
+ ):
536
+ task_id = uuid.uuid4().hex[:12]
537
+ task_dir = UPLOAD_DIR / task_id
538
+ task_dir.mkdir(exist_ok=True)
539
+
540
+ img1_path = None
541
+ img2_path = None
542
+
543
+ if img_start and img_start.filename:
544
+ img1_path = str(task_dir / "start.png")
545
+ with open(img1_path, "wb") as f:
546
+ f.write(await img_start.read())
547
+
548
+ if img_end and img_end.filename:
549
+ img2_path = str(task_dir / "end.png")
550
+ with open(img2_path, "wb") as f:
551
+ f.write(await img_end.read())
552
+
553
+ TASKS[task_id] = {
554
+ "status": "queued",
555
+ "progress": 0,
556
+ "log": "",
557
+ "video_path": None,
558
+ "thumbnail_path": None,
559
+ "video_info": None,
560
+ }
561
+
562
+ background_tasks.add_task(
563
+ run_generation_task,
564
+ task_id=task_id, prompt=prompt,
565
+ img1_path=img1_path, img2_path=img2_path,
566
+ duration=duration, steps=steps, frame_mult=frame_mult,
567
+ )
568
+
569
+ return JSONResponse({"task_id": task_id})
570
+
571
+
572
+ @app.get("/api/task/{task_id}")
573
+ async def api_task_status(task_id: str):
574
+ task = TASKS.get(task_id)
575
+ if not task:
576
+ return JSONResponse({"error": "Task not found"}, status_code=404)
577
+
578
+ resp = {
579
+ "status": task["status"],
580
+ "progress": task["progress"],
581
+ "log": task["log"],
582
+ }
583
+ if task["status"] == "complete" and task.get("video_path"):
584
+ resp["video_url"] = f"/api/video/{task['video_path']}"
585
+ if task.get("thumbnail_path"):
586
+ resp["thumbnail_url"] = f"/api/thumbnail/{task['thumbnail_path']}"
587
+ if task.get("video_info"):
588
+ resp["video_info"] = task["video_info"]
589
+
590
+ return JSONResponse(resp)
591
+
592
+
593
+ @app.get("/api/video/{filename}")
594
+ async def serve_video(filename: str, request: Request):
595
+ video_path = OUTPUT_DIR / filename
596
+ if not video_path.exists():
597
+ return JSONResponse({"error": "Video not found"}, status_code=404)
598
+ return stream_video(str(video_path), request)
599
+
600
+
601
+ @app.get("/api/thumbnail/{filename}")
602
+ async def serve_thumbnail(filename: str):
603
+ thumb_path = OUTPUT_DIR / filename
604
+ if not thumb_path.exists():
605
+ return JSONResponse({"error": "Thumbnail not found"}, status_code=404)
606
+ return FileResponse(
607
+ str(thumb_path), media_type="image/jpeg",
608
+ headers={"Cache-Control": "public, max-age=86400"}
609
+ )
610
+
611
+
612
+ @app.get("/api/stats")
613
+ async def api_stats():
614
+ ip = get_tor_ip()
615
+ return JSONResponse({
616
+ "mode": "anonymous",
617
+ "tor_ip": ip,
618
+ "quota_per_ip": 120,
619
+ "total_tokens": "infinity (Tor rotation)",
620
+ "active_tokens": "∞",
621
+ "cooldown_tokens": 0,
622
+ "version": "1.2",
623
+ })
624
+
625
+
626
+ @app.get("/api/tor-ip")
627
+ async def api_tor_ip():
628
+ _last_tor_ip["ip"] = "unknown" # Force fresh check
629
+ return JSONResponse({"ip": get_tor_ip()})
630
+
631
+
632
+ @app.post("/api/tor-rotate")
633
+ async def api_tor_rotate():
634
+ new_ip = rotate_tor_circuit()
635
+ return JSONResponse({"new_ip": new_ip, "success": new_ip != "rotation_failed"})
636
+
637
+
638
+ @app.on_event("startup")
639
+ async def on_startup():
640
+ ensure_tor_running()
641
+ ip = get_tor_ip()
642
+ print("=" * 60)
643
+ print("🧅 SODA-GEN Anonymous Relay V1.2 — HF Space CPU")
644
+ print(f" 🌐 Tor IP: {ip}")
645
+ print(f" 📡 Target: {TARGET_URL}")
646
+ print(f" 🧅 Proxy: {TOR_PROXY}")
647
+ print(f" ⏱️ Timeout: {PREDICT_TIMEOUT}s")
648
+ print(f" 🎬 ffmpeg: {'✅' if shutil.which('ffmpeg') else '❌'}")
649
+ print(f" 🎬 ffprobe: {'✅' if shutil.which('ffprobe') else '❌'}")
650
+ print("=" * 60)