Skip to content

Instantly share code, notes, and snippets.

@huyaoyu
Created July 2, 2026 18:40
Show Gist options
  • Select an option

  • Save huyaoyu/22bb4a7d0a8432df006f25665439459b to your computer and use it in GitHub Desktop.

Select an option

Save huyaoyu/22bb4a7d0a8432df006f25665439459b to your computer and use it in GitHub Desktop.
Transcode video using HandBrakeCLI on macOS
#!/usr/bin/env bash
#
# transcode.sh — batch video transcoding with HandBrakeCLI on macOS
#
# Usage:
# ./transcode.sh [options] <input_folder> <output_folder>
#
# Options:
# -f, --force Overwrite output files that already exist (default: skip)
# -n, --dry-run Show what would be done; do not transcode anything
# -p, --preset P HandBrake preset to use (default: "Fast 1080p30")
# -h, --help Show this help and exit
#
# Behaviour:
# * Recursively finds common video files in the input folder.
# * Output filenames get a " - hb" suffix, e.g. "abc.mp4" -> "abc - hb.mp4".
# Outputs are written flat into the output folder.
# * By default an existing output file is skipped; use --force to overwrite.
# * Prints live progress (counts, sizes, %, elapsed, ETA) and a final summary.
# * Writes a timestamped plain-text log into the output folder (never
# overwriting an existing log).
#
set -uo pipefail
# --------------------------------------------------------------------------- #
# Defaults / configuration
# --------------------------------------------------------------------------- #
PRESET="Fast 1080p30"
FORCE=0
DRYRUN=0
INPUT=""
OUTPUT=""
# Video extensions we consider (case-insensitive).
EXTENSIONS=(mp4 mkv mov avi m4v wmv flv webm mpg mpeg ts m2ts vob 3gp ogv)
PROG="$(basename "$0")"
# --------------------------------------------------------------------------- #
# Small helpers
# --------------------------------------------------------------------------- #
usage() {
sed -n '3,26p' "$0" | sed 's/^# \{0,1\}//'
}
die() {
echo "$PROG: error: $*" >&2
exit 1
}
# Bytes -> decimal GB, 2 decimals.
to_gb() { awk -v b="${1:-0}" 'BEGIN{printf "%.2f", b/1000000000}'; }
# a, b -> percentage of a within b, 1 decimal.
pct() {
awk -v a="${1:-0}" -v b="${2:-0}" \
'BEGIN{ if (b==0) {printf "0.0"} else {printf "%.1f", a*100.0/b} }'
}
# seconds -> H:MM:SS
fmt_time() {
awk -v s="${1:-0}" 'BEGIN{
s=int(s+0.5); if (s<0) s=0;
h=int(s/3600); m=int((s%3600)/60); sec=s%60;
printf "%d:%02d:%02d", h, m, sec
}'
}
# byte size of a file (macOS/BSD stat).
fsize() { stat -f%z "$1" 2>/dev/null || echo 0; }
# epoch seconds
now() { date +%s; }
# Append a line to the log file and echo it to the terminal.
log() {
printf '%s\n' "$*"
if [[ -n "${LOGFILE:-}" ]]; then
printf '%s\n' "$*" >> "$LOGFILE"
fi
}
# --------------------------------------------------------------------------- #
# Argument parsing
# --------------------------------------------------------------------------- #
while [[ $# -gt 0 ]]; do
case "$1" in
-f|--force) FORCE=1; shift ;;
-n|--dry-run) DRYRUN=1; shift ;;
-p|--preset) [[ $# -ge 2 ]] || die "--preset needs a value"; PRESET="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) die "unknown option: $1 (use --help)" ;;
*)
if [[ -z "$INPUT" ]]; then INPUT="$1"
elif [[ -z "$OUTPUT" ]]; then OUTPUT="$1"
else die "too many positional arguments: $1"
fi
shift ;;
esac
done
# Any remaining args after "--"
while [[ $# -gt 0 ]]; do
if [[ -z "$INPUT" ]]; then INPUT="$1"
elif [[ -z "$OUTPUT" ]]; then OUTPUT="$1"
else die "too many positional arguments: $1"
fi
shift
done
[[ -n "$INPUT" ]] || { usage; exit 1; }
[[ -n "$OUTPUT" ]] || { usage; exit 1; }
[[ -d "$INPUT" ]] || die "input folder does not exist: $INPUT"
# Locate HandBrakeCLI: honor $HANDBRAKE_CLI, then PATH, then common install spots.
HB="${HANDBRAKE_CLI:-}"
if [[ -z "$HB" ]]; then
if command -v HandBrakeCLI >/dev/null 2>&1; then
HB="$(command -v HandBrakeCLI)"
else
for cand in \
/opt/homebrew/bin/HandBrakeCLI \
/usr/local/bin/HandBrakeCLI \
/Applications/HandBrake.app/Contents/MacOS/HandBrakeCLI \
"$HOME/Applications/HandBrake.app/Contents/MacOS/HandBrakeCLI"; do
[[ -x "$cand" ]] && { HB="$cand"; break; }
done
fi
fi
[[ -n "$HB" && -x "$HB" ]] || die "HandBrakeCLI not found. Set HANDBRAKE_CLI=/path/to/HandBrakeCLI"
# --------------------------------------------------------------------------- #
# Prepare output folder + log file
# --------------------------------------------------------------------------- #
if [[ $DRYRUN -eq 0 ]]; then
mkdir -p "$OUTPUT" || die "could not create output folder: $OUTPUT"
else
# For dry-run we don't create anything, but we still want to reason about it.
[[ -d "$OUTPUT" ]] || echo "$PROG: note: output folder does not exist yet (would be created): $OUTPUT" >&2
fi
STAMP="$(date +%Y%m%d-%H%M%S)"
if [[ $DRYRUN -eq 0 ]]; then
LOGFILE="$OUTPUT/transcode-$STAMP.log"
# Guarantee we never clobber an existing log.
n=1
while [[ -e "$LOGFILE" ]]; do
LOGFILE="$OUTPUT/transcode-$STAMP-$n.log"
n=$((n+1))
done
: > "$LOGFILE" || die "cannot write log file: $LOGFILE"
else
LOGFILE=""
fi
# --------------------------------------------------------------------------- #
# Discover video files
# --------------------------------------------------------------------------- #
find_args=()
for i in "${!EXTENSIONS[@]}"; do
[[ $i -gt 0 ]] && find_args+=(-o)
find_args+=(-iname "*.${EXTENSIONS[$i]}")
done
FILES=()
while IFS= read -r -d '' f; do
FILES+=("$f")
done < <(find "$INPUT" -type f \( "${find_args[@]}" \) -print0 | LC_ALL=C sort -z)
[[ ${#FILES[@]} -gt 0 ]] || die "no video files found in: $INPUT"
# --------------------------------------------------------------------------- #
# Build the job queue: decide skip vs. transcode, tally sizes.
# --------------------------------------------------------------------------- #
# Parallel arrays describing every discovered file.
declare -a IN_PATH OUT_PATH IN_SIZE ACTION # ACTION: transcode | skip | overwrite
TOTAL_FILES=${#FILES[@]}
QUEUE_BYTES=0 # bytes of files we will actually transcode
QUEUE_COUNT=0
SKIP_COUNT=0
SKIP_BYTES=0
for f in "${FILES[@]}"; do
name="$(basename "$f")"
base="${name%.*}"
ext="${name##*.}"
out="$OUTPUT/$base - hb.$ext"
sz="$(fsize "$f")"
if [[ -e "$out" ]]; then
if [[ $FORCE -eq 1 ]]; then
action="overwrite"
else
action="skip"
fi
else
action="transcode"
fi
IN_PATH+=("$f")
OUT_PATH+=("$out")
IN_SIZE+=("$sz")
ACTION+=("$action")
if [[ "$action" == "skip" ]]; then
SKIP_COUNT=$((SKIP_COUNT+1))
SKIP_BYTES=$((SKIP_BYTES+sz))
else
QUEUE_COUNT=$((QUEUE_COUNT+1))
QUEUE_BYTES=$((QUEUE_BYTES+sz))
fi
done
# --------------------------------------------------------------------------- #
# Header
# --------------------------------------------------------------------------- #
log "============================================================"
log " HandBrake batch transcode"
log " Started : $(date '+%Y-%m-%d %H:%M:%S')"
log " Input : $INPUT"
log " Output : $OUTPUT"
log " Preset : $PRESET"
log " Encoder : $HB"
log " Mode : $([[ $DRYRUN -eq 1 ]] && echo 'DRY RUN' || echo 'live') | Force overwrite: $([[ $FORCE -eq 1 ]] && echo yes || echo no)"
log "------------------------------------------------------------"
log " Files found : $TOTAL_FILES"
log " To transcode : $QUEUE_COUNT ($(to_gb "$QUEUE_BYTES") GB)"
log " To skip (exists) : $SKIP_COUNT ($(to_gb "$SKIP_BYTES") GB)"
log "============================================================"
# --------------------------------------------------------------------------- #
# Dry run: just report the plan and exit.
# --------------------------------------------------------------------------- #
if [[ $DRYRUN -eq 1 ]]; then
log ""
log "Planned actions:"
for i in "${!IN_PATH[@]}"; do
printf ' [%-9s] %s (%s GB)\n' "${ACTION[$i]}" "$(basename "${IN_PATH[$i]}")" "$(to_gb "${IN_SIZE[$i]}")"
done
log ""
log "Dry run summary:"
log " Would transcode : $QUEUE_COUNT file(s), $(to_gb "$QUEUE_BYTES") GB"
log " Would skip : $SKIP_COUNT file(s), $(to_gb "$SKIP_BYTES") GB"
log " (No time estimate available until a real run measures throughput.)"
exit 0
fi
# --------------------------------------------------------------------------- #
# Transcode loop
# --------------------------------------------------------------------------- #
# Summary bookkeeping.
declare -a R_NAME R_STATUS R_BEFORE R_AFTER R_SECS
done_count=0
done_bytes=0
ok_count=0
fail_count=0
overwrite_count=0
RUN_START="$(now)"
for i in "${!IN_PATH[@]}"; do
in="${IN_PATH[$i]}"
out="${OUT_PATH[$i]}"
sz="${IN_SIZE[$i]}"
action="${ACTION[$i]}"
bn="$(basename "$in")"
if [[ "$action" == "skip" ]]; then
log ""
log ">> SKIP $bn (output already exists)"
R_NAME+=("$bn"); R_STATUS+=("skipped"); R_BEFORE+=("$sz"); R_AFTER+=("0"); R_SECS+=("0")
continue
fi
# ---- Live status block (before this job) -----------------------------
elapsed=$(( $(now) - RUN_START ))
remaining_bytes=$(( QUEUE_BYTES - done_bytes ))
remaining_files=$(( QUEUE_COUNT - done_count ))
if [[ $done_bytes -gt 0 && $elapsed -gt 0 ]]; then
eta=$(awk -v rb="$remaining_bytes" -v db="$done_bytes" -v el="$elapsed" \
'BEGIN{ rate=db/el; if(rate<=0){print 0}else{printf "%d", rb/rate} }')
rate_mb=$(awk -v db="$done_bytes" -v el="$elapsed" 'BEGIN{printf "%.1f", (db/el)/1000000}')
eta_str="$(fmt_time "$eta")"
else
eta_str="calculating..."
rate_mb="--"
fi
log ""
log "------------------------------------------------------------"
log "File $((done_count+1)) of $QUEUE_COUNT | elapsed $(fmt_time "$elapsed") | ETA $eta_str"
log " Done : $done_count/$QUEUE_COUNT files, $(to_gb "$done_bytes") GB ($(pct "$done_bytes" "$QUEUE_BYTES")%)"
log " Remain : $remaining_files files, $(to_gb "$remaining_bytes") GB ($(pct "$remaining_bytes" "$QUEUE_BYTES")%)"
log " Speed : ${rate_mb} MB/s (measured)"
log " $([[ "$action" == "overwrite" ]] && echo 'OVERWRITE' || echo 'ENCODE') : $bn ($(to_gb "$sz") GB)"
log "------------------------------------------------------------"
# ---- Run HandBrakeCLI -------------------------------------------------
# Per-file HandBrakeCLI log: same name as the output video but ".log".
# This one DOES overwrite any existing file by default.
hblog="${out%.*}.log"
file_start="$(now)"
log " HB log : $(basename "$hblog")"
{
echo "# HandBrakeCLI log for: $bn"
echo "# $(date '+%Y-%m-%d %H:%M:%S') preset=$PRESET"
echo "# input : $in"
echo "# output: $out"
echo "# --------------------------------------------------------"
} > "$hblog"
"$HB" --preset "$PRESET" -i "$in" -o "$out" >> "$hblog" 2>&1
hb_status=$?
file_secs=$(( $(now) - file_start ))
if [[ $hb_status -ne 0 ]]; then
log "!! FAILED ($hb_status): $bn after $(fmt_time "$file_secs")"
[[ -e "$out" ]] && rm -f "$out" # remove partial output
R_NAME+=("$bn"); R_STATUS+=("FAILED"); R_BEFORE+=("$sz"); R_AFTER+=("0"); R_SECS+=("$file_secs")
fail_count=$((fail_count+1))
# Still count its bytes as "processed" for ETA realism.
done_count=$((done_count+1))
done_bytes=$((done_bytes+sz))
continue
fi
out_sz="$(fsize "$out")"
status="done"
if [[ "$action" == "overwrite" ]]; then
status="overwritten"
overwrite_count=$((overwrite_count+1))
fi
ok_count=$((ok_count+1))
done_count=$((done_count+1))
done_bytes=$((done_bytes+sz))
R_NAME+=("$bn"); R_STATUS+=("$status"); R_BEFORE+=("$sz"); R_AFTER+=("$out_sz"); R_SECS+=("$file_secs")
log ">> $(printf '%s' "$status" | tr '[:lower:]' '[:upper:]'): $bn | $(to_gb "$sz") GB -> $(to_gb "$out_sz") GB | time $(fmt_time "$file_secs")"
done
TOTAL_SECS=$(( $(now) - RUN_START ))
# --------------------------------------------------------------------------- #
# Final summary
# --------------------------------------------------------------------------- #
log ""
log "============================================================"
log " SUMMARY"
log "============================================================"
printf '%-40s %-12s %10s %10s %10s\n' "File" "Status" "Before" "After" "Time" | tee -a "$LOGFILE"
printf '%-40s %-12s %10s %10s %10s\n' "----" "------" "------" "-----" "----" | tee -a "$LOGFILE"
total_before=0
total_after=0
for i in "${!R_NAME[@]}"; do
nm="${R_NAME[$i]}"
[[ ${#nm} -gt 40 ]] && nm="${nm:0:37}..."
printf '%-40s %-12s %8s GB %8s GB %10s\n' \
"$nm" "${R_STATUS[$i]}" \
"$(to_gb "${R_BEFORE[$i]}")" "$(to_gb "${R_AFTER[$i]}")" \
"$(fmt_time "${R_SECS[$i]}")" | tee -a "$LOGFILE"
total_before=$(( total_before + ${R_BEFORE[$i]} ))
total_after=$(( total_after + ${R_AFTER[$i]} ))
done
log "------------------------------------------------------------"
log " Transcoded : $ok_count (of which overwritten: $overwrite_count)"
log " Skipped : $SKIP_COUNT"
log " Failed : $fail_count"
log " Total time : $(fmt_time "$TOTAL_SECS")"
log " Output size : $(to_gb "$total_after") GB (from $(to_gb "$total_before") GB of processed inputs)"
if [[ $done_bytes -gt 0 && $TOTAL_SECS -gt 0 ]]; then
log " Avg speed : $(awk -v db="$done_bytes" -v el="$TOTAL_SECS" 'BEGIN{printf "%.1f", (db/el)/1000000}') MB/s"
fi
log " Log file : $LOGFILE"
log " Finished : $(date '+%Y-%m-%d %H:%M:%S')"
log "============================================================"
[[ $fail_count -eq 0 ]]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment