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:
219
croppa/main.py
219
croppa/main.py
@@ -13,6 +13,89 @@ import queue
|
|||||||
import subprocess
|
import subprocess
|
||||||
import ctypes
|
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():
|
def get_active_window_title():
|
||||||
"""Get the title of the currently active window"""
|
"""Get the title of the currently active window"""
|
||||||
try:
|
try:
|
||||||
@@ -391,9 +474,6 @@ class VideoEditor:
|
|||||||
# Auto-repeat seeking configuration
|
# Auto-repeat seeking configuration
|
||||||
AUTO_REPEAT_DISPLAY_RATE = 1.0
|
AUTO_REPEAT_DISPLAY_RATE = 1.0
|
||||||
|
|
||||||
# Frame cache configuration
|
|
||||||
MAX_CACHE_FRAMES = 3000
|
|
||||||
|
|
||||||
# Timeline configuration
|
# Timeline configuration
|
||||||
TIMELINE_HEIGHT = 60
|
TIMELINE_HEIGHT = 60
|
||||||
TIMELINE_MARGIN = 20
|
TIMELINE_MARGIN = 20
|
||||||
@@ -521,10 +601,6 @@ class VideoEditor:
|
|||||||
self.cached_frame_number = None
|
self.cached_frame_number = None
|
||||||
self.cached_transform_hash = 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
|
# Project view mode
|
||||||
self.project_view_mode = False
|
self.project_view_mode = False
|
||||||
self.project_view = None
|
self.project_view = None
|
||||||
@@ -777,9 +853,6 @@ class VideoEditor:
|
|||||||
if hasattr(self, "cap") and self.cap:
|
if hasattr(self, "cap") and self.cap:
|
||||||
self.cap.release()
|
self.cap.release()
|
||||||
|
|
||||||
# Clear frame cache when switching videos
|
|
||||||
self.frame_cache.clear()
|
|
||||||
self.cache_access_order.clear()
|
|
||||||
|
|
||||||
self.video_path = media_path
|
self.video_path = media_path
|
||||||
self.is_image_mode = self._is_image_file(media_path)
|
self.is_image_mode = self._is_image_file(media_path)
|
||||||
@@ -811,36 +884,30 @@ class VideoEditor:
|
|||||||
self.cap = None
|
self.cap = None
|
||||||
for backend in backends_to_try:
|
for backend in backends_to_try:
|
||||||
try:
|
try:
|
||||||
self.cap = cv2.VideoCapture(str(self.video_path), backend)
|
self.cap = Cv2BufferedCap(self.video_path, backend)
|
||||||
if self.cap.isOpened():
|
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
|
break
|
||||||
self.cap.release()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not self.cap or not self.cap.isOpened():
|
if not self.cap or not self.cap.isOpened():
|
||||||
raise ValueError(f"Could not open video file: {media_path}")
|
raise ValueError(f"Could not open video file: {media_path}")
|
||||||
|
|
||||||
# Video properties
|
# Video properties from buffered cap
|
||||||
self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
self.total_frames = self.cap.total_frames
|
||||||
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
|
self.fps = self.cap.fps
|
||||||
self.frame_width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
self.frame_width = self.cap.frame_width
|
||||||
self.frame_height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
self.frame_height = self.cap.frame_height
|
||||||
|
|
||||||
# Get codec information for debugging
|
# 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)])
|
codec = "".join([chr((fourcc >> 8 * i) & 0xFF) for i in range(4)])
|
||||||
|
|
||||||
# Get backend information
|
# 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"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")
|
print(f" FPS: {self.fps:.2f} | Frames: {self.total_frames} | Duration: {self.total_frames/self.fps:.1f}s")
|
||||||
|
|
||||||
# Performance warning for known problematic cases
|
# Performance warning for known problematic cases
|
||||||
@@ -888,20 +955,12 @@ class VideoEditor:
|
|||||||
self.current_display_frame = self.static_image.copy()
|
self.current_display_frame = self.static_image.copy()
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
# Check cache first
|
# Use buffered cap to get frame
|
||||||
cached_frame = self._get_frame_from_cache(self.current_frame)
|
try:
|
||||||
if cached_frame is not None:
|
self.current_display_frame = self.cap.get_frame(self.current_frame)
|
||||||
self.current_display_frame = cached_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 True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to load frame {self.current_frame}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -979,14 +1038,12 @@ class VideoEditor:
|
|||||||
self.load_current_frame()
|
self.load_current_frame()
|
||||||
|
|
||||||
def advance_frame(self) -> bool:
|
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:
|
if not self.is_playing:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Calculate how many frames to advance based on speed
|
# 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))
|
frames_to_advance = max(1, int(self.playback_speed))
|
||||||
|
|
||||||
new_frame = self.current_frame + frames_to_advance
|
new_frame = self.current_frame + frames_to_advance
|
||||||
|
|
||||||
# Handle marker looping bounds
|
# Handle marker looping bounds
|
||||||
@@ -994,54 +1051,13 @@ class VideoEditor:
|
|||||||
if new_frame >= self.cut_end_frame:
|
if new_frame >= self.cut_end_frame:
|
||||||
# Loop back to start marker
|
# Loop back to start marker
|
||||||
new_frame = self.cut_start_frame
|
new_frame = self.cut_start_frame
|
||||||
self.current_frame = new_frame
|
|
||||||
self.load_current_frame()
|
|
||||||
return True
|
|
||||||
elif new_frame >= self.total_frames:
|
elif new_frame >= self.total_frames:
|
||||||
new_frame = 0 # Loop - this will require a seek
|
# Loop to beginning
|
||||||
|
new_frame = 0
|
||||||
|
|
||||||
|
# Update current frame and load it
|
||||||
self.current_frame = new_frame
|
self.current_frame = new_frame
|
||||||
self.load_current_frame()
|
return self.load_current_frame()
|
||||||
return True
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
def apply_crop_zoom_and_rotation(self, frame):
|
def apply_crop_zoom_and_rotation(self, frame):
|
||||||
"""Apply current crop, zoom, rotation, and brightness/contrast settings to 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_frame_number = None
|
||||||
self.cached_transform_hash = 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):
|
def apply_rotation(self, frame):
|
||||||
"""Apply rotation to frame"""
|
"""Apply rotation to frame"""
|
||||||
|
Reference in New Issue
Block a user