#!/usr/bin/env python3 """ Video Compressor - Compress a video to a target file size. Requires ffmpeg and ffprobe to be installed and on your PATH. Windows: https://ffmpeg.org/download.html (or `winget install ffmpeg`) Mac: brew install ffmpeg Linux: sudo apt install ffmpeg Usage: python compress_video.py input.mp4 -o output.mp4 --target-size 1000 python compress_video.py input.mp4 --target-size 1024 --audio-bitrate 128 Notes: - target-size is in MEGABYTES (MB). 1000 MB ≈ 0.98 GB (using 1 MB = 1,048,576 bytes). - Uses two-pass H.264 encoding for accurate size targeting and better quality than single-pass at the same bitrate. - For very large size reductions (e.g. 10GB -> 1GB), expect a real quality drop — that's an ~90% size cut. The script will warn you and let you proceed anyway. """ import argparse import json import os import shutil import subprocess import sys def check_ffmpeg(): for tool in ("ffmpeg", "ffprobe"): if shutil.which(tool) is None: sys.exit( f"Error: '{tool}' not found on PATH.\n" f"Install ffmpeg first (see script header for instructions)." ) def get_duration_seconds(input_path): result = subprocess.run( [ "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "json", input_path, ], capture_output=True, text=True, check=True, ) data = json.loads(result.stdout) return float(data["format"]["duration"]) def human_size(num_bytes): for unit in ("B", "KB", "MB", "GB"): if abs(num_bytes) < 1024.0: return f"{num_bytes:.2f} {unit}" num_bytes /= 1024.0 return f"{num_bytes:.2f} TB" def main(): parser = argparse.ArgumentParser(description="Compress a video to a target file size using ffmpeg two-pass encoding.") parser.add_argument("input", help="Path to the input video file") parser.add_argument("-o", "--output", help="Path to the output file (default: _compressed.mp4)") parser.add_argument("--target-size", type=float, default=1024, help="Target output size in MB (default: 1024 = ~1GB)") parser.add_argument("--audio-bitrate", type=int, default=128, help="Audio bitrate in kbps (default: 128)") parser.add_argument("--min-video-bitrate", type=int, default=100, help="Floor for video bitrate in kbps, to avoid unwatchable output (default: 100)") parser.add_argument("-y", "--yes", action="store_true", help="Don't ask for confirmation before a large quality cut") args = parser.parse_args() check_ffmpeg() input_path = args.input if not os.path.isfile(input_path): sys.exit(f"Error: input file not found: {input_path}") output_path = args.output or ( os.path.splitext(input_path)[0] + "_compressed.mp4" ) original_size = os.path.getsize(input_path) duration = get_duration_seconds(input_path) target_bytes = args.target_size * 1024 * 1024 target_bits = target_bytes * 8 audio_bitrate_kbps = args.audio_bitrate audio_bits_total = audio_bitrate_kbps * 1000 * duration # Leave a small safety margin (2%) since container overhead / rate control # isn't perfectly exact. available_bits_for_video = (target_bits - audio_bits_total) * 0.98 video_bitrate_kbps = int(available_bits_for_video / duration / 1000) reduction_pct = (1 - target_bytes / original_size) * 100 if original_size else 0 print(f"Input: {input_path}") print(f"Original size: {human_size(original_size)}") print(f"Duration: {duration/60:.1f} minutes") print(f"Target size: {args.target_size:.0f} MB (~{reduction_pct:.0f}% reduction)") print(f"Audio bitrate: {audio_bitrate_kbps} kbps") print(f"Computed video bitrate: {video_bitrate_kbps} kbps") if video_bitrate_kbps < args.min_video_bitrate: print( f"\nWarning: to hit {args.target_size:.0f} MB, video bitrate would need to be " f"{video_bitrate_kbps} kbps, below the {args.min_video_bitrate} kbps floor. " f"Quality will likely be poor (blocky/blurry).\n" ) video_bitrate_kbps = args.min_video_bitrate if reduction_pct > 80 and not args.yes: resp = input( f"\nThis is a ~{reduction_pct:.0f}% size reduction, which is a heavy compression pass " f"and will visibly reduce quality. Continue? [y/N] " ) if resp.strip().lower() != "y": print("Cancelled.") return passlog_prefix = os.path.join( os.path.dirname(os.path.abspath(output_path)) or ".", "ffmpeg2pass" ) common_args = [ "-i", input_path, "-c:v", "libx264", "-preset", "slow", "-b:v", f"{video_bitrate_kbps}k", "-maxrate", f"{int(video_bitrate_kbps * 1.5)}k", "-bufsize", f"{int(video_bitrate_kbps * 2)}k", "-c:a", "aac", "-b:a", f"{audio_bitrate_kbps}k", ] print("\n--- Pass 1/2 (analyzing) ---") subprocess.run( [ "ffmpeg", "-y", *common_args, "-pass", "1", "-passlogfile", passlog_prefix, "-an", "-f", "mp4", os.devnull if os.name != "nt" else "NUL", ], check=True, ) print("\n--- Pass 2/2 (encoding) ---") subprocess.run( [ "ffmpeg", "-y", *common_args, "-pass", "2", "-passlogfile", passlog_prefix, output_path, ], check=True, ) # Clean up pass log files for suffix in ("-0.log", "-0.log.mbtree"): f = passlog_prefix + suffix if os.path.exists(f): os.remove(f) final_size = os.path.getsize(output_path) print(f"\nDone. Output: {output_path}") print(f"Final size: {human_size(final_size)} (target was {args.target_size:.0f} MB)") if __name__ == "__main__": main()