|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Process all tagged videos using FFmpeg with background compositing. |
| 4 | +
|
| 5 | +Usage: ./process-videos.py [background_image] [output_dir] |
| 6 | +
|
| 7 | +Reads: ~/videos/quadrant-tags.json |
| 8 | +Requires: ffmpeg |
| 9 | +""" |
| 10 | + |
| 11 | +import json |
| 12 | +import os |
| 13 | +import subprocess |
| 14 | +import sys |
| 15 | +from pathlib import Path |
| 16 | + |
| 17 | + |
| 18 | +def get_crop(quadrant: str) -> str: |
| 19 | + """Get FFmpeg crop parameters for a quadrant.""" |
| 20 | + crops = { |
| 21 | + "top-left": "1912:1072:4:4", |
| 22 | + "top-right": "1912:1072:1924:4", |
| 23 | + "bottom-left": "1912:1072:4:1084", |
| 24 | + "bottom-right": "1912:1072:1924:1084", |
| 25 | + } |
| 26 | + if quadrant not in crops: |
| 27 | + raise ValueError(f"Invalid quadrant: {quadrant}") |
| 28 | + return crops[quadrant] |
| 29 | + |
| 30 | + |
| 31 | +def build_filter(presenter_crop: str, slides_crop: str) -> str: |
| 32 | + """Build FFmpeg filter complex. |
| 33 | +
|
| 34 | + Output: composited video with slides large and presenter small in corner. |
| 35 | + """ |
| 36 | + return ( |
| 37 | + f"[1:v]scale=2560:1440[bg]; " |
| 38 | + f"[0:v]crop={slides_crop}[slides_cropped]; " |
| 39 | + f"[slides_cropped]scale=1920:1080[slides]; " |
| 40 | + f"[0:v]crop={presenter_crop}[presenter_raw]; " |
| 41 | + f"[presenter_raw]scale=-1:320[presenter]; " |
| 42 | + f"[slides]scale=1920:1080[slides_s]; " |
| 43 | + f"[bg][slides_s]overlay=(W-w)/2:(H-h)/2[base]; " |
| 44 | + f"[base][presenter]overlay=x=W-w-40:y=H-h-40[outv]" |
| 45 | + ) |
| 46 | + |
| 47 | + |
| 48 | +def process_video(input_path: str, output_path: str, bg_image: str, |
| 49 | + presenter: str, slides: str) -> bool: |
| 50 | + """Process a single video with FFmpeg.""" |
| 51 | + try: |
| 52 | + presenter_crop = get_crop(presenter) |
| 53 | + slides_crop = get_crop(slides) |
| 54 | + except ValueError as e: |
| 55 | + print(f" Error: {e}") |
| 56 | + return False |
| 57 | + |
| 58 | + filter_complex = build_filter(presenter_crop, slides_crop) |
| 59 | + |
| 60 | + cmd = [ |
| 61 | + "ffmpeg", "-y", |
| 62 | + "-i", input_path, |
| 63 | + "-i", bg_image, |
| 64 | + "-filter_complex", filter_complex, |
| 65 | + "-map", "[outv]", |
| 66 | + "-map", "0:a?", |
| 67 | + "-c:v", "libx264", |
| 68 | + "-crf", "18", |
| 69 | + "-preset", "veryfast", |
| 70 | + "-threads", "0", |
| 71 | + "-c:a", "copy", |
| 72 | + output_path |
| 73 | + ] |
| 74 | + |
| 75 | + try: |
| 76 | + result = subprocess.run( |
| 77 | + cmd, |
| 78 | + capture_output=True, |
| 79 | + text=True |
| 80 | + ) |
| 81 | + if result.returncode != 0: |
| 82 | + print(f" FFmpeg error: {result.stderr[-500:]}") |
| 83 | + return False |
| 84 | + return True |
| 85 | + except Exception as e: |
| 86 | + print(f" Error running FFmpeg: {e}") |
| 87 | + return False |
| 88 | + |
| 89 | + |
| 90 | +def main(): |
| 91 | + # Parse arguments |
| 92 | + bg_image = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/gpc-bg.png") |
| 93 | + output_dir = sys.argv[2] if len(sys.argv) > 2 else os.path.expanduser("~/videos/processed") |
| 94 | + tags_file = os.path.expanduser("~/videos/quadrant-tags.json") |
| 95 | + |
| 96 | + # Check dependencies |
| 97 | + if not Path(bg_image).exists(): |
| 98 | + print(f"Background image not found: {bg_image}") |
| 99 | + sys.exit(1) |
| 100 | + |
| 101 | + if not Path(tags_file).exists(): |
| 102 | + print(f"Tags file not found: {tags_file}") |
| 103 | + print("Run tag-videos.sh first to create it") |
| 104 | + sys.exit(1) |
| 105 | + |
| 106 | + # Create output directory |
| 107 | + Path(output_dir).mkdir(parents=True, exist_ok=True) |
| 108 | + |
| 109 | + # Load tags |
| 110 | + with open(tags_file) as f: |
| 111 | + tags = json.load(f) |
| 112 | + |
| 113 | + total = len(tags) |
| 114 | + print("=" * 40) |
| 115 | + print(f"Processing {total} video(s)") |
| 116 | + print(f"Background: {bg_image}") |
| 117 | + print(f"Output dir: {output_dir}") |
| 118 | + print("=" * 40) |
| 119 | + print() |
| 120 | + |
| 121 | + # Process each video |
| 122 | + for i, (filename, data) in enumerate(tags.items(), 1): |
| 123 | + presenter = data["presenter"] |
| 124 | + slides = data["slides"] |
| 125 | + input_path = data["path"] |
| 126 | + |
| 127 | + # Output filename (change extension to .mp4) |
| 128 | + output_name = Path(filename).stem + ".mp4" |
| 129 | + output_path = os.path.join(output_dir, output_name) |
| 130 | + |
| 131 | + # Skip if already processed |
| 132 | + if Path(output_path).exists(): |
| 133 | + print(f"[{i}/{total}] Skipping {filename} (already exists)") |
| 134 | + continue |
| 135 | + |
| 136 | + print(f"[{i}/{total}] Processing: {filename}") |
| 137 | + print(f" Input: {input_path}") |
| 138 | + print(f" Presenter: {presenter}") |
| 139 | + print(f" Slides: {slides}") |
| 140 | + print(f" Output: {output_path}") |
| 141 | + print(" Running FFmpeg...") |
| 142 | + |
| 143 | + if process_video(input_path, output_path, bg_image, presenter, slides): |
| 144 | + print(" Done!") |
| 145 | + else: |
| 146 | + print(" FAILED!") |
| 147 | + print() |
| 148 | + |
| 149 | + print("=" * 40) |
| 150 | + print("Processing complete!") |
| 151 | + print(f"Output directory: {output_dir}") |
| 152 | + print("=" * 40) |
| 153 | + |
| 154 | + # Show summary |
| 155 | + print() |
| 156 | + print("Processed files:") |
| 157 | + for f in Path(output_dir).glob("*.mp4"): |
| 158 | + size_mb = f.stat().st_size / (1024 * 1024) |
| 159 | + print(f" {f.name}: {size_mb:.1f} MB") |
| 160 | + |
| 161 | + |
| 162 | +if __name__ == "__main__": |
| 163 | + main() |
0 commit comments