#!/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" ) # # The following variables are available to presets at source time: # input_codec - video codec of the input file (e.g. h264, hevc) # input_pixel_format - pixel format after yuvj* normalisation (e.g. yuv420p) # output_pixel_format - recommended output pixel format, derived from the # input to preserve chroma subsampling and bit depth # input_frame_rate - raw frame rate fraction as reported by ffprobe # (e.g. 30000/1001, 25/1, 60/1) # input_fps - input_frame_rate rounded to nearest integer # (e.g. 30, 25, 60) # output_gop_size - recommended GOP size derived from input_fps # (≈ 5 s of video, capped at 300 frames) # # Note: Presets are sourced as shell code. Only use trusted presets. # # An optional TOML configuration file may be placed at: # ${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/config.toml # # It may set defaults for any option, for example: # [encoding] # preset = "av1" # nice = 10 # # [processing] # skip_codecs = ["av1", "hevc"] # output_dir = "encoded" # # [size_report] # enabled = false # file = "transcode_size_report" # # CLI flags always take precedence over config file values. # Requires tomlq (https://github.com/nicowillis/tomlq) when config.toml # is present. # # NOTES # This script respects the NO_COLOR standard (https://no-color.org/). # # AUTHOR # Dennis Fink ############################################################################### ############################################################################### # FILE STRUCTURE # # 1. Header, metadata, globals and exit codes # 2. Function definitions # - Output helpers # - Help/version output # - Small utility functions # - Terminal color handling # - Configuration handling # - Codec skip-list handling # - Preset handling # - Cleanup handling # - Size report handling # - Encoding # 3. Main execution ############################################################################### # 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 -eq 1 ]]; 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 # 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 # 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 # Enable extended glob patterns (e.g. *(...) for repetition) shopt -s extglob ############################################################################### # 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-05-23 readonly VERSION=2.1.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 BACKUP_DIR="" OUTPUT_DIR="" CONTINUE_ON_FAIL=0 DRY_RUN=0 ENCODE_FILE="" FFMPEG_LOGLEVEL="fatal" HWACCEL=1 HWACCEL_VALUE="auto" NICE_VALUE=19 ONLY_IF_SMALLER=0 PRESET_NAME="default" SIZE_REPORT=0 SIZE_REPORT_FILE="transcode_size_report" VERIFY_OUTPUT=1 VIDEO_STREAM="v:0" declare -A SKIP_CODECS REMAINING_ARGS=() TEMPORARY_FILES=() readonly PRESET_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/presets" CONFIG_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/config.toml" # Sentinel flags: set to 1 by the CLI option parser so that load_config() knows # which values have already been provided and must not be overridden. _CLI_BACKUP_DIR=0 _CLI_OUTPUT_DIR=0 _CLI_CONFIG_FILE=0 _CLI_CONTINUE=0 _CLI_FFMPEG_LOGLEVEL=0 _CLI_HWACCEL=0 _CLI_NICE=0 _CLI_ONLY_IF_SMALLER=0 _CLI_PRESET=0 _CLI_QUIET=0 _CLI_SIZE_REPORT=0 _CLI_SIZE_REPORT_FILE=0 _CLI_SKIP_CODECS=() _CLI_SKIP_CODECS_OVERRIDE=0 _CLI_VERBOSE=0 _CLI_VERIFY_OUTPUT=0 _CLI_VIDEO_STREAM=0 # Values for size report _REPORT_ENCODING_FAILED=0 _REPORT_INTEGRITY_FAILED=0 _REPORT_SKIPPED_CODEC=0 _REPORT_SKIPPED_LARGER=0 _REPORT_SUCCEEDED=0 _REPORT_TOTAL_ORIGINAL_BYTES=0 _REPORT_TOTAL_NEW_BYTES=0 _REPORT_TOTAL_SAVED_BYTES=0 ############################################################################### # 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 ############################################################################### # OUTPUT HELPERS ############################################################################### function emit() { local prefix="$1" local color="$2" shift 2 if [[ $# -eq 0 ]]; then printf "%b%s%b\n" "${BOLD}${color}" "$prefix" "$ALL_OFF" return fi local mesg="$1" shift local rest="" if [[ $# -gt 0 ]]; then printf -v rest ' %s' "$@" rest="${rest# }" fi printf "%b%s%b %b%s%b" "${BOLD}${color}" "$prefix" "$ALL_OFF" "$BOLD" "$mesg" "$ALL_OFF" if [[ -n "$rest" ]]; then printf " %s" "$rest" fi printf "\n" } function error() { emit "==> ERROR:" "$RED" "$@" >&2 } function msg() { if [[ $QUIET -eq 0 ]]; then emit "==>" "$GREEN" "$@" fi } function warn() { if [[ $QUIET -eq 0 ]]; then emit "==>" "$YELLOW" "$@" fi } function verbose() { if [[ $VERBOSE -eq 1 && $QUIET -eq 0 ]]; then emit "==>" "$BLUE" "$@" fi } function debug() { if [[ $DEBUG -eq 1 ]]; then emit "==> DEBUG:" "$MAGENTA" "$@" fi } ############################################################################### # HELP AND VERSION OUTPUT ############################################################################### function print_help() { printf "${BOLD}${MAGENTA}%s${ALL_OFF} - ${BOLD}${CYAN}%s${ALL_OFF} - ${BOLD}%s${ALL_OFF} ${BOLD}${BLUE}Usage:${ALL_OFF} ${BOLD}${MAGENTA}%s${ALL_OFF} ${BOLD}${YELLOW}[OPTION]... [--]${ALL_OFF} ${BOLD}${GREEN}FILE...${ALL_OFF} Long options that take a value also accept ${BOLD}${YELLOW}--option${ALL_OFF}${BOLD}${CYAN}=${ALL_OFF}${BOLD}${GREEN}VALUE${ALL_OFF} syntax. ${BOLD}${BLUE}Input options:${ALL_OFF} ${BOLD}${YELLOW}--config-file${ALL_OFF} ${BOLD}${GREEN}FILE${ALL_OFF} load configuration from FILE instead of the default path ${BOLD}${YELLOW}--no-config${ALL_OFF} do not load any configuration file ${BOLD}${YELLOW}-f, --encode-file${ALL_OFF} ${BOLD}${GREEN}FILE${ALL_OFF} file containing a list of files to encode ${BOLD}${BLUE}Encoding options:${ALL_OFF} ${BOLD}${YELLOW}-p, --preset${ALL_OFF} ${BOLD}${GREEN}NAME${ALL_OFF} load ffmpeg arguments from a preset (default: ${BOLD}${RED}%s${ALL_OFF}) ${BOLD}${YELLOW}-N, --nice${ALL_OFF} ${BOLD}${GREEN}VALUE${ALL_OFF} nice adjustment for ffmpeg (default: ${BOLD}${RED}%d${ALL_OFF}) ${BOLD}${YELLOW}--hwaccel${ALL_OFF} ${BOLD}${GREEN}[METHOD]${ALL_OFF} enable hardware acceleration; METHOD defaults to ${BOLD}${RED}%s${ALL_OFF} if omitted ${BOLD}${YELLOW}--no-hwaccel${ALL_OFF} disable hardware acceleration (do not pass ${BOLD}${YELLOW}-hwaccel${ALL_OFF} to ffmpeg) ${BOLD}${YELLOW}--video-stream${ALL_OFF} ${BOLD}${GREEN}STREAM_SELECTOR${ALL_OFF} video stream to probe for preset helper variables (default: ${BOLD}${RED}%s${ALL_OFF}) ${BOLD}${BLUE}Processing options:${ALL_OFF} ${BOLD}${YELLOW}-S, --skip-codec${ALL_OFF} ${BOLD}${GREEN}LIST${ALL_OFF} skip files with these codecs; ${BOLD}${GREEN}LIST${ALL_OFF} is comma, space, or colon separated ${BOLD}${YELLOW}--skip-codec-override${ALL_OFF} use only codecs from ${BOLD}${YELLOW}--skip-codec${ALL_OFF}, ignoring ${BOLD}${CYAN}[processing].skip_codecs${ALL_OFF} from config ${BOLD}${YELLOW}-n, --dry-run${ALL_OFF} show what would be done, do not run ffmpeg ${BOLD}${YELLOW}--backup-dir${ALL_OFF} ${BOLD}${GREEN}DIR${ALL_OFF} backup original files to DIR before replacing them ${BOLD}${YELLOW}--output-dir${ALL_OFF} ${BOLD}${GREEN}DIR${ALL_OFF} write encoded files to DIR without replacing originals ${BOLD}${YELLOW}-l, --only-if-smaller${ALL_OFF} only keep the encoded output if it is smaller ${BOLD}${YELLOW}--no-only-if-smaller${ALL_OFF} keep the encoded output even if it is larger ${BOLD}${YELLOW}--verify-output${ALL_OFF} probe output with ffprobe before replacing original (default) ${BOLD}${YELLOW}--no-verify-output${ALL_OFF} skip the post-encode integrity check ${BOLD}${YELLOW}-c, --continue${ALL_OFF} continue with the next file even if ffmpeg fails ${BOLD}${YELLOW}--no-continue${ALL_OFF} do not continue with the next file if ffmpeg fails ${BOLD}${BLUE}Size report options:${ALL_OFF} ${BOLD}${YELLOW}-s, --size-report${ALL_OFF} print size feedback and write a status TSV log ${BOLD}${YELLOW}--no-size-report${ALL_OFF} disable size reporting from config ${BOLD}${YELLOW}--size-report-file${ALL_OFF} ${BOLD}${GREEN}FILE${ALL_OFF} path for the size report log (default: ${BOLD}${RED}%s${ALL_OFF}) ${BOLD}${BLUE}Display options:${ALL_OFF} ${BOLD}${YELLOW}--ffmpeg-loglevel${ALL_OFF} ${BOLD}${GREEN}LEVEL${ALL_OFF} specify the loglevel to pass to ffmpeg (default: ${BOLD}${RED}%s${ALL_OFF}) ${BOLD}${YELLOW}-q, --quiet${ALL_OFF} suppress all output; takes precedence over ${BOLD}${YELLOW}-v${ALL_OFF} ${BOLD}${YELLOW}-v, --verbose${ALL_OFF} explain what is being done ${BOLD}${YELLOW}--color${ALL_OFF} force colored output ${BOLD}${YELLOW}--no-color${ALL_OFF} disable colored output ${BOLD}${BLUE}General options:${ALL_OFF} ${BOLD}${YELLOW}-h, --help${ALL_OFF} display this help and exit ${BOLD}${YELLOW}--version${ALL_OFF} output version information and exit ${BOLD}${YELLOW}--list-presets${ALL_OFF} list available presets and exit ${BOLD}${BLUE}Configuration files:${ALL_OFF} ${BOLD}${CYAN}%s/.sh${ALL_OFF} preset file; must define a bash array ${BOLD}${GREEN}ffargs${ALL_OFF} with the ffmpeg encoding arguments. the following variables are available to presets: ${BOLD}${GREEN}input_codec${ALL_OFF} e.g. h264, hevc ${BOLD}${GREEN}input_pixel_format${ALL_OFF} e.g. yuv420p ${BOLD}${GREEN}output_pixel_format${ALL_OFF} derived from input ${BOLD}${GREEN}input_frame_rate${ALL_OFF} raw fraction from ffprobe (e.g. 30000/1001) ${BOLD}${GREEN}input_fps${ALL_OFF} input_frame_rate rounded to nearest integer ${BOLD}${GREEN}output_gop_size${ALL_OFF} ~5 s GOP derived from input_fps, capped at 300 ${BOLD}${RED}presets are sourced as shell code; only use trusted presets from trusted directories.${ALL_OFF} ${BOLD}${CYAN}%s${ALL_OFF} Use ${BOLD}${YELLOW}--config-file${ALL_OFF} ${BOLD}${GREEN}FILE${ALL_OFF} to load an alternative path. CLI flags always take precedence. Requires ${BOLD}tomlq${ALL_OFF} when the file is present. ${BOLD}${BLUE}Examples:${ALL_OFF} %s video.mp4 %s -s -f list.txt %s -n -N 10 my_movie.mkv\n" \ "$SCRIPTNAME" "$VERSION" "$DESCRIPTION" \ "$SCRIPTNAME" \ "$PRESET_NAME" \ "$NICE_VALUE" \ "$HWACCEL_VALUE" \ "$VIDEO_STREAM" \ "$SIZE_REPORT_FILE" \ "$FFMPEG_LOGLEVEL" \ "$PRESET_DIR" \ "$CONFIG_FILE" \ "$SCRIPTNAME" \ "$SCRIPTNAME" \ "$SCRIPTNAME" } function 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 Copyright (c) 2025 Dennis Fink . Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" \ "$SCRIPTNAME" "$VERSION" "$DESCRIPTION" "$AUTHOR" "$DATE_OF_CREATION" \ "$DATE_OF_REVISION" "$LICENSE" } ############################################################################### # SMALL UTILITY FUNCTIONS ############################################################################### # Return the size of a file in bytes. # Uses GNU stat -c on Linux with a fallback to BSD/macOS stat -f. function filesize() { stat --format="%s" -- "$1" 2>/dev/null || stat -f "%z" -- "$1" } # Resolve a symlink (or plain path) to its canonical absolute path. # Uses readlink -f (GNU) with a fallback to realpath for macOS/BSD. function resolve_path() { readlink --canonicalize -- "$1" 2>/dev/null || realpath -- "$1" } # Format a byte count into a human-readable IEC size. # Uses numfmt so values are displayed with binary units such as K, M, G, etc. function format_filesize() { numfmt --to=iec -- "$1" } # Return an unused path. Existing files are never overwritten; numeric suffixes # are appended until a free name is found. function unique_path() { local target="$1" if [[ ! -e "$target" ]]; then printf '%s\n' "$target" return $EXIT_OK fi local candidate counter counter=1 while :; do candidate="${target}.${counter}" if [[ ! -e "$candidate" ]]; then printf '%s\n' "$candidate" return $EXIT_OK fi ((counter++)) done } # Print TARGET_PATH relative to BASE_DIR. # # Uses GNU realpath --relative-to when available. Falls back to Bash path # manipulation for BSD/macOS systems where realpath may not support # --relative-to. function relative_path_from() { local base_dir="$1" local target_path="$2" local resolved_base resolved_target resolved_base=$(resolve_path "$base_dir") || return $EXIT_USAGE_ERROR resolved_target=$(resolve_path "$target_path") || return $EXIT_USAGE_ERROR # Short-circuit for identical paths. if [[ "$resolved_target" == "$resolved_base" ]]; then printf '.\n' return $EXIT_OK fi local realpath_output if realpath_output=$(realpath --relative-to="$resolved_base" -- "$resolved_target" 2>/dev/null); then printf '%s\n' "$realpath_output" return $EXIT_OK fi # Walk base up until it is a true prefix of target (on a component boundary). local prefix="" while [[ "$resolved_base" != "/" && "$resolved_target" != "$resolved_base"/* && "$resolved_target" != "$resolved_base" ]]; do resolved_base="${resolved_base%/*}" if [[ -z "$resolved_base" ]]; then resolved_base="/" fi prefix="../$prefix" done local rel if [[ "$resolved_target" == "$resolved_base" ]]; then rel="." elif [[ "$resolved_base" == "/" ]]; then rel="${resolved_target#/}" else rel="${resolved_target#"$resolved_base"/}" fi printf '%s%s\n' "$prefix" "$rel" } # Build a path below DEST_DIR by re-rooting SOURCE_PATH relative to # DEST_DIR's parent. This preserves the diverging portion of SOURCE_PATH # while anchoring it under DEST_DIR. Leading ".." components are stripped # so the result cannot escape DEST_DIR. function path_under_directory() { local dest_dir="$1" local source_path="$2" local resolved_dest dest_parent relative_path resolved_dest=$(resolve_path "$dest_dir") || { return $EXIT_USAGE_ERROR } dest_parent=$(dirname -- "$resolved_dest") relative_path=$(relative_path_from "$dest_parent" "$source_path") || { return $EXIT_USAGE_ERROR } relative_path="${relative_path##*([.][.]/)}" if [[ -z "$relative_path" || "$relative_path" == "." ]]; then return $EXIT_USAGE_ERROR fi printf '%s/%s\n' "$resolved_dest" "$relative_path" } function validate_existing_directory_option() { local option_name="$1" local path_value="$2" if [[ ! -e "$path_value" ]]; then error "$option_name directory does not exist:" "$path_value" exit $EXIT_USAGE_ERROR fi if [[ ! -d "$path_value" ]]; then error "$option_name path is not a directory:" "$path_value" exit $EXIT_USAGE_ERROR fi if [[ ! -w "$path_value" || ! -x "$path_value" ]]; then error "$option_name directory is not writable/executable:" "$path_value" exit $EXIT_USAGE_ERROR fi } ############################################################################### # TERMINAL COLOR HANDLING # # Colors and style escape sequences are configured dynamically through the # setup_colors function. Runtime color detection happens in MAIN EXECUTION so # all function definitions stay together before the executable flow. # # 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. ############################################################################### function 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 } ############################################################################### # CONFIGURATION HANDLING # # Load defaults from config.toml if it exists. Values are only applied when the # corresponding CLI flag has NOT already been set (CLI takes precedence). # Requires tomlq when config.toml is present. ############################################################################### # Query a single scalar value from config.toml via tomlq. # Usage: _tomlq_get (e.g. ".encoding.preset") # Outputs the raw string on stdout; returns non-zero if the key is absent or # tomlq fails for any reason. function _tomlq_get() { tomlq --raw-output "$1" "$CONFIG_FILE" 2>/dev/null } # Load configuration from config.toml, respecting CLI precedence. # Called once, after option parsing, so all _CLI_* sentinels are already set. function load_config() { # We use -r and not -f to allow things like /dev/null if [[ ! -r "$CONFIG_FILE" ]]; then if [[ $_CLI_CONFIG_FILE -eq 1 ]]; then error "Config file not found:" "$CONFIG_FILE" exit $EXIT_CONFIG_ERROR fi return 0 fi command -v tomlq >/dev/null 2>&1 || { error "tomlq not found but $CONFIG_FILE exists. Install tomlq or remove the config file." exit $EXIT_MISSING_DEPENDENCY } # Validate that the config file is well-formed TOML before reading any keys. # tomlq exits non-zero and prints to stderr if the file cannot be parsed. if ! tomlq '.' "$CONFIG_FILE" >/dev/null 2>&1; then error "Config file is not valid TOML:" "$CONFIG_FILE" exit $EXIT_CONFIG_ERROR fi debug "Loading config file:" "$CONFIG_FILE" local val # [encoding] preset if [[ $_CLI_PRESET -eq 0 ]]; then val=$(_tomlq_get '.encoding.preset // empty') if [[ -n "$val" ]]; then debug "Config: encoding.preset =" "$val" PRESET_NAME="$val" fi fi # [encoding] nice if [[ $_CLI_NICE -eq 0 ]]; then val=$(_tomlq_get '.encoding.nice // empty') if [[ -n "$val" ]]; then debug "Config: encoding.nice =" "$val" NICE_VALUE="$val" fi fi # [encoding] hwaccel if [[ $_CLI_HWACCEL -eq 0 ]]; then val=$(_tomlq_get '.encoding.hwaccel // empty') if [[ -n "$val" ]]; then debug "Config: encoding.hwaccel =" "$val" if [[ "$val" == "false" ]]; then HWACCEL=0 else HWACCEL=1 # A bare `hwaccel = true` keeps "auto"; any other string is the METHOD. if [[ "$val" != "true" ]]; then HWACCEL_VALUE="$val" fi fi fi fi # [encoding] video_stream if [[ $_CLI_VIDEO_STREAM -eq 0 ]]; then val=$(_tomlq_get '.encoding.video_stream // empty') if [[ -n "$val" ]]; then debug "Config: encoding.video_stream =" "$val" if ! validate_stream_selector "$val"; then warn "Ignoring invalid config value for encoding.video_stream:" "$val" warn "Falling back to default video stream:" "$VIDEO_STREAM" else VIDEO_STREAM="$val" fi fi fi # [processing] skip_codecs # TOML array values are joined and passed through the same parser as CLI --skip-codec. # CLI --skip-codec entries are additive, so we always load config codecs # regardless of _CLI_SKIP_CODEC. Both sources merge into SKIP_CODECS, except # if --skip-codec-override is specified val=$(_tomlq_get '(.processing.skip_codecs | join(", "))?') if [[ -n "$val" ]]; then debug "Config: processing.skip_codecs =" "$val" parse_skip_codec_parameter "$val" fi # [processing] backup_dir if [[ $_CLI_BACKUP_DIR -eq 0 ]]; then val=$(_tomlq_get '.processing.backup_dir // empty') if [[ -n "$val" ]]; then debug "Config: processing.backup_dir =" "$val" BACKUP_DIR="$val" fi fi # [processing] output_dir if [[ $_CLI_OUTPUT_DIR -eq 0 ]]; then val=$(_tomlq_get '.processing.output_dir // empty') if [[ -n "$val" ]]; then debug "Config: processing.output_dir =" "$val" OUTPUT_DIR="$val" fi fi # [processing] only_if_smaller if [[ $_CLI_ONLY_IF_SMALLER -eq 0 ]]; then val=$(_tomlq_get '.processing.only_if_smaller // empty') if [[ -n "$val" ]]; then debug "Config: processing.only_if_smaller =" "$val" if [[ "$val" == "true" ]]; then ONLY_IF_SMALLER=1 else ONLY_IF_SMALLER=0 fi fi fi # [processing] verify_output if [[ $_CLI_VERIFY_OUTPUT -eq 0 ]]; then val=$(_tomlq_get '.processing.verify_output // empty') if [[ -n "$val" ]]; then debug "Config: processing.verify_output =" "$val" if [[ "$val" == "true" ]]; then VERIFY_OUTPUT=1 else VERIFY_OUTPUT=0 fi fi fi # [processing] continue if [[ $_CLI_CONTINUE -eq 0 ]]; then val=$(_tomlq_get '.processing.continue // empty') if [[ -n "$val" ]]; then debug "Config: processing.continue =" "$val" if [[ "$val" == "true" ]]; then CONTINUE_ON_FAIL=1 else CONTINUE_ON_FAIL=0 fi fi fi # [size_report] enabled if [[ $_CLI_SIZE_REPORT -eq 0 ]]; then val=$(_tomlq_get '.size_report.enabled // empty') if [[ -n "$val" ]]; then debug "Config: size_report.enabled =" "$val" if [[ "$val" == "true" ]]; then SIZE_REPORT=1 else SIZE_REPORT=0 fi fi fi # [size_report] file if [[ $_CLI_SIZE_REPORT_FILE -eq 0 ]]; then val=$(_tomlq_get '.size_report.file // empty') if [[ -n "$val" ]]; then debug "Config: size_report.file =" "$val" SIZE_REPORT_FILE="$val" fi fi # [output] ffmpeg_loglevel if [[ $_CLI_FFMPEG_LOGLEVEL -eq 0 ]]; then val=$(_tomlq_get '.output.ffmpeg_loglevel // empty') if [[ -n "$val" ]]; then debug "Config: output.ffmpeg_loglevel =" "$val" FFMPEG_LOGLEVEL="$val" fi fi # [output] quiet if [[ $_CLI_QUIET -eq 0 ]]; then val=$(_tomlq_get '.output.quiet // empty') if [[ -n "$val" ]]; then debug "Config: output.quiet =" "$val" if [[ "$val" == "true" ]]; then QUIET=1 else QUIET=0 fi fi fi # [output] verbose if [[ $_CLI_VERBOSE -eq 0 ]]; then val=$(_tomlq_get '.output.verbose // empty') if [[ -n "$val" ]]; then debug "Config: output.verbose =" "$val" if [[ "$val" == "true" ]]; then VERBOSE=1 else VERBOSE=0 fi fi fi } ############################################################################### # CODEC SKIP-LIST HANDLING ############################################################################### # Parse a user-provided codec list and add each token to SKIP_CODECS. # Separators: comma, space, or colon. # - Normalizes tokens to lowercase # - Ignores empty tokens # - Avoids duplicate entries # - Used both for CLI --skip-codec and for codecs defined in the config file function parse_skip_codec_parameter() { local arg="$1" local IFS=',: ' token for token in $arg; do token=${token,,} # lowercase if [[ -z "$token" ]]; then continue fi if [[ -v SKIP_CODECS["$token"] ]]; then debug "Codec already skipped:" "$token" else debug "Add to skip codec:" "$token" SKIP_CODECS["$token"]= fi done } ############################################################################### # STREAM SELECTOR HANDLING ############################################################################### function validate_stream_selector() { local stream_selector="$1" # Accept only simple video stream indexes for metadata probing. This is not a # general ffmpeg stream selector parser. if [[ ! "$stream_selector" =~ ^((v|V):)?([0-9]|[1-9][0-9]+)$ ]]; then return 1 fi return 0 } ############################################################################### # PRESET HANDLING ############################################################################### # 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, input_frame_rate, # input_fps, output_gop_size function load_preset() { local preset_name="$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_name" || ! "$preset_name" =~ ^[a-zA-Z0-9_-]+$ ]]; then error "Invalid preset name (only alphanumerics, hyphens, underscores allowed):" "$preset_name" exit $EXIT_USAGE_ERROR fi local preset_file="$PRESET_DIR/$preset_name.sh" if [[ ! -f "$preset_file" ]]; then error "Preset not found:" "$preset_file" exit $EXIT_CONFIG_ERROR fi # Reject preset files that are world-writable to prevent arbitrary users from # injecting shell code. # # Symlinks must be resolved first: stat on a symlink returns the permissions # of the symlink itself (always 777 on Linux), not the target. We use # readlink -f (GNU) with a fallback to realpath for macOS/BSD. local resolved_file resolved_file=$(resolve_path "$preset_file") local preset_perms preset_perms=$(stat --format='%a' -- "$resolved_file" 2>/dev/null || stat -f '%OLp' -- "$resolved_file") if [[ "${preset_perms: -1}" =~ [2367] ]]; then error "Preset file is world-writable, refusing to source:" "$resolved_file" exit $EXIT_CONFIG_ERROR fi unset -v ffargs ffargs=() debug "Sourcing preset file:" "$preset_file" # shellcheck source=/dev/null source "$preset_file" if [[ ${#ffargs[@]} -eq 0 ]]; then error "Preset '$preset_name' did not set ffargs" exit $EXIT_RUNTIME_FAILURE fi debug "Preset ffargs:" "$(printf "%q " "${ffargs[@]}")" } # List all presets in PRESET_DIR, printing their name and optional description. # # A description is read from the first line of the preset file only. # If that line matches: # # description: # (case-insensitive, leading whitespace ignored) the text is extracted; # otherwise the preset is listed without a description. # # Symlink handling: # - A symlink whose target resolves to another *.sh file inside PRESET_DIR # is shown as " -> " with no description (the target # entry already carries it). # - A symlink pointing outside PRESET_DIR is followed and treated as a # regular preset file (description is read from its content). function list_presets() { if [[ ! -d "$PRESET_DIR" ]]; then warn "Preset directory does not exist:" "$PRESET_DIR" return $EXIT_OK fi local -a preset_files=() local preset_file preset_name max_name_len max_name_len=0 for preset_file in "$PRESET_DIR"/*.sh; do if [[ -f "$preset_file" ]]; then preset_files+=("$preset_file") preset_name="${preset_file##*/}" preset_name="${preset_name%.sh}" ((${#preset_name} > max_name_len)) && max_name_len=${#preset_name} fi done if [[ ${#preset_files[@]} -eq 0 ]]; then msg "No presets found in:" "$PRESET_DIR" return $EXIT_OK fi local canonical_preset_dir canonical_preset_dir=$(resolve_path "$PRESET_DIR") msg "Available presets in:" "$PRESET_DIR" local target_path target_name description for preset_file in "${preset_files[@]}"; do preset_name="${preset_file##*/}" preset_name="${preset_name%.sh}" if [[ -L "$preset_file" ]]; then target_path=$(resolve_path "$preset_file") if [[ "$target_path" == "$canonical_preset_dir"/*.sh ]]; then target_name="${target_path##*/}" target_name="${target_name%.sh}" printf "${BOLD}${BLUE}%-${max_name_len}s${ALL_OFF} ${BOLD}${YELLOW}-${ALL_OFF} Symlinked to ${BOLD}${BLUE}%s${ALL_OFF}\n" "$preset_name" "$target_name" continue fi fi description=$(sed --quiet '1s/^[[:space:]]*#[[:space:]]*[Dd]escription:[[:space:]]*//p' "$preset_file") if [[ -n "$description" ]]; then printf "${BOLD}${BLUE}%-${max_name_len}s${ALL_OFF} ${BOLD}${YELLOW}-${ALL_OFF} %s\n" "$preset_name" "$description" else printf "${BOLD}${BLUE}%-${max_name_len}s${ALL_OFF}\n" "$preset_name" fi done } ############################################################################### # CLEANUP HANDLING ############################################################################### # Remove temporary output files created during encoding. # Registered via trap EXIT so it runs on normal exit and on failures # shellcheck disable=SC2329 function 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 "${TEMPORARY_FILES[@]+"${TEMPORARY_FILES[@]}"}"; do debug "Cleanup temp file:" "$t" if [[ -f "$t" ]]; then rm --force -- "$t" fi done } ############################################################################### # SIZE REPORT HANDLING ############################################################################### # Calculate integer percentage saved from original/new byte counts. function calculate_saved_percentage() { local original_bytes="$1" local new_bytes="$2" if [[ -n "$original_bytes" && -n "$new_bytes" && "$original_bytes" -gt 0 ]]; then printf '%s\n' "$(((original_bytes - new_bytes) * 100 / original_bytes))" else printf '0\n' fi } # Append one row to the size report TSV, creating the header when needed. # Empty byte/percentage fields are allowed for failures where no output size # exists. function append_size_report_row() { if [[ $SIZE_REPORT -ne 1 || $DRY_RUN -ne 0 ]]; then return $EXIT_OK fi local status="$1" local input_path="$2" local output_path="${3:-}" local original_bytes="${4:-}" local new_bytes="${5:-}" local saved_pct="${6:-}" if [[ ! -f "$SIZE_REPORT_FILE" ]]; then printf "status\tinput_path\toutput_path\toriginal_bytes\tnew_bytes\tsaved_pct\n" >"$SIZE_REPORT_FILE" fi if [[ -n "$saved_pct" ]]; then saved_pct="${saved_pct}%" fi printf "%s\t%s\t%s\t%s\t%s\t%s\n" "$status" "$input_path" "$output_path" "$original_bytes" "$new_bytes" "$saved_pct" >>"$SIZE_REPORT_FILE" } # Print per-file size feedback after an encode attempt that produced an output # file. function print_size_report_feedback() { if [[ $SIZE_REPORT -eq 0 ]]; then return $EXIT_OK fi local status="$1" local original_bytes="$2" local new_bytes="$3" local saved_bytes="$4" local saved_pct="$5" if [[ $saved_bytes -lt 0 ]]; then msg "Size report [$status]:" \ "$(format_filesize "$original_bytes") → $(format_filesize "$new_bytes")," \ "$(format_filesize "$((-saved_bytes))") larger (${saved_pct}%)" else msg "Size report [$status]:" \ "$(format_filesize "$original_bytes") → $(format_filesize "$new_bytes")," \ "$(format_filesize "$saved_bytes") saved (${saved_pct}%)" fi } # Print the end-of-run size report summary for batch runs. function print_size_report_summary() { local amount_total_files="$1" if [[ $SIZE_REPORT -ne 1 || $QUIET -ne 0 || $amount_total_files -le 1 ]]; then return $EXIT_OK fi local skipped_total failed_total average_saved_pct skipped_total=$((_REPORT_SKIPPED_CODEC + _REPORT_SKIPPED_LARGER)) failed_total=$((_REPORT_ENCODING_FAILED + _REPORT_INTEGRITY_FAILED)) average_saved_pct=0 if [[ $_REPORT_TOTAL_ORIGINAL_BYTES -gt 0 ]]; then average_saved_pct=$((_REPORT_TOTAL_SAVED_BYTES * 100 / _REPORT_TOTAL_ORIGINAL_BYTES)) fi msg "Size report summary:" printf "${BOLD}${GREEN}Encoded:${ALL_OFF} %s\n" "$_REPORT_SUCCEEDED" printf "${BOLD}${CYAN}Skipped:${ALL_OFF} %s (${BOLD}${YELLOW}Codec:${ALL_OFF} %s / ${BOLD}${YELLOW}Size:${ALL_OFF} %s)\n" "$skipped_total" "$_REPORT_SKIPPED_CODEC" "$_REPORT_SKIPPED_LARGER" printf "${BOLD}${RED}Failed:${ALL_OFF} %s (${BOLD}${YELLOW}Encoding:${ALL_OFF} %s / ${BOLD}${YELLOW}Integrity:${ALL_OFF} %s)\n" "$failed_total" "$_REPORT_ENCODING_FAILED" "$_REPORT_INTEGRITY_FAILED" printf "${BOLD}${MAGENTA}Total bytes saved:${ALL_OFF} %s (${BOLD}${YELLOW}Original:${ALL_OFF} %s / ${BOLD}${YELLOW}New:${ALL_OFF} %s)\n" "$(format_filesize "$_REPORT_TOTAL_SAVED_BYTES")" "$(format_filesize "$_REPORT_TOTAL_ORIGINAL_BYTES")" "$(format_filesize "$_REPORT_TOTAL_NEW_BYTES")" printf "${BOLD}${BLUE}Average saved:${ALL_OFF} %s%%\n" "$average_saved_pct" } ############################################################################### # OUTPUT FINALIZATION ############################################################################### # Copy the original file to BACKUP_DIR, preserving the source path below that # directory. Existing backup files are never overwritten. function backup_original() { local input_file="$1" if [[ -z "$BACKUP_DIR" ]]; then return $EXIT_OK fi debug "Preparing backup for:" "$input_file" local resolved_input backup_target backup_parent resolved_input=$(resolve_path "$input_file") debug "Resolved input path:" "$resolved_input" backup_target=$(unique_path "$(path_under_directory "$BACKUP_DIR" "$resolved_input")") debug "Selected backup target:" "$backup_target" backup_parent=$(dirname -- "$backup_target") mkdir --parents -- "$backup_parent" || { error "Could not create backup parent directory:" "$backup_parent" return $EXIT_RUNTIME_FAILURE } if ! cp --preserve=mode,ownership,timestamps -- "$input_file" "$backup_target"; then error "Could not copy original to backup directory:" "$backup_target" return $EXIT_RUNTIME_FAILURE fi if [[ ! -r "$backup_target" ]]; then error "Backup copy is not readable:" "$backup_target" return $EXIT_RUNTIME_FAILURE fi verbose "Backed up original to:" "$backup_target" return $EXIT_OK } # Return the final encoded output path for INPUT_FILE. In replacement mode this # is the original path. In --output-dir mode it is a unique path below # OUTPUT_DIR with the source path preserved. function final_output_path() { local input_file="$1" if [[ -z "$OUTPUT_DIR" ]]; then printf '%s\n' "$input_file" return $EXIT_OK fi local output_source_path base directory stem extension target output_source_path="$input_file" base="${output_source_path##*/}" directory=$(dirname -- "$output_source_path") if [[ "$base" == *.* ]]; then stem="${base%.*}" extension="${base##*.}" else stem="$base" extension="" fi if [[ "$directory" == "." ]]; then target=$(path_under_directory "$OUTPUT_DIR" "$stem${extension:+.$extension}") else target=$(path_under_directory "$OUTPUT_DIR" "$directory/$stem${extension:+.$extension}") fi unique_path "$target" } # Finalize an encoded temporary output. This is the single handoff point used by # encode_one() for replacement mode, backup mode and --output-dir mode. function finalize_encoded_output() { local input_file="$1" local temporary_output_file="$2" local final_output_file="$3" if [[ -n "$OUTPUT_DIR" ]]; then if ! backup_original "$input_file"; then return $EXIT_RUNTIME_FAILURE fi local final_parent final_parent=$(dirname -- "$final_output_file") mkdir --parents -- "$final_parent" || { error "Could not create output parent directory:" "$final_parent" return $EXIT_RUNTIME_FAILURE } if mv -- "$temporary_output_file" "$final_output_file"; then msg "Wrote output:" "$final_output_file" return $EXIT_OK fi error "Could not move encoded output to:" "$final_output_file" error "Original file was left in place:" "$input_file" return $EXIT_RUNTIME_FAILURE fi if ! backup_original "$input_file"; then return $EXIT_RUNTIME_FAILURE fi if mv --force -- "$temporary_output_file" "$input_file"; then msg "Replaced:" "$input_file" return $EXIT_OK fi error "Could not overwrite:" "$input_file" return $EXIT_RUNTIME_FAILURE } ############################################################################### # ENCODING ############################################################################### # Print metadata collected earlier in encode_one(). # Intentionally reads encode_one() locals via Bash dynamic scoping. function encode_one_print_metadata() { verbose "Video stream:" "$VIDEO_STREAM" verbose "Input codec:" "$input_codec" verbose "Input pixel format:" "$input_pixel_format" verbose "Output pixel format:" "$output_pixel_format" verbose "Input frame rate:" "$input_frame_rate" verbose "Input FPS:" "$input_fps" verbose "Output GOP size:" "$output_gop_size" } # Encode a single media file according to PRESET_NAME. # # Arguments: # $1 - path to the file to encode # $2 - current index (1-based, for progress display) # $3 - total file count (for progress display) # # Steps: # 1) Probe input codec and pixel format (v:0) in a single ffprobe call. # 2) Optionally skip based on codec. # 3) Normalize pixel formats (handle yuvj* forms). # 4) Select an output pixel format that preserves chroma subsampling/bit depth. # 5) Load preset ffargs. # 6) Run ffmpeg into a temp file in the same directory. # 7) Optionally keep only-if-smaller; optionally report size status; then replace. # # Return: # EXIT_OK on success or if skipped; EXIT_RUNTIME_FAILURE on failure. function encode_one() { local input_file="$1" local current_file_index="$2" local amount_total_files="$3" # --------------------------------------------------------------------------- # Validate input file # --------------------------------------------------------------------------- # Ignore empty input entries so callers can safely pass filtered file lists # without treating blank lines as errors. if [[ -z "$input_file" ]]; then return $EXIT_OK fi msg "Processing [$current_file_index/$amount_total_files]:" "$input_file" if [[ ! -f "$input_file" ]]; then ((_REPORT_ENCODING_FAILED++)) append_size_report_row "failed" "$input_file" error "Not found:" "$input_file" return $EXIT_RUNTIME_FAILURE fi if [[ ! -r "$input_file" ]]; then ((_REPORT_ENCODING_FAILED++)) append_size_report_row "failed" "$input_file" error "File is not readable:" "$input_file" return $EXIT_RUNTIME_FAILURE fi # --------------------------------------------------------------------------- # Probe input stream metadata # --------------------------------------------------------------------------- # Probe codec, pixel format and frame rate in a single ffprobe invocation to # decrease the startup overhead on large batches. local probe_out probe_out=$( ffprobe \ -v error \ -select_streams "$VIDEO_STREAM" \ -show_entries stream=codec_name,pix_fmt,r_frame_rate \ -of default=nk=1:nw=1 \ "file:$input_file" 2>/dev/null ) local input_codec input_pixel_format input_frame_rate input_fps { IFS= read -r input_codec IFS= read -r input_pixel_format IFS= read -r input_frame_rate } <<<"$probe_out" debug "Input codec:" "$input_codec" if [[ -z "$input_codec" ]]; then ((_REPORT_ENCODING_FAILED++)) append_size_report_row "failed" "$input_file" "" "$(filesize "$input_file")" error "Could not determine input video codec:" "$input_file" return $EXIT_RUNTIME_FAILURE fi if [[ -v SKIP_CODECS["$input_codec"] ]]; then ((_REPORT_SKIPPED_CODEC++)) append_size_report_row "skipped_codec" "$input_file" "" "$(filesize "$input_file")" msg "Skipping (codec excluded):" "$input_codec" return $EXIT_OK fi if [[ -z "$input_pixel_format" ]]; then ((_REPORT_ENCODING_FAILED++)) append_size_report_row "failed" "$input_file" "" "$(filesize "$input_file")" error "Could not determine input pixel format:" "$input_file" return $EXIT_RUNTIME_FAILURE fi if [[ -z "$input_frame_rate" ]]; then ((_REPORT_ENCODING_FAILED++)) append_size_report_row "failed" "$input_file" "" "$(filesize "$input_file")" error "Could not determine input frame rate:" "$input_file" return $EXIT_RUNTIME_FAILURE fi # --------------------------------------------------------------------------- # Derive output pixel format and GOP size # --------------------------------------------------------------------------- # Some files report "yuvj*" formats (full-range JPEG-style). For encoding # purposes we normalize to the equivalent "yuv*" formats. input_pixel_format="${input_pixel_format/yuvj/yuv}" 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 subsampling depth output_pixel_format case "$input_pixel_format" in *444*) subsampling="444" ;; *422*) subsampling="422" ;; *) subsampling="420" if [[ "$input_pixel_format" != *420* ]]; then verbose "Unrecognised chroma subsampling in '$input_pixel_format', falling back to yuv420p" fi ;; esac case "$input_pixel_format" in *16*) depth="p16le" ;; *12*) depth="p12le" ;; *10*) depth="p10le" ;; *) depth="p" ;; esac output_pixel_format="yuv${subsampling}${depth}" debug "Selected output pixel format:" "$output_pixel_format" # Convert ffprobe frame rate fractions like 30000/1001 to rounded integer FPS # values for simpler encoder heuristics and logging. local _fps_num _fps_den IFS='/' read -r _fps_num _fps_den <<<"$input_frame_rate" if [[ -n "$_fps_den" && "$_fps_den" -ne 0 ]]; then input_fps=$(((_fps_num + _fps_den / 2) / _fps_den)) else input_fps="$_fps_num" fi debug "Calculated input fps:" "$input_fps" # Use a larger GOP for better compression efficiency while keeping seek # performance reasonable. A common rule-of-thumb is roughly five seconds of # video per GOP, capped at 300 frames. local output_gop_size output_gop_size=$(((10 * input_fps) / 2)) if [[ "$output_gop_size" -ge 300 ]]; then debug "Calculated GOP size exceeds 300 frames, capping to 300" output_gop_size=300 fi debug "Calculated output GOP size:" "$output_gop_size" load_preset "$PRESET_NAME" # --------------------------------------------------------------------------- # Build temporary output path and ffmpeg command # --------------------------------------------------------------------------- local extension directory temporary_output_file base stem final_output_file base="${input_file##*/}" if [[ "$base" == *.* ]]; then extension="${base##*.}" stem="${base%.*}" else extension="" stem="$base" fi directory=$(dirname -- "$input_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). mktemp # provides cryptographically random names and atomic creation, unlike # $$.$RANDOM which has limited entropy and is predictable. temporary_output_file=$(mktemp -- "$directory/.${stem}_${PRESET_NAME}.XXXXXX${extension:+.$extension}") TEMPORARY_FILES+=("$temporary_output_file") debug "Temporary output path:" "$temporary_output_file" final_output_file=$(final_output_path "$input_file") debug "Final output path:" "$final_output_file" # Build ffmpeg command: # - Map all streams, metadata, and chapters # - Default to stream copy for everything # - Prepend -hwaccel before -i when hardware acceleration is requested # - Let the preset override specific streams (usually video) via ffargs local hwaccel_args=() if [[ $HWACCEL -eq 1 ]]; then hwaccel_args=(-hwaccel "$HWACCEL_VALUE") fi local encode_cmd=(ffmpeg -nostdin -y -hide_banner -v "$FFMPEG_LOGLEVEL" -stats "${hwaccel_args[@]}" -i "file:$input_file" -map 0 -map_metadata 0 -map_chapters 0 -c copy "${ffargs[@]}" "file:$temporary_output_file") # --------------------------------------------------------------------------- # Dry-run output # --------------------------------------------------------------------------- if [[ $DRY_RUN -eq 1 ]]; then msg "DRY RUN:" "Would encode with preset $PRESET_NAME" encode_one_print_metadata verbose "Final output path:" "$final_output_file" verbose "Command:" "$(printf "%q " "${encode_cmd[@]}")" return $EXIT_OK fi # --------------------------------------------------------------------------- # Run encode, verify output, and replace original # --------------------------------------------------------------------------- msg "Encoding:" "$input_file" encode_one_print_metadata if nice -n "$NICE_VALUE" "${encode_cmd[@]}"; then # Integrity verification: confirm the encoded output is a valid, playable # file before discarding the original. ffprobe exits non-zero and emits # nothing useful if the container is corrupt or contains no readable # streams, so we treat any non-zero exit as a fatal encode failure. Skip # with --no-verify-output when trust in the encoder is high and the extra # probe round-trip is undesirable. if [[ $VERIFY_OUTPUT -eq 1 ]]; then debug "Verifying output integrity:" "$temporary_output_file" if ! ffprobe -v error "file:$temporary_output_file" >/dev/null 2>&1; then ((_REPORT_INTEGRITY_FAILED++)) append_size_report_row "failed" "$input_file" "$temporary_output_file" "$(filesize "$input_file")" "$(filesize "$temporary_output_file")" error "Output failed integrity check:" "$temporary_output_file" rm -f -- "$temporary_output_file" if [[ $CONTINUE_ON_FAIL -eq 1 ]]; then return $EXIT_RUNTIME_FAILURE else print_size_report_summary "$amount_total_files" exit $EXIT_RUNTIME_FAILURE fi fi verbose "Integrity check passed:" "$temporary_output_file" fi local original_filesize new_filesize original_filesize=$(filesize "$input_file") new_filesize=$(filesize "$temporary_output_file") local filesize_percentage filesize_difference filesize_difference=$((original_filesize - new_filesize)) filesize_percentage=$(calculate_saved_percentage "$original_filesize" "$new_filesize") # If requested, keep the original if the new file is larger. if [[ $ONLY_IF_SMALLER -eq 1 && $new_filesize -gt $original_filesize ]]; then ((_REPORT_SKIPPED_LARGER++)) append_size_report_row "skipped_larger" "$input_file" "$final_output_file" "$original_filesize" "$new_filesize" "$filesize_percentage" print_size_report_feedback "skipped_larger" "$original_filesize" "$new_filesize" "$filesize_difference" "$filesize_percentage" msg "Did not keep output (new file is larger):" "$final_output_file" rm -f -- "$temporary_output_file" return $EXIT_OK fi if ! finalize_encoded_output "$input_file" "$temporary_output_file" "$final_output_file"; then ((_REPORT_ENCODING_FAILED++)) append_size_report_row "failed" "$input_file" "$final_output_file" "$original_filesize" "$new_filesize" error "Output finalization FAILED:" "$final_output_file" if [[ $CONTINUE_ON_FAIL -eq 1 ]]; then return $EXIT_RUNTIME_FAILURE else print_size_report_summary "$amount_total_files" exit $EXIT_RUNTIME_FAILURE fi fi append_size_report_row "encoded" "$input_file" "$final_output_file" "$original_filesize" "$new_filesize" "$filesize_percentage" print_size_report_feedback "encoded" "$original_filesize" "$new_filesize" "$filesize_difference" "$filesize_percentage" ((_REPORT_SUCCEEDED++)) _REPORT_TOTAL_ORIGINAL_BYTES=$((_REPORT_TOTAL_ORIGINAL_BYTES + original_filesize)) _REPORT_TOTAL_NEW_BYTES=$((_REPORT_TOTAL_NEW_BYTES + new_filesize)) _REPORT_TOTAL_SAVED_BYTES=$((_REPORT_TOTAL_SAVED_BYTES + filesize_difference)) else ((_REPORT_ENCODING_FAILED++)) append_size_report_row "failed" "$input_file" "" "$(filesize "$input_file")" error "Encode FAILED:" "$input_file" if [[ $CONTINUE_ON_FAIL -eq 1 ]]; then return $EXIT_RUNTIME_FAILURE # fail this but keep going else print_size_report_summary "$amount_total_files" exit $EXIT_RUNTIME_FAILURE fi fi } ############################################################################### # MAIN EXECUTION ############################################################################### # Configure terminal colors before any user-facing output. # # 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 FUNCTION # DEFINITIONS sections without adjusting those dependencies. # # Keep ffmpeg/libav log coloring in sync with transcode.sh's color mode. # FFmpeg honors AV_LOG_FORCE_COLOR and AV_LOG_FORCE_NOCOLOR for its own output, # so exporting these prevents subprocess logs from using a different color style. if [[ -n "${NO_COLOR:-}" ]]; then debug "Colors are disabled via NO_COLOR" ENABLE_COLOR=0 export AV_LOG_FORCE_NOCOLOR=1 elif [[ -n "${FORCE_COLOR:-}" ]]; then debug "Colors are forced via FORCE_COLOR" ENABLE_COLOR=1 export AV_LOG_FORCE_COLOR=1 elif [[ -t 1 ]]; then debug "Colors are enabled via interactive terminal" ENABLE_COLOR=1 export AV_LOG_FORCE_COLOR=1 else debug "Colors are disabled" ENABLE_COLOR=0 export AV_LOG_FORCE_NOCOLOR=1 fi setup_colors # Reject calls without any positional arguments or options before doing more # setup work. if [[ $# -eq 0 ]]; then debug "No parameters were specified" printf "${BOLD}${BLUE}Usage:${ALL_OFF} ${BOLD}${MAGENTA}%s${ALL_OFF} ${BOLD}${YELLOW}[OPTION] [--]${ALL_OFF} ${BOLD}${GREEN}FILE...${ALL_OFF} Run ${BOLD}${MAGENTA}%s${ALL_OFF} ${BOLD}${YELLOW}--help${ALL_OFF} for full usage information.\n" "$SCRIPTNAME" "$SCRIPTNAME" >&2 exit $EXIT_USAGE_ERROR fi # Parse command-line options. CLI flags are recorded in _CLI_* sentinel # variables so load_config() can respect CLI-over-config precedence. while [[ $# -gt 0 ]]; do case "$1" in --config-file) if [[ $# -lt 2 ]]; then error "Missing value for $1" exit $EXIT_USAGE_ERROR fi if [[ -z "$2" ]]; then error "Value for $1 must not be empty" exit $EXIT_USAGE_ERROR fi CONFIG_FILE="$2" _CLI_CONFIG_FILE=1 shift 2 ;; --config-file=*) if [[ -z "${1#*=}" ]]; then error "Value for --config-file must not be empty" exit $EXIT_USAGE_ERROR fi CONFIG_FILE="${1#*=}" _CLI_CONFIG_FILE=1 shift ;; --no-config) CONFIG_FILE="/dev/null" _CLI_CONFIG_FILE=1 shift ;; -f | --encode-file) if [[ $# -lt 2 ]]; then error "Missing value for $1" exit $EXIT_USAGE_ERROR fi if [[ -z "$2" ]]; then error "Value for $1 must not be empty" exit $EXIT_USAGE_ERROR fi ENCODE_FILE="$2" shift 2 ;; --encode-file=*) if [[ -z "${1#*=}" ]]; then error "Value for --encode-file must not be empty" exit $EXIT_USAGE_ERROR fi ENCODE_FILE="${1#*=}" shift ;; -p | --preset) if [[ $# -lt 2 ]]; then error "Missing value for $1" exit $EXIT_USAGE_ERROR fi if [[ -z "$2" ]]; then error "Value for $1 must not be empty" exit $EXIT_USAGE_ERROR fi PRESET_NAME="$2" _CLI_PRESET=1 shift 2 ;; --preset=*) if [[ -z "${1#*=}" ]]; then error "Value for --preset must not be empty" exit $EXIT_USAGE_ERROR fi PRESET_NAME="${1#*=}" _CLI_PRESET=1 shift ;; -N | --nice) if [[ $# -lt 2 ]]; then error "Missing value for $1" exit $EXIT_USAGE_ERROR fi NICE_VALUE="$2" _CLI_NICE=1 shift 2 ;; --nice=*) NICE_VALUE="${1#*=}" _CLI_NICE=1 shift ;; --hwaccel) HWACCEL=1 # Peek at the next argument: use it as the METHOD only if it is # non-empty and does not look like an option flag. if [[ $# -ge 2 && -n "$2" && "$2" != -* ]]; then HWACCEL_VALUE="$2" shift 2 else HWACCEL_VALUE="auto" shift fi _CLI_HWACCEL=1 ;; --hwaccel=*) HWACCEL=1 HWACCEL_VALUE="${1#*=}" if [[ -z "$HWACCEL_VALUE" ]]; then error "Value for --hwaccel must not be empty" exit $EXIT_USAGE_ERROR fi _CLI_HWACCEL=1 shift ;; --no-hwaccel) HWACCEL=0 _CLI_HWACCEL=1 shift ;; --video-stream) if [[ $# -lt 2 ]]; then error "Missing value for:" "$1" exit $EXIT_USAGE_ERROR fi if [[ -z "$2" ]]; then error "Value for $1 must not be empty" exit $EXIT_USAGE_ERROR fi if ! validate_stream_selector "$2"; then error "Invalid stream selector:" "$2" exit $EXIT_USAGE_ERROR fi VIDEO_STREAM="$2" _CLI_VIDEO_STREAM=1 shift 2 ;; --video-stream=*) if [[ -z "${1#*=}" ]]; then error "Value for --video-stream must not be empty" exit $EXIT_USAGE_ERROR fi if ! validate_stream_selector "${1#*=}"; then error "Invalid stream selector:" "${1#*=}" exit $EXIT_USAGE_ERROR fi VIDEO_STREAM="${1#*=}" _CLI_VIDEO_STREAM=1 shift ;; -S | --skip-codec) if [[ $# -lt 2 ]]; then error "Missing value for $1" exit $EXIT_USAGE_ERROR fi _CLI_SKIP_CODECS+=("$2") shift 2 ;; --skip-codec=*) _CLI_SKIP_CODECS+=("${1#*=}") shift ;; --skip-codec-override) _CLI_SKIP_CODECS_OVERRIDE=1 shift ;; -n | --dry-run) DRY_RUN=1 shift ;; --backup-dir) if [[ $# -lt 2 ]]; then error "Missing value for $1" exit $EXIT_USAGE_ERROR fi if [[ -z "$2" ]]; then error "Value for $1 must not be empty" exit $EXIT_USAGE_ERROR fi BACKUP_DIR="$2" _CLI_BACKUP_DIR=1 shift 2 ;; --backup-dir=*) if [[ -z "${1#*=}" ]]; then error "Value for --backup-dir must not be empty" exit $EXIT_USAGE_ERROR fi BACKUP_DIR="${1#*=}" _CLI_BACKUP_DIR=1 shift ;; --output-dir) if [[ $# -lt 2 ]]; then error "Missing value for $1" exit $EXIT_USAGE_ERROR fi if [[ -z "$2" ]]; then error "Value for $1 must not be empty" exit $EXIT_USAGE_ERROR fi OUTPUT_DIR="$2" _CLI_OUTPUT_DIR=1 shift 2 ;; --output-dir=*) if [[ -z "${1#*=}" ]]; then error "Value for --output-dir must not be empty" exit $EXIT_USAGE_ERROR fi OUTPUT_DIR="${1#*=}" _CLI_OUTPUT_DIR=1 shift ;; -l | --only-if-smaller) ONLY_IF_SMALLER=1 _CLI_ONLY_IF_SMALLER=1 shift ;; --no-only-if-smaller) ONLY_IF_SMALLER=0 _CLI_ONLY_IF_SMALLER=1 shift ;; --verify-output) VERIFY_OUTPUT=1 _CLI_VERIFY_OUTPUT=1 shift ;; --no-verify-output) VERIFY_OUTPUT=0 _CLI_VERIFY_OUTPUT=1 shift ;; -c | --continue) CONTINUE_ON_FAIL=1 _CLI_CONTINUE=1 shift ;; --no-continue) CONTINUE_ON_FAIL=0 _CLI_CONTINUE=1 shift ;; -s | --size-report) SIZE_REPORT=1 _CLI_SIZE_REPORT=1 shift ;; --no-size-report) SIZE_REPORT=0 _CLI_SIZE_REPORT=1 shift ;; --size-report-file) if [[ $# -lt 2 ]]; then error "Missing value for $1" exit $EXIT_USAGE_ERROR fi if [[ -z "$2" ]]; then error "Value for $1 must not be empty" exit $EXIT_USAGE_ERROR fi SIZE_REPORT_FILE="$2" _CLI_SIZE_REPORT_FILE=1 shift 2 ;; --size-report-file=*) if [[ -z "${1#*=}" ]]; then error "Value for --size-report-file must not be empty" exit $EXIT_USAGE_ERROR fi SIZE_REPORT_FILE="${1#*=}" _CLI_SIZE_REPORT_FILE=1 shift ;; --ffmpeg-loglevel) if [[ $# -lt 2 ]]; then error "Missing value for:" "$1" exit $EXIT_USAGE_ERROR fi if [[ -z "$2" ]]; then error "Value for $1 must not be empty" exit $EXIT_USAGE_ERROR fi FFMPEG_LOGLEVEL="$2" _CLI_FFMPEG_LOGLEVEL=1 shift 2 ;; --ffmpeg-loglevel=*) if [[ -z "${1#*=}" ]]; then error "Value for --ffmpeg-loglevel must not be empty" exit $EXIT_USAGE_ERROR fi FFMPEG_LOGLEVEL="${1#*=}" _CLI_FFMPEG_LOGLEVEL=1 shift ;; -q | --quiet) QUIET=1 _CLI_QUIET=1 shift ;; -v | --verbose) VERBOSE=1 _CLI_VERBOSE=1 shift ;; --color) ENABLE_COLOR=1 # Propagate the explicit color choice to ffmpeg/libav subprocesses. export AV_LOG_FORCE_COLOR=1 setup_colors shift ;; --no-color) ENABLE_COLOR=0 # Propagate the explicit color choice to ffmpeg/libav subprocesses. export AV_LOG_FORCE_NOCOLOR=1 setup_colors shift ;; -h | --help | -\?) print_help exit $EXIT_OK ;; --version) print_version exit $EXIT_OK ;; --list-presets) list_presets exit $EXIT_OK ;; --) shift REMAINING_ARGS+=("$@") break ;; -*) error "Unknown option: $1" exit $EXIT_USAGE_ERROR ;; *) REMAINING_ARGS+=("$1") shift ;; esac done # Apply config file defaults. Must run after CLI parsing so _CLI_* sentinels # are set, but before validation so config-supplied values are validated too. load_config if [[ $_CLI_SKIP_CODECS_OVERRIDE -eq 1 ]]; then SKIP_CODECS=() fi for codec_list in "${_CLI_SKIP_CODECS[@]}"; do parse_skip_codec_parameter "$codec_list" done printf -v joined ' %s' "${!SKIP_CODECS[@]}" debug "Effective configuration:" debug " Input:" debug " config-file:" "$CONFIG_FILE" debug " encode-file:" "$ENCODE_FILE" debug " Encoding:" debug " preset:" "$PRESET_NAME" debug " nice:" "$NICE_VALUE" debug " hwaccel:" "$HWACCEL" debug " hwaccel_value:" "$HWACCEL_VALUE" debug " video-stream:" "$VIDEO_STREAM" debug " Processing:" debug " skip-codecs:" "${joined# }" debug " skip-codec-override" "$_CLI_SKIP_CODECS_OVERRIDE" debug " dry-run:" "$DRY_RUN" debug " backup-dir:" "$BACKUP_DIR" debug " output-dir:" "$OUTPUT_DIR" debug " only-if-smaller:" "$ONLY_IF_SMALLER" debug " verify-output:" "$VERIFY_OUTPUT" debug " continue:" "$CONTINUE_ON_FAIL" debug " Size report:" debug " size-report:" "$SIZE_REPORT" debug " size-report-file:" "$SIZE_REPORT_FILE" debug " Display:" debug " ffmpeg-loglevel:" "$FFMPEG_LOGLEVEL" debug " quiet:" "$QUIET" debug " verbose:" "$VERBOSE" 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 [[ $_CLI_SIZE_REPORT_FILE -eq 1 && $SIZE_REPORT -eq 0 ]]; then warn "--size-report-file has no effect without --size-report" fi if [[ -n "$BACKUP_DIR" ]]; then validate_existing_directory_option "Backup" "$BACKUP_DIR" BACKUP_DIR=$(resolve_path "$BACKUP_DIR") fi if [[ -n "$OUTPUT_DIR" ]]; then validate_existing_directory_option "Output" "$OUTPUT_DIR" OUTPUT_DIR=$(resolve_path "$OUTPUT_DIR") fi if [[ ! "$NICE_VALUE" =~ ^-?[0-9]+$ ]]; then error "Invalid nice value (must be an integer):" "$NICE_VALUE" exit $EXIT_USAGE_ERROR fi if [[ ! -z "${joined# }" ]]; then verbose "Skipping files with codecs:" "${joined# }" fi unset joined # Remove temporary output files on normal exit and on failures. trap cleanup EXIT STATUS=$EXIT_OK if [[ -n $ENCODE_FILE ]]; then if [[ ! -f "$ENCODE_FILE" ]]; then error "Encode file not found:" "$ENCODE_FILE" exit $EXIT_USAGE_ERROR fi # Build the file list into an array first so we know the total count. mapfile -t FILES_TO_ENCODE < <( while IFS= read -r file || [[ -n "$file" ]]; do if [[ -n "$file" ]]; then printf '%s\n' "$file" fi done <"$ENCODE_FILE" ) total=${#FILES_TO_ENCODE[@]} for ((i = 0; i < total; i++)); do encode_one "${FILES_TO_ENCODE[i]}" "$((i + 1))" "$total" || STATUS=$EXIT_RUNTIME_FAILURE done else total=${#REMAINING_ARGS[@]} for ((i = 0; i < total; i++)); do encode_one "${REMAINING_ARGS[i]}" "$((i + 1))" "$total" || STATUS=$EXIT_RUNTIME_FAILURE done fi print_size_report_summary "$total" exit $STATUS