Spaces:
Runtime error
Runtime error
| from fastapi import FastAPI, HTTPException, BackgroundTasks | |
| from pydantic import BaseModel | |
| import os | |
| import time | |
| import requests | |
| import subprocess | |
| import base64 | |
| import json | |
| import shutil | |
| import tempfile | |
| from typing import Optional | |
| # 🎯 DÜZELTME 1: Google API için v1beta sürümünü zorunlu kılıyoruz. | |
| # Gemini 2.0 Flash modeli faturalı (Paid) hesaplarda bile sadece v1beta otoyolunda çalışır. | |
| os.environ["GEMINI_API_VERSION"] = "v1beta" | |
| app = FastAPI(title="VoiceClips heavy video processor (Wav2Lip + FFmpeg)") | |
| class TranslationJob(BaseModel): | |
| video_id: str | |
| original_video_url: str | |
| target_lang: str | |
| voice_tone: str | |
| has_lip_sync: bool | |
| has_captions: bool | |
| caption_style: str | |
| resolution: str | |
| user_token: str | |
| custom_font: Optional[str] = None | |
| custom_size: Optional[int] = None | |
| custom_color: Optional[str] = None | |
| supabase_url: Optional[str] = None | |
| supabase_anon_key: Optional[str] = None | |
| gemini_api_key: Optional[str] = None | |
| elevenlabs_api_key: Optional[str] = None | |
| use_voice_cloning: Optional[bool] = True | |
| default_voice_id: Optional[str] = 'Xb7hH8MSUJpSbSDYk0k2' | |
| trim_start: Optional[float] = None | |
| trim_end: Optional[float] = None | |
| def load_env_local(): | |
| """Load environment variables from local .env.local file if it exists.""" | |
| for env_path in [".env.local", "../.env.local"]: | |
| if os.path.exists(env_path): | |
| print(f"Loading environment variables from {env_path}...") | |
| with open(env_path, "r", encoding="utf-8") as f: | |
| for line in f: | |
| line = line.strip() | |
| if line and not line.startswith("#"): | |
| parts = line.split("=", 1) | |
| if len(parts) == 2: | |
| key = parts[0].strip() | |
| val = parts[1].strip() | |
| # Strip quotes | |
| if val.startswith('"') and val.endswith('"'): | |
| val = val[1:-1] | |
| elif val.startswith("'") and val.endswith("'"): | |
| val = val[1:-1] | |
| os.environ[key] = val | |
| break | |
| # Load env variables initially | |
| load_env_local() | |
| def download_file(url: str, dest_path: str): | |
| """Download a file with streaming to prevent memory issues.""" | |
| print(f"Downloading {url} to {dest_path}...") | |
| response = requests.get(url, stream=True) | |
| response.raise_for_status() | |
| with open(dest_path, "wb") as f: | |
| for chunk in response.iter_content(chunk_size=8192): | |
| f.write(chunk) | |
| print(f"Download complete: {dest_path}") | |
| def setup_wav2lip(): | |
| """Ensure Wav2Lip repo, directories, checkpoints, and face detection files are configured and patched.""" | |
| print("Initializing Wav2Lip repository setup...") | |
| # 1. Clone official Wav2Lip repository if missing | |
| if not os.path.exists("Wav2Lip"): | |
| print("Cloning Wav2Lip repository...") | |
| try: | |
| subprocess.run(["git", "clone", "https://github.com/Rudrabha/Wav2Lip.git"], check=True) | |
| print("Wav2Lip cloned successfully.") | |
| except Exception as e: | |
| print(f"Error cloning Wav2Lip: {e}") | |
| return False | |
| # 2. Patch inference.py to support NumPy 1.24+ (np.int -> int, np.float -> float, etc.) | |
| inference_path = "Wav2Lip/inference.py" | |
| if os.path.exists(inference_path): | |
| try: | |
| with open(inference_path, "r", encoding="utf-8") as f: | |
| content = f.read() | |
| if "np.int = int" not in content: | |
| print("Patching Wav2Lip/inference.py to add numpy compatibility monkey-patches...") | |
| patched = content.replace("import numpy as np", "import numpy as np\nnp.int = int\nnp.float = float\nnp.bool = bool") | |
| with open(inference_path, "w", encoding="utf-8") as f: | |
| f.write(patched) | |
| except Exception as e: | |
| print(f"Error patching Wav2Lip/inference.py: {e}") | |
| # 3. Download Wav2Lip GAN model checkpoint | |
| checkpoint_dir = "Wav2Lip/checkpoints" | |
| os.makedirs(checkpoint_dir, exist_ok=True) | |
| wav2lip_checkpoint_path = os.path.join(checkpoint_dir, "wav2lip_gan.pth") | |
| wav2lip_url = "https://huggingface.co/Nekochu/Wav2Lip/resolve/main/wav2lip_gan.pth" | |
| try: | |
| if not os.path.exists(wav2lip_checkpoint_path) or os.path.getsize(wav2lip_checkpoint_path) < 1000000: | |
| print("Downloading Wav2Lip GAN checkpoint...") | |
| download_file(wav2lip_url, wav2lip_checkpoint_path) | |
| except Exception as e: | |
| print(f"Error downloading Wav2Lip GAN checkpoint: {e}") | |
| return False | |
| # 4. Download face detection model checkpoint (s3fd) | |
| sfd_dir = "Wav2Lip/face_detection/detection/sfd" | |
| os.makedirs(sfd_dir, exist_ok=True) | |
| s3fd_checkpoint_path = os.path.join(sfd_dir, "s3fd.pth") | |
| s3fd_url = "https://www.adrianbulat.com/downloads/python-fan/s3fd-619a316812.pth" | |
| try: | |
| if not os.path.exists(s3fd_checkpoint_path) or os.path.getsize(s3fd_checkpoint_path) < 1000000: | |
| print("Downloading s3fd face detection model checkpoint...") | |
| download_file(s3fd_url, s3fd_checkpoint_path) | |
| except Exception as e: | |
| print(f"Error downloading s3fd face detection checkpoint: {e}") | |
| return False | |
| print("Wav2Lip environment setup completed successfully.") | |
| return True | |
| def get_supabase_config(): | |
| supabase_url = os.environ.get("NEXT_PUBLIC_SUPABASE_URL", "") | |
| supabase_anon_key = os.environ.get("NEXT_PUBLIC_SUPABASE_ANON_KEY", "") | |
| return supabase_url, supabase_anon_key | |
| def get_api_keys(): | |
| gemini_key = os.environ.get("GEMINI_API_KEY", "") | |
| elevenlabs_key = os.environ.get("ELEVENLABS_API_KEY", "") | |
| # Check if they are valid or placeholders | |
| is_gemini_mock = not gemini_key or "your-gemini" in gemini_key or "here" in gemini_key | |
| is_eleven_mock = not elevenlabs_key or "your-eleven" in elevenlabs_key or "here" in elevenlabs_key | |
| return gemini_key, elevenlabs_key, (is_gemini_mock or is_eleven_mock) | |
| def update_db_status(video_id: str, user_token: str, status: str, translated_url: str = None, error_message: str = None, supabase_url: str = None, supabase_anon_key: str = None): | |
| if not supabase_url or not supabase_anon_key: | |
| env_url, env_key = get_supabase_config() | |
| supabase_url = supabase_url or env_url | |
| supabase_anon_key = supabase_anon_key or env_key | |
| if not supabase_url or not supabase_anon_key: | |
| print("Error: Supabase config is missing.") | |
| return | |
| # Normalize Supabase base URL (remove trailing slash and rest/v1 path if present) | |
| base_url = supabase_url.rstrip("/") | |
| if base_url.endswith("/rest/v1"): | |
| base_url = base_url[:-8].rstrip("/") | |
| url = f"{base_url}/rest/v1/videos?id=eq.{video_id}" | |
| headers = { | |
| "apikey": supabase_anon_key, | |
| "Authorization": f"Bearer {user_token}", | |
| "Content-Type": "application/json", | |
| "Prefer": "return=minimal" | |
| } | |
| payload = { | |
| "status": status, | |
| "updated_at": "now()" | |
| } | |
| if translated_url: | |
| payload["translated_video_url"] = translated_url | |
| if error_message: | |
| payload["error_message"] = error_message | |
| try: | |
| res = requests.patch(url, headers=headers, json=payload) | |
| res.raise_for_status() | |
| print(f"Database successfully updated for video {video_id} to status: '{status}'") | |
| except Exception as e: | |
| print(f"Error updating database for video {video_id}: {e}") | |
| def clean_srt_content(content: str) -> str: | |
| """Remove markdown codeblock tags if any.""" | |
| content = content.replace("```srt", "").replace("```", "") | |
| return content.strip() | |
| def srt_to_plain_text(srt_content: str) -> str: | |
| """Extract clean text from SRT formatted content for TTS synthesis.""" | |
| lines = srt_content.splitlines() | |
| text_lines = [] | |
| for line in lines: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| if line.isdigit(): | |
| continue | |
| if "-->" in line: | |
| continue | |
| text_lines.append(line) | |
| return " ".join(text_lines) | |
| def get_ffmpeg_style(caption_style: str, custom_font: str = None, custom_size: int = None, custom_color: str = None) -> str: | |
| """Map the frontend style setting to ASS subtitles filter parameters, applying custom overrides if specified.""" | |
| styles = { | |
| "tiktok": { | |
| "Fontname": "Impact", | |
| "Fontsize": "26", | |
| "PrimaryColour": "&H0000FFFF", # Yellow | |
| "OutlineColour": "&H00000000", | |
| "BorderStyle": "1", | |
| "Outline": "2", | |
| "Shadow": "0", | |
| "Alignment": "2" | |
| }, | |
| "retro": { | |
| "Fontname": "Courier New", | |
| "Fontsize": "22", | |
| "PrimaryColour": "&H0000FF00", # Green | |
| "OutlineColour": "&H00000000", | |
| "BorderStyle": "1", | |
| "Outline": "1", | |
| "Shadow": "2", | |
| "Alignment": "2" | |
| }, | |
| "minimalist": { | |
| "Fontname": "Arial", | |
| "Fontsize": "16", | |
| "PrimaryColour": "&H00FFFFFF", # White | |
| "BackColour": "&H80000000", | |
| "BorderStyle": "3", | |
| "Outline": "0", | |
| "Shadow": "0", | |
| "Alignment": "2" | |
| } | |
| } | |
| style = styles.get(caption_style, styles["tiktok"]).copy() | |
| if custom_font: | |
| style["Fontname"] = custom_font | |
| if custom_size: | |
| style["Fontsize"] = str(custom_size) | |
| if custom_color: | |
| # BGR (Blue-Green-Red) hex format for ASS style colors | |
| color_map = { | |
| "yellow": "&H0000FFFF", | |
| "green": "&H0000FF00", | |
| "white": "&H00FFFFFF", | |
| "red": "&H000000FF", | |
| "blue": "&H00FF0000", | |
| "cyan": "&H00FFFF00" | |
| } | |
| style["PrimaryColour"] = color_map.get(custom_color.lower(), custom_color) | |
| style_str = ",".join([f"{k}={v}" for k, v in style.items()]) | |
| return style_str | |
| def run_ffmpeg_command(cmd, task_name="FFmpeg"): | |
| print(f"Running {task_name} command: {' '.join(cmd)}") | |
| res = subprocess.run(cmd, capture_output=True, text=True) | |
| if res.returncode != 0: | |
| error_logs = res.stderr.strip().splitlines() | |
| last_logs = "\n".join(error_logs[-6:]) if error_logs else "No error logs outputted." | |
| raise Exception(f"{task_name} failed with exit code {res.returncode}. FFmpeg error logs:\n{last_logs}") | |
| def read_root(): | |
| gemini_key, eleven_key, is_mock = get_api_keys() | |
| mode = "SIMULATION / MOCK" if is_mock else "PRODUCTION / REAL" | |
| return { | |
| "status": "online", | |
| "message": "VoiceClips processing server is active.", | |
| "mode": mode | |
| } | |
| def health_check(): | |
| return {"status": "healthy"} | |
| def process_video_task(job: TranslationJob): | |
| video_id = job.video_id | |
| user_token = job.user_token | |
| print(f"Starting background process for video ID: {video_id}") | |
| # Resolve Supabase config (prioritize request body, fallback to env) | |
| supabase_url = job.supabase_url or os.environ.get("NEXT_PUBLIC_SUPABASE_URL", "") | |
| supabase_anon_key = job.supabase_anon_key or os.environ.get("NEXT_PUBLIC_SUPABASE_ANON_KEY", "") | |
| # Resolve API keys (prioritize request body, fallback to env) | |
| gemini_key = job.gemini_api_key or os.environ.get("GEMINI_API_KEY", "") | |
| elevenlabs_key = job.elevenlabs_api_key or os.environ.get("ELEVENLABS_API_KEY", "") | |
| # Check if they are valid or placeholders | |
| is_gemini_mock = not gemini_key or "your-gemini" in gemini_key or "here" in gemini_key | |
| is_eleven_mock = not elevenlabs_key or "your-eleven" in elevenlabs_key or "here" in elevenlabs_key | |
| is_mock_mode = is_gemini_mock or is_eleven_mock | |
| if is_mock_mode: | |
| run_simulation_pipeline(job, supabase_url, supabase_anon_key) | |
| else: | |
| run_production_pipeline(job, gemini_key, elevenlabs_key, supabase_url, supabase_anon_key) | |
| def run_simulation_pipeline(job: TranslationJob, supabase_url: str, supabase_anon_key: str): | |
| video_id = job.video_id | |
| user_token = job.user_token | |
| print(f"[{video_id}] Running in SIMULATION MODE...") | |
| try: | |
| # Step 1: Simulate Audio Extraction (Status is already processing) | |
| print(f"[{video_id}] Step 1: Simulating audio extraction...") | |
| time.sleep(2) | |
| # Step 2: Simulate Gemini Translation | |
| print(f"[{video_id}] Step 2: Simulating Gemini 2.5 Flash transcription & translation...") | |
| update_db_status(video_id, user_token, "processing", supabase_url=supabase_url, supabase_anon_key=supabase_anon_key) # Just refresh database connection | |
| time.sleep(3) | |
| # Step 3: Simulate ElevenLabs Voice Clone Synthesis | |
| print(f"[{video_id}] Step 3: Simulating ElevenLabs voice clone synthesis with '{job.voice_tone}' tone...") | |
| time.sleep(2) | |
| # Step 4: Simulate Subtitle Burning and Audio-Video Merge | |
| if job.has_captions: | |
| override_msg = "" | |
| if job.custom_font or job.custom_size or job.custom_color: | |
| override_msg = f" (Overrides: font={job.custom_font}, size={job.custom_size}, color={job.custom_color})" | |
| print(f"[{video_id}] Step 4: Simulating FFmpeg subtitle burning in '{job.caption_style}' style{override_msg}...") | |
| time.sleep(2) | |
| if job.has_lip_sync: | |
| print(f"[{video_id}] Step 5: Simulating Wav2Lip alignment (LipSync active)...") | |
| time.sleep(2) | |
| else: | |
| print(f"[{video_id}] Step 5: Simulating FFmpeg video & audio track merge...") | |
| time.sleep(1) | |
| # Target Lang Mock URL selector | |
| # To make it feel premium, we can point to a high quality public MP4 file | |
| mock_output_video = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4" | |
| print(f"[{video_id}] Simulation complete. Updating database to completed.") | |
| update_db_status(video_id, user_token, "completed", translated_url=mock_output_video, supabase_url=supabase_url, supabase_anon_key=supabase_anon_key) | |
| except Exception as e: | |
| print(f"[{video_id}] Simulation Error: {e}") | |
| update_db_status(video_id, user_token, "failed", error_message=f"Simülasyon Hatası: {str(e)}", supabase_url=supabase_url, supabase_anon_key=supabase_anon_key) | |
| def run_production_pipeline(job: TranslationJob, gemini_key: str, elevenlabs_key: str, supabase_url: str, supabase_anon_key: str): | |
| video_id = job.video_id | |
| user_token = job.user_token | |
| print(f"[{video_id}] Running in PRODUCTION MODE...") | |
| # Detect extension from URL or default to mp4 | |
| url_without_params = job.original_video_url.split('?')[0] | |
| file_ext = url_without_params.split('.')[-1].lower() if '.' in url_without_params else 'mp4' | |
| if file_ext not in ['mp4', 'mov', 'webm']: | |
| file_ext = 'mp4' | |
| # Use /tmp for temp files (reliable on Linux/HuggingFace Space, avoids CWD issues with FFmpeg) | |
| tmp_dir = tempfile.gettempdir() | |
| downloaded_video_path = os.path.join(tmp_dir, f"temp_{video_id}_downloaded.{file_ext}") | |
| input_video_path = os.path.join(tmp_dir, f"temp_{video_id}_input.mp4") # standardized version | |
| extracted_audio_path = os.path.join(tmp_dir, f"temp_{video_id}_audio.mp3") | |
| synthesized_audio_path = os.path.join(tmp_dir, f"temp_{video_id}_tts.mp3") | |
| srt_file_path = os.path.join(tmp_dir, f"temp_{video_id}.srt") | |
| output_video_path = os.path.join(tmp_dir, f"temp_{video_id}_output.mp4") | |
| lipsync_output_path = os.path.join(tmp_dir, f"temp_{video_id}_lipsync.mp4") | |
| try: | |
| # 1. Download original video | |
| print(f"[{video_id}] Downloading original video from: {job.original_video_url}") | |
| res = requests.get(job.original_video_url, stream=True) | |
| res.raise_for_status() | |
| with open(downloaded_video_path, "wb") as f: | |
| for chunk in res.iter_content(chunk_size=8192): | |
| f.write(chunk) | |
| # 1.5. Standardize video (H.264/AAC, 25 FPS, YUV420p) for OpenCV / Wav2Lip stability | |
| print(f"[{video_id}] Standardizing downloaded video to MP4 format...") | |
| cmd_standardize = [ | |
| "ffmpeg", "-y", "-i", downloaded_video_path | |
| ] | |
| if job.trim_start is not None and job.trim_start > 0: | |
| cmd_standardize.extend(["-ss", str(job.trim_start)]) | |
| if job.trim_end is not None and job.trim_end > 0: | |
| cmd_standardize.extend(["-to", str(job.trim_end)]) | |
| cmd_standardize.extend([ | |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "25", | |
| "-c:a", "aac", "-ar", "16000", "-ac", "1", | |
| input_video_path | |
| ]) | |
| run_ffmpeg_command(cmd_standardize, "Video Standardization") | |
| # 2. Extract audio from video using FFmpeg | |
| print(f"[{video_id}] Extracting audio using FFmpeg...") | |
| cmd_extract = [ | |
| "ffmpeg", "-y", "-i", input_video_path, | |
| "-vn", "-acodec", "libmp3lame", "-q:a", "2", | |
| extracted_audio_path | |
| ] | |
| run_ffmpeg_command(cmd_extract, "Audio Extraction") | |
| # 3. Request Transcription & Translation from Gemini 2.5 Flash | |
| print(f"[{video_id}] Calling Gemini for transcription/translation to '{job.target_lang}'...") | |
| with open(extracted_audio_path, "rb") as audio_file: | |
| audio_data = base64.b64encode(audio_file.read()).decode("utf-8") | |
| if job.has_captions: | |
| gemini_prompt = f"Transcribe the following audio, translate it accurately to '{job.target_lang}', and output the result in SRT subtitle format. Keep each caption line short (max 4-5 words) and ensure the timing is aligned with the audio. Output ONLY the raw SRT text, no extra markdown formatting, tags, or explanations." | |
| else: | |
| gemini_prompt = f"Transcribe the following audio, translate it accurately to '{job.target_lang}', and output only the clean translated text. Keep natural pauses. Do not include any tags, notes, or descriptions." | |
| gemini_payload = { | |
| "contents": [ | |
| { | |
| "parts": [ | |
| { | |
| "inline_data": { | |
| "mime_type": "audio/mp3", | |
| "data": audio_data | |
| } | |
| }, | |
| { | |
| "text": gemini_prompt | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| # Retry logic: try gemini-2.5-flash up to 3 times (503/429/500 are transient), | |
| # then fall back to gemini-2.0-flash-exp if it keeps failing. | |
| GEMINI_MODELS = [ | |
| "gemini-2.5-flash", # Primary: best quality | |
| "gemini-2.0-flash-exp", # Fallback: still very capable | |
| ] | |
| RETRY_DELAYS = [5, 15, 30] # seconds between retries per model | |
| gemini_data = None | |
| last_error = None | |
| for model_name in GEMINI_MODELS: | |
| gemini_url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent?key={gemini_key}" | |
| print(f"[{video_id}] Trying Gemini model: {model_name}") | |
| for attempt, delay in enumerate(RETRY_DELAYS, start=1): | |
| try: | |
| gemini_res = requests.post(gemini_url, json=gemini_payload, timeout=300) | |
| if gemini_res.status_code in (503, 429, 500): | |
| last_error = f"HTTP {gemini_res.status_code}" | |
| print(f"[{video_id}] Gemini {model_name} attempt {attempt} returned {gemini_res.status_code}. Retrying in {delay}s...") | |
| time.sleep(delay) | |
| continue | |
| gemini_res.raise_for_status() | |
| gemini_data = gemini_res.json() | |
| print(f"[{video_id}] Gemini {model_name} responded successfully on attempt {attempt}.") | |
| break # success | |
| except Exception as e: | |
| last_error = str(e) | |
| print(f"[{video_id}] Gemini {model_name} attempt {attempt} failed: {e}. Retrying in {delay}s...") | |
| time.sleep(delay) | |
| if gemini_data: | |
| break # got a response, no need to try next model | |
| print(f"[{video_id}] All retries exhausted for {model_name}, trying next model...") | |
| if not gemini_data: | |
| raise Exception(f"Gemini API failed after all retries and model fallbacks. Last error: {last_error}") | |
| gemini_output = gemini_data["candidates"][0]["content"]["parts"][0]["text"].strip() | |
| if job.has_captions: | |
| srt_content = clean_srt_content(gemini_output) | |
| with open(srt_file_path, "w", encoding="utf-8") as srt_file: | |
| srt_file.write(srt_content) | |
| translated_text = srt_to_plain_text(srt_content) | |
| print(f"[{video_id}] Gemini SRT translation generated. Plain text length: {len(translated_text)}") | |
| else: | |
| translated_text = gemini_output | |
| print(f"[{video_id}] Gemini Plain text translation: '{translated_text[:100]}...'") | |
| # 4. Synthesize voice cloning using ElevenLabs API | |
| print(f"[{video_id}] Calling ElevenLabs TTS Voice Cloning with tone '{job.voice_tone}'...") | |
| elevenlabs_headers = { | |
| "xi-api-key": elevenlabs_key | |
| } | |
| voice_id = None | |
| cloned_voice_created = False | |
| # Try to dynamically clone the voice using the extracted audio if requested | |
| if job.use_voice_cloning: | |
| try: | |
| print(f"[{video_id}] Attempting to create a temporary voice clone from extracted audio...") | |
| add_voice_url = "https://api.elevenlabs.io/v1/voices/add" | |
| # Open audio file for upload | |
| with open(extracted_audio_path, "rb") as audio_file: | |
| files = { | |
| "files": (os.path.basename(extracted_audio_path), audio_file, "audio/mpeg") | |
| } | |
| data = { | |
| "name": f"VoiceClips_{video_id}", | |
| "description": f"Temporary cloned voice for job {video_id}" | |
| } | |
| add_res = requests.post(add_voice_url, headers=elevenlabs_headers, files=files, data=data) | |
| if add_res.status_code == 200: | |
| voice_id = add_res.json().get("voice_id") | |
| cloned_voice_created = True | |
| print(f"[{video_id}] Voice clone created successfully. Voice ID: {voice_id}") | |
| else: | |
| print(f"[{video_id}] Voice cloning failed with status code {add_res.status_code}: {add_res.text}") | |
| print(f"[{video_id}] Falling back to standard pre-made voice ({job.default_voice_id}).") | |
| except Exception as clone_err: | |
| print(f"[{video_id}] Exception during voice cloning: {clone_err}") | |
| print(f"[{video_id}] Falling back to standard pre-made voice ({job.default_voice_id}).") | |
| else: | |
| print(f"[{video_id}] Voice cloning disabled by user. Using standard pre-made voice ({job.default_voice_id}).") | |
| # Fallback to default voice if cloning wasn't successful | |
| if not voice_id: | |
| voice_id = job.default_voice_id or "Xb7hH8MSUJpSbSDYk0k2" # Alice (pre-made voice, works on free tier) | |
| elevenlabs_url = f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}" | |
| # Content-Type header is needed for the JSON payload of the TTS request | |
| tts_headers = { | |
| "xi-api-key": elevenlabs_key, | |
| "Content-Type": "application/json" | |
| } | |
| # Optional stability configurations based on voice tone | |
| stability = 0.5 | |
| similarity_boost = 0.75 | |
| if job.voice_tone == 'excited': | |
| stability = 0.35 | |
| similarity_boost = 0.8 | |
| elif job.voice_tone == 'corporate': | |
| stability = 0.7 | |
| similarity_boost = 0.7 | |
| elevenlabs_payload = { | |
| "text": translated_text, | |
| "model_id": "eleven_multilingual_v2", | |
| "voice_settings": { | |
| "stability": stability, | |
| "similarity_boost": similarity_boost | |
| } | |
| } | |
| tts_res = requests.post(elevenlabs_url, headers=tts_headers, json=elevenlabs_payload) | |
| tts_res.raise_for_status() | |
| with open(synthesized_audio_path, "wb") as f: | |
| f.write(tts_res.content) | |
| # If we successfully created a temporary cloned voice, delete it now to free up slots | |
| if cloned_voice_created and voice_id: | |
| try: | |
| print(f"[{video_id}] Deleting temporary cloned voice {voice_id}...") | |
| delete_url = f"https://api.elevenlabs.io/v1/voices/{voice_id}" | |
| del_res = requests.delete(delete_url, headers=elevenlabs_headers) | |
| if del_res.status_code == 200: | |
| print(f"[{video_id}] Temporary cloned voice deleted successfully.") | |
| else: | |
| print(f"[{video_id}] Failed to delete voice: {del_res.text}") | |
| except Exception as del_err: | |
| print(f"[{video_id}] Exception deleting voice: {del_err}") | |
| # 5. Merge synthesized audio back with the original video (or run Lip-Sync if selected) | |
| # NOTE: lipsync_output_path is already defined above using /tmp absolute path — do NOT redefine here. | |
| if job.has_lip_sync: | |
| print(f"[{video_id}] Setting up Wav2Lip model checkpoint files...") | |
| setup_success = setup_wav2lip() | |
| if not setup_success: | |
| raise Exception("Wav2Lip model files setup failed. Please check server logs.") | |
| print(f"[{video_id}] Running Wav2Lip alignment (Lip-Sync)...") | |
| cmd_lipsync = [ | |
| "python", "inference.py", | |
| "--checkpoint_path", "checkpoints/wav2lip_gan.pth", | |
| "--face", input_video_path, | |
| "--audio", synthesized_audio_path, | |
| "--outfile", lipsync_output_path | |
| ] | |
| # Execute in the Wav2Lip directory to resolve relative imports | |
| result = subprocess.run(cmd_lipsync, cwd="Wav2Lip", capture_output=True, text=True) | |
| # Always log stdout/stderr for debugging | |
| if result.stdout: | |
| print(f"[{video_id}] Wav2Lip stdout: {result.stdout[-2000:]}") | |
| if result.stderr: | |
| print(f"[{video_id}] Wav2Lip stderr: {result.stderr[-2000:]}") | |
| if result.returncode != 0: | |
| raise Exception(f"Wav2Lip inference failed with exit code {result.returncode}: {result.stderr[-500:]}") | |
| # Verify Wav2Lip actually wrote the output file (it sometimes exits 0 without writing) | |
| if not os.path.exists(lipsync_output_path) or os.path.getsize(lipsync_output_path) == 0: | |
| # Search in Wav2Lip directory in case --outfile was treated as relative | |
| wav2lip_relative_output = os.path.join("Wav2Lip", f"temp_{video_id}_lipsync.mp4") | |
| results_default = os.path.join("Wav2Lip", "results", "result_voice.mp4") | |
| if os.path.exists(wav2lip_relative_output) and os.path.getsize(wav2lip_relative_output) > 0: | |
| print(f"[{video_id}] WARNING: Wav2Lip wrote to relative path. Moving to expected location.") | |
| shutil.move(wav2lip_relative_output, lipsync_output_path) | |
| elif os.path.exists(results_default) and os.path.getsize(results_default) > 0: | |
| print(f"[{video_id}] WARNING: Wav2Lip wrote to default results path. Copying to expected location.") | |
| shutil.copy2(results_default, lipsync_output_path) | |
| else: | |
| raise Exception(f"Wav2Lip completed (exit 0) but output file not found at: {lipsync_output_path}") | |
| print(f"[{video_id}] Wav2Lip alignment completed. Output size: {os.path.getsize(lipsync_output_path)} bytes") | |
| video_src_for_subtitles = lipsync_output_path | |
| else: | |
| video_src_for_subtitles = input_video_path | |
| if job.has_captions: | |
| print(f"[{video_id}] Burning subtitles with style '{job.caption_style}' using FFmpeg...") | |
| # Guard: verify SRT file was actually written before trying to burn it | |
| if not os.path.exists(srt_file_path) or os.path.getsize(srt_file_path) == 0: | |
| raise Exception(f"SRT subtitle file is missing or empty: {srt_file_path}") | |
| style_str = get_ffmpeg_style( | |
| job.caption_style, | |
| custom_font=job.custom_font, | |
| custom_size=job.custom_size, | |
| custom_color=job.custom_color | |
| ) | |
| # FFmpeg subtitles filter: path must use forward slashes. | |
| # On Windows, the drive-letter colon (C:) must be escaped as C\: | |
| # On Linux (HuggingFace Space), paths start with / so no escaping needed. | |
| srt_filter_path = srt_file_path.replace("\\", "/") | |
| if len(srt_filter_path) >= 2 and srt_filter_path[1] == ":": | |
| # Windows drive letter colon escape | |
| srt_filter_path = srt_filter_path[0] + "\\:" + srt_filter_path[2:] | |
| subtitles_filter = f"subtitles={srt_filter_path}:force_style='{style_str}'" | |
| print(f"[{video_id}] FFmpeg subtitle filter: {subtitles_filter}") | |
| if job.has_lip_sync: | |
| # Lip-synced video already has the synthesized audio merged inside it. | |
| # So we map audio track from the same file (0:a) | |
| cmd_merge = [ | |
| "ffmpeg", "-y", "-i", video_src_for_subtitles, | |
| "-vf", subtitles_filter, | |
| "-map", "0:v", "-map", "0:a", | |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", | |
| output_video_path | |
| ] | |
| else: | |
| # Map audio from input 1 (synthesized audio path) | |
| cmd_merge = [ | |
| "ffmpeg", "-y", "-i", video_src_for_subtitles, "-i", synthesized_audio_path, | |
| "-vf", subtitles_filter, | |
| "-map", "0:v", "-map", "1:a", | |
| "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-shortest", | |
| output_video_path | |
| ] | |
| run_ffmpeg_command(cmd_merge, "Subtitle Burning & Video Merge") | |
| else: | |
| if job.has_lip_sync: | |
| print(f"[{video_id}] Copying lip-synced video to final output path...") | |
| shutil.copy2(lipsync_output_path, output_video_path) | |
| else: | |
| print(f"[{video_id}] Merging audio track into original video using FFmpeg...") | |
| cmd_merge = [ | |
| "ffmpeg", "-y", "-i", video_src_for_subtitles, "-i", synthesized_audio_path, | |
| "-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac", "-shortest", | |
| output_video_path | |
| ] | |
| run_ffmpeg_command(cmd_merge, "Audio-Video Merge") | |
| # Normalize Supabase base URL (remove trailing slash and rest/v1 path if present) | |
| base_url = supabase_url.rstrip("/") | |
| if base_url.endswith("/rest/v1"): | |
| base_url = base_url[:-8].rstrip("/") | |
| # 6. Upload output video to Supabase Storage | |
| print(f"[{video_id}] Uploading output video to Supabase Storage...") | |
| storage_upload_url = f"{base_url}/storage/v1/object/videos/{video_id}_translated.mp4" | |
| with open(output_video_path, "rb") as out_file: | |
| upload_headers = { | |
| "apikey": supabase_anon_key, | |
| "Authorization": f"Bearer {user_token}", | |
| "Content-Type": "video/mp4" | |
| } | |
| upload_res = requests.post(storage_upload_url, headers=upload_headers, data=out_file) | |
| # If exists, we can try to PUT (overwrite) | |
| if upload_res.status_code == 400 and "AlreadyExists" in upload_res.text: | |
| upload_res = requests.put(storage_upload_url, headers=upload_headers, data=out_file) | |
| upload_res.raise_for_status() | |
| # Get public url | |
| public_video_url = f"{base_url}/storage/v1/object/public/videos/{video_id}_translated.mp4" | |
| print(f"[{video_id}] Video successfully uploaded. Public URL: {public_video_url}") | |
| # 7. Update database to completed | |
| update_db_status(video_id, user_token, "completed", translated_url=public_video_url, supabase_url=supabase_url, supabase_anon_key=supabase_anon_key) | |
| except Exception as e: | |
| print(f"[{video_id}] Production Pipeline Error: {e}") | |
| update_db_status(video_id, user_token, "failed", error_message=f"Hata: {str(e)}", supabase_url=supabase_url, supabase_anon_key=supabase_anon_key) | |
| finally: | |
| # Cleanup temporary files | |
| for temp_file in [downloaded_video_path, input_video_path, extracted_audio_path, synthesized_audio_path, srt_file_path, output_video_path, lipsync_output_path]: | |
| if os.path.exists(temp_file): | |
| try: | |
| os.remove(temp_file) | |
| except Exception: | |
| pass | |
| def process_video(job: TranslationJob, background_tasks: BackgroundTasks): | |
| background_tasks.add_task(process_video_task, job) | |
| return {"status": "queued", "message": "Video translation job queued successfully.", "job_id": job.video_id} | |
| if __name__ == "__main__": | |
| import uvicorn | |
| # Make sure we load local variables before starting | |
| load_env_local() | |
| uvicorn.run(app, host="0.0.0.0", port=7860) | |