Spaces:
Runtime error
Runtime error
| 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 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def index(): | |
| return render_template_string(HTML_PAGE) | |
| 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") | |
| 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 | |
| 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 | |
| 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) | |
| 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") | |
| 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) | |
| 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 = """<!DOCTYPE html> | |
| <html lang="my"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no"> | |
| <title>PS Movie Recap Pro</title> | |
| <style> | |
| *{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent} | |
| body{background:#0e0e16;color:#e2e4ec;font-family:'Segoe UI',sans-serif;min-height:100vh;padding-bottom:40px} | |
| ::-webkit-scrollbar{width:4px}::-webkit-scrollbar-thumb{background:#3a3a52;border-radius:8px} | |
| .hdr{background:linear-gradient(135deg,rgba(124,58,237,.15),rgba(232,54,93,.08)); | |
| border-bottom:1px solid rgba(124,58,237,.2);padding:18px 16px 14px;text-align:center} | |
| .hdr h1{font-size:20px;font-weight:900; | |
| background:linear-gradient(120deg,#a78bfa,#e879f9,#f87171); | |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:.5px} | |
| .hdr p{color:#555a6e;font-size:12px;margin-top:4px} | |
| .wrap{max-width:520px;margin:0 auto;padding:14px 14px 0} | |
| .card{background:#16161f;border:1px solid #2a2a3a;border-radius:14px;margin-bottom:12px;overflow:hidden} | |
| .card-hdr{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;cursor:pointer;user-select:none} | |
| .card-hdr span{font-size:13.5px;font-weight:600;color:#b0b8cc} | |
| .card-hdr .arrow{color:#555a6e;font-size:12px;transition:transform .2s} | |
| .card-hdr.open .arrow{transform:rotate(180deg)} | |
| .card-body{padding:0 14px 14px;display:none} | |
| .card-body.show{display:block} | |
| label{display:block;font-size:11.5px;color:#6b7280;font-weight:600;margin-bottom:5px;margin-top:12px} | |
| label:first-child{margin-top:0} | |
| input[type=text],select,textarea{width:100%;background:#1e1e2e;color:#e2e4ec; | |
| border:1px solid #2e2e42;border-radius:10px;padding:10px 12px;font-size:13.5px;outline:none; | |
| transition:border-color .2s,box-shadow .2s} | |
| input:focus,select:focus,textarea:focus{border-color:#7c3aed;box-shadow:0 0 0 3px rgba(124,58,237,.15)} | |
| textarea{resize:vertical;min-height:100px;line-height:1.6} | |
| select{appearance:none;-webkit-appearance:none; | |
| background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%236b7280' d='M1 1l5 5 5-5'/%3E%3C/svg%3E"); | |
| background-repeat:no-repeat;background-position:right 12px center;padding-right:32px} | |
| .slider-wrap{display:flex;align-items:center;gap:10px} | |
| input[type=range]{flex:1;accent-color:#7c3aed;height:4px;cursor:pointer} | |
| .slider-val{font-size:12px;color:#7c3aed;font-weight:700;min-width:36px;text-align:right} | |
| .cb-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px} | |
| .cb-item{display:flex;align-items:center;gap:6px;background:#1e1e2e;border:1px solid #2e2e42; | |
| border-radius:8px;padding:8px 12px;cursor:pointer;flex:1;min-width:70px;justify-content:center; | |
| font-size:12.5px;color:#9aa3b8;transition:all .15s;user-select:none} | |
| .cb-item.active{background:rgba(124,58,237,.15);border-color:#7c3aed;color:#a78bfa} | |
| .cb-item input{display:none} | |
| /* Upload zone */ | |
| .upload-zone{border:2px dashed #2e2e42;border-radius:12px;text-align:center;cursor:pointer; | |
| transition:all .2s;position:relative;overflow:hidden} | |
| .upload-zone:hover,.upload-zone.drag{border-color:#7c3aed;background:rgba(124,58,237,.06)} | |
| .upload-zone input{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%} | |
| .upload-zone.has-file{border-color:#22c55e;background:rgba(34,197,94,.04)} | |
| /* Video preview inside upload zone */ | |
| #videoPreviewWrap{display:none;position:relative;width:100%;background:#000;border-radius:10px;overflow:hidden} | |
| #videoPreview{width:100%;display:block;max-height:220px;object-fit:contain} | |
| .vid-overlay{position:absolute;top:8px;right:8px;background:rgba(0,0,0,.65); | |
| color:#22c55e;font-size:11px;font-weight:700;padding:3px 8px;border-radius:6px} | |
| /* Upload progress */ | |
| #uploadProgress{display:none;margin-top:8px} | |
| .up-bar-wrap{background:#1e1e2e;border-radius:6px;height:5px;overflow:hidden} | |
| .up-bar{height:100%;background:linear-gradient(90deg,#7c3aed,#e8365d);width:0;transition:width .1s;border-radius:6px} | |
| .up-pct{text-align:right;font-size:11px;color:#7c3aed;font-weight:700;margin-top:3px} | |
| /* Upload idle state */ | |
| .upload-idle{padding:22px 16px} | |
| .upload-idle .icon{font-size:32px;margin-bottom:6px} | |
| .upload-idle p{color:#6b7280;font-size:13px} | |
| .upload-idle p b{color:#9aa3b8} | |
| /* Radio pills */ | |
| .radio-group{display:flex;gap:8px} | |
| .radio-pill{flex:1;text-align:center;padding:9px 8px;border-radius:10px; | |
| background:#1e1e2e;border:1px solid #2e2e42;font-size:12.5px;color:#9aa3b8; | |
| cursor:pointer;transition:all .15s;user-select:none} | |
| .radio-pill.active{background:rgba(124,58,237,.2);border-color:#7c3aed;color:#c4b5fd;font-weight:700} | |
| .btn{width:100%;padding:14px;border:none;border-radius:12px; | |
| font-size:15px;font-weight:700;cursor:pointer;transition:all .2s;letter-spacing:.2px;margin-top:10px} | |
| .btn-primary{background:linear-gradient(135deg,#7c3aed,#5b21b6);color:#fff;box-shadow:0 4px 18px rgba(124,58,237,.4)} | |
| .btn-primary:active{transform:scale(.98)} | |
| .btn-run{background:linear-gradient(135deg,#7c3aed,#e8365d);color:#fff;box-shadow:0 4px 18px rgba(124,58,237,.35)} | |
| .btn-run:active{transform:scale(.98)} | |
| .btn:disabled{opacity:.5;cursor:not-allowed;transform:none!important} | |
| .btn-sm{width:auto;padding:8px 16px;font-size:13px;margin-top:0} | |
| .status-bar{background:#1e1e2e;border:1px solid #2e2e42;border-radius:10px; | |
| padding:10px 14px;font-size:12.5px;color:#8892a4;margin-top:10px;min-height:38px} | |
| .status-bar.ok{color:#22c55e;border-color:#166534} | |
| .status-bar.err{color:#ef4444;border-color:#7f1d1d} | |
| .progress{background:#1e1e2e;border-radius:8px;height:6px;overflow:hidden;margin-top:8px;display:none} | |
| .progress-bar{height:100%;background:linear-gradient(90deg,#7c3aed,#e8365d);width:0;transition:width .3s;border-radius:8px} | |
| audio{width:100%;margin-top:8px;border-radius:8px;accent-color:#7c3aed} | |
| video{width:100%;border-radius:12px;margin-top:8px;background:#000} | |
| .dl-btn{background:linear-gradient(135deg,#059669,#047857);color:#fff;text-decoration:none; | |
| border-radius:10px;padding:11px;text-align:center;font-weight:700;font-size:14px; | |
| margin-top:8px;display:block} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="hdr"> | |
| <h1>๐ฌ PS MOVIE RECAP PRO</h1> | |
| <p>AI ยท Myanmar Dubbing ยท Auto Sync ยท TikTok Ready</p> | |
| </div> | |
| <div class="wrap"> | |
| <!-- Video Upload --> | |
| <div class="card" style="margin-top:4px"> | |
| <div class="card-hdr open" onclick="toggle(this)"> | |
| <span>๐ฌ แแฏแแบแแพแแบแแญแฏแแบ แแแบแแซ</span><span class="arrow">โผ</span> | |
| </div> | |
| <div class="card-body show"> | |
| <!-- Hidden file input --> | |
| <input type="file" id="videoInput" accept="video/*" | |
| style="display:none" onchange="onVideoSelect(this)"> | |
| <!-- Upload button โ simple, reliable --> | |
| <div id="uploadIdle"> | |
| <button type="button" onclick="document.getElementById('videoInput').click()" | |
| style="width:100%;background:#1a1a2e;border:2px dashed #3b3b52;border-radius:12px; | |
| padding:28px 16px;cursor:pointer;text-align:center;transition:border-color .2s" | |
| onmouseover="this.style.borderColor='#7c3aed'" | |
| onmouseout="this.style.borderColor='#3b3b52'"> | |
| <div style="font-size:38px;margin-bottom:8px">๐ฌ</div> | |
| <div style="font-weight:700;color:#c4b5fd;font-size:15px">แแฎแแฎแแญแฏแแญแฏแแบ แแฝแฑแธแแซ</div> | |
| <div style="font-size:12px;color:#4b5563;margin-top:5px">MP4 ยท MOV ยท AVI โ แแแบแแแทแบ format แแแญแฏ</div> | |
| </button> | |
| </div> | |
| <!-- Upload progress --> | |
| <div id="uploadProgress" style="display:none;margin-top:8px;padding:14px; | |
| background:#1a1a2e;border-radius:12px;border:1px solid #2e2e42"> | |
| <div style="font-size:13px;color:#a78bfa;font-weight:600;margin-bottom:8px">โฌ๏ธ Server แแญแฏแท แแแบแแฑแแแบ...</div> | |
| <div style="background:#0e0e16;border-radius:6px;height:8px;overflow:hidden"> | |
| <div id="upBar" style="height:100%;background:linear-gradient(90deg,#7c3aed,#e8365d);width:0;transition:width .2s;border-radius:6px"></div> | |
| </div> | |
| <div id="upPct" style="text-align:right;font-size:12px;color:#7c3aed;font-weight:700;margin-top:4px">0%</div> | |
| </div> | |
| <!-- Video preview + change button --> | |
| <div id="videoPreviewWrap" style="display:none;margin-top:8px"> | |
| <video id="videoPreview" muted playsinline controls | |
| style="width:100%;border-radius:10px;max-height:200px;background:#000;display:block"></video> | |
| <div style="margin-top:6px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:6px"> | |
| <div> | |
| <div id="vidFileName" style="font-size:12px;color:#22c55e;font-weight:600"></div> | |
| <div id="vidInfo" style="font-size:11px;color:#6b7280;margin-top:1px"></div> | |
| </div> | |
| <button type="button" onclick="document.getElementById('videoInput').click()" | |
| style="background:#1e1e2e;border:1px solid #3b3b52;color:#9aa3b8; | |
| border-radius:8px;padding:6px 12px;font-size:12px;cursor:pointer"> | |
| ๐ แแผแฑแฌแแบแธแแแบ | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Upload status msg --> | |
| <div id="uploadMsg" style="display:none;margin-top:8px;padding:8px 12px; | |
| border-radius:8px;font-size:12.5px;font-weight:600"></div> | |
| </div> | |
| </div> | |
| <!-- AI Settings --> | |
| <div class="card"> | |
| <div class="card-hdr" onclick="toggle(this)"> | |
| <span>๐ค AI แแแบแแแบ</span><span class="arrow">โผ</span> | |
| </div> | |
| <div class="card-body"> | |
| <label>Content แกแแปแญแฏแธแกแ แฌแธ</label> | |
| <div class="radio-group"> | |
| <div class="radio-pill active" onclick="selectRadio(this,'contentType','Movie Recap')">๐ฌ Movie Recap</div> | |
| <div class="radio-pill" onclick="selectRadio(this,'contentType','Medical/Health')">๐ฅ Medical</div> | |
| </div> | |
| <input type="hidden" id="contentType" value="Movie Recap"> | |
| <label>AI แแฑแฌแบแแแบ</label> | |
| <div class="radio-group"> | |
| <div class="radio-pill active" onclick="selectRadio(this,'apiChoice','Gemini')">โจ Gemini</div> | |
| <div class="radio-pill" onclick="selectRadio(this,'apiChoice','DeepSeek')">๐ฎ DeepSeek</div> | |
| </div> | |
| <input type="hidden" id="apiChoice" value="Gemini"> | |
| </div> | |
| </div> | |
| <!-- Voice --> | |
| <div class="card"> | |
| <div class="card-hdr" onclick="toggle(this)"> | |
| <span>๐๏ธ แกแแถแแแบแแแบ</span><span class="arrow">โผ</span> | |
| </div> | |
| <div class="card-body"> | |
| <label>แกแแถแแแฌแแพแแบ</label> | |
| <select id="voiceSelect"> | |
| <option>แแญแแบ (แแปแฌแธ)</option> | |
| <option>แแฎแแฌ (แแญแแบแธ)</option> | |
| <option>แกแแบแแแฐแธ (แแปแฌแธ)</option> | |
| <option>แแฎแแปแถ (แแปแฌแธ)</option> | |
| <option>แกแฌแแฌ (แแญแแบแธ)</option> | |
| <option>แแแญแฏแแบแแแบ (แแปแฌแธ)</option> | |
| <option>แกแแบแแฌ (แแญแแบแธ)</option> | |
| <option>แแฎแแฎแแแบ (แแญแแบแธ)</option> | |
| <option>แ แฎแแฌแแฎแแฌ (แแญแแบแธ)</option> | |
| <option>แแฌแแฎแแฌ (แแญแแบแธ)</option> | |
| </select> | |
| <label>แกแแผแแบแแพแฏแแบแธ</label> | |
| <div class="slider-wrap"> | |
| <input type="range" min="0" max="50" value="30" id="speedSlider" | |
| oninput="document.getElementById('speedVal').innerText=this.value+'%'"> | |
| <span class="slider-val" id="speedVal">30%</span> | |
| </div> | |
| <button class="btn btn-primary btn-sm" style="margin-top:12px" onclick="previewVoice(event)">โถ แ แแบแธแแฌแธแแฑแฌแแบ</button> | |
| <audio id="previewAudio" controls style="display:none"></audio> | |
| </div> | |
| </div> | |
| <!-- Script Generate --> | |
| <button class="btn btn-primary" id="scriptBtn" onclick="generateScript()" disabled> | |
| ๐ Script แแฏแแบแแแบ | |
| </button> | |
| <div class="status-bar" id="apiStatus">โณ Video แแแบแแผแฎแธแแพ Script แแฏแแบแแญแฏแแบแแแบ</div> | |
| <!-- Script Editor --> | |
| <div class="card" style="margin-top:12px"> | |
| <div class="card-hdr open" onclick="toggle(this)"> | |
| <span>โจ AI แแฏแแบแแฑแธแแฑแฌแกแแผแฑแฌแแบแธแกแแฌ</span><span class="arrow">โผ</span> | |
| </div> | |
| <div class="card-body show"> | |
| <label>๐ฌ แแฑแซแแบแธแ แแบ & Hashtags</label> | |
| <textarea id="viralTitle" rows="3" placeholder="AI แแฏแแบแแฑแธแแแบ..."></textarea> | |
| <label>๐ แแผแแบแแฌ Script</label> | |
| <textarea id="scriptText" rows="10" placeholder="Script แคแแฑแแฌแแฝแแบ แแฑแซแบแแฌแแแบ..."></textarea> | |
| </div> | |
| </div> | |
| <!-- Video Effects --> | |
| <div class="card"> | |
| <div class="card-hdr" onclick="toggle(this)"> | |
| <span>๐จ แแฎแแฎแแญแฏ Effect</span><span class="arrow">โผ</span> | |
| </div> | |
| <div class="card-body"> | |
| <label>๐ง Watermark</label> | |
| <input type="text" id="watermark" value="MM RECAP"> | |
| <label>Effect</label> | |
| <div class="cb-row"> | |
| <label class="cb-item active" onclick="toggleCb(this,'cbFlip')"> | |
| <input type="checkbox" id="cbFlip" checked>โ Flip | |
| </label> | |
| <label class="cb-item active" onclick="toggleCb(this,'cbColor')"> | |
| <input type="checkbox" id="cbColor" checked>โจ Color+ | |
| </label> | |
| <label class="cb-item" onclick="toggleCb(this,'cbTiktok')"> | |
| <input type="checkbox" id="cbTiktok">๐ฑ 9:16 | |
| </label> | |
| </div> | |
| <label style="margin-top:12px">๐ต แแฑแฌแแบแแถแแฎแแปแแบแธ (แแปแแบแแฌแธแแแบแแแบแธแ)</label> | |
| <input type="file" id="bgmInput" accept="audio/*" | |
| style="display:none" onchange="onBgmSelect(this)"> | |
| <button type="button" onclick="document.getElementById('bgmInput').click()" | |
| style="width:100%;background:#1a1a2e;border:2px dashed #2e2e42;border-radius:10px; | |
| padding:12px 14px;cursor:pointer;display:flex;align-items:center;gap:10px; | |
| transition:border-color .2s;margin-top:4px" | |
| onmouseover="this.style.borderColor='#7c3aed'" | |
| onmouseout="this.style.borderColor='#2e2e42'"> | |
| <span style="font-size:22px">๐ต</span> | |
| <span id="bgmLabel" style="font-size:12.5px;color:#6b7280">MP3 / WAV แแญแฏแแบ แแแบแแซ</span> | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Blur --> | |
| <div class="card"> | |
| <div class="card-hdr" onclick="toggle(this)"> | |
| <span>๐ซ๏ธ Blur แแฏแถแธแแฝแแบแแแบ</span><span class="arrow">โผ</span> | |
| </div> | |
| <div class="card-body"> | |
| <label class="cb-item" style="justify-content:flex-start;gap:10px;max-width:180px;margin-top:0" | |
| onclick="toggleCb(this,'cbBlur')"> | |
| <input type="checkbox" id="cbBlur">Blur แแฝแแทแบแแแบ | |
| </label> | |
| <label style="margin-top:12px">๐ แแฑแแฌ % (0=แแญแแบ โ 90=แกแฑแฌแแบแแฏแถแธ)</label> | |
| <div class="slider-wrap"> | |
| <input type="range" min="0" max="90" value="75" id="blurY" | |
| oninput="document.getElementById('blurYVal').innerText=this.value+'%'"> | |
| <span class="slider-val" id="blurYVal">75%</span> | |
| </div> | |
| <label>โ แกแแผแแทแบ % (subtitle โ 10โ12%)</label> | |
| <div class="slider-wrap"> | |
| <input type="range" min="3" max="30" value="12" id="blurH" | |
| oninput="document.getElementById('blurHVal').innerText=this.value+'%'"> | |
| <span class="slider-val" id="blurHVal">12%</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Run --> | |
| <button class="btn btn-run" id="runBtn" onclick="produce()" disabled> | |
| ๐ แแฎแแฎแแญแฏแแฏแแบแแแบ | |
| </button> | |
| <div class="progress" id="progressWrap"> | |
| <div class="progress-bar" id="progressBar"></div> | |
| </div> | |
| <div class="status-bar" id="runStatus" style="display:none"></div> | |
| <!-- Output --> | |
| <div id="outputSection" style="display:none;margin-top:12px"> | |
| <div class="card"> | |
| <div class="card-hdr open" onclick="toggle(this)"> | |
| <span>๐ฆ แแผแฎแธแแฑแฌแแฎแแฎแแญแฏ</span><span class="arrow">โผ</span> | |
| </div> | |
| <div class="card-body show"> | |
| <video id="outputVideo" controls></video> | |
| <a id="dlBtn" class="dl-btn" download="recap_output.mp4">๐ฅ แแฑแซแแบแธแแฏแแบ (MP4)</a> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| var videoFile=null, bgmFile=null, cacheId=null; | |
| function toggle(hdr){ | |
| hdr.classList.toggle('open'); | |
| hdr.nextElementSibling.classList.toggle('show'); | |
| } | |
| function selectRadio(el,fid,val){ | |
| el.closest('.radio-group').querySelectorAll('.radio-pill').forEach(p=>p.classList.remove('active')); | |
| el.classList.add('active'); | |
| document.getElementById(fid).value=val; | |
| } | |
| function toggleCb(lbl,id){ | |
| var cb=document.getElementById(id); | |
| cb.checked=!cb.checked; | |
| lbl.classList.toggle('active',cb.checked); | |
| } | |
| // โโ Video select: preview locally โ upload to server with real % progress โโ | |
| function onVideoSelect(input){ | |
| if(!input.files || !input.files[0]) return; | |
| var file = input.files[0]; | |
| videoFile = file; | |
| cacheId = null; | |
| var sizeMB = (file.size/1024/1024).toFixed(1); | |
| // Show local preview immediately (no server wait needed) | |
| document.getElementById('uploadIdle').style.display = 'none'; | |
| document.getElementById('uploadProgress').style.display = 'block'; | |
| document.getElementById('videoPreviewWrap').style.display = 'none'; | |
| document.getElementById('uploadMsg').style.display = 'none'; | |
| // upload started | |
| // Set local video preview src (plays from device) | |
| var localUrl = URL.createObjectURL(file); | |
| var vid = document.getElementById('videoPreview'); | |
| vid.src = localUrl; | |
| vid.onloadedmetadata = function(){ | |
| var dur = Math.round(vid.duration); | |
| var m = Math.floor(dur/60), s = dur % 60; | |
| document.getElementById('vidInfo').textContent = | |
| m + 'min ' + s + 'sec ยท ' + sizeMB + ' MB'; | |
| }; | |
| document.getElementById('vidFileName').textContent = 'โ ' + file.name; | |
| // Upload to server | |
| var fd = new FormData(); | |
| fd.append('video', file); | |
| var xhr = new XMLHttpRequest(); | |
| xhr.open('POST', '/api/upload_video'); | |
| xhr.timeout = 360000; // 6 minutes | |
| xhr.upload.onprogress = function(e){ | |
| if(e.lengthComputable){ | |
| var pct = Math.round(e.loaded / e.total * 100); | |
| var sent = (e.loaded/1024/1024).toFixed(1); | |
| document.getElementById('upBar').style.width = pct + '%'; | |
| document.getElementById('upPct').textContent = | |
| pct + '% (' + sent + ' / ' + sizeMB + ' MB)'; | |
| } | |
| }; | |
| xhr.onload = function(){ | |
| document.getElementById('uploadProgress').style.display = 'none'; | |
| document.getElementById('videoPreviewWrap').style.display = 'block'; | |
| var msg = document.getElementById('uploadMsg'); | |
| msg.style.display = 'block'; | |
| if(xhr.status === 413){ | |
| msg.style.background = 'rgba(239,68,68,.15)'; | |
| msg.style.color = '#ef4444'; | |
| msg.textContent = 'โ แแญแฏแแบแแผแฎแธแแฝแแบแธ (413) โ 200MB แกแฑแฌแแบ compress แแผแฎแธ แแแบแแผแญแฏแธแ แฌแธแแซ'; | |
| return; | |
| } | |
| if(xhr.status !== 200){ | |
| msg.style.background = 'rgba(239,68,68,.15)'; | |
| msg.style.color = '#ef4444'; | |
| msg.textContent = 'โ Upload แแแฑแฌแแบ (HTTP ' + xhr.status + ') โ ' + xhr.responseText.slice(0,100); | |
| return; | |
| } | |
| try{ | |
| var d = JSON.parse(xhr.responseText); | |
| if(d.error){ | |
| msg.style.background = 'rgba(239,68,68,.15)'; | |
| msg.style.color = '#ef4444'; | |
| msg.textContent = 'โ ' + d.error; | |
| return; | |
| } | |
| cacheId = d.cache_id; | |
| msg.style.background = 'rgba(34,197,94,.1)'; | |
| msg.style.color = '#22c55e'; | |
| msg.textContent = 'โ Server แแญแฏแทแแผแฎแธแแผแฎ (' + d.size_mb + ' MB) โ Script แแฏแแบแแญแฏแแบแแผแฎ'; | |
| // upload done | |
| document.getElementById('scriptBtn').disabled = false; | |
| document.getElementById('runBtn').disabled = false; | |
| }catch(e){ | |
| msg.style.background = 'rgba(239,68,68,.15)'; | |
| msg.style.color = '#ef4444'; | |
| msg.textContent = 'โ Server response error: ' + xhr.responseText.slice(0,100); | |
| } | |
| }; | |
| xhr.ontimeout = function(){ | |
| document.getElementById('uploadProgress').style.display = 'none'; | |
| document.getElementById('videoPreviewWrap').style.display = 'block'; | |
| var msg = document.getElementById('uploadMsg'); | |
| msg.style.display = 'block'; | |
| msg.style.background = 'rgba(245,158,11,.1)'; | |
| msg.style.color = '#f59e0b'; | |
| msg.textContent = 'โฑ๏ธ Upload timeout โ แแญแฏแแบแแฑแธแแฑแฌ (100MBโ) video แแฏแถแธแแซ'; | |
| }; | |
| xhr.onerror = function(){ | |
| document.getElementById('uploadProgress').style.display = 'none'; | |
| document.getElementById('videoPreviewWrap').style.display = 'block'; | |
| var msg = document.getElementById('uploadMsg'); | |
| msg.style.display = 'block'; | |
| msg.style.background = 'rgba(239,68,68,.15)'; | |
| msg.style.color = '#ef4444'; | |
| msg.textContent = 'โ Network error โ Internet แ แ แบแแผแฎแธ แแผแแบแแผแญแฏแธแ แฌแธแแซ'; | |
| }; | |
| xhr.send(fd); | |
| } | |
| function setUpPct(p){ | |
| p=Math.round(p); | |
| document.getElementById('upBar').style.width=p+'%'; | |
| document.getElementById('upPct').textContent=p+'%'; | |
| } | |
| function onBgmSelect(input){ | |
| if(!input.files[0]) return; | |
| bgmFile=input.files[0]; | |
| document.getElementById('bgmZone').classList.add('has-file'); | |
| document.getElementById('bgmLabel').textContent='โ '+bgmFile.name; | |
| } | |
| // Drag-drop on entire page for video | |
| document.addEventListener('dragover', function(ev){ ev.preventDefault(); }); | |
| document.addEventListener('drop', function(ev){ | |
| ev.preventDefault(); | |
| var f = ev.dataTransfer && ev.dataTransfer.files[0]; | |
| if(f && f.type.startsWith('video/')){ | |
| try{ | |
| var dt = new DataTransfer(); | |
| dt.items.add(f); | |
| document.getElementById('videoInput').files = dt.files; | |
| onVideoSelect(document.getElementById('videoInput')); | |
| }catch(e){} | |
| } | |
| }); | |
| async function previewVoice(e){ | |
| var btn=e.currentTarget; btn.disabled=true; btn.textContent='โณ...'; | |
| try{ | |
| var r=await fetch('/api/preview_voice',{method:'POST', | |
| headers:{'Content-Type':'application/json'}, | |
| body:JSON.stringify({voice:document.getElementById('voiceSelect').value, | |
| speed:document.getElementById('speedSlider').value})}); | |
| var blob=await r.blob(); | |
| var a=document.getElementById('previewAudio'); | |
| a.src=URL.createObjectURL(blob); a.style.display='block'; a.play(); | |
| }catch(err){alert('Preview แแแแซ')} | |
| btn.disabled=false; btn.textContent='โถ แ แแบแธแแฌแธแแฑแฌแแบ'; | |
| } | |
| // Script generate โ cache_id แแฌ แแญแฏแท (video แแแบแแแฝแฒ) | |
| function generateScript(){ | |
| if(!cacheId){ | |
| setStatus('apiStatus','โณ Video upload แแแผแฎแธแแฑแธแแซ... แแแ แฑแฌแแทแบแแซ',''); return; | |
| } | |
| var btn=document.getElementById('scriptBtn'); | |
| btn.disabled=true; btn.textContent='โณ Script แแฏแแบแแฑแแแบ...'; | |
| setStatus('apiStatus','โณ AI แแผแแทแบ Script แแฏแแบแแฑแแแบ... (แ-แ แแญแแ แบ แแผแฌแแญแฏแแบแแแบ)',''); | |
| var fd=new FormData(); | |
| fd.append('cache_id',cacheId); | |
| fd.append('api_choice',document.getElementById('apiChoice').value); | |
| fd.append('content_type',document.getElementById('contentType').value); | |
| var xhr=new XMLHttpRequest(); | |
| xhr.open('POST','/api/generate_script'); | |
| xhr.onload=function(){ | |
| try{ | |
| var d=JSON.parse(xhr.responseText); | |
| if(d.error){setStatus('apiStatus','โ '+d.error,'err'); | |
| btn.disabled=false; btn.textContent='๐ Script แแฏแแบแแแบ'; return;} | |
| document.getElementById('scriptText').value=d.script; | |
| var title=d.title.split('\n')[0].trim(); | |
| document.getElementById('viralTitle').value= | |
| title+'\n\n#movierecap #แแผแแบแแฌ #viral #แแฌแแบแแแบแธ #mmrecap #tiktok'; | |
| if(d.cache_id) cacheId=d.cache_id; | |
| setStatus('apiStatus',d.status,'ok'); | |
| }catch(e){setStatus('apiStatus','โ Parse error','err')} | |
| btn.disabled=false; btn.textContent='๐ Script แแฏแแบแแแบ'; | |
| }; | |
| xhr.onerror=function(){ | |
| setStatus('apiStatus','โ Network error','err'); | |
| btn.disabled=false; btn.textContent='๐ Script แแฏแแบแแแบ'; | |
| }; | |
| xhr.send(fd); | |
| } | |
| // Produce โ video แแแบแแแฝแฒแ server streaming แแผแแทแบ แแผแแทแบแแพแฏ + download | |
| async function produce(){ | |
| if(!cacheId){ alert('Video cache แแแพแญแแซ โ แฆแธแ แฝแฌ video แแแบแแผแฎแธ upload แแผแฎแธแแแบแกแแญ แ แฑแฌแแทแบแแซ'); return; } | |
| var script=document.getElementById('scriptText').value.trim(); | |
| if(!script){alert('Script แแแพแญแแซ');return} | |
| var btn=document.getElementById('runBtn'); | |
| btn.disabled=true; btn.textContent='โณ แแฎแแฎแแญแฏแแฏแแบแแฑแแแบ...'; | |
| var pw=document.getElementById('progressWrap'), pb=document.getElementById('progressBar'); | |
| pw.style.display='block'; pb.style.width='8%'; | |
| setStatus('runStatus','๐๏ธ แกแแถแแฝแแบแธแแฑแแแบ...',''); | |
| document.getElementById('runStatus').style.display='block'; | |
| var fd=new FormData(); | |
| fd.append('cache_id',cacheId); | |
| fd.append('script',script); | |
| fd.append('title',document.getElementById('viralTitle').value); | |
| fd.append('voice',document.getElementById('voiceSelect').value); | |
| fd.append('speed',document.getElementById('speedSlider').value); | |
| fd.append('watermark',document.getElementById('watermark').value); | |
| fd.append('flip',document.getElementById('cbFlip').checked); | |
| fd.append('color',document.getElementById('cbColor').checked); | |
| fd.append('tiktok',document.getElementById('cbTiktok').checked); | |
| fd.append('blur',document.getElementById('cbBlur').checked); | |
| fd.append('blur_y',document.getElementById('blurY').value); | |
| fd.append('blur_h',document.getElementById('blurH').value); | |
| if(bgmFile) fd.append('bgm',bgmFile); | |
| var pct=8; | |
| var timer=setInterval(()=>{ | |
| pct=Math.min(pct+3,88); pb.style.width=pct+'%'; | |
| if(pct>40) setStatus('runStatus','๐ฌ แแฎแแฎแแญแฏแแฏแแบแแฑแแแบ...',''); | |
| },2500); | |
| try{ | |
| var r=await fetch('/api/produce',{method:'POST',body:fd}); | |
| clearInterval(timer); pb.style.width='100%'; | |
| var d=await r.json(); | |
| if(!r.ok||d.error){setStatus('runStatus','โ '+(d.error||'Error'),'err'); return;} | |
| // Video player โ stream route (Range support) | |
| // Download button โ download route (attachment) | |
| var streamUrl = '/api/stream/' + d.job_id; | |
| var dlUrl = '/api/download/' + d.job_id; | |
| var vidEl = document.getElementById('outputVideo'); | |
| vidEl.src = ''; | |
| setTimeout(function(){ vidEl.src = streamUrl; vidEl.load(); }, 100); | |
| var dlA = document.getElementById('dlBtn'); | |
| dlA.href = dlUrl; | |
| dlA.removeAttribute('download'); // let server set Content-Disposition | |
| document.getElementById('outputSection').style.display='block'; | |
| setStatus('runStatus','โ แแฎแแฎแแญแฏแแฏแแบแแผแฎแธแแซแแผแฎ! ('+d.size_mb+' MB)','ok'); | |
| document.getElementById('outputSection').scrollIntoView({behavior:'smooth'}); | |
| }catch(e){ | |
| clearInterval(timer); | |
| setStatus('runStatus','โ Error: '+e,'err'); | |
| } | |
| btn.disabled=false; btn.textContent='๐ แแฎแแฎแแญแฏแแฏแแบแแแบ'; | |
| } | |
| function setStatus(id,msg,type){ | |
| var el=document.getElementById(id); | |
| el.textContent=msg; | |
| el.className='status-bar'+(type?' '+type:''); | |
| } | |
| </script> | |
| </body> | |
| </html>""" | |
| if __name__ == "__main__": | |
| app.run(host="0.0.0.0", port=7860, debug=False, threaded=True) | |