Wednesday, January 7, 2026

macOS: Capture Video's Frames using Bash

The blog will cover how to screenshot a video on macOS using Bash. Videos typically run at 24 FPS or 30 FPS, so taking a screenshot every second will likely capture a representative frame of the video. macOS provides a command-line utility, screencapture, that takes screenshots.

The man page for screencapture is viewed as follows:

man screencapture

Below is a screenshot showing the key command-line arguments of screencapture:

When running screencapture from a macOS terminal console, a window may be displayed by the system. Permission will have to be given for Terminal to access the computer’s screen and audio. Similarly, when running screencapture in a Bash script from Visual Studio Code, the same permissions will have to be granted to Visual Studio Code.

To screenshot a video, run the Bash script. Then display the video full screen. The script will capture an image each second. By default, the script runs for 26 minutes, as a great many shows are 24 minutes long. A command line is provided to change the script duration.

If you are reading this, you are a developer. The command line for the script is readable in the comments. The script is less than two hundred lines of code, but most of the work is processing the command line.

Here is the script in its entirety:

#!/usr/bin/env bash
# Version 0.0 January 1, 2025 
# orignal release
# Version 0.1 January 2, 2025
# Made screen capture loop deduct processing time to hit closer to 1 second
# Screenshot Capture Tool (macOS)
#
# Purpose
# -------
# Captures full-screen screenshots at a fixed interval for a fixed duration and
# writes them into a deterministic, hierarchical folder structure suitable for
# show / episodic video processing workflows.
#
# The tool is designed to capture a complete episode into a "raw" frame set that
# can later be post-processed (pruning, cropping, encoding, etc.).
#
#
# Folder Structure
# ----------------
# The output directory structure is built from the provided parameters as follows:
#
#   <CONTENT_ROOT>/
#     <SHOW_NAME>/
#       Season<SS>/
#         EP<EE>/
#           raw/
#             000000.png
#             000001.png
#             000002.png
#             ...
#
# Where:
#   <SS> = zero-padded season number (2 digits)
#   <EE> = zero-padded episode number (2 digits)
#
#
# Example
# -------
# Given:
#   CONTENT_ROOT = ~/ShowContent
#   SHOW_NAME   = Frieren
#   SEASON       = 1
#   EPISODE      = 2
#
# The resulting output directory will be:
#
#   ~/ShowContent/Frieren/Season01/EP02/raw/
#
# With files:
#
#   000000.png
#   000001.png
#   000002.png
#   ...
#
#
# File Naming
# -----------
# - Frames are named sequentially using a zero-padded counter:
#     %06d.png
# - Example: 000000.png, 000001.png, 000002.png
# - Existing files are never overwritten; the script exits on collision.
#
#
# Required Parameters
# -------------------
#   -n <show name>  Show name (folder name, verbatim)
#   -s <season>     Season number (integer)
#   -e <episode>    Episode number (integer)
#
#
# Optional Parameters
# -------------------
#   -r <root>      Content root directory (default: ~/ShowContent)
#   -i <seconds>   Capture interval in seconds (default: 1)
#   -d <minutes>   Total capture duration in minutes (default: 26)
#
#
# Notes
# -----
# - Uses macOS `screencapture` (same pipeline as Cmd+Shift+3)
# - Requires screen recording permission for the shell/terminal
# - Intended for deterministic, repeatable frame extraction
#

set -euo pipefail

CONTENT_ROOT="$HOME/ShowContent"
CAPTURE_INTERVAL_SECONDS=1
RUN_DURATION_MINUTES=26
RUN_DURATION_SECONDS=$((RUN_DURATION_MINUTES * 60))

SHOW_NAME=""
SEASON_NUMBER=""
EPISODE_NUMBER=""
RAW_FOLDER_NAME="raw"

usage() {
  echo "Usage: $0 -n <show name> -s <season> -e <episode> [-r <root>] [-i <interval>] [-d <duration>]"
  exit 1
}

