Compare commits

...

4 Commits

408
main.py
View File

@@ -13,22 +13,19 @@ from typing import List
class MediaGrader:
# Configuration constants
BASE_FRAME_DELAY_MS = 16 # ~30 FPS
KEY_REPEAT_RATE_SEC = 0.5 # How often to process key repeats
FAST_SEEK_ACTIVATION_TIME = 2.0 # How long to hold before fast seek
FRAME_RENDER_TIME_MS = 50 # Time to let frames render between seeks
BASE_FRAME_DELAY_MS = 16
KEY_REPEAT_RATE_SEC = 0.5
FAST_SEEK_ACTIVATION_TIME = 2.0
FRAME_RENDER_TIME_MS = 50
SPEED_INCREMENT = 0.2
MIN_PLAYBACK_SPEED = 0.1
MAX_PLAYBACK_SPEED = 100.0
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
TIMELINE_BAR_HEIGHT = 12
@@ -38,13 +35,11 @@ class MediaGrader:
TIMELINE_COLOR_HANDLE = (255, 255, 255)
TIMELINE_COLOR_BORDER = (200, 200, 200)
# Seek modifiers for A/D keys
SHIFT_SEEK_MULTIPLIER = 5 # SHIFT + A/D multiplier
CTRL_SEEK_MULTIPLIER = 10 # CTRL + A/D multiplier
SHIFT_SEEK_MULTIPLIER = 5
CTRL_SEEK_MULTIPLIER = 10
# Multi-segment mode configuration
SEGMENT_COUNT = 16 # Number of video segments (4x4 grid)
SEGMENT_OVERLAP_PERCENT = 10 # Percentage overlap between segments
SEGMENT_COUNT = 16
SEGMENT_OVERLAP_PERCENT = 10
def __init__(
self, directory: str, seek_frames: int = 30, snap_to_iframe: bool = False
@@ -59,37 +54,26 @@ class MediaGrader:
self.current_frame = 0
self.total_frames = 0
# Multi-segment mode state
self.multi_segment_mode = False
self.segment_count = self.SEGMENT_COUNT # Use the class constant
self.segment_overlap_percent = self.SEGMENT_OVERLAP_PERCENT # Use the class constant
self.segment_caps = [] # List of VideoCapture objects for each segment
self.segment_frames = [] # List of current frames for each segment
self.segment_positions = [] # List of frame positions for each segment
self.segment_count = self.SEGMENT_COUNT
self.segment_overlap_percent = self.SEGMENT_OVERLAP_PERCENT
self.segment_caps = []
self.segment_frames = []
self.segment_positions = []
# 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
self.current_seek_key = None
self.key_first_press_time = 0
self.is_seeking = False
# Seeking modes
self.fine_seek_frames = 1 # Frame-by-frame
self.coarse_seek_frames = self.seek_frames # User-configurable
self.fine_seek_frames = 1
self.coarse_seek_frames = self.seek_frames
self.fast_seek_frames = self.seek_frames * self.FAST_SEEK_MULTIPLIER
# Current frame cache for display
self.current_display_frame = None
# Supported media extensions
self.extensions = [
".png",
".jpg",
@@ -101,27 +85,20 @@ class MediaGrader:
".mkv",
]
# Mouse interaction for timeline
self.mouse_dragging = False
self.timeline_rect = None
self.window_width = 800
self.window_height = 600
# Undo functionality
self.undo_history = [] # List of (source_path, destination_path, original_index) tuples
self.undo_history = []
# 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
self.watched_regions = {}
self.current_watch_start = None
self.last_frame_position = 0
# 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
self.last_jump_position = {}
self.jump_history = {}
# Performance optimization: Thread pool for parallel operations
self.thread_pool = ThreadPoolExecutor(max_workers=4)
def display_with_aspect_ratio(self, frame):
@@ -384,11 +361,7 @@ class MediaGrader:
self.jump_history[current_file].append(self.current_frame)
# Jump to the target frame
if self.multi_segment_mode:
# In multi-segment mode, reposition all segments relative to the jump target
self.reposition_segments_around_frame(target_frame)
else:
# In single mode, just jump the main capture
if not self.multi_segment_mode:
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
self.load_current_frame()
@@ -517,11 +490,24 @@ class MediaGrader:
if not self.is_video(self.media_files[self.current_index]):
return
# Safety check for huge videos
safe_frame_count = max(1, int(self.total_frames * 0.6))
estimated_mb = safe_frame_count * 5 // 1024 # Rough estimate: 5MB per frame
if safe_frame_count > 5000: # ~3 minutes at 30fps
print(f"Video too large for preloading! {safe_frame_count} frames would use ~{estimated_mb}GB RAM")
print("Multi-segment mode not available for videos longer than ~3 minutes")
return
elif safe_frame_count > 1000: # Warn for videos > ~30 seconds
print(f"Large video detected: {safe_frame_count} frames will use ~{estimated_mb}MB RAM")
print("Press any key to continue or 'q' to cancel...")
# Note: In a real implementation, you'd want proper input handling here
start_time = time.time()
print(f"Setting up {self.segment_count} segments with video preloading...")
print(f"Will preload {safe_frame_count} frames (~{safe_frame_count * 5 // 1024}MB RAM)")
try:
# Clean up existing segment captures
print("Cleaning up existing captures...")
self.cleanup_segment_captures()
@@ -541,10 +527,6 @@ class MediaGrader:
print("Error: Video has insufficient frames for multi-segment mode")
return
# Use conservative frame range
safe_frame_count = max(1, int(self.total_frames * 0.6))
print(f"Using safe frame count: {safe_frame_count} (60% of reported {self.total_frames})")
for i in range(self.segment_count):
if self.segment_count <= 1:
position_ratio = 0
@@ -556,32 +538,29 @@ class MediaGrader:
self.segment_current_frames[i] = start_frame # Start each segment at its position
print(f"Segment positions: {self.segment_positions}")
# Preload the entire video into memory
# Preload the entire video into memory - simple and fast
print("Preloading entire video into memory...")
preload_start = time.time()
self.video_frame_cache = [] # Array to hold all frames
if self.current_cap and self.current_cap.isOpened():
# Reset to beginning
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
# Simple, fast sequential read
frames = []
frame_count = 0
while frame_count < safe_frame_count:
ret, frame = self.current_cap.read()
if ret and frame is not None:
self.video_frame_cache.append(frame.copy())
frames.append(frame)
frame_count += 1
# Show progress for large videos
if frame_count % 50 == 0:
print(f"Preloaded {frame_count}/{safe_frame_count} frames...")
else:
print(f"Reached end of video at frame {frame_count}")
break
# Reset main capture to original position
self.video_frame_cache = frames
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, self.current_frame)
else:
self.video_frame_cache = []
preload_time = (time.time() - preload_start) * 1000
print(f"Video preloading: {preload_time:.1f}ms ({len(self.video_frame_cache)} frames)")
@@ -590,7 +569,7 @@ class MediaGrader:
print("Initializing segment frames...")
for i in range(self.segment_count):
if self.segment_current_frames[i] < len(self.video_frame_cache):
self.segment_frames[i] = self.video_frame_cache[self.segment_current_frames[i]].copy()
self.segment_frames[i] = self.video_frame_cache[self.segment_current_frames[i]]
except Exception as e:
print(f"Error in setup: {e}")
@@ -605,63 +584,6 @@ class MediaGrader:
successful_segments = sum(1 for frame in self.segment_frames if frame is not None)
print(f"Successfully preloaded video with {successful_segments}/{self.segment_count} active segments")
def _create_segment_parallel(self, segment_index: int, file_path: str, start_frame: int):
"""Create a single segment capture and load its initial frame (runs in thread)"""
try:
# Create optimized capture for this segment
cap = None
backends_to_try = []
if hasattr(cv2, 'CAP_FFMPEG'):
backends_to_try.append(cv2.CAP_FFMPEG)
if hasattr(cv2, 'CAP_DSHOW'):
backends_to_try.append(cv2.CAP_DSHOW)
backends_to_try.append(cv2.CAP_ANY)
for backend in backends_to_try:
try:
cap = cv2.VideoCapture(file_path, backend)
if cap.isOpened():
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
break
cap.release()
except:
continue
if not cap or not cap.isOpened():
cap = cv2.VideoCapture(file_path) # Fallback
if not cap.isOpened():
return None, None
# Try seeking to the requested frame
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
ret, frame = cap.read()
if ret and frame is not None:
# Reset to start position for future reads
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
return cap, frame.copy()
else:
# Frame doesn't exist - try seeking to a safer position
# Try progressively earlier frames
for fallback_frame in [start_frame // 2, start_frame // 4, 0]:
cap.set(cv2.CAP_PROP_POS_FRAMES, fallback_frame)
ret, frame = cap.read()
if ret and frame is not None:
print(f"Segment {segment_index}: fell back from frame {start_frame} to {fallback_frame}")
cap.set(cv2.CAP_PROP_POS_FRAMES, fallback_frame)
return cap, frame.copy()
# If all fallbacks failed, give up
cap.release()
return None, None
except Exception as e:
print(f"Error creating segment {segment_index}: {e}")
if cap:
cap.release()
return None, None
def cleanup_segment_captures(self):
"""Clean up all segment video captures and preloaded cache"""
for cap in self.segment_caps:
@@ -671,229 +593,24 @@ class MediaGrader:
self.segment_frames = []
self.segment_positions = []
if hasattr(self, 'video_frame_cache'):
self.video_frame_cache = [] # Clear preloaded video cache
self.video_frame_cache = []
if hasattr(self, 'segment_current_frames'):
self.segment_current_frames = [] # Clear frame tracking
# Clear frame cache
self.frame_cache.clear()
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]
# Use optimized backend for temporary capture too
temp_cap = None
backends_to_try = []
if hasattr(cv2, 'CAP_FFMPEG'): # FFmpeg - best for video files
backends_to_try.append(cv2.CAP_FFMPEG)
if hasattr(cv2, 'CAP_DSHOW'): # DirectShow - fallback
backends_to_try.append(cv2.CAP_DSHOW)
backends_to_try.append(cv2.CAP_ANY) # Final fallback
for backend in backends_to_try:
try:
temp_cap = cv2.VideoCapture(str(current_file), backend)
if temp_cap.isOpened():
temp_cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
break
temp_cap.release()
except:
continue
if not temp_cap:
temp_cap = cv2.VideoCapture(str(current_file)) # Fallback
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 with optimized backend
current_file = self.media_files[self.current_index]
# Use optimized backend for segment capture
cap = None
backends_to_try = []
if hasattr(cv2, 'CAP_FFMPEG'): # FFmpeg - best for video files
backends_to_try.append(cv2.CAP_FFMPEG)
if hasattr(cv2, 'CAP_DSHOW'): # DirectShow - fallback
backends_to_try.append(cv2.CAP_DSHOW)
backends_to_try.append(cv2.CAP_ANY) # Final fallback
for backend in backends_to_try:
try:
cap = cv2.VideoCapture(str(current_file), backend)
if cap.isOpened():
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
break
cap.release()
except:
continue
if not cap:
cap = cv2.VideoCapture(str(current_file)) # Fallback
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:
return segment_index, frame
else:
# Loop back to segment start when reaching end
cap.set(cv2.CAP_PROP_POS_FRAMES, self.segment_positions[segment_index])
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 updating segment {segment_index}: {e}")
return segment_index, None
self.segment_current_frames = []
def update_segment_frames(self):
"""Update frames for segments using the preloaded video array - smooth playback!"""
if not self.multi_segment_mode or not self.segment_frames or not hasattr(self, 'video_frame_cache'):
return
# Each segment advances through the video at its own pace
for i in range(len(self.segment_frames)):
if self.segment_frames[i] is not None and self.video_frame_cache:
# Advance this segment's current frame
self.segment_current_frames[i] += 1
# Loop back to start if we reach the end
if self.segment_current_frames[i] >= len(self.video_frame_cache):
self.segment_current_frames[i] = 0
# Update the segment frame from the cache
self.segment_frames[i] = self.video_frame_cache[self.segment_current_frames[i]].copy()
def reposition_segments_around_frame(self, center_frame: int):
"""Reposition all segments around a center frame while maintaining spacing"""
if not self.multi_segment_mode or not self.segment_caps:
return
# Calculate new segment positions around the center frame
# Keep the same relative spacing but center around the new frame
segment_spacing = self.total_frames // (self.segment_count + 1)
new_positions = []
for i in range(self.segment_count):
# Spread segments around center_frame
offset = (i - (self.segment_count - 1) / 2) * segment_spacing
new_frame = int(center_frame + offset)
new_frame = max(0, min(new_frame, self.total_frames - 1))
new_positions.append(new_frame)
# Update segment positions and seek all captures
self.segment_positions = new_positions
for i, cap in enumerate(self.segment_caps):
if cap and cap.isOpened():
cap.set(cv2.CAP_PROP_POS_FRAMES, self.segment_positions[i])
# Load new frame
ret, frame = cap.read()
if ret:
self.segment_frames[i] = frame
# Reset position for next read
cap.set(cv2.CAP_PROP_POS_FRAMES, self.segment_positions[i])
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[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))
# 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
# Direct reference - no copy needed for display
self.segment_frames[i] = self.video_frame_cache[self.segment_current_frames[i]]
def display_current_frame(self):
"""Display the current cached frame with overlays"""
@@ -1176,7 +893,6 @@ class MediaGrader:
return
if self.multi_segment_mode:
# Update all segment frames
self.update_segment_frames()
return True
else:
@@ -1185,9 +901,8 @@ class MediaGrader:
for _ in range(frames_to_skip + 1):
ret, frame = self.current_cap.read()
if not ret:
# Hit actual end of video - check if frame count was wrong
actual_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
if actual_frame < self.total_frames - 5: # Allow some tolerance
if actual_frame < self.total_frames - 5:
print(f"Frame count mismatch! Reported: {self.total_frames}, Actual: {actual_frame}")
self.total_frames = actual_frame
return False
@@ -1195,7 +910,6 @@ class MediaGrader:
self.current_display_frame = frame
self.current_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
# Update watch tracking
self.update_watch_tracking()
return True
@@ -1206,17 +920,17 @@ class MediaGrader:
return
if self.multi_segment_mode:
self.seek_all_segments(frames_delta)
else:
if not self.current_cap:
return
target_frame = max(
0, min(self.current_frame + frames_delta, self.total_frames - 1)
)
return
if not self.current_cap:
return
target_frame = max(
0, min(self.current_frame + frames_delta, self.total_frames - 1)
)
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
self.load_current_frame()
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
self.load_current_frame()
def process_seek_key(self, key: int) -> bool:
"""Process seeking keys with proper rate limiting"""
@@ -1455,7 +1169,7 @@ class MediaGrader:
self.playback_speed + self.SPEED_INCREMENT,
)
elif self.process_seek_key(key):
pass
continue
elif key == ord("n"):
break
elif key == ord("p"):