From 765f32b4fa4f8f0faf89ab9a080954fc7fc326b9 Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Wed, 8 Apr 2026 15:19:02 +1200 Subject: [PATCH] Add mp3-to-mp4: convert MP3 to MP4 with static title card Uses ffmpeg to generate an H.264/AAC video from an MP3 file with a centred title overlay. Supports optional background image (title at bottom) or solid colour (title centred). Includes spec, dryrun support, and 10 tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/mp3-to-mp4 | 142 +++++++++++++++++++++++++++++++++++++++ specs/mp3-to-mp4.spec.md | 99 +++++++++++++++++++++++++++ tests/test-mp3-to-mp4.sh | 136 +++++++++++++++++++++++++++++++++++++ 3 files changed, 377 insertions(+) create mode 100755 scripts/mp3-to-mp4 create mode 100644 specs/mp3-to-mp4.spec.md create mode 100755 tests/test-mp3-to-mp4.sh diff --git a/scripts/mp3-to-mp4 b/scripts/mp3-to-mp4 new file mode 100755 index 0000000..a57d0f8 --- /dev/null +++ b/scripts/mp3-to-mp4 @@ -0,0 +1,142 @@ +#!/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" diff --git a/specs/mp3-to-mp4.spec.md b/specs/mp3-to-mp4.spec.md new file mode 100644 index 0000000..e5f21b6 --- /dev/null +++ b/specs/mp3-to-mp4.spec.md @@ -0,0 +1,99 @@ +# mp3-to-mp4 + +## Purpose + +Convert an MP3 audio file into an MP4 video that displays a static title card, suitable for uploading to video platforms. + +## Dependencies + +- `ffmpeg` (6.x+) — video/audio encoding + +## Usage + +``` +mp3-to-mp4 [OPTIONS] +``` + +### Arguments + +| Argument | Description | +|---|---| +| `` | Path to the MP3 file to convert (required) | + +### Options + +| Flag | Description | +|---|---| +| `--title`, `-t` | Title text to display on the video (required) | +| `--output`, `-o` | Output file path (default: same name as input with `.mp4` extension) | +| `--resolution`, `-r` | Video resolution as WxH (default: `1920x1080`) | +| `--font-size` | Font size in pixels (default: `72`) | +| `--font-color` | Font colour (default: `white`) | +| `--bg-color` | Background colour (default: `black`; ignored when `--image` is set) | +| `--image`, `-i` | Background image path (optional; scaled to fill the resolution) | +| `--dryrun`, `-n` | Preview what would happen without creating files | +| `--help`, `-h` | Show usage information | + +## Behaviour + +1. **Validate inputs.** Check that `` exists and is readable. Check that `ffmpeg` is on PATH. Check that `--title` is provided. Exit 1 with a clear message if any check fails. + +2. **Determine duration.** Use `ffprobe` to get the audio duration in seconds. + +3. **Generate video.** Use ffmpeg to: + - **Without `--image`:** Create a solid colour background at the specified resolution for the audio duration. Title is centred both horizontally and vertically. + - **With `--image`:** Scale the image to fill the resolution (crop to fit). Title is centred horizontally near the bottom of the frame. + - Mux the MP3 audio as the audio stream + - Encode with H.264 video and AAC audio for broad compatibility + - Use `-shortest` to match video duration to audio duration + +4. **Report.** Print the output path on success. + +## Dryrun Behaviour + +When `--dryrun` / `-n` is passed: + +``` +[dryrun] Input: /path/to/episode.mp3 +[dryrun] Output: /path/to/episode.mp4 +[dryrun] Title: My Podcast Episode +[dryrun] Duration: 3m 42s +[dryrun] Resolution: 1920x1080 +[dryrun] Font size: 72 +[dryrun] Font color: white +[dryrun] Background: black (or image path when --image is used) +[dryrun] Dependencies OK: ffmpeg 6.1.1 +[dryrun] Would generate .mp4 video file +``` + +No files are created or modified. + +## Edge Cases + +- **Input file not found:** Exit 1 with `Error: file not found: ` +- **ffmpeg not installed:** Exit 1 with `Error: ffmpeg not found. Install with: sudo apt install ffmpeg` +- **--title not provided:** Exit 1 with `Error: --title is required` +- **Output file already exists:** Overwrite without prompting (standard pipeline behaviour) +- **Title text is very long:** ffmpeg's drawtext wraps naturally; no special handling needed +- **Input is not a valid audio file:** ffprobe will fail; exit 1 with `Error: could not read audio duration from: ` +- **--image file not found:** Exit 1 with `Error: image not found: ` +- **--image with --bg-color:** `--bg-color` is silently ignored when `--image` is provided + +## Examples + +```bash +# Basic conversion +mp3-to-mp4 -t "My Podcast Episode" episode.mp3 + +# Custom output and resolution +mp3-to-mp4 -t "Talk Title" -o talk.mp4 -r 1280x720 recording.mp3 + +# Custom styling +mp3-to-mp4 -t "Keynote" --font-size 96 --font-color yellow --bg-color "#1a1a2e" speech.mp3 + +# With a background image (title at bottom) +mp3-to-mp4 -t "Keynote 2026" -i cover.jpg recording.mp3 + +# Preview without creating files +mp3-to-mp4 --dryrun -t "Test" audio.mp3 +``` diff --git a/tests/test-mp3-to-mp4.sh b/tests/test-mp3-to-mp4.sh new file mode 100755 index 0000000..ef8022c --- /dev/null +++ b/tests/test-mp3-to-mp4.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$SCRIPT_DIR/../scripts/mp3-to-mp4" +PASS=0 +FAIL=0 +GREEN='\033[0;32m' +RED='\033[0;31m' +RESET='\033[0m' + +pass() { echo -e "${GREEN}PASS${RESET}: $1"; ((PASS++)); } +fail() { echo -e "${RED}FAIL${RESET}: $1 — $2"; ((FAIL++)); } + +# Create a short test mp3 using ffmpeg (1 second of silence) +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +TEST_MP3="$TMPDIR/test.mp3" +TEST_IMG="$TMPDIR/test.png" +ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -q:a 9 "$TEST_MP3" 2>/dev/null +ffmpeg -y -f lavfi -i "color=c=blue:s=320x240:d=1" -frames:v 1 "$TEST_IMG" 2>/dev/null + +# --- Tests --- + +# Help flag +desc="--help exits 0 and shows usage" +out=$("$SCRIPT" --help 2>&1) +if [[ $? -eq 0 ]] && echo "$out" | grep -q "Usage:"; then + pass "$desc" +else + fail "$desc" "exit code or output unexpected" +fi + +# Missing input file +desc="missing input exits 1" +out=$("$SCRIPT" -t "Test" 2>&1) +if [[ $? -ne 0 ]] && echo "$out" | grep -qi "error"; then + pass "$desc" +else + fail "$desc" "expected error exit" +fi + +# Missing title +desc="missing --title exits 1" +out=$("$SCRIPT" "$TEST_MP3" 2>&1) +if [[ $? -ne 0 ]] && echo "$out" | grep -qi "title"; then + pass "$desc" +else + fail "$desc" "expected error about title" +fi + +# File not found +desc="non-existent file exits 1" +out=$("$SCRIPT" -t "Test" /tmp/nonexistent_audio_12345.mp3 2>&1) +if [[ $? -ne 0 ]] && echo "$out" | grep -qi "not found"; then + pass "$desc" +else + fail "$desc" "expected file not found error" +fi + +# Dryrun shows expected fields +desc="--dryrun shows all expected fields" +out=$("$SCRIPT" --dryrun -t "My Title" "$TEST_MP3" 2>&1) +if [[ $? -eq 0 ]] \ + && echo "$out" | grep -q "\[dryrun\] Input:" \ + && echo "$out" | grep -q "\[dryrun\] Output:" \ + && echo "$out" | grep -q "\[dryrun\] Title:.*My Title" \ + && echo "$out" | grep -q "\[dryrun\] Duration:" \ + && echo "$out" | grep -q "\[dryrun\] Resolution:.*1920x1080" \ + && echo "$out" | grep -q "\[dryrun\] Font size:.*72" \ + && echo "$out" | grep -q "\[dryrun\] Would generate"; then + pass "$desc" +else + fail "$desc" "missing expected dryrun fields" + echo " Output was:" + echo "$out" | sed 's/^/ /' +fi + +# Dryrun with custom options +desc="--dryrun respects custom options" +out=$("$SCRIPT" --dryrun -t "Custom" -r 1280x720 --font-size 48 --font-color yellow --bg-color blue "$TEST_MP3" 2>&1) +if [[ $? -eq 0 ]] \ + && echo "$out" | grep -q "Resolution:.*1280x720" \ + && echo "$out" | grep -q "Font size:.*48" \ + && echo "$out" | grep -q "Font color:.*yellow" \ + && echo "$out" | grep -q "Background:.*blue"; then + pass "$desc" +else + fail "$desc" "custom options not reflected" + echo " Output was:" + echo "$out" | sed 's/^/ /' +fi + +# Dryrun with custom output +desc="--dryrun respects -o output path" +out=$("$SCRIPT" --dryrun -t "Test" -o /tmp/custom.mp4 "$TEST_MP3" 2>&1) +if [[ $? -eq 0 ]] && echo "$out" | grep -q "Output:.*custom.mp4"; then + pass "$desc" +else + fail "$desc" "custom output not shown" +fi + +# Dryrun with --image shows image as background +desc="--dryrun with --image shows image path" +out=$("$SCRIPT" --dryrun -t "Test" -i "$TEST_IMG" "$TEST_MP3" 2>&1) +if [[ $? -eq 0 ]] && echo "$out" | grep -q "Background:.*test.png.*(image)"; then + pass "$desc" +else + fail "$desc" "expected image path in background" + echo " Output was:" + echo "$out" | sed 's/^/ /' +fi + +# Image not found +desc="--image with non-existent file exits 1" +out=$("$SCRIPT" -t "Test" -i /tmp/nonexistent_img_12345.png "$TEST_MP3" 2>&1) +if [[ $? -ne 0 ]] && echo "$out" | grep -qi "image not found"; then + pass "$desc" +else + fail "$desc" "expected image not found error" +fi + +# Default output replaces .mp3 with .mp4 +desc="default output replaces .mp3 with .mp4" +out=$("$SCRIPT" --dryrun -t "Test" "$TEST_MP3" 2>&1) +if echo "$out" | grep -q "Output:.*test.mp4"; then + pass "$desc" +else + fail "$desc" "expected test.mp4 in output" +fi + +# --- Summary --- +echo "" +echo "Results: $PASS passed, $FAIL failed" +[[ $FAIL -eq 0 ]] && exit 0 || exit 1