Created
November 28, 2025 18:51
-
-
Save basperheim/dc9e31bcf0e48dc9a4c5f84067a3f747 to your computer and use it in GitHub Desktop.
Turn macOS Screen Recordings into GIFs and Screenshots
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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