Skip to content

Instantly share code, notes, and snippets.

@ergosteur
Created September 23, 2025 22:45
Show Gist options
  • Select an option

  • Save ergosteur/88b268983b19d11ae35650fdf28deb63 to your computer and use it in GitHub Desktop.

Select an option

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)
#!/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