diff --git a/croppa/main.py b/croppa/main.py index 5a4f905..a7a5de2 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -47,6 +47,9 @@ class VideoEditor: # Supported video extensions VIDEO_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv", ".webm", ".m4v"} + # Supported image extensions + IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif", ".webp", ".jp2", ".pbm", ".pgm", ".ppm", ".sr", ".ras"} + # Crop adjustment settings CROP_SIZE_STEP = 15 # pixels to expand/contract crop @@ -56,15 +59,18 @@ class VideoEditor: # Video file management self.video_files = [] self.current_video_index = 0 + + # Media type tracking + self.is_image_mode = False # True if current file is an image # Determine if path is file or directory if self.path.is_file(): self.video_files = [self.path] elif self.path.is_dir(): - # Load all video files from directory - self.video_files = self._get_video_files_from_directory(self.path) + # Load all media files from directory + self.video_files = self._get_media_files_from_directory(self.path) if not self.video_files: - raise ValueError(f"No video files found in directory: {path}") + raise ValueError(f"No media files found in directory: {path}") else: raise ValueError(f"Path does not exist: {path}") @@ -122,6 +128,18 @@ class VideoEditor: # Crop adjustment settings self.crop_size_step = self.CROP_SIZE_STEP + def _is_video_file(self, file_path: Path) -> bool: + """Check if file is a supported video format""" + return file_path.suffix.lower() in self.VIDEO_EXTENSIONS + + def _is_image_file(self, file_path: Path) -> bool: + """Check if file is a supported image format""" + return file_path.suffix.lower() in self.IMAGE_EXTENSIONS + + def _is_media_file(self, file_path: Path) -> bool: + """Check if file is a supported media format (video or image)""" + return self._is_video_file(file_path) or self._is_image_file(file_path) + def _get_next_edited_filename(self, video_path: Path) -> str: """Generate the next available _edited_%03d filename""" directory = video_path.parent @@ -145,92 +163,120 @@ class VideoEditor: return f"{base_name}_edited_{next_number:03d}{extension}" - def _get_video_files_from_directory(self, directory: Path) -> List[Path]: - """Get all video files from a directory, sorted by name""" - video_files = set() + def _get_media_files_from_directory(self, directory: Path) -> List[Path]: + """Get all media files (video and image) from a directory, sorted by name""" + media_files = set() for file_path in directory.iterdir(): if ( file_path.is_file() - and file_path.suffix.lower() in self.VIDEO_EXTENSIONS + and self._is_media_file(file_path) ): - video_files.add(file_path) + media_files.add(file_path) # Pattern to match edited files: basename_edited_001.ext, basename_edited_002.ext, etc. edited_pattern = re.compile(r"^(.+)_edited_\d{3}$") edited_base_names = set() - for file_path in video_files: + for file_path in media_files: match = edited_pattern.match(file_path.stem) if match: edited_base_names.add(match.group(1)) - non_edited_videos = set() - for file_path in video_files: - # Skip if this is an edited video + non_edited_media = set() + for file_path in media_files: + # Skip if this is an edited file if edited_pattern.match(file_path.stem): continue - # Skip if there's already an edited version of this video + # Skip if there's already an edited version of this file if file_path.stem in edited_base_names: continue - non_edited_videos.add(file_path) + non_edited_media.add(file_path) - return sorted(non_edited_videos) - def _load_video(self, video_path: Path): - """Load a video file and initialize video properties""" + return sorted(non_edited_media) + def _load_video(self, media_path: Path): + """Load a media file (video or image) and initialize properties""" if hasattr(self, "cap") and self.cap: self.cap.release() - self.video_path = video_path + self.video_path = media_path + self.is_image_mode = self._is_image_file(media_path) - # Try different backends for better performance - # Order of preference: FFmpeg (best for video files), DirectShow (cameras), any available - backends_to_try = [] - if hasattr(cv2, 'CAP_FFMPEG'): # FFmpeg - best for video files - backends_to_try.append(cv2.CAP_FFMPEG) - if hasattr(cv2, 'CAP_DSHOW'): # DirectShow - usually for cameras - backends_to_try.append(cv2.CAP_DSHOW) - backends_to_try.append(cv2.CAP_ANY) # Fallback - - self.cap = None - for backend in backends_to_try: - try: - self.cap = cv2.VideoCapture(str(self.video_path), backend) - if self.cap.isOpened(): - # Optimize buffer settings for better performance - self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Minimize buffer to reduce latency - # Try to set hardware acceleration if available - if hasattr(cv2, 'CAP_PROP_HW_ACCELERATION'): - self.cap.set(cv2.CAP_PROP_HW_ACCELERATION, cv2.VIDEO_ACCELERATION_ANY) - break - self.cap.release() - except: - continue - - if not self.cap or not self.cap.isOpened(): - raise ValueError(f"Could not open video file: {video_path}") + if self.is_image_mode: + # Load static image + self.static_image = cv2.imread(str(media_path)) + if self.static_image is None: + raise ValueError(f"Could not load image file: {media_path}") + + # Set up image properties to mimic video interface + self.frame_height, self.frame_width = self.static_image.shape[:2] + self.total_frames = 1 + self.fps = 30 # Dummy FPS for image mode + self.cap = None + + print(f"Loaded image: {self.video_path.name}") + print(f" Resolution: {self.frame_width}x{self.frame_height}") + else: + # Try different backends for better performance + # Order of preference: FFmpeg (best for video files), DirectShow (cameras), any available + backends_to_try = [] + if hasattr(cv2, 'CAP_FFMPEG'): # FFmpeg - best for video files + backends_to_try.append(cv2.CAP_FFMPEG) + if hasattr(cv2, 'CAP_DSHOW'): # DirectShow - usually for cameras + backends_to_try.append(cv2.CAP_DSHOW) + backends_to_try.append(cv2.CAP_ANY) # Fallback + + self.cap = None + for backend in backends_to_try: + try: + self.cap = cv2.VideoCapture(str(self.video_path), backend) + if self.cap.isOpened(): + # Optimize buffer settings for better performance + self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Minimize buffer to reduce latency + # Try to set hardware acceleration if available + if hasattr(cv2, 'CAP_PROP_HW_ACCELERATION'): + self.cap.set(cv2.CAP_PROP_HW_ACCELERATION, cv2.VIDEO_ACCELERATION_ANY) + break + self.cap.release() + except: + continue + + if not self.cap or not self.cap.isOpened(): + raise ValueError(f"Could not open video file: {media_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)) - # 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)) + # Get codec information for debugging + fourcc = int(self.cap.get(cv2.CAP_PROP_FOURCC)) + codec = "".join([chr((fourcc >> 8 * i) & 0xFF) for i in range(4)]) + + # Get backend information + backend = self.cap.getBackendName() + + print(f"Loaded video: {self.video_path.name} ({self.current_video_index + 1}/{len(self.video_files)})") + print(f" Codec: {codec} | Backend: {backend} | Resolution: {self.frame_width}x{self.frame_height}") + print(f" FPS: {self.fps:.2f} | Frames: {self.total_frames} | Duration: {self.total_frames/self.fps:.1f}s") + + # Performance warning for known problematic cases + if codec in ['H264', 'H.264', 'AVC1', 'avc1'] and self.total_frames > 10000: + print(f" Warning: Large H.264 video detected - seeking may be slow") + if self.frame_width * self.frame_height > 1920 * 1080: + print(f" Warning: High resolution video - decoding may be slow") + if self.fps > 60: + print(f" Warning: High framerate video - may impact playback smoothness") - # Get codec information for debugging - fourcc = int(self.cap.get(cv2.CAP_PROP_FOURCC)) - codec = "".join([chr((fourcc >> 8 * i) & 0xFF) for i in range(4)]) - - # Get backend information - backend = self.cap.getBackendName() - - # Reset playback state for new video + # Reset playback state for new media self.current_frame = 0 - self.is_playing = False + self.is_playing = False if self.is_image_mode else False # Images start paused self.playback_speed = 1.0 self.current_display_frame = None - # Reset crop, zoom, rotation, brightness/contrast, and cut settings for new video + # Reset crop, zoom, rotation, brightness/contrast, and cut settings for new media self.crop_rect = None self.crop_history = [] self.zoom_factor = 1.0 @@ -242,20 +288,6 @@ class VideoEditor: self.cut_end_frame = None self.display_offset = [0, 0] - print( - f"Loaded video: {self.video_path.name} ({self.current_video_index + 1}/{len(self.video_files)})" - ) - print(f" Codec: {codec} | Backend: {backend} | Resolution: {self.frame_width}x{self.frame_height}") - print(f" FPS: {self.fps:.2f} | Frames: {self.total_frames} | Duration: {self.total_frames/self.fps:.1f}s") - - # Performance warning for known problematic cases - if codec in ['H264', 'H.264', 'AVC1', 'avc1'] and self.total_frames > 10000: - print(f" Warning: Large H.264 video detected - seeking may be slow") - if self.frame_width * self.frame_height > 1920 * 1080: - print(f" Warning: High resolution video - decoding may be slow") - if self.fps > 60: - print(f" Warning: High framerate video - may impact playback smoothness") - def switch_to_video(self, index: int): """Switch to a specific video by index""" if 0 <= index < len(self.video_files): @@ -275,12 +307,18 @@ class VideoEditor: 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 + if self.is_image_mode: + # For images, just copy the static image + self.current_display_frame = self.static_image.copy() return True - return False + else: + # For videos, seek and read frame + 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""" @@ -634,6 +672,10 @@ class VideoEditor: def draw_timeline(self, frame): """Draw timeline at the bottom of the frame""" + # Don't draw timeline for images + if self.is_image_mode: + return + height, width = frame.shape[:2] # Timeline background area @@ -753,7 +795,7 @@ class VideoEditor: # Resize to fit window while maintaining aspect ratio height, width = display_frame.shape[:2] - available_height = self.window_height - self.TIMELINE_HEIGHT + available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT) scale = min(self.window_width / width, available_height / height) if scale < 1.0: @@ -790,7 +832,10 @@ class VideoEditor: contrast_text = ( f" | Contrast: {self.contrast:.1f}" if self.contrast != 1.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}{brightness_text}{contrast_text} | {'Playing' if self.is_playing else 'Paused'}" + if self.is_image_mode: + info_text = f"Image | Zoom: {self.zoom_factor:.1f}x{rotation_text}{brightness_text}{contrast_text}" + 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}{brightness_text}{contrast_text} | {'Playing' if self.is_playing else 'Paused'}" cv2.putText( canvas, info_text, @@ -882,12 +927,13 @@ class VideoEditor: # Draw progress bar (if visible) self.draw_progress_bar(canvas) - cv2.imshow("Video Editor", canvas) + window_title = "Image Editor" if self.is_image_mode else "Video Editor" + cv2.imshow(window_title, canvas) def mouse_callback(self, event, x, y, flags, param): """Handle mouse events""" - # Handle timeline interaction - if self.timeline_rect: + # Handle timeline interaction (not for images) + if self.timeline_rect and not self.is_image_mode: bar_x_start, bar_y, bar_width, bar_height = self.timeline_rect bar_x_end = bar_x_start + bar_width @@ -905,7 +951,7 @@ class VideoEditor: # Handle crop selection (Shift + click and drag) if flags & cv2.EVENT_FLAG_SHIFTKEY: - available_height = self.window_height - self.TIMELINE_HEIGHT + available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT) if event == cv2.EVENT_LBUTTONDOWN: self.crop_selecting = True @@ -1133,6 +1179,38 @@ class VideoEditor: self.crop_rect = (new_x, y, new_w, h) def render_video(self, output_path: str): + """Render video or save image with current edits applied""" + if self.is_image_mode: + return self._render_image(output_path) + else: + return self._render_video(output_path) + + def _render_image(self, output_path: str): + """Save image with current edits applied""" + # Get the appropriate file extension + original_ext = self.video_path.suffix.lower() + if not output_path.endswith(original_ext): + output_path += original_ext + + print(f"Saving image to {output_path}...") + + # Apply all transformations to the image + processed_image = self.apply_crop_zoom_and_rotation(self.static_image.copy()) + + if processed_image is not None: + # Save the image + success = cv2.imwrite(output_path, processed_image) + if success: + print(f"Image saved successfully to {output_path}") + return True + else: + print(f"Error: Could not save image to {output_path}") + return False + else: + print("Error: Could not process image") + return False + + def _render_video(self, output_path: str): """Optimized video rendering with multithreading and batch processing""" if not output_path.endswith(".mp4"): output_path += ".mp4" @@ -1324,39 +1402,61 @@ class VideoEditor: def run(self): """Main editor loop""" - print("Video Editor Controls:") - print(" Space: Play/Pause") - 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(" E/Shift+E: Increase/Decrease brightness") - print(" R/Shift+R: Increase/Decrease contrast") - print(" -: Rotate clockwise 90°") - print() - print("Crop Controls:") - print(" Shift+Click+Drag: Select crop area") - print(" I/J/K/L: Contract crop (up/left/down/right)") - print(" Shift+I/J/K/L: Expand crop (up/left/down/right)") - print(" [/]: Contract/Expand crop (cycles directions)") - print(" U: Undo crop") - print(" C: Clear crop") - print() - print("Other Controls:") - print(" Ctrl+Scroll: Zoom in/out") - print(" 1: Set cut start point") - print(" 2: Set cut end point") - print(" T: Toggle loop between markers") - if len(self.video_files) > 1: - print(" N: Next video") - print(" n: Previous video") - print(" Enter: Render video") - print(" Q/ESC: Quit") - print() + if self.is_image_mode: + print("Image Editor Controls:") + print(" E/Shift+E: Increase/Decrease brightness") + print(" R/Shift+R: Increase/Decrease contrast") + print(" -: Rotate clockwise 90°") + print() + print("Crop Controls:") + print(" Shift+Click+Drag: Select crop area") + print(" h/j/k/l: Contract crop (left/down/up/right)") + print(" H/J/K/L: Expand crop (left/down/up/right)") + print(" U: Undo crop") + print(" C: Clear crop") + print() + print("Other Controls:") + print(" Ctrl+Scroll: Zoom in/out") + if len(self.video_files) > 1: + print(" N: Next file") + print(" n: Previous file") + print(" Enter: Save image") + print(" Q/ESC: Quit") + print() + else: + print("Video Editor Controls:") + print(" Space: Play/Pause") + 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(" E/Shift+E: Increase/Decrease brightness") + print(" R/Shift+R: Increase/Decrease contrast") + print(" -: Rotate clockwise 90°") + print() + print("Crop Controls:") + print(" Shift+Click+Drag: Select crop area") + print(" h/j/k/l: Contract crop (left/down/up/right)") + print(" H/J/K/L: Expand crop (left/down/up/right)") + print(" U: Undo crop") + print(" C: Clear crop") + print() + print("Other Controls:") + print(" Ctrl+Scroll: Zoom in/out") + print(" 1: Set cut start point") + print(" 2: Set cut end point") + print(" T: Toggle loop between markers") + if len(self.video_files) > 1: + print(" N: Next video") + print(" n: Previous video") + 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) + window_title = "Image Editor" if self.is_image_mode else "Video Editor" + cv2.namedWindow(window_title, cv2.WINDOW_NORMAL) + cv2.resizeWindow(window_title, self.window_width, self.window_height) + cv2.setMouseCallback(window_title, self.mouse_callback) self.load_current_frame() @@ -1368,43 +1468,58 @@ class VideoEditor: key = cv2.waitKey(delay) & 0xFF # Get modifier key states - modifiers = cv2.getWindowProperty("Video Editor", cv2.WND_PROP_AUTOSIZE) + window_title = "Image Editor" if self.is_image_mode else "Video Editor" + modifiers = cv2.getWindowProperty(window_title, 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 + # Don't allow play/pause for images + if not self.is_image_mode: + self.is_playing = not self.is_playing 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 + # Seeking only for videos + if not self.is_image_mode: + # 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 + # Seeking only for videos + if not self.is_image_mode: + # 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 + # Seeking only for videos + if not self.is_image_mode: + 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 + # Seeking only for videos + if not self.is_image_mode: + 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 - ) + # Speed control only for videos + if not self.is_image_mode: + 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 - ) + # Speed control only for videos + if not self.is_image_mode: + self.playback_speed = max( + self.MIN_PLAYBACK_SPEED, self.playback_speed - self.SPEED_INCREMENT + ) elif key == ord("e") or key == ord("E"): # Brightness adjustment: E (increase), Shift+E (decrease) if key == ord("E"): @@ -1428,11 +1543,15 @@ class VideoEditor: 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}") + # Cut markers only for videos + if not self.is_image_mode: + 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}") + # Cut markers only for videos + if not self.is_image_mode: + self.cut_end_frame = self.current_frame + print(f"Set cut end at frame {self.current_frame}") elif key == ord("N"): if len(self.video_files) > 1: self.previous_video() @@ -1443,7 +1562,9 @@ class VideoEditor: output_name = self._get_next_edited_filename(self.video_path) self.render_video(str(self.video_path.parent / output_name)) elif key == ord("t"): - self.toggle_marker_looping() + # Marker looping only for videos + if not self.is_image_mode: + self.toggle_marker_looping() # Individual direction controls using shift combinations we can detect elif key == ord("J"): # Shift+i - expand up @@ -1474,20 +1595,21 @@ class VideoEditor: print(f"Contracted crop from left by {self.crop_size_step}px") - # Auto advance frame when playing - if self.is_playing: + # Auto advance frame when playing (videos only) + if self.is_playing and not self.is_image_mode: self.advance_frame() - self.cap.release() + if hasattr(self, 'cap') and self.cap: + self.cap.release() cv2.destroyAllWindows() def main(): parser = argparse.ArgumentParser( - description="Fast Video Editor - Crop, Zoom, and Cut videos" + description="Fast Media Editor - Crop, Zoom, and Edit videos and images" ) parser.add_argument( - "video", help="Path to video file or directory containing videos" + "media", help="Path to media file or directory containing videos/images" ) try: @@ -1497,17 +1619,17 @@ def main(): input(f"Argument parsing failed. Press Enter to exit...") return - if not os.path.exists(args.video): - error_msg = f"Error: {args.video} does not exist" + if not os.path.exists(args.media): + error_msg = f"Error: {args.media} does not exist" print(error_msg) input("Press Enter to exit...") # Keep window open in context menu sys.exit(1) try: - editor = VideoEditor(args.video) + editor = VideoEditor(args.media) editor.run() except Exception as e: - error_msg = f"Error initializing video editor: {e}" + error_msg = f"Error initializing media editor: {e}" print(error_msg) import traceback traceback.print_exc() # Full error trace for debugging