diff --git a/croppa/main.py b/croppa/main.py index 4d985d1..595c765 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -80,6 +80,9 @@ class VideoEditor: self.zoom_factor = 1.0 self.zoom_center = None # (x, y) center point for zoom + # Rotation settings + self.rotation_angle = 0 # 0, 90, 180, 270 degrees + # Cut points self.cut_start_frame = None self.cut_end_frame = None @@ -118,11 +121,12 @@ class VideoEditor: self.playback_speed = 1.0 self.current_display_frame = None - # Reset crop, zoom, and cut settings for new video + # Reset crop, zoom, rotation, and cut settings for new video self.crop_rect = None self.crop_history = [] self.zoom_factor = 1.0 self.zoom_center = None + self.rotation_angle = 0 self.cut_start_frame = None self.cut_end_frame = None self.display_offset = [0, 0] @@ -166,6 +170,17 @@ class VideoEditor: self.current_frame = target_frame self.load_current_frame() + def seek_video_with_modifier(self, direction: int, shift_pressed: bool, ctrl_pressed: bool): + """Seek video with different frame counts based on modifiers""" + if ctrl_pressed: + frames = direction * 60 # Ctrl: 60 frames + elif shift_pressed: + frames = direction * 10 # Shift: 10 frames + else: + frames = direction * 1 # Default: 1 frame + + self.seek_video(frames) + def seek_to_frame(self, frame_number: int): """Seek to specific frame""" self.current_frame = max(0, min(frame_number, self.total_frames - 1)) @@ -182,8 +197,8 @@ class VideoEditor: return self.load_current_frame() - def apply_crop_and_zoom(self, frame): - """Apply current crop and zoom settings to frame""" + def apply_crop_zoom_and_rotation(self, frame): + """Apply current crop, zoom, and rotation settings to frame""" if frame is None: return None @@ -201,6 +216,10 @@ class VideoEditor: if w > 0 and h > 0: processed_frame = processed_frame[y:y+h, x:x+w] + # Apply rotation + if self.rotation_angle != 0: + processed_frame = self.apply_rotation(processed_frame) + # Apply zoom if self.zoom_factor != 1.0: height, width = processed_frame.shape[:2] @@ -219,6 +238,22 @@ class VideoEditor: return processed_frame + def apply_rotation(self, frame): + """Apply rotation to frame""" + if self.rotation_angle == 0: + return frame + elif self.rotation_angle == 90: + return cv2.rotate(frame, cv2.ROTATE_90_CLOCKWISE) + elif self.rotation_angle == 180: + return cv2.rotate(frame, cv2.ROTATE_180) + elif self.rotation_angle == 270: + return cv2.rotate(frame, cv2.ROTATE_90_COUNTERCLOCKWISE) + return frame + + def rotate_clockwise(self): + """Rotate video 90 degrees clockwise""" + self.rotation_angle = (self.rotation_angle + 90) % 360 + def draw_timeline(self, frame): """Draw timeline at the bottom of the frame""" height, width = frame.shape[:2] @@ -303,8 +338,8 @@ class VideoEditor: 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()) + # Apply crop, zoom, and rotation transformations for preview + display_frame = self.apply_crop_zoom_and_rotation(self.current_display_frame.copy()) if display_frame is None: return @@ -334,7 +369,8 @@ class VideoEditor: self.draw_crop_overlay(canvas, start_x, start_y, frame_width, frame_height) # 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'}" + rotation_text = f" | Rotation: {self.rotation_angle}°" if self.rotation_angle != 0 else "" + info_text = f"Frame: {self.current_frame}/{self.total_frames} | Speed: {self.playback_speed:.1f}x | Zoom: {self.zoom_factor:.1f}x{rotation_text} | {'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) @@ -429,8 +465,8 @@ class VideoEditor: original_height, original_width = self.current_display_frame.shape[:2] available_height = self.window_height - self.TIMELINE_HEIGHT - # Calculate how the original frame is displayed (after crop/zoom) - display_frame = self.apply_crop_and_zoom(self.current_display_frame.copy()) + # Calculate how the original frame is displayed (after crop/zoom/rotation) + display_frame = self.apply_crop_zoom_and_rotation(self.current_display_frame.copy()) if display_frame is None: return @@ -528,13 +564,21 @@ class VideoEditor: print("Invalid cut range!") return False - # Calculate output dimensions + # Calculate output dimensions (accounting for rotation) if self.crop_rect: - output_width = int(self.crop_rect[2] * self.zoom_factor) - output_height = int(self.crop_rect[3] * self.zoom_factor) + crop_width = int(self.crop_rect[2]) + crop_height = int(self.crop_rect[3]) else: - output_width = int(self.frame_width * self.zoom_factor) - output_height = int(self.frame_height * self.zoom_factor) + crop_width = self.frame_width + crop_height = self.frame_height + + # Swap dimensions if rotation is 90 or 270 degrees + if self.rotation_angle == 90 or self.rotation_angle == 270: + output_width = int(crop_height * self.zoom_factor) + output_height = int(crop_width * self.zoom_factor) + else: + output_width = int(crop_width * self.zoom_factor) + output_height = int(crop_height * self.zoom_factor) # Use mp4v codec (most compatible with MP4) fourcc = cv2.VideoWriter_fourcc(*'mp4v') @@ -604,6 +648,10 @@ class VideoEditor: else: return None + # Apply rotation + if self.rotation_angle != 0: + frame = self.apply_rotation(frame) + # Apply zoom and resize in one step for efficiency if self.zoom_factor != 1.0: height, width = frame.shape[:2] @@ -630,8 +678,11 @@ class VideoEditor: """Main editor loop""" print("Video Editor Controls:") print(" Space: Play/Pause") - print(" A/D: Seek backward/forward") + print(" A/D: Seek backward/forward (1 frame)") + print(" Shift+A/D: Seek backward/forward (10 frames)") + print(" Ctrl+A/D: Seek backward/forward (60 frames)") print(" W/S: Increase/Decrease speed") + print(" -: Rotate clockwise 90°") print(" Shift+Click+Drag: Select crop area") print(" U: Undo crop") print(" C: Clear crop") @@ -657,14 +708,34 @@ class VideoEditor: delay = self.calculate_frame_delay() if self.is_playing else 30 key = cv2.waitKey(delay) & 0xFF + # Get modifier key states + modifiers = cv2.getWindowProperty("Video Editor", cv2.WND_PROP_AUTOSIZE) + # Note: OpenCV doesn't provide direct access to modifier keys in waitKey + # We'll handle this through special key combinations + 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('a') or key == ord('A'): + # Check if it's uppercase A (Shift+A) + if key == ord('A'): + self.seek_video_with_modifier(-1, True, False) # Shift+A: -10 frames + else: + self.seek_video_with_modifier(-1, False, False) # A: -1 frame + elif key == ord('d') or key == ord('D'): + # Check if it's uppercase D (Shift+D) + if key == ord('D'): + self.seek_video_with_modifier(1, True, False) # Shift+D: +10 frames + else: + self.seek_video_with_modifier(1, False, False) # D: +1 frame + elif key == 1: # Ctrl+A + self.seek_video_with_modifier(-1, False, True) # Ctrl+A: -60 frames + elif key == 4: # Ctrl+D + self.seek_video_with_modifier(1, False, True) # Ctrl+D: +60 frames + elif key == ord('-') or key == ord('_'): + self.rotate_clockwise() + print(f"Rotated to {self.rotation_angle}°") elif key == ord('w'): self.playback_speed = min(self.MAX_PLAYBACK_SPEED, self.playback_speed + self.SPEED_INCREMENT) elif key == ord('s'):