""" Professional Video Editor for Generated Shorts - PyQt6 Version Author: Dario Pascoal Description: This is a comprehensive video editing application designed specifically for editing short-form video content, fully migrated to PyQt6 for enhanced performance and professional features. The application provides a professional timeline-based interface similar to industry-standard video editing software, with features including: - Multi-track timeline with visual track roads for professional editing workflow - Hardware-accelerated video preview with frame-accurate scrubbing - Professional editing tools: trim, speed adjustment, volume control, fade effects - Real-time effects system with ripple, fade, and text overlays - Text overlay capabilities with customizable styling - Export functionality with multiple format support - Tabbed interface organizing tools into logical categories - Dark theme optimized for video editing work - Support for multiple video formats (MP4, AVI, MOV, MKV, etc.) - Full keyboard control system (Space, arrows, F11, etc.) - Professional fullscreen mode with all effects PyQt6 Advantages: - Hardware-accelerated video playback with QMediaPlayer - OpenGL timeline rendering for smooth performance - Professional UI components and styling - Better threading and signal/slot architecture - Native multimedia framework integration - GPU-accelerated effects pipeline - Professional dock system for panels - Modern styling with CSS-like stylesheets Technical Architecture: - Uses QMediaPlayer for hardware-accelerated video playback - Implements QGraphicsView-based timeline with precise time calculations - Signal/slot pattern for clean event handling - QThread-based background processing - Hardware-accelerated effects pipeline ready for expansion - Maintains professional video editing workflow patterns """ import sys import os import threading import time from datetime import datetime import cv2 import numpy as np from PIL import Image from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QSlider, QPushButton, QLabel, QFrame, QTabWidget, QScrollArea, QSplitter, QFileDialog, QMessageBox, QSpacerItem, QSizePolicy, QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QCheckBox, QProgressBar, QTextEdit, QGroupBox, QToolButton, QButtonGroup, QDockWidget, QStyleFactory, QStackedWidget, QStackedLayout ) from PyQt6.QtCore import ( Qt, QUrl, QTimer, pyqtSignal, QPropertyAnimation, QEasingCurve, QRect, QThread, pyqtSlot, QObject, QRunnable, QThreadPool, QMutex, QSize, QEvent, QMimeData, QPoint, QMetaObject, Q_ARG ) from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from PyQt6.QtMultimediaWidgets import QVideoWidget from PyQt6.QtGui import ( QPalette, QColor, QFont, QIcon, QKeySequence, QShortcut, QPixmap, QPainter, QBrush, QPen, QPolygon, QAction, QCursor, QDrag ) # Try to import MoviePy, handle if not available try: from moviepy.editor import VideoFileClip, TextClip, CompositeVideoClip from moviepy.video.fx import FadeIn, FadeOut, Resize from moviepy.audio.fx import MultiplyVolume MOVIEPY_AVAILABLE = True except ImportError: print("⚠️ MoviePy not available - using OpenCV backend for video processing") MOVIEPY_AVAILABLE = False class ProfessionalSlider(QSlider): """Custom slider with professional video editing styling""" def __init__(self, orientation=Qt.Orientation.Horizontal): super().__init__(orientation) self.setStyleSheet(""" QSlider::groove:horizontal { background: #2d2d2d; height: 6px; border-radius: 3px; border: 1px solid #404040; } QSlider::handle:horizontal { background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #00aaff, stop:1 #0088cc); border: 2px solid #ffffff; width: 16px; height: 16px; margin: -6px 0; border-radius: 8px; } QSlider::handle:horizontal:hover { background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #00ccff, stop:1 #00aadd); border: 2px solid #ffffff; } QSlider::handle:horizontal:pressed { background: #ffffff; border: 2px solid #00aaff; } QSlider::sub-page:horizontal { background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #00aaff, stop:1 #0077bb); border-radius: 3px; } QSlider::add-page:horizontal { background: #404040; border-radius: 3px; } """) class TimelineWidget(QWidget): """Professional timeline widget for video editing""" positionChanged = pyqtSignal(float) # Emit time position changes def __init__(self): super().__init__() self.duration = 30.0 # Start with 30 seconds default duration self.current_time = 0.0 self.zoom_level = 1.0 self.track_height = 70 # Adjusted for 5 tracks to fit properly self.ruler_height = 30 # Increased ruler height self.timeline_clips = [] # Store clips on timeline self.selected_clip_index = None # Track selected clip self.setAcceptDrops(True) # Enable drag and drop self.setup_ui() def setup_ui(self): """Setup timeline interface""" self.setMinimumHeight(400) # Much larger for proper track visibility self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) # Allow keyboard focus self.setAttribute(Qt.WidgetAttribute.WA_AcceptTouchEvents, False) # Disable touch to avoid conflicts self.setMouseTracking(False) # Ensure we only get mouse press events print(f"🔧 Timeline widget setup: size policy, focus policy, mouse tracking configured") self.setStyleSheet(""" TimelineWidget { background-color: #1e1e1e; border: 1px solid #404040; } """) def paintEvent(self, event): """Custom paint event for timeline rendering""" painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) # Draw timeline background painter.fillRect(self.rect(), QColor("#1e1e1e")) # Draw time ruler first self.draw_time_ruler(painter) # Draw track areas with visual representations like original Tkinter version track_count = 5 # Video 1, Video 2, Video 3, Audio 1, Audio 2 track_names = ["🎬 Video 1", "🎬 Video 2", "🎬 Video 3", "🎵 Audio 1", "🎵 Audio 2"] track_colors = ["#3498db", "#2ecc71", "#9b59b6", "#e74c3c", "#f39c12"] # Start tracks below the ruler ruler_bottom = self.ruler_height for i in range(track_count): y = ruler_bottom + (i * self.track_height) # Draw track background track_rect = QRect(0, y, self.width(), self.track_height) painter.fillRect(track_rect, QColor("#2d2d2d")) # Draw track border painter.setPen(QPen(QColor("#404040"), 1)) painter.drawRect(track_rect) # Draw track content area (like original design) content_rect = QRect(120, y + 10, self.width() - 130, self.track_height - 20) painter.fillRect(content_rect, QColor("#1a1a1a")) painter.setPen(QPen(QColor(track_colors[i]), 2)) painter.drawRect(content_rect) # Draw actual timeline clips for this track # Use effective timeline duration for consistent positioning effective_duration = self.get_effective_timeline_duration() content_width = content_rect.width() pixels_per_second = content_width / effective_duration # Draw clips on this track for clip_index, clip in enumerate(self.timeline_clips): if clip['track'] == i: # Calculate clip position and size clip_x = content_rect.x() + (clip['start_time'] * pixels_per_second) clip_width = max(60, clip['duration'] * pixels_per_second) # Minimum width for visibility clip_rect = QRect(int(clip_x), content_rect.y() + 3, int(clip_width), content_rect.height() - 6) # Determine if this clip is selected is_selected = (self.selected_clip_index == clip_index) # Make clips more visible with brighter colors and gradients if clip['type'] == 'video': # Bright blue for video clips clip_color = QColor("#4A90E2") if not is_selected else QColor("#00AA00") # Green when selected border_color = QColor("#2E5C8A") if not is_selected else QColor("#00FF00") # Bright green border when selected else: # Bright red for audio clips clip_color = QColor("#E74C3C") if not is_selected else QColor("#FF6600") # Orange when selected border_color = QColor("#C0392B") if not is_selected else QColor("#FF8800") # Bright orange border when selected # Fill with clip color painter.fillRect(clip_rect, clip_color) # Add a subtle gradient effect gradient_rect = QRect(clip_rect.x(), clip_rect.y(), clip_rect.width(), clip_rect.height() // 3) lighter_color = clip_color.lighter(130) painter.fillRect(gradient_rect, lighter_color) # Draw border - thicker for selected clips border_width = 3 if is_selected else 2 painter.setPen(QPen(border_color, border_width)) painter.drawRect(clip_rect) # Draw inner highlight - brighter for selected clips highlight_color = QColor("#ffffff") if not is_selected else QColor("#ffff00") painter.setPen(QPen(highlight_color, 1)) inner_rect = QRect(clip_rect.x() + 1, clip_rect.y() + 1, clip_rect.width() - 2, clip_rect.height() - 2) painter.drawRect(inner_rect) # Draw selection indicator for selected clips if is_selected: # Draw selection corners corner_size = 8 painter.setPen(QPen(QColor("#ffff00"), 2)) painter.setBrush(QColor("#ffff00")) # Top-left corner painter.drawRect(clip_rect.x() - 2, clip_rect.y() - 2, corner_size, corner_size) # Top-right corner painter.drawRect(clip_rect.x() + clip_rect.width() - corner_size + 2, clip_rect.y() - 2, corner_size, corner_size) # Bottom-left corner painter.drawRect(clip_rect.x() - 2, clip_rect.y() + clip_rect.height() - corner_size + 2, corner_size, corner_size) # Bottom-right corner painter.drawRect(clip_rect.x() + clip_rect.width() - corner_size + 2, clip_rect.y() + clip_rect.height() - corner_size + 2, corner_size, corner_size) # Draw clip filename with better contrast painter.setFont(QFont("Arial", 9, QFont.Weight.Bold)) text_color = QColor("#ffffff") if not is_selected else QColor("#000000") # Black text on selected clips painter.setPen(QPen(text_color, 1)) # Add text shadow for better readability (only for non-selected clips) if not is_selected: shadow_rect = QRect(clip_rect.x() + 3, clip_rect.y() + 1, clip_rect.width() - 6, clip_rect.height()) painter.setPen(QPen(QColor("#000000"), 1)) painter.drawText(shadow_rect, Qt.AlignmentFlag.AlignCenter, clip['filename'][:12] + "..." if len(clip['filename']) > 12 else clip['filename']) # Draw actual text text_rect = QRect(clip_rect.x() + 2, clip_rect.y(), clip_rect.width() - 4, clip_rect.height()) painter.setPen(QPen(text_color, 1)) painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, clip['filename'][:12] + "..." if len(clip['filename']) > 12 else clip['filename']) # If no clips on this track, draw sample content block for visual reference if not any(clip['track'] == i for clip in self.timeline_clips): block_width = min(200, content_width // 4) block_rect = QRect(content_rect.x() + 10, content_rect.y() + 5, block_width, content_rect.height() - 10) # Fill with track color (dimmed) dim_color = QColor(track_colors[i]) dim_color.setAlpha(80) painter.fillRect(block_rect, dim_color) painter.setPen(QPen(QColor("#666666"), 1)) painter.drawRect(block_rect) # Draw track name on the sample block painter.setFont(QFont("Arial", 9)) painter.setPen(QPen(QColor("#999999"), 1)) text_rect = QRect(block_rect.x() + 5, block_rect.y(), block_rect.width() - 10, block_rect.height()) painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, track_names[i]) # Draw track labels on the left label_rect = QRect(10, y, 100, self.track_height) painter.setFont(QFont("Arial", 11, QFont.Weight.Bold)) painter.setPen(QPen(QColor("#ffffff"), 1)) painter.drawText(label_rect, Qt.AlignmentFlag.AlignCenter, track_names[i]) # Draw separation line between ruler and tracks painter.setPen(QPen(QColor("#666666"), 2)) painter.drawLine(0, ruler_bottom, self.width(), ruler_bottom) # Draw playhead on top of everything effective_duration = self.get_effective_timeline_duration() if effective_duration > 0: # Use current video time directly since timeline now accommodates video duration timeline_position = self.current_time playhead_x = 120 + ((timeline_position / effective_duration) * (self.width() - 130)) # Only draw playhead if it's within the visible timeline area if 120 <= playhead_x <= self.width() - 20: painter.setPen(QPen(QColor("#ff4444"), 3)) painter.drawLine(int(playhead_x), ruler_bottom, int(playhead_x), self.height()) # Draw playhead handle handle_rect = QRect(int(playhead_x) - 8, ruler_bottom - 15, 16, 15) painter.fillRect(handle_rect, QColor("#ff4444")) painter.setPen(QPen(QColor("#ffffff"), 1)) painter.drawRect(handle_rect) def draw_time_ruler(self, painter): """Draw time ruler at the top using effective timeline duration with smart scaling""" painter.fillRect(0, 0, self.width(), self.ruler_height, QColor("#2d2d2d")) effective_duration = self.get_effective_timeline_duration() if effective_duration > 0: # Calculate time intervals based on effective timeline content_area_start = 120 content_area_width = self.width() - 130 pixels_per_second = content_area_width / effective_duration painter.setPen(QPen(QColor("#cccccc"), 1)) painter.setFont(QFont("Arial", 9)) # Smart interval calculation based on timeline duration and available space if effective_duration <= 300: # Under 5 minute major_interval = 10 # Every 10 seconds minor_interval = 1 # Minor marks every 1 seconds elif effective_duration <= 1800: # Under 30 minutes major_interval = 120 # Every 2 minutes minor_interval = 60 # Minor marks every minute else: # Over 30 minutes major_interval = 300 # Every 5 minutes minor_interval = 60 # Minor marks every 1 minutes # Draw major time markers with labels current_time = 0 while current_time <= effective_duration: x = content_area_start + (current_time * pixels_per_second) if x <= self.width() - 10: # Draw major marker line painter.setPen(QPen(QColor("#cccccc"), 2)) painter.drawLine(int(x), 0, int(x), self.ruler_height - 5) # Format and draw time label if current_time >= 60: minutes = int(current_time // 60) seconds = int(current_time % 60) time_text = f"{minutes}:{seconds:02d}" else: time_text = f"{int(current_time)}s" painter.setPen(QPen(QColor("#ffffff"), 1)) painter.drawText(int(x) + 2, self.ruler_height - 12, time_text) current_time += major_interval # Draw minor time markers (no labels) if minor_interval != major_interval: painter.setPen(QPen(QColor("#888888"), 1)) current_time = 0 while current_time <= effective_duration: if current_time % major_interval != 0: # Skip major intervals x = content_area_start + (current_time * pixels_per_second) if x <= self.width() - 10: painter.drawLine(int(x), self.ruler_height - 12, int(x), self.ruler_height - 5) current_time += minor_interval # Draw ruler border painter.setPen(QPen(QColor("#404040"), 1)) painter.drawRect(0, 0, self.width(), self.ruler_height) def mousePressEvent(self, event): """Handle timeline clicking for seeking and clip selection""" print(f"🖱️ Timeline mousePressEvent called: button={event.button()}, pos=({event.position().x():.1f}, {event.position().y():.1f})") if event.button() == Qt.MouseButton.LeftButton: # Only allow operations in the content area (not on track labels) content_area_start = 120 click_x = event.position().x() click_y = event.position().y() if click_x >= content_area_start: # Check if CTRL is pressed for clip selection ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier if ctrl_pressed: # CTRL+Click: Select/deselect clip without moving playhead clicked_clip_index = self.get_clip_at_position(click_x, click_y) if clicked_clip_index is not None: # CTRL+Click on clip: select it self.selected_clip_index = clicked_clip_index clip = self.timeline_clips[clicked_clip_index] print(f"🎯 CTRL+Click: Selected clip: {clip['filename']} on track {clip['track']}") self.update() else: # CTRL+Click on empty area: just deselect if self.selected_clip_index is not None: print("🎯 CTRL+Click: Deselected all clips (playhead stays)") self.selected_clip_index = None self.update() else: print("🎯 CTRL+Click: No clips to deselect (playhead stays)") # Do NOT move playhead when CTRL is pressed else: # Regular click without CTRL clicked_clip_index = self.get_clip_at_position(click_x, click_y) if clicked_clip_index is not None: # Click on clip: select it (no playhead movement) self.selected_clip_index = clicked_clip_index clip = self.timeline_clips[clicked_clip_index] print(f"🎯 Selected clip: {clip['filename']} on track {clip['track']}") self.update() else: # Click on empty area: deselect clips and move playhead # First handle deselection if self.selected_clip_index is not None: print("🎯 Deselected all clips") self.selected_clip_index = None # Then move playhead using effective timeline duration effective_duration = self.get_effective_timeline_duration() if effective_duration > 0: content_width = self.width() - 130 click_x_relative = click_x - content_area_start click_time = (click_x_relative / content_width) * effective_duration self.current_time = max(0, min(click_time, self.duration)) print(f"⏭️ Playhead moved to {self.current_time:.2f}s") self.positionChanged.emit(self.current_time) self.update() else: print(f"🖱️ Click outside content area (x={click_x:.1f}, content_start={content_area_start})") else: print(f"🖱️ Non-left button click: {event.button()}") def get_effective_timeline_duration(self): """Calculate the effective timeline duration for display and interaction This uses a dynamic timeline approach that expands to accommodate both clips and the loaded video duration. """ # Start with a minimum timeline duration min_duration = 30.0 # Find the latest clip end time latest_clip_time = 0 if self.timeline_clips: for clip in self.timeline_clips: clip_end = clip['start_time'] + clip['duration'] latest_clip_time = max(latest_clip_time, clip_end) # Consider the video duration if a video is loaded video_duration = getattr(self, 'duration', 0) # Use the maximum of: # 1. Minimum duration (30s) # 2. Latest clip time + padding # 3. Current video duration (if reasonable) timeline_duration = max( min_duration, latest_clip_time + 10.0, # Add 10s padding after clips min(video_duration, 3600) # Cap at 1 hour for UI performance ) return timeline_duration def get_clip_at_position(self, click_x, click_y): """Get clip index at the clicked position using effective timeline scaling""" if not self.timeline_clips: return None content_area_start = 120 content_width = self.width() - 130 # Use effective timeline duration instead of video duration effective_duration = self.get_effective_timeline_duration() pixels_per_second = content_width / effective_duration # Convert click position to time click_x_relative = click_x - content_area_start clicked_time = click_x_relative / pixels_per_second # Get track from Y position track_index = self.get_track_from_y(click_y) if track_index < 0: return None # Find clip at this position for i, clip in enumerate(self.timeline_clips): if clip['track'] == track_index: clip_start = clip['start_time'] clip_end = clip_start + clip['duration'] if clip_start <= clicked_time <= clip_end: return i return None def select_clip_at_position(self, click_x, click_y): """Select clip at position without affecting playhead""" clicked_clip_index = self.get_clip_at_position(click_x, click_y) if clicked_clip_index is not None: self.selected_clip_index = clicked_clip_index clip = self.timeline_clips[clicked_clip_index] print(f"🎯 Selected clip: {clip['filename']} on track {clip['track']}") else: self.selected_clip_index = None print("🎯 Deselected all clips") self.update() def keyPressEvent(self, event): """Handle keyboard events for clip operations""" if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace: # Delete selected clip self.delete_selected_clip() else: super().keyPressEvent(event) def delete_selected_clip(self): """Delete the currently selected clip""" if (self.selected_clip_index is not None and 0 <= self.selected_clip_index < len(self.timeline_clips)): deleted_clip = self.timeline_clips[self.selected_clip_index] del self.timeline_clips[self.selected_clip_index] print(f"🗑️ Deleted clip: {deleted_clip['filename']}") print(f"📊 Timeline now has {len(self.timeline_clips)} clip(s)") # Clear selection self.selected_clip_index = None # Update display self.update() def set_duration(self, duration): """Set timeline duration""" self.duration = duration self.update() def set_position(self, position): """Set current playhead position""" self.current_time = position self.update() def dragEnterEvent(self, event): """Handle drag enter event - check if it's a media item""" if event.mimeData().hasFormat("application/x-media-item"): event.acceptProposedAction() # Visual feedback self.setStyleSheet(""" TimelineWidget { background-color: #2a3a2a; border: 2px dashed #00aa00; } """) else: event.ignore() def dragMoveEvent(self, event): """Handle drag move event""" if event.mimeData().hasFormat("application/x-media-item"): event.acceptProposedAction() else: event.ignore() def dragLeaveEvent(self, event): """Handle drag leave event - restore normal appearance""" self.setStyleSheet(""" TimelineWidget { background-color: #1e1e1e; border: 1px solid #404040; } """) def dropEvent(self, event): """Handle drop event - add media to timeline""" # Restore normal appearance self.setStyleSheet(""" TimelineWidget { background-color: #1e1e1e; border: 1px solid #404040; } """) if event.mimeData().hasFormat("application/x-media-item"): # Get file path from mime data file_path = event.mimeData().data("application/x-media-item").data().decode() # Calculate drop position drop_x = event.position().x() drop_y = event.position().y() # Check if dropped in valid area (content area) content_area_start = 120 if drop_x >= content_area_start: # If timeline has no duration, set a default duration of 30 seconds if self.duration <= 0: self.duration = 30.0 # Calculate time position content_width = self.width() - 130 click_x = drop_x - content_area_start time_position = (click_x / content_width) * self.duration # Determine which track based on Y position track_index = self.get_track_from_y(drop_y) if track_index >= 0: # Create clip info clip_info = { 'file_path': file_path, 'filename': os.path.basename(file_path), 'start_time': max(0, time_position), 'duration': 3.0, # Default duration 'track': track_index, 'type': 'video' if self.is_video_file(file_path) else 'audio' } # Add to timeline clips self.timeline_clips.append(clip_info) self.update() # Visual and audio feedback for successful drop print(f"✅ Added {os.path.basename(file_path)} to track {track_index} at {time_position:.1f}s") print(f"📊 Timeline now has {len(self.timeline_clips)} clip(s)") # Trigger a repaint to immediately show the new clip QApplication.processEvents() event.acceptProposedAction() else: print("❌ Invalid track area") event.ignore() else: print("❌ Dropped outside content area") event.ignore() else: event.ignore() def get_track_from_y(self, y_pos): """Get track index from Y position""" ruler_bottom = self.ruler_height if y_pos < ruler_bottom: return -1 # Dropped on ruler track_y = y_pos - ruler_bottom track_index = int(track_y // self.track_height) # Validate track index (5 tracks: 0-4) if 0 <= track_index < 5: return track_index return -1 def is_video_file(self, file_path): """Check if file is a video file""" video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm'] ext = os.path.splitext(file_path)[1].lower() return ext in video_extensions class EffectsPanel(QWidget): """Professional effects panel with time-based controls""" effectApplied = pyqtSignal(str, dict) # effect_type, parameters def __init__(self): super().__init__() self.setup_ui() def setup_ui(self): """Setup effects panel interface""" layout = QVBoxLayout(self) # Ripple Effect ripple_group = QGroupBox("🌊 Ripple Effect") ripple_group.setStyleSheet(""" QGroupBox { font-weight: bold; border: 2px solid #404040; border-radius: 5px; margin-top: 10px; color: #ffffff; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px 0 5px; } """) ripple_layout = QVBoxLayout(ripple_group) # Time controls time_layout = QHBoxLayout() time_layout.addWidget(QLabel("Start:")) self.ripple_start = QDoubleSpinBox() self.ripple_start.setRange(0.0, 999.0) self.ripple_start.setSingleStep(0.1) self.ripple_start.setSuffix("s") time_layout.addWidget(self.ripple_start) time_layout.addWidget(QLabel("End:")) self.ripple_end = QDoubleSpinBox() self.ripple_end.setRange(0.0, 999.0) self.ripple_end.setSingleStep(0.1) self.ripple_end.setValue(2.0) self.ripple_end.setSuffix("s") time_layout.addWidget(self.ripple_end) ripple_layout.addLayout(time_layout) # Intensity control intensity_layout = QHBoxLayout() intensity_layout.addWidget(QLabel("Intensity:")) self.ripple_intensity = QSpinBox() self.ripple_intensity.setRange(1, 50) self.ripple_intensity.setValue(10) intensity_layout.addWidget(self.ripple_intensity) ripple_layout.addLayout(intensity_layout) # Apply button apply_ripple_btn = QPushButton("Apply Ripple Effect") apply_ripple_btn.clicked.connect(self.apply_ripple_effect) apply_ripple_btn.setStyleSheet(""" QPushButton { background-color: #0066cc; color: white; border: none; padding: 8px; border-radius: 4px; font-weight: bold; } QPushButton:hover { background-color: #0088ff; } """) ripple_layout.addWidget(apply_ripple_btn) layout.addWidget(ripple_group) # Fade Effect fade_group = QGroupBox("🌅 Fade Effect") fade_group.setStyleSheet(ripple_group.styleSheet()) fade_layout = QVBoxLayout(fade_group) # Fade time controls fade_time_layout = QHBoxLayout() fade_time_layout.addWidget(QLabel("Start:")) self.fade_start = QDoubleSpinBox() self.fade_start.setRange(0.0, 999.0) self.fade_start.setSingleStep(0.1) self.fade_start.setSuffix("s") fade_time_layout.addWidget(self.fade_start) fade_time_layout.addWidget(QLabel("End:")) self.fade_end = QDoubleSpinBox() self.fade_end.setRange(0.0, 999.0) self.fade_end.setSingleStep(0.1) self.fade_end.setValue(3.0) self.fade_end.setSuffix("s") fade_time_layout.addWidget(self.fade_end) fade_layout.addLayout(fade_time_layout) # Apply button apply_fade_btn = QPushButton("Apply Fade Effect") apply_fade_btn.clicked.connect(self.apply_fade_effect) apply_fade_btn.setStyleSheet(apply_ripple_btn.styleSheet()) fade_layout.addWidget(apply_fade_btn) layout.addWidget(fade_group) # Text Effect text_group = QGroupBox("📝 Text Overlay") text_group.setStyleSheet(ripple_group.styleSheet()) text_layout = QVBoxLayout(text_group) # Text input self.text_input = QLineEdit() self.text_input.setPlaceholderText("Enter text to overlay...") self.text_input.setText("Sample Text") text_layout.addWidget(self.text_input) # Text time controls text_time_layout = QHBoxLayout() text_time_layout.addWidget(QLabel("Start:")) self.text_start = QDoubleSpinBox() self.text_start.setRange(0.0, 999.0) self.text_start.setSingleStep(0.1) self.text_start.setSuffix("s") text_time_layout.addWidget(self.text_start) text_time_layout.addWidget(QLabel("End:")) self.text_end = QDoubleSpinBox() self.text_end.setRange(0.0, 999.0) self.text_end.setSingleStep(0.1) self.text_end.setValue(4.0) self.text_end.setSuffix("s") text_time_layout.addWidget(self.text_end) text_layout.addLayout(text_time_layout) # Apply button apply_text_btn = QPushButton("Apply Text Overlay") apply_text_btn.clicked.connect(self.apply_text_effect) apply_text_btn.setStyleSheet(apply_ripple_btn.styleSheet()) text_layout.addWidget(apply_text_btn) layout.addWidget(text_group) # Add stretch to push everything to top layout.addStretch() def apply_ripple_effect(self): """Apply ripple effect with current parameters""" params = { 'start_time': self.ripple_start.value(), 'end_time': self.ripple_end.value(), 'intensity': self.ripple_intensity.value() } self.effectApplied.emit('ripple', params) def apply_fade_effect(self): """Apply fade effect with current parameters""" params = { 'start_time': self.fade_start.value(), 'end_time': self.fade_end.value() } self.effectApplied.emit('fade', params) def apply_text_effect(self): """Apply text overlay with current parameters""" params = { 'start_time': self.text_start.value(), 'end_time': self.text_end.value(), 'text': self.text_input.text() } self.effectApplied.emit('text', params) class DraggableMediaItem(QPushButton): """Draggable media item that can be dragged to timeline""" mediaSelected = pyqtSignal(str) # file_path def __init__(self, file_path, parent=None): super().__init__(f"🎬 {os.path.basename(file_path)}", parent) self.file_path = file_path self.drag_start_position = QPoint() self.dragging = False # Store media info for drag operations self.media_info = { 'path': file_path, 'filename': os.path.basename(file_path), 'type': 'video' if self.is_video_file(file_path) else 'audio' } self.setStyleSheet(""" QPushButton { text-align: left; padding: 4px 6px; margin: 0px; border: 1px solid #404040; border-radius: 3px; background-color: #2d2d2d; color: #ffffff; min-height: 18px; max-height: 26px; font-size: 12px; } QPushButton:hover { background-color: #404040; border: 1px solid #00aaff; } """) self.clicked.connect(self.select_media) def is_video_file(self, file_path): """Check if file is a video file""" video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm'] ext = os.path.splitext(file_path)[1].lower() return ext in video_extensions def select_media(self): """Emit signal for media selection""" self.mediaSelected.emit(self.file_path) print(f"🎬 Selected: {os.path.basename(self.file_path)}") def mousePressEvent(self, event): """Handle mouse press - start potential drag operation""" if event.button() == Qt.MouseButton.LeftButton: self.drag_start_position = event.pos() # Visual feedback for drag start self.setStyleSheet(""" QPushButton { text-align: left; padding: 4px 6px; margin: 0px; border: 2px solid #00aaff; border-radius: 3px; background-color: #003366; color: #ffffff; min-height: 18px; max-height: 26px; font-size: 12px; } """) print(f"🎬 Started dragging: {self.media_info['filename']}") super().mousePressEvent(event) def mouseMoveEvent(self, event): """Handle mouse move - perform drag operation""" if not (event.buttons() & Qt.MouseButton.LeftButton): return if not self.dragging and (event.pos() - self.drag_start_position).manhattanLength() < QApplication.startDragDistance(): return if not self.dragging: self.dragging = True # Start drag operation drag = QDrag(self) mimeData = QMimeData() # Store media info in mime data mimeData.setText(f"media_drag:{self.file_path}") mimeData.setData("application/x-media-item", self.file_path.encode()) drag.setMimeData(mimeData) # Create a more visible drag pixmap pixmap = QPixmap(150, 40) pixmap.fill(QColor(0, 170, 255, 200)) painter = QPainter(pixmap) painter.setPen(QPen(QColor("#ffffff"), 2)) painter.setFont(QFont("Arial", 10, QFont.Weight.Bold)) painter.drawText(10, 25, f"🎬 {os.path.basename(self.file_path)[:12]}...") painter.end() drag.setPixmap(pixmap) drag.setHotSpot(QPoint(75, 20)) # Execute drag dropAction = drag.exec(Qt.DropAction.CopyAction) # Reset dragging state self.dragging = False self.reset_appearance() def mouseReleaseEvent(self, event): """Handle mouse release - end drag operation""" self.reset_appearance() super().mouseReleaseEvent(event) def reset_appearance(self): """Reset button appearance to normal""" self.setStyleSheet(""" QPushButton { text-align: left; padding: 4px 6px; margin: 0px; border: 1px solid #404040; border-radius: 3px; background-color: #2d2d2d; color: #ffffff; min-height: 18px; max-height: 26px; font-size: 12px; } QPushButton:hover { background-color: #404040; border: 1px solid #00aaff; } """) class DropScrollArea(QScrollArea): """Custom scroll area that handles drag and drop events""" def __init__(self, media_bin, parent=None): super().__init__(parent) self.media_bin = media_bin self.setAcceptDrops(True) def dragEnterEvent(self, event): """Forward drag enter event to media bin""" self.media_bin.dragEnterEvent(event) def dragMoveEvent(self, event): """Forward drag move event to media bin""" self.media_bin.dragMoveEvent(event) def dragLeaveEvent(self, event): """Forward drag leave event to media bin""" self.media_bin.dragLeaveEvent(event) def dropEvent(self, event): """Forward drop event to media bin""" self.media_bin.dropEvent(event) class MediaBin(QWidget): """Professional media bin for file management with drag and drop support""" mediaSelected = pyqtSignal(str) # file_path def __init__(self): super().__init__() self.media_files = [] # Enable drag and drop from Windows Explorer self.setAcceptDrops(True) self.setup_ui() self.auto_load_media() def setup_ui(self): """Setup media bin interface""" layout = QVBoxLayout(self) # Header header = QLabel("📁 Media Bin") header.setStyleSheet(""" QLabel { color: #ffffff; font-size: 14px; font-weight: bold; padding: 10px; background-color: #2d2d2d; border-radius: 4px; } """) layout.addWidget(header) # Add media button add_btn = QPushButton("+ Add Media") add_btn.clicked.connect(self.add_media_file) add_btn.setStyleSheet(""" QPushButton { background-color: #006600; color: white; border: none; padding: 8px; border-radius: 4px; font-weight: bold; } QPushButton:hover { background-color: #008800; } """) layout.addWidget(add_btn) # Media list self.media_list = QWidget() self.media_list_layout = QVBoxLayout(self.media_list) # Minimize spacing between media items for ultra-compact layout self.media_list_layout.setSpacing(1) self.media_list_layout.setContentsMargins(2, 2, 2, 2) # Add stretch at the end to push items to the top self.media_list_layout.addStretch() scroll_area = DropScrollArea(self) scroll_area.setWidget(self.media_list) scroll_area.setWidgetResizable(True) # Store reference to handle events self.scroll_area = scroll_area scroll_area.setStyleSheet(""" QScrollArea { border: 1px solid #404040; border-radius: 4px; background-color: #1e1e1e; padding: 2px; } QScrollBar:vertical { width: 12px; background-color: #2d2d2d; } """) layout.addWidget(scroll_area, 1) # Auto-load existing media self.auto_load_media() def auto_load_media(self): """Auto-load media files from current directory""" video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv'] for file in os.listdir('.'): if any(file.lower().endswith(ext) for ext in video_extensions): self.add_media_to_list(file) def add_media_file(self): """Add media file through file dialog""" file_path, _ = QFileDialog.getOpenFileName( self, "Select Media File", "", "Video Files (*.mp4 *.avi *.mov *.mkv *.wmv *.flv *.webm);;All Files (*)" ) if file_path: self.add_media_to_list(file_path) def add_media_to_list(self, file_path): """Add media file to the list""" if file_path not in self.media_files: self.media_files.append(file_path) # Create draggable media item widget media_item = DraggableMediaItem(file_path) media_item.mediaSelected.connect(self.handle_media_selected) # Insert before the stretch to keep items at the top self.media_list_layout.insertWidget(self.media_list_layout.count() - 1, media_item) print(f"📁 Added to media bin: {os.path.basename(file_path)}") def handle_media_selected(self, file_path): """Handle media selection from draggable item""" self.mediaSelected.emit(file_path) def select_media(self, file_path): """Select media file""" self.mediaSelected.emit(file_path) print(f"🎬 Selected: {os.path.basename(file_path)}") def dragEnterEvent(self, event): """Handle drag enter event - check if files are being dragged""" if event.mimeData().hasUrls(): # Check if any of the URLs are video/audio files for url in event.mimeData().urls(): if url.isLocalFile(): file_path = url.toLocalFile() ext = os.path.splitext(file_path)[1].lower() video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm', '.mp3', '.wav', '.aac', '.ogg', '.m4a'] if ext in video_extensions: event.acceptProposedAction() # Visual feedback - change scroll area background color self.scroll_area.setStyleSheet(""" QScrollArea { border: 2px dashed #00aa00; border-radius: 4px; background-color: #2a4a2a; padding: 2px; } QScrollBar:vertical { width: 12px; background-color: #2d2d2d; } """) return event.ignore() def dragMoveEvent(self, event): """Handle drag move event""" if event.mimeData().hasUrls(): event.acceptProposedAction() else: event.ignore() def dragLeaveEvent(self, event): """Handle drag leave event - restore normal appearance""" self.scroll_area.setStyleSheet(""" QScrollArea { border: 1px solid #404040; border-radius: 4px; background-color: #1e1e1e; padding: 2px; } QScrollBar:vertical { width: 12px; background-color: #2d2d2d; } """) def dropEvent(self, event): """Handle drop event - add dropped files to media bin""" # Restore normal appearance self.scroll_area.setStyleSheet(""" QScrollArea { border: 1px solid #404040; border-radius: 4px; background-color: #1e1e1e; padding: 2px; } QScrollBar:vertical { width: 12px; background-color: #2d2d2d; } """) if event.mimeData().hasUrls(): for url in event.mimeData().urls(): if url.isLocalFile(): file_path = url.toLocalFile() ext = os.path.splitext(file_path)[1].lower() video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm', '.mp3', '.wav', '.aac', '.ogg', '.m4a'] if ext in video_extensions: self.add_media_to_list(file_path) print(f"🎯 Dropped file: {os.path.basename(file_path)}") event.acceptProposedAction() else: event.ignore() class ProfessionalVideoEditor(QMainWindow): """Professional video editor with PyQt6""" def __init__(self): super().__init__() # Video state self.current_video = None self.current_time = 0.0 self.video_duration = 0.0 self.is_playing = False self.current_frame = None # Effects state self.effects_enabled = { 'ripple': False, 'fade': False, 'text': False } self.effect_times = { 'ripple': {'start': 0.0, 'end': 2.0}, 'fade': {'start': 0.0, 'end': 3.0}, 'text': {'start': 0.0, 'end': 4.0} } self.effect_params = {} # Fullscreen state self.is_fullscreen = False self.fullscreen_window = None self.setup_ui() self.setup_media_player() self.setup_shortcuts() self.setup_styling() def setup_ui(self): """Setup the main professional video editor interface""" self.setWindowTitle("Professional Video Editor - PyQt6") self.setGeometry(100, 100, 1400, 900) self.setMinimumSize(1000, 700) # Central widget with splitter layout central_widget = QWidget() self.setCentralWidget(central_widget) main_layout = QHBoxLayout(central_widget) main_layout.setContentsMargins(5, 5, 5, 5) # Main splitter main_splitter = QSplitter(Qt.Orientation.Horizontal) main_layout.addWidget(main_splitter) # Left panel (Media Bin) left_panel = QWidget() left_panel.setFixedWidth(250) left_layout = QVBoxLayout(left_panel) self.media_bin = MediaBin() self.media_bin.mediaSelected.connect(self.load_video_file) left_layout.addWidget(self.media_bin) main_splitter.addWidget(left_panel) # Center panel (Video Player + Timeline) center_panel = QWidget() center_layout = QVBoxLayout(center_panel) # Video player area video_frame = QFrame() video_frame.setMinimumHeight(480) # 400 for video + 60 for controls + some padding video_frame.setStyleSheet(""" QFrame { background-color: #2d2d2d; border: 2px solid #404040; border-radius: 8px; } """) video_layout = QVBoxLayout(video_frame) video_layout.setContentsMargins(5, 5, 5, 5) # Add some padding # Adaptive video container that properly constrains video scaling video_container = QWidget() video_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) video_container.setStyleSheet(""" QWidget { background-color: #2d2d2d; border: 2px solid #555555; border-radius: 8px; } """) video_container_layout = QVBoxLayout(video_container) video_container_layout.setContentsMargins(5, 5, 5, 5) # Small padding to ensure video stays inside video_container_layout.setSpacing(0) # Create video widget with proper container-based scaling self.video_widget = QVideoWidget() # Set the video widget to scale with its container self.video_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # Keep aspect ratio and fit within container bounds (won't overflow) self.video_widget.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio) self.video_widget.setStyleSheet(""" QVideoWidget { border: none; border-radius: 6px; background-color: #2d2d2d; } """) # Add video widget centered in container with proper alignment video_container_layout.addWidget(self.video_widget, 0, Qt.AlignmentFlag.AlignCenter) # Store references for dynamic sizing self.video_container = video_container print("📺 Clean video widget initialized (no overlay)") # Add placeholder label for when no video is loaded self.video_placeholder = QLabel("🎬 Select a video from Media Bin to start editing") self.video_placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_placeholder.setStyleSheet(""" QLabel { color: #888888; font-size: 16px; font-weight: bold; background-color: transparent; padding: 20px; } """) # Create a stacked widget to switch between placeholder and video container self.video_stack = QStackedWidget() self.video_stack.addWidget(self.video_placeholder) # Index 0 self.video_stack.addWidget(self.video_container) # Index 1 - use the stored container self.video_stack.setCurrentIndex(0) # Start with placeholder video_layout.addWidget(self.video_stack) # Set object name for easy finding later video_frame.setObjectName("video_frame") center_layout.addWidget(video_frame, 1) # Timeline and playback controls timeline_frame = QFrame() timeline_frame.setFixedHeight(300) # Reduced height to fit all tracks without cutting off timeline_frame.setStyleSheet(""" QFrame { background-color: #1e1e1e; border: 1px solid #404040; border-radius: 4px; } """) timeline_layout = QVBoxLayout(timeline_frame) # Playback controls playback_controls = QHBoxLayout() # Time display self.time_label = QLabel("00:00 / 00:00") self.time_label.setStyleSheet("color: #ffffff; font-weight: bold;") playback_controls.addWidget(self.time_label) playback_controls.addStretch() # Play/Pause button self.play_pause_btn = QPushButton("▶") self.play_pause_btn.clicked.connect(self.toggle_playback) self.play_pause_btn.setStyleSheet(""" QPushButton { background-color: #00aa00; color: white; border: none; padding: 10px 15px; border-radius: 6px; font-size: 16px; font-weight: bold; min-width: 50px; } QPushButton:hover { background-color: #00cc00; } """) playback_controls.addWidget(self.play_pause_btn) # Add some spacing between buttons playback_controls.addSpacing(10) # Fullscreen button - Add next to the play button in timeline self.timeline_fullscreen_btn = QPushButton("⛶ Fullscreen") self.timeline_fullscreen_btn.setToolTip("Fullscreen (F11)") self.timeline_fullscreen_btn.clicked.connect(self.toggle_fullscreen) self.timeline_fullscreen_btn.setStyleSheet(""" QPushButton { background-color: #0066cc; color: white; border: none; padding: 10px 15px; border-radius: 6px; font-size: 14px; font-weight: bold; min-width: 100px; } QPushButton:hover { background-color: #0088ff; } QPushButton:pressed { background-color: #004499; } """) playback_controls.addWidget(self.timeline_fullscreen_btn) print("🎮 Added fullscreen button to timeline controls") playback_controls.addStretch() timeline_layout.addLayout(playback_controls) # Timeline slider timeline_slider_layout = QHBoxLayout() self.timeline_slider = ProfessionalSlider() self.timeline_slider.setRange(0, 1000) self.timeline_slider.sliderPressed.connect(self.on_timeline_pressed) self.timeline_slider.sliderReleased.connect(self.on_timeline_released) self.timeline_slider.valueChanged.connect(self.on_timeline_changed) timeline_slider_layout.addWidget(self.timeline_slider, 1) timeline_layout.addLayout(timeline_slider_layout) # Professional timeline widget self.timeline_widget = TimelineWidget() self.timeline_widget.positionChanged.connect(self.seek_to_time) timeline_layout.addWidget(self.timeline_widget, 1) center_layout.addWidget(timeline_frame) main_splitter.addWidget(center_panel) # Right panel (Tabbed Tools) right_panel = QWidget() right_panel.setFixedWidth(300) right_layout = QVBoxLayout(right_panel) # Create tabbed interface for tools self.tool_tabs = QTabWidget() self.tool_tabs.setStyleSheet(""" QTabWidget::pane { border: 1px solid #404040; background-color: #2d2d2d; } QTabWidget::tab-bar { alignment: center; } QTabBar::tab { background-color: #404040; color: #ffffff; padding: 8px 12px; margin: 2px; border-radius: 4px; } QTabBar::tab:selected { background-color: #00aaff; color: #ffffff; font-weight: bold; } QTabBar::tab:hover { background-color: #606060; } """) # Effects tab self.effects_panel = EffectsPanel() self.effects_panel.effectApplied.connect(self.apply_effect) self.tool_tabs.addTab(self.effects_panel, "🎨 Effects") # Basic editing tab basic_edit_panel = self.create_basic_edit_panel() self.tool_tabs.addTab(basic_edit_panel, "✂️ Edit") # Export tab export_panel = self.create_export_panel() self.tool_tabs.addTab(export_panel, "📤 Export") right_layout.addWidget(self.tool_tabs) main_splitter.addWidget(right_panel) # Set splitter proportions main_splitter.setSizes([250, 850, 300]) # Status bar self.statusBar().showMessage("Ready - Select a video from the media bin to start") def setup_media_player(self): """Setup PyQt6 media player""" self.media_player = QMediaPlayer() self.audio_output = QAudioOutput() self.media_player.setVideoOutput(self.video_widget) self.media_player.setAudioOutput(self.audio_output) # Connect signals self.media_player.positionChanged.connect(self.update_position) self.media_player.durationChanged.connect(self.update_duration) self.media_player.playbackStateChanged.connect(self.update_playback_state) self.media_player.errorOccurred.connect(self.handle_media_error) # Connect video output changed to get video dimensions self.media_player.metaDataChanged.connect(self.update_video_info) # Position update timer self.position_timer = QTimer() self.position_timer.timeout.connect(self.update_timeline_position) self.position_timer.start(50) # 20 FPS updates self.timeline_dragging = False def setup_shortcuts(self): """Setup keyboard shortcuts""" # Spacebar - Play/Pause space_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Space), self) space_shortcut.activated.connect(self.toggle_playback) # Left/Right arrows left_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Left), self) left_shortcut.activated.connect(self.frame_backward) right_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Right), self) right_shortcut.activated.connect(self.frame_forward) # F11 - Fullscreen f11_shortcut = QShortcut(QKeySequence(Qt.Key.Key_F11), self) f11_shortcut.activated.connect(self.toggle_fullscreen) # ESC - Exit fullscreen esc_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self) esc_shortcut.activated.connect(self.exit_fullscreen) print("⌨️ Keyboard shortcuts enabled:") print(" SPACE: Play/Pause") print(" ←/→: Frame navigation") print(" F11: Toggle fullscreen") print(" ESC: Exit fullscreen") def resizeEvent(self, event): """Handle window resize and update video scaling""" super().resizeEvent(event) # Force video widget to update its size to fit within container if hasattr(self, 'video_widget') and hasattr(self, 'video_container'): # Give the layout system time to update QTimer.singleShot(10, self.update_video_size) def update_video_size(self): """Update video widget size to fit within its container""" try: if hasattr(self, 'video_widget') and hasattr(self, 'video_container'): # Get the container's available size (minus padding) container_size = self.video_container.size() available_width = container_size.width() - 10 # Account for padding available_height = container_size.height() - 10 # Account for padding # Calculate the maximum size while maintaining 16:9 aspect ratio aspect_ratio = 16.0 / 9.0 # Try fitting by width first width_constrained_height = available_width / aspect_ratio if width_constrained_height <= available_height: # Width is the limiting factor new_width = available_width new_height = int(width_constrained_height) else: # Height is the limiting factor new_height = available_height new_width = int(available_height * aspect_ratio) # Ensure minimum size new_width = max(new_width, 320) new_height = max(new_height, 180) # Apply the calculated size using size hints instead of fixed size self.video_widget.setMinimumSize(new_width, new_height) self.video_widget.setMaximumSize(new_width, new_height) except Exception as e: print(f"⚠️ Error updating video size: {e}") def setup_styling(self): """Apply professional dark theme""" self.setStyleSheet(""" QMainWindow { background-color: #1e1e1e; color: #ffffff; } QWidget { background-color: #1e1e1e; color: #ffffff; } QLabel { color: #ffffff; } QGroupBox { background-color: #2d2d2d; } QLineEdit, QSpinBox, QDoubleSpinBox { background-color: #404040; border: 1px solid #666666; border-radius: 3px; padding: 5px; color: #ffffff; } QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus { border: 2px solid #00aaff; } """) def create_basic_edit_panel(self): """Create basic editing tools panel""" panel = QWidget() layout = QVBoxLayout(panel) # Trim section trim_group = QGroupBox("✂️ Trim Video") trim_group.setStyleSheet(""" QGroupBox { font-weight: bold; border: 2px solid #404040; border-radius: 5px; margin-top: 10px; color: #ffffff; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px 0 5px; } """) trim_layout = QVBoxLayout(trim_group) # Trim controls trim_controls = QHBoxLayout() trim_controls.addWidget(QLabel("Start:")) self.trim_start = QDoubleSpinBox() self.trim_start.setRange(0.0, 999.0) self.trim_start.setSingleStep(0.1) self.trim_start.setSuffix("s") trim_controls.addWidget(self.trim_start) trim_controls.addWidget(QLabel("End:")) self.trim_end = QDoubleSpinBox() self.trim_end.setRange(0.0, 999.0) self.trim_end.setSingleStep(0.1) self.trim_end.setValue(10.0) self.trim_end.setSuffix("s") trim_controls.addWidget(self.trim_end) trim_layout.addLayout(trim_controls) trim_btn = QPushButton("Apply Trim") trim_btn.clicked.connect(self.apply_trim) trim_btn.setStyleSheet(""" QPushButton { background-color: #cc6600; color: white; border: none; padding: 8px; border-radius: 4px; font-weight: bold; } QPushButton:hover { background-color: #ff8800; } """) trim_layout.addWidget(trim_btn) layout.addWidget(trim_group) # Speed section speed_group = QGroupBox("⚡ Speed Control") speed_group.setStyleSheet(trim_group.styleSheet()) speed_layout = QVBoxLayout(speed_group) speed_controls = QHBoxLayout() speed_controls.addWidget(QLabel("Speed:")) self.speed_factor = QDoubleSpinBox() self.speed_factor.setRange(0.1, 10.0) self.speed_factor.setSingleStep(0.1) self.speed_factor.setValue(1.0) self.speed_factor.setSuffix("x") speed_controls.addWidget(self.speed_factor) speed_layout.addLayout(speed_controls) speed_btn = QPushButton("Apply Speed") speed_btn.clicked.connect(self.apply_speed) speed_btn.setStyleSheet(trim_btn.styleSheet()) speed_layout.addWidget(speed_btn) layout.addWidget(speed_group) # Volume section volume_group = QGroupBox("🔊 Volume Control") volume_group.setStyleSheet(trim_group.styleSheet()) volume_layout = QVBoxLayout(volume_group) volume_controls = QHBoxLayout() volume_controls.addWidget(QLabel("Volume:")) self.volume_factor = QDoubleSpinBox() self.volume_factor.setRange(0.0, 5.0) self.volume_factor.setSingleStep(0.1) self.volume_factor.setValue(1.0) self.volume_factor.setSuffix("x") volume_controls.addWidget(self.volume_factor) volume_layout.addLayout(volume_controls) volume_btn = QPushButton("Apply Volume") volume_btn.clicked.connect(self.apply_volume) volume_btn.setStyleSheet(trim_btn.styleSheet()) volume_layout.addWidget(volume_btn) layout.addWidget(volume_group) layout.addStretch() return panel def create_export_panel(self): """Create export tools panel""" panel = QWidget() layout = QVBoxLayout(panel) # Export settings export_group = QGroupBox("📤 Export Settings") export_group.setStyleSheet(""" QGroupBox { font-weight: bold; border: 2px solid #404040; border-radius: 5px; margin-top: 10px; color: #ffffff; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px 0 5px; } """) export_layout = QVBoxLayout(export_group) # Output filename filename_layout = QHBoxLayout() filename_layout.addWidget(QLabel("Filename:")) self.export_filename = QLineEdit() self.export_filename.setText("edited_video.mp4") filename_layout.addWidget(self.export_filename) export_layout.addLayout(filename_layout) # Quality settings quality_layout = QHBoxLayout() quality_layout.addWidget(QLabel("Quality:")) self.export_quality = QComboBox() self.export_quality.addItems(["High", "Medium", "Low"]) quality_layout.addWidget(self.export_quality) export_layout.addLayout(quality_layout) # Export button export_btn = QPushButton("🎬 Export Video") export_btn.setObjectName("export_btn") export_btn.clicked.connect(self.export_video) export_btn.setStyleSheet(""" QPushButton { background-color: #006600; color: white; border: none; padding: 12px; border-radius: 6px; font-weight: bold; font-size: 14px; } QPushButton:hover { background-color: #008800; } """) export_layout.addWidget(export_btn) layout.addWidget(export_group) # Export progress self.export_progress = QProgressBar() self.export_progress.setVisible(False) layout.addWidget(self.export_progress) layout.addStretch() return panel def apply_trim(self): """Apply trim effect""" QMessageBox.information(self, "Trim Applied", f"Trim applied: {self.trim_start.value():.1f}s to {self.trim_end.value():.1f}s") def apply_speed(self): """Apply speed effect""" QMessageBox.information(self, "Speed Applied", f"Speed changed to {self.speed_factor.value():.1f}x") def apply_volume(self): """Apply volume effect""" QMessageBox.information(self, "Volume Applied", f"Volume changed to {self.volume_factor.value():.1f}x") def export_video(self): """Export the edited video with progress tracking""" if not self.current_video: QMessageBox.warning(self, "No Video", "Please load a video first.") return # Timeline clips are optional - we can export just the main video # or main video with timeline clips overlaid print(f"🎬 Exporting video: {self.current_video}") if hasattr(self.timeline_widget, 'timeline_clips') and self.timeline_widget.timeline_clips: print(f"📊 With {len(self.timeline_widget.timeline_clips)} timeline clips") else: print("📊 Main video only (no timeline clips)") filename = self.export_filename.text().strip() if not filename: filename = "edited_video.mp4" if not filename.endswith('.mp4'): filename += '.mp4' # Create output path in the shorts folder output_path = os.path.join("shorts", "edited", filename) os.makedirs(os.path.dirname(output_path), exist_ok=True) # Show progress bar and disable export button self.export_progress.setVisible(True) self.export_progress.setValue(0) export_btn = self.sender() export_btn.setEnabled(False) export_btn.setText("⏳ Exporting...") # Start export in a separate thread from threading import Thread import time def export_thread(): try: self.export_timeline_video(output_path) except Exception as e: print(f"❌ Export error: {e}") QMetaObject.invokeMethod(self, "export_finished", Qt.ConnectionType.QueuedConnection, Q_ARG(bool, False), Q_ARG(str, str(e))) else: QMetaObject.invokeMethod(self, "export_finished", Qt.ConnectionType.QueuedConnection, Q_ARG(bool, True), Q_ARG(str, output_path)) Thread(target=export_thread, daemon=True).start() @pyqtSlot(bool, str) def export_finished(self, success, message): """Handle export completion""" # Hide progress bar and re-enable export button self.export_progress.setVisible(False) export_btn = self.findChild(QPushButton, "export_btn") if not export_btn: # Find by text if objectName not set for btn in self.findChildren(QPushButton): if "Export" in btn.text(): export_btn = btn break if export_btn: export_btn.setEnabled(True) export_btn.setText("🎬 Export Video") if success: QMessageBox.information(self, "Export Complete", f"Video exported successfully!\n\nSaved to: {message}") else: QMessageBox.critical(self, "Export Failed", f"Export failed: {message}") def export_timeline_video(self, output_path): """Export video with main video as base track and timeline clips overlaid""" import cv2 import numpy as np try: # Check if we have a main video loaded if not self.current_video: raise Exception("No main video loaded") # Get timeline clips (these will be overlays) clips = self.timeline_widget.timeline_clips # Update progress QMetaObject.invokeMethod(self.export_progress, "setValue", Qt.ConnectionType.QueuedConnection, Q_ARG(int, 10)) # Open main video to get its properties and duration main_video_path = self.current_video if not os.path.exists(main_video_path): raise Exception(f"Main video not found: {main_video_path}") main_cap = cv2.VideoCapture(main_video_path) if not main_cap.isOpened(): raise Exception(f"Could not open main video: {main_video_path}") # Get video properties from main video fps = int(main_cap.get(cv2.CAP_PROP_FPS)) width = int(main_cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(main_cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) total_frames = int(main_cap.get(cv2.CAP_PROP_FRAME_COUNT)) main_duration = total_frames / fps print(f"🎬 Main video: {main_video_path}") print(f"📊 Properties: {width}x{height} @ {fps}fps, Duration: {main_duration:.2f}s") # Update progress QMetaObject.invokeMethod(self.export_progress, "setValue", Qt.ConnectionType.QueuedConnection, Q_ARG(int, 20)) # Create video writer fourcc = cv2.VideoWriter_fourcc(*'mp4v') out = cv2.VideoWriter(output_path, fourcc, fps, (width, height)) if not out.isOpened(): main_cap.release() raise Exception(f"Could not create output video: {output_path}") print(f"🎯 Processing {total_frames} frames...") # Process each frame frame_count = 0 while True: ret, main_frame = main_cap.read() if not ret: break current_time = frame_count / fps # Start with the main video frame output_frame = main_frame.copy() # Overlay any timeline clips that are active at this time for clip in clips: if clip['start_time'] <= current_time < clip['start_time'] + clip['duration']: if clip['type'] == 'video': # Calculate position in the clip clip_relative_time = current_time - clip['start_time'] # Open clip video and seek to the right frame clip_cap = cv2.VideoCapture(clip['filename']) clip_cap.set(cv2.CAP_PROP_POS_MSEC, clip_relative_time * 1000) clip_ret, clip_frame = clip_cap.read() clip_cap.release() if clip_ret and clip_frame is not None: # Resize clip frame to match main video if clip_frame.shape[1] != width or clip_frame.shape[0] != height: clip_frame = cv2.resize(clip_frame, (width, height)) # For now, replace the frame (in future could blend/overlay) output_frame = clip_frame # Write the final frame out.write(output_frame) frame_count += 1 # Update progress periodically if frame_count % 100 == 0: progress = 30 + int((frame_count / total_frames) * 60) QMetaObject.invokeMethod(self.export_progress, "setValue", Qt.ConnectionType.QueuedConnection, Q_ARG(int, min(progress, 90))) # Cleanup main_cap.release() out.release() # Final progress update QMetaObject.invokeMethod(self.export_progress, "setValue", Qt.ConnectionType.QueuedConnection, Q_ARG(int, 100)) print(f"✅ Export completed: {output_path}") print(f"📊 Processed {frame_count} frames") except Exception as e: print(f"❌ Export error: {e}") raise def setup_styling(self): """Apply professional dark theme""" self.setStyleSheet(""" QMainWindow { background-color: #1e1e1e; color: #ffffff; } QWidget { background-color: #1e1e1e; color: #ffffff; } QLabel { color: #ffffff; } QGroupBox { background-color: #2d2d2d; } QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox { background-color: #404040; border: 1px solid #666666; border-radius: 3px; padding: 5px; color: #ffffff; } QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus { border: 2px solid #00aaff; } QComboBox::drop-down { border: none; } QComboBox::down-arrow { image: none; border: none; } QProgressBar { border: 1px solid #404040; border-radius: 3px; text-align: center; background-color: #2d2d2d; } QProgressBar::chunk { background-color: #00aaff; border-radius: 2px; } """) def update_video_info(self): """Update video player size based on loaded video dimensions""" try: # Get video metadata if self.media_player.metaData(): video_size = self.media_player.metaData().get('Resolution') if video_size and hasattr(video_size, 'width') and hasattr(video_size, 'height'): self.adapt_video_player_size(video_size.width(), video_size.height()) return # Fallback: Try to get size from video widget after a short delay QTimer.singleShot(1000, self.try_get_video_size_from_widget) except Exception as e: print(f"⚠️ Could not get video dimensions: {e}") # Use default 16:9 sizing self.adapt_video_player_size(1920, 1080) def try_get_video_size_from_widget(self): """Fallback method to get video size""" try: # Try to get size from video widget widget_size = self.video_widget.videoSize() if widget_size and widget_size.width() > 0 and widget_size.height() > 0: self.adapt_video_player_size(widget_size.width(), widget_size.height()) print(f"📐 Video size detected from widget: {widget_size.width()}x{widget_size.height()}") else: # Default to 16:9 if we can't detect self.adapt_video_player_size(1920, 1080) print("📐 Using default 16:9 aspect ratio") except Exception as e: print(f"⚠️ Fallback size detection failed: {e}") self.adapt_video_player_size(1920, 1080) def adapt_video_player_size(self, video_width, video_height): """Use consistent size matching the placeholder - no dynamic scaling""" try: # Use the same size as the default video widget for consistency # This matches the size set in the initialization (640x360) default_width = 795 # Same as initial video widget size default_height = 420 # Same as initial video widget size (16:9 ratio) # Always use the same size regardless of video dimensions # This ensures consistent UI layout and eliminates black bar issues display_width = default_width display_height = default_height # Update video widget size to match default self.video_widget.setMinimumSize(display_width, display_height) self.video_widget.setMaximumSize(display_width, display_height) # Fixed size # Force layout update self.video_container.updateGeometry() self.video_stack.updateGeometry() print(f"📐 Using consistent video player size: {display_width}x{display_height} (original: {video_width}x{video_height})") except Exception as e: print(f"⚠️ Error setting video player size: {e}") def load_video_file(self, file_path): """Load video file""" try: self.current_video = file_path url = QUrl.fromLocalFile(file_path) self.media_player.setSource(url) # Switch from placeholder to video widget self.video_stack.setCurrentIndex(1) # Enable controls self.timeline_slider.setEnabled(True) self.play_pause_btn.setEnabled(True) self.timeline_fullscreen_btn.setEnabled(True) # Update video size to fit container after loading QTimer.singleShot(100, self.update_video_size) self.statusBar().showMessage(f"✅ Loaded: {os.path.basename(file_path)}") print(f"✅ Loaded video: {os.path.basename(file_path)}") except Exception as e: self.statusBar().showMessage(f"❌ Error: {e}") QMessageBox.critical(self, "Error", f"Failed to load video:\n{e}") def toggle_playback(self): """Toggle play/pause""" if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: self.media_player.pause() # Update timeline play button self.play_pause_btn.setText("▶") # Update integrated button text if it exists if hasattr(self, 'integrated_play_btn'): self.integrated_play_btn.setText("▶ Play") else: self.media_player.play() # Update timeline play button self.play_pause_btn.setText("⏸") # Update integrated button text if it exists if hasattr(self, 'integrated_play_btn'): self.integrated_play_btn.setText("⏸ Pause") def frame_backward(self): """Go back one frame""" current_pos = self.media_player.position() frame_duration = 1000 // 30 # ~33ms for 30fps new_pos = max(0, current_pos - frame_duration) self.media_player.setPosition(new_pos) def frame_forward(self): """Go forward one frame""" current_pos = self.media_player.position() frame_duration = 1000 // 30 # ~33ms for 30fps new_pos = min(self.media_player.duration(), current_pos + frame_duration) self.media_player.setPosition(new_pos) def seek_to_time(self, time_seconds): """Seek to specific time""" position_ms = int(time_seconds * 1000) self.media_player.setPosition(position_ms) def toggle_fullscreen(self): """Toggle fullscreen mode""" if self.is_fullscreen: self.exit_fullscreen() else: self.enter_fullscreen() def enter_fullscreen(self): """Enter fullscreen mode with improved widget handling""" try: self.is_fullscreen = True # Create fullscreen window self.fullscreen_window = QWidget() self.fullscreen_window.setWindowTitle("Video Fullscreen") self.fullscreen_window.setStyleSheet("background-color: black;") self.fullscreen_window.showFullScreen() # Create a new video widget for fullscreen (don't move the original) self.fullscreen_video_widget = QVideoWidget() self.media_player.setVideoOutput(self.fullscreen_video_widget) fullscreen_layout = QVBoxLayout(self.fullscreen_window) fullscreen_layout.setContentsMargins(0, 0, 0, 0) fullscreen_layout.addWidget(self.fullscreen_video_widget) # Add exit button overlay exit_btn = QPushButton("✕ Exit Fullscreen (ESC)") exit_btn.clicked.connect(self.exit_fullscreen) exit_btn.setStyleSheet(""" QPushButton { background-color: rgba(51, 51, 51, 200); color: white; border: none; padding: 15px 20px; border-radius: 8px; font-weight: bold; font-size: 14px; } QPushButton:hover { background-color: rgba(77, 77, 77, 220); } """) exit_btn.setFixedSize(200, 50) exit_btn.move(30, 30) exit_btn.setParent(self.fullscreen_window) exit_btn.show() self.fullscreen_window.setFocus() self.timeline_fullscreen_btn.setText("🗗 Exit FS") # Setup fullscreen shortcuts esc_fullscreen = QShortcut(QKeySequence(Qt.Key.Key_Escape), self.fullscreen_window) esc_fullscreen.activated.connect(self.exit_fullscreen) space_fullscreen = QShortcut(QKeySequence(Qt.Key.Key_Space), self.fullscreen_window) space_fullscreen.activated.connect(self.toggle_playback) print("🔳 Entered fullscreen mode successfully") except Exception as e: print(f"⚠️ Error entering fullscreen: {e}") self.is_fullscreen = False def exit_fullscreen(self): """Exit fullscreen mode with improved widget handling""" if self.is_fullscreen and self.fullscreen_window: try: self.is_fullscreen = False # Restore video output to original widget self.media_player.setVideoOutput(self.video_widget) # Clean up fullscreen window and video widget if hasattr(self, 'fullscreen_video_widget'): self.fullscreen_video_widget.setParent(None) self.fullscreen_video_widget = None self.fullscreen_window.close() self.fullscreen_window = None self.timeline_fullscreen_btn.setText("⛶ Fullscreen") print("🔲 Exited fullscreen mode successfully") except Exception as e: print(f"⚠️ Error exiting fullscreen: {e}") self.is_fullscreen = False self.fullscreen_window = None def restore_video_widget(self): """Safely restore video widget to original location""" try: # Find the video frame container for widget in self.centralWidget().findChildren(QFrame): if widget.objectName() == "video_frame": video_layout = widget.layout() if video_layout: video_layout.insertWidget(0, self.video_widget) print("📺 Video widget restored to original position") return # Fallback: recreate the video layout structure self.setup_video_container() except Exception as e: print(f"⚠️ Error restoring video widget: {e}") def setup_video_container(self): """Setup or recreate video container structure""" try: # Find the center panel and recreate video structure if needed center_widget = None for widget in self.centralWidget().findChildren(QWidget): if hasattr(widget, 'layout') and widget.layout(): layout = widget.layout() if layout.count() > 0: center_widget = widget break if center_widget: # Create new video frame video_frame = QFrame() video_frame.setObjectName("video_frame") video_frame.setStyleSheet(""" QFrame { background-color: #000000; border: 2px solid #404040; border-radius: 8px; } """) video_layout = QVBoxLayout(video_frame) video_layout.addWidget(self.video_widget) # Add back to center layout center_layout = center_widget.layout() center_layout.insertWidget(0, video_frame) print("🔧 Video container recreated successfully") except Exception as e: print(f"⚠️ Error setting up video container: {e}") def apply_effect(self, effect_type, params): """Apply effect with parameters""" self.effects_enabled[effect_type] = True self.effect_times[effect_type] = { 'start': params['start_time'], 'end': params['end_time'] } self.effect_params[effect_type] = params QMessageBox.information( self, "Effect Applied", f"{effect_type.title()} effect applied from {params['start_time']:.1f}s to {params['end_time']:.1f}s" ) print(f"🎨 Applied {effect_type} effect: {params}") def on_timeline_pressed(self): """Timeline pressed""" self.timeline_dragging = True def on_timeline_released(self): """Timeline released""" self.timeline_dragging = False if self.media_player.duration() > 0: position = (self.timeline_slider.value() / 1000.0) * self.media_player.duration() self.media_player.setPosition(int(position)) def on_timeline_changed(self, value): """Timeline value changed""" if self.timeline_dragging and self.media_player.duration() > 0: position = (value / 1000.0) * self.media_player.duration() self.current_time = position / 1000.0 self.update_time_display() def update_position(self, position): """Update position from media player""" self.current_time = position / 1000.0 # Update timeline slider if not self.timeline_dragging and self.media_player.duration() > 0: value = (position / self.media_player.duration()) * 1000 self.timeline_slider.setValue(int(value)) # Update timeline widget self.timeline_widget.set_position(self.current_time) # Update time display self.update_time_display() # Debug output for time tracking if int(self.current_time) % 5 == 0: # Every 5 seconds print(f"⏱️ Time: {self.format_time(self.current_time)} / {self.format_time(self.video_duration)}") def update_duration(self, duration): """Update duration""" self.video_duration = duration / 1000.0 self.timeline_widget.set_duration(self.video_duration) self.update_time_display() print(f"📏 Video duration set: {self.format_time(self.video_duration)}") def update_timeline_position(self): """High-frequency timeline updates""" if not self.timeline_dragging and self.media_player.duration() > 0: position = self.media_player.position() # Only update if position actually changed if abs(position - (self.current_time * 1000)) > 100: # 100ms threshold self.update_position(position) def update_time_display(self): """Update time display""" current = self.format_time(self.current_time) total = self.format_time(self.video_duration) self.time_label.setText(f"{current} / {total}") def update_playback_state(self, state): """Update play/pause button""" if state == QMediaPlayer.PlaybackState.PlayingState: self.play_pause_btn.setText("⏸") self.is_playing = True else: self.play_pause_btn.setText("▶") self.is_playing = False def handle_media_error(self, error): """Handle media errors""" error_msg = f"Media error: {error}" self.statusBar().showMessage(f"❌ {error_msg}") QMessageBox.critical(self, "Media Error", error_msg) def format_time(self, seconds): """Format time as mm:ss""" minutes = int(seconds // 60) seconds = int(seconds % 60) return f"{minutes:02d}:{seconds:02d}" def main(): """Run the PyQt6 professional video editor""" app = QApplication(sys.argv) # Set application properties app.setApplicationName("Professional Video Editor - PyQt6") app.setApplicationVersion("2.0") # Apply fusion style for modern look app.setStyle(QStyleFactory.create('Fusion')) # Create and show editor editor = ProfessionalVideoEditor() editor.show() print("🎬 Professional Video Editor (PyQt6) Started!") print("🚀 PyQt6 Features:") print(" • Hardware-accelerated video playback") print(" • Professional timeline with visual tracks") print(" • Real-time effects system") print(" • Modern docking interface") print(" • GPU-accelerated rendering") print(" • Professional keyboard shortcuts") sys.exit(app.exec()) if __name__ == "__main__": main()