feat(main.py): add threaded video rendering with progress updates and cancellation support
This commit is contained in:
191
croppa/main.py
191
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
|
||||
@@ -140,6 +142,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"""
|
||||
if not hasattr(self, 'video_path') or not self.video_path:
|
||||
@@ -1487,43 +1494,39 @@ 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_image(self, output_path: str):
|
||||
"""Save image with current edits applied"""
|
||||
# Get the appropriate file extension
|
||||
original_ext = self.video_path.suffix.lower()
|
||||
if not output_path.endswith(original_ext):
|
||||
output_path += original_ext
|
||||
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
|
||||
|
||||
print(f"Saving image to {output_path}...")
|
||||
# Reset render state
|
||||
self.render_cancelled = False
|
||||
|
||||
# Apply all transformations to the image
|
||||
processed_image = self.apply_crop_zoom_and_rotation(self.static_image.copy())
|
||||
# Start render thread
|
||||
self.render_thread = threading.Thread(
|
||||
target=self._render_video_worker,
|
||||
args=(output_path,),
|
||||
daemon=True
|
||||
)
|
||||
self.render_thread.start()
|
||||
|
||||
if processed_image is not None:
|
||||
# Save the image
|
||||
success = cv2.imwrite(output_path, processed_image)
|
||||
if success:
|
||||
print(f"Image saved successfully to {output_path}")
|
||||
print(f"Started rendering to {output_path} in background thread...")
|
||||
return True
|
||||
else:
|
||||
print(f"Error: Could not save image to {output_path}")
|
||||
return False
|
||||
else:
|
||||
print("Error: Could not process image")
|
||||
return False
|
||||
|
||||
def _render_video(self, output_path: str):
|
||||
"""Optimized video rendering with multithreading and batch processing"""
|
||||
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"
|
||||
|
||||
print(f"Rendering video to {output_path}...")
|
||||
start_time = time.time()
|
||||
|
||||
# Show progress bar
|
||||
self.show_progress_bar("Initializing render...")
|
||||
# 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
|
||||
@@ -1534,12 +1537,11 @@ class VideoEditor:
|
||||
)
|
||||
|
||||
if start_frame >= end_frame:
|
||||
print("Invalid cut range!")
|
||||
self.update_progress_bar(1.0, "Error: Invalid cut range!")
|
||||
self.render_progress_queue.put(("error", "Invalid cut range!", 1.0, 0.0))
|
||||
return False
|
||||
|
||||
# Update progress for initialization
|
||||
self.update_progress_bar(0.05, "Calculating output dimensions...")
|
||||
# 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:
|
||||
@@ -1557,8 +1559,8 @@ class VideoEditor:
|
||||
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...")
|
||||
# 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")
|
||||
@@ -1567,16 +1569,20 @@ class VideoEditor:
|
||||
)
|
||||
|
||||
if not out.isOpened():
|
||||
print("Error: Could not open video writer!")
|
||||
self.update_progress_bar(1.0, "Error: Could not open video writer!")
|
||||
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
|
||||
last_display_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()
|
||||
@@ -1607,23 +1613,9 @@ class VideoEditor:
|
||||
)
|
||||
|
||||
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="",
|
||||
)
|
||||
self.render_progress_queue.put(("progress", progress_text, progress, fps_rate))
|
||||
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
|
||||
@@ -1635,18 +1627,89 @@ class VideoEditor:
|
||||
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,
|
||||
)
|
||||
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)"
|
||||
)
|
||||
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"""
|
||||
# Get the appropriate file extension
|
||||
original_ext = self.video_path.suffix.lower()
|
||||
if not output_path.endswith(original_ext):
|
||||
output_path += original_ext
|
||||
|
||||
print(f"Saving image to {output_path}...")
|
||||
|
||||
# Apply all transformations to the image
|
||||
processed_image = self.apply_crop_zoom_and_rotation(self.static_image.copy())
|
||||
|
||||
if processed_image is not None:
|
||||
# Save the image
|
||||
success = cv2.imwrite(output_path, processed_image)
|
||||
if success:
|
||||
print(f"Image saved successfully to {output_path}")
|
||||
return True
|
||||
else:
|
||||
print(f"Error: Could not save image to {output_path}")
|
||||
return False
|
||||
else:
|
||||
print("Error: Could not process image")
|
||||
return False
|
||||
|
||||
|
||||
def _process_frame_for_render(self, frame, output_width: int, output_height: int):
|
||||
"""Process a single frame for rendering (optimized for speed)"""
|
||||
try:
|
||||
@@ -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