Phoe2004 commited on
Commit
87713b8
·
verified ·
1 Parent(s): 4e36f8f

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +1249 -265
  2. index.html +1278 -300
app.py CHANGED
@@ -1,287 +1,1271 @@
1
- # --- SYSTEM CHECK START ---
2
- print("--- RECAP MAKER SYSTEM STARTING ---")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  try:
4
- import flask
5
- from flask import Flask, render_template, request, jsonify, session, send_file, after_this_request
6
- import yt_dlp
7
- import ffmpeg
8
- import edge_tts
9
- import speech_recognition
10
- import groq
11
- import queue
12
- print("✅ All Libraries Loaded Successfully!")
13
- except ImportError as e:
14
- print(f"❌ CRITICAL ERROR: Missing Library -> {e}")
15
- exit()
16
- # --- SYSTEM CHECK END ---
17
-
18
- import os
19
- import uuid
20
- import logging
21
- import threading
22
- import time
23
- from werkzeug.utils import secure_filename
24
- from utils import process_video_edit, create_ai_audio, analyze_script_with_ai
25
-
26
- # Disable heavy logging
27
- log = logging.getLogger('werkzeug')
28
- log.setLevel(logging.ERROR)
29
-
30
- # --- 1. TEMPLATE FOLDER CHANGE ---
31
- app = Flask(__name__, template_folder='.')
32
- app.secret_key = os.environ.get('SECRET_KEY', 'secure-recap-maker-key')
33
-
34
- # --- CONFIG ---
35
- BASE_DIR = os.path.abspath(os.path.dirname(__file__))
36
- UPLOAD_FOLDER = os.path.join(BASE_DIR, 'static/uploads')
37
- PROCESSED_FOLDER = os.path.join(BASE_DIR, 'static/processed')
38
-
39
- for f in [UPLOAD_FOLDER, PROCESSED_FOLDER]:
40
- os.makedirs(f, exist_ok=True)
41
-
42
- # --- QUEUE SYSTEM ---
43
- task_queue = queue.Queue()
44
- jobs = {}
45
-
46
- # --- WORKER 1: VIDEO PROCESSING ---
47
- def worker():
48
- print("👷 Video Processing Worker Started...")
49
- while True:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  try:
51
- job_id, input_p, output_p, opts = task_queue.get()
52
- print(f"⚙️ Worker: Starting Job {job_id}")
53
- jobs[job_id]['status'] = 'processing'
54
-
55
- success, err_msg = process_video_edit(input_p, output_p, opts)
56
-
57
- if success:
58
- filename = os.path.basename(output_p)
59
- download_url = f"/stream-and-delete/{filename}"
60
- jobs[job_id] = {'status': 'success', 'url': download_url}
61
- print(f"✅ Job {job_id} Complete!")
62
- else:
63
- jobs[job_id] = {'status': 'failed', 'message': f'Rendering Failed: {err_msg}'}
64
- print(f"❌ Job {job_id} Failed! Reason: {err_msg}")
65
 
