Skip to content

Instantly share code, notes, and snippets.

@ParkWardRR
Created December 27, 2025 21:58
Show Gist options
  • Select an option

  • Save ParkWardRR/dcbe438f6068083a07ce2d5fea39b76d to your computer and use it in GitHub Desktop.

Select an option

Save ParkWardRR/dcbe438f6068083a07ce2d5fea39b76d to your computer and use it in GitHub Desktop.
macOS AV1 Batch Encoder (ffmpeg + progress + parallel)
#!/usr/bin/env bash
################################################################################
# macOS AV1 Video Batch Encoder (ffmpeg)
# Version: 2.1.1
#
# What it does:
# - Finds the first video file in ./source (next to this script) and produces
# multiple AV1 resolution variants into ./output (by default).
# - Tracks per-encode progress + ETA by parsing ffmpeg's `-progress` key/value
# file (e.g., out_time_us, speed, progress=end). This is more machine-friendly
# than scraping stderr. (See notes in the progress monitor section.)
#
# Why "macOS friendly":
# - Avoids Bash 4+ features like `mapfile` / `readarray`, so it works on the
# older Bash that ships with macOS by default.
# - Uses BSD `stat` flags (macOS) for file size.
#
# Requirements:
# - ffmpeg + ffprobe on PATH
# - Optional: terminal-notifier (preferred) for macOS notifications
# - Optional: python3 for nicer human-readable byte formatting (falls back if missing)
#
# Outputs:
# - Encoded MKV: <base>__av1_<label>.mkv
# - Log file: <base>__av1_<label>.txt (ffprobe JSON + full ffmpeg output)
# - Progress file: temporary <base>__av1_<label>.progress (deleted after encode)
################################################################################
set -euo pipefail
################################################################################
# CONFIGURATION - Enable/disable resolution variants
#
# Notes:
# - label: used in output filename
# - height: passed to scale filter; width is computed automatically to preserve AR
# - CRF/preset values are starting points; tune per content & encoder.
################################################################################
ENABLE_8K=false # 8K label: 8k, height: 4320, CRF 26, preset 6
ENABLE_4K=false # 4K label: 4k, height: 2160, CRF 28, preset 6
ENABLE_1080P=true # 1080p height: 1080, CRF 30, preset 6
ENABLE_720P=true # 720p height: 720, CRF 34, preset 7
ENABLE_480P=true # 480p height: 480, CRF 38, preset 8
################################################################################
# SCRIPT CONSTANTS
################################################################################
readonly SCRIPT_VERSION="2.1.1"
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC_DIR="${SCRIPT_DIR}/source"
OUTDIR="${SCRIPT_DIR}/output"
PARALLEL=1
NOTIFY=false
VERBOSE=false
readonly RED='\033[0;31m'
readonly GREEN='\033[0;32m'
readonly YELLOW='\033[1;33m'
readonly BLUE='\033[0;34m'
readonly NC='\033[0m'
usage() {
cat << EOF
Usage: ${SCRIPT_NAME} [OPTIONS]
AV1 video encoding with progress tracking and ETA estimation.
OPTIONS:
-i, --input DIR Source directory (default: ./source next to script)
-o, --output DIR Output directory (default: ./output next to script)
-p, --parallel NUM Parallel encodes (default: 1)
-n, --notify macOS notification on completion
-v, --verbose Extra logging
-h, --help Show help
Toggle variants:
Edit ENABLE_* variables at the top of the script.
EOF
exit 0
}
log_info() { echo -e "${BLUE}ℹ${NC} $*"; }
log_success() { echo -e "${GREEN}✓${NC} $*"; }
log_warn() { echo -e "${YELLOW}⚠${NC} $*" >&2; }
log_error() { echo -e "${RED}✗${NC} $*" >&2; }
iso_utc() { date -u +'%Y-%m-%dT%H:%M:%SZ'; }
send_notification() {
local title="$1" message="$2"
if [[ "$NOTIFY" == true ]]; then
# terminal-notifier gives better UX than osascript, but osascript works
# without extra installs.
if command -v terminal-notifier &>/dev/null; then
terminal-notifier -title "$title" -message "$message" -sound default 2>/dev/null || true
else
osascript -e "display notification \"$message\" with title \"$title\"" 2>/dev/null || true
fi
fi
}
human_bytes() {
# Prefer python3 if present; otherwise fall back to raw bytes.
if command -v python3 &>/dev/null; then
python3 - <<'PY' "$1" 2>/dev/null || echo "$1 bytes"
import sys
n = int(sys.argv[1])
units = ["B","KiB","MiB","GiB","TiB","PiB"]
i = 0
f = float(n)
while f >= 1024 and i < len(units)-1:
f /= 1024.0
i += 1
print(f"{f:.2f} {units[i]}")
PY
else
echo "$1 bytes"
fi
}
format_time() {
local sec=$1
local h=$((sec / 3600))
local m=$(((sec % 3600) / 60))
local s=$((sec % 60))
printf "%02d:%02d:%02d" "$h" "$m" "$s"
}
has_encoder() {
local enc="$1"
ffmpeg -hide_banner -encoders 2>/dev/null | grep -qE "[[:space:]]${enc}([[:space:]]|$)"
}
choose_av1_encoder() {
# Priority order: SVT-AV1 (fast), then rav1e, then libaom-av1 (often slow).
if has_encoder libsvtav1; then
echo "libsvtav1"
return 0
fi
log_warn "libsvtav1 not found, falling back to another AV1 encoder"
if has_encoder librav1e; then
log_warn "Using librav1e (slower than SVT)"
echo "librav1e"
return 0
fi
if has_encoder libaom-av1; then
log_warn "Using libaom-av1 (often slow)"
echo "libaom-av1"
return 0
fi
log_error "No AV1 encoder found (checked: libsvtav1, librav1e, libaom-av1)"
return 1
}
check_dependencies() {
local missing=()
for cmd in ffmpeg ffprobe; do
command -v "$cmd" &>/dev/null || missing+=("$cmd")
done
if (( ${#missing[@]} > 0 )); then
log_error "Missing: ${missing[*]}"
exit 1
fi
[[ "$VERBOSE" == true ]] && log_success "ffmpeg/ffprobe detected"
if ! has_encoder libsvtav1; then
log_warn "libsvtav1 not detected; will use fallback if available"
else
[[ "$VERBOSE" == true ]] && log_success "libsvtav1 detected"
fi
}
get_duration() {
# Duration in whole seconds (int). If probing fails, returns empty/0-ish.
local input="$1"
ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$input" 2>/dev/null \
| awk '{print int($1)}'
}
# Global variants array (Bash 3.2 friendly)
VARIANTS=()
build_variants() {
VARIANTS=()
[[ "$ENABLE_8K" == true ]] && VARIANTS+=("8k:4320:26:6")
[[ "$ENABLE_4K" == true ]] && VARIANTS+=("4k:2160:28:6")
[[ "$ENABLE_1080P" == true ]] && VARIANTS+=("1080p:1080:30:6")
[[ "$ENABLE_720P" == true ]] && VARIANTS+=("720p:720:34:7")
[[ "$ENABLE_480P" == true ]] && VARIANTS+=("480p:480:38:8")
if (( ${#VARIANTS[@]} == 0 )); then
log_error "No variants enabled. Set ENABLE_* = true at top of script."
exit 1
fi
}
run_encode() {
local label="$1" height="$2" crf="$3" preset="$4" input="$5" base="$6" vcodec="$7"
local out="${OUTDIR}/${base}__av1_${label}.mkv"
local log="${out%.*}.txt"
local progress_file="${out%.*}.progress"
{
echo "═══════════════════════════════════════════════════════════"
echo "AV1 Encode: ${label}"
echo "═══════════════════════════════════════════════════════════"
echo "Start: $(iso_utc)"
echo "Input: ${input}"
echo "Output: ${out}"
echo "Codec: ${vcodec} | CRF: ${crf} | Preset: ${preset} | Height: ${height}px"
echo ""
echo "═══ INPUT ffprobe (JSON) ═══"
ffprobe -v error -show_entries \
format=filename,format_long_name,duration,size,bit_rate:stream=index,codec_type,codec_name,profile,width,height,pix_fmt,avg_frame_rate,bit_rate,color_space,color_transfer,color_primaries:stream_tags=language \
-of json "$input" || true
echo ""
echo "═══ ffmpeg output (stderr/stdout merged) ═══"
} >"$log"
# Video encoder args (common)
local -a vcodec_args
vcodec_args=(-c:v "$vcodec" -crf "$crf" -preset "$preset")
# SVT-AV1 extras (safe optional defaults; tweak as desired)
if [[ "$vcodec" == "libsvtav1" ]]; then
vcodec_args+=(-svtav1-params "fast-decode=1:tune=0")
fi
local duration
duration="$(get_duration "$input")"
# Progress monitor:
# - ffmpeg writes key=value lines to the file provided by `-progress`.
# - We parse out_time_us (microseconds of output timeline) and speed (e.g. 1.23x).
# - Caveat: if filters/options change timeline (fps, -ss/-t, etc.), duration-based
# percent can be approximate.
: > "$progress_file"
local monitor_pid=""
(
local start_time
start_time=$(date +%s)
# Loop until ffmpeg removes progress file (we remove it after encode ends).
while [[ -f "$progress_file" ]]; do
if [[ -s "$progress_file" ]]; then
local out_time_us speed
out_time_us=$(grep "out_time_us=" "$progress_file" | tail -1 | cut -d'=' -f2)
speed=$(grep "speed=" "$progress_file" | tail -1 | cut -d'=' -f2 | sed 's/x//')
if [[ -n "${out_time_us:-}" && "$out_time_us" != "N/A" && "$out_time_us" -gt 0 ]]; then
local current_sec percent elapsed remaining_sec eta_sec eta
current_sec=$((out_time_us / 1000000))
percent=0
if [[ "${duration:-0}" -gt 0 ]]; then
percent=$((current_sec * 100 / duration))
[[ $percent -gt 100 ]] && percent=100
fi
eta="calculating..."
if [[ -n "${speed:-}" && "$speed" != "N/A" && "$speed" != "0" ]]; then
remaining_sec=$((duration - current_sec))
eta_sec=$(awk "BEGIN {printf \"%.0f\", $remaining_sec / $speed}")
eta=$(format_time "$eta_sec")
fi
elapsed=$(( $(date +%s) - start_time ))
printf "\r${BLUE}▶${NC} %s | %3d%% | %sx | ETA %s | Elapsed %s" \
"$label" "$percent" "${speed:-N/A}" "$eta" "$(format_time "$elapsed")"
fi
fi
sleep 2
done
echo ""
) &
monitor_pid=$!
# Cleanup if interrupted mid-encode (best-effort).
# shellcheck disable=SC2064
trap "rm -f \"$progress_file\"; [[ -n \"$monitor_pid\" ]] && kill \"$monitor_pid\" 2>/dev/null || true" INT TERM
ffmpeg -hide_banner -progress "$progress_file" -y -i "$input" \
-map_metadata 0 -map_chapters 0 \
-map 0:v:0 -map "0:a?" -map "0:s?" \
"${vcodec_args[@]}" \
-vf "scale=-2:${height}:flags=lanczos" \
-pix_fmt yuv420p10le \
-c:a libopus -b:a 128k \
-c:s copy \
"$out" >>"$log" 2>&1
local rc=$?
rm -f "$progress_file"
wait "$monitor_pid" 2>/dev/null || true
# Reset traps for subsequent variants.
trap - INT TERM
{
echo ""
echo "═══ RESULT ═══"
echo "Exit code: ${rc}"
if [[ "$rc" -eq 0 ]]; then
# Preserve timestamps so output "sorts" similarly to the input.
touch -r "$input" "$out"
# BSD/macOS stat: -f%z prints size in bytes.
local in_size out_size reduction
in_size=$(stat -f%z "$input" 2>/dev/null || echo 0)
out_size=$(stat -f%z "$out" 2>/dev/null || echo 0)
if (( in_size > 0 )); then
reduction=$(awk "BEGIN {printf \"%.1f\", (1 - $out_size / $in_size) * 100}")
echo "Size: $(human_bytes "$in_size") → $(human_bytes "$out_size") (${reduction}% reduction)"
fi
fi
echo ""
echo "End: $(iso_utc)"
echo "═══════════════════════════════════════════════════════════"
} >>"$log"
return "$rc"
}
encode_variant() {
local label="$1" height="$2" crf="$3" preset="$4" input="$5" base="$6" vcodec="$7"
if run_encode "$label" "$height" "$crf" "$preset" "$input" "$base" "$vcodec"; then
log_success "${label} completed"
return 0
else
log_error "${label} failed (see log in ${OUTDIR})"
return 1
fi
}
main() {
while [[ $# -gt 0 ]]; do
case $1 in
-i|--input) SRC_DIR="$2"; shift 2 ;;
-o|--output) OUTDIR="$2"; shift 2 ;;
-p|--parallel) PARALLEL="$2"; shift 2 ;;
-n|--notify) NOTIFY=true; shift ;;
-v|--verbose) VERBOSE=true; shift ;;
-h|--help) usage ;;
*) log_error "Unknown option: $1"; usage ;;
esac
done
# Basic validation: PARALLEL must be a positive integer.
if ! [[ "${PARALLEL}" =~ ^[0-9]+$ ]] || [[ "${PARALLEL}" -lt 1 ]]; then
log_error "--parallel must be an integer >= 1 (got: ${PARALLEL})"
exit 1
fi
SRC_DIR="${SRC_DIR/#\~/$HOME}"
OUTDIR="${OUTDIR/#\~/$HOME}"
[[ "$SRC_DIR" != /* ]] && SRC_DIR="${SCRIPT_DIR}/${SRC_DIR}"
[[ "$OUTDIR" != /* ]] && OUTDIR="${SCRIPT_DIR}/${OUTDIR}"
log_info "AV1 Batch Encoder v${SCRIPT_VERSION}"
check_dependencies
mkdir -p "$OUTDIR"
if [[ ! -d "$SRC_DIR" ]]; then
log_error "Source directory not found: ${SRC_DIR}"
exit 1
fi
# NOTE: This intentionally encodes only the first matching file.
# If you want true batch behavior, wrap this script or extend it to loop files.
local input_file
input_file="$(find "$SRC_DIR" -maxdepth 1 -type f \( -iname '*.mkv' -o -iname '*.mp4' -o -iname '*.mov' -o -iname '*.webm' -o -iname '*.avi' \) | head -n 1 || true)"
if [[ -z "${input_file:-}" ]]; then
log_error "No video files found in: ${SRC_DIR}"
exit 1
fi
local base
base="$(basename "${input_file%.*}")"
local vcodec
vcodec="$(choose_av1_encoder)"
log_info "Input: ${input_file}"
log_info "Output: ${OUTDIR}"
log_info "Encoder: ${vcodec}"
build_variants
local enabled_list=""
local v
for v in "${VARIANTS[@]}"; do
enabled_list="${enabled_list}${v%%:*} "
done
log_info "Enabled variants: ${enabled_list}"
local -a failed=()
local -a pids=()
local -a pid_labels=()
local start_time end_time total_duration
start_time=$(date +%s)
if (( PARALLEL > 1 )); then
log_info "Parallel mode: ${PARALLEL} concurrent encodes"
for v in "${VARIANTS[@]}"; do
local label height crf preset
IFS=: read -r label height crf preset <<< "$v"
# Throttle concurrency using job count.
while (( $(jobs -rp | wc -l | tr -d ' ') >= PARALLEL )); do
sleep 1
done
encode_variant "$label" "$height" "$crf" "$preset" "$input_file" "$base" "$vcodec" &
local pid=$!
pids+=("$pid")
pid_labels+=("$label")
done
local idx=0
local pid
for pid in "${pids[@]}"; do
if ! wait "$pid"; then
failed+=("${pid_labels[$idx]}")
fi
idx=$((idx + 1))
done
else
for v in "${VARIANTS[@]}"; do
local label height crf preset
IFS=: read -r label height crf preset <<< "$v"
encode_variant "$label" "$height" "$crf" "$preset" "$input_file" "$base" "$vcodec" || failed+=("$label")
done
fi
end_time=$(date +%s)
total_duration=$((end_time - start_time))
echo ""
echo "═══════════════════════════════════════════════════════════"
if (( ${#failed[@]} == 0 )); then
log_success "All encodes completed in $(format_time "$total_duration")"
send_notification "AV1 Encode Complete" "All variants encoded successfully"
else
log_error "Failed: ${failed[*]}"
log_info "Check logs in: ${OUTDIR}"
send_notification "AV1 Encode Failed" "${#failed[@]} variant(s) failed"
exit 1
fi
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment