| import subprocess |
| import os |
| from pathlib import Path |
|
|
| def create_side_by_side_gif(video_paths, output_gif, gap_width=20, fps=10, scale_height=240, gap_color="white", verbose=False): |
| """ |
| Create a single GIF with multiple videos placed side by side. |
| |
| Args: |
| video_paths (list): List of paths to input MP4 files |
| output_gif (str): Path for output GIF file |
| gap_width (int): Width of gap between videos in pixels |
| fps (int): Frame rate for output GIF |
| scale_height (int): Height to scale all videos to (maintains aspect ratio) |
| gap_color (str): Color for gaps between videos (e.g., "white", "black", "red", "#FF0000") |
| verbose (bool): Whether to print FFmpeg commands and processing messages |
| """ |
| |
| if not video_paths: |
| raise ValueError("No video paths provided") |
| |
| |
| for path in video_paths: |
| if not os.path.exists(path): |
| raise FileNotFoundError(f"Video file not found: {path}") |
| |
| |
| num_videos = len(video_paths) |
| |
| |
| filter_parts = [] |
| scaled_inputs = [] |
| |
| for i, _ in enumerate(video_paths): |
| |
| filter_parts.append(f"[{i}:v]scale=-1:{scale_height}[v{i}]") |
| scaled_inputs.append(f"[v{i}]") |
| |
| |
| if num_videos == 1: |
| hstack_filter = f"{scaled_inputs[0]}copy[stacked]" |
| else: |
| |
| gap_filters = [] |
| for i in range(num_videos - 1): |
| gap_filters.append(f"color={gap_color}:{gap_width}x{scale_height}:d=1[gap{i}]") |
| |
| if gap_filters: |
| filter_parts.extend(gap_filters) |
| |
| |
| hstack_inputs = [] |
| for i in range(num_videos): |
| hstack_inputs.append(scaled_inputs[i]) |
| if i < num_videos - 1: |
| hstack_inputs.append(f"[gap{i}]") |
| |
| hstack_filter = f"{''.join(hstack_inputs)}hstack=inputs={len(hstack_inputs)}[stacked]" |
| |
| filter_parts.append(hstack_filter) |
| |
| |
| stacked_filter = ";".join(filter_parts) |
| |
| |
| cmd = ["ffmpeg", "-y"] |
| |
| |
| for video_path in video_paths: |
| cmd.extend(["-i", video_path]) |
| |
| |
| cmd.extend([ |
| "-filter_complex", f"{stacked_filter};[stacked]split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3", |
| "-r", str(fps), |
| "-loop", "0", |
| output_gif |
| ]) |
| |
| if verbose: |
| print("Running FFmpeg command:") |
| print(" ".join(cmd)) |
| print("\nProcessing...") |
| |
| try: |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) |
| if verbose: |
| print(f"✓ Successfully created GIF: {output_gif}") |
| return True |
| except subprocess.CalledProcessError as e: |
| if verbose: |
| print(f"✗ FFmpeg error: {e.stderr}") |
| return False |
| except FileNotFoundError: |
| if verbose: |
| print("✗ FFmpeg not found. Please install FFmpeg first.") |
| print(" - Windows: Download from https://ffmpeg.org/download.html") |
| print(" - macOS: brew install ffmpeg") |
| print(" - Linux: sudo apt install ffmpeg (Ubuntu/Debian)") |
| return False |
|
|
| def create_top_to_bottom_gif(video_paths, output_gif, gap_height=20, fps=10, scale_width=320, gap_color="white", verbose=False): |
| """ |
| Create a single GIF with multiple videos stacked vertically (top to bottom). |
| |
| Args: |
| video_paths (list): List of paths to input MP4 files |
| output_gif (str): Path for output GIF file |
| gap_height (int): Height of gap between videos in pixels |
| fps (int): Frame rate for output GIF |
| scale_width (int): Width to scale all videos to (maintains aspect ratio) |
| gap_color (str): Color for gaps between videos (e.g., "white", "black", "red", "#FF0000") |
| verbose (bool): Whether to print FFmpeg commands and processing messages |
| """ |
| |
| if not video_paths: |
| raise ValueError("No video paths provided") |
| |
| |
| for path in video_paths: |
| if not os.path.exists(path): |
| raise FileNotFoundError(f"Video file not found: {path}") |
| |
| |
| num_videos = len(video_paths) |
| |
| |
| filter_parts = [] |
| scaled_inputs = [] |
| |
| for i, _ in enumerate(video_paths): |
| |
| filter_parts.append(f"[{i}:v]scale={scale_width}:-1[v{i}]") |
| scaled_inputs.append(f"[v{i}]") |
| |
| |
| if num_videos == 1: |
| vstack_filter = f"{scaled_inputs[0]}copy[stacked]" |
| else: |
| |
| gap_filters = [] |
| for i in range(num_videos - 1): |
| gap_filters.append(f"color={gap_color}:{scale_width}x{gap_height}:d=1[gap{i}]") |
| |
| if gap_filters: |
| filter_parts.extend(gap_filters) |
| |
| |
| vstack_inputs = [] |
| for i in range(num_videos): |
| vstack_inputs.append(scaled_inputs[i]) |
| if i < num_videos - 1: |
| vstack_inputs.append(f"[gap{i}]") |
| |
| vstack_filter = f"{''.join(vstack_inputs)}vstack=inputs={len(vstack_inputs)}[stacked]" |
| |
| filter_parts.append(vstack_filter) |
| |
| |
| stacked_filter = ";".join(filter_parts) |
| |
| |
| cmd = ["ffmpeg", "-y"] |
| |
| |
| for video_path in video_paths: |
| cmd.extend(["-i", video_path]) |
| |
| |
| cmd.extend([ |
| "-filter_complex", f"{stacked_filter};[stacked]split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3", |
| "-r", str(fps), |
| "-loop", "0", |
| output_gif |
| ]) |
| |
| if verbose: |
| print("Running FFmpeg command:") |
| print(" ".join(cmd)) |
| print("\nProcessing...") |
| |
| try: |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) |
| if verbose: |
| print(f"✓ Successfully created GIF: {output_gif}") |
| return True |
| except subprocess.CalledProcessError as e: |
| if verbose: |
| print(f"✗ FFmpeg error: {e.stderr}") |
| return False |
| except FileNotFoundError: |
| if verbose: |
| print("✗ FFmpeg not found. Please install FFmpeg first.") |
| print(" - Windows: Download from https://ffmpeg.org/download.html") |
| print(" - macOS: brew install ffmpeg") |
| print(" - Linux: sudo apt install ffmpeg (Ubuntu/Debian)") |
| return False |
|
|
| def reverse_video(input_video_path, output_filename=None, verbose=False): |
| """ |
| Reverse a video file and save it to /tmp directory. |
| |
| Args: |
| input_video_path (str): Path to input MP4 file |
| output_filename (str, optional): Name for output file. If None, generates from input filename |
| verbose (bool): Whether to print FFmpeg commands and processing messages |
| |
| Returns: |
| str: Path to the reversed video file in /tmp, or None if failed |
| """ |
| |
| if not os.path.exists(input_video_path): |
| raise FileNotFoundError(f"Input video file not found: {input_video_path}") |
| |
| |
| if output_filename is None: |
| input_name = Path(input_video_path).stem |
| output_filename = f"{input_name}_reversed.mp4" |
| |
| |
| if not output_filename.endswith('.mp4'): |
| output_filename += '.mp4' |
| |
| |
| output_path = os.path.join('/tmp', output_filename) |
| |
| |
| cmd = [ |
| "ffmpeg", "-y", |
| "-i", input_video_path, |
| "-vf", "reverse", |
| "-af", "areverse", |
| "-c:v", "libx264", |
| "-c:a", "aac", |
| output_path |
| ] |
| |
| if verbose: |
| print("Running FFmpeg command to reverse video:") |
| print(" ".join(cmd)) |
| print("\nProcessing...") |
| |
| try: |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) |
| if verbose: |
| print(f"✓ Successfully created reversed video: {output_path}") |
| return output_path |
| except subprocess.CalledProcessError as e: |
| if verbose: |
| print(f"✗ FFmpeg error: {e.stderr}") |
| return None |
| except FileNotFoundError: |
| if verbose: |
| print("✗ FFmpeg not found. Please install FFmpeg first.") |
| print(" - Windows: Download from https://ffmpeg.org/download.html") |
| print(" - macOS: brew install ffmpeg") |
| print(" - Linux: sudo apt install ffmpeg (Ubuntu/Debian)") |
| return None |
|
|
| def add_text_overlay(input_video_path, text, output_filename=None, font_size=12, font_color="white", |
| background_color="black", position="top", margin=10, duration=None, verbose=False): |
| """ |
| Add a text overlay to a video with a background title bar. |
| |
| Args: |
| input_video_path (str): Path to input MP4 file |
| text (str): Text to display |
| output_filename (str, optional): Name for output file. If None, generates from input filename |
| font_size (int): Font size for the text (default: 24) |
| font_color (str): Color of the text (default: "white") |
| background_color (str): Color of the background bar (default: "black") |
| position (str): Position of text bar - "top", "bottom", "center" (default: "top") |
| margin (int): Margin from edge in pixels (default: 10) |
| duration (float, optional): Duration to show text in seconds. If None, shows for entire video |
| verbose (bool): Whether to print FFmpeg commands and processing messages |
| |
| Returns: |
| str: Path to the video with text overlay in /tmp, or None if failed |
| """ |
| |
| if not os.path.exists(input_video_path): |
| raise FileNotFoundError(f"Input video file not found: {input_video_path}") |
| |
| |
| if output_filename is None: |
| input_name = Path(input_video_path).stem |
| output_filename = f"{input_name}_with_text.mp4" |
| |
| |
| if not output_filename.endswith('.mp4'): |
| output_filename += '.mp4' |
| |
| |
| output_path = os.path.join('/tmp', output_filename) |
| |
| |
| if position == "top": |
| text_position = f"x={margin}:y={margin+5}" |
| elif position == "bottom": |
| text_position = f"x={margin}:y=h-th-{margin+5}" |
| elif position == "center": |
| text_position = f"x={margin}:y=(h-th)/2" |
| else: |
| text_position = f"x={margin}:y={margin+5}" |
| |
| |
| drawtext_filter = f"drawtext=text='{text}':fontsize={font_size}:fontcolor={font_color}:{text_position}" |
| |
| |
| if background_color != "transparent": |
| |
| |
| estimated_text_height = int(font_size * 1.2) |
| box_height = estimated_text_height + 10 |
| |
| |
| if position == "top": |
| box_y = margin |
| elif position == "bottom": |
| box_y = f"h-{box_height}-{margin}" |
| elif position == "center": |
| box_y = f"(h-{box_height})/2" |
| else: |
| box_y = margin |
| |
| box_filter = f"drawbox=x={margin-5}:y={box_y}:w=iw-{2*(margin-5)}:h={box_height}:color={background_color}@0.7:t=fill" |
| drawtext_filter = f"{box_filter},{drawtext_filter}" |
| |
| |
| if duration is not None: |
| drawtext_filter += f":enable='between(t,0,{duration})'" |
| |
| |
| cmd = [ |
| "ffmpeg", "-y", |
| "-i", input_video_path, |
| "-vf", drawtext_filter, |
| "-c:v", "libx264", |
| "-c:a", "copy", |
| output_path |
| ] |
| |
| if verbose: |
| print("Running FFmpeg command to add text overlay:") |
| print(" ".join(cmd)) |
| print("\nProcessing...") |
| |
| try: |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) |
| if verbose: |
| print(f"✓ Successfully created video with text overlay: {output_path}") |
| return output_path |
| except subprocess.CalledProcessError as e: |
| if verbose: |
| print(f"✗ FFmpeg error: {e.stderr}") |
| return None |
| except FileNotFoundError: |
| if verbose: |
| print("✗ FFmpeg not found. Please install FFmpeg first.") |
| print(" - Windows: Download from https://ffmpeg.org/download.html") |
| print(" - macOS: brew install ffmpeg") |
| print(" - Linux: sudo apt install ffmpeg (Ubuntu/Debian)") |
| return None |
|
|
| def add_text_strip(input_video_path, text, output_filename=None, font_size=16, font_color="white", |
| background_color="black", position="top", text_padding=20, max_width_ratio=0.9, verbose=False): |
| """ |
| Add a text strip/bar to a video (increases video height) rather than overlaying text. |
| |
| Args: |
| input_video_path (str): Path to input MP4 file |
| text (str): Text to display in the strip |
| output_filename (str, optional): Name for output file. If None, generates from input filename |
| font_size (int): Font size for the text (default: 16) |
| font_color (str): Color of the text (default: "white") |
| background_color (str): Color of the background strip (default: "black") |
| position (str): Position of text strip - "top" or "bottom" (default: "top") |
| text_padding (int): Padding around text in pixels (default: 20) |
| max_width_ratio (float): Maximum width of text as ratio of video width (default: 0.9) |
| verbose (bool): Whether to print FFmpeg commands and processing messages |
| |
| Returns: |
| str: Path to the video with text strip in /tmp, or None if failed |
| """ |
| |
| if not os.path.exists(input_video_path): |
| raise FileNotFoundError(f"Input video file not found: {input_video_path}") |
| |
| |
| if output_filename is None: |
| input_name = Path(input_video_path).stem |
| output_filename = f"{input_name}_with_strip.mp4" |
| |
| |
| if not output_filename.endswith('.mp4'): |
| output_filename += '.mp4' |
| |
| |
| output_path = os.path.join('/tmp', output_filename) |
| |
| |
| |
| estimated_chars_per_line = int(font_size * 2) |
| text_lines = text.split('\n') if '\n' in text else [text] |
| |
| |
| wrapped_lines = [] |
| for line in text_lines: |
| if len(line) <= estimated_chars_per_line: |
| wrapped_lines.append(line) |
| else: |
| |
| words = line.split(' ') |
| current_line = "" |
| for word in words: |
| if len(current_line + " " + word) <= estimated_chars_per_line: |
| current_line += (" " + word) if current_line else word |
| else: |
| if current_line: |
| wrapped_lines.append(current_line) |
| current_line = word |
| if current_line: |
| wrapped_lines.append(current_line) |
| |
| |
| line_height = font_size + 5 |
| strip_height = (len(wrapped_lines) * line_height) + (2 * text_padding) |
| |
| |
| |
| if position == "top": |
| |
| text_strip_filter = f"[0:v]pad=iw:ih+{strip_height}:0:{strip_height}:{background_color}[padded];[padded]drawtext=text='{chr(10).join(wrapped_lines)}':fontsize={font_size}:fontcolor={font_color}:x=(w-tw)/2:y={text_padding}:line_spacing={line_height}[stacked]" |
| else: |
| |
| text_strip_filter = f"[0:v]pad=iw:ih+{strip_height}:0:0:{background_color}[padded];[padded]drawtext=text='{chr(10).join(wrapped_lines)}':fontsize={font_size}:fontcolor={font_color}:x=(w-tw)/2:y=h-th-{text_padding}:line_spacing={line_height}[stacked]" |
| |
| |
| cmd = [ |
| "ffmpeg", "-y", |
| "-i", input_video_path, |
| "-filter_complex", text_strip_filter, |
| "-map", "[stacked]", |
| "-map", "0:a", |
| "-c:v", "libx264", |
| "-c:a", "copy", |
| output_path |
| ] |
| |
| if verbose: |
| print("Running FFmpeg command to add text strip:") |
| print(" ".join(cmd)) |
| print("\nProcessing...") |
| |
| try: |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) |
| if verbose: |
| print(f"✓ Successfully created video with text strip: {output_path}") |
| return output_path |
| except subprocess.CalledProcessError as e: |
| if verbose: |
| print(f"✗ FFmpeg error: {e.stderr}") |
| return None |
| except FileNotFoundError: |
| if verbose: |
| print("✗ FFmpeg not found. Please install FFmpeg first.") |
| print(" - Windows: Download from https://ffmpeg.org/download.html") |
| print(" - macOS: brew install ffmpeg") |
| print(" - Linux: sudo apt install ffmpeg (Ubuntu/Debian)") |
| return None |
|
|
| def get_video_info(video_path): |
| """Get basic info about a video file.""" |
| cmd = [ |
| "ffprobe", "-v", "quiet", "-print_format", "json", |
| "-show_format", "-show_streams", video_path |
| ] |
| |
| try: |
| result = subprocess.run(cmd, capture_output=True, text=True, check=True) |
| import json |
| data = json.loads(result.stdout) |
| |
| |
| for stream in data['streams']: |
| if stream['codec_type'] == 'video': |
| return { |
| 'width': stream['width'], |
| 'height': stream['height'], |
| 'duration': float(stream.get('duration', 0)), |
| 'fps': eval(stream.get('r_frame_rate', '0/1')) |
| } |
| except: |
| pass |
| return None |
|
|
|
|
|
|
|
|
|
|
|
|
| |
| if __name__ == "__main__": |
| |
| video_files = [ |
| "examples/folding_paper.mp4", |
| "examples/S008C002P032R002A051.mp4", |
| ] |
| |
| output_file = "combined_videos.gif" |
| |
| |
| existing_files = [f for f in video_files if os.path.exists(f)] |
| |
| if existing_files: |
| print(f"Found {len(existing_files)} video files:") |
| for video in existing_files: |
| info = get_video_info(video) |
| if info: |
| print(f" {video}: {info['width']}x{info['height']}, {info['duration']:.1f}s") |
| else: |
| print(f" {video}: (info unavailable)") |
| |
| |
| success = create_side_by_side_gif( |
| video_paths=existing_files, |
| output_gif=output_file, |
| gap_width=30, |
| fps=12, |
| scale_height=300, |
| gap_color="white" |
| ) |
| |
| |
| vertical_output_file = "combined_videos_vertical.gif" |
| success_vertical = create_top_to_bottom_gif( |
| video_paths=existing_files, |
| output_gif=vertical_output_file, |
| gap_height=20, |
| fps=12, |
| scale_width=320, |
| gap_color="white" |
| ) |
| |
| if success: |
| file_size = os.path.getsize(output_file) / (1024 * 1024) |
| print(f"\nHorizontal GIF size: {file_size:.1f} MB") |
| |
| if success_vertical: |
| vertical_file_size = os.path.getsize(vertical_output_file) / (1024 * 1024) |
| print(f"Vertical GIF size: {vertical_file_size:.1f} MB") |
| |
| |
| if existing_files: |
| print(f"\nReversing first video: {existing_files[0]}") |
| reversed_path = reverse_video(existing_files[0]) |
| if reversed_path: |
| print(f"Reversed video saved to: {reversed_path}") |
| |
| |
| if existing_files: |
| print(f"\nAdding text overlay to first video: {existing_files[0]}") |
| text_video_path = add_text_overlay( |
| input_video_path=existing_files[0], |
| text="Sample Title Text", |
| font_size=30, |
| font_color="white", |
| background_color="black", |
| position="top", |
| margin=15 |
| ) |
| if text_video_path: |
| print(f"Video with text overlay saved to: {text_video_path}") |
| |
| |
| if existing_files: |
| print(f"\nAdding text strip to first video: {existing_files[0]}") |
| strip_video_path = add_text_strip( |
| input_video_path=existing_files[0], |
| text="Video Title Strip", |
| font_size=16, |
| font_color="white", |
| background_color="darkblue", |
| position="top", |
| text_padding=15 |
| ) |
| if strip_video_path: |
| print(f"Video with text strip saved to: {strip_video_path}") |
| else: |
| print("No video files found. Please update the video_files list with your actual MP4 file paths.") |
| print("\nExample usage:") |
| print("video_files = [") |
| print(' "/path/to/your/video1.mp4",') |
| print(' "/path/to/your/video2.mp4",') |
| print(' "/path/to/your/video3.mp4"') |
| print("]") |
| print("\n# Create horizontal GIF") |
| print("create_side_by_side_gif(video_files, 'horizontal.gif')") |
| print("\n# Create vertical GIF") |
| print("create_top_to_bottom_gif(video_files, 'vertical.gif')") |
| print("\n# Reverse a video") |
| print("reversed_path = reverse_video('/path/to/your/video1.mp4')") |
| print("print(f'Reversed video: {reversed_path}')") |
| print("\n# Add text overlay to video") |
| print("text_video = add_text_overlay('/path/to/your/video1.mp4', 'My Title', font_size=30)") |
| print("print(f'Video with text: {text_video}')") |
| print("\n# Add text strip to video (increases video height)") |
| print("strip_video = add_text_strip('/path/to/your/video1.mp4', 'Title Strip', font_size=16)") |
| print("print(f'Video with strip: {strip_video}')") |