Refactor video rendering functionality in VideoEditor and introduce rendering module
This commit refactors the video rendering process in the VideoEditor class by delegating rendering tasks to a new rendering module. The _render_video_threaded and _render_video_worker methods are replaced with start_render_thread and pump_progress functions, enhancing code organization and maintainability. Additionally, the pyproject.toml file is updated to include the new rendering module, ensuring proper module recognition. This restructuring improves the overall clarity and efficiency of the video rendering workflow.
This commit is contained in:
145
croppa/editor.py
145
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"""
|
||||
|
@@ -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"
|
||||
|
303
croppa/rendering.py
Normal file
303
croppa/rendering.py
Normal file
@@ -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
|
Reference in New Issue
Block a user