영상 분위기 자동 분석 후 AI BGM 생성 & 싱크 맞추기

영상 분위기를 Vision LLM으로 분석하고 MusicGen으로 BGM 생성 후 FFmpeg으로 자동 싱크

2026.04.13

MusicGenerationMusicGenAudioCraftVideoEditingFFmpeg

[미검증]

📌 0. 시리즈

응용편제목난이도핵심 기술
응용 1사진 10장으로 AI 캐릭터·프로필 이미지 만들기⭐⭐⭐FLUX.1 dev · LoRA · ComfyUI
응용 2내 목소리 AI 클론 — 유튜브 내레이터 자동화⭐⭐⭐F5-TTS · Kokoro · Sesame CSM-1B
응용 3상품 이미지 1장 → 15초 광고 영상 자동 생성⭐⭐⭐⭐Wan2.2 · HunyuanVideo 1.5 · LTX-2
응용 4공장 불량 자동 검사 — NG/OK 탐지 + 로봇 좌표 추출⭐⭐~⭐⭐⭐⭐⭐YOLOv12 · OpenCV · RealSense
응용 5영상 분위기 분석 → BGM 자동 생성 & 싱크⭐⭐⭐MusicGen · AudioCraft · Stable Audio Open
응용 6주제 한 줄 입력으로 유튜브 영상 완성 — AI 콘텐츠 자동화 파이프라인⭐⭐⭐⭐LangGraph · CrewAI · AutoGen 0.4
응용 7사진 보고 글 쓰는 AI — Vision LLM 상세페이지 자동 작성⭐⭐⭐⭐Qwen2.5-VL · InternVL3 · LLaVA-Next
응용 8내 PDF 문서를 AI가 읽는다 — 사내 지식 RAG 챗봇 구축⭐⭐⭐LlamaIndex · ChromaDB · Qdrant

📌 1. 들어가며

이 포스트에서 만들 것

이 포스트를 끝까지 따라하면 영상 파일 1개만 넣으면 아래 결과물을 자동으로 만들 수 있습니다.

입력: 영상 파일 1개 (광고, 유튜브 영상, 쇼츠 등)
   ↓
[1단계] 영상 자동 분석
  └─ 대표 프레임 추출 → Qwen2.5-VL로 분위기 파악
  └─ "어두운 분위기, 긴장감, 빠른 컷" → 음악 프롬프트 자동 생성

[2단계] AI BGM 생성
  └─ MusicGen / Stable Audio Open으로 맞춤 BGM 생성

[3단계] 영상 싱크
  └─ 영상 길이에 맞게 BGM 자동 조절
  └─ Fade-in / Fade-out 처리

출력: BGM이 완벽하게 씌워진 최종 영상 파일

왜 AI BGM인가 — 저작권 문제 해결

기존 BGM 사용의 문제:
  ├─ 유튜브 저작권 경고 → 수익 박탈
  ├─ 음원 라이선스 구매 비용 (월 수만원~수십만원)
  ├─ 원하는 분위기의 음악을 찾는 데 걸리는 시간
  └─ 상업적 사용 불가 음원 혼용으로 법적 리스크

AI BGM 생성의 장점:
  ├─ MusicGen (Meta): MIT 라이선스 → 상업적 사용 가능
  ├─ Stable Audio Open: Stability AI Open Rail → 상업적 사용 가능
  ├─ 완전히 새로운 음악 생성 → 저작권 침해 원천 차단
  └─ 영상 분위기에 딱 맞는 맞춤 음악 자동 생성

📌 2. 환경 준비

2-1. VRAM 8GB+ 권장

MusicGen 모델별 VRAM:
  small  (300M)  → VRAM 4GB / CPU도 가능 (느림)
  medium (1.5B)  → VRAM 8GB 권장
  large  (3.3B)  → VRAM 16GB 권장
  melody (1.5B)  → VRAM 8GB (허밍/멜로디 기반 생성)

Stable Audio Open:
  → VRAM 8GB+

