summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore204
-rw-r--r--.gitmessage58
-rw-r--r--README.md176
-rwxr-xr-xtranscode.sh746
-rw-r--r--transcode.sh.1274
-rw-r--r--transcode.sh.bash-completion149
6 files changed, 1607 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..28c0e4b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,204 @@
+# Created by https://www.toptal.com/developers/gitignore/api/windows,linux,macos,zsh,fish,vim,emacs,git
+# Edit at https://www.toptal.com/developers/gitignore?templates=windows,linux,macos,zsh,fish,vim,emacs,git
+
+### Emacs ###
+# -*- mode: gitignore; -*-
+*~
+\#*\#
+/.emacs.desktop
+/.emacs.desktop.lock
+*.elc
+auto-save-list
+tramp
+.\#*
+
+# Org-mode
+.org-id-locations
+*_archive
+
+# flymake-mode
+*_flymake.*
+
+# eshell files
+/eshell/history
+/eshell/lastdir
+
+# elpa packages
+/elpa/
+
+# reftex files
+*.rel
+
+# AUCTeX auto folder
+/auto/
+
+# cask packages
+.cask/
+dist/
+
+# Flycheck
+flycheck_*.el
+
+# server auth directory
+/server/
+
+# projectiles files
+.projectile
+
+# directory configuration
+.dir-locals.el
+
+# network security
+/network-security.data
+
+
+### Fish ###
+fishd.*
+fish_history
+fish_variables
+config.local.fish
+
+### Git ###
+# Created by git for backups. To disable backups in Git:
+# $ git config --global mergetool.keepBackup false
+*.orig
+
+# Created by git when using merge tools for conflicts
+*.BACKUP.*
+*.BASE.*
+*.LOCAL.*
+*.REMOTE.*
+*_BACKUP_*.txt
+*_BASE_*.txt
+*_LOCAL_*.txt
+*_REMOTE_*.txt
+
+### Linux ###
+
+# temporary files which can be created if a process still has a handle open of a deleted file
+.fuse_hidden*
+
+# KDE directory preferences
+.directory
+
+# Linux trash folder which might appear on any partition or disk
+.Trash-*
+
+# .nfs files are created when an open file is removed but is still being accessed
+.nfs*
+
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+
+### Vim ###
+# Swap
+[._]*.s[a-v][a-z]
+!*.svg # comment out if you don't need vector files
+[._]*.sw[a-p]
+[._]s[a-rt-v][a-z]
+[._]ss[a-gi-z]
+[._]sw[a-p]
+
+# Session
+Session.vim
+Sessionx.vim
+
+# Temporary
+.netrwhist
+# Auto-generated tag files
+tags
+# Persistent undo
+[._]*.un~
+
+### Windows ###
+# Windows thumbnail cache files
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+### Zsh ###
+# Zsh compiled script + zrecompile backup
+*.zwc
+*.zwc.old
+
+# Zsh completion-optimization dumpfile
+*zcompdump*
+
+# Zsh history
+.zsh_history
+
+# Zsh sessions
+.zsh_sessions
+
+# Zsh zcalc history
+.zcalc_history
+
+# A popular plugin manager's files
+._zinit
+.zinit_lstupd
+
+# zdharma/zshelldoc tool's files
+zsdoc/data
+
+# robbyrussell/oh-my-zsh/plugins/per-directory-history plugin's files
+# (when set-up to store the history in the local directory)
+.directory_history
+
+# MichaelAquilina/zsh-autoswitch-virtualenv plugin's files
+# (for Zsh plugins using Python)
+.venv
+
+# Zunit tests' output
+/tests/_output/*
+!/tests/_output/.gitkeep
+
+# End of https://www.toptal.com/developers/gitignore/api/windows,linux,macos,zsh,fish,vim,emacs,git
diff --git a/.gitmessage b/.gitmessage
new file mode 100644
index 0000000..571fb50
--- /dev/null
+++ b/.gitmessage
@@ -0,0 +1,58 @@
+# <type>[optional scope]: <short description> (max 72 chars total) ------>|
+#
+# Types (choose ONE):
+# feat New feature (MINOR bump in SemVer)
+# fix Bug fix (PATCH bump)
+# docs Documentation only (README, man page, comments)
+# style Formatting, whitespace — no logic change
+# refactor Code restructure — no feature/fix
+# perf Performance improvement
+# test Tests only
+# build Build system / dependency changes
+# ci CI/CD configuration
+# chore Maintenance (release commits, tooling)
+# revert Revert a previous commit
+#
+# Scopes (optional, in parentheses after type):
+# core Main script logic / encode_one()
+# preset Preset loading / preset files
+# color Terminal color / NO_COLOR handling
+# cli Option parsing / help / version output
+# skip Codec skip-list handling
+# saving File-size savings logging
+# docs Documentation (man page, README)
+# completion Bash completion script
+#
+# Breaking changes: append ! before the colon → feat(cli)!: ...
+# --------------------------------------------------------------------------
+
+# [blank line — required before body]
+
+# Body (optional): explain WHY, not WHAT. Wrap at 72 chars. ---->|
+#
+# Motivation for the change, background, trade-offs considered, etc.
+# Use multiple paragraphs separated by blank lines if needed.
+
+# [blank line before footers]
+
+# Footers (optional):
+# BREAKING CHANGE: <description of what breaks and how to migrate>
+# Fixes: #<issue>
+# Refs: #<issue>, <commit-sha>
+# Reviewed-by: Name <email>
+# Co-authored-by: Name <email>
+#
+# --------------------------------------------------------------------------
+# Examples:
+#
+# feat(preset): add support for passing raw ffmpeg flags via env var
+#
+# fix(core): prevent tmp file leak when ffprobe exits non-zero
+#
+# docs: add man page and bash completion script
+#
+# feat(cli)!: rename --encodefile to --file-list for clarity
+#
+# BREAKING CHANGE: --encodefile/-f is now --file-list/-F. Update any
+# scripts or aliases that use the old flag.
+# --------------------------------------------------------------------------
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..65db49a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,176 @@
+# transcode.sh
+
+> Batch transcode helper for media files using [ffmpeg](https://ffmpeg.org/).
+
+`transcode.sh` re-encodes media files in place using user-defined *presets* —
+small shell snippets that supply the ffmpeg arguments. It probes each input
+file to detect the video codec and pixel format, skips files whose codec is on
+the skip list, selects an appropriate output pixel format automatically, and
+replaces the original atomically on success.
+
+## Features
+
+- **Preset-driven** — encoding parameters live in plain shell files, not
+ hard-coded flags.
+- **Batch processing** — pass files on the command line or via a list file
+ (`-f`).
+- **Codec skip list** — per-user and per-invocation lists of codecs to leave
+ untouched (e.g. skip files already encoded as AV1).
+- **Only-if-smaller mode** (`-l`) — discard the re-encoded file if it is
+ larger than the original.
+- **Savings log** (`-s`) — append filename, original size, new size and
+ percentage saved to a log file after each successful encode.
+- **Dry-run** (`-n`) — preview what would happen without touching any files.
+- **Nice** — runs ffmpeg at niceness 19 by default to avoid starving other
+ processes; configurable with `-N`.
+- **Respects [NO\_COLOR](https://no-color.org/)**.
+
+## Requirements
+
+| Tool | Required | Notes |
+|------|----------|-------|
+| `ffmpeg` | always | encoding |
+| `ffprobe` | always | codec/format detection |
+| `nice` | always | process priority control |
+| `bc` | only with `--saving` | percentage calculation |
+
+## Installation
+
+```sh
+# Clone the repo
+git clone https://codeberg.org/yourname/transcode.sh.git
+cd transcode.sh
+
+# Make the script executable
+chmod +x transcode.sh
+
+# Optional: install to a directory in your PATH
+install -Dm755 transcode.sh ~/.local/bin/transcode.sh
+
+# Optional: install the man page
+install -Dm644 transcode.1 ~/.local/share/man/man1/transcode.1
+
+# Optional: install bash completion
+install -Dm644 transcode.sh.bash-completion \
+ /etc/bash_completion.d/transcode.sh
+# or for your user only (requires bash-completion ≥ 2.2):
+install -Dm644 transcode.sh.bash-completion \
+ ~/.local/share/bash-completion/completions/transcode.sh
+```
+
+> **PATH note:** the script resets `PATH` to `/bin:/usr/bin:/usr/local/bin`
+> for security. If your `ffmpeg` lives elsewhere (e.g. Homebrew on macOS,
+> Nix), prepend its directory:
+> ```sh
+> PATH="/opt/homebrew/bin:$PATH" transcode.sh input.mp4
+> ```
+
+## Configuration
+
+### Presets
+
+Presets live in:
+
+```
+${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/presets/<name>.sh
+```
+
+Each preset is sourced as Bash and must define an array named `ffargs`.
+The variable `output_pixel_format` is available and contains the
+automatically selected pixel format (derived from the input file).
+
+**Example — `~/.config/transcode.sh/presets/default.sh`:**
+
+```sh
+ffargs=(
+ -c:v:0 libsvtav1
+ -crf 30
+ -preset 6
+ -pix_fmt "$output_pixel_format"
+)
+```
+
+> **Security:** presets are executed as shell code. Only use presets from
+> trusted sources.
+
+### Codec skip list
+
+To permanently skip files that are already in a particular codec, add codec
+names (one per line) to:
+
+```
+${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/skip.conf
+```
+
+**Example — skip files already encoded as AV1 or HEVC:**
+
+```
+av1
+hevc
+```
+
+## Usage
+
+```
+transcode.sh [OPTION] [--] FILE...
+```
+
+| Option | Description |
+|--------|-------------|
+| `-c`, `--continue` | Continue to next file if ffmpeg fails |
+| `-f PATH`, `--encodefile PATH`, `--encodefile=PATH` | Read file list from PATH (one per line) |
+| `-n`, `--dry-run` | Show what would be done; don't run ffmpeg |
+| `-N VALUE`, `--nice VALUE`, `--nice=VALUE` | Niceness value for ffmpeg (default: 19) |
+| `-s`, `--saving` | Log filesize savings after each encode |
+| `--saving-file PATH`, `--saving-file=PATH` | Savings log path (default: `transcode_savings`) |
+| `-S LIST`, `--skip-codec LIST`, `--skip-codec=LIST` | Codecs to skip (comma/space/colon separated) |
+| `-l`, `--only-if-smaller` | Only replace original if new file is smaller |
+| `-p NAME`, `--preset NAME`, `--preset=NAME` | Preset to use (default: `default`) |
+| `-q`, `--quiet` | Suppress all output (overrides `-v`) |
+| `-v`, `--verbose` | More detailed output |
+| `--color` / `--no-color` | Force or disable colored output |
+| `--version` | Print version information |
+| `-h`, `-?`, `--help` | Show help and exit |
+
+### Examples
+
+```sh
+# Transcode a single file with the default preset
+transcode.sh video.mp4
+
+# Transcode a list of files and record size savings
+transcode.sh --saving --encodefile list.txt
+
+# Dry-run with verbose output to inspect detected formats
+transcode.sh --dry-run --verbose my_movie.mkv
+
+# Use a custom preset, only keep result if smaller, skip AV1 inputs
+transcode.sh --preset av1_fast --only-if-smaller --skip-codec av1 /media/films/*.mkv
+
+# Run at a moderate priority and continue past failures
+transcode.sh --nice 10 --continue *.mp4
+```
+
+## Debugging
+
+```sh
+# High-level state messages
+DEBUG=1 transcode.sh input.mkv
+
+# Full bash execution trace
+TRACE=1 transcode.sh input.mkv
+```
+
+## Exit codes
+
+| Code | Meaning |
+|------|---------|
+| 0 | Success |
+| 1 | Runtime failure (ffmpeg error on one or more files) |
+| 2 | Usage error (unknown option, missing argument) |
+| 3 | Configuration error (missing or invalid preset) |
+| 127 | Missing dependency |
+
+## License
+
+[BSD-3-Clause](LICENSES/BSD-3-Clause.txt) © 2025 Dennis Fink \<dennis.fink@c3l.lu\>
diff --git a/transcode.sh b/transcode.sh
new file mode 100755
index 0000000..0d2e841
--- /dev/null
+++ b/transcode.sh
@@ -0,0 +1,746 @@
+#!/usr/bin/env bash
+
+# SPDX-FileCopyrightText: 2025 Dennis Fink <dennis.fink@c3l.lu>
+#
+# 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/<name>.sh
+#
+# Each preset is a small bash snippet that must set an array named `ffargs`,
+# for example:
+# ffargs=( -c:v:0 libsvtav1 -crf 30 -preset 6 -pix_fmt "$output_pixel_format" )
+#
+# Note: Presets are sourced as shell code. Only use trusted presets.
+#
+# A per-user list of video-codecs to skip can be provided in:
+# ${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/skip.conf
+#
+# NOTES
+# This script respects the NO_COLOR standard (https://no-color.org/).
+#
+# AUTHOR
+# Dennis Fink <dennis.fink@c3l.lu>
+###############################################################################
+
+# TRACE enables bash execution tracing and is intended for debugging control
+# flow / expansions. DEBUG enable structured debug messages (see debug()) and
+# is intended for high-level state reporting.
+#
+# Recommended usage:
+# TRACE=1 ./transcode.sh ... # show every command bash executes
+# DEBUG=1 ./transcode.sh .... # show script-defined debug messages
+
+TRACE=${TRACE:-0}
+if [[ $TRACE -ne 0 ]]; then
+ set -o xtrace
+fi
+
+###############################################################################
+# SHELL SCRIPT HARDENING
+#
+# This script enables strict and predictable behavior for improved safety,
+# security, and debuggability. These measures help avoid common pitfalls
+# such as accidental globbing, unexpected alias expansion, silent failures,
+# or unintended word splitting.
+###############################################################################
+
+# Unalias everything to avoid unexpected alias expansion
+command unalias -a
+
+# Set a predictable and secure PATH (ignores user-controlled paths).
+# Trade-off: tools installed outside these directories (e.g. ffmpeg via
+# Homebrew on macOS at /opt/homebrew/bin, or via Nix at /nix/store/...)
+# will not be found. In that case, invoke this script via a wrapper that
+# prepends the correct path, e.g.: PATH="/opt/homebrew/bin:$PATH" ./transcode.sh
+PATH='/bin:/usr/bin:/usr/local/bin'
+export PATH
+
+# Clear the shell command hash table to avoid stale command lookups
+hash -r
+
+# Set a safe IFS to prevent word-splitting vulnerabilities
+IFS=$'\n\t'
+
+# Set a secure default file creation mask (controls default permissions)
+UMASK=002
+umask "$UMASK"
+
+# Allow aliases to expand inside command substitutions (optional convenience)
+shopt -s expand_aliases
+
+# Ensure ERR traps are inherited by functions and subshells
+set -o errtrace
+# Treat use of undefined variables as an error and exit
+set -o nounset
+# Make pipelines fail if any command in the pipeline fails
+set -o pipefail
+
+###############################################################################
+# SCRIPT METADATA
+#
+# These variables describe the script and are used for the --version output
+# and for informational messages. They are marked readonly to prevent
+# accidental modification at runtime.
+###############################################################################
+readonly SCRIPTNAME=${0##*/}
+readonly DESCRIPTION="Batch transcode helper for media files using ffmpeg."
+readonly DATE_OF_CREATION=2025-08-06
+readonly DATE_OF_REVISION=2026-02-22
+readonly VERSION=1.0.0
+readonly AUTHOR="Dennis Fink <dennis.fink@c3l.lu>"
+readonly LICENSE="BSD-3-Clause"
+
+###############################################################################
+# GLOBAL VARIABLES
+###############################################################################
+
+ALL_OFF=""
+BOLD=""
+RED=""
+GREEN=""
+YELLOW=""
+BLUE=""
+MAGENTA=""
+CYAN=""
+
+DEBUG=${DEBUG:-0}
+QUIET=0
+VERBOSE=0
+
+CONTINUE_ON_FAIL=0
+DRY_RUN=0
+ENCODE_FILE=""
+NICE_VALUE=19
+ONLY_IF_SMALLER=0
+PRESET_NAME="default"
+SAVING=0
+SAVING_FILE="transcode_savings"
+declare -A SKIP_CODECS
+
+REMAINING_ARGS=()
+
+TMP_FILES=()
+
+readonly PRESET_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/presets"
+readonly SKIP_CODECS_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/skip.conf"
+
+###############################################################################
+# EXIT CODES
+###############################################################################
+
+EXIT_OK=0
+EXIT_RUNTIME_FAILURE=1
+EXIT_USAGE_ERROR=2
+EXIT_CONFIG_ERROR=3
+EXIT_MISSING_DEPENDENCY=127
+readonly EXIT_OK EXIT_RUNTIME_FAILURE EXIT_USAGE_ERROR EXIT_CONFIG_ERROR EXIT_MISSING_DEPENDENCY
+
+##############################################################################
+# FUNCTIONS
+#
+# This section defines the helper functions used throughout the script. They
+# handle formatted output, error reporting, version information, utilities
+# functions and runtime behaviour such as displaying usage instructions.
+###############################################################################
+
+error() {
+ local mesg="$1"
+ shift
+ printf "${BOLD}${RED}==> ERROR:${ALL_OFF} ${BOLD}%s${ALL_OFF} %s\n" "$mesg" "$@" >&2
+}
+
+msg() {
+ if [[ $QUIET -eq 0 ]]; then
+ local mesg="$1"
+ shift
+ printf "${BOLD}${GREEN}==>${ALL_OFF} ${BOLD}%s${ALL_OFF} %s\n" "$mesg" "$@"
+ fi
+}
+
+verbose() {
+ if [[ $VERBOSE -eq 1 && $QUIET -eq 0 ]]; then
+ local mesg="$1"
+ shift
+ printf "${BOLD}${BLUE}==>${ALL_OFF} ${BOLD}%s${ALL_OFF} %s\n" "$mesg" "$@"
+ fi
+}
+
+debug() {
+ if [[ $DEBUG -eq 1 ]]; then
+ local mesg="$1"
+ shift
+ printf "${BOLD}${MAGENTA}==> DEBUG:${ALL_OFF} ${BOLD}%s${ALL_OFF} %s\n" "$mesg" "$@"
+ fi
+}
+
+print_help() {
+ printf "%s - %s - %s
+
+Usage: %s [OPTION] [--] FILE...
+
+Options:
+ -c, --continue Continue with next file even if ffmpeg fails
+ -f, --encodefile PATH File containing list of files to encode
+ --encodefile=PATH
+ -n, --dry-run Show what would be done, don't run ffmpeg
+ -N, --nice VALUE Nice value for ffmpeg (default: 19)
+ --nice=VALUE
+ -s, --saving Log filesize savings (filename, orig, new, %%)
+ --saving-file PATH Specify where to save filesize savings (default: transcode_savings)
+ --saving-file=PATH
+ -S, --skip-codec LIST Add codecs to skip (comma/space/colon separated)
+ --skip-codec=LIST
+ -l, --only-if-smaller Only overwrite files if the transcoded file is smaller
+ -p, --preset NAME Load ffmpeg arguments from a preset (default: default)
+ --preset=NAME
+ -h, --help, -? Show this help text and exit
+ -q, --quiet Do not output anything, takes precedence over -v and --verbose
+ -v, --verbose Be more verbose
+ --version Print script information and version
+ --color Force colored output
+ --no-color Disable colored output
+
+Examples:
+ %s video.mp4
+ %s -s -f list.txt
+ %s -n -N 10 my_movie.mkv\n" "$SCRIPTNAME" "$VERSION" "$DESCRIPTION" "$SCRIPTNAME" "$SCRIPTNAME" "$SCRIPTNAME" "$SCRIPTNAME"
+}
+
+print_version() {
+ printf "${RED}${BOLD}Scriptname:${ALL_OFF} %s
+${GREEN}${BOLD}Version:${ALL_OFF} %s
+${YELLOW}${BOLD}Description:${ALL_OFF} %s
+${BLUE}${BOLD}Author:${ALL_OFF} %s
+${MAGENTA}${BOLD}Date of creation:${ALL_OFF} %s
+${CYAN}${BOLD}Date of revision:${ALL_OFF} %s
+${RED}${BOLD}License:${ALL_OFF} %s\n" "$SCRIPTNAME" "$VERSION" "$DESCRIPTION" "$AUTHOR" "$DATE_OF_CREATION" "$DATE_OF_REVISION" "$LICENSE"
+
+ if [[ -f "LICENSES/${LICENSE}.txt" ]]; then
+ printf "\n"
+ cat "LICENSES/${LICENSE}.txt"
+ fi
+}
+
+filesize() {
+ local f="$1"
+ local out
+ if out=$(stat -c "%s" -- "$f" 2>/dev/null); then
+ printf "%s\n" "$out"
+ else
+ stat -f "%z" -- "$f"
+ fi
+}
+
+###############################################################################
+# TERMINAL COLOR SETUP
+#
+# Colors and style escape sequences are configured dynamically through the
+# `setup_colors` function, which enables or disables color output based on
+# the value of ENABLE_COLOR. When enabled, terminal capabilities are detected
+# via `tput`, falling back to ANSI escapes if unavailable.
+#
+# This script respects the NO_COLOR standard (https://no-color.org/).
+# If the environment variable NO_COLOR is set (to any value), all color output
+# is disabled. If FORCE_COLOR is set, colors are always enabled.
+###############################################################################
+
+# NOTE: This block calls debug() and reads $DEBUG. Both must be defined before
+# this point. Do not move this block above the GLOBAL VARIABLES or FUNCTIONS
+# sections without adjusting those dependencies.
+if [[ -n "${NO_COLOR:-}" ]]; then
+ debug "Colors are disabled via NO_COLOR"
+ ENABLE_COLOR=0
+elif [[ -n "${FORCE_COLOR:-}" ]]; then
+ debug "Colors are forced via FORCE_COLOR"
+ ENABLE_COLOR=1
+elif [[ -t 1 ]]; then
+ debug "Colors are enabled via interactive terminal"
+ ENABLE_COLOR=1
+else
+ debug "Colors are disabled"
+ ENABLE_COLOR=0
+fi
+
+setup_colors() {
+ if [[ "$ENABLE_COLOR" -eq 1 ]]; then
+ if tput setaf 0 >/dev/null 2>&1; then
+ debug "Setting colors via tput"
+ ALL_OFF="$(tput sgr0)"
+ BOLD="$(tput bold)"
+ RED="$(tput setaf 1)"
+ GREEN="$(tput setaf 2)"
+ YELLOW="$(tput setaf 3)"
+ BLUE="$(tput setaf 4)"
+ MAGENTA="$(tput setaf 5)"
+ CYAN="$(tput setaf 6)"
+ else
+ # Hardcoded ANSI fallback: colors only (no bold embedded), matching the
+ # tput path where BOLD and color variables are kept separate.
+ debug "Using hardcoded color ANSI escape sequences"
+ ALL_OFF="\e[0m"
+ BOLD="\e[1m"
+ RED="\e[31m"
+ GREEN="\e[32m"
+ YELLOW="\e[33m"
+ BLUE="\e[34m"
+ MAGENTA="\e[35m"
+ CYAN="\e[36m"
+ fi
+ else
+ ALL_OFF=""
+ BOLD=""
+ RED=""
+ GREEN=""
+ YELLOW=""
+ BLUE=""
+ MAGENTA=""
+ CYAN=""
+ fi
+}
+setup_colors
+
+###############################################################################
+# MAIN EXECUTION
+###############################################################################
+
+# Add a codec name to the SKIP_CODECS associative array.
+# - Normalizes to lowercase
+# - Ignores empty tokens
+# - Used both for CLI --skip-codec and for skip.conf lines
+add_skip_codec_token() {
+ local token="$1"
+ [[ -z "$token" ]] && return 0
+ token=${token,,} # lowercase
+ if [[ ! -v SKIP_CODECS["$token"] ]]; then
+ debug "Add to skip codec:" "$token"
+ SKIP_CODECS["$token"]=
+ else
+ debug "Codec already skipped:" "$token"
+ fi
+
+}
+
+# Parse a user-provided codec list (comma/space/colon separated)
+# and add each token to SKIP_CODECS
+parse_skip_codec_parameter() {
+ local arg="$1"
+ local IFS=',: ' token
+ for token in $arg; do
+ [[ -z "$token" ]] && continue
+ add_skip_codec_token "$token"
+ done
+}
+
+if [[ -f "$SKIP_CODECS_FILE" ]]; then
+ while IFS= read -r line || [[ -n "$line" ]]; do
+ # strip leading/trailing whitespace
+ line="${line#"${line%%[![:space:]]*}"}"
+ line="${line%"${line##*[![:space:]]}"}"
+ # skip blanks and comments
+ [[ -z "$line" || "${line:0:1}" == "#" ]] && continue
+ parse_skip_codec_parameter "$line"
+ done <"$SKIP_CODECS_FILE"
+fi
+
+# Load ffmpeg argument preset by name.
+#
+# Security:
+# Presets are sourced as shell code. Names are validated against a strict
+# allowlist (alphanumerics, hyphens, underscores only) to prevent path
+# traversal and shell metacharacter injection. Note: a valid preset name
+# could still be a symlink to an arbitrary file; ensure the preset directory
+# itself is not writable by untrusted users.
+#
+# Contract:
+# The sourced preset MUST set the bash array `ffargs`.
+# Presets may reference variables from the caller (encode_one) such as:
+# output_pixel_format, input_pixel_format, input_codec
+load_preset() {
+ local preset="$1"
+ # Strict allowlist: only alphanumerics, hyphens, and underscores are permitted.
+ # This prevents path traversal, shell metacharacter injection, and symlink-based
+ # attacks more reliably than a blocklist approach.
+ if [[ -z "$preset" || ! "$preset" =~ ^[a-zA-Z0-9_-]+$ ]]; then
+ error "Invalid preset name: $preset (only alphanumerics, hyphens, underscores allowed)"
+ exit $EXIT_USAGE_ERROR
+ fi
+
+ local file="$PRESET_DIR/$preset.sh"
+ [[ -f "$file" ]] || {
+ error "Preset not found: $file"
+ exit $EXIT_CONFIG_ERROR
+ }
+
+ debug "Sourcing preset file:" "$file"
+
+ unset -v ffargs
+ ffargs=()
+
+ # shellcheck source=/dev/null
+ source "$file"
+
+ ((${#ffargs[@]})) || {
+ error "Preset '$preset' did not set ffargs"
+ exit $EXIT_RUNTIME_FAILURE
+ }
+
+ debug "Preset ffargs:" "$(printf "%q " "${ffargs[@]}")"
+}
+
+if [[ $# -eq 0 ]]; then
+ debug "No parameters were specified"
+ print_help
+ exit $EXIT_USAGE_ERROR
+fi
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -h | --help | -\?)
+ print_help
+ exit $EXIT_OK
+ ;;
+ --version)
+ print_version
+ exit $EXIT_OK
+ ;;
+ -q | --quiet)
+ QUIET=1
+ shift
+ ;;
+ -v | --verbose)
+ VERBOSE=1
+ shift
+ ;;
+ --color)
+ ENABLE_COLOR=1
+ setup_colors
+ shift
+ ;;
+ --no-color)
+ ENABLE_COLOR=0
+ setup_colors
+ shift
+ ;;
+ -c | --continue)
+ CONTINUE_ON_FAIL=1
+ shift
+ ;;
+ -f | --encodefile)
+ [[ $# -ge 2 ]] || {
+ error "Missing value for $1"
+ exit $EXIT_USAGE_ERROR
+ }
+ ENCODE_FILE="$2"
+ shift 2
+ ;;
+ --encodefile=*)
+ ENCODE_FILE="${1#*=}"
+ shift
+ ;;
+ -n | --dry-run)
+ DRY_RUN=1
+ shift
+ ;;
+ -N | --nice)
+ [[ $# -ge 2 ]] || {
+ error "Missing value for $1"
+ exit $EXIT_USAGE_ERROR
+ }
+ NICE_VALUE="$2"
+ shift 2
+ ;;
+ --nice=*)
+ NICE_VALUE="${1#*=}"
+ shift
+ ;;
+ -s | --saving)
+ SAVING=1
+ shift
+ ;;
+ --saving-file)
+ [[ $# -ge 2 ]] || {
+ error "Missing value for $1"
+ exit $EXIT_USAGE_ERROR
+ }
+ SAVING_FILE="$2"
+ shift 2
+ ;;
+ --saving-file=*)
+ SAVING_FILE="${1#*=}"
+ shift
+ ;;
+ -S | --skip-codec)
+ [[ $# -ge 2 ]] || {
+ error "Missing value for $1"
+ exit $EXIT_USAGE_ERROR
+ }
+ parse_skip_codec_parameter "$2"
+ shift 2
+ ;;
+ --skip-codec=*)
+ parse_skip_codec_parameter "${1#*=}"
+ shift
+ ;;
+ -l | --only-if-smaller)
+ ONLY_IF_SMALLER=1
+ shift
+ ;;
+ -p | --preset)
+ [[ $# -ge 2 ]] || {
+ error "Missing value for $1"
+ exit $EXIT_USAGE_ERROR
+ }
+ PRESET_NAME="$2"
+ shift 2
+ ;;
+ --preset=*)
+ PRESET_NAME="${1#*=}"
+ shift
+ ;;
+ --)
+ shift
+ break
+ ;;
+ -*)
+ error "Unknown option: $1"
+ exit $EXIT_USAGE_ERROR
+ ;;
+ *)
+ REMAINING_ARGS+=("$1")
+ shift
+ ;;
+ esac
+done
+
+command -v ffmpeg >/dev/null 2>&1 || {
+ error "ffmpeg not found."
+ exit $EXIT_MISSING_DEPENDENCY
+}
+command -v ffprobe >/dev/null 2>&1 || {
+ error "ffprobe not found."
+ exit $EXIT_MISSING_DEPENDENCY
+}
+command -v nice >/dev/null 2>&1 || {
+ error "nice not found."
+ exit $EXIT_MISSING_DEPENDENCY
+}
+
+if [[ $SAVING -eq 1 ]]; then
+ # bc is only required when --saving is active; intentionally checked here
+ # rather than in the main dependency block to avoid requiring it universally.
+ command -v bc >/dev/null 2>&1 || {
+ error "bc not found."
+ exit $EXIT_MISSING_DEPENDENCY
+ }
+fi
+
+# Remove temporary output files created during encoding.
+# Registered via trap EXIT so it runs on normal exit and on failures
+# shellcheck disable=SC2329
+cleanup() {
+ # "${TMP_FILES[@]+"${TMP_FILES[@]}"}" expands to nothing when the array is
+ # empty (safe under nounset), unlike [@]:-} which yields one empty iteration.
+ for t in "${TMP_FILES[@]+"${TMP_FILES[@]}"}"; do
+ debug "Cleanup temp file:" "$t"
+ [[ -f "$t" ]] && rm -f -- "$t" || true
+ done
+}
+trap cleanup EXIT
+
+# Encode a single media file according to PRESET_NAME.
+#
+# Steps:
+# 1) Probe input codec and pixel format (v:0).
+# 2) Optionally skip based on codec.
+# 3) Normalize pixel formats (handle yuvj* forms).
+# 4) Select an output pixel format that preserverse chroma subsampling/bit depth.
+# 5) Load preset ffargs and run ffmpeg into a temp file in the same directory.
+# 6) Optionally keep only-if-smaller; optionally log savings; then replace.
+#
+# Return:
+# EXIT_OK on success or if skipped; EXIT_RUNTIME_FAILURE on failure.
+encode_one() {
+ local file="$1"
+ msg "Processing:" "$file"
+
+ [[ -z "$file" ]] && return $EXIT_OK
+ [[ ! -f "$file" ]] && {
+ error "Not found"
+ return $EXIT_RUNTIME_FAILURE
+ }
+
+ local input_codec
+ input_codec=$(ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=nk=1:nw=1 "file:$file" 2>/dev/null)
+ if [[ -z "$input_codec" ]]; then
+ error "Could not determine input video codec"
+ return $EXIT_RUNTIME_FAILURE
+ fi
+
+ if [[ -v SKIP_CODECS["$input_codec"] ]]; then
+ msg "Skip because of input codec:" "$input_codec"
+ return $EXIT_OK
+ fi
+
+ local input_pixel_format
+ input_pixel_format=$(ffprobe -v error -select_streams v:0 -show_entries stream=pix_fmt -of default=nk=1:nw=1 "file:$file" 2>/dev/null)
+ if [[ -z "$input_pixel_format" ]]; then
+ error "Could not determine input pixel format"
+ return $EXIT_RUNTIME_FAILURE
+ fi
+
+ # Some files report "yuvj*" formats (full-range JPEG-style). For encoding
+ # purposes we normalize to the equivalent "yuv*" formats.
+ # Covers all five variants ffmpeg can produce: 411p, 420p, 422p, 440p, 444p.
+ case "$input_pixel_format" in
+ yuvj411p) input_pixel_format="yuv411p" ;;
+ yuvj420p) input_pixel_format="yuv420p" ;;
+ yuvj422p) input_pixel_format="yuv422p" ;;
+ yuvj440p) input_pixel_format="yuv440p" ;;
+ yuvj444p) input_pixel_format="yuv444p" ;;
+ esac
+ debug "Normalized input pixel format:" "$input_pixel_format"
+
+ # Preserve chroma subsampling (420/422/444) and bit depth (8/10/12/16-bit)
+ # to avoid unintended quality loss or incompatible output.
+ # Known limitation: exotic formats not matching these patterns (e.g. gbrp,
+ # yuva*, gray*) fall through to yuv420p. Extend the logic below if needed.
+ local output_pixel_format
+ if [[ "$input_pixel_format" == *"444"* ]]; then
+ if [[ "$input_pixel_format" == *"16"* ]]; then
+ output_pixel_format="yuv444p16le"
+ elif [[ "$input_pixel_format" == *"12"* ]]; then
+ output_pixel_format="yuv444p12le"
+ elif [[ "$input_pixel_format" == *"10"* ]]; then
+ output_pixel_format="yuv444p10le"
+ else
+ output_pixel_format="yuv444p"
+ fi
+ elif [[ "$input_pixel_format" == *"422"* ]]; then
+ if [[ "$input_pixel_format" == *"16"* ]]; then
+ output_pixel_format="yuv422p16le"
+ elif [[ "$input_pixel_format" == *"12"* ]]; then
+ output_pixel_format="yuv422p12le"
+ elif [[ "$input_pixel_format" == *"10"* ]]; then
+ output_pixel_format="yuv422p10le"
+ else
+ output_pixel_format="yuv422p"
+ fi
+ else
+ if [[ "$input_pixel_format" == *"16"* ]]; then
+ output_pixel_format="yuv420p16le"
+ elif [[ "$input_pixel_format" == *"12"* ]]; then
+ output_pixel_format="yuv420p12le"
+ elif [[ "$input_pixel_format" == *"10"* ]]; then
+ output_pixel_format="yuv420p10le"
+ else
+ output_pixel_format="yuv420p"
+ fi
+ fi
+ debug "Selected output pixel format:" "$output_pixel_format"
+
+ load_preset "$PRESET_NAME"
+
+ local extension filename directory tmp base stem
+
+ base="${file##*/}"
+ if [[ "$base" == *.* ]]; then
+ extension="${base##*.}"
+ stem="${base%.*}"
+ filename="${file%.*}"
+ else
+ extension=""
+ stem="$base"
+ filename="$file"
+ fi
+
+ directory=$(dirname -- "$file")
+
+ # Temp file is created in the same directory as the input so that `mv` is
+ # atomic on the same filesystem (avoid cross-device rename issues).
+ tmp="$directory/.${stem}_$PRESET_NAME.$$.$RANDOM${extension:+.$extension}"
+ TMP_FILES+=("$tmp")
+ debug "Temporary output path:" "$tmp"
+
+ # Build ffmpeg command:
+ # - Map all streams, metadata, and chapters
+ # - Default to stream copy for everything
+ # - Let the preset override specific streams (usually video) via ffargs
+ local cmd=(ffmpeg
+ -nostdin
+ -y
+ -hide_banner
+ -v error
+ -stats
+ -i "file:$file"
+ -map 0
+ -map_metadata 0
+ -map_chapters 0
+ -c copy
+ "${ffargs[@]}"
+ "file:$tmp")
+
+ if [[ $DRY_RUN -eq 1 ]]; then
+ msg "DRY RUN:" "Would encode with preset $PRESET_NAME"
+ verbose "Input codec:" "$input_codec"
+ verbose "Input pixel format:" "$input_pixel_format"
+ verbose "Output pixel format:" "$output_pixel_format"
+ return $EXIT_OK
+ fi
+
+ msg "Encoding: $file"
+ debug "Nice value:" "$NICE_VALUE"
+
+ if nice -n "$NICE_VALUE" "${cmd[@]}"; then
+
+ local original_filesize new_filesize
+ original_filesize=$(filesize "$file")
+ new_filesize=$(filesize "$tmp")
+
+ # If requested, keep the original if the new file is larger.
+ if [[ $ONLY_IF_SMALLER -eq 1 ]]; then
+ if [[ $new_filesize -gt $original_filesize ]]; then
+ msg "Did not replace $file as it was larger than the original."
+ rm -f -- "$tmp"
+ return $EXIT_OK
+ fi
+ fi
+
+ if [[ $SAVING -eq 1 ]]; then
+ local pct
+ if [[ $original_filesize -gt 0 ]]; then
+ pct=$(bc <<<"($original_filesize - $new_filesize) * 100 / $original_filesize")
+ else
+ pct=0
+ fi
+ printf "%s %s %s %s%%\n" "$filename" "$original_filesize" "$new_filesize" "$pct" >>"$SAVING_FILE"
+ fi
+
+ mv -f -- "$tmp" "$file"
+ msg "Replaced: $file"
+ else
+ error "Encode FAILED: $file"
+ if [[ $CONTINUE_ON_FAIL -eq 1 ]]; then
+ return $EXIT_RUNTIME_FAILURE # fail this but keep going
+ else
+ exit $EXIT_RUNTIME_FAILURE
+ fi
+ fi
+}
+
+STATUS=$EXIT_OK
+if [[ -n $ENCODE_FILE ]]; then
+ while IFS= read -r file || [[ -n "$file" ]]; do
+ encode_one "$file" || STATUS=$EXIT_RUNTIME_FAILURE
+ done <"$ENCODE_FILE"
+else
+ for file in "${REMAINING_ARGS[@]}"; do
+ encode_one "$file" || STATUS=$EXIT_RUNTIME_FAILURE
+ done
+fi
+
+exit $STATUS
diff --git a/transcode.sh.1 b/transcode.sh.1
new file mode 100644
index 0000000..9001e8a
--- /dev/null
+++ b/transcode.sh.1
@@ -0,0 +1,274 @@
+.TH TRANSCODE.SH 1 "2026-02-02" "1.0.0" "User Commands"
+.SH NAME
+transcode.sh \- batch transcode helper for media files using ffmpeg
+.SH SYNOPSIS
+.B transcode.sh
+[\fIOPTION\fR]... [\fB\-\-\fR] \fIFILE\fR...
+.SH DESCRIPTION
+.B transcode.sh
+is a batch transcoding wrapper around
+.BR ffmpeg (1)
+that processes one or more media files in place, replacing each with a
+re-encoded version.
+.PP
+Encoding parameters are not hard-coded; instead they are supplied by
+.IR presets ,
+which are small shell snippets stored under the preset directory (see
+.B FILES
+below). Each preset must define a Bash array named
+.B ffargs
+containing the extra arguments passed to
+.BR ffmpeg (1).
+.PP
+The script probes every input file with
+.BR ffprobe (1)
+to detect the input video codec and pixel format. Files whose codec
+appears in the skip list are silently skipped. The output pixel format
+is derived automatically so that chroma subsampling (4:2:0, 4:2:2,
+4:4:4) and bit depth (8, 10, 12, 16\-bit) are preserved.
+.PP
+All streams, metadata and chapter markers from the input are retained by
+default (\fB\-map 0 \-map_metadata 0 \-map_chapters 0 \-c copy\fR); the
+preset only needs to override the streams it cares about (typically
+video).
+.PP
+Encoding is performed into a temporary file in the same directory as the
+input, which ensures an atomic rename on completion.
+.SH OPTIONS
+.TP
+.BR \-c ", " \-\-continue
+Continue processing the next file even if
+.BR ffmpeg (1)
+fails on the current one. Without this flag the script exits immediately
+on the first failure.
+.TP
+.BR \-f " \fIPATH\fR, " \-\-encodefile " \fIPATH\fR, " \-\-encodefile= \fIPATH\fR
+Read the list of files to encode from \fIPATH\fR (one file per line)
+instead of taking files from the command line.
+.TP
+.BR \-n ", " \-\-dry\-run
+Print what would be done without actually invoking
+.BR ffmpeg (1).
+Combine with
+.B \-v
+to see the detected codec and pixel format information.
+.TP
+.BR \-N " \fIVALUE\fR, " \-\-nice " \fIVALUE\fR, " \-\-nice= \fIVALUE\fR
+Run
+.BR ffmpeg (1)
+under
+.BR nice (1)
+with the given niceness value. Defaults to
+.BR 19
+(lowest priority).
+.TP
+.BR \-s ", " \-\-saving
+After each successful encode, append a line to the savings log file
+recording the filename, original size in bytes, new size in bytes and the
+percentage saved. Requires
+.BR bc (1).
+.TP
+.BR \-\-saving\-file " \fIPATH\fR, " \-\-saving\-file= \fIPATH\fR
+Path to the savings log file written by
+.BR \-\-saving .
+Defaults to
+.B transcode_savings
+in the current working directory.
+.TP
+.BR \-S " \fILIST\fR, " \-\-skip\-codec " \fILIST\fR, " \-\-skip\-codec= \fILIST\fR
+Comma-, space- or colon-separated list of video codec names to skip.
+Files whose detected input codec appears in this list are left untouched.
+The per-user skip list from
+.B skip.conf
+(see
+.BR FILES )
+is always loaded in addition to codecs supplied here.
+.TP
+.BR \-l ", " \-\-only\-if\-smaller
+After encoding, replace the original only when the new file is strictly
+smaller. If the transcoded file is larger the temporary file is removed
+and the original is kept unchanged.
+.TP
+.BR \-p " \fINAME\fR, " \-\-preset " \fINAME\fR, " \-\-preset= \fINAME\fR
+Load the preset named \fINAME\fR from the preset directory. Defaults to
+.BR default .
+.TP
+.BR \-h ", " \-\-help ", " \-?
+Print a short help message and exit.
+.TP
+.BR \-q ", " \-\-quiet
+Suppress all normal output. Takes precedence over
+.BR \-v / \-\-verbose .
+.TP
+.BR \-v ", " \-\-verbose
+Emit additional informational messages (detected codec, pixel format,
+etc.).
+.TP
+.B \-\-version
+Print version, author and license information and exit.
+.TP
+.B \-\-color
+Force colored output even when stdout is not a terminal or
+.B NO_COLOR
+is set.
+.TP
+.B \-\-no\-color
+Disable colored output unconditionally.
+.TP
+.B \-\-
+End of options. All subsequent arguments are treated as file names even
+if they begin with
+.BR \- .
+.SH ENVIRONMENT
+.TP
+.B NO_COLOR
+When set (to any value), colored output is disabled. See
+.IR https://no-color.org/ .
+.TP
+.B FORCE_COLOR
+When set, colored output is enabled regardless of
+.B NO_COLOR
+or terminal detection.
+.TP
+.B DEBUG
+Set to
+.B 1
+to enable structured debug messages (high-level state reporting).
+.TP
+.B TRACE
+Set to
+.B 1
+to enable Bash execution tracing
+.RB ( set\ \-x ),
+which prints every command as it is executed.
+.TP
+.B XDG_CONFIG_HOME
+Base directory for user configuration. Defaults to
+.BR $HOME/.config .
+.SH FILES
+.TP
+.IR "${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/presets/<name>.sh"
+Preset files. Each preset is sourced as Bash code and must define an
+array variable named
+.B ffargs
+containing additional arguments for
+.BR ffmpeg (1).
+The variable
+.B output_pixel_format
+is available to presets and contains the automatically selected output
+pixel format string.
+.IP
+Example preset:
+.RS
+.nf
+ffargs=( \-c:v:0 libsvtav1 \-crf 30 \-preset 6 \-pix_fmt "$output_pixel_format" )
+.fi
+.RE
+.IP
+.B Warning:
+presets are executed as shell code. Only use presets from trusted
+sources.
+.TP
+.IR "${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/skip.conf"
+Optional per-user codec skip list. Each line should contain one codec
+name (as reported by
+.BR ffprobe (1)).
+Files whose input video codec matches an entry are skipped without
+encoding.
+.SH EXIT STATUS
+.TP
+.B 0
+Success (or all skipped files were intentionally skipped).
+.TP
+.B 1
+Runtime failure (e.g.
+.BR ffmpeg (1)
+error on one or more files).
+.TP
+.B 2
+Usage error (invalid option or missing argument).
+.TP
+.B 3
+Configuration error (missing or invalid preset).
+.TP
+.B 127
+A required dependency
+.RB ( ffmpeg ,
+.BR ffprobe ,
+.BR nice ,
+or
+.B bc
+when
+.B \-\-saving
+is active) was not found.
+.SH EXAMPLES
+Transcode a single file using the default preset:
+.PP
+.RS
+.nf
+transcode.sh video.mp4
+.fi
+.RE
+.PP
+Transcode a batch of files listed in a file and log size savings:
+.PP
+.RS
+.nf
+transcode.sh \-s \-f list.txt
+.fi
+.RE
+.PP
+Dry-run with verbosity to inspect detected formats:
+.PP
+.RS
+.nf
+transcode.sh \-n \-v my_movie.mkv
+.fi
+.RE
+.PP
+Use a custom preset and only keep the result if it is smaller:
+.PP
+.RS
+.nf
+transcode.sh \-p av1_fast \-l /media/films/*.mkv
+.fi
+.RE
+.PP
+Run at a less aggressive niceness and skip already-AV1 files:
+.PP
+.RS
+.nf
+transcode.sh \-N 10 \-S av1 input.mkv
+.fi
+.RE
+.PP
+Use
+.BR ffmpeg (1)
+installed via Homebrew (macOS):
+.PP
+.RS
+.nf
+PATH="/opt/homebrew/bin:$PATH" transcode.sh input.mp4
+.fi
+.RE
+.SH NOTES
+The script resets
+.B PATH
+to
+.I /bin:/usr/bin:/usr/local/bin
+for security. If
+.BR ffmpeg (1)
+is installed outside these directories (e.g. via Homebrew or Nix),
+prepend the correct directory to
+.B PATH
+before invoking
+.BR transcode.sh .
+.SH SEE ALSO
+.BR ffmpeg (1),
+.BR ffprobe (1),
+.BR nice (1),
+.BR bc (1)
+.SH AUTHOR
+Dennis Fink <dennis.fink@c3l.lu>
+.SH LICENSE
+BSD\-3\-Clause
diff --git a/transcode.sh.bash-completion b/transcode.sh.bash-completion
new file mode 100644
index 0000000..ce153dd
--- /dev/null
+++ b/transcode.sh.bash-completion
@@ -0,0 +1,149 @@
+# bash completion for transcode.sh
+# Install to:
+# /etc/bash_completion.d/transcode.sh
+# or source manually:
+# source transcode.sh.bash-completion
+#
+# Each option that takes an argument is supported in all three forms:
+# short -f list.txt
+# long space --encodefile list.txt
+# long equals --encodefile=list.txt
+
+_transcode_sh() {
+ local cur prev words cword
+ _init_completion || return
+
+ local -r preset_dir="${XDG_CONFIG_HOME:-$HOME/.config}/transcode.sh/presets"
+
+ # ---------------------------------------------------------------------------
+ # Helper: collect preset names from the preset directory
+ # ---------------------------------------------------------------------------
+ _transcode_presets() {
+ local presets=()
+ if [[ -d "$preset_dir" ]]; then
+ local f
+ while IFS= read -r f; do
+ f="${f##*/}" # basename
+ f="${f%.sh}" # strip .sh extension
+ presets+=("$f")
+ done < <(find "$preset_dir" -maxdepth 1 -name '*.sh' -type f 2>/dev/null)
+ fi
+ printf '%s\n' "${presets[@]}"
+ }
+
+ local codecs='h264 hevc av1 vp8 vp9 mpeg2video mpeg4 mjpeg theora'
+ local nice_vals='0 5 10 15 19'
+
+ # ---------------------------------------------------------------------------
+ # Handle --option=VALUE: cur contains the entire "--opt=val" token.
+ # Strip the "opt=" prefix and delegate to the appropriate completer.
+ # This must be checked before the $prev dispatch below.
+ # ---------------------------------------------------------------------------
+ case "$cur" in
+ --encodefile=*)
+ cur="${cur#*=}"
+ _filedir
+ return
+ ;;
+ --saving-file=*)
+ cur="${cur#*=}"
+ _filedir
+ return
+ ;;
+ --nice=*)
+ cur="${cur#*=}"
+ COMPREPLY=( $(compgen -W "$nice_vals" -- "$cur") )
+ return
+ ;;
+ --skip-codec=*)
+ cur="${cur#*=}"
+ COMPREPLY=( $(compgen -W "$codecs" -- "$cur") )
+ return
+ ;;
+ --preset=*)
+ cur="${cur#*=}"
+ COMPREPLY=( $(compgen -W "$(_transcode_presets)" -- "$cur") )
+ return
+ ;;
+ esac
+
+ # ---------------------------------------------------------------------------
+ # Handle VALUE after short form (-f, -N, -S, -p) and
+ # long space form (--encodefile, --nice, --saving-file, --skip-codec, --preset).
+ # $prev is the option word, $cur is the value being typed.
+ # ---------------------------------------------------------------------------
+ case "$prev" in
+ -f|--encodefile)
+ _filedir
+ return
+ ;;
+ -N|--nice)
+ COMPREPLY=( $(compgen -W "$nice_vals" -- "$cur") )
+ return
+ ;;
+ --saving-file)
+ _filedir
+ return
+ ;;
+ -S|--skip-codec)
+ COMPREPLY=( $(compgen -W "$codecs" -- "$cur") )
+ return
+ ;;
+ -p|--preset)
+ COMPREPLY=( $(compgen -W "$(_transcode_presets)" -- "$cur") )
+ return
+ ;;
+ esac
+
+ # ---------------------------------------------------------------------------
+ # Complete option names themselves.
+ # For options that accept an argument we list both the bare long form
+ # (--nice) and the equals form (--nice=) so the user can choose their
+ # preferred style. compopt -o nospace is applied when the sole match
+ # ends with '=' to avoid an unwanted trailing space in that case.
+ # ---------------------------------------------------------------------------
+ case "$cur" in
+ --*)
+ local longopts='
+ --continue
+ --encodefile
+ --encodefile=
+ --dry-run
+ --nice
+ --nice=
+ --saving
+ --saving-file
+ --saving-file=
+ --skip-codec
+ --skip-codec=
+ --only-if-smaller
+ --preset
+ --preset=
+ --help
+ --quiet
+ --verbose
+ --version
+ --color
+ --no-color
+ '
+ COMPREPLY=( $(compgen -W "$longopts" -- "$cur") )
+ # Suppress the trailing space when the only completion ends with '='
+ [[ ${#COMPREPLY[@]} -eq 1 && "${COMPREPLY[0]}" == *= ]] \
+ && compopt -o nospace
+ return
+ ;;
+ -*)
+ local shortopts='-c -f -n -N -s -S -l -p -h -? -q -v --'
+ COMPREPLY=( $(compgen -W "$shortopts" -- "$cur") )
+ return
+ ;;
+ esac
+
+ # ---------------------------------------------------------------------------
+ # Default: complete positional FILE arguments with common media extensions
+ # ---------------------------------------------------------------------------
+ _filedir '@(mkv|mp4|mov|avi|webm|flv|wmv|m4v|ts|mts|m2ts|mpg|mpeg|ogv|3gp|rm|rmvb)'
+}
+
+complete -F _transcode_sh transcode.sh
+complete -F _transcode_sh transcode