66
- task_queue.task_done()
67
- except Exception as e:
68
- print(f"Worker Crash: {e}")
69
- if 'job_id' in locals():
70
- jobs[job_id] = {'status': 'failed', 'message': str(e)}
71
-
72
- # --- WORKER 2: AUTO CLEANUP ---
73
- def cleanup_worker():
74
- print("🧹 Auto Cleanup Worker Started...")
75
- while True:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  try:
77
- time.sleep(600)
78
- now = time.time()
79
- cutoff = 1800
80
-
81
- folders = [UPLOAD_FOLDER, PROCESSED_FOLDER]
82
- deleted_count = 0
83
-
84
- for folder in folders:
85
- if not os.path.exists(folder): continue
86
- for filename in os.listdir(folder):
87
- file_path = os.path.join(folder, filename)
88
- if not os.path.isfile(file_path) or filename.startswith('.'): continue
89
-
90
- try:
91
- file_age = now - os.path.getmtime(file_path)
92
- if file_age > cutoff:
93
- os.remove(file_path)
94
- deleted_count += 1
95
- except Exception as e:
96
- print(f"⚠️ Cleanup Access Error: {filename} - {e}")
97
-
98
- if deleted_count > 0:
99
- print(f"🗑️ Cleaned up {deleted_count} old files.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  except Exception as e:
101
- print(f"Cleanup Loop Error: {e}")
 
 
 
102
 
103
- threading.Thread(target=worker, daemon=True).start()
104
- threading.Thread(target=cleanup_worker, daemon=True).start()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
- # --- HELPER: GET USER ID ---
107
- def get_user_id():
108
- if 'user_id' not in session:
109
- session['user_id'] = str(uuid.uuid4())[:6]
110
- return session['user_id']
111
 
112
- # --- ROUTES ---
 
 
113
 
114
  @app.route('/')
115
- def home():
116
- uid = get_user_id()
117
- return render_template('index.html', user_id=uid)
 
 
 
118
 
119
- @app.route('/stream-and-delete/<filename>')
120
- def stream_and_delete(filename):
 
121
  try:
122
- path = os.path.join(PROCESSED_FOLDER, filename)
123
- if not os.path.exists(path):
124
- return jsonify({'status': 'error', 'message': 'File not found or expired'}), 404
 
 
125
 
126
- @after_this_request
127
- def remove_file(response):
128
- try:
129
- if os.path.exists(path):
130
- os.remove(path)
131
- except Exception as e:
132
- print(f"⚠️ Error deleting {filename}: {e}")
133
- return response
 
 
 
 
 
 
 
134
 
135
- return send_file(path, as_attachment=False)
 
 
 
 
 
 
 
 
 
 
 
 
136
  except Exception as e:
137
- return jsonify({'status': 'error', 'message': str(e)}), 500
 
138
 
139
- @app.route('/upload-video', methods=['POST'])
140
- def up_video():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  try:
142
- if 'video_file' not in request.files:
143
- return jsonify({'status':'error', 'message': 'No file part'})
144
-
145
- f = request.files['video_file']
146
- if f.filename == '':
147
- return jsonify({'status':'error', 'message': 'No selected file'})
148
-
149
- ext = f.filename.rsplit('.', 1)[1].lower() if '.' in f.filename else 'mp4'
150
- secure_uuid = uuid.uuid4().hex
151
- safe_name = f"vid_{secure_uuid}.{ext}"
152
-
153
- path = os.path.join(UPLOAD_FOLDER, safe_name)
154
- f.save(path)
155
-
156
- return jsonify({
157
- 'status':'success',
158
- 'filename':safe_name,
159
- 'path':f'/static/uploads/{safe_name}',
160
- 'translated_text': ''
161
- })
162
- except Exception as e:
163
- print(f"Upload Error: {e}")
164
- return jsonify({'status':'error', 'message':str(e)})
165
-
166
- @app.route('/download-video', methods=['POST'])
167
- def dl_video():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  try:
169
- url = request.json.get('url')
170
- if not url: return jsonify({'status':'error', 'message': 'No URL'})
171
-
172
- secure_uuid = uuid.uuid4().hex
173
-
174
- # --- FIX FOR HUGGING FACE YOUTUBE ERROR ---
175
- opts = {
176
- 'outtmpl': os.path.join(UPLOAD_FOLDER, f'vid_{secure_uuid}.%(ext)s'),
177
- # Transcribe လုပ်ဖို့ Audio သီးသန့်ပဲ ဒေါင်းပါမည် (ပိုမြန်ပြီး Block ခံရမှု နည်းပါသည်)
178
- 'format': 'bestaudio/best',
179
- 'noplaylist': True,
180
- 'quiet': True,
181
- 'nocheckcertificate': True,
182
- # YouTube ကို Android Client အနေနဲ့ လှည့်စားပြီး ဝင်ပါမည်
183
- 'extractor_args': {'youtube': {'player_client': ['android']}},
184
- 'retries': 3
185
- }
186
-
187
- with yt_dlp.YoutubeDL(opts) as ydl:
188
- ydl.download([url])
189
-
190
- target_prefix = f"vid_{secure_uuid}"
191
- f = next((x for x in os.listdir(UPLOAD_FOLDER) if x.startswith(target_prefix)), None)
192
-
193
- if f:
194
- path = os.path.join(UPLOAD_FOLDER, f)
195
- txt = analyze_script_with_ai(path)
196
- return jsonify({'status':'success', 'filename':f, 'path':f'/static/uploads/{f}', 'translated_text':txt})
197
- return jsonify({'status':'error', 'message': 'Download failed'})
198
- except Exception as e: return jsonify({'status':'error', 'message':str(e)})
199
-
200
- @app.route('/re-analyze', methods=['POST'])
201
- def re_analyze():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  try:
203
- filename = request.form.get('filename')
204
-
205
- if not filename: return jsonify({'status':'error', 'message':'No file specified'})
206
-
207
- path = os.path.join(UPLOAD_FOLDER, filename)
208
-
209
- if not os.path.exists(path):
210
- return jsonify({'status':'error', 'message':'File not found (Expired)'})
211
-
212
- txt = analyze_script_with_ai(path)
213
- return jsonify({'status':'success', 'translated_text': txt})
214
- except Exception as e: return jsonify({'status':'error', 'message':str(e)})
215
-
216
- @app.route('/process', methods=['POST'])
217
- def start_process():
218
  try:
219
- d = request.form
220
- vname = d.get('video_filename')
221
-
222
- if not vname:
223
- return jsonify({'status':'error', 'message':'No video selected'})
224
-
225
- ip = os.path.join(UPLOAD_FOLDER, vname)
226
- if not os.path.exists(ip):
227
- return jsonify({'status':'error', 'message':'Source video not found (Expired)'})
228
-
229
- job_id = uuid.uuid4().hex
230
- op = os.path.join(PROCESSED_FOLDER, f"recap_{job_id}.mp4")
231
-
232
- def is_on(k): return d.get(k) in ['on', 'true', '1']
233
-
234
- opts = {
235
- 'text_watermark': d.get('text_watermark'),
236
- 'text_x': int(float(d.get('text_x', 10))),
237
- 'text_y': int(float(d.get('text_y', 10))),
238
- 'blur_enabled': is_on('blur_enabled'),
239
- 'blur_x': int(float(d.get('blur_x',0))), 'blur_y': int(float(d.get('blur_y',0))),
240
- 'blur_w': int(float(d.get('blur_w',0))), 'blur_h': int(float(d.get('blur_h',0))),
241
- 'logo_x': int(float(d.get('logo_x',1))), 'logo_y': int(float(d.get('logo_y',1))),
242
- 'logo_w': int(float(d.get('logo_w',100))), 'logo_h': int(float(d.get('logo_h',100))),
243
- 'bypass_flip': is_on('bypass_flip'),
244
- 'bypass_zoom': is_on('bypass_zoom'),
245
- 'bypass_speed': is_on('bypass_speed'),
246
- 'bypass_color': is_on('bypass_color'),
247
- 'monezlation': is_on('monezlation'),
248
- 'auto_subtitles': is_on('auto_subtitles'),
249
- }
250
-
251
- # Auto Subtitles အတွက် SRT ဖိုင်လမ်းကြောင်း သတ်မှတ်ခြင်း
252
- srt_path = None
253
- if opts['auto_subtitles']:
254
- srt_path = os.path.join(UPLOAD_FOLDER, f"sub_{job_id}.srt")
255
- opts['srt_path'] = srt_path
256
-
257
- if request.files.get('logo_file'):
258
- l = request.files['logo_file']
259
- if l.filename:
260
- lp = os.path.join(UPLOAD_FOLDER, f"logo_{job_id}.png")
261
- l.save(lp)
262
- opts['logo_path'] = lp
263
-
264
- if d.get('ai_text'):
265
- ap = os.path.join(UPLOAD_FOLDER, f"audio_{job_id}.mp3")
266
- gender = d.get('voice_gender','male')
267
- # utils.py သို့ srt_path ပါ ပို့ပေးခြင်း
268
- if create_ai_audio(d.get('ai_text'), ap, gender, srt_path):
269
- opts['ai_audio_path'] = ap
270
- opts['ai_text'] = d.get('ai_text')
271
-
272
- jobs[job_id] = {'status': 'queued'}
273
- task_queue.put((job_id, ip, op, opts))
274
-
275
- return jsonify({'status':'queued', 'job_id': job_id, 'message': 'Added to Queue'})
276
-
277
- except Exception as e: return jsonify({'status':'error', 'message':str(e)})
278
-
279
- @app.route('/status/<job_id>')
280
- def check_status(job_id):
281
- job = jobs.get(job_id)
282
- if not job: return jsonify({'status': 'not_found'})
283
- return jsonify(job)
284
 
285
  if __name__ == '__main__':
286
- print("🚀 Recap Maker Server Running on Port 7860...")
287
- app.run(debug=False, port=7860, host='0.0.0.0')
 
1
+ import os, json, hashlib, uuid, random, re, glob, shutil, subprocess, threading, time, struct, wave
2
+ from collections import defaultdict
3
+
4
+ # ══════════════════════════════════════════════
5
+ # STAGE-BASED PIPELINE SYSTEM
6
+ # ══════════════════════════════════════════════
7
+
8
+ # Per-stage locks — only 1 job per stage at a time for CPU-heavy stages
9
+ _stage_locks = {
10
+ 'whisper': threading.Lock(),
11
+ 'ai': threading.Semaphore(3), # 3 concurrent AI API calls ok
12
+ 'tts': threading.Semaphore(2), # 2 concurrent TTS ok
13
+ 'ffmpeg': threading.Lock(), # 1 ffmpeg at a time (CPU)
14
+ }
15
+
16
+ # Last finish time per stage — enforce gap between jobs
17
+ _stage_last = {
18
+ 'whisper': 0.0,
19
+ 'ai': 0.0,
20
+ 'tts': 0.0,
21
+ 'ffmpeg': 0.0,
22
+ }
23
+ _stage_time_lock = threading.Lock()
24
+
25
+ # Minimum gap (seconds) between consecutive jobs per stage
26
+ STAGE_GAPS = {
27
+ 'whisper': 3,
28
+ 'ai': 2,
29
+ 'tts': 2,
30
+ 'ffmpeg': 4,
31
+ }
32
+
33
+ def run_stage(name, fn, *args, **kwargs):
34
+ """
35
+ Acquire the stage lock, wait out the gap since last job,
36
+ run fn(*args, **kwargs), record finish time, release lock.
37
+ Use this wrapper for every Whisper / AI / TTS / FFmpeg call.
38
+ """
39
+ lock = _stage_locks[name]
40
+ lock.acquire()
41
+ try:
42
+ # Wait remaining gap since last job finished
43
+ gap = STAGE_GAPS.get(name, 0)
44
+ with _stage_time_lock:
45
+ elapsed = time.time() - _stage_last[name]
46
+ wait = max(0.0, gap - elapsed)
47
+ if wait > 0:
48
+ time.sleep(wait)
49
+ # Run the actual work
50
+ result = fn(*args, **kwargs)
51
+ # Record finish time
52
+ with _stage_time_lock:
53
+ _stage_last[name] = time.time()
54
+ return result
55
+ finally:
56
+ lock.release()
57
+ from datetime import datetime
58
+ from pathlib import Path
59
+ from flask import Flask, request, jsonify, send_from_directory, Response
60
+
61
+ try:
62
+ from openai import OpenAI
63
+ except ImportError:
64
+ OpenAI = None
65
+
66
+ try:
67
+ import whisper
68
+ except ImportError:
69
+ whisper = None
70
+
71
  try:
72
+ import edge_tts, asyncio
73
+ except ImportError:
74
+ edge_tts = None
75
+
76
+ try:
77
+ from google import genai as ggenai
78
+ from google.genai import types as gtypes
79
+ except ImportError:
80
+ ggenai = None
81
+
82
+ # ── APP SETUP ──
83
+ BASE_DIR = Path(__file__).parent
84
+ COOKIES_FILE = str(BASE_DIR / 'm_youtube_com_cookies.txt')
85
+ app = Flask(__name__)
86
+
87
+ # #5: YouTube/TikTok/Facebook/Instagram download — hard cap 720p
88
+ def ytdlp_download(out_tmpl, video_url, timeout=600):
89
+ """yt-dlp download — hard cap 720p max, platform-aware, cookies, robust fallback."""
90
+ url_lower = video_url.lower()
91
+ is_tiktok = 'tiktok.com' in url_lower
92
+ is_facebook = 'facebook.com' in url_lower or 'fb.watch' in url_lower
93
+ is_instagram = 'instagram.com' in url_lower
94
+
95
+ if is_tiktok or is_facebook or is_instagram:
96
+ # These platforms don't always have mp4+m4a splits — use best available ≤720p
97
+ fmt = (
98
+ 'bestvideo[height<=720]+bestaudio'
99
+ '/best[height<=720]'
100
+ '/best'
101
+ )
102
+ else:
103
+ # YouTube and others — prefer mp4+m4a for clean merge
104
+ fmt = (
105
+ 'bestvideo[height<=720][ext=mp4]+bestaudio[ext=m4a]'
106
+ '/bestvideo[height<=720]+bestaudio'
107
+ '/best[height<=720][ext=mp4]'
108
+ '/best[height<=720]'
109
+ '/best[height<=480]'
110
+ '/best'
111
+ )
112
+
113
+ cmd = [
114
+ 'yt-dlp', '--no-playlist',
115
+ '-f', fmt,
116
+ '--merge-output-format', 'mp4',
117
+ '--no-check-certificates',
118
+ ]
119
+ if os.path.exists(COOKIES_FILE):
120
+ cmd += ['--cookies', COOKIES_FILE]
121
+ cmd += ['-o', out_tmpl, video_url]
122
+ print(f'[ytdlp] Running: {" ".join(cmd)}')
123
+ subprocess.run(cmd, check=True, timeout=timeout)
124
+
125
+ app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024
126
+
127
+ OUTPUT_DIR = BASE_DIR / 'outputs'
128
+ OUTPUT_DIR.mkdir(exist_ok=True)
129
+
130
+ # ── JOB PROGRESS (for SSE real-time updates) ──
131
+ job_progress = {}
132
+
133
+ # ── CPU QUEUE — 5 second gap between jobs ──
134
+ _cpu_lock = threading.Lock()
135
+ _last_job_time = 0
136
+
137
+ def cpu_queue_wait():
138
+ """Wait until 5 seconds have passed since last job started."""
139
+ global _last_job_time
140
+ with _cpu_lock:
141
+ now = time.time()
142
+ wait = max(0, 5.0 - (now - _last_job_time))
143
+ if wait > 0:
144
+ print(f'[CPU Queue] Waiting {wait:.1f}s before starting...')
145
+ time.sleep(wait)
146
+ _last_job_time = time.time()
147
+
148
+ # ── DB CONFIG ──
149
+ DB_FILE = str(BASE_DIR / 'users_db.json')
150
+ HF_TOKEN = os.getenv('HF_TOKEN', '')
151
+ HF_REPO = 'Phoe2004/MovieRecapDB'
152
+ ADMIN_U = os.getenv('ADMIN_USERNAME', 'Phoe')
153
+ ADMIN_P = os.getenv('ADMIN_PASSWORD', 'phoe1234')
154
+
155
+ GEMINI_KEYS = [os.getenv(f'GEMINI_API_KEY_{i}') for i in range(1, 11)]
156
+ DEEPSEEK_KEYS = [os.getenv('DEEPSEEK_API_KEY')]
157
+
158
+ _rr_idx = 0
159
+ _rr_lock = threading.Lock()
160
+
161
+ def next_gemini_key():
162
+ global _rr_idx
163
+ valid = [k for k in GEMINI_KEYS if k]
164
+ if not valid: return None, []
165
+ with _rr_lock:
166
+ idx = _rr_idx % len(valid)
167
+ _rr_idx += 1
168
+ primary = valid[idx]
169
+ ordered = valid[idx:] + valid[:idx]
170
+ return primary, ordered
171
+
172
+ # ── DB HELPERS ──
173
+ def pull_db():
174
+ if not HF_TOKEN:
175
+ print('⚠️ pull: HF_TOKEN missing')
176
+ return
177
+ try:
178
+ from huggingface_hub import hf_hub_download
179
+ import traceback
180
+ path = hf_hub_download(
181
+ repo_id=HF_REPO, filename='users_db.json', repo_type='dataset',
182
+ token=HF_TOKEN, local_dir=str(BASE_DIR), force_download=True,
183
+ )
184
+ dest = str(BASE_DIR / 'users_db.json')
185
+ if path != dest:
186
+ import shutil as _shutil
187
+ _shutil.copy2(path, dest)
188
+ print('✅ DB pulled from HuggingFace')
189
+ except Exception as e:
190
+ import traceback
191
+ print(f'⚠️ pull failed: {e}')
192
+ traceback.print_exc()
193
+
194
+ _push_lock = threading.Lock()
195
+
196
+ def push_db():
197
+ if not HF_TOKEN:
198
+ print('⚠️ push: HF_TOKEN missing')
199
+ return
200
+ with _push_lock:
201
+ for attempt in range(4):
202
+ try:
203
+ from huggingface_hub import HfApi
204
+ api = HfApi(token=HF_TOKEN)
205
+ api.upload_file(
206
+ path_or_fileobj=DB_FILE, path_in_repo='users_db.json',
207
+ repo_id=HF_REPO, repo_type='dataset',
208
+ commit_message=f'db {datetime.now().strftime("%Y%m%d_%H%M%S")}',
209
+ )
210
+ print(f'✅ DB pushed (attempt {attempt+1})')
211
+ return
212
+ except Exception as e:
213
+ print(f'⚠️ push attempt {attempt+1} failed: {e}')
214
+ if attempt < 3:
215
+ time.sleep(3 * (attempt + 1))
216
+ print('❌ push_db: all retries failed')
217
+
218
+ def load_db():
219
+ if not os.path.exists(DB_FILE): return {'users': {}}
220
+ try:
221
+ with open(DB_FILE, encoding='utf-8') as f: return json.load(f)
222
+ except: return {'users': {}}
223
+
224
+ def save_db(db):
225
+ with open(DB_FILE, 'w', encoding='utf-8') as f:
226
+ json.dump(db, f, ensure_ascii=False, indent=2)
227
+ threading.Thread(target=push_db, daemon=True).start()
228
+
229
+ def hp(p): return hashlib.sha256(p.encode()).hexdigest()
230
+
231
+ ADJ = ['Red','Blue','Gold','Star','Sky','Fire','Moon','Cool','Ice','Dark','Neon','Wild']
232
+ NOUN = ['Tiger','Dragon','Wolf','Hawk','Lion','Fox','Eagle','Storm','Flash','Ghost']
233
+
234
+ def gen_uname():
235
+ db = load_db()
236
+ for _ in range(60):
237
+ u = random.choice(ADJ)+random.choice(NOUN)+str(random.randint(10,999))
238
+ if u not in db['users']: return u
239
+ return 'User'+str(uuid.uuid4())[:6].upper()
240
+
241
+ def login_user(u, p):
242
+ if u == ADMIN_U and p == ADMIN_P: return True, '✅ Admin', -1
243
+ db = load_db()
244
+ if u not in db['users']: return False, '❌ Username not found', 0
245
+ stored = db['users'][u].get('password', '')
246
+ if stored and stored != hp(p): return False, '❌ Wrong password', 0
247
+ db['users'][u]['last_login'] = datetime.now().isoformat()
248
+ save_db(db)
249
+ return True, '✅ Logged in', db['users'][u]['coins']
250
+
251
+ def get_coins(u): return load_db()['users'].get(u, {}).get('coins', 0)
252
+
253
+ def deduct(u, n):
254
+ db = load_db()
255
+ if u not in db['users']: return False, 0
256
+ if db['users'][u]['coins'] < n: return False, db['users'][u]['coins']
257
+ db['users'][u]['coins'] -= n; save_db(db)
258
+ return True, db['users'][u]['coins']
259
+
260
+ def add_coins_fn(u, n):
261
+ db = load_db()
262
+ if u not in db['users']: return '❌ User not found'
263
+ db['users'][u]['coins'] += int(n); save_db(db)
264
+ return f"✅ +{n} → {db['users'][u]['coins']} 🪙"
265
+
266
+ def set_coins_fn(u, n):
267
+ db = load_db()
268
+ if u not in db['users']: return '❌ User not found'
269
+ db['users'][u]['coins'] = int(n); save_db(db)
270
+ return f'✅ Coin = {n} 🪙'
271
+
272
+ def upd_stat(u, t):
273
+ db = load_db()
274
+ if u not in db['users']: return
275
+ k = 'total_transcripts' if t == 'tr' else 'total_videos'
276
+ db['users'][u][k] = db['users'][u].get(k, 0) + 1; save_db(db)
277
+
278
+ def create_user_fn(uname, coins, caller):
279
+ if caller != ADMIN_U: return '❌ Not admin', ''
280
+ uname = (uname or '').strip() or gen_uname()
281
+ db = load_db()
282
+ if uname in db['users']: return f"❌ '{uname}' already exists", ''
283
+ db['users'][uname] = {'password': '', 'coins': int(coins),
284
+ 'created_at': datetime.now().isoformat(), 'last_login': None,
285
+ 'total_transcripts': 0, 'total_videos': 0}
286
+ save_db(db); return f"✅ '{uname}' created", uname
287
+
288
+ # ── AI ──
289
+
290
+ # ── Language-aware system prompts ──
291
+ def get_sys_prompt(ct, vo_lang='my'):
292
+ """
293
+ vo_lang: 'my' = Myanmar (default), 'th' = Thai, 'en' = English
294
+ """
295
+ if vo_lang == 'th':
296
+ # Thai language prompts
297
+ if ct == 'Medical/Health':
298
+ return (
299
+ "คุณคือผู้แปลด้านการแพทย์ภาษาไทย — ภาษาไทยที่พูดในชีวิตประจำวัน\n"
300
+ "Rules: 100% ภาษาไทย | ไม่ใช้ภาษาทางการมากเกินไป | เนื้อหาต้นฉบับเท่านั้น\n"
301
+ "ใช้ตัวเลขไทย: 1=หนึ่ง, 2=สอง, 10=สิบ, 100=ร้อย, 1000=พัน\n"
302
+ "Format EXACTLY:\n[SCRIPT](full thai script here)\n[TITLE](short title)\n[HASHTAGS](exactly 5 hashtags e.g. #สุขภาพ #thailand #health #viral #trending)"
303
+ )
304
+ else:
305
+ return (
306
+ "คุณคือนักเขียนสคริปต์สรุปหนังภาษาไทย — เล่าแบบสนุก ภาษาพูดธรรมชาติ\n"
307
+ "Rules: 100% ภาษาไทย | ไม่ใช้ภาษาทางการ | เนื้อหาต้นฉบับเท่านั้น\n"
308
+ "ใช้ตัวเลขไทย: 1=หนึ่ง, 2=สอง, 10=สิบ, 100=ร้อย, 1000=พัน\n"
309
+ "แปลเนื้อหาต่อไปนี้เป็นภาษาไทย (สไตล์เล่าเรื่อง movie recap ที่สนุก)\n"
310
+ "ตอบเป็นภาษาไทยเท่านั้น ห้ามมีภาษาอังกฤษในสคริปต์\n"
311
+ "Format: [SCRIPT](script)[TITLE](title ≤10 words)[HASHTAGS]#movierecap #thailand"
312
+ )
313
+ elif vo_lang == 'en':
314
+ # English language prompts
315
+ if ct == 'Medical/Health':
316
+ return (
317
+ "You are an English medical content translator — use clear, conversational English.\n"
318
+ "Rules: 100% English | conversational tone | original content only\n"
319
+ "Write numbers as words: 1=one, 2=two, 10=ten, 100=one hundred\n"
320
+ "Format EXACTLY:\n[SCRIPT](full english script here)\n[TITLE](short title)\n[HASHTAGS](exactly 5 hashtags e.g. #health #medical #wellness #viral #trending)"
321
+ )
322
+ else:
323
+ return (
324
+ "You are an English movie recap script writer — engaging storytelling tone, conversational.\n"
325
+ "Rules: 100% English | conversational not formal | original content only\n"
326
+ "Write numbers as words: 1=one, 2=two, 10=ten, 100=one hundred\n"
327
+ "Translate and retell the following content in English (movie recap storytelling style)\n"
328
+ "Format: [SCRIPT](script)[TITLE](title ≤10 words)[HASHTAGS]#movierecap #english"
329
+ )
330
+ else:
331
+ # Myanmar (default)
332
+ if ct == 'Medical/Health':
333
+ return (
334
+ "မြန်မာ ဆေးဘက် ဘာသာပြန်သူ — spoken Myanmar\n"
335
+ "Rules: 100% မြန်မာ | ကျောင်းသုံးစာပေမသုံးရ | ပုဒ်မတိုင်း ။\n"
336
+ "ဂဏန်းများကို မြန်မာစကားဖြင့်သာ ရေးပါ — ဥပမာ 1=တစ်, 2=နှစ်, 10=တစ်ဆယ်, 12=တစ်ဆယ့်နှစ်, 20=နှစ်ဆယ်, 100=တစ်ရာ, 1000=တစ်ထောင် — Arabic digit မသုံးရ\n"
337
+ "Format EXACTLY:\n[SCRIPT](full myanmar script here)\n[TITLE](short title)\n[HASHTAGS](exactly 5 hashtags e.g. #ကျန်းမာရေး #myanmar #health #viral #trending)"
338
+ )
339
+ else:
340
+ return (
341
+ "မြန်မာ movie recap script ရေးသားသူ — spoken Myanmar (နေ့စဉ်ပြောဆိုမှုဘာသာ)\n"
342
+ "Rules: 100% မြန်မာဘာသာ | ကျောင်းသုံးစာပေမသုံးရ | မူလcontent သာ | ပုဒ်မတိုင်း ။\n"
343
+ "ဂဏန်းများကို မြန်မာစကားဖြင့်သာ ရေးပါ — ဥပမာ 1=တစ်, 2=နှစ်, 10=တစ်ဆယ်, 12=တစ်ဆယ့်နှစ်, 20=နှစ်ဆယ်, 100=တစ်ရာ, 1000=တစ်ထောင် — Arabic digit မသုံးရ\n"
344
+ "Translate the following content into Burmese (storytelling tone movie recap tone and keep original content)\n"
345
+ "မြန်မာလိုပဲ ဖြေပေးပါ။ အင်္ဂလိပ်လို ဘာမှမပြန်နဲ့။အင်္ဂလိပ်စကားလုံးတွေကိုတွေ့ရင်လည်း မြန်မာလိုပဲ ဘာသာပြန်ပြီး ဖြေပေးပါ)\n"
346
+ "Format: [SCRIPT](script)[TITLE](title ≤10 words)[HASHTAGS]#movierecap #မြန်မာ"
347
+ )
348
+
349
+ # Keep legacy constants for backward compat
350
+ SYS_MOVIE = get_sys_prompt('Movie Recap', 'my')
351
+ SYS_MED = get_sys_prompt('Medical/Health', 'my')
352
+
353
+ NUM_TO_MM_RULE = (
354
+ "ဂဏန်းများကို မြန်မာစကားဖြင့်သာ ရေးပါ — "
355
+ "ဥပမာ 1=တစ်, 2=နှစ်, 10=တစ်ဆယ်, 12=တစ်ဆယ့်နှစ်, 20=နှစ်ဆယ်, "
356
+ "100=တစ်ရာ, 1000=တစ်ထောင် — Arabic digit မသုံးရ။"
357
+ )
358
+
359
+ def get_num_rule(vo_lang='my'):
360
+ if vo_lang == 'th':
361
+ return "ใช้ตัวเลขไทยเท่านั้น: 1=หนึ่ง, 2=สอง, 10=สิบ, 20=ยี่สิบ, 100=ร้อย, 1000=พัน ห้ามใช้ตัวเลขอารบิก"
362
+ elif vo_lang == 'en':
363
+ return "Write all numbers as English words: 1=one, 2=two, 10=ten, 20=twenty, 100=one hundred, 1000=one thousand — no Arabic digits."
364
+ else:
365
+ return NUM_TO_MM_RULE
366
+
367
+ def call_api(msgs, api='Gemini'):
368
+ if api == 'DeepSeek':
369
+ keys, base, mdl = DEEPSEEK_KEYS, 'https://api.deepseek.com', 'deepseek-chat'
370
+ else:
371
+ keys, base, mdl = GEMINI_KEYS, 'https://generativelanguage.googleapis.com/v1beta/openai/', 'gemini-2.5-flash'
372
+ valid = [(i+1, k) for i, k in enumerate(keys) if k]
373
+ if not valid: raise Exception('No API Key available')
374
+ if api == 'Gemini':
375
+ _, ordered = next_gemini_key()
376
+ valid = sorted(valid, key=lambda x: ordered.index(x[1]) if x[1] in ordered else 99)
377
+ else:
378
+ random.shuffle(valid)
379
+ time.sleep(2)
380
+ for n, k in valid:
381
  try:
382
+ r = OpenAI(api_key=k, base_url=base, timeout=600.0).chat.completions.create(
383
+ model=mdl, messages=msgs, max_tokens=8192)
384
+ if r and r.choices and r.choices[0].message.content:
385
+ return r.choices[0].message.content.strip(), f'✅ Key{n}'
386
+ except: continue
387
+ raise Exception('❌ All API keys failed')
 
 
 
 
 
 
 
 
388
 
389
+ def parse_out(text):
390
+ sc, ti, ht = '', '', ''
391
+ m = re.search(r'\[SCRIPT\](.*?)\[TITLE\]', text, re.DOTALL)
392
+ if m: sc = m.group(1).strip()
393
+ m2 = re.search(r'\[TITLE\](.*?)(\[HASHTAGS\]|$)', text, re.DOTALL)
394
+ m3 = re.search(r'\[HASHTAGS\](.*?)$', text, re.DOTALL)
395
+ if m2: ti = m2.group(1).strip()
396
+ if m3: ht = m3.group(1).strip()
397
+ if not sc: sc = re.sub(r'\[SCRIPT\]|\[TITLE\]|\[HASHTAGS\]', '', text.split('[TITLE]')[0]).strip()
398
+ tags = re.findall(r'#\S+', ht)
399
+ if len(tags) < 5:
400
+ defaults = ['#myanmar','#viral','#trending','#foryou','#entertainment']
401
+ tags = tags + [t for t in defaults if t not in tags]
402
+ ht = ' '.join(tags[:5])
403
+ return sc, ti, ht
404
+
405
+ def split_txt(txt, vo_lang='my'):
406
+ if vo_lang == 'th':
407
+ parts = re.split(r'[。\n]', txt)
408
+ return [s.strip() for s in parts if s.strip()] or [txt]
409
+ elif vo_lang == 'en':
410
+ parts = re.split(r'(?<=[.!?])\s+', txt)
411
+ return [s.strip() for s in parts if s.strip()] or [txt]
412
+ else:
413
+ return [s.strip() + '။' for s in re.split(r'[။]', txt) if s.strip()] or [txt]
414
+
415
+ def dur(fp):
416
+ try:
417
+ r = subprocess.run(
418
+ f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "{fp}"',
419
+ shell=True, capture_output=True, text=True)
420
+ return float(r.stdout.strip())
421
+ except: return 0
422
+
423
+ # ── ASYNC HELPERS ──
424
+ def run_tts_sync(sentences, voice_id, rate, tmp_dir):
425
+ async def _run():
426
+ sil = f'{tmp_dir}/sil.mp3'
427
+ proc = await asyncio.create_subprocess_shell(
428
+ f'ffmpeg -f lavfi -i anullsrc=r=24000:cl=mono -t 0.4 -c:a libmp3lame -q:a 2 "{sil}" -y',
429
+ stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL)
430
+ await proc.wait()
431
+ parts = []
432
+ for i, s in enumerate(sentences):
433
+ raw = f'{tmp_dir}/r{i:03d}.mp3'
434
+ await edge_tts.Communicate(s, voice_id, rate=rate).save(raw)
435
+ parts += [raw, sil]
436
+ return parts
437
+ loop = asyncio.new_event_loop()
438
+ try:
439
+ return loop.run_until_complete(_run())
440
+ finally:
441
+ loop.close()
442
+
443
+ def run_edge_preview(voice_id, rate, out_path):
444
+ # Choose preview text based on voice language
445
+ if voice_id.startswith('th-'):
446
+ text = 'สวัสดีครับ ยินดีต้อนรับ'
447
+ elif voice_id.startswith('en-'):
448
+ text = 'Hello, welcome to Recap Studio.'
449
+ else:
450
+ text = 'မင်္ဂလာပါ။ ကြိုဆိုပါတယ်။'
451
+ async def _run():
452
+ await edge_tts.Communicate(text, voice_id, rate=rate).save(out_path)
453
+ loop = asyncio.new_event_loop()
454
+ try:
455
+ loop.run_until_complete(_run())
456
+ finally:
457
+ loop.close()
458
+
459
+ # ── GEMINI TTS ──
460
+ def _get_gemini_client():
461
+ if ggenai is None:
462
+ raise Exception('google-genai package not installed')
463
+ valid_keys = [k for k in GEMINI_KEYS if k]
464
+ if not valid_keys:
465
+ raise Exception('No Gemini API Key')
466
+ random.shuffle(valid_keys)
467
+ return ggenai.Client(api_key=valid_keys[0]), valid_keys
468
+
469
+ def _save_pcm_as_wav(pcm_data, wav_path, sample_rate=24000, channels=1, sample_width=2):
470
+ with wave.open(wav_path, 'wb') as wf:
471
+ wf.setnchannels(channels)
472
+ wf.setsampwidth(sample_width)
473
+ wf.setframerate(sample_rate)
474
+ wf.writeframes(pcm_data)
475
+
476
+ def _wav_to_mp3(wav_path, mp3_path):
477
+ subprocess.run(
478
+ f'ffmpeg -y -i "{wav_path}" -c:a libmp3lame -q:a 2 "{mp3_path}"',
479
+ shell=True, check=True, capture_output=True)
480
+
481
+ def run_gemini_tts_sync(sentences, voice_name, tmp_dir, speed=0):
482
+ if ggenai is None:
483
+ raise Exception('google-genai package not installed')
484
+ _, ordered_keys = next_gemini_key()
485
+ if not ordered_keys:
486
+ raise Exception('No Gemini API Key')
487
+ time.sleep(2)
488
+ full_script = ' '.join(sentences)
489
+ wav_out = f'{tmp_dir}/gemini_raw.wav'
490
+ mp3_raw = f'{tmp_dir}/gemini_raw.mp3'
491
+ mp3_out = f'{tmp_dir}/gemini_final.mp3'
492
+ last_err = None
493
+ for api_key in ordered_keys:
494
  try:
495
+ client = ggenai.Client(api_key=api_key)
496
+ response = client.models.generate_content(
497
+ model="gemini-2.5-flash-preview-tts",
498
+ contents=full_script,
499
+ config=gtypes.GenerateContentConfig(
500
+ response_modalities=["AUDIO"],
501
+ speech_config=gtypes.SpeechConfig(
502
+ voice_config=gtypes.VoiceConfig(
503
+ prebuilt_voice_config=gtypes.PrebuiltVoiceConfig(
504
+ voice_name=voice_name or "Kore"
505
+ )
506
+ )
507
+ )
508
+ )
509
+ )
510
+ audio_data = None
511
+ if response.candidates:
512
+ for part in response.candidates[0].content.parts:
513
+ if part.inline_data and part.inline_data.mime_type.startswith('audio/'):
514
+ audio_data = part.inline_data.data
515
+ break
516
+ if not audio_data:
517
+ raise Exception('Gemini TTS: no audio data received')
518
+ _save_pcm_as_wav(audio_data, wav_out)
519
+ _wav_to_mp3(wav_out, mp3_raw)
520
+ try: os.remove(wav_out)
521
+ except: pass
522
+ tempo = max(0.5, min(2.0, 1.0 + speed / 100.0))
523
+ if abs(tempo - 1.0) < 0.02:
524
+ import shutil as _sh; _sh.copy2(mp3_raw, mp3_out)
525
+ else:
526
+ subprocess.run(
527
+ f'ffmpeg -y -i "{mp3_raw}" -af "atempo={tempo:.3f}" -c:a libmp3lame -q:a 2 "{mp3_out}"',
528
+ shell=True, check=True, capture_output=True)
529
+ try: os.remove(mp3_raw)
530
+ except: pass
531
+ print(f'✅ Gemini TTS done, key=...{api_key[-6:]}, tempo={tempo:.2f}x')
532
+ return [mp3_out]
533
  except Exception as e:
534
+ last_err = e
535
+ print(f'⚠️ Gemini TTS key failed: {e}')
536
+ continue
537
+ raise Exception(f'❌ Gemini TTS all keys failed: {last_err}')
538
 
539
+ def run_gemini_preview(voice_name, out_path):
540
+ if ggenai is None:
541
+ raise Exception('google-genai package not installed')
542
+ _, ordered_keys = next_gemini_key()
543
+ if not ordered_keys:
544
+ raise Exception('No Gemini API Key')
545
+ wav_path = out_path.replace('.mp3', '.wav')
546
+ for api_key in ordered_keys:
547
+ try:
548
+ client = ggenai.Client(api_key=api_key)
549
+ response = client.models.generate_content(
550
+ model="gemini-2.5-flash-preview-tts",
551
+ contents="မင်္ဂလာပါ။ ဒီနေ့ ဘာများ လုပ်မလဲ။",
552
+ config=gtypes.GenerateContentConfig(
553
+ response_modalities=["AUDIO"],
554
+ speech_config=gtypes.SpeechConfig(
555
+ voice_config=gtypes.VoiceConfig(
556
+ prebuilt_voice_config=gtypes.PrebuiltVoiceConfig(
557
+ voice_name=voice_name or "Kore"
558
+ )
559
+ )
560
+ )
561
+ )
562
+ )
563
+ audio_data = None
564
+ if response.candidates:
565
+ for part in response.candidates[0].content.parts:
566
+ if part.inline_data and part.inline_data.mime_type.startswith('audio/'):
567
+ audio_data = part.inline_data.data
568
+ break
569
+ if not audio_data:
570
+ raise Exception('Gemini TTS preview: no audio data')
571
+ _save_pcm_as_wav(audio_data, wav_path)
572
+ _wav_to_mp3(wav_path, out_path)
573
+ try: os.remove(wav_path)
574
+ except: pass
575
+ return
576
+ except Exception as e:
577
+ print(f'⚠️ Gemini preview key failed: {e}')
578
+ continue
579
+ raise Exception('❌ Gemini TTS preview: all keys failed')
580
 
581
+ # ── PULL DB ON START ──
582
+ threading.Thread(target=pull_db, daemon=True).start()
583
+ whisper_model = None
 
 
584
 
585
+ # ════════════════════════════════════════
586
+ # ROUTES
587
+ # ════════════════════════════════════════
588
 
589
  @app.route('/')
590
+ def index():
591
+ return send_from_directory(str(BASE_DIR), 'index.html')
592
+
593
+ @app.route('/outputs/<path:fn>')
594
+ def serve_output(fn):
595
+ return send_from_directory(str(OUTPUT_DIR), fn)
596
 
597
+ # ── AUTH ──
598
+ @app.route('/api/login', methods=['POST'])
599
+ def api_login():
600
  try:
601
+ d = request.get_json(force=True) or {}
602
+ ok, msg, coins = login_user(d.get('username',''), d.get('password',''))
603
+ return jsonify(ok=ok, msg=msg, coins=coins, is_admin=(d.get('username','')==ADMIN_U and ok))
604
+ except Exception as e:
605
+ return jsonify(ok=False, msg=str(e))
606
 
607
+ @app.route('/api/register', methods=['POST'])
608
+ def api_register():
609
+ try:
610
+ d = request.get_json(force=True) or {}
611
+ uname = (d.get('username') or '').strip() or gen_uname()
612
+ pw = d.get('password', '')
613
+ db = load_db()
614
+ if uname in db['users']: return jsonify(ok=False, msg='❌ Already exists')
615
+ db['users'][uname] = {'password': hp(pw) if pw else '', 'coins': 5,
616
+ 'created_at': datetime.now().isoformat(), 'last_login': None,
617
+ 'total_transcripts': 0, 'total_videos': 0}
618
+ save_db(db)
619
+ return jsonify(ok=True, msg=f'✅ {uname} created', username=uname, coins=5)
620
+ except Exception as e:
621
+ return jsonify(ok=False, msg=str(e))
622
 
623
+ @app.route('/api/preview_voice', methods=['POST'])
624
+ def api_preview_voice():
625
+ try:
626
+ d = request.get_json(force=True) or {}
627
+ voice_id = d.get('voice', 'my-MM-ThihaNeural')
628
+ speed = int(d.get('speed', 30))
629
+ engine = d.get('engine', 'ms')
630
+ out = str(OUTPUT_DIR / f'preview_{uuid.uuid4().hex[:8]}.mp3')
631
+ if engine == 'gemini':
632
+ run_gemini_preview(voice_id, out)
633
+ else:
634
+ run_edge_preview(voice_id, f'+{speed}%', out)
635
+ return jsonify(ok=True, url='/outputs/' + Path(out).name)
636
  except Exception as e:
637
+ import traceback; traceback.print_exc()
638
+ return jsonify(ok=False, msg=str(e))
639
 
640
+ @app.route('/api/gemini_voices')
641
+ def api_gemini_voices():
642
+ voices = [
643
+ {"id": "Kore", "name": "Kore (Female, Firm)"},
644
+ {"id": "Charon", "name": "Charon (Male, Informative)"},
645
+ {"id": "Fenrir", "name": "Fenrir (Male, Excitable)"},
646
+ {"id": "Leda", "name": "Leda (Female, Youthful)"},
647
+ {"id": "Orus", "name": "Orus (Male, Firm)"},
648
+ {"id": "Puck", "name": "Puck (Male, Upbeat)"},
649
+ {"id": "Aoede", "name": "Aoede (Female, Breezy)"},
650
+ {"id": "Zephyr", "name": "Zephyr (Female, Bright)"},
651
+ {"id": "Achelois", "name": "Achelois (Female, Soft)"},
652
+ {"id": "Pegasus", "name": "Pegasus (Male, Confident)"},
653
+ {"id": "Perseus", "name": "Perseus (Male, Casual)"},
654
+ {"id": "Schedar", "name": "Schedar (Male, Even-keeled)"},
655
+ ]
656
+ return jsonify(ok=True, voices=voices)
657
+
658
+ # ── DRAFT ──
659
+ @app.route('/api/draft', methods=['POST'])
660
+ def api_draft():
661
+ global whisper_model
662
  try:
663
+ u = (request.form.get('username') or '').strip()
664
+ video_url = (request.form.get('video_url') or '').strip()
665
+ ct = request.form.get('content_type', 'Movie Recap')
666
+ api = request.form.get('ai_model', 'Gemini')
667
+ vo_lang = request.form.get('vo_lang', 'my') # 'my', 'th', 'en'
668
+ video_file = request.files.get('video_file')
669
+
670
+ if not u: return jsonify(ok=False, msg=' Not logged in')
671
+ is_adm = (u == ADMIN_U)
672
+ if not is_adm and get_coins(u) < 1:
673
+ return jsonify(ok=False, msg='❌ Not enough coins')
674
+
675
+ cpu_queue_wait()
676
+
677
+ tid = uuid.uuid4().hex[:8]
678
+ tmp_dir = str(BASE_DIR / f'temp_{tid}')
679
+ os.makedirs(tmp_dir, exist_ok=True)
680
+ vpath = None
681
+
682
+ try:
683
+ if video_file and video_file.filename:
684
+ vpath = f'{tmp_dir}/input.mp4'
685
+ video_file.save(vpath)
686
+ elif video_url:
687
+ out_tmpl = f'{tmp_dir}/input.%(ext)s'
688
+ ytdlp_download(out_tmpl, video_url)
689
+ found = glob.glob(f'{tmp_dir}/input.*')
690
+ if found: vpath = found[0]
691
+ if not vpath: return jsonify(ok=False, msg='❌ No video selected')
692
+
693
+ if whisper is None: raise Exception('whisper not installed')
694
+ if whisper_model is None:
695
+ whisper_model = whisper.load_model('tiny', device='cpu')
696
+ res = run_stage('whisper', whisper_model.transcribe, vpath, fp16=False)
697
+ tr = res['text']; lang = res.get('language', 'en')
698
+
699
+ if vo_lang == 'en':
700
+ # English — skip AI API, return whisper transcript directly
701
+ sc = tr.strip()
702
+ ti = sc[:60].strip() + ('…' if len(sc) > 60 else '')
703
+ ht = '#english #movierecap #viral #foryou #trending'
704
+ key_n = 'Whisper Direct'
705
+ else:
706
+ sys_p = get_sys_prompt(ct, vo_lang)
707
+ sys_p = sys_p + '\n' + get_num_rule(vo_lang)
708
+ out_txt, key_n = run_stage('ai', call_api,
709
+ [{'role':'system','content':sys_p},
710
+ {'role':'user','content':f'Language:{lang}\n\n{tr}'}], api=api)
711
+ sc, ti, ht = parse_out(out_txt)
712
+
713
+ rem = -1
714
+ if not is_adm: _, rem = deduct(u, 1); upd_stat(u, 'tr')
715
+ return jsonify(ok=True, script=sc, title=ti, hashtags=ht,
716
+ status=f'{key_n} · {lang}', coins=rem)
717
+ finally:
718
+ shutil.rmtree(tmp_dir, ignore_errors=True)
719
+
720
+ except Exception as e:
721
+ return jsonify(ok=False, msg=f'❌ {e}')
722
+
723
+ # ── #7: Audio filter — louder, cleaner voice (no hiss/air noise) ──
724
+ def _build_audio_filter(mpath, ad):
725
+ """
726
+ Voice louder and cleaner - works on ALL inputs (URL + local upload).
727
+ - highpass=f=100 : cut low rumble / breath noise
728
+ - lowpass=f=10000 : cut high hiss / sibilance
729
+ - dynaudnorm : dynamic normalization (no two-pass, any input format)
730
+ - volume=3.5 : extra loudness boost
731
+ """
732
+ voice_chain = 'highpass=f=100,lowpass=f=10000,dynaudnorm=p=0.9:m=100:s=12,volume=3.5'
733
+ if mpath:
734
+ return (f'[1:a]{voice_chain}[nar];'
735
+ f'[2:a]volume=0.10,afade=t=out:st={max(0,ad-2):.3f}:d=2[bgm];'
736
+ f'[nar][bgm]amix=inputs=2:duration=first:dropout_transition=2[outa]')
737
+ else:
738
+ return f'[1:a]{voice_chain}[outa]'
739
+
740
+ # ── Mid-section Audio Sync Correction ──
741
+ def _get_mid_range(duration):
742
+ """
743
+ Return (start_ratio, end_ratio) for middle section based on total duration.
744
+ """
745
+ if duration < 180: # < 3 min
746
+ return 0.30, 0.70
747
+ elif duration < 300: # 3–5 min
748
+ return 0.25, 0.75
749
+ elif duration < 600: # 5–10 min
750
+ return 0.20, 0.80
751
+ else: # > 10 min
752
+ return 0.15, 0.85
753
+
754
+ def _fix_mid_sync(audio_path, video_dur, audio_dur, tmp_dir):
755
+ """
756
+ Split audio into 3 parts: head / middle / tail.
757
+ Apply atempo correction ONLY to middle part if drift > 0.2s.
758
+ Recombine and return new audio path (or original if no fix needed).
759
+ Pitch is preserved (atempo only, no asetrate).
760
+ """
761
+ drift = audio_dur - video_dur
762
+ if abs(drift) <= 0.2:
763
+ print(f'[sync] drift={drift:.3f}s ≤ 0.2s — skip mid-sync')
764
+ return audio_path
765
+
766
+ s_ratio, e_ratio = _get_mid_range(audio_dur)
767
+ t_start = audio_dur * s_ratio
768
+ t_end = audio_dur * e_ratio
769
+ mid_dur = t_end - t_start
770
+
771
+ # Target mid duration after correction
772
+ # We want total audio ≈ video_dur
773
+ # head + mid_corrected + tail = video_dur
774
+ head_dur = t_start
775
+ tail_dur = audio_dur - t_end
776
+ mid_target = video_dur - head_dur - tail_dur
777
+
778
+ if mid_target <= 0:
779
+ print(f'[sync] mid_target invalid ({mid_target:.3f}s) — skip')
780
+ return audio_path
781
+
782
+ tempo = mid_dur / mid_target
783
+ # atempo range: 0.5 ~ 2.0 (chain if needed)
784
+ tempo = max(0.5, min(2.0, tempo))
785
+
786
+ print(f'[sync] drift={drift:.3f}s | mid {t_start:.2f}s~{t_end:.2f}s | tempo={tempo:.4f}x')
787
+
788
+ head_f = f'{tmp_dir}/sync_head.mp3'
789
+ mid_f = f'{tmp_dir}/sync_mid.mp3'
790
+ tail_f = f'{tmp_dir}/sync_tail.mp3'
791
+ mid_fx = f'{tmp_dir}/sync_mid_fx.mp3'
792
+ out_f = f'{tmp_dir}/sync_fixed.mp3'
793
+ lst_f = f'{tmp_dir}/sync_list.txt'
794
+
795
+ try:
796
+ # Cut head
797
+ subprocess.run(
798
+ f'ffmpeg -y -i "{audio_path}" -ss 0 -t {t_start:.6f} '
799
+ f'-c:a libmp3lame -q:a 2 "{head_f}"',
800
+ shell=True, check=True, capture_output=True)
801
+
802
+ # Cut middle
803
+ subprocess.run(
804
+ f'ffmpeg -y -i "{audio_path}" -ss {t_start:.6f} -t {mid_dur:.6f} '
805
+ f'-c:a libmp3lame -q:a 2 "{mid_f}"',
806
+ shell=True, check=True, capture_output=True)
807
+
808
+ # Cut tail
809
+ subprocess.run(
810
+ f'ffmpeg -y -i "{audio_path}" -ss {t_end:.6f} '
811
+ f'-c:a libmp3lame -q:a 2 "{tail_f}"',
812
+ shell=True, check=True, capture_output=True)
813
+
814
+ # Apply atempo to middle (pitch unchanged)
815
+ subprocess.run(
816
+ f'ffmpeg -y -i "{mid_f}" -af "atempo={tempo:.6f}" '
817
+ f'-c:a libmp3lame -q:a 2 "{mid_fx}"',
818
+ shell=True, check=True, capture_output=True)
819
+
820
+ # Concat head + mid_fixed + tail
821
+ with open(lst_f, 'w') as lf:
822
+ for f in [head_f, mid_fx, tail_f]:
823
+ if os.path.exists(f) and os.path.getsize(f) > 0:
824
+ lf.write(f"file '{os.path.abspath(f)}'\n")
825
+ subprocess.run(
826
+ f'ffmpeg -y -f concat -safe 0 -i "{lst_f}" '
827
+ f'-c:a libmp3lame -q:a 2 "{out_f}"',
828
+ shell=True, check=True, capture_output=True)
829
+
830
+ print(f'[sync] mid-sync done → {out_f}')
831
+ return out_f
832
+
833
+ except Exception as e:
834
+ print(f'[sync] mid-sync failed: {e} — using original audio')
835
+ return audio_path
836
+
837
+ # ── #6: Video render — smaller output file ──
838
+ def _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file,
839
+ logo_path=None, logo_x=10, logo_y=10, logo_w=80,
840
+ blur_enabled=False, blur_x=0, blur_y=0, blur_w=0, blur_h=0):
841
+
842
+ raw_ratio = ad / vd
843
+
844
+ # ── Step 1: Pre-process video — resize + fix even dims ──
845
+ pre_out = vpath + '_pre.mp4'
846
+ pre_cmd = (
847
+ f'ffmpeg -y -hide_banner -loglevel error '
848
+ f'-i "{vpath}" '
849
+ f'-vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" '
850
+ f'-c:v libx264 -crf 18 -preset ultrafast -pix_fmt yuv420p '
851
+ f'-an "{pre_out}"'
852
+ )
853
+ subprocess.run(pre_cmd, shell=True, check=True)
854
+
855
+ # ── Step 2: Precise sync calculation ──
856
+ sync_ratio = max(0.8, min(1.5, raw_ratio))
857
+ need_loop = raw_ratio > 1.5
858
+ need_trim = raw_ratio < 0.8
859
+
860
+ # ── Step 3: Build video filters ──
861
+ base = []
862
+ if need_loop:
863
+ loop_times = int(ad / vd) + 2
864
+ base.append(f'loop={loop_times}:size=32767:start=0,trim=duration={ad:.3f},setpts=PTS-STARTPTS')
865
+ elif need_trim:
866
+ base.append(f'trim=duration={ad:.3f},setpts=PTS-STARTPTS')
867
+ else:
868
+ base.append(f'setpts={sync_ratio:.6f}*PTS')
869
+ if flip: base.append('hflip')
870
+ if col: base.append('eq=brightness=0.06:contrast=1.2:saturation=1.4')
871
+ base.append('scale=iw:ih')
872
+ base.append('format=yuv420p')
873
+ base_str = ','.join(base)
874
+
875
+ if crop == '9:16':
876
+ vbase = (
877
+ f'[0:v]{base_str},split[s1][s2];'
878
+ f'[s1]scale=720:1280:force_original_aspect_ratio=increase,crop=720:1280,boxblur=20:20[bg];'
879
+ f'[s2]scale=720:1280:force_original_aspect_ratio=decrease[fg];'
880
+ f'[bg][fg]overlay=(W-w)/2:(H-h)/2'
881
+ )
882
+ elif crop == '16:9':
883
+ vbase = (
884
+ f'[0:v]{base_str},split[s1][s2];'
885
+ f'[s1]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,boxblur=20:20[bg];'
886
+ f'[s2]scale=1280:720:force_original_aspect_ratio=decrease[fg];'
887
+ f'[bg][fg]overlay=(W-w)/2:(H-h)/2'
888
+ )
889
+ elif crop == '1:1':
890
+ vbase = (
891
+ f'[0:v]{base_str},split[s1][s2];'
892
+ f'[s1]scale=720:720:force_original_aspect_ratio=increase,crop=720:720,boxblur=20:20[bg];'
893
+ f'[s2]scale=720:720:force_original_aspect_ratio=decrease[fg];'
894
+ f'[bg][fg]overlay=(W-w)/2:(H-h)/2'
895
+ )
896
+ else:
897
+ vbase = f'[0:v]{base_str}'
898
+
899
+ # ── Blur / Delogo filter (subtitle hide) ──
900
+ if blur_enabled and blur_w > 0 and blur_h > 0:
901
+ vbase = f'{vbase},delogo=x={blur_x}:y={blur_y}:w={blur_w}:h={blur_h}'
902
+
903
+ # ── Logo + Watermark — build as single continuous chain, no label re-use ──
904
+ logo_idx = None
905
+ if logo_path and os.path.exists(logo_path):
906
+ logo_idx = 2 if not mpath else 3
907
+
908
+ if wmk and logo_idx is not None:
909
+ cn = wmk.replace("'","").replace("\\","").replace(":","")
910
+ vff = (
911
+ f'{vbase},'
912
+ f'drawtext=text=\'{cn}\':x=w-tw-40:y=40:fontsize=35:fontcolor=white:shadowcolor=black:shadowx=2:shadowy=2'
913
+ f'[vwmk];'
914
+ f'[{logo_idx}:v]scale={logo_w}:-1[logo];'
915
+ f'[vwmk][logo]overlay={logo_x}:{logo_y}[outv]'
916
+ )
917
+ elif wmk:
918
+ cn = wmk.replace("'","").replace("\\","").replace(":","")
919
+ vff = (
920
+ f'{vbase},'
921
+ f'drawtext=text=\'{cn}\':x=w-tw-40:y=40:fontsize=35:fontcolor=white:shadowcolor=black:shadowx=2:shadowy=2'
922
+ f'[outv]'
923
+ )
924
+ elif logo_idx is not None:
925
+ vff = (
926
+ f'{vbase}[vbase];'
927
+ f'[{logo_idx}:v]scale={logo_w}:-1[logo];'
928
+ f'[vbase][logo]overlay={logo_x}:{logo_y}[outv]'
929
+ )
930
+ else:
931
+ vff = f'{vbase}[outv]'
932
+
933
+ af = _build_audio_filter(mpath, ad)
934
+
935
+ inp = f'-fflags +genpts+igndts -err_detect ignore_err -i "{pre_out}" -i "{cmb}"'
936
+ if mpath:
937
+ inp += f' -stream_loop -1 -i "{mpath}"'
938
+ if logo_idx is not None:
939
+ inp += f' -i "{logo_path}"'
940
+
941
+ cmd = (
942
+ f'nice -n 10 ffmpeg -y -hide_banner -loglevel error {inp} '
943
+ f'-filter_complex "{vff};{af}" '
944
+ f'-map "[outv]" -map "[outa]" '
945
+ f'-c:v libx264 -crf 26 -preset medium -pix_fmt yuv420p '
946
+ f'-c:a aac -ar 44100 -b:a 128k '
947
+ f'-t {ad:.3f} -movflags +faststart "{out_file}"'
948
+ )
949
+ try:
950
+ run_stage('ffmpeg', subprocess.run, cmd, shell=True, check=True)
951
+ finally:
952
+ try: os.remove(pre_out)
953
+ except: pass
954
+
955
+
956
+ # ── PROCESS ──
957
+ @app.route('/api/process', methods=['POST'])
958
+ def api_process():
959
+ try:
960
+ u = (request.form.get('username') or '').strip()
961
+ video_url = (request.form.get('video_url') or '').strip()
962
+ sc = (request.form.get('script') or '').strip()
963
+ voice_id = request.form.get('voice', 'my-MM-ThihaNeural')
964
+ engine = request.form.get('engine', 'ms')
965
+ spd = int(request.form.get('speed', 30))
966
+ wmk = request.form.get('watermark', '')
967
+ crop = request.form.get('crop', '9:16')
968
+ flip = request.form.get('flip', '0') == '1'
969
+ col = request.form.get('color', '0') == '1'
970
+ vo_lang = request.form.get('vo_lang', 'my')
971
+ # Speed default per language (can be overridden by slider)
972
+ LANG_SPD = {'th': 20, 'en': 0, 'my': 30}
973
+ if request.form.get('speed') is None:
974
+ spd = LANG_SPD.get(vo_lang, 30)
975
+ is_adm = (u == ADMIN_U)
976
+ if not is_adm and get_coins(u) < 1:
977
+ return jsonify(ok=False, msg='❌ Not enough coins')
978
+
979
+ cpu_queue_wait()
980
+
981
+ tid = uuid.uuid4().hex[:8]
982
+ tmp_dir = str(BASE_DIR / f'temp_{tid}')
983
+ os.makedirs(tmp_dir, exist_ok=True)
984
+ out_file = str(OUTPUT_DIR / f'final_{tid}.mp4')
985
+ vpath = None; mpath = None
986
+
987
+ try:
988
+ if video_file and video_file.filename:
989
+ vpath = f'{tmp_dir}/input.mp4'
990
+ video_file.save(vpath)
991
+ elif video_url:
992
+ out_tmpl = f'{tmp_dir}/input.%(ext)s'
993
+ ytdlp_download(out_tmpl, video_url)
994
+ found = glob.glob(f'{tmp_dir}/input.*')
995
+ if found: vpath = found[0]
996
+ if not vpath: return jsonify(ok=False, msg='❌ No video selected')
997
+
998
+ if music_file and music_file.filename:
999
+ mpath = f'{tmp_dir}/music.mp3'
1000
+ music_file.save(mpath)
1001
+
1002
+ logo_path = None
1003
+ logo_file = request.files.get('logo_file')
1004
+ logo_x = int(request.form.get('logo_x', 10))
1005
+ logo_y = int(request.form.get('logo_y', 10))
1006
+ logo_w = int(request.form.get('logo_w', 80))
1007
+ if logo_file and logo_file.filename:
1008
+ ext = Path(logo_file.filename).suffix or '.png'
1009
+ logo_path = f'{tmp_dir}/logo{ext}'
1010
+ logo_file.save(logo_path)
1011
+ blur_enabled = request.form.get('blur_enabled') == '1'
1012
+ blur_x = int(request.form.get('blur_x', 0))
1013
+ blur_y = int(request.form.get('blur_y', 0))
1014
+ blur_w = int(request.form.get('blur_w', 0))
1015
+ blur_h = int(request.form.get('blur_h', 0))
1016
+ if engine == 'gemini':
1017
+ parts = run_stage('tts', run_gemini_tts_sync, sentences, voice_id, tmp_dir, speed=spd)
1018
+ else:
1019
+ parts = run_stage('tts', run_tts_sync, sentences, voice_id, rate, tmp_dir)
1020
+
1021
+ cmb = f'{tmp_dir}/combined.mp3'
1022
+ lst = f'{tmp_dir}/list.txt'
1023
+ with open(lst, 'w') as f:
1024
+ for a in parts: f.write(f"file '{os.path.abspath(a)}'\n")
1025
+ subprocess.run(
1026
+ f'ffmpeg -y -f concat -safe 0 -i "{lst}" '
1027
+ f'-af "silenceremove=start_periods=1:stop_periods=-1:stop_duration=0.1:stop_threshold=-50dB" '
1028
+ f'-c:a libmp3lame -q:a 2 "{cmb}"', shell=True, check=True)
1029
+
1030
+ vd = dur(vpath); ad = dur(cmb)
1031
+ if vd <= 0: raise Exception('Video duration read failed')
1032
+ if ad <= 0: raise Exception('Audio duration read failed')
1033
+
1034
+ _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file,
1035
+ logo_path=logo_path, logo_x=logo_x, logo_y=logo_y, logo_w=logo_w,
1036
+ blur_enabled=blur_enabled, blur_x=blur_x, blur_y=blur_y, blur_w=blur_w, blur_h=blur_h)
1037
+ return jsonify(ok=True, output_url=f'/outputs/final_{tid}.mp4', coins=rem)
1038
+
1039
+ finally:
1040
+ shutil.rmtree(tmp_dir, ignore_errors=True)
1041
+
1042
+ except Exception as e:
1043
+ import traceback; traceback.print_exc()
1044
+ return jsonify(ok=False, msg=f'❌ {e}')
1045
+
1046
+
1047
+ # ── PROCESS ALL ──
1048
+ @app.route('/api/progress/<tid>')
1049
+ def api_progress(tid):
1050
+ def generate():
1051
+ sent_done = False
1052
+ for _ in range(1800): # 1800 × 0.4s = 12 minutes max
1053
+ p = job_progress.get(tid)
1054
+ if p is None:
1055
+ yield f"data: {json.dumps({'pct':0,'msg':'Please wait…'})}\n\n"
1056
+ else:
1057
+ yield f"data: {json.dumps(p)}\n\n"
1058
+ if p.get('done') or p.get('error'):
1059
+ sent_done = True
1060
+ break
1061
+ time.sleep(0.4)
1062
+ if not sent_done:
1063
+ yield f"data: {json.dumps({'pct':0,'msg':'Timeout — process took too long','error':True})}\n\n"
1064
+ return Response(generate(), mimetype='text/event-stream',
1065
+ headers={'Cache-Control':'no-cache','X-Accel-Buffering':'no'})
1066
+
1067
+ @app.route('/api/process_all', methods=['POST'])
1068
+ def api_process_all():
1069
+ global whisper_model
1070
  try:
