|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import os |
|
|
import subprocess |
|
|
import logging |
|
|
import random |
|
|
import time |
|
|
import shutil |
|
|
from typing import List, Optional, Tuple |
|
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
class VideoToolError(Exception): |
|
|
"""Custom exception for errors originating from the VideoEncodeTool.""" |
|
|
pass |
|
|
|
|
|
class VideoEncodeTool: |
|
|
""" |
|
|
A specialist for handling video encoding and manipulation tasks. |
|
|
Currently uses FFmpeg as the backend. |
|
|
""" |
|
|
|
|
|
def create_transition_bridge(self, start_image_path: str, end_image_path: str, |
|
|
duration: float, fps: int, target_resolution: Tuple[int, int], |
|
|
workspace_dir: str, effect: Optional[str] = None) -> str: |
|
|
""" |
|
|
Creates a short video clip that transitions between two static images using FFmpeg's xfade filter. |
|
|
This is useful for creating a "bridge" during a hard "cut" decided by the cinematic director. |
|
|
|
|
|
Args: |
|
|
start_image_path (str): The file path to the starting image. |
|
|
end_image_path (str): The file path to the ending image. |
|
|
duration (float): The desired duration of the transition in seconds. |
|
|
fps (int): The frames per second for the output video. |
|
|
target_resolution (Tuple[int, int]): The (width, height) of the output video. |
|
|
workspace_dir (str): The directory to save the output video. |
|
|
effect (Optional[str], optional): The specific xfade effect to use. If None, a random |
|
|
effect is chosen. Defaults to None. |
|
|
|
|
|
Returns: |
|
|
str: The file path to the generated transition video clip. |
|
|
|
|
|
Raises: |
|
|
VideoToolError: If the FFmpeg command fails. |
|
|
""" |
|
|
output_path = os.path.join(workspace_dir, f"bridge_{int(time.time())}.mp4") |
|
|
width, height = target_resolution |
|
|
|
|
|
fade_effects = [ |
|
|
"fade", "wipeleft", "wiperight", "wipeup", "wipedown", "dissolve", |
|
|
"fadeblack", "fadewhite", "radial", "rectcrop", "circleopen", |
|
|
"circleclose", "horzopen", "horzclose" |
|
|
] |
|
|
|
|
|
selected_effect = effect if effect and effect.strip() else random.choice(fade_effects) |
|
|
|
|
|
transition_duration = max(0.1, duration) |
|
|
|
|
|
cmd = ( |
|
|
f"ffmpeg -y -v error -loop 1 -t {transition_duration} -i \"{start_image_path}\" -loop 1 -t {transition_duration} -i \"{end_image_path}\" " |
|
|
f"-filter_complex \"[0:v]scale={width}:{height},setsar=1[v0];[1:v]scale={width}:{height},setsar=1[v1];" |
|
|
f"[v0][v1]xfade=transition={selected_effect}:duration={transition_duration}:offset=0[out]\" " |
|
|
f"-map \"[out]\" -c:v libx264 -r {fps} -pix_fmt yuv420p \"{output_path}\"" |
|
|
) |
|
|
|
|
|
logger.info(f"Creating FFmpeg transition bridge with effect: '{selected_effect}' | Duration: {transition_duration}s") |
|
|
|
|
|
try: |
|
|
subprocess.run(cmd, shell=True, check=True, text=True) |
|
|
except subprocess.CalledProcessError as e: |
|
|
logger.error(f"FFmpeg bridge creation failed. Return code: {e.returncode}") |
|
|
logger.error(f"FFmpeg command: {cmd}") |
|
|
logger.error(f"FFmpeg stderr: {e.stderr}") |
|
|
raise VideoToolError(f"Failed to create transition video. Details: {e.stderr}") |
|
|
|
|
|
return output_path |
|
|
|
|
|
def concatenate_videos(self, video_paths: List[str], output_path: str, workspace_dir: str): |
|
|
""" |
|
|
Concatenates multiple video clips into a single file without re-encoding. |
|
|
|
|
|
Args: |
|
|
video_paths (List[str]): A list of absolute paths to the video clips to be concatenated. |
|
|
output_path (str): The absolute path for the final output video. |
|
|
workspace_dir (str): The directory to use for temporary files, like the concat list. |
|
|
|
|
|
Raises: |
|
|
VideoToolError: If no video paths are provided or if the FFmpeg command fails. |
|
|
""" |
|
|
if not video_paths: |
|
|
raise VideoToolError("VideoEncodeTool: No video fragments provided for concatenation.") |
|
|
|
|
|
if len(video_paths) == 1: |
|
|
logger.info("Only one video clip found. Skipping concatenation and just copying the file.") |
|
|
shutil.copy(video_paths[0], output_path) |
|
|
return |
|
|
|
|
|
list_file_path = os.path.join(workspace_dir, "concat_list.txt") |
|
|
|
|
|
try: |
|
|
with open(list_file_path, 'w', encoding='utf-8') as f: |
|
|
for path in video_paths: |
|
|
f.write(f"file '{os.path.abspath(path)}'\n") |
|
|
|
|
|
cmd_list = ['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', list_file_path, '-c', 'copy', output_path] |
|
|
|
|
|
logger.info(f"Concatenating {len(video_paths)} video clips into {output_path} using FFmpeg...") |
|
|
|
|
|
subprocess.run(cmd_list, check=True, capture_output=True, text=True) |
|
|
|
|
|
logger.info(f"FFmpeg concatenation successful. Final video is at: {output_path}") |
|
|
|
|
|
except subprocess.CalledProcessError as e: |
|
|
logger.error(f"FFmpeg concatenation failed. Return code: {e.returncode}") |
|
|
logger.error(f"FFmpeg stderr: {e.stderr}") |
|
|
raise VideoToolError(f"Failed to assemble the final video using FFmpeg. Details: {e.stderr}") |
|
|
except Exception as e: |
|
|
logger.error(f"An unexpected error occurred during video concatenation: {e}", exc_info=True) |
|
|
raise VideoToolError("An unexpected error occurred during the final video assembly.") |
|
|
finally: |
|
|
if os.path.exists(list_file_path): |
|
|
os.remove(list_file_path) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
video_encode_tool_singleton = VideoEncodeTool() |