Ricky01anjay commited on
Commit
d26d714
·
verified ·
1 Parent(s): db72ff2

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +331 -0
app.py ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import threading
4
+ import requests
5
+ import re
6
+ import time
7
+ from flask import Flask, request, jsonify, render_template_string, send_file
8
+ from werkzeug.utils import secure_filename
9
+ from faster_whisper import WhisperModel
10
+ from moviepy.editor import VideoFileClip, TextClip, CompositeVideoClip
11
+
12
+ app = Flask(__name__)
13
+ app.config['UPLOAD_FOLDER'] = 'downloads'
14
+ app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # Max upload 500MB
15
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
16
+
17
+ # In-memory database
18
+ JOBS = {}
19
+ JOB_TTL = 3600 # Waktu hidup file: 3600 detik (1 Jam)
20
+
21
+ # Model AI Whisper (Dioptimalkan untuk CPU HuggingFace)
22
+ whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
23
+
24
+ # ==========================================
25
+ # AUTO CLEANUP (Pembersih Sampah agar HuggingFace Tidak Penuh)
26
+ # ==========================================
27
+ def cleanup_old_jobs():
28
+ while True:
29
+ time.sleep(600) # Cek setiap 10 Menit
30
+ now = time.time()
31
+ keys_to_delete = []
32
+
33
+ for job_id, job in JOBS.items():
34
+ if now - job['created_at'] > JOB_TTL:
35
+ print(f"[CLEANUP] Menghapus Job kedaluwarsa: {job_id}")
36
+ # Hapus file fisik dari SSD Container
37
+ for file_path in job.get('files_to_delete', []):
38
+ if file_path and os.path.exists(file_path):
39
+ try:
40
+ os.remove(file_path)
41
+ print(f"[CLEANUP] File dihapus: {file_path}")
42
+ except Exception as e:
43
+ print(f"[CLEANUP] Gagal hapus file: {e}")
44
+
45
+ keys_to_delete.append(job_id)
46
+
47
+ # Hapus data dari RAM
48
+ for key in keys_to_delete:
49
+ del JOBS[key]
50
+
51
+ # Mulai thread pembersih otomatis
52
+ cleanup_thread = threading.Thread(target=cleanup_old_jobs, daemon=True)
53
+ cleanup_thread.start()
54
+
55
+ # ==========================================
56
+ # UI HTML (Tailwind & AJAX Polling)
57
+ # ==========================================
58
+ HTML_TEMPLATE = """
59
+ <!DOCTYPE html>
60
+ <html lang="id">
61
+ <head>
62
+ <meta charset="UTF-8">
63
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
64
+ <title>AI Auto Clipper Video</title>
65
+ <script src="https://cdn.tailwindcss.com"></script>
66
+ </head>
67
+ <body class="bg-gray-900 text-white min-h-screen p-4 flex flex-col items-center">
68
+ <div class="w-full max-w-md bg-gray-800 p-6 rounded-xl shadow-lg mt-10">
69
+ <h1 class="text-2xl font-bold mb-4 text-center text-blue-400">AI Auto Clipper</h1>
70
+
71
+ <form id="clipForm" class="space-y-4">
72
+ <div>
73
+ <label class="block text-sm font-medium mb-1">Direct URL Video (MP4)</label>
74
+ <input type="url" id="videoUrl" class="w-full p-2 bg-gray-700 rounded border border-gray-600 focus:border-blue-500 outline-none" placeholder="https://domain.com/video.mp4">
75
+ </div>
76
+ <div class="text-center text-sm font-bold text-gray-400">ATAU</div>
77
+ <div>
78
+ <label class="block text-sm font-medium mb-1">Upload Video</label>
79
+ <input type="file" id="videoFile" accept="video/mp4,video/webm" class="w-full p-2 bg-gray-700 rounded border border-gray-600">
80
+ </div>
81
+ <div>
82
+ <label class="block text-sm font-medium mb-1">Target Durasi</label>
83
+ <select id="duration" class="w-full p-2 bg-gray-700 rounded border border-gray-600">
84
+ <option value="30">30 Detik (Shorts/Reels)</option>
85
+ <option value="60">60 Detik</option>
86
+ <option value="120">120 Detik</option>
87
+ </select>
88
+ </div>
89
+ <button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded transition">Buat Clip!</button>
90
+ </form>
91
+
92
+ <div id="statusArea" class="mt-6 hidden">
93
+ <h3 class="font-bold text-yellow-400 mb-2">Status Proses:</h3>
94
+ <p id="statusText" class="text-sm bg-gray-700 p-3 rounded">Memulai...</p>
95
+ <div class="w-full bg-gray-600 rounded-full h-2.5 mt-2">
96
+ <div id="progressBar" class="bg-blue-500 h-2.5 rounded-full" style="width: 0%"></div>
97
+ </div>
98
+ </div>
99
+
100
+ <div id="resultArea" class="mt-6 hidden text-center">
101
+ <h3 class="font-bold text-green-400 mb-2">Selesai!</h3>
102
+ <p class="text-xs text-gray-400 mb-3">Link berlaku selama 1 Jam</p>
103
+ <a id="downloadBtn" href="#" class="inline-block bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">Download Video</a>
104
+ </div>
105
+ </div>
106
+
107
+ <script>
108
+ document.getElementById('clipForm').addEventListener('submit', async (e) => {
109
+ e.preventDefault();
110
+ const formData = new FormData();
111
+ formData.append('duration', document.getElementById('duration').value);
112
+
113
+ const file = document.getElementById('videoFile').files[0];
114
+ const url = document.getElementById('videoUrl').value;
115
+
116
+ if (file) formData.append('video_file', file);
117
+ else if (url) formData.append('video_url', url);
118
+ else return alert("Masukkan URL atau Upload Video!");
119
+
120
+ document.getElementById('statusArea').classList.remove('hidden');
121
+ document.getElementById('clipForm').classList.add('hidden');
122
+
123
+ const res = await fetch('/generate', { method: 'POST', body: formData });
124
+ const data = await res.json();
125
+
126
+ if (data.job_id) {
127
+ pollStatus(data.job_id);
128
+ }
129
+ });
130
+
131
+ async function pollStatus(jobId) {
132
+ const interval = setInterval(async () => {
133
+ const res = await fetch(`/status/${jobId}`);
134
+ const data = await res.json();
135
+
136
+ document.getElementById('statusText').innerText = data.message;
137
+ document.getElementById('progressBar').style.width = data.progress + "%";
138
+
139
+ if (data.status === 'completed') {
140
+ clearInterval(interval);
141
+ document.getElementById('statusArea').classList.add('hidden');
142
+ document.getElementById('resultArea').classList.remove('hidden');
143
+ document.getElementById('downloadBtn').href = `/download/${data.result}`;
144
+ } else if (data.status === 'error') {
145
+ clearInterval(interval);
146
+ document.getElementById('statusText').innerText = "Error: " + data.message;
147
+ document.getElementById('statusText').classList.add('text-red-400');
148
+ }
149
+ }, 3000);
150
+ }
151
+ </script>
152
+ </body>
153
+ </html>
154
+ """
155
+
156
+ # ==========================================
157
+ # HELPER FUNCTIONS
158
+ # ==========================================
159
+
160
+ def time_to_seconds(time_str):
161
+ try:
162
+ parts = time_str.split(':')
163
+ return int(parts[0]) * 60 + int(parts[1])
164
+ except:
165
+ return 0
166
+
167
+ def background_processor(job_id, video_path, duration):
168
+ try:
169
+ JOBS[job_id]['message'] = "Step 2: Melakukan Transkripsi (AI Whisper)..."
170
+ JOBS[job_id]['progress'] = 20
171
+
172
+ # Transkripsi Video
173
+ segments, info = whisper_model.transcribe(video_path, word_timestamps=True)
174
+ transcript = ""
175
+ words_data = []
176
+
177
+ for segment in segments:
178
+ for word in segment.words:
179
+ words_data.append(word)
180
+ transcript += f"[{int(word.start//60):02d}:{int(word.start%60):02d}] {word.word.strip()}\n"
181
+
182
+ JOBS[job_id]['message'] = "Step 3: Meminta AI menganalisis Hook terbaik..."
183
+ JOBS[job_id]['progress'] = 50
184
+
185
+ # Meminta LLM menganalisa clip
186
+ prompt = f"Cari bagian paling menarik (hook bagus) durasi ~{duration} detik dari transkrip ini. Balas HANYA format 'MM:SS - MM:SS'.\n\nTranskrip:\n{transcript[:3000]}"
187
+ api_url = "https://www.puruboy.kozow.com/api/ai/letmegpt"
188
+ response = requests.post(api_url, json={"message": prompt})
189
+
190
+ ai_result = response.json().get("result", {}).get("answer", "")
191
+
192
+ # Ekstrak waktu
193
+ times = re.findall(r'(\d{1,2}:\d{2})', ai_result)
194
+ if len(times) >= 2:
195
+ start_sec = time_to_seconds(times[0])
196
+ end_sec = time_to_seconds(times[1])
197
+ else:
198
+ start_sec = 0
199
+ end_sec = int(duration)
200
+
201
+ JOBS[job_id]['message'] = f"Step 4: Memotong & render ({times[0]} - {times[1]})..."
202
+ JOBS[job_id]['progress'] = 70
203
+
204
+ # Penyiapan output
205
+ output_filename = f"clip_{job_id}.mp4"
206
+ output_path = os.path.join(app.config['UPLOAD_FOLDER'], output_filename)
207
+
208
+ # Tambahkan output hasil ke daftar sampah yang akan dihapus nanti (1 Jam kemudian)
209
+ JOBS[job_id]['files_to_delete'].append(output_path)
210
+
211
+ # Potong & Crop Rasio 9:16 (Vertikal)
212
+ clip = VideoFileClip(video_path).subclip(start_sec, end_sec)
213
+ w, h = clip.size
214
+ target_w = int(h * 9 / 16)
215
+ x_center = w / 2
216
+ cropped_clip = clip.crop(x1=x_center - target_w/2, y1=0, x2=x_center + target_w/2, y2=h)
217
+
218
+ # Font Absolute Path (Sesuai dengan apt-get fonts-dejavu di Dockerfile)
219
+ font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
220
+
221
+ # Pembuatan Subtitle
222
+ txt_clips = []
223
+ for word in words_data:
224
+ if word.start >= start_sec and word.end <= end_sec:
225
+ rel_start = word.start - start_sec
226
+ rel_end = word.end - start_sec
227
+
228
+ txt = TextClip(
229
+ word.word.strip(),
230
+ font=font_path,
231
+ fontsize=50,
232
+ color='yellow',
233
+ stroke_color='black',
234
+ stroke_width=3
235
+ )
236
+ txt = txt.set_position(('center', 'center')).set_start(rel_start).set_end(rel_end)
237
+ txt_clips.append(txt)
238
+
239
+ final_video = CompositeVideoClip([cropped_clip] + txt_clips)
240
+
241
+ # CPU Render Optimized
242
+ final_video.write_videofile(output_path, fps=30, codec="libx264", audio_codec="aac", threads=4, preset="ultrafast", logger=None)
243
+
244
+ # Tutup memori MoviePy
245
+ clip.close()
246
+ cropped_clip.close()
247
+ final_video.close()
248
+
249
+ # Update Status Selesai
250
+ JOBS[job_id]['status'] = "completed"
251
+ JOBS[job_id]['progress'] = 100
252
+ JOBS[job_id]['message'] = "Video selesai dibuat!"
253
+ JOBS[job_id]['result'] = output_filename
254
+
255
+ except Exception as e:
256
+ JOBS[job_id]['status'] = "error"
257
+ JOBS[job_id]['message'] = str(e)
258
+
259
+
260
+ # ==========================================
261
+ # ROUTES
262
+ # ==========================================
263
+
264
+ @app.route('/')
265
+ def index():
266
+ return render_template_string(HTML_TEMPLATE)
267
+
268
+ @app.route('/generate', methods=['POST'])
269
+ def generate():
270
+ job_id = str(uuid.uuid4())
271
+ duration = request.form.get('duration', '60')
272
+ video_path = ""
273
+
274
+ # Menangani Upload vs URL
275
+ if 'video_file' in request.files and request.files['video_file'].filename != '':
276
+ file = request.files['video_file']
277
+ filename = secure_filename(file.filename)
278
+ video_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{job_id}_{filename}")
279
+ file.save(video_path)
280
+ elif 'video_url' in request.form and request.form['video_url'] != '':
281
+ video_url = request.form['video_url']
282
+ video_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{job_id}_downloaded.mp4")
283
+
284
+ # Download dengan sistem chunk (aman untuk RAM)
285
+ r = requests.get(video_url, stream=True)
286
+ with open(video_path, 'wb') as f:
287
+ for chunk in r.iter_content(chunk_size=1024*1024):
288
+ if chunk: f.write(chunk)
289
+ else:
290
+ return jsonify({"error": "Video tidak ditemukan"}), 400
291
+
292
+ # Daftarkan Job (beserta tracker file mentah agar bisa dibersihkan 1 Jam ke depan)
293
+ JOBS[job_id] = {
294
+ "status": "processing",
295
+ "progress": 10,
296
+ "message": "Step 1: Video berhasil dimuat. Bersiap memproses...",
297
+ "result": None,
298
+ "created_at": time.time(),
299
+ "files_to_delete": [video_path]
300
+ }
301
+
302
+ # Jalankan proses secara asynchronous
303
+ thread = threading.Thread(target=background_processor, args=(job_id, video_path, duration))
304
+ thread.start()
305
+
306
+ return jsonify({"job_id": job_id})
307
+
308
+ @app.route('/status/<job_id>')
309
+ def status(job_id):
310
+ job = JOBS.get(job_id)
311
+ if not job:
312
+ return jsonify({"status": "error", "message": "Job sudah kedaluwarsa atau tidak ditemukan"})
313
+
314
+ # Sembunyikan field sensitif (seperti path/files_to_delete) saat kirim ke client UI
315
+ return jsonify({
316
+ "status": job['status'],
317
+ "progress": job['progress'],
318
+ "message": job['message'],
319
+ "result": job['result']
320
+ })
321
+
322
+ @app.route('/download/<filename>')
323
+ def download(filename):
324
+ file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
325
+ if not os.path.exists(file_path):
326
+ return "File tidak ditemukan atau sudah dihapus server.", 404
327
+ return send_file(file_path, as_attachment=True)
328
+
329
+ if __name__ == '__main__':
330
+ # Harus jalan di port 7860 untuk HuggingFace Spaces
331
+ app.run(host='0.0.0.0', port=7860, debug=False)