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