feat(main.py): add state saving and loading for video editor session
This commit is contained in:
115
croppa/main.py
115
croppa/main.py
@@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Optional, Tuple, List
|
from typing import Optional, Tuple, List
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
class VideoEditor:
|
class VideoEditor:
|
||||||
@@ -133,6 +134,93 @@ class VideoEditor:
|
|||||||
# Crop adjustment settings
|
# Crop adjustment settings
|
||||||
self.crop_size_step = self.CROP_SIZE_STEP
|
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:
|
def _is_video_file(self, file_path: Path) -> bool:
|
||||||
"""Check if file is a supported video format"""
|
"""Check if file is a supported video format"""
|
||||||
return file_path.suffix.lower() in self.VIDEO_EXTENSIONS
|
return file_path.suffix.lower() in self.VIDEO_EXTENSIONS
|
||||||
@@ -349,6 +437,10 @@ class VideoEditor:
|
|||||||
self.cut_end_frame = None
|
self.cut_end_frame = None
|
||||||
self.display_offset = [0, 0]
|
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):
|
def switch_to_video(self, index: int):
|
||||||
"""Switch to a specific video by index"""
|
"""Switch to a specific video by index"""
|
||||||
if 0 <= index < len(self.video_files):
|
if 0 <= index < len(self.video_files):
|
||||||
@@ -411,6 +503,7 @@ class VideoEditor:
|
|||||||
"""Seek to specific frame"""
|
"""Seek to specific frame"""
|
||||||
self.current_frame = max(0, min(frame_number, self.total_frames - 1))
|
self.current_frame = max(0, min(frame_number, self.total_frames - 1))
|
||||||
self.load_current_frame()
|
self.load_current_frame()
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
def advance_frame(self) -> bool:
|
def advance_frame(self) -> bool:
|
||||||
"""Advance to next frame - optimized to avoid seeking, handles playback speed"""
|
"""Advance to next frame - optimized to avoid seeking, handles playback speed"""
|
||||||
@@ -534,6 +627,7 @@ class VideoEditor:
|
|||||||
def rotate_clockwise(self):
|
def rotate_clockwise(self):
|
||||||
"""Rotate video 90 degrees clockwise"""
|
"""Rotate video 90 degrees clockwise"""
|
||||||
self.rotation_angle = (self.rotation_angle + 90) % 360
|
self.rotation_angle = (self.rotation_angle + 90) % 360
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
def apply_brightness_contrast(self, frame):
|
def apply_brightness_contrast(self, frame):
|
||||||
"""Apply brightness and contrast adjustments to frame"""
|
"""Apply brightness and contrast adjustments to frame"""
|
||||||
@@ -552,10 +646,12 @@ class VideoEditor:
|
|||||||
def adjust_brightness(self, delta: int):
|
def adjust_brightness(self, delta: int):
|
||||||
"""Adjust brightness by delta (-100 to 100)"""
|
"""Adjust brightness by delta (-100 to 100)"""
|
||||||
self.brightness = max(-100, min(100, self.brightness + delta))
|
self.brightness = max(-100, min(100, self.brightness + delta))
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
def adjust_contrast(self, delta: float):
|
def adjust_contrast(self, delta: float):
|
||||||
"""Adjust contrast by delta (0.1 to 3.0)"""
|
"""Adjust contrast by delta (0.1 to 3.0)"""
|
||||||
self.contrast = max(0.1, min(3.0, self.contrast + delta))
|
self.contrast = max(0.1, min(3.0, self.contrast + delta))
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
def show_progress_bar(self, text: str = "Processing..."):
|
def show_progress_bar(self, text: str = "Processing..."):
|
||||||
"""Show progress bar with given text"""
|
"""Show progress bar with given text"""
|
||||||
@@ -1086,6 +1182,7 @@ class VideoEditor:
|
|||||||
# Handle zoom center (Ctrl + click)
|
# Handle zoom center (Ctrl + click)
|
||||||
if flags & cv2.EVENT_FLAG_CTRLKEY and event == cv2.EVENT_LBUTTONDOWN:
|
if flags & cv2.EVENT_FLAG_CTRLKEY and event == cv2.EVENT_LBUTTONDOWN:
|
||||||
self.zoom_center = (x, y)
|
self.zoom_center = (x, y)
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
# Handle scroll wheel for zoom (Ctrl + scroll)
|
# Handle scroll wheel for zoom (Ctrl + scroll)
|
||||||
if flags & cv2.EVENT_FLAG_CTRLKEY:
|
if flags & cv2.EVENT_FLAG_CTRLKEY:
|
||||||
@@ -1098,6 +1195,7 @@ class VideoEditor:
|
|||||||
self.zoom_factor = max(
|
self.zoom_factor = max(
|
||||||
self.MIN_ZOOM, self.zoom_factor - self.ZOOM_INCREMENT
|
self.MIN_ZOOM, self.zoom_factor - self.ZOOM_INCREMENT
|
||||||
)
|
)
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
def set_crop_from_screen_coords(self, screen_rect):
|
def set_crop_from_screen_coords(self, screen_rect):
|
||||||
"""Convert screen coordinates to video frame coordinates and set crop"""
|
"""Convert screen coordinates to video frame coordinates and set crop"""
|
||||||
@@ -1217,6 +1315,7 @@ class VideoEditor:
|
|||||||
if self.crop_rect:
|
if self.crop_rect:
|
||||||
self.crop_history.append(self.crop_rect)
|
self.crop_history.append(self.crop_rect)
|
||||||
self.crop_rect = (original_x, original_y, original_w, original_h)
|
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):
|
def seek_to_timeline_position(self, mouse_x, bar_x_start, bar_width):
|
||||||
"""Seek to position based on mouse click on timeline"""
|
"""Seek to position based on mouse click on timeline"""
|
||||||
@@ -1231,6 +1330,7 @@ class VideoEditor:
|
|||||||
self.crop_rect = self.crop_history.pop()
|
self.crop_rect = self.crop_history.pop()
|
||||||
else:
|
else:
|
||||||
self.crop_rect = None
|
self.crop_rect = None
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
def toggle_marker_looping(self):
|
def toggle_marker_looping(self):
|
||||||
"""Toggle looping between cut markers"""
|
"""Toggle looping between cut markers"""
|
||||||
@@ -1286,22 +1386,26 @@ class VideoEditor:
|
|||||||
new_y = max(0, y - amount)
|
new_y = max(0, y - amount)
|
||||||
new_h = h + (y - new_y)
|
new_h = h + (y - new_y)
|
||||||
self.crop_rect = (x, new_y, w, new_h)
|
self.crop_rect = (x, new_y, w, new_h)
|
||||||
|
self.save_state()
|
||||||
else:
|
else:
|
||||||
# Contract from bottom - decrease height
|
# Contract from bottom - decrease height
|
||||||
new_h = max(10, h - amount) # Minimum size of 10 pixels
|
new_h = max(10, h - amount) # Minimum size of 10 pixels
|
||||||
self.crop_rect = (x, y, w, new_h)
|
self.crop_rect = (x, y, w, new_h)
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
elif direction == 'down':
|
elif direction == 'down':
|
||||||
if expand:
|
if expand:
|
||||||
# Expand downward - increase height
|
# Expand downward - increase height
|
||||||
new_h = min(self.frame_height - y, h + amount)
|
new_h = min(self.frame_height - y, h + amount)
|
||||||
self.crop_rect = (x, y, w, new_h)
|
self.crop_rect = (x, y, w, new_h)
|
||||||
|
self.save_state()
|
||||||
else:
|
else:
|
||||||
# Contract from top - increase y, decrease height
|
# Contract from top - increase y, decrease height
|
||||||
amount = min(amount, h - 10) # Don't make it smaller than 10 pixels
|
amount = min(amount, h - 10) # Don't make it smaller than 10 pixels
|
||||||
new_y = y + amount
|
new_y = y + amount
|
||||||
new_h = h - amount
|
new_h = h - amount
|
||||||
self.crop_rect = (x, new_y, w, new_h)
|
self.crop_rect = (x, new_y, w, new_h)
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
elif direction == 'left':
|
elif direction == 'left':
|
||||||
if expand:
|
if expand:
|
||||||
@@ -1309,22 +1413,26 @@ class VideoEditor:
|
|||||||
new_x = max(0, x - amount)
|
new_x = max(0, x - amount)
|
||||||
new_w = w + (x - new_x)
|
new_w = w + (x - new_x)
|
||||||
self.crop_rect = (new_x, y, new_w, h)
|
self.crop_rect = (new_x, y, new_w, h)
|
||||||
|
self.save_state()
|
||||||
else:
|
else:
|
||||||
# Contract from right - decrease width
|
# Contract from right - decrease width
|
||||||
new_w = max(10, w - amount) # Minimum size of 10 pixels
|
new_w = max(10, w - amount) # Minimum size of 10 pixels
|
||||||
self.crop_rect = (x, y, new_w, h)
|
self.crop_rect = (x, y, new_w, h)
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
elif direction == 'right':
|
elif direction == 'right':
|
||||||
if expand:
|
if expand:
|
||||||
# Expand rightward - increase width
|
# Expand rightward - increase width
|
||||||
new_w = min(self.frame_width - x, w + amount)
|
new_w = min(self.frame_width - x, w + amount)
|
||||||
self.crop_rect = (x, y, new_w, h)
|
self.crop_rect = (x, y, new_w, h)
|
||||||
|
self.save_state()
|
||||||
else:
|
else:
|
||||||
# Contract from left - increase x, decrease width
|
# Contract from left - increase x, decrease width
|
||||||
amount = min(amount, w - 10) # Don't make it smaller than 10 pixels
|
amount = min(amount, w - 10) # Don't make it smaller than 10 pixels
|
||||||
new_x = x + amount
|
new_x = x + amount
|
||||||
new_w = w - amount
|
new_w = w - amount
|
||||||
self.crop_rect = (new_x, y, new_w, h)
|
self.crop_rect = (new_x, y, new_w, h)
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
def render_video(self, output_path: str):
|
def render_video(self, output_path: str):
|
||||||
"""Render video or save image with current edits applied"""
|
"""Render video or save image with current edits applied"""
|
||||||
@@ -1633,6 +1741,7 @@ class VideoEditor:
|
|||||||
# Don't allow play/pause for images
|
# Don't allow play/pause for images
|
||||||
if not self.is_image_mode:
|
if not self.is_image_mode:
|
||||||
self.is_playing = not self.is_playing
|
self.is_playing = not self.is_playing
|
||||||
|
self.save_state()
|
||||||
elif key == ord("a") or key == ord("A"):
|
elif key == ord("a") or key == ord("A"):
|
||||||
# Seeking only for videos
|
# Seeking only for videos
|
||||||
if not self.is_image_mode:
|
if not self.is_image_mode:
|
||||||
@@ -1670,12 +1779,14 @@ class VideoEditor:
|
|||||||
self.playback_speed = min(
|
self.playback_speed = min(
|
||||||
self.MAX_PLAYBACK_SPEED, self.playback_speed + self.SPEED_INCREMENT
|
self.MAX_PLAYBACK_SPEED, self.playback_speed + self.SPEED_INCREMENT
|
||||||
)
|
)
|
||||||
|
self.save_state()
|
||||||
elif key == ord("S"):
|
elif key == ord("S"):
|
||||||
# Speed control only for videos
|
# Speed control only for videos
|
||||||
if not self.is_image_mode:
|
if not self.is_image_mode:
|
||||||
self.playback_speed = max(
|
self.playback_speed = max(
|
||||||
self.MIN_PLAYBACK_SPEED, self.playback_speed - self.SPEED_INCREMENT
|
self.MIN_PLAYBACK_SPEED, self.playback_speed - self.SPEED_INCREMENT
|
||||||
)
|
)
|
||||||
|
self.save_state()
|
||||||
elif key == ord("e") or key == ord("E"):
|
elif key == ord("e") or key == ord("E"):
|
||||||
# Brightness adjustment: E (increase), Shift+E (decrease)
|
# Brightness adjustment: E (increase), Shift+E (decrease)
|
||||||
if key == ord("E"):
|
if key == ord("E"):
|
||||||
@@ -1698,16 +1809,19 @@ class VideoEditor:
|
|||||||
if self.crop_rect:
|
if self.crop_rect:
|
||||||
self.crop_history.append(self.crop_rect)
|
self.crop_history.append(self.crop_rect)
|
||||||
self.crop_rect = None
|
self.crop_rect = None
|
||||||
|
self.save_state()
|
||||||
elif key == ord("1"):
|
elif key == ord("1"):
|
||||||
# Cut markers only for videos
|
# Cut markers only for videos
|
||||||
if not self.is_image_mode:
|
if not self.is_image_mode:
|
||||||
self.cut_start_frame = self.current_frame
|
self.cut_start_frame = self.current_frame
|
||||||
print(f"Set cut start at frame {self.current_frame}")
|
print(f"Set cut start at frame {self.current_frame}")
|
||||||
|
self.save_state()
|
||||||
elif key == ord("2"):
|
elif key == ord("2"):
|
||||||
# Cut markers only for videos
|
# Cut markers only for videos
|
||||||
if not self.is_image_mode:
|
if not self.is_image_mode:
|
||||||
self.cut_end_frame = self.current_frame
|
self.cut_end_frame = self.current_frame
|
||||||
print(f"Set cut end at frame {self.current_frame}")
|
print(f"Set cut end at frame {self.current_frame}")
|
||||||
|
self.save_state()
|
||||||
elif key == ord("N"):
|
elif key == ord("N"):
|
||||||
if len(self.video_files) > 1:
|
if len(self.video_files) > 1:
|
||||||
self.previous_video()
|
self.previous_video()
|
||||||
@@ -1759,6 +1873,7 @@ class VideoEditor:
|
|||||||
# Marker looping only for videos
|
# Marker looping only for videos
|
||||||
if not self.is_image_mode:
|
if not self.is_image_mode:
|
||||||
self.toggle_marker_looping()
|
self.toggle_marker_looping()
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
# Individual direction controls using shift combinations we can detect
|
# Individual direction controls using shift combinations we can detect
|
||||||
elif key == ord("J"): # Shift+i - expand up
|
elif key == ord("J"): # Shift+i - expand up
|
||||||
|
Reference in New Issue
Block a user