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 time
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
|
import queue
|
||||||
|
|
||||||
class VideoEditor:
|
class VideoEditor:
|
||||||
# Configuration constants
|
# Configuration constants
|
||||||
@@ -140,6 +142,11 @@ class VideoEditor:
|
|||||||
# Crop adjustment settings
|
# Crop adjustment settings
|
||||||
self.crop_size_step = self.CROP_SIZE_STEP
|
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:
|
def _get_state_file_path(self) -> Path:
|
||||||
"""Get the state file path for the current media file"""
|
"""Get the state file path for the current media file"""
|
||||||
if not hasattr(self, 'video_path') or not self.video_path:
|
if not hasattr(self, 'video_path') or not self.video_path:
|
||||||
@@ -1487,43 +1494,39 @@ class VideoEditor:
|
|||||||
if self.is_image_mode:
|
if self.is_image_mode:
|
||||||
return self._render_image(output_path)
|
return self._render_image(output_path)
|
||||||
else:
|
else:
|
||||||
return self._render_video(output_path)
|
return self._render_video_threaded(output_path)
|
||||||
|
|
||||||
def _render_image(self, output_path: str):
|
def _render_video_threaded(self, output_path: str):
|
||||||
"""Save image with current edits applied"""
|
"""Start video rendering in a separate thread"""
|
||||||
# Get the appropriate file extension
|
# Check if already rendering
|
||||||
original_ext = self.video_path.suffix.lower()
|
if self.render_thread and self.render_thread.is_alive():
|
||||||
if not output_path.endswith(original_ext):
|
print("Render already in progress!")
|
||||||
output_path += original_ext
|
return False
|
||||||
|
|
||||||
print(f"Saving image to {output_path}...")
|
# Reset render state
|
||||||
|
self.render_cancelled = False
|
||||||
|
|
||||||
# Apply all transformations to the image
|
# Start render thread
|
||||||
processed_image = self.apply_crop_zoom_and_rotation(self.static_image.copy())
|
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:
|
print(f"Started rendering to {output_path} in background thread...")
|
||||||
# Save the image
|
|
||||||
success = cv2.imwrite(output_path, processed_image)
|
|
||||||
if success:
|
|
||||||
print(f"Image saved successfully to {output_path}")
|
|
||||||
return True
|
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):
|
def _render_video_worker(self, output_path: str):
|
||||||
"""Optimized video rendering with multithreading and batch processing"""
|
"""Worker method that runs in the render thread"""
|
||||||
|
try:
|
||||||
if not output_path.endswith(".mp4"):
|
if not output_path.endswith(".mp4"):
|
||||||
output_path += ".mp4"
|
output_path += ".mp4"
|
||||||
|
|
||||||
print(f"Rendering video to {output_path}...")
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# Show progress bar
|
# Send progress update to main thread
|
||||||
self.show_progress_bar("Initializing render...")
|
self.render_progress_queue.put(("init", "Initializing render...", 0.0, 0.0))
|
||||||
|
|
||||||
# Determine frame range
|
# Determine frame range
|
||||||
start_frame = self.cut_start_frame if self.cut_start_frame is not None else 0
|
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:
|
if start_frame >= end_frame:
|
||||||
print("Invalid cut range!")
|
self.render_progress_queue.put(("error", "Invalid cut range!", 1.0, 0.0))
|
||||||
self.update_progress_bar(1.0, "Error: Invalid cut range!")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Update progress for initialization
|
# Send progress update
|
||||||
self.update_progress_bar(0.05, "Calculating output dimensions...")
|
self.render_progress_queue.put(("progress", "Calculating output dimensions...", 0.05, 0.0))
|
||||||
|
|
||||||
# Calculate output dimensions (accounting for rotation)
|
# Calculate output dimensions (accounting for rotation)
|
||||||
if self.crop_rect:
|
if self.crop_rect:
|
||||||
@@ -1557,8 +1559,8 @@ class VideoEditor:
|
|||||||
output_width = int(crop_width * self.zoom_factor)
|
output_width = int(crop_width * self.zoom_factor)
|
||||||
output_height = int(crop_height * self.zoom_factor)
|
output_height = int(crop_height * self.zoom_factor)
|
||||||
|
|
||||||
# Update progress for video writer setup
|
# Send progress update
|
||||||
self.update_progress_bar(0.1, "Setting up video writer...")
|
self.render_progress_queue.put(("progress", "Setting up video writer...", 0.1, 0.0))
|
||||||
|
|
||||||
# Use mp4v codec (most compatible with MP4)
|
# Use mp4v codec (most compatible with MP4)
|
||||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||||
@@ -1567,16 +1569,20 @@ class VideoEditor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not out.isOpened():
|
if not out.isOpened():
|
||||||
print("Error: Could not open video writer!")
|
self.render_progress_queue.put(("error", "Could not open video writer!", 1.0, 0.0))
|
||||||
self.update_progress_bar(1.0, "Error: Could not open video writer!")
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Simple sequential processing - the I/O is the bottleneck anyway
|
# Simple sequential processing - the I/O is the bottleneck anyway
|
||||||
total_output_frames = end_frame - start_frame + 1
|
total_output_frames = end_frame - start_frame + 1
|
||||||
last_progress_update = 0
|
last_progress_update = 0
|
||||||
last_display_update = 0
|
|
||||||
|
|
||||||
for frame_idx in range(start_frame, end_frame + 1):
|
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
|
# Read frame
|
||||||
self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
|
self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
|
||||||
ret, frame = self.cap.read()
|
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)"
|
progress_text = f"Rendering {frames_written}/{total_output_frames} frames (ETA: {eta:.1f}s)"
|
||||||
self.update_progress_bar(progress, progress_text, fps_rate)
|
self.render_progress_queue.put(("progress", progress_text, progress, 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
|
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()
|
out.release()
|
||||||
|
|
||||||
# Ensure the video writer is completely closed and file handles are freed
|
# 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
|
avg_fps = total_frames_written / total_time if total_time > 0 else 0
|
||||||
|
|
||||||
# Complete the progress bar
|
# Complete the progress bar
|
||||||
self.update_progress_bar(
|
self.render_progress_queue.put(("complete", f"Complete! Rendered {total_frames_written} frames in {total_time:.1f}s", 1.0, avg_fps))
|
||||||
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"\nVideo rendered successfully to {output_path}")
|
||||||
print(
|
print(f"Rendered {total_frames_written} frames in {total_time:.2f}s (avg {avg_fps:.1f} FPS)")
|
||||||
f"Rendered {total_frames_written} frames in {total_time:.2f}s (avg {avg_fps:.1f} FPS)"
|
|
||||||
)
|
|
||||||
return True
|
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):
|
def _process_frame_for_render(self, frame, output_width: int, output_height: int):
|
||||||
"""Process a single frame for rendering (optimized for speed)"""
|
"""Process a single frame for rendering (optimized for speed)"""
|
||||||
try:
|
try:
|
||||||
@@ -1761,6 +1824,7 @@ class VideoEditor:
|
|||||||
print(" N: Next video")
|
print(" N: Next video")
|
||||||
print(" n: Previous video")
|
print(" n: Previous video")
|
||||||
print(" Enter: Render video")
|
print(" Enter: Render video")
|
||||||
|
print(" X: Cancel render")
|
||||||
print(" Q/ESC: Quit")
|
print(" Q/ESC: Quit")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@@ -1775,6 +1839,9 @@ class VideoEditor:
|
|||||||
# Update auto-repeat seeking if active
|
# Update auto-repeat seeking if active
|
||||||
self.update_auto_repeat_seek()
|
self.update_auto_repeat_seek()
|
||||||
|
|
||||||
|
# Update render progress from background thread
|
||||||
|
self.update_render_progress()
|
||||||
|
|
||||||
# Only update display if needed and throttled
|
# Only update display if needed and throttled
|
||||||
if self.should_update_display():
|
if self.should_update_display():
|
||||||
self.display_current_frame()
|
self.display_current_frame()
|
||||||
@@ -1945,6 +2012,13 @@ class VideoEditor:
|
|||||||
# Marker looping only for videos
|
# Marker looping only for videos
|
||||||
if not self.is_image_mode:
|
if not self.is_image_mode:
|
||||||
self.toggle_marker_looping()
|
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
|
# Individual direction controls using shift combinations we can detect
|
||||||
elif key == ord("J"): # Shift+i - expand up
|
elif key == ord("J"): # Shift+i - expand up
|
||||||
@@ -1980,6 +2054,7 @@ class VideoEditor:
|
|||||||
self.advance_frame()
|
self.advance_frame()
|
||||||
|
|
||||||
self.save_state()
|
self.save_state()
|
||||||
|
self.cleanup_render_thread()
|
||||||
if hasattr(self, 'cap') and self.cap:
|
if hasattr(self, 'cap') and self.cap:
|
||||||
self.cap.release()
|
self.cap.release()
|
||||||
cv2.destroyAllWindows()
|
cv2.destroyAllWindows()
|
||||||
|
Reference in New Issue
Block a user