Add Cv2BufferedCap class for efficient video frame handling in VideoEditor
This commit introduces the Cv2BufferedCap class, which provides a buffered wrapper around cv2.VideoCapture. It implements frame caching with LRU eviction to optimize frame retrieval and reduce latency during video playback. The VideoEditor class has been updated to utilize this new class, enhancing performance and simplifying frame management. Unused frame cache methods have been removed to streamline the codebase.
This commit is contained in:
221
croppa/main.py
221
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:
|
||||
@@ -391,9 +474,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
|
||||
TIMELINE_MARGIN = 20
|
||||
@@ -521,10 +601,6 @@ class VideoEditor:
|
||||
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
|
||||
self.project_view = None
|
||||
@@ -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"""
|
||||
|
Reference in New Issue
Block a user