Spaces:
Sleeping
Sleeping
| import os | |
| import logging | |
| import warnings | |
| # Configure logging | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| # Suppress MoviePy warnings about frame reading | |
| warnings.filterwarnings('ignore', category=UserWarning, module='moviepy') | |
| try: | |
| from moviepy.editor import VideoFileClip, AudioFileClip, ImageClip, CompositeVideoClip, concatenate_audioclips | |
| from moviepy.audio.AudioClip import CompositeAudioClip | |
| from moviepy.audio.fx.volumex import volumex | |
| MOVIEPY_AVAILABLE = True | |
| logger.info("β MoviePy loaded successfully") | |
| except ImportError as e: | |
| logger.error(f"β MoviePy not available: {e}") | |
| MOVIEPY_AVAILABLE = False | |
| try: | |
| import yt_dlp | |
| YT_DLP_AVAILABLE = True | |
| logger.info("β yt-dlp loaded successfully") | |
| except ImportError as e: | |
| logger.error(f"β yt-dlp not available: {e}") | |
| YT_DLP_AVAILABLE = False | |
| try: | |
| from PIL import Image, ImageDraw, ImageFont | |
| import numpy as np | |
| PIL_AVAILABLE = True | |
| logger.info("β PIL (Pillow) loaded successfully") | |
| except ImportError as e: | |
| logger.error(f"β PIL not available: {e}") | |
| PIL_AVAILABLE = False | |
| class VideoEditor: | |
| def __init__(self, temp_folder='temp_audio'): | |
| if not MOVIEPY_AVAILABLE: | |
| raise ImportError("MoviePy is required for video editing. Install with: pip install moviepy") | |
| self.temp_folder = temp_folder | |
| os.makedirs(temp_folder, exist_ok=True) | |
| logger.info(f"β VideoEditor initialized with temp folder: {temp_folder}") | |
| def download_youtube_audio(self, youtube_url): | |
| """Download audio from YouTube video""" | |
| if not YT_DLP_AVAILABLE: | |
| raise ImportError("yt-dlp is required. Install with: pip install yt-dlp") | |
| try: | |
| audio_path = os.path.join(self.temp_folder, 'background_music.m4a') | |
| # Remove old file if exists | |
| if os.path.exists(audio_path): | |
| os.remove(audio_path) | |
| # Also check for mp3 version | |
| mp3_path = os.path.join(self.temp_folder, 'background_music.mp3') | |
| if os.path.exists(mp3_path): | |
| os.remove(mp3_path) | |
| ydl_opts = { | |
| 'format': 'bestaudio/best', | |
| 'outtmpl': os.path.join(self.temp_folder, 'background_music.%(ext)s'), | |
| 'quiet': True, | |
| 'no_warnings': True | |
| } | |
| logger.info(f"π₯ Downloading audio from: {youtube_url}") | |
| with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore | |
| info = ydl.extract_info(youtube_url, download=True) | |
| ext = info.get('ext', 'm4a') | |
| actual_path = os.path.join(self.temp_folder, f'background_music.{ext}') | |
| # Check which file was created | |
| if os.path.exists(actual_path): | |
| logger.info(f"β Downloaded audio from YouTube: {actual_path}") | |
| return actual_path | |
| elif os.path.exists(audio_path): | |
| logger.info(f"β Downloaded audio from YouTube: {audio_path}") | |
| return audio_path | |
| elif os.path.exists(mp3_path): | |
| logger.info(f"β Downloaded audio from YouTube: {mp3_path}") | |
| return mp3_path | |
| else: | |
| raise Exception("Audio file was not created") | |
| except Exception as e: | |
| logger.error(f"β Failed to download YouTube audio: {str(e)}") | |
| raise Exception(f"Failed to download music: {str(e)}") | |
| def add_background_music(self, video, audio_path, volume=0.3): | |
| """Add background music to video with volume control""" | |
| background_audio = None | |
| try: | |
| logger.info(f"π΅ Adding background music to video from: {audio_path}") | |
| # Check if audio_path is a local file or needs to be downloaded | |
| if not os.path.exists(audio_path): | |
| raise Exception(f"Audio file not found: {audio_path}") | |
| background_audio = AudioFileClip(audio_path) | |
| # Adjust background music duration to match video | |
| if background_audio.duration > video.duration: | |
| background_audio = background_audio.subclip(0, video.duration) | |
| else: | |
| # Loop if music is shorter than video | |
| loops_needed = int(video.duration / background_audio.duration) + 1 | |
| background_audio = concatenate_audioclips([background_audio] * loops_needed).subclip(0, video.duration) | |
| # Set background music volume | |
| background_audio = volumex(background_audio, volume) | |
| # Replace original audio with background music (mute original) | |
| video = video.set_audio(background_audio) | |
| logger.info("β Added background music to video (original audio muted)") | |
| return video | |
| except Exception as e: | |
| logger.error(f"β Failed to add background music: {str(e)}") | |
| raise Exception(f"Failed to add music: {str(e)}") | |
| def add_text_overlay(self, video, text, position='top', fontsize=50, color='white', | |
| bg_color='black', duration=None): | |
| """Add text overlay to video using PIL (no ImageMagick needed)""" | |
| try: | |
| if not PIL_AVAILABLE: | |
| raise ImportError("PIL (Pillow) is required. Install with: pip install Pillow") | |
| if duration is None: | |
| duration = video.duration | |
| logger.info(f"π Adding text overlay: {text[:30]}...") | |
| # Create an image with text using PIL | |
| img_width, img_height = video.size | |
| img = Image.new('RGBA', (img_width, img_height), (0, 0, 0, 0)) | |
| draw = ImageDraw.Draw(img) | |
| # Try to use a default font, fall back to default if not available | |
| try: | |
| font = ImageFont.truetype("arial.ttf", fontsize) | |
| except: | |
| try: | |
| font = ImageFont.truetype("Arial.ttf", fontsize) | |
| except: | |
| try: | |
| # For Windows | |
| font = ImageFont.truetype("C:/Windows/Fonts/arial.ttf", fontsize) | |
| except: | |
| logger.warning("β οΈ Could not load Arial font, using default") | |
| font = ImageFont.load_default() | |
| # Get text bounding box | |
| bbox = draw.textbbox((0, 0), text, font=font) | |
| text_width = bbox[2] - bbox[0] | |
| text_height = bbox[3] - bbox[1] | |
| # Position mapping | |
| if position == 'top': | |
| x = (img_width - text_width) // 2 | |
| y = 50 | |
| elif position == 'bottom': | |
| x = (img_width - text_width) // 2 | |
| y = img_height - text_height - 50 | |
| elif position == 'center': | |
| x = (img_width - text_width) // 2 | |
| y = (img_height - text_height) // 2 | |
| elif position == 'top-left': | |
| x = 50 | |
| y = 50 | |
| elif position == 'top-right': | |
| x = img_width - text_width - 50 | |
| y = 50 | |
| else: | |
| x = (img_width - text_width) // 2 | |
| y = 50 | |
| # Parse colors | |
| color_map = { | |
| 'white': (255, 255, 255), | |
| 'black': (0, 0, 0), | |
| 'red': (255, 0, 0), | |
| 'blue': (0, 0, 255), | |
| 'green': (0, 255, 0), | |
| 'yellow': (255, 255, 0) | |
| } | |
| text_color = color_map.get(color.lower(), (255, 255, 255)) | |
| stroke_color = color_map.get(bg_color.lower(), (0, 0, 0)) | |
| # Draw text with stroke (outline) | |
| stroke_width = 3 | |
| for adj_x in range(-stroke_width, stroke_width + 1): | |
| for adj_y in range(-stroke_width, stroke_width + 1): | |
| draw.text((x + adj_x, y + adj_y), text, font=font, fill=stroke_color) | |
| # Draw main text | |
| draw.text((x, y), text, font=font, fill=text_color) | |
| # Convert PIL image to numpy array | |
| img_array = np.array(img) | |
| # Create ImageClip from the array | |
| txt_clip = ImageClip(img_array, transparent=True) | |
| txt_clip = txt_clip.set_duration(duration) | |
| # Add fade in/out effects | |
| txt_clip = txt_clip.fadein(0.5).fadeout(0.5) | |
| # Composite video with text | |
| video = CompositeVideoClip([video, txt_clip]) | |
| logger.info(f"β Added text overlay: {text}") | |
| return video | |
| except Exception as e: | |
| logger.error(f"β Failed to add text overlay: {str(e)}") | |
| raise Exception(f"Failed to add text: {str(e)}") | |
| def edit_video(self, video_path, output_path, music_url=None, music_volume=0.3, | |
| text_overlays=None): | |
| """ | |
| Complete video editing with music and text overlays | |
| Args: | |
| video_path: Path to input video | |
| output_path: Path for output video | |
| music_url: YouTube URL OR local file path for background music | |
| music_volume: Volume of background music (0.0 to 1.0) | |
| text_overlays: List of dict with keys: text, position, duration | |
| """ | |
| if not os.path.exists(video_path): | |
| raise FileNotFoundError(f"Video file not found: {video_path}") | |
| # Ensure output path is a file, not a directory | |
| if os.path.isdir(output_path): | |
| video_filename = os.path.basename(video_path) | |
| output_path = os.path.join(output_path, os.path.splitext(video_filename)[0] + "_edited.mp4") | |
| logger.info(f"Output is a directory, saving to: {output_path}") | |
| video = None | |
| audio_path = None | |
| try: | |
| logger.info(f"π¬ Starting video editing for: {video_path}") | |
| with warnings.catch_warnings(): | |
| warnings.simplefilter("ignore") | |
| video = VideoFileClip(video_path) | |
| logger.info(f"π Video info: {video.duration:.2f}s, {video.size[0]}x{video.size[1]}") | |
| # β NEW: Handle both YouTube URLs and local files | |
| if music_url: | |
| # Check if it's a local file or YouTube URL | |
| if os.path.exists(music_url): | |
| # It's a local file | |
| logger.info(f"π΅ Using local music file: {music_url}") | |
| audio_path = music_url | |
| elif music_url.startswith('http'): | |
| # It's a YouTube URL | |
| logger.info(f"π΅ Downloading music from YouTube: {music_url}") | |
| audio_path = self.download_youtube_audio(music_url) | |
| else: | |
| raise Exception(f"Invalid music source: {music_url}") | |
| video = self.add_background_music(video, audio_path, music_volume) | |
| # Add text overlays if provided | |
| if text_overlays and len(text_overlays) > 0: | |
| logger.info(f"π Adding {len(text_overlays)} text overlays") | |
| for i, overlay in enumerate(text_overlays): | |
| logger.info(f"Adding overlay {i+1}/{len(text_overlays)}: {overlay.get('text', 'N/A')[:30]}") | |
| video = self.add_text_overlay( | |
| video, | |
| text=overlay.get('text', 'Subscribe!'), | |
| position=overlay.get('position', 'top'), | |
| fontsize=overlay.get('fontsize', 50), | |
| color=overlay.get('color', 'white'), | |
| duration=overlay.get('duration', min(5, video.duration)) | |
| ) | |
| # Write output video | |
| logger.info(f"πΎ Writing edited video to: {output_path}") | |
| logger.info(f"β³ This may take a few minutes... Please wait.") | |
| # Suppress warnings during video writing | |
| with warnings.catch_warnings(): | |
| warnings.simplefilter("ignore") | |
| video.write_videofile( | |
| output_path, | |
| codec='libx264', | |
| audio_codec='aac', | |
| temp_audiofile='temp-audio.m4a', | |
| remove_temp=True, | |
| fps=30, | |
| preset='medium', | |
| threads=4, | |
| logger=None # Suppress moviepy's verbose logging | |
| ) | |
| # Verify output file was created | |
| if not os.path.exists(output_path): | |
| raise Exception(f"Output video file was not created: {output_path}") | |
| file_size = os.path.getsize(output_path) | |
| if file_size == 0: | |
| raise Exception(f"Output video file is empty (0 bytes): {output_path}") | |
| file_size_mb = file_size / (1024 * 1024) | |
| logger.info(f"β Video editing completed: {output_path}") | |
| logger.info(f"π Output file size: {file_size_mb:.2f} MB") | |
| return output_path | |
| except Exception as e: | |
| logger.error(f"β Video editing failed: {str(e)}") | |
| import traceback | |
| logger.error(traceback.format_exc()) | |
| raise Exception(f"Video editing failed: {str(e)}") | |
| finally: | |
| # Cleanup - close video and wait a bit for file handles to release | |
| if video: | |
| try: | |
| video.close() | |
| import time | |
| time.sleep(0.5) # Give time for file handles to release | |
| except: | |
| pass | |
| # Clean up temp files | |
| self.cleanup_temp_files() | |
| def cleanup_temp_files(self): | |
| """Clean up temporary files""" | |
| try: | |
| import time | |
| import gc | |
| # Force garbage collection to release file handles | |
| gc.collect() | |
| time.sleep(0.5) | |
| if os.path.exists(self.temp_folder): | |
| for file in os.listdir(self.temp_folder): | |
| file_path = os.path.join(self.temp_folder, file) | |
| try: | |
| if os.path.isfile(file_path): | |
| os.remove(file_path) | |
| logger.debug(f"π§Ή Removed temp file: {file_path}") | |
| except PermissionError: | |
| # File still in use, skip it | |
| logger.debug(f"βοΈ Skipping temp file (still in use): {file_path}") | |
| except Exception as e: | |
| logger.warning(f"β οΈ Failed to remove {file_path}: {e}") | |
| except Exception as e: | |
| logger.warning(f"β οΈ Failed to cleanup temp files: {str(e)}") | |
| def main(): | |
| """Main function to run video editor with user input""" | |
| import argparse | |
| parser = argparse.ArgumentParser(description='Edit videos with background music and text overlays') | |
| parser.add_argument('--video', type=str, help='Path to input video file') | |
| parser.add_argument('--music', type=str, help='YouTube URL for background music') | |
| parser.add_argument('--output', type=str, help='Path for output video file') | |
| parser.add_argument('--volume', type=float, default=0.3, help='Music volume (0.0 to 1.0, default: 0.3)') | |
| parser.add_argument('--text', type=str, help='Text overlay (optional)') | |
| parser.add_argument('--text-position', type=str, default='top', | |
| choices=['top', 'bottom', 'center', 'top-left', 'top-right'], | |
| help='Text position (default: top)') | |
| args = parser.parse_args() | |
| print("\n" + "="*60) | |
| print("π¬ VIDEO EDITOR - Add Background Music & Text") | |
| print("="*60 + "\n") | |
| # Get video path | |
| if args.video: | |
| video_path = args.video | |
| else: | |
| video_path = input("πΉ Enter path to your video file: ").strip().strip('"') | |
| # Validate video path | |
| if not os.path.exists(video_path): | |
| print(f"β Error: Video file not found: {video_path}") | |
| return | |
| # Get music URL | |
| if args.music: | |
| music_url = args.music | |
| else: | |
| music_url = input("π΅ Enter YouTube URL for background music (or press Enter to skip): ").strip() | |
| if not music_url: | |
| music_url = None | |
| # Get output path | |
| if args.output: | |
| output_path = args.output | |
| else: | |
| default_output = os.path.splitext(video_path)[0] + "_edited.mp4" | |
| output_path = input(f"πΎ Enter output path (press Enter for '{default_output}'): ").strip().strip('"') | |
| if not output_path: | |
| output_path = default_output | |
| # Get volume | |
| music_volume = args.volume | |
| # Get text overlay | |
| text_overlays = None | |
| if args.text: | |
| text_overlays = [{ | |
| 'text': args.text, | |
| 'position': args.text_position, | |
| 'duration': None | |
| }] | |
| else: | |
| add_text = input("π Add text overlay? (y/n, press Enter to skip): ").strip().lower() | |
| if add_text == 'y': | |
| text = input("Enter text: ").strip() | |
| position = input("Position (top/bottom/center, default: top): ").strip() or 'top' | |
| text_overlays = [{ | |
| 'text': text, | |
| 'position': position, | |
| 'duration': None | |
| }] | |
| # Process video | |
| try: | |
| print("\n" + "="*60) | |
| print("π Starting video editing...") | |
| print("="*60 + "\n") | |
| editor = VideoEditor() | |
| result = editor.edit_video( | |
| video_path=video_path, | |
| output_path=output_path, | |
| music_url=music_url, | |
| music_volume=music_volume, | |
| text_overlays=text_overlays | |
| ) | |
| print("\n" + "="*60) | |
| print(f"β SUCCESS! Video saved to: {result}") | |
| # Verify and display file info | |
| if os.path.exists(result): | |
| file_size_mb = os.path.getsize(result) / (1024 * 1024) | |
| print(f"π File size: {file_size_mb:.2f} MB") | |
| print(f"π Open folder: {os.path.dirname(result)}") | |
| else: | |
| print("β οΈ Warning: Output file path shown but file not found!") | |
| print("="*60 + "\n") | |
| except Exception as e: | |
| print(f"\nβ Error: {str(e)}\n") | |
| import traceback | |
| traceback.print_exc() | |
| if __name__ == "__main__": | |
| main() | |