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}") @app.get("/") 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 } @app.get("/health") 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 @app.post("/process") 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)