diff --git a/croppa/editor.py b/croppa/editor.py index e7d9956..39d2e99 100644 --- a/croppa/editor.py +++ b/croppa/editor.py @@ -14,6 +14,13 @@ from croppa.capture import Cv2BufferedCap from croppa.tracking import MotionTracker from croppa.utils import get_active_window_title from croppa.project_view import ProjectView +from croppa.rendering import ( + start_render_thread, + pump_progress, + request_cancel, + is_rendering as rendering_is_rendering, + cleanup_thread, +) class VideoEditor: @@ -2313,145 +2320,13 @@ class VideoEditor: 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! Use 'X' to cancel first.") - 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...") - print("You can continue editing while rendering. Press 'X' to cancel.") - return True + return start_render_thread(self, output_path) def _render_video_worker(self, output_path: str): - """Worker method that runs in the render thread""" - render_cap = None - 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)) - - # No need to create VideoCapture since we use FFmpeg directly - - # 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) - - # Ensure dimensions are divisible by 2 for H.264 encoding - output_width = output_width - (output_width % 2) - output_height = output_height - (output_height % 2) - - # Send progress update - self.render_progress_queue.put( - ("progress", "Setting up FFmpeg encoder...", 0.1, 0.0) - ) - - # Debug output dimensions - print(f"Output dimensions: {output_width}x{output_height}") - print(f"Zoom factor: {self.zoom_factor}") - print(f"Crop dimensions: {crop_width}x{crop_height}") - - # Skip all the OpenCV codec bullshit and go straight to FFmpeg - print("Using FFmpeg for encoding with OpenCV transformations...") - return self._render_with_ffmpeg_pipe( - output_path, start_frame, end_frame, output_width, output_height - ) - - except Exception as e: - error_msg = str(e) - # Handle specific FFmpeg threading errors - if "async_lock" in error_msg or "pthread_frame" in error_msg: - error_msg = "FFmpeg threading error - try restarting the application" - elif "Assertion" in error_msg: - error_msg = "Video codec error - the video file may be corrupted or incompatible" - - self.render_progress_queue.put( - ("error", f"Render error: {error_msg}", 1.0, 0.0) - ) - print(f"Render error: {error_msg}") - return False - finally: - # No cleanup needed since we don't create VideoCapture - pass + raise NotImplementedError 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) - # Handle file overwrite if this was an overwrite operation - if ( - hasattr(self, "overwrite_temp_path") - and self.overwrite_temp_path - ): - self._handle_overwrite_completion() - elif update_type == "error": - self.update_progress_bar(progress, text, fps) - # Also show error as feedback message for better visibility - self.show_feedback_message(f"ERROR: {text}") - elif update_type == "cancelled": - self.hide_progress_bar() - self.show_feedback_message("Render cancelled") - - except queue.Empty: - # No more updates in queue - pass + pump_progress(self) def _handle_overwrite_completion(self): """Handle file replacement after successful render""" diff --git a/croppa/pyproject.toml b/croppa/pyproject.toml index dc8300e..bda7b82 100644 --- a/croppa/pyproject.toml +++ b/croppa/pyproject.toml @@ -9,5 +9,16 @@ dependencies = [ "numpy>=1.24.0" ] +[tool.setuptools] +py-modules = [ + "main", + "editor", + "capture", + "tracking", + "utils", + "project_view", + "rendering" +] + [project.scripts] -croppa = "croppa.main:main" +croppa = "main:main" diff --git a/croppa/rendering.py b/croppa/rendering.py new file mode 100644 index 0000000..5838467 --- /dev/null +++ b/croppa/rendering.py @@ -0,0 +1,303 @@ +import os +import time +import subprocess +import threading +import queue +import tempfile +from typing import Optional + +import cv2 + + +def start_render_thread(editor, output_path: str) -> bool: + if editor.render_thread and editor.render_thread.is_alive(): + print("Render already in progress! Use 'X' to cancel first.") + return False + editor.render_cancelled = False + editor.render_thread = threading.Thread(target=_render_worker, args=(editor, output_path), daemon=True) + editor.render_thread.start() + print(f"Started rendering to {output_path} in background thread...") + print("You can continue editing while rendering. Press 'X' to cancel.") + return True + + +def _render_worker(editor, output_path: str) -> bool: + try: + if not output_path.endswith(".mp4"): + output_path += ".mp4" + + start_frame = editor.cut_start_frame if editor.cut_start_frame is not None else 0 + end_frame = editor.cut_end_frame if editor.cut_end_frame is not None else editor.total_frames - 1 + if start_frame >= end_frame: + editor.render_progress_queue.put(("error", "Invalid cut range!", 1.0, 0.0)) + return False + + editor.render_progress_queue.put(("progress", "Calculating output dimensions...", 0.05, 0.0)) + + if editor.crop_rect: + crop_width = int(editor.crop_rect[2]) + crop_height = int(editor.crop_rect[3]) + else: + crop_width = editor.frame_width + crop_height = editor.frame_height + + if editor.rotation_angle in (90, 270): + output_width = int(crop_height * editor.zoom_factor) + output_height = int(crop_width * editor.zoom_factor) + else: + output_width = int(crop_width * editor.zoom_factor) + output_height = int(crop_height * editor.zoom_factor) + + output_width -= output_width % 2 + output_height -= output_height % 2 + + editor.render_progress_queue.put(("progress", "Setting up FFmpeg encoder...", 0.1, 0.0)) + print(f"Output dimensions: {output_width}x{output_height}") + print(f"Zoom factor: {editor.zoom_factor}") + print(f"Crop dimensions: {crop_width}x{crop_height}") + print("Using FFmpeg for encoding with OpenCV transformations...") + + return _render_with_ffmpeg_pipe(editor, output_path, start_frame, end_frame, output_width, output_height) + except Exception as e: + error_msg = str(e) + if "async_lock" in error_msg or "pthread_frame" in error_msg: + error_msg = "FFmpeg threading error - try restarting the application" + elif "Assertion" in error_msg: + error_msg = "Video codec error - the video file may be corrupted or incompatible" + editor.render_progress_queue.put(("error", f"Render error: {error_msg}", 1.0, 0.0)) + print(f"Render error: {error_msg}") + return False + + +def pump_progress(editor): + try: + while True: + update_type, text, progress, fps = editor.render_progress_queue.get_nowait() + if update_type == "init": + editor.show_progress_bar(text) + elif update_type == "progress": + editor.update_progress_bar(progress, text, fps) + elif update_type == "complete": + editor.update_progress_bar(progress, text, fps) + if hasattr(editor, "overwrite_temp_path") and editor.overwrite_temp_path: + _handle_overwrite_completion(editor) + elif update_type == "error": + editor.update_progress_bar(progress, text, fps) + editor.show_feedback_message(f"ERROR: {text}") + elif update_type == "cancelled": + editor.hide_progress_bar() + editor.show_feedback_message("Render cancelled") + except queue.Empty: + pass + + +def _handle_overwrite_completion(editor): + try: + print("Replacing original file...") + if hasattr(editor, "cap") and editor.cap: + editor.cap.release() + import shutil + print(f"DEBUG: Moving {editor.overwrite_temp_path} to {editor.overwrite_target_path}") + try: + shutil.move(editor.overwrite_temp_path, editor.overwrite_target_path) + print("DEBUG: File move successful") + except Exception as e: + print(f"DEBUG: File move failed: {e}") + if os.path.exists(editor.overwrite_temp_path): + os.remove(editor.overwrite_temp_path) + raise + time.sleep(0.1) + try: + editor._load_video(editor.video_path) + editor.load_current_frame() + print("File reloaded successfully") + except Exception as e: + print(f"Warning: Could not reload file after overwrite: {e}") + print("The file was saved successfully, but you may need to restart the editor to continue editing it.") + except Exception as e: + print(f"Error during file overwrite: {e}") + finally: + editor.overwrite_temp_path = None + editor.overwrite_target_path = None + + +def request_cancel(editor) -> bool: + if editor.render_thread and editor.render_thread.is_alive(): + editor.render_cancelled = True + print("Render cancellation requested...") + return True + return False + + +def is_rendering(editor) -> bool: + return editor.render_thread and editor.render_thread.is_alive() + + +def cleanup_thread(editor): + if editor.render_thread and editor.render_thread.is_alive(): + editor.render_cancelled = True + if editor.ffmpeg_process: + try: + editor.ffmpeg_process.terminate() + editor.ffmpeg_process.wait(timeout=1.0) + except Exception: + try: + editor.ffmpeg_process.kill() + except Exception: + pass + editor.ffmpeg_process = None + editor.render_thread.join(timeout=2.0) + if editor.render_thread.is_alive(): + print("Warning: Render thread did not finish gracefully") + editor.render_thread = None + editor.render_cancelled = False + + +def _process_frame_for_render(editor, frame, output_width: int, output_height: int, frame_number: Optional[int] = None): + try: + if editor.crop_rect: + x, y, w, h = map(int, editor.crop_rect) + if editor.motion_tracker.tracking_enabled and frame_number is not None: + current_pos = editor.motion_tracker.get_interpolated_position(frame_number) + if current_pos: + tracked_x, tracked_y = current_pos + new_x = int(tracked_x - w // 2) + new_y = int(tracked_y - h // 2) + x, y = new_x, new_y + h_frame, w_frame = frame.shape[:2] + x = max(0, min(x, w_frame - 1)) + y = max(0, min(y, h_frame - 1)) + w = min(w, w_frame - x) + h = min(h, h_frame - y) + if w > 0 and h > 0: + frame = frame[y:y + h, x:x + w] + else: + return None + frame = editor.apply_brightness_contrast(frame) + if editor.rotation_angle != 0: + frame = editor.apply_rotation(frame) + if editor.zoom_factor != 1.0: + height, width = frame.shape[:2] + zoomed_width = int(width * editor.zoom_factor) + zoomed_height = int(height * editor.zoom_factor) + if zoomed_width == output_width and zoomed_height == output_height: + frame = cv2.resize(frame, (zoomed_width, zoomed_height), interpolation=cv2.INTER_LINEAR) + else: + frame = cv2.resize(frame, (output_width, output_height), interpolation=cv2.INTER_LINEAR) + else: + if frame.shape[1] != output_width or frame.shape[0] != output_height: + frame = cv2.resize(frame, (output_width, output_height), interpolation=cv2.INTER_LINEAR) + return frame + except Exception as e: + print(f"Error processing frame: {e}") + return None + + +def _render_with_ffmpeg_pipe(editor, output_path: str, start_frame: int, end_frame: int, output_width: int, output_height: int): + try: + try: + test_result = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True, timeout=10) + if test_result.returncode != 0: + print(f"FFmpeg test failed with return code {test_result.returncode}") + print(f"FFmpeg stderr: {test_result.stderr}") + editor.render_progress_queue.put(("error", "FFmpeg is not working properly", 1.0, 0.0)) + return False + except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as e: + error_msg = f"FFmpeg not found or not working: {e}" + print(error_msg) + editor.render_progress_queue.put(("error", error_msg, 1.0, 0.0)) + return False + + editor.render_progress_queue.put(("progress", "Starting encoder...", 0.0, 0.0)) + + temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.raw') + temp_file.close() + + ffmpeg_cmd = [ + 'ffmpeg', '-y', + '-f', 'rawvideo', + '-s', f'{output_width}x{output_height}', + '-pix_fmt', 'bgr24', + '-r', str(editor.fps), + '-i', temp_file.name, + '-c:v', 'libx264', + '-preset', 'fast', + '-crf', '18', + '-pix_fmt', 'yuv420p', + output_path + ] + editor.temp_file_name = temp_file.name + + render_cap = cv2.VideoCapture(str(editor.video_path)) + render_cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame) + + total_frames = end_frame - start_frame + 1 + frames_written = 0 + start_time = time.time() + last_progress_update = 0 + editor.render_progress_queue.put(("progress", f"Processing {total_frames} frames...", 0.1, 0.0)) + + with open(editor.temp_file_name, 'wb') as tf: + for i in range(total_frames): + if editor.render_cancelled: + render_cap.release() + editor.render_progress_queue.put(("cancelled", "Render cancelled", 0.0, 0.0)) + return False + ret, frame = render_cap.read() + if not ret: + break + processed = _process_frame_for_render(editor, frame, output_width, output_height, start_frame + i) + if processed is not None: + if i == 0: + print(f"Processed frame dimensions: {processed.shape[1]}x{processed.shape[0]}") + print(f"Expected dimensions: {output_width}x{output_height}") + tf.write(processed.tobytes()) + frames_written += 1 + current_time = time.time() + progress = 0.1 + (0.8 * (i + 1) / total_frames) + if current_time - last_progress_update > 0.5: + elapsed = current_time - start_time + fps_rate = frames_written / elapsed if elapsed > 0 else 0 + editor.render_progress_queue.put(("progress", f"Processed {i+1}/{total_frames} frames", progress, fps_rate)) + last_progress_update = current_time + + render_cap.release() + + editor.render_progress_queue.put(("progress", "Encoding...", 0.9, 0.0)) + result = subprocess.run( + ffmpeg_cmd, + capture_output=True, + text=True, + timeout=300, + creationflags=(subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0) + ) + if os.path.exists(editor.temp_file_name): + try: + os.unlink(editor.temp_file_name) + except OSError: + pass + + if result.returncode == 0: + total_time = time.time() - start_time + avg_fps = frames_written / total_time if total_time > 0 else 0 + editor.render_progress_queue.put(("complete", f"Rendered {frames_written} frames", 1.0, avg_fps)) + print(f"Successfully rendered {frames_written} frames (avg {avg_fps:.1f} FPS)") + return True + else: + error_details = result.stderr if result.stderr else "No error details available" + print(f"Encoding failed with return code {result.returncode}") + print(f"Error: {error_details}") + editor.render_progress_queue.put(("error", f"Encoding failed: {error_details}", 1.0, 0.0)) + return False + except Exception as e: + error_msg = str(e) + print(f"Rendering exception: {error_msg}") + print(f"Exception type: {type(e).__name__}") + if "Errno 22" in error_msg or "invalid argument" in error_msg.lower(): + error_msg = "File system error - try using a different output path" + elif "BrokenPipeError" in error_msg: + error_msg = "Process terminated unexpectedly" + elif "FileNotFoundError" in error_msg or "ffmpeg" in error_msg.lower(): + error_msg = "FFmpeg not found - please install FFmpeg and ensure it's in your PATH" + editor.render_progress_queue.put(("error", f"Rendering failed: {error_msg}", 1.0, 0.0)) + return False