#!/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 # # [skip] # codecs = ["av1", "hevc"] # # [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. # # NOTE: skip.conf is deprecated. If it exists alongside config.toml the # script will warn and ignore it. Migrate codecs to [skip] codecs in # config.toml. # # 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 ############################################################################### # 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-17 readonly VERSION=2.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 BACKUP_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" # Kept for deprecation warning only — no longer read for codec data. readonly SKIP_CODECS_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/skip.conf" # 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_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}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}-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 instead of overwriting them ${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}--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}${YELLOW} --no-hwaccel${ALL_OFF} disable hardware acceleration (do not pass ${BOLD}${YELLOW}-hwaccel${ALL_OFF} to ffmpeg) ${BOLD}${YELLOW}-l, --only-if-smaller${ALL_OFF} only replace original if new file is smaller ${BOLD}${YELLOW}--no-only-if-smaller${ALL_OFF} do replace original even if new file is bigger ${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}${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}${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}-f, --encode-file${ALL_OFF} ${BOLD}${GREEN}FILE${ALL_OFF} file containing a list of files to encode ${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 --skip-codec, ignoring [skip].codecs from config ${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.toml ${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}Output options:${ALL_OFF} ${BOLD}${YELLOW}--ffmpeg-loglevel${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 -c "%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 -f -- "$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 PATH without a leading slash so it can safely be placed below another # directory while still preserving its absolute path structure. function strip_leading_slash() { local path="$1" path="${path#/}" printf '%s\n' "$path" } ############################################################################### # 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 -r "$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() { # Warn about the legacy skip.conf here (not at startup) so --quiet is # respected; by this point the CLI has been fully parsed. if [[ -f "$SKIP_CODECS_FILE" ]]; then warn "skip.conf is deprecated." "Migrate codecs to [skip] codecs in config.toml and remove $SKIP_CODECS_FILE" fi # 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: 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: nice =" "$val" NICE_VALUE="$val" fi fi # [encoding] only_if_smaller if [[ $_CLI_ONLY_IF_SMALLER -eq 0 ]]; then val=$(_tomlq_get '.encoding.only_if_smaller // empty') if [[ -n "$val" ]]; then debug "Config: encoding.only_if_smaller =" "$val" if [[ "$val" == "true" ]]; then ONLY_IF_SMALLER=1 else ONLY_IF_SMALLER=0 fi fi fi # [encoding] continue if [[ $_CLI_CONTINUE -eq 0 ]]; then val=$(_tomlq_get '.encoding.continue // empty') if [[ -n "$val" ]]; then debug "Config: encoding.continue =" "$val" if [[ "$val" == "true" ]]; then CONTINUE_ON_FAIL=1 else CONTINUE_ON_FAIL=0 fi fi fi # [encoding] verify_output if [[ $_CLI_VERIFY_OUTPUT -eq 0 ]]; then val=$(_tomlq_get '.encoding.verify_output // empty') if [[ -n "$val" ]]; then debug "Config: encoding.verify_output =" "$val" if [[ "$val" == "true" ]]; then VERIFY_OUTPUT=1 else VERIFY_OUTPUT=0 fi 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 warning "Ignoring invalid config value for encoding.video_stream:" "$val" warning "Falling back to default video stream:" "$VIDEO_STREAM" else VIDEO_STREAM="$val" fi fi fi if [[ $_CLI_BACKUP_DIR -eq 0 ]]; then val=$(_tomlq_get '.encoding.backup_dir // empty') if [[ -n "$val" ]]; then debug "Config: encoding.backup_dir =" "$val" BACKUP_DIR="$val" fi fi # [skip] codecs (TOML array → one element per line via tomlq -r '.skip.codecs[]') # CLI --skip-codec entries are additive, so we always load config codecs # regardless of _CLI_SKIP_CODEC. Both sources merge into SKIP_CODECS. local codec while IFS= read -r codec; do if [[ -n "$codec" ]]; then add_skip_codec_token "$codec" fi done < <(tomlq -r '.skip.codecs[]?' "$CONFIG_FILE" 2>/dev/null) # [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] 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 # [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] 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 } ############################################################################### # CODEC SKIP-LIST HANDLING ############################################################################### # Add a codec name to the SKIP_CODECS associative array. # - Normalizes to lowercase # - Ignores empty tokens # - Used both for CLI --skip-codec and for codecs defined function add_skip_codec_token() { local token="${1,,}" # 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 function parse_skip_codec_parameter() { local arg="$1" local IFS=',: ' token for token in $arg; do if [[ ! -z "$token" ]]; then add_skip_codec_token "$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 -c '%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 -n '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 -f -- "$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 filename="$2" local original_bytes="${3:-}" local new_bytes="${4:-}" local saved_pct="${5:-}" if [[ ! -f "$SIZE_REPORT_FILE" ]]; then printf "status\tfilename\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\n" "$status" "$filename" "$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" } ############################################################################### # BACKUP HANDLING ############################################################################### # Return an unused backup path. Existing backups are never overwritten; numeric # suffixes are appended until a free name is found. function unique_backup_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 } # Copy the original file to BACKUP_DIR, preserving the absolute source path # below that directory, then move the encoded temporary output into the original # location. The original remains in place until the final replacement step. function backup_then_replace_original() { local input_file="$1" local temporary_output_file="$2" debug "Preparing backup replacement for:" "$input_file" local resolved_input relative_backup_path backup_target backup_parent resolved_input=$(resolve_path "$input_file") debug "Resolved input path:" "$resolved_input" relative_backup_path=$(strip_leading_slash "$resolved_input") debug "Relative backup path:" "$relative_backup_path" backup_target=$(unique_backup_path "$BACKUP_DIR/$relative_backup_path") debug "Selected backup target:" "$backup_target" backup_parent=$(dirname -- "$backup_target") mkdir -p -- "$backup_parent" || { error "Could not create backup parent directory:" "$backup_parent" return $EXIT_RUNTIME_FAILURE } if ! cp -p -- "$input_file" "$backup_target"; then error "Could not copy original to backup directory:" "$backup_target" return $EXIT_RUNTIME_FAILURE else debug "Original copied to backup:" "$backup_target" fi if [[ ! -r "$backup_target" ]]; then error "Backup copy is not readable:" "$backup_target" return $EXIT_RUNTIME_FAILURE else verbose "Backed up original to:" "$backup_target" fi if mv -f -- "$temporary_output_file" "$input_file"; then msg "Replaced:" "$input_file" return $EXIT_OK fi error "Could not move encoded output:" "$input_file" error "Original file was left in place:" "$input_file" return $EXIT_RUNTIME_FAILURE } # Replace the original with the encoded output. If BACKUP_DIR is set, preserve # the original instead of deleting it. function replace_original() { local input_file="$1" local temporary_output_file="$2" if [[ -n "$BACKUP_DIR" ]]; then backup_then_replace_original "$input_file" "$temporary_output_file" return $? fi if mv -f -- "$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 # filename (path without extension) is used only for the size report log. # Note: only the last extension is stripped, so multi-dot names like # "my.show.s01e01.mkv" are logged as "my.show.s01e01". local filename if [[ "$input_file" == *.* ]]; then filename="${input_file%.*}" else filename="$input_file" fi msg "Processing [$current_file_index/$amount_total_files]:" "$input_file" if [[ ! -f "$input_file" ]]; then ((_REPORT_ENCODING_FAILED++)) append_size_report_row "failed" "$filename" error "Not found:" "$input_file" return $EXIT_RUNTIME_FAILURE fi if [[ ! -r "$input_file" ]]; then ((_REPORT_ENCODING_FAILED++)) append_size_report_row "failed" "$filename" 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" "$filename" "$(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" "$filename" "$(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" "$filename" "$(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" "$filename" "$(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 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" # 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 "Command:" "${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" "$filename" "$(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" "$filename" "$original_filesize" "$new_filesize" "$filesize_percentage" print_size_report_feedback "skipped_larger" "$original_filesize" "$new_filesize" "$filesize_difference" "$filesize_percentage" msg "Did not replace (new file is larger):" "$input_file" rm -f -- "$temporary_output_file" return $EXIT_OK fi if ! replace_original "$input_file" "$temporary_output_file"; then ((_REPORT_ENCODING_FAILED++)) append_size_report_row "failed" "$filename" "$original_filesize" "$new_filesize" error "Replacement FAILED:" "$input_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" "$filename" "$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" "$filename" "$(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 -h | --help | -\?) print_help exit $EXIT_OK ;; --version) print_version exit $EXIT_OK ;; --list-presets) list_presets exit $EXIT_OK ;; -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 ;; -c | --continue) CONTINUE_ON_FAIL=1 _CLI_CONTINUE=1 shift ;; --no-continue) CONTINUE_ON_FAIL=0 _CLI_CONTINUE=1 shift ;; --verify-output) VERIFY_OUTPUT=1 _CLI_VERIFY_OUTPUT=1 shift ;; --no-verify-output) VERIFY_OUTPUT=0 _CLI_VERIFY_OUTPUT=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 ;; --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 ;; -n | --dry-run) DRY_RUN=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 ;; -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 ;; -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 ;; -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 ;; --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 ;; -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 ;; --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 ;; --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 ;; --) 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 " backup-dir:" "$BACKUP_DIR" debug " config-file:" "$CONFIG_FILE" debug " dry-run:" "$DRY_RUN" debug " encode-file:" "$ENCODE_FILE" debug " ffmpeg-loglevel:" "$FFMPEG_LOGLEVEL" debug " hwaccel:" "$HWACCEL" debug " hwaccel_value:" "$HWACCEL_VALUE" debug " nice:" "$NICE_VALUE" debug " only-if-smaller:" "$ONLY_IF_SMALLER" debug " preset:" "$PRESET_NAME" debug " quiet:" "$QUIET" debug " size-report-file:" "$SIZE_REPORT_FILE" debug " size-report:" "$SIZE_REPORT" debug " skip-codecs:" "${joined# }" debug " verbose:" "$VERBOSE" debug " verify-output:" "$VERIFY_OUTPUT" debug " video-stream:" "$VIDEO_STREAM" 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 if [[ ! -e "$BACKUP_DIR" ]]; then error "Backup directory does not exist:" "$BACKUP_DIR" exit $EXIT_USAGE_ERROR fi if [[ ! -d "$BACKUP_DIR" ]]; then error "Backup path is not a directory:" "$BACKUP_DIR" exit $EXIT_USAGE_ERROR fi if [[ ! -w "$BACKUP_DIR" || ! -x "$BACKUP_DIR" ]]; then error "Backup directory is not writable/executable:" "$BACKUP_DIR" exit $EXIT_USAGE_ERROR fi BACKUP_DIR=$(resolve_path "$BACKUP_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