Add support for images

This commit is contained in:
2025-09-05 09:21:18 +02:00
parent 4ffd4cd321
commit 0adcc8f32a

View File

@@ -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
@@ -57,14 +60,17 @@ class VideoEditor:
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,45 +163,61 @@ 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)
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 = []
@@ -209,7 +243,7 @@ class VideoEditor:
continue
if not self.cap or not self.cap.isOpened():
raise ValueError(f"Could not open video file: {video_path}")
raise ValueError(f"Could not open video file: {media_path}")
# Video properties
self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
@@ -224,27 +258,7 @@ class VideoEditor:
# Get backend information
backend = self.cap.getBackendName()
# Reset playback state for new video
self.current_frame = 0
self.is_playing = False
self.playback_speed = 1.0
self.current_display_frame = None
# Reset crop, zoom, rotation, brightness/contrast, 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.brightness = 0
self.contrast = 1.0
self.cut_start_frame = None
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"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")
@@ -256,6 +270,24 @@ class VideoEditor:
if self.fps > 60:
print(f" Warning: High framerate video - may impact playback smoothness")
# Reset playback state for new media
self.current_frame = 0
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 media
self.crop_rect = None
self.crop_history = []
self.zoom_factor = 1.0
self.zoom_center = None
self.rotation_angle = 0
self.brightness = 0
self.contrast = 1.0
self.cut_start_frame = None
self.cut_end_frame = None
self.display_offset = [0, 0]
def switch_to_video(self, index: int):
"""Switch to a specific video by index"""
if 0 <= index < len(self.video_files):
@@ -275,6 +307,12 @@ class VideoEditor:
def load_current_frame(self) -> bool:
"""Load the current frame into display cache"""
if self.is_image_mode:
# For images, just copy the static image
self.current_display_frame = self.static_image.copy()
return True
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:
@@ -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,6 +832,9 @@ class VideoEditor:
contrast_text = (
f" | Contrast: {self.contrast:.1f}" if self.contrast != 1.0 else ""
)
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,
@@ -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,6 +1402,28 @@ class VideoEditor:
def run(self):
"""Main editor loop"""
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)")
@@ -1336,9 +1436,8 @@ class VideoEditor:
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(" 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()
@@ -1354,9 +1453,10 @@ class VideoEditor:
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,15 +1468,20 @@ 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(" "):
# 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"):
# 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(
@@ -1385,23 +1490,33 @@ class VideoEditor:
else:
self.seek_video_with_modifier(-1, False, False) # A: -1 frame
elif key == ord("d") or key == ord("D"):
# 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
# 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
# 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"):
# 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"):
# 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
)
@@ -1428,9 +1543,13 @@ class VideoEditor:
self.crop_history.append(self.crop_rect)
self.crop_rect = None
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}")
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}")
elif key == ord("N"):
@@ -1443,6 +1562,8 @@ 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"):
# Marker looping only for videos
if not self.is_image_mode:
self.toggle_marker_looping()
# Individual direction controls using shift combinations we can detect
@@ -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()
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