Add support for images
This commit is contained in:
426
croppa/main.py
426
croppa/main.py
@@ -47,6 +47,9 @@ class VideoEditor:
|
|||||||
# Supported video extensions
|
# Supported video extensions
|
||||||
VIDEO_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".wmv", ".flv", ".webm", ".m4v"}
|
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 adjustment settings
|
||||||
CROP_SIZE_STEP = 15 # pixels to expand/contract crop
|
CROP_SIZE_STEP = 15 # pixels to expand/contract crop
|
||||||
|
|
||||||
@@ -56,15 +59,18 @@ class VideoEditor:
|
|||||||
# Video file management
|
# Video file management
|
||||||
self.video_files = []
|
self.video_files = []
|
||||||
self.current_video_index = 0
|
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
|
# Determine if path is file or directory
|
||||||
if self.path.is_file():
|
if self.path.is_file():
|
||||||
self.video_files = [self.path]
|
self.video_files = [self.path]
|
||||||
elif self.path.is_dir():
|
elif self.path.is_dir():
|
||||||
# Load all video files from directory
|
# Load all media files from directory
|
||||||
self.video_files = self._get_video_files_from_directory(self.path)
|
self.video_files = self._get_media_files_from_directory(self.path)
|
||||||
if not self.video_files:
|
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:
|
else:
|
||||||
raise ValueError(f"Path does not exist: {path}")
|
raise ValueError(f"Path does not exist: {path}")
|
||||||
|
|
||||||
@@ -122,6 +128,18 @@ 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 _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:
|
def _get_next_edited_filename(self, video_path: Path) -> str:
|
||||||
"""Generate the next available _edited_%03d filename"""
|
"""Generate the next available _edited_%03d filename"""
|
||||||
directory = video_path.parent
|
directory = video_path.parent
|
||||||
@@ -145,92 +163,120 @@ class VideoEditor:
|
|||||||
|
|
||||||
return f"{base_name}_edited_{next_number:03d}{extension}"
|
return f"{base_name}_edited_{next_number:03d}{extension}"
|
||||||
|
|
||||||
def _get_video_files_from_directory(self, directory: Path) -> List[Path]:
|
def _get_media_files_from_directory(self, directory: Path) -> List[Path]:
|
||||||
"""Get all video files from a directory, sorted by name"""
|
"""Get all media files (video and image) from a directory, sorted by name"""
|
||||||
video_files = set()
|
media_files = set()
|
||||||
for file_path in directory.iterdir():
|
for file_path in directory.iterdir():
|
||||||
if (
|
if (
|
||||||
file_path.is_file()
|
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.
|
# Pattern to match edited files: basename_edited_001.ext, basename_edited_002.ext, etc.
|
||||||
edited_pattern = re.compile(r"^(.+)_edited_\d{3}$")
|
edited_pattern = re.compile(r"^(.+)_edited_\d{3}$")
|
||||||
|
|
||||||
edited_base_names = set()
|
edited_base_names = set()
|
||||||
for file_path in video_files:
|
for file_path in media_files:
|
||||||
match = edited_pattern.match(file_path.stem)
|
match = edited_pattern.match(file_path.stem)
|
||||||
if match:
|
if match:
|
||||||
edited_base_names.add(match.group(1))
|
edited_base_names.add(match.group(1))
|
||||||
|
|
||||||
non_edited_videos = set()
|
non_edited_media = set()
|
||||||
for file_path in video_files:
|
for file_path in media_files:
|
||||||
# Skip if this is an edited video
|
# Skip if this is an edited file
|
||||||
if edited_pattern.match(file_path.stem):
|
if edited_pattern.match(file_path.stem):
|
||||||
continue
|
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:
|
if file_path.stem in edited_base_names:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
non_edited_videos.add(file_path)
|
non_edited_media.add(file_path)
|
||||||
|
|
||||||
return sorted(non_edited_videos)
|
return sorted(non_edited_media)
|
||||||
def _load_video(self, video_path: Path):
|
def _load_video(self, media_path: Path):
|
||||||
"""Load a video file and initialize video properties"""
|
"""Load a media file (video or image) and initialize properties"""
|
||||||
if hasattr(self, "cap") and self.cap:
|
if hasattr(self, "cap") and self.cap:
|
||||||
self.cap.release()
|
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
|
if self.is_image_mode:
|
||||||
# Order of preference: FFmpeg (best for video files), DirectShow (cameras), any available
|
# Load static image
|
||||||
backends_to_try = []
|
self.static_image = cv2.imread(str(media_path))
|
||||||
if hasattr(cv2, 'CAP_FFMPEG'): # FFmpeg - best for video files
|
if self.static_image is None:
|
||||||
backends_to_try.append(cv2.CAP_FFMPEG)
|
raise ValueError(f"Could not load image file: {media_path}")
|
||||||
if hasattr(cv2, 'CAP_DSHOW'): # DirectShow - usually for cameras
|
|
||||||
backends_to_try.append(cv2.CAP_DSHOW)
|
# Set up image properties to mimic video interface
|
||||||
backends_to_try.append(cv2.CAP_ANY) # Fallback
|
self.frame_height, self.frame_width = self.static_image.shape[:2]
|
||||||
|
self.total_frames = 1
|
||||||
self.cap = None
|
self.fps = 30 # Dummy FPS for image mode
|
||||||
for backend in backends_to_try:
|
self.cap = None
|
||||||
try:
|
|
||||||
self.cap = cv2.VideoCapture(str(self.video_path), backend)
|
print(f"Loaded image: {self.video_path.name}")
|
||||||
if self.cap.isOpened():
|
print(f" Resolution: {self.frame_width}x{self.frame_height}")
|
||||||
# Optimize buffer settings for better performance
|
else:
|
||||||
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Minimize buffer to reduce latency
|
# Try different backends for better performance
|
||||||
# Try to set hardware acceleration if available
|
# Order of preference: FFmpeg (best for video files), DirectShow (cameras), any available
|
||||||
if hasattr(cv2, 'CAP_PROP_HW_ACCELERATION'):
|
backends_to_try = []
|
||||||
self.cap.set(cv2.CAP_PROP_HW_ACCELERATION, cv2.VIDEO_ACCELERATION_ANY)
|
if hasattr(cv2, 'CAP_FFMPEG'): # FFmpeg - best for video files
|
||||||
break
|
backends_to_try.append(cv2.CAP_FFMPEG)
|
||||||
self.cap.release()
|
if hasattr(cv2, 'CAP_DSHOW'): # DirectShow - usually for cameras
|
||||||
except:
|
backends_to_try.append(cv2.CAP_DSHOW)
|
||||||
continue
|
backends_to_try.append(cv2.CAP_ANY) # Fallback
|
||||||
|
|
||||||
if not self.cap or not self.cap.isOpened():
|
self.cap = None
|
||||||
raise ValueError(f"Could not open video file: {video_path}")
|
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
|
# Get codec information for debugging
|
||||||
self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
fourcc = int(self.cap.get(cv2.CAP_PROP_FOURCC))
|
||||||
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
|
codec = "".join([chr((fourcc >> 8 * i) & 0xFF) for i in range(4)])
|
||||||
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 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
|
# Reset playback state for new media
|
||||||
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
|
|
||||||
self.current_frame = 0
|
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.playback_speed = 1.0
|
||||||
self.current_display_frame = None
|
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_rect = None
|
||||||
self.crop_history = []
|
self.crop_history = []
|
||||||
self.zoom_factor = 1.0
|
self.zoom_factor = 1.0
|
||||||
@@ -242,20 +288,6 @@ class VideoEditor:
|
|||||||
self.cut_end_frame = None
|
self.cut_end_frame = None
|
||||||
self.display_offset = [0, 0]
|
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):
|
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):
|
||||||
@@ -275,12 +307,18 @@ class VideoEditor:
|
|||||||
|
|
||||||
def load_current_frame(self) -> bool:
|
def load_current_frame(self) -> bool:
|
||||||
"""Load the current frame into display cache"""
|
"""Load the current frame into display cache"""
|
||||||
self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.current_frame)
|
if self.is_image_mode:
|
||||||
ret, frame = self.cap.read()
|
# For images, just copy the static image
|
||||||
if ret:
|
self.current_display_frame = self.static_image.copy()
|
||||||
self.current_display_frame = frame
|
|
||||||
return True
|
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:
|
def calculate_frame_delay(self) -> int:
|
||||||
"""Calculate frame delay in milliseconds based on playback speed"""
|
"""Calculate frame delay in milliseconds based on playback speed"""
|
||||||
@@ -634,6 +672,10 @@ class VideoEditor:
|
|||||||
|
|
||||||
def draw_timeline(self, frame):
|
def draw_timeline(self, frame):
|
||||||
"""Draw timeline at the bottom of the 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]
|
height, width = frame.shape[:2]
|
||||||
|
|
||||||
# Timeline background area
|
# Timeline background area
|
||||||
@@ -753,7 +795,7 @@ class VideoEditor:
|
|||||||
|
|
||||||
# Resize to fit window while maintaining aspect ratio
|
# Resize to fit window while maintaining aspect ratio
|
||||||
height, width = display_frame.shape[:2]
|
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)
|
scale = min(self.window_width / width, available_height / height)
|
||||||
if scale < 1.0:
|
if scale < 1.0:
|
||||||
@@ -790,7 +832,10 @@ class VideoEditor:
|
|||||||
contrast_text = (
|
contrast_text = (
|
||||||
f" | Contrast: {self.contrast:.1f}" if self.contrast != 1.0 else ""
|
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(
|
cv2.putText(
|
||||||
canvas,
|
canvas,
|
||||||
info_text,
|
info_text,
|
||||||
@@ -882,12 +927,13 @@ class VideoEditor:
|
|||||||
# Draw progress bar (if visible)
|
# Draw progress bar (if visible)
|
||||||
self.draw_progress_bar(canvas)
|
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):
|
def mouse_callback(self, event, x, y, flags, param):
|
||||||
"""Handle mouse events"""
|
"""Handle mouse events"""
|
||||||
# Handle timeline interaction
|
# Handle timeline interaction (not for images)
|
||||||
if self.timeline_rect:
|
if self.timeline_rect and not self.is_image_mode:
|
||||||
bar_x_start, bar_y, bar_width, bar_height = self.timeline_rect
|
bar_x_start, bar_y, bar_width, bar_height = self.timeline_rect
|
||||||
bar_x_end = bar_x_start + bar_width
|
bar_x_end = bar_x_start + bar_width
|
||||||
|
|
||||||
@@ -905,7 +951,7 @@ class VideoEditor:
|
|||||||
|
|
||||||
# Handle crop selection (Shift + click and drag)
|
# Handle crop selection (Shift + click and drag)
|
||||||
if flags & cv2.EVENT_FLAG_SHIFTKEY:
|
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:
|
if event == cv2.EVENT_LBUTTONDOWN:
|
||||||
self.crop_selecting = True
|
self.crop_selecting = True
|
||||||
@@ -1133,6 +1179,38 @@ class VideoEditor:
|
|||||||
self.crop_rect = (new_x, y, new_w, h)
|
self.crop_rect = (new_x, y, new_w, h)
|
||||||
|
|
||||||
def render_video(self, output_path: str):
|
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"""
|
"""Optimized video rendering with multithreading and batch processing"""
|
||||||
if not output_path.endswith(".mp4"):
|
if not output_path.endswith(".mp4"):
|
||||||
output_path += ".mp4"
|
output_path += ".mp4"
|
||||||
@@ -1324,39 +1402,61 @@ class VideoEditor:
|
|||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""Main editor loop"""
|
"""Main editor loop"""
|
||||||
print("Video Editor Controls:")
|
if self.is_image_mode:
|
||||||
print(" Space: Play/Pause")
|
print("Image Editor Controls:")
|
||||||
print(" A/D: Seek backward/forward (1 frame)")
|
print(" E/Shift+E: Increase/Decrease brightness")
|
||||||
print(" Shift+A/D: Seek backward/forward (10 frames)")
|
print(" R/Shift+R: Increase/Decrease contrast")
|
||||||
print(" Ctrl+A/D: Seek backward/forward (60 frames)")
|
print(" -: Rotate clockwise 90°")
|
||||||
print(" W/S: Increase/Decrease speed")
|
print()
|
||||||
print(" E/Shift+E: Increase/Decrease brightness")
|
print("Crop Controls:")
|
||||||
print(" R/Shift+R: Increase/Decrease contrast")
|
print(" Shift+Click+Drag: Select crop area")
|
||||||
print(" -: Rotate clockwise 90°")
|
print(" h/j/k/l: Contract crop (left/down/up/right)")
|
||||||
print()
|
print(" H/J/K/L: Expand crop (left/down/up/right)")
|
||||||
print("Crop Controls:")
|
print(" U: Undo crop")
|
||||||
print(" Shift+Click+Drag: Select crop area")
|
print(" C: Clear crop")
|
||||||
print(" I/J/K/L: Contract crop (up/left/down/right)")
|
print()
|
||||||
print(" Shift+I/J/K/L: Expand crop (up/left/down/right)")
|
print("Other Controls:")
|
||||||
print(" [/]: Contract/Expand crop (cycles directions)")
|
print(" Ctrl+Scroll: Zoom in/out")
|
||||||
print(" U: Undo crop")
|
if len(self.video_files) > 1:
|
||||||
print(" C: Clear crop")
|
print(" N: Next file")
|
||||||
print()
|
print(" n: Previous file")
|
||||||
print("Other Controls:")
|
print(" Enter: Save image")
|
||||||
print(" Ctrl+Scroll: Zoom in/out")
|
print(" Q/ESC: Quit")
|
||||||
print(" 1: Set cut start point")
|
print()
|
||||||
print(" 2: Set cut end point")
|
else:
|
||||||
print(" T: Toggle loop between markers")
|
print("Video Editor Controls:")
|
||||||
if len(self.video_files) > 1:
|
print(" Space: Play/Pause")
|
||||||
print(" N: Next video")
|
print(" A/D: Seek backward/forward (1 frame)")
|
||||||
print(" n: Previous video")
|
print(" Shift+A/D: Seek backward/forward (10 frames)")
|
||||||
print(" Enter: Render video")
|
print(" Ctrl+A/D: Seek backward/forward (60 frames)")
|
||||||
print(" Q/ESC: Quit")
|
print(" W/S: Increase/Decrease speed")
|
||||||
print()
|
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)
|
window_title = "Image Editor" if self.is_image_mode else "Video Editor"
|
||||||
cv2.resizeWindow("Video Editor", self.window_width, self.window_height)
|
cv2.namedWindow(window_title, cv2.WINDOW_NORMAL)
|
||||||
cv2.setMouseCallback("Video Editor", self.mouse_callback)
|
cv2.resizeWindow(window_title, self.window_width, self.window_height)
|
||||||
|
cv2.setMouseCallback(window_title, self.mouse_callback)
|
||||||
|
|
||||||
self.load_current_frame()
|
self.load_current_frame()
|
||||||
|
|
||||||
@@ -1368,43 +1468,58 @@ class VideoEditor:
|
|||||||
key = cv2.waitKey(delay) & 0xFF
|
key = cv2.waitKey(delay) & 0xFF
|
||||||
|
|
||||||
# Get modifier key states
|
# 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
|
# Note: OpenCV doesn't provide direct access to modifier keys in waitKey
|
||||||
# We'll handle this through special key combinations
|
# We'll handle this through special key combinations
|
||||||
|
|
||||||
if key == ord("q") or key == 27: # ESC
|
if key == ord("q") or key == 27: # ESC
|
||||||
break
|
break
|
||||||
elif key == ord(" "):
|
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"):
|
elif key == ord("a") or key == ord("A"):
|
||||||
# Check if it's uppercase A (Shift+A)
|
# Seeking only for videos
|
||||||
if key == ord("A"):
|
if not self.is_image_mode:
|
||||||
self.seek_video_with_modifier(
|
# Check if it's uppercase A (Shift+A)
|
||||||
-1, True, False
|
if key == ord("A"):
|
||||||
) # Shift+A: -10 frames
|
self.seek_video_with_modifier(
|
||||||
else:
|
-1, True, False
|
||||||
self.seek_video_with_modifier(-1, False, False) # A: -1 frame
|
) # Shift+A: -10 frames
|
||||||
|
else:
|
||||||
|
self.seek_video_with_modifier(-1, False, False) # A: -1 frame
|
||||||
elif key == ord("d") or key == ord("D"):
|
elif key == ord("d") or key == ord("D"):
|
||||||
# Check if it's uppercase D (Shift+D)
|
# Seeking only for videos
|
||||||
if key == ord("D"):
|
if not self.is_image_mode:
|
||||||
self.seek_video_with_modifier(1, True, False) # Shift+D: +10 frames
|
# Check if it's uppercase D (Shift+D)
|
||||||
else:
|
if key == ord("D"):
|
||||||
self.seek_video_with_modifier(1, False, False) # D: +1 frame
|
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
|
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
|
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("_"):
|
elif key == ord("-") or key == ord("_"):
|
||||||
self.rotate_clockwise()
|
self.rotate_clockwise()
|
||||||
print(f"Rotated to {self.rotation_angle}°")
|
print(f"Rotated to {self.rotation_angle}°")
|
||||||
elif key == ord("w"):
|
elif key == ord("w"):
|
||||||
self.playback_speed = min(
|
# Speed control only for videos
|
||||||
self.MAX_PLAYBACK_SPEED, self.playback_speed + self.SPEED_INCREMENT
|
if not self.is_image_mode:
|
||||||
)
|
self.playback_speed = min(
|
||||||
|
self.MAX_PLAYBACK_SPEED, self.playback_speed + self.SPEED_INCREMENT
|
||||||
|
)
|
||||||
elif key == ord("s"):
|
elif key == ord("s"):
|
||||||
self.playback_speed = max(
|
# Speed control only for videos
|
||||||
self.MIN_PLAYBACK_SPEED, self.playback_speed - self.SPEED_INCREMENT
|
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"):
|
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"):
|
||||||
@@ -1428,11 +1543,15 @@ class VideoEditor:
|
|||||||
self.crop_history.append(self.crop_rect)
|
self.crop_history.append(self.crop_rect)
|
||||||
self.crop_rect = None
|
self.crop_rect = None
|
||||||
elif key == ord("1"):
|
elif key == ord("1"):
|
||||||
self.cut_start_frame = self.current_frame
|
# Cut markers only for videos
|
||||||
print(f"Set cut start at frame {self.current_frame}")
|
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"):
|
elif key == ord("2"):
|
||||||
self.cut_end_frame = self.current_frame
|
# Cut markers only for videos
|
||||||
print(f"Set cut end at frame {self.current_frame}")
|
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"):
|
elif key == ord("N"):
|
||||||
if len(self.video_files) > 1:
|
if len(self.video_files) > 1:
|
||||||
self.previous_video()
|
self.previous_video()
|
||||||
@@ -1443,7 +1562,9 @@ class VideoEditor:
|
|||||||
output_name = self._get_next_edited_filename(self.video_path)
|
output_name = self._get_next_edited_filename(self.video_path)
|
||||||
self.render_video(str(self.video_path.parent / output_name))
|
self.render_video(str(self.video_path.parent / output_name))
|
||||||
elif key == ord("t"):
|
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
|
# 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
|
||||||
@@ -1474,20 +1595,21 @@ class VideoEditor:
|
|||||||
print(f"Contracted crop from left by {self.crop_size_step}px")
|
print(f"Contracted crop from left by {self.crop_size_step}px")
|
||||||
|
|
||||||
|
|
||||||
# Auto advance frame when playing
|
# Auto advance frame when playing (videos only)
|
||||||
if self.is_playing:
|
if self.is_playing and not self.is_image_mode:
|
||||||
self.advance_frame()
|
self.advance_frame()
|
||||||
|
|
||||||
self.cap.release()
|
if hasattr(self, 'cap') and self.cap:
|
||||||
|
self.cap.release()
|
||||||
cv2.destroyAllWindows()
|
cv2.destroyAllWindows()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
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(
|
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:
|
try:
|
||||||
@@ -1497,17 +1619,17 @@ def main():
|
|||||||
input(f"Argument parsing failed. Press Enter to exit...")
|
input(f"Argument parsing failed. Press Enter to exit...")
|
||||||
return
|
return
|
||||||
|
|
||||||
if not os.path.exists(args.video):
|
if not os.path.exists(args.media):
|
||||||
error_msg = f"Error: {args.video} does not exist"
|
error_msg = f"Error: {args.media} does not exist"
|
||||||
print(error_msg)
|
print(error_msg)
|
||||||
input("Press Enter to exit...") # Keep window open in context menu
|
input("Press Enter to exit...") # Keep window open in context menu
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
editor = VideoEditor(args.video)
|
editor = VideoEditor(args.media)
|
||||||
editor.run()
|
editor.run()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error initializing video editor: {e}"
|
error_msg = f"Error initializing media editor: {e}"
|
||||||
print(error_msg)
|
print(error_msg)
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc() # Full error trace for debugging
|
traceback.print_exc() # Full error trace for debugging
|
||||||
|
Reference in New Issue
Block a user