Skip to content

Instantly share code, notes, and snippets.

@basperheim
Created November 28, 2025 18:51
Show Gist options
  • Select an option

  • Save basperheim/dc9e31bcf0e48dc9a4c5f84067a3f747 to your computer and use it in GitHub Desktop.

Select an option

Save basperheim/dc9e31bcf0e48dc9a4c5f84067a3f747 to your computer and use it in GitHub Desktop.
Turn macOS Screen Recordings into GIFs and Screenshots
#!/usr/bin/env python3
"""
Batch-convert .mov/.MOV QuickTime videos in the current directory:
1. Find all .mov/.MOV files.
2. For each:
- Convert to MP4 (H.264, no audio, slightly reduced quality).
- Generate a high-quality GIF from the MP4.
- Extract JPEG screenshots from the GIF every 1 second.
- If everything succeeds, delete the original .mov/.MOV file.
Requirements:
- ffmpeg must be installed and available on PATH.
WARNING:
- This script WILL delete the original .mov/.MOV after successful processing.
Set DELETE_ORIGINALS = False below if you want to keep them.
"""
import logging
import subprocess
from pathlib import Path
import shutil
import sys
# --- Config -----------------------------------------------------------------
DELETE_ORIGINALS = True
# CRF ~18 is visually lossless, 23 is a good "slightly reduced quality" default.
VIDEO_CRF = "23"
# GIF fps; lower = smaller file size, but choppier motion.
GIF_FPS = "12"
# JPEG quality: 2–5 is usually fine (lower number = higher quality).
JPEG_QUALITY = "3"
# ----------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
def ensure_ffmpeg_available() -> None:
if shutil.which("ffmpeg") is None:
logging.error("ffmpeg not found on PATH. Please install ffmpeg first.")
sys.exit(1)
logging.info("ffmpeg found on PATH.")
def run_ffmpeg(cmd: list[str], step_desc: str) -> bool:
logging.info("Starting: %s", step_desc)
logging.debug("Command: %s", " ".join(cmd))
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result.returncode != 0:
logging.error("ffmpeg failed during: %s", step_desc)
logging.error("stderr:\n%s", result.stderr)
return False
logging.info("Completed: %s", step_desc)
logging.debug("ffmpeg stdout:\n%s", result.stdout)
return True
def find_quicktime_files() -> list[Path]:
exts = {".mov", ".MOV"} # extend here if you want more QuickTime-style extensions.
files = [p for p in Path(".").iterdir() if p.is_file() and p.suffix in exts]
return sorted(files)
def process_video(path: Path) -> None:
logging.info("------------------------------------------------------------")
logging.info("Processing file: %s", path.name)
stem = path.stem
parent = path.parent
mp4_path = parent / f"{stem}.mp4"
gif_path = parent / f"{stem}.gif"
palette_path = parent / f"{stem}_palette.png"
jpeg_pattern = str(parent / f"{stem}_frame_%03d.jpg")
# 1) Convert MOV -> MP4 (no audio, slightly reduced quality, same resolution/aspect).
# Ensure even dimensions for H.264 with scale filter.
cmd_mp4 = [
"ffmpeg",
"-hide_banner",
"-y", # overwrite output if exists
"-i",
str(path),
"-an", # no audio
"-c:v",
"libx264",
"-preset",
"medium",
"-crf",
VIDEO_CRF,
"-vf",
"scale=trunc(iw/2)*2:trunc(ih/2)*2",
"-movflags",
"+faststart",
str(mp4_path),
]
if not run_ffmpeg(cmd_mp4, f"Convert {path.name} to MP4"):
logging.error("Skipping further steps for %s due to MP4 conversion failure.", path.name)
return
# 2) Generate palette for high-quality GIF from MP4.
cmd_palette = [
"ffmpeg",
"-y",
"-i",
str(mp4_path),
"-vf",
f"fps={GIF_FPS},scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=lanczos,palettegen",
str(palette_path),
]
if not run_ffmpeg(cmd_palette, f"Generate GIF palette for {mp4_path.name}"):
logging.error("Skipping GIF and JPEG steps for %s due to palette generation failure.", path.name)
return
# 3) Use palette to generate high-quality GIF.
cmd_gif = [
"ffmpeg",
"-y",
"-i",
str(mp4_path),
"-i",
str(palette_path),
"-lavfi",
f"fps={GIF_FPS},scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=lanczos,paletteuse",
str(gif_path),
]
if not run_ffmpeg(cmd_gif, f"Create GIF for {mp4_path.name}"):
logging.error("Skipping JPEG steps for %s due to GIF creation failure.", path.name)
return
# Remove temporary palette file (non-fatal if this fails).
if palette_path.exists():
try:
palette_path.unlink()
logging.info("Removed temporary palette file: %s", palette_path.name)
except OSError as e:
logging.warning("Could not delete palette file %s: %s", palette_path.name, e)
# 4) Extract JPEGs from GIF every 1 second.
cmd_jpegs = [
"ffmpeg",
"-y",
"-i",
str(gif_path),
"-vf",
"fps=1",
"-q:v",
JPEG_QUALITY,
jpeg_pattern,
]
if not run_ffmpeg(cmd_jpegs, f"Extract JPEG frames from {gif_path.name} (every 1s)"):
logging.error("JPEG extraction failed for %s. Not deleting original.", path.name)
return
# 5) Delete original .mov/.MOV if everything succeeded.
if DELETE_ORIGINALS:
try:
path.unlink()
logging.info("Deleted original file: %s", path.name)
except OSError as e:
logging.error("Failed to delete original file %s: %s", path.name, e)
else:
logging.info("DELETE_ORIGINALS=False; keeping original file: %s", path.name)
def main() -> None:
ensure_ffmpeg_available()
files = find_quicktime_files()
if not files:
logging.info("No .mov/.MOV files found in current directory.")
return
logging.info("Found %d QuickTime file(s) to process.", len(files))
for f in files:
try:
process_video(f)
except KeyboardInterrupt:
logging.warning("Interrupted by user. Stopping.")
break
except Exception as e:
logging.exception("Unexpected error while processing %s: %s", f.name, e)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment