Files
py-media-grader/croppa/main.py

539 lines
22 KiB
Python

import os
import sys
import cv2
import argparse
import numpy as np
from pathlib import Path
from typing import Optional, Tuple, List
class VideoEditor:
# Configuration constants
BASE_FRAME_DELAY_MS = 16 # ~60 FPS
KEY_REPEAT_RATE_SEC = 0.3
FAST_SEEK_ACTIVATION_TIME = 1.5
SPEED_INCREMENT = 0.2
MIN_PLAYBACK_SPEED = 0.1
MAX_PLAYBACK_SPEED = 10.0
# Timeline configuration
TIMELINE_HEIGHT = 60
TIMELINE_MARGIN = 20
TIMELINE_BAR_HEIGHT = 12
TIMELINE_HANDLE_SIZE = 12
TIMELINE_COLOR_BG = (80, 80, 80)
TIMELINE_COLOR_PROGRESS = (0, 120, 255)
TIMELINE_COLOR_HANDLE = (255, 255, 255)
TIMELINE_COLOR_BORDER = (200, 200, 200)
TIMELINE_COLOR_CUT_POINT = (255, 0, 0)
# Zoom and crop settings
MIN_ZOOM = 0.1
MAX_ZOOM = 10.0
ZOOM_INCREMENT = 0.1
def __init__(self, video_path: str):
self.video_path = Path(video_path)
self.cap = cv2.VideoCapture(str(self.video_path))
if not self.cap.isOpened():
raise ValueError(f"Could not open video file: {video_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))
# Playback state
self.current_frame = 0
self.is_playing = False
self.playback_speed = 1.0
self.current_display_frame = None
# Mouse and keyboard interaction
self.mouse_dragging = False
self.timeline_rect = None
self.window_width = 1200
self.window_height = 800
# Seeking state
self.is_seeking = False
self.current_seek_key = None
self.key_first_press_time = 0
self.last_seek_time = 0
# Crop settings
self.crop_rect = None # (x, y, width, height)
self.crop_selecting = False
self.crop_start_point = None
self.crop_preview_rect = None
self.crop_history = [] # For undo
# Zoom settings
self.zoom_factor = 1.0
self.zoom_center = None # (x, y) center point for zoom
# Cut points
self.cut_start_frame = None
self.cut_end_frame = None
# Display offset for panning when zoomed
self.display_offset = [0, 0]
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
return True
return False
def calculate_frame_delay(self) -> int:
"""Calculate frame delay in milliseconds based on playback speed"""
delay_ms = int(self.BASE_FRAME_DELAY_MS / self.playback_speed)
return max(1, delay_ms)
def seek_video(self, frames_delta: int):
"""Seek video by specified number of frames"""
target_frame = max(0, min(self.current_frame + frames_delta, self.total_frames - 1))
self.current_frame = target_frame
self.load_current_frame()
def seek_to_frame(self, frame_number: int):
"""Seek to specific frame"""
self.current_frame = max(0, min(frame_number, self.total_frames - 1))
self.load_current_frame()
def advance_frame(self) -> bool:
"""Advance to next frame"""
if not self.is_playing:
return True
self.current_frame += 1
if self.current_frame >= self.total_frames:
self.current_frame = 0 # Loop
return self.load_current_frame()
def apply_crop_and_zoom(self, frame):
"""Apply current crop and zoom settings to frame"""
if frame is None:
return None
processed_frame = frame.copy()
# Apply crop first
if self.crop_rect:
x, y, w, h = self.crop_rect
x, y, w, h = int(x), int(y), int(w), int(h)
# Ensure crop is within frame bounds
x = max(0, min(x, frame.shape[1] - 1))
y = max(0, min(y, frame.shape[0] - 1))
w = min(w, frame.shape[1] - x)
h = min(h, frame.shape[0] - y)
if w > 0 and h > 0:
processed_frame = processed_frame[y:y+h, x:x+w]
# Apply zoom
if self.zoom_factor != 1.0:
height, width = processed_frame.shape[:2]
new_width = int(width * self.zoom_factor)
new_height = int(height * self.zoom_factor)
processed_frame = cv2.resize(processed_frame, (new_width, new_height), interpolation=cv2.INTER_LINEAR)
# Handle zoom center and display offset
if new_width > self.window_width or new_height > self.window_height:
# Calculate crop from zoomed image to fit window
start_x = max(0, self.display_offset[0])
start_y = max(0, self.display_offset[1])
end_x = min(new_width, start_x + self.window_width)
end_y = min(new_height, start_y + self.window_height)
processed_frame = processed_frame[start_y:end_y, start_x:end_x]
return processed_frame
def draw_timeline(self, frame):
"""Draw timeline at the bottom of the frame"""
height, width = frame.shape[:2]
# Timeline background area
timeline_y = height - self.TIMELINE_HEIGHT
cv2.rectangle(frame, (0, timeline_y), (width, height), (40, 40, 40), -1)
# Calculate timeline bar position
bar_y = timeline_y + (self.TIMELINE_HEIGHT - self.TIMELINE_BAR_HEIGHT) // 2
bar_x_start = self.TIMELINE_MARGIN
bar_x_end = width - self.TIMELINE_MARGIN
bar_width = bar_x_end - bar_x_start
self.timeline_rect = (bar_x_start, bar_y, bar_width, self.TIMELINE_BAR_HEIGHT)
# Draw timeline background
cv2.rectangle(frame, (bar_x_start, bar_y), (bar_x_end, bar_y + self.TIMELINE_BAR_HEIGHT), self.TIMELINE_COLOR_BG, -1)
cv2.rectangle(frame, (bar_x_start, bar_y), (bar_x_end, bar_y + self.TIMELINE_BAR_HEIGHT), self.TIMELINE_COLOR_BORDER, 1)
# Draw progress
if self.total_frames > 0:
progress = self.current_frame / max(1, self.total_frames - 1)
progress_width = int(bar_width * progress)
if progress_width > 0:
cv2.rectangle(frame, (bar_x_start, bar_y), (bar_x_start + progress_width, bar_y + self.TIMELINE_BAR_HEIGHT), self.TIMELINE_COLOR_PROGRESS, -1)
# Draw current position handle
handle_x = bar_x_start + progress_width
handle_y = bar_y + self.TIMELINE_BAR_HEIGHT // 2
cv2.circle(frame, (handle_x, handle_y), self.TIMELINE_HANDLE_SIZE // 2, self.TIMELINE_COLOR_HANDLE, -1)
cv2.circle(frame, (handle_x, handle_y), self.TIMELINE_HANDLE_SIZE // 2, self.TIMELINE_COLOR_BORDER, 2)
# Draw cut points
if self.cut_start_frame is not None:
cut_start_progress = self.cut_start_frame / max(1, self.total_frames - 1)
cut_start_x = bar_x_start + int(bar_width * cut_start_progress)
cv2.line(frame, (cut_start_x, bar_y), (cut_start_x, bar_y + self.TIMELINE_BAR_HEIGHT), self.TIMELINE_COLOR_CUT_POINT, 3)
cv2.putText(frame, "1", (cut_start_x - 5, bar_y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.4, self.TIMELINE_COLOR_CUT_POINT, 1)
if self.cut_end_frame is not None:
cut_end_progress = self.cut_end_frame / max(1, self.total_frames - 1)
cut_end_x = bar_x_start + int(bar_width * cut_end_progress)
cv2.line(frame, (cut_end_x, bar_y), (cut_end_x, bar_y + self.TIMELINE_BAR_HEIGHT), self.TIMELINE_COLOR_CUT_POINT, 3)
cv2.putText(frame, "2", (cut_end_x - 5, bar_y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.4, self.TIMELINE_COLOR_CUT_POINT, 1)
def draw_crop_overlay(self, frame):
"""Draw crop selection overlay"""
if self.crop_preview_rect:
x, y, w, h = self.crop_preview_rect
# Draw rectangle
cv2.rectangle(frame, (int(x), int(y)), (int(x + w), int(y + h)), (0, 255, 0), 2)
# Draw semi-transparent overlay outside crop area
overlay = frame.copy()
cv2.rectangle(overlay, (0, 0), (frame.shape[1], frame.shape[0]), (0, 0, 0), -1)
cv2.rectangle(overlay, (int(x), int(y)), (int(x + w), int(y + h)), (255, 255, 255), -1)
cv2.addWeighted(frame, 0.7, overlay, 0.3, 0, frame)
if self.crop_rect:
x, y, w, h = self.crop_rect
cv2.rectangle(frame, (int(x), int(y)), (int(x + w), int(y + h)), (255, 0, 0), 2)
def display_current_frame(self):
"""Display the current frame with all overlays"""
if self.current_display_frame is None:
return
# Apply crop and zoom transformations for preview
display_frame = self.apply_crop_and_zoom(self.current_display_frame.copy())
if display_frame is None:
return
# Resize to fit window while maintaining aspect ratio
height, width = display_frame.shape[:2]
available_height = self.window_height - self.TIMELINE_HEIGHT
scale = min(self.window_width / width, available_height / height)
if scale < 1.0:
new_width = int(width * scale)
new_height = int(height * scale)
display_frame = cv2.resize(display_frame, (new_width, new_height))
# Create canvas with timeline space
canvas = np.zeros((self.window_height, self.window_width, 3), dtype=np.uint8)
# Center the frame on canvas
frame_height, frame_width = display_frame.shape[:2]
start_y = (available_height - frame_height) // 2
start_x = (self.window_width - frame_width) // 2
canvas[start_y:start_y + frame_height, start_x:start_x + frame_width] = display_frame
# Draw crop overlay on original frame coordinates
if not (self.crop_rect or self.crop_preview_rect):
self.draw_crop_overlay(canvas[start_y:start_y + frame_height, start_x:start_x + frame_width])
# Add info overlay
info_text = f"Frame: {self.current_frame}/{self.total_frames} | Speed: {self.playback_speed:.1f}x | Zoom: {self.zoom_factor:.1f}x | {'Playing' if self.is_playing else 'Paused'}"
cv2.putText(canvas, info_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2)
cv2.putText(canvas, info_text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 1)
# Add crop info
if self.crop_rect:
crop_text = f"Crop: {int(self.crop_rect[0])},{int(self.crop_rect[1])} {int(self.crop_rect[2])}x{int(self.crop_rect[3])}"
cv2.putText(canvas, crop_text, (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
cv2.putText(canvas, crop_text, (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1)
# Add cut info
if self.cut_start_frame is not None or self.cut_end_frame is not None:
cut_text = f"Cut: {self.cut_start_frame or '?'} - {self.cut_end_frame or '?'}"
cv2.putText(canvas, cut_text, (10, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
cv2.putText(canvas, cut_text, (10, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1)
# Draw timeline
self.draw_timeline(canvas)
cv2.imshow("Video Editor", canvas)
def mouse_callback(self, event, x, y, flags, param):
"""Handle mouse events"""
# Handle timeline interaction
if self.timeline_rect:
bar_x_start, bar_y, bar_width, bar_height = self.timeline_rect
bar_x_end = bar_x_start + bar_width
if bar_y <= y <= bar_y + bar_height + 10:
if event == cv2.EVENT_LBUTTONDOWN:
if bar_x_start <= x <= bar_x_end:
self.mouse_dragging = True
self.seek_to_timeline_position(x, bar_x_start, bar_width)
elif event == cv2.EVENT_MOUSEMOVE and self.mouse_dragging:
if bar_x_start <= x <= bar_x_end:
self.seek_to_timeline_position(x, bar_x_start, bar_width)
elif event == cv2.EVENT_LBUTTONUP:
self.mouse_dragging = False
return
# Handle crop selection (Shift + click and drag)
if flags & cv2.EVENT_FLAG_SHIFTKEY:
available_height = self.window_height - self.TIMELINE_HEIGHT
if event == cv2.EVENT_LBUTTONDOWN:
self.crop_selecting = True
self.crop_start_point = (x, y)
self.crop_preview_rect = None
elif event == cv2.EVENT_MOUSEMOVE and self.crop_selecting:
if self.crop_start_point:
start_x, start_y = self.crop_start_point
width = abs(x - start_x)
height = abs(y - start_y)
crop_x = min(start_x, x)
crop_y = min(start_y, y)
self.crop_preview_rect = (crop_x, crop_y, width, height)
elif event == cv2.EVENT_LBUTTONUP and self.crop_selecting:
if self.crop_start_point and self.crop_preview_rect:
# Convert screen coordinates to video coordinates
self.set_crop_from_screen_coords(self.crop_preview_rect)
self.crop_selecting = False
self.crop_start_point = None
self.crop_preview_rect = None
# Handle zoom center (Ctrl + click)
if flags & cv2.EVENT_FLAG_CTRLKEY and event == cv2.EVENT_LBUTTONDOWN:
self.zoom_center = (x, y)
# Handle scroll wheel for zoom (Ctrl + scroll)
if flags & cv2.EVENT_FLAG_CTRLKEY:
if event == cv2.EVENT_MOUSEWHEEL:
if flags > 0: # Scroll up
self.zoom_factor = min(self.MAX_ZOOM, self.zoom_factor + self.ZOOM_INCREMENT)
else: # Scroll down
self.zoom_factor = max(self.MIN_ZOOM, self.zoom_factor - self.ZOOM_INCREMENT)
def set_crop_from_screen_coords(self, screen_rect):
"""Convert screen coordinates to video frame coordinates and set crop"""
# This is a simplified version - in a full implementation you'd need to
# account for the scaling and positioning of the video frame within the window
x, y, w, h = screen_rect
# Simple conversion assuming video fills the display area
if self.current_display_frame is not None:
frame_height, frame_width = self.current_display_frame.shape[:2]
available_height = self.window_height - self.TIMELINE_HEIGHT
# Calculate video display area
scale = min(self.window_width / frame_width, available_height / frame_height)
if scale < 1.0:
display_width = int(frame_width * scale)
display_height = int(frame_height * scale)
else:
display_width = frame_width
display_height = frame_height
start_x = (self.window_width - display_width) // 2
start_y = (available_height - display_height) // 2
# Convert coordinates
video_x = (x - start_x) / scale if scale < 1.0 else (x - start_x)
video_y = (y - start_y) / scale if scale < 1.0 else (y - start_y)
video_w = w / scale if scale < 1.0 else w
video_h = h / scale if scale < 1.0 else h
# Clamp to video bounds
video_x = max(0, min(video_x, frame_width))
video_y = max(0, min(video_y, frame_height))
video_w = min(video_w, frame_width - video_x)
video_h = min(video_h, frame_height - video_y)
if video_w > 10 and video_h > 10: # Minimum size check
# Save current crop for undo
if self.crop_rect:
self.crop_history.append(self.crop_rect)
self.crop_rect = (video_x, video_y, video_w, video_h)
def seek_to_timeline_position(self, mouse_x, bar_x_start, bar_width):
"""Seek to position based on mouse click on timeline"""
relative_x = mouse_x - bar_x_start
position_ratio = max(0, min(1, relative_x / bar_width))
target_frame = int(position_ratio * (self.total_frames - 1))
self.seek_to_frame(target_frame)
def undo_crop(self):
"""Undo the last crop operation"""
if self.crop_history:
self.crop_rect = self.crop_history.pop()
else:
self.crop_rect = None
def render_video(self, output_path: str):
"""Render the video with current crop, zoom, and cut settings"""
if not output_path.endswith('.mp4'):
output_path += '.mp4'
print(f"Rendering video to {output_path}...")
# Determine frame range
start_frame = self.cut_start_frame if self.cut_start_frame is not None else 0
end_frame = self.cut_end_frame if self.cut_end_frame is not None else self.total_frames - 1
if start_frame >= end_frame:
print("Invalid cut range!")
return False
# Calculate output dimensions
if self.crop_rect:
output_width = int(self.crop_rect[2] * self.zoom_factor)
output_height = int(self.crop_rect[3] * self.zoom_factor)
else:
output_width = int(self.frame_width * self.zoom_factor)
output_height = int(self.frame_height * self.zoom_factor)
# Initialize video writer
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
out = cv2.VideoWriter(output_path, fourcc, self.fps, (output_width, output_height))
if not out.isOpened():
print("Error: Could not open video writer!")
return False
# Process frames
total_output_frames = end_frame - start_frame + 1
for frame_idx in range(start_frame, end_frame + 1):
self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
ret, frame = self.cap.read()
if not ret:
break
# Apply crop
if self.crop_rect:
x, y, w, h = self.crop_rect
x, y, w, h = int(x), int(y), int(w), int(h)
x = max(0, min(x, frame.shape[1] - 1))
y = max(0, min(y, frame.shape[0] - 1))
w = min(w, frame.shape[1] - x)
h = min(h, frame.shape[0] - y)
if w > 0 and h > 0:
frame = frame[y:y+h, x:x+w]
# Apply zoom
if self.zoom_factor != 1.0:
height, width = frame.shape[:2]
new_width = int(width * self.zoom_factor)
new_height = int(height * self.zoom_factor)
frame = cv2.resize(frame, (new_width, new_height), interpolation=cv2.INTER_LINEAR)
out.write(frame)
# Progress indicator
progress = (frame_idx - start_frame + 1) / total_output_frames * 100
print(f"Progress: {progress:.1f}%\r", end="")
out.release()
print(f"\nVideo rendered successfully to {output_path}")
return True
def run(self):
"""Main editor loop"""
print("Video Editor Controls:")
print(" Space: Play/Pause")
print(" A/D: Seek backward/forward")
print(" W/S: Increase/Decrease speed")
print(" Shift+Click+Drag: Select crop area")
print(" U: Undo crop")
print(" C: Clear crop")
print(" Ctrl+Scroll: Zoom in/out")
print(" 1: Set cut start point")
print(" 2: Set cut end point")
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)
self.load_current_frame()
while True:
self.display_current_frame()
delay = self.calculate_frame_delay() if self.is_playing else 30
key = cv2.waitKey(delay) & 0xFF
if key == ord('q') or key == 27: # ESC
break
elif key == ord(' '):
self.is_playing = not self.is_playing
elif key == ord('a'):
self.seek_video(-1)
elif key == ord('d'):
self.seek_video(1)
elif key == ord('w'):
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)
elif key == ord('u'):
self.undo_crop()
elif key == ord('c'):
if self.crop_rect:
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}")
elif key == ord('2'):
self.cut_end_frame = self.current_frame
print(f"Set cut end at frame {self.current_frame}")
elif key == 13: # Enter
output_name = f"{self.video_path.stem}_edited.mp4"
self.render_video(str(self.video_path.parent / output_name))
# Auto advance frame when playing
if self.is_playing:
self.advance_frame()
self.cap.release()
cv2.destroyAllWindows()
def main():
parser = argparse.ArgumentParser(description="Fast Video Editor - Crop, Zoom, and Cut videos")
parser.add_argument("video", help="Path to video file")
args = parser.parse_args()
if not os.path.isfile(args.video):
print(f"Error: {args.video} is not a valid file")
sys.exit(1)
try:
editor = VideoEditor(args.video)
editor.run()
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()