1071
+ u = (request.form.get('username') or '').strip()
1072
+ video_url = (request.form.get('video_url') or '').strip()
1073
+ voice_id = request.form.get('voice', 'my-MM-ThihaNeural')
1074
+ engine = request.form.get('engine', 'ms')
1075
+ spd = int(request.form.get('speed', 30))
1076
+ wmk = request.form.get('watermark', '')
1077
+ crop = request.form.get('crop', '9:16')
1078
+ flip = request.form.get('flip', '0') == '1'
1079
+ col = request.form.get('color', '0') == '1'
1080
+ ct = request.form.get('content_type', 'Movie Recap')
1081
+ api = request.form.get('ai_model', 'Gemini')
1082
+ vo_lang = request.form.get('vo_lang', 'my') # 'my', 'th', 'en'
1083
+ # Speed default per language (can be overridden by slider)
1084
+ LANG_SPD = {'th': 20, 'en': 0, 'my': 30}
1085
+ if request.form.get('speed') is None:
1086
+ spd = LANG_SPD.get(vo_lang, 30)
1087
+ video_file = request.files.get('video_file')
1088
+ music_file = request.files.get('music_file')
1089
+ logo_file = request.files.get('logo_file')
1090
+ logo_x = int(request.form.get('logo_x', 10))
1091
+ logo_y = int(request.form.get('logo_y', 10))
1092
+ logo_w = int(request.form.get('logo_w', 80))
1093
+ client_tid = (request.form.get('tid') or '').strip()
1094
+
1095
+ if not u: return jsonify(ok=False, msg='❌ Not logged in')
1096
+ is_adm = (u == ADMIN_U)
1097
+ if not is_adm and get_coins(u) < 2:
1098
+ return jsonify(ok=False, msg=f' Not enough coins (need 2)')
1099
+
1100
+ tid = client_tid if client_tid else uuid.uuid4().hex[:8]
1101
+ tmp_dir = str(BASE_DIR / f'temp_{tid}')
1102
+ os.makedirs(tmp_dir, exist_ok=True)
1103
+ out_file = str(OUTPUT_DIR / f'final_{tid}.mp4')
1104
+ vpath = None; mpath = None
1105
+
1106
+ cur_coins = get_coins(u)
1107
+ coin_msg = 'Admin' if is_adm else f'🪙 {cur_coins} coins'
1108
+ job_progress[tid] = {'pct': 2, 'msg': f'⏳ Starting… {coin_msg}', 'done': False}
1109
+
1110
+ cpu_queue_wait()
1111
+
1112
+ try:
1113
+ job_progress[tid] = {'pct': 8, 'msg': '📥 Downloading video…', 'done': False}
1114
+ if video_file and video_file.filename:
1115
+ vpath = f'{tmp_dir}/input.mp4'
1116
+ video_file.save(vpath)
1117
+ elif video_url:
1118
+ out_tmpl = f'{tmp_dir}/input.%(ext)s'
1119
+ ytdlp_download(out_tmpl, video_url)
1120
+ found = glob.glob(f'{tmp_dir}/input.*')
1121
+ if found: vpath = found[0]
1122
+ if not vpath: return jsonify(ok=False, msg='❌ No video selected')
1123
+
1124
+ job_progress[tid] = {'pct': 20, 'msg': '🎙️ Transcribing with Whisper…', 'done': False}
1125
+ if whisper is None: raise Exception('whisper not installed')
1126
+ if whisper_model is None:
1127
+ whisper_model = whisper.load_model('tiny', device='cpu')
1128
+ res = run_stage('whisper', whisper_model.transcribe, vpath, fp16=False)
1129
+ tr = res['text']; src_lang = res.get('language', 'en')
1130
+
1131
+ if vo_lang == 'en':
1132
+ # English — skip AI API, use whisper transcript directly
1133
+ sc = tr.strip()
1134
+ caption_text = sc[:60].strip() + ('…' if len(sc) > 60 else '')
1135
+ hashtags = '#english #movierecap #viral #foryou #trending'
1136
+ else:
1137
+ job_progress[tid] = {'pct': 45, 'msg': '🤖 Generating AI script…', 'done': False}
1138
+ sys_p = get_sys_prompt(ct, vo_lang)
1139
+ sys_p = sys_p + '\n' + get_num_rule(vo_lang)
1140
+ out_txt, _ = run_stage('ai', call_api,
1141
+ [{'role':'system','content':sys_p},
1142
+ {'role':'user','content':f'Language:{src_lang}\n\n{tr}'}], api=api)
1143
+ sc, caption_text, hashtags = parse_out(out_txt)
1144
+
1145
+ if music_file and music_file.filename:
1146
+ mpath = f'{tmp_dir}/music.mp3'
1147
+ music_file.save(mpath)
1148
+
1149
+ logo_path = None
1150
+ if logo_file and logo_file.filename:
1151
+ ext = Path(logo_file.filename).suffix or '.png'
1152
+ logo_path = f'{tmp_dir}/logo{ext}'
1153
+ logo_file.save(logo_path)
1154
+ blur_enabled = request.form.get('blur_enabled') == '1'
1155
+ blur_x = int(request.form.get('blur_x', 0))
1156
+ blur_y = int(request.form.get('blur_y', 0))
1157
+ blur_w = int(request.form.get('blur_w', 0))
1158
+ blur_h = int(request.form.get('blur_h', 0))
1159
+ rate = f'+{spd}%'
1160
+ sentences = split_txt(sc, vo_lang)
1161
+ if engine == 'gemini':
1162
+ parts = run_stage('tts', run_gemini_tts_sync, sentences, voice_id, tmp_dir, speed=spd)
1163
+ else:
1164
+ parts = run_stage('tts', run_tts_sync, sentences, voice_id, rate, tmp_dir)
1165
+
1166
+ cmb = f'{tmp_dir}/combined.mp3'
1167
+ lst = f'{tmp_dir}/list.txt'
1168
+ with open(lst, 'w') as lf:
1169
+ for a in parts: lf.write(f"file '{os.path.abspath(a)}'\n")
1170
+ subprocess.run(
1171
+ f'ffmpeg -y -f concat -safe 0 -i "{lst}" '
1172
+ f'-af "silenceremove=start_periods=1:stop_periods=-1:stop_duration=0.1:stop_threshold=-50dB" '
1173
+ f'-c:a libmp3lame -q:a 2 "{cmb}"', shell=True, check=True)
1174
+
1175
+ job_progress[tid] = {'pct': 78, 'msg': '🎬 Rendering video…', 'done': False}
1176
+
1177
+ vd = dur(vpath); ad = dur(cmb)
1178
+ if vd <= 0: raise Exception('Video duration read failed')
1179
+ if ad <= 0: raise Exception('Audio duration read failed')
1180
+
1181
+ _build_video(vpath, cmb, mpath, ad, vd, crop, flip, col, wmk, out_file,
1182
+ logo_path=logo_path, logo_x=logo_x, logo_y=logo_y, logo_w=logo_w,
1183
+ blur_enabled=blur_enabled, blur_x=blur_x, blur_y=blur_y, blur_w=blur_w, blur_h=blur_h)
1184
+
1185
+ rem = -1
1186
+ if not is_adm:
1187
+ _, rem = deduct(u, 2); upd_stat(u, 'tr'); upd_stat(u, 'vd')
1188
+
1189
+ job_progress[tid] = {'pct': 100, 'msg': '✅ Done!', 'done': True}
1190
+
1191
+ return jsonify(
1192
+ ok=True,
1193
+ output_url=f'/outputs/final_{tid}.mp4',
1194
+ title=caption_text,
1195
+ caption=caption_text,
1196
+ hashtags=hashtags,
1197
+ source_lang=src_lang,
1198
+ coins=rem,
1199
+ tid=tid
1200
+ )
1201
+
1202
+ finally:
1203
+ shutil.rmtree(tmp_dir, ignore_errors=True)
1204
+
1205
+ except Exception as e:
1206
+ import traceback; traceback.print_exc()
1207
+ try: job_progress[tid] = {'pct': 0, 'msg': f'❌ {e}', 'error': True}
1208
+ except: pass
1209
+ return jsonify(ok=False, msg=f'❌ {e}')
1210
+
1211
+ # ── ADMIN ──
1212
+ @app.route('/api/admin/create_user', methods=['POST'])
1213
+ def api_create_user():
1214
  try:
