Compare commits
2 Commits
f0d540be27
...
5baa2572ea
Author | SHA1 | Date | |
---|---|---|---|
5baa2572ea | |||
c7c092d3f3 |
190
croppa/main.py
190
croppa/main.py
@@ -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
|
||||
|
Reference in New Issue
Block a user