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