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) <noreply@anthropic.com>
This commit is contained in:
142
scripts/mp3-to-mp4
Executable file
142
scripts/mp3-to-mp4
Executable file
@@ -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 <<EOF
|
||||
Usage: $SCRIPT_NAME [OPTIONS] <input.mp3>
|
||||
|
||||
Convert an MP3 audio file into an MP4 video displaying a static title card.
|
||||
|
||||
Arguments:
|
||||
<input.mp3> 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"
|
||||
99
specs/mp3-to-mp4.spec.md
Normal file
99
specs/mp3-to-mp4.spec.md
Normal file
@@ -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] <input.mp3>
|
||||
```
|
||||
|
||||
### Arguments
|
||||
|
||||
| Argument | Description |
|
||||
|---|---|
|
||||
| `<input.mp3>` | 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 `<input.mp3>` 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: <path>`
|
||||
- **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: <path>`
|
||||
- **--image file not found:** Exit 1 with `Error: image not found: <path>`
|
||||
- **--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
|
||||
```
|
||||
136
tests/test-mp3-to-mp4.sh
Executable file
136
tests/test-mp3-to-mp4.sh
Executable file
@@ -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
|
||||
Reference in New Issue
Block a user