#!/usr/bin/env bash # SPDX-FileCopyrightText: 2025 Dennis Fink # # SPDX-License-Identifier: BSD-3-Clause ############################################################################### # DESCRIPTION # Batch transcode helper for media files using ffmpeg. # # The actual ffmpeg encoding parameters are provided by *presets* stored in: # ${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/presets/.sh # # Each preset is a small bash snippet that must set an array named `ffargs`, # for example: # ffargs=( -c:v:0 libsvtav1 -crf 30 -preset 6 -pix_fmt "$output_pixel_format" ) # # Note: Presets are sourced as shell code. Only use trusted presets. # # A per-user list of video-codecs to skip can be provided in: # ${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/skip.conf # # NOTES # This script respects the NO_COLOR standard (https://no-color.org/). # # AUTHOR # Dennis Fink ############################################################################### # TRACE enables bash execution tracing and is intended for debugging control # flow / expansions. DEBUG enable structured debug messages (see debug()) and # is intended for high-level state reporting. # # Recommended usage: # TRACE=1 ./transcode.sh ... # show every command bash executes # DEBUG=1 ./transcode.sh .... # show script-defined debug messages TRACE=${TRACE:-0} if [[ $TRACE -ne 0 ]]; then set -o xtrace fi ############################################################################### # SHELL SCRIPT HARDENING # # This script enables strict and predictable behavior for improved safety, # security, and debuggability. These measures help avoid common pitfalls # such as accidental globbing, unexpected alias expansion, silent failures, # or unintended word splitting. ############################################################################### # Unalias everything to avoid unexpected alias expansion command unalias -a # Set a predictable and secure PATH (ignores user-controlled paths). # Trade-off: tools installed outside these directories (e.g. ffmpeg via # Homebrew on macOS at /opt/homebrew/bin, or via Nix at /nix/store/...) # will not be found. In that case, invoke this script via a wrapper that # prepends the correct path, e.g.: PATH="/opt/homebrew/bin:$PATH" ./transcode.sh PATH='/bin:/usr/bin:/usr/local/bin' export PATH # Clear the shell command hash table to avoid stale command lookups hash -r # Set a safe IFS to prevent word-splitting vulnerabilities IFS=$'\n\t' # Set a secure default file creation mask (controls default permissions) UMASK=002 umask "$UMASK" # Allow aliases to expand inside command substitutions (optional convenience) shopt -s expand_aliases # Ensure ERR traps are inherited by functions and subshells set -o errtrace # Treat use of undefined variables as an error and exit set -o nounset # Make pipelines fail if any command in the pipeline fails set -o pipefail ############################################################################### # SCRIPT METADATA # # These variables describe the script and are used for the --version output # and for informational messages. They are marked readonly to prevent # accidental modification at runtime. ############################################################################### readonly SCRIPTNAME=${0##*/} readonly DESCRIPTION="Batch transcode helper for media files using ffmpeg." readonly DATE_OF_CREATION=2025-08-06 readonly DATE_OF_REVISION=2026-02-22 readonly VERSION=1.0.0 readonly AUTHOR="Dennis Fink " readonly LICENSE="BSD-3-Clause" ############################################################################### # GLOBAL VARIABLES ############################################################################### ALL_OFF="" BOLD="" RED="" GREEN="" YELLOW="" BLUE="" MAGENTA="" CYAN="" DEBUG=${DEBUG:-0} QUIET=0 VERBOSE=0 CONTINUE_ON_FAIL=0 DRY_RUN=0 ENCODE_FILE="" NICE_VALUE=19 ONLY_IF_SMALLER=0 PRESET_NAME="default" SAVING=0 SAVING_FILE="transcode_savings" declare -A SKIP_CODECS REMAINING_ARGS=() TMP_FILES=() readonly PRESET_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/presets" readonly SKIP_CODECS_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/skip.conf" ############################################################################### # EXIT CODES ############################################################################### EXIT_OK=0 EXIT_RUNTIME_FAILURE=1 EXIT_USAGE_ERROR=2 EXIT_CONFIG_ERROR=3 EXIT_MISSING_DEPENDENCY=127 readonly EXIT_OK EXIT_RUNTIME_FAILURE EXIT_USAGE_ERROR EXIT_CONFIG_ERROR EXIT_MISSING_DEPENDENCY ############################################################################## # FUNCTIONS # # This section defines the helper functions used throughout the script. They # handle formatted output, error reporting, version information, utilities # functions and runtime behaviour such as displaying usage instructions. ############################################################################### error() { local mesg="$1" shift printf "${BOLD}${RED}==> ERROR:${ALL_OFF} ${BOLD}%s${ALL_OFF} %s\n" "$mesg" "$@" >&2 } msg() { if [[ $QUIET -eq 0 ]]; then local mesg="$1" shift printf "${BOLD}${GREEN}==>${ALL_OFF} ${BOLD}%s${ALL_OFF} %s\n" "$mesg" "$@" fi } verbose() { if [[ $VERBOSE -eq 1 && $QUIET -eq 0 ]]; then local mesg="$1" shift printf "${BOLD}${BLUE}==>${ALL_OFF} ${BOLD}%s${ALL_OFF} %s\n" "$mesg" "$@" fi } debug() { if [[ $DEBUG -eq 1 ]]; then local mesg="$1" shift printf "${BOLD}${MAGENTA}==> DEBUG:${ALL_OFF} ${BOLD}%s${ALL_OFF} %s\n" "$mesg" "$@" fi } print_help() { printf "%s - %s - %s Usage: %s [OPTION] [--] FILE... Options: -c, --continue Continue with next file even if ffmpeg fails -f, --encodefile PATH File containing list of files to encode --encodefile=PATH -n, --dry-run Show what would be done, don't run ffmpeg -N, --nice VALUE Nice value for ffmpeg (default: 19) --nice=VALUE -s, --saving Log filesize savings (filename, orig, new, %%) --saving-file PATH Specify where to save filesize savings (default: transcode_savings) --saving-file=PATH -S, --skip-codec LIST Add codecs to skip (comma/space/colon separated) --skip-codec=LIST -l, --only-if-smaller Only overwrite files if the transcoded file is smaller -p, --preset NAME Load ffmpeg arguments from a preset (default: default) --preset=NAME -h, --help, -? Show this help text and exit -q, --quiet Do not output anything, takes precedence over -v and --verbose -v, --verbose Be more verbose --version Print script information and version --color Force colored output --no-color Disable colored output Examples: %s video.mp4 %s -s -f list.txt %s -n -N 10 my_movie.mkv\n" "$SCRIPTNAME" "$VERSION" "$DESCRIPTION" "$SCRIPTNAME" "$SCRIPTNAME" "$SCRIPTNAME" "$SCRIPTNAME" } print_version() { printf "${RED}${BOLD}Scriptname:${ALL_OFF} %s ${GREEN}${BOLD}Version:${ALL_OFF} %s ${YELLOW}${BOLD}Description:${ALL_OFF} %s ${BLUE}${BOLD}Author:${ALL_OFF} %s ${MAGENTA}${BOLD}Date of creation:${ALL_OFF} %s ${CYAN}${BOLD}Date of revision:${ALL_OFF} %s ${RED}${BOLD}License:${ALL_OFF} %s\n" "$SCRIPTNAME" "$VERSION" "$DESCRIPTION" "$AUTHOR" "$DATE_OF_CREATION" "$DATE_OF_REVISION" "$LICENSE" if [[ -f "LICENSES/${LICENSE}.txt" ]]; then printf "\n" cat "LICENSES/${LICENSE}.txt" fi } filesize() { local f="$1" local out if out=$(stat -c "%s" -- "$f" 2>/dev/null); then printf "%s\n" "$out" else stat -f "%z" -- "$f" fi } ############################################################################### # TERMINAL COLOR SETUP # # Colors and style escape sequences are configured dynamically through the # `setup_colors` function, which enables or disables color output based on # the value of ENABLE_COLOR. When enabled, terminal capabilities are detected # via `tput`, falling back to ANSI escapes if unavailable. # # This script respects the NO_COLOR standard (https://no-color.org/). # If the environment variable NO_COLOR is set (to any value), all color output # is disabled. If FORCE_COLOR is set, colors are always enabled. ############################################################################### # NOTE: This block calls debug() and reads $DEBUG. Both must be defined before # this point. Do not move this block above the GLOBAL VARIABLES or FUNCTIONS # sections without adjusting those dependencies. if [[ -n "${NO_COLOR:-}" ]]; then debug "Colors are disabled via NO_COLOR" ENABLE_COLOR=0 elif [[ -n "${FORCE_COLOR:-}" ]]; then debug "Colors are forced via FORCE_COLOR" ENABLE_COLOR=1 elif [[ -t 1 ]]; then debug "Colors are enabled via interactive terminal" ENABLE_COLOR=1 else debug "Colors are disabled" ENABLE_COLOR=0 fi setup_colors() { if [[ "$ENABLE_COLOR" -eq 1 ]]; then if tput setaf 0 >/dev/null 2>&1; then debug "Setting colors via tput" ALL_OFF="$(tput sgr0)" BOLD="$(tput bold)" RED="$(tput setaf 1)" GREEN="$(tput setaf 2)" YELLOW="$(tput setaf 3)" BLUE="$(tput setaf 4)" MAGENTA="$(tput setaf 5)" CYAN="$(tput setaf 6)" else # Hardcoded ANSI fallback: colors only (no bold embedded), matching the # tput path where BOLD and color variables are kept separate. debug "Using hardcoded color ANSI escape sequences" ALL_OFF="\e[0m" BOLD="\e[1m" RED="\e[31m" GREEN="\e[32m" YELLOW="\e[33m" BLUE="\e[34m" MAGENTA="\e[35m" CYAN="\e[36m" fi else ALL_OFF="" BOLD="" RED="" GREEN="" YELLOW="" BLUE="" MAGENTA="" CYAN="" fi } setup_colors ############################################################################### # MAIN EXECUTION ############################################################################### # Add a codec name to the SKIP_CODECS associative array. # - Normalizes to lowercase # - Ignores empty tokens # - Used both for CLI --skip-codec and for skip.conf lines add_skip_codec_token() { local token="$1" [[ -z "$token" ]] && return 0 token=${token,,} # lowercase if [[ ! -v SKIP_CODECS["$token"] ]]; then debug "Add to skip codec:" "$token" SKIP_CODECS["$token"]= else debug "Codec already skipped:" "$token" fi } # Parse a user-provided codec list (comma/space/colon separated) # and add each token to SKIP_CODECS parse_skip_codec_parameter() { local arg="$1" local IFS=',: ' token for token in $arg; do [[ -z "$token" ]] && continue add_skip_codec_token "$token" done } if [[ -f "$SKIP_CODECS_FILE" ]]; then while IFS= read -r line || [[ -n "$line" ]]; do # strip leading/trailing whitespace line="${line#"${line%%[![:space:]]*}"}" line="${line%"${line##*[![:space:]]}"}" # skip blanks and comments [[ -z "$line" || "${line:0:1}" == "#" ]] && continue parse_skip_codec_parameter "$line" done <"$SKIP_CODECS_FILE" fi # Load ffmpeg argument preset by name. # # Security: # Presets are sourced as shell code. Names are validated against a strict # allowlist (alphanumerics, hyphens, underscores only) to prevent path # traversal and shell metacharacter injection. Note: a valid preset name # could still be a symlink to an arbitrary file; ensure the preset directory # itself is not writable by untrusted users. # # Contract: # The sourced preset MUST set the bash array `ffargs`. # Presets may reference variables from the caller (encode_one) such as: # output_pixel_format, input_pixel_format, input_codec load_preset() { local preset="$1" # Strict allowlist: only alphanumerics, hyphens, and underscores are permitted. # This prevents path traversal, shell metacharacter injection, and symlink-based # attacks more reliably than a blocklist approach. if [[ -z "$preset" || ! "$preset" =~ ^[a-zA-Z0-9_-]+$ ]]; then error "Invalid preset name: $preset (only alphanumerics, hyphens, underscores allowed)" exit $EXIT_USAGE_ERROR fi local file="$PRESET_DIR/$preset.sh" [[ -f "$file" ]] || { error "Preset not found: $file" exit $EXIT_CONFIG_ERROR } debug "Sourcing preset file:" "$file" unset -v ffargs ffargs=() # shellcheck source=/dev/null source "$file" ((${#ffargs[@]})) || { error "Preset '$preset' did not set ffargs" exit $EXIT_RUNTIME_FAILURE } debug "Preset ffargs:" "$(printf "%q " "${ffargs[@]}")" } if [[ $# -eq 0 ]]; then debug "No parameters were specified" print_help exit $EXIT_USAGE_ERROR fi while [[ $# -gt 0 ]]; do case "$1" in -h | --help | -\?) print_help exit $EXIT_OK ;; --version) print_version exit $EXIT_OK ;; -q | --quiet) QUIET=1 shift ;; -v | --verbose) VERBOSE=1 shift ;; --color) ENABLE_COLOR=1 setup_colors shift ;; --no-color) ENABLE_COLOR=0 setup_colors shift ;; -c | --continue) CONTINUE_ON_FAIL=1 shift ;; -f | --encodefile) [[ $# -ge 2 ]] || { error "Missing value for $1" exit $EXIT_USAGE_ERROR } ENCODE_FILE="$2" shift 2 ;; --encodefile=*) ENCODE_FILE="${1#*=}" shift ;; -n | --dry-run) DRY_RUN=1 shift ;; -N | --nice) [[ $# -ge 2 ]] || { error "Missing value for $1" exit $EXIT_USAGE_ERROR } NICE_VALUE="$2" shift 2 ;; --nice=*) NICE_VALUE="${1#*=}" shift ;; -s | --saving) SAVING=1 shift ;; --saving-file) [[ $# -ge 2 ]] || { error "Missing value for $1" exit $EXIT_USAGE_ERROR } SAVING_FILE="$2" shift 2 ;; --saving-file=*) SAVING_FILE="${1#*=}" shift ;; -S | --skip-codec) [[ $# -ge 2 ]] || { error "Missing value for $1" exit $EXIT_USAGE_ERROR } parse_skip_codec_parameter "$2" shift 2 ;; --skip-codec=*) parse_skip_codec_parameter "${1#*=}" shift ;; -l | --only-if-smaller) ONLY_IF_SMALLER=1 shift ;; -p | --preset) [[ $# -ge 2 ]] || { error "Missing value for $1" exit $EXIT_USAGE_ERROR } PRESET_NAME="$2" shift 2 ;; --preset=*) PRESET_NAME="${1#*=}" shift ;; --) shift break ;; -*) error "Unknown option: $1" exit $EXIT_USAGE_ERROR ;; *) REMAINING_ARGS+=("$1") shift ;; esac done command -v ffmpeg >/dev/null 2>&1 || { error "ffmpeg not found." exit $EXIT_MISSING_DEPENDENCY } command -v ffprobe >/dev/null 2>&1 || { error "ffprobe not found." exit $EXIT_MISSING_DEPENDENCY } command -v nice >/dev/null 2>&1 || { error "nice not found." exit $EXIT_MISSING_DEPENDENCY } if [[ $SAVING -eq 1 ]]; then # bc is only required when --saving is active; intentionally checked here # rather than in the main dependency block to avoid requiring it universally. command -v bc >/dev/null 2>&1 || { error "bc not found." exit $EXIT_MISSING_DEPENDENCY } fi # Remove temporary output files created during encoding. # Registered via trap EXIT so it runs on normal exit and on failures # shellcheck disable=SC2329 cleanup() { # "${TMP_FILES[@]+"${TMP_FILES[@]}"}" expands to nothing when the array is # empty (safe under nounset), unlike [@]:-} which yields one empty iteration. for t in "${TMP_FILES[@]+"${TMP_FILES[@]}"}"; do debug "Cleanup temp file:" "$t" [[ -f "$t" ]] && rm -f -- "$t" || true done } trap cleanup EXIT # Encode a single media file according to PRESET_NAME. # # Steps: # 1) Probe input codec and pixel format (v:0). # 2) Optionally skip based on codec. # 3) Normalize pixel formats (handle yuvj* forms). # 4) Select an output pixel format that preserverse chroma subsampling/bit depth. # 5) Load preset ffargs and run ffmpeg into a temp file in the same directory. # 6) Optionally keep only-if-smaller; optionally log savings; then replace. # # Return: # EXIT_OK on success or if skipped; EXIT_RUNTIME_FAILURE on failure. encode_one() { local file="$1" msg "Processing:" "$file" [[ -z "$file" ]] && return $EXIT_OK [[ ! -f "$file" ]] && { error "Not found" return $EXIT_RUNTIME_FAILURE } local input_codec input_codec=$(ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=nk=1:nw=1 "file:$file" 2>/dev/null) if [[ -z "$input_codec" ]]; then error "Could not determine input video codec" return $EXIT_RUNTIME_FAILURE fi if [[ -v SKIP_CODECS["$input_codec"] ]]; then msg "Skip because of input codec:" "$input_codec" return $EXIT_OK fi local input_pixel_format input_pixel_format=$(ffprobe -v error -select_streams v:0 -show_entries stream=pix_fmt -of default=nk=1:nw=1 "file:$file" 2>/dev/null) if [[ -z "$input_pixel_format" ]]; then error "Could not determine input pixel format" return $EXIT_RUNTIME_FAILURE fi # Some files report "yuvj*" formats (full-range JPEG-style). For encoding # purposes we normalize to the equivalent "yuv*" formats. # Covers all five variants ffmpeg can produce: 411p, 420p, 422p, 440p, 444p. case "$input_pixel_format" in yuvj411p) input_pixel_format="yuv411p" ;; yuvj420p) input_pixel_format="yuv420p" ;; yuvj422p) input_pixel_format="yuv422p" ;; yuvj440p) input_pixel_format="yuv440p" ;; yuvj444p) input_pixel_format="yuv444p" ;; esac debug "Normalized input pixel format:" "$input_pixel_format" # Preserve chroma subsampling (420/422/444) and bit depth (8/10/12/16-bit) # to avoid unintended quality loss or incompatible output. # Known limitation: exotic formats not matching these patterns (e.g. gbrp, # yuva*, gray*) fall through to yuv420p. Extend the logic below if needed. local output_pixel_format if [[ "$input_pixel_format" == *"444"* ]]; then if [[ "$input_pixel_format" == *"16"* ]]; then output_pixel_format="yuv444p16le" elif [[ "$input_pixel_format" == *"12"* ]]; then output_pixel_format="yuv444p12le" elif [[ "$input_pixel_format" == *"10"* ]]; then output_pixel_format="yuv444p10le" else output_pixel_format="yuv444p" fi elif [[ "$input_pixel_format" == *"422"* ]]; then if [[ "$input_pixel_format" == *"16"* ]]; then output_pixel_format="yuv422p16le" elif [[ "$input_pixel_format" == *"12"* ]]; then output_pixel_format="yuv422p12le" elif [[ "$input_pixel_format" == *"10"* ]]; then output_pixel_format="yuv422p10le" else output_pixel_format="yuv422p" fi else if [[ "$input_pixel_format" == *"16"* ]]; then output_pixel_format="yuv420p16le" elif [[ "$input_pixel_format" == *"12"* ]]; then output_pixel_format="yuv420p12le" elif [[ "$input_pixel_format" == *"10"* ]]; then output_pixel_format="yuv420p10le" else output_pixel_format="yuv420p" fi fi debug "Selected output pixel format:" "$output_pixel_format" load_preset "$PRESET_NAME" local extension filename directory tmp base stem base="${file##*/}" if [[ "$base" == *.* ]]; then extension="${base##*.}" stem="${base%.*}" filename="${file%.*}" else extension="" stem="$base" filename="$file" fi directory=$(dirname -- "$file") # Temp file is created in the same directory as the input so that `mv` is # atomic on the same filesystem (avoid cross-device rename issues). tmp="$directory/.${stem}_$PRESET_NAME.$$.$RANDOM${extension:+.$extension}" TMP_FILES+=("$tmp") debug "Temporary output path:" "$tmp" # Build ffmpeg command: # - Map all streams, metadata, and chapters # - Default to stream copy for everything # - Let the preset override specific streams (usually video) via ffargs local cmd=(ffmpeg -nostdin -y -hide_banner -v error -stats -i "file:$file" -map 0 -map_metadata 0 -map_chapters 0 -c copy "${ffargs[@]}" "file:$tmp") if [[ $DRY_RUN -eq 1 ]]; then msg "DRY RUN:" "Would encode with preset $PRESET_NAME" verbose "Input codec:" "$input_codec" verbose "Input pixel format:" "$input_pixel_format" verbose "Output pixel format:" "$output_pixel_format" return $EXIT_OK fi msg "Encoding: $file" debug "Nice value:" "$NICE_VALUE" if nice -n "$NICE_VALUE" "${cmd[@]}"; then local original_filesize new_filesize original_filesize=$(filesize "$file") new_filesize=$(filesize "$tmp") # If requested, keep the original if the new file is larger. if [[ $ONLY_IF_SMALLER -eq 1 ]]; then if [[ $new_filesize -gt $original_filesize ]]; then msg "Did not replace $file as it was larger than the original." rm -f -- "$tmp" return $EXIT_OK fi fi if [[ $SAVING -eq 1 ]]; then local pct if [[ $original_filesize -gt 0 ]]; then pct=$(bc <<<"($original_filesize - $new_filesize) * 100 / $original_filesize") else pct=0 fi printf "%s %s %s %s%%\n" "$filename" "$original_filesize" "$new_filesize" "$pct" >>"$SAVING_FILE" fi mv -f -- "$tmp" "$file" msg "Replaced: $file" else error "Encode FAILED: $file" if [[ $CONTINUE_ON_FAIL -eq 1 ]]; then return $EXIT_RUNTIME_FAILURE # fail this but keep going else exit $EXIT_RUNTIME_FAILURE fi fi } STATUS=$EXIT_OK if [[ -n $ENCODE_FILE ]]; then while IFS= read -r file || [[ -n "$file" ]]; do encode_one "$file" || STATUS=$EXIT_RUNTIME_FAILURE done <"$ENCODE_FILE" else for file in "${REMAINING_ARGS[@]}"; do encode_one "$file" || STATUS=$EXIT_RUNTIME_FAILURE done fi exit $STATUS