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 time
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
|
import queue
|
||||||
|
|
||||||
class VideoEditor:
|
class VideoEditor:
|
||||||
# Configuration constants
|
# Configuration constants
|
||||||
@@ -139,6 +141,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"""
|
||||||
@@ -1487,7 +1494,195 @@ 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_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):
|
def _render_image(self, output_path: str):
|
||||||
"""Save image with current edits applied"""
|
"""Save image with current edits applied"""
|
||||||
@@ -1514,138 +1709,6 @@ class VideoEditor:
|
|||||||
print("Error: Could not process image")
|
print("Error: Could not process image")
|
||||||
return False
|
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):
|
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)"""
|
||||||
@@ -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