Spaces:
Paused
Paused
| #!/usr/bin/env python3 | |
| """ | |
| ViralCutter - 100% Local, No API Keys Required | |
| Video se automatic shorts banaye bina kisi paid API ke | |
| """ | |
| import os | |
| import subprocess | |
| import json | |
| import re | |
| import uuid | |
| import yt_dlp | |
| import whisperx | |
| from flask import Flask, request, send_file, jsonify | |
| from flask_cors import CORS | |
| from datetime import datetime | |
| # ========== CONFIGURATION ========== | |
| BASE_DIR = '/data' if os.path.exists('/data') else os.getcwd() | |
| UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads') | |
| OUTPUT_FOLDER = os.path.join(BASE_DIR, 'outputs') | |
| MODEL_CACHE = os.path.join(BASE_DIR, 'models') | |
| os.makedirs(UPLOAD_FOLDER, exist_ok=True) | |
| os.makedirs(OUTPUT_FOLDER, exist_ok=True) | |
| os.makedirs(MODEL_CACHE, exist_ok=True) | |
| os.environ['XDG_CACHE_HOME'] = MODEL_CACHE | |
| os.environ['TRANSFORMERS_CACHE'] = MODEL_CACHE | |
| os.environ['HF_HOME'] = MODEL_CACHE | |
| # ========== FLASK APP ========== | |
| app = Flask(__name__) | |
| CORS(app) | |
| # ========== VIRAL CUTTER - NO GEMINI ========== | |
| class ViralCutter: | |
| def __init__(self): | |
| self.device = "cpu" | |
| self.compute_type = "int8" | |
| self.batch_size = 16 | |
| print("🚀 ViralCutter API Initialized (No Gemini)") | |
| print(f"📁 Storage: {BASE_DIR}") | |
| def download_video(self, url): | |
| """Download video from YouTube""" | |
| video_id = uuid.uuid4().hex[:8] | |
| output_path = os.path.join(UPLOAD_FOLDER, f"{video_id}_input.mp4") | |
| ydl_opts = { | |
| 'format': 'best[height<=720]/best', | |
| 'outtmpl': output_path, | |
| 'quiet': True, | |
| 'no_warnings': True | |
| } | |
| try: | |
| with yt_dlp.YoutubeDL(ydl_opts) as ydl: | |
| info = ydl.extract_info(url, download=True) | |
| title = info.get('title', 'video') | |
| print(f"✅ Downloaded: {title}") | |
| return output_path, title | |
| except Exception as e: | |
| print(f"❌ Download failed: {e}") | |
| return None, None | |
| def transcribe_audio(self, video_path): | |
| """Transcribe with WhisperX (local, no API)""" | |
| print("🎙️ Transcribing audio with WhisperX...") | |
| audio_path = video_path.replace('.mp4', '.wav') | |
| subprocess.run([ | |
| 'ffmpeg', '-i', video_path, '-ac', '1', '-ar', '16000', | |
| audio_path, '-y' | |
| ], capture_output=True) | |
| # Load WhisperX model (downloads once, caches to /data/models) | |
| model = whisperx.load_model("base", self.device, compute_type=self.compute_type) | |
| result = model.transcribe(audio_path, batch_size=self.batch_size) | |
| # Align for word timestamps | |
| model_a, metadata = whisperx.load_align_model(language_code=result["language"], device=self.device) | |
| result = whisperx.align(result["segments"], model_a, metadata, audio_path, self.device) | |
| os.remove(audio_path) | |
| print(f"✅ Transcription complete: {len(result['segments'])} segments") | |
| return result | |
| def detect_viral_moments(self, transcription): | |
| """Detect viral hooks using keyword analysis (No Gemini)""" | |
| segments = transcription['segments'] | |
| # Viral hook keywords | |
| hook_keywords = { | |
| 'high': [ | |
| 'wow', 'amazing', 'incredible', 'unbelievable', 'crazy', 'insane', | |
| 'never', 'ever', 'finally', 'secret', 'revealed', 'breaking', | |
| 'exclusive', 'shocking', 'wait', 'seriously', 'omg', 'literally' | |
| ], | |
| 'medium': [ | |
| 'how to', 'why', 'what if', 'imagine', 'discover', 'learn', | |
| 'master', 'ultimate', 'best', 'worst', 'top', 'secret' | |
| ], | |
| 'questions': ['?', 'what', 'where', 'when', 'who', 'which', 'why', 'how'] | |
| } | |
| scored_segments = [] | |
| for seg in segments: | |
| score = 0 | |
| text_lower = seg['text'].lower() | |
| duration = seg['end'] - seg['start'] | |
| # Score based on keywords | |
| for kw in hook_keywords['high']: | |
| if kw in text_lower: | |
| score += 3 | |
| break | |
| for kw in hook_keywords['medium']: | |
| if kw in text_lower: | |
| score += 2 | |
| for q in hook_keywords['questions']: | |
| if q in text_lower: | |
| score += 1 | |
| break | |
| # Excitement indicators | |
| if '!' in seg['text']: | |
| score += 1 | |
| # Duration bonus (5-20 seconds is ideal for shorts) | |
| if 5 <= duration <= 20: | |
| score += 1 | |
| elif duration < 3: | |
| score -= 1 | |
| # Word count bonus | |
| word_count = len(text_lower.split()) | |
| if 10 <= word_count <= 30: | |
| score += 1 | |
| scored_segments.append({ | |
| 'start': seg['start'], | |
| 'end': seg['end'], | |
| 'text': seg['text'], | |
| 'score': score, | |
| 'duration': duration | |
| }) | |
| # Sort by score | |
| scored_segments.sort(key=lambda x: x['score'], reverse=True) | |
| # Remove overlapping clips (minimum 2 second gap) | |
| viral_clips = [] | |
| for clip in scored_segments: | |
| overlap = False | |
| for existing in viral_clips: | |
| if not (clip['end'] <= existing['start'] - 2 or clip['start'] >= existing['end'] + 2): | |
| overlap = True | |
| break | |
| if not overlap and len(viral_clips) < 5: | |
| viral_clips.append(clip) | |
| print(f"🔥 Found {len(viral_clips)} viral moments") | |
| for i, clip in enumerate(viral_clips[:3]): | |
| print(f" Hook {i+1}: Score {clip['score']} - \"{clip['text'][:50]}...\"") | |
| return viral_clips | |
| def create_vertical_clip(self, video_path, start, end, output_path): | |
| """Extract and convert to 9:16 vertical format""" | |
| duration = end - start | |
| # Extract clip | |
| temp_path = output_path.replace('.mp4', '_temp.mp4') | |
| subprocess.run([ | |
| 'ffmpeg', '-i', video_path, '-ss', str(start), '-t', str(duration), | |
| '-c', 'copy', '-avoid_negative_ts', 'make_zero', temp_path, '-y' | |
| ], capture_output=True) | |
| # Convert to 9:16 vertical (1080x1920) | |
| subprocess.run([ | |
| 'ffmpeg', '-i', temp_path, | |
| '-vf', 'scale=1080:1920:force_original_aspect_ratio=1,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black', | |
| '-c:a', 'copy', '-preset', 'fast', output_path, '-y' | |
| ], capture_output=True) | |
| if os.path.exists(temp_path): | |
| os.remove(temp_path) | |
| return output_path | |
| def format_srt_time(self, seconds): | |
| """Convert seconds to SRT timestamp""" | |
| hours = int(seconds // 3600) | |
| minutes = int((seconds % 3600) // 60) | |
| secs = seconds % 60 | |
| millis = int((secs % 1) * 1000) | |
| return f"{hours:02d}:{minutes:02d}:{int(secs):02d},{millis:03d}" | |
| def generate_word_by_word_srt(self, transcription, start_offset, end_offset, output_path): | |
| """Generate word-by-word SRT subtitles""" | |
| with open(output_path, 'w', encoding='utf-8') as f: | |
| idx = 1 | |
| for seg in transcription['segments']: | |
| if seg['start'] >= start_offset and seg['end'] <= end_offset: | |
| if 'words' in seg: | |
| for word in seg['words']: | |
| start = self.format_srt_time(word['start'] - start_offset) | |
| end = self.format_srt_time(word['end'] - start_offset) | |
| f.write(f"{idx}\n{start} --> {end}\n{word['word']}\n\n") | |
| idx += 1 | |
| else: | |
| start = self.format_srt_time(seg['start'] - start_offset) | |
| end = self.format_srt_time(seg['end'] - start_offset) | |
| f.write(f"{idx}\n{start} --> {end}\n{seg['text']}\n\n") | |
| idx += 1 | |
| return output_path | |
| def add_subtitles(self, video_path, srt_path, output_path): | |
| """Burn subtitles into video""" | |
| subprocess.run([ | |
| 'ffmpeg', '-i', video_path, | |
| '-vf', f"subtitles={srt_path}:force_style='FontSize=28,FontName=Arial,OutlineColour=&H00000000,BorderStyle=1'", | |
| '-c:a', 'copy', '-preset', 'fast', output_path, '-y' | |
| ], capture_output=True) | |
| return output_path | |
| def process_video(self, video_source, num_clips=3): | |
| """Main processing pipeline""" | |
| # Step 1: Get video (YouTube URL or local file) | |
| if video_source.startswith(('http://', 'https://', 'www.')): | |
| video_path, title = self.download_video(video_source) | |
| if not video_path: | |
| return None, "Download failed" | |
| is_temp = True | |
| else: | |
| video_path = video_source | |
| title = os.path.basename(video_source) | |
| is_temp = False | |
| # Step 2: Transcribe | |
| transcription = self.transcribe_audio(video_path) | |
| # Step 3: Detect viral moments | |
| viral_clips = self.detect_viral_moments(transcription) | |
| if not viral_clips: | |
| return None, "No viral moments detected" | |
| # Step 4: Generate clips | |
| clips_info = [] | |
| video_id = uuid.uuid4().hex[:8] | |
| for i, clip in enumerate(viral_clips[:num_clips]): | |
| start = clip['start'] | |
| end = clip['end'] | |
| # Ensure minimum duration (15 seconds) | |
| if end - start < 15: | |
| end = min(start + 15, transcription['segments'][-1]['end']) | |
| # Ensure maximum duration (45 seconds) | |
| if end - start > 45: | |
| end = start + 45 | |
| vertical_path = os.path.join(OUTPUT_FOLDER, f"{video_id}_clip_{i+1}_vertical.mp4") | |
| srt_path = os.path.join(OUTPUT_FOLDER, f"{video_id}_clip_{i+1}.srt") | |
| final_path = os.path.join(OUTPUT_FOLDER, f"{video_id}_clip_{i+1}_final.mp4") | |
| # Create vertical clip | |
| self.create_vertical_clip(video_path, start, end, vertical_path) | |
| # Generate SRT | |
| self.generate_word_by_word_srt(transcription, start, end, srt_path) | |
| # Add subtitles | |
| self.add_subtitles(vertical_path, srt_path, final_path) | |
| clips_info.append({ | |
| 'clip_num': i+1, | |
| 'path': final_path, | |
| 'srt_path': srt_path, | |
| 'duration': round(end - start, 1), | |
| 'score': clip['score'], | |
| 'hook_text': clip['text'][:100], | |
| 'start_time': round(start, 1), | |
| 'end_time': round(end, 1) | |
| }) | |
| print(f"✅ Clip {i+1}: {start:.1f}s - {end:.1f}s (Score: {clip['score']})") | |
| # Cleanup original file if downloaded | |
| if is_temp and os.path.exists(video_path): | |
| os.remove(video_path) | |
| return clips_info, None | |
| # Initialize cutter | |
| cutter = ViralCutter() | |
| # ========== API ENDPOINTS ========== | |
| def health(): | |
| return jsonify({ | |
| 'status': 'healthy', | |
| 'storage': BASE_DIR, | |
| 'model': 'whisperx (local)', | |
| 'gemini': 'not required' | |
| }) | |
| def process_video(): | |
| """ | |
| Process video and generate shorts | |
| Form data: | |
| - url: YouTube URL | |
| - video: video file | |
| - num_clips: number of clips (default: 3) | |
| """ | |
| # Get parameters | |
| url = request.form.get('url', '') | |
| num_clips = int(request.form.get('num_clips', 3)) | |
| # Check for uploaded file | |
| if 'video' in request.files: | |
| file = request.files['video'] | |
| if file.filename: | |
| video_id = uuid.uuid4().hex[:8] | |
| video_path = os.path.join(UPLOAD_FOLDER, f"{video_id}_uploaded.mp4") | |
| file.save(video_path) | |
| video_source = video_path | |
| else: | |
| return jsonify({'error': 'Empty file'}), 400 | |
| elif url: | |
| video_source = url | |
| else: | |
| return jsonify({'error': 'Provide either video file or YouTube URL'}), 400 | |
| try: | |
| clips, error = cutter.process_video(video_source, num_clips) | |
| if error: | |
| return jsonify({'error': error}), 500 | |
| # Return download links | |
| result = { | |
| 'success': True, | |
| 'num_clips': len(clips), | |
| 'clips': [] | |
| } | |
| for clip in clips: | |
| result['clips'].append({ | |
| 'clip_num': clip['clip_num'], | |
| 'download_url': f"/download/{os.path.basename(clip['path'])}", | |
| 'srt_url': f"/download/{os.path.basename(clip['srt_path'])}", | |
| 'duration': clip['duration'], | |
| 'score': clip['score'], | |
| 'hook_preview': clip['hook_text'] | |
| }) | |
| return jsonify(result) | |
| except Exception as e: | |
| return jsonify({'error': str(e)}), 500 | |
| def download_file(filename): | |
| """Download generated file""" | |
| filepath = os.path.join(OUTPUT_FOLDER, filename) | |
| if not os.path.exists(filepath): | |
| return jsonify({'error': 'File not found'}), 404 | |
| return send_file(filepath, as_attachment=True) | |
| def list_clips(): | |
| """List all generated clips""" | |
| files = [] | |
| for f in os.listdir(OUTPUT_FOLDER): | |
| if f.endswith('_final.mp4'): | |
| stat = os.stat(os.path.join(OUTPUT_FOLDER, f)) | |
| files.append({ | |
| 'filename': f, | |
| 'size_mb': round(stat.st_size / (1024 * 1024), 2), | |
| 'modified': datetime.fromtimestamp(stat.st_mtime).isoformat() | |
| }) | |
| return jsonify({'clips': files}) | |
| def cleanup(): | |
| """Delete all generated files""" | |
| import shutil | |
| for folder in [UPLOAD_FOLDER, OUTPUT_FOLDER]: | |
| for f in os.listdir(folder): | |
| try: | |
| os.remove(os.path.join(folder, f)) | |
| except: | |
| pass | |
| return jsonify({'status': 'cleaned'}) | |
| # ========== MAIN ========== | |
| if __name__ == '__main__': | |
| port = int(os.environ.get('PORT', 7860)) | |
| app.run(host='0.0.0.0', port=port, debug=False) |