From b7e4fac9e75e7aebb0a0764042141a091e95f0d8 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Sun, 7 Sep 2025 23:54:57 +0200 Subject: [PATCH] feat(main.py): add threaded video rendering with progress updates and cancellation support --- croppa/main.py | 341 ++++++++++++++++++++++++++++++------------------- 1 file changed, 208 insertions(+), 133 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index e2e4a8c..74b70a5 100644 --- a/croppa/main.py +++ b/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()