File size: 16,469 Bytes
d26d714
 
 
 
 
 
3915846
d161b1b
 
d279272
3915846
d26d714
 
d279272
d161b1b
d26d714
 
d161b1b
d26d714
 
d279272
 
 
84b0872
d26d714
84b0872
7aed1b3
d279272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84b0872
d26d714
 
d279272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d161b1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d279272
d161b1b
 
 
 
 
 
 
 
 
 
 
 
 
d279272
d161b1b
 
 
 
d279272
d161b1b
d279272
 
 
 
 
 
 
 
 
 
d161b1b
 
 
 
 
 
 
 
 
 
 
 
 
d279272
d161b1b
 
 
 
 
 
 
 
 
 
d279272
d161b1b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d279272
d161b1b
 
d279272
d161b1b
 
 
d279272
d161b1b
 
 
 
 
 
 
 
d279272
 
 
d161b1b
 
d279272
d26d714
 
 
 
 
 
 
d279272
d26d714
 
ffe2671
 
d161b1b
ffe2671
 
 
d161b1b
ffe2671
d161b1b
ffe2671
 
 
d26d714
 
ffe2671
 
84b0872
ffe2671
181a914
ffe2671
 
d26d714
 
 
84b0872
ffe2671
84b0872
d279272
d26d714
 
 
 
ffe2671
 
 
 
d26d714
ffe2671
7aed1b3
d161b1b
d26d714
7aed1b3
 
d26d714
 
ffe2671
84b0872
d26d714
7aed1b3
 
84b0872
 
 
 
 
 
 
 
 
 
ffe2671
d26d714
 
 
 
84b0872
 
 
 
 
 
 
d26d714
84b0872
d26d714
 
181a914
d26d714
 
 
 
3915846
d26d714
181a914
d26d714
 
ffe2671
84b0872
d26d714
 
 
 
 
 
 
 
 
ea89406
d26d714
 
 
 
 
 
 
84b0872
d279272
84b0872
ffe2671
 
7aed1b3
d26d714
ffe2671
 
d26d714
84b0872
 
 
d279272
84b0872
 
b208f0f
84b0872
d26d714
 
 
 
84b0872
d161b1b
84b0872
d26d714
181a914
 
84b0872
 
d161b1b
84b0872
181a914
d26d714
 
84b0872
 
d161b1b
84b0872
d26d714
 
d279272
 
 
 
ffe2671
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
import os
import uuid
import threading
import requests
import re
import time
import json
import queue
import subprocess
import multiprocessing
from flask import Flask, request, jsonify, render_template_string, send_file
from faster_whisper import WhisperModel

# --- CONFIG SERVER & ANTRIAN ---
AI_API_URL = "https://puruboy-api.vercel.app/api/ai/notegpt"
app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = 'downloads'
app.config['MAX_CONTENT_LENGTH'] = 200 * 1024 * 1024  # Max 200 MB
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)

MAX_QUEUE_SIZE = 20           # Maksimal antrian
MAX_WORKERS = 5               # Maksimal proses berjalan bersamaan
AUTO_DELETE_MINUTES = 1440    # Waktu file dihapus (1440 menit = 1 hari)

JOBS = {}
job_queue = queue.Queue(maxsize=MAX_QUEUE_SIZE)

# --- GLOBAL RATE LIMIT CONFIG ---
request_timestamps = []
global_lockout_until = 0

