| 1 | #!/usr/bin/env bash |
| 2 | # |
| 3 | # encode-targetsize-43-crop.sh - Batch encode videos to NTSC 4:3 with hardware accel (multi-OS) |
| 4 | # Targeting iGPUs (Apple Silicon, Intel HD, Intel Arc) |
| 5 | # Uses VideoToolbox on macOS, VAAPI on Linux, and QuickSync on Windows. |
| 6 | # |
| 7 | set -euo pipefail |
| 8 | |
| 9 | # --- Default Settings --- |
| 10 | VIDEO_BITRATE="1500k" |
| 11 | AUDIO_BITRATE="128k" |
| 12 | TARGET_SIZE="" |
| 13 | KEEP_LOGS=0 |
| 14 | VIDEO_BITRATE_MANUALLY_SET=0 |
| 15 | |
| 16 | # --- Global Counters --- |
| 17 | encoded_count=0 |
| 18 | skipped_count=0 |
| 19 | failed_count=0 |
| 20 | |
| 21 | # --- Functions --- |
| 22 | |
| 23 | usage() { |
| 24 | echo "Usage: $0 [options] <input_directory> <output_directory>" |
| 25 | echo "Batch encodes video files to NTSC 4:3 MP4." |
| 26 | echo |
| 27 | echo "Options:" |
| 28 | echo " --target-size <size> Set a target file size (e.g., 250MB, 700MiB). Overrides --video-bitrate." |
| 29 | echo " --video-bitrate <rate> Set video bitrate (e.g., 2000k). Default: $VIDEO_BITRATE" |
| 30 | echo " --audio-bitrate <rate> Set audio bitrate (e.g., 192k). Default: $AUDIO_BITRATE" |
| 31 | echo " --keep-logs Do not delete log files on successful encodes." |
| 32 | echo " -h, --help Show this help message." |
| 33 | exit 1 |
| 34 | } |
| 35 | |
| 36 | check_deps() { |
| 37 | for cmd in ffmpeg ffprobe awk tee bc; do |
| 38 | if ! command -v "$cmd" &> /dev/null; then |
| 39 | echo "❌ Error: Required command '$cmd' is not installed." >&2 |
| 40 | exit 1 |
| 41 | fi |
| 42 | done |
| 43 | } |
| 44 | |
| 45 | # Validate bitrate format (must be ###k) |
| 46 | validate_bitrate() { |
| 47 | local rate="$1" |
| 48 | if [[ ! "$rate" =~ ^[0-9]+k$ ]]; then |
| 49 | echo "❌ Error: Bitrate must be specified in kilobits with a trailing 'k' (e.g., 128k, 2000k)." >&2 |
| 50 | exit 1 |
| 51 | fi |
| 52 | } |
| 53 | |
| 54 | # Converts a size string (e.g., 700MB, 700MiB) to total kilobits. |
| 55 | # Requires explicit MB or MiB suffix. |
| 56 | parse_size_to_kbits() { |
| 57 | local size_str="$1" |
| 58 | echo "$size_str" | awk ' |
| 59 | /MiB$/ { |
| 60 | sub(/MiB$/, "", $1) |
| 61 | val=$1 * 1024 * 1024 # bytes |
| 62 | print int((val * 8) / 1000) # kilobits |
| 63 | next |
| 64 | } |
| 65 | /MB$/ { |
| 66 | sub(/MB$/, "", $1) |
| 67 | val=$1 * 1024 * 1024 # bytes (treat MB as MiB for consistency) |
| 68 | print int((val * 8) / 1000) # kilobits |
| 69 | next |
| 70 | } |
| 71 | { |
| 72 | print "Error: Target size must end with MB or MiB (case-sensitive)." > "/dev/stderr" |
| 73 | exit 1 |
| 74 | } |
| 75 | ' |
| 76 | } |
| 77 | |
| 78 | process_file() { |
| 79 | local in_file="$1" |
| 80 | local rel_path="${in_file#$INPUT_DIR/}" |
| 81 | local out_file="$OUTPUT_DIR/${rel_path%.*}.ntsc43.mp4" |
| 82 | local log_file="$OUTPUT_DIR/${rel_path%.*}.ntsc43.log" |
| 83 | |
| 84 | mkdir -p "$(dirname "$out_file")" |
| 85 | echo "▶ Encoding: $rel_path" |
| 86 | |
| 87 | local current_video_bitrate="$VIDEO_BITRATE" |
| 88 | |
| 89 | # If target size is set, calculate the required video bitrate |
| 90 | if [[ -n "$TARGET_SIZE" ]]; then |
| 91 | local duration |
| 92 | duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$in_file") |
| 93 | if [[ -z "$duration" || $(echo "$duration <= 0" | bc -l) -eq 1 ]]; then |
| 94 | echo "❌ FAILED: Could not determine duration of $rel_path. Cannot calculate target bitrate." |
| 95 | failed_count=$((failed_count + 1)) |
| 96 | return |
| 97 | fi |
| 98 | |
| 99 | local target_kbits audio_kbits |
| 100 | target_kbits=$(parse_size_to_kbits "$TARGET_SIZE") |
| 101 | audio_kbits=$(echo "$AUDIO_BITRATE" | tr -d 'k') |
| 102 | |
| 103 | # video_bitrate = (total_kbits / duration_sec) - audio_bitrate |
| 104 | local calculated_vbitrate |
| 105 | calculated_vbitrate=$(echo "scale=2; ($target_kbits / $duration) - $audio_kbits" | bc) |
| 106 | |
| 107 | if [[ "${calculated_vbitrate%.*}" -lt 100 ]]; then |
| 108 | echo "⚠️ Warning: Calculated bitrate ($calculated_vbitrate) is very low for $rel_path. Clamping to 100k." |
| 109 | calculated_vbitrate=100 |
| 110 | fi |
| 111 | |
| 112 | current_video_bitrate="${calculated_vbitrate}k" |
| 113 | echo " Target Size: $TARGET_SIZE -> Calculated Video Bitrate: $current_video_bitrate" |
| 114 | fi |
| 115 | |
| 116 | # Skip logic AFTER validation |
| 117 | if [[ -f "$out_file" ]]; then |
| 118 | echo "⏭ Skipping (already exists): $rel_path" |
| 119 | skipped_count=$((skipped_count + 1)) |
| 120 | return |
| 121 | fi |
| 122 | |
| 123 | # Get dimensions and determine if we need to crop in one go |
| 124 | local is_widescreen |
| 125 | is_widescreen=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height \ |
| 126 | -of csv=p=0:s=, "$in_file" | awk -F, '{w=$1; h=$2; print (w/h > 1.34) ? 1 : 0}') |
| 127 | |
| 128 | local vfilt |
| 129 | if [[ "$is_widescreen" -eq 1 ]]; then |
| 130 | vfilt="crop=in_h*4/3:in_h" |
| 131 | else |
| 132 | vfilt="scale=in_w:in_h" |
| 133 | fi |
| 134 | |
| 135 | (set -o pipefail && ffmpeg -y -nostdin \ |
| 136 | $HWACCEL \ |
| 137 | -i "$in_file" \ |
| 138 | -vf "$vfilt,fps=30000/1001,format=yuv420p,scale=720:480,setsar=8/9" \ |
| 139 | -c:v "$VIDEO_CODEC" -b:v "$current_video_bitrate" \ |
| 140 | -c:a aac -b:a "$AUDIO_BITRATE" -ac 2 -ar 48000 \ |
| 141 | -af "loudnorm=I=-16:TP=-1.5:LRA=11" \ |
| 142 | "$out_file" 2>&1 | tee "$log_file") |
| 143 | |
| 144 | local ffmpeg_exit_code=$? |
| 145 | if [ $ffmpeg_exit_code -ne 0 ]; then |
| 146 | echo "❌ FAILED: $rel_path (ffmpeg exited with code $ffmpeg_exit_code). See log for details: $log_file" |
| 147 | failed_count=$((failed_count + 1)) |
| 148 | rm -f "$out_file" |
| 149 | return |
| 150 | fi |
| 151 | |
| 152 | echo "✅ Finished: $rel_path" |
| 153 | encoded_count=$((encoded_count + 1)) |
| 154 | |
| 155 | if [[ "$KEEP_LOGS" -eq 0 ]]; then |
| 156 | rm -f "$log_file" |
| 157 | fi |
| 158 | } |
| 159 | |
| 160 | # --- Main Script --- |
| 161 | |
| 162 | # 1. Parse Command-Line Arguments |
| 163 | while [[ $# -gt 0 ]]; do |
| 164 | key="$1" |
| 165 | case $key in |
| 166 | --target-size) |
| 167 | TARGET_SIZE="$2" |
| 168 | shift 2 |
| 169 | ;; |
| 170 | --video-bitrate) |
| 171 | VIDEO_BITRATE="$2" |
| 172 | VIDEO_BITRATE_MANUALLY_SET=1 |
| 173 | shift 2 |
| 174 | ;; |
| 175 | --audio-bitrate) |
| 176 | AUDIO_BITRATE="$2" |
| 177 | shift 2 |
| 178 | ;; |
| 179 | --keep-logs) |
| 180 | KEEP_LOGS=1 |
| 181 | shift |
| 182 | ;; |
| 183 | -h|--help) |
| 184 | usage |
| 185 | ;; |
| 186 | -* ) |
| 187 | echo "Unknown option: $1" |
| 188 | usage |
| 189 | ;; |
| 190 | *) |
| 191 | break |
| 192 | ;; |
| 193 | esac |
| 194 | done |
| 195 | |
| 196 | # 2. Validate Dependencies & Positional Arguments |
| 197 | check_deps |
| 198 | [ "$#" -ne 2 ] && usage |
| 199 | INPUT_DIR=$(realpath "$1") |
| 200 | OUTPUT_DIR=$(realpath "$2") |
| 201 | |
| 202 | if [ ! -d "$INPUT_DIR" ]; then |
| 203 | echo "❌ Error: Input directory not found: $1" >&2 |
| 204 | exit 1 |
| 205 | fi |
| 206 | |
| 207 | # Enforce bitrate formats before processing |
| 208 | validate_bitrate "$AUDIO_BITRATE" |
| 209 | validate_bitrate "$VIDEO_BITRATE" |
| 210 | |
| 211 | if [[ -n "$TARGET_SIZE" && "$VIDEO_BITRATE_MANUALLY_SET" -eq 1 ]]; then |
| 212 | echo "⚠️ Warning: Both --target-size and --video-bitrate were specified." |
| 213 | echo " The --target-size option will take precedence and calculate the video bitrate automatically." |
| 214 | fi |
| 215 | |
| 216 | mkdir -p "$OUTPUT_DIR" |
| 217 | |
| 218 | # 3. Set up OS-specific FFmpeg parameters |
| 219 | OS="$(uname -s)" |
| 220 | case "$OS" in |
| 221 | Linux*) VIDEO_CODEC="h264_vaapi"; HWACCEL="-hwaccel vaapi -vaapi_device /dev/dri/renderD128" ;; |
| 222 | Darwin*) VIDEO_CODEC="h264_videotoolbox"; HWACCEL="-hwaccel videotoolbox" ;; |
| 223 | MINGW*|MSYS*|CYGWIN*) VIDEO_CODEC="h264_qsv"; HWACCEL="-hwaccel qsv" ;; |
| 224 | *) echo "❌ Unsupported OS: $OS"; exit 1 ;; |
| 225 | esac |
| 226 | |
| 227 | echo "🚀 Starting batch encode..." |
| 228 | echo " Input: $INPUT_DIR" |
| 229 | echo " Output: $OUTPUT_DIR" |
| 230 | echo " OS: $OS ($VIDEO_CODEC)" |
| 231 | if [[ -n "$TARGET_SIZE" ]]; then |
| 232 | echo " Target Size: $TARGET_SIZE (video bitrate will be calculated per file)" |
| 233 | else |
| 234 | echo " Video Bitrate: $VIDEO_BITRATE" |
| 235 | fi |
| 236 | echo " Audio Bitrate: $AUDIO_BITRATE" |
| 237 | echo " Keep Logs: $(if [ $KEEP_LOGS -eq 1 ]; then echo 'Yes'; else echo 'No'; fi)" |
| 238 | echo "--------------------------------------------------" |
| 239 | |
| 240 | # 4. Find and process video files sequentially |
| 241 | while IFS= read -r -d '' file; do |
| 242 | process_file "$file" |
| 243 | echo "--------------------------------------------------" |
| 244 | done < <(find "$INPUT_DIR" -type f \ |
| 245 | \( -iname '*.mp4' -o -iname '*.mov' -o -iname '*.mkv' -o -iname '*.avi' -o -iname '*.ts' -o -iname '*.m4v' \) \ |
| 246 | -print0) |
| 247 | |
| 248 | echo "🎉 All done! Encoded files are in: $OUTPUT_DIR" |
| 249 | echo " Encoded: $encoded_count" |
| 250 | echo " Skipped: $skipped_count" |
| 251 | echo " Failed: $failed_count" |
| 252 |
ergosteur / Encode 4:3 video
Last active 1 month ago
Batch encode videos to NTSC 4:3 with hardware accel (multi-OS)
Revision 4405a85f1e2524ee9b2d3a1ec845c35e93861b07