432 lines
16 KiB
Bash
Executable File
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
|