[READ-ONLY] Mirror of https://github.com/mrgnw/scripts.
0

Configure Feed

Select the types of activity you want to include in your feed.

scripts / stitch-frames
5.0 kB 166 lines
1#!/usr/bin/env -S uv run --script 2# /// script 3# requires-python = ">=3.14" 4# dependencies = [ 5# "pillow", 6# "pillow-heif", 7# "pillow-jxl-plugin", 8# ] 9# /// 10 11""" 12Stitches image frames into an HD 1080p video. 13Handles both portrait and landscape images by fitting them into 1920x1080 frames. 14""" 15 16import argparse 17import subprocess 18import sys 19from pathlib import Path 20from PIL import Image 21import tempfile 22import shutil 23 24# Register HEIF and JXL plugins 25try: 26 from pillow_heif import register_heif_opener 27 register_heif_opener() 28except ImportError: 29 pass 30 31try: 32 import pillow_jxl 33except ImportError: 34 pass 35 36def analyze_images(image_dir: Path): 37 """Analyze images to determine orientation mix.""" 38 image_files = sorted([ 39 f for f in image_dir.iterdir() 40 if f.suffix.lower() in {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.webp', '.heic', '.heif', '.jxl', '.avif'} 41 ]) 42 43 if not image_files: 44 print(f"No image files found in {image_dir}") 45 sys.exit(1) 46 47 orientations = {'portrait': 0, 'landscape': 0, 'square': 0} 48 49 for img_path in image_files: 50 try: 51 with Image.open(img_path) as img: 52 width, height = img.size 53 if width > height: 54 orientations['landscape'] += 1 55 elif height > width: 56 orientations['portrait'] += 1 57 else: 58 orientations['square'] += 1 59 except Exception as e: 60 print(f"Warning: Could not read {img_path}: {e}") 61 62 return image_files, orientations 63 64def process_images(image_files: list[Path], output_dir: Path, target_width: int = 1920, target_height: int = 1080): 65 """Process images to fit into target resolution with letterboxing.""" 66 output_dir.mkdir(parents=True, exist_ok=True) 67 68 for idx, img_path in enumerate(image_files): 69 try: 70 with Image.open(img_path) as img: 71 # Convert to RGB if necessary 72 if img.mode != 'RGB': 73 img = img.convert('RGB') 74 75 # Calculate scaling to fit within target dimensions 76 img_width, img_height = img.size 77 scale = min(target_width / img_width, target_height / img_height) 78 79 new_width = int(img_width * scale) 80 new_height = int(img_height * scale) 81 82 # Resize image 83 img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) 84 85 # Create black background at target resolution 86 background = Image.new('RGB', (target_width, target_height), (0, 0, 0)) 87 88 # Calculate position to center the image 89 x_offset = (target_width - new_width) // 2 90 y_offset = (target_height - new_height) // 2 91 92 # Paste resized image onto background 93 background.paste(img_resized, (x_offset, y_offset)) 94 95 # Save with sequential numbering for ffmpeg 96 output_path = output_dir / f"frame_{idx:06d}.jpg" 97 background.save(output_path, 'JPEG', quality=95) 98 99 except Exception as e: 100 print(f"Error processing {img_path}: {e}") 101 sys.exit(1) 102 103 return len(image_files) 104 105def create_video(frames_dir: Path, output_path: Path, fps: int = 30): 106 """Use ffmpeg to create video from processed frames.""" 107 cmd = [ 108 'ffmpeg', 109 '-framerate', str(fps), 110 '-i', str(frames_dir / 'frame_%06d.jpg'), 111 '-c:v', 'libx264', 112 '-pix_fmt', 'yuv420p', 113 '-crf', '18', 114 '-y', 115 str(output_path) 116 ] 117 118 try: 119 subprocess.run(cmd, check=True) 120 print(f"\nVideo created: {output_path}") 121 except subprocess.CalledProcessError as e: 122 print(f"Error running ffmpeg: {e}") 123 sys.exit(1) 124 except FileNotFoundError: 125 print("Error: ffmpeg not found. Please install ffmpeg.") 126 sys.exit(1) 127 128def main(): 129 parser = argparse.ArgumentParser(description='Stitch image frames into HD 1080p video') 130 parser.add_argument('directory', type=Path, help='Directory containing image frames') 131 parser.add_argument('-o', '--output', type=Path, help='Output video path (default: output.mp4)') 132 parser.add_argument('-f', '--fps', type=int, default=30, help='Frames per second (default: 30)') 133 parser.add_argument('--width', type=int, default=1920, help='Output width (default: 1920)') 134 parser.add_argument('--height', type=int, default=1080, help='Output height (default: 1080)') 135 136 args = parser.parse_args() 137 138 if not args.directory.is_dir(): 139 print(f"Error: {args.directory} is not a directory") 140 sys.exit(1) 141 142 output_path = args.output or Path('output.mp4') 143 144 print(f"Analyzing images in {args.directory}...") 145 image_files, orientations = analyze_images(args.directory) 146 147 print(f"\nFound {len(image_files)} images:") 148 print(f" Landscape: {orientations['landscape']}") 149 print(f" Portrait: {orientations['portrait']}") 150 print(f" Square: {orientations['square']}") 151 print(f"\nTarget resolution: {args.width}x{args.height}") 152 print(f"Frame rate: {args.fps} fps\n") 153 154 # Create temporary directory for processed frames 155 with tempfile.TemporaryDirectory() as temp_dir: 156 temp_path = Path(temp_dir) 157 158 print("Processing images...") 159 frame_count = process_images(image_files, temp_path, args.width, args.height) 160 print(f"Processed {frame_count} frames") 161 162 print("\nCreating video with ffmpeg...") 163 create_video(temp_path, output_path, args.fps) 164 165if __name__ == '__main__': 166 main()