From 5fcf85215c5a094f7a27a93a4e13c5136cafa55e Mon Sep 17 00:00:00 2001 From: dabruh <11458706-dabruh@users.noreply.gitlab.com> Date: Sun, 16 Nov 2025 00:28:26 +0100 Subject: [PATCH] feat: add videncode script for video encoding with H.264 and scaling --- .local/bin/videncode | 234 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100755 .local/bin/videncode diff --git a/.local/bin/videncode b/.local/bin/videncode new file mode 100755 index 0000000..933ab2f --- /dev/null +++ b/.local/bin/videncode @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# Video encoding script using ffmpeg with H.264 and scaling to FHD. + +set -euo pipefail + +# Colors for log output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Logging functions for user-friendly output +log_info() { echo -e "${BLUE}INFO:${NC} $1"; } +log_success() { echo -e "${GREEN}SUCCESS:${NC} $1"; } +log_warn() { echo -e "${YELLOW}WARN:${NC} $1"; } +log_error() { echo -e "${RED}ERROR:${NC} $1" >&2; } + +# Gets the duration of a video file in seconds using ffprobe. +get_duration() { + ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$1" 2>/dev/null || echo "0" +} + +# Default encoding parameters tuned for quality and compatibility. +readonly DEFAULT_CRF="23" +readonly DEFAULT_PRESET="medium" +readonly DEFAULT_MAXRATE="6M" +readonly DEFAULT_BUFSIZE="12M" +readonly DEFAULT_MAX_W="1920" +readonly DEFAULT_MAX_H="1080" + +# Global state variables modified during argument parsing and validation. +INFILE="" +OUTFILE="" +CRF="${DEFAULT_CRF}" +PRESET="${DEFAULT_PRESET}" +MAXRATE="${DEFAULT_MAXRATE}" +BUFSIZE="${DEFAULT_BUFSIZE}" +EXTRA_ARGS=() +MAX_W="${DEFAULT_MAX_W}" +MAX_H="${DEFAULT_MAX_H}" +OVERWRITE_SAME=false +OVERWRITE_DIFFERENT=false +IS_BATCH=false +INPUT_DIR="" +INPUT_FILES=() + +# Prints usage information to stdout. +usage() { + cat <_videncode.mp4 or original name if different dir) + -c, --crf CRF value (default: ${DEFAULT_CRF}) + -p, --preset x264 preset (default: ${DEFAULT_PRESET}) + -m, --maxrate Maxrate constraint (default: ${DEFAULT_MAXRATE}) + -b, --bufsize Bufsize constraint (default: ${DEFAULT_BUFSIZE}) + --extra Extra ffmpeg args (wrap in quotes). May be specified multiple times. + --max-w Maximum width in pixels (default: ${DEFAULT_MAX_W}) + --max-h Maximum height in pixels (default: ${DEFAULT_MAX_H}) + --overwrite-same Overwrite output even if duration matches input + --overwrite-different Overwrite output even if duration differs from input + -h, --help Show this help +EOF +} + +# Parses command-line arguments, supporting both short and long options. +parse_args() { + if [[ $# -eq 0 ]]; then + usage + exit 1 + fi + + while [[ $# -gt 0 ]]; do + case "$1" in + -i|--input) INFILE="$2"; shift 2 ;; + -o|--output) OUTFILE="$2"; shift 2 ;; + -c|--crf) CRF="$2"; shift 2 ;; + -p|--preset) PRESET="$2"; shift 2 ;; + -m|--maxrate) MAXRATE="$2"; shift 2 ;; + -b|--bufsize) BUFSIZE="$2"; shift 2 ;; + --extra) EXTRA_ARGS+=("$2"); shift 2 ;; + --max-w) MAX_W="$2"; shift 2 ;; + --max-h) MAX_H="$2"; shift 2 ;; + --overwrite-same) OVERWRITE_SAME=true; shift ;; + --overwrite-different) OVERWRITE_DIFFERENT=true; shift ;; + -h|--help) usage; exit 0 ;; + --) shift; break ;; + *) echo "Unknown argument: $1" >&2; usage; exit 1 ;; + esac + done +} + +# Validates input and determines output path based on rules to avoid overwriting. +validate_input() { + if [[ -z "${INFILE:-}" ]]; then + log_error "input (-i/--input) is required." + usage + exit 1 + fi + + if [[ -d "$INFILE" ]]; then + IS_BATCH=true + INPUT_DIR="$INFILE" + log_info "Processing directory: $INFILE" + # Find video files in the directory + while IFS= read -r -d '' file; do + INPUT_FILES+=("$file") + done < <(find "$INPUT_DIR" -type f \( -iname "*.mp4" -o -iname "*.avi" -o -iname "*.mkv" -o -iname "*.mov" -o -iname "*.flv" -o -iname "*.wmv" -o -iname "*.webm" -o -iname "*.m4v" \) -print0) + if [[ ${#INPUT_FILES[@]} -eq 0 ]]; then + log_error "no video files found in directory: $INFILE" + exit 1 + fi + log_info "Found ${#INPUT_FILES[@]} video file(s) to process" + elif [[ -f "$INFILE" ]]; then + IS_BATCH=false + INPUT_FILES=("$INFILE") + log_info "Processing single file: $INFILE" + else + log_error "input not found or not a file/directory: $INFILE" + exit 1 + fi +} + +# Validates single input file and determines output path. +validate_input_single() { + # Determine output path: use _videncode.mp4 suffix in same dir, original name in different dir. + if [[ -n "$OUTFILE" && -d "$OUTFILE" ]]; then + if [[ "$OUTFILE" == "$INPUT_DIR" ]]; then + OUTFILE="${OUTFILE%/}/$(basename "${INFILE%.*}")_videncode.mp4" + else + OUTFILE="${OUTFILE%/}/$(basename "$INFILE")" + fi + elif [[ -z "$OUTFILE" ]]; then + OUTFILE="${INFILE%.*}_videncode.mp4" + fi + + log_info "Output will be: $OUTFILE" + + # Prevent accidental overwriting of the input file. + if [[ "$OUTFILE" == "$INFILE" ]]; then + log_error "output file would overwrite input file." + exit 1 + fi +} + +# Assembles the full ffmpeg command array with all configured options. +build_ffmpeg_cmd() { + # Sets audio flags to copy streams unchanged for fidelity. + local audio_flags=("-c:a" "copy") + + # Constructs the ffmpeg video scaling filter to fit within max dimensions while preserving aspect ratio. + # Scale logic: for landscape (w>h), constrain width to MAX_W and height to MAX_H; + # for portrait (h>w), constrain width to MAX_H and height to MAX_W. + # Commas are escaped for safe shell passing; force_original_aspect_ratio=decrease avoids upscaling. + local vf="scale='if(gt(iw\\,ih)\\,min(iw\\,${MAX_W})\\,min(iw\\,${MAX_H}))':'if(gt(iw\\,ih)\\,min(ih\\,${MAX_H})\\,min(ih\\,${MAX_W}))':force_original_aspect_ratio=decrease" + + CMD=(ffmpeg -y -i "$INFILE" -map_metadata 0 -c:v libx264 -preset "$PRESET" -crf "$CRF" -maxrate "$MAXRATE" -bufsize "$BUFSIZE" -vf "$vf" "${audio_flags[@]}" -movflags +faststart) + + # Safely append user-provided extra arguments by splitting on whitespace. + if [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; then + for ea in "${EXTRA_ARGS[@]}"; do + read -r -a split <<< "$ea" + CMD+=("${split[@]}") + done + fi + + CMD+=("$OUTFILE") +} + +# Executes the constructed ffmpeg command after displaying it for transparency. +run_ffmpeg() { + log_info "Processing: $INFILE -> $OUTFILE" + if [[ -f "$OUTFILE" ]]; then + input_duration=$(get_duration "$INFILE") + output_duration=$(get_duration "$OUTFILE") + if [[ "$input_duration" == "$output_duration" && "$output_duration" != "0" ]]; then + if [[ "$OVERWRITE_SAME" == false ]]; then + log_warn "Skipping existing file with matching duration: $OUTFILE" + return + fi + elif [[ "$IS_BATCH" == false ]]; then + if [[ "$OVERWRITE_DIFFERENT" == false ]]; then + read -p "Output file exists with different duration, overwrite? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_warn "User chose not to overwrite: $OUTFILE" + return + fi + fi + else + if [[ "$OVERWRITE_DIFFERENT" == false ]]; then + log_warn "Skipping existing file with different duration: $OUTFILE" + return + fi + fi + fi + log_info "Starting encoding..." + log_info "Command:" + printf ' %q' "${CMD[@]}" + echo + "${CMD[@]}" + log_success "Encoding completed: $OUTFILE" +} + +# Main entry point: orchestrates parsing, validation, command building, and execution. +main() { + parse_args "$@" + validate_input + if [[ "$IS_BATCH" == true ]]; then + # Save the output directory for batch processing + local OUTPUT_DIR="$OUTFILE" + local total=${#INPUT_FILES[@]} + local count=1 + for file in "${INPUT_FILES[@]}"; do + log_info "Processing file $count of $total: $(basename "$file")" + INFILE="$file" + OUTFILE="$OUTPUT_DIR" # Reset OUTFILE for each iteration + validate_input_single + build_ffmpeg_cmd + run_ffmpeg + ((count++)) + done + log_success "Batch processing completed: $total file(s) processed" + else + validate_input_single + build_ffmpeg_cmd + run_ffmpeg + fi +} + +main "$@"