| 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 |
|
|
| |
| 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 |
| os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) |
|
|
| MAX_QUEUE_SIZE = 20 |
| MAX_WORKERS = 5 |
| AUTO_DELETE_MINUTES = 1440 |
|
|
| JOBS = {} |
| job_queue = queue.Queue(maxsize=MAX_QUEUE_SIZE) |
|
|
| |
| request_timestamps = [] |
| global_lockout_until = 0 |
|
|
| |
| |
| 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!") |
|
|
| |
| |
| |
| @app.before_request |
| def rate_limiter(): |
| global request_timestamps, global_lockout_until |
| |
| |
| if request.endpoint == 'generate' and request.method == 'POST': |
| current_time = time.time() |
| |
| |
| if current_time < global_lockout_until: |
| return jsonify({"error": "demi keamanan kamu sengaja mematikan api ini selama 2 menit karena ada yang spam"}), 429 |
|
|
| |
| request_timestamps = [t for t in request_timestamps if current_time - t < 60] |
| |
| |
| if len(request_timestamps) >= 30: |
| global_lockout_until = current_time + 120 |
| return jsonify({"error": "demi keamanan kamu sengaja mematikan api ini selama 2 menit karena ada yang spam"}), 429 |
| |
| request_timestamps.append(current_time) |
|
|
| |
| |
| |
| 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)) |
|
|
| |
| |
| |
| 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] |
| print(f"[{job_id}] Auto-cleanup selesai.") |
|
|
| |
| |
| |
| 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: |
| |
| 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) |
|
|
| |
| JOBS[job_id].update({"message": "Menganalisa suara manusia (AI Whisper)...", "progress": 25}) |
| |
| |
| 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.") |
|
|
| |
| 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 |
| |
| |
| JOBS[job_id].update({"message": "Membuat efek subtitle viral...", "progress": 70}) |
| generate_srt(all_words, s_time, e_time, srt_path) |
|
|
| |
| 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: |
| |
| cleanup_files(audio_path, srt_path) |
| |
| |
| timer = threading.Timer(AUTO_DELETE_MINUTES * 60, cleanup_files, args=(path_in, out_path), kwargs={'job_id': job_id}) |
| timer.start() |
|
|
| |
| 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() |
|
|
| |
| for _ in range(MAX_WORKERS): |
| threading.Thread(target=queue_worker, daemon=True).start() |
|
|
| |
| |
| |
| 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> |
| """ |
|
|
| |
| |
| |
| @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__': |
| |
| |
| |
| |
| app.run(host='0.0.0.0', port=7860, debug=False) |