1215
+ d = request.get_json(force=True) or {}
1216
+ msg, uname = create_user_fn(d.get('username',''), d.get('coins',10), d.get('caller',''))
1217
+ return jsonify(ok=bool(uname), msg=msg, username=uname)
1218
+ except Exception as e:
1219
+ return jsonify(ok=False, msg=str(e))
1220
+
1221
+ @app.route('/api/admin/coins', methods=['POST'])
1222
+ def api_coins():
 
 
 
 
 
 
 
1223
  try:
1224
+ d = request.get_json(force=True) or {}
1225
+ if d.get('caller') != ADMIN_U: return jsonify(ok=False, msg='❌ Admin only')
1226
+ u = d.get('username',''); n = d.get('amount', 10)
1227
+ msg = set_coins_fn(u, n) if d.get('action') == 'set' else add_coins_fn(u, n)
1228
+ return jsonify(ok=True, msg=msg)
1229
+ except Exception as e:
1230
+ return jsonify(ok=False, msg=str(e))
1231
+
1232
+ @app.route('/api/admin/users')
1233
+ def api_users():
1234
+ try:
1235
+ if request.args.get('caller') != ADMIN_U:
1236
+ return jsonify(ok=False, msg='❌ Admin only')
1237
+ db = load_db()
1238
+ users = [{'username':k,'coins':v.get('coins',0),
1239
+ 'transcripts':v.get('total_transcripts',0),
1240
+ 'videos':v.get('total_videos',0),
1241
+ 'created':v.get('created_at','')[:10]}
1242
+ for k,v in db['users'].items()]
1243
+ return jsonify(ok=True, users=users)
1244
+ except Exception as e:
1245
+ return jsonify(ok=False, msg=str(e))
1246
+
1247
+ @app.route('/api/admin/delete_user', methods=['POST'])
1248
+ def api_delete_user():
1249
+ try:
1250
+ d = request.get_json(force=True) or {}
1251
+ if d.get('caller') != ADMIN_U: return jsonify(ok=False, msg='❌ Admin only')
1252
+ u = d.get('username','').strip()
1253
+ if not u: return jsonify(ok=False, msg='❌ No username')
1254
+ db = load_db()
1255
+ if u not in db['users']: return jsonify(ok=False, msg='❌ User not found')
1256
+ del db['users'][u]; save_db(db)
1257
+ return jsonify(ok=True, msg=f'✅ {u} deleted')
1258
+ except Exception as e:
1259
+ return jsonify(ok=False, msg=str(e))
1260
+
1261
+ @app.route('/api/admin/gen_username')
1262
+ def api_gen_username():
1263
+ try:
1264
+ if request.args.get('caller') != ADMIN_U:
1265
+ return jsonify(ok=False, msg='❌ Admin only')
1266
+ return jsonify(ok=True, username=gen_uname())
1267
+ except Exception as e:
1268
+ return jsonify(ok=False, msg=str(e))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1269
 
1270
  if __name__ == '__main__':
1271
+ app.run(host='0.0.0.0', port=7860, debug=False, threaded=True)
 
index.html CHANGED
@@ -1,317 +1,1295 @@
1
  <!DOCTYPE html>
