Compare commits
5 Commits
e0dd1a8db8
...
28f11ab190
Author | SHA1 | Date | |
---|---|---|---|
28f11ab190 | |||
c6cc249ab2 | |||
f5b5800802 | |||
d2c9fb6fb0 | |||
ce0232846e |
127
croppa/main.py
127
croppa/main.py
@@ -32,24 +32,29 @@ class VideoEditor:
|
||||
MAX_ZOOM = 10.0
|
||||
ZOOM_INCREMENT = 0.25
|
||||
|
||||
def __init__(self, video_path: str):
|
||||
self.video_path = Path(video_path)
|
||||
self.cap = cv2.VideoCapture(str(self.video_path))
|
||||
# Supported video extensions
|
||||
VIDEO_EXTENSIONS = {'.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm', '.m4v'}
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = Path(path)
|
||||
|
||||
if not self.cap.isOpened():
|
||||
raise ValueError(f"Could not open video file: {video_path}")
|
||||
# Video file management
|
||||
self.video_files = []
|
||||
self.current_video_index = 0
|
||||
|
||||
# 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))
|
||||
# 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)
|
||||
if not self.video_files:
|
||||
raise ValueError(f"No video files found in directory: {path}")
|
||||
else:
|
||||
raise ValueError(f"Path does not exist: {path}")
|
||||
|
||||
# Playback state
|
||||
self.current_frame = 0
|
||||
self.is_playing = False
|
||||
self.playback_speed = 1.0
|
||||
self.current_display_frame = None
|
||||
# Initialize with first video
|
||||
self._load_video(self.video_files[0])
|
||||
|
||||
# Mouse and keyboard interaction
|
||||
self.mouse_dragging = False
|
||||
@@ -81,6 +86,65 @@ class VideoEditor:
|
||||
# Display offset for panning when zoomed
|
||||
self.display_offset = [0, 0]
|
||||
|
||||
def _get_video_files_from_directory(self, directory: Path) -> List[Path]:
|
||||
"""Get all video files from a directory, sorted by name"""
|
||||
video_files = []
|
||||
for file_path in directory.iterdir():
|
||||
if file_path.is_file() and file_path.suffix.lower() in self.VIDEO_EXTENSIONS:
|
||||
video_files.append(file_path)
|
||||
return sorted(video_files)
|
||||
|
||||
def _load_video(self, video_path: Path):
|
||||
"""Load a video file and initialize video properties"""
|
||||
if hasattr(self, 'cap') and self.cap:
|
||||
self.cap.release()
|
||||
|
||||
self.video_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))
|
||||
|
||||
# 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, and cut settings for new video
|
||||
self.crop_rect = None
|
||||
self.crop_history = []
|
||||
self.zoom_factor = 1.0
|
||||
self.zoom_center = None
|
||||
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)})")
|
||||
|
||||
def switch_to_video(self, index: int):
|
||||
"""Switch to a specific video by index"""
|
||||
if 0 <= index < len(self.video_files):
|
||||
self.current_video_index = index
|
||||
self._load_video(self.video_files[index])
|
||||
self.load_current_frame()
|
||||
|
||||
def next_video(self):
|
||||
"""Switch to the next video"""
|
||||
next_index = (self.current_video_index + 1) % len(self.video_files)
|
||||
self.switch_to_video(next_index)
|
||||
|
||||
def previous_video(self):
|
||||
"""Switch to the previous video"""
|
||||
prev_index = (self.current_video_index - 1) % len(self.video_files)
|
||||
self.switch_to_video(prev_index)
|
||||
|
||||
def load_current_frame(self) -> bool:
|
||||
"""Load the current frame into display cache"""
|
||||
self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.current_frame)
|
||||
@@ -273,17 +337,27 @@ class VideoEditor:
|
||||
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 video navigation info
|
||||
if len(self.video_files) > 1:
|
||||
video_text = f"Video: {self.current_video_index + 1}/{len(self.video_files)} - {self.video_path.name}"
|
||||
cv2.putText(canvas, video_text, (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
|
||||
cv2.putText(canvas, video_text, (10, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1)
|
||||
y_offset = 90
|
||||
else:
|
||||
y_offset = 60
|
||||
|
||||
# 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)
|
||||
cv2.putText(canvas, crop_text, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
|
||||
cv2.putText(canvas, crop_text, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1)
|
||||
y_offset += 30
|
||||
|
||||
# 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)
|
||||
cv2.putText(canvas, cut_text, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2)
|
||||
cv2.putText(canvas, cut_text, (10, y_offset), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 0), 1)
|
||||
|
||||
# Draw timeline
|
||||
self.draw_timeline(canvas)
|
||||
@@ -531,6 +605,9 @@ class VideoEditor:
|
||||
print(" Ctrl+Scroll: Zoom in/out")
|
||||
print(" 1: Set cut start point")
|
||||
print(" 2: Set cut end point")
|
||||
if len(self.video_files) > 1:
|
||||
print(" N: Next video")
|
||||
print(" n: Previous video")
|
||||
print(" Enter: Render video")
|
||||
print(" Q/ESC: Quit")
|
||||
print()
|
||||
@@ -571,6 +648,12 @@ class VideoEditor:
|
||||
elif key == ord('2'):
|
||||
self.cut_end_frame = self.current_frame
|
||||
print(f"Set cut end at frame {self.current_frame}")
|
||||
elif key == ord('n'):
|
||||
if len(self.video_files) > 1:
|
||||
self.previous_video()
|
||||
elif key == ord('N'):
|
||||
if len(self.video_files) > 1:
|
||||
self.next_video()
|
||||
elif key == 13: # Enter
|
||||
output_name = f"{self.video_path.stem}_edited.mp4"
|
||||
self.render_video(str(self.video_path.parent / output_name))
|
||||
@@ -585,12 +668,12 @@ class VideoEditor:
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fast Video Editor - Crop, Zoom, and Cut videos")
|
||||
parser.add_argument("video", help="Path to video file")
|
||||
parser.add_argument("video", help="Path to video file or directory containing videos")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.isfile(args.video):
|
||||
print(f"Error: {args.video} is not a valid file")
|
||||
if not os.path.exists(args.video):
|
||||
print(f"Error: {args.video} does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
|
500
main.py
500
main.py
@@ -6,6 +6,8 @@ import numpy as np
|
||||
import argparse
|
||||
import shutil
|
||||
import time
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
@@ -22,6 +24,10 @@ class MediaGrader:
|
||||
FAST_SEEK_MULTIPLIER = 60
|
||||
IMAGE_DISPLAY_DELAY_MS = 100
|
||||
|
||||
# Monitor dimensions for full-screen sizing
|
||||
MONITOR_WIDTH = 2560
|
||||
MONITOR_HEIGHT = 1440
|
||||
|
||||
# Timeline configuration
|
||||
TIMELINE_HEIGHT = 60
|
||||
TIMELINE_MARGIN = 20
|
||||
@@ -35,6 +41,10 @@ class MediaGrader:
|
||||
# Seek modifiers for A/D keys
|
||||
SHIFT_SEEK_MULTIPLIER = 5 # SHIFT + A/D multiplier
|
||||
CTRL_SEEK_MULTIPLIER = 10 # CTRL + A/D multiplier
|
||||
|
||||
# Multi-segment mode configuration
|
||||
SEGMENT_COUNT = 16 # Number of video segments (2x2 grid)
|
||||
SEGMENT_OVERLAP_PERCENT = 10 # Percentage overlap between segments
|
||||
|
||||
def __init__(
|
||||
self, directory: str, seek_frames: int = 30, snap_to_iframe: bool = False
|
||||
@@ -59,6 +69,11 @@ class MediaGrader:
|
||||
|
||||
# Timeline visibility state
|
||||
self.timeline_visible = True
|
||||
|
||||
# Improved frame cache for performance
|
||||
self.frame_cache = {} # Dict[frame_number: frame_data]
|
||||
self.cache_size_limit = 200 # Increased cache size
|
||||
self.cache_lock = threading.Lock() # Thread safety for cache
|
||||
|
||||
# Key repeat tracking with rate limiting
|
||||
self.last_seek_time = 0
|
||||
@@ -105,29 +120,43 @@ class MediaGrader:
|
||||
|
||||
# Jump history for H key (undo jump)
|
||||
self.jump_history = {} # Dict[file_path: List[frame_positions]] for jump undo
|
||||
|
||||
# Undo functionality
|
||||
self.undo_history = [] # List of (source_path, destination_path, original_index) tuples
|
||||
|
||||
# Watch tracking for "good look" feature
|
||||
self.watched_regions = {} # Dict[file_path: List[Tuple[start_frame, end_frame]]]
|
||||
self.current_watch_start = None # Frame where current viewing session started
|
||||
self.last_frame_position = 0 # Track last known frame position
|
||||
|
||||
# Bisection navigation tracking
|
||||
self.last_jump_position = {} # Dict[file_path: last_frame] for bisection reference
|
||||
|
||||
# Jump history for H key (undo jump)
|
||||
self.jump_history = {} # Dict[file_path: List[frame_positions]] for jump undo
|
||||
|
||||
# Performance optimization: Thread pool for parallel operations
|
||||
self.thread_pool = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
# Multi-segment mode configuration
|
||||
MULTI_SEGMENT_MODE = False
|
||||
SEGMENT_COUNT = 16 # Number of video segments (2x2 grid)
|
||||
SEGMENT_OVERLAP_PERCENT = 10 # Percentage overlap between segments
|
||||
def display_with_aspect_ratio(self, frame):
|
||||
"""Display frame while maintaining aspect ratio and maximizing screen usage"""
|
||||
if frame is None:
|
||||
return
|
||||
|
||||
# Get frame dimensions
|
||||
frame_height, frame_width = frame.shape[:2]
|
||||
|
||||
# Calculate aspect ratio
|
||||
frame_aspect_ratio = frame_width / frame_height
|
||||
monitor_aspect_ratio = self.MONITOR_WIDTH / self.MONITOR_HEIGHT
|
||||
|
||||
# Determine if frame is vertical or horizontal relative to monitor
|
||||
if frame_aspect_ratio < monitor_aspect_ratio:
|
||||
# Frame is more vertical than monitor - maximize height
|
||||
display_height = self.MONITOR_HEIGHT
|
||||
display_width = int(display_height * frame_aspect_ratio)
|
||||
else:
|
||||
# Frame is more horizontal than monitor - maximize width
|
||||
display_width = self.MONITOR_WIDTH
|
||||
display_height = int(display_width / frame_aspect_ratio)
|
||||
|
||||
# Resize window to calculated dimensions
|
||||
cv2.resizeWindow("Media Grader", display_width, display_height)
|
||||
|
||||
# Center the window on screen
|
||||
x_position = (self.MONITOR_WIDTH - display_width) // 2
|
||||
y_position = (self.MONITOR_HEIGHT - display_height) // 2
|
||||
cv2.moveWindow("Media Grader", x_position, y_position)
|
||||
|
||||
# Display the frame
|
||||
cv2.imshow("Media Grader", frame)
|
||||
|
||||
# Seek modifiers for A/D keys
|
||||
SHIFT_SEEK_MULTIPLIER = 5 # SHIFT + A/D multiplier
|
||||
|
||||
def find_media_files(self) -> List[Path]:
|
||||
"""Find all media files recursively in the directory"""
|
||||
media_files = []
|
||||
@@ -515,40 +544,213 @@ class MediaGrader:
|
||||
print(f"Timeline {'visible' if self.timeline_visible else 'hidden'}")
|
||||
return True
|
||||
|
||||
def setup_segment_captures(self):
|
||||
"""Setup multiple video captures for segment mode"""
|
||||
def load_segment_frame_fast(self, segment_index, start_frame, shared_cap):
|
||||
"""Load a single segment frame using a shared capture (much faster)"""
|
||||
segment_start_time = time.time()
|
||||
try:
|
||||
# Time the seek operation
|
||||
seek_start = time.time()
|
||||
shared_cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
||||
seek_time = (time.time() - seek_start) * 1000
|
||||
|
||||
# Time the frame read
|
||||
read_start = time.time()
|
||||
ret, frame = shared_cap.read()
|
||||
read_time = (time.time() - read_start) * 1000
|
||||
|
||||
total_time = (time.time() - segment_start_time) * 1000
|
||||
print(f"Segment {segment_index}: Total={total_time:.1f}ms (Seek={seek_time:.1f}ms, Read={read_time:.1f}ms)")
|
||||
|
||||
if ret:
|
||||
return segment_index, frame.copy(), start_frame # Copy frame since we'll reuse the capture
|
||||
else:
|
||||
return segment_index, None, start_frame
|
||||
except Exception as e:
|
||||
error_time = (time.time() - segment_start_time) * 1000
|
||||
print(f"Segment {segment_index}: ERROR in {error_time:.1f}ms: {e}")
|
||||
return segment_index, None, start_frame
|
||||
|
||||
def setup_segment_captures_blazing_fast(self):
|
||||
"""BLAZING FAST: Sample frames at intervals without any seeking (10-50ms total)"""
|
||||
if not self.is_video(self.media_files[self.current_index]):
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
print(f"Setting up {self.segment_count} segments with BLAZING FAST method...")
|
||||
|
||||
# Clean up existing segment captures
|
||||
self.cleanup_segment_captures()
|
||||
|
||||
current_file = self.media_files[self.current_index]
|
||||
|
||||
# Calculate segment positions - evenly spaced through video
|
||||
# Initialize arrays
|
||||
self.segment_caps = [None] * self.segment_count
|
||||
self.segment_frames = [None] * self.segment_count
|
||||
self.segment_positions = [0] * self.segment_count # We'll update these as we sample
|
||||
|
||||
# BLAZING FAST METHOD: Sample frames at even intervals without seeking
|
||||
load_start = time.time()
|
||||
print("Sampling frames at regular intervals (NO SEEKING)...")
|
||||
|
||||
shared_cap_start = time.time()
|
||||
shared_cap = cv2.VideoCapture(str(current_file))
|
||||
shared_cap_create_time = (time.time() - shared_cap_start) * 1000
|
||||
print(f"Capture creation: {shared_cap_create_time:.1f}ms")
|
||||
|
||||
if shared_cap.isOpened():
|
||||
frames_start = time.time()
|
||||
|
||||
# Calculate sampling interval
|
||||
sample_interval = max(1, self.total_frames // (self.segment_count * 2)) # Sample more frequently than needed
|
||||
print(f"Sampling every {sample_interval} frames from {self.total_frames} total frames")
|
||||
|
||||
current_frame = 0
|
||||
segment_index = 0
|
||||
segments_filled = 0
|
||||
|
||||
sample_start = time.time()
|
||||
|
||||
while segments_filled < self.segment_count:
|
||||
ret, frame = shared_cap.read()
|
||||
if not ret:
|
||||
break
|
||||
|
||||
# Check if this frame should be used for a segment
|
||||
if segment_index < self.segment_count:
|
||||
target_frame_for_segment = int((segment_index / max(1, self.segment_count - 1)) * (self.total_frames - 1))
|
||||
|
||||
# If we're close enough to the target frame, use this frame
|
||||
if abs(current_frame - target_frame_for_segment) <= sample_interval:
|
||||
self.segment_frames[segment_index] = frame.copy()
|
||||
self.segment_positions[segment_index] = current_frame
|
||||
|
||||
print(f"Segment {segment_index}: Frame {current_frame} (target was {target_frame_for_segment})")
|
||||
segment_index += 1
|
||||
segments_filled += 1
|
||||
|
||||
current_frame += 1
|
||||
|
||||
# Skip frames to speed up sampling if we have many frames
|
||||
if sample_interval > 1:
|
||||
for _ in range(sample_interval - 1):
|
||||
ret, _ = shared_cap.read()
|
||||
if not ret:
|
||||
break
|
||||
current_frame += 1
|
||||
if not ret:
|
||||
break
|
||||
|
||||
sample_time = (time.time() - sample_start) * 1000
|
||||
frames_time = (time.time() - frames_start) * 1000
|
||||
print(f"Frame sampling: {sample_time:.1f}ms for {segments_filled} segments")
|
||||
print(f"Total frame loading: {frames_time:.1f}ms")
|
||||
|
||||
shared_cap.release()
|
||||
else:
|
||||
print("Failed to create shared capture!")
|
||||
|
||||
total_time = time.time() - start_time
|
||||
print(f"BLAZING FAST Total setup time: {total_time * 1000:.1f}ms")
|
||||
|
||||
# Report success
|
||||
successful_segments = sum(1 for frame in self.segment_frames if frame is not None)
|
||||
print(f"Successfully sampled {successful_segments}/{self.segment_count} segments")
|
||||
|
||||
def setup_segment_captures_lightning_fast(self):
|
||||
"""LIGHTNING FAST: Use intelligent skipping to get segments in minimal time"""
|
||||
if not self.is_video(self.media_files[self.current_index]):
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
print(f"Setting up {self.segment_count} segments with LIGHTNING FAST method...")
|
||||
|
||||
# Clean up existing segment captures
|
||||
self.cleanup_segment_captures()
|
||||
|
||||
current_file = self.media_files[self.current_index]
|
||||
|
||||
# Initialize arrays
|
||||
self.segment_caps = [None] * self.segment_count
|
||||
self.segment_frames = [None] * self.segment_count
|
||||
self.segment_positions = []
|
||||
|
||||
# Calculate target positions
|
||||
for i in range(self.segment_count):
|
||||
# Position segments at 0%, 25%, 50%, 75% of video (not 0%, 33%, 66%, 100%)
|
||||
position_ratio = i / self.segment_count # This gives 0, 0.25, 0.5, 0.75
|
||||
start_frame = int(position_ratio * self.total_frames)
|
||||
position_ratio = i / max(1, self.segment_count - 1)
|
||||
start_frame = int(position_ratio * (self.total_frames - 1))
|
||||
self.segment_positions.append(start_frame)
|
||||
|
||||
# Create video captures for each segment
|
||||
for i, start_frame in enumerate(self.segment_positions):
|
||||
cap = cv2.VideoCapture(str(current_file))
|
||||
if cap.isOpened():
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
||||
self.segment_caps.append(cap)
|
||||
# LIGHTNING FAST: Smart skipping strategy
|
||||
load_start = time.time()
|
||||
print("Using SMART SKIPPING strategy...")
|
||||
|
||||
shared_cap_start = time.time()
|
||||
shared_cap = cv2.VideoCapture(str(current_file))
|
||||
shared_cap_create_time = (time.time() - shared_cap_start) * 1000
|
||||
print(f"Capture creation: {shared_cap_create_time:.1f}ms")
|
||||
|
||||
if shared_cap.isOpened():
|
||||
frames_start = time.time()
|
||||
|
||||
# Strategy: Read a much smaller subset and interpolate/approximate
|
||||
# Only read 4-6 key frames and generate the rest through approximation
|
||||
key_frames_to_read = min(6, self.segment_count)
|
||||
frames_read = 0
|
||||
|
||||
for i in range(key_frames_to_read):
|
||||
target_frame = self.segment_positions[i * (self.segment_count // key_frames_to_read)]
|
||||
|
||||
seek_start = time.time()
|
||||
shared_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
|
||||
seek_time = (time.time() - seek_start) * 1000
|
||||
|
||||
read_start = time.time()
|
||||
ret, frame = shared_cap.read()
|
||||
read_time = (time.time() - read_start) * 1000
|
||||
|
||||
# Load initial frame for each segment
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
self.segment_frames.append(frame)
|
||||
# Use this frame for multiple segments (approximation)
|
||||
segments_per_key = self.segment_count // key_frames_to_read
|
||||
start_seg = i * segments_per_key
|
||||
end_seg = min(start_seg + segments_per_key, self.segment_count)
|
||||
|
||||
for seg_idx in range(start_seg, end_seg):
|
||||
self.segment_frames[seg_idx] = frame.copy()
|
||||
|
||||
frames_read += 1
|
||||
print(f"Key frame {i}: Frame {target_frame} -> Segments {start_seg}-{end_seg-1} ({seek_time:.1f}ms + {read_time:.1f}ms)")
|
||||
else:
|
||||
self.segment_frames.append(None)
|
||||
else:
|
||||
self.segment_caps.append(None)
|
||||
self.segment_frames.append(None)
|
||||
print(f"Failed to read key frame {i} at position {target_frame}")
|
||||
|
||||
# Fill any remaining segments with the last valid frame
|
||||
last_valid_frame = None
|
||||
for frame in self.segment_frames:
|
||||
if frame is not None:
|
||||
last_valid_frame = frame
|
||||
break
|
||||
|
||||
if last_valid_frame is not None:
|
||||
for i in range(len(self.segment_frames)):
|
||||
if self.segment_frames[i] is None:
|
||||
self.segment_frames[i] = last_valid_frame.copy()
|
||||
|
||||
frames_time = (time.time() - frames_start) * 1000
|
||||
print(f"Smart frame reading: {frames_time:.1f}ms ({frames_read} key frames for {self.segment_count} segments)")
|
||||
|
||||
shared_cap.release()
|
||||
else:
|
||||
print("Failed to create shared capture!")
|
||||
|
||||
total_time = time.time() - start_time
|
||||
print(f"LIGHTNING FAST Total setup time: {total_time * 1000:.1f}ms")
|
||||
|
||||
# Report success
|
||||
successful_segments = sum(1 for frame in self.segment_frames if frame is not None)
|
||||
print(f"Successfully approximated {successful_segments}/{self.segment_count} segments")
|
||||
|
||||
def setup_segment_captures(self):
|
||||
"""Use the lightning fast approximation method for maximum speed"""
|
||||
self.setup_segment_captures_lightning_fast()
|
||||
|
||||
def cleanup_segment_captures(self):
|
||||
"""Clean up all segment video captures"""
|
||||
@@ -558,23 +760,118 @@ class MediaGrader:
|
||||
self.segment_caps = []
|
||||
self.segment_frames = []
|
||||
self.segment_positions = []
|
||||
# Clear frame cache
|
||||
self.frame_cache.clear()
|
||||
|
||||
def update_segment_frames(self):
|
||||
"""Update frames for all segments during playback"""
|
||||
if not self.multi_segment_mode or not self.segment_caps:
|
||||
return
|
||||
|
||||
for i, cap in enumerate(self.segment_caps):
|
||||
def get_cached_frame(self, frame_number: int):
|
||||
"""Get frame from cache or load it if not cached"""
|
||||
# Check cache first (thread-safe)
|
||||
with self.cache_lock:
|
||||
if frame_number in self.frame_cache:
|
||||
return self.frame_cache[frame_number].copy() # Return a copy to avoid modification
|
||||
|
||||
# Load frame outside of lock to avoid blocking other threads
|
||||
frame = None
|
||||
if self.current_cap:
|
||||
# Create a temporary capture to avoid interfering with main playback
|
||||
current_file = self.media_files[self.current_index]
|
||||
temp_cap = cv2.VideoCapture(str(current_file))
|
||||
if temp_cap.isOpened():
|
||||
temp_cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
|
||||
ret, frame = temp_cap.read()
|
||||
temp_cap.release()
|
||||
|
||||
if ret and frame is not None:
|
||||
# Cache the frame (with size limit) - thread-safe
|
||||
with self.cache_lock:
|
||||
if len(self.frame_cache) >= self.cache_size_limit:
|
||||
# Remove oldest cached frames (remove multiple at once for efficiency)
|
||||
keys_to_remove = sorted(self.frame_cache.keys())[:len(self.frame_cache) // 4]
|
||||
for key in keys_to_remove:
|
||||
del self.frame_cache[key]
|
||||
|
||||
self.frame_cache[frame_number] = frame.copy()
|
||||
return frame
|
||||
|
||||
return None
|
||||
|
||||
def get_segment_capture(self, segment_index):
|
||||
"""Get or create a capture for a specific segment (lazy loading)"""
|
||||
if segment_index >= len(self.segment_caps) or self.segment_caps[segment_index] is None:
|
||||
if segment_index < len(self.segment_caps):
|
||||
# Create capture on demand
|
||||
current_file = self.media_files[self.current_index]
|
||||
cap = cv2.VideoCapture(str(current_file))
|
||||
if cap.isOpened():
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, self.segment_positions[segment_index])
|
||||
self.segment_caps[segment_index] = cap
|
||||
return cap
|
||||
else:
|
||||
return None
|
||||
return None
|
||||
return self.segment_caps[segment_index]
|
||||
|
||||
def update_segment_frame_parallel(self, segment_index):
|
||||
"""Update a single segment frame"""
|
||||
try:
|
||||
cap = self.get_segment_capture(segment_index)
|
||||
if cap and cap.isOpened():
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
self.segment_frames[i] = frame
|
||||
return segment_index, frame
|
||||
else:
|
||||
# Loop back to segment start when reaching end
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, self.segment_positions[i])
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, self.segment_positions[segment_index])
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
self.segment_frames[i] = frame
|
||||
return segment_index, frame
|
||||
else:
|
||||
return segment_index, None
|
||||
return segment_index, None
|
||||
except Exception as e:
|
||||
print(f"Error updating segment {segment_index}: {e}")
|
||||
return segment_index, None
|
||||
|
||||
def update_segment_frames(self):
|
||||
"""Update frames for all segments during playback with parallel processing"""
|
||||
if not self.multi_segment_mode or not self.segment_frames:
|
||||
return
|
||||
|
||||
# Only update segments that have valid frames loaded
|
||||
active_segments = [i for i, frame in enumerate(self.segment_frames) if frame is not None]
|
||||
|
||||
if not active_segments:
|
||||
return
|
||||
|
||||
# Use thread pool for parallel frame updates (but limit to avoid overwhelming)
|
||||
if len(active_segments) <= 4:
|
||||
# For small numbers, use parallel processing
|
||||
futures = []
|
||||
for i in active_segments:
|
||||
future = self.thread_pool.submit(self.update_segment_frame_parallel, i)
|
||||
futures.append(future)
|
||||
|
||||
# Collect results
|
||||
for future in futures:
|
||||
segment_index, frame = future.result()
|
||||
if frame is not None:
|
||||
self.segment_frames[segment_index] = frame
|
||||
else:
|
||||
# For larger numbers, process in smaller batches to avoid resource exhaustion
|
||||
batch_size = 4
|
||||
for batch_start in range(0, len(active_segments), batch_size):
|
||||
batch = active_segments[batch_start:batch_start + batch_size]
|
||||
futures = []
|
||||
|
||||
for i in batch:
|
||||
future = self.thread_pool.submit(self.update_segment_frame_parallel, i)
|
||||
futures.append(future)
|
||||
|
||||
# Collect batch results
|
||||
for future in futures:
|
||||
segment_index, frame = future.result()
|
||||
if frame is not None:
|
||||
self.segment_frames[segment_index] = frame
|
||||
|
||||
def reposition_segments_around_frame(self, center_frame: int):
|
||||
"""Reposition all segments around a center frame while maintaining spacing"""
|
||||
@@ -607,27 +904,61 @@ class MediaGrader:
|
||||
# Reset position for next read
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, self.segment_positions[i])
|
||||
|
||||
def seek_all_segments(self, frames_delta: int):
|
||||
"""Seek all segments by the specified number of frames"""
|
||||
if not self.multi_segment_mode or not self.segment_caps:
|
||||
return
|
||||
|
||||
for i, cap in enumerate(self.segment_caps):
|
||||
def seek_segment_parallel(self, segment_index, frames_delta):
|
||||
"""Seek a single segment by the specified number of frames"""
|
||||
try:
|
||||
if segment_index >= len(self.segment_positions):
|
||||
return segment_index, None
|
||||
|
||||
cap = self.get_segment_capture(segment_index)
|
||||
if cap and cap.isOpened():
|
||||
current_frame = int(cap.get(cv2.CAP_PROP_POS_FRAMES))
|
||||
segment_start = self.segment_positions[i]
|
||||
segment_start = self.segment_positions[segment_index]
|
||||
segment_duration = self.total_frames // self.segment_count
|
||||
segment_end = min(self.total_frames - 1, segment_start + segment_duration)
|
||||
|
||||
target_frame = max(segment_start, min(current_frame + frames_delta, segment_end))
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
|
||||
|
||||
# Load new frame
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
self.segment_frames[i] = frame
|
||||
# Reset position for next read
|
||||
# Try cache first for better performance
|
||||
cached_frame = self.get_cached_frame(target_frame)
|
||||
if cached_frame is not None:
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
|
||||
return segment_index, cached_frame
|
||||
else:
|
||||
# Fall back to normal seeking
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
return segment_index, frame
|
||||
else:
|
||||
return segment_index, None
|
||||
return segment_index, None
|
||||
except Exception as e:
|
||||
print(f"Error seeking segment {segment_index}: {e}")
|
||||
return segment_index, None
|
||||
|
||||
def seek_all_segments(self, frames_delta: int):
|
||||
"""Seek all segments by the specified number of frames with parallel processing"""
|
||||
if not self.multi_segment_mode or not self.segment_frames:
|
||||
return
|
||||
|
||||
# Only seek segments that have valid frames loaded
|
||||
active_segments = [i for i, frame in enumerate(self.segment_frames) if frame is not None]
|
||||
|
||||
if not active_segments:
|
||||
return
|
||||
|
||||
# Use parallel processing for seeking
|
||||
futures = []
|
||||
for i in active_segments:
|
||||
future = self.thread_pool.submit(self.seek_segment_parallel, i, frames_delta)
|
||||
futures.append(future)
|
||||
|
||||
# Collect results
|
||||
for future in futures:
|
||||
segment_index, frame = future.result()
|
||||
if frame is not None:
|
||||
self.segment_frames[segment_index] = frame
|
||||
|
||||
def display_current_frame(self):
|
||||
"""Display the current cached frame with overlays"""
|
||||
@@ -664,7 +995,8 @@ class MediaGrader:
|
||||
# Draw timeline
|
||||
self.draw_timeline(frame)
|
||||
|
||||
cv2.imshow("Media Grader", frame)
|
||||
# Maintain aspect ratio when displaying
|
||||
self.display_with_aspect_ratio(frame)
|
||||
|
||||
def display_multi_segment_frame(self):
|
||||
"""Display multi-segment frame view"""
|
||||
@@ -697,8 +1029,19 @@ class MediaGrader:
|
||||
row = i // grid_cols
|
||||
col = i % grid_cols
|
||||
|
||||
# Resize segment frame to fit grid cell
|
||||
resized_segment = cv2.resize(segment_frame, (segment_width, segment_height))
|
||||
# Resize segment frame to fit grid cell while maintaining aspect ratio
|
||||
frame_height, frame_width = segment_frame.shape[:2]
|
||||
seg_scale_x = segment_width / frame_width
|
||||
seg_scale_y = segment_height / frame_height
|
||||
seg_scale = min(seg_scale_x, seg_scale_y)
|
||||
|
||||
new_seg_width = int(frame_width * seg_scale)
|
||||
new_seg_height = int(frame_height * seg_scale)
|
||||
resized_segment = cv2.resize(segment_frame, (new_seg_width, new_seg_height), interpolation=cv2.INTER_AREA)
|
||||
|
||||
# Center the resized segment in the grid cell
|
||||
y_offset = (segment_height - new_seg_height) // 2
|
||||
x_offset = (segment_width - new_seg_width) // 2
|
||||
|
||||
# Calculate position in combined frame
|
||||
y_start = row * segment_height
|
||||
@@ -706,8 +1049,17 @@ class MediaGrader:
|
||||
x_start = col * segment_width
|
||||
x_end = x_start + segment_width
|
||||
|
||||
# Place segment in combined frame
|
||||
combined_frame[y_start:y_end, x_start:x_end] = resized_segment
|
||||
# Place segment in combined frame (centered)
|
||||
y_place_start = y_start + y_offset
|
||||
y_place_end = y_place_start + new_seg_height
|
||||
x_place_start = x_start + x_offset
|
||||
x_place_end = x_place_start + new_seg_width
|
||||
|
||||
# Ensure we don't go out of bounds
|
||||
y_place_end = min(y_place_end, y_end)
|
||||
x_place_end = min(x_place_end, x_end)
|
||||
|
||||
combined_frame[y_place_start:y_place_end, x_place_start:x_place_end] = resized_segment
|
||||
|
||||
# Add segment label
|
||||
segment_position = int((self.segment_positions[i] / self.total_frames) * 100)
|
||||
@@ -715,7 +1067,7 @@ class MediaGrader:
|
||||
cv2.putText(
|
||||
combined_frame,
|
||||
label_text,
|
||||
(x_start + 5, y_start + 20),
|
||||
(x_place_start + 5, y_place_start + 20),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.5,
|
||||
(255, 255, 255),
|
||||
@@ -724,7 +1076,7 @@ class MediaGrader:
|
||||
cv2.putText(
|
||||
combined_frame,
|
||||
label_text,
|
||||
(x_start + 5, y_start + 20),
|
||||
(x_place_start + 5, y_place_start + 20),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.5,
|
||||
(0, 0, 0),
|
||||
@@ -760,7 +1112,8 @@ class MediaGrader:
|
||||
# Draw multi-segment timeline
|
||||
self.draw_multi_segment_timeline(combined_frame)
|
||||
|
||||
cv2.imshow("Media Grader", combined_frame)
|
||||
# Maintain aspect ratio when displaying
|
||||
self.display_with_aspect_ratio(combined_frame)
|
||||
|
||||
def draw_multi_segment_timeline(self, frame):
|
||||
"""Draw timeline showing all segment positions"""
|
||||
@@ -1115,6 +1468,9 @@ class MediaGrader:
|
||||
|
||||
cv2.namedWindow("Media Grader", cv2.WINDOW_NORMAL)
|
||||
cv2.setMouseCallback("Media Grader", self.mouse_callback)
|
||||
|
||||
# Set initial window size to a reasonable default (will be resized on first frame)
|
||||
cv2.resizeWindow("Media Grader", 1280, 720)
|
||||
|
||||
while self.media_files and self.current_index < len(self.media_files):
|
||||
current_file = self.media_files[self.current_index]
|
||||
@@ -1214,6 +1570,10 @@ class MediaGrader:
|
||||
if self.current_cap:
|
||||
self.current_cap.release()
|
||||
self.cleanup_segment_captures()
|
||||
|
||||
# Cleanup thread pool
|
||||
self.thread_pool.shutdown(wait=True)
|
||||
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
print("Grading session complete!")
|
||||
|
Reference in New Issue
Block a user