CoolFace
Datasetpublic

mrdarkbr/wan22-code-backup

sourceHugging Faceupdated 29d agoView on Hugging Face
0likes56downloads
video_api_client.py718 linesDownload Raw Back to naked
1"""
2video_api_client.py — Cliente de geração de vídeo para o Naked AI Bot.
3Conecta ao servidor Modal e gerencia a geração de vídeo com updates diretos ao Telegram.
4"""
5import asyncio
6import aiohttp
7from http_client import get_http_session
8import base64
9import os
10import tempfile
11import time
12
13# ── URL do servidor Modal (T2V — imagem para vídeo) ───────────────────────────
14VIDEO_API_URL = "https://awh2707--wan22-api-v7-comfyuiworker-gerar-video.modal.run"
15
16# ── URL do servidor Modal (Animate — foto + vídeo referência) ─────────────────
17ANIMATE_API_URL = "https://awh2707--70e265.modal.run"
18
19# ── URL do servidor Modal (MiniMax H3 — foto + prompt → vídeo realístico) ─────
20MINIMAX_API_URL = "https://awh2707--minimax-api-v1-minimaxworker-gerar-video.modal.run"
21MINIMAX_EXTEND_API_URL = "https://awh2707--minimax-h3-extend-minimaxh3extender-extend.modal.run"
22
23# Pasta de saída dos vídeos
24OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "video_outputs")
25os.makedirs(OUTPUT_DIR, exist_ok=True)
26
27# Arquivo de timestamp para detectar cold start
28_STAMP_FILE = os.path.join(os.path.dirname(__file__), ".modal_video_last_success")
29
30
31def _load_stamp() -> float:
32    try:
33        with open(_STAMP_FILE) as f:
34            return float(f.read().strip())
35    except Exception:
36        return 0.0
37
38
39def _save_stamp():
40    try:
41        with open(_STAMP_FILE, "w") as f:
42            f.write(str(time.time()))
43    except Exception:
44        pass
45
46
47def _cold_start() -> bool:
48    last = _load_stamp()
49    return last == 0 or (time.time() - last) > 600
50
51
52async def gerar_video_async(
53    image_path: str,
54    prompt_text: str = "",
55    length_frames: int = 81,
56    resolution: int = 1080,
57    bot_token: str = "",
58    chat_id: int = 0,
59    message_id: int = 0,
60    preset_name: str = "custom",
61) -> str | None:
62    """
63    Envia a imagem para o servidor Modal e aguarda o vídeo gerado.
64    O Modal atualiza o status diretamente no Telegram via bot_token/chat_id/message_id.
65    Retorna o caminho local do .mp4 gerado, ou None em caso de erro.
66    """
67    print(f"[VideoAPI] Processando imagem local: {image_path}")
68    with open(image_path, "rb") as f:
69        image_b64 = base64.b64encode(f.read()).decode()
70
71    payload = {
72        "image_b64": image_b64,
73        "prompt": prompt_text,
74        "length": length_frames,
75        "resolution": resolution,
76        "bot_token": bot_token,
77        "chat_id": chat_id,
78        "message_id": message_id,
79        "preset_name": preset_name,
80    }
81
82    is_cold = _cold_start()
83    timeout_total = 1800 if is_cold else 1500
84    print(f"[VideoAPI] {'🥶 Cold start' if is_cold else '🔥 Warm start'} — timeout: {timeout_total}s")
85
86    try:
87        session = await get_http_session()
88        async with asyncio.timeout(timeout_total):
89            print(f"[VideoAPI] Enviando para {VIDEO_API_URL}...")
90            async with session.post(VIDEO_API_URL, json=payload) as resp:
91                if resp.status != 200:
92                    body = await resp.text()
93                    print(f"[VideoAPI] ❌ HTTP {resp.status}: {body[:300]}")
94                    return None
95
96                data = await resp.json()
97
98                if data.get("status") == "error":
99                    print(f"[VideoAPI] ❌ Erro na API: {data.get('message')}")
100                    return None
101                elif data.get("status") == "success":
102                    video_b64 = data.get("video_b64", "")
103                    if not video_b64:
104                        print("[VideoAPI] ❌ video_b64 vazio na resposta")
105                        return None
106
107                    print(f"[VideoAPI] ✅ Vídeo recebido! ({len(video_b64)} chars)")
108
109                    fd, local_path = tempfile.mkstemp(suffix=".mp4", prefix="nakedvideo_", dir=OUTPUT_DIR)
110                    os.close(fd)
111
112                    with open(local_path, "wb") as f:
113                        f.write(base64.b64decode(video_b64))
114
115                    _save_stamp()
116                    print(f"[VideoAPI] ✅ Vídeo salvo: {local_path}")
117                    return local_path
118        return None
119
120    except asyncio.TimeoutError:
121        print(f"[VideoAPI] ⏰ Timeout após {timeout_total}s")
122        return None
123    except Exception as e:
124        print(f"[VideoAPI] ❌ Exceção: {e}")
125        return None
126
127
128# ── Arquivo de timestamp para MiniMax ─────────────────────────────────────────
129_MINIMAX_STAMP_FILE = os.path.join(os.path.dirname(__file__), ".minimax_last_success")
130
131
132def _load_minimax_stamp() -> float:
133    try:
134        with open(_MINIMAX_STAMP_FILE) as f:
135            return float(f.read().strip())
136    except Exception:
137        return 0.0
138
139
140def _save_minimax_stamp():
141    try:
142        with open(_MINIMAX_STAMP_FILE, "w") as f:
143            f.write(str(time.time()))
144    except Exception:
145        pass
146
147
148def _minimax_cold_start() -> bool:
149    last = _load_minimax_stamp()
150    return last == 0 or (time.time() - last) > 600
151
152
153async def gerar_minimax_video_async(
154    image_path: str,
155    prompt_text: str = "The person moves naturally",
156    image_path2: str = None,
157    lora_name: str = "HMNSFW_AIO_V2.safetensors",
158    lora_strength: float = 0.5,
159    bot_token: str = "",
160    chat_id: int = 0,
161    message_id: int = 0,
162    duration: float = 10.5,
163    mp: float = 0.5,
164    first_frame_path: str = None,  # Caminho para o frame inicial (usado no Expand)
165) -> str | None:
166    """
167    Envia imagem + prompt ao servidor Modal e aguarda o vídeo gerado.
168    Quando first_frame_path é fornecido, o vídeo começa exatamente desse frame.
169    Retorna o caminho local do .mp4 gerado, ou None em caso de erro.
170    """
171    print(f"[MiniMaxAPI] Processando imagem local: {image_path}")
172    with open(image_path, "rb") as f:
173        image_b64 = base64.b64encode(f.read()).decode()
174
175    # Se houver first_frame, encodar também
176    first_frame_b64 = ""
177    if first_frame_path and os.path.exists(first_frame_path):
178        with open(first_frame_path, "rb") as f:
179            first_frame_b64 = base64.b64encode(f.read()).decode()
180        print(f"[MiniMaxAPI] 🎬 First frame carregado: {first_frame_path}")
181
182    image2_b64 = ""
183    if image_path2 and os.path.exists(image_path2):
184        with open(image_path2, "rb") as f2:
185            image2_b64 = base64.b64encode(f2.read()).decode()
186
187    payload = {
188        "mode": "generate",
189        "steps": 25,
190        "image_b64": image_b64,
191        "image2_b64": image2_b64,
192        "first_frame_b64": first_frame_b64,
193        "prompt": prompt_text,
194        "lora_name": lora_name,
195        "lora_strength": lora_strength,
196        "bot_token": bot_token,
197        "chat_id": chat_id,
198        "message_id": message_id,
199        "duration": duration,
200        "mp": mp,
201    }
202
203    is_cold = _minimax_cold_start()
204    timeout_total = 1800 if is_cold else 1500
205    print(f"[MiniMaxAPI] {'🥶 Cold start' if is_cold else '🔥 Warm start'} — timeout: {timeout_total}s")
206
207    try:
208        session = await get_http_session()
209        async with asyncio.timeout(timeout_total):
210            print(f"[MiniMaxAPI] Enviando para {MINIMAX_API_URL}...")
211            async with session.post(MINIMAX_API_URL, json=payload) as resp:
212                if resp.status != 200:
213                    body = await resp.text()
214                    print(f"[MiniMaxAPI] ❌ HTTP {resp.status}: {body[:300]}")
215                    return None
216
217                data = await resp.json()
218
219                if data.get("status") == "error":
220                    print(f"[MiniMaxAPI] ❌ Erro na API: {data.get('message')}")
221                    return None
222                elif data.get("status") == "success":
223                    video_b64 = data.get("video_b64", "")
224                    if not video_b64:
225                        print("[MiniMaxAPI] ❌ video_b64 vazio na resposta")
226                        return None
227
228                    print(f"[MiniMaxAPI] ✅ Vídeo recebido! ({len(video_b64)} chars)")
229
230                    fd, local_path = tempfile.mkstemp(
231                        suffix=".mp4", prefix="minimax_video_", dir=OUTPUT_DIR
232                    )
233                    os.close(fd)
234
235                    with open(local_path, "wb") as f:
236                        f.write(base64.b64decode(video_b64))
237
238                    _save_minimax_stamp()
239                    print(f"[MiniMaxAPI] ✅ Vídeo salvo: {local_path}")
240                    return local_path
241        return None
242
243    except asyncio.TimeoutError:
244        print(f"[MiniMaxAPI] ⏰ Timeout após {timeout_total}s")
245        return None
246    except Exception as e:
247        print(f"[MiniMaxAPI] ❌ Exceção: {e}")
248        return None
249
250async def gerar_video_referencia_async(
251    image_path: str,
252    video_path: str,
253    prompt_text: str = "",
254    bot_token: str = "",
255    chat_id: int = 0,
256    message_id: int = 0,
257) -> str | None:
258    """
259    Envia a imagem e o vídeo de referência para o servidor Modal e aguarda o vídeo gerado.
260    Retorna o caminho local do .mp4 gerado, ou None em caso de erro.
261    """
262    print(f"[VideoAPI-Ref] Processando imagem: {image_path} | video: {video_path}")
263    with open(image_path, "rb") as f:
264        image_b64 = base64.b64encode(f.read()).decode()
265
266    with open(video_path, "rb") as f:
267        video_b64 = base64.b64encode(f.read()).decode()
268
269    payload = {
270        "image_b64": image_b64,
271        "video_b64": video_b64,
272        "prompt": prompt_text,
273        "bot_token": bot_token,
274        "chat_id": chat_id,
275        "message_id": message_id,
276    }
277
278    is_cold = _cold_start()
279    timeout_total = 2000 if is_cold else 1800
280    print(f"[VideoAPI-Ref] {'🥶 Cold start' if is_cold else '🔥 Warm start'} — timeout: {timeout_total}s")
281
282    try:
283        session = await get_http_session()
284        async with asyncio.timeout(timeout_total):
285            print(f"[VideoAPI-Ref] Enviando para {ANIMATE_API_URL}...")
286            async with session.post(ANIMATE_API_URL, json=payload) as resp:
287                if resp.status != 200:
288                    body = await resp.text()
289                    print(f"[VideoAPI-Ref] ❌ HTTP {resp.status}: {body[:300]}")
290                    return None
291
292                data = await resp.json()
293
294                if data.get("status") == "error":
295                    print(f"[VideoAPI-Ref] ❌ Erro na API: {data.get('message')}")
296                    return None
297                elif data.get("status") == "success":
298                    video_out_b64 = data.get("video_b64", "")
299                    if not video_out_b64:
300                        print("[VideoAPI-Ref] ❌ video_b64 vazio na resposta")
301                        return None
302
303                    print(f"[VideoAPI-Ref] ✅ Vídeo recebido! ({len(video_out_b64)} chars)")
304
305                    fd, local_path = tempfile.mkstemp(suffix=".mp4", prefix="nakedrefvideo_", dir=OUTPUT_DIR)
306                    os.close(fd)
307
308                    with open(local_path, "wb") as f:
309                        f.write(base64.b64decode(video_out_b64))
310
311                    _save_stamp()
312                    print(f"[VideoAPI-Ref] ✅ Vídeo salvo: {local_path}")
313                    return local_path
314        return None
315
316    except asyncio.TimeoutError:
317        print(f"[VideoAPI-Ref] ⏰ Timeout após {timeout_total}s")
318        return None
319    except Exception as e:
320        print(f"[VideoAPI-Ref] ❌ Exceção: {e}")
321        return None
322
323async def expand_video_async(
324    video_path: str,
325    prompt_text: str = "",
326    length_frames: int = 153,
327    resolution: int = 1080,
328    bot_token: str = "",
329    chat_id: int = 0,
330    message_id: int = 0,
331    preset_name: str = "custom",
332    duration_seconds: float = 10.0,
333    continuation_mode: str = "guide",
334    context_length: int = 22,
335    steps: int = 20,
336) -> str | None:
337    """Expande um MP4 usando a cauda temporal como contexto MiniMax H3."""
338    print(f"[MiniMaxContext-Expand] Expandindo video local: {video_path}\n[MiniMaxContext-Expand] Prompt final: {prompt_text}")
339    if not os.path.isfile(video_path) or os.path.getsize(video_path) == 0:
340        print("[MiniMaxContext-Expand] Video original ausente ou vazio")
341        return None
342    try:
343        with open(video_path, "rb") as source:
344            video_b64 = base64.b64encode(source.read()).decode("ascii")
345        payload = {
346            "video_b64": video_b64,
347            "prompt": prompt_text,
348            "duration": float(duration_seconds),
349            "continuation_mode": continuation_mode,
350            "context_length": int(context_length),
351            "steps": int(steps),
352            "prepend_original": True,
353            "run_name": f"user_{chat_id or 'web'}_{int(time.time())}_{os.urandom(4).hex()}",
354        }
355        session = await get_http_session()
356        async with asyncio.timeout(1800):
357            print(f"[MiniMaxContext-Expand] Enviando para {MINIMAX_EXTEND_API_URL}")
358            async with session.post(MINIMAX_EXTEND_API_URL, json=payload) as response:
359                if response.status != 200:
360                    error = await response.text()
361                    print(f"[MiniMaxContext-Expand] HTTP {response.status}: {error[:500]}")
362                    return None
363                data = await response.json()
364        if data.get("status") != "success" or not data.get("video_b64"):
365            print(f"[MiniMaxContext-Expand] API falhou: {data.get('message', data)}")
366            return None
367        fd, result_path = tempfile.mkstemp(
368            suffix=".mp4", prefix="minimax_context_ext_", dir=OUTPUT_DIR
369        )
370        os.close(fd)
371        with open(result_path, "wb") as result:
372            result.write(base64.b64decode(data["video_b64"]))
373        _save_stamp()
374        print(f"[MiniMaxContext-Expand] Video contextual salvo: {result_path}")
375        return result_path
376    except asyncio.TimeoutError:
377        print("[MiniMaxContext-Expand] Timeout apos 1800s")
378        return None
379    except Exception as exc:
380        print(f"[MiniMaxContext-Expand] Excecao: {type(exc).__name__}: {exc}")
381        return None
382
383
384async def _expand_video_legacy(
385    video_path: str,
386    prompt_text: str = "",
387    length_frames: int = 153,
388    resolution: int = 1080,
389    bot_token: str = "",
390    chat_id: int = 0,
391    message_id: int = 0,
392    preset_name: str = "custom",
393) -> str | None:
394    """
395    Envia o vídeo para o servidor Modal e aguarda o vídeo expandido.
396    """
397    print(f"[VideoAPI] Expandindo vídeo local: {video_path}")
398    with open(video_path, "rb") as f:
399        video_b64 = base64.b64encode(f.read()).decode()
400
401    payload = {
402        "mode": "extend",
403        "video_b64": video_b64,
404        "prompt": prompt_text,
405        "length": length_frames,
406        "resolution": resolution,
407        "bot_token": bot_token,
408        "chat_id": chat_id,
409        "message_id": message_id,
410        "preset_name": preset_name,
411    }
412
413    is_cold = _cold_start()
414    timeout_total = 1800 if is_cold else 1500
415    print(f"[VideoAPI] {'🥶 Cold start' if is_cold else '🔥 Warm start'} — timeout: {timeout_total}s")
416
417    try:
418        session = await get_http_session()
419        async with asyncio.timeout(timeout_total):
420            print(f"[VideoAPI] Enviando para {VIDEO_API_URL}...")
421            async with session.post(VIDEO_API_URL, json=payload) as resp:
422                if resp.status != 200:
423                    body = await resp.text()
424                    print(f"[VideoAPI] ❌ HTTP {resp.status}: {body[:300]}")
425                    return None
426
427                data = await resp.json()
428
429                if data.get("status") == "error":
430                    print(f"[VideoAPI] ❌ Erro na API: {data.get('message')}")
431                    return None
432                elif data.get("status") == "success":
433                    video_out_b64 = data.get("video_b64", "")
434                    if not video_out_b64:
435                        print("[VideoAPI] ❌ video_b64 vazio na resposta")
436                        return None
437
438                    print(f"[VideoAPI] ✅ Vídeo expandido recebido! ({len(video_out_b64)} chars)")
439
440                    fd, local_path = tempfile.mkstemp(suffix=".mp4", prefix="nakedvideo_ext_", dir=OUTPUT_DIR)
441                    os.close(fd)
442
443                    with open(local_path, "wb") as f:
444                        f.write(base64.b64decode(video_out_b64))
445
446                    _save_stamp()
447                    print(f"[VideoAPI] ✅ Vídeo Expandido salvo: {local_path}")
448                    return local_path
449        return None
450
451    except asyncio.TimeoutError:
452        print(f"[VideoAPI] ⏰ Timeout após {timeout_total}s")
453        return None
454    except Exception as e:
455        print(f"[VideoAPI] ❌ Exceção: {e}")
456        return None
457
458
459
460async def upscale_video_async(video_path: str, api_key: str, target_resolution: str = "1080p") -> str:
461    """Upscala um video via Wavespeed API (Bytedance video-upscaler)."""
462    if not os.path.isfile(video_path):
463        return None
464        
465    session = await get_http_session()
466    
467    # 1. Upload the video binary
468    upload_url = "https://api.wavespeed.ai/api/v3/media/upload/binary?ext=mp4"
469    with open(video_path, "rb") as f:
470        video_data = f.read()
471    
472    headers = {
473        "Authorization": f"Bearer {api_key}",
474        "Content-Type": "video/mp4"
475    }
476    
477    async with session.post(upload_url, headers=headers, data=video_data) as up_res:
478        if up_res.status != 200:
479            print(f"[VideoUpscaler] Falha no upload: {await up_res.text()}")
480            return None
481        up_json = await up_res.json()
482        dl_url = up_json.get("data", {}).get("download_url")
483        if not dl_url:
484            return None
485            
486    # 2. Submit prediction
487    submit_url = "https://api.wavespeed.ai/api/v3/bytedance/video-upscaler"
488    submit_payload = {
489        "video": dl_url,
490        "target_resolution": target_resolution
491    }
492    submit_headers = {
493        "Authorization": f"Bearer {api_key}",
494        "Content-Type": "application/json"
495    }
496    
497    async with session.post(submit_url, headers=submit_headers, json=submit_payload) as sub_res:
498        if sub_res.status != 200:
499            print(f"[VideoUpscaler] Falha ao enviar task: {await sub_res.text()}")
500            return None
501        task_data = await sub_res.json()
502        # Wavespeed API returns data inside 'data' key or directly at root
503        task = task_data.get("data", task_data)
504        prediction_id = task.get("id")
505        if not prediction_id:
506            return None
507            
508        result_url = task.get("urls", {}).get("get", f"https://api.wavespeed.ai/api/v3/predictions/{prediction_id}/result")
509
510    # 3. Poll
511    poll_headers = {"Authorization": f"Bearer {api_key}"}
512    import asyncio
513    while True:
514        await asyncio.sleep(3)
515        async with session.get(result_url, headers=poll_headers) as poll_res:
516            poll_data = await poll_res.json()
517            res_obj = poll_data.get("data", poll_data)
518            status = res_obj.get("status", "")
519            
520            if status == "completed":
521                outputs = res_obj.get("outputs", [])
522                if not outputs:
523                    return None
524                # Pode ser array de urls
525                out_url = outputs[0] if isinstance(outputs, list) else outputs
526                break
527            elif status in ["failed", "cancelled", "timeout"]:
528                print(f"[VideoUpscaler] Task falhou com status {status}")
529                return None
530            # continuara processando (created, processing, starting)
531
532    # 4. Download final video
533    async with session.get(out_url) as dl_res:
534        if dl_res.status == 200:
535            import uuid
536            import time
537            final_name = f"upscaled_{int(time.time())}_{uuid.uuid4().hex[:8]}.mp4"
538            final_path = os.path.join(os.path.dirname(video_path), final_name)
539            with open(final_path, "wb") as out_f:
540                out_f.write(await dl_res.read())
541            return final_path
542    return None
543
544
545
546async def expand_minimax_video_async(
547    video_path: str,
548    prompt_text: str = "The person moves naturally",
549    duration: float = 10.0,
550    bot_token: str = "",
551    chat_id: int = 0,
552    message_id: int = 0,
553    mp: float = 0.5,
554) -> str | None:
555    """
556    Uses the minimax-h3-extend endpoint to seamlessly expand an existing video.
557    """
558    import base64
559    import asyncio
560    import uuid
561    import json
562    import os
563    
564    MINIMAX_EXTEND_URL = "https://awh2707--minimax-h3-extend-minimaxh3extender-extend.modal.run"
565    
566    print(f"[MiniMaxExpandAPI] Processando video local: {video_path}")
567    with open(video_path, "rb") as f:
568        video_b64 = base64.b64encode(f.read()).decode()
569
570    payload = {
571        "video_b64": video_b64,
572        "prompt": prompt_text,
573        "duration": duration,
574        "continuation_mode": "guide",
575        "context_length": 22,
576        "steps": 20,
577        "prepend_original": True,
578        "run_name": f"expand_tg_{uuid.uuid4().hex[:8]}",
579        "bot_token": bot_token,
580        "chat_id": chat_id,
581        "message_id": message_id,
582        "mp": mp,
583    }
584
585    try:
586        session = await get_http_session()
587        async with asyncio.timeout(1800):
588            print(f"[MiniMaxExpandAPI] Enviando para {MINIMAX_EXTEND_URL}...")
589            async with session.post(MINIMAX_EXTEND_URL, json=payload) as resp:
590                if resp.status != 200:
591                    body = await resp.text()
592                    print(f"[MiniMaxExpandAPI] HTTP {resp.status}: {body[:300]}")
593                    return None
594                data = await resp.json()
595                if data.get("status") != "success" or not data.get("video_b64"):
596                    print(f"[MiniMaxExpandAPI] Erro na API: {data.get('message', 'Sem resposta de video')}")
597                    return None
598                
599                output_bytes = base64.b64decode(data["video_b64"])
600                _video_outputs_dir = os.path.join(os.path.dirname(__file__), "video_outputs")
601                os.makedirs(_video_outputs_dir, exist_ok=True)
602                out_path = os.path.join(_video_outputs_dir, f"minimax_expanded_{uuid.uuid4().hex[:8]}.mp4")
603                with open(out_path, "wb") as f:
604                    f.write(output_bytes)
605                print(f"[MiniMaxExpandAPI] Video expandido salvo em {out_path}")
606                return out_path
607    except Exception as e:
608        print(f"[MiniMaxExpandAPI] Erro interno: {e}")
609        return None
610
611
612# ── URL do servidor Modal (Face Swap em Vídeo com MaskVid e GFPGAN HD) ─────────
613FACESWAP_VIDEO_API_URL = os.getenv(
614    "FACESWAP_VIDEO_API_URL",
615    "https://uch2f82placa--maskvid-faceswap-maskvidworker-api-swap-dev.modal.run"
616)
617
618
619async def gerar_video_faceswap_async(
620    image_path: str,
621    video_path: str,
622    enhance: bool = True,
623    enhance_weight: float = 0.90,
624    megapixels: float = 1.0,
625    crop_scale: float = 2.2,
626) -> str | None:
627    """
628    Envia a foto de origem e o vídeo de destino para o servidor Modal Face Swap.
629    Executa MaskVid (rastreamento temporal sem jitter) + GFPGAN HD (restauração facial).
630    Retorna o caminho do arquivo .mp4 salvo localmente ou None em caso de falha.
631    """
632    print(f"[FaceSwapVideoAPI] Iniciando Face Swap em Vídeo...")
633    print(f"   Foto: {image_path}")
634    print(f"   Vídeo: {video_path}")
635
636    if not os.path.exists(image_path) or not os.path.exists(video_path):
637        print(f"[FaceSwapVideoAPI] ❌ Arquivos de entrada não encontrados.")
638        return None
639
640    with open(image_path, "rb") as f_img:
641        source_b64 = base64.b64encode(f_img.read()).decode("utf-8")
642    with open(video_path, "rb") as f_vid:
643        target_b64 = base64.b64encode(f_vid.read()).decode("utf-8")
644
645    payload = {
646        "source_image": source_b64,
647        "target_video": target_b64,
648        "enhance": enhance,
649        "enhance_weight": enhance_weight,
650        "megapixels": megapixels,
651        "crop_scale": crop_scale,
652    }
653
654    endpoints = [
655        FACESWAP_VIDEO_API_URL,
656        "https://uch2f82placa--maskvid-faceswap-maskvidworker-api-swap.modal.run",
657        "https://uch2f82placa--maskvid-faceswap-maskvidworker-api-swap-dev.modal.run",
658    ]
659
660    try:
661        session = await get_http_session()
662        for ep in endpoints:
663            try:
664                print(f"[FaceSwapVideoAPI] Enviando requisição para {ep}...")
665                async with asyncio.timeout(600):
666                    async with session.post(ep, json=payload) as resp:
667                        if resp.status == 200:
668                            data = await resp.json()
669                            if data.get("status") == "success" and data.get("video_base64"):
670                                video_bytes = base64.b64decode(data["video_base64"])
671                                out_filename = f"faceswap_video_{uuid.uuid4().hex[:12]}.mp4"
672                                out_path = os.path.join(OUTPUT_DIR, out_filename)
673                                with open(out_path, "wb") as f_out:
674                                    f_out.write(video_bytes)
675                                print(f"[FaceSwapVideoAPI] ✅ Vídeo gerado com sucesso: {out_path} ({len(video_bytes)} bytes)")
676                                return out_path
677                            else:
678                                print(f"[FaceSwapVideoAPI] ⚠️ Resposta da API sem sucesso: {data.get('message')}")
679                        else:
680                            body = await resp.text()
681                            print(f"[FaceSwapVideoAPI] ⚠️ Endpoint {ep} retornou status {resp.status}: {body[:200]}")
682            except Exception as ep_err:
683                print(f"[FaceSwapVideoAPI] ⚠️ Tentativa no endpoint {ep} falhou: {ep_err}")
684                continue
685
686        # Fallback: se os endpoints HTTP falharem, executa via Modal CLI / Python Runner
687        print("[FaceSwapVideoAPI] Tentando fallback via modal run...")
688        script_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "maskvid_modal.py")
689        out_filename = f"faceswap_video_{uuid.uuid4().hex[:12]}.mp4"
690        out_path = os.path.join(OUTPUT_DIR, out_filename)
691
692        cmd = [
693            "python", "-m", "modal", "run", script_path,
694            "--source", image_path,
695            "--target", video_path,
696            "--output", out_path,
697            "--enhance-weight", str(enhance_weight),
698            "--megapixels", str(megapixels),
699            "--crop-scale", str(crop_scale),
700        ]
701        proc = await asyncio.create_subprocess_exec(
702            *cmd,
703            stdout=asyncio.subprocess.PIPE,
704            stderr=asyncio.subprocess.PIPE,
705        )
706        stdout, stderr = await proc.communicate()
707        if proc.returncode == 0 and os.path.exists(out_path) and os.path.getsize(out_path) > 1000:
708            print(f"[FaceSwapVideoAPI] ✅ Fallback Modal CLI concluído com sucesso: {out_path}")
709            return out_path
710        else:
711            print(f"[FaceSwapVideoAPI] ❌ Fallback Modal CLI falhou ({proc.returncode}): {stderr.decode()[:300]}")
712            return None
713
714    except Exception as e:
715        print(f"[FaceSwapVideoAPI] ❌ Erro geral em gerar_video_faceswap_async: {e}")
716        return None
717
718