VideoTools/video-tools.sh

432 lines
16 KiB
Bash
Executable File

#!/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 <input1> <input2> ... <output.mp4> [--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 <file1> [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 <input> <output.mp4> [--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 <input1> <input2> ... <output.mp4> [--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 <input> <output.mp4> [--hi-rate|--portable]"
echo " $0 convert-multiple <input1> <input2> ... <output.mp4> [--hi-rate|--portable]"
echo " $0 videoinfo <file1> [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