Shinekoko commited on
Commit
22e60dd
·
verified ·
1 Parent(s): 4590b5b

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +36 -0
  2. app.py +284 -0
  3. index.html +317 -0
  4. requirements.txt +10 -0
  5. utils.py +242 -0
Dockerfile ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.11 Slim (Latest Stable for Google AI)
2
+ FROM python:3.11-slim
3
+
4
+ # Install System Dependencies
5
+ RUN apt-get update && \
6
+ apt-get install -y ffmpeg git && \
7
+ rm -rf /var/lib/apt/lists/*
8
+
9
+ # Set Working Directory
10
+ WORKDIR /app
11
+
12
+ # Create a non-root user
13
+ RUN useradd -m -u 1000 user
14
+
15
+ # Copy Requirements first
16
+ COPY requirements.txt .
17
+
18
+ # Install Python Libraries
19
+ RUN pip install --no-cache-dir -r requirements.txt
20
+
21
+ # Copy all app files
22
+ COPY . .
23
+
24
+ # Permissions
25
+ RUN mkdir -p static/uploads static/processed && \
26
+ chown -R user:user /app && \
27
+ chmod -R 777 static/uploads static/processed
28
+
29
+ # Switch User
30
+ USER user
31
+
32
+ # Expose Port
33
+ EXPOSE 7860
34
+
35
+ # Command
36
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Session key keeps purely for UI purposes, not for file security anymore
33
+ app.secret_key = os.environ.get('SECRET_KEY', 'secure-recap-maker-key')
34
+
35
+ # --- CONFIG ---
36
+ BASE_DIR = os.path.abspath(os.path.dirname(__file__))
37
+ UPLOAD_FOLDER = os.path.join(BASE_DIR, 'static/uploads')
38
+ PROCESSED_FOLDER = os.path.join(BASE_DIR, 'static/processed')
39
+
40
+ for f in [UPLOAD_FOLDER, PROCESSED_FOLDER]:
41
+ os.makedirs(f, exist_ok=True)
42
+
43
+ # --- QUEUE SYSTEM ---
44
+ task_queue = queue.Queue()
45
+ jobs = {}
46
+
47
+ # --- WORKER 1: VIDEO PROCESSING ---
48
+ def worker():
49
+ print("👷 Video Processing Worker Started...")
50
+ while True:
51
+ try:
52
+ job_id, input_p, output_p, opts = task_queue.get()
53
+ print(f"⚙️ Worker: Starting Job {job_id}")
54
+ jobs[job_id]['status'] = 'processing'
55
+
56
+ success, err_msg = process_video_edit(input_p, output_p, opts)
57
+
58
+ if success:
59
+ filename = os.path.basename(output_p)
60
+ download_url = f"/stream-and-delete/{filename}"
61
+ jobs[job_id] = {'status': 'success', 'url': download_url}
62
+ print(f"✅ Job {job_id} Complete!")
63
+ else:
64
+ jobs[job_id] = {'status': 'failed', 'message': f'Rendering Failed: {err_msg}'}
65
+ print(f"❌ Job {job_id} Failed! Reason: {err_msg}")
66
+
67
+ task_queue.task_done()
68
+ except Exception as e:
69
+ print(f"Worker Crash: {e}")
70
+ if 'job_id' in locals():
71
+ jobs[job_id] = {'status': 'failed', 'message': str(e)}
72
+
73
+ # --- WORKER 2: AUTO CLEANUP ---
74
+ def cleanup_worker():
75
+ print("🧹 Auto Cleanup Worker Started...")
76
+ while True:
77
+ try:
78
+ time.sleep(600)
79
+ now = time.time()
80
+ cutoff = 1800
81
+
82
+ folders = [UPLOAD_FOLDER, PROCESSED_FOLDER]
83
+ deleted_count = 0
84
+
85
+ for folder in folders:
86
+ if not os.path.exists(folder): continue
87
+ for filename in os.listdir(folder):
88
+ file_path = os.path.join(folder, filename)
89
+ if not os.path.isfile(file_path) or filename.startswith('.'): continue
90
+
91
+ try:
92
+ file_age = now - os.path.getmtime(file_path)
93
+ if file_age > cutoff:
94
+ os.remove(file_path)
95
+ deleted_count += 1
96
+ except Exception as e:
97
+ print(f"⚠️ Cleanup Access Error: {filename} - {e}")
98
+
99
+ if deleted_count > 0:
100
+ print(f"🗑️ Cleaned up {deleted_count} old files.")
101
+ except Exception as e:
102
+ print(f"Cleanup Loop Error: {e}")
103
+
104
+ threading.Thread(target=worker, daemon=True).start()
105
+ threading.Thread(target=cleanup_worker, daemon=True).start()
106
+
107
+ # --- HELPER: GET USER ID (UI Display Only) ---
108
+ def get_user_id():
109
+ if 'user_id' not in session:
110
+ session['user_id'] = str(uuid.uuid4())[:6]
111
+ return session['user_id']
112
+
113
+ # --- ROUTES ---
114
+
115
+ @app.route('/')
116
+ def home():
117
+ uid = get_user_id()
118
+ return render_template('index.html', user_id=uid)
119
+
120
+ @app.route('/stream-and-delete/<filename>')
121
+ def stream_and_delete(filename):
122
+ try:
123
+ path = os.path.join(PROCESSED_FOLDER, filename)
124
+ if not os.path.exists(path):
125
+ return jsonify({'status': 'error', 'message': 'File not found or expired'}), 404
126
+
127
+ @after_this_request
128
+ def remove_file(response):
129
+ try:
130
+ if os.path.exists(path):
131
+ os.remove(path)
132
+ except Exception as e:
133
+ print(f"⚠️ Error deleting {filename}: {e}")
134
+ return response
135
+
136
+ return send_file(path, as_attachment=False)
137
+ except Exception as e:
138
+ return jsonify({'status': 'error', 'message': str(e)}), 500
139
+
140
+ @app.route('/upload-video', methods=['POST'])
141
+ def up_video():
142
+ try:
143
+ if 'video_file' not in request.files:
144
+ return jsonify({'status':'error', 'message': 'No file part'})
145
+
146
+ f = request.files['video_file']
147
+ if f.filename == '':
148
+ return jsonify({'status':'error', 'message': 'No selected file'})
149
+
150
+ ext = f.filename.rsplit('.', 1)[1].lower() if '.' in f.filename else 'mp4'
151
+
152
+ # --- SECURITY FIX: ULTRA-SECURE FILENAME ---
153
+ # We use a 32-character UUID Hex.
154
+ # Probability of collision is virtually zero.
155
+ # Example: vid_a1b2c3d4e5f678901234567890abcdef.mp4
156
+ secure_uuid = uuid.uuid4().hex
157
+ safe_name = f"vid_{secure_uuid}.{ext}"
158
+
159
+ path = os.path.join(UPLOAD_FOLDER, safe_name)
160
+ f.save(path)
161
+
162
+ return jsonify({
163
+ 'status':'success',
164
+ 'filename':safe_name,
165
+ 'path':f'/static/uploads/{safe_name}',
166
+ 'translated_text': ''
167
+ })
168
+ except Exception as e:
169
+ print(f"Upload Error: {e}")
170
+ return jsonify({'status':'error', 'message':str(e)})
171
+
172
+ @app.route('/download-video', methods=['POST'])
173
+ def dl_video():
174
+ try:
175
+ url = request.json.get('url')
176
+ if not url: return jsonify({'status':'error', 'message': 'No URL'})
177
+
178
+ # --- SECURITY FIX ---
179
+ secure_uuid = uuid.uuid4().hex
180
+
181
+ opts = {
182
+ 'outtmpl': os.path.join(UPLOAD_FOLDER, f'vid_{secure_uuid}.%(ext)s'),
183
+ 'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best',
184
+ 'noplaylist': True,
185
+ 'quiet': True,
186
+ 'nocheckcertificate': True,
187
+ }
188
+
189
+ with yt_dlp.YoutubeDL(opts) as ydl:
190
+ ydl.download([url])
191
+
192
+ # Find the file we just downloaded (starts with vid_secure_uuid)
193
+ target_prefix = f"vid_{secure_uuid}"
194
+ f = next((x for x in os.listdir(UPLOAD_FOLDER) if x.startswith(target_prefix)), None)
195
+
196
+ if f:
197
+ path = os.path.join(UPLOAD_FOLDER, f)
198
+ txt = analyze_script_with_ai(path)
199
+ return jsonify({'status':'success', 'filename':f, 'path':f'/static/uploads/{f}', 'translated_text':txt})
200
+ return jsonify({'status':'error', 'message': 'Download failed'})
201
+ except Exception as e: return jsonify({'status':'error', 'message':str(e)})
202
+
203
+ @app.route('/re-analyze', methods=['POST'])
204
+ def re_analyze():
205
+ try:
206
+ filename = request.form.get('filename')
207
+
208
+ if not filename: return jsonify({'status':'error', 'message':'No file specified'})
209
+
210
+ # Since filenames are random 32-char UUIDs,
211
+ # knowing the filename IS the authorization.
212
+ path = os.path.join(UPLOAD_FOLDER, filename)
213
+
214
+ if not os.path.exists(path):
215
+ return jsonify({'status':'error', 'message':'File not found (Expired)'})
216
+
217
+ txt = analyze_script_with_ai(path)
218
+ return jsonify({'status':'success', 'translated_text': txt})
219
+ except Exception as e: return jsonify({'status':'error', 'message':str(e)})
220
+
221
+ @app.route('/process', methods=['POST'])
222
+ def start_process():
223
+ try:
224
+ d = request.form
225
+ vname = d.get('video_filename')
226
+
227
+ if not vname:
228
+ return jsonify({'status':'error', 'message':'No video selected'})
229
+
230
+ ip = os.path.join(UPLOAD_FOLDER, vname)
231
+ if not os.path.exists(ip):
232
+ return jsonify({'status':'error', 'message':'Source video not found (Expired)'})
233
+
234
+ # Generate unique output filename
235
+ job_id = uuid.uuid4().hex
236
+ op = os.path.join(PROCESSED_FOLDER, f"recap_{job_id}.mp4")
237
+
238
+ def is_on(k): return d.get(k) in ['on', 'true', '1']
239
+
240
+ opts = {
241
+ 'text_watermark': d.get('text_watermark'),
242
+ 'blur_enabled': is_on('blur_enabled'),
243
+ 'blur_x': int(float(d.get('blur_x',0))), 'blur_y': int(float(d.get('blur_y',0))),
244
+ 'blur_w': int(float(d.get('blur_w',0))), 'blur_h': int(float(d.get('blur_h',0))),
245
+ 'logo_x': int(float(d.get('logo_x',1))), 'logo_y': int(float(d.get('logo_y',1))),
246
+ 'logo_w': int(float(d.get('logo_w',100))), 'logo_h': int(float(d.get('logo_h',100))),
247
+ 'bypass_flip': is_on('bypass_flip'),
248
+ 'bypass_zoom': is_on('bypass_zoom'),
249
+ 'bypass_speed': is_on('bypass_speed'),
250
+ 'bypass_color': is_on('bypass_color'),
251
+ 'monezlation': is_on('monezlation'),
252
+ }
253
+
254
+ if request.files.get('logo_file'):
255
+ l = request.files['logo_file']
256
+ if l.filename:
257
+ # Use secure UUID for logo too
258
+ lp = os.path.join(UPLOAD_FOLDER, f"logo_{job_id}.png")
259
+ l.save(lp)
260
+ opts['logo_path'] = lp
261
+
262
+ if d.get('ai_text'):
263
+ ap = os.path.join(UPLOAD_FOLDER, f"audio_{job_id}.mp3")
264
+ gender = d.get('voice_gender','male')
265
+ if create_ai_audio(d.get('ai_text'), ap, gender):
266
+ opts['ai_audio_path'] = ap
267
+
268
+ jobs[job_id] = {'status': 'queued'}
269
+ task_queue.put((job_id, ip, op, opts))
270
+
271
+ return jsonify({'status':'queued', 'job_id': job_id, 'message': 'Added to Queue'})
272
+
273
+ except Exception as e: return jsonify({'status':'error', 'message':str(e)})
274
+
275
+ @app.route('/status/<job_id>')
276
+ def check_status(job_id):
277
+ job = jobs.get(job_id)
278
+ if not job: return jsonify({'status': 'not_found'})
279
+ return jsonify(job)
280
+
281
+ if __name__ == '__main__':
282
+ print("🚀 Recap Maker Server Running on Port 7860...")
283
+ app.run(debug=False, port=7860, host='0.0.0.0')
284
+
index.html ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ Flask
2
+ yt-dlp
3
+ ffmpeg-python
4
+ edge-tts
5
+ uuid
6
+ SpeechRecognition
7
+ requests
8
+ groq
9
+ python-dotenv
10
+ google-genai
utils.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ffmpeg
2
+ import os
3
+ import asyncio
4
+ import edge_tts
5
+ import uuid
6
+ import time
7
+ import math # <--- Added for smart loop calculation
8
+ from google import genai
9
+ from google.genai import types
10
+ from groq import Groq
11
+
12
+ # --- 1. GEMINI MANAGER (DYNAMIC FETCH & FLASH ONLY) ---
13
+ class GeminiManager:
14
+ def __init__(self):
15
+ self.keys = []
16
+ for i in range(1, 6):
17
+ k = os.getenv(f'GEMINI_API_KEY_{i}')
18
+ if k: self.keys.append(k)
19
+
20
+ if not self.keys:
21
+ k = os.getenv('GEMINI_API_KEY')
22
+ if k: self.keys.append(k)
23
+
24
+ self.key_models_cache = {}
25
+ self.key_status = {}
26
+ for k in self.keys:
27
+ self.key_status[k] = {"usage_count": 0, "window_start": time.time(), "cooldown_until": 0}
28
+ self.RPM_LIMIT = 12
29
+
30
+ def get_healthy_key(self):
31
+ current_time = time.time()
32
+ for key in self.keys:
33
+ stats = self.key_status[key]
34
+ if current_time < stats["cooldown_until"]: continue
35
+ if current_time - stats["window_start"] > 60:
36
+ stats["usage_count"] = 0; stats["window_start"] = current_time
37
+ if stats["usage_count"] < self.RPM_LIMIT:
38
+ stats["usage_count"] += 1
39
+ return key
40
+ time.sleep(1)
41
+ return self.get_healthy_key()
42
+
43
+ def mark_key_error(self, key):
44
+ print(f"⚠️ Key Error on ...{key[-4:]}. Cooling down.")
45
+ self.key_status[key]["cooldown_until"] = time.time() + 60
46
+
47
+ def fetch_flash_models(self, client, key):
48
+ if key in self.key_models_cache: return self.key_models_cache[key]
49
+ try:
50
+ flash_list = []
51
+ for m in client.models.list():
52
+ if 'flash' in m.name.lower():
53
+ flash_list.append(m.name.replace('models/', ''))
54
+ flash_list.sort(reverse=True)
55
+ if not flash_list: flash_list = ["gemini-1.5-flash"]
56
+ self.key_models_cache[key] = flash_list
57
+ return flash_list
58
+ except: return ["gemini-1.5-flash"]
59
+
60
+ def translate_text(self, text):
61
+ max_attempts = 3
62
+ attempts = 0
63
+ last_error = ""
64
+
65
+ while attempts < max_attempts:
66
+ key = self.get_healthy_key()
67
+ if not key: return "System Busy"
68
+
69
+ try:
70
+ client = genai.Client(api_key=key, http_options={'api_version': 'v1beta'})
71
+ available_models = self.fetch_flash_models(client, key)
72
+
73
+ for model_name in available_models:
74
+ try:
75
+ response = client.models.generate_content(
76
+ model=model_name,
77
+ contents=text,
78
+ config=types.GenerateContentConfig(
79
+ temperature=0.3,
80
+ max_output_tokens=8192,
81
+ system_instruction=(
82
+ "You are a Translator. Convert the input English text into 'Natural Spoken Burmese' (အပြောစကား). "
83
+ "Output ONLY the Burmese translation. No Markdown."
84
+ )
85
+ )
86
+ )
87
+ return response.text.strip()
88
+ except Exception as e:
89
+ err = str(e).lower()
90
+ if "429" in err or "403" in err or "resource exhausted" in err:
91
+ self.mark_key_error(key)
92
+ break
93
+ continue
94
+ except Exception as e:
95
+ last_error = str(e)
96
+ attempts += 1
97
+ return f"Translation Failed: {last_error}"
98
+
99
+ gemini_manager = GeminiManager()
100
+
101
+ # --- 2. GROQ LOGIC ---
102
+ def transcribe_audio_groq(audio_path):
103
+ try:
104
+ k = os.getenv('GROQ_API_KEY_1') or os.getenv('GROQ_API_KEY')
105
+ if not k: return None
106
+ client = Groq(api_key=k)
107
+ with open(audio_path, "rb") as file:
108
+ transcription = client.audio.transcriptions.create(
109
+ file=(os.path.basename(audio_path), file.read()),
110
+ model="whisper-large-v3-turbo",
111
+ response_format="text"
112
+ )
113
+ return str(transcription).strip()
114
+ except Exception as e:
115
+ print(f"❌ Groq Transcription Error: {e}")
116
+ return None
117
+
118
+ # --- 3. MAIN ANALYSIS ---
119
+ def analyze_script_with_ai(video_path):
120
+ unique_id = str(uuid.uuid4())[:8]
121
+ audio_path = f"temp_{unique_id}.mp3"
122
+ try:
123
+ if os.path.exists(audio_path): os.remove(audio_path)
124
+ (
125
+ ffmpeg.input(video_path)
126
+ .output(audio_path, format='mp3', acodec='libmp3lame', ab='64k')
127
+ .run(quiet=True, overwrite_output=True)
128
+ )
129
+ print("🚀 Step 1: Transcribing with Groq...")
130
+ english_text = transcribe_audio_groq(audio_path)
131
+ if not english_text:
132
+ if os.path.exists(audio_path): os.remove(audio_path)
133
+ return "Transcription Failed (Check Groq Key)"
134
+
135
+ print(f"🧠 Step 2: Translating {len(english_text)} chars with Gemini...")
136
+ burmese_text = gemini_manager.translate_text(english_text)
137
+
138
+ if os.path.exists(audio_path): os.remove(audio_path)
139
+ return burmese_text
140
+ except Exception as e:
141
+ if os.path.exists(audio_path): os.remove(audio_path)
142
+ return f"Error: {str(e)}"
143
+
144
+ # --- 4. VIDEO PROCESSING (SMART LOOP FIX) ---
145
+ async def generate_voice(text, output_file, voice):
146
+ communicate = edge_tts.Communicate(text, voice)
147
+ await communicate.save(output_file)
148
+
149
+ def create_ai_audio(text, output_path, gender='male'):
150
+ voice = "my-MM-ThihaNeural" if gender == 'male' else "my-MM-NilarNeural"
151
+ try: asyncio.run(generate_voice(text, output_path, voice)); return True
152
+ except: return False
153
+
154
+ def process_video_edit(input_path, output_path, options):
155
+ try:
156
+ probe = ffmpeg.probe(input_path)
157
+ vid_info = next(s for s in probe['streams'] if s['codec_type'] == 'video')
158
+ width = int(vid_info['width'])
159
+ height = int(vid_info['height'])
160
+ duration = float(probe['format']['duration'])
161
+
162
+ input_stream = ffmpeg.input(input_path)
163
+ v = input_stream.video
164
+ a = input_stream.audio
165
+
166
+ # --- A. AUDIO SYNC FIRST ---
167
+ if options.get('ai_audio_path') and os.path.exists(options['ai_audio_path']):
168
+ ai_a = ffmpeg.input(options['ai_audio_path']).audio
169
+ ai_probe = ffmpeg.probe(options['ai_audio_path'])
170
+ ai_duration = float(ai_probe['format']['duration'])
171
+
172
+ if duration > 0 and ai_duration > 0:
173
+ tempo = ai_duration / duration
174
+ if tempo < 0.5: tempo = 0.5
175
+ if tempo > 2.0: tempo = 2.0
176
+ a = ai_a.filter('atempo', tempo).filter('atrim', duration=duration)
177
+ else:
178
+ a = ai_a
179
+
180
+ # --- B. SMART MONEZLATION LOOP ---
181
+ if options.get('monezlation') and duration < 70:
182
+ target_duration = 70.0
183
+ # Calculate total plays needed (Ceiling division)
184
+ # E.g., 70 / 20 = 3.5 -> Ceil = 4 total plays
185
+ total_plays = math.ceil(target_duration / duration)
186
+
187
+ # FFmpeg loop arg is 'repetitions' (plays - 1)
188
+ # If total_plays is 4, we need to repeat 3 times
189
+ loop_count = total_plays - 1
190
+
191
+ print(f"💰 Loop Calculation: Src={duration}s, Need={target_duration}s -> Plays={total_plays}, FFmpeg_Loop={loop_count}")
192
+
193
+ if loop_count > 0:
194
+ v = v.filter('loop', loop=loop_count, size=32767)
195
+ a = a.filter('aloop', loop=loop_count, size=2147483647)
196
+ duration = duration * total_plays
197
+
198
+ # Filters
199
+ if options.get('bypass_flip'): v = v.hflip()
200
+ if options.get('bypass_speed'):
201
+ v = v.filter('setpts', 'PTS/1.05')
202
+ a = a.filter('atempo', '1.05')
203
+
204
+ if options.get('bypass_zoom'):
205
+ crop_w = int(width * 0.95)
206
+ crop_h = int(height * 0.95)
207
+ v = v.crop(x='(in_w-ow)/2', y='(in_h-oh)/2', width=crop_w, height=crop_h)
208
+ v = v.filter('scale', width, height)
209
+
210
+ if options.get('bypass_color'):
211
+ v = v.filter('eq', contrast=1.1, brightness=0.05, saturation=1.2)
212
+
213
+ # Coordinate Fix
214
+ if options.get('blur_enabled'):
215
+ bx = int(options.get('blur_x', 0))
216
+ by = int(options.get('blur_y', 0))
217
+ bw = int(options.get('blur_w', 0))
218
+ bh = int(options.get('blur_h', 0))
219
+ if bw > 0 and bh > 0:
220
+ v = v.filter('delogo', x=bx, y=by, w=bw, h=bh)
221
+
222
+ if options.get('logo_path'):
223
+ try:
224
+ lw = int(options.get('logo_w', 100))
225
+ lh = int(options.get('logo_h', 100))
226
+ lx = int(options.get('logo_x', 10))
227
+ ly = int(options.get('logo_y', 10))
228
+ logo = ffmpeg.input(options['logo_path']).filter('scale', lw, lh)
229
+ v = v.overlay(logo, x=lx, y=ly)
230
+ except: pass
231
+
232
+ try:
233
+ v = v.drawtext(text='Shine Movie Recap', x=50, y=50, fontsize=20, fontcolor='red', box=1, boxcolor='black@0.5')
234
+ except: pass
235
+
236
+ output = ffmpeg.output(v, a, output_path, vcodec='libx264', acodec='aac', preset='veryfast', shortest=None)
237
+ output.run(overwrite_output=True, quiet=True)
238
+ return True, "Success"
239
+
240
+ except Exception as e:
241
+ return False, str(e)
242
+