feat(main.py): add threaded video rendering with progress updates and cancellation support
This commit is contained in:
341
croppa/main.py
341
croppa/main.py
@@ -8,6 +8,8 @@ from typing import List
|
||||
import time
|
||||
import re
|
||||
import json
|
||||
import threading
|
||||
import queue
|
||||
|
||||
class VideoEditor:
|
||||
# Configuration constants
|
||||
@@ -139,6 +141,11 @@ class VideoEditor:
|
||||
|
||||
# Crop adjustment settings
|
||||
self.crop_size_step = self.CROP_SIZE_STEP
|
||||
|
||||
# Render thread management
|
||||
self.render_thread = None
|
||||
self.render_cancelled = False
|
||||
self.render_progress_queue = queue.Queue()
|
||||
|
||||
def _get_state_file_path(self) -> Path:
|
||||
"""Get the state file path for the current media file"""
|
||||
@@ -1487,7 +1494,195 @@ class VideoEditor:
|
||||
if self.is_image_mode:
|
||||
return self._render_image(output_path)
|
||||
else:
|
||||
return self._render_video(output_path)
|
||||
return self._render_video_threaded(output_path)
|
||||
|
||||
def _render_video_threaded(self, output_path: str):
|
||||
"""Start video rendering in a separate thread"""
|
||||
# Check if already rendering
|
||||
if self.render_thread and self.render_thread.is_alive():
|
||||
print("Render already in progress!")
|
||||
return False
|
||||
|
||||
# Reset render state
|
||||
self.render_cancelled = False
|
||||
|
||||
# Start render thread
|
||||
self.render_thread = threading.Thread(
|
||||
target=self._render_video_worker,
|
||||
args=(output_path,),
|
||||
daemon=True
|
||||
)
|
||||
self.render_thread.start()
|
||||
|
||||
print(f"Started rendering to {output_path} in background thread...")
|
||||
return True
|
||||
|
||||
def _render_video_worker(self, output_path: str):
|
||||
"""Worker method that runs in the render thread"""
|
||||
try:
|
||||
if not output_path.endswith(".mp4"):
|
||||
output_path += ".mp4"
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Send progress update to main thread
|
||||
self.render_progress_queue.put(("init", "Initializing render...", 0.0, 0.0))
|
||||
|
||||
# Determine frame range
|
||||
start_frame = self.cut_start_frame if self.cut_start_frame is not None else 0
|
||||
end_frame = (
|
||||
self.cut_end_frame
|
||||
if self.cut_end_frame is not None
|
||||
else self.total_frames - 1
|
||||
)
|
||||
|
||||
if start_frame >= end_frame:
|
||||
self.render_progress_queue.put(("error", "Invalid cut range!", 1.0, 0.0))
|
||||
return False
|
||||
|
||||
# Send progress update
|
||||
self.render_progress_queue.put(("progress", "Calculating output dimensions...", 0.05, 0.0))
|
||||
|
||||
# Calculate output dimensions (accounting for rotation)
|
||||
if self.crop_rect:
|
||||
crop_width = int(self.crop_rect[2])
|
||||
crop_height = int(self.crop_rect[3])
|
||||
else:
|
||||
crop_width = self.frame_width
|
||||
crop_height = self.frame_height
|
||||
|
||||
# Swap dimensions if rotation is 90 or 270 degrees
|
||||
if self.rotation_angle == 90 or self.rotation_angle == 270:
|
||||
output_width = int(crop_height * self.zoom_factor)
|
||||
output_height = int(crop_width * self.zoom_factor)
|
||||
else:
|
||||
output_width = int(crop_width * self.zoom_factor)
|
||||
output_height = int(crop_height * self.zoom_factor)
|
||||
|
||||
# Send progress update
|
||||
self.render_progress_queue.put(("progress", "Setting up video writer...", 0.1, 0.0))
|
||||
|
||||
# Use mp4v codec (most compatible with MP4)
|
||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||
out = cv2.VideoWriter(
|
||||
output_path, fourcc, self.fps, (output_width, output_height)
|
||||
)
|
||||
|
||||
if not out.isOpened():
|
||||
self.render_progress_queue.put(("error", "Could not open video writer!", 1.0, 0.0))
|
||||
return False
|
||||
|
||||
# Simple sequential processing - the I/O is the bottleneck anyway
|
||||
total_output_frames = end_frame - start_frame + 1
|
||||
last_progress_update = 0
|
||||
|
||||
for frame_idx in range(start_frame, end_frame + 1):
|
||||
# Check for cancellation
|
||||
if self.render_cancelled:
|
||||
out.release()
|
||||
self.render_progress_queue.put(("cancelled", "Render cancelled", 0.0, 0.0))
|
||||
return False
|
||||
|
||||
# Read frame
|
||||
self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
|
||||
ret, frame = self.cap.read()
|
||||
|
||||
if not ret:
|
||||
break
|
||||
|
||||
# Process and write frame directly (minimize memory copies)
|
||||
processed_frame = self._process_frame_for_render(
|
||||
frame, output_width, output_height
|
||||
)
|
||||
|
||||
if processed_frame is not None:
|
||||
out.write(processed_frame)
|
||||
|
||||
frames_written = frame_idx - start_frame + 1
|
||||
current_time = time.time()
|
||||
|
||||
# Update progress bar (10% to 95% of progress reserved for frame processing)
|
||||
progress = 0.1 + (0.85 * (frames_written / total_output_frames))
|
||||
|
||||
# Throttled progress update
|
||||
if current_time - last_progress_update > 0.5:
|
||||
elapsed = current_time - start_time
|
||||
fps_rate = frames_written / elapsed
|
||||
eta = (elapsed / frames_written) * (
|
||||
total_output_frames - frames_written
|
||||
)
|
||||
|
||||
progress_text = f"Rendering {frames_written}/{total_output_frames} frames (ETA: {eta:.1f}s)"
|
||||
self.render_progress_queue.put(("progress", progress_text, progress, fps_rate))
|
||||
last_progress_update = current_time
|
||||
|
||||
out.release()
|
||||
|
||||
# Ensure the video writer is completely closed and file handles are freed
|
||||
del out
|
||||
time.sleep(0.1) # Small delay to ensure file is unlocked
|
||||
|
||||
total_time = time.time() - start_time
|
||||
total_frames_written = end_frame - start_frame + 1
|
||||
avg_fps = total_frames_written / total_time if total_time > 0 else 0
|
||||
|
||||
# Complete the progress bar
|
||||
self.render_progress_queue.put(("complete", f"Complete! Rendered {total_frames_written} frames in {total_time:.1f}s", 1.0, avg_fps))
|
||||
|
||||
print(f"\nVideo rendered successfully to {output_path}")
|
||||
print(f"Rendered {total_frames_written} frames in {total_time:.2f}s (avg {avg_fps:.1f} FPS)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.render_progress_queue.put(("error", f"Render error: {str(e)}", 1.0, 0.0))
|
||||
print(f"Render error: {e}")
|
||||
return False
|
||||
|
||||
def update_render_progress(self):
|
||||
"""Process progress updates from the render thread"""
|
||||
try:
|
||||
while True:
|
||||
# Non-blocking get from queue
|
||||
update_type, text, progress, fps = self.render_progress_queue.get_nowait()
|
||||
|
||||
if update_type == "init":
|
||||
self.show_progress_bar(text)
|
||||
elif update_type == "progress":
|
||||
self.update_progress_bar(progress, text, fps)
|
||||
elif update_type == "complete":
|
||||
self.update_progress_bar(progress, text, fps)
|
||||
elif update_type == "error":
|
||||
self.update_progress_bar(progress, text, fps)
|
||||
elif update_type == "cancelled":
|
||||
self.hide_progress_bar()
|
||||
self.show_feedback_message("Render cancelled")
|
||||
|
||||
except queue.Empty:
|
||||
# No more updates in queue
|
||||
pass
|
||||
|
||||
def cancel_render(self):
|
||||
"""Cancel the current render operation"""
|
||||
if self.render_thread and self.render_thread.is_alive():
|
||||
self.render_cancelled = True
|
||||
print("Render cancellation requested...")
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_rendering(self):
|
||||
"""Check if a render operation is currently active"""
|
||||
return self.render_thread and self.render_thread.is_alive()
|
||||
|
||||
def cleanup_render_thread(self):
|
||||
"""Clean up render thread resources"""
|
||||
if self.render_thread and self.render_thread.is_alive():
|
||||
self.render_cancelled = True
|
||||
# Wait a bit for the thread to finish gracefully
|
||||
self.render_thread.join(timeout=2.0)
|
||||
if self.render_thread.is_alive():
|
||||
print("Warning: Render thread did not finish gracefully")
|
||||
self.render_thread = None
|
||||
self.render_cancelled = False
|
||||
|
||||
def _render_image(self, output_path: str):
|
||||
"""Save image with current edits applied"""
|
||||
@@ -1514,138 +1709,6 @@ class VideoEditor:
|
||||
print("Error: Could not process image")
|
||||
return False
|
||||
|
||||
def _render_video(self, output_path: str):
|
||||
"""Optimized video rendering with multithreading and batch processing"""
|
||||
if not output_path.endswith(".mp4"):
|
||||
output_path += ".mp4"
|
||||
|
||||
print(f"Rendering video to {output_path}...")
|
||||
start_time = time.time()
|
||||
|
||||
# Show progress bar
|
||||
self.show_progress_bar("Initializing render...")
|
||||
|
||||
# Determine frame range
|
||||
start_frame = self.cut_start_frame if self.cut_start_frame is not None else 0
|
||||
end_frame = (
|
||||
self.cut_end_frame
|
||||
if self.cut_end_frame is not None
|
||||
else self.total_frames - 1
|
||||
)
|
||||
|
||||
if start_frame >= end_frame:
|
||||
print("Invalid cut range!")
|
||||
self.update_progress_bar(1.0, "Error: Invalid cut range!")
|
||||
return False
|
||||
|
||||
# Update progress for initialization
|
||||
self.update_progress_bar(0.05, "Calculating output dimensions...")
|
||||
|
||||
# Calculate output dimensions (accounting for rotation)
|
||||
if self.crop_rect:
|
||||
crop_width = int(self.crop_rect[2])
|
||||
crop_height = int(self.crop_rect[3])
|
||||
else:
|
||||
crop_width = self.frame_width
|
||||
crop_height = self.frame_height
|
||||
|
||||
# Swap dimensions if rotation is 90 or 270 degrees
|
||||
if self.rotation_angle == 90 or self.rotation_angle == 270:
|
||||
output_width = int(crop_height * self.zoom_factor)
|
||||
output_height = int(crop_width * self.zoom_factor)
|
||||
else:
|
||||
output_width = int(crop_width * self.zoom_factor)
|
||||
output_height = int(crop_height * self.zoom_factor)
|
||||
|
||||
# Update progress for video writer setup
|
||||
self.update_progress_bar(0.1, "Setting up video writer...")
|
||||
|
||||
# Use mp4v codec (most compatible with MP4)
|
||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||
out = cv2.VideoWriter(
|
||||
output_path, fourcc, self.fps, (output_width, output_height)
|
||||
)
|
||||
|
||||
if not out.isOpened():
|
||||
print("Error: Could not open video writer!")
|
||||
self.update_progress_bar(1.0, "Error: Could not open video writer!")
|
||||
return False
|
||||
|
||||
# Simple sequential processing - the I/O is the bottleneck anyway
|
||||
total_output_frames = end_frame - start_frame + 1
|
||||
last_progress_update = 0
|
||||
last_display_update = 0
|
||||
|
||||
for frame_idx in range(start_frame, end_frame + 1):
|
||||
# Read frame
|
||||
self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
|
||||
ret, frame = self.cap.read()
|
||||
|
||||
if not ret:
|
||||
break
|
||||
|
||||
# Process and write frame directly (minimize memory copies)
|
||||
processed_frame = self._process_frame_for_render(
|
||||
frame, output_width, output_height
|
||||
)
|
||||
|
||||
if processed_frame is not None:
|
||||
out.write(processed_frame)
|
||||
|
||||
frames_written = frame_idx - start_frame + 1
|
||||
current_time = time.time()
|
||||
|
||||
# Update progress bar (10% to 95% of progress reserved for frame processing)
|
||||
progress = 0.1 + (0.85 * (frames_written / total_output_frames))
|
||||
|
||||
# Throttled progress update
|
||||
if current_time - last_progress_update > 0.5:
|
||||
elapsed = current_time - start_time
|
||||
fps_rate = frames_written / elapsed
|
||||
eta = (elapsed / frames_written) * (
|
||||
total_output_frames - frames_written
|
||||
)
|
||||
|
||||
progress_text = f"Rendering {frames_written}/{total_output_frames} frames (ETA: {eta:.1f}s)"
|
||||
self.update_progress_bar(progress, progress_text, fps_rate)
|
||||
|
||||
print(
|
||||
f"Progress: {progress*100:.1f}% | {frames_written}/{total_output_frames} | "
|
||||
f"FPS: {fps_rate:.1f} | ETA: {eta:.1f}s\r",
|
||||
end="",
|
||||
)
|
||||
last_progress_update = current_time
|
||||
|
||||
# Update display more frequently to show progress bar
|
||||
if (
|
||||
current_time - last_display_update > 0.1
|
||||
): # Update display every 100ms
|
||||
self.display_current_frame()
|
||||
cv2.waitKey(1) # Allow OpenCV to process events
|
||||
last_display_update = current_time
|
||||
|
||||
out.release()
|
||||
|
||||
# Ensure the video writer is completely closed and file handles are freed
|
||||
del out
|
||||
time.sleep(0.1) # Small delay to ensure file is unlocked
|
||||
|
||||
total_time = time.time() - start_time
|
||||
total_frames_written = end_frame - start_frame + 1
|
||||
avg_fps = total_frames_written / total_time if total_time > 0 else 0
|
||||
|
||||
# Complete the progress bar
|
||||
self.update_progress_bar(
|
||||
1.0,
|
||||
f"Complete! Rendered {total_frames_written} frames in {total_time:.1f}s",
|
||||
avg_fps,
|
||||
)
|
||||
|
||||
print(f"\nVideo rendered successfully to {output_path}")
|
||||
print(
|
||||
f"Rendered {total_frames_written} frames in {total_time:.2f}s (avg {avg_fps:.1f} FPS)"
|
||||
)
|
||||
return True
|
||||
|
||||
def _process_frame_for_render(self, frame, output_width: int, output_height: int):
|
||||
"""Process a single frame for rendering (optimized for speed)"""
|
||||
@@ -1761,6 +1824,7 @@ class VideoEditor:
|
||||
print(" N: Next video")
|
||||
print(" n: Previous video")
|
||||
print(" Enter: Render video")
|
||||
print(" X: Cancel render")
|
||||
print(" Q/ESC: Quit")
|
||||
print()
|
||||
|
||||
@@ -1775,6 +1839,9 @@ class VideoEditor:
|
||||
# Update auto-repeat seeking if active
|
||||
self.update_auto_repeat_seek()
|
||||
|
||||
# Update render progress from background thread
|
||||
self.update_render_progress()
|
||||
|
||||
# Only update display if needed and throttled
|
||||
if self.should_update_display():
|
||||
self.display_current_frame()
|
||||
@@ -1945,6 +2012,13 @@ class VideoEditor:
|
||||
# Marker looping only for videos
|
||||
if not self.is_image_mode:
|
||||
self.toggle_marker_looping()
|
||||
elif key == ord("x"):
|
||||
# Cancel render if active
|
||||
if self.is_rendering():
|
||||
self.cancel_render()
|
||||
print("Render cancellation requested")
|
||||
else:
|
||||
print("No render operation to cancel")
|
||||
|
||||
# Individual direction controls using shift combinations we can detect
|
||||
elif key == ord("J"): # Shift+i - expand up
|
||||
@@ -1980,6 +2054,7 @@ class VideoEditor:
|
||||
self.advance_frame()
|
||||
|
||||
self.save_state()
|
||||
self.cleanup_render_thread()
|
||||
if hasattr(self, 'cap') and self.cap:
|
||||
self.cap.release()
|
||||
cv2.destroyAllWindows()
|
||||
|
Reference in New Issue
Block a user