#!/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()