Created
December 27, 2025 21:58
-
-
Save ParkWardRR/dcbe438f6068083a07ce2d5fea39b76d to your computer and use it in GitHub Desktop.
macOS AV1 Batch Encoder (ffmpeg + progress + parallel)
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 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