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:
2025-09-16 13:35:55 +02:00
parent c7c092d3f3
commit 5baa2572ea

View File

@@ -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"""