CPU만 있는 경우:
  → MusicGen small 사용
  → 30초 생성에 약 2~5분 소요 (GPU 대비 10배 느림)

2-2. audiocraft, transformers, ffmpeg 설치

bash# Python 환경 생성
python -m venv venv
source venv/bin/activate       # Linux/Mac
venv\Scripts\activate          # Windows

# PyTorch 설치 (CUDA 12.1)
pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu121

# Meta AudioCraft (MusicGen 포함)
pip install audiocraft

# HuggingFace Transformers (Stable Audio Open, Qwen2.5-VL)
pip install transformers accelerate

# 영상 분석
pip install opencv-python pillow

# 오디오 처리
pip install soundfile scipy numpy

# FFmpeg 설치
# Windows: https://ffmpeg.org/download.html → PATH 등록
# Linux:
sudo apt install ffmpeg
# Mac:
brew install ffmpeg

FFmpeg 설치 확인:

bashffmpeg -version
ffprobe -version

📌 3. 영상 분위기 분석

3-1. 영상에서 대표 프레임 추출 (OpenCV)

영상 전체를 분석하면 시간이 오래 걸리므로, 구간별 대표 프레임을 추출해 분위기를 파악합니다.

pythonimport cv2
import os
import numpy as np
from PIL import Image

def extract_keyframes(video_path, num_frames=6, output_dir="./keyframes"):
    """
    영상에서 균등 간격으로 대표 프레임 추출
    num_frames: 추출할 프레임 수 (구간 분석용)
    """
    os.makedirs(output_dir, exist_ok=True)

    cap      = cv2.VideoCapture(video_path)
    total    = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    fps      = cap.get(cv2.CAP_PROP_FPS)
    duration = total / fps

    print(f"영상 정보: {total}프레임 / {fps:.1f}fps / {duration:.1f}초")

    # 균등 간격 프레임 위치 계산
    positions = [int(total * i / num_frames) for i in range(num_frames)]

    saved_paths = []
    for i, pos in enumerate(positions):
        cap.set(cv2.CAP_PROP_POS_FRAMES, pos)
        ret, frame = cap.read()
        if not ret:
            continue

        # BGR → RGB 변환
        frame_rgb  = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        img        = Image.fromarray(frame_rgb)
        save_path  = os.path.join(output_dir, f"frame_{i:02d}.jpg")
        img.save(save_path, quality=90)
        saved_paths.append(save_path)

        timestamp = pos / fps
        print(f"프레임 {i+1}/{num_frames} 추출 완료: {timestamp:.1f}초 지점")

    cap.release()
    return saved_paths, duration

# 실행
frame_paths, video_duration = extract_keyframes(
    video_path  = "./my_video.mp4",
    num_frames  = 6,
    output_dir  = "./keyframes"
)

장면 변화 감지 기반 추출 (고급):

pythondef extract_scene_changes(video_path, threshold=30.0):
    """
    장면 변화가 있는 지점의 프레임 자동 추출
    threshold: 프레임 간 차이 임계값 (낮을수록 민감)
    """
    cap      = cv2.VideoCapture(video_path)
    fps      = cap.get(cv2.CAP_PROP_FPS)
    prev     = None
    scenes   = []
    frame_no = 0

    while True:
        ret, frame = cap.read()
        if not ret:
            break

        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

        if prev is not None:
            diff  = cv2.absdiff(prev, gray)
            score = np.mean(diff)

            if score > threshold:
                timestamp = frame_no / fps
                scenes.append((frame_no, timestamp, frame.copy()))

        prev     = gray
        frame_no += 1

    cap.release()
    print(f"장면 변화 감지: {len(scenes)}개 씬")
    return scenes

3-2. Qwen2.5-VL로 장면 설명 생성

pythonfrom transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
from PIL import Image
import torch

def load_vision_model(model_name="Qwen/Qwen2.5-VL-7B-Instruct"):
    """Qwen2.5-VL 모델 로드"""
    print(f"모델 로드 중: {model_name}")
    model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
        model_name,
        torch_dtype  = torch.bfloat16,
        device_map   = "auto",
    )
    processor = AutoProcessor.from_pretrained(model_name)
    return model, processor

