#!/usr/bin/env bash set -uo pipefail SCRIPT_NAME="$(basename "$0")" # Defaults TITLE="" OUTPUT="" RESOLUTION="1920x1080" FONT_SIZE="72" FONT_COLOR="white" BG_COLOR="black" IMAGE="" DRYRUN=false usage() { cat < Convert an MP3 audio file into an MP4 video displaying a static title card. Arguments: Path to the MP3 file (required) Options: -t, --title TEXT Title text to display (required) -o, --output PATH Output file path (default: input with .mp4 extension) -r, --resolution WxH Video resolution (default: 1920x1080) --font-size N Font size in pixels (default: 72) --font-color COLOR Font colour (default: white) --bg-color COLOR Background colour (default: black; ignored with --image) -i, --image PATH Background image (scaled to fill; title at bottom) -n, --dryrun Preview what would happen without creating files -h, --help Show this help message EOF } die() { echo "Error: $1" >&2 exit 1 } # Parse arguments INPUT="" while [[ $# -gt 0 ]]; do case "$1" in -t|--title) TITLE="$2"; shift 2 ;; -o|--output) OUTPUT="$2"; shift 2 ;; -r|--resolution) RESOLUTION="$2"; shift 2 ;; --font-size) FONT_SIZE="$2"; shift 2 ;; --font-color) FONT_COLOR="$2"; shift 2 ;; --bg-color) BG_COLOR="$2"; shift 2 ;; -i|--image) IMAGE="$2"; shift 2 ;; -n|--dryrun) DRYRUN=true; shift ;; -h|--help) usage; exit 0 ;; -*) die "unknown option: $1" ;; *) INPUT="$1"; shift ;; esac done # Validate [[ -z "$INPUT" ]] && die "no input file specified. See --help." [[ -f "$INPUT" ]] || die "file not found: $INPUT" [[ -z "$TITLE" ]] && die "--title is required" command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found. Install with: sudo apt install ffmpeg" command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found. Install with: sudo apt install ffmpeg" [[ -n "$IMAGE" && ! -f "$IMAGE" ]] && die "image not found: $IMAGE" # Default output path if [[ -z "$OUTPUT" ]]; then OUTPUT="${INPUT%.*}.mp4" fi # Get audio duration DURATION_SECS=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$INPUT" 2>/dev/null) \ || die "could not read audio duration from: $INPUT" # Format duration for display format_duration() { local secs="${1%.*}" local mins=$((secs / 60)) local rem=$((secs % 60)) if [[ $mins -gt 0 ]]; then echo "${mins}m ${rem}s" else echo "${rem}s" fi } DURATION_DISPLAY=$(format_duration "$DURATION_SECS") # Get ffmpeg version FFMPEG_VERSION=$(ffmpeg -version | head -1 | awk '{print $3}') if $DRYRUN; then echo "[dryrun] Input: $INPUT" echo "[dryrun] Output: $OUTPUT" echo "[dryrun] Title: $TITLE" echo "[dryrun] Duration: $DURATION_DISPLAY" echo "[dryrun] Resolution: $RESOLUTION" echo "[dryrun] Font size: $FONT_SIZE" echo "[dryrun] Font color: $FONT_COLOR" if [[ -n "$IMAGE" ]]; then echo "[dryrun] Background: $IMAGE (image)" else echo "[dryrun] Background: $BG_COLOR" fi echo "[dryrun] Dependencies OK: ffmpeg $FFMPEG_VERSION" echo "[dryrun] Would generate .mp4 video file" exit 0 fi # Parse resolution WIDTH="${RESOLUTION%x*}" HEIGHT="${RESOLUTION#*x}" # Escape title for ffmpeg drawtext (colons and backslashes need escaping) ESCAPED_TITLE=$(echo "$TITLE" | sed "s/'/\\\\\\\\'/g" | sed 's/:/\\:/g') if [[ -n "$IMAGE" ]]; then # Image background: scale+crop to fill, title centred at bottom ffmpeg -y \ -loop 1 -i "$IMAGE" \ -i "$INPUT" \ -vf "scale=${WIDTH}:${HEIGHT}:force_original_aspect_ratio=increase,crop=${WIDTH}:${HEIGHT},drawtext=text='${ESCAPED_TITLE}':fontsize=${FONT_SIZE}:fontcolor=${FONT_COLOR}:x=(w-text_w)/2:y=h-text_h-40" \ -c:v libx264 -tune stillimage -pix_fmt yuv420p \ -c:a aac -b:a 192k \ -shortest \ "$OUTPUT" else # Solid colour background: title centred ffmpeg -y \ -f lavfi -i "color=c=${BG_COLOR}:s=${RESOLUTION}:d=${DURATION_SECS}" \ -i "$INPUT" \ -vf "drawtext=text='${ESCAPED_TITLE}':fontsize=${FONT_SIZE}:fontcolor=${FONT_COLOR}:x=(w-text_w)/2:y=(h-text_h)/2" \ -c:v libx264 -tune stillimage -pix_fmt yuv420p \ -c:a aac -b:a 192k \ -shortest \ "$OUTPUT" fi echo "$OUTPUT"