Files changed (1) hide show
  1. app.py +408 -0
app.py ADDED
@@ -0,0 +1,408 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ ViralCutter - 100% Local, No API Keys Required
4
+ Video se automatic shorts banaye bina kisi paid API ke
5
+ """
6
+
7
+ import os
8
+ import subprocess
9
+ import json
10
+ import re
11
+ import uuid
12
+ import yt_dlp
13
+ import whisperx
14
+ from flask import Flask, request, send_file, jsonify
15
+ from flask_cors import CORS
16
+ from datetime import datetime
17
+
18
+ # ========== CONFIGURATION ==========
19
+ BASE_DIR = '/data' if os.path.exists('/data') else os.getcwd()
20
+ UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
21
+ OUTPUT_FOLDER = os.path.join(BASE_DIR, 'outputs')
22
+ MODEL_CACHE = os.path.join(BASE_DIR, 'models')
23
+
24
+ os.makedirs(UPLOAD_FOLDER, exist_ok=True)
25
+ os.makedirs(OUTPUT_FOLDER, exist_ok=True)
26
+ os.makedirs(MODEL_CACHE, exist_ok=True)
27
+
28
+ os.environ['XDG_CACHE_HOME'] = MODEL_CACHE
29
+ os.environ['TRANSFORMERS_CACHE'] = MODEL_CACHE
30
+ os.environ['HF_HOME'] = MODEL_CACHE
31
+
32
+ # ========== FLASK APP ==========
33
+ app = Flask(__name__)
34
+ CORS(app)
35
+
36
+ # ========== VIRAL CUTTER - NO GEMINI ==========
37
+ class ViralCutter:
38
+ def __init__(self):
39
+ self.device = "cpu"
40
+ self.compute_type = "int8"
41
+ self.batch_size = 16
42
+ print("🚀 ViralCutter API Initialized (No Gemini)")
43
+ print(f"📁 Storage: {BASE_DIR}")
44
+
45
+ def download_video(self, url):
46
+ """Download video from YouTube"""
47
+ video_id = uuid.uuid4().hex[:8]
48
+ output_path = os.path.join(UPLOAD_FOLDER, f"{video_id}_input.mp4")
49
+
50
+ ydl_opts = {
51
+ 'format': 'best[height<=720]/best',
52
+ 'outtmpl': output_path,
53
+ 'quiet': True,
54
+ 'no_warnings': True
55
+ }
56
+
57
+ try:
58
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
59
+ info = ydl.extract_info(url, download=True)
60
+ title = info.get('title', 'video')
61
+ print(f"✅ Downloaded: {title}")
62
+ return output_path, title
63
+ except Exception as e:
64
+ print(f"❌ Download failed: {e}")
65
+ return None, None
66
+
67
+ def transcribe_audio(self, video_path):
68
+ """Transcribe with WhisperX (local, no API)"""
69
+ print("🎙️ Transcribing audio with WhisperX...")
70
+
71
+ audio_path = video_path.replace('.mp4', '.wav')
72
+ subprocess.run([
73
+ 'ffmpeg', '-i', video_path, '-ac', '1', '-ar', '16000',
74
+ audio_path, '-y'
75
+ ], capture_output=True)
76
+
77
+ # Load WhisperX model (downloads once, caches to /data/models)
78
+ model = whisperx.load_model("base", self.device, compute_type=self.compute_type)
79
+ result = model.transcribe(audio_path, batch_size=self.batch_size)
80
+
81
+ # Align for word timestamps
82
+ model_a, metadata = whisperx.load_align_model(language_code=result["language"], device=self.device)
83
+ result = whisperx.align(result["segments"], model_a, metadata, audio_path, self.device)
84
+
85
+ os.remove(audio_path)
86
+ print(f"✅ Transcription complete: {len(result['segments'])} segments")
87
+ return result
88
+
89
+ def detect_viral_moments(self, transcription):
90
+ """Detect viral hooks using keyword analysis (No Gemini)"""
91
+ segments = transcription['segments']
92
+
93
+ # Viral hook keywords
94
+ hook_keywords = {
95
+ 'high': [
96
+ 'wow', 'amazing', 'incredible', 'unbelievable', 'crazy', 'insane',
97
+ 'never', 'ever', 'finally', 'secret', 'revealed', 'breaking',
98
+ 'exclusive', 'shocking', 'wait', 'seriously', 'omg', 'literally'
99
+ ],
100
+ 'medium': [
101
+ 'how to', 'why', 'what if', 'imagine', 'discover', 'learn',
102
+ 'master', 'ultimate', 'best', 'worst', 'top', 'secret'
103
+ ],
104
+ 'questions': ['?', 'what', 'where', 'when', 'who', 'which', 'why', 'how']
105
+ }
106
+
107
+ scored_segments = []
108
+ for seg in segments:
109
+ score = 0
110
+ text_lower = seg['text'].lower()
111
+ duration = seg['end'] - seg['start']
112
+
113
+ # Score based on keywords
114
+ for kw in hook_keywords['high']:
115
+ if kw in text_lower:
116
+ score += 3
117
+ break
118
+ for kw in hook_keywords['medium']:
119
+ if kw in text_lower:
120
+ score += 2
121
+ for q in hook_keywords['questions']:
122
+ if q in text_lower:
123
+ score += 1
124
+ break
125
+
126
+ # Excitement indicators
127
+ if '!' in seg['text']:
128
+ score += 1
129
+
130
+ # Duration bonus (5-20 seconds is ideal for shorts)
131
+ if 5 <= duration <= 20:
132
+ score += 1
133
+ elif duration < 3:
134
+ score -= 1
135
+
136
+ # Word count bonus
137
+ word_count = len(text_lower.split())
138
+ if 10 <= word_count <= 30:
139
+ score += 1
140
+
141
+ scored_segments.append({
142
+ 'start': seg['start'],
143
+ 'end': seg['end'],
144
+ 'text': seg['text'],
145
+ 'score': score,
146
+ 'duration': duration
147
+ })
148
+
149
+ # Sort by score
150
+ scored_segments.sort(key=lambda x: x['score'], reverse=True)
151
+
152
+ # Remove overlapping clips (minimum 2 second gap)
153
+ viral_clips = []
154
+ for clip in scored_segments:
155
+ overlap = False
156
+ for existing in viral_clips:
157
+ if not (clip['end'] <= existing['start'] - 2 or clip['start'] >= existing['end'] + 2):
158
+ overlap = True
159
+ break
160
+ if not overlap and len(viral_clips) < 5:
161
+ viral_clips.append(clip)
162
+
163
+ print(f"🔥 Found {len(viral_clips)} viral moments")
164
+ for i, clip in enumerate(viral_clips[:3]):
165
+ print(f" Hook {i+1}: Score {clip['score']} - \"{clip['text'][:50]}...\"")
166
+
167
+ return viral_clips
168
+
169
+ def create_vertical_clip(self, video_path, start, end, output_path):
170
+ """Extract and convert to 9:16 vertical format"""
171
+ duration = end - start
172
+
173
+ # Extract clip
174
+ temp_path = output_path.replace('.mp4', '_temp.mp4')
175
+ subprocess.run([
176
+ 'ffmpeg', '-i', video_path, '-ss', str(start), '-t', str(duration),
177
+ '-c', 'copy', '-avoid_negative_ts', 'make_zero', temp_path, '-y'
178
+ ], capture_output=True)
179
+
180
+ # Convert to 9:16 vertical (1080x1920)
181
+ subprocess.run([
182
+ 'ffmpeg', '-i', temp_path,
183
+ '-vf', 'scale=1080:1920:force_original_aspect_ratio=1,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black',
184
+ '-c:a', 'copy', '-preset', 'fast', output_path, '-y'
185
+ ], capture_output=True)
186
+
187
+ if os.path.exists(temp_path):
188
+ os.remove(temp_path)
189
+
190
+ return output_path
191
+
192
+ def format_srt_time(self, seconds):
193
+ """Convert seconds to SRT timestamp"""
194
+ hours = int(seconds // 3600)
195
+ minutes = int((seconds % 3600) // 60)
196
+ secs = seconds % 60
197
+ millis = int((secs % 1) * 1000)
198
+ return f"{hours:02d}:{minutes:02d}:{int(secs):02d},{millis:03d}"
199
+
200
+ def generate_word_by_word_srt(self, transcription, start_offset, end_offset, output_path):
201
+ """Generate word-by-word SRT subtitles"""
202
+ with open(output_path, 'w', encoding='utf-8') as f:
203
+ idx = 1
204
+ for seg in transcription['segments']:
205
+ if seg['start'] >= start_offset and seg['end'] <= end_offset:
206
+ if 'words' in seg:
207
+ for word in seg['words']:
208
+ start = self.format_srt_time(word['start'] - start_offset)
209
+ end = self.format_srt_time(word['end'] - start_offset)
210
+ f.write(f"{idx}\n{start} --> {end}\n{word['word']}\n\n")
211
+ idx += 1
212
+ else:
213
+ start = self.format_srt_time(seg['start'] - start_offset)
214
+ end = self.format_srt_time(seg['end'] - start_offset)
215
+ f.write(f"{idx}\n{start} --> {end}\n{seg['text']}\n\n")
216
+ idx += 1
217
+ return output_path
218
+
219
+ def add_subtitles(self, video_path, srt_path, output_path):
220
+ """Burn subtitles into video"""
221
+ subprocess.run([
222
+ 'ffmpeg', '-i', video_path,
223
+ '-vf', f"subtitles={srt_path}:force_style='FontSize=28,FontName=Arial,OutlineColour=&H00000000,BorderStyle=1'",
224
+ '-c:a', 'copy', '-preset', 'fast', output_path, '-y'
225
+ ], capture_output=True)
226
+ return output_path
227
+
228
+ def process_video(self, video_source, num_clips=3):
229
+ """Main processing pipeline"""
230
+
231
+ # Step 1: Get video (YouTube URL or local file)
232
+ if video_source.startswith(('http://', 'https://', 'www.')):
233
+ video_path, title = self.download_video(video_source)
234
+ if not video_path:
235
+ return None, "Download failed"
236
+ is_temp = True
237
+ else:
238
+ video_path = video_source
239
+ title = os.path.basename(video_source)
240
+ is_temp = False
241
+
242
+ # Step 2: Transcribe
243
+ transcription = self.transcribe_audio(video_path)
244
+
245
+ # Step 3: Detect viral moments
246
+ viral_clips = self.detect_viral_moments(transcription)
247
+
248
+ if not viral_clips:
249
+ return None, "No viral moments detected"
250
+
251
+ # Step 4: Generate clips
252
+ clips_info = []
253
+ video_id = uuid.uuid4().hex[:8]
254
+
255
+ for i, clip in enumerate(viral_clips[:num_clips]):
256
+ start = clip['start']
257
+ end = clip['end']
258
+
259
+ # Ensure minimum duration (15 seconds)
260
+ if end - start < 15:
261
+ end = min(start + 15, transcription['segments'][-1]['end'])
262
+
263
+ # Ensure maximum duration (45 seconds)
264
+ if end - start > 45:
265
+ end = start + 45
266
+
267
+ vertical_path = os.path.join(OUTPUT_FOLDER, f"{video_id}_clip_{i+1}_vertical.mp4")
268
+ srt_path = os.path.join(OUTPUT_FOLDER, f"{video_id}_clip_{i+1}.srt")
269
+ final_path = os.path.join(OUTPUT_FOLDER, f"{video_id}_clip_{i+1}_final.mp4")
270
+
271
+ # Create vertical clip
272
+ self.create_vertical_clip(video_path, start, end, vertical_path)
273
+
274
+ # Generate SRT
275
+ self.generate_word_by_word_srt(transcription, start, end, srt_path)
276
+
277
+ # Add subtitles
278
+ self.add_subtitles(vertical_path, srt_path, final_path)
279
+
280
+ clips_info.append({
281
+ 'clip_num': i+1,
282
+ 'path': final_path,
283
+ 'srt_path': srt_path,
284
+ 'duration': round(end - start, 1),
285
+ 'score': clip['score'],
286
+ 'hook_text': clip['text'][:100],
287
+ 'start_time': round(start, 1),
288
+ 'end_time': round(end, 1)
289
+ })
290
+
291
+ print(f"✅ Clip {i+1}: {start:.1f}s - {end:.1f}s (Score: {clip['score']})")
292
+
293
+ # Cleanup original file if downloaded
294
+ if is_temp and os.path.exists(video_path):
295
+ os.remove(video_path)
296
+
297
+ return clips_info, None
298
+
299
+ # Initialize cutter
300
+ cutter = ViralCutter()
301
+
302
+ # ========== API ENDPOINTS ==========
303
+
304
+ @app.route('/health', methods=['GET'])
305
+ def health():
306
+ return jsonify({
307
+ 'status': 'healthy',
308
+ 'storage': BASE_DIR,
309
+ 'model': 'whisperx (local)',
310
+ 'gemini': 'not required'
311
+ })
312
+
313
+ @app.route('/process', methods=['POST'])
314
+ def process_video():
315
+ """
316
+ Process video and generate shorts
317
+
318
+ Form data:
319
+ - url: YouTube URL
320
+ - video: video file
321
+ - num_clips: number of clips (default: 3)
322
+ """
323
+
324
+ # Get parameters
325
+ url = request.form.get('url', '')
326
+ num_clips = int(request.form.get('num_clips', 3))
327
+
328
+ # Check for uploaded file
329
+ if 'video' in request.files:
330
+ file = request.files['video']
331
+ if file.filename:
332
+ video_id = uuid.uuid4().hex[:8]
333
+ video_path = os.path.join(UPLOAD_FOLDER, f"{video_id}_uploaded.mp4")
334
+ file.save(video_path)
335
+ video_source = video_path
336
+ else:
337
+ return jsonify({'error': 'Empty file'}), 400
338
+ elif url:
339
+ video_source = url
340
+ else:
341
+ return jsonify({'error': 'Provide either video file or YouTube URL'}), 400
342
+
343
+ try:
344
+ clips, error = cutter.process_video(video_source, num_clips)
345
+
346
+ if error:
347
+ return jsonify({'error': error}), 500
348
+
349
+ # Return download links
350
+ result = {
351
+ 'success': True,
352
+ 'num_clips': len(clips),
353
+ 'clips': []
354
+ }
355
+
356
+ for clip in clips:
357
+ result['clips'].append({
358
+ 'clip_num': clip['clip_num'],
359
+ 'download_url': f"/download/{os.path.basename(clip['path'])}",
360
+ 'srt_url': f"/download/{os.path.basename(clip['srt_path'])}",
361
+ 'duration': clip['duration'],
362
+ 'score': clip['score'],
363
+ 'hook_preview': clip['hook_text']
364
+ })
365
+
366
+ return jsonify(result)
367
+
368
+ except Exception as e:
369
+ return jsonify({'error': str(e)}), 500
370
+
371
+ @app.route('/download/<filename>', methods=['GET'])
372
+ def download_file(filename):
373
+ """Download generated file"""
374
+ filepath = os.path.join(OUTPUT_FOLDER, filename)
375
+ if not os.path.exists(filepath):
376
+ return jsonify({'error': 'File not found'}), 404
377
+ return send_file(filepath, as_attachment=True)
378
+
379
+ @app.route('/list', methods=['GET'])
380
+ def list_clips():
381
+ """List all generated clips"""
382
+ files = []
383
+ for f in os.listdir(OUTPUT_FOLDER):
384
+ if f.endswith('_final.mp4'):
385
+ stat = os.stat(os.path.join(OUTPUT_FOLDER, f))
386
+ files.append({
387
+ 'filename': f,
388
+ 'size_mb': round(stat.st_size / (1024 * 1024), 2),
389
+ 'modified': datetime.fromtimestamp(stat.st_mtime).isoformat()
390
+ })
391
+ return jsonify({'clips': files})
392
+
393
+ @app.route('/cleanup', methods=['POST'])
394
+ def cleanup():
395
+ """Delete all generated files"""
396
+ import shutil
397
+ for folder in [UPLOAD_FOLDER, OUTPUT_FOLDER]:
398
+ for f in os.listdir(folder):
399
+ try:
400
+ os.remove(os.path.join(folder, f))
401
+ except:
402
+ pass
403
+ return jsonify({'status': 'cleaned'})
404
+
405
+ # ========== MAIN ==========
406
+ if __name__ == '__main__':
407
+ port = int(os.environ.get('PORT', 7860))
408
+ app.run(host='0.0.0.0', port=port, debug=False)