def analyze_frame_mood(image_path, model, processor):
    """
    단일 프레임 분위기 분석
    반환: 분위기 설명 텍스트
    """
    image = Image.open(image_path).convert("RGB")

    prompt = """이 영상 프레임의 분위기를 분석해주세요.
다음 항목을 간결하게 설명하세요:
1. 전반적 분위기 (예: 긴장감, 평화로움, 역동적, 감성적)
2. 색감 (예: 어두운, 밝은, 따뜻한, 차가운)
3. 속도감 (예: 빠른, 느린, 정적인)
4. 배경 상황 (예: 공장, 자연, 도시, 실내)

한 줄로 요약: """

    messages = [{
        "role": "user",
        "content": [
            {"type": "image", "image": image},
            {"type": "text",  "text":  prompt}
        ]
    }]

    text = processor.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True
    )
    inputs = processor(
        text=[text], images=[image],
        return_tensors="pt"
    ).to(model.device)

    with torch.no_grad():
        output_ids = model.generate(
            **inputs,
            max_new_tokens = 100,
            temperature    = 0.3,
        )

    response = processor.decode(
        output_ids[0][inputs.input_ids.shape[1]:],
        skip_special_tokens=True
    )
    return response.strip()

def analyze_all_frames(frame_paths, model, processor):
    """모든 프레임 분위기 통합 분석"""
    descriptions = []

    for i, path in enumerate(frame_paths):
        desc = analyze_frame_mood(path, model, processor)
        descriptions.append(desc)
        print(f"[{i+1}/{len(frame_paths)}] {desc}")

    # 전체 영상 분위기 통합 요약
    combined = " / ".join(descriptions)
    summary_prompt = f"""다음은 영상 각 구간의 분위기 설명입니다:
{combined}

전체 영상의 BGM으로 어울리는 음악을 영어로 설명해주세요.
형식: [장르], [분위기], [BPM 범위], [악기]
예시: cinematic orchestral, tense and dramatic, 80-100 BPM, strings and percussion"""

    messages = [{"role": "user", "content": summary_prompt}]
    text     = processor.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True
    )
    inputs   = processor(text=[text], return_tensors="pt").to(model.device)

    with torch.no_grad():
        output_ids = model.generate(**inputs, max_new_tokens=80)

    summary = processor.decode(
        output_ids[0][inputs.input_ids.shape[1]:],
        skip_special_tokens=True
    )
    return descriptions, summary.strip()

3-3. 분위기 텍스트 → 음악 프롬프트 변환 로직

python# 분위기 키워드 → MusicGen 프롬프트 자동 매핑 테이블
MOOD_TO_PROMPT = {
    # 분위기 키워드: MusicGen 프롬프트
    "긴장감":      "tense cinematic music, suspenseful strings, low brass, dark atmosphere",
    "평화로움":    "peaceful ambient music, soft piano, gentle nature sounds, calm",
    "역동적":      "energetic electronic music, driving beat, synth bass, powerful",
    "감성적":      "emotional ballad, soft piano, slow tempo, melancholic",
    "어두운":      "dark atmospheric music, minor key, deep bass, moody",
    "밝은":        "upbeat cheerful music, major key, bright strings, optimistic",
    "따뜻한":      "warm acoustic music, guitar, cozy atmosphere, gentle",
    "차가운":      "cold ambient soundscape, electronic pads, minimalist, icy",
    "빠른":        "fast paced electronic, high tempo 140bpm, energetic beat",
    "느린":        "slow cinematic, 60bpm, long reverb, meditative",
    "공장":        "industrial ambient, mechanical sounds, rhythmic percussion",
    "자연":        "nature sounds, acoustic instruments, birds, flowing water",
    "도시":        "urban electronic, city vibes, modern beat, metropolitan",
    "고급스러운":  "luxury lounge jazz, sophisticated, smooth, elegant piano",
    "광고":        "corporate upbeat, inspiring, motivational, clean production",
}

