Spaces:
Paused
Paused
Update app.py
Browse files
app.py
CHANGED
|
@@ -7,6 +7,7 @@ import time
|
|
| 7 |
import json
|
| 8 |
from flask import Flask, request, jsonify, render_template_string, send_file
|
| 9 |
from moviepy.editor import VideoFileClip, TextClip, CompositeVideoClip, concatenate_videoclips
|
|
|
|
| 10 |
from faster_whisper import WhisperModel
|
| 11 |
|
| 12 |
app = Flask(__name__)
|
|
@@ -18,7 +19,7 @@ JOBS = {}
|
|
| 18 |
whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
|
| 19 |
|
| 20 |
# ==========================================
|
| 21 |
-
# UI HTML
|
| 22 |
# ==========================================
|
| 23 |
HTML_TEMPLATE = """
|
| 24 |
<!DOCTYPE html>
|
|
@@ -26,13 +27,13 @@ HTML_TEMPLATE = """
|
|
| 26 |
<head>
|
| 27 |
<meta charset="UTF-8">
|
| 28 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 29 |
-
<title>AI Pro Clip Editor</title>
|
| 30 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 31 |
</head>
|
| 32 |
<body class="bg-gray-950 text-gray-200 min-h-screen flex flex-col items-center p-6">
|
| 33 |
<div class="w-full max-w-2xl bg-gray-900 p-8 rounded-3xl border border-gray-800 shadow-2xl">
|
| 34 |
-
<h1 class="text-3xl font-black text-center mb-2 bg-gradient-to-r from-green-400 to-blue-500 bg-clip-text text-transparent">AI
|
| 35 |
-
<p class="text-center text-gray-500 text-sm mb-8">
|
| 36 |
|
| 37 |
<form id="clipForm" class="space-y-6">
|
| 38 |
<input type="url" id="videoUrl" class="w-full p-4 bg-gray-800 rounded-xl border border-gray-700 outline-none focus:ring-2 focus:ring-green-500" placeholder="Masukkan Link Video MP4...">
|
|
@@ -120,11 +121,11 @@ def time_to_seconds(time_str):
|
|
| 120 |
def background_processor(job_id, video_path):
|
| 121 |
try:
|
| 122 |
full_clip = VideoFileClip(video_path)
|
| 123 |
-
|
| 124 |
total_duration = full_clip.duration
|
| 125 |
|
| 126 |
-
# 1. Transkripsi
|
| 127 |
-
JOBS[job_id].update({"message": "AI mentranskrip &
|
| 128 |
segments_whisper, _ = whisper_model.transcribe(video_path, word_timestamps=True)
|
| 129 |
|
| 130 |
words_data = []
|
|
@@ -132,111 +133,103 @@ def background_processor(job_id, video_path):
|
|
| 132 |
for segment in segments_whisper:
|
| 133 |
for word in segment.words:
|
| 134 |
words_data.append(word)
|
| 135 |
-
# Buat transkrip per kalimat untuk AI agar konteksnya jelas
|
| 136 |
transcript_text += f"[{int(segment.start//60):02d}:{int(segment.start%60):02d} - {int(segment.end//60):02d}:{int(segment.end%60):02d}] {segment.text.strip()}\n"
|
| 137 |
|
| 138 |
-
# 2. AI
|
| 139 |
-
JOBS[job_id].update({"message": "Menyusun
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
Tugasmu adalah memotong transkrip video ini menjadi 1 hingga 3 klip pendek yang jika digabungkan akan menjadi satu cerita yang sangat menarik, nyambung, dan tidak terpotong di tengah kalimat.
|
| 144 |
|
| 145 |
-
Syarat
|
| 146 |
-
1.
|
| 147 |
-
2.
|
| 148 |
-
3. Balas HANYA dengan
|
| 149 |
-
|
| 150 |
-
Format JSON yang diwajibkan:
|
| 151 |
[
|
| 152 |
-
{{"start": "MM:SS", "end": "MM:SS", "narasi": "Alasan
|
| 153 |
-
{{"start": "MM:SS", "end": "MM:SS", "narasi": "Alasan
|
| 154 |
]
|
| 155 |
|
| 156 |
-
Transkrip
|
| 157 |
{transcript_text[:3500]}
|
| 158 |
"""
|
| 159 |
-
|
| 160 |
try:
|
| 161 |
r = requests.post("https://www.puruboy.kozow.com/api/ai/letmegpt", json={"message": prompt}, timeout=25).json()
|
| 162 |
ai_ans = r.get("result", {}).get("answer", "[]")
|
| 163 |
-
|
| 164 |
-
# Ekstraksi ketat JSON
|
| 165 |
json_match = re.search(r'\[\s*\{.*?\}\s*\]', ai_ans, re.DOTALL)
|
| 166 |
points = json.loads(json_match.group(0)) if json_match else []
|
| 167 |
except:
|
| 168 |
-
|
| 169 |
-
points = [{"start": "00:00", "end": "00:15", "narasi": "Fallback awal"}]
|
| 170 |
|
| 171 |
-
if not points:
|
| 172 |
-
raise Exception("AI gagal merumuskan JSON. Silakan coba lagi.")
|
| 173 |
|
| 174 |
-
# 3.
|
| 175 |
-
JOBS[job_id].update({"message": "
|
| 176 |
-
cut_ranges = []
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
for p in points:
|
| 179 |
raw_start = time_to_seconds(p.get('start', '00:00'))
|
| 180 |
-
raw_end = time_to_seconds(p.get('end', '00:
|
| 181 |
|
| 182 |
-
#
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
JOBS[job_id].update({"message": "Merender gabungan klip...", "progress": 70})
|
| 191 |
-
|
| 192 |
-
subclips = []
|
| 193 |
-
final_words_clips = []
|
| 194 |
-
current_timeline = 0
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
for r in cut_ranges:
|
| 200 |
-
# Subclip Video Asli
|
| 201 |
-
v_sub = full_clip.subclip(r['start'], r['end'])
|
| 202 |
-
subclips.append(v_sub)
|
| 203 |
-
|
| 204 |
-
# Buat Subtitle per kata dengan penyesuaian timeline
|
| 205 |
for wd in words_data:
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
rel_start = max(0, wd.start - r['start']) + current_timeline
|
| 210 |
-
rel_end = max(0, wd.end - r['start']) + current_timeline
|
| 211 |
|
| 212 |
t = TextClip(
|
| 213 |
wd.word.strip().upper(),
|
| 214 |
font=font_path,
|
| 215 |
-
fontsize=original_height*0.06,
|
| 216 |
color='yellow',
|
| 217 |
stroke_color='black',
|
| 218 |
stroke_width=2,
|
| 219 |
method='label'
|
| 220 |
-
).set_start(rel_start).set_end(rel_end).set_position(('center', int(original_height*0.85)))
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
#
|
| 225 |
-
|
|
|
|
| 226 |
|
| 227 |
-
#
|
| 228 |
-
|
| 229 |
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
| 232 |
|
| 233 |
output_filename = f"final_{job_id}.mp4"
|
| 234 |
output_path = os.path.join(app.config['UPLOAD_FOLDER'], output_filename)
|
| 235 |
-
|
| 236 |
JOBS[job_id]['files_to_delete'].append(output_path)
|
| 237 |
|
| 238 |
-
|
| 239 |
-
final_result.write_videofile(
|
| 240 |
output_path,
|
| 241 |
fps=24,
|
| 242 |
codec="libx264",
|
|
@@ -246,23 +239,17 @@ Transkrip Video:
|
|
| 246 |
logger=None
|
| 247 |
)
|
| 248 |
|
| 249 |
-
# Bersihkan Memory RAM
|
| 250 |
full_clip.close()
|
| 251 |
merged_video.close()
|
| 252 |
-
|
| 253 |
|
| 254 |
-
JOBS[job_id].update({
|
| 255 |
-
"status": "completed",
|
| 256 |
-
"progress": 100,
|
| 257 |
-
"message": "Video Siap Ditonton!",
|
| 258 |
-
"result": output_filename
|
| 259 |
-
})
|
| 260 |
|
| 261 |
except Exception as e:
|
| 262 |
JOBS[job_id].update({"status": "error", "message": str(e)})
|
| 263 |
|
| 264 |
# ==========================================
|
| 265 |
-
# ROUTES
|
| 266 |
# ==========================================
|
| 267 |
|
| 268 |
@app.route('/')
|
|
@@ -289,7 +276,6 @@ def generate():
|
|
| 289 |
"status": "processing", "progress": 5, "message": "Membaca Video...",
|
| 290 |
"result": None, "created_at": time.time(), "files_to_delete": [video_path]
|
| 291 |
}
|
| 292 |
-
|
| 293 |
threading.Thread(target=background_processor, args=(job_id, video_path)).start()
|
| 294 |
return jsonify({"job_id": job_id})
|
| 295 |
|
|
|
|
| 7 |
import json
|
| 8 |
from flask import Flask, request, jsonify, render_template_string, send_file
|
| 9 |
from moviepy.editor import VideoFileClip, TextClip, CompositeVideoClip, concatenate_videoclips
|
| 10 |
+
from moviepy.audio.fx.all import audio_fadein, audio_fadeout
|
| 11 |
from faster_whisper import WhisperModel
|
| 12 |
|
| 13 |
app = Flask(__name__)
|
|
|
|
| 19 |
whisper_model = WhisperModel("base", device="cpu", compute_type="int8")
|
| 20 |
|
| 21 |
# ==========================================
|
| 22 |
+
# UI HTML (Tetap sama, elegan & modern)
|
| 23 |
# ==========================================
|
| 24 |
HTML_TEMPLATE = """
|
| 25 |
<!DOCTYPE html>
|
|
|
|
| 27 |
<head>
|
| 28 |
<meta charset="UTF-8">
|
| 29 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 30 |
+
<title>AI Pro Clip Editor - Smooth Edit</title>
|
| 31 |
<script src="https://cdn.tailwindcss.com"></script>
|
| 32 |
</head>
|
| 33 |
<body class="bg-gray-950 text-gray-200 min-h-screen flex flex-col items-center p-6">
|
| 34 |
<div class="w-full max-w-2xl bg-gray-900 p-8 rounded-3xl border border-gray-800 shadow-2xl">
|
| 35 |
+
<h1 class="text-3xl font-black text-center mb-2 bg-gradient-to-r from-green-400 to-blue-500 bg-clip-text text-transparent">AI SMOOTH CLIPPER</h1>
|
| 36 |
+
<p class="text-center text-gray-500 text-sm mb-8">Pemotongan akurat pada jeda napas & kata</p>
|
| 37 |
|
| 38 |
<form id="clipForm" class="space-y-6">
|
| 39 |
<input type="url" id="videoUrl" class="w-full p-4 bg-gray-800 rounded-xl border border-gray-700 outline-none focus:ring-2 focus:ring-green-500" placeholder="Masukkan Link Video MP4...">
|
|
|
|
| 121 |
def background_processor(job_id, video_path):
|
| 122 |
try:
|
| 123 |
full_clip = VideoFileClip(video_path)
|
| 124 |
+
original_height = full_clip.h
|
| 125 |
total_duration = full_clip.duration
|
| 126 |
|
| 127 |
+
# 1. Transkripsi dengan akurasi per kata
|
| 128 |
+
JOBS[job_id].update({"message": "AI mentranskrip & memetakan jeda...", "progress": 15})
|
| 129 |
segments_whisper, _ = whisper_model.transcribe(video_path, word_timestamps=True)
|
| 130 |
|
| 131 |
words_data = []
|
|
|
|
| 133 |
for segment in segments_whisper:
|
| 134 |
for word in segment.words:
|
| 135 |
words_data.append(word)
|
|
|
|
| 136 |
transcript_text += f"[{int(segment.start//60):02d}:{int(segment.start%60):02d} - {int(segment.end//60):02d}:{int(segment.end%60):02d}] {segment.text.strip()}\n"
|
| 137 |
|
| 138 |
+
# 2. AI Editor Memilih Klip
|
| 139 |
+
JOBS[job_id].update({"message": "Menyusun cerita yang nyambung...", "progress": 35})
|
| 140 |
|
| 141 |
+
prompt = f"""Kamu adalah Video Editor. Tugasmu memilih 2-3 segmen berdurasi agak panjang (minimal 10-15 detik per klip) dari video ini agar membentuk satu cerita utuh.
|
| 142 |
+
JANGAN memilih klip yang terlalu pendek agar video tidak terlihat patah-patah.
|
|
|
|
| 143 |
|
| 144 |
+
Syarat:
|
| 145 |
+
1. Setiap segmen HARUS kalimat utuh.
|
| 146 |
+
2. Ceritanya harus nyambung dari awal sampai akhir.
|
| 147 |
+
3. Balas HANYA dengan JSON array:
|
|
|
|
|
|
|
| 148 |
[
|
| 149 |
+
{{"start": "MM:SS", "end": "MM:SS", "narasi": "Alasan"}},
|
| 150 |
+
{{"start": "MM:SS", "end": "MM:SS", "narasi": "Alasan"}}
|
| 151 |
]
|
| 152 |
|
| 153 |
+
Transkrip:
|
| 154 |
{transcript_text[:3500]}
|
| 155 |
"""
|
|
|
|
| 156 |
try:
|
| 157 |
r = requests.post("https://www.puruboy.kozow.com/api/ai/letmegpt", json={"message": prompt}, timeout=25).json()
|
| 158 |
ai_ans = r.get("result", {}).get("answer", "[]")
|
|
|
|
|
|
|
| 159 |
json_match = re.search(r'\[\s*\{.*?\}\s*\]', ai_ans, re.DOTALL)
|
| 160 |
points = json.loads(json_match.group(0)) if json_match else []
|
| 161 |
except:
|
| 162 |
+
points = [{"start": "00:00", "end": "00:15", "narasi": "Fallback"}]
|
|
|
|
| 163 |
|
| 164 |
+
if not points: raise Exception("AI gagal merumuskan JSON.")
|
|
|
|
| 165 |
|
| 166 |
+
# 3. LOGIKA SMART BOUNDARY (Pemotongan Akurat di Jeda Kata)
|
| 167 |
+
JOBS[job_id].update({"message": "Menghaluskan titik potong audio...", "progress": 55})
|
|
|
|
| 168 |
|
| 169 |
+
final_clips_to_concat = []
|
| 170 |
+
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
|
| 171 |
+
if not os.path.exists(font_path): font_path = "Arial"
|
| 172 |
+
|
| 173 |
for p in points:
|
| 174 |
raw_start = time_to_seconds(p.get('start', '00:00'))
|
| 175 |
+
raw_end = time_to_seconds(p.get('end', '00:15'))
|
| 176 |
|
| 177 |
+
# Cari kata aktual yang paling dekat dengan pilihan AI
|
| 178 |
+
start_word = next((w for w in words_data if w.start >= max(0, raw_start - 2)), None)
|
| 179 |
+
end_word = next((w for w in reversed(words_data) if w.end <= min(total_duration, raw_end + 2)), None)
|
| 180 |
+
|
| 181 |
+
if start_word and end_word and start_word.start < end_word.end:
|
| 182 |
+
# Beri ruang napas 0.3 detik sebelum dan sesudah kata diucapkan
|
| 183 |
+
actual_start = max(0, start_word.start - 0.3)
|
| 184 |
+
actual_end = min(total_duration, end_word.end + 0.3)
|
| 185 |
+
else:
|
| 186 |
+
actual_start = max(0, raw_start - 0.3)
|
| 187 |
+
actual_end = min(total_duration, raw_end + 0.3)
|
| 188 |
+
|
| 189 |
+
# Potong Video Berdasarkan Kata (Bukan Angka Mentah AI)
|
| 190 |
+
subclip = full_clip.subclip(actual_start, actual_end)
|
| 191 |
|
| 192 |
+
# Terapkan AUDIO FADE IN & OUT agar perpindahan tidak "Klik/Cekrek"
|
| 193 |
+
subclip = subclip.set_audio(
|
| 194 |
+
subclip.audio.fx(audio_fadein, 0.1).fx(audio_fadeout, 0.1)
|
| 195 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
+
# Tambahkan Subtitle HANYA untuk klip ini (Sinkronisasi 100% sempurna)
|
| 198 |
+
txt_clips = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
for wd in words_data:
|
| 200 |
+
if wd.start >= actual_start and wd.end <= actual_end:
|
| 201 |
+
rel_start = wd.start - actual_start
|
| 202 |
+
rel_end = wd.end - actual_start
|
|
|
|
|
|
|
| 203 |
|
| 204 |
t = TextClip(
|
| 205 |
wd.word.strip().upper(),
|
| 206 |
font=font_path,
|
| 207 |
+
fontsize=original_height * 0.06,
|
| 208 |
color='yellow',
|
| 209 |
stroke_color='black',
|
| 210 |
stroke_width=2,
|
| 211 |
method='label'
|
| 212 |
+
).set_start(rel_start).set_end(rel_end).set_position(('center', int(original_height * 0.85)))
|
| 213 |
|
| 214 |
+
txt_clips.append(t)
|
| 215 |
+
|
| 216 |
+
# Gabungkan Video + Subtitle untuk segmen ini
|
| 217 |
+
composite_subclip = CompositeVideoClip([subclip] + txt_clips)
|
| 218 |
+
final_clips_to_concat.append(composite_subclip)
|
| 219 |
|
| 220 |
+
# 4. GABUNGKAN SEMUA KLIP YANG SUDAH JADI
|
| 221 |
+
JOBS[job_id].update({"message": "Merender video akhir...", "progress": 70})
|
| 222 |
|
| 223 |
+
if not final_clips_to_concat:
|
| 224 |
+
raise Exception("Gagal memotong video. Durasi tidak valid.")
|
| 225 |
+
|
| 226 |
+
merged_video = concatenate_videoclips(final_clips_to_concat, method="compose")
|
| 227 |
|
| 228 |
output_filename = f"final_{job_id}.mp4"
|
| 229 |
output_path = os.path.join(app.config['UPLOAD_FOLDER'], output_filename)
|
|
|
|
| 230 |
JOBS[job_id]['files_to_delete'].append(output_path)
|
| 231 |
|
| 232 |
+
merged_video.write_videofile(
|
|
|
|
| 233 |
output_path,
|
| 234 |
fps=24,
|
| 235 |
codec="libx264",
|
|
|
|
| 239 |
logger=None
|
| 240 |
)
|
| 241 |
|
|
|
|
| 242 |
full_clip.close()
|
| 243 |
merged_video.close()
|
| 244 |
+
for c in final_clips_to_concat: c.close()
|
| 245 |
|
| 246 |
+
JOBS[job_id].update({"status": "completed", "progress": 100, "message": "Video Selesai!", "result": output_filename})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
except Exception as e:
|
| 249 |
JOBS[job_id].update({"status": "error", "message": str(e)})
|
| 250 |
|
| 251 |
# ==========================================
|
| 252 |
+
# ROUTES
|
| 253 |
# ==========================================
|
| 254 |
|
| 255 |
@app.route('/')
|
|
|
|
| 276 |
"status": "processing", "progress": 5, "message": "Membaca Video...",
|
| 277 |
"result": None, "created_at": time.time(), "files_to_delete": [video_path]
|
| 278 |
}
|
|
|
|
| 279 |
threading.Thread(target=background_processor, args=(job_id, video_path)).start()
|
| 280 |
return jsonify({"job_id": job_id})
|
| 281 |
|