#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.14" # dependencies = [ # "pillow", # "pillow-heif", # "pillow-jxl-plugin", # ] # /// """ Stitches image frames into an HD 1080p video. Handles both portrait and landscape images by fitting them into 1920x1080 frames. """ import argparse import subprocess import sys from pathlib import Path from PIL import Image import tempfile import shutil # Register HEIF and JXL plugins try: from pillow_heif import register_heif_opener register_heif_opener() except ImportError: pass try: import pillow_jxl except ImportError: pass def analyze_images(image_dir: Path): """Analyze images to determine orientation mix.""" image_files = sorted([ f for f in image_dir.iterdir() if f.suffix.lower() in {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.webp', '.heic', '.heif', '.jxl', '.avif'} ]) if not image_files: print(f"No image files found in {image_dir}") sys.exit(1) orientations = {'portrait': 0, 'landscape': 0, 'square': 0} for img_path in image_files: try: with Image.open(img_path) as img: width, height = img.size if width > height: orientations['landscape'] += 1 elif height > width: orientations['portrait'] += 1 else: orientations['square'] += 1 except Exception as e: print(f"Warning: Could not read {img_path}: {e}") return image_files, orientations def process_images(image_files: list[Path], output_dir: Path, target_width: int = 1920, target_height: int = 1080): """Process images to fit into target resolution with letterboxing.""" output_dir.mkdir(parents=True, exist_ok=True) for idx, img_path in enumerate(image_files): try: with Image.open(img_path) as img: # Convert to RGB if necessary if img.mode != 'RGB': img = img.convert('RGB') # Calculate scaling to fit within target dimensions img_width, img_height = img.size scale = min(target_width / img_width, target_height / img_height) new_width = int(img_width * scale) new_height = int(img_height * scale) # Resize image img_resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS) # Create black background at target resolution background = Image.new('RGB', (target_width, target_height), (0, 0, 0)) # Calculate position to center the image x_offset = (target_width - new_width) // 2 y_offset = (target_height - new_height) // 2 # Paste resized image onto background background.paste(img_resized, (x_offset, y_offset)) # Save with sequential numbering for ffmpeg output_path = output_dir / f"frame_{idx:06d}.jpg" background.save(output_path, 'JPEG', quality=95) except Exception as e: print(f"Error processing {img_path}: {e}") sys.exit(1) return len(image_files) def create_video(frames_dir: Path, output_path: Path, fps: int = 30): """Use ffmpeg to create video from processed frames.""" cmd = [ 'ffmpeg', '-framerate', str(fps), '-i', str(frames_dir / 'frame_%06d.jpg'), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18', '-y', str(output_path) ] try: subprocess.run(cmd, check=True) print(f"\nVideo created: {output_path}") except subprocess.CalledProcessError as e: print(f"Error running ffmpeg: {e}") sys.exit(1) except FileNotFoundError: print("Error: ffmpeg not found. Please install ffmpeg.") sys.exit(1) def main(): parser = argparse.ArgumentParser(description='Stitch image frames into HD 1080p video') parser.add_argument('directory', type=Path, help='Directory containing image frames') parser.add_argument('-o', '--output', type=Path, help='Output video path (default: output.mp4)') parser.add_argument('-f', '--fps', type=int, default=30, help='Frames per second (default: 30)') parser.add_argument('--width', type=int, default=1920, help='Output width (default: 1920)') parser.add_argument('--height', type=int, default=1080, help='Output height (default: 1080)') args = parser.parse_args() if not args.directory.is_dir(): print(f"Error: {args.directory} is not a directory") sys.exit(1) output_path = args.output or Path('output.mp4') print(f"Analyzing images in {args.directory}...") image_files, orientations = analyze_images(args.directory) print(f"\nFound {len(image_files)} images:") print(f" Landscape: {orientations['landscape']}") print(f" Portrait: {orientations['portrait']}") print(f" Square: {orientations['square']}") print(f"\nTarget resolution: {args.width}x{args.height}") print(f"Frame rate: {args.fps} fps\n") # Create temporary directory for processed frames with tempfile.TemporaryDirectory() as temp_dir: temp_path = Path(temp_dir) print("Processing images...") frame_count = process_images(image_files, temp_path, args.width, args.height) print(f"Processed {frame_count} frames") print("\nCreating video with ffmpeg...") create_video(temp_path, output_path, args.fps) if __name__ == '__main__': main()