2236 lines
88 KiB
Python
2236 lines
88 KiB
Python
"""
|
|
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
|
|
)
|
|
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 matches video aspect ratio
|
|
video_container = QWidget()
|
|
video_container.setStyleSheet("""
|
|
QWidget {
|
|
background-color: #2d2d2d;
|
|
border: 2px solid #555555;
|
|
border-radius: 8px;
|
|
}
|
|
""")
|
|
video_container_layout = QVBoxLayout(video_container)
|
|
video_container_layout.setContentsMargins(0, 0, 0, 0)
|
|
video_container_layout.setSpacing(0)
|
|
|
|
# Adaptive video widget with intelligent sizing
|
|
self.video_widget = QVideoWidget()
|
|
|
|
# Set default size for 16:9 aspect ratio (1080p scaled down)
|
|
default_width = 640 # Standard width for video player
|
|
default_height = int(default_width * 9 / 16) # 16:9 aspect ratio = 360
|
|
|
|
self.video_widget.setMinimumSize(default_width, default_height)
|
|
self.video_widget.setMaximumHeight(600) # Limit maximum height
|
|
self.video_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
|
|
|
# Keep aspect ratio to prevent distortion, but adapt container size
|
|
self.video_widget.setAspectRatioMode(Qt.AspectRatioMode.KeepAspectRatio)
|
|
|
|
self.video_widget.setStyleSheet("""
|
|
QVideoWidget {
|
|
border: none;
|
|
border-radius: 6px;
|
|
background-color: #2d2d2d;
|
|
}
|
|
""")
|
|
|
|
# Add video widget with top alignment to push it up
|
|
video_container_layout.addWidget(self.video_widget, 0, Qt.AlignmentFlag.AlignTop)
|
|
|
|
# Add stretch to push video to top and leave space at bottom
|
|
video_container_layout.addStretch()
|
|
|
|
# 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(500) # Much larger to accommodate all tracks properly
|
|
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"""
|
|
super().resizeEvent(event)
|
|
|
|
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.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"""
|
|
if not self.current_video:
|
|
QMessageBox.warning(self, "No Video", "Please load a video first.")
|
|
return
|
|
|
|
filename = self.export_filename.text()
|
|
if not filename.endswith('.mp4'):
|
|
filename += '.mp4'
|
|
|
|
QMessageBox.information(self, "Export Started",
|
|
f"Video export started: {filename}\n"
|
|
f"Quality: {self.export_quality.currentText()}")
|
|
|
|
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)
|
|
|
|
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()
|