def text_to_music_prompt(mood_description, video_duration):
    """
    분위기 설명 → MusicGen 최적화 프롬프트 생성
    """
    # 키워드 매핑
    matched_prompts = []
    for keyword, prompt in MOOD_TO_PROMPT.items():
        if keyword in mood_description:
            matched_prompts.append(prompt)

    # 매핑된 프롬프트 조합
    if matched_prompts:
        base_prompt = matched_prompts[0]
    else:
        # 기본값: Qwen2.5-VL이 생성한 설명 그대로 사용
        base_prompt = mood_description

    # 영상 길이 → 음악 스타일 보완
    if video_duration < 15:
        base_prompt += ", short punchy, high energy intro"
    elif video_duration < 60:
        base_prompt += ", full arrangement, verse and chorus structure"
    else:
        base_prompt += ", long form, gradual build, sustained atmosphere"

    print(f"\n🎵 생성될 음악 프롬프트:\n{base_prompt}\n")
    return base_prompt

# 전체 파이프라인 실행
model, processor = load_vision_model("Qwen/Qwen2.5-VL-7B-Instruct")
frame_paths, video_duration = extract_keyframes("./my_video.mp4")
descriptions, summary = analyze_all_frames(frame_paths, model, processor)
music_prompt = text_to_music_prompt(summary, video_duration)

📌 4. BGM 생성

4-1. MusicGen 사용법

pythonimport torch
import soundfile as sf
from audiocraft.models import MusicGen
from audiocraft.data.audio import audio_write

def load_musicgen(size="medium"):
    """
    size: "small" / "medium" / "large" / "melody"
    """
    print(f"MusicGen {size} 모델 로드 중...")
    model = MusicGen.get_pretrained(f"facebook/musicgen-{size}")
    return model

모델 크기 선택 가이드:

small  (300M):
  VRAM: 4GB / CPU 가능
  품질: ⭐⭐⭐
  속도: 매우 빠름
  → 빠른 테스트, 저사양 환경

medium (1.5B):
  VRAM: 8GB
  품질: ⭐⭐⭐⭐
  속도: 빠름
  → 실용적인 균형 (권장)

large  (3.3B):
  VRAM: 16GB
  품질: ⭐⭐⭐⭐⭐
  속도: 보통
  → 최고 품질 필요 시

melody (1.5B):
  VRAM: 8GB
  특징: 허밍/멜로디 입력 → 편곡 가능
  → 특정 멜로디를 기반으로 BGM 생성 시

duration, top_k, temperature 파라미터:

pythondef generate_bgm_musicgen(model, prompt, duration=30.0,
                           top_k=250, temperature=1.0):
    """
    model:       MusicGen 모델
    prompt:      음악 설명 텍스트
    duration:    생성 길이 (초) — 최대 30초 권장
    top_k:       샘플링 다양성
                 낮을수록 → 예측 가능하고 안정적
                 높을수록 → 창의적이지만 불안정
    temperature: 창의성 강도
                 0.5 → 보수적, 일관성 높음
                 1.0 → 균형 (권장)
                 1.5 → 창의적, 예상치 못한 결과
    """
    model.set_generation_params(
        duration    = duration,
        top_k       = top_k,
        temperature = temperature,
        cfg_coef    = 3.0,     # 프롬프트 충실도 (3.0 권장)
    )

    print(f"BGM 생성 중... ({duration}초)")
    with torch.no_grad():
        wav = model.generate([prompt])   # 배치 처리 가능

    # wav shape: [batch, channels, samples]
    audio = wav[0].cpu().numpy()        # 첫 번째 결과 선택

    return audio, model.sample_rate

# 실행
musicgen_model = load_musicgen("medium")

audio, sample_rate = generate_bgm_musicgen(
    model       = musicgen_model,
    prompt      = music_prompt,
    duration    = 30.0,
    top_k       = 250,
    temperature = 1.0,
)

sf.write("./bgm_raw.wav", audio.T, sample_rate)
print(f"BGM 저장 완료: ./bgm_raw.wav ({sample_rate}Hz)")

