mm125 commited on
Commit
9684893
·
verified ·
1 Parent(s): 7d75522

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +1064 -0
app.py ADDED
@@ -0,0 +1,1064 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import subprocess, os, asyncio, edge_tts, uuid, random, re, glob, shutil, threading, time
2
+ from flask import Flask, request, jsonify, send_file, render_template_string, Response
3
+ from openai import OpenAI
4
+ import whisper
5
+
6
+ app = Flask(__name__)
7
+ app.config['MAX_CONTENT_LENGTH'] = 2 * 1024 * 1024 * 1024
8
+ whisper_model = whisper.load_model("tiny", device="cpu")
9
+
10
+ # ─── Server-side video cache ──────────────────────────────────────────────
11
+ VIDEO_CACHE = {} # { cache_id: {"path": str, "ts": float} }
12
+ OUTPUT_CACHE = {} # { job_id: {"path": str, "ts": float} }
13
+ CACHE_TTL = 3600
14
+
15
+ def _store_video(path):
16
+ vid = uuid.uuid4().hex[:10]
17
+ VIDEO_CACHE[vid] = {"path": path, "ts": time.time()}
18
+ _evict_cache()
19
+ return vid
20
+
21
+ def _get_video(cache_id):
22
+ entry = VIDEO_CACHE.get(cache_id)
23
+ if entry and os.path.exists(entry["path"]):
24
+ entry["ts"] = time.time()
25
+ return entry["path"]
26
+ return None
27
+
28
+ def _evict_cache():
29
+ now = time.time()
30
+ for d in [VIDEO_CACHE, OUTPUT_CACHE]:
31
+ stale = [k for k,v in d.items() if now - v["ts"] > CACHE_TTL]
32
+ for k in stale:
33
+ try:
34
+ p = d[k]["path"]
35
+ if os.path.exists(p): os.remove(p)
36
+ except: pass
37
+ d.pop(k, None)
38
+
39
+ GEMINI_API_KEYS = [
40
+ os.getenv("GEMINI_API_KEY_1"), os.getenv("GEMINI_API_KEY_2"),
41
+ os.getenv("GEMINI_API_KEY_3"), os.getenv("GEMINI_API_KEY_4"),
42
+ os.getenv("GEMINI_API_KEY_5")
43
+ ]
44
+ DEEPSEEK_API_KEYS = [os.getenv("DEEPSEEK_API_KEY")]
45
+
46
+ VOICE_MAP = {
47
+ "သိဟ် (ကျား)": "my-MM-ThihaNeural",
48
+ "နီလာ (မိန်း)": "my-MM-NilarNeural",
49
+ "အန်ဒရူး (ကျား)": "en-US-AndrewMultilingualNeural",
50
+ "ဝီလျံ (ကျား)": "en-US-WilliamMultilingualNeural",
51
+ "အာဗာ (မိန်း)": "en-US-AvaMultilingualNeural",
52
+ "ဘရိုင်ယန် (ကျား)":"en-US-BrianMultilingualNeural",
53
+ "အယ်မာ (မိန်း)": "en-US-EmmaMultilingualNeural",
54
+ "ဗီဗီယန် (မိန်း)": "fr-FR-VivienneMultilingualNeural",
55
+ "စီရာဖီနာ (မိန်း)":"de-DE-SeraphinaMultilingualNeural",
56
+ "သာလီတာ (မိန်း)": "pt-BR-ThalitaMultilingualNeural",
57
+ }
58
+
59
+ # ─── Helpers ────────────────────────────────────────────────────────────────
60
+
61
+ def call_api_with_fallback(messages, max_tokens=8192, task_name="API Call", api_choice="Gemini"):
62
+ if api_choice == "DeepSeek":
63
+ api_keys_list = DEEPSEEK_API_KEYS
64
+ base_url = "https://api.deepseek.com"
65
+ model_name = "deepseek-chat"
66
+ api_name = "DeepSeek"
67
+ else:
68
+ api_keys_list = GEMINI_API_KEYS
69
+ base_url = "https://generativelanguage.googleapis.com/v1beta/openai/"
70
+ model_name = "gemini-2.5-flash-lite"
71
+ api_name = "Gemini"
72
+ valid_keys = [(i+1, k) for i, k in enumerate(api_keys_list) if k]
73
+ if not valid_keys:
74
+ raise Exception(f"No valid {api_name} API keys found")
75
+ random.shuffle(valid_keys)
76
+ last_error = None
77
+ for key_num, api_key in valid_keys:
78
+ try:
79
+ client = OpenAI(api_key=api_key, base_url=base_url, timeout=600.0)
80
+ response = client.chat.completions.create(model=model_name, messages=messages, max_tokens=max_tokens)
81
+ if not response or not response.choices or not response.choices[0].message or not response.choices[0].message.content:
82
+ last_error = f"{api_name} Key {key_num}: Empty response"; continue
83
+ content = response.choices[0].message.content.strip()
84
+ return content, f"✅ {task_name}: {api_name} Key {key_num}"
85
+ except Exception as e:
86
+ err = str(e)
87
+ 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]}"
88
+ continue
89
+ raise Exception(f"❌ All {api_name} keys failed. Last: {last_error}")
90
+
91
+ def cleanup_old_files():
92
+ for pattern in ["final_*.mp4", "temp_voice_*.mp3", "temp_*", "list.txt", "preview_*.mp3"]:
93
+ for f in glob.glob(pattern):
94
+ try:
95
+ shutil.rmtree(f) if os.path.isdir(f) else os.remove(f)
96
+ except: pass
97
+
98
+ def get_duration(file_path):
99
+ try:
100
+ cmd = f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "{file_path}"'
101
+ r = subprocess.run(cmd, shell=True, capture_output=True, text=True)
102
+ return float(r.stdout.strip())
103
+ except: return 0
104
+
105
+ def smart_text_processor(full_text):
106
+ clean = re.sub(r'\(.*?\d+.*?\)|#+.*?\d+.*?-.*?\d+|\d+[:\.]\d+[:\.]\d+|\d+[:\.]\d+|#+.*?\d+[:\.]\d+', '', full_text)
107
+ clean = re.sub(r'[a-zA-Z]|\(|\)|\[|\]|\.|\.\.\.|\-|\!|\#', '', clean)
108
+ sentences = [s.strip() for s in re.split(r'[။]', clean) if s.strip()]
109
+ paragraphs = []
110
+ for i in range(0, len(sentences), 3):
111
+ chunk = sentences[i:i+3]
112
+ paragraphs.append(" ".join(chunk) + "။")
113
+ return paragraphs
114
+
115
+ def get_system_prompt(content_type):
116
+ if content_type == "Medical/Health":
117
+ return """သင်သည် မြန်မာဘာသာပြန်သူဖြစ်သည်။ အောက်ပါ transcript ကို spoken Myanmar (နေ့စဉ်ပြောဆိုမှုဘာသာ) သို့ဘာသာပြန်ပါ။
118
+ ## အရေးကြီးဆုံးစည်းမျဉ်း
119
+ - မူလ transcript တွင်ပါသောအကြောင်းအရာသာ ဘာသာပြန်ရမည်
120
+ - English စကားလုံး တစ်လုံးမျှ မပါရ — 100% မြန်မာဘာသာသာ သုံးရမည်
121
+ - ကျောင်းသုံးစာပေ (သည်/၏/၍/သော/သည့်) — လုံးဝမသုံးရ
122
+ ## Spoken Myanmar
123
+ - ကြိယာ → တယ်/ပါတယ်/ရတယ်/ပြီ | မေးခွန်း → လား/မလား/လဲ
124
+ - ကိုယ်ရည် → ကျမ၊ ကျနော် | ပုဒ်မ → ။
125
+ ## Medical Terms
126
+ diabetes→ဆီးချိုရောဂါ|heart disease→နှလုံးရောဂါ|high blood pressure→သွေးတိုးရောဂါ|cancer→ကင်ဆာ|symptom→လက္ခဏာ|treatment→ကုသမှု|doctor→ဆရာဝန်|medicine→ဆေး|hospital→ဆေးရုံ|patient→လူနာ
127
+ ## Output Format
128
+ [SCRIPT]
129
+ (ဘာသာပြန်ထားသော script)
130
+ [TITLE]
131
+ (ခေါင်းစဉ် မြန်မာ ၁၀ လုံးအတွင်း)
132
+ [HASHTAGS]
133
+ #ကျန်းမာရေး #ဆေး #မြန်မာ"""
134
+ else:
135
+ return """
136
+ STRICT TRANSLATION RULES:
137
+ 1. Translate EXACTLY what is said - NO additions, NO drama
138
+ 2. ZERO English words - 100% Myanmar only
139
+ === OUTPUT FORMAT ===
140
+ [SCRIPT]
141
+ (Exact translation)
142
+ [TITLE]
143
+ (Short catchy title max 10 words)
144
+ [HASHTAGS]
145
+ #movierecap #မြန်မာ #viral #ဇာတ်လမ်း
146
+ === RULES ===
147
+ ✓ USE: တယ်/လား/ရတယ် - NEVER သည်/၏/၍
148
+ ✓ Substitutions: ကျွန်မ→ကျမ, အစ်ကို→အကို
149
+ ✓ CEO→သူဌေး,car→ကား,school→ကျောင်း,office→ရုံး,phone→ဖုန်း,money→ပိုက်ဆံ,police→ရဲ,house→အိမ်
150
+ CRITICAL: Word-for-word only. 100% Myanmar."""
151
+
152
+ def parse_ai_response(combined_response):
153
+ final_script = ""
154
+ viral_layout = ""
155
+ if '[SCRIPT]' in combined_response and '[TITLE]' in combined_response:
156
+ m = re.search(r'\[SCRIPT\](.*?)\[TITLE\]', combined_response, re.DOTALL)
157
+ if m: final_script = m.group(1).strip()
158
+ if '[HASHTAGS]' in combined_response:
159
+ tm = re.search(r'\[TITLE\](.*?)\[HASHTAGS\]', combined_response, re.DOTALL)
160
+ hm = re.search(r'\[HASHTAGS\](.*?)$', combined_response, re.DOTALL)
161
+ if tm and hm: viral_layout = f"{tm.group(1).strip()}\n\n{hm.group(1).strip()}"
162
+ else:
163
+ tm = re.search(r'\[TITLE\](.*?)$', combined_response, re.DOTALL)
164
+ if tm: viral_layout = tm.group(1).strip()
165
+ else:
166
+ parts = combined_response.split('[TITLE]')
167
+ final_script = parts[0].replace('[SCRIPT]','').strip()
168
+ if len(parts) > 1: viral_layout = parts[1].replace('[HASHTAGS]','').strip()
169
+ final_script = re.sub(r'\[SCRIPT\]|\[TITLE\]|\[HASHTAGS\]','',final_script).strip()
170
+ viral_layout = re.sub(r'\[SCRIPT\]|\[TITLE\]|\[HASHTAGS\]','',viral_layout).strip()
171
+ return final_script, viral_layout
172
+
173
+ # ─── Flask Routes ────────────────────────────────────────────────────────────
174
+
175
+ @app.route("/")
176
+ def index():
177
+ return render_template_string(HTML_PAGE)
178
+
179
+ @app.route("/api/preview_voice", methods=["POST"])
180
+ def api_preview_voice():
181
+ data = request.json
182
+ voice_label = data.get("voice", "သိဟ် — ကျား (မြန်မာ)")
183
+ speed = int(data.get("speed", 15))
184
+ voice = VOICE_MAP.get(voice_label, "my-MM-ThihaNeural")
185
+ f_speed = f"+{speed}%"
186
+ path = f"preview_{uuid.uuid4().hex[:6]}.mp3"
187
+ async def _gen():
188
+ await edge_tts.Communicate("မင်္ဂလာပါ။", voice, rate=f_speed).save(path)
189
+ loop = asyncio.new_event_loop()
190
+ asyncio.set_event_loop(loop)
191
+ loop.run_until_complete(_gen())
192
+ loop.close()
193
+ return send_file(path, mimetype="audio/mpeg")
194
+
195
+ @app.route("/api/upload_video", methods=["POST"])
196
+ def api_upload_video():
197
+ """Upload video once, cache on server, return cache_id."""
198
+ try:
199
+ if "video" not in request.files:
200
+ return jsonify({"error": "No video file in request"}), 400
201
+ video_file = request.files["video"]
202
+ if not video_file or video_file.filename == "":
203
+ return jsonify({"error": "Empty file"}), 400
204
+ task_id = uuid.uuid4().hex[:10]
205
+ # Save with original extension if possible
206
+ orig = video_file.filename or "video.mp4"
207
+ ext = os.path.splitext(orig)[1] or ".mp4"
208
+ video_path = f"cached_vid_{task_id}{ext}"
209
+ video_file.save(video_path)
210
+ if not os.path.exists(video_path) or os.path.getsize(video_path) == 0:
211
+ return jsonify({"error": "File save failed or empty"}), 500
212
+ cache_id = _store_video(video_path)
213
+ size_mb = round(os.path.getsize(video_path)/1024/1024, 1)
214
+ return jsonify({"cache_id": cache_id, "size_mb": size_mb, "filename": orig})
215
+ except Exception as e:
216
+ return jsonify({"error": f"Upload error: {str(e)}"}), 500
217
+
218
+ @app.route("/api/generate_script", methods=["POST"])
219
+ def api_generate_script():
220
+ cache_id = request.form.get("cache_id", "")
221
+ api_choice = request.form.get("api_choice", "Gemini")
222
+ content_type = request.form.get("content_type", "Movie Recap")
223
+ video_path = _get_video(cache_id) if cache_id else None
224
+ # fallback: accept inline upload too
225
+ if not video_path and "video" in request.files:
226
+ task_id = uuid.uuid4().hex[:8]
227
+ video_path = f"cached_vid_{task_id}.mp4"
228
+ request.files["video"].save(video_path)
229
+ cache_id = _store_video(video_path)
230
+ video_path = _get_video(cache_id)
231
+ if not video_path:
232
+ return jsonify({"error": "Video မတွေ့ပါ — ဦးစွာ upload လုပ်ပါ"}), 400
233
+ try:
234
+ result = whisper_model.transcribe(video_path, fp16=False, language=None)
235
+ transcript = result["text"]
236
+ detected_lang = result.get("language", "unknown")
237
+ system_prompt = get_system_prompt(content_type)
238
+ combined, status = call_api_with_fallback(
239
+ messages=[
240
+ {"role": "system", "content": system_prompt},
241
+ {"role": "user", "content": f"Original Language: {detected_lang}\n\nContent: {transcript}"}
242
+ ], max_tokens=8192, task_name="Script+Title", api_choice=api_choice
243
+ )
244
+ final_script, viral_layout = parse_ai_response(combined)
245
+ return jsonify({"script": final_script, "title": viral_layout,
246
+ "status": f"{status} (Lang: {detected_lang})", "cache_id": cache_id})
247
+ except Exception as e:
248
+ return jsonify({"error": str(e)}), 500
249
+
250
+ @app.route("/api/stream/<job_id>")
251
+ def api_stream(job_id):
252
+ """Range-capable video streaming."""
253
+ entry = OUTPUT_CACHE.get(job_id)
254
+ if not entry or not os.path.exists(entry["path"]):
255
+ return "Not found", 404
256
+ fpath = entry["path"]
257
+ fsize = os.path.getsize(fpath)
258
+ range_header = request.headers.get("Range", None)
259
+
260
+ if range_header:
261
+ try:
262
+ byte_range = range_header.replace("bytes=", "").strip()
263
+ parts = byte_range.split("-")
264
+ start = int(parts[0]) if parts[0] else 0
265
+ end = int(parts[1]) if len(parts) > 1 and parts[1] else fsize - 1
266
+ end = min(end, fsize - 1)
267
+ length = end - start + 1
268
+ except:
269
+ start, end, length = 0, fsize - 1, fsize
270
+
271
+ def generate_range():
272
+ with open(fpath, "rb") as f:
273
+ f.seek(start)
274
+ remaining = length
275
+ while remaining > 0:
276
+ chunk = f.read(min(65536, remaining))
277
+ if not chunk:
278
+ break
279
+ remaining -= len(chunk)
280
+ yield chunk
281
+
282
+ headers = {
283
+ "Content-Range": f"bytes {start}-{end}/{fsize}",
284
+ "Accept-Ranges": "bytes",
285
+ "Content-Length": str(length),
286
+ "Content-Type": "video/mp4",
287
+ "Content-Disposition": "inline; filename=recap_output.mp4",
288
+ }
289
+ return Response(generate_range(), 206, headers=headers)
290
+ else:
291
+ # Full file
292
+ def generate_full():
293
+ with open(fpath, "rb") as f:
294
+ while True:
295
+ chunk = f.read(65536)
296
+ if not chunk:
297
+ break
298
+ yield chunk
299
+
300
+ headers = {
301
+ "Accept-Ranges": "bytes",
302
+ "Content-Length": str(fsize),
303
+ "Content-Type": "video/mp4",
304
+ "Content-Disposition": "inline; filename=recap_output.mp4",
305
+ }
306
+ return Response(generate_full(), 200, headers=headers)
307
+
308
+ @app.route("/api/download/<job_id>")
309
+ def api_download(job_id):
310
+ """Direct download (as attachment)."""
311
+ entry = OUTPUT_CACHE.get(job_id)
312
+ if not entry or not os.path.exists(entry["path"]):
313
+ return "Not found", 404
314
+ return send_file(entry["path"], mimetype="video/mp4",
315
+ as_attachment=True, download_name="recap_output.mp4")
316
+
317
+ @app.route("/api/produce", methods=["POST"])
318
+ def api_produce():
319
+ data = request.form
320
+ cache_id = data.get("cache_id", "")
321
+ final_script = data.get("script", "")
322
+ voice_label = data.get("voice", "သိဟ် (ကျား)")
323
+ v_speed = int(data.get("speed", 30))
324
+ channel_name = data.get("watermark", "MM RECAP")
325
+ flip = data.get("flip","false") == "true"
326
+ color_bp = data.get("color","false") == "true"
327
+ tiktok_ratio = data.get("tiktok","false") == "true"
328
+ blur_enabled = data.get("blur","false") == "true"
329
+ blur_y_pct = float(data.get("blur_y", 75))
330
+ blur_h_pct = float(data.get("blur_h", 12))
331
+ bgm_file = request.files.get("bgm")
332
+
333
+ if not final_script:
334
+ return jsonify({"error": "Script မရှိပါ"}), 400
335
+
336
+ # Use cached video — no re-upload needed
337
+ video_path = _get_video(cache_id) if cache_id else None
338
+ if not video_path and "video" in request.files:
339
+ task_id = uuid.uuid4().hex[:8]
340
+ video_path = f"cached_vid_{task_id}.mp4"
341
+ request.files["video"].save(video_path)
342
+ cache_id = _store_video(video_path)
343
+ video_path = _get_video(cache_id)
344
+ if not video_path:
345
+ return jsonify({"error": "Video မတွေ့ပါ — ဦးစွာ upload လုပ်ပါ"}), 400
346
+
347
+ task_id = uuid.uuid4().hex[:8]
348
+ output_video = f"final_{task_id}.mp4"
349
+ temp_folder = f"temp_{task_id}"
350
+ os.makedirs(temp_folder, exist_ok=True)
351
+ combined_audio = f"{temp_folder}/combined.mp3"
352
+ music_path = None
353
+
354
+ if bgm_file:
355
+ music_path = f"bgm_{task_id}.mp3"
356
+ bgm_file.save(music_path)
357
+
358
+ try:
359
+ # TTS
360
+ voice = VOICE_MAP.get(voice_label, "my-MM-ThihaNeural")
361
+ f_speed = f"+{v_speed}%"
362
+ paragraphs = smart_text_processor(final_script)
363
+ silence_path = f"{temp_folder}/silence.mp3"
364
+ 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)
365
+
366
+ async def _tts():
367
+ parts = []
368
+ for i, p in enumerate(paragraphs):
369
+ rp = f"{temp_folder}/raw_{i:03d}.mp3"
370
+ await edge_tts.Communicate(p, voice, rate=f_speed).save(rp)
371
+ parts.append(rp)
372
+ parts.append(silence_path)
373
+ return parts
374
+ loop = asyncio.new_event_loop()
375
+ asyncio.set_event_loop(loop)
376
+ audio_parts = loop.run_until_complete(_tts())
377
+ loop.close()
378
+
379
+ list_file = f"{temp_folder}/list.txt"
380
+ with open(list_file, "w", encoding="utf-8") as f:
381
+ for a in audio_parts: f.write(f"file '{os.path.abspath(a)}'\n")
382
+ subprocess.run(
383
+ f'ffmpeg -f concat -safe 0 -i "{list_file}" '
384
+ f'-af "silenceremove=start_periods=1:stop_periods=-1:stop_duration=0.1:stop_threshold=-50dB" '
385
+ f'-c:a libmp3lame -q:a 2 "{combined_audio}"', shell=True, check=True)
386
+
387
+ # Sync
388
+ orig_dur = get_duration(video_path)
389
+ audio_dur = get_duration(combined_audio)
390
+ if orig_dur <= 0: raise Exception("Video duration ဖတ်မရပါ")
391
+ if audio_dur <= 0: raise Exception("Audio duration ဖတ်မရပါ")
392
+
393
+ raw_ratio = audio_dur / orig_dur
394
+ sync_ratio = max(0.5, min(3.0, raw_ratio))
395
+
396
+ # === Build video filters (exact Gradio original) ===
397
+ v_filters = []
398
+ if raw_ratio > 3.0:
399
+ loop_times = int(audio_dur / orig_dur) + 2
400
+ v_filters.append(f"loop={loop_times}:size=32767:start=0,trim=duration={audio_dur:.3f},setpts=PTS-STARTPTS")
401
+ elif raw_ratio < 0.5:
402
+ v_filters.append(f"trim=duration={audio_dur:.3f},setpts=PTS-STARTPTS")
403
+ else:
404
+ v_filters.append(f"setpts={sync_ratio:.6f}*PTS")
405
+
406
+ v_filters.append("crop=iw:ih*0.82:0:0,scale=iw:ih")
407
+ if flip: v_filters.append("hflip")
408
+ if color_bp: v_filters.append("eq=brightness=0.06:contrast=1.2:saturation=1.4")
409
+ v_filter_str = ",".join(v_filters)
410
+
411
+ # === Build layout ===
412
+ if tiktok_ratio:
413
+ v_layout = (
414
+ f"[0:v]{v_filter_str},scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280,boxblur=20:10[bg]; "
415
+ f"[0:v]{v_filter_str},scale=720:1280:force_original_aspect_ratio=decrease[fg]; "
416
+ f"[bg][fg]overlay=(W-w)/2:(H-h)/2"
417
+ )
418
+ else:
419
+ v_layout = f"[0:v]{v_filter_str}"
420
+
421
+ # === Blur Band ===
422
+ if blur_enabled:
423
+ h_pct = max(0.03, blur_h_pct / 100.0)
424
+ y_pct = max(0.0, min(0.95, blur_y_pct / 100.0))
425
+ y_expr = f"ih*{y_pct:.3f}"
426
+ blur_filter = (
427
+ f"[0:v]{v_filter_str}[base];"
428
+ f"[base]crop=iw:ih*{h_pct:.3f}:0:{y_expr}[band];"
429
+ f"[band]boxblur=25:5[blurred];"
430
+ f"[base][blurred]overlay=0:{y_expr}"
431
+ )
432
+ v_layout = blur_filter
433
+
434
+ # === Watermark ===
435
+ if channel_name:
436
+ clean_name = channel_name.replace("'","").replace("\\","").replace(":","")
437
+ 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]"
438
+ else:
439
+ vff = f"{v_layout}[outv]"
440
+
441
+ input_args = f'-fflags +genpts+igndts -err_detect ignore_err -i "{video_path}" -i "{combined_audio}"'
442
+ if music_path:
443
+ input_args += f' -stream_loop -1 -i "{music_path}"'
444
+ af = (f"[1:a]loudnorm=I=-14:TP=-1.5:LRA=11,volume=1.2[nar];"
445
+ f"[2:a]volume=0.15,afade=t=out:st={max(0,audio_dur-2):.3f}:d=2[bgm];"
446
+ f"[nar][bgm]amix=inputs=2:duration=first:dropout_transition=2[outa]")
447
+ else:
448
+ af = "[1:a]loudnorm=I=-14:TP=-1.5:LRA=11,volume=1.2[outa]"
449
+
450
+ cmd = (f'ffmpeg -y -hide_banner -loglevel warning {input_args} '
451
+ f'-filter_complex "{vff};{af}" '
452
+ f'-map "[outv]" -map "[outa]" '
453
+ f'-c:v libx264 -crf 23 -preset fast '
454
+ f'-c:a aac -ar 44100 -b:a 128k '
455
+ f'-t {audio_dur:.3f} -movflags +faststart "{output_video}"')
456
+ result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
457
+ if result.returncode != 0:
458
+ raise Exception(f"ffmpeg error: {result.stderr[-800:] if result.stderr else 'unknown'}")
459
+
460
+ # Cache output for streaming — don't send raw bytes
461
+ job_id = uuid.uuid4().hex[:10]
462
+ OUTPUT_CACHE[job_id] = {"path": output_video, "ts": time.time()}
463
+ fsize = os.path.getsize(output_video)
464
+ return jsonify({"job_id": job_id, "size_mb": round(fsize/1024/1024,1)})
465
+ except Exception as e:
466
+ return jsonify({"error": str(e)}), 500
467
+ finally:
468
+ if os.path.exists(temp_folder): shutil.rmtree(temp_folder, ignore_errors=True)
469
+ # Note: do NOT remove video_path here — it stays in VIDEO_CACHE
470
+ if music_path and os.path.exists(music_path): os.remove(music_path)
471
+
472
+ @app.route("/api/ping")
473
+ def api_ping():
474
+ import psutil, shutil as sh
475
+ disk = sh.disk_usage("/")
476
+ return jsonify({
477
+ "status": "ok",
478
+ "disk_free_gb": round(disk.free/1024**3, 1),
479
+ "cached_videos": len(VIDEO_CACHE),
480
+ "cached_outputs": len(OUTPUT_CACHE),
481
+ })
482
+
483
+ # ─── HTML UI ─────────────────────────────────────────────────────────────────
484
+
485
+ HTML_PAGE = """<!DOCTYPE html>
486
+ <html lang="my">
487
+ <head>
488
+ <meta charset="UTF-8">
489
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
490
+ <title>PS Movie Recap Pro</title>
491
+ <style>
492
+ *{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
493
+ body{background:#0e0e16;color:#e2e4ec;font-family:'Segoe UI',sans-serif;min-height:100vh;padding-bottom:40px}
494
+ ::-webkit-scrollbar{width:4px}::-webkit-scrollbar-thumb{background:#3a3a52;border-radius:8px}
495
+ .hdr{background:linear-gradient(135deg,rgba(124,58,237,.15),rgba(232,54,93,.08));
496
+ border-bottom:1px solid rgba(124,58,237,.2);padding:18px 16px 14px;text-align:center}
497
+ .hdr h1{font-size:20px;font-weight:900;
498
+ background:linear-gradient(120deg,#a78bfa,#e879f9,#f87171);
499
+ -webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:.5px}
500
+ .hdr p{color:#555a6e;font-size:12px;margin-top:4px}
501
+ .wrap{max-width:520px;margin:0 auto;padding:14px 14px 0}
502
+ .card{background:#16161f;border:1px solid #2a2a3a;border-radius:14px;margin-bottom:12px;overflow:hidden}
503
+ .card-hdr{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;cursor:pointer;user-select:none}
504
+ .card-hdr span{font-size:13.5px;font-weight:600;color:#b0b8cc}
505
+ .card-hdr .arrow{color:#555a6e;font-size:12px;transition:transform .2s}
506
+ .card-hdr.open .arrow{transform:rotate(180deg)}
507
+ .card-body{padding:0 14px 14px;display:none}
508
+ .card-body.show{display:block}
509
+ label{display:block;font-size:11.5px;color:#6b7280;font-weight:600;margin-bottom:5px;margin-top:12px}
510
+ label:first-child{margin-top:0}
511
+ input[type=text],select,textarea{width:100%;background:#1e1e2e;color:#e2e4ec;
512
+ border:1px solid #2e2e42;border-radius:10px;padding:10px 12px;font-size:13.5px;outline:none;
513
+ transition:border-color .2s,box-shadow .2s}
514
+ input:focus,select:focus,textarea:focus{border-color:#7c3aed;box-shadow:0 0 0 3px rgba(124,58,237,.15)}
515
+ textarea{resize:vertical;min-height:100px;line-height:1.6}
516
+ select{appearance:none;-webkit-appearance:none;
517
+ 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");
518
+ background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}
519
+ .slider-wrap{display:flex;align-items:center;gap:10px}
520
+ input[type=range]{flex:1;accent-color:#7c3aed;height:4px;cursor:pointer}
521
+ .slider-val{font-size:12px;color:#7c3aed;font-weight:700;min-width:36px;text-align:right}
522
+ .cb-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
523
+ .cb-item{display:flex;align-items:center;gap:6px;background:#1e1e2e;border:1px solid #2e2e42;
524
+ border-radius:8px;padding:8px 12px;cursor:pointer;flex:1;min-width:70px;justify-content:center;
525
+ font-size:12.5px;color:#9aa3b8;transition:all .15s;user-select:none}
526
+ .cb-item.active{background:rgba(124,58,237,.15);border-color:#7c3aed;color:#a78bfa}
527
+ .cb-item input{display:none}
528
+ /* Upload zone */
529
+ .upload-zone{border:2px dashed #2e2e42;border-radius:12px;text-align:center;cursor:pointer;
530
+ transition:all .2s;position:relative;overflow:hidden}
531
+ .upload-zone:hover,.upload-zone.drag{border-color:#7c3aed;background:rgba(124,58,237,.06)}
532
+ .upload-zone input{position:absolute;inset:0;opacity:0;cursor:pointer;width:100%;height:100%}
533
+ .upload-zone.has-file{border-color:#22c55e;background:rgba(34,197,94,.04)}
534
+ /* Video preview inside upload zone */
535
+ #videoPreviewWrap{display:none;position:relative;width:100%;background:#000;border-radius:10px;overflow:hidden}
536
+ #videoPreview{width:100%;display:block;max-height:220px;object-fit:contain}
537
+ .vid-overlay{position:absolute;top:8px;right:8px;background:rgba(0,0,0,.65);
538
+ color:#22c55e;font-size:11px;font-weight:700;padding:3px 8px;border-radius:6px}
539
+ /* Upload progress */
540
+ #uploadProgress{display:none;margin-top:8px}
541
+ .up-bar-wrap{background:#1e1e2e;border-radius:6px;height:5px;overflow:hidden}
542
+ .up-bar{height:100%;background:linear-gradient(90deg,#7c3aed,#e8365d);width:0;transition:width .1s;border-radius:6px}
543
+ .up-pct{text-align:right;font-size:11px;color:#7c3aed;font-weight:700;margin-top:3px}
544
+ /* Upload idle state */
545
+ .upload-idle{padding:22px 16px}
546
+ .upload-idle .icon{font-size:32px;margin-bottom:6px}
547
+ .upload-idle p{color:#6b7280;font-size:13px}
548
+ .upload-idle p b{color:#9aa3b8}
549
+ /* Radio pills */
550
+ .radio-group{display:flex;gap:8px}
551
+ .radio-pill{flex:1;text-align:center;padding:9px 8px;border-radius:10px;
552
+ background:#1e1e2e;border:1px solid #2e2e42;font-size:12.5px;color:#9aa3b8;
553
+ cursor:pointer;transition:all .15s;user-select:none}
554
+ .radio-pill.active{background:rgba(124,58,237,.2);border-color:#7c3aed;color:#c4b5fd;font-weight:700}
555
+ .btn{width:100%;padding:14px;border:none;border-radius:12px;
556
+ font-size:15px;font-weight:700;cursor:pointer;transition:all .2s;letter-spacing:.2px;margin-top:10px}
557
+ .btn-primary{background:linear-gradient(135deg,#7c3aed,#5b21b6);color:#fff;box-shadow:0 4px 18px rgba(124,58,237,.4)}
558
+ .btn-primary:active{transform:scale(.98)}
559
+ .btn-run{background:linear-gradient(135deg,#7c3aed,#e8365d);color:#fff;box-shadow:0 4px 18px rgba(124,58,237,.35)}
560
+ .btn-run:active{transform:scale(.98)}
561
+ .btn:disabled{opacity:.5;cursor:not-allowed;transform:none!important}
562
+ .btn-sm{width:auto;padding:8px 16px;font-size:13px;margin-top:0}
563
+ .status-bar{background:#1e1e2e;border:1px solid #2e2e42;border-radius:10px;
564
+ padding:10px 14px;font-size:12.5px;color:#8892a4;margin-top:10px;min-height:38px}
565
+ .status-bar.ok{color:#22c55e;border-color:#166534}
566
+ .status-bar.err{color:#ef4444;border-color:#7f1d1d}
567
+ .progress{background:#1e1e2e;border-radius:8px;height:6px;overflow:hidden;margin-top:8px;display:none}
568
+ .progress-bar{height:100%;background:linear-gradient(90deg,#7c3aed,#e8365d);width:0;transition:width .3s;border-radius:8px}
569
+ audio{width:100%;margin-top:8px;border-radius:8px;accent-color:#7c3aed}
570
+ video{width:100%;border-radius:12px;margin-top:8px;background:#000}
571
+ .dl-btn{background:linear-gradient(135deg,#059669,#047857);color:#fff;text-decoration:none;
572
+ border-radius:10px;padding:11px;text-align:center;font-weight:700;font-size:14px;
573
+ margin-top:8px;display:block}
574
+ </style>
575
+ </head>
576
+ <body>
577
+
578
+ <div class="hdr">
579
+ <h1>🎬 PS MOVIE RECAP PRO</h1>
580
+ <p>AI · Myanmar Dubbing · Auto Sync · TikTok Ready</p>
581
+ </div>
582
+
583
+ <div class="wrap">
584
+
585
+ <!-- Video Upload -->
586
+ <div class="card" style="margin-top:4px">
587
+ <div class="card-hdr open" onclick="toggle(this)">
588
+ <span>🎬 ရုပ်ရှင်ဖိုင် တင်ပါ</span><span class="arrow">▼</span>
589
+ </div>
590
+ <div class="card-body show">
591
+
592
+ <!-- Hidden file input -->
593
+ <input type="file" id="videoInput" accept="video/*"
594
+ style="display:none" onchange="onVideoSelect(this)">
595
+
596
+ <!-- Upload button — simple, reliable -->
597
+ <div id="uploadIdle">
598
+ <button type="button" onclick="document.getElementById('videoInput').click()"
599
+ style="width:100%;background:#1a1a2e;border:2px dashed #3b3b52;border-radius:12px;
600
+ padding:28px 16px;cursor:pointer;text-align:center;transition:border-color .2s"
601
+ onmouseover="this.style.borderColor='#7c3aed'"
602
+ onmouseout="this.style.borderColor='#3b3b52'">
603
+ <div style="font-size:38px;margin-bottom:8px">🎬</div>
604
+ <div style="font-weight:700;color:#c4b5fd;font-size:15px">ဗီဒီယိုဖိုင် ရွေးပါ</div>
605
+ <div style="font-size:12px;color:#4b5563;margin-top:5px">MP4 · MOV · AVI — မည်သည့် format မဆို</div>
606
+ </button>
607
+ </div>
608
+
609
+ <!-- Upload progress -->
610
+ <div id="uploadProgress" style="display:none;margin-top:8px;padding:14px;
611
+ background:#1a1a2e;border-radius:12px;border:1px solid #2e2e42">
612
+ <div style="font-size:13px;color:#a78bfa;font-weight:600;margin-bottom:8px">⬆️ Server သို့ တင်နေသည်...</div>
613
+ <div style="background:#0e0e16;border-radius:6px;height:8px;overflow:hidden">
614
+ <div id="upBar" style="height:100%;background:linear-gradient(90deg,#7c3aed,#e8365d);width:0;transition:width .2s;border-radius:6px"></div>
615
+ </div>
616
+ <div id="upPct" style="text-align:right;font-size:12px;color:#7c3aed;font-weight:700;margin-top:4px">0%</div>
617
+ </div>
618
+
619
+ <!-- Video preview + change button -->
620
+ <div id="videoPreviewWrap" style="display:none;margin-top:8px">
621
+ <video id="videoPreview" muted playsinline controls
622
+ style="width:100%;border-radius:10px;max-height:200px;background:#000;display:block"></video>
623
+ <div style="margin-top:6px;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:6px">
624
+ <div>
625
+ <div id="vidFileName" style="font-size:12px;color:#22c55e;font-weight:600"></div>
626
+ <div id="vidInfo" style="font-size:11px;color:#6b7280;margin-top:1px"></div>
627
+ </div>
628
+ <button type="button" onclick="document.getElementById('videoInput').click()"
629
+ style="background:#1e1e2e;border:1px solid #3b3b52;color:#9aa3b8;
630
+ border-radius:8px;padding:6px 12px;font-size:12px;cursor:pointer">
631
+ 🔄 ပြောင်းရန်
632
+ </button>
633
+ </div>
634
+ </div>
635
+
636
+ <!-- Upload status msg -->
637
+ <div id="uploadMsg" style="display:none;margin-top:8px;padding:8px 12px;
638
+ border-radius:8px;font-size:12.5px;font-weight:600"></div>
639
+
640
+ </div>
641
+ </div>
642
+
643
+ <!-- AI Settings -->
644
+ <div class="card">
645
+ <div class="card-hdr" onclick="toggle(this)">
646
+ <span>🤖 AI ဆက်တင်</span><span class="arrow">▼</span>
647
+ </div>
648
+ <div class="card-body">
649
+ <label>Content အမျိုးအစား</label>
650
+ <div class="radio-group">
651
+ <div class="radio-pill active" onclick="selectRadio(this,'contentType','Movie Recap')">🎬 Movie Recap</div>
652
+ <div class="radio-pill" onclick="selectRadio(this,'contentType','Medical/Health')">🏥 Medical</div>
653
+ </div>
654
+ <input type="hidden" id="contentType" value="Movie Recap">
655
+ <label>AI မော်ဒယ်</label>
656
+ <div class="radio-group">
657
+ <div class="radio-pill active" onclick="selectRadio(this,'apiChoice','Gemini')">✨ Gemini</div>
658
+ <div class="radio-pill" onclick="selectRadio(this,'apiChoice','DeepSeek')">🔮 DeepSeek</div>
659
+ </div>
660
+ <input type="hidden" id="apiChoice" value="Gemini">
661
+ </div>
662
+ </div>
663
+
664
+ <!-- Voice -->
665
+ <div class="card">
666
+ <div class="card-hdr" onclick="toggle(this)">
667
+ <span>🎙️ အသံဆက်တင်</span><span class="arrow">▼</span>
668
+ </div>
669
+ <div class="card-body">
670
+ <label>အသံပညာရှင်</label>
671
+ <select id="voiceSelect">
672
+ <option>သိဟ် (ကျား)</option>
673
+ <option>နီလာ (မိန်း)</option>
674
+ <option>အန်ဒရူး (ကျား)</option>
675
+ <option>ဝီလျံ (ကျား)</option>
676
+ <option>အာဗာ (မိန်း)</option>
677
+ <option>ဘရိုင်ယန် (ကျား)</option>
678
+ <option>အယ်မာ (မိန်း)</option>
679
+ <option>ဗီဗီယန် (မိန်း)</option>
680
+ <option>စီရာဖီနာ (မိန်း)</option>
681
+ <option>သာလီတာ (မိန်း)</option>
682
+ </select>
683
+ <label>အမြန်နှုန်း</label>
684
+ <div class="slider-wrap">
685
+ <input type="range" min="0" max="50" value="30" id="speedSlider"
686
+ oninput="document.getElementById('speedVal').innerText=this.value+'%'">
687
+ <span class="slider-val" id="speedVal">30%</span>
688
+ </div>
689
+ <button class="btn btn-primary btn-sm" style="margin-top:12px" onclick="previewVoice(event)">▶ စမ်းနားထောင်</button>
690
+ <audio id="previewAudio" controls style="display:none"></audio>
691
+ </div>
692
+ </div>
693
+
694
+ <!-- Script Generate -->
695
+ <button class="btn btn-primary" id="scriptBtn" onclick="generateScript()" disabled>
696
+ 📝 Script ထုတ်မည်
697
+ </button>
698
+ <div class="status-bar" id="apiStatus">⏳ Video တင်ပြီးမှ Script ထုတ်နိုင်မည်</div>
699
+
700
+ <!-- Script Editor -->
701
+ <div class="card" style="margin-top:12px">
702
+ <div class="card-hdr open" onclick="toggle(this)">
703
+ <span>✨ AI ထုတ်ပေးသောအကြောင်းအရာ</span><span class="arrow">▼</span>
704
+ </div>
705
+ <div class="card-body show">
706
+ <label>🎬 ခေါင်းစဉ် & Hashtags</label>
707
+ <textarea id="viralTitle" rows="3" placeholder="AI ထုတ်ပေးမည်..."></textarea>
708
+ <label>📝 မြန်မာ Script</label>
709
+ <textarea id="scriptText" rows="10" placeholder="Script ဤနေရာတွင် ပေါ်လာမည်..."></textarea>
710
+ </div>
711
+ </div>
712
+
713
+ <!-- Video Effects -->
714
+ <div class="card">
715
+ <div class="card-hdr" onclick="toggle(this)">
716
+ <span>🎨 ဗီဒီယို Effect</span><span class="arrow">▼</span>
717
+ </div>
718
+ <div class="card-body">
719
+ <label>💧 Watermark</label>
720
+ <input type="text" id="watermark" value="MM RECAP">
721
+ <label>Effect</label>
722
+ <div class="cb-row">
723
+ <label class="cb-item active" onclick="toggleCb(this,'cbFlip')">
724
+ <input type="checkbox" id="cbFlip" checked>↔ Flip
725
+ </label>
726
+ <label class="cb-item active" onclick="toggleCb(this,'cbColor')">
727
+ <input type="checkbox" id="cbColor" checked>✨ Color+
728
+ </label>
729
+ <label class="cb-item" onclick="toggleCb(this,'cbTiktok')">
730
+ <input type="checkbox" id="cbTiktok">📱 9:16
731
+ </label>
732
+ </div>
733
+ <label style="margin-top:12px">🎵 နောက်ခံသီချင်း (ချန်ထားရင်လည်းရ)</label>
734
+ <input type="file" id="bgmInput" accept="audio/*"
735
+ style="display:none" onchange="onBgmSelect(this)">
736
+ <button type="button" onclick="document.getElementById('bgmInput').click()"
737
+ style="width:100%;background:#1a1a2e;border:2px dashed #2e2e42;border-radius:10px;
738
+ padding:12px 14px;cursor:pointer;display:flex;align-items:center;gap:10px;
739
+ transition:border-color .2s;margin-top:4px"
740
+ onmouseover="this.style.borderColor='#7c3aed'"
741
+ onmouseout="this.style.borderColor='#2e2e42'">
742
+ <span style="font-size:22px">🎵</span>
743
+ <span id="bgmLabel" style="font-size:12.5px;color:#6b7280">MP3 / WAV ဖိုင် တင်ပါ</span>
744
+ </button>
745
+ </div>
746
+ </div>
747
+
748
+ <!-- Blur -->
749
+ <div class="card">
750
+ <div class="card-hdr" onclick="toggle(this)">
751
+ <span>🌫️ Blur ဖုံးကွယ်ရန်</span><span class="arrow">▼</span>
752
+ </div>
753
+ <div class="card-body">
754
+ <label class="cb-item" style="justify-content:flex-start;gap:10px;max-width:180px;margin-top:0"
755
+ onclick="toggleCb(this,'cbBlur')">
756
+ <input type="checkbox" id="cbBlur">Blur ဖွင့်မည်
757
+ </label>
758
+ <label style="margin-top:12px">📍 နေရာ % (0=ထိပ် → 90=အောက်ဆုံး)</label>
759
+ <div class="slider-wrap">
760
+ <input type="range" min="0" max="90" value="75" id="blurY"
761
+ oninput="document.getElementById('blurYVal').innerText=this.value+'%'">
762
+ <span class="slider-val" id="blurYVal">75%</span>
763
+ </div>
764
+ <label>↕ အမြင့် % (subtitle ≈ 10–12%)</label>
765
+ <div class="slider-wrap">
766
+ <input type="range" min="3" max="30" value="12" id="blurH"
767
+ oninput="document.getElementById('blurHVal').innerText=this.value+'%'">
768
+ <span class="slider-val" id="blurHVal">12%</span>
769
+ </div>
770
+ </div>
771
+ </div>
772
+
773
+ <!-- Run -->
774
+ <button class="btn btn-run" id="runBtn" onclick="produce()" disabled>
775
+ 🚀 ဗီဒီယိုထုတ်မည်
776
+ </button>
777
+ <div class="progress" id="progressWrap">
778
+ <div class="progress-bar" id="progressBar"></div>
779
+ </div>
780
+ <div class="status-bar" id="runStatus" style="display:none"></div>
781
+
782
+ <!-- Output -->
783
+ <div id="outputSection" style="display:none;margin-top:12px">
784
+ <div class="card">
785
+ <div class="card-hdr open" onclick="toggle(this)">
786
+ <span>📦 ပြီးသောဗီဒီယို</span><span class="arrow">▼</span>
787
+ </div>
788
+ <div class="card-body show">
789
+ <video id="outputVideo" controls></video>
790
+ <a id="dlBtn" class="dl-btn" download="recap_output.mp4">📥 ဒေါင်းလုဒ် (MP4)</a>
791
+ </div>
792
+ </div>
793
+ </div>
794
+
795
+ </div>
796
+
797
+ <script>
798
+ var videoFile=null, bgmFile=null, cacheId=null;
799
+
800
+ function toggle(hdr){
801
+ hdr.classList.toggle('open');
802
+ hdr.nextElementSibling.classList.toggle('show');
803
+ }
804
+ function selectRadio(el,fid,val){
805
+ el.closest('.radio-group').querySelectorAll('.radio-pill').forEach(p=>p.classList.remove('active'));
806
+ el.classList.add('active');
807
+ document.getElementById(fid).value=val;
808
+ }
809
+ function toggleCb(lbl,id){
810
+ var cb=document.getElementById(id);
811
+ cb.checked=!cb.checked;
812
+ lbl.classList.toggle('active',cb.checked);
813
+ }
814
+
815
+ // ── Video select: preview locally → upload to server with real % progress ──
816
+ function onVideoSelect(input){
817
+ if(!input.files || !input.files[0]) return;
818
+ var file = input.files[0];
819
+ videoFile = file;
820
+ cacheId = null;
821
+ var sizeMB = (file.size/1024/1024).toFixed(1);
822
+
823
+ // Show local preview immediately (no server wait needed)
824
+ document.getElementById('uploadIdle').style.display = 'none';
825
+ document.getElementById('uploadProgress').style.display = 'block';
826
+ document.getElementById('videoPreviewWrap').style.display = 'none';
827
+ document.getElementById('uploadMsg').style.display = 'none';
828
+ // upload started
829
+
830
+ // Set local video preview src (plays from device)
831
+ var localUrl = URL.createObjectURL(file);
832
+ var vid = document.getElementById('videoPreview');
833
+ vid.src = localUrl;
834
+ vid.onloadedmetadata = function(){
835
+ var dur = Math.round(vid.duration);
836
+ var m = Math.floor(dur/60), s = dur % 60;
837
+ document.getElementById('vidInfo').textContent =
838
+ m + 'min ' + s + 'sec · ' + sizeMB + ' MB';
839
+ };
840
+ document.getElementById('vidFileName').textContent = '✅ ' + file.name;
841
+
842
+ // Upload to server
843
+ var fd = new FormData();
844
+ fd.append('video', file);
845
+ var xhr = new XMLHttpRequest();
846
+ xhr.open('POST', '/api/upload_video');
847
+ xhr.timeout = 360000; // 6 minutes
848
+
849
+ xhr.upload.onprogress = function(e){
850
+ if(e.lengthComputable){
851
+ var pct = Math.round(e.loaded / e.total * 100);
852
+ var sent = (e.loaded/1024/1024).toFixed(1);
853
+ document.getElementById('upBar').style.width = pct + '%';
854
+ document.getElementById('upPct').textContent =
855
+ pct + '% (' + sent + ' / ' + sizeMB + ' MB)';
856
+ }
857
+ };
858
+
859
+ xhr.onload = function(){
860
+ document.getElementById('uploadProgress').style.display = 'none';
861
+ document.getElementById('videoPreviewWrap').style.display = 'block';
862
+
863
+ var msg = document.getElementById('uploadMsg');
864
+ msg.style.display = 'block';
865
+
866
+ if(xhr.status === 413){
867
+ msg.style.background = 'rgba(239,68,68,.15)';
868
+ msg.style.color = '#ef4444';
869
+ msg.textContent = '❌ ဖိုင်ကြီးလွန်း (413) — 200MB အောက် compress ပြီး ထပ်ကြိုးစားပါ';
870
+ return;
871
+ }
872
+ if(xhr.status !== 200){
873
+ msg.style.background = 'rgba(239,68,68,.15)';
874
+ msg.style.color = '#ef4444';
875
+ msg.textContent = '❌ Upload မရောက် (HTTP ' + xhr.status + ') — ' + xhr.responseText.slice(0,100);
876
+ return;
877
+ }
878
+ try{
879
+ var d = JSON.parse(xhr.responseText);
880
+ if(d.error){
881
+ msg.style.background = 'rgba(239,68,68,.15)';
882
+ msg.style.color = '#ef4444';
883
+ msg.textContent = '❌ ' + d.error;
884
+ return;
885
+ }
886
+ cacheId = d.cache_id;
887
+ msg.style.background = 'rgba(34,197,94,.1)';
888
+ msg.style.color = '#22c55e';
889
+ msg.textContent = '✅ Server သို့ပြီးပြီ (' + d.size_mb + ' MB) — Script ထုတ်နိုင်ပြီ';
890
+ // upload done
891
+ document.getElementById('scriptBtn').disabled = false;
892
+ document.getElementById('runBtn').disabled = false;
893
+ }catch(e){
894
+ msg.style.background = 'rgba(239,68,68,.15)';
895
+ msg.style.color = '#ef4444';
896
+ msg.textContent = '❌ Server response error: ' + xhr.responseText.slice(0,100);
897
+ }
898
+ };
899
+
900
+ xhr.ontimeout = function(){
901
+ document.getElementById('uploadProgress').style.display = 'none';
902
+ document.getElementById('videoPreviewWrap').style.display = 'block';
903
+ var msg = document.getElementById('uploadMsg');
904
+ msg.style.display = 'block';
905
+ msg.style.background = 'rgba(245,158,11,.1)';
906
+ msg.style.color = '#f59e0b';
907
+ msg.textContent = '⏱️ Upload timeout — ဖိုင်သေးသော (100MB↓) video သုံးပါ';
908
+ };
909
+
910
+ xhr.onerror = function(){
911
+ document.getElementById('uploadProgress').style.display = 'none';
912
+ document.getElementById('videoPreviewWrap').style.display = 'block';
913
+ var msg = document.getElementById('uploadMsg');
914
+ msg.style.display = 'block';
915
+ msg.style.background = 'rgba(239,68,68,.15)';
916
+ msg.style.color = '#ef4444';
917
+ msg.textContent = '❌ Network error — Internet စစ်ပြီး ပြန်ကြိုးစားပါ';
918
+ };
919
+
920
+ xhr.send(fd);
921
+ }
922
+ function setUpPct(p){
923
+ p=Math.round(p);
924
+ document.getElementById('upBar').style.width=p+'%';
925
+ document.getElementById('upPct').textContent=p+'%';
926
+ }
927
+
928
+ function onBgmSelect(input){
929
+ if(!input.files[0]) return;
930
+ bgmFile=input.files[0];
931
+ document.getElementById('bgmZone').classList.add('has-file');
932
+ document.getElementById('bgmLabel').textContent='✅ '+bgmFile.name;
933
+ }
934
+
935
+ // Drag-drop on entire page for video
936
+ document.addEventListener('dragover', function(ev){ ev.preventDefault(); });
937
+ document.addEventListener('drop', function(ev){
938
+ ev.preventDefault();
939
+ var f = ev.dataTransfer && ev.dataTransfer.files[0];
940
+ if(f && f.type.startsWith('video/')){
941
+ try{
942
+ var dt = new DataTransfer();
943
+ dt.items.add(f);
944
+ document.getElementById('videoInput').files = dt.files;
945
+ onVideoSelect(document.getElementById('videoInput'));
946
+ }catch(e){}
947
+ }
948
+ });
949
+
950
+ async function previewVoice(e){
951
+ var btn=e.currentTarget; btn.disabled=true; btn.textContent='⏳...';
952
+ try{
953
+ var r=await fetch('/api/preview_voice',{method:'POST',
954
+ headers:{'Content-Type':'application/json'},
955
+ body:JSON.stringify({voice:document.getElementById('voiceSelect').value,
956
+ speed:document.getElementById('speedSlider').value})});
957
+ var blob=await r.blob();
958
+ var a=document.getElementById('previewAudio');
959
+ a.src=URL.createObjectURL(blob); a.style.display='block'; a.play();
960
+ }catch(err){alert('Preview မရပါ')}
961
+ btn.disabled=false; btn.textContent='▶ စမ်းနားထောင်';
962
+ }
963
+
964
+ // Script generate — cache_id သာ ပို့ (video ထပ်မဆွဲ)
965
+ function generateScript(){
966
+ if(!cacheId){
967
+ setStatus('apiStatus','⏳ Video upload မပြီးသေးပါ... ခဏစောင့်ပါ',''); return;
968
+ }
969
+ var btn=document.getElementById('scriptBtn');
970
+ btn.disabled=true; btn.textContent='⏳ Script ထုတ်နေသည်...';
971
+ setStatus('apiStatus','⏳ AI ဖြင့် Script ထုတ်နေသည်... (၁-၂ မိနစ် ကြာနိုင်သည်)','');
972
+ var fd=new FormData();
973
+ fd.append('cache_id',cacheId);
974
+ fd.append('api_choice',document.getElementById('apiChoice').value);
975
+ fd.append('content_type',document.getElementById('contentType').value);
976
+ var xhr=new XMLHttpRequest();
977
+ xhr.open('POST','/api/generate_script');
978
+ xhr.onload=function(){
979
+ try{
980
+ var d=JSON.parse(xhr.responseText);
981
+ if(d.error){setStatus('apiStatus','❌ '+d.error,'err');
982
+ btn.disabled=false; btn.textContent='📝 Script ထုတ်မည်'; return;}
983
+ document.getElementById('scriptText').value=d.script;
984
+ var title=d.title.split('\n')[0].trim();
985
+ document.getElementById('viralTitle').value=
986
+ title+'\n\n#movierecap #မြန်မာ #viral #ဇာတ်လမ်း #mmrecap #tiktok';
987
+ if(d.cache_id) cacheId=d.cache_id;
988
+ setStatus('apiStatus',d.status,'ok');
989
+ }catch(e){setStatus('apiStatus','❌ Parse error','err')}
990
+ btn.disabled=false; btn.textContent='📝 Script ထုတ်မည်';
991
+ };
992
+ xhr.onerror=function(){
993
+ setStatus('apiStatus','❌ Network error','err');
994
+ btn.disabled=false; btn.textContent='📝 Script ထုတ်မည်';
995
+ };
996
+ xhr.send(fd);
997
+ }
998
+
999
+ // Produce — video ထပ်မဆွဲ၊ server streaming ဖြင့် ကြည့်ရှု + download
1000
+ async function produce(){
1001
+ if(!cacheId){ alert('Video cache မရှိပါ — ဦးစွာ video တင်ပြီး upload ပြီးသည်အထိ စောင့်ပါ'); return; }
1002
+ var script=document.getElementById('scriptText').value.trim();
1003
+ if(!script){alert('Script မရှိပါ');return}
1004
+ var btn=document.getElementById('runBtn');
1005
+ btn.disabled=true; btn.textContent='⏳ ဗီဒီယိုထုတ်နေသည်...';
1006
+ var pw=document.getElementById('progressWrap'), pb=document.getElementById('progressBar');
1007
+ pw.style.display='block'; pb.style.width='8%';
1008
+ setStatus('runStatus','🎙️ အသံသွင်းနေသည်...','');
1009
+ document.getElementById('runStatus').style.display='block';
1010
+ var fd=new FormData();
1011
+ fd.append('cache_id',cacheId);
1012
+ fd.append('script',script);
1013
+ fd.append('title',document.getElementById('viralTitle').value);
1014
+ fd.append('voice',document.getElementById('voiceSelect').value);
1015
+ fd.append('speed',document.getElementById('speedSlider').value);
1016
+ fd.append('watermark',document.getElementById('watermark').value);
1017
+ fd.append('flip',document.getElementById('cbFlip').checked);
1018
+ fd.append('color',document.getElementById('cbColor').checked);
1019
+ fd.append('tiktok',document.getElementById('cbTiktok').checked);
1020
+ fd.append('blur',document.getElementById('cbBlur').checked);
1021
+ fd.append('blur_y',document.getElementById('blurY').value);
1022
+ fd.append('blur_h',document.getElementById('blurH').value);
1023
+ if(bgmFile) fd.append('bgm',bgmFile);
1024
+ var pct=8;
1025
+ var timer=setInterval(()=>{
1026
+ pct=Math.min(pct+3,88); pb.style.width=pct+'%';
1027
+ if(pct>40) setStatus('runStatus','🎬 ဗီဒီယိုထုတ်နေသည်...','');
1028
+ },2500);
1029
+ try{
1030
+ var r=await fetch('/api/produce',{method:'POST',body:fd});
1031
+ clearInterval(timer); pb.style.width='100%';
1032
+ var d=await r.json();
1033
+ if(!r.ok||d.error){setStatus('runStatus','❌ '+(d.error||'Error'),'err'); return;}
1034
+ // Video player → stream route (Range support)
1035
+ // Download button → download route (attachment)
1036
+ var streamUrl = '/api/stream/' + d.job_id;
1037
+ var dlUrl = '/api/download/' + d.job_id;
1038
+ var vidEl = document.getElementById('outputVideo');
1039
+ vidEl.src = '';
1040
+ setTimeout(function(){ vidEl.src = streamUrl; vidEl.load(); }, 100);
1041
+ var dlA = document.getElementById('dlBtn');
1042
+ dlA.href = dlUrl;
1043
+ dlA.removeAttribute('download'); // let server set Content-Disposition
1044
+ document.getElementById('outputSection').style.display='block';
1045
+ setStatus('runStatus','✅ ဗီဒီယိုထုတ်ပြီးပါပြီ! ('+d.size_mb+' MB)','ok');
1046
+ document.getElementById('outputSection').scrollIntoView({behavior:'smooth'});
1047
+ }catch(e){
1048
+ clearInterval(timer);
1049
+ setStatus('runStatus','❌ Error: '+e,'err');
1050
+ }
1051
+ btn.disabled=false; btn.textContent='🚀 ဗီဒီယိုထုတ်မည်';
1052
+ }
1053
+
1054
+ function setStatus(id,msg,type){
1055
+ var el=document.getElementById(id);
1056
+ el.textContent=msg;
1057
+ el.className='status-bar'+(type?' '+type:'');
1058
+ }
1059
+ </script>
1060
+ </body>
1061
+ </html>"""
1062
+
1063
+ if __name__ == "__main__":
1064
+ app.run(host="0.0.0.0", port=7860, debug=False, threaded=True)