feat(main.py): add threaded video rendering with progress updates and cancellation support

This commit is contained in:
2025-09-07 23:54:57 +02:00
parent a815679a38
commit b7e4fac9e7

View File

@@ -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()