Ricky01anjay commited on
Commit
14b0ed7
·
verified ·
1 Parent(s): bc15886

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +358 -0
app.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import threading
4
+ import asyncio
5
+ import requests
6
+ import json
7
+ import time
8
+ from flask import Flask, request, jsonify, render_template_string, send_from_directory
9
+ import moviepy.editor as mp
10
+ import whisper
11
+ import edge_tts
12
+
13
+ app = Flask(__name__)
14
+ app.config['UPLOAD_FOLDER'] = 'uploads'
15
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
16
+
17
+ tasks = {}
18
+
19
+ # Konfigurasi Suara Edge-TTS
20
+ VOICE_MAP = {
21
+ 'id-ID': 'id-ID-ArdiNeural',
22
+ 'en-US': 'en-US-ChristopherNeural',
23
+ 'ja-JP': 'ja-JP-KeitaNeural'
24
+ }
25
+
26
+ print("Loading Whisper Model...")
27
+ whisper_model = whisper.load_model("base")
28
+ print("Whisper Model Loaded!")
29
+
30
+ # ==========================================
31
+ # FUNGSI TRANSLATOR LLM (DENGAN BACKOFF)
32
+ # ==========================================
33
+ def translate_with_llm(text, custom_prompt, max_retries=3):
34
+ # Gabungkan instruksi user dengan teks asli
35
+ instruction = custom_prompt if custom_prompt else "Terjemahkan teks berikut. Hanya berikan hasil terjemahannya saja tanpa penjelasan tambahan, tanpa tanda kutip."
36
+ full_prompt = f"{instruction}\n\nTeks asli:\n{text}"
37
+
38
+ url = "https://www.puruboy.kozow.com/api/ai/notegpt"
39
+ payload = {
40
+ "prompt": full_prompt,
41
+ "model": "gemini-3-flash-preview",
42
+ "chat_mode": "standard"
43
+ }
44
+ headers = {"Content-Type": "application/json"}
45
+
46
+ for attempt in range(max_retries):
47
+ try:
48
+ # Menggunakan stream=True karena response berupa SSE
49
+ response = requests.post(url, json=payload, headers=headers, stream=True)
50
+ response.raise_for_status()
51
+
52
+ translated_text = ""
53
+ for line in response.iter_lines():
54
+ if line:
55
+ decoded_line = line.decode('utf-8')
56
+ if decoded_line.startswith("data: "):
57
+ json_str = decoded_line[len("data: "):]
58
+ try:
59
+ data = json.loads(json_str)
60
+ # Ambil teks jika ada
61
+ if "text" in data:
62
+ translated_text += data["text"]
63
+ except json.JSONDecodeError:
64
+ continue # Abaikan jika bukan JSON valid (misal: finish)
65
+
66
+ translated_text = translated_text.strip()
67
+
68
+ # Jika respon tidak kosong, kembalikan hasilnya
69
+ if translated_text:
70
+ return translated_text
71
+
72
+ print(f"Respon AI kosong. Mencoba lagi... (Percobaan ke-{attempt+1})")
73
+
74
+ except Exception as e:
75
+ print(f"Error koneksi ke LLM: {str(e)}")
76
+
77
+ # Exponential backoff: tunggu 1s, 2s, 4s...
78
+ time.sleep(2 ** attempt)
79
+
80
+ raise Exception("Gagal mendapatkan terjemahan dari AI setelah beberapa kali percobaan (Respon selalu kosong).")
81
+
82
+
83
+ # ==========================================
84
+ # FUNGSI PEMROSESAN UTAMA (BACKGROUND)
85
+ # ==========================================
86
+ def process_dubbing(task_id, video_path, target_voice, custom_prompt):
87
+ try:
88
+ tasks[task_id]['status'] = 'Mengekstrak audio dari video...'
89
+
90
+ # 1. Pengecekan Durasi & Ekstrak Audio
91
+ video = mp.VideoFileClip(video_path)
92
+ if video.duration > 120.0:
93
+ video.close()
94
+ os.remove(video_path)
95
+ raise Exception("Durasi video melebihi batas maksimal 2 menit (120 detik).")
96
+
97
+ audio_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{task_id}.wav")
98
+ if video.audio is None:
99
+ raise Exception("Video tidak memiliki suara.")
100
+ video.audio.write_audiofile(audio_path, logger=None)
101
+
102
+ # 2. Transkripsi Audio ke Teks (Speech-to-Text)
103
+ tasks[task_id]['status'] = 'Mentranskripsi suara asli...'
104
+ result = whisper_model.transcribe(audio_path)
105
+ original_text = result['text']
106
+
107
+ if not original_text.strip():
108
+ raise Exception("Tidak ada kata-kata yang terdeteksi di dalam video.")
109
+
110
+ # 3. Terjemahkan Teks via Custom LLM
111
+ tasks[task_id]['status'] = 'Menerjemahkan teks (Menggunakan AI)...'
112
+ translated_text = translate_with_llm(original_text, custom_prompt)
113
+
114
+ # 4. Text-to-Speech (AI Voice generation)
115
+ tasks[task_id]['status'] = 'Membuat Suara AI (Dubbing)...'
116
+ ai_audio_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{task_id}_ai.mp3")
117
+ voice = VOICE_MAP.get(target_voice, 'id-ID-ArdiNeural')
118
+
119
+ async def generate_tts():
120
+ communicate = edge_tts.Communicate(translated_text, voice)
121
+ await communicate.save(ai_audio_path)
122
+ asyncio.run(generate_tts())
123
+
124
+ # 5. Gabungkan Audio AI ke Video Asli
125
+ tasks[task_id]['status'] = 'Menggabungkan Video dan Suara AI...'
126
+ output_video_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{task_id}_output.mp4")
127
+
128
+ new_audio = mp.AudioFileClip(ai_audio_path)
129
+ final_video = video.set_audio(new_audio)
130
+ final_video.write_videofile(output_video_path, codec='libx264', audio_codec='aac', logger=None)
131
+
132
+ # Bersihkan memori dan file temp
133
+ video.close()
134
+ new_audio.close()
135
+ os.remove(audio_path)
136
+ os.remove(ai_audio_path)
137
+
138
+ tasks[task_id]['status'] = 'Selesai'
139
+ tasks[task_id]['result_video'] = f"/download/{task_id}_output.mp4"
140
+
141
+ except Exception as e:
142
+ tasks[task_id]['status'] = 'Error'
143
+ tasks[task_id]['error_message'] = str(e)
144
+
145
+
146
+ # ==========================================
147
+ # FRONTEND HTML (Bootstrap 5)
148
+ # ==========================================
149
+ HTML_TEMPLATE = """
150
+ <!DOCTYPE html>
151
+ <html lang="id">
152
+ <head>
153
+ <meta charset="UTF-8">
154
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
155
+ <title>AI Video Dubbing</title>
156
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
157
+ <style>
158
+ body { background-color: #f8f9fa; }
159
+ .card { border-radius: 15px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
160
+ </style>
161
+ </head>
162
+ <body>
163
+ <div class="container mt-4 mb-5">
164
+ <div class="row justify-content-center">
165
+ <div class="col-md-8 col-lg-6">
166
+ <div class="card p-4">
167
+ <h3 class="text-center mb-3">🎙️ AI Video Dubbing</h3>
168
+ <div class="alert alert-info text-center small">Maximal durasi video: 2 Menit</div>
169
+
170
+ <form id="uploadForm">
171
+ <div class="mb-3">
172
+ <label class="form-label fw-bold">Unggah Video (.mp4)</label>
173
+ <input class="form-control" type="file" id="videoFile" accept="video/mp4,video/x-m4v,video/*" required>
174
+ </div>
175
+
176
+ <div class="mb-3">
177
+ <label class="form-label fw-bold">Karakter Suara AI (Output)</label>
178
+ <select class="form-select" id="targetVoice" required>
179
+ <option value="id-ID">Pria Indonesia (id-ID)</option>
180
+ <option value="en-US">Pria Inggris (en-US)</option>
181
+ <option value="ja-JP">Pria Jepang (ja-JP)</option>
182
+ </select>
183
+ </div>
184
+
185
+ <div class="mb-3">
186
+ <label class="form-label fw-bold">Prompt Terjemahan (Opsional)</label>
187
+ <textarea class="form-control" id="customPrompt" rows="2" placeholder="Contoh: Terjemahkan ke bahasa Indonesia gaul dan santai"></textarea>
188
+ <div class="form-text">Biarkan kosong untuk terjemahan baku.</div>
189
+ </div>
190
+
191
+ <button type="submit" class="btn btn-primary w-100 fw-bold" id="submitBtn">Generate Dubbing</button>
192
+ </form>
193
+
194
+ <div id="statusArea" class="mt-4 d-none text-center">
195
+ <h5 id="statusText" class="text-primary fw-bold">Memproses...</h5>
196
+ <div class="spinner-border text-primary my-2" role="status" id="loadingSpinner"></div>
197
+ </div>
198
+
199
+ <div id="resultArea" class="mt-4 d-none">
200
+ <h5 class="text-success text-center fw-bold">Berhasil!</h5>
201
+ <video id="resultVideo" controls class="w-100 rounded mt-2 border"></video>
202
+ <a id="downloadBtn" href="#" class="btn btn-success w-100 mt-3 fw-bold" download>⬇️ Unduh Video</a>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ </div>
208
+
209
+ <script>
210
+ document.getElementById('uploadForm').addEventListener('submit', async (e) => {
211
+ e.preventDefault();
212
+
213
+ const fileInput = document.getElementById('videoFile');
214
+ const voiceInput = document.getElementById('targetVoice');
215
+ const promptInput = document.getElementById('customPrompt');
216
+ const submitBtn = document.getElementById('submitBtn');
217
+ const statusArea = document.getElementById('statusArea');
218
+ const resultArea = document.getElementById('resultArea');
219
+ const statusText = document.getElementById('statusText');
220
+ const loadingSpinner = document.getElementById('loadingSpinner');
221
+
222
+ if (fileInput.files.length === 0) return;
223
+
224
+ const file = fileInput.files[0];
225
+
226
+ // Validasi Durasi di Frontend (Max 120 Detik)
227
+ const videoElement = document.createElement('video');
228
+ videoElement.preload = 'metadata';
229
+ videoElement.onloadedmetadata = async function() {
230
+ window.URL.revokeObjectURL(videoElement.src);
231
+ if (videoElement.duration > 120) {
232
+ alert('Error: Durasi video tidak boleh lebih dari 2 menit.');
233
+ return;
234
+ }
235
+
236
+ // Lanjut Upload jika durasi aman
237
+ mulaiProsesUpload();
238
+ }
239
+ videoElement.src = URL.createObjectURL(file);
240
+
241
+ async function mulaiProsesUpload() {
242
+ const formData = new FormData();
243
+ formData.append('video', file);
244
+ formData.append('voice', voiceInput.value);
245
+ formData.append('prompt', promptInput.value);
246
+
247
+ submitBtn.disabled = true;
248
+ statusArea.classList.remove('d-none');
249
+ resultArea.classList.add('d-none');
250
+ loadingSpinner.classList.remove('d-none');
251
+ statusText.innerText = 'Mengunggah video...';
252
+ statusText.className = 'text-primary fw-bold';
253
+
254
+ try {
255
+ const response = await fetch('/generate', { method: 'POST', body: formData });
256
+ const data = await response.json();
257
+
258
+ if (response.ok && data.task_id) {
259
+ pollStatus(data.task_id);
260
+ } else {
261
+ alert(data.error || 'Gagal memulai proses.');
262
+ resetUI();
263
+ }
264
+ } catch (err) {
265
+ alert('Terjadi kesalahan jaringan saat mengunggah.');
266
+ resetUI();
267
+ }
268
+ }
269
+
270
+ function resetUI() {
271
+ submitBtn.disabled = false;
272
+ statusArea.classList.add('d-none');
273
+ }
274
+ });
275
+
276
+ async function pollStatus(taskId) {
277
+ const statusText = document.getElementById('statusText');
278
+ const interval = setInterval(async () => {
279
+ try {
280
+ const res = await fetch(`/status?task_id=${taskId}`);
281
+ const data = await res.json();
282
+
283
+ statusText.innerText = data.status;
284
+
285
+ if (data.status === 'Selesai') {
286
+ clearInterval(interval);
287
+ document.getElementById('loadingSpinner').classList.add('d-none');
288
+ document.getElementById('resultArea').classList.remove('d-none');
289
+ document.getElementById('resultVideo').src = data.result_video;
290
+ document.getElementById('downloadBtn').href = data.result_video;
291
+ document.getElementById('submitBtn').disabled = false;
292
+ } else if (data.status === 'Error') {
293
+ clearInterval(interval);
294
+ statusText.innerText = 'Error: ' + data.error_message;
295
+ statusText.className = 'text-danger fw-bold';
296
+ document.getElementById('loadingSpinner').classList.add('d-none');
297
+ document.getElementById('submitBtn').disabled = false;
298
+ }
299
+ } catch (e) {
300
+ console.error("Polling error", e);
301
+ }
302
+ }, 2000); // Cek status tiap 2 detik
303
+ }
304
+ </script>
305
+ </body>
306
+ </html>
307
+ """
308
+
309
+ # ==========================================
310
+ # ROUTING FLASK
311
+ # ==========================================
312
+
313
+ @app.route('/')
314
+ def index():
315
+ return render_template_string(HTML_TEMPLATE)
316
+
317
+ @app.route('/generate', methods=['POST'])
318
+ def generate():
319
+ if 'video' not in request.files:
320
+ return jsonify({'error': 'Tidak ada video yang diunggah'}), 400
321
+
322
+ file = request.files['video']
323
+ target_voice = request.form.get('voice', 'id-ID')
324
+ custom_prompt = request.form.get('prompt', '').strip()
325
+
326
+ if file.filename == '':
327
+ return jsonify({'error': 'File kosong'}), 400
328
+
329
+ task_id = str(uuid.uuid4())
330
+ video_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{task_id}.mp4")
331
+ file.save(video_path)
332
+
333
+ tasks[task_id] = {
334
+ 'status': 'Menunggu antrean...',
335
+ 'result_video': None,
336
+ 'error_message': None
337
+ }
338
+
339
+ # Jalankan proses background
340
+ thread = threading.Thread(target=process_dubbing, args=(task_id, video_path, target_voice, custom_prompt))
341
+ thread.start()
342
+
343
+ return jsonify({'task_id': task_id}), 200
344
+
345
+ @app.route('/status', methods=['GET'])
346
+ def status():
347
+ task_id = request.args.get('task_id')
348
+ if task_id in tasks:
349
+ return jsonify(tasks[task_id])
350
+ return jsonify({'status': 'Error', 'error_message': 'Task ID tidak ditemukan'}), 404
351
+
352
+ @app.route('/download/<filename>', methods=['GET'])
353
+ def download(filename):
354
+ return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
355
+
356
+ if __name__ == '__main__':
357
+ # Jalankan di Port 7860
358
+ app.run(host='0.0.0.0', port=7860, debug=False)