import subprocess, os, asyncio, edge_tts, uuid, random, re, glob, shutil, threading, time from flask import Flask, request, jsonify, send_file, render_template_string, Response from openai import OpenAI import whisper app = Flask(__name__) app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024 whisper_model = whisper.load_model("tiny", device="cpu") # ─── Server-side video cache ────────────────────────────────────────────── VIDEO_CACHE = {} # { cache_id: {"path": str, "ts": float} } OUTPUT_CACHE = {} # { job_id: {"path": str, "ts": float} } CACHE_TTL = 3600 def _store_video(path): vid = uuid.uuid4().hex[:10] VIDEO_CACHE[vid] = {"path": path, "ts": time.time()} _evict_cache() return vid def _get_video(cache_id): entry = VIDEO_CACHE.get(cache_id) if entry and os.path.exists(entry["path"]): entry["ts"] = time.time() return entry["path"] return None def _evict_cache(): now = time.time() for d in [VIDEO_CACHE, OUTPUT_CACHE]: stale = [k for k,v in d.items() if now - v["ts"] > CACHE_TTL] for k in stale: try: p = d[k]["path"] if os.path.exists(p): os.remove(p) except: pass d.pop(k, None) GEMINI_API_KEYS = [ os.getenv("GEMINI_API_KEY_1"), os.getenv("GEMINI_API_KEY_2"), os.getenv("GEMINI_API_KEY_3"), os.getenv("GEMINI_API_KEY_4"), os.getenv("GEMINI_API_KEY_5") ] DEEPSEEK_API_KEYS = [os.getenv("DEEPSEEK_API_KEY")] VOICE_MAP = { "သိဟ် (ကျား)": "my-MM-ThihaNeural", "နီလာ (မိန်း)": "my-MM-NilarNeural", "အန်ဒရူး (ကျား)": "en-US-AndrewMultilingualNeural", "ဝီလျံ (ကျား)": "en-US-WilliamMultilingualNeural", "အာဗာ (မိန်း)": "en-US-AvaMultilingualNeural", "ဘရိုင်ယန် (ကျား)":"en-US-BrianMultilingualNeural", "အယ်မာ (မိန်း)": "en-US-EmmaMultilingualNeural", "ဗီဗီယန် (မိန်း)": "fr-FR-VivienneMultilingualNeural", "စီရာဖီနာ (မိန်း)":"de-DE-SeraphinaMultilingualNeural", "သာလီတာ (မိန်း)": "pt-BR-ThalitaMultilingualNeural", } # ─── Helpers ──────────────────────────────────────────────────────────────── def call_api_with_fallback(messages, max_tokens=8192, task_name="API Call", api_choice="Gemini"): if api_choice == "DeepSeek": api_keys_list = DEEPSEEK_API_KEYS base_url = "https://api.deepseek.com" model_name = "deepseek-chat" api_name = "DeepSeek" else: api_keys_list = GEMINI_API_KEYS base_url = "https://generativelanguage.googleapis.com/v1beta/openai/" model_name = "gemini-2.5-flash-lite" api_name = "Gemini" valid_keys = [(i+1, k) for i, k in enumerate(api_keys_list) if k] if not valid_keys: raise Exception(f"No valid {api_name} API keys found") random.shuffle(valid_keys) last_error = None for key_num, api_key in valid_keys: try: client = OpenAI(api_key=api_key, base_url=base_url, timeout=600.0) response = client.chat.completions.create(model=model_name, messages=messages, max_tokens=max_tokens) if not response or not response.choices or not response.choices[0].message or not response.choices[0].message.content: last_error = f"{api_name} Key {key_num}: Empty response"; continue content = response.choices[0].message.content.strip() return content, f"✅ {task_name}: {api_name} Key {key_num}" except Exception as e: err = str(e) last_error = f"{api_name} Key {key_num}: Rate limit" if ("429" in err or "rate_limit" in err.lower()) else f"{api_name} Key {key_num}: {err[:60]}" continue raise Exception(f"❌ All {api_name} keys failed. Last: {last_error}") def cleanup_old_files(): for pattern in ["final_*.mp4", "temp_voice_*.mp3", "temp_*", "list.txt", "preview_*.mp3"]: for f in glob.glob(pattern): try: shutil.rmtree(f) if os.path.isdir(f) else os.remove(f) except: pass def get_duration(file_path): try: cmd = f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "{file_path}"' r = subprocess.run(cmd, shell=True, capture_output=True, text=True) return float(r.stdout.strip()) except: return 0 def smart_text_processor(full_text): clean = re.sub(r'\(.*?\d+.*?\)|#+.*?\d+.*?-.*?\d+|\d+[:\.]\d+[:\.]\d+|\d+[:\.]\d+|#+.*?\d+[:\.]\d+', '', full_text) clean = re.sub(r'[a-zA-Z]|\(|\)|\[|\]|\.|\.\.\.|\-|\!|\#', '', clean) sentences = [s.strip() for s in re.split(r'[။]', clean) if s.strip()] paragraphs = [] for i in range(0, len(sentences), 3): chunk = sentences[i:i+3] paragraphs.append(" ".join(chunk) + "။") return paragraphs def get_system_prompt(content_type): if content_type == "Medical/Health": return """သင်သည် မြန်မာဘာသာပြန်သူဖြစ်သည်။ အောက်ပါ transcript ကို spoken Myanmar (နေ့စဉ်ပြောဆိုမှုဘာသာ) သို့ဘာသာပြန်ပါ။ ## အရေးကြီးဆုံးစည်းမျဉ်း - မူလ transcript တွင်ပါသောအကြောင်းအရာသာ ဘာသာပြန်ရမည် - English စကားလုံး တစ်လုံးမျှ မပါရ — 100% မြန်မာဘာသာသာ သုံးရမည် - ကျောင်းသုံးစာပေ (သည်/၏/၍/သော/သည့်) — လုံးဝမသုံးရ ## Spoken Myanmar - ကြိယာ → တယ်/ပါတယ်/ရတယ်/ပြီ | မေးခွန်း → လား/မလား/လဲ - ကိုယ်ရည် → ကျမ၊ ကျနော် | ပုဒ်မ → ။ ## Medical Terms diabetes→ဆီးချိုရောဂါ|heart disease→နှလုံးရောဂါ|high blood pressure→သွေးတိုးရောဂါ|cancer→ကင်ဆာ|symptom→လက္ခဏာ|treatment→ကုသမှု|doctor→ဆရာဝန်|medicine→ဆေး|hospital→ဆေးရုံ|patient→လူနာ ## Output Format [SCRIPT] (ဘာသာပြန်ထားသော script) [TITLE] (ခေါင်းစဉ် မြန်မာ ၁၀ လုံးအတွင်း) [HASHTAGS] #ကျန်းမာရေး #ဆေး #မြန်မာ""" else: return """ STRICT TRANSLATION RULES: 1. Translate EXACTLY what is said - NO additions, NO drama 2. ZERO English words - 100% Myanmar only === OUTPUT FORMAT === [SCRIPT] (Exact translation) [TITLE] (Short catchy title max 10 words) [HASHTAGS] #movierecap #မြန်မာ #viral #ဇာတ်လမ်း === RULES === ✓ USE: တယ်/လား/ရတယ် - NEVER သည်/၏/၍ ✓ Substitutions: ကျွန်မ→ကျမ, အစ်ကို→အကို ✓ CEO→သူဌေး,car→ကား,school→ကျောင်း,office→ရုံး,phone→ဖုန်း,money→ပိုက်ဆံ,police→ရဲ,house→အိမ် CRITICAL: Word-for-word only. 100% Myanmar.""" def parse_ai_response(combined_response): final_script = "" viral_layout = "" if '[SCRIPT]' in combined_response and '[TITLE]' in combined_response: m = re.search(r'\[SCRIPT\](.*?)\[TITLE\]', combined_response, re.DOTALL) if m: final_script = m.group(1).strip() if '[HASHTAGS]' in combined_response: tm = re.search(r'\[TITLE\](.*?)\[HASHTAGS\]', combined_response, re.DOTALL) hm = re.search(r'\[HASHTAGS\](.*?)$', combined_response, re.DOTALL) if tm and hm: viral_layout = f"{tm.group(1).strip()}\n\n{hm.group(1).strip()}" else: tm = re.search(r'\[TITLE\](.*?)$', combined_response, re.DOTALL) if tm: viral_layout = tm.group(1).strip() else: parts = combined_response.split('[TITLE]') final_script = parts[0].replace('[SCRIPT]','').strip() if len(parts) > 1: viral_layout = parts[1].replace('[HASHTAGS]','').strip() final_script = re.sub(r'\[SCRIPT\]|\[TITLE\]|\[HASHTAGS\]','',final_script).strip() viral_layout = re.sub(r'\[SCRIPT\]|\[TITLE\]|\[HASHTAGS\]','',viral_layout).strip() return final_script, viral_layout # ─── Flask Routes ──────────────────────────────────────────────────────────── @app.route("/") def index(): return render_template_string(HTML_PAGE) @app.route("/api/preview_voice", methods=["POST"]) def api_preview_voice(): data = request.json voice_label = data.get("voice", "သိဟ် — ကျား (မြန်မာ)") speed = int(data.get("speed", 15)) voice = VOICE_MAP.get(voice_label, "my-MM-ThihaNeural") f_speed = f"+{speed}%" path = f"preview_{uuid.uuid4().hex[:6]}.mp3" async def _gen(): await edge_tts.Communicate("မင်္ဂလာပါ။", voice, rate=f_speed).save(path) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(_gen()) loop.close() return send_file(path, mimetype="audio/mpeg") @app.route("/api/upload_video", methods=["POST"]) def api_upload_video(): """Upload video once, cache on server, return cache_id.""" try: if "video" not in request.files: return jsonify({"error": "No video file in request"}), 400 video_file = request.files["video"] if not video_file or video_file.filename == "": return jsonify({"error": "Empty file"}), 400 task_id = uuid.uuid4().hex[:10] # Save with original extension if possible orig = video_file.filename or "video.mp4" ext = os.path.splitext(orig)[1] or ".mp4" video_path = f"cached_vid_{task_id}{ext}" video_file.save(video_path) if not os.path.exists(video_path) or os.path.getsize(video_path) == 0: return jsonify({"error": "File save failed or empty"}), 500 cache_id = _store_video(video_path) size_mb = round(os.path.getsize(video_path)/1024/1024, 1) return jsonify({"cache_id": cache_id, "size_mb": size_mb, "filename": orig}) except Exception as e: return jsonify({"error": f"Upload error: {str(e)}"}), 500 @app.route("/api/generate_script", methods=["POST"]) def api_generate_script(): cache_id = request.form.get("cache_id", "") api_choice = request.form.get("api_choice", "Gemini") content_type = request.form.get("content_type", "Movie Recap") video_path = _get_video(cache_id) if cache_id else None # fallback: accept inline upload too if not video_path and "video" in request.files: task_id = uuid.uuid4().hex[:8] video_path = f"cached_vid_{task_id}.mp4" request.files["video"].save(video_path) cache_id = _store_video(video_path) video_path = _get_video(cache_id) if not video_path: return jsonify({"error": "Video မတွေ့ပါ — ဦးစွာ upload လုပ်ပါ"}), 400 try: result = whisper_model.transcribe(video_path, fp16=False, language=None) transcript = result["text"] detected_lang = result.get("language", "unknown") system_prompt = get_system_prompt(content_type) combined, status = call_api_with_fallback( messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": f"Original Language: {detected_lang}\n\nContent: {transcript}"} ], max_tokens=8192, task_name="Script+Title", api_choice=api_choice ) final_script, viral_layout = parse_ai_response(combined) return jsonify({"script": final_script, "title": viral_layout, "status": f"{status} (Lang: {detected_lang})", "cache_id": cache_id}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/stream/") def api_stream(job_id): """Range-capable video streaming.""" entry = OUTPUT_CACHE.get(job_id) if not entry or not os.path.exists(entry["path"]): return "Not found", 404 fpath = entry["path"] fsize = os.path.getsize(fpath) range_header = request.headers.get("Range", None) if range_header: try: byte_range = range_header.replace("bytes=", "").strip() parts = byte_range.split("-") start = int(parts[0]) if parts[0] else 0 end = int(parts[1]) if len(parts) > 1 and parts[1] else fsize - 1 end = min(end, fsize - 1) length = end - start + 1 except: start, end, length = 0, fsize - 1, fsize def generate_range(): with open(fpath, "rb") as f: f.seek(start) remaining = length while remaining > 0: chunk = f.read(min(65536, remaining)) if not chunk: break remaining -= len(chunk) yield chunk headers = { "Content-Range": f"bytes {start}-{end}/{fsize}", "Accept-Ranges": "bytes", "Content-Length": str(length), "Content-Type": "video/mp4", "Content-Disposition": "inline; filename=recap_output.mp4", } return Response(generate_range(), 206, headers=headers) else: # Full file def generate_full(): with open(fpath, "rb") as f: while True: chunk = f.read(65536) if not chunk: break yield chunk headers = { "Accept-Ranges": "bytes", "Content-Length": str(fsize), "Content-Type": "video/mp4", "Content-Disposition": "inline; filename=recap_output.mp4", } return Response(generate_full(), 200, headers=headers) @app.route("/api/download/") def api_download(job_id): """Direct download (as attachment).""" entry = OUTPUT_CACHE.get(job_id) if not entry or not os.path.exists(entry["path"]): return "Not found", 404 return send_file(entry["path"], mimetype="video/mp4", as_attachment=True, download_name="recap_output.mp4") @app.route("/api/produce", methods=["POST"]) def api_produce(): data = request.form cache_id = data.get("cache_id", "") final_script = data.get("script", "") voice_label = data.get("voice", "သိဟ် (ကျား)") v_speed = int(data.get("speed", 30)) channel_name = data.get("watermark", "MM RECAP") flip = data.get("flip","false") == "true" color_bp = data.get("color","false") == "true" tiktok_ratio = data.get("tiktok","false") == "true" blur_enabled = data.get("blur","false") == "true" blur_y_pct = float(data.get("blur_y", 75)) blur_h_pct = float(data.get("blur_h", 12)) bgm_file = request.files.get("bgm") if not final_script: return jsonify({"error": "Script မရှိပါ"}), 400 # Use cached video — no re-upload needed video_path = _get_video(cache_id) if cache_id else None if not video_path and "video" in request.files: task_id = uuid.uuid4().hex[:8] video_path = f"cached_vid_{task_id}.mp4" request.files["video"].save(video_path) cache_id = _store_video(video_path) video_path = _get_video(cache_id) if not video_path: return jsonify({"error": "Video မတွေ့ပါ — ဦးစွာ upload လုပ်ပါ"}), 400 task_id = uuid.uuid4().hex[:8] output_video = f"final_{task_id}.mp4" temp_folder = f"temp_{task_id}" os.makedirs(temp_folder, exist_ok=True) combined_audio = f"{temp_folder}/combined.mp3" music_path = None if bgm_file: music_path = f"bgm_{task_id}.mp3" bgm_file.save(music_path) try: # TTS voice = VOICE_MAP.get(voice_label, "my-MM-ThihaNeural") f_speed = f"+{v_speed}%" paragraphs = smart_text_processor(final_script) silence_path = f"{temp_folder}/silence.mp3" subprocess.run(f'ffmpeg -f lavfi -i anullsrc=r=24000:cl=mono -t 0.4 -c:a libmp3lame -q:a 2 "{silence_path}"', shell=True, check=True) async def _tts(): parts = [] for i, p in enumerate(paragraphs): rp = f"{temp_folder}/raw_{i:03d}.mp3" await edge_tts.Communicate(p, voice, rate=f_speed).save(rp) parts.append(rp) parts.append(silence_path) return parts loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) audio_parts = loop.run_until_complete(_tts()) loop.close() list_file = f"{temp_folder}/list.txt" with open(list_file, "w", encoding="utf-8") as f: for a in audio_parts: f.write(f"file '{os.path.abspath(a)}'\n") subprocess.run( f'ffmpeg -f concat -safe 0 -i "{list_file}" ' f'-af "silenceremove=start_periods=1:stop_periods=-1:stop_duration=0.1:stop_threshold=-50dB" ' f'-c:a libmp3lame -q:a 2 "{combined_audio}"', shell=True, check=True) # Sync orig_dur = get_duration(video_path) audio_dur = get_duration(combined_audio) if orig_dur <= 0: raise Exception("Video duration ဖတ်မရပါ") if audio_dur <= 0: raise Exception("Audio duration ဖတ်မရပါ") raw_ratio = audio_dur / orig_dur sync_ratio = max(0.5, min(3.0, raw_ratio)) # === Build video filters (exact Gradio original) === v_filters = [] if raw_ratio > 3.0: loop_times = int(audio_dur / orig_dur) + 2 v_filters.append(f"loop={loop_times}:size=32767:start=0,trim=duration={audio_dur:.3f},setpts=PTS-STARTPTS") elif raw_ratio < 0.5: v_filters.append(f"trim=duration={audio_dur:.3f},setpts=PTS-STARTPTS") else: v_filters.append(f"setpts={sync_ratio:.6f}*PTS") v_filters.append("crop=iw:ih*0.82:0:0,scale=iw:ih") if flip: v_filters.append("hflip") if color_bp: v_filters.append("eq=brightness=0.06:contrast=1.2:saturation=1.4") v_filter_str = ",".join(v_filters) # === Build layout === if tiktok_ratio: v_layout = ( f"[0:v]{v_filter_str},scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280,boxblur=20:10[bg]; " f"[0:v]{v_filter_str},scale=720:1280:force_original_aspect_ratio=decrease[fg]; " f"[bg][fg]overlay=(W-w)/2:(H-h)/2" ) else: v_layout = f"[0:v]{v_filter_str}" # === Blur Band === if blur_enabled: h_pct = max(0.03, blur_h_pct / 100.0) y_pct = max(0.0, min(0.95, blur_y_pct / 100.0)) y_expr = f"ih*{y_pct:.3f}" blur_filter = ( f"[0:v]{v_filter_str}[base];" f"[base]crop=iw:ih*{h_pct:.3f}:0:{y_expr}[band];" f"[band]boxblur=25:5[blurred];" f"[base][blurred]overlay=0:{y_expr}" ) v_layout = blur_filter # === Watermark === if channel_name: clean_name = channel_name.replace("'","").replace("\\","").replace(":","") vff = f"{v_layout},drawtext=text='{clean_name}':x=w-tw-40:y=40:fontsize=35:fontcolor=white:shadowcolor=black:shadowx=2:shadowy=2[outv]" else: vff = f"{v_layout}[outv]" input_args = f'-fflags +genpts+igndts -err_detect ignore_err -i "{video_path}" -i "{combined_audio}"' if music_path: input_args += f' -stream_loop -1 -i "{music_path}"' af = (f"[1:a]loudnorm=I=-14:TP=-1.5:LRA=11,volume=1.2[nar];" f"[2:a]volume=0.15,afade=t=out:st={max(0,audio_dur-2):.3f}:d=2[bgm];" f"[nar][bgm]amix=inputs=2:duration=first:dropout_transition=2[outa]") else: af = "[1:a]loudnorm=I=-14:TP=-1.5:LRA=11,volume=1.2[outa]" cmd = (f'ffmpeg -y -hide_banner -loglevel warning {input_args} ' f'-filter_complex "{vff};{af}" ' f'-map "[outv]" -map "[outa]" ' f'-c:v libx264 -crf 23 -preset fast ' f'-c:a aac -ar 44100 -b:a 128k ' f'-t {audio_dur:.3f} -movflags +faststart "{output_video}"') result = subprocess.run(cmd, shell=True, capture_output=True, text=True) if result.returncode != 0: raise Exception(f"ffmpeg error: {result.stderr[-800:] if result.stderr else 'unknown'}") # Cache output for streaming — don't send raw bytes job_id = uuid.uuid4().hex[:10] OUTPUT_CACHE[job_id] = {"path": output_video, "ts": time.time()} fsize = os.path.getsize(output_video) return jsonify({"job_id": job_id, "size_mb": round(fsize/1024/1024,1)}) except Exception as e: return jsonify({"error": str(e)}), 500 finally: if os.path.exists(temp_folder): shutil.rmtree(temp_folder, ignore_errors=True) # Note: do NOT remove video_path here — it stays in VIDEO_CACHE if music_path and os.path.exists(music_path): os.remove(music_path) @app.route("/api/ping") def api_ping(): import psutil, shutil as sh disk = sh.disk_usage("/") return jsonify({ "status": "ok", "disk_free_gb": round(disk.free/1024**3, 1), "cached_videos": len(VIDEO_CACHE), "cached_outputs": len(OUTPUT_CACHE), }) # ─── HTML UI ───────────────────────────────────────────────────────────────── HTML_PAGE = """ PS Movie Recap Pro

🎬 PS MOVIE RECAP PRO

AI · Myanmar Dubbing · Auto Sync · TikTok Ready

🎬 ရုပ်ရှင်ဖိုင် တင်ပါ
🤖 AI ဆက်တင်
🎬 Movie Recap
🏥 Medical
✨ Gemini
🔮 DeepSeek
🎙️ အသံဆက်တင်
30%
⏳ Video တင်ပြီးမှ Script ထုတ်နိုင်မည်
✨ AI ထုတ်ပေးသောအကြောင်းအရာ
🎨 ဗီဒီယို Effect
🌫️ Blur ဖုံးကွယ်ရန်
75%
12%
""" if __name__ == "__main__": app.run(host="0.0.0.0", port=7860, debug=False, threaded=True)