[READ-ONLY] Mirror of https://github.com/mrgnw/scripts.
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()