diff --git a/croppa/main.py b/croppa/main.py index 4fa3222..7b28338 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -13,6 +13,89 @@ import queue import subprocess import ctypes +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)) + + # Frame cache + self.frame_cache = {} + self.cache_access_order = [] + self.MAX_CACHE_FRAMES = 3000 + + # Current position tracking + self.current_frame = 0 + + def _manage_cache(self): + """Manage cache size using LRU eviction""" + while len(self.frame_cache) > self.MAX_CACHE_FRAMES: + oldest_frame = self.cache_access_order.pop(0) + if oldest_frame in self.frame_cache: + del self.frame_cache[oldest_frame] + + def _add_to_cache(self, frame_number, frame): + """Add frame to cache""" + self.frame_cache[frame_number] = frame.copy() + if frame_number in self.cache_access_order: + self.cache_access_order.remove(frame_number) + self.cache_access_order.append(frame_number) + self._manage_cache() + + def _get_from_cache(self, frame_number): + """Get frame from cache and update LRU""" + if frame_number in self.frame_cache: + if frame_number in self.cache_access_order: + self.cache_access_order.remove(frame_number) + self.cache_access_order.append(frame_number) + return self.frame_cache[frame_number].copy() + return None + + 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)) + + # Check cache first + cached_frame = self._get_from_cache(frame_number) + if cached_frame is not None: + self.current_frame = frame_number + return cached_frame + + # Not in cache, seek to frame and read + self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number) + ret, frame = self.cap.read() + + if ret: + self._add_to_cache(frame_number, frame) + 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() + def get_active_window_title(): """Get the title of the currently active window""" try: @@ -390,9 +473,6 @@ class VideoEditor: # Auto-repeat seeking configuration AUTO_REPEAT_DISPLAY_RATE = 1.0 - - # Frame cache configuration - MAX_CACHE_FRAMES = 3000 # Timeline configuration TIMELINE_HEIGHT = 60 @@ -520,10 +600,6 @@ class VideoEditor: self.cached_transformed_frame = None self.cached_frame_number = None self.cached_transform_hash = None - - # Frame cache for video playback - self.frame_cache = {} # frame_number -> frame_data - self.cache_access_order = [] # Track access order for LRU eviction # Project view mode self.project_view_mode = False @@ -777,9 +853,6 @@ class VideoEditor: if hasattr(self, "cap") and self.cap: self.cap.release() - # Clear frame cache when switching videos - self.frame_cache.clear() - self.cache_access_order.clear() self.video_path = media_path self.is_image_mode = self._is_image_file(media_path) @@ -811,36 +884,30 @@ class VideoEditor: self.cap = None for backend in backends_to_try: try: - self.cap = cv2.VideoCapture(str(self.video_path), backend) + self.cap = Cv2BufferedCap(self.video_path, backend) if self.cap.isOpened(): - # Optimize buffer settings for better performance - self.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.cap.set(cv2.CAP_PROP_HW_ACCELERATION, cv2.VIDEO_ACCELERATION_ANY) break - self.cap.release() except Exception: continue if not self.cap or not self.cap.isOpened(): raise ValueError(f"Could not open video file: {media_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)) + # Video properties from buffered cap + self.total_frames = self.cap.total_frames + self.fps = self.cap.fps + self.frame_width = self.cap.frame_width + self.frame_height = self.cap.frame_height # Get codec information for debugging - fourcc = int(self.cap.get(cv2.CAP_PROP_FOURCC)) + fourcc = int(self.cap.cap.get(cv2.CAP_PROP_FOURCC)) codec = "".join([chr((fourcc >> 8 * i) & 0xFF) for i in range(4)]) # Get backend information - backend = self.cap.getBackendName() + backend_name = "FFmpeg" if hasattr(cv2, 'CAP_FFMPEG') and backend == cv2.CAP_FFMPEG else "Other" print(f"Loaded video: {self.video_path.name} ({self.current_video_index + 1}/{len(self.video_files)})") - print(f" Codec: {codec} | Backend: {backend} | Resolution: {self.frame_width}x{self.frame_height}") + print(f" Codec: {codec} | Backend: {backend_name} | Resolution: {self.frame_width}x{self.frame_height}") print(f" FPS: {self.fps:.2f} | Frames: {self.total_frames} | Duration: {self.total_frames/self.fps:.1f}s") # Performance warning for known problematic cases @@ -888,21 +955,13 @@ class VideoEditor: self.current_display_frame = self.static_image.copy() return True else: - # Check cache first - cached_frame = self._get_frame_from_cache(self.current_frame) - if cached_frame is not None: - self.current_display_frame = cached_frame + # Use buffered cap to get frame + try: + self.current_display_frame = self.cap.get_frame(self.current_frame) return True - - # Not in cache, read from capture - self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.current_frame) - ret, frame = self.cap.read() - if ret: - self.current_display_frame = frame - # Add to cache - self._add_frame_to_cache(self.current_frame, frame) - return True - return False + except Exception as e: + print(f"Failed to load frame {self.current_frame}: {e}") + return False def calculate_frame_delay(self) -> int: @@ -979,14 +1038,12 @@ class VideoEditor: self.load_current_frame() def advance_frame(self) -> bool: - """Advance to next frame - optimized to avoid seeking, handles playback speed""" + """Advance to next frame - handles playback speed and marker looping""" if not self.is_playing: return True # Calculate how many frames to advance based on speed - # For speeds > 1.0, we skip frames. For speeds < 1.0, we delay in main loop frames_to_advance = max(1, int(self.playback_speed)) - new_frame = self.current_frame + frames_to_advance # Handle marker looping bounds @@ -994,54 +1051,13 @@ class VideoEditor: if new_frame >= self.cut_end_frame: # Loop back to start marker new_frame = self.cut_start_frame - self.current_frame = new_frame - self.load_current_frame() - return True elif new_frame >= self.total_frames: - new_frame = 0 # Loop - this will require a seek - self.current_frame = new_frame - self.load_current_frame() - return True + # Loop to beginning + new_frame = 0 - # For sequential playback at normal speed, just read the next frame without seeking - if frames_to_advance == 1: - ret, frame = self.cap.read() - if ret: - self.current_frame = new_frame - self.current_display_frame = frame - return True - - else: - # If sequential read failed, we've hit the actual end of video - # Update total_frames to the actual count and loop - print(f"Reached actual end of video at frame {self.current_frame} (reported: {self.total_frames})") - self.total_frames = self.current_frame - self.current_frame = 0 # Loop back to start - self.load_current_frame() - return True - else: - # For speed > 1.0, we need to seek to skip frames - self.current_frame = new_frame - success = self.load_current_frame() - if not success: - # Hit actual end of video - print(f"Reached actual end of video at frame {self.current_frame} (reported: {self.total_frames})") - self.total_frames = self.current_frame - if self.looping_between_markers and self.cut_start_frame is not None: - self.current_frame = self.cut_start_frame # Loop back to start marker - else: - self.current_frame = 0 # Loop back to start - self.load_current_frame() - return True - - # Handle marker looping after successful frame load - if self.looping_between_markers and self.cut_start_frame is not None and self.cut_end_frame is not None: - if self.current_frame >= self.cut_end_frame: - self.current_frame = self.cut_start_frame - self.load_current_frame() - return True - - return success + # Update current frame and load it + self.current_frame = new_frame + return self.load_current_frame() def apply_crop_zoom_and_rotation(self, frame): """Apply current crop, zoom, rotation, and brightness/contrast settings to frame""" @@ -1119,35 +1135,6 @@ class VideoEditor: self.cached_frame_number = None self.cached_transform_hash = None - def _manage_frame_cache(self): - """Manage frame cache size using LRU eviction""" - while len(self.frame_cache) > self.MAX_CACHE_FRAMES: - # Remove least recently used frame - oldest_frame = self.cache_access_order.pop(0) - if oldest_frame in self.frame_cache: - del self.frame_cache[oldest_frame] - - def _add_frame_to_cache(self, frame_number: int, frame_data): - """Add frame to cache with LRU management""" - self.frame_cache[frame_number] = frame_data.copy() - - # Update access order - if frame_number in self.cache_access_order: - self.cache_access_order.remove(frame_number) - self.cache_access_order.append(frame_number) - - # Manage cache size - self._manage_frame_cache() - - def _get_frame_from_cache(self, frame_number: int): - """Get frame from cache and update access order""" - if frame_number in self.frame_cache: - # Update access order for LRU - if frame_number in self.cache_access_order: - self.cache_access_order.remove(frame_number) - self.cache_access_order.append(frame_number) - return self.frame_cache[frame_number].copy() - return None def apply_rotation(self, frame): """Apply rotation to frame"""