프롬프트 예시 모음:

pythonPROMPT_EXAMPLES = {
    "광고_고급":
        "luxury cinematic music, elegant piano and strings, "
        "slow tempo 70bpm, sophisticated, dramatic crescendo, "
        "Hollywood style, 4K quality audio",

    "광고_역동":
        "energetic corporate commercial music, driving synth bass, "
        "inspiring brass, upbeat 120bpm, motivational, "
        "clean production, modern electronic",

    "유튜브_브이로그":
        "warm acoustic indie pop, ukulele and guitar, "
        "cheerful 90bpm, positive vibes, summer feel, "
        "light percussion, whistling melody",

    "공장_산업":
        "industrial ambient techno, mechanical rhythm, "
        "dark electronic, 130bpm, metallic percussion, "
        "dystopian atmosphere, low drone bass",

    "제품_스킨케어":
        "gentle spa ambient music, soft piano, "
        "nature sounds birds, slow 60bpm, relaxing, "
        "clean minimalist, airy texture",

    "게임_액션":
        "epic action orchestral, fast drums 160bpm, "
        "powerful brass, dramatic strings, intense battle, "
        "Hans Zimmer style, aggressive",

    "자연_다큐":
        "peaceful nature documentary, ambient orchestral, "
        "solo cello, soft strings, 50bpm, serene, "
        "David Attenborough style, flowing",
}

4-2. Stable Audio Open 사용법

pythonfrom transformers import AutoModel, AutoProcessor
import torch
import soundfile as sf
import numpy as np

def load_stable_audio():
    """Stable Audio Open 모델 로드"""
    print("Stable Audio Open 로드 중...")
    model = AutoModel.from_pretrained(
        "stabilityai/stable-audio-open-1.0",
        torch_dtype  = torch.float16,
        trust_remote_code = True,
    ).to("cuda")

    processor = AutoProcessor.from_pretrained(
        "stabilityai/stable-audio-open-1.0",
        trust_remote_code = True,
    )
    return model, processor

def generate_bgm_stable_audio(model, processor, prompt,
                               duration=30.0, steps=100):
    """
    Stable Audio Open으로 BGM 생성
    duration: 생성 길이 (초) — 최대 47초
    steps:    디노이징 스텝 수 (높을수록 고품질)
    """
    inputs = processor(
        text      = [prompt],
        return_tensors = "pt",
    ).to("cuda")

    with torch.no_grad():
        audio = model.generate(
            **inputs,
            seconds_total = duration,
            num_inference_steps = steps,
        )

    audio_np = audio.cpu().numpy().squeeze()
    sample_rate = 44100   # Stable Audio: 44.1kHz 스테레오

    return audio_np, sample_rate

# 실행
sa_model, sa_processor = load_stable_audio()

audio_sa, sr = generate_bgm_stable_audio(
    model     = sa_model,
    processor = sa_processor,
    prompt    = music_prompt,
    duration  = 30.0,
    steps     = 100,
)

sf.write("./bgm_stable_audio.wav", audio_sa.T, sr)
print("Stable Audio Open BGM 저장 완료")

MusicGen vs Stable Audio Open 비교:

항목MusicGenStable Audio Open
음질⭐⭐⭐⭐⭐⭐⭐⭐⭐
샘플레이트32kHz44.1kHz 스테레오
최대 길이30초47초
VRAM8GB (medium)8GB+
속도빠름보통
라이선스MITStability AI Open Rail
장르 다양성매우 넓음넓음
권장 상황빠른 생성, 다양한 장르고음질, 스테레오 필요 시

📌 5. 영상 싱크 맞추기

5-1. 영상 길이 감지 (FFprobe)

pythonimport subprocess
import json