2
- <html lang="my">
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
- <title>Recap Maker - Secure Mode</title>
7
-
8
- <style>
9
- :root {
10
- --bg-color: #0f172a; --card-bg: #1e293b; --text-main: #e2e8f0;
11
- --text-muted: #94a3b8; --accent: #f43f5e; --border: #334155;
12
- --danger: #ef4444; --success: #22c55e;
13
- }
14
- body { background-color: var(--bg-color); color: var(--text-main); font-family: 'Segoe UI', system-ui, sans-serif; margin: 0; padding: 15px; overscroll-behavior: none; }
15
- .container { max-width: 600px; margin: 0 auto; padding-bottom: 80px; }
16
- h2 { text-align: center; color: var(--accent); letter-spacing: 2px; margin-bottom: 5px; font-weight: 900; text-transform: uppercase; }
17
- .user-badge { text-align: center; color: var(--text-muted); font-size: 12px; margin-bottom: 20px; display: block; }
18
- .card { background: var(--card-bg); border-radius: 16px; padding: 20px; margin-bottom: 20px; border: 1px solid var(--border); }
19
- .card-title { font-size: 14px; font-weight: bold; color: var(--text-muted); margin-bottom: 10px; display: block; text-transform: uppercase; }
20
- input[type="text"], textarea, select { width: 100%; padding: 14px; background: #020617; border: 1px solid var(--border); border-radius: 10px; color: white; font-size: 16px; outline: none; box-sizing: border-box; margin-bottom: 10px; }
21
- .btn { width: 100%; padding: 16px; border: none; border-radius: 12px; font-weight: bold; font-size: 16px; cursor: pointer; display: flex; justify-content: center; align-items: center; gap: 8px; text-decoration: none; transition: 0.2s;}
22
- .btn-primary { background: var(--accent); color: white; }
23
- .btn-success { background: var(--success); color: #000; }
24
- .btn-outline { background: transparent; border: 2px solid var(--border); color: var(--text-muted); }
25
- .btn-outline.active { border-color: var(--accent); color: var(--accent); background: rgba(244, 63, 94, 0.1); }
26
-
27
- .video-box { width: 100%; background: #000; border-radius: 12px; border: 2px dashed var(--border); position: relative; overflow: hidden; margin-top: 15px; min-height: 150px; display: flex; justify-content: center; align-items: center; }
28
- video { width: 100%; height: auto; display: block; }
29
- .placeholder-text { position: absolute; color: var(--text-muted); font-size: 14px; pointer-events: none; }
30
- .drag-box { position: absolute; display: none; z-index: 50; touch-action: none; }
31
- .resize-handle { width: 30px; height: 30px; background: white; position: absolute; bottom: -10px; right: -10px; border-radius: 50%; box-shadow: 0 0 5px rgba(0,0,0,0.5); z-index: 60;}
32
-
33
- .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px; }
34
- .checkbox-wrapper { display: flex; align-items: center; background: #020617; padding: 12px; border-radius: 8px; border: 1px solid var(--border); cursor: pointer; }
35
- .checkbox-wrapper input { width: auto; margin-right: 10px; accent-color: var(--accent); }
36
- .switch-group { display: flex; justify-content: space-between; align-items: center; background: #020617; padding: 15px; border-radius: 10px; border: 1px solid var(--accent); }
37
- .switch { position: relative; display: inline-block; width: 50px; height: 26px; }
38
- .switch input { opacity: 0; width: 0; height: 0; }
39
- .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #334155; transition: .4s; border-radius: 34px; }
40
- .slider:before { position: absolute; content: ""; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; }
41
- input:checked + .slider { background-color: var(--accent); }
42
- input:checked + .slider:before { transform: translateX(24px); }
43
- #result-section { display: none; border-color: var(--success); }
44
- #loader { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(15, 23, 42, 0.95); z-index: 9999; text-align: center; padding-top: 35vh; color: var(--accent); font-size: 1.2rem; }
45
- </style>
46
-
47
- <script>
48
- function switchSource(type) {
49
- document.getElementById('src-link').style.display = 'none'; document.getElementById('src-file').style.display = 'none';
50
- document.getElementById('btnSrcLink').classList.remove('active'); document.getElementById('btnSrcFile').classList.remove('active');
51
- if(type === 'link') { document.getElementById('src-link').style.display = 'block'; document.getElementById('btnSrcLink').classList.add('active'); }
52
- else { document.getElementById('src-file').style.display = 'block'; document.getElementById('btnSrcFile').classList.add('active'); }
53
- }
54
- function loader(show, msg="Processing...") {
55
- const el = document.getElementById('loader');
56
- el.innerHTML = `<div>${msg}</div><div style="font-size:14px; color:#94a3b8; margin-top:10px;">(Do not close this page)</div>`;
57
- el.style.display = show ? 'block' : 'none';
58
- }
59
- function statusMsg(text) { const el = document.getElementById('statusMsg'); el.innerText = text; el.style.display = text ? 'block' : 'none'; }
60
-
61
- async function showResult(serverUrl) {
62
- loader(true, "📥 Saving to Browser Memory...");
63
- try {
64
- const response = await fetch(serverUrl);
65
- if (!response.ok) throw new Error("File transfer failed.");
66
- const blob = await response.blob();
67
- const objectUrl = URL.createObjectURL(blob);
68
- const section = document.getElementById('result-section');
69
- const player = document.getElementById('resultVideo');
70
- const dlBtn = document.getElementById('downloadBtn');
71
- player.src = objectUrl; dlBtn.href = objectUrl; dlBtn.download = "recap_" + new Date().getTime() + ".mp4";
72
- section.style.display = 'block'; section.scrollIntoView({behavior: "smooth"});
73
- statusMsg("✅ Video Ready!");
74
- } catch (e) { alert("Error: " + e); } finally { loader(false); }
75
- }
76
-
77
- async function downloadVideo() {
78
- const u = document.getElementById('videoUrl').value; if(!u) return alert("Enter URL");
79
- statusMsg("Downloading Video..."); loader(true, "Downloading...");
80
- try {
81
- const res = await fetch('/download-video', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({url:u})});
82
- handleRes(await res.json());
83
- } catch(e) { alert("Error: " + e); } loader(false);
84
- }
85
-
86
- async function uploadVideoFile() {
87
- const f = document.getElementById('videoFileInput').files[0]; if(!f) return alert("Select File");
88
- const fd = new FormData(); fd.append('video_file', f);
89
- statusMsg("Uploading..."); loader(true, "Uploading...");
90
- try {
91
- const res = await fetch('/upload-video', {method:'POST', body:fd});
92
- handleRes(await res.json());
93
- } catch(e) { alert("Error"); } loader(false);
94
- }
95
-
96
- function handleRes(d) {
97
- if(d.status === 'success') {
98
- document.getElementById('videoFilename').value = d.filename;
99
- document.querySelector('.placeholder-text').style.display = 'none';
100
- const old = document.getElementById('mainVideo'); if(old) old.remove();
101
- const v = document.createElement('video');
102
- v.id = 'mainVideo'; v.src = d.path; v.controls = true; v.style.width = '100%';
103
- document.getElementById('videoContainer').prepend(v);
104
-
105
- v.onloadedmetadata = () => {
106
- updateCoords('blurBox'); updateCoords('logoBox');
107
- };
108
-
109
- statusMsg("✅ Video Uploaded! Checking AI...");
110
- if(!d.translated_text) reloadTranslation(); else document.querySelector('textarea[name="ai_text"]').value = d.translated_text;
111
- } else { alert(d.message); }
112
- }
113
-
114
- async function reloadTranslation() {
115
- const fname = document.getElementById('videoFilename').value;
116
- if(!fname) return;
117
- const btn = document.getElementById('btnReloadAI'); btn.innerHTML = "⏳ Fixing..."; btn.disabled = true;
118
- document.querySelector('textarea[name="ai_text"]').value = "⏳ AI Analyzing...";
119
- try {
120
- const fd = new FormData(); fd.append('filename', fname);
121
- const res = await fetch('/re-analyze', {method:'POST', body:fd});
122
- const d = await res.json();
123
- document.querySelector('textarea[name="ai_text"]').value = d.translated_text || d.message;
124
- } catch(e) { alert("Connection Error"); }
125
- btn.innerHTML = "↻ Retry AI"; btn.disabled = false;
126
- }
127
-
128
- async function startProcessing() {
129
- if(!document.getElementById('videoFilename').value) return alert("No Video!");
130
- document.getElementById('result-section').style.display = 'none'; loader(true, "Joining Queue...");
131
- const fd = new FormData(document.getElementById('processForm'));
132
- try {
133
- const res = await fetch('/process', {method:'POST', body:fd});
134
- const d = await res.json();
135
- if(d.status === 'queued') checkStatus(d.job_id); else { alert(d.message); loader(false); }
136
- } catch(e) { alert("Error"); loader(false); }
137
- }
138
-
139
- function checkStatus(jobId) {
140
- const interval = setInterval(async () => {
141
- try {
142
- const res = await fetch('/status/' + jobId);
143
- const d = await res.json();
144
- if(d.status === 'success') { clearInterval(interval); loader(false); showResult(d.url); }
145
- else if(d.status === 'failed') { clearInterval(interval); loader(false); alert("Failed: " + d.message); }
146
- } catch(e) {}
147
- }, 3000);
148
- }
149
-
150
- function toggleBox(id, show) {
151
- const el = document.getElementById(id); if(el) { el.style.display = show ? 'block' : 'none'; if(show) updateCoords(id); }
152
- }
153
- function loadLogo(e) {
154
- if(e.target.files[0]) {
155
- const r = new FileReader();
156
- r.onload = function(ev) { document.getElementById('previewLogo').src=ev.target.result; toggleBox('logoBox', true); };
157
- r.readAsDataURL(e.target.files[0]);
158
- }
159
- }
160
-
161
- function updateCoords(id) {
162
- const v = document.getElementById('mainVideo');
163
- const box = document.getElementById(id);
164
- if(!v || !box || v.videoWidth === 0) return;
165
-
166
- const vr = v.getBoundingClientRect();
167
- const br = box.getBoundingClientRect();
168
-
169
- const scaleX = v.videoWidth / vr.width;
170
- const scaleY = v.videoHeight / vr.height;
171
-
172
- const x = Math.round((br.left - vr.left) * scaleX);
173
- const y = Math.round((br.top - vr.top) * scaleY);
174
- const w = Math.round(br.width * scaleX);
175
- const h = Math.round(br.height * scaleY);
176
-
177
- if(id==='blurBox') {
178
- document.getElementById('blur_x').value=x; document.getElementById('blur_y').value=y;
179
- document.getElementById('blur_w').value=w; document.getElementById('blur_h').value=h;
180
- } else {
181
- document.getElementById('logo_x').value=x; document.getElementById('logo_y').value=y;
182
- document.getElementById('logo_w').value=w; document.getElementById('logo_h').value=h;
183
- }
184
- }
185
-
186
- function makeInteractive(id) {
187
- const box = document.getElementById(id);
188
- const handle = box.querySelector('.resize-handle');
189
- if(!box || !handle) return;
190
- let isDrag=false, isResize=false, startX, startY, sl, st, sw, sh;
191
- const start = (e) => {
192
- if(e.target === handle) return;
193
- isDrag = true; const p = e.type.includes('mouse') ? e : e.touches[0];
194
- startX = p.clientX; startY = p.clientY; sl = box.offsetLeft; st = box.offsetTop;
195
- };
196
- const resizeStart = (e) => {
197
- e.stopPropagation(); isResize = true; const p = e.type.includes('mouse') ? e : e.touches[0];
198
- startX = p.clientX; startY = p.clientY; sw = box.offsetWidth; sh = box.offsetHeight;
199
- };
200
- const move = (e) => {
201
- const c = document.getElementById('videoContainer');
202
- if(!isDrag && !isResize) return;
203
- if(e.cancelable) e.preventDefault();
204
- const p = e.type.includes('mouse') ? e : e.touches[0];
205
- if(isDrag) {
206
- let nl = sl + (p.clientX - startX); let nt = st + (p.clientY - startY);
207
- nl = Math.max(0, Math.min(nl, c.clientWidth - box.offsetWidth));
208
- nt = Math.max(0, Math.min(nt, c.clientHeight - box.offsetHeight));
209
- box.style.left = nl + 'px'; box.style.top = nt + 'px'; updateCoords(id);
210
- }
211
- if(isResize) {
212
- let nw = sw + (p.clientX - startX); let nh = sh + (p.clientY - startY);
213
- nw = Math.max(30, nw); nh = Math.max(30, nh);
214
- box.style.width = nw + 'px'; box.style.height = nh + 'px'; updateCoords(id);
215
- }
216
- };
217
- const end = () => { isDrag = false; isResize = false; };
218
- box.addEventListener('mousedown', start); box.addEventListener('touchstart', start, {passive: false});
219
- handle.addEventListener('mousedown', resizeStart); handle.addEventListener('touchstart', resizeStart, {passive: false});
220
- window.addEventListener('mousemove', move); window.addEventListener('touchmove', move, {passive: false});
221
- window.addEventListener('mouseup', end); window.addEventListener('touchend', end);
222
- }
223
-
224
- document.addEventListener('DOMContentLoaded', () => {
225
- makeInteractive('blurBox'); makeInteractive('logoBox');
226
- window.addEventListener('resize', () => { updateCoords('blurBox'); updateCoords('logoBox'); });
227
- });
228
- </script>
229
  </head>
230
  <body>
231
- <div id="loader">Processing...</div>
232
- <div class="container">
233
- <h2>Recap Maker</h2>
234
- <span class="user-badge">User Session: {{ user_id }}</span>
235
-
236
- <div id="result-section" class="card" style="border: 2px solid var(--success);">
237
- <span class="card-title" style="color:var(--success);">✅ Result Video</span>
238
- <div class="video-box" style="border-color:var(--success);">
239
- <video id="resultVideo" controls style="width:100%; height:auto;"></video>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  </div>
241
- <br>
242
- <a id="downloadBtn" href="#" download class="btn btn-success" style="color:white; font-weight:bold;">⬇️ Download Video</a>
 
 
 
 
 
 
 
243
  </div>
244
 
245
- <div id="view-editor">
246
- <div class="card">
247
- <span class="card-title">Video Source</span>
248
- <div class="grid-2">
249
- <button id="btnSrcLink" class="btn btn-outline active" onclick="switchSource('link')">🔗 URL Link</button>
250
- <button id="btnSrcFile" class="btn btn-outline" onclick="switchSource('file')">📂 Upload File</button>
251
- </div>
252
- <div id="src-link" style="margin-top:15px;">
253
- <input type="text" id="videoUrl" placeholder="https://youtube.com/...">
254
- <button onclick="downloadVideo()" class="btn btn-primary">⬇️ Fetch Video</button>
255
- </div>
256
- <div id="src-file" style="margin-top:15px; display:none;">
257
- <input type="file" id="videoFileInput" style="margin-bottom:10px;">
258
- <button onclick="uploadVideoFile()" class="btn btn-primary">⬆️ Upload Video</button>
259
- </div>
260
- <p id="statusMsg" style="text-align:center; color:var(--accent); margin-top:10px; display:none;"></p>
261
- <div class="video-box" id="videoContainer">
262
- <span class="placeholder-text">Video Preview Area</span>
263
- <div id="blurBox" class="drag-box" style="width:100px; height:50px; background:rgba(239, 68, 68, 0.4); border:2px solid var(--danger);"><div class="resize-handle"></div></div>
264
- <div id="logoBox" class="drag-box" style="width:80px; height:80px; border:2px dashed var(--success);"><img id="previewLogo" style="width:100%; height:100%; object-fit:contain; pointer-events:none;"><div class="resize-handle"></div></div>
265
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  </div>
 
267
 
268
- <form id="processForm">
269
- <input type="hidden" name="video_filename" id="videoFilename">
270
- <input type="hidden" name="blur_x" id="blur_x" value="0"><input type="hidden" name="blur_y" id="blur_y" value="0">
271
- <input type="hidden" name="blur_w" id="blur_w" value="0"><input type="hidden" name="blur_h" id="blur_h" value="0">
272
- <input type="hidden" name="logo_x" id="logo_x" value="0"><input type="hidden" name="logo_y" id="logo_y" value="0">
273
- <input type="hidden" name="logo_w" id="logo_w" value="100"><input type="hidden" name="logo_h" id="logo_h" value="100">
274
-
275
- <div class="card">
276
- <span class="card-title">🛡️ Copyright Bypass Tools</span>
277
- <div class="grid-2">
278
- <label class="checkbox-wrapper"><input type="checkbox" name="bypass_flip"> Flip</label>
279
- <label class="checkbox-wrapper"><input type="checkbox" name="bypass_zoom"> Zoom</label>
280
- <label class="checkbox-wrapper"><input type="checkbox" name="bypass_speed"> Speed</label>
281
- <label class="checkbox-wrapper"><input type="checkbox" name="bypass_color"> Color</label>
282
- </div>
283
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
- <div class="switch-group">
286
- <div style="display:flex; align-items:center; gap:10px;">
287
- <span style="font-size:20px;">💰</span>
288
- <div><strong style="color:white;">Monezlation Mode</strong><small style="color:var(--text-muted); display:block;">Sync & Loop (1:10 min)</small></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  </div>
290
- <label class="switch"><input type="checkbox" name="monezlation"><span class="slider"></span></label>
291
- </div>
292
- <br>
293
- <div class="card">
294
- <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
295
- <span class="card-title" style="margin:0;">AI Dubbing</span>
296
- <button type="button" id="btnReloadAI" onclick="reloadTranslation()" class="btn btn-outline" style="padding:6px 12px; font-size:12px; width:auto;">↻ Retry AI</button>
297
  </div>
298
- <textarea name="ai_text" rows="4" placeholder="Translated Burmese script..."></textarea>
299
- <select name="voice_gender"><option value="male">Male Voice</option><option value="female">Female Voice</option></select>
300
- </div>
301
- <div class="card">
302
- <span class="card-title">Overlays</span>
303
- <div style="position:relative; margin-bottom:10px;">
304
- <label style="color:var(--text-muted); font-size:12px; display:block;">Text Watermark</label>
305
- <input type="text" name="text_watermark" value="Shine Movie Recap" readonly style="color:var(--accent); background:#1e293b; font-weight:bold;">
306
- <span style="position:absolute; right:15px; top:35px; font-size:12px; color:var(--accent);">🔒 LOCKED</span>
307
  </div>
308
- <label class="checkbox-wrapper" style="margin-bottom:10px;"><input type="checkbox" onclick="toggleBox('blurBox', this.checked)" name="blur_enabled"> Enable Blur Mask (Red Box)</label>
309
- <label style="color:var(--text-muted); font-size:14px;">Logo Overlay:</label>
310
- <input type="file" name="logo_file" accept="image/*" onchange="loadLogo(event)">
311
  </div>
312
- <button type="button" onclick="startProcessing()" class="btn btn-primary">🚀 START PROCESSING</button>
313
- </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  </div>
 
315
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  </body>
317
- </html>
 
1
  <!DOCTYPE html>
2
+ <html lang="en">
3
  <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Recap Studio</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
10
+ <style>
11
+ :root {
12
+ --bg: #ffffff;
13
+ --bg2: #f8f9fb;
14
+ --bg3: #f0f1f4;
15
+ --border: #e2e4ea;
16
+ --border2: #d0d3db;
17
+ --text: #1a1d28;
18
+ --muted: #6b7080;
19
+ --muted2: #8b90a0;
20
+ --amber: #f5a623;
21
+ --amber2: #e69500;
22
+ --violet: #6c5ce7;
23
+ --green: #00b894;
24
+ --red: #e74c3c;
25
+ --cyan: #0984e3;
26
+ --sans: 'Inter', -apple-system, sans-serif;
27
+ }
28
+ *{box-sizing:border-box;margin:0;padding:0}
29
+ body{background:var(--bg);color:var(--text);font-family:var(--sans);min-height:100vh;overflow-x:hidden}
30
+
31
+ /* ── TOPBAR ── */
32
+ .topbar{display:flex;align-items:center;justify-content:space-between;padding:0 20px;height:54px;background:var(--bg);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:100}
33
+ .logo{display:flex;align-items:center;gap:10px;font-weight:700;font-size:1.05rem;color:var(--text)}
34
+ .logo-icon{width:32px;height:32px;background:linear-gradient(135deg,var(--amber),var(--violet));border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:.9rem}
35
+ .topbar-right{display:flex;align-items:center;gap:10px}
36
+ .coin-badge{display:flex;align-items:center;gap:6px;padding:5px 12px;background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.2);border-radius:20px;font-size:.82rem;font-weight:600;color:var(--amber2)}
37
+ .btn-sm{padding:6px 14px;border-radius:6px;border:none;cursor:pointer;font-family:var(--sans);font-size:.8rem;font-weight:600;transition:.2s}
38
+ .btn-logout{background:transparent;border:1px solid var(--border2);color:var(--muted2)}
39
+ .btn-logout:hover{border-color:var(--red);color:var(--red)}
40
+ .btn-admin{background:rgba(108,92,231,.08);border:1px solid rgba(108,92,231,.2);color:var(--violet)}
41
+ .btn-buy{background:linear-gradient(135deg,var(--amber),var(--amber2));color:#fff;font-weight:700}
42
+
43
+ /* ── LOGIN ── */
44
+ #login-screen{min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg)}
45
+ .login-box{width:380px;padding:40px;background:var(--bg);border:1px solid var(--border);border-radius:16px;box-shadow:0 4px 24px rgba(0,0,0,.06)}
46
+ .login-box h2{font-size:1.4rem;font-weight:700;margin-bottom:6px;color:var(--text)}
47
+ .login-box p{color:var(--muted2);font-size:.85rem;margin-bottom:28px}
48
+ .field{margin-bottom:14px}
49
+ .field label{display:block;font-size:.72rem;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--muted);margin-bottom:6px}
50
+ .input{width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--text);font-family:var(--sans);font-size:.9rem;outline:none;transition:.2s}
51
+ .input:focus{border-color:var(--amber)}
52
+ .btn-primary{width:100%;padding:11px;background:linear-gradient(135deg,var(--amber),var(--amber2));border:none;border-radius:8px;color:#fff;font-family:var(--sans);font-size:.95rem;font-weight:700;cursor:pointer;transition:.2s;margin-top:4px}
53
+ .btn-primary:hover{opacity:.9;transform:translateY(-1px)}
54
+ .msg-box{padding:10px 12px;border-radius:7px;font-size:.82rem;margin-bottom:12px;display:none}
55
+ .msg-ok{background:rgba(0,184,148,.08);border:1px solid rgba(0,184,148,.2);color:var(--green)}
56
+ .msg-err{background:rgba(231,76,60,.08);border:1px solid rgba(231,76,60,.2);color:var(--red)}
57
+
58
+ /* ── APP LAYOUT ── */
59
+ #app-screen{display:none}
60
+ .app-wrap{display:grid;grid-template-columns:1fr 340px;gap:16px;padding:16px;max-width:1200px;margin:0 auto}
61
+ @media(max-width:900px){.app-wrap{grid-template-columns:1fr}}
62
+
63
+ /* ── CARDS ── */
64
+ .card{background:var(--bg);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:12px}
65
+ .card-label{font-size:.68rem;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted);margin-bottom:12px;display:flex;align-items:center;gap:6px}
66
+ .card-label i{color:var(--amber)}
67
+
68
+ /* ── UPLOAD ── */
69
+ .upload-area{border:2px dashed var(--border);border-radius:8px;padding:24px;text-align:center;cursor:pointer;transition:.2s;position:relative;overflow:hidden}
70
+ .upload-area:hover{border-color:var(--amber);background:rgba(245,166,35,.02)}
71
+ .upload-area.drag{border-color:var(--amber);background:rgba(245,166,35,.04)}
72
+ .upload-area input{position:absolute;inset:0;opacity:0;cursor:pointer}
73
+ .upload-icon{font-size:1.8rem;color:var(--muted);margin-bottom:8px}
74
+ .upload-text{font-size:.85rem;color:var(--muted2)}
75
+ .upload-text span{color:var(--amber)}
76
+ .upload-name{margin-top:8px;font-size:.8rem;color:var(--green);display:none}
77
+
78
+ /* ── URL INPUT ── */
79
+ .input-row{display:flex;gap:8px}
80
+ .paste-btn{padding:0 14px;background:var(--bg3);border:1px solid var(--border);border-radius:7px;color:var(--muted2);cursor:pointer;transition:.2s;flex-shrink:0}
81
+ .paste-btn:hover{color:var(--amber);border-color:var(--amber)}
82
+ .platform-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:5px;font-size:.75rem;font-weight:600;margin-top:8px}
83
+
84
+ /* ── VOICE ── */
85
+ .vcat-tabs{display:flex;gap:6px;margin-bottom:10px}
86
+ .vcat-btn{flex:1;padding:7px;background:var(--bg3);border:1px solid var(--border);color:var(--muted2);font-family:var(--sans);font-size:.78rem;font-weight:600;border-radius:5px;cursor:pointer;transition:.2s}
87
+ .vcat-btn.active{background:rgba(245,166,35,.08);border-color:var(--amber);color:var(--amber2)}
88
+ .voice-search{width:100%;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:var(--sans);font-size:.8rem;outline:none;margin-bottom:8px}
89
+ .voice-search:focus{border-color:var(--amber)}
90
+ .voice-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;max-height:220px;overflow-y:auto;padding-right:2px}
91
+ .voice-grid::-webkit-scrollbar{width:3px}
92
+ .voice-grid::-webkit-scrollbar-track{background:transparent}
93
+ .voice-grid::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
94
+ .vcard{background:var(--bg3);border:1px solid var(--border);border-radius:7px;padding:8px 6px;text-align:center;cursor:pointer;transition:.2s}
95
+ .vcard:hover{border-color:var(--border2);background:var(--bg2)}
96
+ .vcard.selected{border-color:var(--amber);background:rgba(245,166,35,.06)}
97
+ .vcard-name{font-size:.72rem;font-weight:600;margin-bottom:2px;line-height:1.2}
98
+ .vcard-sub{font-size:.6rem;color:var(--muted);margin-bottom:4px}
99
+ .vcard-play{display:flex;align-items:center;justify-content:center;gap:3px;margin-top:4px;padding:3px 0;border-radius:4px;background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.15);color:var(--amber2);font-size:.6rem;font-weight:600;cursor:pointer;transition:.2s}
100
+ .vcard-play:hover{background:rgba(245,166,35,.18)}
101
+ .vcard-play.playing{background:rgba(0,184,148,.08);border-color:rgba(0,184,148,.2);color:var(--green)}
102
+
103
+ /* ── SPEED ── */
104
+ .speed-toggle{display:flex;align-items:center;gap:6px;margin-top:10px;cursor:pointer;font-size:.78rem;color:var(--muted2);font-weight:500;user-select:none}
105
+ .speed-toggle:hover{color:var(--amber)}
106
+ .speed-toggle i{transition:transform .2s}
107
+ .speed-toggle.open i{transform:rotate(180deg)}
108
+ .speed-row{display:none;align-items:center;gap:10px;margin-top:8px;padding:8px 10px;background:var(--bg3);border-radius:6px}
109
+ .speed-row.visible{display:flex}
110
+ .speed-label{font-size:.75rem;color:var(--muted2);white-space:nowrap}
111
+ .speed-val{font-size:.75rem;color:var(--amber2);font-weight:600;min-width:32px;text-align:right}
112
+ input[type=range]{flex:1;accent-color:var(--amber);cursor:pointer}
113
+
114
+ /* ── OPTIONS ── */
115
+ .checks-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
116
+ .check-item{display:flex;align-items:center;gap:8px;padding:9px 10px;background:var(--bg3);border:1px solid var(--border);border-radius:5px;cursor:pointer;font-size:.8rem;color:var(--muted);transition:.2s;user-select:none}
117
+ .check-item input{display:none}
118
+ .check-item:hover{border-color:var(--border2);color:var(--text)}
119
+ .check-item.checked{border-color:var(--amber);color:var(--text);background:rgba(245,166,35,.05)}
120
+ .check-box{width:15px;height:15px;border-radius:3px;border:1.5px solid var(--border2);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:.6rem;transition:.2s}
121
+ .check-item.checked .check-box{background:var(--amber);border-color:var(--amber);color:#fff}
122
+
123
+ .grid2{display:grid;grid-template-columns:1fr 1fr;gap:8px}
124
+ .field-label{font-size:.72rem;color:var(--muted);margin-bottom:5px;font-weight:600}
125
+ select.input{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%236b7080'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;padding-right:28px}
126
+
127
+ /* ── SCRIPT ── */
128
+ .script-area{width:100%;min-height:140px;padding:10px 12px;background:var(--bg3);border:1px solid var(--border);border-radius:7px;color:var(--text);font-family:var(--sans);font-size:.88rem;resize:vertical;outline:none;line-height:1.6;transition:.2s}
129
+ .script-area:focus{border-color:var(--amber)}
130
+ .script-meta{display:flex;justify-content:space-between;margin-top:6px;font-size:.72rem;color:var(--muted)}
131
+
132
+ /* ── BUTTONS ── */
133
+ .btn-full{width:100%;padding:12px;border:none;border-radius:8px;font-family:var(--sans);font-size:.95rem;font-weight:700;cursor:pointer;transition:.2s;display:flex;align-items:center;justify-content:center;gap:8px}
134
+ .btn-amber{background:linear-gradient(135deg,var(--amber),var(--amber2));color:#fff}
135
+ .btn-amber:hover{opacity:.9;transform:translateY(-1px)}
136
+ .btn-amber:disabled{opacity:.4;transform:none;cursor:not-allowed}
137
+ .btn-violet{background:rgba(108,92,231,.1);border:1px solid rgba(108,92,231,.2);color:var(--violet)}
138
+ .btn-violet:hover{background:rgba(108,92,231,.18)}
139
+ .btn-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px}
140
+
141
+ /* ── PROGRESS ── */
142
+ .prog-wrap{margin-top:10px;display:none}
143
+ .prog-bar-bg{height:6px;background:var(--bg3);border-radius:3px;overflow:hidden;margin-bottom:6px}
144
+ .prog-bar{height:100%;background:linear-gradient(90deg,var(--amber),var(--amber2));border-radius:3px;transition:width .4s ease;width:0%}
145
+ .prog-msg{font-size:.78rem;color:var(--muted2);line-height:1.5}
146
+
147
+ /* ── PREVIEW ── */
148
+ .preview-panel{position:sticky;top:70px}
149
+ .preview-box{background:var(--bg);border:1px solid var(--border);border-radius:10px;overflow:hidden}
150
+ .preview-top{padding:12px 14px;border-bottom:1px solid var(--border);font-size:.68rem;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--muted);display:flex;align-items:center;gap:6px}
151
+ .preview-top i{color:var(--amber)}
152
+ .video-wrap{aspect-ratio:9/16;background:#f0f1f4;position:relative;max-height:400px;overflow:hidden}
153
+ .video-wrap video{width:100%;height:100%;object-fit:contain;position:relative;z-index:2}
154
+ .video-placeholder{width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--muted)}
155
+ .video-placeholder i{font-size:2.5rem}
156
+ .video-placeholder p{font-size:.8rem}
157
+ .preview-bottom{padding:12px 14px}
158
+ .meta-title{font-size:.85rem;font-weight:600;color:var(--text);margin-bottom:4px;line-height:1.4}
159
+ .meta-tags{font-size:.72rem;color:var(--cyan)}
160
+ .download-btn{width:100%;margin-top:10px;padding:9px;background:rgba(0,184,148,.06);border:1px solid rgba(0,184,148,.2);border-radius:7px;color:var(--green);font-family:var(--sans);font-weight:600;font-size:.82rem;cursor:pointer;display:none;transition:.2s}
161
+ .download-btn:hover{background:rgba(0,184,148,.12)}
162
+ .copy-caption-btn{width:100%;margin-top:6px;padding:8px;background:rgba(9,132,227,.06);border:1px solid rgba(9,132,227,.2);border-radius:7px;color:var(--cyan);font-family:var(--sans);font-size:.82rem;font-weight:600;cursor:pointer;display:none;transition:.2s}
163
+ .copy-caption-btn:hover{background:rgba(9,132,227,.12)}
164
+
165
+ /* ── TOAST ── */
166
+ .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(80px);background:var(--text);color:var(--bg);padding:10px 20px;border-radius:8px;font-size:.82rem;font-weight:500;transition:.3s;z-index:999;opacity:0;white-space:nowrap}
167
+ .toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
168
+
169
+ /* ── ADMIN MODAL ── */
170
+ #admin-modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:200;align-items:center;justify-content:center}
171
+ #admin-modal.open{display:flex}
172
+ .admin-box{background:var(--bg);border:1px solid var(--border);border-radius:14px;padding:24px;width:500px;max-width:95vw;max-height:80vh;overflow-y:auto;box-shadow:0 8px 32px rgba(0,0,0,.1)}
173
+ .admin-box h3{font-size:1.1rem;font-weight:700;margin-bottom:18px;display:flex;align-items:center;gap:8px}
174
+ .admin-box h3 i{color:var(--amber)}
175
+ .admin-section{margin-bottom:20px}
176
+ .admin-section h4{font-size:.75rem;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--muted);margin-bottom:10px}
177
+ .admin-row{display:flex;gap:8px;margin-bottom:8px}
178
+ .admin-row .input{flex:1}
179
+ .btn-admin-action{padding:9px 16px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;color:var(--text);cursor:pointer;font-family:var(--sans);font-size:.82rem;white-space:nowrap;transition:.2s}
180
+ .btn-admin-action:hover{border-color:var(--amber);color:var(--amber)}
181
+ .users-table{width:100%;border-collapse:collapse;font-size:.78rem;margin-top:8px}
182
+ .users-table th{padding:7px 8px;text-align:left;color:var(--muted);font-weight:600;border-bottom:1px solid var(--border)}
183
+ .users-table td{padding:7px 8px;border-bottom:1px solid var(--border);color:var(--muted2)}
184
+ .users-table tr:hover td{color:var(--text)}
185
+ .close-admin{float:right;background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;transition:.2s}
186
+ .close-admin:hover{color:var(--red)}
187
+
188
+ /* ── MODE TABS ── */
189
+ .mode-tabs{display:flex;gap:0;margin-bottom:14px;background:var(--bg3);border-radius:8px;padding:3px}
190
+ .mode-tab{flex:1;padding:8px;border:none;background:transparent;color:var(--muted2);font-family:var(--sans);font-size:.82rem;font-weight:600;border-radius:6px;cursor:pointer;transition:.2s}
191
+ .mode-tab.active{background:var(--bg);color:var(--text);box-shadow:0 1px 3px rgba(0,0,0,.08)}
192
+
193
+ /* ── SCROLLBAR ── */
194
+ ::-webkit-scrollbar{width:4px;height:4px}
195
+ ::-webkit-scrollbar-track{background:transparent}
196
+ ::-webkit-scrollbar-thumb{background:var(--border2);border-radius:2px}
197
+
198
+ @keyframes spin{to{transform:rotate(360deg)}}
199
+ .spinning{animation:spin .8s linear infinite}
200
+ @keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
201
+ .fade-in{animation:fadeIn .3s ease forwards}
202
+
203
+ /* ── LANGUAGE SELECTOR ── */
204
+ .lang-btn{background:var(--bg3);border:1.5px solid var(--border);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;transition:.2s;font-size:.75rem;font-weight:600;color:var(--muted2);user-select:none;line-height:1.6}
205
+ .lang-btn:hover{border-color:var(--border2);color:var(--text)}
206
+ .lang-btn.active{border-color:var(--amber);background:rgba(245,166,35,.08);color:var(--amber2)}
207
+
208
+ /* ── LOGO POSITION GRID ── */
209
+ .pos-btn{background:var(--bg3);border:1px solid var(--border);border-radius:5px;padding:6px 0;font-size:.9rem;cursor:pointer;transition:.2s;color:var(--muted2);font-family:var(--sans)}
210
+ .pos-btn:hover{border-color:var(--amber);color:var(--amber);background:rgba(245,166,35,.06)}
211
+ .pos-btn.active{border-color:var(--amber);background:rgba(245,166,35,.1);color:var(--amber2)}
212
+
213
+ /* ── BLUR BOX (drag overlay on video preview) ── */
214
+ .drag-box{position:absolute;display:none;z-index:50;touch-action:none}
215
+ .resize-handle{width:26px;height:26px;background:white;position:absolute;bottom:-8px;right:-8px;border-radius:50%;box-shadow:0 0 5px rgba(0,0,0,.4);z-index:60;cursor:se-resize}
216
+ #blurBox{background:rgba(239,68,68,.35);border:2px solid var(--red)}
217
+ #logoBox{background:transparent;border:2px dashed var(--green)}
218
+ </style>
 
 
 
 
 
 
 
 
 
 
219
  </head>
