diff --git a/main.py b/main.py index f4ced0b..d3c4d20 100644 --- a/main.py +++ b/main.py @@ -13,7 +13,7 @@ class MediaGrader: # Configuration constants DEFAULT_FPS = 30 BASE_FRAME_DELAY_MS = 33 # ~30 FPS - KEY_REPEAT_THRESHOLD_SEC = 0.2 # Faster detection for repeat + KEY_REPEAT_THRESHOLD_SEC = 0.5 # Faster detection for repeat FAST_SEEK_ACTIVATION_TIME = 0.5 # How long to hold before fast seek WINDOW_MAX_WIDTH = 1200 WINDOW_MAX_HEIGHT = 800 @@ -24,6 +24,7 @@ class MediaGrader: FAST_SEEK_MULTIPLIER = 5 IFRAME_SNAP_INTERVAL = 30 IMAGE_DISPLAY_DELAY_MS = 100 + SEEK_DISPLAY_INTERVAL = 10 # Update display every N frames during seeking def __init__( self, directory: str, seek_frames: int = 30, snap_to_iframe: bool = False @@ -51,6 +52,7 @@ class MediaGrader: # Current frame cache for display self.current_display_frame = None + self.window_resized = False # Supported media extensions self.extensions = [ @@ -126,6 +128,7 @@ class MediaGrader: # Load initial frame self.load_current_frame() + self.window_resized = False return True def load_current_frame(self): @@ -149,6 +152,63 @@ class MediaGrader: return True return False + def display_current_frame(self): + """Display the current cached frame with overlays""" + if self.current_display_frame is None: + return + + frame = self.current_display_frame.copy() + + # Auto-resize window on first frame + if not self.window_resized: + self.auto_resize_window(frame) + self.window_resized = True + + # Add info overlay + current_file = self.media_files[self.current_index] + info_text = f"Speed: {self.playback_speed:.1f}x | Frame: {self.current_frame}/{self.total_frames} | File: {self.current_index + 1}/{len(self.media_files)} | {'Playing' if self.is_playing else 'PAUSED'}" + help_text = "Seek: A/D (hold=FAST) ,. (fine) | W/S speed | 1-5 grade | Space pause | Q quit" + + # White background for text visibility + cv2.putText( + frame, + info_text, + (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + (255, 255, 255), + 2, + ) + cv2.putText( + frame, + info_text, + (10, 30), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + (0, 0, 0), + 1, + ) + cv2.putText( + frame, + help_text, + (10, 60), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + (255, 255, 255), + 2, + ) + cv2.putText( + frame, + help_text, + (10, 60), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + (0, 0, 0), + 1, + ) + + cv2.imshow("Media Grader", frame) + def advance_frame(self): """Advance to next frame(s) based on playback speed""" if ( @@ -191,6 +251,45 @@ class MediaGrader: cv2.resizeWindow("Media Grader", new_width, new_height) + def seek_to_iframe(self, target_frame): + """Seek to the nearest I-frame at or before target_frame""" + if not self.current_cap: + return False + + # For more reliable seeking, always snap to I-frames + iframe_frame = ( + target_frame // self.IFRAME_SNAP_INTERVAL + ) * self.IFRAME_SNAP_INTERVAL + iframe_frame = max(0, min(iframe_frame, self.total_frames - 1)) + + self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, iframe_frame) + + # If we need to get closer to target, read frames sequentially + current_pos = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES)) + frames_to_read = target_frame - current_pos + + if frames_to_read > 0 and frames_to_read < 60: # Only if it's reasonable + for i in range(frames_to_read): + ret, frame = self.current_cap.read() + if not ret: + break + # Update display every few frames during seeking + if i % self.SEEK_DISPLAY_INTERVAL == 0: + self.current_display_frame = frame + self.current_frame = int( + self.current_cap.get(cv2.CAP_PROP_POS_FRAMES) + ) + self.display_current_frame() + cv2.waitKey(1) # Process display events + else: + # For large seeks, just go to the I-frame + ret, frame = self.current_cap.read() + if ret: + self.current_display_frame = frame + + self.current_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES)) + return True + def seek_video(self, frames_delta: int): """Seek video by specified number of frames""" if not self.current_cap or not self.is_video( @@ -198,19 +297,23 @@ class MediaGrader: ): return - new_frame = max( + target_frame = max( 0, min(self.current_frame + frames_delta, self.total_frames - 1) ) - if self.snap_to_iframe and frames_delta < 0: - # Find previous I-frame (approximation) - new_frame = max(0, new_frame - (new_frame % self.IFRAME_SNAP_INTERVAL)) + print( + f"Seeking from {self.current_frame} to {target_frame} (delta: {frames_delta})" + ) - self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, new_frame) + # Use I-frame seeking for smoother performance + if abs(frames_delta) > 5 or self.snap_to_iframe: + self.seek_to_iframe(target_frame) + else: + # For small seeks, use direct frame seeking + self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame) + self.load_current_frame() - # Load the frame we just seeked to and display it immediately - self.load_current_frame() - print(f"Seeked by {frames_delta} frames to frame {new_frame}") + print(f"Seeked to frame {self.current_frame}") def handle_seeking_key(self, key: int) -> bool: """Handle seeking keys with different granularities. Returns True if key was handled.""" @@ -334,61 +437,9 @@ class MediaGrader: window_title = f"Media Grader - {current_file.name} ({self.current_index + 1}/{len(self.media_files)})" cv2.setWindowTitle("Media Grader", window_title) - window_resized = False - while True: # Always display the current cached frame - if self.current_display_frame is not None: - frame = self.current_display_frame.copy() - - # Auto-resize window on first frame - if not window_resized: - self.auto_resize_window(frame) - window_resized = True - - # Add info overlay - info_text = f"Speed: {self.playback_speed:.1f}x | Frame: {self.current_frame}/{self.total_frames} | File: {self.current_index + 1}/{len(self.media_files)} | {'Playing' if self.is_playing else 'PAUSED'}" - help_text = "Seek: A/D (hold=FAST) ,. (fine) | W/S speed | 1-5 grade | Space pause | Q quit" - - # White background for text visibility - cv2.putText( - frame, - info_text, - (10, 30), - cv2.FONT_HERSHEY_SIMPLEX, - 0.7, - (255, 255, 255), - 2, - ) - cv2.putText( - frame, - info_text, - (10, 30), - cv2.FONT_HERSHEY_SIMPLEX, - 0.7, - (0, 0, 0), - 1, - ) - cv2.putText( - frame, - help_text, - (10, 60), - cv2.FONT_HERSHEY_SIMPLEX, - 0.5, - (255, 255, 255), - 2, - ) - cv2.putText( - frame, - help_text, - (10, 60), - cv2.FONT_HERSHEY_SIMPLEX, - 0.5, - (0, 0, 0), - 1, - ) - - cv2.imshow("Media Grader", frame) + self.display_current_frame() # Calculate appropriate delay if self.is_video(current_file): @@ -407,13 +458,13 @@ class MediaGrader: elif key == ord(" "): # Space - pause/play self.is_playing = not self.is_playing print(f"{'Playing' if self.is_playing else 'Paused'}") - elif key == ord("s"): # W - decrease speed + elif key == ord("w"): self.playback_speed = max( self.MIN_PLAYBACK_SPEED, self.playback_speed - self.SPEED_INCREMENT, ) print(f"Speed: {self.playback_speed:.1f}x") - elif key == ord("w"): # S - increase speed + elif key == ord("w"): self.playback_speed = min( self.MAX_PLAYBACK_SPEED, self.playback_speed + self.SPEED_INCREMENT, diff --git a/uv.lock b/uv.lock index b8affd1..c3fefac 100644 --- a/uv.lock +++ b/uv.lock @@ -5,7 +5,7 @@ requires-python = ">=3.13" [[package]] name = "grader" version = "0.1.0" -source = { virtual = "." } +source = { editable = "." } dependencies = [ { name = "opencv-python" }, ]