while getopts "n:s:e:r:i:d:" opt; do
  case "$opt" in
    n) SHOW_NAME="$OPTARG" ;;
    s) SEASON_NUMBER="$OPTARG" ;;
    e) EPISODE_NUMBER="$OPTARG" ;;
    r) CONTENT_ROOT="$OPTARG" ;;
    i) CAPTURE_INTERVAL_SECONDS="$OPTARG" ;;
    d) RUN_DURATION_MINUTES="$OPTARG"
      RUN_DURATION_SECONDS=$((RUN_DURATION_MINUTES * 60))
      ;;
    *) usage ;;
  esac
done

if [[ -z "$SHOW_NAME" || -z "$SEASON_NUMBER" || -z "$EPISODE_NUMBER" ]]; then
  usage
fi

CONTENT_ROOT=$(cd "$(dirname "$CONTENT_ROOT")" && pwd)/$(basename "$CONTENT_ROOT")
if [[ "$CONTENT_ROOT" == /Volumes/* ]]; then
  VOLUME_ROOT="/Volumes/$(basename "$(dirname "$CONTENT_ROOT")")"

  if [ ! -d "$VOLUME_ROOT" ]; then
    echo "External drive not mounted: $VOLUME_ROOT" >&2
    exit 1
  fi
fi

if ! mkdir -p "$CONTENT_ROOT"; then
  echo "Failed to create content root: $CONTENT_ROOT" >&2
  exit 1
fi

SEASON_TAG=$(printf '%02d' "$SEASON_NUMBER")
EPISODE_TAG=$(printf '%02d' "$EPISODE_NUMBER")

RAW_DIR="$CONTENT_ROOT/$SHOW_NAME/Season$SEASON_TAG/EP$EPISODE_TAG/$RAW_FOLDER_NAME"

mkdir -p "$RAW_DIR"

printf 'Starting capture\n'
printf '  Show        : %s\n' "$SHOW_NAME"
printf '  Season       : %02d\n' "$SEASON_NUMBER"
printf '  Episode      : %02d\n' "$EPISODE_NUMBER"
printf '  Content root : %s\n' "$CONTENT_ROOT"
printf '  Output dir   : %s\n' "$RAW_DIR"
printf '  Interval (s) : %d\n' "$CAPTURE_INTERVAL_SECONDS"
printf '  Duration (m) : %d\n' "$RUN_DURATION_MINUTES"
printf '\n'

START_TIME_SECONDS=$SECONDS
END_TIME_SECONDS=$((START_TIME_SECONDS + RUN_DURATION_SECONDS))
FRAME_COUNTER=0
LAST_STATUS_SECONDS=0

while (( SECONDS < END_TIME_SECONDS )); do
  ITERATION_START=$SECONDS
  FRAME_NAME=$(printf '%06d.png' "$FRAME_COUNTER")
  FILE_PATH="$RAW_DIR/$FRAME_NAME"

  if [ -e "$FILE_PATH" ]; then
    echo "Frame file already exists: $FILE_PATH" >&2
    exit 1
  fi

  screencapture -x "$FILE_PATH"
  if [ ! -s "$FILE_PATH" ]; then
    echo "Screenshot failed or file not written: $FILE_PATH" >&2
    exit 1
  fi

  FRAME_COUNTER=$((FRAME_COUNTER + 1))

  if (( SECONDS - LAST_STATUS_SECONDS >= 30 )); then
    ELAPSED=$((SECONDS - START_TIME_SECONDS))
    printf '[%02d:%02d] frames=%d last=%s\n' \
      $((ELAPSED/60)) $((ELAPSED%60)) "$FRAME_COUNTER" "$FRAME_NAME"
    LAST_STATUS_SECONDS=$SECONDS
  fi

  ITERATION_END=$SECONDS
  PROCESSING_TIME=$((ITERATION_END - ITERATION_START))
  SLEEP_TIME=$((CAPTURE_INTERVAL_SECONDS - PROCESSING_TIME))

  if (( SLEEP_TIME > 0 )); then
    sleep "$SLEEP_TIME"
  fi
done