220
  <body>
221
+
222
+ <!-- LOGIN -->
223
+ <div id="login-screen">
224
+ <div class="login-box fade-in">
225
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:20px">
226
+ <div class="logo-icon"><i class="fas fa-film" style="color:#fff"></i></div>
227
+ <div>
228
+ <div style="font-weight:800;font-size:1.1rem">Recap Studio</div>
229
+ <div style="font-size:.72rem;color:var(--muted)">AI Video Recap Tool</div>
230
+ </div>
231
+ </div>
232
+ <h2 id="auth-title">Sign In</h2>
233
+ <p id="auth-sub">Welcome back, please sign in</p>
234
+ <div id="auth-msg" class="msg-box"></div>
235
+ <div class="field"><label>Username</label><input class="input" id="auth-user" placeholder="Enter username" autocomplete="username"></div>
236
+ <div class="field"><label>Password</label><input class="input" id="auth-pass" type="password" placeholder="Enter password" autocomplete="current-password"></div>
237
+ <button class="btn-primary" onclick="doAuth()" id="auth-btn">Sign In</button>
238
+ <div style="margin-top:14px;text-align:center;font-size:.78rem;color:var(--muted2)">Don't have an account? <a href="http://t.me/PhoeShan2001" target="_blank" style="color:var(--cyan);text-decoration:none"><i class="fab fa-telegram"></i> Contact Admin</a></div>
239
+ </div>
240
+ </div>
241
+
242
+ <!-- TOPBAR -->
243
+ <div class="topbar" id="topbar" style="display:none">
244
+ <div class="logo">
245
+ <div class="logo-icon"><i class="fas fa-film" style="color:#fff"></i></div>
246
+ Recap Studio
247
+ </div>
248
+ <div class="topbar-right">
249
+ <div style="font-size:.78rem;color:var(--muted2);font-weight:600"><i class="fas fa-user" style="color:var(--amber)"></i> <span id="tb-username"></span></div>
250
+ <div class="coin-badge"><i class="fas fa-coins"></i> <span id="tb-coins">0</span> Coins</div>
251
+ <button class="btn-sm btn-buy" id="buy-btn" onclick="openBuyModal()"><i class="fas fa-wallet"></i> Buy Credits</button>
252
+ <button class="btn-sm btn-admin" id="admin-btn" style="display:none" onclick="openAdmin()"><i class="fas fa-crown"></i> Admin</button>
253
+ <button class="btn-sm btn-logout" onclick="doLogout()"><i class="fas fa-sign-out-alt"></i></button>
254
+ </div>
255
+ </div>
256
+
257
+ <!-- MAIN APP -->
258
+ <div id="app-screen">
259
+ <div class="app-wrap">
260
+ <div>
261
+ <div class="mode-tabs" style="pointer-events:none;opacity:.8">
262
+ <button class="mode-tab active" id="tab-full"><i class="fas fa-magic"></i> Auto Process</button>
263
+ </div>
264
+
265
+ <!-- VIDEO INPUT -->
266
+ <div class="card">
267
+ <div class="card-label"><i class="fas fa-video"></i> VIDEO INPUT</div>
268
+ <div style="display:flex;gap:0;margin-bottom:12px;background:var(--bg3);border-radius:7px;padding:2px">
269
+ <button class="mode-tab active" id="input-tab-url" onclick="switchInputMode('url')" style="flex:1;padding:7px;font-size:.8rem"><i class="fas fa-link"></i> YouTube URL</button>
270
+ <button class="mode-tab" id="input-tab-upload" onclick="switchInputMode('upload')" style="flex:1;padding:7px;font-size:.8rem"><i class="fas fa-upload"></i> Upload File</button>
271
+ </div>
272
+ <div id="input-url-section">
273
+ <div class="input-row">
274
+ <input class="input" id="video-url" placeholder="https://youtube.com/watch?v=... paste URL here" style="flex:1">
275
+ <button class="paste-btn" onclick="pasteUrl()" title="Paste"><i class="fas fa-paste"></i></button>
276
+ </div>
277
+ <div id="url-platform" style="margin-top:8px;display:none">
278
+ <span class="platform-badge" id="url-badge" style="background:rgba(231,76,60,.06);border:1px solid rgba(231,76,60,.15);color:var(--red)"></span>
279
+ </div>
280
+ <div style="margin-top:6px;font-size:.72rem;color:var(--muted)">
281
+ <i class="fas fa-info-circle"></i> Supports YouTube, TikTok, Facebook, Instagram links
282
  </div>
283
+ </div>
284
+ <div id="input-upload-section" style="display:none">
285
+ <div class="upload-area" id="upload-area" ondragover="dragOver(event)" ondragleave="dragLeave(event)" ondrop="dropFile(event)">
286
+ <input type="file" id="video-file" accept="video/*" onchange="onFileSelect(this)">
287
+ <div class="upload-icon"><i class="fas fa-cloud-upload-alt"></i></div>
288
+ <div class="upload-text">Drag & drop here or <span>browse files</span></div>
289
+ <div class="upload-name" id="upload-name"></div>
290
+ </div>
291
+ </div>
292
  </div>
293
 
294
+ <!-- SCRIPT SECTIONS -->
295
+ <div id="draft-result-section" style="display:none">
296
+ <div class="card">
297
+ <div class="card-label"><i class="fas fa-file-alt"></i> SCRIPT</div>
298
+ <textarea class="script-area" id="script-out" placeholder="Script will appear here…"></textarea>
299
+ <div class="script-meta"><span id="script-chars">0 chars</span><span id="script-lang"></span></div>
300
+ </div>
301
+ </div>
302
+ <div id="manual-script-section" style="display:none">
303
+ <div class="card">
304
+ <div class="card-label"><i class="fas fa-keyboard"></i> ENTER SCRIPT</div>
305
+ <textarea class="script-area" id="script-in" placeholder="Type script here…" oninput="updateScriptCount()"></textarea>
306
+ <div class="script-meta"><span id="script-in-chars">0 chars</span></div>
307
+ </div>
308
+ </div>
309
+
310
+ <!-- VOICE MODEL -->
311
+ <div class="card">
312
+ <div class="card-label"><i class="fas fa-microphone-alt"></i> VOICE MODEL</div>
313
+ <div class="vcat-tabs">
314
+ <button class="vcat-btn active" id="vcat-ms" onclick="switchVCat('ms')"><i class="fab fa-microsoft"></i> Microsoft</button>
315
+ <button class="vcat-btn" id="vcat-g" onclick="switchVCat('g')"><i class="fas fa-gem"></i> Gemini</button>
316
+ </div>
317
+ <input class="voice-search" id="voice-search" placeholder="Search voices…" oninput="filterVoices(this.value)">
318
+ <div class="voice-grid" id="voice-grid"></div>
319
+ <div class="speed-toggle" id="speed-toggle" onclick="toggleSpeed()">
320
+ <i class="fas fa-chevron-down"></i> <span>Speed Settings</span>
321
+ </div>
322
+ <div class="speed-row" id="speed-row">
323
+ <span class="speed-label"><i class="fas fa-tachometer-alt"></i> Speed</span>
324
+ <input type="range" id="speed-slider" min="-20" max="80" value="30" oninput="document.getElementById('speed-val').textContent=this.value+'%'">
325
+ <span class="speed-val" id="speed-val">30%</span>
326
+ </div>
327
+ </div>
328
+
329
+ <!-- OPTIONS -->
330
+ <div class="card">
331
+ <div class="card-label"><i class="fas fa-toggle-on"></i> OPTIONS</div>
332
+ <div class="checks-grid">
333
+ <div class="check-item" id="chk-fl" onclick="togCheck(this,'fl')"><div class="check-box"></div><span><i class="fas fa-arrows-alt-h" style="color:var(--green)"></i> Flip Video</span></div>
334
+ <div class="check-item" id="chk-ac" onclick="togCheck(this,'ac')"><div class="check-box"></div><span><i class="fas fa-magic" style="color:var(--violet)"></i> Auto Color</span></div>
335
+ </div>
336
+ </div>
337
+
338
+ <!-- SETTINGS -->
339
+ <div class="card">
340
+ <div class="card-label"><i class="fas fa-cog"></i> SETTINGS</div>
341
+
342
+ <!-- LANGUAGE -->
343
+ <div style="margin-bottom:12px">
344
+ <div class="field-label"><i class="fas fa-globe"></i> Output Language</div>
345
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px">
346
+ <div class="lang-btn active" id="lang-my" onclick="switchLang('my')"><span style="font-size:1.1rem">🇲🇲</span><br><span>Myanmar</span></div>
347
+ <div class="lang-btn" id="lang-th" onclick="switchLang('th')"><span style="font-size:1.1rem">🇹🇭</span><br><span>Thailand</span></div>
348
+ <div class="lang-btn" id="lang-en" onclick="switchLang('en')"><span style="font-size:1.1rem">🇬🇧</span><br><span>English</span></div>
349
  </div>
350
+ </div>
351
 
352
+ <div class="grid2">
353
+ <div>
354
+ <div class="field-label"><i class="fas fa-crop-alt"></i> Crop Ratio</div>
355
+ <select id="crop" class="input">
356
+ <option value="original" selected>🎬 Original</option>
357
+ <option value="9:16">📱 9:16 (TikTok)</option>
358
+ <option value="16:9">🖥️ 16:9 (YouTube)</option>
359
+ <option value="1:1">⬛ 1:1 (Square)</option>
360
+ </select>
361
+ </div>
362
+ <div>
363
+ <div class="field-label"><i class="fas fa-robot"></i> AI Model</div>
364
+ <select id="ai-model" class="input">
365
+ <option value="Gemini"> Gemini</option>
366
+ <option value="DeepSeek">🔮 DeepSeek</option>
367
+ </select>
368
+ </div>
369
+ <div>
370
+ <div class="field-label"><i class="fas fa-video"></i> Content Type</div>
371
+ <select id="content-type" class="input" onchange="onContentTypeChange(this.value)">
372
+ <option value="Movie Recap">🎬 Movie Recap</option>
373
+ <option value="Funny/Meme">😂 Funny / Meme</option>
374
+ <option value="Medical/Health">🏥 Medical/Health</option>
375
+ </select>
376
+ <div id="funny-notice" style="display:none;margin-top:6px;padding:8px 10px;background:#fff8e1;border-left:3px solid #f59e0b;border-radius:6px;font-size:12px;color:#92400e;">
377
+ 🎭 <b>Funny/Meme Mode</b> — Gemini က video ထဲ လူရဲ့ expression, gesture, dialog ကိုသာ ကြည့်ပြီး script ရေးမည်။
378
+ </div>
379
+ </div>
380
+ <div>
381
+ <div class="field-label"><i class="fas fa-font"></i> Watermark</div>
382
+ <input class="input" id="watermark" placeholder="@username">
383
+ </div>
384
+ </div>
385
+
386
+ <!-- MUSIC -->
387
+ <div style="margin-top:8px">
388
+ <div class="field-label"><i class="fas fa-music"></i> Background Music (optional)</div>
389
+ <div class="upload-area" style="padding:12px" onclick="document.getElementById('music-file').click()">
390
+ <input type="file" id="music-file" accept="audio/*" style="display:none" onchange="onMusicSelect(this)">
391
+ <span style="font-size:.8rem;color:var(--muted2)"><i class="fas fa-music"></i> <span id="music-name">Choose MP3</span></span>
392
+ </div>
393
+ </div>
394
 
395
+ <!-- ═══════════════════════════════════════════
396
+ BLUR BOX drag red box on video preview
397
+ ═══════════════════════════════════════════ -->
398
+ <div style="margin-top:12px">
399
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
400
+ <div class="field-label" style="margin:0"><i class="fas fa-eye-slash" style="color:var(--red)"></i> Subtitle Blur Mask</div>
401
+ <div class="check-item" id="chk-blur" onclick="toggleBlur(this)" style="padding:4px 12px;border-radius:20px;font-size:.75rem;width:auto;gap:6px">
402
+ <div class="check-box"></div><span id="blur-lbl">Off</span>
403
+ </div>
404
+ </div>
405
+ <div id="blur-section" style="display:none">
406
+ <div style="padding:10px;background:rgba(231,76,60,.04);border:1px solid rgba(231,76,60,.15);border-radius:8px;font-size:.75rem;color:var(--muted2);line-height:1.6">
407
+ <i class="fas fa-info-circle" style="color:var(--red)"></i>
408
+ ညာဘက် <b>Preview</b> ထဲမှာ <span style="color:var(--red);font-weight:700">🟥 Red Box</span> ကို subtitles ပေါ်သို့ ဆွဲ၍ ချိန်ညှိပါ။ Box ကို drag ပြောင်းနိုင်သည်၊ ထောင့်ဖြင့် resize လုပ်နိုင်သည်။
409
+ </div>
410
+ </div>
411
+ </div>
412
+
413
+ <!-- ═══════════════════════════════
414
+ LOGO OVERLAY
415
+ ═══════════════════════════════ -->
416
+ <div style="margin-top:12px">
417
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
418
+ <div class="field-label" style="margin:0"><i class="fas fa-image"></i> Logo Overlay</div>
419
+ <div class="check-item" id="chk-logo" onclick="toggleLogo(this)" style="padding:4px 12px;border-radius:20px;font-size:.75rem;width:auto;gap:6px">
420
+ <div class="check-box"></div><span id="logo-lbl">Off</span>
421
+ </div>
422
+ </div>
423
+ <div id="logo-section" style="display:none">
424
+ <div class="upload-area" style="padding:12px" onclick="document.getElementById('logo-file').click()">
425
+ <input type="file" id="logo-file" accept="image/*" style="display:none" onchange="onLogoSelect(this)">
426
+ <span style="font-size:.8rem;color:var(--muted2)"><i class="fas fa-image"></i> <span id="logo-name">Choose Logo (PNG/JPG)</span></span>
427
+ </div>
428
+ <!-- Logo position picker -->
429
+ <div id="logo-pos-wrap" style="display:none;margin-top:8px">
430
+ <div class="field-label" style="margin-bottom:6px"><i class="fas fa-crosshairs"></i> Logo Position — drag green box on preview</div>
431
+ <div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
432
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;flex:1;min-width:120px">
433
+ <button class="pos-btn" onclick="setLogoPos('tl')">↖</button>
434
+ <button class="pos-btn" onclick="setLogoPos('tc')">↑</button>
435
+ <button class="pos-btn" onclick="setLogoPos('tr')">↗</button>
436
+ <button class="pos-btn" onclick="setLogoPos('ml')">←</button>
437
+ <button class="pos-btn" onclick="setLogoPos('mc')">·</button>
438
+ <button class="pos-btn" onclick="setLogoPos('mr')">→</button>
439
+ <button class="pos-btn" onclick="setLogoPos('bl')">↙</button>
440
+ <button class="pos-btn" onclick="setLogoPos('bc')">↓</button>
441
+ <button class="pos-btn" onclick="setLogoPos('br')">↘</button>
442
+ </div>
443
+ <div style="flex:2;min-width:160px">
444
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:5px">
445
+ <span style="font-size:.7rem;color:var(--muted);white-space:nowrap">Size</span>
446
+ <input type="range" id="logo-size" min="40" max="300" value="80" style="flex:1;accent-color:var(--amber)" oninput="onLogoSizeChange(this.value)">
447
+ <span id="logo-size-val" style="font-size:.72rem;color:var(--amber2);min-width:32px">80px</span>
448
  </div>
449
+ <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
450
+ <span style="font-size:.7rem;color:var(--muted);min-width:10px">X</span>
451
+ <input type="range" id="logo-x-slider" min="0" max="660" value="10" style="flex:1;accent-color:var(--amber)" oninput="onLogoPosSlider()">
452
+ <span id="logo-x-val" style="font-size:.72rem;color:var(--muted2);min-width:32px">10</span>
 
 
 
453
  </div>
454
+ <div style="display:flex;align-items:center;gap:6px">
455
+ <span style="font-size:.7rem;color:var(--muted);min-width:10px">Y</span>
456
+ <input type="range" id="logo-y-slider" min="0" max="1200" value="10" style="flex:1;accent-color:var(--amber)" oninput="onLogoPosSlider()">
457
+ <span id="logo-y-val" style="font-size:.72rem;color:var(--muted2);min-width:32px">10</span>
 
 
 
 
 
458
  </div>
459
+ </div>
 
 
460
  </div>
461
+ </div>
462
+ </div>
463
+ </div>
464
+ </div><!-- end .card SETTINGS -->
465
+
466
+ <!-- ACTION BUTTONS -->
467
+ <div id="action-full">
468
+ <button class="btn-full btn-amber" id="btn-process-all" onclick="doProcessAll()"><i class="fas fa-magic"></i> Auto Process (2 Coins)</button>
469
+ </div>
470
+ <div id="action-draft" style="display:none">
471
+ <button class="btn-full btn-violet" id="btn-draft" onclick="doDraft()"><i class="fas fa-file-alt"></i> Draft Script (1 Coin)</button>
472
+ </div>
473
+ <div id="action-manual" style="display:none">
474
+ <button class="btn-full btn-amber" id="btn-process" onclick="doProcess()"><i class="fas fa-film"></i> Process Video (1 Coin)</button>
475
+ </div>
476
+
477
+ <div class="prog-wrap" id="prog-wrap">
478
+ <div class="prog-bar-bg"><div class="prog-bar" id="prog-bar"></div></div>
479
+ <div class="prog-msg" id="prog-msg">Please wait…</div>
480
+ </div>
481
+ </div>
482
+
483
+ <!-- PREVIEW PANEL — blur box + logo box live here -->
484
+ <div class="preview-panel">
485
+ <div class="preview-box">
486
+ <div class="preview-top"><i class="fas fa-play-circle"></i> PREVIEW</div>
487
+ <!-- video-wrap is the coordinate space for drag boxes -->
488
+ <div class="video-wrap" id="video-wrap-box" style="position:relative;overflow:hidden">
489
+ <img id="thumb-preview" style="display:none;width:100%;height:100%;object-fit:cover;position:absolute;top:0;left:0;z-index:1" alt="thumbnail">
490
+ <video id="preview-video" controls style="display:none;position:relative;z-index:2;width:100%;height:100%;object-fit:contain"></video>
491
+ <div class="video-placeholder" id="video-placeholder"><i class="fas fa-film"></i><p>Preview will appear here</p></div>
492
+
493
+ <!-- 🟥 BLUR BOX — drag to position over subtitles -->
494
+ <div id="blurBox" class="drag-box" style="width:200px;height:50px;left:50px;top:200px">
495
+ <div class="resize-handle"></div>
496
+ </div>
497
+
498
+ <!-- 🟩 LOGO BOX — drag to position logo -->
499
+ <div id="logoBox" class="drag-box" style="width:70px;height:70px;left:10px;top:10px">
500
+ <img id="logo-preview-img" style="width:100%;height:100%;object-fit:contain;pointer-events:none;display:none" draggable="false">
501
+ <div class="resize-handle"></div>
502
+ </div>
503
+ </div>
504
+ <div class="preview-bottom">
505
+ <div class="meta-title" id="meta-title" style="display:none"></div>
506
+ <div class="meta-tags" id="meta-tags" style="display:none"></div>
507
+ <button class="download-btn" id="download-btn" onclick="downloadVideo()"><i class="fas fa-download"></i> Download MP4</button>
508
+ <button class="copy-caption-btn" id="copy-caption-btn" onclick="copyCaption()"><i class="fas fa-copy"></i> Copy Caption</button>
509
+ <button class="copy-caption-btn" id="copy-link-btn" onclick="copyVideoLink()" style="display:none;margin-top:6px"><i class="fas fa-link"></i> Copy Video Link</button>
510
+ <button class="download-btn" id="download-btn2" onclick="downloadVideo()" style="display:none;margin-top:6px;background:rgba(9,132,227,.06);border-color:rgba(9,132,227,.2);color:var(--cyan)"><i class="fas fa-link"></i> Open Link</button>
511
+ </div>
512
+ </div>
513
+ </div>
514
+ </div><!-- end .app-wrap -->
515
+ </div><!-- end #app-screen -->
516
+
517
+ <!-- ADMIN MODAL -->
518
+ <div id="admin-modal">
519
+ <div class="admin-box">
520
+ <h3><i class="fas fa-crown"></i> Admin Panel <button class="close-admin" onclick="closeAdmin()"><i class="fas fa-times"></i></button></h3>
521
+ <div class="admin-section">
522
+ <h4>Create User</h4>
523
+ <div class="admin-row">
524
+ <input class="input" id="new-uname" placeholder="Username (leave blank = auto)">
525
+ <input class="input" id="new-coins" type="number" value="10" style="width:80px;flex:none">
526
+ <button class="btn-admin-action" onclick="genUsername()">⚡ Gen</button>
527
+ <button class="btn-admin-action" onclick="adminCreateUser()">+ Create</button>
528
+ </div>
529
+ <div id="create-result" style="font-size:.78rem;margin-top:4px"></div>
530
+ </div>
531
+ <div class="admin-section">
532
+ <h4>Manage Coins</h4>
533
+ <div class="admin-row">
534
+ <input class="input" id="coin-user" placeholder="Username">
535
+ <input class="input" id="coin-amt" type="number" value="10" style="width:80px;flex:none">
536
+ <button class="btn-admin-action" onclick="adminCoins('add')">+ Add</button>
537
+ <button class="btn-admin-action" onclick="adminCoins('set')">= Set</button>
538
+ </div>
539
+ <div id="coin-result" style="font-size:.78rem;margin-top:4px"></div>
540
+ </div>
541
+ <div class="admin-section">
542
+ <h4>Users</h4>
543
+ <div id="users-wrap"></div>
544
  </div>
545
+ </div>
546
  </div>
