Created
September 23, 2025 22:45
-
-
Save ergosteur/88b268983b19d11ae35650fdf28deb63 to your computer and use it in GitHub Desktop.
encode-ntsc-43-improved.sh - Batch encode videos to NTSC 4:3 with hardware accel (multi-OS)
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 | |
| # | |
| # 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] <input_directory> <output_directory>" | |
| echo "Batch encodes video files to NTSC 4:3 MP4." | |
| echo | |
| echo "Options:" | |
| echo " --target-size <size> Set a target file size (e.g., 250MB, 700MiB). Overrides --video-bitrate." | |
| echo " --video-bitrate <rate> Set video bitrate (e.g., 2000k). Default: $VIDEO_BITRATE" | |
| echo " --audio-bitrate <rate> 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" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment