User5Dara / app.py
Yout666t's picture
Update app.py
dab8c93 verified
Raw
History Blame Contribute Delete
13.4 kB
import os
import re
import json
import base64
import asyncio
import requests
import gradio as gr
from moviepy import VideoFileClip
from pydub import AudioSegment
import edge_tts
custom_css = """
body { background-color: #0b0f19 !important; color: #e2e8f0 !important; font-family: 'Inter', sans-serif !important; }
.gradio-container { width: 100% !important; max-width: 1200px !important; margin: 0 auto !important; background: transparent !important; padding: 20px !important; box-sizing: border-box !important; }
.premium-card { background: rgba(17, 24, 39, 0.7) !important; backdrop-filter: blur(16px) !important; border: 1px solid rgba(255, 255, 255, 0.08) !important; border-radius: 20px !important; padding: 25px !important; box-shadow: 0 20px 40px -15px rgba(0, 0, 0, 0.7) !important; margin-bottom: 20px !important; }
.premium-btn { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%) !important; color: white !important; border: none !important; border-radius: 14px !important; font-weight: 700 !important; font-size: 16px !important; height: 55px !important; cursor: pointer !important; box-shadow: 0 4px 20px 0 rgba(79, 70, 229, 0.4) !important; width: 100% !important; transition: all 0.3s ease !important; }
.premium-btn:hover { transform: translateY(-3px) !important; box-shadow: 0 8px 25px 0 rgba(79, 70, 229, 0.6) !important; }
.premium-btn:disabled { background: #374151 !important; color: #9ca3af !important; box-shadow: none !important; cursor: not-allowed !important; transform: none !important; }
@media (max-width: 768px) {
.premium-card { padding: 15px !important; }
}
"""
OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "")
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
def clean_khmer_text(text):
""" αžŸαž˜αŸ’αž’αžΆαžαž’αž€αŸ’αžŸαžšαž–αž·αžŸαŸαžŸαŸ—αžŠαžΎαž˜αŸ’αž”αžΈαž±αŸ’αž™ Edge-TTS αž’αžΆαž“αž”αžΆαž“αžšαž›αžΌαž“ αž“αž·αž„αž˜αž·αž“αž‚αžΆαŸ†αž„ """
if not text:
return ""
# αž›αž»αž”αžŸαž‰αŸ’αž‰αžΆαžŠαŸ‚αž›αž’αžΆαž…αž’αŸ’αžœαžΎαž±αŸ’αž™ TTS αž‚αžΆαŸ†αž„ αž¬αž’αžΆαž“αžαž»αžŸ
text = re.sub(r'[~@#$^*()_+={}\[\]|\\<>`~]', '', text)
return text.strip()
def call_gemini_vision_api(optimized_video_path, total_duration):
print("[INFO] STEP 3: Sending video to Gemini 2.5 Flash with strict JSON Schema...")
try:
with open(optimized_video_path, "rb") as f:
video_base64 = base64.b64encode(f.read()).decode("utf-8")
except Exception as e:
raise gr.Error(f"αž˜αž·αž“αž’αžΆαž…αž’αžΆαž“αž―αž€αžŸαžΆαžšαžœαžΈαžŠαŸαž’αžΌαž”αžΆαž“αž‘αŸ: {str(e)}")
gemini_prompt = f"""
You are an elite film dubbing director. Analyze the video and audio tracks of this short movie.
The total timeline of the movie is exactly {total_duration:.2f} seconds.
STRICT OPERATIONAL DIRECTIVES:
1. AUDIO-FIRST GENDER CLASSIFICATION: Listen carefully to the voice pitch and tone of each spoken line. Classify the gender strictly as "Male" or "Female".
2. TIMELINE ACCURACY: Capture the exact start and end timestamps for every single dialogue dialogue utterance.
3. HIGH-QUALITY TRANSLATION: Translate the lines beautifully, naturally, and contextually into formal Khmer ("khmer_translation"). Do not literal-translate idioms.
You must output a JSON object adhering exactly to this structure:
{{
"total_duration": {total_duration:.2f},
"segments": [
{{
"start": 1.2,
"end": 3.5,
"speaker_gender": "Male",
"khmer_translation": "αžŸαž½αžŸαŸ’αžŠαžΈ αžαžΎαž’αŸ’αž“αž€αžŸαž»αžαžŸαž”αŸ’αž”αžΆαž™αž‘αŸ?"
}}
]
}}
"""
# αž”αŸ’αžšαžΎαž”αŸ’αžšαžΆαžŸαŸ‹ Structured Outputs αžšαž”αžŸαŸ‹ Gemini αžαžΆαž˜αžšαž™αŸˆ OpenRouter αžŠαžΎαž˜αŸ’αž”αžΈαž€αžΆαžšαž–αžΆαžšαž€αŸ†αž αž»αžŸαž‘αž˜αŸ’αžšαž„αŸ‹ JSON
payload = {
"model": "google/gemini-2.5-flash",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": gemini_prompt},
{"type": "image_url", "image_url": {"url": f"data:video/mp4;base64,{video_base64}"}}
]
}
],
"response_format": {
"type": "json_object",
"schema": {
"type": "object",
"properties": {
"total_duration": {"type": "number"},
"segments": {
"type": "array",
"items": {
"type": "object",
"properties": {
"start": {"type": "number"},
"end": {"type": "number"},
"speaker_gender": {"type": "string", "enum": ["Male", "Female"]},
"khmer_translation": {"type": "string"}
},
"required": ["start", "end", "speaker_gender", "khmer_translation"]
}
}
},
"required": ["total_duration", "segments"]
}
}
}
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json"
}
response = requests.post(OPENROUTER_URL, json=payload, headers=headers)
if response.status_code != 200:
raise gr.Error(f"OpenRouter API Error (Status {response.status_code}): {response.text}")
try:
raw_content = response.json()['choices'][0]['message']['content']
json_clean = re.sub(r'^```json\s*|```$', '', raw_content.strip(), flags=re.IGNORECASE)
return json.loads(json_clean)
except Exception as e:
raise gr.Error("αž›αž‘αŸ’αž’αž•αž›αž–αžΈ Gemini αž˜αž·αž“αžŸαŸ’αžαž·αžαž€αŸ’αž“αž»αž„αž‘αž˜αŸ’αžšαž„αŸ‹ JSON αžŠαŸ‚αž›αžαŸ’αžšαžΉαž˜αžαŸ’αžšαžΌαžœαž‘αŸ! αžŸαžΌαž˜αž–αŸ’αž™αžΆαž™αžΆαž˜αž˜αŸ’αžŠαž„αž‘αŸ€αžαŸ”")
async def generate_khmer_audio_segment(text, gender_type, target_duration, output_filename):
clean_text = clean_khmer_text(text)
if not clean_text:
return None
clean_gender = str(gender_type).strip().lower()
voice = "km-KH-SreymomNeural" if clean_gender == "female" else "km-KH-PisethNeural"
temp_mp3 = f"temp_{output_filename}.mp3"
temp_wav = f"temp_raw_{output_filename}.wav"
try:
communicate = edge_tts.Communicate(clean_text, voice)
await communicate.save(temp_mp3)
except Exception as e:
print(f"[ERROR] Edge-TTS failed for text: {clean_text}. Error: {e}")
return None
if not os.path.exists(temp_mp3):
return None
# αž”αŸ†αž”αŸ’αž›αŸ‚αž„αž‘αŸ…αž‡αžΆ WAV αž‚αž»αžŽαž—αžΆαž–αžαŸ’αž–αžŸαŸ‹ 24kHz
os.system(f'ffmpeg -y -i "{temp_mp3}" -ar 24000 "{temp_wav}" > /dev/null 2>&1')
if os.path.exists(temp_mp3):
os.remove(temp_mp3)
if not os.path.exists(temp_wav):
return None
audio_segment = AudioSegment.from_wav(temp_wav)
current_duration = len(audio_segment) / 1000.0
# αž–αž·αž“αž·αžαŸ’αž™ αž“αž·αž„αž€αŸ‚αžŸαž˜αŸ’αžšαž½αž›αž›αŸ’αž”αžΏαž“αž“αž·αž™αžΆαž™ (Audio Speed Matching) αž±αŸ’αž™αžαŸ’αžšαžΌαžœαž“αžΉαž„ Timeline ដើម ៑០០%
if current_duration > target_duration and target_duration > 0:
speed_factor = current_duration / target_duration
# αž€αž˜αŸ’αžšαž·αžαž›αŸ’αž”αžΏαž“αž’αžαž·αž”αžšαž˜αžΆ 1.5x αžŠαžΎαž˜αŸ’αž”αžΈαž€αž»αŸ†αž±αŸ’αž™αžŸαŸ†αž‘αŸαž„αž”αŸ’αžšαž‰αžΆαž”αŸ‹αž–αŸαž€αžŸαŸ’αžαžΆαž”αŸ‹αž˜αž·αž“αž™αž›αŸ‹
if speed_factor > 1.5:
speed_factor = 1.5
print(f"[DEBUG] Speeding up audio: {current_duration:.2f}s -> {target_duration:.2f}s ({speed_factor:.2f}x)")
os.system(f'ffmpeg -y -i "{temp_wav}" -filter:a "atempo={speed_factor}" -ar 24000 "{output_filename}" > /dev/null 2>&1')
else:
os.system(f'ffmpeg -y -i "{temp_wav}" -ar 24000 "{output_filename}" > /dev/null 2>&1')
if os.path.exists(temp_wav):
os.remove(temp_wav)
return output_filename
async def run_vox_crown_pipeline(video_input):
if not OPENROUTER_API_KEY:
raise gr.Error("αžŸαžΌαž˜αžšαŸ€αž”αž…αŸ†αžŠαžΆαž€αŸ‹ OPENROUTER_API_KEY αž€αŸ’αž“αž»αž„ Settings Secrets!")
if video_input is None:
raise gr.Error("αžŸαžΌαž˜αž”αž‰αŸ’αž…αžΌαž›αžœαžΈαžŠαŸαž’αžΌαžšαž”αžŸαŸ‹αž’αŸ’αž“αž€αž‡αžΆαž˜αž»αž“αžŸαž·αž“!")
video_clip = VideoFileClip(video_input)
duration = video_clip.duration
video_clip.close()
if duration > 60.10:
raise gr.Error("αžœαžΈαžŠαŸαž’αžΌαžœαŸ‚αž„αž αž½αžŸαž€αŸ†αžŽαžαŸ‹ 60.10 αžœαž·αž“αžΆαž‘αžΈ!")
optimized_video_path = "optimized_480p.mp4"
muted_video_path = "muted_original_video.mp4"
# αž”αž„αŸ’αž€αžΎαžαžœαžΈαžŠαŸαž’αžΌαž‚αŸ’αž˜αžΆαž“αžŸαŸ†αž‘αŸαž„ (Pure Muted Video) αž“αž·αž„αžœαžΈαžŠαŸαž’αžΌαž‚αŸ†αžšαžΌαž•αŸ’αž‰αžΎαž‘αŸ… Gemini
os.system(f'ffmpeg -y -i "{video_input}" -vf "scale=-2:480" -vcodec libx264 -crf 30 -preset fast -acodec aac -b:a 64k "{optimized_video_path}" > /dev/null 2>&1')
os.system(f'ffmpeg -y -i "{video_input}" -an -vcodec copy "{muted_video_path}" > /dev/null 2>&1')
gemini_result = call_gemini_vision_api(optimized_video_path, duration)
if os.path.exists(optimized_video_path):
os.remove(optimized_video_path)
segments = gemini_result.get("segments", [])
# αž”αž„αŸ’αž€αžΎαž Audio Track αž‘αž‘αŸαžŸαŸ’αž’αžΆαž (Silent) αžŠαžΎαž˜αŸ’αž”αžΈαž€αž»αŸ†αž±αŸ’αž™αž˜αžΆαž“αžŸαŸ†αž‘αŸαž„αžšαŸ†αžαžΆαž“ ឬ Background Music αž–αžΈαžœαžΈαžŠαŸαž’αžΌαž…αžΆαžŸαŸ‹
full_voice_track = AudioSegment.silent(duration=int(duration * 1000), frame_rate=24000)
for index, seg in enumerate(segments):
start_time = float(seg.get("start", 0.0))
end_time = float(seg.get("end", 0.0))
gender = seg.get("speaker_gender", "Male")
text_kh = seg.get("khmer_translation", "")
target_dur = end_time - start_time
if target_dur <= 0 or not text_kh.strip():
continue
seg_filename = f"segment_{index}.wav"
await generate_khmer_audio_segment(text_kh, gender, target_dur, seg_filename)
if os.path.exists(seg_filename):
segment_audio = AudioSegment.from_wav(seg_filename)
# Overlay αž…αžΌαž›αž‘αŸ…αž€αŸ’αž“αž»αž„ Timeline αž…αŸ’αž”αžΆαžŸαŸ‹αž›αžΆαžŸαŸ‹
full_voice_track = full_voice_track.overlay(segment_audio, position=int(start_time * 1000))
os.remove(seg_filename)
final_voice_track_path = "complete_khmer_voice_track.wav"
full_voice_track.export(final_voice_track_path, format="wav")
final_output_movie = "vox_crown_pure_dubbed.mp4"
# αž”αž‰αŸ’αž…αžΌαž›αžœαžΈαžŠαŸαž’αžΌ Zin (αž‚αŸ’αž˜αžΆαž“αžŸαŸ†αž‘αŸαž„αž…αžΆαžŸαŸ‹) αž‡αžΆαž˜αž½αž™ Track αžŸαŸ†αž‘αŸαž„αžαŸ’αž˜αŸ‚αžšαžαŸ’αž˜αžΈαžŸαž»αž‘αŸ’αž’αžŸαžΆαž’ ៑០០%
ffmpeg_merge_cmd = f'ffmpeg -y -i "{muted_video_path}" -i "{final_voice_track_path}" -c:v copy -c:a aac -b:a 192k "{final_output_movie}" > /dev/null 2>&1'
os.system(ffmpeg_merge_cmd)
if os.path.exists(muted_video_path):
os.remove(muted_video_path)
if os.path.exists(final_voice_track_path):
os.remove(final_voice_track_path)
formatted_json_result = json.dumps(gemini_result, ensure_ascii=False, indent=2)
status_msg = "πŸŽ‰ [SUCCESS] αž”αŸ’αžšαž–αŸαž“αŸ’αž’ VOX CROWN AI αž”αžΆαž“αžŠαŸ†αžŽαžΎαžšαž€αžΆαžš Render αžœαžΈαžŠαŸαž’αžΌαž”αŸ†αž”αŸ’αž›αŸ‚αž„αžŸαŸ†αž‘αŸαž„αžαŸ’αž˜αŸ‚αžšαž”αžΆαž“αžαŸ’αžšαžΉαž˜αžαŸ’αžšαžΌαžœ ៑០០%!"
return status_msg, final_output_movie, formatted_json_result
with gr.Blocks(css=custom_css, theme=gr.themes.Default()) as demo:
gr.HTML("""
<div style="text-align: center; padding: 30px 0; margin-bottom: 25px; border-bottom: 1px solid rgba(255,255,255,0.08);">
<h1 style="font-size: 40px; font-weight: 900; background: linear-gradient(to right, #4f46e5, #9333ea, #ec4899); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin: 0; letter-spacing: 2px;">VOX CROWN</h1>
</div>
""")
with gr.Row():
with gr.Column(scale=5, elem_classes=["premium-card"]):
gr.HTML("<span style='font-size: 18px; font-weight: 700; color: #f1f5f9; display:block; margin-bottom:10px;'>Media Upload</span>")
video_in = gr.Video(label="Source Movie File (Max 60.10s)", container=False)
btn = gr.Button("GENERATE GEMINI DUBBING", elem_classes=["premium-btn"])
with gr.Column(scale=7, elem_classes=["premium-card"]):
gr.HTML("<span style='font-size: 18px; font-weight: 700; color: #f1f5f9; display:block; margin-bottom:10px;'>Output Studio Player</span>")
video_out = gr.Video(label="Final Dubbed Movie Result", container=False)
json_out = gr.Textbox(visible=False)
status_out = gr.Textbox(visible=False)
btn.click(
fn=lambda: gr.update(interactive=False, value="PROCESSING DUBBING... PLEASE WAIT"),
inputs=None, outputs=[btn]
).then(
fn=run_vox_crown_pipeline, inputs=[video_in], outputs=[status_out, video_out, json_out]
).then(
fn=lambda: gr.update(interactive=True, value="GENERATE GEMINI DUBBING"),
inputs=None, outputs=[btn]
)
if __name__ == "__main__":
demo.launch()