#!/usr/bin/env bash # SPDX-FileCopyrightText: 2026 Dennis Fink # # SPDX-License-Identifier: BSD-3-Clause ############################################################################### # DESCRIPTION # Test file generator for transcode.sh. # # This developer script creates small synthetic video files with ffmpeg. It # uses a lavfi source, such as smptebars, so test inputs can be generated # without keeping binary media files in the repository. Codec, resolution, # duration, frame rate, source filter, and output path can be selected from # the command line. # # 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 enables structured debug messages (see debug()) and # is intended for high-level state reporting. # # Recommended usage: # TRACE=1 devscripts/generate-testfile.sh ... # show every command bash executes # DEBUG=1 devscripts/generate-testfile.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="Test file generator for transcode.sh." readonly DATE_OF_CREATION=2026-05-16 readonly DATE_OF_REVISION=2026-05-16 readonly VERSION=0.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} DRY_RUN=0 QUIET=0 VERBOSE=0 OVERWRITE=0 CODEC="libx264" RESOLUTION="1280x720" LENGTH="5" FRAME_RATE="30" FILTER="smptebars" PIX_FMT="yuv420p" FFMPEG_LOGLEVEL="warning" OUTPUT="" FFMPEG_COMMAND=() readonly OUTPUT_DIR="testfiles" ############################################################################### # 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 ############################################################################### 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 } ############################################################################### # HELP AND VERSION OUTPUT ############################################################################### 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}[OUTPUT]${ALL_OFF} ${BOLD}${BLUE}Generation options:${ALL_OFF} ${BOLD}${YELLOW}-c, --codec${ALL_OFF} ${BOLD}${GREEN}CODEC${ALL_OFF} ffmpeg video codec to use (default: libx264) ${BOLD}${YELLOW}-r, --resolution${ALL_OFF} ${BOLD}${GREEN}WIDTHxHEIGHT${ALL_OFF} output resolution (default: 1280x720) ${BOLD}${YELLOW}-l, --length${ALL_OFF} ${BOLD}${GREEN}DURATION${ALL_OFF} test file length, in ffmpeg duration syntax (default: 5) ${BOLD}${YELLOW}--rate${ALL_OFF} ${BOLD}${GREEN}FPS${ALL_OFF} frame rate for the generated source (default: 30) ${BOLD}${YELLOW}--source${ALL_OFF} ${BOLD}${GREEN}SOURCE${ALL_OFF} lavfi video source (default: smptebars) ${BOLD}${YELLOW}--pix-fmt${ALL_OFF} ${BOLD}${GREEN}FORMAT${ALL_OFF} output pixel format (default: yuv420p) ${BOLD}${YELLOW}--ffmpeg-loglevel${ALL_OFF} ${BOLD}${GREEN}LEVEL${ALL_OFF} ffmpeg log level (default: warning) ${BOLD}${YELLOW}-o, --output${ALL_OFF} ${BOLD}${GREEN}FILE${ALL_OFF} write to FILE instead of generated testfiles/ name ${BOLD}${YELLOW}-f, --force${ALL_OFF} overwrite an existing output file ${BOLD}${YELLOW}-n, --dry-run${ALL_OFF} show the ffmpeg command without creating a file ${BOLD}${BLUE}Output options:${ALL_OFF} ${BOLD}${YELLOW}-q, --quiet${ALL_OFF} suppress informational output ${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}${BLUE}Examples:${ALL_OFF} %s --codec libx264 --resolution 1280x720 --length 5 %s --codec libx265 --resolution 1920x1080 --length 00:00:10 %s --codec libvpx-vp9 --resolution 640x360 --length 3 --output testfiles/vp9.webm %s --source testsrc2 --codec mpeg4 --resolution 320x240 --length 2 " \ "$SCRIPTNAME" "$VERSION" "$DESCRIPTION" \ "$SCRIPTNAME" \ "$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 Copyright (c) 2026 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" } ############################################################################### # 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. ############################################################################### 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 } ############################################################################### # GENERATION HELPERS ############################################################################### require_command() { local command_name="$1" command -v "$command_name" >/dev/null 2>&1 || { error "$command_name not found." exit $EXIT_MISSING_DEPENDENCY } } validate_resolution() { if [[ ! "$RESOLUTION" =~ ^[1-9][0-9]*x[1-9][0-9]*$ ]]; then error "Resolution must use WIDTHxHEIGHT format:" "$RESOLUTION" exit $EXIT_USAGE_ERROR fi } validate_length() { if [[ "$LENGTH" =~ ^- ]]; then error "Length must be a positive ffmpeg duration:" "$LENGTH" exit $EXIT_USAGE_ERROR fi } validate_frame_rate() { if [[ ! "$FRAME_RATE" =~ ^[1-9][0-9]*([.][0-9]+)?$ ]]; then error "Frame rate must be a positive number:" "$FRAME_RATE" exit $EXIT_USAGE_ERROR fi } extension_for_codec() { case "$CODEC" in libvpx | libvpx-vp9) printf "webm" ;; libaom-av1 | librav1e | libsvtav1) printf "mkv" ;; *) printf "mp4" ;; esac } sanitized_name_part() { local value="$1" value="${value//\//-}" value="${value//:/-}" value="${value// /-}" printf "%s" "$value" } default_output_path() { local extension codec_part length_part extension="$(extension_for_codec)" codec_part="$(sanitized_name_part "$CODEC")" length_part="$(sanitized_name_part "$LENGTH")" printf "%s/%s-%s-%ss.%s" "$OUTPUT_DIR" "$codec_part" "$RESOLUTION" "$length_part" "$extension" } print_command() { local quoted_command printf -v quoted_command '%q ' "${FFMPEG_COMMAND[@]}" printf "%s\n" "${quoted_command% }" } generate_test_file() { local output_file="$1" if [[ -e "$output_file" && $OVERWRITE -eq 0 ]]; then error "Output file already exists; use --force to overwrite:" "$output_file" exit $EXIT_RUNTIME_FAILURE fi local overwrite_flag="-n" if [[ $OVERWRITE -eq 1 ]]; then overwrite_flag="-y" fi FFMPEG_COMMAND=( ffmpeg -hide_banner -loglevel "$FFMPEG_LOGLEVEL" "$overwrite_flag" -f lavfi -i "${FILTER}=size=${RESOLUTION}:rate=${FRAME_RATE}" -t "$LENGTH" -an -c:v "$CODEC" -pix_fmt "$PIX_FMT" "$output_file" ) if [[ $DRY_RUN -eq 1 ]]; then msg "DRY RUN:" "Would generate test file: $output_file" print_command return $EXIT_OK fi mkdir -p -- "$(dirname -- "$output_file")" msg "Generating test file:" "$output_file" verbose "Codec:" "$CODEC" verbose "Resolution:" "$RESOLUTION" verbose "Length:" "$LENGTH" verbose "Filter:" "$FILTER" verbose "Command:" if [[ $VERBOSE -eq 1 && $QUIET -eq 0 ]]; then print_command fi "${FFMPEG_COMMAND[@]}" msg "Created:" "$output_file" } ############################################################################### # MAIN EXECUTION ############################################################################### # 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 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 ;; -n | --dry-run) DRY_RUN=1 shift ;; -f | --force) OVERWRITE=1 shift ;; -c | --codec) 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 CODEC="$2" shift 2 ;; --codec=*) if [[ ! -n "${1#*=}" ]]; then error "Value for --codec must not be empty" exit $EXIT_USAGE_ERROR fi CODEC="${1#*=}" shift ;; -r | --resolution) 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 RESOLUTION="$2" shift 2 ;; --resolution=*) if [[ ! -n "${1#*=}" ]]; then error "Value for --resolution must not be empty" exit $EXIT_USAGE_ERROR fi RESOLUTION="${1#*=}" shift ;; -l | --length | --duration) 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 LENGTH="$2" shift 2 ;; --length=*) if [[ ! -n "${1#*=}" ]]; then error "Value for --length must not be empty" exit $EXIT_USAGE_ERROR fi LENGTH="${1#*=}" shift ;; --duration=*) if [[ ! -n "${1#*=}" ]]; then error "Value for --duration must not be empty" exit $EXIT_USAGE_ERROR fi LENGTH="${1#*=}" shift ;; --rate) 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 FRAME_RATE="$2" shift 2 ;; --rate=*) if [[ ! -n "${1#*=}" ]]; then error "Value for --rate must not be empty" exit $EXIT_USAGE_ERROR fi FRAME_RATE="${1#*=}" shift ;; --source) 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 FILTER="$2" shift 2 ;; --source=*) if [[ ! -n "${1#*=}" ]]; then error "Value for --source must not be empty" exit $EXIT_USAGE_ERROR fi FILTER="${1#*=}" shift ;; --pix-fmt) 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 PIX_FMT="$2" shift 2 ;; --pix-fmt=*) if [[ ! -n "${1#*=}" ]]; then error "Value for --pix-fmt must not be empty" exit $EXIT_USAGE_ERROR fi PIX_FMT="${1#*=}" shift ;; --ffmpeg-loglevel) 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 FFMPEG_LOGLEVEL="$2" shift 2 ;; --ffmpeg-loglevel=*) if [[ ! -n "${1#*=}" ]]; then error "Value for --ffmpeg-loglevel must not be empty" exit $EXIT_USAGE_ERROR fi FFMPEG_LOGLEVEL="${1#*=}" shift ;; -o | --output) 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 OUTPUT="$2" shift 2 ;; --output=*) if [[ ! -n "${1#*=}" ]]; then error "Value for --output must not be empty" exit $EXIT_USAGE_ERROR fi OUTPUT="${1#*=}" shift ;; --) shift break ;; -*) error "Unknown option:" "$1" exit $EXIT_USAGE_ERROR ;; *) if [[ -n "$OUTPUT" ]]; then error "Unexpected extra argument:" "$1" exit $EXIT_USAGE_ERROR fi OUTPUT="$1" shift ;; esac done if [[ $# -gt 0 ]]; then if [[ -n "$OUTPUT" ]]; then error "Unexpected extra argument:" "$1" exit $EXIT_USAGE_ERROR fi OUTPUT="$1" shift fi require_command ffmpeg validate_resolution validate_length validate_frame_rate if [[ -z "$OUTPUT" ]]; then OUTPUT="$(default_output_path)" fi generate_test_file "$OUTPUT" exit $EXIT_OK