def get_video_duration(video_path):
    """
    FFprobe로 영상 길이 정확히 감지
    반환: 초 단위 float
    """
    cmd = [
        "ffprobe",
        "-v",            "quiet",
        "-print_format", "json",
        "-show_streams",
        video_path
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    info   = json.loads(result.stdout)

    for stream in info["streams"]:
        if stream["codec_type"] == "video":
            duration = float(stream["duration"])
            fps_str  = stream["r_frame_rate"]        # "24/1" 형식
            fps_num, fps_den = map(int, fps_str.split("/"))
            fps = fps_num / fps_den

            print(f"영상 길이: {duration:.2f}초 / FPS: {fps:.1f}")
            return duration, fps

    raise ValueError("영상 스트림을 찾을 수 없습니다.")

video_duration, video_fps = get_video_duration("./my_video.mp4")

5-2. BGM 자르기 / 루프 처리

pythonimport numpy as np
import soundfile as sf

def adjust_bgm_duration(bgm_path, target_duration, output_path,
                         crossfade_sec=2.0):
    """
    BGM을 영상 길이에 맞게 자르거나 루프 처리

    crossfade_sec: 루프 이음새 크로스페이드 길이 (초)
    """
    audio, sr  = sf.read(bgm_path)
    bgm_length = len(audio) / sr

    print(f"BGM 원본 길이: {bgm_length:.2f}초")
    print(f"목표 길이:     {target_duration:.2f}초")

    if bgm_length >= target_duration:
        # --- 자르기 ---
        target_samples = int(target_duration * sr)
        adjusted = audio[:target_samples]
        print("→ 자르기 처리")

    else:
        # --- 루프 처리 ---
        crossfade_samples = int(crossfade_sec * sr)
        loops_needed = int(np.ceil(target_duration / bgm_length)) + 1

        # BGM을 필요한 횟수만큼 이어붙이기
        looped = np.tile(audio, (loops_needed, 1)) if audio.ndim == 2 \
                 else np.tile(audio, loops_needed)

        # 크로스페이드 처리 (이음새 자연스럽게)
        if audio.ndim == 2:
            fade_out = np.linspace(1, 0, crossfade_samples)[:, np.newaxis]
            fade_in  = np.linspace(0, 1, crossfade_samples)[:, np.newaxis]
        else:
            fade_out = np.linspace(1, 0, crossfade_samples)
            fade_in  = np.linspace(0, 1, crossfade_samples)

        # 루프 이음새 위치에 크로스페이드 적용
        loop_point = len(audio) - crossfade_samples
        looped[loop_point:loop_point + crossfade_samples] = (
            audio[loop_point:] * fade_out +
            audio[:crossfade_samples] * fade_in
        )

        target_samples = int(target_duration * sr)
        adjusted = looped[:target_samples]
        print(f"→ 루프 처리 ({loops_needed-1}회 반복)")

    sf.write(output_path, adjusted, sr)
    print(f"BGM 조정 완료: {output_path}")
    return output_path

adjust_bgm_duration(
    bgm_path        = "./bgm_raw.wav",
    target_duration = video_duration,
    output_path     = "./bgm_adjusted.wav",
    crossfade_sec   = 2.0,
)

5-3. Fade-in / Fade-out 처리

pythondef apply_fade(audio_path, output_path,
               fade_in_sec=1.5, fade_out_sec=2.0):
    """
    오디오에 Fade-in / Fade-out 적용
    fade_in_sec:  시작 페이드인 길이 (초)
    fade_out_sec: 끝 페이드아웃 길이 (초)
    """
    audio, sr = sf.read(audio_path)
    total     = len(audio)

    # Fade-in 처리 (선형 증가)
    fade_in_samples  = int(fade_in_sec * sr)
    fade_in_curve    = np.linspace(0.0, 1.0, fade_in_samples)

    # Fade-out 처리 (선형 감소)
    fade_out_samples = int(fade_out_sec * sr)
    fade_out_curve   = np.linspace(1.0, 0.0, fade_out_samples)

    if audio.ndim == 2:
        # 스테레오
        fade_in_curve  = fade_in_curve[:, np.newaxis]
        fade_out_curve = fade_out_curve[:, np.newaxis]

    audio[:fade_in_samples]                  *= fade_in_curve
    audio[total - fade_out_samples:]         *= fade_out_curve

    sf.write(output_path, audio, sr)
    print(f"Fade 처리 완료: in={fade_in_sec}s / out={fade_out_sec}s")
    return output_path

apply_fade(
    audio_path   = "./bgm_adjusted.wav",
    output_path  = "./bgm_final.wav",
    fade_in_sec  = 1.5,
    fade_out_sec = 2.0,
)

📌 6. FFmpeg으로 최종 합치기

오디오 볼륨 조절 & 믹싱 커맨드

기본 합치기 (BGM만 있는 경우):

bashffmpeg \
  -i ./my_video.mp4 \
  -i ./bgm_final.wav \
  -c:v copy \
  -c:a aac \
  -b:a 192k \
  -shortest \
  -filter:a "volume=0.85" \
  ./output_with_bgm.mp4

영상 원본 음성 + BGM 믹싱 (목소리 더빙이 있는 경우):

bashffmpeg \
  -i ./my_video.mp4 \
  -i ./bgm_final.wav \
  -filter_complex \
    "[0:a]volume=1.0[voice];
     [1:a]volume=0.3[bgm];
     [voice][bgm]amix=inputs=2:duration=shortest[aout]" \
  -map 0:v \
  -map "[aout]" \
  -c:v copy \
  -c:a aac \
  -b:a 192k \
  ./output_mixed.mp4

# 볼륨 조절 가이드:
# [voice] volume=1.0  → 목소리 원본 볼륨 100%
# [bgm]   volume=0.3  → BGM은 30% (배경음 역할)

Python으로 전체 파이프라인 자동화:

pythonimport subprocess

def merge_video_bgm(video_path, bgm_path, output_path,
                    bgm_volume=0.85, has_original_audio=False,
                    voice_volume=1.0):
    """
    영상 + BGM 최종 합치기

    has_original_audio: True → 원본 음성 + BGM 믹싱
                        False → BGM만 사용
    """
    if has_original_audio:
        # 목소리 + BGM 믹싱
        cmd = [
            "ffmpeg", "-y",
            "-i",   video_path,
            "-i",   bgm_path,
            "-filter_complex",
            f"[0:a]volume={voice_volume}[voice];"
            f"[1:a]volume={bgm_volume}[bgm];"
            f"[voice][bgm]amix=inputs=2:duration=shortest[aout]",
            "-map",  "0:v",
            "-map",  "[aout]",
            "-c:v",  "copy",
            "-c:a",  "aac",
            "-b:a",  "192k",
            output_path
        ]
    else:
        # BGM만 추가
        cmd = [
            "ffmpeg", "-y",
            "-i",   video_path,
            "-i",   bgm_path,
            "-c:v", "copy",
            "-c:a", "aac",
            "-b:a", "192k",
            "-shortest",
            "-filter:a", f"volume={bgm_volume}",
            output_path
        ]

    print("FFmpeg 실행 중...")
    subprocess.run(cmd, check=True)
    print(f"✅ 최종 영상 저장: {output_path}")

# 실행
merge_video_bgm(
    video_path         = "./my_video.mp4",
    bgm_path           = "./bgm_final.wav",
    output_path        = "./final_with_bgm.mp4",
    bgm_volume         = 0.85,
    has_original_audio = False,
)

전체 자동화 파이프라인 한 번에 실행:

pythondef full_pipeline(video_path, output_path,
                  musicgen_size="medium", bgm_volume=0.85):
    """
    영상 입력 → BGM 자동 분석/생성/싱크 → 최종 영상 출력
    """
    print("=" * 50)
    print("🎬 BGM 자동 생성 파이프라인 시작")
    print("=" * 50)

    # 1단계: 프레임 추출
    print("\n📷 [1/6] 대표 프레임 추출 중...")
    frame_paths, duration = extract_keyframes(video_path)

    # 2단계: 분위기 분석
    print("\n🔍 [2/6] 영상 분위기 분석 중...")
    model_vl, processor_vl = load_vision_model()
    _, summary = analyze_all_frames(frame_paths, model_vl, processor_vl)

    # 3단계: 프롬프트 생성
    print("\n✍️  [3/6] 음악 프롬프트 생성 중...")
    music_prompt = text_to_music_prompt(summary, duration)

    # 4단계: BGM 생성
    print("\n🎵 [4/6] BGM 생성 중...")
    musicgen = load_musicgen(musicgen_size)
    audio, sr = generate_bgm_musicgen(
        musicgen, music_prompt, duration=min(duration, 30.0)
    )
    sf.write("./temp_bgm_raw.wav", audio.T, sr)

    # 5단계: 싱크 & 페이드
    print("\n✂️  [5/6] BGM 싱크 맞추는 중...")
    adjust_bgm_duration("./temp_bgm_raw.wav", duration, "./temp_bgm_adjusted.wav")
    apply_fade("./temp_bgm_adjusted.wav", "./temp_bgm_final.wav")

    # 6단계: 최종 합치기
    print("\n🎬 [6/6] 영상 + BGM 합치는 중...")
    merge_video_bgm(video_path, "./temp_bgm_final.wav", output_path, bgm_volume)

    print("\n" + "=" * 50)
    print(f"✅ 완성! → {output_path}")
    print("=" * 50)

# 한 줄 실행
full_pipeline(
    video_path     = "./my_video.mp4",
    output_path    = "./final_with_bgm.mp4",
    musicgen_size  = "medium",
    bgm_volume     = 0.85,
)

📌 7. 결과 확인 & 트러블슈팅

분위기가 영상과 안 맞을 때

원인 1: Qwen2.5-VL 분석 결과가 부정확
  → 프레임 수 늘리기: num_frames=6 → 12
  → 장면 변화 감지 방식으로 교체

원인 2: 프롬프트 매핑 테이블 미스매치
  → MOOD_TO_PROMPT에 해당 분위기 키워드 추가
  → Qwen2.5-VL 출력을 직접 프롬프트로 사용

원인 3: MusicGen이 프롬프트를 잘못 해석
  → 프롬프트를 더 구체적으로 작성
  → 나쁜 예: "sad music"
  → 좋은 예: "melancholic solo piano, slow 60bpm,
              minor key, emotional, no drums"

원인 4: temperature 값이 너무 높음
  → temperature=1.0 → 0.7로 낮추기
  → 낮을수록 프롬프트에 충실한 결과

직접 프롬프트 수동 입력:
  music_prompt = "luxury cinematic, elegant piano and strings,
                  slow 70bpm, sophisticated"
  audio, sr = generate_bgm_musicgen(musicgen, music_prompt, duration=30)

음악이 뚝 끊길 때

원인 1: 루프 이음새 크로스페이드 부족
  → crossfade_sec=2.0 → 3.0으로 늘리기

원인 2: Fade-out 처리 미적용
  → apply_fade()의 fade_out_sec 확인
  → 최소 2.0초 이상 권장

원인 3: 영상 길이 > BGM 길이인데 루프 처리 없음
  → adjust_bgm_duration() 함수 적용 확인
  → crossfade_sec=3.0 권장

원인 4: FFmpeg 인코딩 중 오디오 잘림
  → -shortest 옵션 제거 후 -t 옵션으로 길이 명시
  ffmpeg -i video.mp4 -i bgm.wav \
    -t {video_duration} \         ← 명시적 길이 지정
    -c:v copy -c:a aac output.mp4

오디오 품질 확인 (FFprobe):
  ffprobe -v quiet -show_streams ./final_with_bgm.mp4 | grep -E "codec|sample|duration"

완성 체크리스트

  • 영상 대표 프레임 추출 확인 (6장 이상)
  • Qwen2.5-VL 분위기 분석 결과 확인
  • 음악 프롬프트 자동 생성 확인
  • MusicGen BGM 생성 성공 (30초 WAV 파일)
  • BGM 영상 길이에 맞게 싱크 조정 완료
  • Fade-in / Fade-out 적용 확인
  • FFmpeg 최종 합치기 완료 및 재생 확인