#!/usr/bin/env bash # video-tools.sh # ---------------------------------------------------------- # Simple FFmpeg CLI Toolset # Provides easy commands for single or multi-file video conversion. # Works on Linux and Windows (Git Bash / WSL) as long as ffmpeg is installed. # ---------------------------------------------------------- set -euo pipefail # ========================================================== # Configuration system # ========================================================== SCRIPT_DIR="$(dirname "$0")" CONFIG_FILE="$SCRIPT_DIR/config/config.json" LOG_DIR="$HOME/.local/share/video-tools/logs" # ------------------------- # Default configuration # ------------------------- OUTPUT_DIR="$HOME/Videos" VIDEO_CODEC="libx264" AUDIO_CODEC="aac" CRF="18" # lower = higher quality PRESET="slow" # slower = better compression AUDIO_BITRATE="192k" VERSION="0.1.1" # ========================================================== # Utility: Colour logging # ========================================================== RED="\033[31m" YELLOW="\033[33m" GREEN="\033[32m" BLUE="\033[34m" NC="\033[0m" # reset info() { echo -e "${BLUE}[INFO]${NC} $*"; } success() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } # ========================================================== # Dependency check # ========================================================== if ! command -v ffmpeg >/dev/null 2>&1; then error "ffmpeg is not installed or not in PATH." exit 1 fi if [[ -f "$CONFIG_FILE" ]] && command -v jq >/dev/null 2>&1; then info "Loading configuration from: $CONFIG_FILE" OUTPUT_DIR="$(eval echo "$(jq -r '.output_dir // empty' "$CONFIG_FILE")")" || true VIDEO_CODEC="$(jq -r '.video_codec // empty' "$CONFIG_FILE" 2>/dev/null || echo "$VIDEO_CODEC")" AUDIO_CODEC="$(jq -r '.audio_codec // empty' "$CONFIG_FILE" 2>/dev/null || echo "$AUDIO_CODEC")" CRF="$(jq -r '.crf // empty' "$CONFIG_FILE" 2>/dev/null || echo "$CRF")" PRESET="$(jq -r '.preset // empty' "$CONFIG_FILE" 2>/dev/null || echo "$PRESET")" AUDIO_BITRATE="$(jq -r '.audio_bitrate // empty' "$CONFIG_FILE" 2>/dev/null || echo "$AUDIO_BITRATE")" VERSION="$(jq -r '.version // empty' "$CONFIG_FILE" 2>/dev/null || echo "$VERSION")" elif [[ ! -f "$CONFIG_FILE" ]]; then warn "No config.json found — using internal defaults." else warn "jq not found — skipping config.json parsing." fi mkdir -p "$OUTPUT_DIR" || { error "Failed to create output directory $OUTPUT_DIR"; exit 1; } mkdir -p "$LOG_DIR" || { error "Failed to create log directory $LOG_DIR"; exit 1; } # ========================================================== # Conversion Profiles # ========================================================== apply_profile() { local profile="$1" case "$profile" in hi-rate|--hi-rate) info "Applying high bitrate profile..." CRF="14" PRESET="veryslow" AUDIO_BITRATE="320k" ;; portable|--portable) info "Applying portable/mobile profile..." CRF="24" PRESET="faster" AUDIO_BITRATE="128k" ;; default|--default|"") ;; *) warn "Unknown profile: $profile — ignoring." ;; esac } # ========================================================== # Header output # ========================================================== print_header() { echo "----------------------------------------------" echo "Video Tools v$VERSION" echo "Output directory: $OUTPUT_DIR" echo "Video codec : $VIDEO_CODEC" echo "Audio codec : $AUDIO_CODEC" echo "CRF : $CRF" echo "Preset : $PRESET" echo "Audio rate : $AUDIO_BITRATE" echo "----------------------------------------------" } # ========================================================== # Path sanitization (handles spaces, quotes, etc.) # ========================================================== safe_path() { local p="$1" printf "%s" "$p" | sed "s/'/'\\\\''/g" } sanitize_name() { local name="$1" echo "$name" | sed 's/[^A-Za-z0-9._-]/_/g' } # ========================================================== # Summary report parser # ========================================================== print_summary() { local log_file="$1" local input="$2" local output="$3" local start_time="$4" local end_time="$5" local elapsed=$((end_time - start_time)) local elapsed_min=$((elapsed / 60)) local elapsed_sec=$((elapsed % 60)) local duration bitrate speed fps frames mux_overhead vbitrate abitrate duration=$(grep -Eo "time=[0-9:.]+" "$log_file" | tail -1 | cut -d= -f2) bitrate=$(grep -Eo "bitrate=[0-9.]+kbits/s" "$log_file" | tail -1 | awk -F= '{print $2}') speed=$(grep -Eo "speed=[0-9.]+x" "$log_file" | tail -1 | cut -d= -f2) fps=$(grep -Eo "fps=[0-9.]+" "$log_file" | tail -1 | cut -d= -f2) frames=$(grep -Eo "frame=[0-9]+" "$log_file" | tail -1 | awk -F= '{print $2}') mux_overhead=$(grep -Eo "muxing overhead: [0-9.]+%" "$log_file" | tail -1 | awk '{print $3}') vbitrate=$(grep -Eo "kb/s:[0-9.]+" "$log_file" | tail -1 | cut -d: -f2) abitrate=$(echo "$AUDIO_BITRATE" | sed 's/k//') echo "" echo "----------------------------------------------" echo "Conversion Summary" echo "----------------------------------------------" echo "Input File : $input" echo "Output File : $output" echo "Log File : $log_file" echo "Duration : ${duration:-unknown}" echo "Encoding Speed : ${speed:-unknown}" echo "Elapsed Time : ${elapsed_min}m ${elapsed_sec}s" echo "Status : ✅ Successful" echo "----------------------------------------------" echo "[Technical Stats]" echo "Average Bitrate : ${bitrate:-N/A}" echo "Video Bitrate : ${vbitrate:-N/A} kb/s" echo "Audio Bitrate : ${abitrate:-N/A} kb/s" echo "Frames Encoded : ${frames:-N/A}" echo "FPS Achieved : ${fps:-N/A}" echo "Mux Overhead : ${mux_overhead:-N/A}" echo "CRF Setting : $CRF" echo "----------------------------------------------" echo "" } # ========================================================== # Generate timestamped log file # ========================================================== generate_logfile() { local output_name="$1" local timestamp timestamp=$(date +"%Y-%m-%d_%H-%M-%S") local base base=$(basename "$output_name" .mp4) local safe_base safe_base=$(sanitize_name "$base") echo "$LOG_DIR/${timestamp}_${safe_base}.log" } # ========================================================== # Convert one file # ========================================================== convert_single() { local input="$1" local output_name="$2" local output_path="$OUTPUT_DIR/$output_name" local safe_input safe_input="$(safe_path "$input")" local log_file log_file=$(generate_logfile "$output_name") local start_time end_time print_header info "Converting single video..." info "Input : $input" info "Output: $output_path" info "Log : $log_file" if [[ ! -f "$input" ]]; then error "Input file not found: $input" exit 1 fi start_time=$(date +%s) ffmpeg -hide_banner -loglevel info -fflags +genpts \ -i "$input" \ -c:v "$VIDEO_CODEC" -crf "$CRF" -preset "$PRESET" \ -c:a "$AUDIO_CODEC" -b:a "$AUDIO_BITRATE" -movflags +faststart \ "$output_path" 2>&1 | tee "$log_file" end_time=$(date +%s) ln -sf "$log_file" "$LOG_DIR/latest.log" 2>/dev/null || true success "Conversion complete: $output_path" print_summary "$log_file" "$safe_input" "$output_path" "$start_time" "$end_time" } # ========================================================== # Combine multiple files # ========================================================== convert_multiple() { # Rebuild args array safely to preserve quoted file paths local args=() while [[ $# -gt 0 ]]; do args+=("$1") shift done # Ensure at least two inputs + one output if [[ ${#args[@]} -lt 3 ]]; then error "Usage: $0 convert-multiple ... [--hi-rate|--portable]" exit 1 fi # Extract and verify output filename local output_name="${args[-1]}" unset 'args[-1]' if [[ -z "${output_name:-}" ]]; then warn "Output name missing — defaulting to combined_output.mp4" output_name="combined_output.mp4" fi # Setup working paths local list_file list_file=$(mktemp) local output_path="$OUTPUT_DIR/$output_name" local log_file log_file=$(generate_logfile "$output_name") local start_time end_time print_header info "Combining multiple videos..." info "Output: $output_path" info "Log : $log_file" # Verify each input and build concat list for input in "${args[@]}"; do if [[ ! -f "$input" ]]; then error "Missing input file: '$input'" rm -f "$list_file" exit 1 fi printf "file '%s'\n" "$(safe_path "$input")" >> "$list_file" info " + Added: $input" done echo "----------------------------------------------" # Run FFmpeg and record timing start_time=$(date +%s) ffmpeg -hide_banner -loglevel info -f concat -safe 0 -i "$list_file" \ -c:v "$VIDEO_CODEC" -crf "$CRF" -preset "$PRESET" \ -c:a "$AUDIO_CODEC" -b:a "$AUDIO_BITRATE" -movflags +faststart \ "$output_path" 2>&1 | tee "$log_file" end_time=$(date +%s) # Cleanup and summary ln -sf "$log_file" "$LOG_DIR/latest.log" 2>/dev/null || true rm -f "$list_file" success "Combined video created: $output_path" print_summary "$log_file" "Multiple inputs" "$output_path" "$start_time" "$end_time" } # ========================================================== # Show concise or verbose video information (cross-platform) # ========================================================== show_videoinfo() { if ! command -v ffprobe >/dev/null 2>&1; then error "ffprobe is not installed or not in PATH." exit 1 fi if [[ $# -lt 1 ]]; then error "Usage: $0 videoinfo [file2 ...] [--verbose]" exit 1 fi # Detect verbose flag local verbose=false for arg in "$@"; do [[ "$arg" == "--verbose" ]] && verbose=true done for input in "$@"; do [[ "$input" == "--verbose" ]] && continue if [[ ! -f "$input" ]]; then error "File not found: $input" continue fi echo "----------------------------------------------" echo "File: $input" echo "----------------------------------------------" if $verbose; then ffprobe -v error \ -show_entries format=filename,format_name,duration,size,bit_rate \ -show_streams \ -of default=noprint_wrappers=1:nokey=0 \ "$input" | sed 's/^/ /' else # Collect metadata safely local duration size format vcodec acodec width height fps profile pix_fmt dar sar vbitrate abitrate channels arate bitrate duration=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$input") size=$(ffprobe -v error -show_entries format=size -of default=noprint_wrappers=1:nokey=1 "$input") format=$(ffprobe -v error -show_entries format=format_name -of default=noprint_wrappers=1:nokey=1 "$input") bitrate=$(ffprobe -v error -show_entries format=bit_rate -of default=noprint_wrappers=1:nokey=1 "$input") vcodec=$(ffprobe -v error -select_streams v:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 "$input") profile=$(ffprobe -v error -select_streams v:0 -show_entries stream=profile -of default=noprint_wrappers=1:nokey=1 "$input") width=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of default=noprint_wrappers=1:nokey=1 "$input") height=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of default=noprint_wrappers=1:nokey=1 "$input") fps=$(ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of default=noprint_wrappers=1:nokey=1 "$input" | awk -F'/' '{if ($2>0) printf "%.2f", $1/$2; else print $1}') pix_fmt=$(ffprobe -v error -select_streams v:0 -show_entries stream=pix_fmt -of default=noprint_wrappers=1:nokey=1 "$input") dar=$(ffprobe -v error -select_streams v:0 -show_entries stream=display_aspect_ratio -of default=noprint_wrappers=1:nokey=1 "$input") sar=$(ffprobe -v error -select_streams v:0 -show_entries stream=sample_aspect_ratio -of default=noprint_wrappers=1:nokey=1 "$input") vbitrate=$(ffprobe -v error -select_streams v:0 -show_entries stream=bit_rate -of default=noprint_wrappers=1:nokey=1 "$input") acodec=$(ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 "$input") abitrate=$(ffprobe -v error -select_streams a:0 -show_entries stream=bit_rate -of default=noprint_wrappers=1:nokey=1 "$input") channels=$(ffprobe -v error -select_streams a:0 -show_entries stream=channels -of default=noprint_wrappers=1:nokey=1 "$input") arate=$(ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate -of default=noprint_wrappers=1:nokey=1 "$input") # Compute duration minutes without bc local mins secs mins=$(( ${duration%.*} / 60 )) secs=$(( ${duration%.*} % 60 )) # Simple size + bitrate formatting (cross-platform safe) local size_mb=$(( size / 1048576 )) local bitrate_mbps=$(( bitrate / 1000000 )) local vbitrate_mbps=$(( vbitrate / 1000000 )) local abitrate_kbps=$(( abitrate / 1000 )) echo " Duration: ${duration:-N/A} sec (${mins}m ${secs}s)" echo " Size: ${size_mb} MB" echo " Container: ${format:-N/A}" echo " Video Codec: ${vcodec:-N/A} (${profile:-N/A})" echo " Resolution: ${width:-N/A}x${height:-N/A} @ ${fps:-N/A} fps" echo " Aspect Ratio: ${dar:-N/A} (SAR ${sar:-N/A})" echo " Pixel Format: ${pix_fmt:-N/A}" echo " Video Bitrate: ${vbitrate_mbps} Mbps" echo " Audio Codec: ${acodec:-N/A}" echo " Audio: ${channels:-N/A} ch @ ${arate:-N/A} Hz" echo " Audio Bitrate: ${abitrate_kbps} kbps" echo " Overall Rate: ${bitrate_mbps} Mbps" fi echo "" done } # ========================================================== # Dispatcher # ========================================================== case "${1:-}" in convert-single) shift if [[ $# -lt 2 ]]; then error "Usage: $0 convert-single [--hi-rate|--portable]" exit 1 fi # Optional profile flag if [[ $# -eq 3 ]]; then apply_profile "${3:-}" fi convert_single "$1" "$2" ;; convert-multiple) shift if [[ $# -lt 3 ]]; then error "Usage: $0 convert-multiple ... [--hi-rate|--portable]" exit 1 fi # Handle optional profile flag last_arg="${@: -1}" case "$last_arg" in --hi-rate|--portable) apply_profile "$last_arg" set -- "${@:1:$(($#-1))}" ;; esac convert_multiple "$@" ;; videoinfo|--videoinfo|--fileinfo) shift show_videoinfo "$@" ;; *) echo "Video Tools v$VERSION" echo "Usage:" echo " $0 convert-single [--hi-rate|--portable]" echo " $0 convert-multiple ... [--hi-rate|--portable]" echo " $0 videoinfo [file2 ...]" echo "" echo "Profiles:" echo " --hi-rate Highest bitrate and quality (CRF 14, 320k audio)" echo " --portable Smaller file size for mobile (CRF 24, 128k audio)" echo "" echo "All outputs will be saved in: $OUTPUT_DIR" echo "Logs stored in: $LOG_DIR" ;; esac