import os import sys import cv2 import argparse import numpy as np from pathlib import Path from typing import Optional, Tuple, List class VideoEditor: # Configuration constants BASE_FRAME_DELAY_MS = 16 # ~60 FPS KEY_REPEAT_RATE_SEC = 0.3 FAST_SEEK_ACTIVATION_TIME = 1.5 SPEED_INCREMENT = 0.2 MIN_PLAYBACK_SPEED = 0.1 MAX_PLAYBACK_SPEED = 10.0 # Timeline configuration TIMELINE_HEIGHT = 60 TIMELINE_MARGIN = 20 TIMELINE_BAR_HEIGHT = 12 TIMELINE_HANDLE_SIZE = 12 TIMELINE_COLOR_BG = (80, 80, 80) TIMELINE_COLOR_PROGRESS = (0, 120, 255) TIMELINE_COLOR_HANDLE = (255, 255, 255) TIMELINE_COLOR_BORDER = (200, 200, 200) TIMELINE_COLOR_CUT_POINT = (255, 0, 0) # Zoom and crop settings MIN_ZOOM = 0.1 MAX_ZOOM = 10.0 ZOOM_INCREMENT = 0.1 def __init__(self, video_path: str): self.video_path = Path(video_path) self.cap = cv2.VideoCapture(str(self.video_path)) if not self.cap.isOpened(): raise ValueError(f"Could not open video file: {video_path}") # Video properties self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT)) self.fps = self.cap.get(cv2.CAP_PROP_FPS) self.frame_width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) self.frame_height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) # Playback state self.current_frame = 0 self.is_playing = False self.playback_speed = 1.0 self.current_display_frame = None # Mouse and keyboard interaction self.mouse_dragging = False self.timeline_rect = None self.window_width = 1200 self.window_height = 800 # Seeking state self.is_seeking = False self.current_seek_key = None self.key_first_press_time = 0 self.last_seek_time = 0 # Crop settings self.crop_rect = None # (x, y, width, height) self.crop_selecting = False self.crop_start_point = None self.crop_preview_rect = None self.crop_history = [] # For undo # Zoom settings self.zoom_factor = 1.0 self.zoom_center = None # (x, y) center point for zoom # Cut points self.cut_start_frame = None self.cut_end_frame = None # Display offset for panning when zoomed self.display_offset = [0, 0] def load_current_frame(self) -> bool: """Load the current frame into display cache""" self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.current_frame) ret, frame = self.cap.read() if ret: self.current_display_frame = frame return True return False def calculate_frame_delay(self) -> int: """Calculate frame delay in milliseconds based on playback speed""" delay_ms = int(self.BASE_FRAME_DELAY_MS / self.playback_speed) return max(1, delay_ms) def seek_video(self, frames_delta: int): """Seek video by specified number of frames""" target_frame = max(0, min(self.current_frame + frames_delta, self.total_frames - 1)) self.current_frame = target_frame self.load_current_frame() def seek_to_frame(self, frame_number: int): """Seek to specific frame""" self.current_frame = max(0, min(frame_number, self.total_frames - 1)) self.load_current_frame() def advance_frame(self) -> bool: """Advance to next frame""" if not self.is_playing: return True self.current_frame += 1 if self.current_frame >= self.total_frames: self.current_frame = 0 # Loop return self.load_current_frame() def apply_crop_and_zoom(self, frame): """Apply current crop and zoom settings to frame""" if frame is None: return None processed_frame = frame.copy() # Apply crop first if self.crop_rect: x, y, w, h = self.crop_rect x, y, w, h = int(x), int(y), int(w), int(h) # Ensure crop is within frame bounds x = max(0, min(x, frame.shape[1] - 1)) y = max(0, min(y, frame.shape[0] - 1)) w = min(w, frame.shape[1] - x) h = min(h, frame.shape[0] - y) if w > 0 and h > 0: processed_frame = processed_frame[y:y+h, x:x+w] # Apply zoom if self.zoom_factor != 1.0: height, width = processed_frame.shape[:2] new_width = int(width * self.zoom_factor) new_height = int(height * self.zoom_factor) processed_frame = cv2.resize(processed_frame, (new_width, new_height), interpolation=cv2.INTER_LINEAR) # Handle zoom center and display offset if new_width > self.window_width or new_height > self.window_height: # Calculate crop from zoomed image to fit window start_x = max(0, self.display_offset[0]) start_y = max(0, self.display_offset[1]) end_x = min(new_width, start_x + self.window_width) end_y = min(new_height, start_y + self.window_height) processed_frame = processed_frame[start_y:end_y, start_x:end_x] return processed_frame def draw_timeline(self, frame): """Draw timeline at the bottom of the frame""" height, width = frame.shape[:2] # Timeline background area timeline_y = height - self.TIMELINE_HEIGHT cv2.rectangle(frame, (0, timeline_y), (width, height), (40, 40, 40), -1) # Calculate timeline bar position bar_y = timeline_y + (self.TIMELINE_HEIGHT - self.TIMELINE_BAR_HEIGHT) // 2 bar_x_start = self.TIMELINE_MARGIN bar_x_end = width - self.TIMELINE_MARGIN bar_width = bar_x_end - bar_x_start self.timeline_rect = (bar_x_start, bar_y, bar_width, self.TIMELINE_BAR_HEIGHT) # Draw timeline background cv2.rectangle(frame, (bar_x_start, bar_y), (bar_x_end, bar_y + self.TIMELINE_BAR_HEIGHT), self.TIMELINE_COLOR_BG, -1) cv2.rectangle(frame, (bar_x_start, bar_y), (bar_x_end, bar_y + self.TIMELINE_BAR_HEIGHT), self.TIMELINE_COLOR_BORDER, 1) # Draw progress if self.total_frames > 0: progress = self.current_frame / max(1, self.total_frames - 1) progress_width = int(bar_width * progress) if progress_width > 0: cv2.rectangle(frame, (bar_x_start, bar_y), (bar_x_start + progress_width, bar_y + self.TIMELINE_BAR_HEIGHT), self.TIMELINE_COLOR_PROGRESS, -1) # Draw current position handle handle_x = bar_x_start + progress_width handle_y = bar_y + self.TIMELINE_BAR_HEIGHT // 2 cv2.circle(frame, (handle_x, handle_y), self.TIMELINE_HANDLE_SIZE // 2, self.TIMELINE_COLOR_HANDLE, -1) cv2.circle(frame, (handle_x, handle_y), self.TIMELINE_HANDLE_SIZE // 2, self.TIMELINE_COLOR_BORDER, 2) # Draw cut points if self.cut_start_frame is not None: cut_start_progress = self.cut_start_frame / max(1, self.total_frames - 1) cut_start_x = bar_x_start + int(bar_width * cut_start_progress) cv2.line(frame, (cut_start_x, bar_y), (cut_start_x, bar_y + self.TIMELINE_BAR_HEIGHT), self.TIMELINE_COLOR_CUT_POINT, 3) cv2.putText(frame, "1", (cut_start_x - 5, bar_y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.4, self.TIMELINE_COLOR_CUT_POINT, 1) if self.cut_end_frame is not None: cut_end_progress = self.cut_end_frame / max(1, self.total_frames - 1) cut_end_x = bar_x_start + int(bar_width * cut_end_progress) cv2.line(frame, (cut_end_x, bar_y), (cut_end_x, bar_y + self.TIMELINE_BAR_HEIGHT), self.TIMELINE_COLOR_CUT_POINT, 3) cv2.putText(frame, "2", (cut_end_x - 5, bar_y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.4, self.TIMELINE_COLOR_CUT_POINT, 1) def draw_crop_overlay(self, frame): """Draw crop selection overlay""" if self.crop_preview_rect: x, y, w, h = self.crop_preview_rect # Draw rectangle cv2.rectangle(frame, (int(x), int(y)), (int(x + w), int(y + h)), (0, 255, 0), 2) # Draw semi-transparent overlay outside crop area overlay = frame.copy() cv2.rectangle(overlay, (0, 0), (frame.shape[1], frame.shape[0]), (0, 0, 0), -1) cv2.rectangle(overlay, (int(x), int(y)), (int(x + w), int(y + h)), (255, 255, 255), -1) cv2.addWeighted(frame, 0.7, overlay, 0.3, 0, frame) if self.crop_rect: x, y, w, h = self.crop_rect cv2.rectangle(frame, (int(x), int(y)), (int(x + w), int(y + h)), (255, 0, 0), 2) def display_current_frame(self): """Display the current frame with all overlays""" if self.current_display_frame is None: return # Apply crop and zoom transformations for preview display_frame = self.apply_crop_and_zoom(self.current_display_frame.copy()) if display_frame is None: return # Resize to fit window while maintaining aspect ratio height, width = display_frame.shape[:2] available_height = self.window_height - self.TIMELINE_HEIGHT scale = min(self.window_width / width, available_height / height) if scale < 1.0: new_width = int(width * scale) new_height = int(height * scale) display_frame = cv2.resize(display_frame, (new_width, new_height)) # Create canvas with timeline space canvas = np.zeros((self.window_height, self.window_width, 3), dtype=np.uint8) # Center the frame on canvas frame_height, frame_width = display_frame.shape[:2] start_y = (available_height - frame_height) // 2 start_x = (self.window_width - frame_width) // 2 canvas[start_y:start_y + frame_height, start_x:start_x + frame_width] = display_frame # Draw crop overlay on original frame coordinates if not (self.crop_rect or self.crop_preview_rect): self.draw_crop_overlay(canvas[start_y:start_y + frame_height, start_x:start_x + frame_width]) # Add info overlay info_text = f"Frame: {self.current_frame}/{self.total_frames} | Speed: {self.playback_speed:.1f}x | Zoom: {self.zoom_factor:.1f}x | {'Playing' if self.is_playing else 'Paused'}" cv2.putText(canvas, info_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2) cv2.putText(canvas, info_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 1) # Add crop info if self.crop_rect: crop_text = f"Crop: {int(self.crop_rect[0])},{int(self.crop_rect[1])} {int(self.crop_rect[2])}x{int(self.crop_rect[3])}" cv2.putText(canvas, crop_text, (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2) cv2.putText(canvas, crop_text, (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1) # Add cut info if self.cut_start_frame is not None or self.cut_end_frame is not None: cut_text = f"Cut: {self.cut_start_frame or '?'} - {self.cut_end_frame or '?'}" cv2.putText(canvas, cut_text, (10, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2) cv2.putText(canvas, cut_text, (10, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1) # Draw timeline self.draw_timeline(canvas) cv2.imshow("Video Editor", canvas) def mouse_callback(self, event, x, y, flags, param): """Handle mouse events""" # Handle timeline interaction if self.timeline_rect: bar_x_start, bar_y, bar_width, bar_height = self.timeline_rect bar_x_end = bar_x_start + bar_width if bar_y <= y <= bar_y + bar_height + 10: if event == cv2.EVENT_LBUTTONDOWN: if bar_x_start <= x <= bar_x_end: self.mouse_dragging = True self.seek_to_timeline_position(x, bar_x_start, bar_width) elif event == cv2.EVENT_MOUSEMOVE and self.mouse_dragging: if bar_x_start <= x <= bar_x_end: self.seek_to_timeline_position(x, bar_x_start, bar_width) elif event == cv2.EVENT_LBUTTONUP: self.mouse_dragging = False return # Handle crop selection (Shift + click and drag) if flags & cv2.EVENT_FLAG_SHIFTKEY: available_height = self.window_height - self.TIMELINE_HEIGHT if event == cv2.EVENT_LBUTTONDOWN: self.crop_selecting = True self.crop_start_point = (x, y) self.crop_preview_rect = None elif event == cv2.EVENT_MOUSEMOVE and self.crop_selecting: if self.crop_start_point: start_x, start_y = self.crop_start_point width = abs(x - start_x) height = abs(y - start_y) crop_x = min(start_x, x) crop_y = min(start_y, y) self.crop_preview_rect = (crop_x, crop_y, width, height) elif event == cv2.EVENT_LBUTTONUP and self.crop_selecting: if self.crop_start_point and self.crop_preview_rect: # Convert screen coordinates to video coordinates self.set_crop_from_screen_coords(self.crop_preview_rect) self.crop_selecting = False self.crop_start_point = None self.crop_preview_rect = None # Handle zoom center (Ctrl + click) if flags & cv2.EVENT_FLAG_CTRLKEY and event == cv2.EVENT_LBUTTONDOWN: self.zoom_center = (x, y) # Handle scroll wheel for zoom (Ctrl + scroll) if flags & cv2.EVENT_FLAG_CTRLKEY: if event == cv2.EVENT_MOUSEWHEEL: if flags > 0: # Scroll up self.zoom_factor = min(self.MAX_ZOOM, self.zoom_factor + self.ZOOM_INCREMENT) else: # Scroll down self.zoom_factor = max(self.MIN_ZOOM, self.zoom_factor - self.ZOOM_INCREMENT) def set_crop_from_screen_coords(self, screen_rect): """Convert screen coordinates to video frame coordinates and set crop""" # This is a simplified version - in a full implementation you'd need to # account for the scaling and positioning of the video frame within the window x, y, w, h = screen_rect # Simple conversion assuming video fills the display area if self.current_display_frame is not None: frame_height, frame_width = self.current_display_frame.shape[:2] available_height = self.window_height - self.TIMELINE_HEIGHT # Calculate video display area scale = min(self.window_width / frame_width, available_height / frame_height) if scale < 1.0: display_width = int(frame_width * scale) display_height = int(frame_height * scale) else: display_width = frame_width display_height = frame_height start_x = (self.window_width - display_width) // 2 start_y = (available_height - display_height) // 2 # Convert coordinates video_x = (x - start_x) / scale if scale < 1.0 else (x - start_x) video_y = (y - start_y) / scale if scale < 1.0 else (y - start_y) video_w = w / scale if scale < 1.0 else w video_h = h / scale if scale < 1.0 else h # Clamp to video bounds video_x = max(0, min(video_x, frame_width)) video_y = max(0, min(video_y, frame_height)) video_w = min(video_w, frame_width - video_x) video_h = min(video_h, frame_height - video_y) if video_w > 10 and video_h > 10: # Minimum size check # Save current crop for undo if self.crop_rect: self.crop_history.append(self.crop_rect) self.crop_rect = (video_x, video_y, video_w, video_h) def seek_to_timeline_position(self, mouse_x, bar_x_start, bar_width): """Seek to position based on mouse click on timeline""" relative_x = mouse_x - bar_x_start position_ratio = max(0, min(1, relative_x / bar_width)) target_frame = int(position_ratio * (self.total_frames - 1)) self.seek_to_frame(target_frame) def undo_crop(self): """Undo the last crop operation""" if self.crop_history: self.crop_rect = self.crop_history.pop() else: self.crop_rect = None def render_video(self, output_path: str): """Render the video with current crop, zoom, and cut settings""" if not output_path.endswith('.mp4'): output_path += '.mp4' print(f"Rendering video to {output_path}...") # Determine frame range start_frame = self.cut_start_frame if self.cut_start_frame is not None else 0 end_frame = self.cut_end_frame if self.cut_end_frame is not None else self.total_frames - 1 if start_frame >= end_frame: print("Invalid cut range!") return False # Calculate output dimensions if self.crop_rect: output_width = int(self.crop_rect[2] * self.zoom_factor) output_height = int(self.crop_rect[3] * self.zoom_factor) else: output_width = int(self.frame_width * self.zoom_factor) output_height = int(self.frame_height * self.zoom_factor) # Initialize video writer fourcc = cv2.VideoWriter_fourcc(*'mp4v') out = cv2.VideoWriter(output_path, fourcc, self.fps, (output_width, output_height)) if not out.isOpened(): print("Error: Could not open video writer!") return False # Process frames total_output_frames = end_frame - start_frame + 1 for frame_idx in range(start_frame, end_frame + 1): self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) ret, frame = self.cap.read() if not ret: break # Apply crop if self.crop_rect: x, y, w, h = self.crop_rect x, y, w, h = int(x), int(y), int(w), int(h) x = max(0, min(x, frame.shape[1] - 1)) y = max(0, min(y, frame.shape[0] - 1)) w = min(w, frame.shape[1] - x) h = min(h, frame.shape[0] - y) if w > 0 and h > 0: frame = frame[y:y+h, x:x+w] # Apply zoom if self.zoom_factor != 1.0: height, width = frame.shape[:2] new_width = int(width * self.zoom_factor) new_height = int(height * self.zoom_factor) frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_LINEAR) out.write(frame) # Progress indicator progress = (frame_idx - start_frame + 1) / total_output_frames * 100 print(f"Progress: {progress:.1f}%\r", end="") out.release() print(f"\nVideo rendered successfully to {output_path}") return True def run(self): """Main editor loop""" print("Video Editor Controls:") print(" Space: Play/Pause") print(" A/D: Seek backward/forward") print(" W/S: Increase/Decrease speed") print(" Shift+Click+Drag: Select crop area") print(" U: Undo crop") print(" C: Clear crop") print(" Ctrl+Scroll: Zoom in/out") print(" 1: Set cut start point") print(" 2: Set cut end point") print(" Enter: Render video") print(" Q/ESC: Quit") print() cv2.namedWindow("Video Editor", cv2.WINDOW_NORMAL) cv2.resizeWindow("Video Editor", self.window_width, self.window_height) cv2.setMouseCallback("Video Editor", self.mouse_callback) self.load_current_frame() while True: self.display_current_frame() delay = self.calculate_frame_delay() if self.is_playing else 30 key = cv2.waitKey(delay) & 0xFF if key == ord('q') or key == 27: # ESC break elif key == ord(' '): self.is_playing = not self.is_playing elif key == ord('a'): self.seek_video(-1) elif key == ord('d'): self.seek_video(1) elif key == ord('w'): self.playback_speed = min(self.MAX_PLAYBACK_SPEED, self.playback_speed + self.SPEED_INCREMENT) elif key == ord('s'): self.playback_speed = max(self.MIN_PLAYBACK_SPEED, self.playback_speed - self.SPEED_INCREMENT) elif key == ord('u'): self.undo_crop() elif key == ord('c'): if self.crop_rect: self.crop_history.append(self.crop_rect) self.crop_rect = None elif key == ord('1'): self.cut_start_frame = self.current_frame print(f"Set cut start at frame {self.current_frame}") elif key == ord('2'): self.cut_end_frame = self.current_frame print(f"Set cut end at frame {self.current_frame}") elif key == 13: # Enter output_name = f"{self.video_path.stem}_edited.mp4" self.render_video(str(self.video_path.parent / output_name)) # Auto advance frame when playing if self.is_playing: self.advance_frame() self.cap.release() cv2.destroyAllWindows() def main(): parser = argparse.ArgumentParser(description="Fast Video Editor - Crop, Zoom, and Cut videos") parser.add_argument("video", help="Path to video file") args = parser.parse_args() if not os.path.isfile(args.video): print(f"Error: {args.video} is not a valid file") sys.exit(1) try: editor = VideoEditor(args.video) editor.run() except Exception as e: print(f"Error: {e}") sys.exit(1) if __name__ == "__main__": main()