Ricky01anjay commited on
Commit
ffe2671
·
verified ·
1 Parent(s): b208f0f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +109 -113
app.py CHANGED
@@ -11,23 +11,21 @@ from moviepy.editor import VideoFileClip, TextClip, CompositeVideoClip, concaten
11
  import moviepy.video.fx.all as vfx
12
  from faster_whisper import WhisperModel
13
 
14
- # --- KONFIGURASI ---
15
  PURU_API_URL = "https://puruboy-api.vercel.app/api/ai/puruai"
16
  app = Flask(__name__)
17
  app.config['UPLOAD_FOLDER'] = 'downloads'
18
- app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024
19
  os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
20
 
21
- # Database & Queue
22
  JOBS = {}
23
- QUEUE = deque()
24
- MAX_QUEUE_SIZE = 5
25
 
26
- # Model Subtitle (Whisper Base)
27
  whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
28
 
29
  # ==========================================
30
- # UI HTML
31
  # ==========================================
32
  HTML_TEMPLATE = """
33
  <!DOCTYPE html>
@@ -35,56 +33,59 @@ HTML_TEMPLATE = """
35
  <head>
36
  <meta charset="UTF-8">
37
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
38
- <title>AI Video Clipper (Free API)</title>
39
  <script src="https://cdn.tailwindcss.com"></script>
40
  </head>
41
- <body class="bg-slate-950 text-slate-200 min-h-screen flex flex-col items-center p-6 font-sans">
42
- <div class="w-full max-w-2xl bg-slate-900 p-8 rounded-3xl border border-slate-800 shadow-2xl">
43
- <h1 class="text-3xl font-black text-center mb-1 bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent italic">AI VIRAL CLIPPER</h1>
44
- <p class="text-center text-slate-500 text-sm mb-8 font-medium">Powered by PuruBoy AI & Faster Whisper</p>
45
-
46
- <form id="clipForm" class="space-y-6">
47
- <input type="file" id="videoFile" accept="video/mp4" class="w-full text-sm text-slate-400 file:mr-4 file:py-3 file:px-6 file:rounded-xl file:border-0 file:bg-purple-600 file:text-white hover:file:bg-purple-500 transition-all cursor-pointer">
48
- <button type="submit" class="w-full bg-purple-600 hover:bg-purple-500 text-white font-black py-4 rounded-xl transition-all transform active:scale-95">PROSES VIDEO ✨</button>
 
 
 
 
49
  </form>
50
 
51
- <div id="statusArea" class="mt-10 hidden">
52
- <div class="flex justify-between mb-2">
53
- <span id="statusText" class="text-sm font-bold text-purple-400">Menunggu...</span>
54
- <span id="percentText" class="text-sm font-black text-white">0%</span>
55
  </div>
56
- <div class="w-full bg-slate-800 rounded-full h-3">
57
- <div id="progressBar" class="bg-purple-500 h-3 rounded-full transition-all duration-500" style="width: 0%"></div>
58
  </div>
59
  </div>
60
 
61
- <div id="resultArea" class="mt-10 hidden text-center">
62
- <video id="player" controls class="w-full rounded-2xl border border-slate-700 mb-6"></video>
63
- <div class="flex gap-3">
64
- <a id="downloadBtn" href="#" class="flex-1 bg-white text-black font-black py-3 rounded-xl">DOWNLOAD</a>
65
- <button onclick="location.reload()" class="px-6 bg-slate-800 text-white rounded-xl">ULANG</button>
66
- </div>
67
  </div>
68
  </div>
69
 
70
  <script>
71
- const form = document.getElementById('clipForm');
72
- form.addEventListener('submit', async (e) => {
 
 
73
  e.preventDefault();
74
- const file = document.getElementById('videoFile').files[0];
75
  if (!file) return alert("Pilih video!");
76
 
77
  const formData = new FormData();
78
  formData.append('video_file', file);
79
 
80
  document.getElementById('statusArea').classList.remove('hidden');
81
- form.classList.add('hidden');
82
 
83
  const res = await fetch('/generate', { method: 'POST', body: formData });
84
  const data = await res.json();
85
  if (data.job_id) pollStatus(data.job_id);
86
- else alert(data.error);
87
- });
88
 
