RRM logo Ops Team

Compress .MOV files

Category: Development
Language: python
Created by: lvcaspacifico
Created At: Mar 10, 2024
Updated: Mar 10, 2024

Observations

Please pay attention to the comment section in the code, it contains instructions on how to run the script.

Script

"""
Video compressor
----------------
Finds all .mov files recursively under a folder and compresses any that
are over a size threshold (default 90 MB) to bring them safely under
GitHub's 100 MB hard limit.

Output goes to a "compressed/" subfolder mirroring the original structure.
Originals are never touched.

Encoding: H.264 video + AAC audio inside a .mov container.
Uses CRF (Constant Rate Factor) — quality-based encoding, not a fixed
bitrate. CRF 23 is FFmpeg's default and looks great for social/web video.
Lower = better quality + bigger file. Raise to 26-28 if files are still too big.

Requirements:
FFmpeg installed and available on PATH
https://ffmpeg.org/download.php  (Windows: get the build from gyan.dev)

Usage:
python compress_videos.py                        # processes current folder
python compress_videos.py "C:/path/to/folder"   # processes specific folder
"""

import os
import sys
import shutil
import subprocess

SIZE_THRESHOLD_MB = 19         # only compress files larger than this
TARGET_MAX_MB     = 19         # aim to stay under this (used for fallback)
CRF               = 24          # quality: 18 (great) → 28 (smaller/lower)
OUTPUT_DIR_NAME   = "compressed"


# ── Helpers ───────────────────────────────────────────────────────────────────

def check_ffmpeg():
    if not shutil.which('ffmpeg'):
        print("""
Error: FFmpeg not found on PATH.

Install it from: https://ffmpeg.org/download.php
Windows: download the 'full' build from https://www.gyan.dev/ffmpeg/builds/
Extract it, then add the bin/ folder to your system PATH.
Restart your terminal after doing this.
""")
        sys.exit(1)


def find_mov_files(folder: str):
    results = []
    for root, _, files in os.walk(folder):
        # Don't recurse into our own output folder
        if OUTPUT_DIR_NAME in root.split(os.sep):
            continue
        for f in files:
            if f.lower().endswith('.mov'):
                results.append(os.path.join(root, f))
    return results


def file_mb(path: str) -> float:
    return os.path.getsize(path) / (1024 * 1024)


def compress(src: str, dst: str, crf: int) -> bool:
    os.makedirs(os.path.dirname(dst), exist_ok=True)
    cmd = [
        'ffmpeg',
        '-i', src,
        '-c:v', 'libx264',      # H.264 video
        '-crf', str(crf),       # quality factor
        '-preset', 'slow',      # slower = better compression at same quality
        '-c:a', 'aac',          # AAC audio
        '-b:a', '128k',         # audio bitrate
        '-movflags', '+faststart',  # web-friendly: moov atom at front
        '-y',                   # overwrite output without asking
        dst
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"    [ffmpeg error]\n{result.stderr[-800:]}")
        return False
    return True


# ── Main ──────────────────────────────────────────────────────────────────────

def main():
    folder = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
    folder = os.path.abspath(folder)

    if not os.path.isdir(folder):
        print(f"Error: '{folder}' is not a valid directory.")
        sys.exit(1)

    check_ffmpeg()

    mov_files = find_mov_files(folder)
    if not mov_files:
        print(f"No .mov files found under: {folder}")
        sys.exit(0)

    out_root = os.path.join(folder, OUTPUT_DIR_NAME)

    # Split into needs-compression vs small-enough
    to_compress = [f for f in mov_files if file_mb(f) > SIZE_THRESHOLD_MB]
    small       = [f for f in mov_files if file_mb(f) <= SIZE_THRESHOLD_MB]

    print(f"Source:  {folder}")
    print(f"Output:  {out_root}")
    print(f"Found {len(mov_files)} .mov file(s) — {len(to_compress)} need compression, {len(small)} already small enough\n")

    ok = failed = skipped = 0

    # ── Files that need compressing ───────────────────────────────────────────
    if to_compress:
        print("── Compressing ──────────────────────────────────────")
    for src in to_compress:
        rel     = os.path.relpath(src, folder)
        dst     = os.path.join(out_root, rel)
        name    = os.path.basename(src)
        size_in = file_mb(src)

        if os.path.exists(dst):
            print(f"  SKIP  {name}  (already in output folder)")
            skipped += 1
            continue

        print(f"  ...   {name}  ({size_in:.1f} MB)  CRF {CRF}")
        if compress(src, dst, CRF):
            size_out = file_mb(dst)
            saved    = size_in - size_out
            flag     = "  ⚠ still over 100MB — raise CRF" if size_out > 100 else ""
            print(f"  OK    {name}{size_out:.1f} MB  (saved {saved:.1f} MB){flag}")
            ok += 1
        else:
            print(f"  FAIL  {name}")
            failed += 1

    # ── Files that are already small — just copy ──────────────────────────────
    if small:
        print("\n── Already small enough (copying as-is) ─────────────")
    for src in small:
        rel  = os.path.relpath(src, folder)
        dst  = os.path.join(out_root, rel)
        name = os.path.basename(src)

        if os.path.exists(dst):
            print(f"  SKIP  {name}  (already in output folder)")
            skipped += 1
            continue

        os.makedirs(os.path.dirname(dst), exist_ok=True)
        shutil.copy2(src, dst)
        print(f"  OK    {name}  ({file_mb(src):.1f} MB)  copied")
        ok += 1

    print(f"""
Done.  {ok} file(s) processed
    {skipped} skipped
    {failed} failed
""")

    if failed:
        print("Tip: re-run the script — failed files will be retried.")


if __name__ == '__main__':
    main()