#!/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 ############################################################################### # 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-08 readonly VERSION=1.0.1 readonly AUTHOR="Dennis Fink " readonly LICENSE="BSD-3-Clause" ############################################################################### # GLOBAL VARIABLES ############################################################################### ALL_OFF="" BOLD="" RED="" GREEN="" YELLOW="" BLUE="" MAGENTA="" CYAN="" DEBUG=${DEBUG:-0} QUIET=0 VERBOSE=0 CONTINUE_ON_FAIL=0 DRY_RUN=0 ENCODE_FILE="" 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 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_CONFIG_FILE=0 _CLI_CONTINUE=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_VERBOSE=0 _CLI_VERIFY_OUTPUT=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 ############################################################################## # FUNCTIONS # # This section defines the helper functions used throughout the script. They # handle formatted output, error reporting, version information, utilities # functions and runtime behaviour such as displaying usage instructions. ############################################################################### 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" } error() { emit "==> ERROR:" "$RED" "$@" >&2 } msg() { if [[ $QUIET -eq 0 ]]; then emit "==>" "$GREEN" "$@" fi } warn() { if [[ $QUIET -eq 0 ]]; then emit "==>" "$YELLOW" "$@" fi } verbose() { if [[ $VERBOSE -eq 1 && $QUIET -eq 0 ]]; then emit "==>" "$BLUE" "$@" fi } debug() { if [[ $DEBUG -eq 1 ]]; then emit "==> DEBUG:" "$MAGENTA" "$@" fi } 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 value 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} --hwaccel${ALL_OFF} ${BOLD}${GREEN}[METHOD]${ALL_OFF} enable hardware acceleration; METHOD defaults to ${BOLD}${RED}auto${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}-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}${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}-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" "$SIZE_REPORT_FILE" \ "$PRESET_DIR" "$CONFIG_FILE" \ "$SCRIPTNAME" "$SCRIPTNAME" "$SCRIPTNAME" } print_version() { printf "${RED}${BOLD}Scriptname:${ALL_OFF} %s ${GREEN}${BOLD}Version:${ALL_OFF} %s ${YELLOW}${BOLD}Description:${ALL_OFF} %s ${BLUE}${BOLD}Author:${ALL_OFF} %s ${MAGENTA}${BOLD}Date of creation:${ALL_OFF} %s ${CYAN}${BOLD}Date of revision:${ALL_OFF} %s ${RED}${BOLD}License:${ALL_OFF} %s 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" } # Return the size of a file in bytes. # Uses GNU stat -c on Linux with a fallback to BSD/macOS stat -f. 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. 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. format_filesize() { numfmt --to=iec -- "$1" } ############################################################################### # TERMINAL COLOR SETUP # # Colors and style escape sequences are configured dynamically through the # `setup_colors` function, which enables or disables color output based on the # value of ENABLE_COLOR. When enabled, terminal capabilities are detected via # `tput`, falling back to ANSI escapes if unavailable. # # This script respects the NO_COLOR standard (https://no-color.org/). If the # environment variable NO_COLOR is set (to any value), all color output is # disabled. If FORCE_COLOR is set, colors are always enabled. ############################################################################### # NOTE: This block calls debug() and reads $DEBUG. Both must be defined before # this point. Do not move this block above the GLOBAL VARIABLES or FUNCTIONS # sections without adjusting those dependencies. if [[ -n "${NO_COLOR:-}" ]]; then debug "Colors are disabled via NO_COLOR" ENABLE_COLOR=0 elif [[ -n "${FORCE_COLOR:-}" ]]; then debug "Colors are forced via FORCE_COLOR" ENABLE_COLOR=1 elif [[ -t 1 ]]; then debug "Colors are enabled via interactive terminal" ENABLE_COLOR=1 else debug "Colors are disabled" ENABLE_COLOR=0 fi setup_colors() { if [[ "$ENABLE_COLOR" -eq 1 ]]; then if tput setaf 0 >/dev/null 2>&1; then debug "Setting colors via tput" ALL_OFF="$(tput sgr0)" BOLD="$(tput bold)" RED="$(tput setaf 1)" GREEN="$(tput setaf 2)" YELLOW="$(tput setaf 3)" BLUE="$(tput setaf 4)" MAGENTA="$(tput setaf 5)" CYAN="$(tput setaf 6)" else # Hardcoded ANSI fallback: colors only (no bold embedded), matching the # tput path where BOLD and color variables are kept separate. debug "Using hardcoded color ANSI escape sequences" ALL_OFF="\e[0m" BOLD="\e[1m" RED="\e[31m" GREEN="\e[32m" YELLOW="\e[33m" BLUE="\e[34m" MAGENTA="\e[35m" CYAN="\e[36m" fi else ALL_OFF="" BOLD="" RED="" GREEN="" YELLOW="" BLUE="" MAGENTA="" CYAN="" fi } setup_colors ############################################################################### # MAIN EXECUTION ############################################################################### ############################################################################### # CONFIGURATION FILE # # 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. _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. 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 # [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 } # 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 # in the config file 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 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 } # 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 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). 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 } 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 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 setup_colors shift ;; --no-color) ENABLE_COLOR=0 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 ;; --config-file) if [[ $# -lt 2 ]]; then error "Missing value for $1" exit $EXIT_USAGE_ERROR fi if [[ ! -n "$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 [[ ! -n "${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 [[ ! -n "$2" ]]; then error "Value for $1 must not be empty" exit $EXIT_USAGE_ERROR fi ENCODE_FILE="$2" shift 2 ;; --encode-file=*) if [[ ! -n "${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 [[ ! -n "$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 [[ ! -n "${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 parse_skip_codec_parameter "$2" shift 2 ;; --skip-codec=*) parse_skip_codec_parameter "${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 [[ ! -n "$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 [[ ! -n "$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 [[ ! -n "${1#*=}" ]]; then error "Value for --preset must not be empty" exit $EXIT_USAGE_ERROR fi PRESET_NAME="${1#*=}" _CLI_PRESET=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 printf -v joined ' %s' "${!SKIP_CODECS[@]}" debug "Effective configuration:" debug " config-file:" "$CONFIG_FILE" debug " dry-run:" "$DRY_RUN" debug " encode-file:" "$ENCODE_FILE" 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" 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 [[ ! "$NICE_VALUE" =~ ^-?[0-9]+$ || $NICE_VALUE -lt -20 || $NICE_VALUE -gt 19 ]]; then error "Invalid nice value (must be an integer between -20 and 19):" "$NICE_VALUE" exit $EXIT_USAGE_ERROR fi if [[ ! -z "${joined# }" ]]; then verbose "Skipping files with codecs:" "${joined# }" fi unset joined # Remove temporary output files created during encoding. # Registered via trap EXIT so it runs on normal exit and on failures # shellcheck disable=SC2329 cleanup() { # "${TMP_FILES[@]+"${TMP_FILES[@]}"}" expands to nothing when the array is # empty (safe under nounset), unlike [@]:-} which yields one empty iteration. for t in "${TEMPORARY_FILES[@]+"${TEMPORARY_FILES[@]}"}"; do debug "Cleanup temp file:" "$t" if [[ -f "$t" ]]; then rm -f -- "$t" fi done } trap cleanup EXIT # Calculate integer percentage saved from original/new byte counts. 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. 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. 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. 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" } # 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. encode_one() { local input_file="$1" local current_file_index="$2" local amount_total_files="$3" # 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 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 v:0 \ -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 # 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" 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 error -stats "${hwaccel_args[@]}" -i "file:$input_file" -map 0 -map_metadata 0 -map_chapters 0 -c copy "${ffargs[@]}" "file:$temporary_output_file") if [[ $DRY_RUN -eq 1 ]]; then msg "DRY RUN:" "Would encode with preset $PRESET_NAME" verbose "Input codec:" "$input_codec" verbose "Input pixel format:" "$input_pixel_format" verbose "Output pixel format:" "$output_pixel_format" verbose "Input frame rate:" "$input_frame_rate" verbose "Input FPS:" "$input_fps" verbose "Output GOP size:" "$output_gop_size" verbose "Command:" "${encode_cmd[*]}" return $EXIT_OK fi msg "Encoding:" "$input_file" 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 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" mv -f -- "$temporary_output_file" "$input_file" ((_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)) msg "Replaced:" "$input_file" 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 } 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