diff --git a/main.py b/main.py index 585d0ac..f410e09 100644 --- a/main.py +++ b/main.py @@ -7,23 +7,74 @@ import argparse import shutil import time import threading -import subprocess -import json from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import List +class Cv2BufferedCap: + """Buffered wrapper around cv2.VideoCapture that handles frame loading, seeking, and caching correctly""" + + def __init__(self, video_path, backend=None): + self.video_path = video_path + self.cap = cv2.VideoCapture(str(video_path), backend) + if not self.cap.isOpened(): + raise ValueError(f"Could not open video: {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)) + + # Current position tracking + self.current_frame = 0 + + def get_frame(self, frame_number): + """Get frame at specific index - always accurate""" + # Clamp frame number to valid range + frame_number = max(0, min(frame_number, self.total_frames - 1)) + + # Optimize for sequential reading (next frame) + if frame_number == self.current_frame + 1: + ret, frame = self.cap.read() + else: + # Seek for non-sequential access + self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number) + ret, frame = self.cap.read() + + if ret: + self.current_frame = frame_number + return frame + else: + raise ValueError(f"Failed to read frame {frame_number}") + + def advance_frame(self, frames=1): + """Advance by specified number of frames""" + new_frame = self.current_frame + frames + return self.get_frame(new_frame) + + def release(self): + """Release the video capture""" + if self.cap: + self.cap.release() + + def isOpened(self): + """Check if capture is opened""" + return self.cap and self.cap.isOpened() + + class MediaGrader: - BASE_FRAME_DELAY_MS = 16 + # Configuration constants - matching croppa implementation + TARGET_FPS = 80 # Target FPS for speed calculations + SPEED_INCREMENT = 0.1 + MIN_PLAYBACK_SPEED = 0.05 + MAX_PLAYBACK_SPEED = 1.0 + + # Legacy constants for compatibility 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_WIDTH = 2560 MONITOR_HEIGHT = 1440 @@ -158,19 +209,18 @@ class MediaGrader: 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 calculate_frames_to_skip(self) -> int: - """Calculate how many frames to skip for high-speed playback""" - if self.playback_speed <= 1.0: - return 0 - elif self.playback_speed <= 2.0: - return 0 - elif self.playback_speed <= 5.0: - return int(self.playback_speed - 1) + # Round to 2 decimals to handle floating point precision issues + speed = round(self.playback_speed, 2) + if speed >= 1.0: + # Speed >= 1: maximum FPS (no delay) + return 1 else: - return int(self.playback_speed * 2) + # Speed < 1: scale FPS based on speed + # Formula: fps = TARGET_FPS * speed, so delay = 1000 / fps + target_fps = self.TARGET_FPS * speed + delay_ms = int(1000 / target_fps) + return max(1, delay_ms) + def load_media(self, file_path: Path) -> bool: """Load media file for display""" @@ -178,43 +228,17 @@ class MediaGrader: self.current_cap.release() if self.is_video(file_path): - # Try different backends for better performance - # For video files: FFmpeg is usually best, DirectShow is for cameras - 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 - usually for cameras, but try as fallback - backends_to_try.append(cv2.CAP_DSHOW) - backends_to_try.append(cv2.CAP_ANY) # Final fallback - - self.current_cap = None - for backend in backends_to_try: - try: - self.current_cap = cv2.VideoCapture(str(file_path), backend) - if self.current_cap.isOpened(): - # Optimize buffer settings for better performance - self.current_cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Minimize buffer to reduce latency - # Try to set hardware acceleration if available - if hasattr(cv2, 'CAP_PROP_HW_ACCELERATION'): - self.current_cap.set(cv2.CAP_PROP_HW_ACCELERATION, cv2.VIDEO_ACCELERATION_ANY) - break - self.current_cap.release() - except: - continue - - if not self.current_cap or not self.current_cap.isOpened(): - print(f"Warning: Could not open video file {file_path.name} (unsupported codec)") - return False + try: + # Use Cv2BufferedCap for better frame handling + self.current_cap = Cv2BufferedCap(file_path) + self.total_frames = self.current_cap.total_frames + self.current_frame = 0 - self.total_frames = int(self.current_cap.get(cv2.CAP_PROP_FRAME_COUNT)) - self.current_frame = 0 - - # Get codec information for debugging - fourcc = int(self.current_cap.get(cv2.CAP_PROP_FOURCC)) - codec = "".join([chr((fourcc >> 8 * i) & 0xFF) for i in range(4)]) - backend = self.current_cap.getBackendName() - - print(f"Loaded: {file_path.name} | Codec: {codec} | Backend: {backend} | Frames: {self.total_frames}") + print(f"Loaded: {file_path.name} | Frames: {self.total_frames} | FPS: {self.current_cap.fps:.2f}") + + except Exception as e: + print(f"Warning: Could not open video file {file_path.name}: {e}") + return False else: self.current_cap = None @@ -235,12 +259,13 @@ class MediaGrader: if not self.current_cap: return False - 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)) + try: + # Use Cv2BufferedCap to get frame + self.current_display_frame = self.current_cap.get_frame(self.current_frame) return True - return False + except Exception as e: + print(f"Failed to load frame {self.current_frame}: {e}") + return False else: frame = cv2.imread(str(self.media_files[self.current_index])) if frame is not None: @@ -904,38 +929,30 @@ class MediaGrader: target_frame = max(0, min(target_frame, self.total_frames - 1)) # Seek to target frame - self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame) + self.current_frame = target_frame self.load_current_frame() def advance_frame(self): - """Advance to next frame(s) based on playback speed""" - if ( - not self.is_video(self.media_files[self.current_index]) - or not self.is_playing - ): - return + """Advance to next frame - handles playback speed and marker looping""" + if not self.is_playing: + return True if self.multi_segment_mode: self.update_segment_frames() return True else: - frames_to_skip = self.calculate_frames_to_skip() + # Always advance by 1 frame - speed is controlled by delay timing + new_frame = self.current_frame + 1 - for _ in range(frames_to_skip + 1): - ret, frame = self.current_cap.read() - if not ret: - actual_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES)) - 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 + # Handle looping bounds + if new_frame >= self.total_frames: + # Loop to beginning + new_frame = 0 - self.current_display_frame = frame - self.current_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES)) - + # Update current frame and load it + self.current_frame = new_frame self.update_watch_tracking() - - return True + return self.load_current_frame() def seek_video(self, frames_delta: int): """Seek video by specified number of frames""" @@ -952,7 +969,7 @@ class MediaGrader: 0, min(self.current_frame + frames_delta, self.total_frames - 1) ) - self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame) + self.current_frame = target_frame self.load_current_frame() def process_seek_key(self, key: int) -> bool: @@ -1165,32 +1182,42 @@ class MediaGrader: cv2.setWindowTitle("Media Grader", window_title) while True: + # Update display self.display_current_frame() - if self.is_video(current_file): - if self.is_seeking: - delay = self.FRAME_RENDER_TIME_MS - else: - delay = self.calculate_frame_delay() + # Calculate appropriate delay based on playback state + if self.is_playing and self.is_video(current_file): + # Use calculated frame delay for proper playback speed + delay_ms = self.calculate_frame_delay() else: - delay = self.IMAGE_DISPLAY_DELAY_MS + # Use minimal delay for immediate responsiveness when not playing + delay_ms = 1 + + # Auto advance frame when playing (videos only) + if self.is_playing and self.is_video(current_file): + self.advance_frame() - key = cv2.waitKey(delay) & 0xFF + # Key capture with appropriate delay + key = cv2.waitKey(delay_ms) & 0xFF if key == ord("q") or key == 27: return elif key == ord(" "): self.is_playing = not self.is_playing elif key == ord("s"): - self.playback_speed = max( - self.MIN_PLAYBACK_SPEED, - self.playback_speed - self.SPEED_INCREMENT, - ) + # Speed control only for videos + if self.is_video(current_file): + self.playback_speed = max( + self.MIN_PLAYBACK_SPEED, + self.playback_speed - self.SPEED_INCREMENT, + ) elif key == ord("w"): - self.playback_speed = min( - self.MAX_PLAYBACK_SPEED, - self.playback_speed + self.SPEED_INCREMENT, - ) + # Speed control only for videos + if self.is_video(current_file): + self.playback_speed = min( + self.MAX_PLAYBACK_SPEED, + self.playback_speed + self.SPEED_INCREMENT, + ) elif self.process_seek_key(key): continue elif key == ord("n"): @@ -1229,17 +1256,6 @@ class MediaGrader: if self.is_seeking and self.current_seek_key is not None: self.process_seek_key(self.current_seek_key) - if ( - self.is_playing - and self.is_video(current_file) - and not self.is_seeking - ): - if not self.advance_frame(): - # Video reached the end, restart it instead of navigating - self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0) - self.current_frame = 0 - self.load_current_frame() - if key not in [ord("p"), ord("u"), ord("1"), ord("2"), ord("3"), ord("4"), ord("5")]: print("Navigating to (pu12345): ", self.current_index) self.current_index += 1