Created
July 2, 2026 18:40
-
-
Save huyaoyu/22bb4a7d0a8432df006f25665439459b to your computer and use it in GitHub Desktop.
Transcode video using HandBrakeCLI on macOS
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 | |
| # | |
| # 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