voiceclips-sync / app.py
VoiceClips's picture
Update app.py
c639940 verified
Raw
History Blame Contribute Delete
34.6 kB
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)