diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rwxr-xr-x | devscripts/generate-testfile.sh | 697 | ||||
| -rw-r--r-- | justfile | 5 |
3 files changed, 703 insertions, 0 deletions
@@ -1,4 +1,5 @@ dist/ +testfiles/ # 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 diff --git a/devscripts/generate-testfile.sh b/devscripts/generate-testfile.sh new file mode 100755 index 0000000..e8bcbe8 --- /dev/null +++ b/devscripts/generate-testfile.sh @@ -0,0 +1,697 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# 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 <me+coding@dennisfink.me> +############################################################################### + +# 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 <me+coding@dennisfink.me>" +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 <me+coding@dennisfink.me>. + +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 @@ -9,5 +9,10 @@ prepare version: release version: (prepare version) ./devscripts/release.sh release "{{version}}" +# Generate a synthetic video test file with ffmpeg. +testfile *args: + ./devscripts/generate-testfile.sh {{args}} + clean: rm -f dist/transcode-*.tar.gz + rm -f testfiles/* |
