Compare commits

..

2 Commits

Author SHA1 Message Date
5baa2572ea 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.
2025-09-16 13:35:55 +02:00
c7c092d3f3 Implement frame caching in VideoEditor: add methods for managing frame cache with LRU eviction, improving playback performance by reducing frame retrieval time. Clear cache when switching videos to optimize memory usage. 2025-09-16 13:29:09 +02:00

View File

@@ -12,7 +12,89 @@ import threading
import queue
import subprocess
import ctypes
from ctypes import wintypes
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"""
@@ -39,7 +121,6 @@ class ProjectView:
THUMBNAIL_MARGIN = 20
PROGRESS_BAR_HEIGHT = 8
TEXT_HEIGHT = 30
ITEM_HEIGHT = THUMBNAIL_SIZE[1] + PROGRESS_BAR_HEIGHT + TEXT_HEIGHT + THUMBNAIL_MARGIN
# Colors
BG_COLOR = (40, 40, 40)
@@ -772,6 +853,7 @@ class VideoEditor:
if hasattr(self, "cap") and self.cap:
self.cap.release()
self.video_path = media_path
self.is_image_mode = self._is_image_file(media_path)
@@ -802,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
@@ -879,13 +955,13 @@ class VideoEditor:
self.current_display_frame = self.static_image.copy()
return True
else:
# For videos, use OpenCV for reliable seeking
self.cap.set(cv2.CAP_PROP_POS_FRAMES, self.current_frame)
ret, frame = self.cap.read()
if ret:
self.current_display_frame = frame
# Use buffered cap to get frame
try:
self.current_display_frame = self.cap.get_frame(self.current_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:
@@ -962,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
@@ -977,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"""
@@ -1102,6 +1135,7 @@ class VideoEditor:
self.cached_frame_number = None
self.cached_transform_hash = None
def apply_rotation(self, frame):
"""Apply rotation to frame"""
if self.rotation_angle == 0:
@@ -2537,8 +2571,16 @@ class VideoEditor:
project_canvas = self.project_view.draw()
cv2.imshow("Project View", project_canvas)
# Key capture with NO DELAY - keys should be instant
key = cv2.waitKey(1) & 0xFF
# Calculate appropriate delay based on playback state
if self.is_playing and not self.is_image_mode:
# Use calculated frame delay for proper playback speed
delay_ms = self.calculate_frame_delay()
else:
# Use minimal delay when not playing for responsive UI
delay_ms = 1
# Key capture with appropriate delay
key = cv2.waitKey(delay_ms) & 0xFF
# Route keys based on window focus
if key != 255: # Key was pressed