#!/usr/bin/env bash # # encode-targetsize-43-crop.sh - Batch encode videos to NTSC 4:3 with hardware accel (multi-OS) # Targeting iGPUs (Apple Silicon, Intel HD, Intel Arc) # Uses VideoToolbox on macOS, VAAPI on Linux, and QuickSync on Windows. # set -euo pipefail # --- Default Settings --- VIDEO_BITRATE="1500k" AUDIO_BITRATE="128k" TARGET_SIZE="" KEEP_LOGS=0 VIDEO_BITRATE_MANUALLY_SET=0 # --- Global Counters --- encoded_count=0 skipped_count=0 failed_count=0 # --- Functions --- usage() { echo "Usage: $0 [options] " echo "Batch encodes video files to NTSC 4:3 MP4." echo echo "Options:" echo " --target-size Set a target file size (e.g., 250MB, 700MiB). Overrides --video-bitrate." echo " --video-bitrate Set video bitrate (e.g., 2000k). Default: $VIDEO_BITRATE" echo " --audio-bitrate Set audio bitrate (e.g., 192k). Default: $AUDIO_BITRATE" echo " --keep-logs Do not delete log files on successful encodes." echo " -h, --help Show this help message." exit 1 } check_deps() { for cmd in ffmpeg ffprobe awk tee bc; do if ! command -v "$cmd" &> /dev/null; then echo "❌ Error: Required command '$cmd' is not installed." >&2 exit 1 fi done } # Validate bitrate format (must be ###k) validate_bitrate() { local rate="$1" if [[ ! "$rate" =~ ^[0-9]+k$ ]]; then echo "❌ Error: Bitrate must be specified in kilobits with a trailing 'k' (e.g., 128k, 2000k)." >&2 exit 1 fi } # Converts a size string (e.g., 700MB, 700MiB) to total kilobits. # Requires explicit MB or MiB suffix. parse_size_to_kbits() { local size_str="$1" echo "$size_str" | awk ' /MiB$/ { sub(/MiB$/, "", $1) val=$1 * 1024 * 1024 # bytes print int((val * 8) / 1000) # kilobits next } /MB$/ { sub(/MB$/, "", $1) val=$1 * 1024 * 1024 # bytes (treat MB as MiB for consistency) print int((val * 8) / 1000) # kilobits next } { print "Error: Target size must end with MB or MiB (case-sensitive)." > "/dev/stderr" exit 1 } ' } process_file() { local in_file="$1" local rel_path="${in_file#$INPUT_DIR/}" local out_file="$OUTPUT_DIR/${rel_path%.*}.ntsc43.mp4" local log_file="$OUTPUT_DIR/${rel_path%.*}.ntsc43.log" mkdir -p "$(dirname "$out_file")" echo "▶ Encoding: $rel_path" local current_video_bitrate="$VIDEO_BITRATE" # If target size is set, calculate the required video bitrate if [[ -n "$TARGET_SIZE" ]]; then local duration duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$in_file") if [[ -z "$duration" || $(echo "$duration <= 0" | bc -l) -eq 1 ]]; then echo "❌ FAILED: Could not determine duration of $rel_path. Cannot calculate target bitrate." failed_count=$((failed_count + 1)) return fi local target_kbits audio_kbits target_kbits=$(parse_size_to_kbits "$TARGET_SIZE") audio_kbits=$(echo "$AUDIO_BITRATE" | tr -d 'k') # video_bitrate = (total_kbits / duration_sec) - audio_bitrate local calculated_vbitrate calculated_vbitrate=$(echo "scale=2; ($target_kbits / $duration) - $audio_kbits" | bc) if [[ "${calculated_vbitrate%.*}" -lt 100 ]]; then echo "⚠️ Warning: Calculated bitrate ($calculated_vbitrate) is very low for $rel_path. Clamping to 100k." calculated_vbitrate=100 fi current_video_bitrate="${calculated_vbitrate}k" echo " Target Size: $TARGET_SIZE -> Calculated Video Bitrate: $current_video_bitrate" fi # Skip logic AFTER validation if [[ -f "$out_file" ]]; then echo "⏭ Skipping (already exists): $rel_path" skipped_count=$((skipped_count + 1)) return fi # Get dimensions and determine if we need to crop in one go local is_widescreen is_widescreen=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height \ -of csv=p=0:s=, "$in_file" | awk -F, '{w=$1; h=$2; print (w/h > 1.34) ? 1 : 0}') local vfilt if [[ "$is_widescreen" -eq 1 ]]; then vfilt="crop=in_h*4/3:in_h" else vfilt="scale=in_w:in_h" fi (set -o pipefail && ffmpeg -y -nostdin \ $HWACCEL \ -i "$in_file" \ -vf "$vfilt,fps=30000/1001,format=yuv420p,scale=720:480,setsar=8/9" \ -c:v "$VIDEO_CODEC" -b:v "$current_video_bitrate" \ -c:a aac -b:a "$AUDIO_BITRATE" -ac 2 -ar 48000 \ -af "loudnorm=I=-16:TP=-1.5:LRA=11" \ "$out_file" 2>&1 | tee "$log_file") local ffmpeg_exit_code=$? if [ $ffmpeg_exit_code -ne 0 ]; then echo "❌ FAILED: $rel_path (ffmpeg exited with code $ffmpeg_exit_code). See log for details: $log_file" failed_count=$((failed_count + 1)) rm -f "$out_file" return fi echo "✅ Finished: $rel_path" encoded_count=$((encoded_count + 1)) if [[ "$KEEP_LOGS" -eq 0 ]]; then rm -f "$log_file" fi } # --- Main Script --- # 1. Parse Command-Line Arguments while [[ $# -gt 0 ]]; do key="$1" case $key in --target-size) TARGET_SIZE="$2" shift 2 ;; --video-bitrate) VIDEO_BITRATE="$2" VIDEO_BITRATE_MANUALLY_SET=1 shift 2 ;; --audio-bitrate) AUDIO_BITRATE="$2" shift 2 ;; --keep-logs) KEEP_LOGS=1 shift ;; -h|--help) usage ;; -* ) echo "Unknown option: $1" usage ;; *) break ;; esac done # 2. Validate Dependencies & Positional Arguments check_deps [ "$#" -ne 2 ] && usage INPUT_DIR=$(realpath "$1") OUTPUT_DIR=$(realpath "$2") if [ ! -d "$INPUT_DIR" ]; then echo "❌ Error: Input directory not found: $1" >&2 exit 1 fi # Enforce bitrate formats before processing validate_bitrate "$AUDIO_BITRATE" validate_bitrate "$VIDEO_BITRATE" if [[ -n "$TARGET_SIZE" && "$VIDEO_BITRATE_MANUALLY_SET" -eq 1 ]]; then echo "⚠️ Warning: Both --target-size and --video-bitrate were specified." echo " The --target-size option will take precedence and calculate the video bitrate automatically." fi mkdir -p "$OUTPUT_DIR" # 3. Set up OS-specific FFmpeg parameters OS="$(uname -s)" case "$OS" in Linux*) VIDEO_CODEC="h264_vaapi"; HWACCEL="-hwaccel vaapi -vaapi_device /dev/dri/renderD128" ;; Darwin*) VIDEO_CODEC="h264_videotoolbox"; HWACCEL="-hwaccel videotoolbox" ;; MINGW*|MSYS*|CYGWIN*) VIDEO_CODEC="h264_qsv"; HWACCEL="-hwaccel qsv" ;; *) echo "❌ Unsupported OS: $OS"; exit 1 ;; esac echo "🚀 Starting batch encode..." echo " Input: $INPUT_DIR" echo " Output: $OUTPUT_DIR" echo " OS: $OS ($VIDEO_CODEC)" if [[ -n "$TARGET_SIZE" ]]; then echo " Target Size: $TARGET_SIZE (video bitrate will be calculated per file)" else echo " Video Bitrate: $VIDEO_BITRATE" fi echo " Audio Bitrate: $AUDIO_BITRATE" echo " Keep Logs: $(if [ $KEEP_LOGS -eq 1 ]; then echo 'Yes'; else echo 'No'; fi)" echo "--------------------------------------------------" # 4. Find and process video files sequentially while IFS= read -r -d '' file; do process_file "$file" echo "--------------------------------------------------" done < <(find "$INPUT_DIR" -type f \ \( -iname '*.mp4' -o -iname '*.mov' -o -iname '*.mkv' -o -iname '*.avi' -o -iname '*.ts' -o -iname '*.m4v' \) \ -print0) echo "🎉 All done! Encoded files are in: $OUTPUT_DIR" echo " Encoded: $encoded_count" echo " Skipped: $skipped_count" echo " Failed: $failed_count"