#!/usr/bin/env bash # Video encoding script using ffmpeg with H.264 and scaling to FHD. set -euo pipefail # Colors for log output GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' BLUE='\033[0;34m' NC='\033[0m' # Logging functions for user-friendly output log_info() { echo -e "${BLUE}INFO:${NC} $1"; } log_success() { echo -e "${GREEN}SUCCESS:${NC} $1"; } log_warn() { echo -e "${YELLOW}WARN:${NC} $1"; } log_error() { echo -e "${RED}ERROR:${NC} $1" >&2; } # Gets the duration of a video file in seconds using ffprobe. get_duration() { ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$1" 2>/dev/null || echo "0" } # Default encoding parameters tuned for quality and compatibility. readonly DEFAULT_CRF="23" readonly DEFAULT_PRESET="medium" readonly DEFAULT_MAXRATE="6M" readonly DEFAULT_BUFSIZE="12M" readonly DEFAULT_MAX_W="1920" readonly DEFAULT_MAX_H="1080" # Global state variables modified during argument parsing and validation. INFILE="" OUTFILE="" CRF="${DEFAULT_CRF}" PRESET="${DEFAULT_PRESET}" MAXRATE="${DEFAULT_MAXRATE}" BUFSIZE="${DEFAULT_BUFSIZE}" EXTRA_ARGS=() MAX_W="${DEFAULT_MAX_W}" MAX_H="${DEFAULT_MAX_H}" OVERWRITE_SAME=false OVERWRITE_DIFFERENT=false IS_BATCH=false INPUT_DIR="" INPUT_FILES=() AUDIO_MODE="transcode" # Prints usage information to stdout. usage() { cat <_videncode.mp4 or original name if different dir) -c, --crf CRF value (default: ${DEFAULT_CRF}) -p, --preset x264 preset (default: ${DEFAULT_PRESET}) -m, --maxrate Maxrate constraint (default: ${DEFAULT_MAXRATE}) -b, --bufsize Bufsize constraint (default: ${DEFAULT_BUFSIZE}) --extra Extra ffmpeg args (wrap in quotes). May be specified multiple times. --max-w Maximum width in pixels (default: ${DEFAULT_MAX_W}) --max-h Maximum height in pixels (default: ${DEFAULT_MAX_H}) --overwrite-same Overwrite output even if duration matches input --overwrite-different Overwrite output even if duration differs from input --audio-mode Audio mode: transcode (default) or copy -h, --help Show this help EOF } # Parses command-line arguments, supporting both short and long options. parse_args() { if [[ $# -eq 0 ]]; then usage exit 1 fi while [[ $# -gt 0 ]]; do case "$1" in -i | --input) INFILE="$2" shift 2 ;; -o | --output) OUTFILE="$2" shift 2 ;; -c | --crf) CRF="$2" shift 2 ;; -p | --preset) PRESET="$2" shift 2 ;; -m | --maxrate) MAXRATE="$2" shift 2 ;; -b | --bufsize) BUFSIZE="$2" shift 2 ;; --extra) EXTRA_ARGS+=("$2") shift 2 ;; --max-w) MAX_W="$2" shift 2 ;; --max-h) MAX_H="$2" shift 2 ;; --overwrite-same) OVERWRITE_SAME=true shift ;; --overwrite-different) OVERWRITE_DIFFERENT=true shift ;; --audio-mode) AUDIO_MODE="$2" shift 2 ;; -h | --help) usage exit 0 ;; --) shift break ;; *) echo "Unknown argument: $1" >&2 usage exit 1 ;; esac done } # Validates input and determines output path based on rules to avoid overwriting. validate_input() { if [[ -z "${INFILE:-}" ]]; then log_error "input (-i/--input) is required." usage exit 1 fi if [[ -d "$INFILE" ]]; then IS_BATCH=true INPUT_DIR="$INFILE" log_info "Processing directory: $INFILE" # Find video files in the directory while IFS= read -r -d '' file; do INPUT_FILES+=("$file") done < <(find "$INPUT_DIR" -type f \( -iname "*.mp4" -o -iname "*.avi" -o -iname "*.mkv" -o -iname "*.mov" -o -iname "*.flv" -o -iname "*.wmv" -o -iname "*.webm" -o -iname "*.m4v" \) -print0) if [[ ${#INPUT_FILES[@]} -eq 0 ]]; then log_error "no video files found in directory: $INFILE" exit 1 fi log_info "Found ${#INPUT_FILES[@]} video file(s) to process" elif [[ -f "$INFILE" ]]; then IS_BATCH=false INPUT_FILES=("$INFILE") log_info "Processing single file: $INFILE" else log_error "input not found or not a file/directory: $INFILE" exit 1 fi } # Validates single input file and determines output path. validate_input_single() { # Determine output path: use _videncode.mp4 suffix in same dir, original name in different dir. if [[ -n "$OUTFILE" && -d "$OUTFILE" ]]; then if [[ "$OUTFILE" == "$INPUT_DIR" ]]; then OUTFILE="${OUTFILE%/}/$(basename "${INFILE%.*}")_videncode.mp4" else OUTFILE="${OUTFILE%/}/$(basename "$INFILE")" fi elif [[ -z "$OUTFILE" ]]; then OUTFILE="${INFILE%.*}_videncode.mp4" fi log_info "Output will be: $OUTFILE" # Prevent accidental overwriting of the input file. if [[ "$OUTFILE" == "$INFILE" ]]; then log_error "output file would overwrite input file." exit 1 fi } # Assembles the full ffmpeg command array with all configured options. build_ffmpeg_cmd() { # Sets audio flags to transcode to Opus or copy streams unchanged. local audio_flags if [[ "$AUDIO_MODE" == "transcode" ]]; then audio_flags=("-c:a" "libopus") else audio_flags=("-c:a" "copy") fi # Constructs the ffmpeg video scaling filter to fit within max dimensions while preserving aspect ratio. # Scale logic: for landscape (w>h), constrain width to MAX_W and height to MAX_H; # for portrait (h>w), constrain width to MAX_H and height to MAX_W. # Commas are escaped for safe shell passing; force_original_aspect_ratio=decrease avoids upscaling. local vf="scale='if(gt(iw\\,ih)\\,min(iw\\,${MAX_W})\\,min(iw\\,${MAX_H}))':'if(gt(iw\\,ih)\\,min(ih\\,${MAX_H})\\,min(ih\\,${MAX_W}))':force_original_aspect_ratio=decrease" CMD=(ffmpeg -y -i "$INFILE" -map_metadata 0 -c:v libx264 -preset "$PRESET" -crf "$CRF" -maxrate "$MAXRATE" -bufsize "$BUFSIZE" -vf "$vf" "${audio_flags[@]}" -movflags +faststart) # Safely append user-provided extra arguments by splitting on whitespace. if [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; then for ea in "${EXTRA_ARGS[@]}"; do read -r -a split <<<"$ea" CMD+=("${split[@]}") done fi CMD+=("$OUTFILE") } # Executes the constructed ffmpeg command after displaying it for transparency. run_ffmpeg() { log_info "Processing: $INFILE -> $OUTFILE" if [[ -f "$OUTFILE" ]]; then input_duration=$(get_duration "$INFILE") output_duration=$(get_duration "$OUTFILE") if [[ "$input_duration" == "$output_duration" && "$output_duration" != "0" ]]; then if [[ "$OVERWRITE_SAME" == false ]]; then log_warn "Skipping existing file with matching duration: $OUTFILE" return fi elif [[ "$IS_BATCH" == false ]]; then if [[ "$OVERWRITE_DIFFERENT" == false ]]; then read -p "Output file exists with different duration, overwrite? (y/N) " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then log_warn "User chose not to overwrite: $OUTFILE" return fi fi else if [[ "$OVERWRITE_DIFFERENT" == false ]]; then log_warn "Skipping existing file with different duration: $OUTFILE" return fi fi fi log_info "Starting encoding..." log_info "Command:" printf ' %q' "${CMD[@]}" echo "${CMD[@]}" log_success "Encoding completed: $OUTFILE" } # Main entry point: orchestrates parsing, validation, command building, and execution. main() { parse_args "$@" validate_input if [[ "$IS_BATCH" == true ]]; then # Save the output directory for batch processing local OUTPUT_DIR="$OUTFILE" local total=${#INPUT_FILES[@]} local count=1 for file in "${INPUT_FILES[@]}"; do log_info "Processing file $count of $total: $(basename "$file")" INFILE="$file" OUTFILE="$OUTPUT_DIR" # Reset OUTFILE for each iteration validate_input_single build_ffmpeg_cmd run_ffmpeg ((count++)) done log_success "Batch processing completed: $total file(s) processed" else validate_input_single build_ffmpeg_cmd run_ffmpeg fi } main "$@"