├── docs └── image.png ├── requirements.txt ├── LICENSE ├── src ├── components │ ├── progress_dialog.py │ ├── waveform.py │ ├── video_player.py │ ├── timeline.py │ └── unified_timeline.py ├── utils │ ├── async_worker.py │ ├── audio_processor.py │ └── video_processor.py └── main.py ├── README.md └── .gitignore /docs/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/royshil/cut-it-out/main/docs/image.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PySide6>=6.4.0 2 | python-vlc>=3.0.18122 3 | pyqtgraph>=0.13.1 4 | numpy>=1.21.0 5 | pydub>=0.25.1 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Locaal AI: Open Tools for AI Developers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/progress_dialog.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtWidgets import QDialog, QProgressBar, QLabel, QVBoxLayout 2 | from PySide6.QtCore import Qt, Signal 3 | 4 | class ProgressDialog(QDialog): 5 | canceled = Signal() 6 | 7 | def __init__(self, parent=None): 8 | super().__init__(parent) 9 | self.setWindowTitle("Processing...") 10 | self.setWindowModality(Qt.WindowModal) 11 | self.setWindowFlags(Qt.Dialog | Qt.CustomizeWindowHint | Qt.WindowTitleHint) 12 | 13 | # Create layout 14 | layout = QVBoxLayout(self) 15 | 16 | # Status label 17 | self.status_label = QLabel("Loading...") 18 | layout.addWidget(self.status_label) 19 | 20 | # Progress bar 21 | self.progress_bar = QProgressBar() 22 | self.progress_bar.setRange(0, 100) 23 | self.progress_bar.setTextVisible(True) 24 | layout.addWidget(self.progress_bar) 25 | 26 | # Fixed size 27 | self.setFixedSize(300, 100) 28 | 29 | def set_progress(self, value, status=None): 30 | """Update progress bar and optionally status text""" 31 | self.progress_bar.setValue(int(value)) 32 | if status: 33 | self.status_label.setText(status) -------------------------------------------------------------------------------- /src/components/waveform.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pyqtgraph as pg 3 | from PySide6.QtWidgets import QWidget, QVBoxLayout 4 | from PySide6.QtCore import Qt 5 | 6 | class WaveformView(QWidget): 7 | def __init__(self): 8 | super().__init__() 9 | self.layout = QVBoxLayout(self) 10 | self.layout.setContentsMargins(0, 0, 0, 0) 11 | 12 | # Set up pyqtgraph plot 13 | self.plot_widget = pg.PlotWidget() 14 | self.plot_widget.setBackground('w') 15 | self.plot_widget.setMaximumHeight(150) 16 | self.plot_widget.showGrid(x=True, y=False) 17 | self.plot_widget.setLabel('bottom', 'Time (s)') 18 | 19 | # Remove Y axis - we don't need specific amplitude values 20 | self.plot_widget.hideAxis('left') 21 | 22 | self.layout.addWidget(self.plot_widget) 23 | 24 | def set_audio_data(self, audio_data): 25 | # Clear previous plot 26 | self.plot_widget.clear() 27 | 28 | # Plot the waveform 29 | pen = pg.mkPen(color='b', width=1) 30 | self.plot_widget.plot(audio_data['time'], audio_data['samples'], pen=pen) 31 | 32 | # Update view limits 33 | self.plot_widget.setXRange(0, audio_data['duration']) 34 | self.plot_widget.setYRange(-1, 1) -------------------------------------------------------------------------------- /src/utils/async_worker.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtCore import QObject, Signal, QThread 2 | import time 3 | 4 | class VideoLoadWorker(QObject): 5 | progress = Signal(int, str) 6 | finished = Signal(dict) 7 | error = Signal(str) 8 | 9 | def __init__(self, video_processor, audio_processor, file_path): 10 | super().__init__() 11 | self.video_processor = video_processor 12 | self.audio_processor = audio_processor 13 | self.file_path = file_path 14 | 15 | def run(self): 16 | try: 17 | # Check video metadata 18 | self.progress.emit(10, "Checking video file...") 19 | duration = self.video_processor.get_duration(self.file_path) 20 | 21 | # Extract audio data 22 | self.progress.emit(30, "Extracting audio...") 23 | audio_data = self.audio_processor.extract_audio(self.file_path) 24 | 25 | # Process video (check frames, generate thumbnails, etc.) 26 | self.progress.emit(60, "Processing video...") 27 | fps = self.video_processor.get_fps(self.file_path) 28 | 29 | # Final verification 30 | self.progress.emit(90, "Finalizing...") 31 | 32 | result = { 33 | 'duration': duration, 34 | 'audio_data': audio_data, 35 | 'fps': fps, 36 | 'path': self.file_path 37 | } 38 | 39 | self.progress.emit(100, "Complete!") 40 | self.finished.emit(result) 41 | 42 | except Exception as e: 43 | self.error.emit(str(e)) -------------------------------------------------------------------------------- /src/utils/audio_processor.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from pydub import AudioSegment 3 | import tempfile 4 | import subprocess 5 | 6 | 7 | class AudioProcessor: 8 | def __init__(self): 9 | self.sample_rate = 44100 10 | 11 | def extract_audio(self, video_path): 12 | """Extract audio from video file and return normalized samples""" 13 | # Extract audio using ffmpeg 14 | temp_audio = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) 15 | subprocess.run( 16 | [ 17 | "ffmpeg", 18 | "-i", 19 | video_path, 20 | "-vn", # No video 21 | "-acodec", 22 | "pcm_s16le", # PCM format 23 | "-ar", 24 | str(self.sample_rate), # Sample rate 25 | "-ac", 26 | "1", # Mono 27 | "-y", # Overwrite output file 28 | temp_audio.name, 29 | ], 30 | check=True, 31 | ) 32 | 33 | # Load audio file 34 | audio = AudioSegment.from_wav(temp_audio.name) 35 | 36 | # Convert to numpy array and normalize 37 | samples = np.array(audio.get_array_of_samples(), dtype=np.float32) 38 | samples = samples / np.max(np.abs(samples)) 39 | 40 | # Generate time array 41 | duration = len(samples) / self.sample_rate 42 | time = np.linspace(0, duration, len(samples)) 43 | 44 | # Downsample for display if necessary 45 | if len(samples) > 10000: 46 | target_size = 10000 47 | samples = self._downsample(samples, target_size) 48 | time = np.linspace(0, duration, target_size) 49 | 50 | return { 51 | "samples": samples, 52 | "time": time, 53 | "duration": duration, 54 | "sample_rate": self.sample_rate, 55 | } 56 | 57 | def _downsample(self, samples, target_size): 58 | """Downsample array to target size preserving peaks and troughs""" 59 | # Calculate chunk size 60 | chunk_size = len(samples) // target_size 61 | 62 | result = np.zeros(target_size) 63 | for i in range(target_size): 64 | chunk = samples[i * chunk_size : (i + 1) * chunk_size] 65 | if len(chunk) > 0: 66 | # Find both max and min in the chunk 67 | max_val = np.max(chunk) 68 | min_val = np.min(chunk) 69 | 70 | # Use the value with larger absolute magnitude 71 | result[i] = max_val if abs(max_val) > abs(min_val) else min_val 72 | 73 | return result 74 | -------------------------------------------------------------------------------- /src/utils/video_processor.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import json 3 | import tempfile 4 | from pathlib import Path 5 | 6 | class VideoProcessor: 7 | def get_duration(self, video_path): 8 | """Get video duration in seconds""" 9 | result = subprocess.run([ 10 | 'ffprobe', 11 | '-v', 'quiet', 12 | '-print_format', 'json', 13 | '-show_format', 14 | video_path 15 | ], capture_output=True, text=True, check=True) 16 | 17 | metadata = json.loads(result.stdout) 18 | return float(metadata['format']['duration']) 19 | 20 | def export_with_cuts(self, input_path, output_path, markers): 21 | """Export video with sections removed based on markers""" 22 | # Create temporary file for filter script 23 | filter_script = tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) 24 | 25 | # Generate filter complex command for cuts 26 | parts = [] 27 | for i, (start, end) in enumerate(markers): 28 | parts.append(f"[0:v]trim=start={start}:end={end},setpts=PTS-STARTPTS[v{i}]; " 29 | f"[0:a]atrim=start={start}:end={end},asetpts=PTS-STARTPTS[a{i}]") 30 | 31 | # Concatenate all parts 32 | n_parts = len(markers) 33 | video_stream = ''.join(f'[v{i}]' for i in range(n_parts)) 34 | audio_stream = ''.join(f'[a{i}]' for i in range(n_parts)) 35 | filter_script.write(f"{';'.join(parts)}; " 36 | f"{video_stream}concat=n={n_parts}:v=1:a=0[outv]; " 37 | f"{audio_stream}concat=n={n_parts}:v=0:a=1[outa]") 38 | filter_script.close() 39 | 40 | # Run ffmpeg 41 | subprocess.run([ 42 | 'ffmpeg', 43 | '-i', input_path, 44 | '-filter_complex_script', filter_script.name, 45 | '-map', '[outv]', 46 | '-map', '[outa]', 47 | '-c:v', 'libx264', 48 | '-c:a', 'aac', 49 | '-y', # Overwrite output 50 | output_path 51 | ], check=True) 52 | 53 | # Clean up 54 | Path(filter_script.name).unlink() 55 | 56 | def get_fps(self, video_path): 57 | """Get video frames per second""" 58 | result = subprocess.run([ 59 | 'ffprobe', 60 | '-v', 'quiet', 61 | '-select_streams', 'v:0', 62 | '-print_format', 'json', 63 | '-show_streams', 64 | video_path 65 | ], capture_output=True, text=True, check=True) 66 | 67 | metadata = json.loads(result.stdout) 68 | stream = metadata['streams'][0] 69 | 70 | # Parse frame rate fraction (e.g., "24000/1001" for 23.976 fps) 71 | num, den = map(int, stream['r_frame_rate'].split('/')) 72 | return num / den -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cut-It-Out 2 | 3 | A lightweight, cross-platform video editor focused on quick and simple cuts. Perfect for removing unwanted sections from video recordings, lectures, or any other video content. 4 | 5 | ![Cut-It-Out Screenshot](docs/image.png) 6 | 7 | ## Features 8 | 9 | - 🎥 Simple, intuitive video cutting interface 10 | - 🌊 Audio waveform visualization for precise cuts 11 | - ⌨️ Keyboard shortcuts for efficient editing 12 | - 🎯 Frame-accurate cutting 13 | - 🔄 Non-destructive editing 14 | - 💻 Cross-platform (Windows, macOS, Linux) 15 | 16 | ## Requirements 17 | 18 | - Python 3.7 or higher 19 | - VLC media player 20 | - FFmpeg 21 | 22 | ### System-specific Requirements 23 | 24 | #### Windows 25 | - VLC media player (64-bit version if using 64-bit Python) 26 | - FFmpeg added to system PATH 27 | 28 | #### macOS 29 | ```bash 30 | brew install vlc ffmpeg 31 | ``` 32 | 33 | #### Linux (Ubuntu/Debian) 34 | ```bash 35 | sudo apt-get install vlc ffmpeg python3-dev 36 | ``` 37 | 38 | ## Installation 39 | 40 | 1. Clone the repository: 41 | ```bash 42 | git clone https://github.com/yourusername/cut-it-out.git 43 | cd cut-it-out 44 | ``` 45 | 46 | 2. Create and activate a virtual environment: 47 | ```bash 48 | python -m venv venv 49 | # Windows 50 | venv\Scripts\activate 51 | # macOS/Linux 52 | source venv/bin/activate 53 | ``` 54 | 55 | 3. Install dependencies: 56 | ```bash 57 | pip install -r requirements.txt 58 | ``` 59 | 60 | ## Running the Application 61 | 62 | From the project directory: 63 | ```bash 64 | python main.py 65 | ``` 66 | 67 | ## Usage 68 | 69 | 1. Click "Load Video" to open your video file 70 | 2. Click on the timeline to set start point (green marker) 71 | 3. Click again to set end point (red marker) 72 | 4. Use keyboard shortcuts for precise control: 73 | - `ESC`: Remove last marker 74 | - `DELETE`: Remove selected section 75 | - `SPACE`: Play/Pause 76 | - `←/→`: Step frame by frame 77 | 5. Click "Export" to save your edited video 78 | 79 | ## Project Structure 80 | 81 | ``` 82 | cut-it-out/ 83 | ├── main.py # Application entry point 84 | ├── components/ 85 | │ ├── video_player.py # Video playback component 86 | │ ├── unified_timeline.py # Timeline with waveform 87 | │ └── progress_dialog.py # Loading progress feedback 88 | ├── utils/ 89 | │ ├── audio_processor.py # Audio waveform extraction 90 | │ ├── video_processor.py # Video processing utilities 91 | │ └── async_worker.py # Async loading handler 92 | └── requirements.txt # Python dependencies 93 | ``` 94 | 95 | ## Dependencies 96 | 97 | ``` 98 | PySide6>=6.4.0 99 | python-vlc>=3.0.18122 100 | pyqtgraph>=0.13.1 101 | numpy>=1.21.0 102 | pydub>=0.25.1 103 | ``` 104 | 105 | ## Known Issues 106 | 107 | - VLC media player must be installed and match Python architecture (32/64-bit) 108 | - Some video codecs might require additional system codecs 109 | - On Windows, FFmpeg must be properly added to system PATH 110 | 111 | ## Contributing 112 | 113 | Contributions are welcome! Please feel free to submit a Pull Request. 114 | 115 | ## License 116 | 117 | This project is licensed under the MIT License - see the LICENSE file for details. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /src/components/video_player.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import vlc 3 | from PySide6.QtWidgets import QFrame, QVBoxLayout 4 | from PySide6.QtCore import Qt, Signal 5 | 6 | class VideoPlayer(QFrame): 7 | position_changed = Signal(float) # Emits position in seconds 8 | duration_changed = Signal(float) # Emits duration in seconds 9 | 10 | def __init__(self): 11 | super().__init__() 12 | self.setMinimumHeight(400) 13 | self.setFrameStyle(QFrame.Panel | QFrame.Sunken) 14 | 15 | # Create VLC instance and player 16 | self.instance = vlc.Instance() 17 | self.player = self.instance.media_player_new() 18 | self.current_video_path = None 19 | 20 | # Create a widget to hold the video 21 | if sys.platform == "darwin": # macOS 22 | from PyQt6.QtWidgets import QMacCocoaViewContainer 23 | self.video_widget = QMacCocoaViewContainer(0) 24 | else: # Windows/Linux 25 | from PySide6.QtWidgets import QWidget 26 | self.video_widget = QWidget() 27 | 28 | # Set black background 29 | self.video_widget.setStyleSheet("background-color: black;") 30 | 31 | # Create layout 32 | self.layout = QVBoxLayout(self) 33 | self.layout.setContentsMargins(0, 0, 0, 0) 34 | self.layout.addWidget(self.video_widget) 35 | 36 | # Ensure video widget gets focus 37 | self.video_widget.setAttribute(Qt.WA_OpaquePaintEvent) 38 | 39 | # Platform-specific setup 40 | if sys.platform.startswith('linux'): 41 | self.player.set_xwindow(int(self.video_widget.winId())) 42 | elif sys.platform == "win32": 43 | self.player.set_hwnd(int(self.video_widget.winId())) 44 | elif sys.platform == "darwin": 45 | self.player.set_nsobject(int(self.video_widget.winId())) 46 | 47 | # Set up media player events 48 | self.player.event_manager().event_attach( 49 | vlc.EventType.MediaPlayerTimeChanged, 50 | self._on_time_changed 51 | ) 52 | self.player.event_manager().event_attach( 53 | vlc.EventType.MediaPlayerLengthChanged, 54 | self._on_length_changed 55 | ) 56 | 57 | def load_video(self, file_path): 58 | """Load a video file""" 59 | self.current_video_path = file_path 60 | media = self.instance.media_new(file_path) 61 | self.player.set_media(media) 62 | # Auto-resize video 63 | self.player.video_set_aspect_ratio(None) 64 | self.player.video_set_scale(0) 65 | self.player.play() # Start playing 66 | self.player.pause() # Immediately pause 67 | 68 | def play(self): 69 | """Start playback""" 70 | self.player.play() 71 | 72 | def pause(self): 73 | """Pause playback""" 74 | self.player.pause() 75 | 76 | def toggle_play(self): 77 | """Toggle between play and pause""" 78 | if self.player.is_playing(): 79 | self.pause() 80 | else: 81 | self.play() 82 | 83 | def seek(self, position): 84 | """Seek to position in seconds""" 85 | if self.player.get_length() > 0: 86 | # Convert position to milliseconds 87 | ms_position = int(position * 1000) 88 | self.player.set_time(ms_position) 89 | 90 | def get_position(self): 91 | """Get current position in seconds""" 92 | return self.player.get_time() / 1000.0 if self.player.get_time() >= 0 else 0 93 | 94 | def get_duration(self): 95 | """Get video duration in seconds""" 96 | return self.player.get_length() / 1000.0 97 | 98 | def _on_time_changed(self, event): 99 | """Handle time changed events""" 100 | self.position_changed.emit(self.get_position()) 101 | 102 | def _on_length_changed(self, event): 103 | """Handle length changed events""" 104 | self.duration_changed.emit(self.get_duration()) -------------------------------------------------------------------------------- /src/components/timeline.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, 2 | QLabel, QStyle, QFrame) 3 | from PySide6.QtCore import Qt, Signal, QPointF, QRectF, QTimer 4 | from PySide6.QtGui import (QPainter, QColor, QPen, QBrush, 5 | QLinearGradient, QKeySequence, QShortcut) 6 | import numpy as np 7 | 8 | class TimelineWidget(QFrame): 9 | # Signals 10 | position_changed = Signal(float) # Emits position in seconds 11 | marker_added = Signal(float) # Emits marker position 12 | marker_removed = Signal(float) # Emits marker position 13 | section_deleted = Signal(tuple) # Emits (start, end) tuple 14 | 15 | def __init__(self): 16 | super().__init__() 17 | self.setFrameStyle(QFrame.Panel | QFrame.Sunken) 18 | self.setMinimumHeight(60) 19 | self.duration = 0 20 | self.position = 0 21 | self.markers = [] # List of (position, is_start_marker) tuples 22 | self.dragging = False 23 | self.dragging_marker = None 24 | self.current_marker = None 25 | 26 | # Visual settings 27 | self.marker_width = 10 28 | self.marker_height = 20 29 | self.time_markers_height = 15 30 | 31 | # Colors 32 | self.colors = { 33 | 'background': QColor(240, 240, 240), 34 | 'timeline': QColor(200, 200, 200), 35 | 'position': QColor(255, 100, 100), 36 | 'start_marker': QColor(50, 150, 50), 37 | 'end_marker': QColor(150, 50, 50), 38 | 'selection': QColor(100, 150, 255, 50) 39 | } 40 | 41 | # Enable mouse tracking for hover effects 42 | self.setMouseTracking(True) 43 | 44 | # Timer for smooth updates 45 | self.update_timer = QTimer(self) 46 | self.update_timer.timeout.connect(self.update) 47 | self.update_timer.start(16) # ~60 FPS 48 | 49 | self.setup_shortcuts() 50 | 51 | def setup_shortcuts(self): 52 | """Set up keyboard shortcuts""" 53 | self.delete_shortcut = QShortcut(QKeySequence(Qt.Key_Delete), self) 54 | self.delete_shortcut.activated.connect(self.delete_selected_section) 55 | return self.delete_shortcut 56 | 57 | def set_duration(self, duration): 58 | """Set the total duration of the timeline in seconds""" 59 | self.duration = duration 60 | self.update() 61 | 62 | def set_position(self, position): 63 | """Set the current playback position in seconds""" 64 | self.position = min(max(0, position), self.duration) 65 | self.position_changed.emit(self.position) 66 | self.update() 67 | 68 | def add_marker(self, position, is_start=True): 69 | """Add a marker at the specified position""" 70 | # Remove existing marker of same type 71 | self.markers = [(pos, is_start_) for pos, is_start_ in self.markers 72 | if is_start_ != is_start] 73 | self.markers.append((position, is_start)) 74 | self.markers.sort() # Sort by position 75 | self.marker_added.emit(position) 76 | self.update() 77 | 78 | def get_markers(self): 79 | """Return list of (start, end) tuples for valid sections""" 80 | starts = [pos for pos, is_start in self.markers if is_start] 81 | ends = [pos for pos, is_start in self.markers if not is_start] 82 | return list(zip(starts, ends)) 83 | 84 | def has_markers(self): 85 | """Check if there are valid start and end markers""" 86 | return len(self.get_markers()) > 0 87 | 88 | def delete_selected_section(self): 89 | """Delete the currently selected section""" 90 | markers = self.get_markers() 91 | if markers: 92 | start, end = markers[0] # For now, just handle one section 93 | self.section_deleted.emit((start, end)) 94 | self.markers = [] 95 | self.update() 96 | 97 | def paintEvent(self, event): 98 | """Draw the timeline and markers""" 99 | super().paintEvent(event) 100 | 101 | painter = QPainter(self) 102 | painter.setRenderHint(QPainter.Antialiasing) 103 | 104 | # Draw background 105 | painter.fillRect(self.rect(), self.colors['background']) 106 | 107 | # Draw time markers 108 | self._draw_time_markers(painter) 109 | 110 | # Draw timeline base 111 | timeline_rect = self._get_timeline_rect() 112 | painter.fillRect(timeline_rect, self.colors['timeline']) 113 | 114 | # Draw selection regions 115 | self._draw_selections(painter, timeline_rect) 116 | 117 | # Draw position indicator 118 | self._draw_position(painter, timeline_rect) 119 | 120 | # Draw markers 121 | self._draw_markers(painter, timeline_rect) 122 | 123 | def _get_timeline_rect(self): 124 | """Get the rectangle for the main timeline area""" 125 | return QRectF( 126 | 0, 127 | self.time_markers_height, 128 | self.width(), 129 | self.height() - self.time_markers_height 130 | ) 131 | 132 | def _draw_time_markers(self, painter): 133 | """Draw time markers and labels""" 134 | if self.duration == 0: 135 | return 136 | width = self.width() 137 | # Draw major time markers every 5 seconds 138 | interval = 5 139 | num_markers = int(self.duration / interval) + 1 140 | 141 | painter.setPen(QPen(Qt.darkGray)) 142 | for i in range(num_markers): 143 | x = (i * interval / self.duration) * width 144 | painter.drawLine(int(x), 0, int(x), self.time_markers_height) 145 | painter.drawText( 146 | int(x) - 20, 147 | 0, 148 | 40, 149 | self.time_markers_height, 150 | Qt.AlignCenter, 151 | f"{i * interval}s" 152 | ) 153 | 154 | def _draw_selections(self, painter, timeline_rect): 155 | """Draw selected regions""" 156 | markers = self.get_markers() 157 | for start, end in markers: 158 | x1 = (start / self.duration) * timeline_rect.width() 159 | x2 = (end / self.duration) * timeline_rect.width() 160 | selection_rect = QRectF( 161 | x1, 162 | timeline_rect.top(), 163 | x2 - x1, 164 | timeline_rect.height() 165 | ) 166 | painter.fillRect(selection_rect, self.colors['selection']) 167 | 168 | def _draw_position(self, painter, timeline_rect): 169 | """Draw the current position indicator""" 170 | if self.duration > 0: 171 | x = (self.position / self.duration) * timeline_rect.width() 172 | painter.setPen(QPen(self.colors['position'], 2)) 173 | painter.drawLine( 174 | int(x), 175 | int(timeline_rect.top()), 176 | int(x), 177 | int(timeline_rect.bottom()) 178 | ) 179 | 180 | def _draw_markers(self, painter, timeline_rect): 181 | """Draw start and end markers""" 182 | for position, is_start in self.markers: 183 | x = (position / self.duration) * timeline_rect.width() 184 | color = self.colors['start_marker'] if is_start else self.colors['end_marker'] 185 | 186 | # Draw triangle marker 187 | painter.setPen(Qt.NoPen) 188 | painter.setBrush(QBrush(color)) 189 | 190 | if is_start: 191 | points = [ 192 | QPointF(x, timeline_rect.top()), 193 | QPointF(x + self.marker_width, timeline_rect.top()), 194 | QPointF(x, timeline_rect.top() + self.marker_height) 195 | ] 196 | else: 197 | points = [ 198 | QPointF(x - self.marker_width, timeline_rect.top()), 199 | QPointF(x, timeline_rect.top()), 200 | QPointF(x, timeline_rect.top() + self.marker_height) 201 | ] 202 | 203 | painter.drawPolygon(points) 204 | 205 | def mousePressEvent(self, event): 206 | """Handle mouse press events for marker manipulation and position setting""" 207 | if event.button() == Qt.LeftButton: 208 | timeline_rect = self._get_timeline_rect() 209 | if timeline_rect.contains(event.position()): 210 | # Check if clicking near existing marker 211 | click_pos = self._position_from_x(event.position().x()) 212 | marker_index = self._find_nearby_marker(click_pos) 213 | 214 | if marker_index is not None: 215 | # Start dragging existing marker 216 | self.dragging = True 217 | self.dragging_marker = marker_index 218 | else: 219 | # Set new marker 220 | is_start = not any(is_start for _, is_start in self.markers) 221 | self.add_marker(click_pos, is_start) 222 | 223 | self.dragging = True 224 | 225 | def mouseMoveEvent(self, event): 226 | """Handle mouse move events for marker dragging and hover effects""" 227 | if self.dragging and self.dragging_marker is not None: 228 | # Update marker position 229 | new_pos = self._position_from_x(event.position().x()) 230 | self.markers[self.dragging_marker] = (new_pos, self.markers[self.dragging_marker][1]) 231 | self.markers.sort() 232 | self.update() 233 | else: 234 | # Update hover state 235 | click_pos = self._position_from_x(event.position().x()) 236 | self.current_marker = self._find_nearby_marker(click_pos) 237 | self.update() 238 | 239 | def mouseReleaseEvent(self, event): 240 | """Handle mouse release events""" 241 | if event.button() == Qt.LeftButton: 242 | self.dragging = False 243 | self.dragging_marker = None 244 | # Emit final position if marker was being dragged 245 | if self.current_marker is not None: 246 | self.marker_added.emit(self.markers[self.current_marker][0]) 247 | 248 | def _position_from_x(self, x): 249 | """Convert x coordinate to timeline position""" 250 | return max(0, min(self.duration, (x / self.width()) * self.duration)) 251 | 252 | def _find_nearby_marker(self, position, threshold=0.5): 253 | """Find marker near given position within threshold (in seconds)""" 254 | for i, (pos, _) in enumerate(self.markers): 255 | if abs(pos - position) < threshold: 256 | return i 257 | return None -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import sys 3 | from PySide6.QtWidgets import ( 4 | QApplication, 5 | QMainWindow, 6 | QWidget, 7 | QVBoxLayout, 8 | QHBoxLayout, 9 | QPushButton, 10 | QFileDialog, 11 | QMessageBox, 12 | ) 13 | from PySide6.QtCore import Qt 14 | from components.unified_timeline import UnifiedTimeline 15 | from components.video_player import VideoPlayer 16 | from components.waveform import WaveformView 17 | from components.timeline import TimelineWidget 18 | from utils.audio_processor import AudioProcessor 19 | from utils.video_processor import VideoProcessor 20 | from PySide6.QtCore import QThread 21 | from components.progress_dialog import ProgressDialog 22 | from utils.async_worker import VideoLoadWorker 23 | 24 | 25 | class MainWindow(QMainWindow): 26 | def __init__(self): 27 | super().__init__() 28 | self.setWindowTitle("Cut-It-Out Video Editor") 29 | self.setGeometry(100, 100, 800, 600) 30 | self.setMinimumWidth(500) 31 | 32 | self.video_processor = VideoProcessor() 33 | self.audio_processor = AudioProcessor() 34 | 35 | self.setup_ui() 36 | self.setup_connections() 37 | 38 | def setup_ui(self): 39 | # Main widget and layout 40 | main_widget = QWidget() 41 | self.setCentralWidget(main_widget) 42 | layout = QVBoxLayout(main_widget) 43 | 44 | # Video player 45 | self.video_player = VideoPlayer() 46 | layout.addWidget(self.video_player) 47 | 48 | self.timeline = UnifiedTimeline() 49 | layout.addWidget(self.timeline) 50 | 51 | # Controls 52 | controls_layout = QHBoxLayout() 53 | 54 | self.load_button = QPushButton("Load Video") 55 | self.load_button.clicked.connect(self.load_video) 56 | 57 | self.export_button = QPushButton("Export") 58 | self.export_button.clicked.connect(self.export_video) 59 | self.export_button.setEnabled(False) 60 | 61 | controls_layout.addWidget(self.load_button) 62 | controls_layout.addWidget(self.export_button) 63 | controls_layout.addStretch() 64 | 65 | layout.addLayout(controls_layout) 66 | 67 | def on_video_loaded(self, result): 68 | """Handle successful video load""" 69 | if self.progress_dialog: 70 | self.progress_dialog.close() 71 | self.progress_dialog = None 72 | 73 | try: 74 | # Update video player 75 | self.video_player.load_video(result["path"]) 76 | 77 | # Update unified timeline 78 | self.timeline.set_audio_data(result["audio_data"]) 79 | 80 | # Enable export 81 | self.export_button.setEnabled(True) 82 | 83 | # Show success message 84 | self.statusBar().showMessage( 85 | f"Loaded video: {Path(result['path']).name} " 86 | f"({result['duration']:.1f}s, {result['fps']:.2f} fps)", 87 | 5000, 88 | ) 89 | 90 | except Exception as e: 91 | self.on_load_error(str(e)) 92 | 93 | def load_video(self): 94 | file_path, _ = QFileDialog.getOpenFileName( 95 | self, 96 | "Open Video File", 97 | "", 98 | "Video Files (*.mp4 *.avi *.mkv *.mov);;All Files (*.*)", 99 | ) 100 | 101 | if file_path: 102 | # Create and show progress dialog 103 | self.progress_dialog = ProgressDialog(self) 104 | self.progress_dialog.show() 105 | 106 | # Create worker and thread 107 | self.loading_thread = QThread() 108 | self.worker = VideoLoadWorker( 109 | self.video_processor, self.audio_processor, file_path 110 | ) 111 | self.worker.moveToThread(self.loading_thread) 112 | 113 | # Connect signals 114 | self.loading_thread.started.connect(self.worker.run) 115 | self.worker.progress.connect(self.update_progress) 116 | self.worker.finished.connect(self.on_video_loaded) 117 | self.worker.error.connect(self.on_load_error) 118 | self.worker.finished.connect(self.loading_thread.quit) 119 | self.worker.finished.connect(self.worker.deleteLater) 120 | self.loading_thread.finished.connect(self.loading_thread.deleteLater) 121 | 122 | # Start loading 123 | self.loading_thread.start() 124 | 125 | def update_progress(self, value, status): 126 | """Update progress dialog""" 127 | if self.progress_dialog: 128 | self.progress_dialog.set_progress(value, status) 129 | 130 | def on_video_loaded(self, result): 131 | """Handle successful video load""" 132 | # Clean up progress dialog 133 | if self.progress_dialog: 134 | self.progress_dialog.close() 135 | self.progress_dialog = None 136 | 137 | try: 138 | # Update video player 139 | self.video_player.load_video(result["path"]) 140 | 141 | # Update waveform 142 | self.timeline.set_audio_data(result["audio_data"]) 143 | 144 | # Enable export 145 | self.export_button.setEnabled(True) 146 | 147 | # Show success message 148 | self.statusBar().showMessage( 149 | f"Loaded video: {Path(result['path']).name} " 150 | f"({result['duration']:.1f}s, {result['fps']:.2f} fps)", 151 | 5000, 152 | ) 153 | 154 | except Exception as e: 155 | self.on_load_error(str(e)) 156 | 157 | def on_load_error(self, error_message): 158 | """Handle loading errors""" 159 | # Clean up progress dialog 160 | if self.progress_dialog: 161 | self.progress_dialog.close() 162 | self.progress_dialog = None 163 | 164 | # Show error message 165 | QMessageBox.critical(self, "Error", f"Failed to load video: {error_message}") 166 | 167 | # Reset UI state 168 | self.export_button.setEnabled(False) 169 | 170 | def export_video(self): 171 | """Handle video export with deleted segments""" 172 | if not self.timeline.get_deleted_segments(): 173 | QMessageBox.warning(self, "Warning", "No sections marked for deletion") 174 | return 175 | 176 | output_path, _ = QFileDialog.getSaveFileName( 177 | self, "Export Video", "", "Video Files (*.mp4);;All Files (*.*)" 178 | ) 179 | 180 | if output_path: 181 | try: 182 | # Get all segments marked for deletion 183 | deleted_segments = self.timeline.get_deleted_segments() 184 | 185 | # Convert deleted segments into kept segments 186 | kept_segments = self._calculate_kept_segments(deleted_segments) 187 | 188 | # Export video with only kept segments 189 | self.video_processor.export_with_cuts( 190 | self.video_player.current_video_path, output_path, kept_segments 191 | ) 192 | QMessageBox.information(self, "Success", "Video exported successfully!") 193 | 194 | # Optionally clear deleted segments after successful export 195 | self.timeline.clear_deleted_segments() 196 | 197 | except Exception as e: 198 | QMessageBox.critical(self, "Error", f"Failed to export video: {str(e)}") 199 | 200 | def _calculate_kept_segments(self, deleted_segments): 201 | """Calculate segments to keep based on deleted segments""" 202 | if not deleted_segments: 203 | return [] 204 | 205 | # Sort deleted segments by start time 206 | deleted_segments.sort(key=lambda x: x[0]) 207 | 208 | # Get video duration 209 | duration = self.video_processor.get_duration( 210 | self.video_player.current_video_path 211 | ) 212 | 213 | # Calculate kept segments 214 | kept_segments = [] 215 | current_pos = 0 216 | 217 | for start, end in deleted_segments: 218 | # Add segment before deletion if it exists 219 | if start > current_pos: 220 | kept_segments.append((current_pos, start)) 221 | current_pos = end 222 | 223 | # Add final segment if there's remaining video 224 | if current_pos < duration: 225 | kept_segments.append((current_pos, duration)) 226 | 227 | return kept_segments 228 | 229 | def setup_connections(self): 230 | """Set up signal/slot connections between components""" 231 | # Timeline position changes update video position 232 | self.timeline.position_changed.connect(self.on_timeline_position_changed) 233 | 234 | # Timeline edit markers trigger video processing 235 | self.timeline.section_deleted.connect(self.on_section_deleted) 236 | 237 | # Connect video player signals 238 | self.video_player.position_changed.connect(self.timeline.set_position) 239 | # self.video_player.duration_changed.connect(self.timeline.set_duration) 240 | 241 | # Connect timeline signals 242 | self.timeline.position_changed.connect(self.video_player.seek) 243 | self.timeline.marker_removed.connect(self.on_marker_removed) 244 | self.timeline.play_toggled.connect(self.video_player.toggle_play) 245 | 246 | def on_timeline_position_changed(self, position): 247 | """Handle timeline position changes""" 248 | # Convert position in seconds to VLC position (0-1) 249 | if self.video_player.current_video_path: 250 | duration = self.video_processor.get_duration( 251 | self.video_player.current_video_path 252 | ) 253 | vlc_pos = position / duration 254 | self.video_player.seek(vlc_pos) 255 | 256 | def on_video_position_changed(self, position): 257 | """Handle video position changes""" 258 | if self.video_player.current_video_path: 259 | duration = self.video_processor.get_duration( 260 | self.video_player.current_video_path 261 | ) 262 | time_pos = position * duration 263 | self.timeline.set_position(time_pos) 264 | self.waveform_view.update_position(time_pos) 265 | 266 | def on_section_deleted(self, section): 267 | """Handle deletion of a timeline section""" 268 | start, end = section 269 | # Optionally auto-save or preview the edit 270 | self.statusBar().showMessage(f"Section deleted: {start:.1f}s - {end:.1f}s") 271 | 272 | def on_marker_removed(self, position): 273 | """Handle marker removal""" 274 | self.statusBar().showMessage(f"Removed marker at {position:.2f}s", 2000) 275 | 276 | # If no markers left, disable export 277 | if not self.timeline.has_markers(): 278 | self.export_button.setEnabled(False) 279 | 280 | 281 | def main(): 282 | app = QApplication(sys.argv) 283 | window = MainWindow() 284 | window.show() 285 | sys.exit(app.exec()) 286 | 287 | 288 | if __name__ == "__main__": 289 | main() 290 | -------------------------------------------------------------------------------- /src/components/unified_timeline.py: -------------------------------------------------------------------------------- 1 | from PySide6.QtWidgets import QVBoxLayout, QFrame 2 | from PySide6.QtCore import Qt, Signal 3 | from PySide6.QtGui import QKeySequence, QShortcut, QBrush, QColor 4 | import pyqtgraph as pg 5 | 6 | 7 | class UnifiedTimeline(QFrame): 8 | position_changed = Signal(float) # Emits position in seconds 9 | marker_added = Signal(float) # Emits marker position 10 | section_deleted = Signal(tuple) # Emits (start, end) tuple 11 | marker_removed = Signal(float) 12 | play_toggled = Signal() 13 | 14 | def __init__(self): 15 | super().__init__() 16 | self.setFrameStyle(QFrame.Panel | QFrame.Sunken) 17 | self.setMinimumHeight(150) 18 | 19 | # Setup main layout 20 | self.layout = QVBoxLayout(self) 21 | self.layout.setContentsMargins(0, 0, 0, 0) 22 | 23 | # Create pyqtgraph plot widget 24 | self.plot_widget = pg.PlotWidget() 25 | self.plot_widget.setBackground("black") 26 | self.plot_widget.showGrid(x=True, y=False) 27 | self.plot_widget.setMouseEnabled(x=True, y=False) 28 | self.plot_widget.setMenuEnabled(False) 29 | 30 | # Get the ViewBox for setting zoom limits 31 | self.view_box = self.plot_widget.getViewBox() 32 | 33 | # Set up zoom limits 34 | # Maximum zoom out will show 2x the duration 35 | # Maximum zoom in will show 1 second of audio 36 | self.min_zoom_range = 1.0 # 1 second minimum view 37 | self.max_zoom_range = None # Will be set when duration is known 38 | 39 | # Set up ViewBox scaling limits 40 | self.view_box.setLimits( 41 | xMin=0, # Cannot scroll before start 42 | yMin=-1.1, # Slight padding for waveform 43 | yMax=1.1, 44 | minXRange=self.min_zoom_range, # Minimum view range (maximum zoom in) 45 | maxYRange=2.2, # Fixed vertical range 46 | maxXRange=None, # Will be set when duration is known 47 | ) 48 | 49 | # Disable vertical scrolling completely 50 | self.view_box.setMouseEnabled(x=True, y=False) 51 | 52 | # Connect range change signal 53 | self.view_box.sigRangeChangedManually.connect(self._on_range_changed) 54 | 55 | # Remove Y axis - we don't need amplitude values 56 | self.plot_widget.hideAxis("left") 57 | 58 | # Customize X axis 59 | self.plot_widget.getAxis("bottom").setLabel("Time (s)") 60 | 61 | # Add plot widget to layout 62 | self.layout.addWidget(self.plot_widget) 63 | 64 | # Initialize state 65 | self.duration = 0 66 | self.position = 0 67 | self.markers = [] # List of (position, is_start_marker) tuples 68 | self.audio_data = None 69 | self.waveform_item = None 70 | self.position_line = self.plot_widget.addLine(x=0, pen=pg.mkPen("r", width=2)) 71 | 72 | # Setup marker items 73 | self.marker_items = [] 74 | self.selection_region = pg.LinearRegionItem( 75 | [0, 0], brush=pg.mkBrush(100, 100, 255, 50) 76 | ) 77 | self.selection_region.hide() 78 | self.plot_widget.addItem(self.selection_region) 79 | 80 | # Connect signals 81 | self.plot_widget.scene().sigMouseClicked.connect(self.on_mouse_clicked) 82 | self.selection_region.sigRegionChanged.connect(self.on_region_changed) 83 | 84 | # Enable mouse tracking for hover effects 85 | self.setMouseTracking(True) 86 | self.setup_shortcuts() 87 | 88 | # Add tracking for deleted segments 89 | self.deleted_segments = [] # List of (start, end) tuples 90 | 91 | # Create pattern for deleted segments 92 | self.deletion_pattern = self._create_deletion_pattern() 93 | 94 | # Create separate LinearRegionItems for regular selection and deleted segments 95 | self.selection_region = pg.LinearRegionItem( 96 | [0, 0], brush=pg.mkBrush(100, 100, 255, 50), movable=True 97 | ) 98 | self.selection_region.hide() 99 | self.plot_widget.addItem(self.selection_region) 100 | 101 | # Create initial deleted region item 102 | self.deleted_regions = [] 103 | 104 | def set_audio_data(self, audio_data): 105 | """Set audio data and display waveform""" 106 | self.audio_data = audio_data 107 | self.duration = audio_data["duration"] 108 | 109 | # Update maximum zoom range (2x duration) 110 | self.max_zoom_range = self.duration * 2 111 | 112 | # Update ViewBox limits 113 | self.view_box.setLimits( 114 | xMax=self.duration, # Cannot scroll past end 115 | maxXRange=self.max_zoom_range, # Maximum view range (maximum zoom out) 116 | ) 117 | 118 | # Clear existing waveform 119 | if self.waveform_item is not None: 120 | self.plot_widget.removeItem(self.waveform_item) 121 | 122 | # Plot new waveform 123 | pen = pg.mkPen("w", width=1) 124 | self.waveform_item = self.plot_widget.plot( 125 | audio_data["time"], audio_data["samples"], pen=pen 126 | ) 127 | 128 | # Set initial view to show full duration 129 | self.view_box.setXRange(0, self.duration, padding=0) 130 | 131 | def set_position(self, position): 132 | """Update current position marker""" 133 | self.position = position 134 | self.position_line.setValue(position) 135 | 136 | def add_marker(self, position, is_start=True): 137 | """Add a marker and update selection region""" 138 | self.markers = [ 139 | (pos, is_start_) for pos, is_start_ in self.markers if is_start_ != is_start 140 | ] 141 | self.markers.append((position, is_start)) 142 | self.markers.sort() 143 | 144 | self._update_selection_region() 145 | self.marker_added.emit(position) 146 | 147 | def _update_selection_region(self): 148 | """Update the selection region based on markers""" 149 | starts = [pos for pos, is_start in self.markers if is_start] 150 | ends = [pos for pos, is_start in self.markers if not is_start] 151 | 152 | if starts and ends: 153 | self.selection_region.setRegion((starts[0], ends[0])) 154 | self.selection_region.show() 155 | else: 156 | self.selection_region.hide() 157 | 158 | def on_mouse_clicked(self, event): 159 | """Handle mouse clicks for adding markers and scrubbing""" 160 | if event.button() == Qt.LeftButton: 161 | # Get click position in scene coordinates 162 | scene_pos = event.scenePos() 163 | view_pos = self.plot_widget.plotItem.vb.mapSceneToView(scene_pos) 164 | click_time = view_pos.x() 165 | 166 | # Constrain to valid range 167 | click_time = max(0, min(self.duration, click_time)) 168 | 169 | # Check if click is near existing marker 170 | is_near_marker = False 171 | threshold = self.duration / 100 # 1% of duration 172 | for pos, _ in self.markers: 173 | if abs(pos - click_time) < threshold: 174 | is_near_marker = True 175 | break 176 | 177 | if not is_near_marker: 178 | # Add new marker 179 | is_start = not any(is_start for _, is_start in self.markers) 180 | self.add_marker(click_time, is_start) 181 | 182 | # Update position 183 | self.set_position(click_time) 184 | self.position_changed.emit(click_time) 185 | 186 | def on_region_changed(self): 187 | """Handle selection region changes""" 188 | start, end = self.selection_region.getRegion() 189 | # Update markers to match region 190 | self.markers = [(start, True), (end, False)] 191 | 192 | def delete_selected_section(self): 193 | """Delete the currently selected section""" 194 | if self.markers: 195 | start = min(pos for pos, is_start in self.markers if is_start) 196 | end = max(pos for pos, is_start in self.markers if not is_start) 197 | self.section_deleted.emit((start, end)) 198 | self.markers = [] 199 | self.selection_region.hide() 200 | 201 | def get_markers(self): 202 | """Get list of (start, end) tuples for valid sections""" 203 | starts = [pos for pos, is_start in self.markers if is_start] 204 | ends = [pos for pos, is_start in self.markers if not is_start] 205 | return list(zip(starts, ends)) 206 | 207 | def has_markers(self): 208 | """Check if there are valid start and end markers""" 209 | return len(self.get_markers()) > 0 210 | 211 | def setup_shortcuts(self): 212 | """Set up keyboard shortcuts""" 213 | # ESC key to remove last marker 214 | self.esc_shortcut = QShortcut(QKeySequence(Qt.Key_Escape), self) 215 | self.esc_shortcut.activated.connect(self.remove_last_marker) 216 | 217 | # Delete key to remove selection 218 | self.del_shortcut = QShortcut(QKeySequence(Qt.Key_Delete), self) 219 | self.del_shortcut.activated.connect(self.delete_selected_section) 220 | 221 | # Space bar for play/pause (emit signal) 222 | self.space_shortcut = QShortcut(QKeySequence(Qt.Key_Space), self) 223 | self.space_shortcut.activated.connect(self.toggle_play) 224 | 225 | # Left/Right arrow keys for frame stepping 226 | self.left_shortcut = QShortcut(QKeySequence(Qt.Key_Left), self) 227 | self.left_shortcut.activated.connect(lambda: self.step_frame(-1)) 228 | 229 | self.right_shortcut = QShortcut(QKeySequence(Qt.Key_Right), self) 230 | self.right_shortcut.activated.connect(lambda: self.step_frame(1)) 231 | 232 | def remove_last_marker(self): 233 | """Remove the most recently added marker""" 234 | if self.markers: 235 | removed_marker = self.markers.pop() 236 | self.marker_removed.emit(removed_marker[0]) 237 | self._update_selection_region() 238 | 239 | # If we removed a start marker but have an end marker, remove the end marker too 240 | if removed_marker[1] and any(not is_start for _, is_start in self.markers): 241 | self.markers = [ 242 | (pos, is_start) for pos, is_start in self.markers if is_start 243 | ] 244 | 245 | # If we removed an end marker but have a start marker, keep the start marker 246 | if not removed_marker[1] and any(is_start for _, is_start in self.markers): 247 | pass # Keep the start marker 248 | 249 | def step_frame(self, direction): 250 | """Step one frame forward or backward""" 251 | frame_duration = ( 252 | 1 / 30 253 | ) # Assume 30fps, this should be set based on actual video 254 | new_pos = self.position + (direction * frame_duration) 255 | new_pos = max(0, min(self.duration, new_pos)) 256 | self.set_position(new_pos) 257 | self.position_changed.emit(new_pos) 258 | 259 | def toggle_play(self): 260 | """Emit signal to toggle play/pause""" 261 | # This will be connected to the main window to control video playback 262 | self.play_toggled.emit() 263 | 264 | def _create_deletion_pattern(self): 265 | """Create a striped pattern for deleted segments""" 266 | # Create a brush with diagonal lines 267 | brush = QBrush(QColor(255, 0, 0, 80)) 268 | brush.setStyle(Qt.BDiagPattern) # Diagonal line pattern 269 | return pg.mkBrush(brush) 270 | 271 | def delete_selected_section(self): 272 | """Mark the current section for deletion""" 273 | if self.markers and len(self.markers) >= 2: 274 | # Get start and end markers 275 | start = min(pos for pos, is_start in self.markers if is_start) 276 | end = max(pos for pos, is_start in self.markers if not is_start) 277 | 278 | # Add to deleted segments 279 | self.deleted_segments.append((start, end)) 280 | 281 | # Create new deleted region visualization 282 | deleted_region = pg.LinearRegionItem( 283 | values=[start, end], 284 | brush=self.deletion_pattern, 285 | movable=False, # Can't move deleted regions 286 | span=(0, 1), # Full height 287 | ) 288 | # Make the border red and dashed 289 | deleted_region.lines[0].setPen(pg.mkPen("r", style=Qt.DashLine)) 290 | deleted_region.lines[1].setPen(pg.mkPen("r", style=Qt.DashLine)) 291 | 292 | self.plot_widget.addItem(deleted_region) 293 | self.deleted_regions.append(deleted_region) 294 | 295 | # Clear current selection 296 | self.markers = [] 297 | self.selection_region.hide() 298 | 299 | # Emit signal with all deleted segments 300 | self.section_deleted.emit((start, end)) 301 | 302 | def get_deleted_segments(self): 303 | """Return list of segments marked for deletion""" 304 | return self.deleted_segments 305 | 306 | def clear_deleted_segments(self): 307 | """Clear all deleted segments""" 308 | self.deleted_segments = [] 309 | for region in self.deleted_regions: 310 | self.plot_widget.removeItem(region) 311 | self.deleted_regions = [] 312 | 313 | def remove_last_deleted_segment(self): 314 | """Remove the most recently deleted segment""" 315 | if self.deleted_segments: 316 | self.deleted_segments.pop() 317 | if self.deleted_regions: 318 | region = self.deleted_regions.pop() 319 | self.plot_widget.removeItem(region) 320 | 321 | def keyPressEvent(self, event): 322 | """Handle keyboard events""" 323 | if event.key() == Qt.Key_Delete: 324 | self.delete_selected_section() 325 | elif event.key() == Qt.Key_Escape: 326 | if self.markers: 327 | self.remove_last_marker() 328 | elif self.deleted_segments: 329 | # If no markers but there are deleted segments, undo last deletion 330 | self.remove_last_deleted_segment() 331 | else: 332 | super().keyPressEvent(event) 333 | 334 | def _on_range_changed(self): 335 | """Handle manual range changes (zoom/pan)""" 336 | # Get current view range 337 | view_range = self.view_box.viewRange() 338 | x_range = view_range[0] 339 | 340 | # Ensure minimum zoom level (maximum range) 341 | if x_range[1] - x_range[0] > self.max_zoom_range: 342 | center = sum(x_range) / 2 343 | half_range = self.max_zoom_range / 2 344 | self.view_box.setXRange(center - half_range, center + half_range, padding=0) 345 | 346 | # Ensure maximum zoom level (minimum range) 347 | elif x_range[1] - x_range[0] < self.min_zoom_range: 348 | center = sum(x_range) / 2 349 | half_range = self.min_zoom_range / 2 350 | self.view_box.setXRange(center - half_range, center + half_range, padding=0) 351 | 352 | # Ensure we don't scroll past the valid range 353 | if x_range[0] < 0: 354 | shift = -x_range[0] 355 | self.view_box.setXRange(x_range[0] + shift, x_range[1] + shift, padding=0) 356 | elif x_range[1] > self.duration: 357 | shift = self.duration - x_range[1] 358 | self.view_box.setXRange(x_range[0] + shift, x_range[1] + shift, padding=0) 359 | 360 | def wheelEvent(self, event): 361 | """Custom wheel event to control zoom behavior""" 362 | if self.duration is None: 363 | return 364 | 365 | # Get current range 366 | view_range = self.view_box.viewRange() 367 | current_range = view_range[0][1] - view_range[0][0] 368 | 369 | # Calculate zoom factor based on wheel delta 370 | zoom_factor = 0.9 if event.angleDelta().y() > 0 else 1.1 371 | new_range = current_range * zoom_factor 372 | 373 | # Check zoom limits 374 | if self.min_zoom_range <= new_range <= self.max_zoom_range: 375 | # Calculate zoom center point based on mouse position 376 | mouse_point = self.plot_widget.plotItem.vb.mapSceneToView(event.position()) 377 | center_x = mouse_point.x() 378 | 379 | # Calculate new bounds 380 | half_range = new_range / 2 381 | left = center_x - half_range 382 | right = center_x + half_range 383 | 384 | # Adjust bounds if they exceed limits 385 | if left < 0: 386 | left = 0 387 | right = new_range 388 | elif right > self.duration: 389 | right = self.duration 390 | left = right - new_range 391 | 392 | # Apply new range 393 | self.view_box.setXRange(left, right, padding=0) 394 | 395 | # Consume the event 396 | event.accept() 397 | --------------------------------------------------------------------------------