diff --git a/croppa/main.py b/croppa/main.py index de49c62..8b58ef0 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import Optional, Tuple, List import time import re +import json class VideoEditor: @@ -133,6 +134,93 @@ class VideoEditor: # Crop adjustment settings self.crop_size_step = self.CROP_SIZE_STEP + def _get_state_file_path(self) -> Path: + """Get the state file path for the current media file""" + if not hasattr(self, 'video_path') or not self.video_path: + return None + return self.video_path.with_suffix('.json') + + def save_state(self): + """Save current editor state to JSON file""" + state_file = self._get_state_file_path() + if not state_file: + return False + + try: + state = { + 'timestamp': time.time(), + 'current_frame': getattr(self, 'current_frame', 0), + 'crop_rect': self.crop_rect, + 'zoom_factor': self.zoom_factor, + 'zoom_center': self.zoom_center, + 'rotation_angle': self.rotation_angle, + 'brightness': self.brightness, + 'contrast': self.contrast, + 'cut_start_frame': self.cut_start_frame, + 'cut_end_frame': self.cut_end_frame, + 'looping_between_markers': self.looping_between_markers, + 'display_offset': self.display_offset, + 'playback_speed': getattr(self, 'playback_speed', 1.0), + 'is_playing': getattr(self, 'is_playing', False) + } + + with open(state_file, 'w') as f: + json.dump(state, f, indent=2) + return True + except Exception as e: + print(f"Error saving state: {e}") + return False + + def load_state(self) -> bool: + """Load editor state from JSON file""" + state_file = self._get_state_file_path() + if not state_file or not state_file.exists(): + return False + + try: + with open(state_file, 'r') as f: + state = json.load(f) + + # Restore state values + if 'current_frame' in state: + self.current_frame = state['current_frame'] + if 'crop_rect' in state and state['crop_rect']: + self.crop_rect = tuple(state['crop_rect']) + if 'zoom_factor' in state: + self.zoom_factor = state['zoom_factor'] + if 'zoom_center' in state and state['zoom_center']: + self.zoom_center = tuple(state['zoom_center']) + if 'rotation_angle' in state: + self.rotation_angle = state['rotation_angle'] + if 'brightness' in state: + self.brightness = state['brightness'] + if 'contrast' in state: + self.contrast = state['contrast'] + if 'cut_start_frame' in state: + self.cut_start_frame = state['cut_start_frame'] + if 'cut_end_frame' in state: + self.cut_end_frame = state['cut_end_frame'] + if 'looping_between_markers' in state: + self.looping_between_markers = state['looping_between_markers'] + if 'display_offset' in state: + self.display_offset = state['display_offset'] + if 'playback_speed' in state: + self.playback_speed = state['playback_speed'] + if 'is_playing' in state: + self.is_playing = state['is_playing'] + + # Validate and clamp values + self.current_frame = max(0, min(self.current_frame, getattr(self, 'total_frames', 1) - 1)) + self.zoom_factor = max(self.MIN_ZOOM, min(self.MAX_ZOOM, self.zoom_factor)) + self.brightness = max(-100, min(100, self.brightness)) + self.contrast = max(0.1, min(3.0, self.contrast)) + self.playback_speed = max(self.MIN_PLAYBACK_SPEED, min(self.MAX_PLAYBACK_SPEED, self.playback_speed)) + + return True + except Exception as e: + print(f"Error loading state: {e}") + return False + 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 @@ -349,6 +437,10 @@ class VideoEditor: self.cut_end_frame = None self.display_offset = [0, 0] + # Try to load saved state for this media file + if self.load_state(): + print("Loaded saved state for this media file") + def switch_to_video(self, index: int): """Switch to a specific video by index""" if 0 <= index < len(self.video_files): @@ -411,6 +503,7 @@ class VideoEditor: """Seek to specific frame""" self.current_frame = max(0, min(frame_number, self.total_frames - 1)) self.load_current_frame() + self.save_state() def advance_frame(self) -> bool: """Advance to next frame - optimized to avoid seeking, handles playback speed""" @@ -534,6 +627,7 @@ class VideoEditor: def rotate_clockwise(self): """Rotate video 90 degrees clockwise""" self.rotation_angle = (self.rotation_angle + 90) % 360 + self.save_state() def apply_brightness_contrast(self, frame): """Apply brightness and contrast adjustments to frame""" @@ -552,10 +646,12 @@ class VideoEditor: def adjust_brightness(self, delta: int): """Adjust brightness by delta (-100 to 100)""" self.brightness = max(-100, min(100, self.brightness + delta)) + self.save_state() def adjust_contrast(self, delta: float): """Adjust contrast by delta (0.1 to 3.0)""" self.contrast = max(0.1, min(3.0, self.contrast + delta)) + self.save_state() def show_progress_bar(self, text: str = "Processing..."): """Show progress bar with given text""" @@ -1086,6 +1182,7 @@ class VideoEditor: # Handle zoom center (Ctrl + click) if flags & cv2.EVENT_FLAG_CTRLKEY and event == cv2.EVENT_LBUTTONDOWN: self.zoom_center = (x, y) + self.save_state() # Handle scroll wheel for zoom (Ctrl + scroll) if flags & cv2.EVENT_FLAG_CTRLKEY: @@ -1098,6 +1195,7 @@ class VideoEditor: self.zoom_factor = max( self.MIN_ZOOM, self.zoom_factor - self.ZOOM_INCREMENT ) + self.save_state() def set_crop_from_screen_coords(self, screen_rect): """Convert screen coordinates to video frame coordinates and set crop""" @@ -1217,6 +1315,7 @@ class VideoEditor: if self.crop_rect: self.crop_history.append(self.crop_rect) self.crop_rect = (original_x, original_y, original_w, original_h) + self.save_state() def seek_to_timeline_position(self, mouse_x, bar_x_start, bar_width): """Seek to position based on mouse click on timeline""" @@ -1231,6 +1330,7 @@ class VideoEditor: self.crop_rect = self.crop_history.pop() else: self.crop_rect = None + self.save_state() def toggle_marker_looping(self): """Toggle looping between cut markers""" @@ -1286,22 +1386,26 @@ class VideoEditor: new_y = max(0, y - amount) new_h = h + (y - new_y) self.crop_rect = (x, new_y, w, new_h) + self.save_state() else: # Contract from bottom - decrease height new_h = max(10, h - amount) # Minimum size of 10 pixels self.crop_rect = (x, y, w, new_h) + self.save_state() elif direction == 'down': if expand: # Expand downward - increase height new_h = min(self.frame_height - y, h + amount) self.crop_rect = (x, y, w, new_h) + self.save_state() else: # Contract from top - increase y, decrease height amount = min(amount, h - 10) # Don't make it smaller than 10 pixels new_y = y + amount new_h = h - amount self.crop_rect = (x, new_y, w, new_h) + self.save_state() elif direction == 'left': if expand: @@ -1309,22 +1413,26 @@ class VideoEditor: new_x = max(0, x - amount) new_w = w + (x - new_x) self.crop_rect = (new_x, y, new_w, h) + self.save_state() else: # Contract from right - decrease width new_w = max(10, w - amount) # Minimum size of 10 pixels self.crop_rect = (x, y, new_w, h) + self.save_state() elif direction == 'right': if expand: # Expand rightward - increase width new_w = min(self.frame_width - x, w + amount) self.crop_rect = (x, y, new_w, h) + self.save_state() else: # Contract from left - increase x, decrease width amount = min(amount, w - 10) # Don't make it smaller than 10 pixels new_x = x + amount new_w = w - amount self.crop_rect = (new_x, y, new_w, h) + self.save_state() def render_video(self, output_path: str): """Render video or save image with current edits applied""" @@ -1633,6 +1741,7 @@ class VideoEditor: # Don't allow play/pause for images if not self.is_image_mode: self.is_playing = not self.is_playing + self.save_state() elif key == ord("a") or key == ord("A"): # Seeking only for videos if not self.is_image_mode: @@ -1670,12 +1779,14 @@ class VideoEditor: self.playback_speed = min( self.MAX_PLAYBACK_SPEED, self.playback_speed + self.SPEED_INCREMENT ) + self.save_state() elif key == ord("S"): # 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 ) + self.save_state() elif key == ord("e") or key == ord("E"): # Brightness adjustment: E (increase), Shift+E (decrease) if key == ord("E"): @@ -1698,16 +1809,19 @@ class VideoEditor: if self.crop_rect: self.crop_history.append(self.crop_rect) self.crop_rect = None + self.save_state() elif key == ord("1"): # 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}") + self.save_state() elif key == ord("2"): # 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}") + self.save_state() elif key == ord("N"): if len(self.video_files) > 1: self.previous_video() @@ -1759,6 +1873,7 @@ class VideoEditor: # Marker looping only for videos if not self.is_image_mode: self.toggle_marker_looping() + self.save_state() # Individual direction controls using shift combinations we can detect elif key == ord("J"): # Shift+i - expand up