/ scripts / add-music.sh
add-music.sh
  1  #!/usr/bin/env bash
  2  # Mix a BGM track into an MP4 video.
  3  #
  4  # Usage:
  5  #   bash add-music.sh <input.mp4> [--mood=<name>] [--music=<path>] [--out=<path>]
  6  #
  7  # Mood library (in ../assets/, matching bgm-<mood>.mp3):
  8  #   tech              — Apple Silicon / product keynote vibe, minimal synth+piano (default)
  9  #   ad                — upbeat modern, clear build + drop, social-media ad energy
 10  #   educational       — warm, patient, inviting learning tone
 11  #   educational-alt   — alternate take of educational
 12  #   tutorial          — lo-fi background, stays out of voiceover's way
 13  #   tutorial-alt      — alternate take of tutorial
 14  #
 15  # Flags (all optional):
 16  #   --mood=<name>     pick a preset from the library (default: tech)
 17  #   --music=<path>    override with your own audio file (wins over --mood)
 18  #   --out=<path>      output path (default: <input-basename>-bgm.mp4)
 19  #
 20  # Legacy positional form still works: bash add-music.sh in.mp4 music.mp3 out.mp4
 21  #
 22  # Behavior:
 23  #   - Music is trimmed to match video duration
 24  #   - 0.3s fade in, 1.0s fade out (avoids hard cuts)
 25  #   - Video stream copied (no re-encode), audio AAC 192k
 26  #
 27  # Examples:
 28  #   bash add-music.sh my.mp4                              # default: tech mood
 29  #   bash add-music.sh my.mp4 --mood=ad                    # switch mood
 30  #   bash add-music.sh my.mp4 --mood=educational --out=final.mp4
 31  #   bash add-music.sh my.mp4 --music=~/Downloads/song.mp3 # bring your own
 32  #
 33  set -e
 34  
 35  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
 36  ASSETS_DIR="$SCRIPT_DIR/../assets"
 37  
 38  # ── Parse args ───────────────────────────────────────────────────────
 39  INPUT=""
 40  MOOD="tech"
 41  CUSTOM_MUSIC=""
 42  OUTPUT=""
 43  POSITIONAL=()
 44  
 45  for arg in "$@"; do
 46    case "$arg" in
 47      --mood=*)  MOOD="${arg#*=}" ;;
 48      --music=*) CUSTOM_MUSIC="${arg#*=}" ;;
 49      --out=*)   OUTPUT="${arg#*=}" ;;
 50      *)         POSITIONAL+=("$arg") ;;
 51    esac
 52  done
 53  
 54  # Legacy positional: <input> [music] [output]
 55  INPUT="${POSITIONAL[0]}"
 56  [ -z "$CUSTOM_MUSIC" ] && [ -n "${POSITIONAL[1]}" ] && CUSTOM_MUSIC="${POSITIONAL[1]}"
 57  [ -z "$OUTPUT" ]       && [ -n "${POSITIONAL[2]}" ] && OUTPUT="${POSITIONAL[2]}"
 58  
 59  if [ -z "$INPUT" ] || [ ! -f "$INPUT" ]; then
 60    echo "Usage: bash add-music.sh <input.mp4> [--mood=<name>] [--music=<path>] [--out=<path>]" >&2
 61    echo "Moods available: $(ls "$ASSETS_DIR" | grep -E '^bgm-.*\.mp3$' | sed 's/^bgm-//;s/\.mp3$//' | tr '\n' ' ')" >&2
 62    exit 1
 63  fi
 64  
 65  # ── Resolve music source: --music wins, else --mood ─────────────────
 66  if [ -n "$CUSTOM_MUSIC" ]; then
 67    MUSIC="$CUSTOM_MUSIC"
 68    SOURCE_LABEL="custom: $MUSIC"
 69  else
 70    MUSIC="$ASSETS_DIR/bgm-${MOOD}.mp3"
 71    SOURCE_LABEL="mood: $MOOD"
 72  fi
 73  
 74  if [ ! -f "$MUSIC" ]; then
 75    echo "✗ Music not found: $MUSIC" >&2
 76    echo "  Available moods: $(ls "$ASSETS_DIR" | grep -E '^bgm-.*\.mp3$' | sed 's/^bgm-//;s/\.mp3$//' | tr '\n' ' ')" >&2
 77    exit 1
 78  fi
 79  
 80  # ── Resolve output path ─────────────────────────────────────────────
 81  INPUT_DIR="$(cd "$(dirname "$INPUT")" && pwd)"
 82  INPUT_NAME="$(basename "$INPUT" .mp4)"
 83  [ -z "$OUTPUT" ] && OUTPUT="$INPUT_DIR/$INPUT_NAME-bgm.mp4"
 84  
 85  # ── Measure video duration, compute fade-out start ──────────────────
 86  DURATION=$(ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "$INPUT")
 87  if [ -z "$DURATION" ]; then
 88    echo "✗ Could not read video duration" >&2
 89    exit 1
 90  fi
 91  FADE_OUT_START=$(awk "BEGIN { d = $DURATION - 1; if (d < 0) d = 0; print d }")
 92  
 93  echo "▸ Mixing BGM into video"
 94  echo "  input:    $INPUT"
 95  echo "  music:    $SOURCE_LABEL"
 96  echo "  duration: ${DURATION}s"
 97  echo "  output:   $OUTPUT"
 98  
 99  ffmpeg -y -loglevel error \
100    -i "$INPUT" \
101    -i "$MUSIC" \
102    -filter_complex "[1:a]atrim=0:${DURATION},asetpts=PTS-STARTPTS,afade=t=in:st=0:d=0.3,afade=t=out:st=${FADE_OUT_START}:d=1[a]" \
103    -map 0:v -map "[a]" \
104    -c:v copy -c:a aac -b:a 192k -shortest \
105    "$OUTPUT"
106  
107  SIZE=$(du -h "$OUTPUT" | cut -f1)
108  echo "✓ Done: $OUTPUT ($SIZE)"