diff --git a/main.py b/main.py index 426603e..f41ad89 100644 --- a/main.py +++ b/main.py @@ -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() @@ -560,27 +533,26 @@ class MediaGrader: print("Preloading entire video into memory...") preload_start = time.time() - self.video_frame_cache = [] # Array to hold all frames + self.video_frame_cache = [] if self.current_cap and self.current_cap.isOpened(): - # Reset to beginning self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + # Pre-allocate list for better performance + self.video_frame_cache = [None] * safe_frame_count + 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()) + # Direct assignment - no copy() needed yet + self.video_frame_cache[frame_count] = 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}") + # Truncate list to actual size + self.video_frame_cache = self.video_frame_cache[:frame_count] break - # Reset main capture to original position self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, self.current_frame) preload_time = (time.time() - preload_start) * 1000 @@ -605,63 +577,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,230 +586,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 - def display_current_frame(self): """Display the current cached frame with overlays""" if self.multi_segment_mode: @@ -1176,7 +885,6 @@ class MediaGrader: return if self.multi_segment_mode: - # Update all segment frames self.update_segment_frames() return True else: @@ -1185,9 +893,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 +902,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 +912,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 +1161,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"):