89
  async function pollStatus(jobId) {
90
  const interval = setInterval(async () => {
@@ -102,7 +103,7 @@ HTML_TEMPLATE = """
102
  document.getElementById('resultArea').classList.remove('hidden');
103
  } else if (data.status === 'error') {
104
  clearInterval(interval);
105
- alert("Error: " + data.message);
106
  }
107
  }, 3000);
108
  }
@@ -112,108 +113,106 @@ HTML_TEMPLATE = """
112
  """
113
 
114
  # ==========================================
115
- # CORE PROCESSOR
116
  # ==========================================
117
 
118
- def process_video_job(job_id):
119
- video_path = JOBS[job_id]['input_path']
120
  try:
121
- # 1. Transkripsi Whisper (Cari Teks & Timestamp)
122
- JOBS[job_id].update({"message": "Menganalisis audio (Whisper)...", "progress": 20})
123
- segments, _ = whisper_model.transcribe(video_path, word_timestamps=True)
124
 
125
- full_transcript = []
126
  all_words = []
 
127
  for s in segments:
128
- full_transcript.append(f"[{s.start:.2f}s - {s.end:.2f}s]: {s.text}")
129
- for w in s.words:
130
- all_words.append(w)
131
 
132
- transcript_text = "\n".join(full_transcript)
133
 
134
- # 2. Kirim ke PuruBoy AI untuk Seleksi Bagian Viral
135
- JOBS[job_id].update({"message": "AI memilih bagian terbaik...", "progress": 45})
136
-
137
- prompt_ai = f"""
138
- Identifikasi bagian paling menarik dari transkrip video ini untuk dijadikan clip pendek (max 60 detik).
139
- Berikan output HANYA dalam format JSON array seperti ini:
140
- [{"start_s": 10.5, "end_s": 25.0, "reason": "lucu"}]
141
-
142
- TOTAL DURASI SEMUA CLIP TIDAK BOLEH LEBIH DARI 60 DETIK.
143
 
 
 
 
 
 
 
 
 
144
  TRANSKRIP:
145
- {transcript_text}
146
  """
147
 
148
  payload = {
149
  "userid": job_id,
150
- "prompt": prompt_ai,
151
  "model": "puruboy-flash",
152
- "system": "Kamu adalah editor video viral. Kamu hanya merespon dengan format JSON array berisi timestamp start_s dan end_s."
153
  }
154
 
155
- response = requests.post(PURU_API_URL, json=payload).json()
156
- ai_response_text = response['result'][0]['parts'][0]['text']
157
-
158
- # Ekstrak JSON dari teks AI
159
- json_match = re.search(r'\[.*\]', ai_response_text, re.DOTALL)
160
- if not json_match:
161
- raise Exception("AI tidak memberikan format timestamp yang benar")
162
 
 
 
 
163
  selected_clips = json.loads(json_match.group(0))
164
 
165
- # 3. Editing dengan MoviePy
166
- JOBS[job_id].update({"message": "Memotong & Menambah Subtitle...", "progress": 70})
167
 
168
- video = VideoFileClip(video_path)
169
- final_clips = []
170
 
171
- # Path font (sesuaikan dengan OS, atau biarkan default)
172
- font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
173
- if not os.path.exists(font_path): font_path = "Arial"
174
 
175
- for clip_info in selected_clips:
176
- start = clip_info['start_s']
177
- end = min(clip_info['end_s'], start + 59) # Safety limit per clip
178
 
179
- subclip = video.subclip(start, end).fx(vfx.fadein, 0.2).fx(vfx.fadeout, 0.2)
 
180
 
181
- # Subtitle Generator
182
- txt_elements = []
183
  for wd in all_words:
184
- if wd.start >= start and wd.end <= end:
 
185
  t = TextClip(
186
- wd.word.strip().upper(), font=font_path,
187
- fontsize=video.h * 0.07, color='yellow',
188
- method='label', stroke_color='black', stroke_width=2
189
- ).set_start(wd.start - start).set_end(wd.end - start).set_position(('center', video.h * 0.8))
190
- txt_elements.append(t)
 
 
 
191
 
192
- combined = CompositeVideoClip([subclip] + txt_elements)
193
- final_clips.append(combined)
194
-
195
- if not final_clips:
196
- raise Exception("Tidak ada clip yang dihasilkan")
197
 
198
- # Gabungkan semua clip (Total tetap dibatasi 60s oleh prompt AI)
199
- final_video = concatenate_videoclips(final_clips, method="compose")
200
- if final_video.duration > 60:
201
- final_video = final_video.subclip(0, 60)
 
202
 
203
- output_name = f"result_{job_id}.mp4"
204
- output_path = os.path.join(app.config['UPLOAD_FOLDER'], output_name)
205
 
206
- final_video.write_videofile(output_path, fps=24, codec="libx264", audio_codec="aac", preset="ultrafast", logger=None)
207
 
208
- # Cleanup
209
- video.close()
210
- final_video.close()
211
- os.remove(video_path)
212
 
213
- JOBS[job_id].update({"status": "completed", "progress": 100, "message": "Selesai!", "result": output_name})
214
 
215
  except Exception as e:
216
- print(f"Error: {e}")
217
  JOBS[job_id].update({"status": "error", "message": str(e)})
218
 
219
  # ==========================================
@@ -226,24 +225,21 @@ def index():
226
 
227
  @app.route('/generate', methods=['POST'])
228
  def generate():
229
- if 'video_file' not in request.files:
230
- return jsonify({"error": "No file"}), 400
231
 
232
- file = request.files['video_file']
233
  job_id = str(uuid.uuid4())
234
- path = os.path.join(app.config['UPLOAD_FOLDER'], f"{job_id}_in.mp4")
235
- file.save(path)
236
 
237
- JOBS[job_id] = {"status": "processing", "progress": 5, "message": "Memulai...", "input_path": path}
238
-
239
- # Jalankan background task
240
- threading.Thread(target=process_video_job, args=(job_id,)).start()
241
 
 
242
  return jsonify({"job_id": job_id})
243
 
244
  @app.route('/status/<job_id>')
245
  def status(job_id):
246
- return jsonify(JOBS.get(job_id, {"status": "error", "message": "Job not found"}))
247
 
248
  @app.route('/view/<filename>')
249
  def view_video(filename):
@@ -254,4 +250,4 @@ def download(filename):
254
  return send_file(os.path.join(app.config['UPLOAD_FOLDER'], filename), as_attachment=True)
255
 
256
  if __name__ == '__main__':
257
- app.run(host='0.0.0.0', port=7860)
 
11
  import moviepy.video.fx.all as vfx
12
  from faster_whisper import WhisperModel
13
 
14
+ # --- CONFIG ---
15
  PURU_API_URL = "https://puruboy-api.vercel.app/api/ai/puruai"
16
  app = Flask(__name__)
17
  app.config['UPLOAD_FOLDER'] = 'downloads'
18
+ app.config['MAX_CONTENT_LENGTH'] = 200 * 1024 * 1024
19
  os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
20
 
21
+ # Database Sederhana
22
  JOBS = {}
 
 
23
 
24
+ # Load Model Whisper (Gunakan CPU secara default)
25
  whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
26
 
27
  # ==========================================
28
+ # UI HTML (Dark Mode Aesthetic)
29
  # ==========================================
30
  HTML_TEMPLATE = """
31
  <!DOCTYPE html>
 
33
  <head>
34
  <meta charset="UTF-8">
35
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
36
+ <title>AI Viral Clipper</title>
37
  <script src="https://cdn.tailwindcss.com"></script>
38
  </head>
39
+ <body class="bg-black text-slate-200 min-h-screen flex flex-col items-center p-6">
40
+ <div class="w-full max-w-xl bg-slate-900 p-8 rounded-3xl border border-slate-800 shadow-2xl">
41
+ <h1 class="text-2xl font-black text-center mb-6 bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">AI VIDEO CLIPPER</h1>
42
+
43
+ <form id="clipForm" class="space-y-4">
44
+ <div class="border-2 border-dashed border-slate-700 rounded-2xl p-6 text-center hover:border-blue-500 transition-colors">
45
+ <input type="file" id="videoFile" accept="video/mp4" class="hidden">
46
+ <label for="videoFile" class="cursor-pointer block">
47
+ <span class="text-sm text-slate-400" id="fileName">Klik untuk upload MP4 (Maks 6 Menit)</span>
48
+ </label>
49
+ </div>
50
+ <button type="submit" class="w-full bg-blue-600 hover:bg-blue-500 text-white font-bold py-4 rounded-xl transition-all shadow-lg">BUAT VIDEO VIRAL ✨</button>
51
  </form>
52
 
53
+ <div id="statusArea" class="mt-8 hidden">
54
+ <div class="flex justify-between mb-2 italic">
55
+ <span id="statusText" class="text-xs text-blue-400">Sedang memproses...</span>
56
+ <span id="percentText" class="text-xs text-white">0%</span>
57
  </div>
58
+ <div class="w-full bg-slate-800 rounded-full h-2">
59
+ <div id="progressBar" class="bg-blue-500 h-2 rounded-full transition-all duration-500" style="width: 0%"></div>
60
  </div>
61
  </div>
62
 
63
+ <div id="resultArea" class="mt-8 hidden">
64
+ <video id="player" controls class="w-full rounded-xl border border-slate-700 mb-4"></video>
65
+ <a id="downloadBtn" href="#" class="block w-full text-center bg-white text-black font-bold py-3 rounded-xl">DOWNLOAD CLIP</a>
 
 
 
66
  </div>
67
  </div>
68
 
69
  <script>
70
+ const fileInput = document.getElementById('videoFile');
71
+ fileInput.onchange = () => { document.getElementById('fileName').innerText = fileInput.files[0].name; };
72
+
73
+ document.getElementById('clipForm').onsubmit = async (e) => {
74
  e.preventDefault();
75
+ const file = fileInput.files[0];
76
  if (!file) return alert("Pilih video!");
77
 
78
  const formData = new FormData();
79
  formData.append('video_file', file);
80
 
81
  document.getElementById('statusArea').classList.remove('hidden');
82
+ document.getElementById('clipForm').classList.add('hidden');
83
 
84
  const res = await fetch('/generate', { method: 'POST', body: formData });
85
  const data = await res.json();
86
  if (data.job_id) pollStatus(data.job_id);
87
+ else alert("Error: " + data.error);
88
+ };
89
 
90
  async function pollStatus(jobId) {
91
  const interval = setInterval(async () => {
 
103
  document.getElementById('resultArea').classList.remove('hidden');
104
  } else if (data.status === 'error') {
105
  clearInterval(interval);
106
+ alert("Gagal: " + data.message);
107
  }
108
  }, 3000);
109
  }
 
113
  """
114
 
115
  # ==========================================
116
+ # LOGIC PEMROSESAN
117
  # ==========================================
118
 
119
+ def process_video(job_id):
120
+ path_in = JOBS[job_id]['input_path']
121
  try:
122
+ # 1. WHISPER TRANSCRIPTION
123
+ JOBS[job_id].update({"message": "Menyalin teks dari video...", "progress": 20})
124
+ segments, _ = whisper_model.transcribe(path_in, word_timestamps=True)
125
 
 
126
  all_words = []
127
+ transcript_for_ai = []
128
  for s in segments:
129
+ transcript_for_ai.append(f"[{s.start:.1f}s - {s.end:.1f}s]: {s.text}")
130
+ for w in s.words: all_words.append(w)
 
131
 
132
+ transcript_str = "\n".join(transcript_for_ai)
133
 
134
+ # 2. AI SELECTION (FIXED F-STRING ERROR)
135
+ JOBS[job_id].update({"message": "AI sedang memilih bagian viral...", "progress": 45})
 
 
 
 
 
 
 
136
 
137
+ # Perbaikan: Menggunakan {{ }} agar Python f-string tidak error membaca kurung kurawal JSON
138
+ prompt = f"""
139
+ Tugas: Pilih bagian paling menarik dari transkrip video ini untuk dijadikan clip pendek.
140
+ Aturan:
141
+ 1. Total durasi semua clip harus di bawah 60 detik.
142
+ 2. Format output WAJIB JSON array seperti contoh: [{{"start_s": 1.5, "end_s": 15.0, "reason": "highlight"}}]
143
+ 3. Jangan ada teks penjelasan lain, hanya JSON.
144
+
145
  TRANSKRIP:
146
+ {transcript_str}
147
  """
148
 
149
  payload = {
150
  "userid": job_id,
151
+ "prompt": prompt,
152
  "model": "puruboy-flash",
153
+ "system": "Kamu adalah asisten video editor profesional. Kamu hanya memberikan output dalam format JSON array."
154
  }
155
 
156
+ resp = requests.post(PURU_API_URL, json=payload).json()
157
+ ai_text = resp['result'][0]['parts'][0]['text']
 
 
 
 
 
158
 
159
+ # Ekstrak JSON
160
+ json_match = re.search(r'\[.*\]', ai_text, re.DOTALL)
161
+ if not json_match: raise Exception("AI tidak memberikan data timestamp yang valid.")
162
  selected_clips = json.loads(json_match.group(0))
163
 
164
+ # 3. EDITING VIDEO
165
+ JOBS[job_id].update({"message": "Proses rendering & subtitle...", "progress": 70})
166
 
167
+ full_video = VideoFileClip(path_in)
168
+ final_segments = []
169
 
170
+ # Font settings
171
+ font_name = "Arial-Bold" # Atau path ke .ttf jika di Linux server
 
172
 
173
+ for clip_data in selected_clips:
174
+ s_time = clip_data['start_s']
175
+ e_time = min(clip_data['end_s'], s_time + 59)
176
 
177
+ # Potong clip
178
+ sub = full_video.subclip(s_time, e_time).fx(vfx.fadein, 0.2).fx(vfx.fadeout, 0.2)
179
 
180
+ # Buat subtitle per kata (Auto Highlight)
181
+ txt_clips = []
182
  for wd in all_words:
183
+ if wd.start >= s_time and wd.end <= e_time:
184
+ # Render teks per kata
185
  t = TextClip(
186
+ wd.word.strip().upper(),
187
+ fontsize=full_video.h * 0.08,
188
+ color='yellow',
189
+ stroke_color='black',
190
+ stroke_width=2,
191
+ method='label'
192
+ ).set_start(wd.start - s_time).set_end(wd.end - s_time).set_position(('center', full_video.h * 0.8))
193
+ txt_clips.append(t)
194
 
195
+ final_segments.append(CompositeVideoClip([sub] + txt_clips))
 
 
 
 
196
 
197
+ # Gabungkan semua
198
+ if not final_segments: raise Exception("AI tidak memilih bagian apapun.")
199
+
200
+ merged = concatenate_videoclips(final_segments, method="compose")
201
+ if merged.duration > 60: merged = merged.subclip(0, 60) # Limit keras 60 detik
202
 
203
+ out_filename = f"out_{job_id}.mp4"
204
+ out_path = os.path.join(app.config['UPLOAD_FOLDER'], out_filename)
205
 
206
+ merged.write_videofile(out_path, fps=24, codec="libx264", audio_codec="aac", preset="ultrafast", logger=None)
207
 
208
+ # Tutup file agar tidak memori leak
209
+ full_video.close()
210
+ merged.close()
 
211
 
212
+ JOBS[job_id].update({"status": "completed", "progress": 100, "message": "Selesai!", "result": out_filename})
213
 
214
  except Exception as e:
215
+ print(f"ERROR: {str(e)}")
216
  JOBS[job_id].update({"status": "error", "message": str(e)})
217
 
218
  # ==========================================
 
225
 
226
  @app.route('/generate', methods=['POST'])
227
  def generate():
228
+ file = request.files.get('video_file')
229
+ if not file: return jsonify({"error": "File kosong"}), 400
230
 
 
231
  job_id = str(uuid.uuid4())
232
+ save_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{job_id}_in.mp4")
233
+ file.save(save_path)
234
 
235
+ JOBS[job_id] = {"status": "processing", "progress": 5, "message": "Video diterima...", "input_path": save_path}
 
 
 
236
 
237
+ threading.Thread(target=process_video, args=(job_id,)).start()
238
  return jsonify({"job_id": job_id})
239
 
240
  @app.route('/status/<job_id>')
241
  def status(job_id):
242
+ return jsonify(JOBS.get(job_id, {"status": "error", "message": "ID tidak ditemukan"}))
243
 
244
  @app.route('/view/<filename>')
245
  def view_video(filename):
 
250
  return send_file(os.path.join(app.config['UPLOAD_FOLDER'], filename), as_attachment=True)
251
 
252
  if __name__ == '__main__':
253
+ app.run(host='0.0.0.0', port=7860, debug=False)