547
+
548
+ <!-- BUY MODAL -->
549
+ <div id="buy-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:200;align-items:center;justify-content:center">
550
+ <div class="admin-box" style="max-width:360px">
551
+ <h3><i class="fas fa-wallet"></i> Buy Credits <button class="close-admin" onclick="closeBuyModal()"><i class="fas fa-times"></i></button></h3>
552
+ <div style="display:flex;flex-direction:column;gap:10px;margin-bottom:14px">
553
+ <div class="buy-pkg" onclick="selectPkg(this,10,10000)" style="padding:14px 16px;border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:.2s;display:flex;justify-content:space-between;align-items:center">
554
+ <div><div style="font-weight:700;font-size:1rem">🪙 10 Coins</div><div style="font-size:.75rem;color:var(--muted2);margin-top:2px">Process 5 videos</div></div>
555
+ <div style="font-weight:700;color:var(--amber)">10,000 MMK</div>
556
+ </div>
557
+ <div class="buy-pkg" onclick="selectPkg(this,20,18000)" style="padding:14px 16px;border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:.2s;display:flex;justify-content:space-between;align-items:center">
558
+ <div><div style="font-weight:700;font-size:1rem">🪙 20 Coins</div><div style="font-size:.75rem;color:var(--muted2);margin-top:2px">Process 10 videos</div></div>
559
+ <div style="font-weight:700;color:var(--amber)">18,000 MMK</div>
560
+ </div>
561
+ <div class="buy-pkg" onclick="selectPkg(this,30,28000)" style="padding:14px 16px;border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:.2s;display:flex;justify-content:space-between;align-items:center">
562
+ <div><div style="font-weight:700;font-size:1rem">🪙 30 Coins</div><div style="font-size:.75rem;color:var(--muted2);margin-top:2px">Process 15 videos</div></div>
563
+ <div style="font-weight:700;color:var(--amber)">28,000 MMK</div>
564
+ </div>
565
+ </div>
566
+ <div id="buy-selected" style="display:none;padding:12px;background:var(--bg3);border-radius:8px;margin-bottom:14px;font-size:.82rem;color:var(--muted2);text-align:center">
567
+ <span id="buy-pkg-txt"></span><br>
568
+ <a href="http://t.me/PhoeShan2001" target="_blank" style="color:var(--cyan);font-size:.75rem;text-decoration:none"><i class="fab fa-telegram"></i> Contact Admin after payment → @PhoeShan2001</a>
569
+ </div>
570
+ <button onclick="closeBuyModal()" style="width:100%;padding:11px;background:var(--bg3);border:1px solid var(--border);border-radius:8px;color:var(--muted2);font-family:var(--sans);font-size:.9rem;cursor:pointer">Close</button>
571
+ </div>
572
+ </div>
573
+
574
+ <div class="toast" id="toast"></div>
575
+
576
+ <script>
577
+ /* ═══════════════════════════════════════════
578
+ STATE
579
+ ═══════════════════════════════════════════ */
580
+ let CUR_USER = localStorage.getItem('recap_user') || '';
581
+ let CUR_COINS = 0;
582
+ let IS_ADMIN = false;
583
+ let AUTH_MODE = 'login';
584
+ let SELECTED_VOICE = 'my-MM-ThihaNeural';
585
+ let SELECTED_ENGINE = 'ms';
586
+ let VO_LANG = 'my';
587
+ let MODE = 'full';
588
+ let INPUT_MODE = 'url';
589
+ let VCAT = 'ms';
590
+ let CUR_OUTPUT_URL = '';
591
+ let CUR_CAPTION = '';
592
+ let CUR_HASHTAGS = '';
593
+ let CURRENT_TID = '';
594
+ let SSE_SOURCE = null;
595
+
596
+ // Logo state
597
+ let LOGO_X = 10, LOGO_Y = 10, LOGO_W = 80, LOGO_ENABLED = false;
598
+
599
+ // Blur state
600
+ let BLUR_ENABLED = false;
601
+ let BLUR_X = 0, BLUR_Y = 0, BLUR_W = 0, BLUR_H = 0;
602
+
603
+ /* ═══════════════════════════════════════════
604
+ VOICE DATA
605
+ ═══════════════════════════════════════════ */
606
+ const MS_V = [
607
+ {id:'my-MM-ThihaNeural', name:'သီ���', sub:'ကျား — ယုံကြည်မှုရှိ၊ ကြည်လင်', lang:'my'},
608
+ {id:'my-MM-NilarNeural', name:'နီလာ', sub:'မိန်း — နူးညံ့၊ သာယာ', lang:'my'},
609
+ {id:'th-TH-PremwadeeNeural', name:'ပြမ်းဝါဒီ', sub:'မိန်း — နူးညံ့၊ သဘာဝကျ', lang:'th'},
610
+ {id:'th-TH-NiwatNeural', name:'နီဝတ်', sub:'ကျား — ပြတ်သား၊ ရှင်းလင်း', lang:'th'},
611
+ {id:'th-TH-AcharaNeural', name:'အာချာရာ', sub:'မိန်း — ဖော်ရွေ၊ သက်ဆင်း', lang:'th'},
612
+ {id:'en-US-AriaNeural', name:'Aria', sub:'မိန်း — ကြည်လင်၊ သဘာဝကျ', lang:'en'},
613
+ {id:'en-US-GuyNeural', name:'Guy', sub:'ကျား — နက်ရှိုင်း၊ ယုံကြည်မှုရှိ',lang:'en'},
614
+ {id:'en-US-JennyNeural', name:'Jenny', sub:'မိန်း — ဖော်ရွေ၊ ပူးပေါင်း', lang:'en'},
615
+ {id:'en-US-AnaNeural', name:'Ana', sub:'မိန်း — ချိုသာ၊ လတ်ဆတ်', lang:'en'},
616
+ {id:'en-US-DavisNeural', name:'Davis', sub:'ကျား — ပျော်ရွှင်၊ တက်ကြွ', lang:'en'},
617
+ {id:'en-US-EmmaNeural', name:'Emma', sub:'မိန်း — နူးညံ့၊ သာယာ', lang:'en'},
618
+ {id:'en-US-JasonNeural', name:'Jason', sub:'ကျား — နက်ရှိုင်း၊ တည်ငြိမ်', lang:'en'},
619
+ {id:'en-US-SaraNeural', name:'Sara', sub:'မိန်း — ကြည်လင်၊ ကျွမ်းကျင်', lang:'en'},
620
+ {id:'en-US-TonyNeural', name:'Tony', sub:'ကျား — တက်ကြွ၊ ထက်မြက်', lang:'en'},
621
+ {id:'en-GB-SoniaNeural', name:'Sonia', sub:'မိန်း (UK) — ယဉ်ကျေး', lang:'en'},
622
+ {id:'en-GB-RyanNeural', name:'Ryan', sub:'ကျား (UK) — ပြတ်သား', lang:'en'},
623
+ {id:'en-GB-LibbyNeural', name:'Libby', sub:'မိန်း (UK) — ဖော်ရွေ', lang:'en'},
624
+ {id:'en-GB-MaisieNeural', name:'Maisie', sub:'မိန်း (UK) — ချိုသာ', lang:'en'},
625
+ {id:'en-GB-ThomasNeural', name:'Thomas', sub:'ကျား (UK) — တည်ငြိမ်', lang:'en'},
626
+ ];
627
+ let GEMINI_V = [];
628
+
629
+ /* ═══════════════════════════════════════════
630
+ INIT
631
+ ═══════════════════════════════════════════ */
632
+ window.addEventListener('DOMContentLoaded', () => {
633
+ if(CUR_USER) autoLogin(); else renderVoices('ms');
634
+ makeInteractive('blurBox');
635
+ makeInteractive('logoBox');
636
+ window.addEventListener('resize', () => { syncBoxCoords('blurBox'); syncBoxCoords('logoBox'); });
637
+ });
638
+
639
+ /* ═══════════════════════════════════════════
640
+ AUTH
641
+ ═══════════════════════════════════════════ */
642
+ async function autoLogin(){
643
+ try {
644
+ const r = await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:CUR_USER,password:''})});
645
+ const d = await r.json();
646
+ if(d.ok) showApp(CUR_USER, d.coins, d.is_admin);
647
+ else { localStorage.removeItem('recap_user'); renderVoices('ms'); }
648
+ } catch { localStorage.removeItem('recap_user'); renderVoices('ms'); }
649
+ }
650
+
651
+ async function doAuth(){
652
+ const u = document.getElementById('auth-user').value.trim();
653
+ const p = document.getElementById('auth-pass').value;
654
+ const msgEl = document.getElementById('auth-msg');
655
+ if(!u){ msgEl.textContent='Enter username'; msgEl.className='msg-box msg-err'; msgEl.style.display='block'; return; }
656
+ const btn = document.getElementById('auth-btn'); btn.disabled=true;
657
+ try {
658
+ const ep = AUTH_MODE==='register' ? '/api/register' : '/api/login';
659
+ const r = await fetch(ep,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})});
660
+ const d = await r.json();
661
+ msgEl.textContent = d.msg; msgEl.className = 'msg-box '+(d.ok?'msg-ok':'msg-err'); msgEl.style.display='block';
662
+ if(d.ok){
663
+ const uname = d.username || u;
664
+ localStorage.setItem('recap_user', uname); CUR_USER = uname;
665
+ setTimeout(()=>showApp(uname, d.coins, d.is_admin), 600);
666
+ }
667
+ } catch(e){ msgEl.textContent='Connection error'; msgEl.className='msg-box msg-err'; msgEl.style.display='block'; }
668
+ btn.disabled=false;
669
+ }
670
+
671
+ function showApp(user, coins, isAdmin){
672
+ CUR_USER=user; CUR_COINS=coins===-1?999:coins; IS_ADMIN=isAdmin;
673
+ document.getElementById('login-screen').style.display='none';
674
+ document.getElementById('app-screen').style.display='block';
675
+ document.getElementById('topbar').style.display='flex';
676
+ document.getElementById('tb-username').textContent = user;
677
+ document.getElementById('tb-coins').textContent = coins===-1?'∞':coins;
678
+ renderVoices('ms');
679
+ if(isAdmin) document.getElementById('admin-btn').style.display='';
680
+ }
681
+
682
+ function doLogout(){ localStorage.removeItem('recap_user'); location.reload(); }
683
+
684
+ /* ═══════════════════════════════════════════
685
+ DRAG BOX SYSTEM (blur + logo)
686
+ Reads actual pixel pos from #video-wrap-box
687
+ and converts to video coordinates.
688
+ ═══════════════════════════════════════════ */
689
+ function makeInteractive(id){
690
+ const box = document.getElementById(id);
691
+ const handle = box.querySelector('.resize-handle');
692
+ if(!box || !handle) return;
693
+ let isDrag=false, isResize=false, startX, startY, sl, st, sw, sh;
694
+
695
+ const getContainer = () => document.getElementById('video-wrap-box');
696
+
697
+ const onStart = (e) => {
698
+ if(e.target === handle) return;
699
+ isDrag=true;
700
+ const p = e.type.includes('mouse') ? e : e.touches[0];
701
+ startX=p.clientX; startY=p.clientY; sl=box.offsetLeft; st=box.offsetTop;
702
+ };
703
+ const onResizeStart = (e) => {
704
+ e.stopPropagation(); isResize=true;
705
+ const p = e.type.includes('mouse') ? e : e.touches[0];
706
+ startX=p.clientX; startY=p.clientY; sw=box.offsetWidth; sh=box.offsetHeight;
707
+ };
708
+ const onMove = (e) => {
709
+ const c = getContainer();
710
+ if(!isDrag && !isResize) return;
711
+ if(e.cancelable) e.preventDefault();
712
+ const p = e.type.includes('mouse') ? e : e.touches[0];
713
+ if(isDrag){
714
+ let nl = sl+(p.clientX-startX);
715
+ let nt = st+(p.clientY-startY);
716
+ nl = Math.max(0, Math.min(nl, c.clientWidth-box.offsetWidth));
717
+ nt = Math.max(0, Math.min(nt, c.clientHeight-box.offsetHeight));
718
+ box.style.left=nl+'px'; box.style.top=nt+'px';
719
+ }
720
+ if(isResize){
721
+ let nw = Math.max(30, sw+(p.clientX-startX));
722
+ let nh = Math.max(20, sh+(p.clientY-startY));
723
+ box.style.width=nw+'px'; box.style.height=nh+'px';
724
+ }
725
+ syncBoxCoords(id);
726
+ };
727
+ const onEnd = () => { isDrag=false; isResize=false; };
728
+
729
+ box.addEventListener('mousedown', onStart);
730
+ box.addEventListener('touchstart', onStart, {passive:false});
731
+ handle.addEventListener('mousedown', onResizeStart);
732
+ handle.addEventListener('touchstart', onResizeStart, {passive:false});
733
+ window.addEventListener('mousemove', onMove);
734
+ window.addEventListener('touchmove', onMove, {passive:false});
735
+ window.addEventListener('mouseup', onEnd);
736
+ window.addEventListener('touchend', onEnd);
737
+ }
738
+
739
+ function syncBoxCoords(id){
740
+ const container = document.getElementById('video-wrap-box');
741
+ const box = document.getElementById(id);
742
+ if(!container || !box) return;
743
+
744
+ // We use the container dimensions as the coordinate space.
745
+ // The backend will scale these to actual video resolution via the ratio.
746
+ const cw = container.clientWidth || 720;
747
+ const ch = container.clientHeight || 1280;
748
+
749
+ // video native dims (fallback to 9:16)
750
+ const vid = document.getElementById('preview-video');
751
+ const vw = (vid && vid.videoWidth) ? vid.videoWidth : 720;
752
+ const vh = (vid && vid.videoHeight) ? vid.videoHeight : 1280;
753
+
754
+ const scaleX = vw / cw;
755
+ const scaleY = vh / ch;
756
+
757
+ const x = Math.round(box.offsetLeft * scaleX);
758
+ const y = Math.round(box.offsetTop * scaleY);
759
+ const w = Math.round(box.offsetWidth * scaleX);
760
+ const h = Math.round(box.offsetHeight * scaleY);
761
+
762
+ if(id === 'blurBox'){
763
+ BLUR_X=x; BLUR_Y=y; BLUR_W=w; BLUR_H=h;
764
+ } else {
765
+ LOGO_X=x; LOGO_Y=y; LOGO_W=w;
766
+ // Update sliders to match drag
767
+ const sx = document.getElementById('logo-x-slider');
768
+ const sy = document.getElementById('logo-y-slider');
769
+ if(sx){ sx.value=x; document.getElementById('logo-x-val').textContent=x; }
770
+ if(sy){ sy.value=y; document.getElementById('logo-y-val').textContent=y; }
771
+ updateLogoBoxSize();
772
+ }
773
+ }
774
+
775
+ /* ═══════════════════════════════════════════
776
+ BLUR TOGGLE
777
+ ═══════════════════════════════════════════ */
778
+ function toggleBlur(el){
779
+ el.classList.toggle('checked');
780
+ BLUR_ENABLED = el.classList.contains('checked');
781
+ el.querySelector('.check-box').innerHTML = BLUR_ENABLED ? '<i class="fas fa-check"></i>' : '';
782
+ document.getElementById('blur-lbl').textContent = BLUR_ENABLED ? 'On' : 'Off';
783
+ document.getElementById('blur-section').style.display = BLUR_ENABLED ? '' : 'none';
784
+ const box = document.getElementById('blurBox');
785
+ box.style.display = BLUR_ENABLED ? 'block' : 'none';
786
+ if(BLUR_ENABLED) syncBoxCoords('blurBox');
787
+ }
788
+
789
+ /* ���══════════════════════════════════════════
790
+ LOGO TOGGLE + HANDLERS
791
+ ═══════════════════════════════════════════ */
792
+ function toggleLogo(el){
793
+ el.classList.toggle('checked');
794
+ LOGO_ENABLED = el.classList.contains('checked');
795
+ el.querySelector('.check-box').innerHTML = LOGO_ENABLED ? '<i class="fas fa-check"></i>' : '';
796
+ document.getElementById('logo-lbl').textContent = LOGO_ENABLED ? 'On' : 'Off';
797
+ document.getElementById('logo-section').style.display = LOGO_ENABLED ? '' : 'none';
798
+ const box = document.getElementById('logoBox');
799
+ box.style.display = LOGO_ENABLED ? 'block' : 'none';
800
+ }
801
+
802
+ function onLogoSelect(inp){
803
+ if(!inp.files[0]) return;
804
+ document.getElementById('logo-name').textContent = '🖼️ '+inp.files[0].name;
805
+ const img = document.getElementById('logo-preview-img');
806
+ img.src = URL.createObjectURL(inp.files[0]);
807
+ img.style.display = 'block';
808
+ document.getElementById('logo-pos-wrap').style.display = '';
809
+ syncBoxCoords('logoBox');
810
+ }
811
+
812
+ function updateLogoBoxSize(){
813
+ const box = document.getElementById('logoBox');
814
+ const container = document.getElementById('video-wrap-box');
815
+ if(!box || !container) return;
816
+ const cw = container.clientWidth || 720;
817
+ const vw = 720;
818
+ const displayW = Math.round(LOGO_W * cw / vw);
819
+ box.style.width = displayW+'px';
820
+ box.style.height = displayW+'px';
821
+ }
822
+
823
+ function onLogoSizeChange(val){
824
+ LOGO_W = parseInt(val);
825
+ document.getElementById('logo-size-val').textContent = val+'px';
826
+ updateLogoBoxSize();
827
+ }
828
+
829
+ function onLogoPosSlider(){
830
+ LOGO_X = parseInt(document.getElementById('logo-x-slider').value);
831
+ LOGO_Y = parseInt(document.getElementById('logo-y-slider').value);
832
+ document.getElementById('logo-x-val').textContent = LOGO_X;
833
+ document.getElementById('logo-y-val').textContent = LOGO_Y;
834
+ document.querySelectorAll('.pos-btn').forEach(b=>b.classList.remove('active'));
835
+ // Move logoBox on screen
836
+ const container = document.getElementById('video-wrap-box');
837
+ const box = document.getElementById('logoBox');
838
+ if(container && box){
839
+ const cw = container.clientWidth || 720;
840
+ const ch = container.clientHeight || 1280;
841
+ box.style.left = Math.round(LOGO_X * cw / 720)+'px';
842
+ box.style.top = Math.round(LOGO_Y * ch / 1280)+'px';
843
+ }
844
+ }
845
+
846
+ function setLogoPos(pos){
847
+ const dims = getCropDims();
848
+ const pad = 20;
849
+ const positions = {
850
+ tl:{x:pad, y:pad},
851
+ tc:{x:(dims.w-LOGO_W)/2, y:pad},
852
+ tr:{x:dims.w-LOGO_W-pad, y:pad},
853
+ ml:{x:pad, y:(dims.h-LOGO_W)/2},
854
+ mc:{x:(dims.w-LOGO_W)/2, y:(dims.h-LOGO_W)/2},
855
+ mr:{x:dims.w-LOGO_W-pad, y:(dims.h-LOGO_W)/2},
856
+ bl:{x:pad, y:dims.h-LOGO_W-pad},
857
+ bc:{x:(dims.w-LOGO_W)/2, y:dims.h-LOGO_W-pad},
858
+ br:{x:dims.w-LOGO_W-pad, y:dims.h-LOGO_W-pad},
859
+ };
860
+ const p = positions[pos];
861
+ LOGO_X = Math.max(0, Math.round(p.x));
862
+ LOGO_Y = Math.max(0, Math.round(p.y));
863
+ document.getElementById('logo-x-slider').value = LOGO_X;
864
+ document.getElementById('logo-y-slider').value = LOGO_Y;
865
+ document.getElementById('logo-x-val').textContent = LOGO_X;
866
+ document.getElementById('logo-y-val').textContent = LOGO_Y;
867
+ document.querySelectorAll('.pos-btn').forEach(b=>b.classList.remove('active'));
868
+ event.target.classList.add('active');
869
+ onLogoPosSlider();
870
+ }
871
+
872
+ function getCropDims(){
873
+ const crop = document.getElementById('crop').value;
874
+ if(crop==='16:9') return {w:1280,h:720};
875
+ if(crop==='1:1') return {w:720,h:720};
876
+ return {w:720,h:1280};
877
+ }
878
+
879
+ /* ═══════════════════════════════════════════
880
+ VIDEO INPUT
881
+ ═══════════════════════════════════════════ */
882
+ function switchInputMode(mode){
883
+ INPUT_MODE=mode;
884
+ document.getElementById('input-tab-url').classList.toggle('active', mode==='url');
885
+ document.getElementById('input-tab-upload').classList.toggle('active', mode==='upload');
886
+ document.getElementById('input-url-section').style.display = mode==='url' ? '' : 'none';
887
+ document.getElementById('input-upload-section').style.display = mode==='upload' ? '' : 'none';
888
+ }
889
+
890
+ function pasteUrl(){
891
+ navigator.clipboard.readText().then(t=>{
892
+ document.getElementById('video-url').value = t.trim();
893
+ detectPlatform(t.trim()); fetchThumbnail(t.trim());
894
+ }).catch(()=>toast('Clipboard access denied'));
895
+ }
896
+
897
+ function detectPlatform(url){
898
+ const badge=document.getElementById('url-badge'), wrap=document.getElementById('url-platform');
899
+ let p='';
900
+ if(/youtu\.?be/i.test(url)) p='<i class="fab fa-youtube"></i> YouTube';
901
+ else if(/tiktok/i.test(url)) p='<i class="fab fa-tiktok"></i> TikTok';
902
+ else if(/facebook|fb\./i.test(url)) p='<i class="fab fa-facebook"></i> Facebook';
903
+ else if(/instagram/i.test(url)) p='<i class="fab fa-instagram"></i> Instagram';
904
+ else if(/twitter|x\.com/i.test(url)) p='<i class="fab fa-twitter"></i> Twitter/X';
905
+ else if(url) p='<i class="fas fa-globe"></i> Video URL';
906
+ if(p){ badge.innerHTML=p; wrap.style.display=''; } else { wrap.style.display='none'; }
907
+ }
908
+
909
+ document.addEventListener('DOMContentLoaded', ()=>{
910
+ const ui = document.getElementById('video-url');
911
+ if(ui) ui.addEventListener('input', e=>{ detectPlatform(e.target.value); fetchThumbnail(e.target.value); });
912
+ });
913
+
914
+ function hasVideoInput(){
915
+ if(INPUT_MODE==='url') return document.getElementById('video-url').value.trim().length>0;
916
+ return !!(document.getElementById('video-file').files[0]);
917
+ }
918
+
919
+ function onFileSelect(inp){
920
+ if(inp.files[0]){
921
+ const nm=document.getElementById('upload-name');
922
+ nm.textContent='📎 '+inp.files[0].name; nm.style.display='block';
923
+ showPreviewVideo(URL.createObjectURL(inp.files[0]));
924
+ }
925
+ }
926
+ function onMusicSelect(inp){ if(inp.files[0]) document.getElementById('music-name').textContent='🎵 '+inp.files[0].name; }
927
+
928
+ /* ═══════════════════════════════════════════
929
+ THUMBNAIL / DRAG-DROP
930
+ ═══════════════════════════════════════════ */
931
+ async function fetchThumbnail(url){
932
+ if(!url||!url.startsWith('http')) return;
933
+ const thumb=document.getElementById('thumb-preview');
934
+ const yt=url.match(/(?:youtu\.be\/|[?&]v=|shorts\/)([A-Za-z0-9_-]{11})/);
935
+ if(yt){ thumb.src=`https://img.youtube.com/vi/${yt[1]}/hqdefault.jpg`; thumb.style.display='block'; document.getElementById('video-placeholder').style.display='none'; return; }
936
+ if(/tiktok\.com/i.test(url)){
937
+ try{ const r=await fetch(`https://www.tiktok.com/oembed?url=${encodeURIComponent(url)}`); const d=await r.json(); if(d.thumbnail_url){ thumb.src=d.thumbnail_url; thumb.style.display='block'; document.getElementById('video-placeholder').style.display='none'; return; } }catch(e){}
938
+ }
939
+ if(/facebook\.com|fb\.watch/i.test(url)){ thumb.style.display='none'; const ph=document.getElementById('video-placeholder'); ph.style.display='flex'; ph.innerHTML='<i class="fab fa-facebook" style="font-size:2.5rem;color:#1877f2"></i><p style="font-size:.8rem">Facebook Video</p>'; return; }
940
+ if(/instagram\.com/i.test(url)){ thumb.style.display='none'; const ph=document.getElementById('video-placeholder'); ph.style.display='flex'; ph.innerHTML='<i class="fab fa-instagram" style="font-size:2.5rem;color:#e1306c"></i><p style="font-size:.8rem">Instagram Video</p>'; return; }
941
+ }
942
+
943
+ function dragOver(e){ e.preventDefault(); document.getElementById('upload-area').classList.add('drag'); }
944
+ function dragLeave(){ document.getElementById('upload-area').classList.remove('drag'); }
945
+ function dropFile(e){
946
+ e.preventDefault(); dragLeave();
947
+ const f=e.dataTransfer.files[0];
948
+ if(f&&f.type.startsWith('video/')){
949
+ const inp=document.getElementById('video-file');
950
+ const dt=new DataTransfer(); dt.items.add(f); inp.files=dt.files; onFileSelect(inp);
951
+ }
952
+ }
953
+
954
+ /* ═══════════════════════════════════════════
955
+ SPEED TOGGLE
956
+ ═══════════════════════════════════════════ */
957
+ function toggleSpeed(){
958
+ document.getElementById('speed-toggle').classList.toggle('open');
959
+ document.getElementById('speed-row').classList.toggle('visible');
960
+ }
961
+
962
+ /* ═══════════════════════════════════════════
963
+ LANGUAGE / VOICE
964
+ ═══════════════════════════════════════════ */
965
+ const LANG_DEFAULT_VOICE = {
966
+ my:{id:'my-MM-ThihaNeural', engine:'ms'},
967
+ th:{id:'th-TH-PremwadeeNeural', engine:'ms'},
968
+ en:{id:'en-US-AriaNeural', engine:'ms'},
969
+ };
970
+ const LANG_DEFAULT_SPEED = {my:30, th:20, en:0};
971
+
972
+ function switchLang(lang){
973
+ VO_LANG=lang;
974
+ ['my','th','en'].forEach(l=>document.getElementById('lang-'+l).classList.toggle('active', l===lang));
975
+ const spd=LANG_DEFAULT_SPEED[lang]??30;
976
+ const sl=document.getElementById('speed-slider');
977
+ if(sl){ sl.value=spd; document.getElementById('speed-val').textContent=spd+'%'; }
978
+ if(VCAT==='ms'||lang!=='my'){
979
+ if(lang!=='my'){ VCAT='ms'; SELECTED_ENGINE='ms'; document.getElementById('vcat-ms').classList.add('active'); document.getElementById('vcat-g').classList.remove('active'); }
980
+ const def=LANG_DEFAULT_VOICE[lang]; SELECTED_VOICE=def.id; SELECTED_ENGINE=def.engine;
981
+ }
982
+ renderVoices(VCAT);
983
+ }
984
+
985
+ function switchVCat(c){
986
+ VCAT=c;
987
+ document.getElementById('vcat-ms').classList.toggle('active', c==='ms');
988
+ document.getElementById('vcat-g').classList.toggle('active', c==='g');
989
+ SELECTED_ENGINE=c==='g'?'gemini':'ms';
990
+ if(c==='g'&&GEMINI_V.length===0){
991
+ fetch('/api/gemini_voices').then(r=>r.json()).then(d=>{ if(d.ok) d.voices.forEach(v=>GEMINI_V.push({id:v.id,name:v.name,sub:'',lang:'my'})); renderVoices('g'); });
992
+ } else renderVoices(c);
993
+ }
994
+
995
+ function renderVoices(cat){
996
+ const grid=document.getElementById('voice-grid');
997
+ const q=document.getElementById('voice-search').value.toLowerCase();
998
+ let voices = cat==='g' ? GEMINI_V : MS_V.filter(v=>v.lang===VO_LANG);
999
+ const filtered=voices.filter(v=>v.name.toLowerCase().includes(q)||v.id.toLowerCase().includes(q)||(v.sub||'').toLowerCase().includes(q));
1000
+ const isMS=cat!=='g';
1001
+ grid.innerHTML=filtered.map(v=>`
1002
+ <div class="vcard ${SELECTED_VOICE===v.id?'selected':''}" onclick="selectVoice('${v.id}','${v.lang||'my'}',this)">
1003
+ <div class="vcard-name">${v.name}</div>
1004
+ <div class="vcard-sub">${v.sub||''}</div>
1005
+ ${isMS?`<div class="vcard-play" id="play-${v.id.replace(/[^a-z0-9]/gi,'_')}" onclick="event.stopPropagation();previewVoice('${v.id}',this)"><i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်</div>`:''}
1006
+ </div>`).join('');
1007
+ }
1008
+
1009
+ function filterVoices(){ renderVoices(VCAT); }
1010
+
1011
+ let _previewAudio=null;
1012
+ async function previewVoice(voiceId, btnEl){
1013
+ if(_previewAudio){ _previewAudio.pause(); _previewAudio=null; }
1014
+ document.querySelectorAll('.vcard-play').forEach(b=>{ b.classList.remove('playing'); b.innerHTML='<i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်'; });
1015
+ btnEl.classList.add('playing'); btnEl.innerHTML='<i class="fas fa-spinner spinning" style="font-size:.55rem"></i> Loading…';
1016
+ try {
1017
+ const spd=document.getElementById('speed-slider').value;
1018
+ const r=await fetch('/api/preview_voice',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:voiceId,speed:parseInt(spd),engine:'ms'})});
1019
+ const d=await r.json();
1020
+ if(!d.ok){ toast('❌ Preview failed'); btnEl.classList.remove('playing'); btnEl.innerHTML='<i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်'; return; }
1021
+ _previewAudio=new Audio(d.url); _previewAudio.play();
1022
+ btnEl.innerHTML='<i class="fas fa-volume-up" style="font-size:.55rem"></i> ဖွင့်နေ…';
1023
+ _previewAudio.onended=()=>{ btnEl.classList.remove('playing'); btnEl.innerHTML='<i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်'; _previewAudio=null; };
1024
+ } catch(e){ toast('❌ '+e); btnEl.classList.remove('playing'); btnEl.innerHTML='<i class="fas fa-play" style="font-size:.55rem"></i> နားထောင်'; }
1025
+ }
1026
+
1027
+ function selectVoice(id, lang, el){
1028
+ SELECTED_VOICE=id; if(lang) VO_LANG=lang;
1029
+ document.querySelectorAll('.vcard').forEach(c=>c.classList.remove('selected')); el.classList.add('selected');
1030
+ }
1031
+
1032
+ /* ═══════════════════════════════════════════
1033
+ OPTIONS / CHECKS
1034
+ ═══════════════════════════════════════════ */
1035
+ function togCheck(el){
1036
+ el.classList.toggle('checked');
1037
+ el.querySelector('.check-box').innerHTML = el.classList.contains('checked') ? '<i class="fas fa-check"></i>' : '';
1038
+ }
1039
+ function isChecked(id){ return document.getElementById(id).classList.contains('checked'); }
1040
+
1041
+ function onContentTypeChange(val){
1042
+ const n=document.getElementById('funny-notice');
1043
+ if(n) n.style.display=(val==='Funny/Meme')?'block':'none';
1044
+ }
1045
+
1046
+ /* ═══════════════════════════════════════════
1047
+ BUILD FORM DATA — includes blur + logo
1048
+ ═══════════════════════════════════════════ */
1049
+ function buildFormData(includeScript){
1050
+ const fd=new FormData();
1051
+ fd.append('username', CUR_USER);
1052
+ fd.append('voice', SELECTED_VOICE);
1053
+ fd.append('engine', SELECTED_ENGINE);
1054
+ fd.append('vo_lang', VO_LANG);
1055
+ fd.append('speed', document.getElementById('speed-slider').value);
1056
+ fd.append('crop', document.getElementById('crop').value);
1057
+ fd.append('flip', isChecked('chk-fl')?'1':'0');
1058
+ fd.append('color', isChecked('chk-ac')?'1':'0');
1059
+ fd.append('watermark',document.getElementById('watermark').value);
1060
+ fd.append('content_type', document.getElementById('content-type').value);
1061
+ fd.append('ai_model', document.getElementById('ai-model').value);
1062
+
1063
+ // Video input
1064
+ if(INPUT_MODE==='url'){
1065
+ const url=document.getElementById('video-url').value.trim();
1066
+ if(url) fd.append('video_url', url);
1067
+ } else {
1068
+ const vf=document.getElementById('video-file').files[0];
1069
+ if(vf) fd.append('video_file', vf);
1070
+ }
1071
+
1072
+ // Music
1073
+ const mf=document.getElementById('music-file').files[0];
1074
+ if(mf) fd.append('music_file', mf);
1075
+
1076
+ // ── Blur box ──
1077
+ fd.append('blur_enabled', BLUR_ENABLED ? '1' : '0');
1078
+ if(BLUR_ENABLED){
1079
+ fd.append('blur_x', BLUR_X);
1080
+ fd.append('blur_y', BLUR_Y);
1081
+ fd.append('blur_w', BLUR_W);
1082
+ fd.append('blur_h', BLUR_H);
1083
+ }
1084
+
1085
+ // ── Logo ──
1086
+ if(LOGO_ENABLED){
1087
+ const lf=document.getElementById('logo-file').files[0];
1088
+ if(lf){
1089
+ fd.append('logo_file', lf);
1090
+ fd.append('logo_x', LOGO_X);
1091
+ fd.append('logo_y', LOGO_Y);
1092
+ fd.append('logo_w', LOGO_W);
1093
+ }
1094
+ }
1095
+
1096
+ if(includeScript){
1097
+ const sc=document.getElementById('script-in').value.trim();
1098
+ if(sc) fd.append('script', sc);
1099
+ }
1100
+ return fd;
1101
+ }
1102
+
1103
+ /* ═══════════════════════════════════════════
1104
+ PROCESS ACTIONS
1105
+ ═══════════════════════════════════════════ */
1106
+ async function doProcessAll(){
1107
+ if(!hasVideoInput()){ toast('❌ Select a video or enter a URL'); return; }
1108
+ CURRENT_TID=uuid8(); showProg(true); startSSE(CURRENT_TID);
1109
+ const fd=buildFormData(false); fd.append('tid', CURRENT_TID);
1110
+ try {
1111
+ const ctrl=new AbortController();
1112
+ const timer=setTimeout(()=>ctrl.abort(), 1200000);
1113
+ const r=await fetch('/api/process_all',{method:'POST',body:fd,signal:ctrl.signal});
1114
+ clearTimeout(timer);
1115
+ const d=await r.json();
1116
+ if(SSE_SOURCE){ SSE_SOURCE.close(); SSE_SOURCE=null; }
1117
+ if(d.ok){ updateCoins(d.coins); showResult(d.output_url,d.title,d.hashtags,d.caption); }
1118
+ else { showProg(false); toast(d.msg||'❌ Error'); }
1119
+ } catch(e){ showProg(false); toast('❌ '+e); }
1120
+ }
1121
+
1122
+ async function doDraft(){
1123
+ if(!hasVideoInput()){ toast('❌ Select a video or enter a URL'); return; }
1124
+ const btn=document.getElementById('btn-draft'); btn.disabled=true; btn.innerHTML='<i class="fas fa-spinner spinning"></i> Drafting…';
1125
+ const fd=buildFormData(false);
1126
+ try {
1127
+ const r=await fetch('/api/draft',{method:'POST',body:fd});
1128
+ const d=await r.json();
1129
+ if(d.ok){
1130
+ document.getElementById('script-out').value=d.script;
1131
+ document.getElementById('script-chars').textContent=d.script.length+' chars';
1132
+ document.getElementById('script-lang').textContent=d.status||'';
1133
+ updateCoins(d.coins);
1134
+ document.getElementById('draft-result-section').style.display='';
1135
+ } else toast(d.msg||'❌ Error');
1136
+ } catch(e){ toast('❌ '+e); }
1137
+ btn.disabled=false; btn.innerHTML='<i class="fas fa-file-alt"></i> Draft Script (1 Coin)';
1138
+ }
1139
+
1140
+ async function doProcess(){
1141
+ const sc=document.getElementById('script-in').value.trim();
1142
+ if(!hasVideoInput()){ toast('❌ Select a video or enter a URL'); return; }
1143
+ if(!sc){ toast('❌ Enter a script'); return; }
1144
+ const btn=document.getElementById('btn-process'); btn.disabled=true; btn.innerHTML='<i class="fas fa-spinner spinning"></i> Processing…';
1145
+ const fd=buildFormData(true);
1146
+ try {
1147
+ const r=await fetch('/api/process',{method:'POST',body:fd});
1148
+ const d=await r.json();
1149
+ if(d.ok){ updateCoins(d.coins); showResult(d.output_url,'','',''); }
1150
+ else toast(d.msg||'❌ Error');
1151
+ } catch(e){ toast('❌ '+e); }
1152
+ btn.disabled=false; btn.innerHTML='<i class="fas fa-film"></i> Process Video (1 Coin)';
1153
+ }
1154
+
1155
+ /* ═══════════════════════════════════════════
1156
+ PROGRESS / RESULT
1157
+ ═══════════════════════════════════════════ */
1158
+ function startSSE(tid){
1159
+ if(SSE_SOURCE) SSE_SOURCE.close();
1160
+ SSE_SOURCE=new EventSource('/api/progress/'+tid);
1161
+ SSE_SOURCE.onmessage=e=>{
1162
+ try {
1163
+ const p=JSON.parse(e.data);
1164
+ document.getElementById('prog-bar').style.width=(p.pct||0)+'%';
1165
+ document.getElementById('prog-msg').textContent=(p.msg||'').replace(/KEY-\d+\s*·\s*\S+/g,'').trim();
1166
+ if(p.done||p.error) SSE_SOURCE.close();
1167
+ } catch{}
1168
+ };
1169
+ }
1170
+
1171
+ function showProg(show){
1172
+ document.getElementById('prog-wrap').style.display=show?'block':'none';
1173
+ document.getElementById('prog-bar').style.width='0%';
1174
+ document.getElementById('prog-msg').textContent='⏳ Starting…';
1175
+ }
1176
+
1177
+ function showResult(url, title, hashtags, caption){
1178
+ showProg(false);
1179
+ CUR_OUTPUT_URL=url; CUR_CAPTION=caption||title; CUR_HASHTAGS=hashtags;
1180
+ showPreviewVideo(url);
1181
+ if(title){ const t=document.getElementById('meta-title'); t.textContent=title; t.style.display=''; }
1182
+ if(hashtags){ const h=document.getElementById('meta-tags'); h.textContent=hashtags; h.style.display=''; }
1183
+ document.getElementById('download-btn').style.display='block';
1184
+ document.getElementById('copy-caption-btn').style.display='block';
1185
+ document.getElementById('download-btn2').style.display='block';
1186
+ toast('✅ Video completed!');
1187
+ }
1188
+
1189
+ function showPreviewVideo(url){
1190
+ const v=document.getElementById('preview-video');
1191
+ v.src=url; v.style.display='block';
1192
+ document.getElementById('video-placeholder').style.display='none';
1193
+ document.getElementById('thumb-preview').style.display='none';
1194
+ }
1195
+
1196
+ function downloadVideo(){
1197
+ if(!CUR_OUTPUT_URL) return;
1198
+ const a=document.createElement('a'); a.href=CUR_OUTPUT_URL; a.download='recap_'+Date.now()+'.mp4'; a.click();
1199
+ }
1200
+
1201
+ function copyCaption(){
1202
+ const text=[CUR_CAPTION,'',CUR_HASHTAGS].filter(x=>x!==undefined).join('\n').trim();
1203
+ if(!text){ toast('❌ No caption yet'); return; }
1204
+ navigator.clipboard.writeText(text).then(()=>toast('✅ Caption + Hashtags copied')).catch(()=>{
1205
+ const ta=document.createElement('textarea'); ta.value=text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); toast('✅ Caption copied');
1206
+ });
1207
+ }
1208
+
1209
+ function copyVideoLink(){
1210
+ if(!CUR_OUTPUT_URL){ toast('❌ No video yet'); return; }
1211
+ const full=window.location.origin+CUR_OUTPUT_URL;
1212
+ navigator.clipboard.writeText(full).then(()=>toast('✅ Link copied')).catch(()=>toast('❌ Copy failed'));
1213
+ }
1214
+
1215
+ function updateCoins(c){ if(c===-1) return; CUR_COINS=c; document.getElementById('tb-coins').textContent=c===-1?'∞':c; }
1216
+ function updateScriptCount(){ document.getElementById('script-in-chars').textContent=document.getElementById('script-in').value.length+' chars'; }
1217
+ function uuid8(){ return Math.random().toString(36).substring(2,10); }
1218
+ function toast(msg){ const el=document.getElementById('toast'); el.textContent=msg; el.classList.add('show'); setTimeout(()=>el.classList.remove('show'), 2800); }
1219
+
1220
+ /* ═══════════════════════════════════════════
1221
+ BUY MODAL
1222
+ ═══════════════════════════════════════════ */
1223
+ function openBuyModal(){ document.getElementById('buy-modal').style.display='flex'; }
1224
+ function closeBuyModal(){
1225
+ document.getElementById('buy-modal').style.display='none';
1226
+ document.querySelectorAll('.buy-pkg').forEach(p=>p.style.border='1px solid var(--border)');
1227
+ document.getElementById('buy-selected').style.display='none';
1228
+ }
1229
+ function selectPkg(el, coins, price){
1230
+ document.querySelectorAll('.buy-pkg').forEach(p=>{ p.style.border='1px solid var(--border)'; p.style.background='transparent'; });
1231
+ el.style.border='2px solid var(--amber)'; el.style.background='rgba(245,166,35,.04)';
1232
+ document.getElementById('buy-pkg-txt').textContent=`🪙 ${coins} Coins — ${price.toLocaleString()} MMK selected`;
1233
+ document.getElementById('buy-selected').style.display='block';
1234
+ }
1235
+
1236
+ /* ═══════════════════════════════════════════
1237
+ ADMIN
1238
+ ═══════════════════════════════════════════ */
1239
+ function openAdmin(){ document.getElementById('admin-modal').classList.add('open'); loadUsers(); }
1240
+ function closeAdmin(){ document.getElementById('admin-modal').classList.remove('open'); }
1241
+
1242
+ async function genUsername(){
1243
+ const r=await fetch('/api/admin/gen_username?caller='+CUR_USER);
1244
+ const d=await r.json(); if(d.ok) document.getElementById('new-uname').value=d.username;
1245
+ }
1246
+
1247
+ async function adminCreateUser(){
1248
+ const u=document.getElementById('new-uname').value.trim();
1249
+ const c=document.getElementById('new-coins').value;
1250
+ const r=await fetch('/api/admin/create_user',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,coins:parseInt(c),caller:CUR_USER})});
1251
+ const d=await r.json();
1252
+ const el=document.getElementById('create-result');
1253
+ el.textContent=d.msg; el.style.color=d.ok?'var(--green)':'var(--red)';
1254
+ if(d.ok){ document.getElementById('new-uname').value=''; loadUsers(); }
1255
+ }
1256
+
1257
+ async function adminCoins(action){
1258
+ const u=document.getElementById('coin-user').value.trim();
1259
+ const n=document.getElementById('coin-amt').value;
1260
+ if(!u){ document.getElementById('coin-result').textContent='❌ Enter username'; return; }
1261
+ const r=await fetch('/api/admin/coins',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({caller:CUR_USER,username:u,amount:parseInt(n),action})});
1262
+ const d=await r.json();
1263
+ const el=document.getElementById('coin-result');
1264
+ el.textContent=d.msg; el.style.color=d.ok?'var(--green)':'var(--red)';
1265
+ if(d.ok) loadUsers();
1266
+ }
1267
+
1268
+ async function deleteUser(u){
1269
+ if(!confirm(`Delete "${u}"? This cannot be undone.`)) return;
1270
+ const r=await fetch('/api/admin/delete_user',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({caller:CUR_USER,username:u})});
1271
+ const d=await r.json(); toast(d.msg); if(d.ok) loadUsers();
1272
+ }
1273
+
1274
+ async function loadUsers(){
1275
+ const wrap=document.getElementById('users-wrap');
1276
+ wrap.innerHTML='<div style="color:var(--muted);font-size:.8rem">Loading…</div>';
1277
+ try {
1278
+ const r=await fetch('/api/admin/users?caller='+CUR_USER);
1279
+ const d=await r.json();
1280
+ if(!d.ok){ wrap.innerHTML='<div style="color:var(--red);font-size:.8rem">'+d.msg+'</div>'; return; }
1281
+ if(!d.users.length){ wrap.innerHTML='<div style="color:var(--muted);font-size:.8rem">No users yet</div>'; return; }
1282
+ wrap.innerHTML=`<table class="users-table">
1283
+ <tr><th>Username</th><th>Coins</th><th>TR</th><th>VD</th><th>Created</th><th></th></tr>
1284
+ ${d.users.map(u=>`<tr>
1285
+ <td style="color:var(--text);font-weight:500">${u.username}</td>
1286
+ <td><b style="color:var(--amber)">${u.coins}</b></td>
1287
+ <td>${u.transcripts}</td><td>${u.videos}</td><td>${u.created}</td>
1288
+ <td><button onclick="deleteUser('${u.username}')" style="background:rgba(231,76,60,.08);border:1px solid rgba(231,76,60,.2);color:var(--red);border-radius:4px;padding:3px 8px;cursor:pointer;font-size:.7rem"><i class="fas fa-trash"></i></button></td>
1289
+ </tr>`).join('')}
1290
+ </table>`;
1291
+ } catch(e){ wrap.innerHTML='<div style="color:var(--red);font-size:.8rem">Error: '+e+'</div>'; }
1292
+ }
1293
+ </script>
1294
  </body>
1295
+ </html>