From 205b2220a1ec7bc58d9ce8d4a9e04687e727315c Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Tue, 19 Aug 2025 09:00:39 +0200 Subject: [PATCH] feat(croppa): implement basic video editing functionality with cropping and zooming --- croppa/main.py | 538 ++++++++++++++++++++++++++++++++++++++++++ croppa/pyproject.toml | 10 + pyproject.toml | 5 + 3 files changed, 553 insertions(+) create mode 100644 croppa/main.py create mode 100644 croppa/pyproject.toml diff --git a/croppa/main.py b/croppa/main.py new file mode 100644 index 0000000..1d4e014 --- /dev/null +++ b/croppa/main.py @@ -0,0 +1,538 @@ +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() diff --git a/croppa/pyproject.toml b/croppa/pyproject.toml new file mode 100644 index 0000000..730a090 --- /dev/null +++ b/croppa/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "croppa" +version = "0.1.0" +description = "Fast and lightweight video editor for cropping, zooming, and cutting" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "opencv-python>=4.8.0", + "numpy>=1.24.0" +] diff --git a/pyproject.toml b/pyproject.toml index 0bb5e67..d968d47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,3 +16,8 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] include = ["main.py"] + +[tool.uv.workspace] +members = [ + "croppa", +]