# --- OPTIMASI CPU WHISPER ---
# Mengatur thread CPU per model agar 5 worker tidak membuat CPU bertabrakan (bottleneck)
total_cores = multiprocessing.cpu_count()
threads_per_worker = max(1, total_cores // MAX_WORKERS)

print(f"Loading Whisper Model (CPU Cores/Worker: {threads_per_worker})...")
whisper_model = WhisperModel(
    "base", 
    device="cpu", 
    compute_type="int8", 
    cpu_threads=threads_per_worker
)
print("Model Loaded!")

# ==========================================
# GLOBAL RATE LIMITER (Mencegah Spam)
# ==========================================
@app.before_request
def rate_limiter():
    global request_timestamps, global_lockout_until
    
    # Hanya batasi endpoint pembuatan video
    if request.endpoint == 'generate' and request.method == 'POST':
        current_time = time.time()
        
        # Cek apakah sedang dalam masa hukuman 2 menit
        if current_time < global_lockout_until:
            return jsonify({"error": "demi keamanan kamu sengaja mematikan api ini selama 2 menit karena ada yang spam"}), 429

        # Bersihkan timestamp yang sudah lebih dari 60 detik (1 menit)
        request_timestamps = [t for t in request_timestamps if current_time - t < 60]
        
        # Jika request lebih dari 30 dalam 1 menit
        if len(request_timestamps) >= 30:
            global_lockout_until = current_time + 120  # Kunci selama 120 detik (2 menit)
            return jsonify({"error": "demi keamanan kamu sengaja mematikan api ini selama 2 menit karena ada yang spam"}), 429
            
        request_timestamps.append(current_time)

# ==========================================
# FUNGSI PARSING AI (Server-Sent Events)
# ==========================================
def get_ai_viral_clip(transcript_str):
    payload = {
        "prompt": f"""
Kamu adalah video editor TikTok/Reels profesional.
Tugas: Cari 1 bagian (segmen berkelanjutan) PALING MENARIK/LUCU/VIRAL dari transkrip ini.
Aturan:
1. Durasi segmen harus antara 15 detik sampai maksimal 60 detik.
2. Cari kalimat yang punya hook kuat di awal dan konklusi di akhir.
3. OUTPUT HANYA JSON. JANGAN ADA TEKS LAIN.
Format: {{"start_s": 10.5, "end_s": 45.2, "reason": "Alasan kenapa ini viral"}}

TRANSKRIP:
{transcript_str}
""",
        "model": "gemini-3-flash-preview",
        "chat_mode": "standard"
    }

    headers = {"Content-Type": "application/json"}
    response = requests.post(AI_API_URL, json=payload, headers=headers, stream=True)
    
    full_text = ""
    for line in response.iter_lines():
        if line:
            decoded_line = line.decode('utf-8')
            if decoded_line.startswith("data: "):
                data_str = decoded_line[len("data: "):]
                try:
                    data_json = json.loads(data_str)
                    if "text" in data_json:
                        full_text += data_json["text"]
                    if data_json.get("done") or data_json.get("type") == "finish":
                        break
                except json.JSONDecodeError:
                    continue

    json_match = re.search(r'\{.*\}', full_text, re.DOTALL)
    if not json_match:
        raise Exception("Gagal mengekstrak format JSON dari AI.")
    
    return json.loads(json_match.group(0))

# ==========================================
# FUNGSI HELPER VIDEO & SUBTITLE
# ==========================================
def format_time_srt(seconds):
    hrs = int(seconds // 3600)
    mins = int((seconds % 3600) // 60)
    secs = int(seconds % 60)
    msec = int((seconds - int(seconds)) * 1000)
    return f"{hrs:02d}:{mins:02d}:{secs:02d},{msec:03d}"

def generate_srt(words, start_offset, end_offset, srt_path):
    with open(srt_path, 'w', encoding='utf-8') as f:
        idx = 1
        for w in words:
            if w.start >= start_offset and w.end <= end_offset:
                s_time = format_time_srt(w.start - start_offset)
                e_time = format_time_srt(w.end - start_offset)
                text = w.word.strip().upper()
                f.write(f"{idx}\n{s_time} --> {e_time}\n{text}\n\n")
                idx += 1

def cleanup_files(*file_paths, job_id=None):
    for path in file_paths:
        if path and os.path.exists(path):
            try: os.remove(path)
            except: pass
    if job_id and job_id in JOBS:
        del JOBS[job_id] # Hapus dari memory agar RAM tidak bengkak
    print(f"[{job_id}] Auto-cleanup selesai.")

# ==========================================
# PROSES UTAMA (WORKER)
# ==========================================
def process_video(job_id):
    path_in = JOBS[job_id]['input_path']
    audio_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{job_id}.wav")
    srt_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{job_id}.srt")
    out_filename = f"out_{job_id}.mp4"
    out_path = os.path.join(app.config['UPLOAD_FOLDER'], out_filename)
    
    try:
        # 1. EKSTRAK AUDIO CEPAT
        JOBS[job_id].update({"message": "Mengekstrak audio video...", "progress": 10})
        subprocess.run(['ffmpeg', '-y', '-i', path_in, '-vn', '-acodec', 'pcm_s16le', '-ar', '16000', '-ac', '1', audio_path], 
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)

        # 2. TRANSCRIBE DENGAN OPTIMASI SUPER CEPAT (Untuk Video >20 Menit)
        JOBS[job_id].update({"message": "Menganalisa suara manusia (AI Whisper)...", "progress": 25})
        
        # beam_size=1 dan condition_on_previous_text=False membuat proses 3x - 5x lebih cepat
        segments, _ = whisper_model.transcribe(
            audio_path, 
            word_timestamps=True, 
            vad_filter=True, 
            vad_parameters=dict(min_silence_duration_ms=500),
            beam_size=1, 
            condition_on_previous_text=False
        )
        
        transcript_for_ai = []
        all_words = []
        
        for s in segments:
            transcript_for_ai.append(f"[{s.start:.1f} - {s.end:.1f}]: {s.text}")
            for w in s.words: 
                all_words.append(w)
                
        transcript_str = "\n".join(transcript_for_ai)
        if not transcript_str.strip():
            raise Exception("Tidak ada suara manusia yang terdeteksi.")

        # 3. AI MENCARI BAGIAN VIRAL
        JOBS[job_id].update({"message": "AI sedang meracik bagian viral...", "progress": 50})
        clip_data = get_ai_viral_clip(transcript_str)
        
        s_time = max(0, float(clip_data['start_s']) - 0.2)
        e_time = float(clip_data['end_s']) + 0.3
        
        # 4. BUAT SUBTITLE SRT
        JOBS[job_id].update({"message": "Membuat efek subtitle viral...", "progress": 70})
        generate_srt(all_words, s_time, e_time, srt_path)

        # 5. POTONG & BURN SUBTITLE
        JOBS[job_id].update({"message": "Rendering Video Final (Super Cepat)...", "progress": 85})
        
        safe_srt_path = srt_path.replace('\\', '/')
        style = "Fontname=Arial,Fontsize=24,PrimaryColour=&H0000FFFF,OutlineColour=&H00000000,BorderStyle=1,Outline=2,Shadow=0,Alignment=2,MarginV=25,Bold=1"
        
        ffmpeg_cmd = [
            'ffmpeg', '-y',
            '-ss', str(s_time),
            '-to', str(e_time),
            '-i', path_in,
            '-vf', f"subtitles={safe_srt_path}:force_style='{style}'",
            '-c:v', 'libx264',
            '-preset', 'superfast',
            '-crf', '23',
            '-c:a', 'aac',
            '-b:a', '128k',
            out_path
        ]
        
        subprocess.run(ffmpeg_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
        
        JOBS[job_id].update({"status": "completed", "progress": 100, "message": "Selesai! Video siap.", "result": out_filename})

    except Exception as e:
        print(f"ERROR: {str(e)}")
        if job_id in JOBS:
            JOBS[job_id].update({"status": "error", "message": str(e)})

    finally:
        # File sementara dihapus langsung
        cleanup_files(audio_path, srt_path)
        
        # File final dihapus setelah 1 Hari (1440 menit)
        timer = threading.Timer(AUTO_DELETE_MINUTES * 60, cleanup_files, args=(path_in, out_path), kwargs={'job_id': job_id})
        timer.start()

# --- MULTI-WORKER SYSTEM (Menjalankan 5 Proses Sekaligus) ---
def queue_worker():
    while True:
        job_id = job_queue.get()
        if job_id in JOBS:
            JOBS[job_id]["status"] = "processing"
            process_video(job_id)
        job_queue.task_done()

# Menjalankan 5 Thread Workers (Sehingga bisa render 5 video serentak)
for _ in range(MAX_WORKERS):
    threading.Thread(target=queue_worker, daemon=True).start()

# ==========================================
# UI HTML
# ==========================================
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="id">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AI Viral Clipper Ultra (Pro)</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-black text-slate-200 min-h-screen flex flex-col items-center p-6">
    <div class="w-full max-w-xl bg-slate-900 p-8 rounded-3xl border border-slate-800 shadow-2xl">
        <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 ULTRA</h1>
        
        <form id="clipForm" class="space-y-4">
            <div class="border-2 border-dashed border-slate-700 rounded-2xl p-6 text-center hover:border-blue-500 transition-colors">
                <input type="file" id="videoFile" accept="video/mp4,video/quicktime" class="hidden">
                <label for="videoFile" class="cursor-pointer block">
                    <span class="text-sm text-slate-400" id="fileName">Klik untuk upload Video (Maks 200MB)</span>
                </label>
            </div>
            <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>
        </form>

        <div id="statusArea" class="mt-8 hidden">
            <div class="flex justify-between mb-2 italic">
                <span id="statusText" class="text-xs text-blue-400">Menunggu giliran...</span>
                <span id="percentText" class="text-xs text-white">0%</span>
            </div>
            <div class="w-full bg-slate-800 rounded-full h-2">
                <div id="progressBar" class="bg-blue-500 h-2 rounded-full transition-all duration-500" style="width: 0%"></div>
            </div>
        </div>

        <div id="resultArea" class="mt-8 hidden text-center">
            <video id="player" controls class="w-full rounded-xl border border-slate-700 mb-4"></video>
            <a id="downloadBtn" href="#" class="block w-full text-center bg-white text-black font-bold py-3 rounded-xl hover:bg-gray-200 transition">DOWNLOAD CLIP</a>
            <p class="text-xs text-red-400 mt-3 font-semibold">⚠️ Video akan dihapus otomatis dari server dalam 24 Jam.</p>
        </div>
    </div>

    <script>
        const fileInput = document.getElementById('videoFile');
        fileInput.onchange = () => { document.getElementById('fileName').innerText = fileInput.files[0].name; };

        document.getElementById('clipForm').onsubmit = async (e) => {
            e.preventDefault();
            const file = fileInput.files[0];
            if (!file) return alert("Pilih video!");
            if (file.size > 200 * 1024 * 1024) return alert("File terlalu besar! Maksimal 200MB.");

            const formData = new FormData();
            formData.append('video_file', file);

            document.getElementById('statusArea').classList.remove('hidden');
            document.getElementById('clipForm').classList.add('hidden');
            document.getElementById('statusText').innerText = "Mengupload video...";

            const res = await fetch('/generate', { method: 'POST', body: formData });
            const data = await res.json();
            
            if (res.status === 429) {
                alert(data.error);
                location.reload();
            } else if (data.job_id) {
                pollStatus(data.job_id);
            } else {
                alert("Error: " + data.error);
                location.reload();
            }
        };

        async function pollStatus(jobId) {
            const interval = setInterval(async () => {
                const res = await fetch(`/status/${jobId}`);
                if(res.status === 404) {
                    clearInterval(interval);
                    alert("Sesi telah berakhir atau file sudah dihapus oleh sistem.");
                    location.reload();
                    return;
                }

                const data = await res.json();
                
                document.getElementById('statusText').innerText = data.message;
                document.getElementById('progressBar').style.width = data.progress + "%";
                document.getElementById('percentText').innerText = data.progress + "%";

                if (data.status === 'completed') {
                    clearInterval(interval);
                    document.getElementById('statusArea').classList.add('hidden');
                    document.getElementById('player').src = `/view/${data.result}`;
                    document.getElementById('downloadBtn').href = `/download/${data.result}`;
                    document.getElementById('resultArea').classList.remove('hidden');
                } else if (data.status === 'error') {
                    clearInterval(interval);
                    alert("Gagal: " + data.message);
                    location.reload();
                }
            }, 3000);
        }
    </script>
</body>
</html>
"""

# ==========================================
# ROUTES
# ==========================================
@app.route('/')
def index():
    return render_template_string(HTML_TEMPLATE)

@app.route('/generate', methods=['POST'])
def generate():
    if job_queue.full():
        return jsonify({"error": "Antrian sedang penuh (Maksimal 20). Coba beberapa saat lagi."}), 429

    file = request.files.get('video_file')
    if not file: return jsonify({"error": "File kosong"}), 400
    
    job_id = str(uuid.uuid4())
    save_path = os.path.join(app.config['UPLOAD_FOLDER'], f"{job_id}_in.mp4")
    file.save(save_path)

    JOBS[job_id] = {
        "status": "queued", 
        "progress": 5, 
        "message": "Menunggu giliran dalam antrian...", 
        "input_path": save_path
    }
    
    job_queue.put(job_id)
    return jsonify({"job_id": job_id})

@app.route('/status/<job_id>')
def status(job_id):
    if job_id not in JOBS:
        return jsonify({"error": "Sesi tidak ditemukan"}), 404
    return jsonify(JOBS[job_id])

@app.route('/view/<filename>')
def view_video(filename):
    file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
    if not os.path.exists(file_path):
        return "File kadaluarsa", 404
    return send_file(file_path)

@app.route('/download/<filename>')
def download(filename):
    file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
    if not os.path.exists(file_path):
        return "File kadaluarsa", 404
    return send_file(file_path, as_attachment=True)

if __name__ == '__main__':
    # Untuk level produksi sebenarnya, lebih baik disajikan via Waitress atau Gunicorn
    # pip install waitress
    # from waitress import serve
    # serve(app, host="0.0.0.0", port=7860, threads=10)
    app.run(host='0.0.0.0', port=7860, debug=False)